From bbb168677ef76de174173082fcb22f7a0dfc28e4 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Fri, 25 Sep 2026 13:19:28 -0400 Subject: [PATCH 1/5] Harden portable runtime validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 712fbe09-8920-4820-8cba-cd665d429179 --- CHANGELOG.md | 2 ++ docs/installation.md | 7 +++-- docs/troubleshooting.md | 6 ++-- scripts/install-fixlab.ps1 | 28 ++++++++++++++--- scripts/repair-node-runtime.ps1 | 55 +++++++++++++++++++++++++++++---- test/fixlab-cli.test.js | 13 ++++++++ 6 files changed, 96 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0811f7..62d9db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ 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. - 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 diff --git a/docs/installation.md b/docs/installation.md index 24dbf67..abb83f2 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -113,9 +113,10 @@ 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. - 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4dd66a2..dddf330 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -70,8 +70,10 @@ pwsh -File scripts/repair-node-runtime.ps1 -Repository C:\path\to\application pwsh -File scripts/repair-node-runtime.ps1 -Repository C:\path\to\application -Yes ``` -The script verifies the official Node.js SHA-256 checksum and does not alter -NVM or the permanent system `PATH`. +The script verifies the official Node.js SHA-256 checksum, requires a real npm +operation to succeed, and does not alter NVM or the permanent system `PATH`. +It refuses to execute an existing portable runtime unless its destination root +is already marked as FixLab-owned. ## Dependency restore returns `E401` or `E403` diff --git a/scripts/install-fixlab.ps1 b/scripts/install-fixlab.ps1 index 2b76fd3..0731491 100644 --- a/scripts/install-fixlab.ps1 +++ b/scripts/install-fixlab.ps1 @@ -35,6 +35,20 @@ function Get-SafeSourceDisplay { ) } +function Get-PortableRepairCommand { + param( + [string]$Root, + [string]$Version + ) + + $repairScript = Join-Path $PSScriptRoot "repair-node-runtime.ps1" + $command = "pwsh -File `"$repairScript`" -Repository `"$Root`"" + if ($Version) { + $command += " -NodeVersion $Version" + } + return "$command -Yes" +} + function Get-RepositoryNodeVersion { param([string]$Root) @@ -283,17 +297,21 @@ $nvmRoots | ForEach-Object { Write-Output " $_" } $runtime = Find-NodeRuntime -Roots $nvmRoots -RequiredVersion $requestedVersion if (-not $runtime -and $requestedVersion) { + $repairCommand = Get-PortableRepairCommand ` + -Root $repositoryRoot ` + -Version $requestedVersion $nvmExecutable = $nvmRoots | ForEach-Object { Join-Path $_ "nvm.exe" } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 if (-not $nvmExecutable) { - throw "Node.js $requestedVersion is not installed coherently, and nvm.exe was not found under the discovered roots." + throw "Node.js $requestedVersion is not installed coherently, and nvm.exe was not found under the discovered roots. Portable alternative: $repairCommand" } if (-not $InstallNode) { Write-Output "Node.js $requestedVersion is not installed coherently." Write-Output "Next action: `"$nvmExecutable`" install $requestedVersion" Write-Output "Rerun this installer with -InstallNode to approve that installation." + Write-Output "Portable alternative: $repairCommand" exit 2 } if (-not $Yes) { @@ -309,13 +327,15 @@ if (-not $runtime -and $requestedVersion) { } $runtime = Find-NodeRuntime -Roots $nvmRoots -RequiredVersion $requestedVersion if (-not $runtime) { - throw "NVM reported success but did not create a coherent Node.js $requestedVersion runtime. Repair or reinstall NVM for Windows, then retry." + Write-Warning "NVM reported success but did not create a coherent Node.js $requestedVersion runtime." } } if (-not $runtime) { - $repairScript = Join-Path $PSScriptRoot "repair-node-runtime.ps1" - throw "No coherent NVM Node.js/npm installation was found. Repair NVM or run: pwsh -File `"$repairScript`" -Repository `"$repositoryRoot`" -NodeVersion $requestedVersion -Yes" + $repairCommand = Get-PortableRepairCommand ` + -Root $repositoryRoot ` + -Version $requestedVersion + throw "No coherent NVM Node.js/npm installation was found. Repair NVM or run: $repairCommand" } Write-Output "Selected runtime:" diff --git a/scripts/repair-node-runtime.ps1 b/scripts/repair-node-runtime.ps1 index c214487..a067fd1 100644 --- a/scripts/repair-node-runtime.ps1 +++ b/scripts/repair-node-runtime.ps1 @@ -110,9 +110,11 @@ function Test-PortableRuntime { ) $nodePath = Join-Path $Directory "node.exe" + $npmPath = Join-Path $Directory "npm.cmd" $npmCliPath = Join-Path $Directory "node_modules\npm\bin\npm-cli.js" if ( -not (Test-Path -LiteralPath $nodePath -PathType Leaf) -or + -not (Test-Path -LiteralPath $npmPath -PathType Leaf) -or -not (Test-Path -LiteralPath $npmCliPath -PathType Leaf) ) { return $null @@ -126,13 +128,48 @@ function Test-PortableRuntime { return $null } - $npmOutput = (& $nodePath $npmCliPath --version 2>&1 | Out-String).Trim() + $npmOutput = (& $npmPath --version 2>&1 | Out-String).Trim() if ($LASTEXITCODE -ne 0 -or -not $npmOutput) { return $null } + $probeDirectory = Join-Path ( + [System.IO.Path]::GetTempPath() + ) "fixlab-portable-npm-probe-$([guid]::NewGuid())" + New-Item -ItemType Directory -Path $probeDirectory | Out-Null + try { + Set-Content ` + -LiteralPath (Join-Path $probeDirectory "package.json") ` + -Value '{"name":"fixlab-portable-npm-probe","version":"1.0.0"}' + Push-Location $probeDirectory + try { + $probeOutput = ( + & $npmPath pack --dry-run --ignore-scripts --json 2>&1 | + Out-String + ) + $probeStatus = $LASTEXITCODE + } + finally { + Pop-Location + } + if ( + $probeStatus -ne 0 -or + $probeOutput -match 'Class extends value undefined|(?:TypeError|ReferenceError|SyntaxError):' + ) { + return $null + } + } + finally { + Remove-Item ` + -LiteralPath $probeDirectory ` + -Recurse ` + -Force ` + -ErrorAction SilentlyContinue + } + return [pscustomobject]@{ Node = $nodePath + Npm = $npmPath NpmCli = $npmCliPath NodeVersion = $RequiredVersion NpmVersion = $npmOutput @@ -194,13 +231,19 @@ Write-Output " checksum manifest: $checksumsUrl" Write-Output " system PATH changes: none" Write-Output " NVM changes: none" -$current = Test-PortableRuntime ` - -Directory $runtimePath ` - -RequiredVersion $requestedVersion +$current = $null +if (Test-Path -LiteralPath $runtimePath) { + if (-not (Test-Path -LiteralPath $ownershipMarker -PathType Leaf)) { + throw "Refusing to inspect an existing runtime because DestinationRoot is not marked as FixLab-owned: $destinationRootFull" + } + $current = Test-PortableRuntime ` + -Directory $runtimePath ` + -RequiredVersion $requestedVersion +} if ($current) { Write-Output "Portable runtime is already ready:" Write-Output " Node.js: $($current.NodeVersion) ($($current.Node))" - Write-Output " npm: $($current.NpmVersion) ($($current.NpmCli))" + Write-Output " npm: $($current.NpmVersion) ($($current.Npm))" exit 0 } @@ -284,6 +327,6 @@ if (-not $runtime) { Write-Output "Portable runtime repaired and verified:" Write-Output " Node.js: $($runtime.NodeVersion) ($($runtime.Node))" -Write-Output " npm: $($runtime.NpmVersion) ($($runtime.NpmCli))" +Write-Output " npm: $($runtime.NpmVersion) ($($runtime.Npm))" Write-Output "Use it in the current PowerShell session without changing the permanent PATH:" Write-Output " `$env:PATH = `"$runtimePath;`$env:PATH`"" diff --git a/test/fixlab-cli.test.js b/test/fixlab-cli.test.js index de541e9..2f7150c 100644 --- a/test/fixlab-cli.test.js +++ b/test/fixlab-cli.test.js @@ -135,6 +135,9 @@ test("Windows bootstrap keeps NVM and Node changes explicit", () => { assert.match(installer, /npm\.cmd/); assert.match(installer, /pack --dry-run --ignore-scripts --json/); assert.match(installer, /NVM reported success but did not create a coherent/); + assert.match(installer, /Get-PortableRepairCommand/); + assert.match(installer, /Portable alternative: \$repairCommand/); + assert.match(installer, /if \(\$Version\)/); assert.match(installer, /Get-SafeSourceDisplay/); assert.match(installer, /\[REDACTED\]/); assert.doesNotMatch(installer, /Write-Output "FixLab source: \$Source"/); @@ -175,8 +178,18 @@ test("portable Node repair is plan-first and checksum verified", () => { assert.match(repair, /NVM changes: none/); assert.match(repair, /\.fixlab-runtime-root/); assert.match(repair, /not marked as FixLab-owned/); + assert.match(repair, /npm\.cmd/); + assert.match(repair, /pack --dry-run --ignore-scripts --json/); assert.match(repair, /if \(-not \$Yes\)/); assert.match(repair, /No changes made/); + const ownershipCheck = repair.indexOf( + "if (-not (Test-Path -LiteralPath $ownershipMarker -PathType Leaf))" + ); + const existingRuntimeProbe = repair.indexOf( + "$current = Test-PortableRuntime" + ); + assert.ok(ownershipCheck >= 0); + assert.ok(existingRuntimeProbe > ownershipCheck); assert.doesNotMatch(repair, /nvm\s+(?:install|uninstall)|setx\s+PATH/i); }); From 36012d1933c3de7acda9e598a682ff6641462094 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Fri, 25 Sep 2026 13:30:49 -0400 Subject: [PATCH 2/5] Add owned dashboard shutdown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 712fbe09-8920-4820-8cba-cd665d429179 --- CHANGELOG.md | 2 + README.md | 3 +- bin/fixlab.js | 151 +++++++++++++++++++++++++++++++--- dashboard/server.js | 30 ++++++- docs/commands.md | 6 ++ scripts/install-fixlab.ps1 | 15 ++-- test/dashboard-server.test.js | 45 ++++++++++ test/fixlab-cli.test.js | 18 ++++ 8 files changed, 251 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d9db5..22d38d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to FixLab are documented in this file. 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 + 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 diff --git a/README.md b/README.md index c372291..01b7bbe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bin/fixlab.js b/bin/fixlab.js index 34b6057..0ce65ba 100644 --- a/bin/fixlab.js +++ b/bin/fixlab.js @@ -1,13 +1,16 @@ #!/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, mkdirSync, - readFileSync + readFileSync, + rmSync, + writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -72,7 +75,7 @@ Usage: fixlab authenticate [repository] [--yes] fixlab run [repository] [--runtime ] [--environment ] [--] [request...] fixlab validate [repository] --pr [--runtime ] - fixlab dashboard [repository] [--port ] [--no-open] [--runtime ] + fixlab dashboard [repository] [--port ] [--no-open] [--stop] [--runtime ] fixlab --help Commands: @@ -86,7 +89,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). @@ -1006,8 +1009,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; @@ -1035,10 +1043,108 @@ 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 writeDashboardControl(repository, port, token) { + const controlPath = dashboardControlPath(repository); + mkdirSync(dirname(controlPath), { recursive: true }); + writeFileSync( + controlPath, + JSON.stringify({ + version: 1, + repository: resolve(repository), + host: "127.0.0.1", + port, + pid: process.pid, + token + }), + { mode: 0o600 } + ); + return controlPath; +} + +function removeDashboardControl(controlPath, token) { + try { + const record = JSON.parse(readFileSync(controlPath, "utf8")); + if (record.token === token) { + rmSync(controlPath, { force: true }); + } + } catch { + // A missing or replaced record does not belong to this dashboard instance. + } +} + +async function stopDashboard(repository) { + const controlPath = dashboardControlPath(repository); + if (!existsSync(controlPath)) { + console.log(`No running FixLab dashboard is registered for ${repository}.`); + return 0; + } + + let record; + try { + record = JSON.parse(readFileSync(controlPath, "utf8")); + } catch { + console.error(`FixLab dashboard control record is invalid: ${controlPath}`); + 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 { + rmSync(controlPath, { force: true }); + console.log( + `No running FixLab dashboard was found; removed stale control record for ${repository}.` + ); + return 0; + } + + console.log(`Stopping FixLab dashboard for ${repository}.`); + return 0; +} + function parseOnboardArguments(args) { const runtimeArguments = parseRuntimeArguments(args); if (runtimeArguments.error) { @@ -1195,10 +1301,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 { @@ -1211,21 +1321,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; } @@ -1330,6 +1456,9 @@ async function main(args) { console.error(parsed.error); return 1; } + if (parsed.stop) { + return stopDashboard(parsed.repository); + } return dashboard( parsed.repository, parsed.port, diff --git a/dashboard/server.js b/dashboard/server.js index 270b582..83e0bbc 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -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, @@ -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; @@ -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, { diff --git a/docs/commands.md b/docs/commands.md index 8a1d9e6..b50414d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -60,6 +60,7 @@ read credentials, or make network requests. ```text fixlab dashboard [repository] [--port ] [--no-open] +fixlab dashboard [repository] --stop ``` The command starts the packaged local UI on `127.0.0.1:4317` by default and @@ -68,6 +69,11 @@ 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. + 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 diff --git a/scripts/install-fixlab.ps1 b/scripts/install-fixlab.ps1 index 0731491..be4f61e 100644 --- a/scripts/install-fixlab.ps1 +++ b/scripts/install-fixlab.ps1 @@ -42,7 +42,9 @@ function Get-PortableRepairCommand { ) $repairScript = Join-Path $PSScriptRoot "repair-node-runtime.ps1" - $command = "pwsh -File `"$repairScript`" -Repository `"$Root`"" + $scriptLiteral = "'" + $repairScript.Replace("'", "''") + "'" + $rootLiteral = "'" + $Root.Replace("'", "''") + "'" + $command = "pwsh -File $scriptLiteral -Repository $rootLiteral" if ($Version) { $command += " -NodeVersion $Version" } @@ -332,10 +334,13 @@ if (-not $runtime -and $requestedVersion) { } if (-not $runtime) { - $repairCommand = Get-PortableRepairCommand ` - -Root $repositoryRoot ` - -Version $requestedVersion - throw "No coherent NVM Node.js/npm installation was found. Repair NVM or run: $repairCommand" + if ($requestedVersion) { + $repairCommand = Get-PortableRepairCommand ` + -Root $repositoryRoot ` + -Version $requestedVersion + throw "No coherent NVM Node.js/npm installation was found. Repair NVM or run: $repairCommand" + } + throw "No coherent NVM Node.js/npm installation was found, and the repository has no exact Node.js pin. Set toolchain.nodeVersion, .nvmrc, .node-version, or an exact package.json engines.node value, then retry; alternatively pass an exact major.minor.patch value to repair-node-runtime.ps1 with -NodeVersion." } Write-Output "Selected runtime:" diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index 0e511c7..4b49862 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -400,6 +400,51 @@ test("dashboard reports readiness and serves only known static assets", async () } }); +test("dashboard stop control requires its instance token", async () => { + const repository = createRepository(); + let resolveShutdown; + const shutdownRequested = new Promise((resolve) => { + resolveShutdown = resolve; + }); + const { dashboard, url } = await startDashboard( + repository, + () => { + throw new Error("executor should not run"); + }, + undefined, + { + shutdownToken: "11111111-1111-4111-8111-111111111111", + onShutdown: resolveShutdown + } + ); + + try { + const rejected = await jsonRequest(url, "/api/control/stop", { + method: "POST", + headers: { + "X-FixLab-Shutdown-Token": + "22222222-2222-4222-8222-222222222222" + } + }); + assert.equal(rejected.response.status, 403); + assert.equal(rejected.body.error, "dashboard stop was not authorized"); + + const accepted = await jsonRequest(url, "/api/control/stop", { + method: "POST", + headers: { + "X-FixLab-Shutdown-Token": + "11111111-1111-4111-8111-111111111111" + } + }); + assert.equal(accepted.response.status, 202); + assert.equal(accepted.body.stopping, true); + await shutdownRequested; + } finally { + await dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + test("loads Azure DevOps intake and passes local screenshots without caching them", async () => { const repository = createGitRepository(); const profilePath = join( diff --git a/test/fixlab-cli.test.js b/test/fixlab-cli.test.js index 2f7150c..7f1499d 100644 --- a/test/fixlab-cli.test.js +++ b/test/fixlab-cli.test.js @@ -35,6 +35,7 @@ test("help lists supported commands", () => { assert.match(result.stdout, /fixlab authenticate/); assert.match(result.stdout, /fixlab validate/); assert.match(result.stdout, /fixlab dashboard/); + assert.match(result.stdout, /--stop/); assert.match(result.stdout, /--runtime /); assert.match(result.stdout, /--environment /); assert.match(result.stdout, /FIXLAB_RUNTIME/); @@ -138,6 +139,10 @@ test("Windows bootstrap keeps NVM and Node changes explicit", () => { assert.match(installer, /Get-PortableRepairCommand/); assert.match(installer, /Portable alternative: \$repairCommand/); assert.match(installer, /if \(\$Version\)/); + assert.match(installer, /\$repairScript\.Replace\("'", "''"\)/); + assert.match(installer, /\$Root\.Replace\("'", "''"\)/); + assert.match(installer, /repository has no exact Node\.js pin/); + assert.match(installer, /pass an exact major\.minor\.patch value/); assert.match(installer, /Get-SafeSourceDisplay/); assert.match(installer, /\[REDACTED\]/); assert.doesNotMatch(installer, /Write-Output "FixLab source: \$Source"/); @@ -770,6 +775,19 @@ test("dashboard rejects a missing repository before starting", () => { assert.match(result.stderr, /repository does not exist/); }); +test("dashboard stop succeeds when no owned dashboard is registered", () => { + const repository = mkdtempSync(join(tmpdir(), "fixlab-cli-")); + + try { + const result = run(["dashboard", repository, "--stop"], repository); + + assert.equal(result.status, 0); + assert.match(result.stdout, /No running FixLab dashboard is registered/); + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); + test("run launches the Agency-resolved FixLab agent", () => { const repository = mkdtempSync(join(tmpdir(), "fixlab-cli-")); const executableDirectory = join(repository, "bin"); From d43ae93c5e65636a0d683e8dbb901270628acd98 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Fri, 25 Sep 2026 13:40:37 -0400 Subject: [PATCH 3/5] Reject runtime reparse points Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 712fbe09-8920-4820-8cba-cd665d429179 --- docs/installation.md | 2 + docs/troubleshooting.md | 3 +- scripts/repair-node-runtime.ps1 | 99 +++++++++++++++++++++++++++++++-- test/fixlab-cli.test.js | 6 +- 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index abb83f2..0bb93dd 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -117,6 +117,8 @@ The repair script: 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dddf330..77c17ed 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -73,7 +73,8 @@ pwsh -File scripts/repair-node-runtime.ps1 -Repository C:\path\to\application -Y The script verifies the official Node.js SHA-256 checksum, requires a real npm operation to succeed, and does not alter NVM or the permanent system `PATH`. It refuses to execute an existing portable runtime unless its destination root -is already marked as FixLab-owned. +is already marked as FixLab-owned, and rejects junctions, symbolic links, or +other reparse points before runtime execution or recursive cleanup. ## Dependency restore returns `E401` or `E403` diff --git a/scripts/repair-node-runtime.ps1 b/scripts/repair-node-runtime.ps1 index a067fd1..ebf368d 100644 --- a/scripts/repair-node-runtime.ps1 +++ b/scripts/repair-node-runtime.ps1 @@ -22,6 +22,72 @@ function Get-ExactVersion { return "" } +function Assert-NoReparsePoints { + param( + [string]$Path, + [switch]$InspectDescendants + ) + + $fullPath = [System.IO.Path]::GetFullPath($Path) + $current = [System.IO.Path]::GetPathRoot($fullPath) + $relative = $fullPath.Substring($current.Length) + foreach ($segment in $relative.Split( + [char[]]@( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ), + [System.StringSplitOptions]::RemoveEmptyEntries + )) { + $current = Join-Path $current $segment + if (-not (Test-Path -LiteralPath $current)) { + continue + } + $item = Get-Item -LiteralPath $current -Force + if ( + $item.Attributes -band + [System.IO.FileAttributes]::ReparsePoint + ) { + throw "Refusing to use a path containing a reparse point: $current" + } + } + + if ( + $InspectDescendants -and + (Test-Path -LiteralPath $fullPath -PathType Container) + ) { + $reparsePoint = Get-ChildItem ` + -LiteralPath $fullPath ` + -Force ` + -Recurse ` + -Attributes ReparsePoint ` + -ErrorAction Stop | + Select-Object -First 1 + if ($reparsePoint) { + throw "Refusing to use a directory containing a reparse point: $($reparsePoint.FullName)" + } + } + + return $fullPath +} + +function Test-FixLabOwnershipMarker { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $false + } + $item = Get-Item -LiteralPath $Path -Force + if ( + $item.Attributes -band + [System.IO.FileAttributes]::ReparsePoint + ) { + throw "Refusing to trust a reparse-point ownership marker: $Path" + } + return (Get-Content -LiteralPath $Path -Raw).Trim() -eq ( + "FixLab portable runtime root" + ) +} + function Get-RepositoryNodeVersion { param([string]$Root) @@ -199,6 +265,7 @@ if (-not $requestedVersion) { } $destinationRootFull = [System.IO.Path]::GetFullPath($DestinationRoot) +$destinationRootFull = Assert-NoReparsePoints -Path $destinationRootFull $ownershipMarker = Join-Path $destinationRootFull ".fixlab-runtime-root" $runtimeName = "node-v$requestedVersion-win-$Architecture" $runtimePath = [System.IO.Path]::GetFullPath( @@ -233,9 +300,12 @@ Write-Output " NVM changes: none" $current = $null if (Test-Path -LiteralPath $runtimePath) { - if (-not (Test-Path -LiteralPath $ownershipMarker -PathType Leaf)) { + if (-not (Test-FixLabOwnershipMarker -Path $ownershipMarker)) { throw "Refusing to inspect an existing runtime because DestinationRoot is not marked as FixLab-owned: $destinationRootFull" } + Assert-NoReparsePoints ` + -Path $runtimePath ` + -InspectDescendants | Out-Null $current = Test-PortableRuntime ` -Directory $runtimePath ` -RequiredVersion $requestedVersion @@ -253,19 +323,27 @@ if (-not $Yes) { } New-Item -ItemType Directory -Path $destinationRootFull -Force | Out-Null +$destinationRootFull = Assert-NoReparsePoints -Path $destinationRootFull if ( (Test-Path -LiteralPath $runtimePath) -and - -not (Test-Path -LiteralPath $ownershipMarker -PathType Leaf) + -not (Test-FixLabOwnershipMarker -Path $ownershipMarker) ) { throw "Refusing to replace an existing runtime because DestinationRoot is not marked as FixLab-owned: $destinationRootFull" } -if (-not (Test-Path -LiteralPath $ownershipMarker -PathType Leaf)) { +if (-not (Test-FixLabOwnershipMarker -Path $ownershipMarker)) { Set-Content ` -LiteralPath $ownershipMarker ` -Value "FixLab portable runtime root" } -$downloadRoot = Join-Path $destinationRootFull ( - ".downloads\repair-$([guid]::NewGuid())" +$downloadsContainer = Join-Path $destinationRootFull ".downloads" +$downloadsContainer = Assert-NoReparsePoints -Path $downloadsContainer +New-Item ` + -ItemType Directory ` + -Path $downloadsContainer ` + -Force | Out-Null +$downloadsContainer = Assert-NoReparsePoints -Path $downloadsContainer +$downloadRoot = Join-Path $downloadsContainer ( + "repair-$([guid]::NewGuid())" ) $archivePath = Join-Path $downloadRoot $archiveName $checksumsPath = Join-Path $downloadRoot "SHASUMS256.txt" @@ -297,6 +375,9 @@ try { New-Item -ItemType Directory -Path $extractPath | Out-Null Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath $extractedRuntime = Join-Path $extractPath $runtimeName + Assert-NoReparsePoints ` + -Path $extractedRuntime ` + -InspectDescendants | Out-Null $verified = Test-PortableRuntime ` -Directory $extractedRuntime ` -RequiredVersion $requestedVersion @@ -306,8 +387,11 @@ try { if ( (Test-Path -LiteralPath $runtimePath) -and - (Test-Path -LiteralPath $ownershipMarker -PathType Leaf) + (Test-FixLabOwnershipMarker -Path $ownershipMarker) ) { + Assert-NoReparsePoints ` + -Path $runtimePath ` + -InspectDescendants | Out-Null Remove-Item -LiteralPath $runtimePath -Recurse -Force } Move-Item -LiteralPath $extractedRuntime -Destination $runtimePath @@ -318,6 +402,9 @@ finally { } } +$runtimePath = Assert-NoReparsePoints ` + -Path $runtimePath ` + -InspectDescendants $runtime = Test-PortableRuntime ` -Directory $runtimePath ` -RequiredVersion $requestedVersion diff --git a/test/fixlab-cli.test.js b/test/fixlab-cli.test.js index 7f1499d..857f81a 100644 --- a/test/fixlab-cli.test.js +++ b/test/fixlab-cli.test.js @@ -183,12 +183,16 @@ test("portable Node repair is plan-first and checksum verified", () => { assert.match(repair, /NVM changes: none/); assert.match(repair, /\.fixlab-runtime-root/); assert.match(repair, /not marked as FixLab-owned/); + assert.match(repair, /Assert-NoReparsePoints/); + assert.match(repair, /FileAttributes\]::ReparsePoint/); + assert.match(repair, /InspectDescendants/); + assert.match(repair, /Test-FixLabOwnershipMarker/); assert.match(repair, /npm\.cmd/); assert.match(repair, /pack --dry-run --ignore-scripts --json/); assert.match(repair, /if \(-not \$Yes\)/); assert.match(repair, /No changes made/); const ownershipCheck = repair.indexOf( - "if (-not (Test-Path -LiteralPath $ownershipMarker -PathType Leaf))" + "if (-not (Test-FixLabOwnershipMarker -Path $ownershipMarker))" ); const existingRuntimeProbe = repair.indexOf( "$current = Test-PortableRuntime" From ad980e3212be5f88fe9de50de875290d4f4f001f Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Fri, 25 Sep 2026 13:49:05 -0400 Subject: [PATCH 4/5] Protect dashboard control records Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 712fbe09-8920-4820-8cba-cd665d429179 --- CHANGELOG.md | 3 +- bin/fixlab.js | 84 ++++++++++++++++++++++++++++++++++------- docs/commands.md | 4 +- test/fixlab-cli.test.js | 36 +++++++++++++++++- 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d38d0..27b6360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ All notable changes to FixLab are documented in this file. - 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 - so users can gracefully stop only the FixLab-owned dashboard. + 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 diff --git a/bin/fixlab.js b/bin/fixlab.js index 0ce65ba..dad704c 100644 --- a/bin/fixlab.js +++ b/bin/fixlab.js @@ -5,9 +5,10 @@ import { createHash, randomUUID } from "node:crypto"; import { copyFileSync, existsSync, + lstatSync, mkdirSync, readFileSync, - rmSync, + unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -1061,9 +1062,44 @@ function dashboardControlPath(repository) { ); } -function writeDashboardControl(repository, port, token) { +function dashboardControlDirectory(repository, create) { const controlPath = dashboardControlPath(repository); - mkdirSync(dirname(controlPath), { recursive: true }); + 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({ @@ -1074,34 +1110,51 @@ function writeDashboardControl(repository, port, token) { pid: process.pid, token }), - { mode: 0o600 } + { 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) { - rmSync(controlPath, { force: true }); + unlinkSync(controlPath); + return true; } } catch { // A missing or replaced record does not belong to this dashboard instance. } + return false; } async function stopDashboard(repository) { - const controlPath = dashboardControlPath(repository); - if (!existsSync(controlPath)) { + 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 { - console.error(`FixLab dashboard control record is invalid: ${controlPath}`); + } catch (error) { + console.error( + `FixLab dashboard control record is invalid: ${controlPath} (${error.message})` + ); return 1; } if ( @@ -1134,11 +1187,16 @@ async function stopDashboard(repository) { return 1; } } catch { - rmSync(controlPath, { force: true }); - console.log( - `No running FixLab dashboard was found; removed stale control record for ${repository}.` + 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 0; + return 1; } console.log(`Stopping FixLab dashboard for ${repository}.`); diff --git a/docs/commands.md b/docs/commands.md index b50414d..dd3c4e3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -72,7 +72,9 @@ 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. +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 diff --git a/test/fixlab-cli.test.js b/test/fixlab-cli.test.js index 857f81a..1924dc3 100644 --- a/test/fixlab-cli.test.js +++ b/test/fixlab-cli.test.js @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmodSync, existsSync, @@ -7,9 +8,11 @@ import { mkdirSync, readFileSync, rmSync, + symlinkSync, + unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -792,6 +795,37 @@ test("dashboard stop succeeds when no owned dashboard is registered", () => { } }); +test("dashboard stop rejects a substituted control directory", () => { + const repository = mkdtempSync(join(tmpdir(), "fixlab-cli-")); + const target = mkdtempSync(join(tmpdir(), "fixlab-control-target-")); + const identity = createHash("sha256") + .update(resolve(repository)) + .digest("hex") + .slice(0, 20); + const controlRoot = join(tmpdir(), "fixlab-dashboard-control"); + const controlDirectory = join(controlRoot, identity); + + try { + mkdirSync(controlRoot, { recursive: true }); + symlinkSync( + target, + controlDirectory, + process.platform === "win32" ? "junction" : "dir" + ); + + const result = run(["dashboard", repository, "--stop"], repository); + + assert.equal(result.status, 1); + assert.match(result.stderr, /not a trusted local directory/); + } finally { + if (existsSync(controlDirectory)) { + unlinkSync(controlDirectory); + } + rmSync(target, { recursive: true, force: true }); + rmSync(repository, { recursive: true, force: true }); + } +}); + test("run launches the Agency-resolved FixLab agent", () => { const repository = mkdtempSync(join(tmpdir(), "fixlab-cli-")); const executableDirectory = join(repository, "bin"); From c94d6bf4d2ab28bf56f3402ca6d2de48957c7d56 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Fri, 25 Sep 2026 14:00:16 -0400 Subject: [PATCH 5/5] Reject dangling runtime links Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 712fbe09-8920-4820-8cba-cd665d429179 --- scripts/repair-node-runtime.ps1 | 18 ++++++++++++++---- test/fixlab-cli.test.js | 1 + 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/scripts/repair-node-runtime.ps1 b/scripts/repair-node-runtime.ps1 index ebf368d..38f413a 100644 --- a/scripts/repair-node-runtime.ps1 +++ b/scripts/repair-node-runtime.ps1 @@ -39,10 +39,13 @@ function Assert-NoReparsePoints { [System.StringSplitOptions]::RemoveEmptyEntries )) { $current = Join-Path $current $segment - if (-not (Test-Path -LiteralPath $current)) { + $item = Get-Item ` + -LiteralPath $current ` + -Force ` + -ErrorAction SilentlyContinue + if (-not $item) { continue } - $item = Get-Item -LiteralPath $current -Force if ( $item.Attributes -band [System.IO.FileAttributes]::ReparsePoint @@ -73,16 +76,22 @@ function Assert-NoReparsePoints { function Test-FixLabOwnershipMarker { param([string]$Path) - if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + $item = Get-Item ` + -LiteralPath $Path ` + -Force ` + -ErrorAction SilentlyContinue + if (-not $item) { return $false } - $item = Get-Item -LiteralPath $Path -Force if ( $item.Attributes -band [System.IO.FileAttributes]::ReparsePoint ) { throw "Refusing to trust a reparse-point ownership marker: $Path" } + if ($item.PSIsContainer) { + throw "Refusing to trust a non-file ownership marker: $Path" + } return (Get-Content -LiteralPath $Path -Raw).Trim() -eq ( "FixLab portable runtime root" ) @@ -283,6 +292,7 @@ if ( ) { throw "Runtime destination must remain inside DestinationRoot." } +$runtimePath = Assert-NoReparsePoints -Path $runtimePath $archiveName = "$runtimeName.zip" $downloadBase = "https://nodejs.org/dist/v$requestedVersion" diff --git a/test/fixlab-cli.test.js b/test/fixlab-cli.test.js index 1924dc3..f4ea958 100644 --- a/test/fixlab-cli.test.js +++ b/test/fixlab-cli.test.js @@ -188,6 +188,7 @@ test("portable Node repair is plan-first and checksum verified", () => { assert.match(repair, /not marked as FixLab-owned/); assert.match(repair, /Assert-NoReparsePoints/); assert.match(repair, /FileAttributes\]::ReparsePoint/); + assert.match(repair, /Get-Item[\s\S]*-ErrorAction SilentlyContinue/); assert.match(repair, /InspectDescendants/); assert.match(repair, /Test-FixLabOwnershipMarker/); assert.match(repair, /npm\.cmd/);