diff --git a/CHANGELOG.md b/CHANGELOG.md index a0811f7..27b6360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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..dad704c 100644 --- a/bin/fixlab.js +++ b/bin/fixlab.js @@ -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 { @@ -72,7 +76,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 +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). @@ -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; @@ -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) { @@ -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 { @@ -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; } @@ -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, 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..dd3c4e3 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,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 diff --git a/docs/installation.md b/docs/installation.md index 24dbf67..0bb93dd 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4dd66a2..77c17ed 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -70,8 +70,11 @@ 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, 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/install-fixlab.ps1 b/scripts/install-fixlab.ps1 index 2b76fd3..be4f61e 100644 --- a/scripts/install-fixlab.ps1 +++ b/scripts/install-fixlab.ps1 @@ -35,6 +35,22 @@ function Get-SafeSourceDisplay { ) } +function Get-PortableRepairCommand { + param( + [string]$Root, + [string]$Version + ) + + $repairScript = Join-Path $PSScriptRoot "repair-node-runtime.ps1" + $scriptLiteral = "'" + $repairScript.Replace("'", "''") + "'" + $rootLiteral = "'" + $Root.Replace("'", "''") + "'" + $command = "pwsh -File $scriptLiteral -Repository $rootLiteral" + if ($Version) { + $command += " -NodeVersion $Version" + } + return "$command -Yes" +} + function Get-RepositoryNodeVersion { param([string]$Root) @@ -283,17 +299,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 +329,18 @@ 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" + 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/scripts/repair-node-runtime.ps1 b/scripts/repair-node-runtime.ps1 index c214487..38f413a 100644 --- a/scripts/repair-node-runtime.ps1 +++ b/scripts/repair-node-runtime.ps1 @@ -22,6 +22,81 @@ 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 + $item = Get-Item ` + -LiteralPath $current ` + -Force ` + -ErrorAction SilentlyContinue + if (-not $item) { + continue + } + 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) + + $item = Get-Item ` + -LiteralPath $Path ` + -Force ` + -ErrorAction SilentlyContinue + if (-not $item) { + return $false + } + 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" + ) +} + function Get-RepositoryNodeVersion { param([string]$Root) @@ -110,9 +185,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 +203,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 @@ -162,6 +274,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( @@ -179,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" @@ -194,13 +308,22 @@ 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-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 +} 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 } @@ -210,19 +333,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" @@ -254,6 +385,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 @@ -263,8 +397,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 @@ -275,6 +412,9 @@ finally { } } +$runtimePath = Assert-NoReparsePoints ` + -Path $runtimePath ` + -InspectDescendants $runtime = Test-PortableRuntime ` -Directory $runtimePath ` -RequiredVersion $requestedVersion @@ -284,6 +424,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/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 de541e9..f4ea958 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"; @@ -35,6 +38,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/); @@ -135,6 +139,13 @@ 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, /\$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"/); @@ -175,8 +186,23 @@ 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, /Get-Item[\s\S]*-ErrorAction SilentlyContinue/); + 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-FixLabOwnershipMarker -Path $ownershipMarker))" + ); + 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); }); @@ -757,6 +783,50 @@ 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("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");