diff --git a/.github/agents/fixlab-autofix.agent.md b/.github/agents/fixlab-autofix.agent.md index 50f454c..967c25a 100644 --- a/.github/agents/fixlab-autofix.agent.md +++ b/.github/agents/fixlab-autofix.agent.md @@ -100,19 +100,6 @@ validation with identical command and repository state. Never reuse startup, health, authentication, browser or live-environment checks, external-data checks, dependency installation, deployments, or mutating commands. -Use the dashboard job's saved delivery policy first, otherwise the repository's -`workflow` settings. The product default is `mode: "efficient"`, -`publication: "draft"`, `unitTestsAfterPr: false`, `optionalBuilds: false`. Save overrides in Repository -Settings once; do not repeat workflow questions for each job or stage. -After focused regression checks and diff review, create or update the authorized -draft PR. Ready-for-review publication requires explicit `publication: "ready"`. -Broad tests and builds run only by saved preference or explicit selection; -update the same PR with actual results. -Explicit `validation.gates[].required: true`, repository instructions, managed -approvals and CI/merge policies remain mandatory. Pending or failed optional -checks remain pending or failed; publication never authorizes merging. -An explicit saved strict workflow retains the following draft-first behavior: - When the developer authorizes pull-request publication, do not wait for every independent validation command to finish before creating review visibility. After the scoped production change and its smallest focused regression checks @@ -123,10 +110,7 @@ push later reviewed checkpoints to the same branch, and never mark the PR ready until every required local gate passes or the developer explicitly accepts the recorded outcome. -Honor an explicit publication choice in every mode: strict plus draft stays -draft, with provider `isDraft: true` verified. Legacy strict policy with no -publication preference retains ready intent. Strict required checks never change. -Only for saved ready publication (including that legacy default), after required local validation and approval gates pass or have an explicitly +After required local validation and approval gates pass or have an explicitly accepted outcome, publish the authorized PR by marking it ready for review. Do not wait for remote PR CI or build completion before publication; publication can trigger those checks. Verify the PR is no longer draft before reporting the @@ -136,16 +120,6 @@ policies intact. Use `gh pr ready ` for GitHub or `isDraft` from the provider to confirm it is false. If publication or state verification fails, report the PR stage blocked or failed, not passed. -Reduce redundant regression cases, not correctness. Reuse existing test files -and fixtures; move punctuation, length and duplicate matrices to fast pure -unit checks. Browser coverage uses representative distinct flows, preserving -safety, permissions, lease/queue and real bug reproductions. Measure comparable -baseline and final elapsed time; never claim savings from count alone. -Keep Jest and Playwright discovery separate. Bare Jest may collect E2E specs -and bare Playwright may select all projects: use repository-owned unit selectors -and explicitly selected authenticated browser projects. Do not silently edit -consumer repositories to repair discovery. - Prefix FixLab-created pull-request titles with `FLAB:` followed by a concise description of the coding change. Use the repository's PR template and clear Markdown sections for the problem, changes, validation and risks. Keep @@ -153,18 +127,6 @@ validation checkboxes accurate; do not describe a pending gate as passed. ## Connected dashboard recovery -Use normal Copilot reasoning and native tools; stage markers report work, not -a recipe requiring extra commands. For explicit browser-only reproduction or -deployed-result checks, select `playwright-only` and first reuse an approved -existing application and authorized validation session when supported. Skip -unnecessary local-stack work honestly; do not clone, restore or run broad -unit/build gates merely to repeat deployed behavior. Never copy browser state, -bypass a profile lock, take over another browser or silently change targets. -Keep sign-in and mutation guards. A deployed baseline does not prove local -changes or a PR; those require the matching source commit and application. -For fix requests, start with the smallest known reproduction and prepare -applications only when needed to exercise changed code. - Use `fixlab_dashboard_control` for user-authorized job/queue, configured gate, frontend/authentication session, scoped permission and intake operations. Read status first and bind its instance and current job. Do not ask again for @@ -183,9 +145,7 @@ existing PR and preserve uncommitted work. A changed fingerprint requires a new review. Folder permissions and human authentication remain separate. An idle, unconnected Copilot session cannot receive injected questions. Honor user-selected scoped iteration: full regression is explicitly deferred, -not passed; in strict workflow PR readiness stays blocked, whereas efficient -workflow permits publication after focused checks and explicitly required -pre-publication gates. Continue independent safe checks +not passed, and PR readiness stays blocked. Continue independent safe checks after unrelated failures without bypassing dependencies. Do not delete or edit transient test artifacts while validation is discovering or executing them; wait for the owned validation to finish before cleanup. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ae51e0..926995d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,36 +2,7 @@ All notable changes to FixLab are documented in this file. -## 0.7.4 - Unreleased - -- Make browser-only reproduction and deployed-result checks existing-application - first, with mode-aware runner/authentication preflight instead of unused - backend toolchain requirements. Preserve target, commit and data-safety - boundaries and native Copilot reasoning/tools across entry points. -- Default new editing jobs to focused checks and a ready-for-review PR, with - broader local unit tests afterward and optional builds saved once in - Repository Settings. Preserve explicit required checks and strict delivery. -- Keep optional validation pending, failed or stale honestly; retain legacy - saved-job policy until an explicitly confirmed stopped-job migration. -- Add reviewed, persistent job-bound source/test read-write trust and narrowly - owned validation-gate/output access without granting secrets, arbitrary shell - commands, other jobs or managed-approval exceptions. -- Classify JavaScript/TypeScript `auth.spec` and `auth.test` source as tests - under reviewed file trust, without admitting saved authentication state, - sensitive directories, credential markers or configured protected paths. -- Keep the permission display limit from invalidating reviewed source-edit - trust; larger writes require bounded full-content safety scanning and retain - all job, checkout, path, managed-policy and sandbox boundaries. -- Hide input-token budget controls by default behind a Show budget checkbox; - preserve existing limits, usage reporting and stopped-job review. -- Simplify the dashboard around chat, with advanced controls in Run options and - the sidebar, responsive navigation, and preserved unsent messages. -- Limit fullscreen header requests to two visible lines while retaining the - complete title on hover and keeping the exit control accessible. -- Recover stopped shared-worktree jobs through an explicitly reviewed, - fingerprint-bound stash of eligible untracked source files and a new job - branch from the latest configured base. Preserve the previous branch, job, - and verified stash without automatically restoring files or reusing grants. +## Unreleased - Keep managed frontend input open so React-style development servers do not stop on EOF; preserve one-shot helper behavior and owned process cleanup. diff --git a/README.md b/README.md index 541dc31..cebe082 100644 --- a/README.md +++ b/README.md @@ -33,52 +33,11 @@ FixLab runs through **Agency Copilot** or directly through ## Get started -With a supported runtime installed and signed in: - -```powershell -npm install --global github:MonaDevAI/FixLab -fixlab chat C:\source\application --runtime copilot -``` - -Replace the path with your clone; use `--runtime agency` for Agency. -Review guided setup when prompted, then describe your request in the dashboard. -Use **Menu** on small screens. **Run options** and the sidebar reveal advanced -controls only when needed; **Back to chat** returns to your unsent message. -See [Installation](docs/installation.md) for prerequisites and troubleshooting. - -
-Delivery and validation defaults - -**Efficient delivery is the product default:** focused regression checks and -diff review, then an authorized draft PR. Broad unit tests and optional builds -are off by default; run them by saved preference or explicit selection. Save preferences -once in **Repository Settings**, not for every job or stage. -Explicitly required repository checks, organization approvals and CI/merge -policies are never waived; a ready PR does not mean every check passed. -Set `workflow.publication: "ready"` only when ready-for-review publication is intended. -Keep Jest discovery separate from Playwright, explicitly selecting authenticated -browser projects; do not use bare commands that accidentally collect browser specs. -Consolidate duplicate regressions and move value matrices to pure unit tests, -while retaining representative browser flows and critical safety boundaries. -Use `workflow.mode: "strict"` to retain full local validation before readiness. -Existing saved jobs retain their recorded policy; migrating one requires an -explicit stopped-job review. - -For **browser-only reproduction or deployed-result checks**, use the browser -reproduction/result-check mode (`playwright-only`). FixLab first checks the -approved existing application and validation session; it does not require -local-stack toolchains or broad unit/build gates merely to repeat a deployed -scenario. Copilot keeps its native tools and reasoning; stage markers report -work rather than impose extra tasks. Existing deployment results never stand -in for validation of an unpublished local fix or a different PR commit. - -
- Already inside Copilot? Follow the [Copilot installation steps](docs/installation.md#starting-from-an-existing-copilot-session) without opening a separate terminal. -### 1. Install a supported runtime (manual setup) +### 1. Install a supported runtime Agency is the default compatibility runtime: @@ -92,25 +51,6 @@ For direct mode, install and authenticate GitHub Copilot CLI, then use Direct Copilot dashboard jobs use the official Copilot SDK and its bundled runtime to show live **Allow once / Deny** permission prompts and agent questions. -Bounded read-only exceptions cover native filename discovery, provenance-bound -source search in one canonical checkout directory, and -`node --help`. Source search requires one matching -live native `rg` event, an explicit source glob and bounded output; multi-root -searches and arbitrary directory reads remain manual. Managed human approvals -and sandbox escalation still require review. -Eligible requests also offer separately confirmed, executor-local repository-file -read or exact test-command grants. Manual review remains the default, and -organization policy stays authoritative. Optional per-job input-token ceilings -default to unlimited; all native modes use earlier context compaction. -Budget controls are hidden by default; select **Show budget** in Current job -details to reveal them. Hiding them never changes an existing job's ceiling. -For a stopped Copilot job, **Current job details > Repository file trust** can -separately authorize non-sensitive source/test reads and edits for that exact -job and canonical checkout, including resumes and dashboard restart. Other -jobs never inherit it. The same review can optionally trust dashboard-owned -validation gates bound to this job, checkout and current dashboard port. -Sensitive files, configuration edits and other shell commands remain separately -controlled; read-only jobs cannot acquire edit trust. Your response continues the same executor; human wait does not trigger the idle timeout. Authentication stays in the normal browser. Agency and unclassified text denials retain the existing fail-closed folder-review flow. See @@ -356,9 +296,6 @@ Read the complete [security model](docs/security.md). ## Current availability -The next version being prepared is **v0.7.4**; updating local installations does -not publish a GitHub release or npm package. - The current GitHub release is **v0.7.3**. It includes: - The dependency-free FixLab CLI. diff --git a/agents/fixlab.md b/agents/fixlab.md index c2448b2..94b2823 100644 --- a/agents/fixlab.md +++ b/agents/fixlab.md @@ -19,29 +19,6 @@ Turn a defect or pull request into a verified engineering outcome: Never report a skipped or timed-out check as passed. -## Copilot-first execution - -Use normal Copilot reasoning and native tools to choose the shortest -evidence-backed path. Stage markers report work; they are not a recipe -requiring extra setup or commands. Start with the known symptom and nearest -relevant test, reuse verified context, and adapt to evidence. Do not ask users -to direct routine work already authorized. - -For explicit browser-only reproduction or deployed-result checks, select -`playwright-only`. First check the approved existing application for the -selected environment and reuse its authorized validation browser connection -when supported. Do not clone, restore dependencies, start another local stack -or run broad unit/build gates just to repeat a deployed UI scenario. Mark -unnecessary local-stack work skipped, not passed. Keep authentication, profile -locks and mutation guards; never copy browser state or take over another -browser. Do not silently change an unavailable target or environment. - -For a fix request, use the smallest known reproduction before preparing -applications needed for changed code. An existing deployment or baseline does -not prove an unpublished fix or PR: validate those against the matching source -commit and correct application. Explicit manual-localhost requests retain -their local prerequisites. - ## Repository contract Look for `.github/fixlab/repository-profile.json` before running repository @@ -94,21 +71,6 @@ and stage are unchanged. Never reuse application startup, health checks, authentication, browser or live-environment validation, external-data checks, dependency installation, deployments, or mutating commands. -Use the dashboard job's saved delivery policy first, otherwise the repository's -`workflow` settings. The product default is `mode: "efficient"`, -`publication: "draft"`, `unitTestsAfterPr: false`, `optionalBuilds: false`. Do not ask users to select -these again for each job or stage. Repository Settings saves overrides once. -Efficient delivery runs the focused regression checks and reviews the effective -diff, then creates or updates the authorized **draft** PR before optional broad -validation. Ready-for-review publication requires the explicit saved -`publication: "ready"` preference. Broad tests and builds run only by saved -preference or explicit selection; update the same PR with actual results. -Keep not-run and failed checks visible; -ready for review never means fully validated or authorized to merge. -Explicit `validation.gates[].required: true`, repository instructions, -organization/managed approvals, and CI/merge policies remain mandatory. -An explicit saved strict workflow retains the following draft-first behavior: - When pull-request publication is already authorized, do not wait for every independent validation command to finish before creating review visibility. After the scoped production change and its smallest focused regression checks @@ -120,10 +82,7 @@ keep validation status and risks current in the PR, and never mark it ready for review until every required local gate passes or repository policy records an explicitly accepted outcome. -Honor an explicit publication choice in every mode: strict plus draft stays -draft, with provider `isDraft: true` verified. Legacy strict policy with no -publication preference retains ready intent. Strict required checks never change. -Only for saved ready publication (including that legacy default), after required local validation and approval gates pass or have an explicitly +After required local validation and approval gates pass or have an explicitly accepted outcome, publish the authorized PR by marking it ready for review. Do not wait for remote PR CI or build completion before publication; publication can trigger those checks. Verify the PR is no longer draft before reporting the @@ -133,16 +92,6 @@ policies intact. Use `gh pr ready ` for GitHub or `isDraft` from the provider to confirm it is false. If publication or state verification fails, report the PR stage blocked or failed, not passed. -Reduce redundant regression cases, not correctness. Reuse existing test files -and fixtures; move punctuation, length and duplicate matrices to fast pure -unit checks. Browser coverage uses representative distinct flows, preserving -safety, permissions, lease/queue and real bug reproductions. Measure comparable -baseline and final elapsed time; never claim savings from count alone. -Keep Jest and Playwright discovery separate. Bare Jest may collect E2E specs -and bare Playwright may select all projects: use repository-owned unit selectors -and explicitly selected authenticated browser projects. Do not silently edit -consumer repositories to repair discovery. - Prefix FixLab-created pull-request titles with `FLAB:` followed by a concise description of the coding change. Use the repository's PR template and clear Markdown sections for the problem, changes, validation and risks. Keep @@ -215,9 +164,7 @@ separate approvals. The dashboard cannot inject questions into an idle, unconnected Copilot session; read status when the user requests progress. Honor user-selected scoped iteration: full regression is explicitly deferred, -not passed; in strict workflow PR readiness stays blocked. Efficient delivery -permits publication after focused checks and explicitly required pre-publication -gates. Continue independent safe checks +not passed, and PR readiness stays blocked. Continue independent safe checks after unrelated failures, while preserving their exact outcomes and honoring dependencies. Do not delete or edit transient test artifacts while validation is discovering or executing them; wait for the owned validation to finish diff --git a/bin/fixlab.js b/bin/fixlab.js index 5cfe275..6243eb2 100644 --- a/bin/fixlab.js +++ b/bin/fixlab.js @@ -42,12 +42,10 @@ import { runStructuredCommand } from "./structured-command.js"; import { applyWorktreeSetup, browserConfigurationErrors, - planWorktreeSetup, - REPOSITORY_PROMPT_NAMES + planWorktreeSetup } from "../dashboard/repository-setup.js"; import { runValidationGate } from "./validation-gate-client.js"; import { installNotifications } from "./notifications.js"; -import { configuredWorkflow, efficientWorkflowGuidance, COPILOT_EXECUTION_GUIDANCE } from "../dashboard/workflow-policy.js"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const profileRelativePath = join( @@ -55,7 +53,16 @@ const profileRelativePath = join( "fixlab", "repository-profile.json" ); -const promptNames = REPOSITORY_PROMPT_NAMES; +const promptNames = [ + "fixlab.bugfix.prompt.md", + "fixlab.intake.prompt.md", + "fixlab.diagnose.prompt.md", + "fixlab.reproduce.prompt.md", + "fixlab.fix.prompt.md", + "fixlab.validate.prompt.md", + "fixlab.live-test.prompt.md", + "fixlab.pr.prompt.md" +]; const vscodeAgentName = "fixlab-autofix.agent.md"; const troubleshootingUrl = "https://github.com/MonaDevAI/FixLab/blob/main/docs/troubleshooting.md"; @@ -609,7 +616,6 @@ function doctor(repository, runtime) { console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name} (${check.detail})`); } - console.log("Doctor does not prove an authenticated browser session. Required sign-in needs the owning job's fresh authentication proof."); const failed = checks.filter((check) => !check.ok); if (failed.length > 0) { console.error(`FixLab doctor found ${failed.length} blocking check(s).`); @@ -642,7 +648,7 @@ function doctor(repository, runtime) { return 1; } - console.log(`FixLab configuration checks passed for ${profile.name ?? repository}.`); + console.log(`FixLab is ready for ${profile.name ?? repository}.`); return 0; } @@ -939,7 +945,7 @@ function authenticate(repository, approved) { return 1; } - console.log("Browser authentication helper completed and required state is present; this does not prove sign-in. Run the repository-owned authenticated browser proof for the selected job and environment."); + console.log("Browser authentication completed and required state is ready."); return 0; } @@ -959,9 +965,7 @@ function launch(repository, request, runtime, targetEnvironment = "", model = "" } let selectedEnvironment; - let workflow; try { - workflow = configuredWorkflow(profile); selectedEnvironment = validateTargetEnvironment( targetEnvironment, configuredValidationEnvironments(profile) @@ -979,20 +983,14 @@ function launch(repository, request, runtime, targetEnvironment = "", model = "" } const guidance = [ - COPILOT_EXECUTION_GUIDANCE, "For known external-folder prerequisites, offer permissionPreflight before executor launch; it only opens review, never grants access. Use reviewed component path-to-gate policies for change-aware validation; unknown paths, full-validation paths, explicit full coverage, and previously executed gates retain their required checks.", "Use fixlab_dashboard_control for user-authorized dashboard operations. Read status first and bind its instance and current job. Do not ask again for routine steps already authorized; ask only for missing decisions or required approvals. For 'test this PR', verify the assigned checkout matches the PR source commit, then start playwright-only using the repository's Playwright configuration, selected environment and human-controlled authentication. Do not change code or use another checkout's evidence. Normal guards apply.", - "For a connected host conversation, use fixlab_dashboard_status and fixlab_dashboard_reply to surface a recovery question using the host's question UI and submit only the user's explicit answer to the same instance and job. Never infer approval. Honor user-selected scoped iteration: full regression is explicitly deferred, not passed; in strict workflow PR readiness stays blocked. Efficient delivery permits publication after focused checks and explicitly required pre-publication gates. Continue independent safe checks after unrelated failures without bypassing dependencies. Reviewed workspace reuse must preserve the current branch, uncommitted work and existing PR; a changed fingerprint requires a new review. Runtime permissions and human authentication remain separate approvals.", - !readOnly && workflow.mode === "efficient" - ? efficientWorkflowGuidance(workflow, { dashboard: false }) - : "Use strict delivery: all applicable local gates and required approvals remain mandatory before readiness. Honor the saved draft/ready publication choice. Read-only runs never create or update a PR.", + "For a connected host conversation, use fixlab_dashboard_status and fixlab_dashboard_reply to surface a recovery question using the host's question UI and submit only the user's explicit answer to the same instance and job. Never infer approval. Honor user-selected scoped iteration: full regression is explicitly deferred, not passed, and PR readiness stays blocked. Continue independent safe checks after unrelated failures without bypassing dependencies. Reviewed workspace reuse must preserve the current branch, uncommitted work and existing PR; a changed fingerprint requires a new review. Runtime permissions and human authentication remain separate approvals.", "Do not delete or edit transient test artifacts while validation is discovering or executing them; wait for owned validation to finish before cleanup.", "Before validation in a new isolated worktree, plan `fixlab onboard WORKTREE --from SOURCE --runtime RUNTIME`. After scoped setup approval, add `--yes`. The source profile declares non-sensitive configuration in `worktreeSetup.files`. Never copy credentials or browser state. Missing configuration, authentication or denied runtime access is a blocker; do not retry unchanged approvals or grant blanket path access. Sign-in and MFA remain human-controlled. In read-only modes, do not provision or alter configuration; report missing prerequisites as blocked.", "Prefix FixLab-created pull-request titles with `FLAB:` followed by a concise coding-change description. Use the repository's PR template with clear Markdown sections for the problem, changes, validation and risks, and accurate validation checkboxes.", readOnly ? "Do not publish a pull request or change its draft status in read-only mode." - : workflow.publication === "draft" - ? "Keep the published PR draft and read back provider isDraft=true before reporting publication passed. Marking ready requires a separate reviewed decision. Strict required checks remain mandatory." : "After required local validation and approval gates pass or have an explicitly accepted outcome, publish the authorized PR by marking it ready for review. Do not wait for remote PR CI or build completion before publication; publication can trigger those checks. Verify the PR is no longer draft before reporting the PR stage passed. Report remote checks as pending, not passed, and leave merge policies intact. Use `gh pr ready PR_NUMBER` for GitHub or `az repos pr update --id PR_ID --draft false` for Azure DevOps, then read back `isDraft` from the provider to confirm it is false. If publication or state verification fails, report the PR stage blocked or failed, not passed.", "Treat runtime-synthesized Playwright scenarios as transient validation artifacts by default. Remove their source files and validation-only configuration edits before review, commit, push, or pull-request creation. Do not add newly generated authenticated tests such as *.auth.spec.ts unless the user explicitly requests permanent browser-test coverage or repository instructions require that exact persisted test.", ...(selectedEnvironment @@ -1010,7 +1008,7 @@ function launch(repository, request, runtime, targetEnvironment = "", model = "" : []) ] : []) - ].join(" ").replace(/\s+/gu, " "); + ].join(" "); const prompt = request || selectedEnvironment || interactiveRequest ? `${guidance}${request || interactiveRequest ? ` Request: ${request || interactiveRequest}` : ""}` diff --git a/bin/structured-command.js b/bin/structured-command.js index 17e6d22..5918ec8 100644 --- a/bin/structured-command.js +++ b/bin/structured-command.js @@ -1,9 +1,7 @@ import { spawn, spawnSync } from "node:child_process"; -import { spawnWindowsExecutor, quoteWindowsArgument } from "../dashboard/windows-executor.js"; import { createHash, randomUUID } from "node:crypto"; import { existsSync, - lstatSync, mkdirSync, readFileSync, realpathSync, @@ -62,7 +60,7 @@ function isContained(root, candidate) { ); } -export function resolveEvidenceRoot(repository) { +function resolveEvidenceRoot(repository) { const result = git(repository, [ "rev-parse", "--git-path", @@ -282,31 +280,6 @@ function resolveWindowsNpmCommand(command, args) { : null; } -export function resolveWindowsAzureCommand(command, args, platform = process.platform, searchPath = process.env.PATH ?? "") { - if (platform !== "win32" || !/^az(?:\.cmd)?$/i.test(basename(command))) { - return null; - } - if (!isAbsolute(command) && command !== basename(command)) { - return null; - } - const candidates = isAbsolute(command) ? [command] : - searchPath.split(";").filter((path) => isAbsolute(path)).map((path) => join(path, "az.cmd")); - for (const wrapper of candidates) { - if (basename(dirname(wrapper)).toLowerCase() !== "wbin" || - basename(dirname(dirname(wrapper))).toLowerCase() !== "cli2") { - continue; - } - const python = join(dirname(dirname(wrapper)), "python.exe"); - if (existsSync(wrapper) && existsSync(python) && - statSync(wrapper).isFile() && statSync(python).isFile() && - realpathSync.native(wrapper) === resolve(wrapper) && - realpathSync.native(python) === resolve(python)) { - return { command: python, args: ["-m", "azure.cli", ...args] }; - } - } - return null; -} - function parsePositiveInteger(value, name) { const number = Number(value); if ( @@ -649,7 +622,7 @@ function cacheFingerprint(options, state) { function formatSummary(result, reused = false) { const lines = [ - `${reused ? "REUSED" : result.status === 0 ? "PASS" : "FAIL"} fixlab exec: ${sanitize(result.displayCommand)} (${(result.durationMs / 1000).toFixed(1)}s)`, + `${reused ? "REUSED" : result.status === 0 ? "PASS" : "FAIL"} fixlab exec: ${result.displayCommand} (${(result.durationMs / 1000).toFixed(1)}s)`, `Tool: ${sanitize(result.tool ?? "command")}` ]; const tests = result.stdout.tests ?? result.stderr.tests; @@ -727,31 +700,18 @@ function formatSummary(result, reused = false) { return { text, metrics }; } -function readReusableResult(cachePath, evidenceRoot, stderr) { +function readReusableResult(cachePath) { if (!existsSync(cachePath)) { return null; } try { const result = JSON.parse(readFileSync(cachePath, "utf8")); - const count = (value) => Number.isSafeInteger(value) && value >= 0; - const stream = (value) => value && count(value.totalBytes) && count(value.lineCount) && - ["errors", "warnings", "tail"].every((key) => Array.isArray(value[key]) && - value[key].length <= 100 && value[key].every((line) => typeof line === "string" && line.length <= 65536)) && - (!value.tests || ["passed", "failed", "skipped", "total"].every((key) => count(value.tests[key]))); - const valid = result?.status === 0 && result.terminationVerified === true && - typeof result.displayCommand === "string" && result.displayCommand.length <= 8192 && - Number.isFinite(result.durationMs) && result.durationMs >= 0 && - stream(result.stdout) && stream(result.stderr) && - typeof result.evidenceDirectory === "string" && dirname(result.evidenceDirectory) === evidenceRoot && - existsSync(result.evidenceDirectory) && lstatSync(result.evidenceDirectory).isDirectory() && - !lstatSync(result.evidenceDirectory).isSymbolicLink(); - if (valid) { return result; } - stderr.write("Ignored invalid or unavailable reusable command evidence; running validation again.\n"); - } catch (error) { - if (!(error instanceof SyntaxError)) { throw error; } - stderr.write("Ignored malformed reusable command metadata; running validation again.\n"); + return result?.status === 0 && existsSync(result.evidenceDirectory) + ? result + : null; + } catch { + return null; } - return null; } export async function executeStructuredCommand(options, io = {}) { @@ -772,7 +732,7 @@ export async function executeStructuredCommand(options, io = {}) { options.reuse && reusableValidationCommand(options.command, options.commandArgs); if (reusable) { - const cached = readReusableResult(cachePath, evidenceRoot, stderr); + const cached = readReusableResult(cachePath); if (cached) { stdout.write(formatSummary(cached, true).text); return 0; @@ -795,12 +755,10 @@ export async function executeStructuredCommand(options, io = {}) { const stdoutCollector = createCollector(join(evidenceDirectory, "stdout.log")); const stderrCollector = createCollector(join(evidenceDirectory, "stderr.log")); const startedAt = now(); - let terminationVerified = true; - let executionFailed = false; const displayCommand = sanitize( [options.command, ...options.commandArgs].join(" ") ); - const windowsNpmCommand = resolveWindowsAzureCommand(options.command, options.commandArgs) ?? resolveWindowsNpmCommand( + const windowsNpmCommand = resolveWindowsNpmCommand( options.command, options.commandArgs ); @@ -835,84 +793,24 @@ export async function executeStructuredCommand(options, io = {}) { let forceTimeout = null; let abandonTimeout = null; let cancelled = false; - let containment = null; - let child; - let terminating = false; - const ownedGroup = process.platform !== "win32" && io.ownProcessTree && !io.spawn; - const settlementFailure = (message) => { - terminationVerified = false; - executionFailed = true; - stderrCollector.write(`${message}; owned-tree settlement is unverified and its repository lease must remain held.\n`); - }; - const verifyOwnedTree = () => { - if (containment) { - try { - terminationVerified = containment.hasStopped(); - if (terminationVerified && executionFailed) { - stderrCollector.write("Owned-tree stopped proof verified; the command failure is retained.\n"); - } - } catch (error) { - settlementFailure(`Owned containment proof failed (${error.code ?? "invalid proof"})`); - } - } else if (ownedGroup && Number.isInteger(child.pid)) { - try { - process.kill(-child.pid, 0); - terminationVerified = false; - } catch (error) { - terminationVerified = error.code === "ESRCH"; - } - } else { - return false; - } - if (!terminationVerified) { - stderrCollector.write("Owned command tree termination could not be verified; its repository lease must remain held.\n"); - } - return terminationVerified; - }; - const signalChild = (signal) => { - try { - if (ownedGroup && Number.isInteger(child.pid)) { - process.kill(-child.pid, signal); - } else if (!child.kill(signal)) { - settlementFailure("Owned command termination was not accepted"); - } - } catch (error) { - if (!ownedGroup || error.code !== "ESRCH") { - settlementFailure(`Owned command termination failed (${error.code ?? "unknown error"})`); - } - } - }; const terminate = (exitCode) => { - if (terminating || settled) { return; } - terminating = true; - if (containment) { - try { - containment.terminate(); - } catch (error) { - settlementFailure(`Owned containment termination failed (${error.code ?? "unknown error"})`); - } - } else if (process.platform === "win32" && Number.isInteger(child.pid)) { + if (process.platform === "win32" && Number.isInteger(child.pid)) { const killed = spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { encoding: "utf8", windowsHide: true }); - if (killed.status !== 0) { - settlementFailure("Could not terminate the owned command tree"); - signalChild("SIGTERM"); + if (killed.status !== 0 && child.exitCode === null) { + stderrCollector.write("Could not terminate the owned command tree; attempting direct termination.\n"); + child.kill("SIGTERM"); } } else { - signalChild("SIGTERM"); + child.kill("SIGTERM"); } forceTimeout = setTimeout(() => { if (!settled) { - if (!containment) { signalChild("SIGKILL"); } + child.kill("SIGKILL"); abandonTimeout = setTimeout(() => { if (!settled) { - if (!verifyOwnedTree()) { - settlementFailure("Timed out waiting for owned command termination"); - } else { - stderrCollector.write("Command close event was unavailable; owned-tree stopped proof verified and command failure retained.\n"); - } settled = true; clearTimers(); child.stdout.destroy(); @@ -938,23 +836,14 @@ export async function executeStructuredCommand(options, io = {}) { }; const environment = { ...process.env }; delete environment.NODE_TEST_CONTEXT; - const spawnOptions = { + const child = spawnProcess(executable, spawnedArguments, { cwd: options.cwd, env: environment, shell: false, - detached: process.platform !== "win32" && io.ownProcessTree === true && !io.spawn, windowsVerbatimArguments: windowsCommandShell, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] - }; - if (process.platform === "win32" && io.ownProcessTree && !io.spawn) { - containment = (io.spawnWindowsExecutor ?? spawnWindowsExecutor)({ - executable, args: spawnedArguments, repository: options.cwd, - environment: { ...environment, NODE_TEST_CONTEXT: undefined }, - ...(windowsCommandShell ? { commandLine: `${quoteWindowsArgument(executable)} /d /v:off /c ${spawnedCommand}` } : {}) - }); - } - child = containment?.child ?? spawnProcess(executable, spawnedArguments, spawnOptions); + }); child.stdout.on("data", stdoutCollector.write); child.stderr.on("data", stderrCollector.write); io.signal?.addEventListener("abort", abort, { once: true }); @@ -964,11 +853,6 @@ export async function executeStructuredCommand(options, io = {}) { child.on("error", (error) => { stderrCollector.write(`${error.message}\n`); if (!settled) { - if (Number.isInteger(child.pid)) { - settlementFailure("Owned command emitted a post-spawn error"); - terminate(cancelled ? 130 : timedOut ? 124 : 1); - return; - } settled = true; clearTimers(); let exitCode = 1; @@ -984,7 +868,6 @@ export async function executeStructuredCommand(options, io = {}) { }); child.on("close", (code, signal) => { if (!settled) { - if (containment || ownedGroup) { verifyOwnedTree(); } settled = true; clearTimers(); if (signal) { @@ -998,14 +881,14 @@ export async function executeStructuredCommand(options, io = {}) { } else if (Number.isInteger(code)) { exitCode = code; } - resolveStatus(terminationVerified && !executionFailed ? exitCode : exitCode || 1); + resolveStatus(exitCode); } }); - if (!settled && options.timeoutSeconds > 0) { + if (options.timeoutSeconds > 0) { timeout = setTimeout(() => { timedOut = true; stderrCollector.write( - `Command exceeded ${options.timeoutSeconds} second(s); requesting owned-tree termination.\n` + `Command exceeded ${options.timeoutSeconds} second(s) and was terminated.\n` ); terminate(124); }, options.timeoutSeconds * 1000); @@ -1015,7 +898,6 @@ export async function executeStructuredCommand(options, io = {}) { const result = { version: 2, status, - terminationVerified, stage: options.stage, displayCommand, tool: sanitize(describeTool(options.command, options.commandArgs)), @@ -1053,5 +935,5 @@ export async function runStructuredCommand( io = {} ) { const options = parseStructuredCommandArguments(args, defaultRepository); - return executeStructuredCommand(options, { ownProcessTree: true, ...io }); + return executeStructuredCommand(options, io); } diff --git a/com.github.copilot/agents/fixlab.agent.md b/com.github.copilot/agents/fixlab.agent.md index c2448b2..94b2823 100644 --- a/com.github.copilot/agents/fixlab.agent.md +++ b/com.github.copilot/agents/fixlab.agent.md @@ -19,29 +19,6 @@ Turn a defect or pull request into a verified engineering outcome: Never report a skipped or timed-out check as passed. -## Copilot-first execution - -Use normal Copilot reasoning and native tools to choose the shortest -evidence-backed path. Stage markers report work; they are not a recipe -requiring extra setup or commands. Start with the known symptom and nearest -relevant test, reuse verified context, and adapt to evidence. Do not ask users -to direct routine work already authorized. - -For explicit browser-only reproduction or deployed-result checks, select -`playwright-only`. First check the approved existing application for the -selected environment and reuse its authorized validation browser connection -when supported. Do not clone, restore dependencies, start another local stack -or run broad unit/build gates just to repeat a deployed UI scenario. Mark -unnecessary local-stack work skipped, not passed. Keep authentication, profile -locks and mutation guards; never copy browser state or take over another -browser. Do not silently change an unavailable target or environment. - -For a fix request, use the smallest known reproduction before preparing -applications needed for changed code. An existing deployment or baseline does -not prove an unpublished fix or PR: validate those against the matching source -commit and correct application. Explicit manual-localhost requests retain -their local prerequisites. - ## Repository contract Look for `.github/fixlab/repository-profile.json` before running repository @@ -94,21 +71,6 @@ and stage are unchanged. Never reuse application startup, health checks, authentication, browser or live-environment validation, external-data checks, dependency installation, deployments, or mutating commands. -Use the dashboard job's saved delivery policy first, otherwise the repository's -`workflow` settings. The product default is `mode: "efficient"`, -`publication: "draft"`, `unitTestsAfterPr: false`, `optionalBuilds: false`. Do not ask users to select -these again for each job or stage. Repository Settings saves overrides once. -Efficient delivery runs the focused regression checks and reviews the effective -diff, then creates or updates the authorized **draft** PR before optional broad -validation. Ready-for-review publication requires the explicit saved -`publication: "ready"` preference. Broad tests and builds run only by saved -preference or explicit selection; update the same PR with actual results. -Keep not-run and failed checks visible; -ready for review never means fully validated or authorized to merge. -Explicit `validation.gates[].required: true`, repository instructions, -organization/managed approvals, and CI/merge policies remain mandatory. -An explicit saved strict workflow retains the following draft-first behavior: - When pull-request publication is already authorized, do not wait for every independent validation command to finish before creating review visibility. After the scoped production change and its smallest focused regression checks @@ -120,10 +82,7 @@ keep validation status and risks current in the PR, and never mark it ready for review until every required local gate passes or repository policy records an explicitly accepted outcome. -Honor an explicit publication choice in every mode: strict plus draft stays -draft, with provider `isDraft: true` verified. Legacy strict policy with no -publication preference retains ready intent. Strict required checks never change. -Only for saved ready publication (including that legacy default), after required local validation and approval gates pass or have an explicitly +After required local validation and approval gates pass or have an explicitly accepted outcome, publish the authorized PR by marking it ready for review. Do not wait for remote PR CI or build completion before publication; publication can trigger those checks. Verify the PR is no longer draft before reporting the @@ -133,16 +92,6 @@ policies intact. Use `gh pr ready ` for GitHub or `isDraft` from the provider to confirm it is false. If publication or state verification fails, report the PR stage blocked or failed, not passed. -Reduce redundant regression cases, not correctness. Reuse existing test files -and fixtures; move punctuation, length and duplicate matrices to fast pure -unit checks. Browser coverage uses representative distinct flows, preserving -safety, permissions, lease/queue and real bug reproductions. Measure comparable -baseline and final elapsed time; never claim savings from count alone. -Keep Jest and Playwright discovery separate. Bare Jest may collect E2E specs -and bare Playwright may select all projects: use repository-owned unit selectors -and explicitly selected authenticated browser projects. Do not silently edit -consumer repositories to repair discovery. - Prefix FixLab-created pull-request titles with `FLAB:` followed by a concise description of the coding change. Use the repository's PR template and clear Markdown sections for the problem, changes, validation and risks. Keep @@ -215,9 +164,7 @@ separate approvals. The dashboard cannot inject questions into an idle, unconnected Copilot session; read status when the user requests progress. Honor user-selected scoped iteration: full regression is explicitly deferred, -not passed; in strict workflow PR readiness stays blocked. Efficient delivery -permits publication after focused checks and explicitly required pre-publication -gates. Continue independent safe checks +not passed, and PR readiness stays blocked. Continue independent safe checks after unrelated failures, while preserving their exact outcomes and honoring dependencies. Do not delete or edit transient test artifacts while validation is discovering or executing them; wait for the owned validation to finish diff --git a/dashboard/browser-ownership.js b/dashboard/browser-ownership.js deleted file mode 100644 index ace6215..0000000 --- a/dashboard/browser-ownership.js +++ /dev/null @@ -1,90 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, realpathSync, statSync } from "node:fs"; -import { isAbsolute, relative, resolve, sep } from "node:path"; - -function contained(root, path) { - const child = relative(root, path); - return child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child); -} - -function browserProcesses() { - const windows = process.platform === "win32"; - const script = ` -$ErrorActionPreference = 'Stop' -$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value -$rows = @() -foreach ($p in Get-CimInstance Win32_Process -Filter "Name='msedge.exe' OR Name='chrome.exe' OR Name='chromium.exe'") { - if ($p.CommandLine -notmatch '--user-data-dir' -or $p.CommandLine -match '--type=') { continue } - $owner = Invoke-CimMethod -InputObject $p -MethodName GetOwnerSid - if ($owner.ReturnValue -ne 0) { throw 'Cannot verify browser ownership' } - if ($owner.Sid -eq $sid) { $rows += @{ pid = $p.ProcessId; commandLine = $p.CommandLine } } -} -ConvertTo-Json -Compress -InputObject @($rows)`; - const result = spawnSync(windows ? "powershell.exe" : "ps", - windows ? ["-NoProfile", "-NonInteractive", "-Command", script] - : ["-u", String(process.getuid()), "-o", "pid=,args="], - { encoding: "utf8", windowsHide: true, timeout: 15000, maxBuffer: 512 * 1024 }); - if (result.error || result.status !== 0) { - throw new Error("Cannot verify browser-profile ownership; no browser or agent was started."); - } - if (windows) { - try { - return JSON.parse(result.stdout.trim()); - } catch { - throw new Error("Cannot parse browser-profile ownership metadata; no browser or agent was started."); - } - } - return result.stdout.split("\n").flatMap((line) => { - const match = line.trim().match(/^(\d+)\s+(.+)$/); - return match && /^(?:"[^"]*(?:chrome|chromium|msedge)[^"]*"|\S*(?:chrome|chromium|msedge)\S*)(?:\s|$)/i.test(match[2]) - ? [{ pid: Number(match[1]), commandLine: match[2] }] : []; - }); -} - -export function inspectBrowserProfileOwnership(configuration, { query = browserProcesses } = {}) { - const root = realpathSync(configuration.workingDirectory); - const directories = []; - for (const value of configuration.statusPaths) { - const path = resolve(root, value); - if (!contained(root, path)) { - throw new Error("Browser authentication status paths must stay within their configured working directory."); - } - if (!existsSync(path) || !statSync(path).isDirectory()) { - continue; - } - const actual = realpathSync(path); - if (!contained(root, actual)) { - throw new Error("Browser authentication profile resolves outside its configured working directory."); - } - directories.push(actual); - } - if (!directories.length) { - return null; - } - const records = query(); - if (!Array.isArray(records) || records.length > 256 || - records.some((record) => !record || !Number.isSafeInteger(record.pid) || record.pid < 1 || - typeof record.commandLine !== "string")) { - throw new Error("Browser-profile ownership inspection returned invalid process metadata."); - } - const owners = new Set(); - for (const record of records) { - const match = record.commandLine.match(/--user-data-dir(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/); - if (!match || /--type=/.test(record.commandLine)) { - continue; - } - const path = match[1] ?? match[2] ?? match[3]; - if (!isAbsolute(path) || !existsSync(path)) { - continue; - } - const actual = realpathSync(path); - if (directories.includes(actual)) { - owners.add(record.pid); - } - } - return owners.size ? { - kind: "browser-profile-busy", - pids: [...owners], - message: `The configured authentication browser profile is already in use by browser PID(s) ${[...owners].join(", ")}. Close the previous test browser after preserving any unsaved form data, then retry. No process was stopped. Repeating approval or confirming readiness cannot release a live profile lock.` - } : null; -} diff --git a/dashboard/conversation-operations.js b/dashboard/conversation-operations.js index 8caabb0..02fb515 100644 --- a/dashboard/conversation-operations.js +++ b/dashboard/conversation-operations.js @@ -19,7 +19,6 @@ export const conversationOperations = Object.freeze({ "permission-review": { method: "POST", path: "/api/job/permissions", action: "review-directory" }, "permission-approve": { method: "POST", path: "/api/job/permissions", action: "approve-directory" }, "validation-run": { method: "POST", path: "/api/validation/run" }, - "validation-select": { method: "POST", path: "/api/job/checks" }, "onboarding-configure": { method: "POST", path: "/api/onboarding/azure-devops" }, "work-items-load": { method: "POST", path: "/api/azure-devops/load" } }); @@ -40,8 +39,7 @@ const resultKeys = new Set([ "total", "passed", "failed", "blocked", "cancelled", "heldCount", "queuedCount", "completed", "bugs", "averageDurationMs", "usageJobs", "totalInputTokens", "totalCachedInputTokens", "totalCacheWriteTokens", "totalOutputTokens", - "averageCacheHitPercent", "inputTokenLimit", "budgetExceeded", "inputTokens", "fileAccess", - "workflow", "publication", "unitTestsAfterPr", "optionalBuilds", "fullValidationPassed" + "averageCacheHitPercent" ]); export function compactControlResult(value, depth = 0) { diff --git a/dashboard/copilot-session.js b/dashboard/copilot-session.js index 1b177af..2946c36 100644 --- a/dashboard/copilot-session.js +++ b/dashboard/copilot-session.js @@ -1,6 +1,6 @@ import { createInterface } from "node:readline"; import { createRequire } from "node:module"; -import { dirname, isAbsolute, join, relative } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { MAX_RUNTIME_FRAME_BYTES, @@ -9,8 +9,6 @@ import { runtimeQuestionPrompt, runtimeRequestId } from "./runtime-requests.js"; -import { createRuntimePermissionPolicy, validateJobFileAccess } from "./runtime-permission-policy.js"; -import { createPublicationPermissions } from "./publication-permissions.js"; export function extensionLaunchProvider(bootstrap) { return { @@ -38,33 +36,7 @@ function extensionBootstrapPath() { return join(root, "preloads", "extension_bootstrap.mjs"); } -export async function runCopilotSession(config, { client, ask, output, onUsage = () => {}, - publicationContext = async (verify) => { - if (!Number.isInteger(config.dashboardPort) || config.dashboardPort < 1 || config.dashboardPort > 65535) { - throw new Error("Publication consent requires the owning dashboard."); - } - const response = await fetch(`http://127.0.0.1:${config.dashboardPort}/api/job/publication-context`, { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jobId: config.sessionId, repository: config.repository, verify }), - signal: AbortSignal.timeout(5000) - }); - if (!response.ok) { throw new Error("The owning dashboard could not verify publication readiness."); } - return response.json(); - } -}) { - const inputTokenLimit = config.inputTokenLimit ?? 0; - if (!Number.isSafeInteger(inputTokenLimit) || inputTokenLimit < 0) { - throw new Error("inputTokenLimit must be a non-negative safe integer."); - } - const fileAccess = validateJobFileAccess(config.fileAccess, config.repository, config.sessionId, config.mode); - if (fileAccess?.allowValidationGates && (!Number.isInteger(config.dashboardPort) || - config.dashboardPort < 1 || config.dashboardPort > 65535)) { - throw new Error("Reviewed validation-gate trust requires the owning dashboard's current port."); - } - const permissions = createRuntimePermissionPolicy(config.repository, config.protectedPaths, fileAccess, config.dashboardPort); - const publication = createPublicationPermissions(config.repository, config.protectedPaths); - let publicationGranted = false; - let policyReady = false; +export async function runCopilotSession(config, { client, ask, output }) { const usage = { inputTokens: config.usage?.inputTokens ?? 0, cachedInputTokens: config.usage?.cachedInputTokens ?? 0, @@ -72,25 +44,12 @@ export async function runCopilotSession(config, { client, ask, output, onUsage = outputTokens: config.usage?.outputTokens ?? 0 }; let streamed = false; - let budgetReached = false; - let interruptForBudget; - const budget = new Promise((resolve) => { interruptForBudget = resolve; }); - const budgetError = () => { - const error = new Error(`Input-token ceiling reached (${usage.inputTokens}/${inputTokenLimit}, cached input included). Review the budget before resuming; no additional work was authorized.`); - error.code = "INPUT_TOKEN_LIMIT"; - return error; - }; const options = { sessionId: config.sessionId, workingDirectory: config.repository, agent: "fixlab:fixlab", enableConfigDiscovery: true, enableManagedSettings: true, - toolSearch: { enabled: true, deferThreshold: 1 }, - largeOutput: { enabled: true, maxSizeBytes: 8192 }, - infiniteSessions: { - enabled: true, backgroundCompactionThreshold: 0.25, bufferExhaustionThreshold: 0.4 - }, extensionSdkPath: config.extensionSdkPath, streaming: true, additionalDirectories: config.additionalDirs, @@ -98,82 +57,16 @@ export async function runCopilotSession(config, { client, ask, output, onUsage = ...(config.model === "hydrafusion" ? { enableExperimentalMode: true } : {}), onPermissionRequest: async (request) => { const prompt = runtimePermissionPrompt(request); - if (budgetReached) { - return { kind: "reject" }; - } - const automatic = policyReady ? permissions.automaticApproval(request, prompt) : null; - if (automatic) { - output("stdout", `\nApplied a built-in read-only permission: ${automatic}. No persistent, broad shell, escalation or managed-policy grant was created.\n`); - return { kind: "approve-once", approvedInteractively: false }; - } - if (policyReady && permissions.allows(request, prompt)) { - output("stdout", "\nApplied an explicitly reviewed job-scoped permission. Sensitive paths, changed commands, escalation and managed human approval remain outside this grant.\n"); - return { kind: "approve-once", approvedInteractively: false }; - } - let publicationReview = null; - if (policyReady && publication.eligible(request, prompt)) { - try { - const readiness = await publicationContext(!publicationGranted); - if (publication.allows(request, readiness)) { - output("stdout", "\nApplied the reviewed publication scope for this exact checkout, tree, branches and destination. Managed approvals remain manual.\n"); - return { kind: "approve-once", approvedInteractively: false }; - } - publicationReview = publication.review(request, readiness); - } catch (error) { - output("stderr", `\nPublication scope unavailable: ${error.message} Review this request manually.\n`); - } - } - const reviewedGrant = publicationReview ?? - (policyReady && request.kind !== "write" ? permissions.review(request, prompt) : null); - const grantScope = reviewedGrant?.scope; - if (grantScope) { - prompt.details = JSON.stringify({ - ...JSON.parse(prompt.details), - jobGrant: { - repository: config.repository, - ...(grantScope === "publication-batch" - ? { publication: JSON.parse(reviewedGrant.key) } - : grantScope === "exact-test-command" - ? { workingDirectoryWithinRepository: relative(config.repository, request.resolvedWorkingDirectory) || "." } - : { files: "Non-sensitive source, documentation and selected project files only; never directories." }) - } - }, null, 2); - } - const response = await ask({ ...prompt, ...(grantScope ? { grantScope } : {}) }); - if (budgetReached) { - return { kind: "reject" }; - } + const response = await ask(prompt); if (response.action === "allow" && !prompt.canApprove) { throw new Error("An unreviewable permission request cannot be approved."); } - if (response.scope !== undefined) { - if (response.action !== "allow" || response.scope !== grantScope) { - throw new Error("The selected job-scoped permission was not offered."); - } - if (response.scope === "publication-batch") { - const fresh = publication.review(request, await publicationContext(true)); - if (!fresh || fresh.key !== reviewedGrant.key) { - throw new Error("Publication scope changed during review; no grant was created."); - } - publication.grant(fresh); - publicationGranted = true; - } else { - permissions.grant(request, prompt, response.scope, reviewedGrant); - } - output("stdout", "\nThe user explicitly granted a reviewed runtime scope for this job's current executor only. The grant expires on completion, stop, hold, failure or restart.\n"); - } return response.action === "allow" ? { kind: "approve-once", approvedInteractively: true } : { kind: "reject" }; }, onUserInputRequest: async (request) => { - if (budgetReached) { - throw budgetError(); - } const response = await ask(runtimeQuestionPrompt(request)); - if (budgetReached) { - throw budgetError(); - } if (response.action !== "answer") { throw new Error("The user cancelled the runtime question."); } @@ -181,10 +74,6 @@ export async function runCopilotSession(config, { client, ask, output, onUsage = } }; try { - onUsage({ ...usage }); - if (inputTokenLimit > 0 && usage.inputTokens >= inputTokenLimit) { - throw budgetError(); - } await client.start(); const session = config.resume ? await client.resumeSession(config.sessionId, options) @@ -193,9 +82,7 @@ export async function runCopilotSession(config, { client, ask, output, onUsage = if (policy?.failClosed !== false) { throw new Error("Enterprise policy could not be determined; execution is blocked."); } - policyReady = true; session.on((event) => { - permissions.observeToolEvent(event); if (event.type === "assistant.message_delta") { streamed = true; output("stdout", event.data.deltaContent); @@ -214,48 +101,11 @@ export async function runCopilotSession(config, { client, ask, output, onUsage = usage[target] += event.data[source]; } } - onUsage({ ...usage }); output("stdout", `\nTokens \u2191 ${usage.inputTokens} (${usage.cachedInputTokens} cached, ${usage.cacheWriteTokens} written) \u2022 \u2193 ${usage.outputTokens}\n`); - if (inputTokenLimit > 0 && usage.inputTokens >= inputTokenLimit) { - budgetReached = true; - interruptForBudget(); - } } }); - if (config.resume) { - const model = await session.rpc.model.getCurrent(); - if (typeof model.modelId !== "string" || !model.modelId) { - throw new Error("The resumed session has no selected model for context measurement."); - } - const context = await session.rpc.metadata.recomputeContextTokens({ modelId: model.modelId }); - if (!Number.isSafeInteger(context.messagesTokenCount) || context.messagesTokenCount < 0) { - throw new Error("The resumed session's context size could not be measured."); - } - if (context.messagesTokenCount > 24000) { - output("stdout", "\nFIXLAB_ACTIVITY|intake|Compacting oversized retained job history before continuing; no approvals or browser actions are inferred.\n"); - const result = await session.rpc.history.compact({ - customInstructions: "Preserve the exact user request and latest explicit business decisions, scope, environment, mutation restrictions, denied and pending permissions, human authentication requirements, existing browser/form state, completed and remaining assertions, current blocker, and retained script/config/evidence paths. Preserve branch/checkout ownership, changed files, exact completed test commands and results, baseline failures, deferred required gates, input-token ceiling and remaining work. Never include credentials or raw browser state. Summarize successful discovery and repeated tool output once. Do not infer approvals, rerun completed checks without changed inputs, or replay stale form values. Keep selectors and reusable script details needed to continue the same job safely. Runtime grants are host-owned and must be reviewed again after executor termination." - }); - if (!result.success || !Number.isFinite(result.tokensRemoved) || result.tokensRemoved <= 0) { - throw new Error("Oversized job history could not be compacted; continuation is blocked to avoid repeating expensive context."); - } - output("stdout", `\nFIXLAB_ACTIVITY|intake|Retained job history compacted: ${result.tokensRemoved} context tokens removed. Existing safety policy and explicit permission review remain required.\n`); - } - } - if (budgetReached) { - throw budgetError(); - } - const interrupted = await Promise.race([ - session.sendAndWait({ prompt: config.prompt }, 24 * 60 * 60 * 1000).then(() => false), - budget.then(() => true) - ]); - if (interrupted || budgetReached) { - throw budgetError(); - } + await session.sendAndWait({ prompt: config.prompt }, 24 * 60 * 60 * 1000); } finally { - policyReady = false; - permissions.clear(); - publication.clear(); const errors = await client.stop(); if (errors.length > 0) { throw new Error("Copilot runtime shutdown failed; owned process containment must complete before resuming."); @@ -302,7 +152,6 @@ async function main() { task = runCopilotSession(message, { client, output: (stream, text) => emit({ type: "output", stream, text }), - onUsage: (usage) => emit({ type: "usage", usage }), ask(request) { if (pending.size >= MAX_RUNTIME_REQUESTS) { return Promise.reject(new Error("Too many pending runtime requests.")); @@ -318,7 +167,7 @@ async function main() { lines.close(); }, (error) => { process.exitCode = 1; - emit({ type: "error", message: error.message, code: error.code }); + emit({ type: "error", message: error.message }); cancel(); lines.close(); }); diff --git a/dashboard/preflight.js b/dashboard/preflight.js index 85dcfc3..411b0df 100644 --- a/dashboard/preflight.js +++ b/dashboard/preflight.js @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; -const TOOLS = new Set(["npm", "npx", "pnpm", "yarn", "dotnet"]); +const TOOLS = new Set(["npm", "pnpm", "yarn", "dotnet"]); const DEFAULT_PROBE_TIMEOUT_MS = 30000; const MIN_PROBE_TIMEOUT_MS = 1000; const MAX_PROBE_TIMEOUT_MS = 600000; @@ -36,12 +36,8 @@ function leadingTool(command) { } // Offline checks only: nothing here contacts the application, DEV, or the network. -export function runJobPreflight({ profile, mode, resolve = resolveOnPath } = {}) { - const commands = mode === "playwright-only" ? [ - profile?.browserAutomation?.testCommand, - ...(profile?.browserAutomation?.authentication?.required - ? [profile.browserAutomation.authentication.command] : []) - ] : [ +export function runJobPreflight({ profile, resolve = resolveOnPath } = {}) { + const commands = [ profile?.validation?.commands?.frontendRestore, profile?.validation?.commands?.backendRestore, profile?.applications?.frontend?.command, diff --git a/dashboard/public/app.js b/dashboard/public/app.js index 3c1bb26..71487b4 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -20,43 +20,6 @@ const stagesElement = document.querySelector("#stages"); const validationPanel = document.querySelector("#validation-panel"); const validationSummary = document.querySelector("#validation-summary"); const validationGatesElement = document.querySelector("#validation-gates"); -const validationControls = document.querySelector("#validation-controls"); -const validationSelection = document.querySelector("#validation-selection"); -const validationRunSelection = document.querySelector("#validation-run-selection"); -const validationComplete = document.querySelector("#validation-complete"); -const validationControlMessage = document.querySelector("#validation-control-message"); -let validationControlBusy = false; - -async function controlValidation(action) { - const job = observedJobs.get(selectedJobId) ?? activeJob; - if (!job || job.id !== activeJob?.id || (validationControlBusy && action !== "complete")) {return;} - const identity = { instanceId: dashboardInstanceId, jobId: job.id, repository: dashboardRepository }; - const [selection, value] = validationSelection.value.split(":"); - if (!window.confirm(action === "complete" - ? "Complete this scoped job and advance the queue after owned checks stop? Failed and deferred checks remain unverified; this does not approve a PR." - : `Run ${validationSelection.selectedOptions[0].textContent} for this stopped job and checkout?`)) {return;} - validationControlBusy = true; - validationRunSelection.disabled = true; - try { - await fetchJson("/api/job/checks", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...identity, action, selection, value, confirmed: true }) - }); - if (dashboardInstanceId === identity.instanceId && activeJob?.id === identity.jobId) { - validationControlMessage.textContent = action === "complete" ? "Scoped job completed; evidence retained." : "Selected checks settled. Inspect their actual outcomes."; - await refresh(); - } - } catch (error) { - if (dashboardInstanceId === identity.instanceId && activeJob?.id === identity.jobId) { - validationControlMessage.textContent = error.message; - } - } finally { - validationControlBusy = false; - validationRunSelection.disabled = false; - } -} -validationRunSelection.addEventListener("click", () => controlValidation("run")); -validationComplete.addEventListener("click", () => controlValidation("complete")); const jobStatusElement = document.querySelector("#job-status"); const testEvidenceElement = document.querySelector("#test-evidence"); const testEvidenceScenario = document.querySelector( @@ -116,127 +79,10 @@ const permissionScope = document.querySelector("#permission-scope"); const permissionMessage = document.querySelector("#permission-message"); const permissionHelper = document.querySelector("#permission-helper"); let permissionSelection = null; -const workspaceRecoveryPanel = document.querySelector("#workspace-recovery-panel"); -const workspaceRecoveryReview = document.querySelector("#workspace-recovery-review"); -const workspaceRecoverySummary = document.querySelector("#workspace-recovery-summary"); -const workspaceRecoveryStart = document.querySelector("#workspace-recovery-start"); -const workspaceRecoveryCancel = document.querySelector("#workspace-recovery-cancel"); -const workspaceRecoveryMessage = document.querySelector("#workspace-recovery-message"); -const workspaceStashResult = document.querySelector("#workspace-stash-result"); -let workspaceRecoverySelection = null; -let workspaceRecoveryBusy = false; - -function clearWorkspaceRecovery() { - workspaceRecoverySelection = null; - workspaceRecoverySummary.hidden = true; - workspaceRecoveryStart.hidden = true; - workspaceRecoveryCancel.hidden = true; -} - -function renderWorkspaceRecovery(job, isActive) { - const available = isActive && Boolean(job?.workspaceReview) && - ["blocked", "failed"].includes(job.status) && !job.executorStopping && - !job.permissionRequest && !job.runtimeRequests?.length; - workspaceRecoveryPanel.hidden = !available; - if (!available || (workspaceRecoverySelection && - (workspaceRecoverySelection.jobId !== job.id || - workspaceRecoverySelection.instanceId !== dashboardInstanceId))) { - clearWorkspaceRecovery(); - } - workspaceRecoveryReview.disabled = workspaceRecoveryBusy; - workspaceRecoveryCancel.disabled = workspaceRecoveryBusy; - workspaceRecoveryStart.disabled = workspaceRecoveryBusy || - !workspaceRecoverySelection?.workspaceReview.freshBranch?.available; - workspaceStashResult.hidden = !isActive || !job?.workspaceStash?.stashRef; - workspaceStashResult.textContent = workspaceStashResult.hidden ? "" : - `Preserved stash: ${job.workspaceStash.stashRef}. Restore it separately when needed.`; -} - -workspaceRecoveryReview.addEventListener("click", async () => { - clearWorkspaceRecovery(); - workspaceRecoveryBusy = true; - workspaceRecoveryReview.disabled = true; - workspaceRecoveryMessage.textContent = "Reviewing the current branch and files..."; - try { - const body = await fetchJson("/api/conversation"); - if (body.instanceId !== dashboardInstanceId || body.job?.id !== activeJob?.id || - body.repository !== dashboardRepository || body.question?.kind !== "workspace" || - !body.workspaceReview?.freshBranch) { - throw new Error("The job or workspace changed. Refresh and review again."); - } - workspaceRecoverySelection = { - instanceId: body.instanceId, jobId: body.job.id, - repository: body.repository, workspaceReview: body.workspaceReview - }; - const plan = body.workspaceReview.freshBranch; - workspaceRecoverySummary.textContent = - `Folder: ${body.repository}\nCurrent branch: ${body.workspaceReview.branch}\nNew base: ${plan.remote}/${plan.baseBranch}\nFiles to stash:\n${plan.files.length ? plan.files.join("\n") : "None"}`; - workspaceRecoverySummary.hidden = false; - workspaceRecoveryStart.textContent = plan.files.length ? "Stash files and start fresh" : "Start fresh from base"; - workspaceRecoveryStart.hidden = false; - workspaceRecoveryStart.disabled = !plan.available; - workspaceRecoveryCancel.hidden = false; - workspaceRecoveryMessage.textContent = plan.reason || "Review complete. No files or jobs have changed."; - } catch (error) { - clearWorkspaceRecovery(); - workspaceRecoveryMessage.textContent = error.message; - } finally { - workspaceRecoveryBusy = false; - workspaceRecoveryReview.disabled = false; - } -}); - -workspaceRecoveryCancel.addEventListener("click", () => { - if (workspaceRecoveryBusy) {return;} - clearWorkspaceRecovery(); - workspaceRecoveryMessage.textContent = "The job remains stopped. No files were changed."; -}); - -workspaceRecoveryStart.addEventListener("click", async () => { - const selection = workspaceRecoverySelection; - if (!selection || workspaceRecoveryBusy || selection.jobId !== activeJob?.id || - selection.instanceId !== dashboardInstanceId || !selection.workspaceReview.freshBranch?.available) { - workspaceRecoveryMessage.textContent = "Review the current workspace before starting."; - return; - } - const plan = selection.workspaceReview.freshBranch; - if (!window.confirm(`Save ${plan.files.length} reviewed file(s) in a stash and start a NEW job from ${plan.remote}/${plan.baseBranch} in this folder? The old branch ${selection.workspaceReview.branch} and job are preserved. No reset, deletion or automatic stash restore.`)) { - return; - } - workspaceRecoveryBusy = true; - workspaceRecoveryStart.disabled = true; - workspaceRecoveryReview.disabled = true; - workspaceRecoveryCancel.disabled = true; - workspaceRecoveryMessage.textContent = "Preserving files and preparing the new branch..."; - try { - const body = await fetchJson("/api/jobs/restart", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - confirmed: true, instanceId: selection.instanceId, jobId: selection.jobId, - repository: selection.repository, - workspaceRecovery: { - branch: selection.workspaceReview.branch, fingerprint: selection.workspaceReview.fingerprint - } - }) - }); - selectedJobId = body.restartedJob?.id ?? body.job?.id ?? null; - clearWorkspaceRecovery(); - await refresh(); - } catch (error) { - clearWorkspaceRecovery(); - workspaceRecoveryMessage.textContent = error.message; - } finally { - workspaceRecoveryBusy = false; - workspaceRecoveryReview.disabled = false; - workspaceRecoveryCancel.disabled = false; - } -}); - let runtimeHelperDirectory = ""; let permissionBusy = false; let permissionEdited = false; let dashboardInstanceId = null; -let dashboardRepository = ""; let runtimeSelection = null; let runtimeBusy = false; const runtimePanel = document.querySelector("#runtime-request-panel"); @@ -246,268 +92,21 @@ const runtimeAnswer = document.querySelector("#runtime-request-answer"); const runtimeAnswerLabel = document.querySelector("#runtime-request-answer-label"); const runtimeActions = document.querySelector("#runtime-request-actions"); const runtimeMessage = document.querySelector("#runtime-request-message"); -const runtimeBatchReview = document.querySelector("#runtime-batch-review"); -const runtimeBatchSummary = document.querySelector("#runtime-batch-summary"); -const runtimeBatchRequests = document.querySelector("#runtime-batch-requests"); -const runtimeBatchAllow = document.querySelector("#runtime-allow-pending"); -const runtimeBatchResult = document.querySelector("#runtime-batch-result"); -let runtimeBatchSelection = null; -const budgetPanel = document.querySelector("#job-budget-panel"); -const budgetToggle = document.querySelector("#show-input-budget"); -const budgetLimit = document.querySelector("#job-budget-limit"); -const budgetSummary = document.querySelector("#job-budget-summary"); -const budgetMessage = document.querySelector("#job-budget-message"); -const budgetSave = document.querySelector("#job-budget-save"); -let budgetSelection = null; -let budgetBusy = false; -const fileAccessPanel = document.querySelector("#job-file-access-panel"); -const fileAccessMode = document.querySelector("#job-file-access-mode"); -const fileAccessGates = document.querySelector("#job-file-access-gates"); -const fileAccessSummary = document.querySelector("#job-file-access-summary"); -const fileAccessMessage = document.querySelector("#job-file-access-message"); -const fileAccessSave = document.querySelector("#job-file-access-save"); -let fileAccessSelection = null; -let fileAccessBusy = false; - -function renderFileAccess(job, isActive) { - fileAccessPanel.hidden = !job || job.runtime !== "copilot"; - if (fileAccessPanel.hidden) { - fileAccessSelection = null; - return; - } - const mode = job.fileAccess?.mode ?? "manual"; - const key = `${dashboardInstanceId}:${job.id}:${mode}:${job.fileAccess?.allowValidationGates === true}`; - if (fileAccessSelection?.key !== key) { - fileAccessSelection = { - key, instanceId: dashboardInstanceId, jobId: job.id, - repository: job.fileAccess?.repository ?? dashboardRepository - }; - fileAccessMode.value = mode; - fileAccessGates.checked = job.fileAccess?.allowValidationGates === true; - fileAccessMessage.textContent = ""; - } - fileAccessSummary.textContent = mode === "manual" ? "Individual file approvals are active." - : `${mode === "source-read-write" ? "Read and edit" : "Read"} non-sensitive source/test files for this job and checkout, including resumes. ${job.fileAccess?.allowValidationGates ? "Dashboard-owned validation gates are also trusted; other commands remain manual." : "Commands remain separately reviewed."}`; - const editable = isActive && ["blocked", "failed", "passed"].includes(job.status); - fileAccessMode.disabled = fileAccessBusy || !editable; - fileAccessSave.disabled = fileAccessBusy || !editable; - fileAccessGates.disabled = fileAccessBusy || !editable || fileAccessMode.value === "manual"; - fileAccessMode.querySelector('option[value="source-read-write"]').disabled = job.mode !== "fix-and-validate"; -} -fileAccessMode.addEventListener("change", () => { - fileAccessGates.disabled = fileAccessBusy || fileAccessMode.disabled || fileAccessMode.value === "manual"; - if (fileAccessMode.value === "manual") { - fileAccessGates.checked = false; - } -}); - -fileAccessSave.addEventListener("click", async () => { - const selection = fileAccessSelection; - const mode = fileAccessMode.value; - if (!selection || fileAccessBusy) {return;} - const allowValidationGates = mode !== "manual" && fileAccessGates.checked; - if (!window.confirm(`Set repository file trust to ${mode} for this job and checkout, including resumes? Dashboard-owned validation gates: ${allowValidationGates ? "also trusted" : "manual"}. Other commands, sensitive files, configuration edits and organization-policy exceptions are never granted. This does not resume the job.`)) {return;} - fileAccessBusy = true; - fileAccessSave.disabled = true; - try { - const body = await fetchJson("/api/job/file-access", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...selection, mode, allowValidationGates, confirmed: true }) - }); - if (dashboardInstanceId === selection.instanceId && fileAccessSelection?.jobId === selection.jobId) { - activeJob = body.job; - rememberJob(body.job); - renderJob(body.job, body.job); - fileAccessMessage.textContent = "File trust updated for this job only. Resume separately when ready."; - } - } catch (error) { - if (dashboardInstanceId === selection.instanceId && fileAccessSelection?.jobId === selection.jobId) { - fileAccessMessage.textContent = error.message; - } - } finally { - fileAccessBusy = false; - const job = observedJobs.get(selectedJobId) ?? activeJob; - renderFileAccess(job, job?.id === activeJob?.id); - } -}); - -function renderBudget(job, isActive) { - budgetPanel.hidden = !budgetToggle.checked || !job || job.runtime !== "copilot"; - if (budgetPanel.hidden) { - budgetSelection = null; - return; - } - const key = `${dashboardInstanceId}:${job.id}:${job.inputTokenLimit ?? 0}`; - if (budgetSelection?.key !== key) { - budgetSelection = { key, jobId: job.id, instanceId: dashboardInstanceId }; - budgetLimit.value = String(job.inputTokenLimit ?? 0); - budgetMessage.textContent = ""; - } - const input = job.usage?.inputTokens ?? 0; - const cached = job.usage?.cachedInputTokens ?? 0; - budgetSummary.textContent = `${input.toLocaleString()} input tokens (${cached.toLocaleString()} cached; ${Math.max(0, input - cached).toLocaleString()} uncached). Ceiling: ${job.inputTokenLimit ? job.inputTokenLimit.toLocaleString() : "unlimited"}. ${job.budgetExceeded ? "Paused: explicitly update the budget, then resume. " : ""}An in-flight model call can exceed a positive ceiling; it is not a hard billing cap.`; - const editable = isActive && ["blocked", "failed", "passed"].includes(job.status) && - (job.canResume || job.budgetExceeded); - budgetLimit.disabled = budgetBusy || !editable; - budgetSave.disabled = budgetBusy || !editable; -} - -budgetToggle.addEventListener("change", () => { - document.querySelector("#input-budget-control").hidden = - !budgetToggle.checked || document.querySelector("#input-token-limit").disabled; - const job = observedJobs.get(selectedJobId) ?? activeJob; - renderBudget(job, job?.id === activeJob?.id); -}); - -budgetSave.addEventListener("click", async () => { - const selection = budgetSelection; - const inputTokenLimit = Number(budgetLimit.value); - if (!selection || budgetBusy || !Number.isSafeInteger(inputTokenLimit) || inputTokenLimit < 0) { - budgetMessage.textContent = "Enter a non-negative safe integer; 0 means unlimited."; - return; - } - if (!window.confirm(`Set this stopped job's input-token ceiling to ${inputTokenLimit || "unlimited"}? Usage is preserved. This does not resume the job or grant any tool permissions.`)) { - return; - } - budgetBusy = true; - budgetSave.disabled = true; - try { - const body = await fetchJson("/api/job/budget", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - instanceId: selection.instanceId, jobId: selection.jobId, - inputTokenLimit, confirmed: true - }) - }); - if (budgetSelection?.jobId === selection.jobId && dashboardInstanceId === selection.instanceId) { - activeJob = body.job; - rememberJob(body.job); - renderJob(body.job, body.job); - budgetMessage.textContent = "Budget updated. Usage is preserved; resume separately when ready."; - } - } catch (error) { - if (budgetSelection?.jobId === selection.jobId && dashboardInstanceId === selection.instanceId) { - budgetMessage.textContent = error.message; - } - } finally { - budgetBusy = false; - const job = observedJobs.get(selectedJobId) ?? activeJob; - renderBudget(job, job?.id === activeJob?.id); - } -}); - -function setRuntimeBusy(busy) { - runtimeBusy = busy; - runtimePanel.querySelectorAll("button, textarea").forEach((element) => { - element.disabled = busy; - }); -} - -function renderRuntimeBatch(job, isActive) { - const requests = isActive - ? (job?.runtimeRequests ?? []).filter((request) => request.kind === "permission" && request.canApprove === true) - : []; - runtimeBatchReview.hidden = requests.length < 2; - runtimeBatchAllow.disabled = runtimeBusy; - if (runtimeBatchResult.dataset.jobId !== job?.id || !isActive) { - runtimeBatchResult.hidden = true; - } - if (requests.length < 2) { - runtimeBatchSelection = null; - return; - } - const key = JSON.stringify([dashboardInstanceId, job.id, requests.map((request) => request.id)]); - if (runtimeBatchSelection?.key === key) { - return; - } - runtimeBatchSelection = { - key, instanceId: dashboardInstanceId, jobId: job.id, - requests: requests.map(({ id, title, details }) => ({ id, title, details })) - }; - runtimeBatchReview.open = false; - runtimeBatchSummary.textContent = `Review all ${requests.length} pending permissions`; - runtimeBatchRequests.replaceChildren(); - for (const request of requests) { - const article = document.createElement("article"); - const title = document.createElement("h3"); - const details = document.createElement("pre"); - title.textContent = request.title; - details.textContent = request.details; - article.append(title, details); - runtimeBatchRequests.append(article); - } -} -async function allowPendingPermissions() { - const selection = runtimeBatchSelection; - if (!selection || runtimeBusy || !window.confirm( - `Allow only the ${selection.requests.length} permission requests listed in this review once? This can run their commands and access or change their listed files and URLs. Questions and future requests still require a separate response. Organization policy and business-action restrictions remain in effect.` - )) { - return; - } - setRuntimeBusy(true); - runtimeBatchResult.dataset.jobId = selection.jobId; - runtimeBatchResult.hidden = false; - runtimeBatchResult.classList.remove("error"); - runtimeBatchResult.textContent = "Checking the reviewed permission requests before sending approvals..."; - let approved = 0; - let attempted = false; - try { - const current = await fetchJson("/api/status"); - if (current.instanceId !== selection.instanceId || current.job?.id !== selection.jobId || - current.job.status !== "running" || !Array.isArray(current.job.runtimeRequests) || - !selection.requests.every((reviewed) => current.job.runtimeRequests.some((request) => - request.id === reviewed.id && request.kind === "permission" && request.canApprove === true && - request.title === reviewed.title && request.details === reviewed.details))) { - throw new Error("The reviewed permissions or live job changed. Review the current requests again."); - } - for (const request of selection.requests) { - attempted = true; - const body = await fetchJson("/api/job/runtime-response", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - instanceId: selection.instanceId, jobId: selection.jobId, - requestId: request.id, action: "allow", confirmed: true - }) - }); - approved++; - activeJob = body.job; - observedJobs.set(body.job.id, body.job); - runtimeBatchResult.textContent = `Confirmed ${approved} of ${selection.requests.length} one-time approvals.`; - } - renderJob(activeJob, activeJob); - runtimeBatchResult.textContent = `Allowed ${approved} reviewed permission requests once. New requests still require approval.`; - } catch (error) { - runtimeBatchResult.hidden = false; - runtimeBatchResult.classList.add("error"); - runtimeBatchResult.textContent = attempted - ? `Batch stopped after ${approved} confirmed approval(s) out of ${selection.requests.length}: ${error.message} No automatic retry was made; review the remaining requests.` - : `No approvals were sent. ${error.message}`; - } finally { - setRuntimeBusy(false); - } -} - -runtimeBatchAllow.addEventListener("click", () => void allowPendingPermissions()); - -async function respondToRuntime(action, choice, scope) { +async function respondToRuntime(action, choice) { const selection = runtimeSelection; if (!selection || runtimeBusy) { return; } - if (action === "allow" && !window.confirm(scope === "repository-reads" - ? "Allow non-sensitive source, documentation and selected project-file reads within this job's repository for this executor? Directories, hidden/sensitive files, browser state, outside paths, symbolic links, escalation and managed human approval remain manual. The grant expires when the executor ends." - : scope === "exact-test-command" - ? "Allow this exact reviewed test command for this executor? Its working directory, reported paths and package.json are bound to this grant. Tests can have side effects: review the script before approving. Changed commands, network requests, sandbox escalation, business writes and managed human approvals are not separately authorized. The grant expires when the executor ends." - : scope === "publication-batch" - ? "Allow the reviewed staged checkpoint's routine commit, non-force origin push and explicit-provider PR creation for this executor? Review every listed path (including deletions), destination, source/target and draft policy above. Only one unchanged tree and its single-parent commit transition qualify. Untracked scratch is not staged or published. Changed content, destinations, required readiness, managed approvals, merge and deployment remain outside this grant." - : "Allow only this exact Copilot permission request once? Review its command, files, URLs and any sandbox escalation above. This does not override organization policy." + if (action === "allow" && !window.confirm( + "Allow only this exact Copilot permission request once? Review its command, files, URLs and any sandbox escalation above. This does not override organization policy." )) { return; } - setRuntimeBusy(true); + runtimeBusy = true; + runtimePanel.querySelectorAll("button, textarea").forEach((element) => { + element.disabled = true; + }); try { const body = await fetchJson("/api/job/runtime-response", { method: "POST", @@ -518,7 +117,6 @@ async function respondToRuntime(action, choice, scope) { requestId: selection.requestId, confirmed: true, action, - ...(scope ? { scope } : {}), ...(choice === undefined ? { answer: runtimeAnswer.value } : { choice }) }) }); @@ -529,12 +127,14 @@ async function respondToRuntime(action, choice, scope) { } catch (error) { runtimeMessage.textContent = error.message; } finally { - setRuntimeBusy(false); + runtimeBusy = false; + runtimePanel.querySelectorAll("button, textarea").forEach((element) => { + element.disabled = false; + }); } } function renderRuntimeRequest(job, isActive) { - renderRuntimeBatch(job, isActive); const pending = isActive ? job?.runtimeRequests?.[0] : null; runtimePanel.hidden = !pending; if (!pending) { @@ -555,24 +155,16 @@ function renderRuntimeRequest(job, isActive) { runtimeActions.replaceChildren(); runtimeAnswer.hidden = pending.kind !== "question" || !pending.allowFreeform; runtimeAnswerLabel.hidden = runtimeAnswer.hidden; - const button = (parent, label, action, choice, scope) => { + const button = (parent, label, action, choice) => { const element = document.createElement("button"); element.type = "button"; - element.disabled = runtimeBusy; element.textContent = label; - element.addEventListener("click", () => void respondToRuntime(action, choice, scope)); + element.addEventListener("click", () => void respondToRuntime(action, choice)); parent.append(element); }; if (pending.kind === "permission") { if (pending.canApprove) { button(runtimeActions, "Allow once", "allow"); - if (pending.grantScope === "repository-reads") { - button(runtimeActions, "Allow repository-file reads for this job", "allow", undefined, pending.grantScope); - } else if (pending.grantScope === "exact-test-command") { - button(runtimeActions, "Allow this test command for this job", "allow", undefined, pending.grantScope); - } else if (pending.grantScope === "publication-batch") { - button(runtimeActions, "Allow this reviewed publication checkpoint", "allow", undefined, pending.grantScope); - } } button(runtimeActions, "Deny", "deny"); } else { @@ -777,303 +369,12 @@ const metricStatuses = document.querySelector("#metric-statuses"); let loadedWorkItems = []; let selectedScreenshots = []; let activeJob = null; -const currentJobDialog = document.querySelector("#current-job-dialog"); -const currentJobFullscreen = document.querySelector("#current-job-fullscreen"); -const currentJobDetails = document.querySelector("#details-view"); -let previousJobView = null; - -currentJobFullscreen.addEventListener("click", () => { - if (!activeJob || currentJobDialog.open) { - return; - } - previousJobView = { selectedJobId, draftingConversation }; - selectedJobId = activeJob.id; - draftingConversation = false; - document.querySelector("#current-job-fullscreen-content").append(currentJobDetails); - renderJob(activeJob, activeJob); - currentJobDialog.showModal(); - void refreshPlaywrightEvidence(); -}); -document.querySelector("#current-job-fullscreen-close").addEventListener("click", () => currentJobDialog.close()); -currentJobDialog.addEventListener("close", () => { - document.querySelector("#details-group").append(currentJobDetails); - if (previousJobView) { - selectedJobId = previousJobView.selectedJobId; - draftingConversation = previousJobView.draftingConversation; - previousJobView = null; - } - renderJob(draftingConversation ? null : observedJobs.get(selectedJobId) ?? activeJob, activeJob); - void refreshPlaywrightEvidence(); - currentJobFullscreen.focus(); -}); let selectedJobId = null; let pendingRetryAfterConnectJobId = null; let playwrightAuthenticationRunning = false; const observedJobs = new Map(); const expandedJobIds = new Set(); let renderedEvidenceJobId = null; -let playwrightEvidenceRequestId = 0; -let repositoryCatalog = []; -let workspaceSignature = ""; -let memorySignature = ""; -let draftingConversation = false; -const chatThread = document.querySelector("#chat-thread"); -const conversationTitle = document.querySelector("#conversation-state-title"); -const conversationDetail = document.querySelector("#conversation-state-detail"); -const needsResponse = document.querySelector("#needs-response"); -const workspaceTools = document.querySelector("#workspace-tools"); -const navigationToggle = document.querySelector("#navigation-toggle"); -const repositorySidebar = document.querySelector(".repository-sidebar"); - -function setNavigationExpanded(expanded) { - repositorySidebar.classList.toggle("navigation-open", expanded); - navigationToggle.setAttribute("aria-expanded", String(expanded)); -} - -navigationToggle.addEventListener("click", () => { - setNavigationExpanded(navigationToggle.getAttribute("aria-expanded") !== "true"); -}); - -for (const panel of document.querySelectorAll("[data-area]")) { - document.getElementById(panel.dataset.area).append(panel); -} -document.querySelector("#chat-panels").append(document.querySelector("#new-job-panel")); -document.querySelector("#details-view").prepend(queuePanel); -chatThread.before(runtimePanel, runtimeBatchResult, permissionPanel); - -export function openWorkspaceSection(id) { - const section = document.getElementById(id); - if (!section) { - throw new Error(`Unknown workspace section: ${id}`); - } - if (workspaceTools.contains(section)) { - workspaceTools.hidden = false; - } - for (let ancestor = section; ancestor; ancestor = ancestor.parentElement) { - if (ancestor instanceof HTMLDetailsElement) { - ancestor.open = true; - } - } - const navigationId = workspaceTools.contains(section) - ? id === "queue-panel" ? id : section.closest(".inspector-group")?.id - : "chat-view"; - for (const button of document.querySelectorAll(".workspace-navigation [data-open]")) { - if (button.dataset.open === navigationId) { - button.setAttribute("aria-current", "page"); - } else { - button.removeAttribute("aria-current"); - } - } - if (window.matchMedia("(max-width: 760px)").matches) { - setNavigationExpanded(false); - } - const focusTarget = section instanceof HTMLDetailsElement - ? section.querySelector(":scope > summary") : section; - if (focusTarget.tabIndex < 0 && !focusTarget.hasAttribute("tabindex")) { - focusTarget.setAttribute("tabindex", "-1"); - } - focusTarget.focus({ preventScroll: true }); - section.scrollIntoView({ behavior: "smooth", block: "start" }); -} - -document.querySelector("#close-workspace-tools").addEventListener("click", () => { - workspaceTools.hidden = true; - for (const group of workspaceTools.querySelectorAll(".inspector-group")) { - group.open = false; - } - openWorkspaceSection("chat-view"); - requestInput.focus(); -}); -document.querySelector("#welcome-add-repository").addEventListener("click", () => { - document.querySelector("#add-local-repository").click(); -}); -document.querySelector("#add-local-repository").addEventListener("click", () => { - setNavigationExpanded(false); -}); -document.querySelectorAll("[data-open]").forEach((button) => { - button.addEventListener("click", () => openWorkspaceSection(button.dataset.open)); -}); -document.querySelector("#new-conversation").addEventListener("click", () => { - draftingConversation = true; - renderJob(null, activeJob); - openWorkspaceSection("new-job-panel"); - requestInput.focus(); -}); -document.querySelectorAll("[data-suggestion]").forEach((button) => { - button.addEventListener("click", () => { - requestInput.value = button.dataset.suggestion === "browser" - ? "Validate this browser workflow: " - : "Investigate this bug: "; - form.elements.mode.value = button.dataset.suggestion === "browser" - ? "playwright-only" : "fix-and-validate"; - openWorkspaceSection("new-job-panel"); - requestInput.focus(); - }); -}); -needsResponse.addEventListener("click", () => { - const panel = [runtimePanel, permissionPanel, jobInputPanel].find((element) => !element.hidden); - if (panel) { - openWorkspaceSection("chat-view"); - panel.scrollIntoView({ behavior: "smooth", block: "center" }); - panel.querySelector("button:not([hidden]):not(:disabled), textarea:not([hidden])")?.focus(); - } -}); - -function renderRepositoryCatalog() { - const list = document.querySelector("#repository-list"); - list.replaceChildren(); - const repositories = new Map(); - for (const entry of repositoryCatalog) { - if (!repositories.has(entry.repository) || entry.id === dashboardInstanceId) { - repositories.set(entry.repository, entry); - } - } - for (const entry of [...repositories.values()].sort((a, b) => - Number(b.id === dashboardInstanceId) - Number(a.id === dashboardInstanceId))) { - const url = new URL(entry.url); - if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || - !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash) { - throw new Error("A registered repository has an invalid local dashboard URL."); - } - const link = document.createElement("a"); - link.href = url.href; - link.className = "repository-link"; - link.title = entry.repository; - if (entry.id === dashboardInstanceId) { - link.setAttribute("aria-current", "page"); - } - const name = document.createElement("strong"); - name.textContent = entry.repository.split(/[\\/]/u).filter(Boolean).pop(); - const status = document.createElement("small"); - status.textContent = entry.id === dashboardInstanceId ? "Current workspace" : entry.status; - link.append(name, status); - list.append(link); - } -} - -async function refreshRepositories() { - const button = document.querySelector("#repositories-refresh"); - const message = document.querySelector("#repositories-message"); - button.disabled = true; - try { - const body = await fetchJson("/api/repositories"); - repositoryCatalog = body.repositories; - renderRepositoryCatalog(); - message.textContent = body.unavailableCount - ? `Local dashboards only. ${body.unavailableCount} unavailable registration(s); no repository scan or model calls.` - : ""; - } catch (error) { - message.textContent = `Repository list unavailable: ${error.message}`; - } finally { - button.disabled = false; - } -} -document.querySelector("#repositories-refresh").addEventListener("click", refreshRepositories); - -function renderConversation(job, isActive) { - document.querySelector("#chat-welcome").hidden = Boolean(job); - document.querySelector("#conversation-state").hidden = !job; - const pending = isActive ? job?.runtimeRequests?.[0] : null; - const waiting = Boolean(pending || (isActive && (job?.permissionRequest || - job?.status === "blocked" || job?.status === "failed"))); - document.querySelector("#conversation-state").classList.toggle("waiting", waiting); - needsResponse.hidden = !waiting; - if (!job) { - conversationTitle.textContent = "Ready when you are"; - conversationDetail.textContent = "Describe an outcome. Run options control the mode and environment; nothing runs until you send it."; - } else if (pending) { - conversationTitle.textContent = pending.kind === "permission" ? "Waiting for your permission" : "Waiting for your answer"; - conversationDetail.textContent = pending.title; - } else if (job.permissionRequest) { - conversationTitle.textContent = "Access review required"; - conversationDetail.textContent = "The executor is paused. Review the exact folder scope; no access has been granted."; - } else if (job.browserBlocker) { - conversationTitle.textContent = "Browser launch blocked; no retry running"; - conversationDetail.textContent = job.browserBlocker.message; - } else { - conversationTitle.textContent = { - running: "Working on your request", - blocked: "Paused until you choose to continue", - failed: "Stopped with a reported failure", - passed: "Run passed its required gates", - queued: "Waiting in the queue", - held: "On hold", - cancelled: "Cancelled" - }[job.status] ?? job.status; - const stage = Object.entries(job.stages ?? {}).find(([, value]) => - ["running", "blocked", "failed"].includes(value.status)); - conversationDetail.textContent = job.error || stage?.[1].message || - (job.status === "running" - ? "Progress below comes from this job's evidence, not additional AI polling." - : "Selecting saved work does not run it. Use an explicit lifecycle action to continue."); - } - const messages = [ - ...(job ? [{ role: "You", message: job.request }] : []), - ...(job?.activity ?? []).slice(-20).map((entry) => ({ - role: "FixLab", message: entry.message, stage: entry.stage - })) - ]; - const signature = JSON.stringify([job?.id, messages]); - if (signature !== workspaceSignature) { - workspaceSignature = signature; - chatThread.replaceChildren(); - for (const entry of messages) { - const article = document.createElement("article"); - article.className = `chat-message ${entry.role === "You" ? "user-message" : "assistant-message"}`; - const name = document.createElement("strong"); - name.textContent = entry.stage ? `${entry.role} / ${entry.stage}` : entry.role; - const message = document.createElement("p"); - message.textContent = entry.message; - article.append(name, message); - chatThread.append(article); - } - } -} - -function renderRepositoryMemory() { - const jobs = [...observedJobs.values()].filter((job) => - !["queued", "running", "holding"].includes(job.status)).slice(0, 20); - const signature = JSON.stringify(jobs.map((job) => [job.id, job.request, job.status, job.stages])); - if (signature === memorySignature) { - return; - } - memorySignature = signature; - const list = document.querySelector("#repository-memory-list"); - const conversations = document.querySelector("#sidebar-conversations"); - list.replaceChildren(); - conversations.replaceChildren(); - document.querySelector("#repository-memory-empty").hidden = jobs.length > 0; - for (const job of jobs) { - const item = document.createElement("li"); - const button = document.createElement("button"); - button.type = "button"; - button.className = "memory-entry"; - const title = document.createElement("strong"); - title.textContent = job.request; - const result = document.createElement("span"); - const completed = Object.values(job.stages ?? {}).filter((stage) => stage.status === "passed").length; - result.textContent = `${job.status} / ${completed} passed stage(s)${job.targetEnvironment ? ` / ${job.targetEnvironment}` : ""}`; - button.append(title, result); - button.addEventListener("click", () => { - selectJob(job.id); - openWorkspaceSection("chat-view"); - }); - item.append(button); - list.append(item); - if (conversations.childElementCount < 6) { - const recent = document.createElement("button"); - recent.type = "button"; - recent.className = "recent-conversation"; - recent.textContent = job.request; - recent.title = `${job.status}: ${job.request}`; - recent.addEventListener("click", () => { - selectJob(job.id); - openWorkspaceSection("chat-view"); - }); - conversations.append(recent); - } - } -} const maxScreenshots = 5; const maxScreenshotBytes = 2 * 1024 * 1024; @@ -1106,17 +407,10 @@ function formatDuration(milliseconds) { } function renderReadiness(readiness) { - dashboardRepository = readiness.repository; - document.querySelector("#workspace-title").textContent = readiness.profileName || "FixLab"; - document.querySelector("#workspace-repository").textContent = readiness.repository; const ready = readiness.repositoryReady && readiness.profileReady; - document.querySelector("#welcome-add-repository").hidden = ready; - document.querySelector("#welcome-guidance").textContent = ready - ? "Describe a bug, change, or browser check." - : "Set up a repository to get started."; readinessElement.className = `readiness ${ready ? "ready" : "blocked"}`; readinessElement.textContent = ready - ? "Ready" + ? `Ready: ${readiness.profileName || readiness.repository}` : `Not ready: ${readiness.error}`; startButton.disabled = !ready; const selectedEnvironment = targetEnvironmentSelect.value; @@ -1142,9 +436,6 @@ function renderReadiness(readiness) { function renderRuntime(runtime) { runtimeHelperDirectory = runtime?.helperDirectory ?? ""; const supported = runtime?.supportsModelSelection === true; - document.querySelector("#input-budget-visibility").hidden = !supported; - document.querySelector("#input-budget-control").hidden = !supported || !budgetToggle.checked; - document.querySelector("#input-token-limit").disabled = !supported; modelControl.hidden = !supported; modelSelect.disabled = !supported; if (!supported) { @@ -1209,23 +500,11 @@ function inferActiveStageFromLogs(logs = []) { } function renderJob(job, currentActiveJob = job) { - currentJobFullscreen.disabled = !currentActiveJob; - currentJobFullscreen.hidden = !currentActiveJob; - const currentJobTitle = document.querySelector("#current-job-dialog-title"); - currentJobTitle.textContent = currentActiveJob - ? `Current job: ${currentActiveJob.request}` : "No current job"; - currentJobTitle.title = currentJobTitle.textContent; const running = job?.status === "running"; const isActive = Boolean(job?.id && job.id === currentActiveJob?.id); - renderBudget(job, isActive); - renderFileAccess(job, isActive); - renderWorkspaceRecovery(job, isActive); const activeNeedsQueue = currentActiveJob && ["running", "blocked", "failed"].includes(currentActiveJob.status); - document.querySelector(".composer-heading span").textContent = activeNeedsQueue - ? "Queues a new job" - : "New job"; startButton.disabled = readinessElement.classList.contains("blocked"); startButton.textContent = activeNeedsQueue @@ -1319,19 +598,6 @@ function renderJob(job, currentActiveJob = job) { } const validation = job?.validation; - validationControls.hidden = !job || job.id !== activeJob?.id || !["passed", "blocked", "failed"].includes(job.status); - const savedSelection = validationSelection.value; - validationSelection.replaceChildren(); - const selections = [["all", "All configured checks"], ["tests", "Tests only"], ["builds", "Builds only"], - ...(validation?.gates ?? []).map((gate) => [`gate:${gate.id}`, `Gate: ${gate.id}`]), - ...[...new Set((validation?.gates ?? []).map((gate) => gate.stage).filter(Boolean))].map((stage) => [`stage:${stage}`, `Stage: ${stage}`])]; - for (const [value, label] of selections) { - const option = document.createElement("option"); - option.value = value; - option.textContent = label; - validationSelection.append(option); - } - if (selections.some(([value]) => value === savedSelection)) {validationSelection.value = savedSelection;} validationPanel.hidden = !validation; validationSummary.textContent = validation?.message ?? ""; validationGatesElement.replaceChildren(); @@ -1419,7 +685,7 @@ function renderJob(job, currentActiveJob = job) { renderPermission(job, isActive); renderRuntimeRequest(job, isActive); - const canResume = isActive && Boolean(job?.canResume) && !job?.permissionRequest && !job?.workspaceReview; + const canResume = isActive && Boolean(job?.canResume) && !job?.permissionRequest; const canComment = isActive && Boolean(job?.canComment) && !job?.permissionRequest; const canRetryLiveTest = canResume && @@ -1435,7 +701,7 @@ function renderJob(job, currentActiveJob = job) { canResume && job?.pullRequestReadiness?.status === "approval-required"; const canDismissFailedJob = - isActive && Boolean(job?.canDismiss ?? job?.canResume) && + isActive && Boolean(job?.canDismiss ?? canResume) && ["failed", "blocked"].includes(job?.status); const dismissOnly = canDismissFailedJob && !canResume && !canComment; dismissFailedJobButton.textContent = @@ -1459,8 +725,7 @@ function renderJob(job, currentActiveJob = job) { resumeHeldJobButton.disabled = !canResumeHeldJob; restartJobButton.hidden = !canRestartJob; restartJobButton.disabled = !canRestartJob; - jobInputPanel.hidden = Boolean(job?.workspaceReview) || - !(canResume || canComment || canCancelQueuedJob || canDismissFailedJob); + jobInputPanel.hidden = !(canResume || canComment || canCancelQueuedJob || canDismissFailedJob); jobInputSubmit.disabled = !(canResume || canComment); jobInputSubmit.hidden = canCancelQueuedJob || canConfirmManualLocalhost || dismissOnly; jobInputAction.hidden = canCancelQueuedJob || canConfirmManualLocalhost || dismissOnly; @@ -1534,7 +799,6 @@ function renderJob(job, currentActiveJob = job) { jobInputGuidance.textContent = "Removing this waiting job prevents it from starting, records it as cancelled in history, and deletes its queued screenshot artifacts."; } - renderConversation(job, isActive); } function updateLiveTestRetryAvailability() { @@ -1590,8 +854,8 @@ async function resumeAfterConnect(status) { return; } pendingRetryAfterConnectJobId = null; - if (!result.ok || !status.ready) { - formError.textContent = `${result.message} Authenticated browser readiness is unverified; the live test was not retried.`; + if (!result.ok) { + formError.textContent = `${result.message} The live test was not retried.`; return; } if (activeJob?.id === jobId && activeJob.status === "blocked") { @@ -1746,10 +1010,9 @@ async function refreshPlaywrightStatus() { } async function refreshPlaywrightEvidence() { - const requestId = ++playwrightEvidenceRequestId; - const jobId = selectedJobId ?? activeJob?.id; playwrightEvidenceRefresh.disabled = true; try { + const jobId = selectedJobId ?? activeJob?.id; if (!jobId) { playwrightEvidenceGuidance.textContent = "Select a job to view its browser evidence."; @@ -1759,9 +1022,6 @@ async function refreshPlaywrightEvidence() { const body = await fetchJson( `/api/playwright/artifacts?jobId=${encodeURIComponent(jobId)}` ); - if (requestId !== playwrightEvidenceRequestId || jobId !== (selectedJobId ?? activeJob?.id)) { - return; - } playwrightEvidenceList.replaceChildren(); playwrightEvidenceGuidance.textContent = body.artifacts.length > 0 @@ -1797,13 +1057,9 @@ async function refreshPlaywrightEvidence() { playwrightEvidenceList.append(item); } } catch (error) { - if (requestId === playwrightEvidenceRequestId && jobId === (selectedJobId ?? activeJob?.id)) { - playwrightEvidenceGuidance.textContent = error.message; - } + playwrightEvidenceGuidance.textContent = error.message; } finally { - if (requestId === playwrightEvidenceRequestId) { - playwrightEvidenceRefresh.disabled = false; - } + playwrightEvidenceRefresh.disabled = false; } } @@ -1842,7 +1098,6 @@ function rememberJob(job) { } function selectJob(jobId) { - draftingConversation = false; selectedJobId = jobId; const selectedJob = observedJobs.get(jobId); if (selectedJob) { @@ -1898,8 +1153,7 @@ function renderQueue(currentJob, queue = [], held = [], history = []) { ...recentJobs.map((job) => ({ job, label: "Recent" })) ]; - queuePanel.hidden = false; - document.querySelector("#queue-nav-count").textContent = String(queue.length); + queuePanel.hidden = entries.length < 2; queuePanel.querySelector("h3").textContent = "Jobs"; queueCount.textContent = `${queue.length} waiting · ${held.length} held · ${recentJobs.length} recent`; @@ -1961,7 +1215,6 @@ function renderQueue(currentJob, queue = [], held = [], history = []) { } queueList.append(item); } - renderRepositoryMemory(); } function renderMetrics(metrics, warning) { @@ -1978,7 +1231,7 @@ function renderMetrics(metrics, warning) { `${metrics.passed} passed · ${metrics.failed} failed · ${metrics.blocked} blocked · ${metrics.cancelled} cancelled · exact usage available for ${metrics.usageJobs} completed job(s)`; } -export async function fetchJson(url, options) { +async function fetchJson(url, options) { const response = await fetch(url, options); const body = await response.json(); if (!response.ok) { @@ -2282,15 +1535,8 @@ document.addEventListener("paste", async (event) => { async function refresh() { try { const body = await fetchJson("/api/status"); - const instanceChanged = dashboardInstanceId !== body.instanceId; dashboardInstanceId = body.instanceId; - if (instanceChanged && repositoryCatalog.length) { - renderRepositoryCatalog(); - } activeJob = body.job; - if (currentJobDialog.open) { - selectedJobId = activeJob?.id ?? null; - } rememberJob(activeJob); for (const queuedJob of body.queue) { const existing = observedJobs.get(queuedJob.id); @@ -2310,11 +1556,8 @@ async function refresh() { } renderRuntime(body.runtime); renderReadiness(body.readiness); - renderJob(draftingConversation ? null : observedJobs.get(selectedJobId) ?? activeJob, activeJob); + renderJob(observedJobs.get(selectedJobId) ?? activeJob, activeJob); renderQueue(activeJob, body.queue, body.held, body.history); - const memoryEmpty = document.querySelector("#repository-memory-empty"); - memoryEmpty.hidden = !body.historyWarning && document.querySelector("#repository-memory-list").childElementCount > 0; - memoryEmpty.textContent = body.historyWarning || "Completed work will appear here for this repository."; } catch (error) { formError.textContent = error.message; } @@ -2356,8 +1599,6 @@ form.addEventListener("submit", async (event) => { mode: data.get("mode"), validationIntent: data.get("validationIntent") || undefined, permissionPreflight: data.get("permissionPreflight") === "on", - routineFileAccess: data.get("routineFileAccess") === "on", - inputTokenLimit: Number(data.get("inputTokenLimit") ?? 0), pullRequestStrategy: data.get("separatePullRequests") === "on" ? "per-bug" : "common", @@ -2379,7 +1620,6 @@ form.addEventListener("submit", async (event) => { selectedScreenshots = []; renderScreenshots(); showScreenshotResult(""); - draftingConversation = false; activeJob = body.job; selectedJobId = body.job?.id ?? null; rememberJob(body.job); @@ -2692,9 +1932,7 @@ async function retryLiveTest() { body: JSON.stringify({ action: "retry", details: - activeJob?.mode === "playwright-only" - ? "Retry only the browser-validation prerequisite and focused Playwright journey using the retained same-job script, selected environment and existing authentication. Do not retry an unchanged profile lock, copy browser state, delete a script still needed for continuation, change product source, or perform PR work. Report unavailable browser prerequisites as blocked and non-browser stages as skipped." - : "Reuse the repository-profile-defined browser authentication and all completed diagnosis, fix, review, and local validation evidence. Rerun only the blocked Playwright live-test gate using the profile-defined browser and test command, save at least one non-sensitive screenshot under the profile-defined working directory's test-results folder, emit the terminal live-test result, and continue to pull-request creation. Do not skip the live-test or PR stage." + "Reuse the repository-profile-defined browser authentication and all completed diagnosis, fix, review, and local validation evidence. Rerun only the blocked Playwright live-test gate using the profile-defined browser and test command, save at least one non-sensitive screenshot under the profile-defined working directory's test-results folder, emit the terminal live-test result, and continue to pull-request creation. Do not skip the live-test or PR stage." }) }); jobInputMessage.textContent = @@ -2742,9 +1980,7 @@ skipLiveTestButton.addEventListener("click", async () => { body: JSON.stringify({ action: "skip", details: - activeJob?.mode === "playwright-only" - ? "The user explicitly chose to skip the blocked browser validation. Mark live-test skipped, retain the exact unverified risk and missing browser evidence, and leave diagnosis, reproduce, fix, review and pr skipped. Do not claim validation passed or perform implementation or PR work." - : "The user explicitly chose to skip only the blocked authenticated Playwright live-test gate. Mark live-test skipped, preserve the exact unverified browser risk and missing screenshot evidence, do not report it as passed, and continue the safe pull-request outcome according to repository policy without repeating completed work." + "The user explicitly chose to skip only the blocked authenticated Playwright live-test gate. Mark live-test skipped, preserve the exact unverified browser risk and missing screenshot evidence, do not report it as passed, and continue the safe pull-request outcome according to repository policy without repeating completed work." }) }); @@ -2839,7 +2075,6 @@ refreshPlaywrightStatus(); refreshFrontendStatus(); refreshPlaywrightEvidence(); refreshOnboarding(); -void refreshRepositories(); setInterval(refresh, 1000); setInterval(refreshMetrics, 5000); setInterval(refreshPlaywrightStatus, 5000); diff --git a/dashboard/public/index.html b/dashboard/public/index.html index b7dfa7f..418edb2 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -7,87 +7,17 @@ -
- -
+
-

FixLab

-

Connecting to your local repository

+

Local repository workflow

+

FixLab Dashboard

+

Describe the bug or required enhancement. FixLab uses the repository profile to own diagnosis, implementation, local validation, live testing, evidence, and the gated pull-request outcome.

Checking repository…
-
-
-
-

What are we working on?

-

Describe a bug, change, or browser check.

- -
- - - -
-
-
- Not started -

Ready when you are

-

Choose a repository and describe what you want to accomplish.

- -
-
-
-
- - -
+

Playwright authentication

Checking… @@ -99,7 +29,7 @@

Playwright authentication

-
+

React frontend

Checking… @@ -111,7 +41,7 @@

React frontend

-
+

Session controls

@@ -121,53 +51,12 @@

Session controls

-
-
-

Repository-level settings

- -
-

Edit this repository's paths, commands, browser sign-in configuration, environments and branch settings. No credentials belong here. Preview before saving; active resources are never stopped to apply settings.

- - -

-
- - - -
+
-

Azure DevOps intake (optional)

+

Repository onboarding

Checking…
-

Load work items using this repository's Azure DevOps settings. Defaults come from Git when available.

+

Configure Azure DevOps intake for this repository. Values are detected from the Git origin when possible and saved only to the repository-owned FixLab profile.

-
-

Message FixLab

New job
+
- - - - -
- Paste with Ctrl+V · Image limits -

Up to 5 PNG, JPEG or WebP files; 2 MiB each, 8 MiB total. Images stay local and are removed when replaced or when the dashboard stops.

-
-
    -

    -
    - Run options Mode, environment & evidence - -
    - Access scope -

    Submission binds consent to the new job and canonical checkout, including resumes. Fix mode permits source edits; read-only modes permit reads only. Secrets, protected configuration, arbitrary commands, external access, managed approvals and publication remain separately reviewed.

    -

    FixLab proceeds autonomously and asks for authentication, unsafe-data approval, deployment or PR approval, or a genuine blocker.

    -
    +

    FixLab proceeds autonomously. It asks for human input only for authentication, unsafe-data approval, deployment or PR approval, or a genuine blocker.

    -
    +

    Job

    + Not started
    - - - -
    -
    +

    Workflow statistics

    -
    -
    - -
    -

    Current job details and evidence

    - -
    -
    -
    - diff --git a/dashboard/public/repository-workspace.js b/dashboard/public/repository-workspace.js deleted file mode 100644 index 92d90bd..0000000 --- a/dashboard/public/repository-workspace.js +++ /dev/null @@ -1,318 +0,0 @@ -import { fetchJson, openWorkspaceSection } from "./app.js"; - -const wizardPanel = document.querySelector("#repository-onboarding-chat"); -const wizardForm = document.querySelector("#repository-onboarding-form"); -const wizardAnswer = document.querySelector("#repository-onboarding-answer"); -const wizardMessages = document.querySelector("#repository-onboarding-messages"); -const wizardMessage = document.querySelector("#repository-onboarding-message"); -const suggested = document.querySelector("#repository-onboarding-suggested"); -const wizardApply = document.querySelector("#repository-onboarding-apply"); -const wizardOpen = document.querySelector("#repository-onboarding-open"); -const settingsForm = document.querySelector("#repository-settings-form"); -const settingsFields = document.querySelector("#repository-settings-fields"); -const settingsMessage = document.querySelector("#repository-settings-message"); -const settingsApply = document.querySelector("#repository-settings-apply"); -let wizard = null; -let settings = null; - -function configurationRequest(context, body) { - return fetchJson("/api/repository/configuration", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...body, instanceId: context.instanceId }) - }); -} - -function appendMessage(role, text) { - const article = document.createElement("article"); - article.className = `chat-message ${role === "You" ? "user-message" : "assistant-message"}`; - const name = document.createElement("strong"); - name.textContent = role; - const message = document.createElement("p"); - message.textContent = text; - article.append(name, message); - wizardMessages.append(article); -} - -function wizardBusy(busy) { - wizardForm.querySelectorAll("button, input").forEach((element) => { element.disabled = busy; }); -} - -function displayPlan(target, plan) { - document.querySelector(target).textContent = - `${plan.notice}\n\nRepository: ${plan.repository}\nFiles:\n${plan.files.join("\n")}\n\nSettings:\n${JSON.stringify(plan.values, null, 2)}`; -} - -async function loadConfiguration(repository) { - const status = await fetchJson("/api/status"); - const context = { instanceId: status.instanceId }; - return { ...await configurationRequest(context, { - action: "inspect", ...(repository === undefined ? {} : { repository }) - }), instanceId: context.instanceId }; -} - -function question() { - while (wizard.index < wizard.fields.length) { - const field = wizard.fields[wizard.index]; - if (!field.when || wizard.values[field.when]) { - const value = wizard.values[field.key]; - const hint = field.type === "boolean" ? (value ? "yes" : "no") : String(value); - appendMessage("FixLab", `${field.label}${hint ? ` Suggested: ${hint}` : ""}`); - document.querySelector("#repository-onboarding-label").textContent = field.label; - wizardAnswer.value = ""; - wizardAnswer.placeholder = hint; - suggested.hidden = !hint; - wizardAnswer.focus(); - return; - } - wizard.index++; - } - void previewWizard(); -} - -async function previewWizard() { - const current = wizard; - wizardBusy(true); - try { - const plan = await configurationRequest(current, { - action: "preview", repository: current.repository, revision: current.revision, - values: current.values, install: true - }); - if (wizard !== current) { return; } - current.plan = plan; - wizardForm.hidden = true; - displayPlan("#repository-onboarding-plan", plan); - document.querySelector("#repository-onboarding-review").hidden = false; - appendMessage("FixLab", "Review the proposed configuration and listed files. Applying this plan does not install dependencies, sign in, run commands or start a job."); - } catch (error) { - if (wizard === current) { - wizardMessage.textContent = error.message; - current.index = 0; - appendMessage("FixLab", "The plan was not applied. Correct the answers; existing files and running jobs are unchanged."); - question(); - } - } finally { - if (wizard === current) { wizardBusy(false); } - } -} - -document.querySelector("#add-local-repository").addEventListener("click", () => { - wizard = { index: -1, fields: [], values: {} }; - wizardMessages.replaceChildren(); - wizardMessage.textContent = ""; - wizardAnswer.value = ""; - wizardAnswer.placeholder = "Absolute path to an existing local Git working tree"; - document.querySelector("#repository-onboarding-label").textContent = "Local repository folder"; - wizardForm.hidden = false; - suggested.hidden = true; - wizardApply.disabled = false; - document.querySelector("#repository-onboarding-reload").disabled = false; - wizardOpen.hidden = true; - wizardOpen.disabled = false; - document.querySelector("#repository-onboarding-review").hidden = true; - wizardPanel.hidden = false; - wizardBusy(false); - appendMessage("FixLab", "Which existing local Git repository would you like to set up? Paste its absolute root folder path. I will not clone a repository or scan your other folders."); - openWorkspaceSection("repository-onboarding-chat"); - wizardAnswer.focus(); -}); -document.querySelector("#repository-onboarding-close").addEventListener("click", () => { - wizard = null; - wizardPanel.hidden = true; -}); -suggested.addEventListener("click", () => { - if (!wizard || wizard.index < 0) { return; } - const field = wizard.fields[wizard.index]; - wizardAnswer.value = field.type === "boolean" ? (wizard.values[field.key] ? "yes" : "no") : String(wizard.values[field.key]); - wizardForm.requestSubmit(); -}); -wizardForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const current = wizard; - if (!current) { return; } - wizardMessage.textContent = ""; - try { - if (current.index === -1) { - const path = wizardAnswer.value.trim(); - wizardBusy(true); - const configuration = await loadConfiguration(path); - if (wizard !== current) { return; } - Object.assign(current, configuration, { index: 0 }); - appendMessage("You", configuration.repository); - appendMessage("FixLab", configuration.exists - ? "This repository already has a profile. Existing custom settings and scaffolding will be preserved unless you explicitly change the displayed fields." - : "Let's configure its applications and browser checks. Missing prerequisites will still need their normal setup or sign-in."); - } else { - const field = current.fields[current.index]; - const text = wizardAnswer.value.trim(); - const value = field.type === "number" ? Number(text) : field.type === "boolean" - ? (/^yes$/i.test(text) ? true : /^no$/i.test(text) ? false : undefined) : text; - if (value === undefined || (field.type === "number" && (!Number.isInteger(value) || value < 1 || value > field.max)) || - (field.type === "text" && !text)) { - throw new Error(field.type === "boolean" ? "Answer yes or no." : "Enter a valid value or use the suggested answer."); - } - current.values[field.key] = value; - appendMessage("You", field.type === "boolean" ? (value ? "yes" : "no") : String(value)); - current.index++; - } - wizardBusy(false); - question(); - } catch (error) { - if (wizard === current) { wizardMessage.textContent = error.message; wizardBusy(false); } - } -}); -wizardApply.addEventListener("click", async () => { - const current = wizard; - if (!current?.plan || !window.confirm(`Apply only the reviewed FixLab setup in ${current.repository}? No commands or jobs will run.`)) { return; } - wizardApply.disabled = true; - document.querySelector("#repository-onboarding-reload").disabled = true; - try { - await configurationRequest(current, { action: "apply", planId: current.plan.planId, confirmed: true }); - if (wizard !== current) { return; } - wizardMessage.textContent = "Configuration saved. Dependencies, browser installation and human sign-in are not claimed as complete. Open the workspace to see readiness and connection controls."; - wizardOpen.hidden = false; - } catch (error) { - if (wizard === current) { wizardMessage.textContent = error.message; wizardApply.disabled = false; } - } finally { - if (wizard === current) { document.querySelector("#repository-onboarding-reload").disabled = false; } - } -}); -document.querySelector("#repository-onboarding-reload").addEventListener("click", async () => { - const current = wizard; - if (!current) { return; } - const button = document.querySelector("#repository-onboarding-reload"); - button.disabled = true; - try { - const configuration = await loadConfiguration(current.repository); - if (wizard !== current) { return; } - Object.assign(current, configuration, { index: 0, plan: null }); - wizardForm.hidden = false; - wizardApply.disabled = false; - wizardOpen.hidden = true; - wizardOpen.disabled = false; - document.querySelector("#repository-onboarding-review").hidden = true; - wizardMessage.textContent = ""; - appendMessage("FixLab", "Loaded the repository's current settings. No previous setup plan can be applied from this conversation."); - question(); - } catch (error) { - if (wizard === current) { wizardMessage.textContent = error.message; } - } finally { - if (wizard === current) { button.disabled = false; } - } -}); -wizardOpen.addEventListener("click", async () => { - const current = wizard; - if (!current) { return; } - wizardOpen.disabled = true; - try { - const result = await configurationRequest(current, { action: "open", repository: current.repository, confirmed: true }); - if (wizard !== current) { return; } - const url = new URL(result.url); - if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || - url.username || url.password || url.search || url.hash || url.pathname !== "/") { - throw new Error("The repository workspace returned an invalid local URL."); - } - window.location.assign(url.href); - } catch (error) { - if (wizard === current) { wizardMessage.textContent = error.message; wizardOpen.disabled = false; } - } -}); - -function settingValues() { - return Object.fromEntries(settings.fields.map((field) => { - const input = settingsForm.elements.namedItem(field.key); - return [field.key, field.type === "boolean" ? input.checked : field.type === "number" ? Number(input.value) : input.value]; - })); -} -function settingsVisibility() { - if (!settings) { return; } - const values = settingValues(); - for (const field of settings.fields) { - settingsForm.elements.namedItem(field.key).closest("label").hidden = Boolean(field.when && !values[field.when]); - } -} -function settingsBusy(busy) { - settingsForm.querySelectorAll("input, button").forEach((element) => { element.disabled = busy; }); - document.querySelector("#repository-settings-load").disabled = busy; - settingsApply.disabled = busy || !settings?.plan; -} -document.querySelector("#repository-settings-load").addEventListener("click", async (event) => { - const button = event.currentTarget; - button.disabled = true; - settings = null; - settingsForm.hidden = true; - document.querySelector("#repository-settings-review").hidden = true; - settingsApply.disabled = true; - settingsMessage.textContent = ""; - try { - settings = await loadConfiguration(); - settingsFields.replaceChildren(); - for (const field of settings.fields) { - const label = document.createElement("label"); - label.textContent = field.label; - const input = document.createElement("input"); - input.name = field.key; - input.type = field.type === "boolean" ? "checkbox" : field.type; - input.maxLength = 2048; - if (field.type === "boolean") { input.checked = settings.values[field.key]; } - else { input.value = settings.values[field.key]; } - if (field.type === "number") { input.min = "1"; input.max = String(field.max); } - label.append(input); - settingsFields.append(label); - } - settingsForm.hidden = false; - document.querySelector("#repository-settings-review").hidden = true; - settingsVisibility(); - } catch (error) { - settingsMessage.textContent = error.message; - } finally { - button.disabled = false; - } -}); -settingsForm.addEventListener("input", () => { - if (settings) { settings.plan = null; } - document.querySelector("#repository-settings-review").hidden = true; - settingsVisibility(); -}); -settingsForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const current = settings; - if (!current) { return; } - const values = settingValues(); - current.plan = null; - document.querySelector("#repository-settings-review").hidden = true; - settingsBusy(true); - settingsMessage.textContent = ""; - try { - const plan = await configurationRequest(current, { action: "preview", - repository: current.repository, revision: current.revision, values, install: false }); - if (settings !== current) { return; } - current.plan = plan; - displayPlan("#repository-settings-plan", plan); - document.querySelector("#repository-settings-review").hidden = false; - } catch (error) { - if (settings === current) { settingsMessage.textContent = error.message; } - } finally { - if (settings === current) { settingsBusy(false); } - } -}); -settingsApply.addEventListener("click", async () => { - const current = settings; - if (!current?.plan || !window.confirm(`Apply the reviewed settings to ${current.repository}? Active resources will not be stopped.`)) { return; } - settingsBusy(true); - try { - await configurationRequest(current, { action: "apply", planId: current.plan.planId, confirmed: true }); - if (settings !== current) { return; } - current.plan = null; - document.querySelector("#repository-settings-review").hidden = true; - settingsMessage.textContent = "Repository settings saved. Reload settings before another edit; no job or command was started."; - settingsForm.hidden = true; - } catch (error) { - if (settings === current) { - current.plan = null; - document.querySelector("#repository-settings-review").hidden = true; - settingsMessage.textContent = `${error.message} Review a new plan before saving again.`; - } - } finally { - if (settings === current) { settingsBusy(false); } - } -}); diff --git a/dashboard/public/styles.css b/dashboard/public/styles.css index c2f36b4..7438cd3 100644 --- a/dashboard/public/styles.css +++ b/dashboard/public/styles.css @@ -647,773 +647,3 @@ pre { grid-template-columns: 1fr; } } - -:root { - color-scheme: light; - font-family: "Segoe UI", ui-sans-serif, system-ui, sans-serif; - background: #f5f5fb; - color: #242438; -} - -[hidden] { - display: none !important; -} - -body { - background: radial-gradient(ellipse at 85% 0, #e9e4ff 0, transparent 42rem), - radial-gradient(ellipse at 25% 55%, #e9f1ff 0, transparent 48rem), #f8f9fd; -} - -button, -textarea, -input, -select, -a { - accent-color: #6253bc; -} - -:focus-visible { - outline: 3px solid #7160d1; - outline-offset: 3px; -} - -button { - background: #6253bc; - color: #fff; - font-size: .9rem; - font-weight: 600; -} - -button:hover:not(:disabled) { - filter: brightness(.94); -} - -button.secondary, -.job-expand { - border-color: #d7d5e6; - background: #fff; - color: #514584; -} - -textarea, -input[type="text"], -input[type="file"], -select, -.intake-box, -.loaded-summary, -.stage, -.metrics article, -.job-selector, -.activity-list li, -.evidence-gallery a, -.evidence-gallery .evidence-item { - border-color: #dddde9; - background: #f8f9fd; - color: #34334a; -} - -textarea { - line-height: 1.6; -} - -.summary, -.guidance, -.file-list, -.stage p, -.queue-panel span, -.activity-list p, -.activity-list time, -.metrics span, -th, -.bug-outcome td:first-child span { - color: #64647b; -} - -.eyebrow, -.activity-list strong, -.bug-outcome a, -.work-item-list a, -.loaded-summary a, -.evidence-gallery a, -.evidence-gallery .evidence-item { - color: #6151a4; -} - -.metrics strong { - color: #2c2948; -} - -.readiness, -.badge { - background: #eeedf7; - color: #514b75; -} - -.ready, -.passed { - color: #18734a; -} - -.blocked, -.failed, -.error { - color: #a42b40; -} - -.held, -.holding, -.skipped { - color: #805215; -} - -.running { - color: #4e4eae; -} - -.cancelled { - color: #686678; -} - -.panel { - margin-top: 0; - padding: 1.25rem; - border-color: #e5e4ee; - background: rgba(255, 255, 255, .92); - box-shadow: 0 5px 24px rgba(53, 40, 102, .035); -} - -.intervention { - border-color: #b8a5e4; - background: #faf7ff; -} - -.job-selector.selected { - border-color: #8c7cce; - background: #efebfc; -} - -.stage.running { - border-color: #8071ca; - background: #f1edff; -} - -.job-controls, -.queue-panel, -.playwright-evidence, -th, -td { - border-color: #e4e2ee; -} - -pre { - background: #f1f2f8; - color: #43435b; -} - -.app-shell { - display: grid; - grid-template-columns: 244px minmax(0, 1fr); - min-height: 100vh; -} - -.repository-sidebar { - position: sticky; - top: 0; - align-self: start; - height: 100vh; - overflow-y: auto; - padding: 1.6rem 1rem; - border-right: 1px solid #e7e5f0; - background: rgba(246, 245, 252, .94); -} - -.sidebar-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1.5rem; -} - -.sidebar-top .brand { - margin-bottom: 0; -} - -#navigation-toggle { - display: none; -} - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} - -.brand { - display: flex; - align-items: center; - gap: .65rem; - margin-bottom: 2rem; - padding: 0 .4rem; - color: #35304e; - font-size: 1.2rem; - font-weight: 700; - text-decoration: none; -} - -.brand small { - display: block; - margin-top: .2rem; - color: #747187; - font-size: .68rem; - font-weight: 400; -} - -.brand-mark, -.welcome-mark { - display: inline-grid; - place-items: center; - width: 38px; - height: 38px; - border-radius: 12px; - background: linear-gradient(135deg, #607bd4, #9b61c3); - color: white; - font-weight: 700; -} - -.new-conversation { - width: 100%; - margin-bottom: 1.8rem; - border: 1px solid #d6d0e9; - background: #fff; - color: #594b91; - text-align: left; -} - -.sidebar-heading { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0 .5rem; -} - -.sidebar-heading h2 { - margin: 0; - color: #767086; - font-size: .74rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: .07em; -} - -.icon-button { - padding: .3rem; - background: transparent; - color: #6b618a; -} - -.repository-link { - display: block; - overflow-wrap: anywhere; - margin-top: .4rem; - padding: .75rem; - border: 1px solid transparent; - border-radius: 10px; - color: #59516f; - text-decoration: none; - font-size: .85rem; -} - -.repository-link[aria-current="page"] { - border-color: #e0d9f4; - background: #ebe6f8; - color: #50407b; -} - -.repository-link small { - display: block; - margin-top: .3rem; - color: #777086; -} - -.sidebar-note, -.sidebar-footer { - margin: .8rem .5rem 1.4rem; - color: #7a738b; - font-size: .73rem; - line-height: 1.6; -} - -.sidebar-note:empty { - display: none; -} - -.workspace-navigation { - display: grid; - gap: .3rem; - margin: 1.5rem 0; -} - -.workspace-navigation button, -.recent-conversation { - padding: .7rem .75rem; - background: transparent; - color: #615a73; - font-weight: 400; - text-align: left; -} - -.workspace-navigation button[aria-current="page"] { - background: #eae5f6; - color: #5a4694; -} - -.recent-conversation { - display: -webkit-box; - overflow: hidden; - width: 100%; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - font-size: .78rem; -} - -.workspace { - display: grid; - grid-template-columns: minmax(0, 1fr); - align-content: start; - gap: 1.8rem; - width: 100%; - min-width: 0; - margin: 0; - padding: 2rem 2.5rem 4rem; -} - -.workspace > header { - grid-column: 1 / -1; - align-items: start; - flex-wrap: wrap; -} - -.workspace > header > div:first-child { - min-width: 0; - flex: 1; -} - -.workspace > header, -.conversation-column, -.inspector { - width: min(100%, 960px); - justify-self: center; -} - -.workspace h1 { - margin-bottom: .4rem; - font-size: 1.6rem; - font-weight: 600; -} - -#workspace-repository { - margin: 0; - overflow-wrap: anywhere; - font-size: .82rem; -} - -.conversation-column, -.inspector { - min-width: 0; -} - -.chat-welcome { - padding: 1rem 0 0; - text-align: center; -} - -.welcome-mark { - width: 54px; - height: 54px; - margin-bottom: 1rem; - border-radius: 18px; - font-size: 1.6rem; -} - -.chat-welcome h2 { - margin-bottom: .5rem; - font-size: clamp(1.4rem, 3vw, 1.8rem); - font-weight: 500; -} - -.chat-welcome p { - color: #79738a; - line-height: 1.7; -} - -.suggestion-grid { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: .65rem; - margin: 1rem 0; -} - -.suggestion-grid button { - border: 1px solid #ddd7ed; - background: rgba(255, 255, 255, .8); - color: #655681; - font-size: .8rem; -} - -.conversation-state { - padding: 1rem 1.2rem; - border: 1px solid #e4def3; - border-radius: 14px; - background: rgba(255, 255, 255, .6); -} - -.conversation-state.waiting { - border-color: #b19bdc; - background: #f3eefe; -} - -.conversation-state h2 { - margin: .8rem 0 .4rem; - font-size: 1rem; - font-weight: 600; -} - -.conversation-state p { - margin-bottom: .4rem; - color: #716980; - line-height: 1.6; - overflow-wrap: anywhere; -} - -.chat-thread { - display: grid; - gap: 1rem; - margin: 1rem 0; -} - -.chat-thread:empty { - display: none; -} - -.chat-message { - min-width: 0; - padding: 1.1rem 1.25rem; - border: 1px solid #e8e4f1; - border-radius: 16px; - background: rgba(255, 255, 255, .85); -} - -.user-message { - margin-left: 8%; - background: #efebfa; -} - -.chat-message strong { - color: #75658f; - font-size: .75rem; -} - -.chat-message p { - margin: .6rem 0 0; - white-space: pre-wrap; - overflow-wrap: anywhere; - font-size: .94rem; - line-height: 1.7; -} - -#chat-panels { - display: grid; - gap: 1rem; -} - -.composer-heading { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: .8rem; - margin-bottom: .5rem; -} - -.composer-panel { - margin-top: 0; -} - -.help-note { - margin: .6rem 0; - color: #655c78; - font-size: .8rem; -} - -.help-note > summary { - width: fit-content; - cursor: pointer; -} - -.help-note .guidance { - margin-top: .5rem; -} - -.consent-summary { - margin-top: .4rem; - font-size: .8rem; -} - -.inspector:not([hidden]) { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; - align-items: start; -} - -.tools-heading { - grid-column: 1 / -1; -} - -.tools-heading h2 { - margin: 0; - font-size: 1rem; -} - -.composer-heading h2 { - margin: 0; - font-size: 1rem; -} - -.composer-heading span, -.detail-hint { - color: #888093; - font-size: .74rem; - font-weight: 400; -} - -.inspector-group { - margin-bottom: 0; - border: 1px solid #e6e2ee; - border-radius: 14px; - background: rgba(255, 255, 255, .72); -} - -.inspector-group > summary, -.run-options > summary { - padding: 1rem; - cursor: pointer; - font-size: .86rem; - font-weight: 600; -} - -.inspector-group .detail-hint { - display: block; - margin: .4rem 0 0 1rem; -} - -.inspector-group .panel { - margin: .75rem; - padding: .9rem; -} - -.inspector-group h2 { - font-size: .95rem; -} - -.inspector-group .section-heading, -.inspector-group fieldset { - flex-wrap: wrap; -} - -.inspector-group .stages, -.inspector-group .onboarding-grid { - grid-template-columns: minmax(0, 1fr); -} - -#repository-memory { - padding: 0 1rem 1rem; -} - -#repository-memory-list { - margin: 1rem 0 0; - padding: 0; - list-style: none; -} - -.memory-entry { - width: 100%; - margin-bottom: .5rem; - padding: .8rem; - border: 1px solid #e8e3f1; - background: #fbfaff; - color: #554d6b; - text-align: left; -} - -.memory-entry strong { - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - font-size: .8rem; - font-weight: 500; -} - -.memory-entry span { - display: block; - margin-top: .5rem; - color: #81768f; - font-size: .72rem; -} - -.run-options { - margin: 1rem 0; - border-block: 1px solid #eae6f2; -} - -.run-options[open] > summary { - margin-bottom: 1rem; -} - -.run-options fieldset { - flex-wrap: wrap; - margin-top: 1rem; -} - -@media (max-width: 1250px) { - .workspace { - grid-template-columns: minmax(0, 1fr); - padding-inline: 1.5rem; - } - -} - -@media (max-width: 760px) { - .app-shell { - display: block; - } - - .repository-sidebar { - position: static; - height: auto; - padding: .75rem 1rem; - } - - .sidebar-top { - margin-bottom: 0; - } - - #navigation-toggle { - display: inline-flex; - } - - .repository-sidebar:not(.navigation-open) #repository-navigation { - display: none; - } - - .repository-sidebar.navigation-open #repository-navigation { - padding-top: 1rem; - } - - .brand, - .new-conversation { - margin-bottom: .8rem; - } - - .workspace-navigation { - display: flex; - flex-wrap: wrap; - margin: .5rem 0; - } - - .workspace-navigation button { - font-size: .75rem; - } - - #sidebar-conversations, - .sidebar-footer { - display: none; - } - - .workspace { - gap: 1rem; - padding: 1rem; - } - - .workspace > header { - flex-wrap: wrap; - } - - .inspector:not([hidden]) { - grid-template-columns: minmax(0, 1fr); - } - - .chat-welcome { - padding-top: .5rem; - } - - .suggestion-grid { - display: none; - } - - .composer-panel { - padding: 1rem; - } -} - -@media (prefers-reduced-motion: reduce) { - .stage.running.inferred { - animation: none; - } -} -#current-job-dialog { - width: 100vw; - max-width: 100vw; - height: 100dvh; - max-height: 100dvh; - margin: 0; - box-sizing: border-box; - border: 0; - padding: 1.5rem; - color: #282638; - background: #f7f7fc; -} - -#current-job-dialog::backdrop { - background: rgb(20 18 40 / 45%); -} - -#current-job-dialog #queue-panel { - display: none; -} - -#current-job-dialog > .section-heading { - flex-direction: row; - align-items: flex-start; - position: sticky; - top: -1.5rem; - z-index: 1; - padding: 1rem 0; - background: #f7f7fc; -} - -#current-job-dialog h2 { - overflow-wrap: anywhere; -} - -#current-job-dialog-title { - flex: 1; - min-width: 0; - margin: 0; - line-height: 1.35; - display: -webkit-box; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - -#current-job-fullscreen-close { - flex-shrink: 0; - white-space: nowrap; -} diff --git a/dashboard/publication-permissions.js b/dashboard/publication-permissions.js deleted file mode 100644 index c5124e5..0000000 --- a/dashboard/publication-permissions.js +++ /dev/null @@ -1,234 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { lstatSync, realpathSync } from "node:fs"; -import { isAbsolute, relative, resolve, sep } from "node:path"; -import { safeRuntimeText } from "./runtime-requests.js"; -import { runtimeSourceName } from "./runtime-permission-policy.js"; - -export function publicationDestination(value) { - if (typeof value !== "string") { return null; } - let url; - try { - url = new URL(value); - } catch { - return null; - } - if (url.protocol !== "https:" || url.username || url.password || url.port || - url.search || url.hash || /%|\\/.test(url.pathname)) { - return null; - } - const parts = url.pathname.replace(/\/$/, "").split("/").slice(1); - if (parts.some((part) => !/^[A-Za-z0-9_.-]+$/.test(part) || [".", ".."].includes(part))) { - return null; - } - if (url.hostname === "github.com" && parts.length === 2) { - const repository = parts.join("/").replace(/\.git$/, ""); - return { provider: "github", repository: repository.toLowerCase(), - url: `https://github.com/${repository.toLowerCase()}` }; - } - if (url.hostname === "dev.azure.com" && parts.length === 4 && parts[2] === "_git") { - return { provider: "azure", organization: `https://dev.azure.com/${parts[0]}`, - project: parts[1], repository: parts[3], - url: `https://dev.azure.com/${parts[0]}/${parts[1]}/_git/${parts[3]}` }; - } - return null; -} - -// Deliberately exclude shell composition, aliases, implicit destinations and provider defaults. -export function publicationCommand(command) { - if (!safeRuntimeText(command, 8192) || /[\r\n`$;&|<>()%!]/.test(command)) { return null; } - const tokens = command.match(/'[^']*'|"[^"]*"|[^\s'"]+/g); - if (!tokens || tokens.join(" ") !== command) { return null; } - const args = tokens.map((token) => /^['"]/.test(token) ? token.slice(1, -1) : token); - if (args.length === 4 && args[0] === "git" && args[1] === "commit" && - args[2] === "-m" && args[3].trim() && !args[3].startsWith("-")) { - return { operation: "commit" }; - } - if (args.length === 4 && args[0] === "git" && args[1] === "push" && - args[2] === "origin" && /^HEAD:refs\/heads\/[A-Za-z0-9_./-]+$/.test(args[3])) { - return { operation: "push", source: args[3].slice("HEAD:refs/heads/".length) }; - } - const github = args.slice(0, 3).join(" ") === "gh pr create"; - const azure = args.slice(0, 4).join(" ") === "az repos pr create"; - if (!github && !azure) { return null; } - const options = {}; - for (let index = github ? 3 : 4; index < args.length; index += 2) { - const key = args[index]; - if (Object.hasOwn(options, key)) { return null; } - if (github && key === "--draft") { - options[key] = true; - index--; - } else { - if (args[index + 1] === undefined || args[index + 1].startsWith("--")) { return null; } - options[key] = args[index + 1]; - } - } - const required = github - ? ["--repo", "--head", "--base", "--title", "--body"] - : ["--organization", "--project", "--repository", "--source-branch", "--target-branch", - "--title", "--description", "--draft", "--detect", "--output"]; - if (required.some((key) => typeof options[key] !== "string" || !options[key].trim()) || - Object.keys(options).some((key) => !required.includes(key) && !(github && key === "--draft")) || - (azure && (options["--detect"] !== "false" || options["--output"] !== "json" || - !["true", "false"].includes(options["--draft"])))) { - return null; - } - return { - operation: "pr", - destination: publicationDestination(github ? options["--repo"] : - `${options["--organization"]}/${options["--project"]}/_git/${options["--repository"]}`), - source: options[github ? "--head" : "--source-branch"], - target: options[github ? "--base" : "--target-branch"], - draft: github ? options["--draft"] === true : options["--draft"] === "true" - }; -} - -function git(repository, args) { - const result = spawnSync("git", args, { - cwd: repository, encoding: "utf8", shell: false, windowsHide: true, - timeout: 2000, maxBuffer: 256 * 1024 - }); - if (result.error || result.status !== 0) { - throw new Error("Publication scope could not be verified; review this command manually."); - } - return result.stdout.trim(); -} - -export function createPublicationPermissions(repository, protectedPaths = []) { - let consent = null; - let lastReview = null; - const eligible = (request) => request.kind === "shell" && request.canOfferSessionApproval === true && - !request.managedApprovalRequired && !request.requestSandboxBypass && - !request.requestSandboxPermissive && !request.sandboxPathGrant && - !request.warning && !request.hasWriteFileRedirection && - Array.isArray(request.possiblePaths) && Array.isArray(request.possibleUrls) && - request.possiblePaths.every((path) => path === repository && - (!request.resolvedPaths?.[path] || request.resolvedPaths[path] === repository)); - const snapshot = () => { - const root = realpathSync.native(repository); - if (!isAbsolute(repository) || repository !== root || lstatSync(repository).isSymbolicLink()) { - return null; - } - const configCount = Number(process.env.GIT_CONFIG_COUNT ?? 0); - if (!Number.isInteger(configCount) || configCount < 0 || configCount > 32) { return null; } - for (let index = 0; index < configCount; index++) { - const key = process.env[`GIT_CONFIG_KEY_${index}`]; - const value = process.env[`GIT_CONFIG_VALUE_${index}`]; - if (!((key === "core.fsmonitor" && value === "false") || - (key === "safe.bareRepository" && ["all", "explicit"].includes(value)) || - (key === "credential.interactive" && ["false", "never"].includes(value)))) { - return null; - } - } - if (Object.keys(process.env).some((key) => - /^GIT_(?:DIR|WORK_TREE|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|CONFIG(?:_SYSTEM|_GLOBAL|_NOSYSTEM)?)$/i.test(key)) || - git(root, ["rev-parse", "--show-toplevel"]).replace(/\\/g, "/") !== root.replace(/\\/g, "/")) { - return null; - } - const pushConfig = spawnSync("git", ["config", "--get-regexp", - "^(push\\.(followtags|pushoption)|remote\\.origin\\.(mirror|receivepack))$"], { - cwd: root, encoding: "utf8", shell: false, windowsHide: true, timeout: 2000, maxBuffer: 8192 - }); - if (pushConfig.error || pushConfig.status !== 1) { return null; } - const branch = git(root, ["symbolic-ref", "--short", "HEAD"]); - const urls = git(root, ["remote", "get-url", "--push", "--all", "origin"]).split(/\r?\n/); - const destination = urls.length === 1 ? publicationDestination(urls[0]) : null; - if (!destination || git(root, ["diff", "--name-only"]).length || - git(root, ["ls-files", "--stage"]).split("\n").some((line) => - line && !/^(?:100644|100755) [a-f0-9]+ 0\t/.test(line))) { return null; } - const head = git(root, ["rev-parse", "HEAD"]); - const tree = git(root, ["write-tree"]); - const profile = git(root, ["hash-object", "--no-filters", ".github/fixlab/repository-profile.json"]); - return { root, branch, destination, head, tree, profile }; - }; - const changedPaths = (base) => git(repository, [ - "diff", "--cached", "--name-only", "--no-renames", "-z", base - ]).split("\0").filter(Boolean); - const safeChange = (path) => { - const parts = path.split(/[\\/]/); - if (!path || /[\u0000-\u001f:]/.test(path) || parts.some((part, index) => - part === ".." || part.startsWith(".") || - /^(?:node_modules|vendor|bin|obj|dist|build|artifacts|test-results|playwright-report)$/i.test(part) || - /auth|secret|credential|token|cookie|password|storage.?state|private|environment|appsettings/i - .test(index === parts.length - 1 ? runtimeSourceName(part) : part))) { - return false; - } - const absolute = resolve(repository, path); - const suffix = relative(repository, absolute); - if (!suffix || isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`)) { - return false; - } - if (protectedPaths.some((protectedRoot) => { - const suffix = relative(protectedRoot, absolute); - return !suffix || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); - })) { return false; } - let current = realpathSync.native(repository); - for (const part of parts) { - current = resolve(current, part); - try { - const stat = lstatSync(current); - if (stat.isSymbolicLink() || (current === absolute && (!stat.isFile() || stat.nlink !== 1))) { return false; } - } catch (error) { - if (error.code === "ENOENT") { - return /^(?:100644|100755) blob /.test(git(repository, ["ls-tree", "HEAD", "--", path])); - } - throw error; - } - } - const size = Number(git(repository, ["cat-file", "-s", `:${path}`])); - return Number.isInteger(size) && size <= 256 * 1024 && - safeRuntimeText(git(repository, ["cat-file", "blob", `:${path}`]), 256 * 1024); - }; - return { - clear() { consent = null; lastReview = null; }, - eligible(request, prompt) { - return prompt.canApprove && eligible(request) && publicationCommand(request.fullCommandText) !== null; - }, - review(request, readiness) { - lastReview = null; - const command = publicationCommand(request.fullCommandText); - if (!eligible(request) || command?.operation !== "commit" || readiness?.ready !== true) { return null; } - const state = snapshot(); - if (!state || state.branch !== readiness.source || !readiness.target || - request.resolvedWorkingDirectory !== state.root || request.possibleUrls.length) { return null; } - const paths = changedPaths(state.head); - if (!paths.length || paths.length > 30 || paths.some((path) => !safeChange(path))) { return null; } - const key = JSON.stringify({ ...state, readiness, paths }); - if (key.length > 6000) { return null; } - lastReview = key; - return { scope: "publication-batch", key }; - }, - grant(reviewed) { - if (reviewed?.scope !== "publication-batch" || !lastReview || reviewed.key !== lastReview) { - throw new Error("Publication consent requires this executor's current reviewed checkpoint."); - } - consent = JSON.parse(lastReview); - lastReview = null; - }, - allows(request, readiness) { - const command = publicationCommand(request.fullCommandText); - if (!eligible(request) || !command || !consent || - JSON.stringify(readiness) !== JSON.stringify(consent.readiness)) { return false; } - const state = snapshot(); - if (!state || request.resolvedWorkingDirectory !== consent.root || - ["root", "branch", "tree", "profile"].some((key) => state[key] !== consent[key]) || - JSON.stringify(state.destination) !== JSON.stringify(consent.destination) || - request.possibleUrls.some((url) => ![state.destination.url, state.destination.organization].includes(url))) { - return false; - } - if (command.operation === "commit") { return state.head === consent.head; } - if (state.head === consent.head || - git(repository, ["rev-list", "--parents", "-n", "1", "HEAD"]) !== `${state.head} ${consent.head}` || - git(repository, ["rev-parse", "HEAD^{tree}"]) !== consent.tree || - git(repository, ["diff", "--cached", "--name-only"]).length) { return false; } - if (command.operation === "push") { return command.source === consent.branch; } - if (command.destination?.provider === "github" && - ((process.env.GH_HOST && process.env.GH_HOST !== "github.com") || - (process.env.GH_REPO && publicationDestination(process.env.GH_REPO)?.url !== consent.destination.url))) { - return false; - } - return command.destination?.url === consent.destination.url && - command.source === consent.branch && command.target === readiness.target && - command.draft === (readiness.publication === "draft"); - } - }; -} diff --git a/dashboard/repository-settings.js b/dashboard/repository-settings.js deleted file mode 100644 index 9e79391..0000000 --- a/dashboard/repository-settings.js +++ /dev/null @@ -1,277 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { createHash, randomUUID } from "node:crypto"; -import { closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; -import { basename, dirname, isAbsolute, join, parse } from "node:path"; -import { createExecutorLease } from "./executor-lease.js"; -import { SETUP_PROFILE_PATH, REPOSITORY_PROMPT_NAMES, setupPath, browserConfigurationErrors } from "./repository-setup.js"; -import { safeRuntimeText } from "./runtime-requests.js"; -import { configuredWorkflow } from "./workflow-policy.js"; - -export const REPOSITORY_SETTING_FIELDS = [ - { key: "efficientWorkflow", label: "Use efficient delivery by default: focused checks, then draft PR?", type: "boolean" }, - { key: "readyPublication", label: "Publish ready for review instead of draft? Required checks and publication consent still apply.", type: "boolean" }, - { key: "unitTestsAfterPr", label: "Run broader local unit tests after PR creation?", type: "boolean" }, - { key: "optionalBuilds", label: "Run optional builds after PR creation? Saved once, not asked each stage.", type: "boolean" }, - { key: "name", label: "What should this repository be called?", type: "text" }, - { key: "frontendDirectory", label: "Where is the frontend? Use a repository-relative folder.", type: "text" }, - { key: "frontendCommand", label: "Which command starts the frontend?", type: "text" }, - { key: "frontendPort", label: "Which local port does the frontend use?", type: "number", max: 65535 }, - { key: "frontendHealthUrl", label: "What is its local health URL?", type: "text" }, - { key: "backendEnabled", label: "Configure a local backend too?", type: "boolean" }, - { key: "backendDirectory", label: "Where is the backend?", type: "text", when: "backendEnabled" }, - { key: "backendCommand", label: "Which command starts the backend?", type: "text", when: "backendEnabled" }, - { key: "backendPort", label: "Which local port does the backend use?", type: "number", max: 65535, when: "backendEnabled" }, - { key: "backendHealthUrl", label: "What is the backend's local health URL?", type: "text", when: "backendEnabled" }, - { key: "browserDirectory", label: "Where should Playwright run?", type: "text" }, - { key: "browserCommand", label: "Which command runs the browser tests?", type: "text" }, - { key: "authenticationRequired", label: "Does browser validation require sign-in?", type: "boolean" }, - { key: "authenticationCommand", label: "Which repository command opens sign-in? Never enter credentials.", type: "text", when: "authenticationRequired" }, - { key: "authenticationPaths", label: "Which relative authentication status paths should be checked? Separate them with commas; do not paste file contents.", type: "text", when: "authenticationRequired" }, - { key: "environments", label: "Which non-production environments are allowed? Separate them with commas.", type: "text" }, - { key: "targetBranch", label: "Which branch should pull requests target?", type: "text" }, - { key: "branchUserId", label: "What developer alias should own new branches?", type: "text" }, - { key: "branchPrefix", label: "Which branch prefix template should be used? Use {userId} for the alias.", type: "text" }, - { key: "idleTimeoutMinutes", label: "How many idle minutes may an executor wait? Choose 1 to 120.", type: "number", max: 120 } -]; -const split = (value) => value.split(",").map((part) => part.trim()).filter(Boolean); - -export function localRepository(value) { - if (typeof value !== "string" || value.length > 2048 || !isAbsolute(value)) { - throw new Error("Choose an existing local Git repository using its absolute folder path."); - } - const root = realpathSync.native(value); - if (!lstatSync(root).isDirectory() || root === parse(root).root) { - throw new Error("A filesystem root is not a repository workspace."); - } - const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { - cwd: root, encoding: "utf8", shell: false, windowsHide: true, timeout: 10000, maxBuffer: 65536 - }); - if (git.status !== 0 || realpathSync.native(git.stdout.trim()) !== root) { - throw new Error("Select the root of an existing Git working tree, not a subfolder or bare repository."); - } - return root; -} - -function readConfiguration(repository) { - const root = localRepository(repository); - const path = setupPath(root, SETUP_PROFILE_PATH); - let raw = null; - if (existsSync(path)) { - const info = lstatSync(path); - if (!info.isFile() || info.size > 65536) { - throw new Error("The repository profile must be a regular JSON file no larger than 64 KiB."); - } - raw = readFileSync(path); - } - let profile = {}; - if (raw) { - try { - profile = JSON.parse(raw.toString("utf8")); - } catch { - throw new Error("Repair the existing repository profile's JSON before editing settings."); - } - if (!profile || typeof profile !== "object" || Array.isArray(profile)) { - throw new Error("The repository profile must be a JSON object."); - } - } - const directory = lstatSync(root); - const revision = createHash("sha256").update(`${root}\0${directory.dev}\0${directory.ino}\0`) - .update(raw ?? "profile-not-created").digest("hex"); - return { root, path, profile, revision, exists: raw !== null }; -} - -export function inspectRepositorySettings(repository) { - const info = readConfiguration(repository); - const p = info.profile; - const f = p.applications?.frontend ?? {}; - const b = p.applications?.backend ?? {}; - const browser = p.browserAutomation ?? {}; - const auth = browser.authentication ?? {}; - const workflow = configuredWorkflow(p); - const values = { - efficientWorkflow: workflow.mode === "efficient", - readyPublication: workflow.publication === "ready", - unitTestsAfterPr: workflow.unitTestsAfterPr, - optionalBuilds: workflow.optionalBuilds, - name: p.name ?? basename(info.root), - frontendDirectory: f.workingDirectory ?? (existsSync(join(info.root, "frontend")) ? "frontend" : "."), - frontendCommand: f.command ?? "npm start", frontendPort: f.port ?? 3000, - frontendHealthUrl: f.healthUrl ?? "http://127.0.0.1:3000", - backendEnabled: Boolean(p.applications?.backend), backendDirectory: b.workingDirectory ?? ".", - backendCommand: b.command ?? "dotnet run", backendPort: b.port ?? 5000, - backendHealthUrl: b.healthUrl ?? "http://127.0.0.1:5000/health", - browserDirectory: browser.workingDirectory ?? f.workingDirectory ?? ".", - browserCommand: browser.testCommand ?? "npx playwright test", - authenticationRequired: auth.required ?? false, authenticationCommand: auth.command ?? "", - authenticationPaths: (auth.statusPaths ?? []).join(", "), - environments: (Array.isArray(p.environments) ? p.environments : - p.environments && typeof p.environments === "object" ? Object.keys(p.environments) : ["local", "dev", "sit"]).join(", "), - targetBranch: p.pullRequests?.defaultTargetBranch ?? "main", - branchUserId: p.pullRequests?.branchNaming?.userId ?? "", - branchPrefix: p.pullRequests?.branchNaming?.prefixTemplate ?? "users/{userId}", - idleTimeoutMinutes: p.validation?.agentIdleTimeoutMinutes ?? 20 - }; - for (const value of Object.values(values)) { - if (typeof value === "string" && (!safeRuntimeText(value, 2048) || - /--(?:password|passwd|secret|token|cookie|api-key|authorization)(?:=|\s)/i.test(value))) { - throw new Error("Editable settings contain sensitive or oversized values. Remove credentials from profile commands before using this editor."); - } - } - return { repository: info.root, revision: info.revision, exists: info.exists, - fields: REPOSITORY_SETTING_FIELDS, values }; -} - -function configuredProfile(root, original, values) { - if (!values || typeof values !== "object" || Array.isArray(values) || - Object.keys(values).some((key) => !REPOSITORY_SETTING_FIELDS.some((field) => field.key === key))) { - throw new Error("Only the displayed repository settings may be updated."); - } - for (const field of REPOSITORY_SETTING_FIELDS) { - if (field.when && values[field.when] === false) { continue; } - const value = values[field.key]; - if (field.type === "boolean" ? typeof value !== "boolean" - : field.type === "number" ? !Number.isInteger(value) || value < 1 || value > field.max - : !safeRuntimeText(value, 2048) || !value.trim() || - /--(?:password|passwd|secret|token|cookie|api-key|authorization)(?:=|\s)/i.test(value)) { - throw new Error(`Invalid setting: ${field.key}. Use a non-sensitive value of the displayed type.`); - } - } - const directory = (value) => { - const path = setupPath(root, value); - if (!existsSync(path) || !lstatSync(path).isDirectory()) { - throw new Error("Application and browser working directories must already exist inside the repository."); - } - return value; - }; - const application = (prefix, previous) => { - const url = new URL(values[`${prefix}HealthUrl`]); - if (!["http:", "https:"].includes(url.protocol) || !["localhost", "127.0.0.1"].includes(url.hostname) || - url.username || url.password || url.search || url.hash) { - throw new Error("Application health URLs must be credential-free loopback HTTP(S) URLs without query strings."); - } - return { ...previous, workingDirectory: directory(values[`${prefix}Directory`]), - command: values[`${prefix}Command`], port: values[`${prefix}Port`], healthUrl: url.href }; - }; - const environments = split(values.environments); - if (!environments.length || environments.length > 12 || environments.some((name) => - !/^[a-z0-9][a-z0-9_-]{0,31}$/i.test(name) || /^(?:prod|production)(?:$|[-_])/i.test(name))) { - throw new Error("Choose at most 12 named non-production environments."); - } - const environmentDefinitions = original.environments && !Array.isArray(original.environments) && - typeof original.environments === "object" ? original.environments : null; - if (environmentDefinitions && environments.some((name) => !Object.hasOwn(environmentDefinitions, name))) { - throw new Error("Configure new structured environment definitions in the repository profile before enabling them here."); - } - if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(values.branchUserId) || - !/^[a-z0-9][a-z0-9._/-]{0,199}$/i.test(values.targetBranch) || - values.targetBranch.includes("..")) { - throw new Error("Use a safe developer alias and pull-request target branch."); - } - const prefix = values.branchPrefix.replaceAll("{userId}", values.branchUserId); - if (!/^[a-z0-9][a-z0-9._/-]{0,199}$/i.test(prefix) || prefix.includes("..")) { - throw new Error("Use a safe branch prefix, with only the optional {userId} placeholder."); - } - if (original.browserAutomation?.dataSafety?.productionAllowed === true) { - throw new Error("This editor cannot change a profile permitting production operations. Review its safety policy separately."); - } - const browserDirectory = directory(values.browserDirectory); - const statusPaths = values.authenticationRequired ? split(values.authenticationPaths) : []; - if (values.authenticationRequired && (!statusPaths.length || statusPaths.length > 8)) { - throw new Error("Authenticated browser setup requires 1 to 8 relative status paths."); - } - for (const path of statusPaths) { setupPath(setupPath(root, browserDirectory), path); } - const applications = { ...original.applications, frontend: application("frontend", original.applications?.frontend) }; - if (values.backendEnabled) { applications.backend = application("backend", original.applications?.backend); } - else { delete applications.backend; } - return { - ...original, version: original.version ?? 1, name: values.name, applications, - workflow: configuredWorkflow({ workflow: { - mode: values.efficientWorkflow ? "efficient" : "strict", - publication: values.readyPublication ? "ready" : "draft", - unitTestsAfterPr: values.unitTestsAfterPr, optionalBuilds: values.optionalBuilds - } }), - validation: { ...original.validation, agentIdleTimeoutMinutes: values.idleTimeoutMinutes }, - browserAutomation: { - package: "@playwright/test", browser: "chromium", ...original.browserAutomation, - workingDirectory: browserDirectory, testCommand: values.browserCommand, - testSynthesis: original.browserAutomation?.testSynthesis ?? { - enabled: true, defaultDataSource: "synthetic-intercepted", mutationMode: "intercepted", requireScenarioEvidence: true - }, - authentication: { ...original.browserAutomation?.authentication, - required: values.authenticationRequired, command: values.authenticationRequired ? values.authenticationCommand : "", - statusPaths }, - dataSafety: { policy: "Use synthetic data and intercept mutations; real writes require separate explicit authorization.", - ...original.browserAutomation?.dataSafety, productionAllowed: false } - }, - environments: environmentDefinitions - ? Object.fromEntries(environments.map((name) => [name, environmentDefinitions[name]])) : environments, - pullRequests: { ...original.pullRequests, defaultTargetBranch: values.targetBranch, - requireConfirmation: original.pullRequests?.requireConfirmation ?? true, - branchNaming: { ...original.pullRequests?.branchNaming, - userId: values.branchUserId, prefixTemplate: values.branchPrefix } } - }; -} - -export function planRepositorySettings({ repository, revision, values, install, packageRoot, validate }) { - const info = readConfiguration(repository); - if (revision !== info.revision) { throw new Error("Repository settings changed. Reload and review a new plan."); } - const profile = configuredProfile(info.root, info.profile, values); - if (Buffer.byteLength(`${JSON.stringify(profile, null, 2)}\n`) > 65536) { - throw new Error("The updated repository profile would exceed the 64 KiB settings limit."); - } - const validation = validate(profile); - const errors = [...(validation.ok ? [] : [validation.detail]), ...browserConfigurationErrors(profile, info.root)]; - if (errors.length) { throw new Error(`Profile configuration is incomplete: ${errors.join("; ")}`); } - const assets = install ? [ - ...REPOSITORY_PROMPT_NAMES.map((name) => ({ name: join(".github", "prompts", name), - source: join(packageRoot, "prompts", name) })), - { name: join(".github", "agents", "fixlab-autofix.agent.md"), - source: join(packageRoot, "templates", "fixlab-autofix.agent.md") } - ].filter((asset) => { - const path = setupPath(info.root, asset.name); - if (!existsSync(path)) { return true; } - if (!lstatSync(path).isFile()) { throw new Error("Existing FixLab scaffolding must use regular non-symlink files."); } - return false; - }) : []; - return { id: randomUUID(), createdAt: Date.now(), repository: info.root, revision, - values, profile, assets, files: [...assets.map((asset) => asset.name), SETUP_PROFILE_PATH] }; -} - -export function applyRepositorySettings(plan, ownerId) { - const lease = createExecutorLease(plan.repository, ownerId).acquire(plan.id); - let temporary = null; - try { - const current = readConfiguration(plan.repository); - if (current.revision !== plan.revision) { throw new Error("The repository changed after review; no settings were saved."); } - for (const asset of plan.assets) { - const path = setupPath(plan.repository, asset.name); - if (existsSync(path)) { throw new Error("Setup files changed after review. Review a new plan before applying."); } - } - for (const asset of plan.assets) { - const path = setupPath(plan.repository, asset.name); - mkdirSync(dirname(path), { recursive: true }); - setupPath(plan.repository, asset.name); - copyFileSync(asset.source, path, constants.COPYFILE_EXCL); - } - const path = setupPath(plan.repository, SETUP_PROFILE_PATH); - mkdirSync(dirname(path), { recursive: true }); - setupPath(plan.repository, SETUP_PROFILE_PATH); - const candidate = `${path}.${randomUUID()}.tmp`; - const descriptor = openSync(candidate, "wx", 0o600); - temporary = candidate; - try { - writeFileSync(descriptor, `${JSON.stringify(plan.profile, null, 2)}\n`); - } finally { - closeSync(descriptor); - } - renameSync(temporary, path); - temporary = null; - return { saved: true, repository: plan.repository, files: plan.files }; - } finally { - try { - if (temporary) { unlinkSync(temporary); } - } finally { - lease.release(); - } - } -} diff --git a/dashboard/repository-setup.js b/dashboard/repository-setup.js index 0f0f30a..1175567 100644 --- a/dashboard/repository-setup.js +++ b/dashboard/repository-setup.js @@ -3,10 +3,8 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { canonicalExecutionRepository } from "./executor-lease.js"; export const SETUP_PROFILE_PATH = join(".github", "fixlab", "repository-profile.json"); -export const REPOSITORY_PROMPT_NAMES = ["bugfix", "intake", "diagnose", "reproduce", "fix", "validate", "live-test", "pr"] - .map((name) => `fixlab.${name}.prompt.md`); -export function setupPath(root, value) { +function setupPath(root, value) { if ( typeof value !== "string" || !value || isAbsolute(value) || /^[A-Za-z]:|^[/\\]/u.test(value) || diff --git a/dashboard/runtime-permission-policy.js b/dashboard/runtime-permission-policy.js deleted file mode 100644 index 7df74aa..0000000 --- a/dashboard/runtime-permission-policy.js +++ /dev/null @@ -1,384 +0,0 @@ -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { lstatSync, readFileSync, realpathSync } from "node:fs"; -import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import { safeRuntimeText } from "./runtime-requests.js"; -import { resolveEvidenceRoot } from "../bin/structured-command.js"; - -const sourceExtension = /^\.(?:[cm]?[jt]sx?|cs|csproj|sln|props|targets|md|mdx|html|css|scss|sass|less|fs|vb|go|rs|py|sh|ps1)$/i; -const sensitiveName = /auth|credential|secret|password|passwd|token|cookie|storage.?state|private|environment|appsettings/i; -const testCommand = /^(?:npm (?:test|run test(?::[\w-]+)?)|pnpm (?:test|run test(?::[\w-]+)?)|yarn test(?::[\w-]+)?|node --test|dotnet test|npx playwright test)(?: [A-Za-z0-9_./\\:= -]+)?$/; -const helperPath = fileURLToPath(new URL("../bin/fixlab.js", import.meta.url)); -const excludedPart = (part) => (part.startsWith(".") && part !== ".github") || - /^(?:node_modules|vendor|bin|obj|dist|build|artifacts|test-results|playwright-report)$/i.test(part) || - sensitiveName.test(part); - -export function runtimeSourceName(name) { - return name.replace(/(^|[._-])auth(?=\.(?:spec|test)\.[cm]?[jt]sx?$)/i, "$1source"); -} - -export function validateJobFileAccess(value, repository, jobId, mode) { - if (value === undefined || value === null) { - return null; - } - if (!["source-read", "source-read-write"].includes(value.mode) || - (value.allowValidationGates !== undefined && typeof value.allowValidationGates !== "boolean") || - typeof jobId !== "string" || !/^[a-f0-9-]{36}$/i.test(jobId) || - value.jobId !== jobId || value.repository !== realpathSync.native(repository) || - (value.mode === "source-read-write" && mode !== "fix-and-validate")) { - throw new Error("Repository file trust must match this exact job and canonical checkout; edits require fix-and-validate mode."); - } - return { mode: value.mode, jobId, repository: value.repository, allowValidationGates: value.allowValidationGates === true }; -} - -function safeProfile(path) { - if (lstatSync(path).size > 256 * 1024) { - return false; - } - const text = readFileSync(path, "utf8"); - if (!safeRuntimeText(text, 256 * 1024)) { - return false; - } - let profile; - try { - profile = JSON.parse(text); - } catch (error) { - if (error instanceof SyntaxError) {return false;} - throw error; - } - const sensitive = (value) => value && typeof value === "object" && - Object.entries(value).some(([key, child]) => - /password|passwd|secret|token|cookie|credential|authorization|storage.?state|^origins$|^headers$/i.test(key) || - sensitive(child)); - return profile?.version === 1 && typeof profile.name === "string" && profile.name.trim().length > 0 && - !sensitive(profile); -} - -export function createRuntimePermissionPolicy(repository, protectedPaths = [], fileAccess = null, dashboardPort = null) { - if (!Array.isArray(protectedPaths) || protectedPaths.some((path) => typeof path !== "string" || !isAbsolute(path))) { - throw new Error("Protected runtime paths must be an array of absolute paths."); - } - let reads = null; - const commands = new Set(); - const liveTools = new Map(); - - const root = () => realpathSync.native(repository); - const protectedPath = (path) => protectedPaths.some((protectedRoot) => { - const suffix = relative(protectedRoot, path); - return !suffix || (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)); - }); - const contained = (path, directory = root(), newFile = false) => { - if (typeof path !== "string" || !path || /[*?\u0000-\u001f]/.test(path)) { - return null; - } - const absolute = resolve(directory, path); - const suffix = relative(directory, absolute); - if (!suffix || isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`) || - suffix.includes(":")) { - return null; - } - let current = directory; - const parts = suffix.split(sep); - for (const [index, part] of parts.entries()) { - current = join(current, part); - let stat; - try { - stat = lstatSync(current); - } catch (error) { - if (newFile && error.code === "ENOENT" && index === parts.length - 1 && - dirname(absolute) === realpathSync.native(dirname(absolute))) { - return absolute; - } - throw error; - } - if (stat.isSymbolicLink()) { - return null; - } - } - return absolute === realpathSync.native(absolute) ? absolute : null; - }; - - const eligible = (request, prompt) => typeof repository === "string" && isAbsolute(repository) && - (prompt.canApprove || (request.kind === "write" && fileAccess?.mode === "source-read-write" && - prompt.canUseFileTrust === true)) && !request.managedApprovalRequired && - !request.requestSandboxBypass && !request.requestSandboxPermissive && - !request.sandboxPathGrant && !request.warning && !request.hasWriteFileRedirection; - const trustedHelper = (path) => path === helperPath && - contained(path, realpathSync.native(fileURLToPath(new URL("../", import.meta.url)))) === helperPath && - lstatSync(path).isFile(); - - function candidate(request, prompt, automatic = false) { - if (!eligible(request, prompt)) { - return null; - } - try { - const directory = root(); - if (lstatSync(repository).isSymbolicLink()) { - return null; - } - if (automatic) { - if (request.kind === "read" && request.intention === `Search in directory: ${request.path}`) { - const requestedPath = typeof request.path === "string" ? resolve(directory, request.path) : null; - const calls = [...liveTools.values()].filter((call) => - Date.now() - call.started < 60000 && call.toolName === "rg" && - (typeof call.arguments?.paths === "string" || - (Array.isArray(call.arguments?.paths) && call.arguments.paths.length === 1 && - typeof call.arguments.paths[0] === "string")) && - resolve(directory, typeof call.arguments.paths === "string" - ? call.arguments.paths : call.arguments.paths[0]) === requestedPath); - if (calls.length !== 1 || (request.toolCallId && request.toolCallId !== calls[0].toolCallId)) { - return null; - } - const args = calls[0].arguments; - const allowed = ["paths", "pattern", "glob", "output_mode", "head_limit", "-n", "-i", "-A", "-B", "-C"]; - const path = requestedPath === directory ? directory : contained(requestedPath, directory); - if (!path || protectedPath(path) || !lstatSync(path).isDirectory() || - (request.resolvedPath && request.resolvedPath !== path) || - Object.keys(args).some((key) => !allowed.includes(key)) || - !safeRuntimeText(args.pattern, 256) || !args.pattern.trim() || - typeof args.glob !== "string" || args.glob.length > 120 || - !/^[A-Za-z0-9_.*?{},/\\-]+$/.test(args.glob) || args.glob.startsWith("!") || - args.glob.includes("..") || - !/^[A-Za-z0-9_*?/-]*\.(?:[cm]?[jt]sx?|cs|md|css|html|\{(?:[cm]?[jt]sx?|cs|md|css|html)(?:,(?:[cm]?[jt]sx?|cs|md|css|html))+\})$/.test(args.glob) || - !["content", "files_with_matches", "count"].includes(args.output_mode) || - !Number.isInteger(args.head_limit) || args.head_limit < 1 || args.head_limit > 100 || - ["-A", "-B", "-C"].some((key) => args[key] !== undefined && - (!Number.isInteger(args[key]) || args[key] < 0 || args[key] > 3)) || - ["-n", "-i"].some((key) => args[key] !== undefined && typeof args[key] !== "boolean")) { - return null; - } - // Inspect the actual bounded filename selection, never infer safety from the intention alone. - const selection = spawnSync("rg", ["--files", "--glob", args.glob, path], { - encoding: "utf8", shell: false, windowsHide: true, timeout: 1000, maxBuffer: 128 * 1024 - }); - if (selection.error || ![0, 1].includes(selection.status)) { return null; } - const files = selection.stdout.split(/\r?\n/).filter(Boolean); - if (files.length > 500 || files.some((file) => - !candidate({ kind: "read", path: file }, { canApprove: true }))) { - return null; - } - return { scope: "routine-read-only", key: "bounded live native source search" }; - } - if (request.kind === "read" && typeof request.path === "string" && - request.intention === `Search for files matching pattern in: ${request.path}`) { - // The native glob tool's fixed intention identifies filename-only discovery, not content search. - const path = request.path === directory ? directory : contained(request.path, directory); - if (path && !protectedPath(path) && lstatSync(path).isDirectory() && - !relative(directory, path).split(sep).some(excludedPart) && - (!request.resolvedPath || request.resolvedPath === path)) { - return { scope: "routine-read-only", key: "repository filename discovery (no file contents)" }; - } - } - if (request.kind === "shell" && typeof request.fullCommandText === "string" && - !/[\r\n`$;&|<>()]/.test(request.fullCommandText) && - Array.isArray(request.possibleUrls) && request.possibleUrls.length === 0 && - Array.isArray(request.possiblePaths)) { - const match = /^node (?:'([^']+)'|"([^"]+)"|([^\s'"]+)) --help$/.exec(request.fullCommandText); - const path = match && (match[1] ?? match[2] ?? match[3]); - if (trustedHelper(path) && - request.possiblePaths.every((reported) => reported === helperPath) && - (!request.resolvedPaths?.[path] || request.resolvedPaths[path] === helperPath)) { - return { scope: "routine-read-only", key: "installed FixLab CLI --help" }; - } - } - return null; - } - if (["read", "write"].includes(request.kind)) { - const writing = request.kind === "write"; - const requestedPath = writing ? request.fileName : request.path; - let file = contained(requestedPath, directory, writing || Boolean(fileAccess)); - let evidenceRoot; - if (!writing && fileAccess?.jobId && typeof requestedPath === "string" && - isAbsolute(requestedPath) && !file) { - lstatSync(join(directory, ".git")); - evidenceRoot = resolveEvidenceRoot(directory); - if (realpathSync.native(evidenceRoot) !== evidenceRoot || protectedPath(evidenceRoot)) {return null;} - file = contained(requestedPath, evidenceRoot); - } - if (!file || protectedPath(file) || - (request.resolvedPath && request.resolvedPath !== file)) { - return null; - } - let stat; - try { - stat = lstatSync(file); - } catch (error) { - if ((!writing && !fileAccess) || error.code !== "ENOENT") {throw error;} - } - if (stat && (!stat.isFile() || stat.nlink !== 1)) {return null;} - const parts = relative(directory, file).split(sep); - const evidenceParts = evidenceRoot ? relative(evidenceRoot, file).split(sep) : - parts.slice(0, 3).join("/") === ".git/fixlab/command-evidence" ? parts.slice(3) : []; - if (!writing && stat && fileAccess?.jobId && evidenceParts.length === 2 && - /^\d{8}T\d{6}-[a-f0-9-]{36}$/i.test(evidenceParts[0]) && - ["stdout.log", "stderr.log"].includes(evidenceParts[1])) { - const ownerPath = contained(join(dirname(file), "job.json"), evidenceRoot ?? directory); - if (!ownerPath || protectedPath(ownerPath)) {return null;} - const ownerStat = lstatSync(ownerPath); - if (!ownerStat.isFile() || ownerStat.nlink !== 1 || ownerStat.size > 2048 || - stat.size > 1024 * 1024) {return null;} - let owner; - try { - owner = JSON.parse(readFileSync(ownerPath, "utf8")); - } catch (error) { - if (error instanceof SyntaxError) {return null;} - throw error; - } - return owner?.jobId === fileAccess.jobId && owner.repository === directory && - typeof owner.gateId === "string" && owner.gateId.length > 0 && - safeRuntimeText(readFileSync(file, "utf8").replace(/\u001b\[[0-9;]*m/g, ""), 1024 * 1024) - ? { scope: "repository-reads", key: directory } : null; - } - // An auth marker in test source is not an authentication directory or saved state. - const sourceName = runtimeSourceName(basename(file)); - if (parts.slice(0, -1).some(excludedPart) || excludedPart(sourceName)) { - return null; - } - if (file === join(directory, ".github", "fixlab", "repository-profile.json")) { - return !writing && safeProfile(file) ? { scope: "repository-reads", key: directory } : null; - } - if (writing) { - if (!sourceExtension.test(extname(file)) || parts[0] === ".github" || - /^\.(?:csproj|sln|props|targets|sh|ps1)$/i.test(extname(file)) || - /^(?:AGENTS|CLAUDE|GEMINI)\.md$/i.test(basename(file)) || - /(?:^|[._-])(?:config|policy|settings)(?:[._-]|$)/i.test(basename(file))) { - return null; - } - return { scope: "repository-source-write", key: directory }; - } - if (!sourceExtension.test(extname(file)) && basename(file) !== "package.json" && - !/^tsconfig(?:\.[\w-]+)?\.json$/.test(basename(file))) { - return null; - } - return { scope: "repository-reads", key: directory }; - } - if (request.kind === "shell" && fileAccess?.allowValidationGates === true && - typeof request.fullCommandText === "string" && !/[\r\n`$;&|<>()]/.test(request.fullCommandText) && - Array.isArray(request.possiblePaths) && Array.isArray(request.possibleUrls) && - request.possibleUrls.length === 0) { - const tokens = request.fullCommandText.match(/'[^']*'|"[^"]*"|[^\s'"]+/g); - if (tokens?.length === 11 && tokens.join(" ") === request.fullCommandText) { - const args = tokens.map((token) => /^['"]/.test(token) ? token.slice(1, -1) : token); - const options = new Map(); - for (let index = 3; index < args.length; index += 2) { - options.set(args[index], args[index + 1]); - } - if (args[0] === "node" && trustedHelper(args[1]) && args[2] === "gate" && - options.size === 4 && [...options.keys()].every((key) => - ["--port", "--job", "--gate", "--repository"].includes(key)) && - /^\d{1,5}$/.test(options.get("--port")) && Number(options.get("--port")) === dashboardPort && - options.get("--job") === fileAccess.jobId && options.get("--repository") === directory && - /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(options.get("--gate")) && - request.possiblePaths.every((path) => [directory, helperPath].includes(path) && - (!request.resolvedPaths?.[path] || request.resolvedPaths[path] === path))) { - return { scope: "reviewed-validation-gate", key: directory }; - } - } - } - if (request.kind !== "shell" || request.canOfferSessionApproval !== true || - !testCommand.test(request.fullCommandText) || - /[A-Za-z]:[\\/]|(?:^|[\s=])(?:[/\\]|\.\.(?:[/\\]|\s|$))|[/\\]\.\.(?:[/\\]|$)/.test(request.fullCommandText) || - !Array.isArray(request.possiblePaths) || !Array.isArray(request.possibleUrls) || - request.possibleUrls.length > 0 || typeof request.resolvedWorkingDirectory !== "string") { - return null; - } - const cwd = request.resolvedWorkingDirectory === directory - ? directory : contained(request.resolvedWorkingDirectory, directory); - if (!cwd || !lstatSync(cwd).isDirectory() || - relative(directory, cwd).split(sep).some((part) => part.startsWith(".") || sensitiveName.test(part)) || - request.possiblePaths.some((path) => { - const file = typeof path === "string" ? contained(resolve(cwd, path), directory) : null; - return !file || protectedPath(file) || (request.resolvedPaths?.[path] && request.resolvedPaths[path] !== file) || - relative(directory, file).split(sep).some((part) => part.startsWith(".") || sensitiveName.test(part)); - })) { - return null; - } - const manifests = []; - for (const path of new Set([directory, cwd])) { - try { - const manifest = contained(join(path, "package.json"), directory); - if (!manifest || protectedPath(manifest) || !lstatSync(manifest).isFile() || lstatSync(manifest).size > 256 * 1024) { - return null; - } - manifests.push(createHash("sha256").update(readFileSync(manifest)).digest("hex")); - } catch (error) { - if (error.code !== "ENOENT") { - throw error; - } - manifests.push("absent"); - } - } - return { - scope: "exact-test-command", - key: JSON.stringify([directory, cwd, request.fullCommandText, - request.possiblePaths, request.possibleUrls, request.commandSegments, manifests]) - }; - } catch (error) { - if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "EINVAL", "ELOOP"].includes(error.code)) { - return null; - } - throw error; - } - } - - return { - observeToolEvent(event) { - if (["session.idle", "session.error", "session.shutdown"].includes(event?.type)) { - liveTools.clear(); - return; - } - const id = event?.data?.toolCallId; - if (typeof id !== "string" || id.length > 200) { return; } - if (event.type === "tool.execution_start") { - if (liveTools.size >= 8) { liveTools.clear(); } - liveTools.set(id, { ...event.data, started: Date.now() }); - } else if (event.type === "tool.execution_complete") { - liveTools.delete(id); - } - }, - clear() { - liveTools.clear(); - reads = null; - commands.clear(); - }, - automaticApproval(request, prompt) { - return candidate(request, prompt, true)?.key ?? null; - }, - review(request, prompt) { - const match = candidate(request, prompt); - return ["repository-reads", "exact-test-command"].includes(match?.scope) ? match : null; - }, - scopeFor(request, prompt) { - const match = candidate(request, prompt); - return ["repository-reads", "exact-test-command"].includes(match?.scope) ? match.scope : null; - }, - isSourceFile(path) { - return candidate({ kind: "write", fileName: path }, { canApprove: true })?.scope === "repository-source-write"; - }, - allows(request, prompt) { - const match = candidate(request, prompt); - return Boolean(match && (match.scope === "repository-reads" - ? reads === match.key || (fileAccess?.repository === match.key && - ["source-read", "source-read-write"].includes(fileAccess.mode)) - : match.scope === "repository-source-write" - ? fileAccess?.repository === match.key && fileAccess.mode === "source-read-write" - : match.scope === "reviewed-validation-gate" - ? fileAccess?.repository === match.key && fileAccess.allowValidationGates === true - : commands.has(match.key))); - }, - grant(request, prompt, scope, reviewed) { - const match = candidate(request, prompt); - if (!match || !["repository-reads", "exact-test-command"].includes(scope) || - match.scope !== scope || reviewed?.scope !== scope || reviewed.key !== match.key || - (scope === "exact-test-command" && commands.size >= 16)) { - throw new Error("This request cannot receive the selected job-scoped grant. Review it again or allow once."); - } - if (scope === "repository-reads") { - reads = match.key; - } else { - commands.add(match.key); - } - } - }; -} diff --git a/dashboard/runtime-requests.js b/dashboard/runtime-requests.js index 1be69ac..cfb3770 100644 --- a/dashboard/runtime-requests.js +++ b/dashboard/runtime-requests.js @@ -11,22 +11,6 @@ export function safeRuntimeText(value, limit = 8192) { !secret.test(value) && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value); } -export function validateRuntimeUsage(value, previous = {}) { - const usage = {}; - for (const name of ["inputTokens", "cachedInputTokens", "cacheWriteTokens", "outputTokens"]) { - if (!Number.isSafeInteger(value?.[name]) || value[name] < (previous[name] ?? 0)) { - throw new Error("Copilot sent invalid or decreasing structured usage counters."); - } - usage[name] = value[name]; - } - if (usage.cachedInputTokens > usage.inputTokens) { - throw new Error("Copilot cached input cannot exceed total structured input."); - } - usage.cacheReusePercent = usage.inputTokens > 0 - ? usage.cachedInputTokens / usage.inputTokens * 100 : 0; - return usage; -} - export function runtimePermissionPrompt(request) { const text = (value) => typeof value === "string" && value.trim().length > 0; const supported = { @@ -59,17 +43,12 @@ export function runtimePermissionPrompt(request) { const sensitivePath = ["read", "write"].includes(request?.kind) && credentialPath.test(request.path ?? request.fileName ?? ""); const reviewable = supported && !sensitivePath && safeRuntimeText(details); - const canUseFileTrust = request?.kind === "write" && supported && !sensitivePath && - Buffer.byteLength(details, "utf8") <= MAX_RUNTIME_FRAME_BYTES && - safeRuntimeText(request.diff, MAX_RUNTIME_FRAME_BYTES) && - safeRuntimeText(details, MAX_RUNTIME_FRAME_BYTES); return { kind: "permission", title: "Copilot requests permission", details: reviewable ? details : "This request has unsupported, sensitive or oversized details. Approval is unavailable; deny it and inspect it in an interactive Copilot session.", canApprove: reviewable, - canUseFileTrust, choices: [], allowFreeform: false }; @@ -109,10 +88,7 @@ export function createRuntimeRequestBridge({ send, onChange }) { !Array.isArray(value.choices) || value.choices.length > 20 || value.choices.some((choice) => !safeRuntimeText(choice, 1000)) || typeof value.canApprove !== "boolean" || - typeof value.allowFreeform !== "boolean" || - (value.grantScope !== undefined && - (!value.canApprove || value.kind !== "permission" || - !["repository-reads", "exact-test-command", "publication-batch"].includes(value.grantScope)))) { + typeof value.allowFreeform !== "boolean") { throw new Error("Copilot sent an invalid or excessive runtime request."); } pending.set(value.id, { @@ -122,8 +98,7 @@ export function createRuntimeRequestBridge({ send, onChange }) { details: value.details, choices: [...value.choices], canApprove: value.canApprove, - allowFreeform: value.allowFreeform, - ...(value.grantScope ? { grantScope: value.grantScope } : {}) + allowFreeform: value.allowFreeform }); publish(); }, @@ -134,12 +109,6 @@ export function createRuntimeRequestBridge({ send, onChange }) { } const { action, choice, answer } = response; let result = { type: "response", id, action }; - if (response.scope !== undefined) { - if (action !== "allow" || !request.grantScope || response.scope !== request.grantScope) { - throw new Error("This job-scoped permission decision is not available."); - } - result.scope = response.scope; - } if (request.kind === "permission") { if (!["allow", "deny"].includes(action) || (action === "allow" && !request.canApprove)) { diff --git a/dashboard/server.js b/dashboard/server.js index 588e926..d1c6ec1 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -36,18 +36,14 @@ import { } from "./intake.js"; import { formatPreflightFailure } from "./preflight.js"; import { createSessionRegistry } from "./sessions.js"; -import { inspectBrowserProfileOwnership } from "./browser-ownership.js"; -import { canonicalExecutionRepository, createExecutorLease } from "./executor-lease.js"; +import { createExecutorLease } from "./executor-lease.js"; import { spawnWindowsExecutor } from "./windows-executor.js"; -import { createRuntimeRequestBridge, MAX_RUNTIME_FRAME_BYTES, validateRuntimeUsage } from "./runtime-requests.js"; -import { validateJobFileAccess } from "./runtime-permission-policy.js"; -import { configuredWorkflow, efficientWorkflowGuidance, COPILOT_EXECUTION_GUIDANCE, EXISTING_APPLICATION_GUIDANCE } from "./workflow-policy.js"; +import { createRuntimeRequestBridge, MAX_RUNTIME_FRAME_BYTES } from "./runtime-requests.js"; import { browserConfigurationErrors } from "./repository-setup.js"; -import { applyRepositorySettings, inspectRepositorySettings, localRepository, planRepositorySettings } from "./repository-settings.js"; -import { prepareSharedWorktree, reviewSharedWorktree, sharedWorktreeConfig, sharedWorktreeGuidance, stashReviewedSharedWorktree, verifySharedWorktreeBranch } from "./shared-worktree.js"; +import { prepareSharedWorktree, reviewSharedWorktree, sharedWorktreeConfig, sharedWorktreeGuidance, verifySharedWorktreeBranch } from "./shared-worktree.js"; import { compactControlResult, conversationOperations } from "./conversation-operations.js"; const windowsOwnedTrees = new WeakMap(); -import { ValidationSession, validationGates, selectedValidationGates } from "./validation-gates.js"; +import { ValidationSession, validationGates } from "./validation-gates.js"; import { repositoryState } from "../bin/structured-command.js"; import { newPermissionRequest, @@ -69,15 +65,6 @@ export const MAX_DASHBOARD_HISTORY_ENTRIES = 20; export const MAX_QUEUED_JOBS = 20; export const MAX_PLAYWRIGHT_ARTIFACTS = 20; -function sameExistingRepository(value, root) { - try { - return realpathSync.native(value) === root; - } catch (error) { - if (error.code === "ENOENT") { return false; } - throw error; - } -} - function validatedManualLocalhostUrl(value) { if (typeof value !== "string" || !value.trim()) { throw new Error("manual localhost URL is required"); @@ -199,7 +186,6 @@ const staticFiles = new Map([ ["/", "index.html"], ["/index.html", "index.html"], ["/app.js", "app.js"], - ["/repository-workspace.js", "repository-workspace.js"], ["/styles.css", "styles.css"] ]); const contentTypes = { @@ -453,12 +439,8 @@ function publicQueue(jobs) { targetEnvironment: job.targetEnvironment, sharedWorkspace: job.sharedWorkspace ?? null, workspaceReview: job.workspaceReview ?? null, - workspaceStash: job.workspaceStash ?? null, validationIntent: job.validationIntent ?? "full", - workflow: job.workflow ?? { mode: "strict" }, prestartFrontend: job.prestartFrontend ?? false, - inputTokenLimit: job.inputTokenLimit ?? 0, - fileAccess: job.fileAccess ?? null, recordPlaywrightVideo: job.recordPlaywrightVideo, manualLocalhostTest: job.manualLocalhostTest, manualLocalhostUrl: job.manualLocalhostUrl, @@ -530,11 +512,9 @@ function dashboardSnapshot(job) { branchNaming: job.branchNaming ?? null, sharedWorkspace: job.sharedWorkspace ?? null, workspaceReview: job.workspaceReview ?? null, - workspaceStash: job.workspaceStash ?? null, validationIntent: job.validationIntent ?? "full", validationBaseline: job.validationBaseline ?? null, validationHistory: job.validationSession?.executedGates() ?? job.validationHistory ?? null, - workflow: job.workflow ?? { mode: "strict" }, intakeSource: job.intakeSource, workItem: persistedWorkItem(job.workItem), workItems: job.workItems.map(persistedWorkItem), @@ -545,13 +525,6 @@ function dashboardSnapshot(job) { heldAt: job.heldAt ?? null, finishedAt: job.finishedAt, lastActivityAt: job.lastActivityAt, - usage: job.usage, - inputTokenLimit: job.inputTokenLimit ?? 0, - budgetExceeded: job.budgetExceeded === true, - fileAccess: job.fileAccess ?? null, - browserPreflightBlocked: job.browserPreflightBlocked === true, - browserSessionNotStarted: job.browserSessionNotStarted === true, - browserBlocker: job.browserBlocker ?? null, error: job.error ? safePersistedSummary(job.error) : null, permissionBlocked: Boolean(job.permissionRequest), permissionPreflight: job.permissionPreflight === true, @@ -577,24 +550,6 @@ function dashboardSnapshot(job) { }; } -function initializeJobValidation(job, repository) { - job.validationSession ??= new ValidationSession( - repository, - loadRepositoryProfile(repository), - job.mode, - { - validationIntent: job.validationIntent, - advisory: job.mode === "fix-and-validate" && job.workflow?.mode === "efficient", - baseCommit: job.validationBaseline ?? job.sharedWorkspace?.baseCommit ?? - (job.validationRecovered ? null : undefined), - history: job.validationHistory ?? (job.validationRecovered ? null : undefined) - } - ); - job.validationSession.validationIntent = - job.runAllUiScenarios || job.manualLocalhostTest ? "full" : job.validationIntent; - job.validationBaseline = job.validationSession.baseCommit; -} - function restoreDashboardJob(snapshot, repository) { const profile = loadRepositoryProfile(repository); let manualLocalhostUrl = ""; @@ -616,9 +571,6 @@ function restoreDashboardJob(snapshot, repository) { ? newPermissionRequest({ preflight: snapshot.permissionPreflightPending === true }) : permissionRequest(snapshot.stages), runtimeDirectoryGrants: [], - workflow: configuredWorkflow({ workflow: snapshot.workflow - ? { publication: "ready", ...snapshot.workflow } - : { mode: "strict" } }), model: snapshot.model ?? "", runtime: snapshot.runtime ?? (snapshot.model ? "copilot" : ""), manualLocalhostTest: @@ -642,7 +594,13 @@ function restoreDashboardJob(snapshot, repository) { artifactDirectory: null, inputs: [], pendingInputs: [], - usage: retainedUsage(snapshot.usage), + usage: { + inputTokens: 0, + cachedInputTokens: 0, + cacheWriteTokens: 0, + outputTokens: 0, + cacheReusePercent: 0 + }, activity: [], logs: [], droppedLogs: 0, @@ -670,7 +628,6 @@ function restoreDashboardJob(snapshot, repository) { } restored.initialPrompt = buildJobPrompt({ request: restored.request, - workflow: restored.workflow, mode: restored.mode, requestType: restored.requestType, pullRequestStrategy: restored.pullRequestStrategy, @@ -764,24 +721,6 @@ function getPullRequestReadiness(job) { ? "External-folder review is pending before executor launch. No permission has been granted." : PERMISSION_BLOCKED_MESSAGE }; } - if (job.workflow?.mode === "efficient" && job.mode === "fix-and-validate") { - const validation = job.validationSession?.summary(); - if (validation && !["passed", "advisory", "unconfigured"].includes(validation.status)) { - return { status: "blocked", ready: false, message: "Explicitly required local validation is pending, failed or stale." }; - } - if (job.manualLocalhostPending || job.manualLocalhostResult === "failed" || job.testEvidence?.modeViolation) { - return { status: "blocked", ready: false, message: "An explicitly requested manual check or data-safety requirement is unresolved." }; - } - if (job.stages?.pr?.status === "passed") { - return { status: "created", ready: false, message: `${job.stages.pr.message || "Pull request created or updated."} Optional/remote checks retain their actual status; ready for review does not mean fully validated.` }; - } - if (job.stages?.fix?.status !== "passed" || job.stages?.review?.status !== "passed" || - ["held", "holding"].includes(job.status)) { - return { status: "blocked", ready: false, message: "Waiting for the focused fix, regression checks and diff review." }; - } - return { status: job.stages?.pr?.status === "blocked" ? "approval-required" : "ready", - ready: true, message: "Focused checkpoint reviewed. The authorized PR can be ready for review before optional broad validation; pending checks are not passed." }; - } if (job.validationIntent === "scoped") { return { status: "blocked", ready: false, message: "Full regression is explicitly deferred during scoped iteration, not passed." }; } @@ -948,13 +887,11 @@ export function buildJobPrompt({ intakeSource = "manual", workItem = null, workItems = [], - screenshotPaths = [], - workflow = null + screenshotPaths = [] }) { const validateOnly = mode === "validate-only"; const playwrightOnly = mode === "playwright-only"; const readOnly = validateOnly || playwrightOnly; - const efficient = !readOnly && workflow?.mode === "efficient"; const smallEnhancement = requestType === "small-enhancement"; const intakeWorkItems = workItems.length > 0 ? workItems : workItem ? [workItem] : []; @@ -985,8 +922,6 @@ export function buildJobPrompt({ const bugResults = extractBugResults(request, intakeWorkItems); const pullRequestGuidance = readOnly ? "- Pull-request strategy is informational in read-only mode; do not create or update pull requests." - : efficient - ? `- Use ${pullRequestStrategy === "per-bug" ? "a separate branch and PR for each bug; never combine unrelated changes" : "one common PR for the reviewed combined change"}. Publish after the focused checkpoint, then run saved post-PR checks.` : pullRequestStrategy === "per-bug" ? `- Process each selected bug as an isolated delivery unit. Keep its code changes, focused validation, browser evidence, commit, and pull-request outcome separate from every other bug. - After one bug's scoped fix and smallest focused regression pass, remove transient validation artifacts, self-review its diff, and create or update its authorized draft PR before running remaining independent validation. Keep that PR draft until every required local gate for that bug reaches an accepted outcome, then publish it without waiting for remote CI. Never combine unrelated bug changes in one PR.` @@ -1041,56 +976,6 @@ ${process.platform === "win32" ? "- Windows executor children are job-contained : `- Generate a concise fix contract from the reported behavior and expected outcome. - Diagnose and reproduce with repository evidence. Do not claim a cause or reproduction without evidence. - Use the smallest focused reproduction that demonstrates the reported defect.`; - if (playwrightOnly) { - return `FixLab output protocol (follow before any tool call): -- First emit FIXLAB_STAGE|intake|running|Accepted the request and starting intake. -- Before each stage emit FIXLAB_STAGE|stage|running|next action; emit its terminal outcome before starting another stage. Never defer or batch stage markers until the end. -- Write markers as literal assistant response lines, never through shell, files, tool results or code blocks. -- Stages: ${FIXLAB_STAGES.join(", ")}. Terminal statuses: passed, skipped, blocked, failed. Emit every stage's terminal outcome before finishing; never report skipped checks as passed. -- Emit FIXLAB_ACTIVITY|stage|summary only for meaningful evidence or blockers, at most twice per stage unless a new blocker changes the plan. Do not expose secrets or private data. -- Emit FIXLAB_TEST|source|mutation-mode|scenario after selecting the scenario and whenever its data source or mutation behavior changes. Sources: ${[...testDataSources].join(", ")}. Mutation modes: ${[...mutationModes].join(", ")}. -- Expected bug IDs: ${bugResults.map((bug) => bug.id).join(", ") || "none"}. For each, emit FIXLAB_BUG|id|outcome|owner|summary before finishing. Outcomes: ${[...bugOutcomeStatuses].join(", ")}. Attribute upstream limitations honestly. - -Run a FixLab playwright-only job. -Playwright-only mode: browser validation, not implementation. -Request type: ${requestType} -Selected validation environment: ${targetEnvironment || "profile-defined"} -Record Playwright video evidence: ${recordPlaywrightVideo ? "yes" : "no"} -Open React localhost for manual validation: ${manualLocalhostTest ? "yes" : "no"} -Run all UI scenarios at end: ${runAllUiScenarios ? "yes" : "no"} -Configured test data source: ${testEvidence.source} -Configured mutation mode: ${testEvidence.mutationMode} -Test synthesis: ${testEvidence.enabled ? "enabled" : "disabled"} -Scenario evidence required: ${testEvidence.requireScenarioEvidence ? "yes" : "no"} -${intakeSummary.length ? `Loaded work items: ${JSON.stringify(intakeSummary)}\n` : ""}${screenshotPaths.length ? `User-provided screenshots: ${JSON.stringify(screenshotPaths)}. Inspect only these; do not download additional attachments.\n` : ""}${cacheSummary ? `Verified same-repository cache metadata: ${JSON.stringify(cacheSummary)}. Verify task-specific facts; this is not source evidence.\n` : ""} -Repository requirements: -- Read .github/fixlab/repository-profile.json and all applicable repository instructions; preserve their safety, authentication, environment and process-ownership requirements. -${COPILOT_EXECUTION_GUIDANCE} -${EXISTING_APPLICATION_GUIDANCE} -${requestGuidance} -- Skip source diagnosis, separate reproduction, implementation, diff review and unrelated non-browser gates. The diagnosis, reproduce, fix, review, and pr stages must be explicitly skipped unless their own required prerequisite fails. -- Do not edit files other than an authorized transient browser-validation artifact. Do not commit, push, create or update pull requests, or change repository configuration. -- Read the profile, existing browser configuration, fixture and nearest matching test in one bounded batch. Prefer an existing journey or retained same-job script. Inspect implementation files only to resolve a concrete missing selector or prerequisite. -- Generate one parameterized deterministic Playwright script for repeated rows and scenarios; execute it through the repository's runner, not one model/tool round-trip per row or field. -- Keep the same-job script and its compact scenario/result summary through retries and human waits. Reuse unchanged selectors and helpers; modify only the failing step. Do not delete artifacts while a test is running. Remove transient source only when validation is finished and no continuation needs it; permanent product test coverage needs separate authorization. -- Preserve forms and human-entered values. Never replay a stale snapshot or repeat successful mutations to recover context. Do not copy browser state or credentials; sign-in and MFA remain human-controlled. -- Do not reinstall dependencies that are already available. Run only minimum setup required by the repository profile; verify browser authentication without treating existing state files as proof of sign-in. -${uiScenarioGuidance} -${environmentGuidance} -${testDataGuidance} -- Profile safety rules remain authoritative. Read-only POST searches are not mutations merely because of their method. No Save, Submit, Approve, autosave or other external write is authorized by this validation request or its interception selector. -${videoGuidance} -${manualLocalhostGuidance} -- Save at least one non-sensitive screenshot under repository-owned test-results. Preserve exact assertions, request counts, errors and evidence paths. Do not fabricate execution or claim intercepted payloads prove backend writes. -- Run verbose commands through fixlab exec --stage live-test -- [args...]; quote '--' in Windows PowerShell. Keep compact results in context, inspecting only a bounded diagnostic on failure. Never reuse cached live-browser, authentication or external-data results. -- Continue independent safe browser steps when an unrelated check fails; do not bypass a failed prerequisite. Report required full regression as deferred, not passed. -- Do not delegate one continuous browser journey or repeatedly enumerate the repository. Keep command output bounded and batch independent reads. -- For genuine missing decisions, authentication or authorization, report the exact blocker. Native dashboard instructions below govern live questions; never infer approvals, waive policy or collect credentials. Otherwise block the affected stage and stop safely. -- Before finishing emit all required terminal markers, exact passed/failed assertions, skipped gates, blockers and remaining risks. Do not call task_complete before the protocol is complete. - -Request: -${request}`; - } return `FixLab output protocol (follow before any tool call): - Your first assistant output must be this literal plain-text line: FIXLAB_STAGE|intake|running|Accepted the request and starting intake. @@ -1126,8 +1011,6 @@ Use this concise metadata only to avoid repeated discovery. Verify task-specific Repository requirements: - Read .github/fixlab/repository-profile.json and all repository-owned instructions before acting. -${COPILOT_EXECUTION_GUIDANCE} -- For a fix request, begin with the smallest known reproduction, using an approved existing application when appropriate; prepare the local stack only when needed to exercise changed code. Do not claim a deployed baseline proves an unpublished fix. - Before validation in a new isolated worktree, plan \`fixlab onboard WORKTREE --from SOURCE --runtime RUNTIME\`. After scoped setup approval, add \`--yes\`. The source profile declares non-sensitive configuration in \`worktreeSetup.files\`. Never copy credentials or browser state. Missing configuration, authentication or denied runtime access is a blocker; do not retry unchanged approvals or grant blanket path access. Sign-in and MFA remain human-controlled. In read-only modes, do not provision or alter configuration; report missing prerequisites as blocked. - For repeat work, inspect the current git status and effective diff first, then search task-relevant symbols and files instead of rescanning the whole repository. - Do not perform a whole-repository rescan when the current diff, cached metadata, and focused symbol/path searches are sufficient. @@ -1142,9 +1025,8 @@ ${manualLocalhostGuidance} ${branchNamingGuidance} - ${readOnly ? "Do not edit files, create commits, push branches, create pull requests, or update pull requests." : "Make the smallest complete change that resolves the request. Make no code change when the evidence shows none is required."} - Autonomously complete the lifecycle without asking the user to direct routine engineering steps. -- Saved delivery workflow: ${efficient ? `efficient; focused checks before the ${workflow.publication === "ready" ? "ready-for-review" : "draft"} PR, broader checks only by saved preference or explicit selection` : "strict; all applicable configured gates remain required before readiness"}. This recorded job policy takes precedence over the product default. - Use fixlab_dashboard_control for user-authorized dashboard operations. Read status first and bind its instance and current job. Do not ask again for routine steps already authorized; ask only for missing decisions or required approvals. For "test this PR", verify the assigned checkout matches the PR source commit, then start playwright-only using the repository's Playwright configuration, selected environment and human-controlled authentication. Do not change code or use another checkout's evidence. Normal guards apply. -- For a connected host conversation, use fixlab_dashboard_status and fixlab_dashboard_reply to surface a recovery question using the host's question UI and submit only the user's explicit answer to the same instance and job. Never infer approval. Honor user-selected scoped iteration: full regression is explicitly deferred, not passed${efficient ? "; publication follows the saved efficient workflow and explicitly required gates" : ", and PR readiness stays blocked"}. Continue independent safe checks after unrelated failures without bypassing dependencies. Reviewed workspace reuse must preserve the current branch, uncommitted work and existing PR; a changed fingerprint requires a new review. Runtime permissions and human authentication remain separate approvals. +- For a connected host conversation, use fixlab_dashboard_status and fixlab_dashboard_reply to surface a recovery question using the host's question UI and submit only the user's explicit answer to the same instance and job. Never infer approval. Honor user-selected scoped iteration: full regression is explicitly deferred, not passed, and PR readiness stays blocked. Continue independent safe checks after unrelated failures without bypassing dependencies. Reviewed workspace reuse must preserve the current branch, uncommitted work and existing PR; a changed fingerprint requires a new review. Runtime permissions and human authentication remain separate approvals. - For known external-folder prerequisites, offer permissionPreflight before executor launch; it only opens review, never grants access. Use reviewed component path-to-gate policies for change-aware validation; unknown paths, full-validation paths, explicit full coverage, and previously executed gates retain their required checks. - Do not delete or edit transient test artifacts while validation is discovering or executing them; wait for owned validation to finish before cleanup. - ${playwrightOnly ? "Skip source diagnosis, separate reproduction, implementation, diff review, and non-browser validation. Mark diagnosis, reproduce, fix, and review skipped with the reason Playwright-only mode was selected." : "Inspect the affected surface, implement the smallest required code when edits are allowed, and self-review the effective diff for correctness, scope, and unrelated changes."} @@ -1158,11 +1040,11 @@ ${branchNamingGuidance} - Do not add a newly generated authenticated test such as \`*.auth.spec.ts\` to the product change unless the user explicitly requests permanent browser-test coverage or repository instructions require that exact persisted test. - Existing repository-owned Playwright tests may be changed only when the reported product behavior directly requires that regression update; do not broaden the pull request to repair unrelated or stale browser journeys. ${testDataGuidance} -${efficient ? efficientWorkflowGuidance(workflow) : `- Once the scoped production change and its smallest focused regression checks pass, remove transient validation artifacts and self-review the effective diff. When pull-request publication is already authorized, commit and push that reviewed checkpoint and create or update a draft pull request before remaining independent validation completes. +- Once the scoped production change and its smallest focused regression checks pass, remove transient validation artifacts and self-review the effective diff. When pull-request publication is already authorized, commit and push that reviewed checkpoint and create or update a draft pull request before remaining independent validation completes. - Start long-running independent validation commands in parallel when the runtime supports it. While those commands run, prepare or refresh the authorized draft PR description with the confirmed symptom, scoped change, checks already passed, checks still running, and known risks. - Push later reviewed checkpoints to the same branch only after their affected focused checks pass. Reuse the same draft PR and update its evidence instead of waiting to publish one large final update or creating duplicates. -- Keep the pull request draft and clearly marked validation-in-progress while any required local validation or approval gate is pending, running, blocked, skipped with unaccepted risk, or failed. Never mark it ready for review or report the pr stage passed until all required local gates pass or repository policy records an explicitly accepted outcome.`} -- ${readOnly ? "Do not publish a pull request or change its draft status in read-only mode." : workflow?.publication === "draft" ? "Keep the published PR draft and read back provider isDraft=true before reporting publication passed. Marking ready requires a separate reviewed decision. Strict required checks remain mandatory." : "After required local validation and approval gates pass or have an explicitly accepted outcome, publish the authorized PR by marking it ready for review. Do not wait for remote PR CI or build completion before publication; publication can trigger those checks. Verify the PR is no longer draft before reporting the PR stage passed. Report remote checks as pending, not passed, and leave merge policies intact. Use `gh pr ready ` for GitHub or `az repos pr update --id --draft false` for Azure DevOps, then read back `isDraft` from the provider to confirm it is false. If publication or state verification fails, report the PR stage blocked or failed, not passed."} +- Keep the pull request draft and clearly marked validation-in-progress while any required local validation or approval gate is pending, running, blocked, skipped with unaccepted risk, or failed. Never mark it ready for review or report the pr stage passed until all required local gates pass or repository policy records an explicitly accepted outcome. +- ${readOnly ? "Do not publish a pull request or change its draft status in read-only mode." : "After required local validation and approval gates pass or have an explicitly accepted outcome, publish the authorized PR by marking it ready for review. Do not wait for remote PR CI or build completion before publication; publication can trigger those checks. Verify the PR is no longer draft before reporting the PR stage passed. Report remote checks as pending, not passed, and leave merge policies intact. Use `gh pr ready ` for GitHub or `az repos pr update --id --draft false` for Azure DevOps, then read back `isDraft` from the provider to confirm it is false. If publication or state verification fails, report the PR stage blocked or failed, not passed."} - Prefix FixLab-created pull-request titles with FLAB: followed by a concise description of the coding change. Format the body with the repository's PR template, clear Markdown sections for the problem, changes, validation and risks, and accurate validation checkboxes. - Emit one browser evidence line after the scenario is selected and again if the actual source or mutation behavior changes: FIXLAB_TEST|source|mutation-mode|scenario @@ -1171,7 +1053,7 @@ ${efficient ? efficientWorkflowGuidance(workflow) : `- Once the scoped productio - Start only the applications defined by the repository profile, then execute the repository-defined live test against the allowed required system or environment from that profile. Do not invent or hardcode environment choices. - For every Playwright live test, save at least one non-sensitive screenshot under the test's repository-owned test-results directory so the dashboard can display the browser evidence. - ${playwrightOnly ? "Collect evidence for required setup, authentication readiness, application startup, the focused Playwright result, skipped gates, blockers, and remaining risks." : "Collect evidence for diagnosis or surface inspection, the effective diff, review, local validation, application startup, live testing, skipped gates, and remaining risks."} -- ${readOnly ? "Report the pull-request outcome without creating or updating a pull request." : efficient ? "Create or update the authorized PR with its saved draft policy after the reviewed focused checkpoint and explicitly required local checks; then run only saved or explicitly selected post-PR checks. Do not wait for remote CI." : "Create or update an authorized draft pull request after the reviewed focused checkpoint; finalize its evidence and publish it after required local validation and approval gates pass, without waiting for remote CI."} +- ${readOnly ? "Report the pull-request outcome without creating or updating a pull request." : "Create or update an authorized draft pull request after the reviewed focused checkpoint; finalize its evidence and publish it after required local validation and approval gates pass, without waiting for remote CI."} - Human interaction is limited to authentication, unsafe-data approval, deployment or pull-request approval, and genuine blockers that cannot be resolved from repository evidence. - When one of those human actions is required, emit FIXLAB_STAGE|stage|blocked|exact action needed, emit blocked outcomes for affected bugs when applicable, mark later stages skipped because of the blocker, and exit. The dashboard will collect user input and resume this same session. - Emit concise, user-visible analysis updates when evidence changes the diagnosis, validation status, blocker, or next action: @@ -1364,23 +1246,7 @@ function writeMetrics(path, records) { renameSync(temporaryPath, path); } -function retainedUsage(value) { - const counters = Object.fromEntries( - ["inputTokens", "cachedInputTokens", "cacheWriteTokens", "outputTokens"] - .map((key) => [key, value?.[key] ?? 0]) - ); - if (Object.values(counters).some((count) => !Number.isSafeInteger(count) || count < 0)) { - throw new Error("Saved dashboard token usage contains invalid counters."); - } - return { - ...counters, - cacheReusePercent: counters.inputTokens > 0 - ? Math.round(counters.cachedInputTokens / counters.inputTokens * 1000) / 10 - : 0 - }; -} - -function readDashboardHistory(path, metricsRecords) { +function readDashboardHistory(path) { if (!path || !existsSync(path)) { return { jobs: [], warning: null }; } @@ -1401,7 +1267,6 @@ function readDashboardHistory(path, metricsRecords) { .slice(0, MAX_DASHBOARD_HISTORY_ENTRIES) .map((job) => ({ ...job, - usage: retainedUsage(job.usage ?? metricsRecords.find((record) => record.id === job.id)), canResume: false, canComment: false, logs: [] @@ -1843,14 +1708,8 @@ function createExecutor({ command, packageRoot, buildInvocation, defaultModel = sessionId, resume, model, - mode, usage, - inputTokenLimit, - fileAccess, - dashboardPort, - protectedPaths, onOutput, - onUsage = () => {}, onRuntimeRequests = () => {} }) => { const executable = native ? process.execPath : findDirectExecutable(command); @@ -1883,7 +1742,7 @@ function createExecutor({ command, packageRoot, buildInvocation, defaultModel = invocation = { args: [join(packageRoot, "dashboard", "copilot-session.js")], input: JSON.stringify({ - type: "start", packageRoot, repository, prompt, sessionId, resume, usage, mode, inputTokenLimit, protectedPaths, fileAccess, dashboardPort, + type: "start", packageRoot, repository, prompt, sessionId, resume, usage, model: model ?? defaultModel, additionalDirs: [ ...linkedDependencyDirectories(repository), ...artifactDirectories, ...runtimeDirectories @@ -1903,21 +1762,8 @@ function createExecutor({ command, packageRoot, buildInvocation, defaultModel = }); let protocolError = null; - let terminationError = null; - let nativeUsage = retainedUsage(usage); let buffer = ""; - const stop = () => { - try { - if (containment) { - containment.terminate(); - } else { - stopProcessTree(child); - } - } catch (error) { - terminationError = error; - onOutput("stderr", `Owned executor termination is unverified; retaining its lease: ${error.message}\n`); - } - }; + const stop = () => containment ? containment.terminate() : stopProcessTree(child); const bridge = native ? createRuntimeRequestBridge({ send: (message) => child.stdin.write(`${JSON.stringify(message)}\n`), onChange: onRuntimeRequests @@ -1940,19 +1786,12 @@ function createExecutor({ command, packageRoot, buildInvocation, defaultModel = const message = JSON.parse(line); if (message.type === "request") { bridge.receive(message); - } else if (message.type === "usage") { - nativeUsage = validateRuntimeUsage(message.usage, nativeUsage); - onUsage(nativeUsage); } else if (message.type === "output" && typeof message.text === "string" && ["stdout", "stderr"].includes(message.stream)) { onOutput(message.stream, message.text); } else { - const error = new Error(message.type === "error" + throw new Error(message.type === "error" ? message.message : "Copilot sent an unsupported runtime message."); - if (message.type === "error" && message.code === "INPUT_TOKEN_LIMIT") { - error.code = message.code; - } - throw error; } } if (Buffer.byteLength(buffer) > MAX_RUNTIME_FRAME_BYTES) { @@ -1982,32 +1821,26 @@ function createExecutor({ command, packageRoot, buildInvocation, defaultModel = const completion = createProcessCompletion(child, onOutput).then(async (result) => { if (!containment && child.pid) { - stop(); + stopProcessTree(child); const started = Date.now(); - try { - while (ownedTreeRunning(child)) { - if (Date.now() - started >= 7000) { - throw new Error("Owned process group did not settle after force termination."); - } - if (Date.now() - started >= 5000) { + while (ownedTreeRunning(child)) { + if (Date.now() - started >= 5000) { + try { process.kill(-child.pid, "SIGKILL"); + } catch (error) { + if (error.code !== "ESRCH") { + throw error; + } } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } catch (error) { - if (error.code !== "ESRCH") { - terminationError = error; - onOutput("stderr", `Owned executor settlement is unverified; retaining its lease: ${error.message}\n`); } + await new Promise((resolve) => setTimeout(resolve, 25)); } } if (native && buffer.trim() && !protocolError) { protocolError = new Error("Copilot runtime exited with an incomplete protocol message."); } bridge?.cancel(false); - return protocolError || terminationError - ? { code: null, error: protocolError ?? terminationError } - : result; + return protocolError ? { code: null, error: protocolError } : result; }); return { @@ -2020,9 +1853,6 @@ function createExecutor({ command, packageRoot, buildInvocation, defaultModel = bridge.respond(id, decision); }, hasStopped() { - if (terminationError) { - return false; - } if (containment) { return containment.hasStopped(); } @@ -2046,7 +1876,11 @@ function createExecutor({ command, packageRoot, buildInvocation, defaultModel = }, terminate() { bridge?.cancel(); - stop(); + if (containment) { + containment.terminate(); + return; + } + stopProcessTree(child); } }; }; @@ -2143,9 +1977,7 @@ function publicJob(job) { targetEnvironment: job.targetEnvironment, sharedWorkspace: job.sharedWorkspace ?? null, workspaceReview: job.workspaceReview ?? null, - workspaceStash: job.workspaceStash ?? null, validationIntent: job.validationIntent ?? "full", - workflow: job.workflow ?? { mode: "strict" }, recordPlaywrightVideo: job.recordPlaywrightVideo, manualLocalhostTest: job.manualLocalhostTest, manualLocalhostUrl: job.manualLocalhostUrl, @@ -2168,16 +2000,11 @@ function publicJob(job) { lastActivityAt: job.lastActivityAt, error: job.error, permissionRequest: job.permissionRequest ?? null, - browserBlocker: job.browserBlocker ?? null, runtimeRequests: job.runtimeRequests ?? [], - fileAccess: job.fileAccess ?? null, - inputTokenLimit: job.inputTokenLimit ?? 0, - budgetExceeded: job.budgetExceeded === true, runtimeDirectoryGrants: (job.runtimeDirectoryGrants ?? []).map((scope) => scope.directory), canResume: !job.resumeDisabled && !job.executorStopping && - !job.budgetExceeded && !job.permissionRequest && ["blocked", "failed", "passed", "held"].includes(job.status), canReviewPermissions: @@ -2432,7 +2259,7 @@ function appendLine(job, stream, line) { message: line }); const usage = parseUsageLine(line); - if (usage && job.runtime !== "copilot") { + if (usage) { job.usage = usage; } if (runtimePermissionDenialLine(line)) { @@ -2644,14 +2471,13 @@ function finishJob(job, result) { job.stages["live-test"] = { status: "failed", message }; job.error = job.error ? `${job.error}; ${message}` : message; } - if (job.validationIntent === "scoped" && - !(job.mode === "fix-and-validate" && job.workflow?.mode === "efficient")) { + if (job.validationIntent === "scoped") { job.stages.review = { status: "blocked", message: "Full regression is explicitly deferred during scoped iteration, not passed." }; } if (job.validationSession) { try { const validation = job.validationSession.verify(); - if (!["passed", "unconfigured", ...(job.workflow?.mode === "efficient" ? ["advisory"] : [])].includes(validation.status)) { + if (validation.status !== "passed" && validation.status !== "unconfigured") { job.stages.review = { status: "blocked", message: "Required executor-backed validation is missing, failed, or stale." @@ -2705,15 +2531,6 @@ function finishJob(job, result) { ? "blocked" : "passed" : "failed"; - if (result?.error?.code === "INPUT_TOKEN_LIMIT") { - job.budgetExceeded = true; - job.status = "blocked"; - job.error = result.error.message; - const stage = FIXLAB_STAGES.find((name) => !terminalStatuses.has(job.stages[name].status)); - if (stage) { - job.stages[stage] = { status: "blocked", message: job.error }; - } - } job.finishedAt = new Date().toISOString(); writeCacheSummary(job.cacheContext, job); } @@ -2749,23 +2566,6 @@ function holdJob(job) { } function buildResumePrompt(job, { action, details }) { - if (job.mode === "playwright-only") { - return `${job.initialPrompt} - -Resume the existing FixLab dashboard session for job ${job.id}. -User action: ${action} -User input: -${details} - -Browser-only continuation: -- The original browser-only mode, environment, interception selection and data-safety rules above remain authoritative over generic retry wording. -- Reuse completed browser setup, exact user decisions, retained scripts and evidence. Do not repeat completed assertions or broad investigation. -- Do not perform implementation, review, commits, push, pull-request creation or publication. Report diagnosis, reproduce, fix, review and pr skipped because this is browser-only validation. -- A busy authentication profile is a prerequisite blocker. Do not retry unchanged browser launch, change profile or copy authentication state. Human confirmation alone is not evidence that the owning process exited. -- Keep the same-job test script while a browser prerequisite remains unresolved; do not delete and regenerate it during retries. -- If the prerequisite is still unavailable, report live-test blocked with the exact reason and stop. Never fabricate screenshots, assertions or a pass. -- Emit required FIXLAB_BUG and terminal FIXLAB_STAGE markers before finishing.`; - } const expectedBugs = job.bugs.map((bug) => bug.id).join(", "); const manualResult = job.manualLocalhostResult ? `Persisted manual React localhost result: ${job.manualLocalhostResult}. @@ -2817,7 +2617,7 @@ function requiresFreshRetry(job, action) { ); return ( action === "retry" && - (noRuntimeProgress || admissionOnly || job.browserSessionNotStarted === true) && + (noRuntimeProgress || admissionOnly) && job.usage.inputTokens === 0 && job.usage.outputTokens === 0 ); @@ -2883,7 +2683,6 @@ function playwrightAuthenticationConfig(repository) { ); return { command, - proofCommand: typeof authentication.proofCommand === "string" ? authentication.proofCommand.trim() : "", environment, statusPaths, workingDirectory @@ -3039,26 +2838,7 @@ async function frontendStatus(repository, connection) { }; } -function authenticationProofIdentity(configuration, job) { - return JSON.stringify({ - command: configuration.command, - proofCommand: configuration.proofCommand, - workingDirectory: configuration.workingDirectory, - environment: configuration.environment, - jobId: job?.id ?? null, - targetEnvironment: job?.targetEnvironment ?? null, - paths: configuration.statusPaths.map((path) => { - const file = resolveContainedPath(configuration.workingDirectory, path, "authentication status path"); - const info = lstatSync(file); - if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) { - throw new Error("Authentication status must be a regular, unlinked file."); - } - return [path, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs]; - }) - }); -} - -function playwrightAuthenticationStatus(repository, connection, job) { +function playwrightAuthenticationStatus(repository, connection) { let configuration; try { configuration = playwrightAuthenticationConfig(repository); @@ -3083,15 +2863,6 @@ function playwrightAuthenticationStatus(repository, connection, job) { ready: existsSync(path) }; }); - let proofMatches = false; - if (connection.proof && configuration.proofCommand && paths.every((entry) => entry.ready)) { - try { - proofMatches = connection.proof === authenticationProofIdentity(configuration, job); - } catch (error) { - return { configured: true, ready: false, running: Boolean(connection.handle), - error: error.message, lastResult: connection.lastResult, paths }; - } - } return { configured: Boolean( configuration.command && configuration.statusPaths.length > 0 @@ -3099,9 +2870,7 @@ function playwrightAuthenticationStatus(repository, connection, job) { ready: Boolean(configuration.command) && paths.length > 0 && - paths.every((entry) => entry.ready) && proofMatches, - statePresent: paths.length > 0 && paths.every((entry) => entry.ready), - proofConfigured: Boolean(configuration.proofCommand), + paths.every((entry) => entry.ready), running: Boolean(connection.handle), error: "", lastResult: connection.lastResult, @@ -3249,13 +3018,9 @@ export function createDashboardServer({ executor = createRuntimeExecutor({ packageRoot, runtime, model }), workItemLoader = createAzureDevOpsLoader(), browserOpener = openLocalUrl, - browserOwnership = inspectBrowserProfileOwnership, preflight = null, executionIdleTimeoutMs, - executionHeartbeatMs = DEFAULT_EXECUTION_HEARTBEAT_MS, - workspaceServers = new Map(), - ownsWorkspaceServers = true, - workspaceHostState = { closing: false } + executionHeartbeatMs = DEFAULT_EXECUTION_HEARTBEAT_MS }) { const resolvedRepository = resolve(repository); const defaultModel = validateCopilotModel(model, runtime); @@ -3277,19 +3042,6 @@ export function createDashboardServer({ const executorLease = createExecutorLease(resolvedRepository, sessions.id); let dashboardUrl = ""; let closing = false; - const setupPlans = new Map(); - - async function repositoryWorkspaceOwners(root) { - const common = canonicalExecutionRepository(root).repository; - const catalog = await sessions.repositories({ scopeIds: [ - createSessionRegistry(root).scopeId, - createSessionRegistry(hash(common)).scopeId - ] }); - if (catalog.unavailableCount) { - throw new Error("Cannot verify every registered owner of this repository. Reconnect to its owner and verify its resources are idle before changing settings or opening another workspace."); - } - return catalog.repositories.filter((entry) => sameExistingRepository(entry.repository, root)); - } async function launchFrontend(verifyConversation = () => {}) { const generation = frontendStartGeneration; @@ -3402,7 +3154,7 @@ export function createDashboardServer({ const loadedMetrics = readMetrics(metricsPath); let metricsRecords = loadedMetrics.records; let metricsWarning = loadedMetrics.warning; - const loadedDashboardHistory = readDashboardHistory(dashboardHistoryPath, metricsRecords); + const loadedDashboardHistory = readDashboardHistory(dashboardHistoryPath); let persistedJobs = loadedDashboardHistory.jobs.map((job) => recoverInterruptedDashboardJob(job) ); @@ -3518,15 +3270,11 @@ export function createDashboardServer({ const snapshot = { ...dashboardSnapshot(sourceJob), id: randomUUID(), - fileAccess: null, - runtimeDirectoryGrants: [], - workflow: configuredWorkflow(loadRepositoryProfile(resolvedRepository)), status, createdAt, startedAt: status === "running" ? createdAt : null, heldAt: null, sharedWorkspace: null, - workspaceReview: null, validationBaseline: null, validationHistory: { complete: true, gates: [] }, finishedAt: null, @@ -3580,14 +3328,6 @@ export function createDashboardServer({ } function startJob(job, prompt, resume = false) { - if (job.inputTokenLimit > 0 && job.usage.inputTokens >= job.inputTokenLimit) { - const error = new Error("Input-token ceiling already reached. Explicitly review and increase or remove this job's budget before resuming."); - error.code = "INPUT_TOKEN_LIMIT"; - throw error; - } - if (job.browserSessionNotStarted) { - resume = false; - } if (job.permissionPreflight && !job.permissionPreflightReviewed && !job.permissionRequest) { job.permissionRequest = newPermissionRequest({ preflight: true }); job.status = "blocked"; @@ -3616,37 +3356,6 @@ export function createDashboardServer({ throw new Error("Saved job runtime does not match this dashboard; reconnect using its originating runtime."); } validateCopilotModel(job.model, runtime); - const authentication = runtime === "copilot" ? playwrightAuthenticationConfig(resolvedRepository) : null; - const protectedPaths = authentication?.statusPaths.map((path) => - resolveContainedPath(authentication.workingDirectory, path, "browserAutomation.authentication.statusPaths entry") - ) ?? []; - if (job.mode === "playwright-only" && !job.manualLocalhostTest) { - let blocker; - try { - blocker = browserOwnership(playwrightAuthenticationConfig(resolvedRepository)); - } catch (error) { - blocker = { kind: "browser-preflight-error", message: error.message }; - } - if (blocker) { - job.browserBlocker = blocker; - job.browserPreflightBlocked = true; - job.browserSessionNotStarted = job.browserSessionNotStarted === true || !resume; - job.status = "blocked"; - job.error = blocker.message; - job.stages["live-test"] = { status: "blocked", message: blocker.message }; - for (const stage of ["diagnosis", "reproduce", "fix", "review", "pr"]) { - job.stages[stage] = { status: "skipped", message: "Not applicable to browser-only validation." }; - } - job.finishedAt = new Date().toISOString(); - pushActivity(job, { stage: "live-test", message: blocker.message, timestamp: job.finishedAt }); - recordJobMetric(job); - persistDashboardState(); - return; - } - job.browserBlocker = null; - job.browserPreflightBlocked = false; - job.browserSessionNotStarted = false; - } if (job.testEvidence.selectedMutationMode) { const effective = configuredTestSynthesis( loadRepositoryProfile(resolvedRepository), job.testEvidence.selectedMutationMode @@ -3671,9 +3380,7 @@ export function createDashboardServer({ if (closing) { throw new Error("the dashboard is shutting down"); } - if (job.workflow?.mode === "efficient" && job.mode === "fix-and-validate") { - prompt += "\nThe saved efficient delivery workflow applies to this resume too: publish the authorized reviewed focused checkpoint before optional broad unit tests. Required repository/organization checks remain mandatory. Follow the saved post-PR test/build preferences; do not ask for the same workflow selection again."; - } else if (job.validationIntent === "scoped") { + if (job.validationIntent === "scoped") { prompt += "\nUser-selected scoped iteration: run only tests covering changed behavior for now. Full regression is explicitly deferred, not passed. Keep required broad gates pending and PR readiness blocked. Continue independent safe checks after an unrelated failure; never bypass dependencies, authentication, runtime permissions or data-safety approvals."; } job.acceptsValidation = true; @@ -3766,7 +3473,20 @@ export function createDashboardServer({ }); } } - initializeJobValidation(job, resolvedRepository); + job.validationSession ??= new ValidationSession( + resolvedRepository, + loadRepositoryProfile(resolvedRepository), + job.mode, + { + validationIntent: job.validationIntent, + baseCommit: job.validationBaseline ?? job.sharedWorkspace?.baseCommit ?? + (job.validationRecovered ? null : undefined), + history: job.validationHistory ?? (job.validationRecovered ? null : undefined) + } + ); + job.validationSession.validationIntent = + job.runAllUiScenarios || job.manualLocalhostTest ? "full" : job.validationIntent; + job.validationBaseline = job.validationSession.baseCommit; const validation = job.validationSession.summary(); if (validation.status !== "unconfigured" && validation.gates.length > 0) { prompt += `\nDashboard-owned required validation: @@ -3774,10 +3494,9 @@ export function createDashboardServer({ - For separate isolation, onboard the worktree and start its dashboard before creating a gated job. - Run each gate using: node "${join(packageRoot, "bin", "fixlab.js")}" gate --port ${server.address().port} --job ${job.id} --gate --repository "${resolvedRepository}" -- Required gate IDs: ${validation.gates.filter((gate) => gate.required).map((gate) => gate.id).join(", ") || "none"}. -- Optional gate IDs: ${validation.gates.filter((gate) => !gate.required).map((gate) => gate.id).join(", ") || "none"}. Optional means runnable, not passed. +- Required gate IDs: ${validation.gates.map((gate) => gate.id).join(", ")}. - These operations return compact structured evidence. Do not run the same required commands separately. -- Validation intent: ${job.validationSession.validationIntent ?? "full"}. ${job.validationSession.advisory ? "Efficient delivery separates applicability from publication requirements. Broad unit tests and optional builds run only by saved preference or explicit selection. Explicit required: true gates still run before publication." : "Full runs every configured gate."} Affected uses only the reviewed profile's change-aware policy; the dashboard, not the agent, determines applicability from the complete Git diff. +- Validation intent: ${job.validationSession.validationIntent ?? "full"}. Full runs every configured gate. Affected uses only the reviewed profile's change-aware policy; the dashboard, not the agent, determines applicability from the complete Git diff. - Invoke the configured gates after changes. An excluded gate returns NOT APPLICABLE, never PASS. Do not run its command separately or claim that it passed. - Report API-scope validation separately from full validation. Recheck scope after edits; frontend, contract, shared or unknown paths require every gate. - A validation.changeAware component policy combines every gate set matching the changed paths. Frontend paths require their mapped browser/type-check/build gates; unclassified or fullValidationPaths require every gate. Do not guess exclusions or weaken repository-required checks. @@ -3785,7 +3504,7 @@ export function createDashboardServer({ - API-only scope does not require frontend startup or browser validation unless the user explicitly requests it or the profile declares applicable browser evidence. Record unnecessary stages skipped with the reason, not passed. Playwright-only mode, manual localhost checks and explicit all-UI requests retain their existing requirements. - Run required gates before reporting readiness. Stage markers cannot pass these gates. - Rerun affected gates after source edits. Matching job-local evidence is reused only when the profile opts in for deterministic local validation and inputs are unchanged. -- ${job.validationSession.advisory ? "Missing optional results never establish full validation. Only explicitly required local gates block the reviewed focused checkpoint from publication; keep every optional and CI result honest." : "Unconfigured or stale validation cannot establish pull-request readiness. Never mark a pull request ready outside these checks."}`; +- Unconfigured or stale validation cannot establish pull-request readiness. Never mark a pull request ready outside these checks.`; } } catch (error) { lease.release(); @@ -3878,14 +3597,6 @@ export function createDashboardServer({ resume, model: job.model, usage: job.usage, - inputTokenLimit: job.inputTokenLimit ?? 0, - fileAccess: validateJobFileAccess(job.fileAccess, resolvedRepository, job.id, job.mode), - dashboardPort: server.address().port, - protectedPaths, - mode: job.mode, - onUsage(usage) { - job.usage = usage; - }, onRuntimeRequests(requests) { job.runtimeRequests = requests; job.lastActivityAt = new Date().toISOString(); @@ -3951,9 +3662,6 @@ export function createDashboardServer({ const actualCompletion = Promise.resolve(handle.completion).then(async (result) => { job.acceptsValidation = false; await job.validationCompletion; - if (job.validationSession?.terminationUnverified) { - throw new Error("Validation termination is unverified; the repository lease remains reserved."); - } if (handle.hasStopped && !handle.hasStopped()) { throw new Error("Executor exit was observed but owned descendants may still be running; the repository lease remains reserved."); } @@ -4071,9 +3779,6 @@ export function createDashboardServer({ if (job.permissionRequest) { return; } - if (job.budgetExceeded) { - return; - } if (job.pendingInputs.length > 0) { const pendingInputs = job.pendingInputs.splice(0); const details = pendingInputs @@ -4103,7 +3808,7 @@ export function createDashboardServer({ async function startNextJob() { if ( - activeHandle || currentJob?.postValidation || + activeHandle || queuedJobs.length === 0 || (currentJob && currentJob.status !== "passed") ) { @@ -4159,12 +3864,6 @@ export function createDashboardServer({ question = { kind: "permission", message: job.permissionRequest.preflight ? "Pre-launch folder review is pending. Ask the user for the exact required external folder, then use permission-review and a separate explicit permission-approve bound to this job and request. Review alone grants nothing." : PERMISSION_BLOCKED_MESSAGE, actions: [] }; - } else if (job?.budgetExceeded) { - question = { - kind: "budget", - message: "The input-token ceiling was reached. Review and update this stopped job's budget in the dashboard, then explicitly resume. Comments and retries cannot increase the ceiling or reset consumed usage.", - actions: [] - }; } else if (job?.manualLocalhostPending) { question = { kind: "manual-test", message: "Report the actual manual localhost result.", actions: ["manual-pass", "manual-fail"] }; } else if (job?.workspaceReview) { @@ -4196,11 +3895,6 @@ export function createDashboardServer({ scope: job.permissionRequest.scope ? { directory: job.permissionRequest.scope.directory } : null } : null, validationIntent: job.validationIntent, - workflow: job.workflow ?? { mode: "strict" }, - inputTokenLimit: job.inputTokenLimit, - fileAccess: job.fileAccess ?? null, - budgetExceeded: job.budgetExceeded, - inputTokens: job.usage.inputTokens, stages: Object.fromEntries(Object.entries(job.stages).map(([stage, value]) => [ stage, { status: value.status, message: safePersistedSummary(value.message).slice(0, 500) } ])), @@ -4252,11 +3946,6 @@ export function createDashboardServer({ sendJson(response, 503, { error: "the dashboard is shutting down" }); return; } - if (currentJob?.postValidation && request.method === "POST" && - !["/api/job/checks", "/api/jobs"].includes(requestUrl.pathname)) { - sendJson(response, 409, { error: "This job owns optional validation. Complete/cancel those checks and wait for settlement before changing or resuming work." }); - return; - } if (request.method === "GET" && requestUrl.pathname === "/api/conversation") { try { @@ -4299,15 +3988,6 @@ export function createDashboardServer({ return; } - if (request.method === "GET" && requestUrl.pathname === "/api/repositories") { - try { - sendJson(response, 200, await sessions.repositories()); - } catch (error) { - sendJson(response, 500, { error: error.message }); - } - return; - } - if ( request.method === "POST" && ["/api/sessions/control", "/api/session/control"].includes(requestUrl.pathname) @@ -4463,53 +4143,6 @@ export function createDashboardServer({ return; } - if (request.method === "POST" && requestUrl.pathname === "/api/job/publication-context") { - try { - if (request.headers.host !== `${DASHBOARD_HOST}:${server.address().port}` || - request.headers.origin || !request.headers["content-type"]?.startsWith("application/json")) { - throw new Error("Publication review requires the owning local JSON client."); - } - const body = await readJsonBody(request); - const job = currentJob; - if (closing || !job || job.executorStopping || job.status !== "running" || - !job.acceptsValidation || body.jobId !== job.id || job.mode !== "fix-and-validate" || - typeof body.verify !== "boolean" || typeof body.repository !== "string" || - realpathSync(body.repository) !== realpathSync(resolvedRepository)) { - throw new Error("Publication review requires this running editing job and its exact assigned checkout."); - } - const profile = loadRepositoryProfile(resolvedRepository); - if (body.verify) { job.validationSession?.verify(); } - const branch = spawnSync("git", ["symbolic-ref", "--short", "HEAD"], { - cwd: resolvedRepository, encoding: "utf8", windowsHide: true, timeout: 2000 - }); - if (branch.error || branch.status !== 0) { - throw new Error("Publication review could not resolve the source branch."); - } - const source = branch.stdout.trim(); - const ready = getPullRequestReadiness({ ...job, status: "completed" }).ready; - const target = profile.pullRequests?.defaultTargetBranch; - const prefix = job.branchNaming?.prefix; - sendJson(response, 200, { - ready: ready && !job.manualLocalhostPending && job.manualLocalhostResult !== "failed" && - !job.testEvidence?.modeViolation && typeof target === "string" && target !== source && - typeof prefix === "string" && source.startsWith(`${prefix}/`), - source, target: target ?? null, publication: job.workflow.publication, - revision: createHash("sha256").update(JSON.stringify({ - instance: sessions.id, job: job.id, repository: realpathSync(resolvedRepository), - workflow: job.workflow, - stages: Object.fromEntries(FIXLAB_STAGES.filter((stage) => stage !== "pr") - .map((stage) => [stage, job.stages[stage]])), - validation: job.validationSession?.summary(), - manual: [job.manualLocalhostPending, job.manualLocalhostResult], - safety: job.testEvidence?.modeViolation - })).digest("hex") - }); - } catch (error) { - sendJson(response, 400, { error: error.message }); - } - return; - } - if (request.method === "POST" && requestUrl.pathname === "/api/validation/run") { try { const expectedHost = `${DASHBOARD_HOST}:${server.address().port}`; @@ -4543,8 +4176,7 @@ export function createDashboardServer({ const completion = job.validationSession.run( body.gateId, body.repository, - loadRepositoryProfile(resolvedRepository), - { jobId: job.id } + loadRepositoryProfile(resolvedRepository) ); job.validationCompletion = completion.catch(() => {}); job.refreshIdleWatchdog(); @@ -4569,79 +4201,6 @@ export function createDashboardServer({ return; } - if (request.method === "POST" && requestUrl.pathname === "/api/job/checks") { - try { - const expectedOrigin = `http://${DASHBOARD_HOST}:${server.address().port}`; - if (!request.verifyConversation && - (request.headers.host !== new URL(expectedOrigin).host || request.headers.origin !== expectedOrigin)) { - throw new Error("Check controls require this dashboard's exact loopback host and origin."); - } - if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) { - throw new Error("Content-Type must be application/json."); - } - const body = await readJsonBody(request); - const job = currentJob; - if (!job || closing || activeHandle || job.executorStopping || - !["passed", "failed", "blocked"].includes(job.status) || - body.instanceId !== sessions.id || body.jobId !== job.id || body.confirmed !== true || - body.repository !== realpathSync.native(resolvedRepository)) { - throw new Error("Check controls require the exact stopped job, instance and canonical checkout. Stop the executor first."); - } - if (body.action === "complete") { - if (job.postValidation) { - job.postValidation.cancelled = true; - job.validationSession.cancel(); - await job.validationCompletion; - } - if (job.postValidation || job.executorStopping || activeHandle || currentJob !== job) { - throw new Error("Owned work has not settled; the job and lease remain reserved."); - } - pushLog(job, { index: job.nextLogIndex, timestamp: new Date().toISOString(), stream: "dashboard", - message: "User completed this scoped job. Failed, deferred and unverified checks retain their actual outcomes; completion is not validation or merge approval." }); - archiveJob(job); - currentJob = null; - await startNextJob(); - persistDashboardState(); - sendJson(response, 200, { job: publicJob(currentJob), completed: true }); - return; - } - if (body.action !== "run" || job.postValidation || job.validationSession?.active) { - throw new Error("Select run or complete; overlapping checks are not allowed."); - } - initializeJobValidation(job, resolvedRepository); - const gates = selectedValidationGates(job.validationSession.gates, body.selection, body.value); - const lease = executorLease.acquire(job.id); - const operation = { cancelled: false }; - job.postValidation = operation; - const completion = (async () => { - try { - for (const gate of gates) { - if (operation.cancelled) {break;} - const result = await job.validationSession.run(gate.id, resolvedRepository, - loadRepositoryProfile(resolvedRepository), { jobId: job.id }); - pushLog(job, { index: job.nextLogIndex, timestamp: new Date().toISOString(), - stream: "dashboard", message: result.summary }); - recordJobMetric(job); - } - } finally { - if (job.validationSession.terminationUnverified) { - job.executorStopping = true; - throw new Error("Validation termination is unverified; the repository lease remains reserved."); - } - lease.release(); - job.postValidation = null; - recordJobMetric(job); - } - })(); - job.validationCompletion = completion; - await completion; - sendJson(response, 200, { job: publicJob(currentJob) }); - } catch (error) { - sendJson(response, 400, { error: error.message }); - } - return; - } - if (request.method === "GET" && requestUrl.pathname === "/api/metrics") { const period = requestUrl.searchParams.get("period") ?? "7d"; if (!["24h", "7d", "30d", "all"].includes(period)) { @@ -4657,103 +4216,6 @@ export function createDashboardServer({ return; } - if (request.method === "POST" && requestUrl.pathname === "/api/repository/configuration") { - const expectedOrigin = `http://${DASHBOARD_HOST}:${server.address().port}`; - if (request.headers.host !== new URL(expectedOrigin).host || request.headers.origin !== expectedOrigin) { - sendJson(response, 403, { error: "Repository setup requires the dashboard's exact loopback host and origin." }); - return; - } - try { - if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) { - throw new Error("Content-Type must be application/json."); - } - const body = await readJsonBody(request); - if (closing || workspaceHostState.closing || body.instanceId !== sessions.id) { - sendJson(response, 409, { error: "The dashboard changed. Reload before configuring a repository." }); - return; - } - for (const [id, plan] of setupPlans) { - if (Date.now() - plan.createdAt > 10 * 60 * 1000) { setupPlans.delete(id); } - } - if (body.action === "inspect") { - sendJson(response, 200, inspectRepositorySettings(body.repository ?? resolvedRepository)); - } else if (body.action === "preview") { - const plan = planRepositorySettings({ - repository: body.repository, revision: body.revision, values: body.values, - install: body.install === true, packageRoot, validate: validateLiveTestProfile - }); - for (const [id, previous] of setupPlans) { - if (previous.repository === plan.repository) { setupPlans.delete(id); } - } - if (setupPlans.size >= 8) { throw new Error("Too many open setup plans. Finish an existing review or wait for it to expire."); } - setupPlans.set(plan.id, plan); - sendJson(response, 200, { planId: plan.id, repository: plan.repository, files: plan.files, - values: plan.values, notice: "Only the listed FixLab configuration files will change. No commands, sign-in, installs or jobs will run. Prerequisites are checked separately." }); - } else if (body.action === "apply") { - const plan = setupPlans.get(body.planId); - if (!plan || body.confirmed !== true) { - throw new Error("An unexpired reviewed plan and explicit confirmation are required."); - } - for (const owner of await repositoryWorkspaceOwners(plan.repository)) { - const checked = await fetch(`${owner.url}/api/session`, { redirect: "error", signal: AbortSignal.timeout(1500) }); - if (!checked.ok) { throw new Error("Cannot verify the repository's resource owner before saving settings."); } - const state = await checked.json(); - if (state.id !== owner.id || state.repository !== owner.repository) { - throw new Error("Repository ownership changed. Review its settings again."); - } - if (state.job?.running || state.frontend?.running || state.authentication?.running) { - throw new Error("This repository has an active executor, frontend or sign-in session. Save settings after those resources are idle; nothing was stopped."); - } - } - if (closing || workspaceHostState.closing || setupPlans.get(body.planId) !== plan || Date.now() - plan.createdAt > 10 * 60 * 1000) { - throw new Error("The setup review changed or expired."); - } - if (sameExistingRepository(resolvedRepository, plan.repository) && - (activeHandle || activeTermination || frontendConnection.handle || - frontendConnection.starting || playwrightConnection.handle)) { - throw new Error("This repository has an active executor, frontend or sign-in session; nothing was stopped."); - } - setupPlans.delete(body.planId); - sendJson(response, 200, applyRepositorySettings(plan, sessions.id)); - } else if (body.action === "open") { - if (body.confirmed !== true) { throw new Error("Confirm opening this local repository workspace."); } - const root = localRepository(body.repository); - const settings = inspectRepositorySettings(root); - if (!settings.exists) { throw new Error("Apply the reviewed repository setup before opening its workspace."); } - const existing = (await repositoryWorkspaceOwners(root))[0]; - if (existing) { - sendJson(response, 200, { url: existing.url, repository: root, startedJob: false }); - } else { - if (closing || workspaceHostState.closing) { throw new Error("The dashboard is shutting down."); } - let workspace = workspaceServers.get(root); - if (!workspace) { - if (workspaceServers.size >= 8) { throw new Error("This host already owns eight additional repository workspaces."); } - const dashboard = createDashboardServer({ - repository: root, packageRoot, runtime, model, executor, preflight, browserOpener, browserOwnership, - workspaceServers, ownsWorkspaceServers: false, workspaceHostState - }); - workspace = { dashboard, ready: dashboard.listen({ port: 0 }) }; - workspaceServers.set(root, workspace); - } - try { - const address = await workspace.ready; - if (closing || workspaceHostState.closing) { throw new Error("The dashboard is shutting down."); } - sendJson(response, 200, { url: address.url, repository: root, startedJob: false }); - } catch (error) { - workspaceServers.delete(root); - await workspace.dashboard.close(); - throw error; - } - } - } else { - throw new Error("Unknown repository configuration action."); - } - } catch (error) { - sendJson(response, 400, { error: error.message }); - } - return; - } - if ( request.method === "GET" && requestUrl.pathname === "/api/onboarding" @@ -4975,9 +4437,7 @@ export function createDashboardServer({ timestamp: new Date().toISOString(), stream: "dashboard", message: body.action === "allow" - ? body.scope - ? "The user explicitly reviewed a job-scoped native permission. It expires when this executor ends; other scopes and managed human approvals remain manual." - : "The user approved one exact native runtime request. No future or blanket approval was granted." + ? "The user approved one exact native runtime request. No future or blanket approval was granted." : body.action === "deny" ? "The user denied the native runtime permission request. That operation was not authorized." : body.action === "cancel" @@ -4991,132 +4451,6 @@ export function createDashboardServer({ return; } - if (request.method === "POST" && requestUrl.pathname === "/api/job/workflow") { - const expectedOrigin = `http://${DASHBOARD_HOST}:${server.address().port}`; - if (request.headers.host !== new URL(expectedOrigin).host || request.headers.origin !== expectedOrigin) { - sendJson(response, 403, { error: "Workflow review requires the dashboard's exact loopback host and origin." }); - return; - } - try { - if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) { - throw new Error("Content-Type must be application/json."); - } - const body = await readJsonBody(request); - if (closing || !currentJob || activeHandle || currentJob.executorStopping || - !["blocked", "failed", "passed"].includes(currentJob.status) || - body.instanceId !== sessions.id || body.jobId !== currentJob.id) { - sendJson(response, 409, { error: "Workflow migration requires this exact stopped job." }); - return; - } - if (body.confirmed !== true || currentJob.mode !== "fix-and-validate" || - typeof body.repository !== "string" || !sameExistingRepository(body.repository, resolvedRepository) || - !["efficient", "strict"].includes(body.mode)) { - sendJson(response, 400, { error: "Explicitly confirm a supported workflow for this exact checkout and editing job." }); - return; - } - const profile = loadRepositoryProfile(resolvedRepository); - const workflow = configuredWorkflow({ workflow: { ...configuredWorkflow(profile), mode: body.mode } }); - if (profile.workflow?.mode === "strict" && workflow.mode !== "strict") { - throw new Error("The repository explicitly requires strict delivery. Review repository settings before changing that policy."); - } - currentJob.workflow = workflow; - if (currentJob.validationSession) { - currentJob.validationSession.advisory = workflow.mode === "efficient"; - } - currentJob.initialPrompt = buildJobPrompt({ ...currentJob, - screenshotPaths: currentJob.screenshots.map((file) => file.path) }); - pushLog(currentJob, { index: currentJob.nextLogIndex, timestamp: new Date().toISOString(), - stream: "dashboard", message: `The user reviewed ${workflow.mode} delivery for this existing job. Required repository checks and validation history are preserved. The job has not been resumed.` }); - recordJobMetric(currentJob); - sendJson(response, 200, { job: publicJob(currentJob) }); - } catch (error) { - sendJson(response, 400, { error: error.message }); - } - return; - } - - if (request.method === "POST" && requestUrl.pathname === "/api/job/file-access") { - const expectedOrigin = `http://${DASHBOARD_HOST}:${server.address().port}`; - if (request.headers.host !== new URL(expectedOrigin).host || request.headers.origin !== expectedOrigin) { - sendJson(response, 403, { error: "File trust requires the dashboard's exact loopback host and origin." }); - return; - } - try { - if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) { - throw new Error("Content-Type must be application/json."); - } - const body = await readJsonBody(request); - if (closing || !currentJob || activeHandle || currentJob.executorStopping || - !["blocked", "failed", "passed"].includes(currentJob.status) || - body.instanceId !== sessions.id || body.jobId !== currentJob.id) { - sendJson(response, 409, { error: "File trust review requires this exact stopped job. Refresh before changing it." }); - return; - } - if (runtime !== "copilot" || body.confirmed !== true || - !["manual", "source-read", "source-read-write"].includes(body.mode) || - (body.allowValidationGates !== undefined && typeof body.allowValidationGates !== "boolean") || - (body.mode === "manual" && body.allowValidationGates === true) || - typeof body.repository !== "string" || - !sameExistingRepository(body.repository, realpathSync.native(resolvedRepository))) { - sendJson(response, 400, { error: "Explicitly confirm a supported file mode for this exact canonical checkout." }); - return; - } - currentJob.fileAccess = validateJobFileAccess(body.mode === "manual" ? null : { - mode: body.mode, repository: realpathSync.native(resolvedRepository), jobId: currentJob.id, - allowValidationGates: body.allowValidationGates === true - }, resolvedRepository, currentJob.id, currentJob.mode); - pushLog(currentJob, { - index: currentJob.nextLogIndex, timestamp: new Date().toISOString(), stream: "dashboard", - message: `The user reviewed repository file access: ${body.mode}, for this job including resumes only. Sensitive and safety-configuration files, commands and managed human approvals remain separately controlled. The job has not been resumed.` - }); - recordJobMetric(currentJob); - sendJson(response, 200, { job: publicJob(currentJob) }); - } catch (error) { - sendJson(response, 400, { error: error.message }); - } - return; - } - - if (request.method === "POST" && requestUrl.pathname === "/api/job/budget") { - const expectedOrigin = `http://${DASHBOARD_HOST}:${server.address().port}`; - if (request.headers.host !== new URL(expectedOrigin).host || - request.headers.origin !== expectedOrigin) { - sendJson(response, 403, { error: "Budget changes require the dashboard's exact loopback host and origin." }); - return; - } - try { - if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) { - throw new Error("Content-Type must be application/json."); - } - const body = await readJsonBody(request); - if (closing || !currentJob || activeHandle || currentJob.executorStopping || - !["blocked", "failed", "passed"].includes(currentJob.status) || - body.instanceId !== sessions.id || body.jobId !== currentJob.id) { - sendJson(response, 409, { error: "Budget review requires this exact stopped job. Refresh before changing it." }); - return; - } - if (runtime !== "copilot" || body.confirmed !== true || - !Number.isSafeInteger(body.inputTokenLimit) || body.inputTokenLimit < 0 || - (body.inputTokenLimit > 0 && body.inputTokenLimit <= currentJob.usage.inputTokens)) { - sendJson(response, 400, { error: "Explicitly confirm a safe integer ceiling above input already used, or 0 for unlimited." }); - return; - } - currentJob.inputTokenLimit = body.inputTokenLimit; - currentJob.budgetExceeded = false; - pushLog(currentJob, { - index: currentJob.nextLogIndex, - timestamp: new Date().toISOString(), - stream: "dashboard", - message: `The user explicitly changed the input-token ceiling to ${body.inputTokenLimit || "unlimited"}. Usage is preserved; the job has not been resumed.` - }); - recordJobMetric(currentJob); - sendJson(response, 200, { job: publicJob(currentJob) }); - } catch (error) { - sendJson(response, 400, { error: error.message }); - } - return; - } - if (request.method === "POST" && requestUrl.pathname === "/api/job/permissions") { const expectedOrigin = `http://${DASHBOARD_HOST}:${server.address().port}`; if (request.headers.host !== new URL(expectedOrigin).host || @@ -5340,20 +4674,6 @@ export function createDashboardServer({ }); return; } - if (!running && currentJob.browserPreflightBlocked && !manualResult && action !== "skip") { - try { - const blocker = browserOwnership(playwrightAuthenticationConfig(resolvedRepository)); - if (blocker) { - currentJob.browserBlocker = blocker; - currentJob.error = blocker.message; - sendJson(response, 409, { error: blocker.message, job: publicJob(currentJob) }); - return; - } - } catch (error) { - sendJson(response, 409, { error: `Browser preflight remains blocked: ${error.message}` }); - return; - } - } const submittedDetails = typeof body.details === "string" ? body.details.trim() : ""; const defaultDetails = @@ -5790,44 +5110,6 @@ export function createDashboardServer({ }); return; } - let workspaceStash = null; - let restartedJob = null; - if (body.workspaceRecovery) { - if (body.confirmed !== true || body.instanceId !== sessions.id || - typeof body.repository !== "string" || resolve(body.repository) !== resolvedRepository || - currentJob?.id !== source.id || !source.workspaceReview || - source.mode !== "fix-and-validate" || activeHandle || source.executorStopping || - source.postValidation || source.permissionRequest || source.runtimeRequests?.length || - queuedJobs.length || frontendConnection.handle || frontendConnection.starting || - playwrightConnection.handle) { - sendJson(response, 409, { error: "Fresh workspace recovery requires this exact stopped job, no pending permissions or queued work, and stopped React/authentication sessions. Nothing was stashed." }); - return; - } - let recoveryLease; - try { - recoveryLease = executorLease.acquire(`workspace-recovery-${source.id}`); - workspaceStash = stashReviewedSharedWorktree({ - repository: resolvedRepository, profile: loadRepositoryProfile(resolvedRepository), - reviewedWorkspace: body.workspaceRecovery - }); - source.workspaceStash = workspaceStash; - pushLog(source, { - index: source.nextLogIndex, timestamp: new Date().toISOString(), stream: "dashboard", - message: `Fresh workspace recovery preserved ${workspaceStash.files.length} file(s) in stash ${workspaceStash.stashRef ?? "none"}; the old branch remains ${workspaceStash.previousBranch}.` - }); - persistDashboardState(); - restartedJob = createRestartedJob(source, "running"); - restartedJob.sharedWorkspace = prepareSharedWorktree({ - repository: resolvedRepository, profile: loadRepositoryProfile(resolvedRepository), - jobId: restartedJob.id - }); - } catch (error) { - sendJson(response, 409, { error: error.message }); - return; - } finally { - recoveryLease?.release(); - } - } if (currentJob?.id === source.id) { retireCurrentJob(); } @@ -5838,7 +5120,7 @@ export function createDashboardServer({ if (!shouldQueue) { retireCurrentJob(); } - restartedJob ??= createRestartedJob( + const restartedJob = createRestartedJob( source, shouldQueue ? "queued" : "running" ); @@ -5864,8 +5146,7 @@ export function createDashboardServer({ queued: shouldQueue, queue: publicQueue(queuedJobs), held: publicHeld(heldJobs), - history: dashboardHistory(), - workspaceStash + history: dashboardHistory() }); return; } @@ -5950,7 +5231,7 @@ export function createDashboardServer({ if (request.method === "POST" && requestUrl.pathname === "/api/jobs") { let shouldQueue = - Boolean(activeHandle || currentJob?.postValidation) || + Boolean(activeHandle) || ["running", "blocked", "failed"].includes(currentJob?.status) || queuedJobs.length > 0; if (shouldQueue && queuedJobs.length >= MAX_QUEUED_JOBS) { @@ -6005,12 +5286,6 @@ export function createDashboardServer({ sendJson(response, 400, { error: "permissionPreflight must be a boolean." }); return; } - const inputTokenLimit = body.inputTokenLimit ?? 0; - if (!Number.isSafeInteger(inputTokenLimit) || inputTokenLimit < 0 || - (inputTokenLimit > 0 && runtime !== "copilot")) { - sendJson(response, 400, { error: "inputTokenLimit must be a non-negative safe integer; positive ceilings require the Copilot runtime." }); - return; - } const requestType = body.requestType ?? "bug-fix"; if (!["bug-fix", "small-enhancement"].includes(requestType)) { sendJson(response, 400, { @@ -6052,15 +5327,8 @@ export function createDashboardServer({ return; } const repositoryProfile = loadRepositoryProfile(resolvedRepository); - let workflow; - try { - workflow = configuredWorkflow(repositoryProfile); - } catch (error) { - sendJson(response, 400, { error: error.message }); - return; - } const preflightIssues = preflight - ? preflight({ repository: resolvedRepository, profile: repositoryProfile, mode: body.mode }) + ? preflight({ repository: resolvedRepository, profile: repositoryProfile }) : []; if (preflightIssues.length > 0) { sendJson(response, 400, { @@ -6183,7 +5451,7 @@ export function createDashboardServer({ } shouldQueue = - Boolean(activeHandle || currentJob?.postValidation) || + Boolean(activeHandle) || ["running", "blocked", "failed"].includes(currentJob?.status) || queuedJobs.length > 0; if (shouldQueue && queuedJobs.length >= MAX_QUEUED_JOBS) { @@ -6200,14 +5468,6 @@ export function createDashboardServer({ archiveJob(currentJob); } const jobId = randomUUID(); - if (body.routineFileAccess !== undefined && typeof body.routineFileAccess !== "boolean") { - sendJson(response, 400, { error: "routineFileAccess must be an explicit boolean consent." }); - return; - } - const fileAccess = body.routineFileAccess === true ? validateJobFileAccess({ - mode: body.mode === "fix-and-validate" ? "source-read-write" : "source-read", - jobId, repository: realpathSync.native(resolvedRepository), allowValidationGates: true - }, resolvedRepository, jobId, body.mode) : null; let storedScreenshots; try { storedScreenshots = storeScreenshots({ @@ -6229,11 +5489,7 @@ export function createDashboardServer({ request: requestText, requestType, validationIntent, - workflow, permissionPreflight: body.permissionPreflight === true, - fileAccess, - inputTokenLimit, - budgetExceeded: false, model: selectedModel, runtime, pullRequestStrategy, @@ -6288,7 +5544,6 @@ export function createDashboardServer({ job.resumeSession = false; job.initialPrompt = buildJobPrompt({ request: requestText, - workflow, mode: body.mode, requestType, pullRequestStrategy, @@ -6342,8 +5597,7 @@ export function createDashboardServer({ 200, playwrightAuthenticationStatus( resolvedRepository, - playwrightConnection, - currentJob + playwrightConnection ) ); return; @@ -6473,16 +5727,6 @@ export function createDashboardServer({ }); return; } - try { - const blocker = browserOwnership(configuration); - if (blocker) { - sendJson(response, 409, { error: blocker.message, blocker }); - return; - } - } catch (error) { - sendJson(response, 409, { error: `Browser preflight failed: ${error.message}` }); - return; - } if (!existsSync(configuration.workingDirectory)) { sendJson(response, 400, { error: "configured Playwright working directory does not exist" @@ -6490,14 +5734,7 @@ export function createDashboardServer({ return; } playwrightConnection.lastResult = null; - playwrightConnection.proof = null; - const authenticationJob = currentJob; - const startAuthentication = (proving = false) => { - const proofIdentity = proving ? authenticationProofIdentity(configuration, authenticationJob) : null; - const child = spawnOwnedShell({ ...configuration, - environment: { ...configuration.environment, - FIXLAB_TARGET_ENVIRONMENT: authenticationJob?.targetEnvironment ?? "local" }, - command: proving ? configuration.proofCommand : configuration.command }); + const child = spawnOwnedShell(configuration); playwrightConnection.handle = child; child.once("fixlab-input-error", (error) => { playwrightConnection.lastResult = { @@ -6532,34 +5769,11 @@ export function createDashboardServer({ }; return; } - if (code === 0 && !proving && configuration.proofCommand) { - try { - startAuthentication(true); - } catch (error) { - playwrightConnection.handle = null; - playwrightConnection.lastResult = { ok: false, message: `Authentication proof could not start: ${error.message}`, - finishedAt: new Date().toISOString() }; - } - return; - } - let proofError = ""; - if (code === 0 && proving) { - try { - if (currentJob !== authenticationJob) {throw new Error("The authentication job changed; rerun proof for the current job.");} - if (proofIdentity !== authenticationProofIdentity(configuration, authenticationJob)) { - throw new Error("Authentication state changed during proof; rerun the owned browser check."); - } - playwrightConnection.proof = proofIdentity; - } catch (error) { - proofError = error.message; - } - } playwrightConnection.lastResult = { - ok: code === 0 && !proofError, + ok: code === 0, message: code === 0 - ? proofError || (proving ? "Repository-owned authenticated browser proof passed." : - "Authentication helper completed; sign-in remains unverified without a repository-owned proofCommand.") + ? "Playwright authentication completed." : `Playwright authentication exited with code ${code ?? "unknown"}${ signal ? ` (${signal})` : "" }.`, @@ -6567,13 +5781,10 @@ export function createDashboardServer({ }; playwrightConnection.handle = null; }); - }; - startAuthentication(); sendJson(response, 202, { ...playwrightAuthenticationStatus( resolvedRepository, - playwrightConnection, - currentJob + playwrightConnection ), started: true }); @@ -6660,7 +5871,7 @@ export function createDashboardServer({ return { server, async listen({ port = DEFAULT_DASHBOARD_PORT } = {}) { - const validatedPort = port === 0 ? 0 : validatePort(port); + const validatedPort = validatePort(port); await new Promise((resolveListen, reject) => { server.once("error", reject); server.listen(validatedPort, DASHBOARD_HOST, resolveListen); @@ -6691,22 +5902,7 @@ export function createDashboardServer({ failures.push(error); } }; - if (ownsWorkspaceServers) { - workspaceHostState.closing = true; - for (const workspace of workspaceServers.values()) { - await attempt(async () => { - try { await workspace.ready; } finally { await workspace.dashboard.close(); } - }); - } - workspaceServers.clear(); - } await attempt(() => persistDashboardState()); - await attempt(async () => { - if (currentJob?.postValidation) { - currentJob.postValidation.cancelled = true; - await currentJob.validationCompletion; - } - }); await attempt(async () => { const handle = activeHandle; const termination = activeTermination; diff --git a/dashboard/sessions.js b/dashboard/sessions.js index 877bc3b..fa79a42 100644 --- a/dashboard/sessions.js +++ b/dashboard/sessions.js @@ -8,7 +8,7 @@ import { writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { isAbsolute, join } from "node:path"; +import { join } from "node:path"; const ownerPattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/u; @@ -38,11 +38,11 @@ export function createSessionRegistry(repositoryIdentity) { const path = join(directory, `${id}.json`); let owner = null; - function readOwner(ownerId, ownerDirectory = directory) { + function readOwner(ownerId) { if (!ownerPattern.test(ownerId)) { throw new Error("invalid FixLab session owner ID"); } - const ownerPath = join(ownerDirectory, `${ownerId}.json`); + const ownerPath = join(directory, `${ownerId}.json`); const info = lstatSync(ownerPath); if (!info.isFile() || info.isSymbolicLink() || info.size > 2048) { throw new Error("invalid FixLab session ownership record"); @@ -50,7 +50,7 @@ export function createSessionRegistry(repositoryIdentity) { return validateOwner(JSON.parse(readFileSync(ownerPath, "utf8")), ownerId); } - async function inspectOwner(record, scopeId = key) { + async function inspectOwner(record) { const response = await fetch(`${record.url}/api/session`, { signal: AbortSignal.timeout(1500), redirect: "error" @@ -59,7 +59,7 @@ export function createSessionRegistry(repositoryIdentity) { throw new Error(`owner returned HTTP ${response.status}`); } const session = await response.json(); - if (session.id !== record.id || session.scopeId !== scopeId) { + if (session.id !== record.id || session.scopeId !== key) { throw new Error("the port no longer belongs to this repository's FixLab session"); } return { ...session, url: record.url }; @@ -98,45 +98,6 @@ export function createSessionRegistry(repositoryIdentity) { } })); }, - async repositories({ scopeIds } = {}) { - const root = join(tmpdir(), "fixlab-dashboard-sessions"); - const scopes = readdirSync(root, { withFileTypes: true }).filter((entry) => - entry.isDirectory() && !entry.isSymbolicLink() && /^[0-9a-f]{64}$/u.test(entry.name) - ); - if (scopes.length > 10000) { - throw new Error("too many retained repository session directories"); - } - const records = []; - for (const scope of scopes) { - if (scopeIds && !scopeIds.includes(scope.name)) { continue; } - const ownerDirectory = join(root, scope.name); - for (const name of readdirSync(ownerDirectory).filter((name) => ownerPattern.test(name.slice(0, -5)) && name.endsWith(".json"))) { - if (records.length >= 250) { - throw new Error("too many retained local dashboard records; repository discovery is bounded"); - } - records.push({ ownerDirectory, scopeId: scope.name, ownerId: name.slice(0, -5) }); - } - } - const results = await Promise.all(records.map(async ({ ownerDirectory, scopeId, ownerId }) => { - try { - const session = await inspectOwner(readOwner(ownerId, ownerDirectory), scopeId); - if (typeof session.repository !== "string" || !isAbsolute(session.repository) || - session.repository.length > 2048) { - throw new Error("invalid repository identity"); - } - return { - id: session.id, repository: session.repository, url: session.url, - status: session.job?.status ?? "idle" - }; - } catch { - return null; - } - })); - return { - repositories: results.filter(Boolean), - unavailableCount: results.filter((result) => result === null).length - }; - }, async control(ownerId, control) { const record = readOwner(ownerId); await inspectOwner(record); diff --git a/dashboard/shared-worktree.js b/dashboard/shared-worktree.js index bfcf3a9..2d4f1e2 100644 --- a/dashboard/shared-worktree.js +++ b/dashboard/shared-worktree.js @@ -1,23 +1,12 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; import { repositoryState } from "../bin/structured-command.js"; -import { createRuntimePermissionPolicy } from "./runtime-permission-policy.js"; -import { safeRuntimeText } from "./runtime-requests.js"; const jobIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; -function verifyIdleGit(repository) { - for (const name of ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "sequencer"]) { - const value = git(repository, ["rev-parse", "--git-path", name], "Git operation verification"); - if (existsSync(isAbsolute(value) ? value : resolve(repository, value))) { - throw new Error("Finish the in-progress Git operation before reusing the shared worktree"); - } - } -} - -function git(repository, args, description, raw = false) { +function git(repository, args, description) { const result = spawnSync("git", args, { cwd: repository, encoding: "utf8", @@ -32,7 +21,7 @@ function git(repository, args, description, raw = false) { "Resolve the Git prerequisite and retry; FixLab will not reset, stash, clean or force-switch this checkout." ); } - return raw ? result.stdout : result.stdout.trim(); + return result.stdout.trim(); } function branchRef(repository, branch, description) { @@ -90,68 +79,14 @@ export function reviewSharedWorktree(repository, profile) { return null; } const state = repositoryState(repository); - const files = git(repository, ["ls-files", "--others", "--exclude-standard", "-z"], "Untracked file review", true) - .split("\0").filter(Boolean); - const policy = createRuntimePermissionPolicy(repository); - let reason = git(repository, ["status", "--porcelain", "--untracked-files=no"], "Tracked change review") - ? "Preserve tracked changes separately before starting a fresh branch." - : files.length > 50 ? "Too many untracked files for automatic recovery; review them separately." : ""; - if (!reason && files.some((file) => { - const path = resolve(repository, file); - return !policy.isSourceFile(path) || - lstatSync(path).size > 1024 * 1024; - })) { - reason = "Untracked files include sensitive, linked, non-source or oversized files; preserve them separately."; - } return { branch, head: state.head, dirty: Boolean(git(repository, ["status", "--porcelain", "--untracked-files=all"], "Workspace review")), - fingerprint: createHash("sha256").update(JSON.stringify({ branch, config, state })).digest("hex"), - freshBranch: { - remote: config.remote, baseBranch: config.baseBranch, - files: files.slice(0, 50), available: !reason, reason - } + fingerprint: createHash("sha256").update(JSON.stringify({ branch, config, state })).digest("hex") }; } -export function stashReviewedSharedWorktree({ repository, profile, reviewedWorkspace }) { - verifyIdleGit(repository); - const review = reviewSharedWorktree(repository, profile); - if (!review || review.branch !== reviewedWorkspace?.branch || - review.fingerprint !== reviewedWorkspace?.fingerprint) { - throw new Error("The workspace changed since review. Review the branch and files again."); - } - if (!review.freshBranch.available) { - throw new Error(review.freshBranch.reason); - } - const { files, remote, baseBranch } = review.freshBranch; - for (const file of files) { - if (!safeRuntimeText(readFileSync(resolve(repository, file), "utf8"), 1024 * 1024)) { - throw new Error("A reviewed source file may contain sensitive content; automatic stashing is unavailable."); - } - } - let stashRef = null; - if (files.length) { - const previous = git(repository, ["stash", "list", "--max-count=1", "--format=%H"], "Existing stash review"); - git(repository, ["--literal-pathspecs", "stash", "push", "--include-untracked", - "--message", `FixLab fresh branch from ${review.branch}`, "--", ...files], "Stash reviewed source files"); - stashRef = git(repository, ["stash", "list", "--max-count=1", "--format=%H"], "Saved stash verification"); - if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(stashRef) || stashRef === previous) { - throw new Error("A new stash could not be verified. Stop and inspect the checkout; no reset or cleanup will be attempted."); - } - for (const file of files) { - git(repository, ["cat-file", "-e", `${stashRef}^3:${file}`], "Stashed file verification"); - } - } - const remaining = git(repository, ["status", "--porcelain", "--untracked-files=all"], "Clean checkout verification"); - if (remaining) { - const summary = safeRuntimeText(remaining, 8000) ? remaining : "Review local Git status for the remaining changes."; - throw new Error(`The checkout is not clean. Preserved stash: ${stashRef ?? "none"}. No reset or cleanup will be attempted.\n${summary}`); - } - return { stashRef, files, previousBranch: review.branch, remote, baseBranch }; -} - function recoveryRequired(repository, profile, message) { const error = new Error(message); error.workspaceReview = reviewSharedWorktree(repository, profile); @@ -169,7 +104,13 @@ export function prepareSharedWorktree({ repository, profile, jobId, saved = null if (!jobIdPattern.test(jobId)) { throw new Error("Shared worktree jobs require a valid job ID"); } - verifyIdleGit(repository); + const gitPath = (name) => { + const value = git(repository, ["rev-parse", "--git-path", name], "Git operation verification"); + return isAbsolute(value) ? value : resolve(repository, value); + }; + if (["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "sequencer"].some((name) => existsSync(gitPath(name)))) { + throw new Error("Finish the in-progress Git operation before reusing the shared worktree"); + } const currentBranch = git(repository, ["branch", "--show-current"], "Current branch verification"); const dirty = git(repository, ["status", "--porcelain", "--untracked-files=all"], "Shared worktree cleanliness check"); if (reviewedWorkspace) { diff --git a/dashboard/validation-gates.js b/dashboard/validation-gates.js index b289262..0aea682 100644 --- a/dashboard/validation-gates.js +++ b/dashboard/validation-gates.js @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { executeStructuredCommand, @@ -161,9 +161,6 @@ export function validationGates(profile, mode) { gate.args.some((argument) => typeof argument !== "string") || typeof gate.workingDirectory !== "string" || (gate.reuse !== undefined && typeof gate.reuse !== "boolean") || - (gate.required !== undefined && typeof gate.required !== "boolean") || - (gate.kind !== undefined && !["tests", "builds", "checks"].includes(gate.kind)) || - (gate.stage !== undefined && !["review", "local-stack", "live-test", "fix", "pr"].includes(gate.stage)) || !Number.isInteger(gate.timeoutSeconds) || gate.timeoutSeconds < 1 || gate.timeoutSeconds > 7200 @@ -177,10 +174,6 @@ export function validationGates(profile, mode) { args: [...gate.args], workingDirectory: gate.workingDirectory, reuse: gate.reuse === true && reusableValidationCommand(gate.command, gate.args), - required: gate.required === true, - kind: gate.kind ?? (/^(?:test|test:|--test)/.test(gate.args[0] === "run" ? gate.args[1] ?? "" : gate.args[0] ?? "") - ? "tests" : gate.args.includes("build") ? "builds" : "checks"), - stage: gate.stage ?? "review", timeoutSeconds: gate.timeoutSeconds }; }); @@ -188,26 +181,14 @@ export function validationGates(profile, mode) { return normalized; } -export function selectedValidationGates(gates, selection, value) { - if (!Array.isArray(gates) || !["all", "tests", "builds", "gate", "stage"].includes(selection) || - (["gate", "stage"].includes(selection) && typeof value !== "string")) { - throw new Error("Select configured all, tests, builds, gate or stage validation."); - } - const selected = gates.filter((gate) => selection === "all" || - (selection === "gate" ? gate.id === value : selection === "stage" ? gate.stage === value : gate.kind === selection)); - if (!selected.length) {throw new Error("No configured validation gates match this selection.");} - return selected; -} - export class ValidationSession { constructor(repository, profile, mode, { - validationIntent = "full", baseCommit, history = { complete: true, gates: [] }, advisory = false + validationIntent = "full", baseCommit, history = { complete: true, gates: [] } } = {}) { this.repository = realpathSync(repository); this.gates = validationGates(profile, mode); this.policy = mode === "playwright-only" ? null : validationScopePolicy(profile, this.gates); this.validationIntent = validationIntent; - this.advisory = advisory && profile.workflow?.mode !== "strict"; this.baseCommit = null; this.scopeWarning = ""; if (this.policy) { @@ -300,10 +281,6 @@ export class ValidationSession { }; } - blocking(gate, scope) { - return this.required(gate, scope) && (!this.advisory || gate.required); - } - summary() { // Refresh scope at validation checkpoints, not on every dashboard poll. const { scope, reason } = this.validationIntent === "affected" @@ -314,16 +291,14 @@ export class ValidationSession { status = "blocked"; } else if (this.gates === null) { status = "unconfigured"; - } else if (this.advisory && !this.gates.some((gate) => this.blocking(gate, scope))) { - status = "advisory"; } else if (this.gates.every((gate) => - !this.blocking(gate, scope) || this.results.get(gate.id)?.status === "passed" + !this.required(gate, scope) || this.results.get(gate.id)?.status === "passed" )) { status = "passed"; } else if (this.active) { status = "running"; - } else if (this.gates.some((gate) => this.blocking(gate, scope) && - ["failed", "cancelled", "stale"].includes(this.results.get(gate.id)?.status) + } else if ([...this.results.values()].some((result) => + ["failed", "cancelled", "stale"].includes(result.status) )) { status = "blocked"; } @@ -331,12 +306,8 @@ export class ValidationSession { status, scope, reason, - fullValidationPassed: !this.error && this.gates !== null && this.gates.every((gate) => - !this.required(gate, scope) || this.results.get(gate.id)?.status === "passed"), message: this.error ?? (this.gates === null ? "Configure validation.gates for executor-backed readiness." - : this.advisory - ? "Efficient delivery: only explicitly required local gates block publication. Optional checks retain their real pending, failed or stale status; ready for review does not mean fully validated." : scope === "api-only" ? `${status === "passed" ? "API-scope validation passed." : "API-scope validation."} ${reason}` : scope === "affected" @@ -344,9 +315,7 @@ export class ValidationSession { : `Required gates use dashboard-owned execution; agent markers are not evidence. ${reason}`), gates: (this.gates ?? []).map((gate) => ({ id: gate.id, - kind: gate.kind, - stage: gate.stage, - required: this.blocking(gate, scope), + required: this.required(gate, scope), ...Object.fromEntries(Object.entries( this.results.get(gate.id) ?? (this.required(gate, scope) ? { status: "pending" } @@ -385,9 +354,6 @@ export class ValidationSession { } async run(id, target, profile, io = {}) { - if (io.jobId !== undefined && (typeof io.jobId !== "string" || !/^[a-f0-9-]{36}$/i.test(io.jobId))) { - throw new Error("Validation evidence requires a valid owning job ID."); - } if (this.active) { throw new Error("A validation gate is already running for this job."); } @@ -446,17 +412,8 @@ export class ValidationSession { stdout: output, stderr: output, signal: controller.signal, - ownProcessTree: true, repositoryState: inputs, - onResult: (result) => { - evidence = result; - if (result.terminationVerified === false) {this.terminationUnverified = true;} - if (io.jobId !== undefined) { - writeFileSync(join(result.evidenceDirectory, "job.json"), JSON.stringify({ - jobId: io.jobId, repository: realpathSync.native(repository), gateId: id - }), { mode: 0o600, flag: "wx" }); - } - } + onResult: (result) => { evidence = result; } }); const after = JSON.stringify(repositoryState(repository)); const gateStatus = controller.signal.aborted diff --git a/dashboard/windows-executor.js b/dashboard/windows-executor.js index 25c0485..c7b848a 100644 --- a/dashboard/windows-executor.js +++ b/dashboard/windows-executor.js @@ -1,4 +1,4 @@ -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { lstatSync, @@ -9,7 +9,7 @@ import { writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { isAbsolute, join } from "node:path"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; export function quoteWindowsArgument(value) { @@ -26,18 +26,6 @@ export function spawnWindowsExecutor({ environment = {}, retainDescendants = false }) { - if (!isAbsolute(executable)) { - const lookup = spawnSync("where.exe", [executable], { - encoding: "utf8", shell: false, windowsHide: true - }); - const resolved = lookup.status === 0 - ? lookup.stdout.split(/\r?\n/).find((path) => isAbsolute(path) && /\.exe$/i.test(path)) - : null; - if (!resolved) { - throw new Error("Owned Windows execution requires an available direct executable."); - } - executable = resolved; - } const directory = mkdtempSync(join(tmpdir(), "fixlab-windows-executor-")); const stopPath = join(directory, "stop"); const proofPath = join(directory, "quiescent.json"); diff --git a/dashboard/workflow-policy.js b/dashboard/workflow-policy.js deleted file mode 100644 index 8bd3a2a..0000000 --- a/dashboard/workflow-policy.js +++ /dev/null @@ -1,36 +0,0 @@ -export function configuredWorkflow(profile = {}) { - const value = profile.workflow ?? {}; - if (!value || typeof value !== "object" || Array.isArray(value) || - (value.mode !== undefined && !["efficient", "strict"].includes(value.mode)) || - (value.publication !== undefined && !["draft", "ready"].includes(value.publication)) || - ["unitTestsAfterPr", "optionalBuilds"].some((key) => - value[key] !== undefined && typeof value[key] !== "boolean")) { - throw new Error("workflow must declare efficient or strict mode, draft or ready publication, and boolean unitTestsAfterPr/optionalBuilds preferences."); - } - return { - mode: value.mode ?? "efficient", - unitTestsAfterPr: value.unitTestsAfterPr ?? false, - publication: value.publication ?? (value.mode === "strict" ? "ready" : "draft"), - optionalBuilds: value.optionalBuilds ?? false - }; -} - -export const COPILOT_EXECUTION_GUIDANCE = - "Use normal Copilot reasoning and native tools to choose the shortest evidence-backed investigation, reproduction and fix. Stage markers report work; they are not a recipe requiring extra commands. Start with the known symptom and nearest relevant test, reuse verified context, and adapt to evidence instead of rediscovering the repository. Do not ask the user to direct routine authorized engineering steps. Required instructions, permissions, human authentication and data-safety controls remain authoritative."; - -export const EXISTING_APPLICATION_GUIDANCE = - "Existing-application fast path: for explicit browser-only reproduction or deployed-result checks, use playwright-only mode and first check the approved existing application for the selected environment. Resolve its target from repository profile/browser configuration, not an arbitrary report URL. Reuse a healthy permitted application and an authorized validation browser connection when the repository supports it; never copy browser state, take over an unrelated browser, bypass a profile lock or treat a state file as proof of sign-in. Do not clone a checkout, restore dependencies, start another local stack, or run broad unit/build gates merely to repeat a deployed UI scenario. Mark unnecessary local-stack work skipped, not passed. Start only prerequisites actually required by that journey; if the requested existing deployment is unavailable, report it rather than silently changing environment or target. Record the tested target/version and exact assertions. A baseline or deployed result does not validate unpublished local changes or a PR: those require the matching source commit and correct application. Explicit manual-localhost requests retain their local requirements."; - -export function efficientWorkflowGuidance(workflow, { dashboard = true } = {}) { - return `Saved efficient delivery policy (applies without repeated job/stage questions): -- Inspect the effective diff, make the smallest fix, run the focused regression checks that cover it, and self-review the checkpoint. -- Explicitly required repository checks (validation.gates entries with required: true), repository instructions, human PR authorization, managed policy and branch/merge policies remain authoritative. Efficient mode never waives them. -- After that reviewed focused checkpoint and any explicitly required pre-publication checks, create or update the authorized ${workflow.publication === "ready" ? "READY-FOR-REVIEW" : "DRAFT"} pull request now. Do not wait for optional broad local tests or builds. Never create an empty PR or stage unrelated files. -- Verify provider isDraft is ${workflow.publication !== "ready"} before reporting the PR stage passed. Publication is not proof of completed validation or permission to mark ready, merge, deploy or force-push. -- ${workflow.unitTestsAfterPr ? `After creating the PR, run the broader local unit-test gates against the same checkout through ${dashboard ? "dashboard-owned execution" : "fixlab exec with compact structured evidence"}. Update the same PR with actual results; do not delay PR creation while those tests run.` : "Broader local unit tests are optional and are not automatically requested. Leave their status not run/pending and report required remote CI separately."} -- ${workflow.optionalBuilds ? "The saved repository preference requests optional builds after PR creation; run them and report their actual outcomes." : "Optional builds are not automatically requested. Offer the saved repository Settings preference once when needed, not a question for every job or stage. Never run a full build merely to decide whether a PR may be created."} -- Keep every pending, failed, stale, skipped or not-run check explicit in the PR and dashboard. Never convert an optional check into passed, reuse stale evidence, bypass an explicitly required gate, or disable a CI/merge policy. -- Continue independent safe work without repeating successful discovery or unchanged focused checks. Use the same branch and PR; never create duplicate PRs. -- Reduce redundant regression cases, not correctness: reuse fixtures and existing test files; move punctuation, length and duplicate-value matrices to pure units. Browser cases represent distinct flows. Preserve safety, permissions, lease/queue and real reproduction boundaries. Do not hide repeated expensive work inside one case. Measure comparable baseline/final elapsed time and report actual savings. -- Keep Jest and Playwright discovery separate: bare Jest may collect E2E specs and bare Playwright selects all projects. Inspect configuration, select exact unit paths with an explicit E2E exclusion, and select the focused browser journey and authenticated project explicitly. Do not silently edit consumer configuration.`; -} diff --git a/docs/architecture.md b/docs/architecture.md index 8cabb7b..3a860fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,66 +47,6 @@ Result, evidence, and pull request status ## Components -### Delivery policy - -`dashboard/workflow-policy.js` supplies product-wide defaults for new jobs: -`workflow: { mode: "efficient", publication: "draft", unitTestsAfterPr: false, optionalBuilds: false }`. -Repository Settings persists overrides once. The reviewed focused checkpoint -precedes an authorized draft PR; ready-for-review publication requires the -explicit `publication: "ready"` preference. Broad unit tests and builds run -only by saved preference or explicit selection. Legacy saved jobs without a -publication field retain their recorded ready-for-review intent. The runtime -also preserves legacy strict-ready intent when no publication preference is -specified. An explicit strict plus draft choice remains draft, with provider -`isDraft: true` verified; strict validation requirements are unchanged. -The runtime -executes these checks through the existing permission-controlled, owned gate -interface; no independent unreviewed shell scheduler is introduced. - -Stopped jobs expose identity-bound, explicitly confirmed configured gate -selection: all, tests, builds, one gate, or a stage. Gate `kind` and `stage` -metadata disambiguate repository commands. Runs acquire the canonical executor -lease; overlapping work and queue advancement are blocked until owned execution -settles. Completing a scoped job cancels and waits for owned validation before -advancing the queue, without changing failed, stale or deferred outcomes. -Unverified termination retains the lease for recovery. - -Regression slimming removes duplicate expensive setup, not safety assertions. -Value matrices belong in fast pure checks; browser tests cover representative -distinct flows. Jest and Playwright discovery remain separate, and authenticated -projects must be explicitly selected rather than added to broad unit commands. - -Gate applicability and publication requirements are separate. In efficient -editing jobs, only applicable gates explicitly marked `required: true` block -publication. Other gates remain executable with honest pending/failed/stale -results. `advisory` is not `passed`, and `fullValidationPassed` reports whether -all applicable configured gates really passed. Read-only jobs and explicit -strict delivery retain the original all-applicable-gates requirement. - -Snapshots retain delivery policy. Legacy snapshots restore as strict instead -of silently weakening an existing job. `/api/job/workflow` permits an explicitly -confirmed, exact-origin, identity-bound stopped-job migration without editing -the pinned repository profile, clearing validation history or resuming work. -An explicitly strict repository policy cannot be waived through this route. -Required approvals, repository instructions and remote CI/merge policies remain -authoritative in both modes. - -Browser-only reproduction and deployed-result checking use the existing -`playwright-only` read-only scope. The runtime first checks the selected -environment's approved existing application and supported authorized browser -connection. It skips unnecessary stack setup and preserves exact target/version -and assertion evidence. No browser state is copied and profile locks remain -authoritative; an unavailable deployment does not authorize a different target. -Offline job preflight receives the mode and checks only the browser runner and -required authentication tools for this scope, rather than rejecting it for an -unused backend or restore toolchain. Fix/checkout validation retains those -checks. A deployed baseline cannot prove unpublished code or another PR commit. - -The SDK continues to load native Copilot configuration, tools and instructions, -without a replacement system prompt or restricted tool list. FixLab supplies -scope, safety, evidence and continuity guidance, not a separate cut-down coding -model. The selected model and environment still affect speed and results. - ### Dashboard or CLI Collects defect details, screenshots, repository selection, validation mode, @@ -262,65 +202,11 @@ and heartbeat are suspended until all human requests resolve. The dashboard offers allow-once/deny or the question's actual choices and optional freeform answer. Responses require the exact loopback Host and browser Origin, current instance/job/request IDs, JSON and explicit confirmation. Consumed or -cancelled IDs cannot be replayed. **Allow all pending once** displays a bounded -snapshot of approvable permissions and requires one explicit confirmation. -The client rechecks the instance, live job and exact reviewed request details, -then uses the existing single-use response endpoint for each captured ID. -New requests, questions and non-approvable requests are excluded. Partial delivery -stops immediately and reports confirmed approvals; it never rolls them back, -retries automatically, or grants permission to future requests. -Stop, hold, shutdown and executor loss invalidate +cancelled IDs cannot be replayed. Stop, hold, shutdown and executor loss invalidate pending requests; no answer or approval is inferred. Sensitive, oversized, malformed or unsupported permission details cannot be approved. Credentials and MFA remain in the browser, never question responses. Managed runtime policy -remains authoritative; no blanket or permanent grant is synthesized. -After enterprise policy is available, two built-in read-only exceptions return -one-request, non-interactive approvals: native glob filename discovery under -canonical, non-sensitive checkout directories, and the exact -`node --help` command. Discovery is identified by the -native glob tool's fixed intention, never a generic directory read or content -search. It may list protected filenames but grants no access to their contents. -Help accepts only the running package's canonical CLI entrypoint, without extra -arguments, composition, redirects, reported external paths or URLs. Neither -exception creates a scope or overrides escalation, warnings or managed human -approval. -Additionally, `/api/job/file-access` reviews source-read or source-read-write -trust for one stopped native job, bound to its canonical checkout and job UUID. -Exact loopback Host/Origin, current instance/job, JSON and explicit confirmation -are required. It neither resumes nor approves pending runtime requests. The -private job snapshot retains this separate grant across same-job resumes and -dashboard restart; creating or copying a job resets it. The native host validates -the identity again; non-fix modes cannot receive edit trust. -The policy checks each path again, including configured authentication paths, -symlinks, hard links, file type and protected configuration names. New files -require canonical existing parents. Source/test trust grants neither commands -nor managed-policy exceptions. The exact repository profile is read-eligible -only with bounded valid JSON and sensitive-content checks; writes and arbitrary -JSON remain outside trust. Stop the executor before changing or revoking it. -Dashboard-owned validation writes an exclusive `job.json` sidecar beside its -sanitized command output. Reviewed file trust can read bounded stdout/stderr -logs inside the checkout's `.git/fixlab/command-evidence` only after matching -that sidecar's job/repository identity and rechecking links and sensitive -content. Older unowned artifacts and other Git metadata remain manual. -An optional `allowValidationGates` flag in that explicit review additionally -matches only the running package's `node ...fixlab.js gate` invocation with -this job UUID, canonical checkout, current owning dashboard port and four -known options. Extra arguments, composition, reported external paths/URLs, -escalation and managed human approval stay manual. The dashboard retains all -gate-membership, checkout, profile-freshness and execution-state checks; this -is not a general shell grant. - -Eligible native requests separately offer a reviewed executor-local scope: -non-sensitive repository source/documentation/project-file reads, or an exact -test command bound to its canonical in-repository working directory, reported -paths and package manifests. Directories, hidden/sensitive files, linked paths, -unknown commands, network requests, escalation and managed human approvals stay -manual. Missing canonical command metadata cannot receive a command grant. -Changed package manifests or command identity require review again. These scopes -remain only in the SDK host's memory and expire on every executor termination, -including an automatic continuation; snapshots, retries and new jobs cannot -restore them. Automatic matches still return one-request decisions and never -claim that the matched request itself was interactively approved. +remains authoritative; no session-wide or permanent grant is synthesized. Workflow input and MCP conversation replies cannot approve native requests. The SDK's Node.js requirement is 20.19+ in the 20.x line or 22.12+. @@ -534,67 +420,6 @@ request summaries, bug identities and outcomes, stage summaries, held state, and pull-request readiness, but no raw logs, screenshots, credentials, authentication state, or source content. -The dashboard uses a chat-first workspace with an explicitly local repository -sidebar and inline human-response panels. Run details, memory, connections and -settings are hidden until opened from navigation, rather than occupying a -permanent inspector column. Back to chat collapses those tools and restores -focus to the preserved draft. Mobile navigation uses an explicitly expanded -Menu control. New run options and secondary help remain collapsed until -requested; critical request payloads, errors and approval actions stay visible. -Repository -memory is a read-only view of retained job summaries and reported stage -outcomes; selecting a memory never resumes work or grants permission. -The repository catalog reads bounded local dashboard registrations and verifies -their loopback owner identity before linking to another workspace. It does not -scan repositories, list GitHub access, launch dashboards, or make model calls. -The chat presents existing activity summaries, not generated explanations or -hidden reasoning, so rendering and refreshing it adds no model token usage. -**Current job full screen** moves the existing details/evidence inspector into -an accessible viewport-sized dialog. It follows the current job even when a -history entry was selected, and restores that selection and focus on exit or -Escape. Its header limits the request to two visible lines, retaining the full -text for assistive technology and on hover while keeping the exit button visible. -Opening and closing the inspector never starts, resumes or approves work. -Stale artifact responses cannot replace another selected job's evidence. - -Shared-worktree blockers expose **Review workspace** in the chat. A separate -confirmed **Stash files and start fresh** action preserves reviewed untracked -source files in a retained Git stash, fetches the configured remote/base branch, -and creates a new job branch in the same folder. The original job and branch -are retained; no reset, clean, force-switch or automatic stash restore occurs. -Review is bound to the dashboard, job and workspace fingerprint. Tracked changes, -sensitive or linked files, active execution, pending permissions, queued jobs, -and owned React/authentication sessions block automatic recovery. Fresh jobs -do not inherit file trust or external-folder grants. - -Local repository setup uses an explicit same-origin, instance-bound API and a -deterministic chat, not an onboarding model session. A bounded, expiring plan -captures the canonical Git working tree, profile fingerprint, supported field -values and non-overwriting scaffold list. Application paths stay inside that -tree; credentials and authentication-state contents are not exposed. Applying -acquires the repository executor lease, rechecks the fingerprint and uses an -exclusive temporary profile file followed by atomic rename. Existing resource -owners must be idle; the editor does not stop them. Opening an onboarded workspace -registers a separate loopback owner in the same host without launching a job. -Host shutdown fences new workspace creation and closes only its tracked children. -The Queue sidebar action opens the jobs list at the top of Run details, with -the waiting count visible without opening the inspector. Navigation never -starts, resumes, or grants access to a job. - -Before starting or resuming an automated browser-only executor, the dashboard -checks directory-valued authentication `statusPaths` for a matching same-user -browser process. It reads filesystem/process metadata, not authentication-file -or browser-profile contents. A busy declared profile blocks before acquiring -the execution lease or starting the model. Repeating a retry while that owner -remains returns a conflict without recording another input or starting AI. -The authentication connection endpoint applies the same ownership check. -FixLab never terminates or attaches to an existing browser to release a lock; -the user must first preserve unsaved form data and close the previous test -browser. Ownership inspection errors also block and are shown explicitly. -State-file-only configuration cannot identify a persistent profile, and a -profile acquired after preflight can still require a human-owned recovery; -this is a prerequisite check, not a universal browser lock manager. - ## Context and token efficiency Each job reads the repository profile and existing instructions first, then @@ -604,69 +429,21 @@ Unchanged files, completed diagnosis, available dependencies, and broad checks are not repeated without new risk evidence. Tests and validation are focused and risk-scaled. -Native Copilot sessions explicitly defer MCP/external tool definitions behind -tool search and move tool output above 8 KiB to runtime-managed evidence files. -This preserves tool availability, repository configuration discovery and managed -policy; it does not replace the runtime's safety prompt or grant permissions. -Playwright-only jobs use a smaller browser-specific contract instead of the -implementation/PR lifecycle. They reuse a parameterized same-job script and -retain it through retries rather than rediscovering selectors for every row. -Browser-only continuation and dashboard retry/skip input keep the original -environment, request, decisions and browser evidence; they do not introduce -implementation or PR lifecycle work. A first launch blocked by browser -preflight has no model session to resume, including after a dashboard restart. - -All native Copilot sessions start background compaction at 25% of the model context -allowance and block for compaction at 40%. On resume, the host re-tokenizes -retained conversation using the selected model; above 24,000 conversation -tokens it asks the official runtime to compact history before sending the next -job prompt. The summary must preserve explicit decisions, pending/denied -permissions, form state, safety constraints, script paths and exact evidence. -It also preserves checkout ownership, changed files, completed test commands, -baseline failures, deferred gates, budget and remaining work. -Compaction failure blocks continuation rather than clearing history, inventing -approvals or silently resending oversized context. Numeric compaction outcomes -are visible in activity; raw summaries are not sent to the dashboard. -Cumulative usage is not reset by compaction or dashboard restarts and is not a -dollar-cost estimate. Only numeric counters are retained in job summaries; -older summaries recover counters from the same job's local metrics record -when available. -Native counters arrive in dedicated, validated monotonic protocol frames; -rendered token-summary text cannot overwrite them or reset a resumed budget. -Agency retains its legacy rendered-usage parser. - -Native jobs accept an optional `inputTokenLimit` (safe non-negative integer, -default `0`, unlimited). Cumulative SDK usage, including cached input and -compaction, pauses the owned executor when a reported total reaches the ceiling. -The current call may exceed it; this is a soft usage limit, not a billing cap. -Exhausted resumes cannot open another runtime, and queued comments cannot -automatically continue an exhausted job. A stopped-job budget change requires -exact browser origin, current instance/job identity and explicit confirmation; -it preserves usage and neither resumes work nor grants permissions. Agency -rejects positive ceilings rather than pretending to enforce SDK usage. - For fix-and-validate jobs with pull-request publication already authorized, -FixLab follows the selected publication policy. Efficient mode defaults to a -draft PR after the scoped change, focused regression checks, effective-diff -review, and explicitly required pre-publication gates. Transient validation -artifacts are removed before pushing the reviewed checkpoint. A successful -authorized draft publication requires verifying the provider's `isDraft` state -is true; it does not authorize clearing draft status. Marking a draft ready -requires a separate reviewed decision. Explicit ready intent or the legacy -strict-ready policy requires normal review, required fresh validation and -approval gates, and verification that `isDraft` is false before the PR stage -passes. Strict-required gates, freshness checks, provider protections, and -managed approvals remain unchanged. - -Later reviewed checkpoints and validation results update the same branch and -PR. Optional broad unit tests and builds are not automatically requested when -disabled; enabled independent checks can continue after publication without -blocking efficient delivery. Every pending, failed, stale, skipped, or not-run -check remains explicit rather than being reported as passed. Publication does -not wait for remote CI or build completion: some providers trigger PR builds -only after publication. Remote checks remain pending until their results are -known, and normal review, build, and merge policies remain enforced. Failed -publication or selected-state verification blocks or fails the PR stage. +FixLab uses an early-draft pipeline. After the scoped production change and +smallest focused regression checks pass, it removes transient validation +artifacts, reviews the effective diff, pushes the checkpoint, and creates or +updates a draft pull request. Independent builds, application startup, and +browser journeys continue while the draft is visible. Later reviewed +checkpoints update the same branch and PR. The PR remains explicitly +validation-in-progress until required local validation and approval gates +pass or repository policy records an accepted outcome. FixLab then publishes +the authorized PR by clearing its draft status and verifies the provider's +`isDraft` state is false before reporting the PR stage passed. Publication +does not wait for remote CI or build completion: some providers trigger PR +builds only after publication. Those checks remain reported as pending, not +passed, and normal review, build, and merge policies remain enforced. Failed +publication or state verification blocks or fails the PR stage. Stage summaries remain structured independently of raw output. The dashboard retains a bounded local log window and reports how many older entries were diff --git a/docs/commands.md b/docs/commands.md index c25326a..b1c0c2b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,25 +1,5 @@ # FixLab commands -New editing jobs use efficient delivery automatically: focused regression -checks and reviewed diff, then an authorized draft PR. Broader local unit tests -and optional builds are not automatically requested. Repository Settings saves -`workflow.mode` (`efficient` or `strict`), `workflow.publication` (`draft` by -default in efficient mode, `ready` by default in strict mode), -`workflow.unitTestsAfterPr` (default `false`) and `workflow.optionalBuilds` -(default `false`) once. Marking a draft ready requires a separate reviewed -decision. No per-stage workflow selection is needed. -`validation.gates[].required: true` remains publication-blocking in efficient -mode. Optional gates remain runnable and keep their actual status; neither -publication nor `advisory` implies validation passed. Existing saved jobs retain -strict behavior unless explicitly migrated while stopped. - -The browser reproduction/result-check mode (`playwright-only`) is the light -path for explicit reproduction-only and deployed browser-result requests. It -first reuses an approved existing application/validation session when supported, -skips irrelevant local-stack setup, and does not run broad unit/build gates or -create a PR. Missing browser runner/authentication prerequisites still block. -Local-fix and PR claims still require the matching checkout and application. - ## Interactive repository shell ```text @@ -218,15 +198,6 @@ resets preflight and requires fresh approval. If the first approved launch is blocked by workspace admission, the job keeps its already approved folder and exposes a separate workspace review. Explicit workspace approval preserves the reviewed work and starts the first runtime session fresh. -To avoid resuming that job, use **Review workspace**, inspect the proposed -base and untracked-file list, then confirm **Stash files and start fresh**. -FixLab keeps the same folder, saves eligible files in a retained stash, refreshes -the configured base (for example, `origin/develop`), and creates a new job branch. -The old job and branch remain preserved. Restore the reported stash separately; -it is never automatically applied to the clean new branch. -This separately confirmed Git recovery can prepare the branch before a new -job's external-folder preflight. It grants no folder or tool access; the fresh -executor still waits for any required approval. If the approved folder changes before that first launch, FixLab clears the invalid grant and requires a replacement review and approval while preserving the fresh initial prompt and non-resume launch. @@ -388,78 +359,12 @@ passwords, tokens or MFA codes into FixLab. The same executor waits for your response, retains its repository lease, and suspends its idle timer. No restart or generic Resume is needed. Multiple requests -are shown one at a time. When two or more approvable permission requests are -pending, expand **Review all pending permissions**, inspect every listed scope, -then choose **Allow all pending once** and confirm. This sends individual -one-time responses only for that reviewed snapshot. Questions, requests whose -approval is unavailable, and requests arriving afterward remain unanswered. -The dashboard rechecks the snapshot before sending; a stale request or delivery -failure stops the batch and reports confirmed partial progress without retrying. -Already delivered approvals cannot be rolled back. Nothing is enabled for the -rest of the job or for another job. -Stop/hold/shutdown cancels unanswered requests. Responses +are shown one at a time. Stop/hold/shutdown cancels unanswered requests. Responses are live-only and are not saved in dashboard history or copied to another job. Unknown, sensitive or oversized permission details offer **Deny**, not approval. Organization policy remains authoritative; an approval is not a passed test or permission for unrelated production operations. The dashboard does not offer -blanket or permanent approvals. - -Native filename-only discovery in canonical, non-sensitive checkout directories -and the exact `node --help` command are built-in -read-only exceptions: they do not prompt or create a reusable grant. Discovery -does not read file contents; protected filenames may appear, but reading them -still requires their own permission check. Generic directory reads, content -search and other CLI commands are not included. Help must target the running -package's canonical CLI file, without extra arguments, shell composition, -redirects, reported external paths or URLs. Both exceptions retain managed human -approval, warning and sandbox-escalation checks. - -Eligible native requests also offer **Allow repository-file reads for this job** -or **Allow this test command for this job**, each with a separate confirmation. -Separately, a stopped native job's **Repository file trust** editor can review -source/test reads or source/test reads and edits for that exact job and canonical -checkout, including resumes and dashboard restart. This setting is not inherited -by another job, a fresh restart-as-new-job, a copied job or another checkout. -Change or revoke it while the executor is stopped; applying it never resumes. -Edit trust is available only in fix-and-validate mode. It allows new source/test -files under existing canonical directories, not directory creation, links, -hard-linked files, sensitive files, build/safety configuration, profile writes, -shell commands, external access or managed-policy exceptions. -The exact `.github/fixlab/repository-profile.json` is an eligible read after -bounded valid-profile and sensitive-content checks; other JSON is not added. -Configured authentication paths remain protected regardless of their names. -JavaScript/TypeScript test-source names such as `auth.spec.ts` and -`scenario.auth.test.ts` are eligible under reviewed file trust; the `auth` -test marker is not saved authentication state. Hidden files, authentication -directories and other sensitive filename markers remain excluded. -Manual permission review remains limited to 8,192 characters. Separately -reviewed source-edit trust can cover larger write requests when their complete -serialized review payload is at most 1 MiB and passes the full sensitive-content -scan. Display size never replaces the job/checkout, protected-path, source-file, -managed-approval or sandbox checks. Without reviewed edit trust, oversized writes -still cannot be approved through the dashboard. -Read trust also covers bounded, non-sensitive `stdout.log`/`stderr.log` under -this checkout's `.git/fixlab/command-evidence`, only with a canonical, regular -`job.json` receipt binding the gate to this exact job and repository. New -dashboard gate executions write that receipt. Other Git metadata, older -unowned logs, linked files and output containing detected secrets remain manual. -The review can also explicitly opt into dashboard-owned validation gates. -Only `node gate` with this exact job, canonical -checkout and current dashboard port is matched, with four known options and -no extra arguments, shell composition or external URLs. The dashboard still -verifies gate membership, assigned checkout, profile freshness and job state. -Unknown or arbitrary shell commands remain separately reviewed. -Reads cover non-sensitive source, documentation and selected project files in -this checkout, never whole directories, hidden/sensitive files, browser state, -outside paths or symbolic links. Commands cover only the exact reviewed test -invocation in its canonical repository working directory, with the same reported -paths and package manifests. Shell composition, unknown commands, outside paths, -network permission requests, escalation and managed human approvals remain -manual. Missing command metadata means allow-once only. Tests can have side -effects: review their scripts; a command grant does not separately authorize -production/business writes. Grants are in-memory and expire whenever this -executor ends, including stop, hold, failure, restart or automatic continuation. -Ordinary **Allow once** and **Allow all pending once** never create these grants. +blanket, session-wide or permanent approvals. Agency and older text-only jobs cannot forward native prompts. When one reports a non-interactive permission denial, FixLab stops the owned @@ -483,25 +388,6 @@ After owned termination is verified, **Dismiss blocked job and continue queue** also remains available without granting permissions or enabling ordinary Resume. If an already approved folder target changes, its grants are cleared and a new permission review is required; a failed restart cannot restore the stale grants. - -### Token controls - -Native Copilot jobs in every mode use earlier context compaction, not just -Playwright-only jobs. Resumes measure retained context and compact above 24,000 -conversation tokens, preserving decisions, safety restrictions, exact results -and remaining work instead of replaying discovery. - -Under run options, **Input-token ceiling** defaults to `0` (unlimited). A positive -integer pauses once cumulative SDK-reported input, including cached input and -compaction, reaches it. A running model call can overshoot; it is not a hard -billing cap or dollar-cost estimate. Retries retain consumed usage. The -**Input-token budget** panel separates cached and uncached input; after verified -executor termination, explicitly update the ceiling above consumed input (or -choose `0`) and resume separately. A comment or retry cannot reset usage or bypass -an exhausted ceiling. Agency does not support positive SDK token ceilings. -Connected conversation status reports the exhausted budget and directs the user -to its separate browser review instead of suggesting an ineffective retry. - 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. diff --git a/docs/installation.md b/docs/installation.md index 1cfd2a5..64d6878 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -27,20 +27,6 @@ The commands below are manual setup and troubleshooting alternatives, not a checklist that every user must run. A PR-only feature needs its approved commit installed until the change reaches the default branch. -## Use the dashboard - -Describe your request and select **Start job**. Attach images when useful; -**Run options** holds mode, environment and evidence settings. - -The sidebar opens saved work, run details, connections and settings on demand. -On a small screen, select **Menu** first. **Back to chat** hides those tools -without clearing your draft or running a job. A repository that needs setup -shows **Set up a repository**; Azure DevOps intake is optional and separate. - -Approval requests and errors remain inline. Review the actual command, path -or diff before approving; enter sign-in details only in the normal browser. -Required checks and managed approvals are unchanged. - ## Prerequisites The machine running FixLab needs: diff --git a/docs/releasing.md b/docs/releasing.md index 6d5789b..8d88cb0 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -54,7 +54,7 @@ external GitHub source by adding an entry like this to "repo": "MonaDevAI/FixLab" }, "description": "Evidence-backed defect diagnosis, repair, and validation for React and .NET repositories.", - "version": "0.7.4", + "version": "0.7.3", "author": { "name": "MonaDevAI", "url": "https://github.com/MonaDevAI" diff --git a/docs/repository-onboarding.md b/docs/repository-onboarding.md index bef85cc..3e0a43d 100644 --- a/docs/repository-onboarding.md +++ b/docs/repository-onboarding.md @@ -3,41 +3,6 @@ FixLab must not assume that every React and .NET repository has the same layout. The target repository owns a versioned profile. -## Dashboard chat setup - -Choose **Add local repository** in the repository sidebar and enter the absolute -root of an existing local Git working tree. A deterministic chat asks about -application folders, commands and loopback health URLs, browser tests, sign-in -configuration, non-production environments and branch ownership. It makes no -model calls and never asks for credentials or authentication-file contents. - -Review the proposed settings and exact file list before **Apply reviewed setup**. -Only the profile and missing packaged FixLab prompts/agent files are written; -existing custom scaffolding is not overwritten. No dependencies are installed, -commands executed, authentication performed or jobs started. Configuration alone -is not a passed Doctor or browser check. Failed multi-file scaffolding can leave -some newly created files; reload and review again rather than blindly retrying. - -**Open repository workspace** reuses a verified local owner or opens an idle -loopback workspace in the same dashboard host. The original repository job keeps -running when you switch workspaces. The host owns at most eight additional -workspaces; shutting down that host closes its owned workspaces and resources, -not independently started dashboard processes. - -Under **Settings & usage**, **Load repository settings** exposes the supported -repository-level fields without returning environment-variable values or a raw -profile. Preview and confirm every save. Unshown configuration is preserved, -including structured environment definitions; add new structured definitions -in the repository profile before enabling them through the editor. Production -operation policy cannot be enabled here. An executor lease or active owned -frontend/sign-in session prevents saving, without stopping those resources. -Only the selected repository's ownership scopes are checked for setup writes -and workspace opening. An unreachable or identity-mismatched registered owner -in those scopes blocks the operation; it is not evidence that its resources -stopped. Unrelated unavailable dashboard registrations do not block setup. -Plans expire after ten minutes and are invalidated by concurrent profile edits. -Setup paths reject traversal and symbolic links. - ## Required information ### Repository @@ -88,14 +53,6 @@ The profile must also declare: scenario evidence. - `browserAutomation.authentication.required`, explicitly stating whether login is needed. - An authentication command and status paths when authentication is required. -- A reviewed `browserAutomation.authentication.proofCommand` for authenticated - browser readiness. It must exercise a read-only signed-in assertion against - `FIXLAB_TARGET_ENVIRONMENT`, fail for expired/unauthenticated state, and close - its owned browser. A helper exit or state-file existence is not login proof. - Dashboard proof is memory-only and bound to the job, selected environment, - configuration and unchanged regular status-file identities; new jobs and - changed state require a fresh proof. No browser-state contents are inspected - or copied by this check. - `browserAutomation.dataSafety.policy`, describing mock, read-only, or intercepted-mutation behavior. - `browserAutomation.dataSafety.productionAllowed: false`. diff --git a/e2e/dashboard-readiness.spec.js b/e2e/dashboard-readiness.spec.js index 01cf6f4..ffd6729 100644 --- a/e2e/dashboard-readiness.spec.js +++ b/e2e/dashboard-readiness.spec.js @@ -20,16 +20,6 @@ let repository; let dashboard; let baseUrl; -async function openWorkflowControls(page, url) { - await page.goto(url); - await page.locator('.workspace-navigation [data-open="details-group"]').click(); - for (const id of ["run-options", "details-group", "connections-group", "settings-group"]) { - if (await page.locator(`#${id}`).getAttribute("open") === null) { - await page.locator(`#${id} > summary`).click(); - } - } -} - async function availablePort() { const probe = createPortProbe(); await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); @@ -104,7 +94,6 @@ test.beforeAll(async () => { join(profileDirectory, "repository-profile.json"), JSON.stringify({ name: "E2E repository", - workflow: { mode: "strict" }, applications: { frontend: { workingDirectory: ".", @@ -171,7 +160,7 @@ test("dashboard submits interception as a per-run option rather than a shared de }) }); }); - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); await page.locator("#request").fill("Synthetic per-run interception scenario."); await page.locator("input[name=mode][value=playwright-only]").check(); await page.locator("#job-test-mutation-mode").selectOption("intercepted"); @@ -189,22 +178,13 @@ test("native Copilot prompts allow once, deny and answer choices in the same liv readFileSync(join(repository, ".github", "fixlab", "repository-profile.json"))); let bridge; let launches = 0; - let incomingPermission = null; const sent = []; const instance = createDashboardServer({ repository: directory, packageRoot, runtime: "copilot", browserOpener() {}, executor({ onRuntimeRequests }) { launches++; bridge = createRuntimeRequestBridge({ - send(message) { - sent.push(message); - if (message.action === "allow" && incomingPermission) { - const incoming = incomingPermission; - incomingPermission = null; - bridge.receive(incoming); - } - }, - onChange: onRuntimeRequests + send: (message) => sent.push(message), onChange: onRuntimeRequests }); let finish; return { @@ -225,7 +205,7 @@ test("native Copilot prompts allow once, deny and answer choices in the same liv ...runtimePermissionPrompt({ kind: "shell", fullCommandText: "npm test" }), id: randomUUID() }); bridge.receive(permission()); - await openWorkflowControls(page, address.url); + await page.goto(address.url); await expect(page.locator("#job-status")).toContainText("waiting for your response"); await expect(page.locator("#runtime-request-details")).toContainText("npm test"); page.once("dialog", (dialog) => dialog.accept()); @@ -260,194 +240,7 @@ test("native Copilot prompts allow once, deny and answer choices in the same liv await expect(page.getByRole("button", { name: "Allow once", exact: true })).toHaveCount(0); await page.getByRole("button", { name: "Deny", exact: true }).click(); await expect.poll(() => sent[4]?.action).toBe("deny"); - - const first = { ...permission(), grantScope: "exact-test-command" }; - const second = { - ...runtimePermissionPrompt({ kind: "shell", fullCommandText: "npm run build" }), id: randomUUID() - }; - const unavailable = { - ...runtimePermissionPrompt({ kind: "unsupported-native-kind" }), id: randomUUID() - }; - const question = { - ...runtimeQuestionPrompt({ question: "Which synthetic journey?", choices: ["First", "Second"] }), id: randomUUID() - }; - for (const request of [first, second, unavailable, question]) { - bridge.receive(request); - } - await expect(page.locator("#runtime-batch-summary")).toHaveText("Review all 2 pending permissions"); - await page.locator("#runtime-batch-summary").click(); - await expect(page.locator("#runtime-batch-requests")).toContainText("npm test"); - await expect(page.locator("#runtime-batch-requests")).toContainText("npm run build"); - await expect(page.locator("#runtime-batch-requests article")).toHaveCount(2); - page.once("dialog", (dialog) => dialog.dismiss()); - await page.locator("#runtime-allow-pending").click(); - expect(sent).toHaveLength(5); - - const arriving = permission(); - incomingPermission = arriving; - page.once("dialog", (dialog) => dialog.accept()); - await page.locator("#runtime-allow-pending").click(); - await expect(page.locator("#runtime-batch-result")).toHaveText( - "Allowed 2 reviewed permission requests once. New requests still require approval." - ); - expect(sent.slice(5)).toEqual([ - { type: "response", id: first.id, action: "allow" }, - { type: "response", id: second.id, action: "allow" } - ]); - const remaining = await (await page.request.get(`${address.url}/api/job`)).json(); - expect(remaining.job.runtimeRequests.map((request) => request.id)).toEqual([ - unavailable.id, question.id, arriving.id - ]); - expect(launches).toBe(1); - bridge.cancel(false); - for (const [grantScope, label] of [ - ["exact-test-command", "Allow this test command for this job"], - ["repository-reads", "Allow repository-file reads for this job"] - ]) { - const request = { - ...runtimePermissionPrompt(grantScope === "repository-reads" - ? { kind: "read", path: "src/example.ts" } - : { kind: "shell", fullCommandText: "npm test" }), - id: randomUUID(), grantScope - }; - bridge.receive(request); - const count = sent.length; - page.once("dialog", (dialog) => dialog.dismiss()); - await page.getByRole("button", { name: label, exact: true }).click(); - expect(sent).toHaveLength(count); - page.once("dialog", (dialog) => dialog.accept()); - await page.getByRole("button", { name: label, exact: true }).click(); - await expect.poll(() => sent.at(-1)?.scope).toBe(grantScope); - expect(sent.at(-1)).toEqual({ - type: "response", id: request.id, action: "allow", scope: grantScope - }); - } - } finally { - await instance.close(); - rmSync(directory, { recursive: true, force: true }); - } -}); - -test("Copilot file trust requires a stopped exact job and a separate confirmation without resuming", async ({ page }) => { - const directory = mkdtempSync(join(tmpdir(), "fixlab-file-trust-ui-")); - mkdirSync(join(directory, ".github", "fixlab"), { recursive: true }); - writeFileSync(join(directory, ".github", "fixlab", "repository-profile.json"), - readFileSync(join(repository, ".github", "fixlab", "repository-profile.json"))); - let finish; - let launches = 0; - const instance = createDashboardServer({ - repository: directory, packageRoot, runtime: "copilot", browserOpener() {}, - executor() { - launches++; - return { - completion: new Promise((resolve) => { finish = resolve; }), - terminate() { finish({ code: 130 }); }, hasStopped: () => true - }; - } - }); - try { - const address = await instance.listen({ port: await availablePort() }); - await openWorkflowControls(page, address.url); - await page.locator("#request").fill("Synthetic reviewed source file trust."); - await page.locator("#start-button").click(); - await expect(page.locator("#job-file-access-mode")).toHaveValue("manual"); - await expect(page.locator("#job-file-access-save")).toBeDisabled(); - finish({ code: 1 }); - await expect(page.locator("#job-file-access-save")).toBeEnabled(); - await page.locator("#job-file-access-mode").selectOption("source-read-write"); - await page.locator("#job-file-access-gates").check(); - page.once("dialog", (dialog) => dialog.dismiss()); - await page.locator("#job-file-access-save").click(); - expect((await (await page.request.get(`${address.url}/api/job`)).json()).job.fileAccess).toBeNull(); - page.once("dialog", (dialog) => dialog.accept()); - await page.locator("#job-file-access-save").click(); - await expect(page.locator("#job-file-access-message")).toContainText("this job only"); - await expect(page.locator("#job-file-access-summary")).toContainText("including resumes"); - expect((await (await page.request.get(`${address.url}/api/job`)).json()).job.fileAccess.mode).toBe("source-read-write"); - expect((await (await page.request.get(`${address.url}/api/job`)).json()).job.fileAccess.allowValidationGates).toBe(true); - expect(launches).toBe(1); - await page.reload(); - await openWorkflowControls(page, address.url); - await expect(page.locator("#job-file-access-mode")).toHaveValue("source-read-write"); - await expect(page.locator("#job-file-access-gates")).toBeChecked(); - await page.locator("#job-file-access-mode").selectOption("manual"); - page.once("dialog", (dialog) => dialog.accept()); - await page.locator("#job-file-access-save").click(); - await expect(page.locator("#job-file-access-summary")).toContainText("Individual file approvals"); - } finally { - await instance.close(); - rmSync(directory, { recursive: true, force: true }); - } -}); - -test("Show budget is unchecked by default and reveals usage and stopped-job controls without changing limits", async ({ page }) => { - const directory = mkdtempSync(join(tmpdir(), "fixlab-budget-ui-")); - mkdirSync(join(directory, ".github", "fixlab"), { recursive: true }); - writeFileSync(join(directory, ".github", "fixlab", "repository-profile.json"), - readFileSync(join(repository, ".github", "fixlab", "repository-profile.json"))); - let finish; - let launches = 0; - let selectedLimit; - const instance = createDashboardServer({ - repository: directory, packageRoot, runtime: "copilot", browserOpener() {}, - executor(input) { - launches++; - selectedLimit = input.inputTokenLimit; - input.onUsage({ - inputTokens: 110, cachedInputTokens: 90, cacheWriteTokens: 20, - outputTokens: 5, cacheReusePercent: 90 / 110 * 100 - }); - input.onOutput("stdout", "Tokens \u2191 110 (90 cached, 20 written) \u2022 \u2193 5\n"); - return { - completion: new Promise((resolve) => { finish = resolve; }), - terminate() { finish({ code: 130 }); }, - hasStopped: () => true - }; - } - }); - try { - const address = await instance.listen({ port: await availablePort() }); - await openWorkflowControls(page, address.url); - await expect(page.locator("#show-input-budget")).not.toBeChecked(); - await expect(page.locator("#input-budget-control")).toBeHidden(); - await expect(page.locator("#job-budget-panel")).toBeHidden(); - await page.locator("#show-input-budget").check(); - await expect(page.locator("#input-budget-control")).toBeVisible(); - await expect(page.locator("#input-token-limit")).toHaveValue("0"); - await page.locator("#request").fill("Synthetic input budget browser scenario."); - await page.locator("#input-token-limit").fill("100"); - await page.locator("#start-button").click(); - await expect.poll(() => selectedLimit).toBe(100); - await expect(page.locator("#job-budget-summary")).toContainText("110 input tokens (90 cached; 20 uncached)"); - await expect(page.locator("#job-budget-save")).toBeDisabled(); - const error = new Error("Input-token ceiling reached."); - error.code = "INPUT_TOKEN_LIMIT"; - finish({ code: null, error }); - await expect(page.locator("#job-budget-summary")).toContainText("Paused:"); - await expect(page.locator("#job-budget-save")).toBeEnabled(); - await page.locator("#job-budget-limit").fill("200"); - page.once("dialog", (dialog) => dialog.dismiss()); - await page.locator("#job-budget-save").click(); - const unchanged = await (await page.request.get(`${address.url}/api/job`)).json(); - expect(unchanged.job.inputTokenLimit).toBe(100); - expect(unchanged.job.budgetExceeded).toBe(true); - page.once("dialog", (dialog) => dialog.accept()); - await page.locator("#job-budget-save").click(); - await expect(page.locator("#job-budget-message")).toContainText("Usage is preserved; resume separately"); - await expect(page.locator("#job-budget-summary")).toContainText("Ceiling: 200"); - const updated = await (await page.request.get(`${address.url}/api/job`)).json(); - expect(updated.job.status).toBe("blocked"); - expect(updated.job.usage.inputTokens).toBe(110); - expect(updated.job.inputTokenLimit).toBe(200); expect(launches).toBe(1); - await page.locator("#show-input-budget").uncheck(); - await expect(page.locator("#job-budget-panel")).toBeHidden(); - await expect(page.locator("#input-budget-control")).toBeHidden(); - const hidden = await (await page.request.get(`${address.url}/api/job`)).json(); - expect(hidden.job.inputTokenLimit).toBe(200); - expect(hidden.job.usage.inputTokens).toBe(110); - await page.locator("#show-input-budget").check(); - await expect(page.locator("#job-budget-summary")).toContainText("Ceiling: 200"); } finally { await instance.close(); rmSync(directory, { recursive: true, force: true }); @@ -507,7 +300,7 @@ test(`${componentPolicy ? "Component" : "API-only"} gates display not applicable }); try { const address = await scopedDashboard.listen({ port: await availablePort() }); - await openWorkflowControls(page, address.url); + await page.goto(address.url); await page.getByRole("radio", { name: "Validate only", exact: true }).check(); const start = async (coverage) => { await page.getByLabel("Bug or required enhancement").fill("Verify internal API validation."); @@ -576,7 +369,7 @@ test("pre-launch folder review starts no executor until the user explicitly appr }); try { const address = await preflightDashboard.listen({ port: await availablePort() }); - await openWorkflowControls(page, address.url); + await page.goto(address.url); await page.getByRole("radio", { name: "Validate only", exact: true }).check(); await page.getByLabel("Warm React in parallel when a local job starts").uncheck(); await page.getByLabel("Review required external-folder access before starting the executor").check(); @@ -636,7 +429,7 @@ test("runtime folder approval is reviewed and confirmed in the browser without a await page.request.post(`${address.url}/api/jobs`, { data: { request: "Verify browser-only scoped runtime approval.", mode: "validate-only" } }); - await openWorkflowControls(page, address.url); + await page.goto(address.url); await expect(page.getByRole("heading", { name: "Copilot permission required" })).toBeVisible(); await expect(page.locator("#job-input-submit")).toBeHidden(); await expect(page.locator("#permission-review")).toBeEnabled(); @@ -707,7 +500,7 @@ test("Copilot model control honors configured defaults and explicit per-job sele }); try { const address = await modelDashboard.listen({ port: await availablePort() }); - await openWorkflowControls(page, address.url); + await page.goto(address.url); await expect(page.locator("#model-control")).toBeVisible(); await expect(page.locator("#model")).toHaveValue("claude-sonnet-4.5"); await page.locator("#model").selectOption("hydrafusion"); @@ -768,7 +561,7 @@ test("session controls reconnect, confirm another owner and resume its stopped j }); expect(started.status).toBe(202); const identity = await (await fetch(`${ownerUrl}/api/session`)).json(); - await test.step("Opening the session observer", () => openWorkflowControls(page, observerUrl)); + await test.step("Opening the session observer", () => page.goto(observerUrl)); const row = page.locator("#sessions-list article").filter({ hasText: identity.id }); await expect(row).toContainText("Other FixLab dashboard"); await expect(row.getByRole("link", { name: "Reconnect to live dashboard" })) @@ -821,7 +614,7 @@ test("dashboard exposes repository readiness through the running server", async expect(body.readiness.profileName).toBe("E2E repository"); expect(body.readiness.environments).toEqual(["local", "dev", "sit"]); - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); await expect(page.locator("#target-environment option")).toHaveText([ "Profile default", "LOCAL", @@ -836,13 +629,13 @@ test("dashboard exposes repository readiness through the running server", async test("dashboard exposes multi-bug intake and resumable user input", async ({ page }) => { - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); await expect( page.getByRole("heading", { name: "Playwright authentication" }) ).toBeVisible(); await expect( - page.getByRole("heading", { name: "Azure DevOps intake (optional)", exact: true }) + page.getByRole("heading", { name: "Repository onboarding" }) ).toBeVisible(); await page .getByLabel("Azure DevOps organization") @@ -869,7 +662,7 @@ test("dashboard exposes multi-bug intake and resumable user input", async ({ page.getByRole("button", { name: "Refresh evidence" }) ).toBeVisible(); await expect( - page.getByLabel("Browser reproduction / result check only") + page.getByLabel("Playwright validation only") ).toBeVisible(); await expect(page.getByLabel("Create a separate PR per bug")).not.toBeChecked(); await expect( @@ -897,7 +690,7 @@ test("dashboard accepts pasted images and records exact usage metrics", async ({ page, request }) => { - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); const requestField = page.getByLabel("Bug or required enhancement"); const textPastePrevented = await requestField.evaluate((element) => { const transfer = new DataTransfer(); @@ -941,7 +734,8 @@ test("dashboard accepts pasted images and records exact usage metrics", async ({ await requestField.fill("Validate pasted screenshot"); await page.getByRole("button", { name: "Start job" }).click(); - await expect(page.locator(".stage.inferred")).toContainText(/diagnosis\s*active now/); + await expect(page.locator(".stage.inferred strong")).toHaveText("diagnosis"); + await expect(page.locator(".stage.inferred")).toContainText("active now"); await expect(page.locator("#job-status")).toContainText( "passed · bug-fix" ); @@ -1133,7 +927,7 @@ test("dashboard lets users select active and queued job roadmaps", async ({ }); }); - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); const queuedText = page.locator(".job-row").filter({ has: page.getByRole("button", { name: /Waiting #1/ }) }); @@ -1236,7 +1030,7 @@ test("dashboard clears the bug input after adding a job to the queue", async ({ }); }); - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); const requestField = page.getByLabel("Bug or required enhancement"); await requestField.fill("Queue this bug and clear the form."); await page.getByRole("button", { name: "Add to queue" }).click(); @@ -1271,7 +1065,6 @@ test("dashboard explains how to resume a failed job and release the queue", asyn await route.fulfill({ contentType: "application/json", body: JSON.stringify({ - instanceId: "recovery-owner", readiness: { repository: "C:\\repo", repositoryReady: true, @@ -1324,7 +1117,7 @@ test("dashboard explains how to resume a failed job and release the queue", asyn }); }); - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); await expect(page.getByRole("heading", { name: "Retry or correct this job" })).toBeVisible(); await expect(page.locator("#job-input-guidance")).toContainText( @@ -1334,62 +1127,6 @@ test("dashboard explains how to resume a failed job and release the queue", asyn "dismiss it as failed" ); await expect(page.getByRole("button", { name: "Resume FixLab" })).toBeVisible(); - const mutations = []; - const review = { - branch: "users/test/fixlab-existing", fingerprint: "review-one", - freshBranch: { remote: "origin", baseBranch: "develop", - files: ["e2e/preserved.auth.spec.ts"], available: true, reason: "" } - }; - failedJob.status = "blocked"; - failedJob.workspaceReview = review; - await page.route("**/api/conversation", (route) => route.fulfill({ - contentType: "application/json", body: JSON.stringify({ - instanceId: "recovery-owner", repository: "C:\\repo", - job: failedJob, question: { kind: "workspace" }, workspaceReview: review - }) - })); - await page.route("**/api/jobs/restart", async (route) => { - mutations.push(route.request().postDataJSON()); - if (mutations.length === 1) { - return route.fulfill({ status: 409, contentType: "application/json", - body: JSON.stringify({ error: "The workspace changed since review. Review again." }) }); - } - failedJob.id = "fresh-job"; - failedJob.status = "failed"; - failedJob.workspaceReview = null; - failedJob.workspaceStash = { stashRef: "b".repeat(40) }; - return route.fulfill({ status: 202, contentType: "application/json", - body: JSON.stringify({ job: failedJob, restartedJob: failedJob }) }); - }); - await expect(page.locator("#workspace-recovery-panel")).toBeVisible(); - await expect(page.getByRole("button", { name: "Resume FixLab", exact: true })).toBeHidden(); - await page.locator("#workspace-recovery-review").click(); - await expect(page.locator("#workspace-recovery-summary")).toContainText("origin/develop"); - await expect(page.locator("#workspace-recovery-summary")).toContainText("e2e/preserved.auth.spec.ts"); - expect(mutations).toEqual([]); - await page.locator("#workspace-recovery-cancel").click(); - await expect(page.locator("#workspace-recovery-start")).toBeHidden(); - expect(mutations).toEqual([]); - await page.locator("#workspace-recovery-review").click(); - await expect(page.locator("#workspace-recovery-start")).toBeEnabled(); - page.once("dialog", (dialog) => dialog.dismiss()); - await page.locator("#workspace-recovery-start").click(); - expect(mutations).toEqual([]); - page.once("dialog", (dialog) => dialog.accept()); - await page.locator("#workspace-recovery-start").click(); - await expect(page.locator("#workspace-recovery-message")).toContainText("changed since review"); - await expect(page.locator("#workspace-recovery-start")).toBeHidden(); - review.fingerprint = "review-two"; - await page.locator("#workspace-recovery-review").click(); - await expect(page.locator("#workspace-recovery-start")).toBeEnabled(); - page.once("dialog", (dialog) => dialog.accept()); - await page.locator("#workspace-recovery-start").click(); - await expect.poll(() => mutations.length).toBe(2); - expect(mutations[1]).toMatchObject({ - confirmed: true, instanceId: "recovery-owner", jobId: "failed-job", - repository: "C:\\repo", workspaceRecovery: { fingerprint: "review-two", branch: review.branch } - }); - await expect(page.locator("#workspace-stash-result")).toContainText("b".repeat(40)); const dismissButton = page.getByRole("button", { name: "Dismiss failed job and continue queue" }); @@ -1457,7 +1194,7 @@ test("dashboard resumes a failed job without requiring extra details", async ({ }); }); - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); await page.getByRole("button", { name: "Resume FixLab" }).click(); await expect.poll(() => submittedInput?.action).toBe("continue"); @@ -1543,7 +1280,7 @@ test("dashboard retries a blocked Playwright gate without shell input", async ({ }); }); - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); await expect( page.getByRole("button", { name: "Skip Playwright live test" }) ).toBeVisible(); @@ -1628,17 +1365,17 @@ test("dashboard requires explicit manual localhost confirmation", async ({ }); }); - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); await expect( page.getByRole("button", { name: "Open React localhost" }) ).toBeVisible(); - await expect(page.getByRole("button", { name: "Passed", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Failed", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Passed" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Failed" })).toBeVisible(); await expect(page.locator("#job-input-guidance")).toContainText( "confirm Passed or Failed" ); - await page.getByRole("button", { name: "Passed", exact: true }).click(); + await page.getByRole("button", { name: "Passed" }).click(); expect(submittedInput).toEqual({ action: "manual-pass" }); }); @@ -1723,7 +1460,7 @@ test("dashboard exposes explicit pull-request approval when validation is comple }); }); - await openWorkflowControls(page, baseUrl); + await page.goto(baseUrl); await expect( page.getByRole("button", { name: "Approve and create PR" }) ).toBeVisible(); @@ -1735,380 +1472,3 @@ test("dashboard exposes explicit pull-request approval when validation is comple expect(submittedInput.action).toBe("continue"); expect(submittedInput.details).toContain("explicitly approve"); }); - -async function mockChatWorkspace(page, { job = null, queue = [], history = [] } = {}) { - await page.route("**/api/status", (route) => route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ - instanceId: "workspace-owner", - readiness: { repositoryReady: true, profileReady: true, - repository: "C:\\code\\alpha", profileName: "Alpha workspace" }, - runtime: { name: "copilot", supportsModelSelection: true, defaultModel: "" }, - job, queue, held: [], history - }) - })); - await page.route("**/api/repositories", (route) => route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ repositories: [ - { id: "workspace-owner", repository: "C:\\code\\alpha", url: baseUrl, status: "idle" }, - { id: "other-owner", repository: "C:\\code\\beta", url: "http://127.0.0.1:48555", status: "blocked" } - ], unavailableCount: 0 }) - })); -} - -test("chat workspace defaults to a compact composer and local repository navigation", async ({ page }) => { - const mutations = []; - page.on("request", (request) => { - if (request.method() === "POST") { - mutations.push(request.url()); - } - }); - await mockChatWorkspace(page); - await page.setViewportSize({ width: 1500, height: 1000 }); - await page.goto(baseUrl); - await expect(page.locator("#workspace-title")).toHaveText("Alpha workspace"); - await expect(page.locator("#chat-welcome")).toBeVisible(); - await expect(page.locator("#run-options")).not.toHaveAttribute("open"); - await expect(page.locator("#details-group")).not.toHaveAttribute("open"); - await expect(page.locator("#connections-group")).not.toHaveAttribute("open"); - await expect(page.locator("#settings-group")).not.toHaveAttribute("open"); - await expect(page.locator("#workspace-tools")).toBeHidden(); - await expect(page.locator("#memory-group")).not.toHaveAttribute("open"); - await expect(page.locator("#repository-list a")).toHaveCount(2); - await expect(page.locator("#repository-list a").first()).toHaveAttribute("aria-current", "page"); - await expect(page.locator("#repository-list a").last()).toHaveAttribute("href", "http://127.0.0.1:48555/"); - await page.getByRole("button", { name: "Validate a browser workflow", exact: true }).click(); - await expect(page.locator("#request")).toHaveValue("Validate this browser workflow: "); - await expect(page.locator("input[name=mode][value=playwright-only]")).toBeChecked(); - await page.getByRole("button", { name: "Settings", exact: true }).click(); - await expect(page.locator("#workspace-tools")).toBeVisible(); - await expect(page.locator("#settings-group")).toHaveAttribute("open", ""); - await page.getByRole("button", { name: "Back to chat", exact: true }).click(); - await expect(page.locator("#workspace-tools")).toBeHidden(); - await expect(page.locator("#request")).toBeFocused(); - await expect(page.locator("#request")).toHaveValue("Validate this browser workflow: "); - await page.screenshot({ path: test.info().outputPath("chat-workspace-desktop.png"), fullPage: true }); - await page.setViewportSize({ width: 390, height: 844 }); - await expect(page.locator("#repository-navigation")).toBeHidden(); - await expect(page.locator("#start-button")).toBeInViewport(); - await page.locator("#navigation-toggle").click(); - await expect(page.locator("#navigation-toggle")).toHaveAttribute("aria-expanded", "true"); - await expect(page.locator("#repository-list")).toBeVisible(); - await page.locator('.workspace-navigation [data-open="settings-group"]').click(); - await expect(page.locator("#navigation-toggle")).toHaveAttribute("aria-expanded", "false"); - await expect(page.locator("#settings-group")).toHaveAttribute("open", ""); - await page.locator("#close-workspace-tools").click(); - await expect(page.locator("#request")).toBeFocused(); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); - expect(mutations).toEqual([]); - await page.screenshot({ path: test.info().outputPath("chat-workspace-mobile-composer.png"), fullPage: true }); -}); - -test("repository memory shows saved outcomes safely and selecting one never resumes it", async ({ page }) => { - let posts = 0; - page.on("request", (request) => { if (request.method() === "POST") { posts++; } }); - const saved = { - id: "saved-chat", request: " Synthetic remembered work", - status: "blocked", requestType: "bug-fix", targetEnvironment: "dev", - stages: { diagnosis: { status: "passed", message: "Synthetic diagnosis complete" } }, - bugs: [], logs: [], activity: [], canResume: false, canComment: false - }; - await mockChatWorkspace(page, { history: [saved] }); - await page.goto(baseUrl); - await page.locator('.workspace-navigation [data-open="memory-group"]').click(); - await expect(page.locator("#repository-memory-list")).toContainText("Synthetic remembered work"); - await expect(page.locator("#repository-memory-list")).toContainText("1 passed stage(s)"); - await page.locator("#repository-memory-list button").click(); - await expect(page.locator("#chat-thread .user-message")).toContainText(saved.request); - await expect(page.locator("#chat-thread img")).toHaveCount(0); - await expect(page.locator("#job-input-panel")).toBeHidden(); - await page.locator("#request").fill("Keep this unsent draft"); - await page.locator("#new-conversation").click(); - await expect(page.locator("#chat-welcome")).toBeVisible(); - await expect(page.locator("#chat-thread")).toBeEmpty(); - await expect(page.locator("#request")).toHaveValue("Keep this unsent draft"); - expect(posts).toBe(0); -}); - -test("chat keeps manual permissions inline and mobile layout within the viewport", async ({ page }) => { - let grants = 0; - page.on("request", (request) => { - if (request.method() === "POST") { grants++; } - }); - await mockChatWorkspace(page, { job: { - id: "waiting-chat", request: "Synthetic browser validation", - requestType: "bug-fix", mode: "playwright-only", status: "running", - stages: {}, bugs: [], logs: [], activity: [], - runtimeRequests: [{ id: "manual-read", kind: "permission", canApprove: true, - title: "Read synthetic source", details: "Read the selected synthetic file once." }] - } }); - await page.setViewportSize({ width: 390, height: 844 }); - await page.goto(baseUrl); - await expect(page.locator("#conversation-state-title")).toHaveText("Waiting for your permission"); - await page.locator("#needs-response").click(); - await expect(page.locator("#runtime-request-panel")).toBeVisible(); - await expect(page.getByRole("button", { name: "Allow once", exact: true })).toBeFocused(); - await expect(page.getByRole("button", { name: "Deny", exact: true })).toBeVisible(); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); - expect(grants).toBe(0); - await page.screenshot({ path: test.info().outputPath("chat-workspace-mobile.png"), fullPage: true }); -}); - -test("queue navigation opens the inspector at the first card without changing jobs", async ({ page }) => { - let posts = 0; - page.on("request", (request) => { if (request.method() === "POST") { posts++; } }); - const job = { id: "queue-current", request: "Synthetic blocked browser check", - requestType: "bug-fix", mode: "playwright-only", status: "blocked", - stages: {}, bugs: [], logs: [], activity: [], canResume: false }; - await mockChatWorkspace(page, { job, queue: [{ - id: "queue-next", request: "Synthetic next bug", requestType: "bug-fix", - mode: "playwright-only", status: "queued", position: 1, bugCount: 1 - }] }); - await page.goto(baseUrl); - await expect(page.locator("#queue-nav-count")).toHaveText("1"); - await expect(page.locator("#details-group")).not.toHaveAttribute("open"); - await page.locator("[data-open=queue-panel]").click(); - await expect(page.locator("#details-group")).toHaveAttribute("open", ""); - await expect(page.locator("#queue-panel")).toBeInViewport(); - await expect(page.locator("#details-view > :first-child")).toHaveAttribute("id", "queue-panel"); - await expect(page.locator("#queue-list")).toContainText("Waiting #1"); - await expect(page.locator("#queue-list")).toContainText("Synthetic next bug"); - expect(posts).toBe(0); -}); - -test("browser-only retry preserves browser scope and makes its prerequisite blocker visible", async ({ page }) => { - const job = { id: "browser-busy-chat", request: "Synthetic browser-only check", - requestType: "bug-fix", mode: "playwright-only", status: "blocked", - stages: { "live-test": { status: "blocked", message: "Synthetic browser is busy" } }, - bugs: [], logs: [], activity: [], canResume: true, canComment: false, - browserBlocker: { kind: "browser-profile-busy", pids: [777], - message: "Synthetic profile is in use by browser PID 777. Preserve unsaved data before closing it." } }; - let submitted; - await mockChatWorkspace(page, { job }); - await page.route("**/api/job/input", async (route) => { - submitted = route.request().postDataJSON(); - await route.fulfill({ status: 409, contentType: "application/json", - body: JSON.stringify({ error: job.browserBlocker.message }) }); - }); - await page.goto(baseUrl); - await expect(page.locator("#conversation-state-title")).toHaveText("Browser launch blocked; no retry running"); - await expect(page.locator("#conversation-state-detail")).toContainText("Preserve unsaved data"); - await page.locator("#retry-live-test").click(); - await expect(page.locator("#form-error")).toContainText("browser PID 777"); - expect(submitted.action).toBe("retry"); - expect(submitted.details).toContain("retained same-job script"); - expect(submitted.details).toContain("Do not retry an unchanged profile lock"); - expect(submitted.details).not.toContain("continue to pull-request creation"); -}); - -function pendingApprovalJob(commands) { - return { - id: "synthetic-pending-batch", request: "Synthetic pending permission review", - requestType: "bug-fix", mode: "playwright-only", status: "running", - stages: {}, bugs: [], logs: [], activity: [], canResume: false, - runtimeRequests: commands.map((command) => ({ - ...runtimePermissionPrompt({ kind: "shell", fullCommandText: command }), id: randomUUID() - })) - }; -} - -test("current-job full screen ignores historical selection, follows the live job and restores the prior view", async ({ page }) => { - const posts = []; - let historicalEvidence; - page.on("request", (request) => { if (request.method() === "POST") { posts.push(request.url()); } }); - const longRequest = "Detailed expected behavior and evidence. ".repeat(80) + - "https://example.test/" + "long-path".repeat(40); - const job = { id: "fullscreen-current", request: `Current synthetic live journey ${longRequest}`, - requestType: "bug-fix", mode: "playwright-only", status: "running", - stages: { "live-test": { status: "running", message: "Current stage sentinel" } }, - bugs: [], logs: [], activity: [] }; - const saved = { ...job, id: "fullscreen-history", request: "Historical synthetic journey", status: "passed" }; - await mockChatWorkspace(page, { job, history: [saved] }); - await page.route(/\/api\/playwright\/artifacts\?jobId=/, (route) => { - const id = new URL(route.request().url()).searchParams.get("jobId"); - if (id === saved.id && !historicalEvidence) { - historicalEvidence = route; - return; - } - return route.fulfill({ contentType: "application/json", body: JSON.stringify({ artifacts: [{ - kind: "screenshot", name: `${id}.png`, relativePath: `test-results/${id}.png`, url: `/synthetic-${id}.png` - }] }) }); - }); - await page.route("**/synthetic-*.png", (route) => route.fulfill({ - contentType: "image/png", body: Buffer.from(loadedPng, "base64") - })); - await page.goto(baseUrl); - await page.locator('.workspace-navigation [data-open="memory-group"]').click(); - await page.locator("#repository-memory-list button").click(); - await expect(page.locator("#chat-thread .user-message")).toContainText(saved.request); - await expect.poll(() => Boolean(historicalEvidence)).toBe(true); - await page.locator("#current-job-fullscreen").click(); - await expect(page.locator("#current-job-dialog")).toBeVisible(); - await expect(page.locator("#current-job-dialog-title")).toContainText(job.request); - const expectCompactHeader = async () => { - const title = page.locator("#current-job-dialog-title"); - await expect(title).toHaveAttribute("title", `Current job: ${job.request}`); - const layout = await title.evaluate((element) => { - const bounds = element.getBoundingClientRect(); - const exit = document.querySelector("#current-job-fullscreen-close").getBoundingClientRect(); - return { - height: bounds.height, lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight), - scrollHeight: element.scrollHeight, titleRight: bounds.right, - exitLeft: exit.left, exitRight: exit.right, viewportWidth: window.innerWidth - }; - }); - expect(layout.height).toBeLessThanOrEqual(layout.lineHeight * 2 + 1); - expect(layout.scrollHeight).toBeGreaterThan(layout.height); - expect(layout.exitLeft).toBeGreaterThanOrEqual(layout.titleRight); - expect(layout.exitRight).toBeLessThanOrEqual(layout.viewportWidth); - await expect(page.locator("#current-job-fullscreen-close")).toBeVisible(); - }; - await expectCompactHeader(); - await expect(page.locator("#current-job-dialog #details-view")).toHaveCount(1); - await expect(page.locator("#current-job-dialog #stages")).toContainText("Current stage sentinel"); - await expect(page.locator("#current-job-dialog img").first()).toHaveAttribute("src", "/synthetic-fullscreen-current.png"); - await historicalEvidence.fulfill({ status: 400, contentType: "application/json", - body: JSON.stringify({ error: "Stale historical evidence sentinel" }) }); - await expect(page.locator("#playwright-evidence-guidance")).toContainText("1 evidence artifact(s)"); - job.id = "fullscreen-next"; - job.request = `Next synthetic live journey ${longRequest}`; - await expect(page.locator("#current-job-dialog-title")).toContainText(job.request); - await expect(page.locator("#current-job-dialog img").first()).toHaveAttribute("src", "/synthetic-fullscreen-next.png"); - await page.setViewportSize({ width: 390, height: 844 }); - const dimensions = await page.locator("#current-job-dialog").evaluate((element) => ({ - width: element.getBoundingClientRect().width, overflow: element.scrollWidth > element.clientWidth - })); - expect(dimensions.width).toBe(390); - expect(dimensions.overflow).toBe(false); - await expectCompactHeader(); - await page.keyboard.press("Escape"); - await expect(page.locator("#current-job-dialog")).not.toBeVisible(); - await expect(page.locator("#details-group #details-view")).toHaveCount(1); - await expect(page.locator("#chat-thread .user-message")).toContainText(saved.request); - await expect(page.locator("#current-job-fullscreen")).toBeFocused(); - expect(posts).toEqual([]); -}); - -test("local repository chat onboarding and settings require a reviewed apply and never start a job", async ({ page }) => { - test.setTimeout(90_000); - const directory = mkdtempSync(join(tmpdir(), "fixlab-chat-onboarding-")); - const git = spawnSync("git", ["init", "--quiet"], { cwd: directory, windowsHide: true, encoding: "utf8" }); - expect(git.status).toBe(0); - let launches = 0; - const instance = createDashboardServer({ - repository: directory, packageRoot, browserOpener() {}, - executor() { launches++; throw new Error("Setup must not start an executor"); } - }); - const startedJobs = []; - page.on("request", (request) => { - if (request.method() === "POST" && new URL(request.url()).pathname === "/api/jobs") { startedJobs.push(request.url()); } - }); - const { REPOSITORY_SETTING_FIELDS } = await import(pathToFileURL(join(packageRoot, "dashboard", "repository-settings.js")).href); - try { - const address = await instance.listen({ port: 0 }); - await page.goto(address.url); - await page.locator("#add-local-repository").click(); - await page.locator("#repository-onboarding-answer").fill(directory); - await page.locator("#repository-onboarding-form button[type=submit]").click(); - for (const field of REPOSITORY_SETTING_FIELDS.filter((entry) => !entry.when)) { - await expect(page.locator("#repository-onboarding-label")).toHaveText(field.label); - if (field.key === "name" || field.key === "branchUserId") { - await page.locator("#repository-onboarding-answer").fill(field.key === "name" ? "Configured in chat" : "synthetic-user"); - await page.locator("#repository-onboarding-form button[type=submit]").click(); - } else { - await page.locator("#repository-onboarding-suggested").click(); - } - } - await expect(page.locator("#repository-onboarding-review")).toBeVisible(); - await expect(page.locator("#repository-onboarding-plan")).toContainText("Configured in chat"); - const profilePath = join(directory, ".github", "fixlab", "repository-profile.json"); - expect(() => readFileSync(profilePath)).toThrow(); - page.once("dialog", (dialog) => dialog.dismiss()); - await page.locator("#repository-onboarding-apply").click(); - expect(() => readFileSync(profilePath)).toThrow(); - page.once("dialog", (dialog) => dialog.accept()); - await page.locator("#repository-onboarding-apply").click(); - await expect(page.locator("#repository-onboarding-open")).toBeVisible(); - expect(JSON.parse(readFileSync(profilePath, "utf8")).name).toBe("Configured in chat"); - await page.locator("#repository-onboarding-close").click(); - await page.locator("[data-open=settings-group]").click(); - await page.locator("#repository-settings-load").click(); - await page.locator("#repository-settings-fields input[name=name]").fill("Updated repository settings"); - let releasePreview; - const previewReady = new Promise((resolve) => { releasePreview = resolve; }); - await page.route("**/api/repository/configuration", async (route) => { - const body = route.request().postDataJSON(); - if (body.action === "preview" && body.install === false) { await previewReady; } - await route.continue(); - }); - await page.locator("#repository-settings-form button[type=submit]").click(); - try { - await expect(page.locator("#repository-settings-fields input[name=name]")).toBeDisabled(); - await expect(page.locator("#repository-settings-load")).toBeDisabled(); - await expect(page.locator("#repository-settings-apply")).toBeDisabled(); - } finally { - releasePreview(); - } - await expect(page.locator("#repository-settings-review")).toBeVisible(); - await expect(page.locator("#repository-settings-fields input[name=name]")).toBeEnabled(); - expect(JSON.parse(readFileSync(profilePath, "utf8")).name).toBe("Configured in chat"); - page.once("dialog", (dialog) => dialog.accept()); - await page.locator("#repository-settings-apply").click(); - await expect(page.locator("#repository-settings-message")).toContainText("Repository settings saved"); - expect(JSON.parse(readFileSync(profilePath, "utf8")).name).toBe("Updated repository settings"); - expect(launches).toBe(0); - expect(startedJobs).toEqual([]); - } finally { - await instance.close(); - rmSync(directory, { recursive: true, force: true }); - } -}); - -test("pending approval batch sends nothing when the reviewed snapshot becomes stale", async ({ page }) => { - const job = pendingApprovalJob(['echo ""', "npm run build"]); - const posts = []; - await mockChatWorkspace(page, { job }); - await page.route("**/api/job/runtime-response", (route) => { - posts.push(route.request().postDataJSON()); - return route.fulfill({ status: 409, contentType: "application/json", - body: JSON.stringify({ error: "No stale approval should have been sent." }) }); - }); - await page.goto(baseUrl); - await page.locator("#runtime-batch-summary").click(); - await expect(page.locator("#runtime-batch-requests")).toContainText(""); - await expect(page.locator("#runtime-batch-requests img")).toHaveCount(0); - page.once("dialog", async (dialog) => { - job.runtimeRequests = [job.runtimeRequests[0]]; - await dialog.accept(); - }); - await page.locator("#runtime-allow-pending").click(); - await expect(page.locator("#runtime-batch-result")).toContainText("No approvals were sent."); - await expect(page.locator("#runtime-batch-result")).toContainText("permissions or live job changed"); - expect(posts).toEqual([]); -}); - -test("pending approval batch reports partial failure and never retries or sends the remaining request", async ({ page }) => { - const job = pendingApprovalJob(["npm test", "npm run build", "npm run lint"]); - const reviewedIds = job.runtimeRequests.map((request) => request.id); - const posts = []; - await mockChatWorkspace(page, { job }); - await page.route("**/api/job/runtime-response", async (route) => { - posts.push(route.request().postDataJSON()); - if (posts.length === 1) { - job.runtimeRequests = job.runtimeRequests.filter((request) => request.id !== reviewedIds[0]); - await route.fulfill({ contentType: "application/json", body: JSON.stringify({ job }) }); - } else { - await route.fulfill({ status: 409, contentType: "application/json", - body: JSON.stringify({ error: "Synthetic second request became stale." }) }); - } - }); - await page.goto(baseUrl); - await page.locator("#runtime-batch-summary").click(); - page.once("dialog", (dialog) => dialog.accept()); - await page.locator("#runtime-allow-pending").click(); - await expect(page.locator("#runtime-batch-result")).toContainText("Batch stopped after 1 confirmed approval(s) out of 3"); - await expect(page.locator("#runtime-batch-result")).toContainText("No automatic retry was made"); - expect(posts).toEqual(reviewedIds.slice(0, 2).map((requestId) => ({ - instanceId: "workspace-owner", jobId: job.id, requestId, action: "allow", confirmed: true - }))); -}); diff --git a/package-lock.json b/package-lock.json index 57ea8de..1769332 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fixlab/cli", - "version": "0.7.4", + "version": "0.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fixlab/cli", - "version": "0.7.4", + "version": "0.7.3", "license": "MIT", "dependencies": { "@github/copilot-sdk": "1.0.23" diff --git a/package.json b/package.json index 2fc95e5..bf84487 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fixlab/cli", - "version": "0.7.4", + "version": "0.7.3", "description": "Copilot-powered CLI and plugin for evidence-backed React and .NET defect validation.", "license": "MIT", "homepage": "https://github.com/MonaDevAI/FixLab#readme", diff --git a/plugin.json b/plugin.json index e5abcda..b08b26e 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "fixlab", - "version": "0.7.4", + "version": "0.7.3", "description": "Evidence-backed defect diagnosis, repair, and validation for React and .NET repositories.", "homepage": "https://github.com/MonaDevAI/FixLab", "repository": "https://github.com/MonaDevAI/FixLab", diff --git a/templates/fixlab-autofix.agent.md b/templates/fixlab-autofix.agent.md index 50f454c..967c25a 100644 --- a/templates/fixlab-autofix.agent.md +++ b/templates/fixlab-autofix.agent.md @@ -100,19 +100,6 @@ validation with identical command and repository state. Never reuse startup, health, authentication, browser or live-environment checks, external-data checks, dependency installation, deployments, or mutating commands. -Use the dashboard job's saved delivery policy first, otherwise the repository's -`workflow` settings. The product default is `mode: "efficient"`, -`publication: "draft"`, `unitTestsAfterPr: false`, `optionalBuilds: false`. Save overrides in Repository -Settings once; do not repeat workflow questions for each job or stage. -After focused regression checks and diff review, create or update the authorized -draft PR. Ready-for-review publication requires explicit `publication: "ready"`. -Broad tests and builds run only by saved preference or explicit selection; -update the same PR with actual results. -Explicit `validation.gates[].required: true`, repository instructions, managed -approvals and CI/merge policies remain mandatory. Pending or failed optional -checks remain pending or failed; publication never authorizes merging. -An explicit saved strict workflow retains the following draft-first behavior: - When the developer authorizes pull-request publication, do not wait for every independent validation command to finish before creating review visibility. After the scoped production change and its smallest focused regression checks @@ -123,10 +110,7 @@ push later reviewed checkpoints to the same branch, and never mark the PR ready until every required local gate passes or the developer explicitly accepts the recorded outcome. -Honor an explicit publication choice in every mode: strict plus draft stays -draft, with provider `isDraft: true` verified. Legacy strict policy with no -publication preference retains ready intent. Strict required checks never change. -Only for saved ready publication (including that legacy default), after required local validation and approval gates pass or have an explicitly +After required local validation and approval gates pass or have an explicitly accepted outcome, publish the authorized PR by marking it ready for review. Do not wait for remote PR CI or build completion before publication; publication can trigger those checks. Verify the PR is no longer draft before reporting the @@ -136,16 +120,6 @@ policies intact. Use `gh pr ready ` for GitHub or `isDraft` from the provider to confirm it is false. If publication or state verification fails, report the PR stage blocked or failed, not passed. -Reduce redundant regression cases, not correctness. Reuse existing test files -and fixtures; move punctuation, length and duplicate matrices to fast pure -unit checks. Browser coverage uses representative distinct flows, preserving -safety, permissions, lease/queue and real bug reproductions. Measure comparable -baseline and final elapsed time; never claim savings from count alone. -Keep Jest and Playwright discovery separate. Bare Jest may collect E2E specs -and bare Playwright may select all projects: use repository-owned unit selectors -and explicitly selected authenticated browser projects. Do not silently edit -consumer repositories to repair discovery. - Prefix FixLab-created pull-request titles with `FLAB:` followed by a concise description of the coding change. Use the repository's PR template and clear Markdown sections for the problem, changes, validation and risks. Keep @@ -153,18 +127,6 @@ validation checkboxes accurate; do not describe a pending gate as passed. ## Connected dashboard recovery -Use normal Copilot reasoning and native tools; stage markers report work, not -a recipe requiring extra commands. For explicit browser-only reproduction or -deployed-result checks, select `playwright-only` and first reuse an approved -existing application and authorized validation session when supported. Skip -unnecessary local-stack work honestly; do not clone, restore or run broad -unit/build gates merely to repeat deployed behavior. Never copy browser state, -bypass a profile lock, take over another browser or silently change targets. -Keep sign-in and mutation guards. A deployed baseline does not prove local -changes or a PR; those require the matching source commit and application. -For fix requests, start with the smallest known reproduction and prepare -applications only when needed to exercise changed code. - Use `fixlab_dashboard_control` for user-authorized job/queue, configured gate, frontend/authentication session, scoped permission and intake operations. Read status first and bind its instance and current job. Do not ask again for @@ -183,9 +145,7 @@ existing PR and preserve uncommitted work. A changed fingerprint requires a new review. Folder permissions and human authentication remain separate. An idle, unconnected Copilot session cannot receive injected questions. Honor user-selected scoped iteration: full regression is explicitly deferred, -not passed; in strict workflow PR readiness stays blocked, whereas efficient -workflow permits publication after focused checks and explicitly required -pre-publication gates. Continue independent safe checks +not passed, and PR readiness stays blocked. Continue independent safe checks after unrelated failures without bypassing dependencies. Do not delete or edit transient test artifacts while validation is discovering or executing them; wait for the owned validation to finish before cleanup. diff --git a/templates/repository-profile.json b/templates/repository-profile.json index 29eaaf2..a4e1d23 100644 --- a/templates/repository-profile.json +++ b/templates/repository-profile.json @@ -1,11 +1,5 @@ { "version": 1, - "workflow": { - "mode": "efficient", - "unitTestsAfterPr": false, - "publication": "draft", - "optionalBuilds": false - }, "name": "Example React and .NET repository", "architecture": "React frontend with ASP.NET Core backend", "components": [ diff --git a/test/dashboard-auth-resume.test.js b/test/dashboard-auth-resume.test.js index 85d013d..bd28511 100644 --- a/test/dashboard-auth-resume.test.js +++ b/test/dashboard-auth-resume.test.js @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { runInNewContext } from "node:vm"; const appSource = readFileSync( new URL("../dashboard/public/app.js", import.meta.url), @@ -39,46 +38,17 @@ test("connecting Playwright starts the frontend first", () => { assert.match(appSource, /\/api\/frontend\/start/u); }); -test("the live test is retried only after successful authentication", async () => { +test("the live test is retried only after successful authentication", () => { const start = appSource.indexOf("async function resumeAfterConnect"); const body = appSource.slice( start, - appSource.indexOf("\nfunction renderFrontendStatus", start) + appSource.indexOf("async function refreshPlaywrightStatus") + ); + assert.match(body, /status\.running/u); + assert.match(body, /if \(!result\.ok\)/u); + assert.match(body, /activeJob\?\.id === jobId/u); + assert.match(body, /activeJob\.status === "blocked"/u); + assert.ok( + body.indexOf("if (!result.ok)") < body.indexOf("await retryLiveTest()") ); - assert.ok(start >= 0 && body.includes("await retryLiveTest()")); - for (const scenario of [ - { running: true, ready: true, ok: true, expected: 0, pending: "job" }, - { ready: true, ok: false, expected: 0 }, - { ready: false, ok: true, expected: 0 }, - { ready: true, ok: true, jobId: "other", expected: 0 }, - { ready: true, ok: true, jobStatus: "completed", expected: 0 }, - { ready: true, ok: true, expected: 1 } - ]) { - let retries = 0; - const context = { - pendingRetryAfterConnectJobId: "job", - activeJob: { - id: scenario.jobId ?? "job", - status: scenario.jobStatus ?? "blocked" - }, - formError: { textContent: "" }, - retryLiveTest: async () => { - retries += 1; - } - }; - const resume = runInNewContext(`(${body})`, context); - await resume({ - running: scenario.running ?? false, - ready: scenario.ready, - lastResult: { ok: scenario.ok, message: "Authentication finished." } - }); - assert.equal(retries, scenario.expected, JSON.stringify(scenario)); - assert.equal( - context.pendingRetryAfterConnectJobId, - scenario.pending ?? null - ); - if (!scenario.ok || !scenario.ready) { - assert.match(context.formError.textContent, /readiness is unverified/u); - } - } }); diff --git a/test/dashboard-browser-ownership.test.js b/test/dashboard-browser-ownership.test.js deleted file mode 100644 index 5cf4c85..0000000 --- a/test/dashboard-browser-ownership.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; -import { inspectBrowserProfileOwnership } from "../dashboard/browser-ownership.js"; - -test("browser ownership matches only declared profile directories and ignores subprocesses", () => { - const root = mkdtempSync(join(tmpdir(), "fixlab-browser-owner-")); - try { - const profile = join(root, "profile with spaces"); - const other = join(root, "other-profile"); - mkdirSync(profile); - mkdirSync(other); - writeFileSync(join(root, "auth-state.json"), "{}"); - const configuration = { workingDirectory: root, statusPaths: ["profile with spaces", "auth-state.json"] }; - const blocker = inspectBrowserProfileOwnership(configuration, { - query: () => [ - { pid: 111, commandLine: `msedge --user-data-dir="${profile}" --enable-automation` }, - { pid: 112, commandLine: `msedge --user-data-dir="${profile}" --type=renderer` }, - { pid: 113, commandLine: `msedge --user-data-dir="${other}"` } - ] - }); - assert.deepEqual(blocker.pids, [111]); - assert.equal(blocker.kind, "browser-profile-busy"); - assert.match(blocker.message, /No process was stopped/); - assert.doesNotMatch(JSON.stringify(blocker), /auth-state|profile with spaces|commandLine/); - assert.equal(inspectBrowserProfileOwnership(configuration, { query: () => [] }), null); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("browser ownership never reads authentication files and does not inspect processes without a declared profile", () => { - const root = mkdtempSync(join(tmpdir(), "fixlab-browser-state-")); - try { - writeFileSync(join(root, "state.json"), "synthetic non-JSON sentinel, not authentication"); - assert.equal(inspectBrowserProfileOwnership({ - workingDirectory: root, statusPaths: ["state.json", "missing-directory"] - }, { query() { throw new Error("Process inspection must not run"); } }), null); - assert.throws(() => inspectBrowserProfileOwnership({ - workingDirectory: root, statusPaths: [".."] - }), /within.*working directory/); - mkdirSync(join(root, "profile")); - assert.throws(() => inspectBrowserProfileOwnership({ - workingDirectory: root, statusPaths: ["profile"] - }, { query() { throw new Error("Synthetic process inspection failure"); } }), /inspection failure/); - assert.throws(() => inspectBrowserProfileOwnership({ - workingDirectory: root, statusPaths: ["profile"] - }, { query: () => [{ pid: "invalid", commandLine: "" }] }), /invalid process metadata/); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); diff --git a/test/dashboard-preflight.test.js b/test/dashboard-preflight.test.js index 1680219..a277573 100644 --- a/test/dashboard-preflight.test.js +++ b/test/dashboard-preflight.test.js @@ -32,25 +32,6 @@ test("preflight passes when every required tool resolves", () => { assert.deepEqual(runJobPreflight({ profile, resolve: () => true }), []); }); -test("browser-only preflight checks only its runner and required authentication, not local-stack tools", () => { - const resolved = []; - const browserProfile = { ...profile, - applications: { frontend: { command: "npm start" }, backend: { command: "dotnet run" } }, - browserAutomation: { - testCommand: "npx playwright test", - authentication: { required: false, command: "pnpm login-browser" } - } - }; - assert.deepEqual(runJobPreflight({ profile: browserProfile, mode: "playwright-only", - resolve: (tool) => { resolved.push(tool); return tool === "npx"; } }), []); - assert.deepEqual(resolved, ["npx"]); - browserProfile.browserAutomation.authentication.required = true; - const issues = runJobPreflight({ profile: browserProfile, mode: "playwright-only", - resolve: (tool) => tool === "npx" }); - assert.deepEqual(issues.map((issue) => issue.check), ["pnpm on PATH"]); - assert.ok(runJobPreflight({ profile: browserProfile, mode: "fix-and-validate", - resolve: (tool) => tool !== "dotnet" }).some((issue) => issue.check === "dotnet on PATH")); -}); test("preflight ignores commands it does not recognise and missing profiles", () => { const unknown = { validation: { commands: { frontendRestore: "make deps" } } }; assert.deepEqual(runJobPreflight({ profile: unknown, resolve: () => false }), []); diff --git a/test/dashboard-repository-settings.test.js b/test/dashboard-repository-settings.test.js deleted file mode 100644 index d77cc3d..0000000 --- a/test/dashboard-repository-settings.test.js +++ /dev/null @@ -1,144 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; -import { applyRepositorySettings, inspectRepositorySettings, localRepository, planRepositorySettings } from "../dashboard/repository-settings.js"; -import { canonicalExecutionRepository, createExecutorLease } from "../dashboard/executor-lease.js"; -import { SETUP_PROFILE_PATH } from "../dashboard/repository-setup.js"; -import { validateLiveTestProfile } from "../dashboard/server.js"; - -const packageRoot = fileURLToPath(new URL("..", import.meta.url)); -function fixture() { - const repository = mkdtempSync(join(tmpdir(), "fixlab-settings-")); - const initialized = spawnSync("git", ["init", "--quiet"], { - cwd: repository, windowsHide: true, encoding: "utf8" - }); - assert.equal(initialized.status, 0, initialized.stderr); - return repository; -} -function plan(repository, overrides = {}, install = true) { - const inspected = inspectRepositorySettings(repository); - return planRepositorySettings({ repository, revision: inspected.revision, - values: { ...inspected.values, branchUserId: "synthetic-user", ...overrides }, - install, packageRoot, validate: validateLiveTestProfile }); -} - -test("repository onboarding previews without writes and creates only missing reviewed scaffolding", () => { - const repository = fixture(); - try { - assert.equal(localRepository(repository), repository); - const preview = plan(repository); - assert.equal(existsSync(join(repository, ".github")), false); - assert.equal(preview.files.length, 10); - const applied = applyRepositorySettings(preview, "synthetic-settings-owner"); - assert.equal(applied.saved, true); - const profile = JSON.parse(readFileSync(join(repository, SETUP_PROFILE_PATH), "utf8")); - assert.equal(profile.pullRequests.branchNaming.userId, "synthetic-user"); - assert.deepEqual(profile.workflow, { - mode: "efficient", publication: "draft", unitTestsAfterPr: false, optionalBuilds: false - }); - assert.equal(profile.browserAutomation.dataSafety.productionAllowed, false); - assert.equal(validateLiveTestProfile(profile).ok, true); - assert.equal(existsSync(join(repository, ".github", "agents", "fixlab-autofix.agent.md")), true); - assert.equal(existsSync(canonicalExecutionRepository(repository).directory), false); - assert.equal(plan(repository).assets.length, 0); - } finally { - rmSync(repository, { recursive: true, force: true }); - } -}); - -test("repository settings preserve undisplayed configuration and never expose environment values", () => { - const repository = fixture(); - try { - applyRepositorySettings(plan(repository), "synthetic-settings-owner"); - const path = join(repository, SETUP_PROFILE_PATH); - const profile = JSON.parse(readFileSync(path, "utf8")); - profile.browserAutomation.authentication.environment = { SYNTHETIC_PRIVATE: "noncredential-private-sentinel" }; - profile.pullRequests.requireConfirmation = false; - profile.workflow.publication = "ready"; - profile.customContract = { retained: true }; - profile.environments = { dev: { syntheticContract: "preserved" } }; - writeFileSync(path, JSON.stringify(profile)); - const inspected = inspectRepositorySettings(repository); - assert.doesNotMatch(JSON.stringify(inspected), /noncredential-private-sentinel|SYNTHETIC_PRIVATE/); - const preview = plan(repository, { name: "Updated synthetic repository", - efficientWorkflow: false, optionalBuilds: true, unitTestsAfterPr: false }, false); - assert.deepEqual(preview.files, [SETUP_PROFILE_PATH]); - applyRepositorySettings(preview, "synthetic-settings-owner"); - const updated = JSON.parse(readFileSync(path, "utf8")); - assert.equal(updated.name, "Updated synthetic repository"); - assert.deepEqual(updated.workflow, { - mode: "strict", publication: "ready", unitTestsAfterPr: false, optionalBuilds: true - }); - assert.deepEqual(updated.customContract, { retained: true }); - assert.equal(updated.pullRequests.requireConfirmation, false); - assert.deepEqual(updated.environments, profile.environments); - assert.deepEqual(updated.browserAutomation.authentication.environment, profile.browserAutomation.authentication.environment); - } finally { - rmSync(repository, { recursive: true, force: true }); - } -}); - -test("repository settings reject stale plans and never replace an active executor lease", () => { - const repository = fixture(); - try { - const reviewed = plan(repository); - const lease = createExecutorLease(repository, "synthetic-active-owner").acquire("synthetic-live-job"); - try { - assert.throws(() => applyRepositorySettings(reviewed, "synthetic-settings-owner"), /lease is reserved/); - assert.equal(existsSync(join(repository, SETUP_PROFILE_PATH)), false); - } finally { - lease.release(); - } - mkdirSync(join(repository, ".github", "fixlab"), { recursive: true }); - writeFileSync(join(repository, SETUP_PROFILE_PATH), '{"name":"Concurrent edit"}'); - assert.throws(() => applyRepositorySettings(reviewed, "synthetic-settings-owner"), /changed after review/); - assert.equal(readFileSync(join(repository, SETUP_PROFILE_PATH), "utf8"), '{"name":"Concurrent edit"}'); - assert.equal(existsSync(canonicalExecutionRepository(repository).directory), false); - } finally { - rmSync(repository, { recursive: true, force: true }); - } -}); - -test("repository configuration rejects traversal, credential commands and unsafe health or environment settings", () => { - const repository = fixture(); - try { - for (const override of [ - { frontendDirectory: ".." }, - { frontendHealthUrl: "https://example.com/health" }, - { frontendHealthUrl: "http://user:password@127.0.0.1:3000" }, - { frontendCommand: "npm --token synthetic-value" }, - { environments: "local,production" }, - { branchPrefix: "../outside" }, - { unexpected: "Not an editable field" }, - { authenticationRequired: true, authenticationCommand: "npm run auth", authenticationPaths: "../outside" } - ]) { - assert.throws(() => plan(repository, override), undefined, JSON.stringify(override)); - } - const inspected = inspectRepositorySettings(repository); - assert.throws(() => planRepositorySettings({ repository, revision: "stale", - values: inspected.values, install: false, packageRoot, validate: validateLiveTestProfile }), /changed/); - const subdirectory = join(repository, "subdirectory"); - mkdirSync(subdirectory); - assert.throws(() => localRepository(subdirectory), /root of an existing Git/); - assert.equal(existsSync(join(repository, SETUP_PROFILE_PATH)), false); - } finally { - rmSync(repository, { recursive: true, force: true }); - } -}); - -test("repository settings refuse symlinked scaffolding instead of writing outside the selected repository", () => { - const repository = fixture(); - const outside = mkdtempSync(join(tmpdir(), "fixlab-settings-outside-")); - try { - symlinkSync(outside, join(repository, ".github"), process.platform === "win32" ? "junction" : "dir"); - assert.throws(() => inspectRepositorySettings(repository), /symbolic links/); - assert.equal(existsSync(join(outside, "fixlab")), false); - } finally { - rmSync(repository, { recursive: true, force: true }); - rmSync(outside, { recursive: true, force: true }); - } -}); diff --git a/test/dashboard-runtime-policy.test.js b/test/dashboard-runtime-policy.test.js deleted file mode 100644 index cfd9122..0000000 --- a/test/dashboard-runtime-policy.test.js +++ /dev/null @@ -1,761 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { linkSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { randomUUID } from "node:crypto"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; -import { runCopilotSession } from "../dashboard/copilot-session.js"; -import { createRuntimePermissionPolicy, validateJobFileAccess } from "../dashboard/runtime-permission-policy.js"; -import { MAX_RUNTIME_FRAME_BYTES, runtimePermissionPrompt } from "../dashboard/runtime-requests.js"; -import { createPublicationPermissions, publicationCommand, publicationDestination } from "../dashboard/publication-permissions.js"; - -function fixture(t) { - const directory = mkdtempSync(join(tmpdir(), "fixlab-runtime-policy-")); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const repository = join(realpathSync.native(directory), "repo"); - mkdirSync(join(repository, "src"), { recursive: true }); - writeFileSync(join(repository, "src", "index.ts"), "export const value = 1;\n"); - writeFileSync(join(repository, "package.json"), '{"scripts":{"test":"node --test"}}'); - const read = (path = join(repository, "src", "index.ts")) => ({ kind: "read", path }); - const command = (fullCommandText = "npm test") => ({ - kind: "shell", fullCommandText, canOfferSessionApproval: true, - possiblePaths: [], possibleUrls: [], resolvedWorkingDirectory: repository - }); - const policy = createRuntimePermissionPolicy(repository); - const allows = (request) => policy.allows(request, runtimePermissionPrompt(request)); - const scope = (request) => policy.scopeFor(request, runtimePermissionPrompt(request)); - const grant = (request, selected) => { - const prompt = runtimePermissionPrompt(request); - policy.grant(request, prompt, selected, policy.review(request, prompt)); - }; - return { directory, repository, read, command, policy, allows, scope, grant }; -} - -test("publication consent binds Azure/GitHub checkpoint transitions and leaves unsupported or stale scope manual", async (t) => { - const state = fixture(t); - const git = (...args) => { - const result = spawnSync("git", args, { cwd: state.repository, encoding: "utf8", windowsHide: true }); - assert.equal(result.status, 0, result.stderr); - return result.stdout.trim(); - }; - for (const command of [ - "npm test", "git commit --amend -m revised", "git push --force origin HEAD", - "gh pr merge 1", "az repos pr create --auto-complete true" - ]) { - assert.equal(publicationCommand(command), null); - assert.equal(createPublicationPermissions("missing").review(state.command(command), { ready: true }), null); - } - for (const url of ["git@github.com:example/repo.git", "https://other.invalid/repo", - "https://github.com/example/repo?x=1", "https://dev.azure.com/example/project%20name/_git/repo"]) { - assert.equal(publicationDestination(url), null); - } - git("init", "--quiet"); - git("config", "user.name", "FixLab Test"); - git("config", "user.email", "fixlab@example.test"); - git("checkout", "-b", "users/test/checkpoint"); - mkdirSync(join(state.repository, ".github", "fixlab"), { recursive: true }); - writeFileSync(join(state.repository, ".github", "fixlab", "repository-profile.json"), '{"version":1}'); - const deletedTest = "prodhierarchy-naming-parity.auth.spec.ts"; - writeFileSync(join(state.repository, deletedTest), "export const removed = true;\n"); - git("add", "."); - git("commit", "--quiet", "-m", "Initial fixture"); - rmSync(join(state.repository, deletedTest)); - writeFileSync(join(state.repository, "src", "index.ts"), "export const value = 2;\n"); - git("add", "--", deletedTest, "src/index.ts"); - writeFileSync(join(state.repository, "preserved-scratch.txt"), "Preserved, never staged or read by publication scope.\n"); - const contexts = [ - ["https://dev.azure.com/example/project/_git/repo", - 'az repos pr create --organization https://dev.azure.com/example --project project --repository repo --source-branch users/test/checkpoint --target-branch main --title "Scoped fix" --description "Synthetic evidence" --draft true --detect false --output json'], - ["https://github.com/example/repo.git", - 'gh pr create --repo https://github.com/example/repo --head users/test/checkpoint --base main --title "Scoped fix" --body "Synthetic evidence" --draft'] - ]; - git("remote", "add", "origin", contexts[0][0]); - const readiness = { ready: true, source: "users/test/checkpoint", target: "main", publication: "draft", revision: "fresh" }; - const alias = join(state.directory, "checkout-alias"); - symlinkSync(state.repository, alias, process.platform === "win32" ? "junction" : "dir"); - const canonicalRequest = state.command('git commit -m "Scoped fix"'); - assert.equal(canonicalRequest.resolvedWorkingDirectory, state.repository); - assert.deepEqual(canonicalRequest.possiblePaths, []); - assert.ok(createPublicationPermissions(state.repository).review(canonicalRequest, readiness)); - for (const protectedPaths of [[], [join(state.repository, "src")]]) { - assert.equal(createPublicationPermissions(alias, protectedPaths).review(canonicalRequest, readiness), null, - "an alias cannot qualify through canonical cwd metadata or miss canonical protected paths"); - } - const protectedPolicy = createPublicationPermissions(state.repository, [join(state.repository, "src")]); - assert.equal(protectedPolicy.review(state.command('git commit -m "Scoped fix"'), readiness), null); - const linked = join(state.repository, "linked-copy.ts"); - linkSync(join(state.repository, "src", "index.ts"), linked); - assert.equal(createPublicationPermissions(state.repository).review(state.command('git commit -m "Scoped fix"'), readiness), null); - rmSync(linked); - assert.equal(createPublicationPermissions(state.repository).allows( - state.command("git push origin HEAD:refs/heads/users/test/checkpoint"), readiness), false); - const policies = contexts.map(([destination]) => { - git("remote", "set-url", "origin", destination); - const policy = createPublicationPermissions(state.repository); - const request = state.command('git commit -m "Scoped fix"'); - assert.equal(policy.review(request, { ...readiness, ready: false }), null); - const reviewed = policy.review(request, readiness); - assert.ok(reviewed); - policy.grant(reviewed); - assert.equal(policy.allows({ ...request, managedApprovalRequired: true }, readiness), false); - assert.equal(policy.allows(request, { ...readiness, revision: "stale" }), false); - assert.equal(policy.allows(request, readiness), true); - return policy; - }); - git("remote", "set-url", "origin", contexts[0][0]); - let requests = 0; - await runCopilotSession({ repository: state.repository, prompt: "Synthetic Azure checkpoint." }, { - publicationContext: async () => readiness, - async ask(prompt) { - requests++; - if (requests === 1) { - assert.equal(prompt.grantScope, "publication-batch"); - return { action: "allow", scope: "publication-batch" }; - } - return { action: "deny" }; - }, - output() {}, - client: { - async start() {}, - async createSession(options) { - return { - rpc: { managedSettings: { async get() { return { failClosed: false }; } } }, - on() {}, - async sendAndWait() { - const commit = state.command('git commit -m "Scoped fix"'); - assert.equal((await options.onPermissionRequest(commit)).kind, "approve-once"); - git("commit", "--quiet", "-m", "Scoped fix"); - for (const command of ["git push origin HEAD:refs/heads/users/test/checkpoint", contexts[0][1]]) { - assert.equal((await options.onPermissionRequest(state.command(command))).kind, "approve-once"); - } - assert.equal((await options.onPermissionRequest({ ...commit, managedApprovalRequired: true })).kind, "reject"); - } - }; - }, - async stop() { return []; } - } - }); - assert.equal(requests, 2, "one consent covers commit, push and Azure PR; managed review stays manual"); - for (const [index, [destination, create]] of contexts.entries()) { - const policy = policies[index]; - git("remote", "set-url", "origin", destination); - const push = state.command("git push origin HEAD:refs/heads/users/test/checkpoint"); - assert.equal(policy.allows(push, readiness), true); - assert.equal(policy.allows(state.command(create), readiness), true); - assert.equal(policy.allows(state.command(create.replace("--target-branch main", "--target-branch other") - .replace("--base main", "--base other")), readiness), false); - assert.equal(policy.allows({ ...push, managedApprovalRequired: true }, readiness), false); - git("config", "remote.origin.pushurl", "https://github.com/other/repo"); - assert.equal(policy.allows(push, readiness), false); - git("config", "--unset", "remote.origin.pushurl"); - writeFileSync(join(state.repository, "src", "index.ts"), "export const value = 3;\n"); - assert.equal(policy.allows(push, readiness), false); - writeFileSync(join(state.repository, "src", "index.ts"), "export const value = 2;\n"); - policy.clear(); - assert.equal(policy.allows(push, readiness), false); - } -}); - -test("native filename discovery is automatic only in canonical non-sensitive repository directories", (t) => { - const state = fixture(t); - const discovery = (path = state.repository) => ({ - ...state.read(path), intention: `Search for files matching pattern in: ${path}` - }); - const automatic = (request) => state.policy.automaticApproval(request, runtimePermissionPrompt(request)); - for (const path of [state.repository, join(state.repository, "src")]) { - assert.equal(automatic(discovery(path)), "repository filename discovery (no file contents)"); - assert.equal(state.allows(state.read(path)), false, "no directory-content grant is created"); - } - for (const path of [".auth", ".git", "node_modules", "src/authentication", "test-results"]) { - const directory = join(state.repository, path); - mkdirSync(directory, { recursive: true }); - assert.equal(automatic(discovery(directory)), null); - } - symlinkSync(state.directory, join(state.repository, "linked"), "junction"); - for (const request of [ - state.read(state.repository), { ...discovery(), intention: `Search in directory: ${state.repository}` }, - { ...discovery(), intention: "Search for files matching pattern in: somewhere else" }, - discovery(state.directory), discovery(join(state.repository, "linked")), - discovery(join(state.repository, "src", "index.ts")), - discovery(join(state.repository, "missing")), - { ...discovery(), resolvedPath: state.directory } - ]) { - assert.equal(automatic(request), null, JSON.stringify(request)); - } - const protectedPolicy = createRuntimePermissionPolicy(state.repository, [join(state.repository, "src")]); - const protectedRequest = discovery(join(state.repository, "src")); - assert.equal(protectedPolicy.automaticApproval(protectedRequest, runtimePermissionPrompt(protectedRequest)), null); - const search = { ...state.read(join(state.repository, "src")), - intention: `Search in directory: ${join(state.repository, "src")}` }; - const start = (paths, extra = {}, id = "search") => state.policy.observeToolEvent({ - type: "tool.execution_start", data: { toolCallId: id, toolName: "rg", - arguments: { paths, pattern: "value", glob: "*.{js,ts}", output_mode: "content", head_limit: 25, ...extra } } - }); - for (const paths of ["src", ["src"]]) { - start(paths); - assert.equal(automatic(search), "bounded live native source search"); - } - for (const [paths, extra] of [ - [["src", "elsewhere"], {}], [state.directory, {}], - ["linked", {}], ["src", { glob: "*" }], ["src", { head_limit: 1000 }], - ["src", { follow: true }] - ]) { - start(paths, extra); - assert.equal(automatic(search), null); - } - start("src"); - start(["src"], {}, "ambiguous"); - assert.equal(automatic(search), null); - state.policy.observeToolEvent({ type: "tool.execution_complete", data: { toolCallId: "ambiguous" } }); - assert.equal(automatic({ ...search, managedApprovalRequired: true }), null); - state.policy.observeToolEvent({ type: "tool.execution_complete", data: { toolCallId: "search" } }); - assert.equal(automatic(search), null); -}); - -test("only this runtime's exact installed CLI help command is automatic", (t) => { - const state = fixture(t); - const helper = fileURLToPath(new URL("../bin/fixlab.js", import.meta.url)); - const automatic = (request) => state.policy.automaticApproval(request, runtimePermissionPrompt(request)); - for (const path of [`'${helper}'`, `"${helper}"`, ...(!helper.includes(" ") ? [helper] : [])]) { - assert.equal(automatic(state.command(`node ${path} --help`)), "installed FixLab CLI --help"); - } - const command = state.command(`node '${helper}' --help`); - assert.equal(automatic({ ...command, possiblePaths: [helper] }), "installed FixLab CLI --help"); - const otherHelper = join(state.repository, "fixlab.js"); - writeFileSync(otherHelper, "throw new Error('Must never execute');\n"); - for (const request of [ - state.command(`node '${otherHelper}' --help`), - state.command(`node '${helper}' dashboard`), - state.command(`node '${helper}' --help --eval x`), - state.command(`node '${helper}' --help; whoami`), - state.command(`node '${helper}' --help > output.txt`), - state.command(`node --require preload.js '${helper}' --help`), - { ...command, possiblePaths: [otherHelper] }, - { ...command, possiblePaths: undefined }, - { ...command, possibleUrls: ["https://example.invalid"] }, - { ...command, resolvedPaths: { [helper]: otherHelper } } - ]) { - assert.equal(automatic(request), null, JSON.stringify(request)); - } - const discovery = { - ...state.read(state.repository), - intention: `Search for files matching pattern in: ${state.repository}` - }; - for (const change of [ - { managedApprovalRequired: true }, { requestSandboxBypass: true }, - { requestSandboxPermissive: true }, { sandboxPathGrant: {} }, - { warning: "Review this risk" }, { hasWriteFileRedirection: true } - ]) { - for (const request of [command, discovery]) { - assert.equal(automatic({ ...request, ...change }), null); - } - } -}); - -test("SDK handles routine discovery and its own CLI help without prompting or granting broader access", async (t) => { - const state = fixture(t); - const helper = fileURLToPath(new URL("../bin/fixlab.js", import.meta.url)); - let options; - let observe; - let asked = 0; - const discovery = { - ...state.read(state.repository), - intention: `Search for files matching pattern in: ${state.repository}` - }; - const help = state.command(`node '${helper}' --help`); - const client = { - async start() {}, - async createSession(value) { - options = value; - return { - rpc: { managedSettings: { async get() { return { failClosed: false }; } } }, - on(callback) { observe = callback; }, - async sendAndWait() { - observe({ type: "tool.execution_start", data: { - toolCallId: "native-search", toolName: "rg", - arguments: { paths: ["src"], pattern: "value", glob: "*.ts", output_mode: "content", head_limit: 25 } - } }); - const search = { ...state.read(join(state.repository, "src")), - intention: `Search in directory: ${join(state.repository, "src")}` }; - assert.deepEqual(await options.onPermissionRequest(search), { - kind: "approve-once", approvedInteractively: false - }); - observe({ type: "tool.execution_complete", data: { toolCallId: "native-search" } }); - for (const request of [discovery, help]) { - assert.deepEqual(await options.onPermissionRequest(request), { - kind: "approve-once", approvedInteractively: false - }); - } - for (const request of [ - state.read(), state.read(state.repository), state.command("npm run build"), - { ...discovery, managedApprovalRequired: true }, - { ...help, requestSandboxBypass: true } - ]) { - assert.deepEqual(await options.onPermissionRequest(request), { kind: "reject" }); - } - } - }; - }, - async stop() { return []; } - }; - await runCopilotSession({ repository: state.repository, prompt: "Synthetic routine permissions." }, { - client, async ask() { asked++; return { action: "deny" }; }, output() {} - }); - assert.equal(asked, 5); -}); - -test("repository-file grants are opt-in, canonical and limited to non-sensitive regular files", (t) => { - const state = fixture(t); - const write = { kind: "write", fileName: join(state.repository, "src", "index.ts") }; - assert.equal(state.policy.isSourceFile(write.fileName), true); - assert.equal(state.policy.scopeFor(write, { canApprove: true }), null); - assert.equal(state.policy.allows(write, { canApprove: true }), false); - assert.equal(state.allows(state.read()), false); - assert.equal(state.scope(state.read()), "repository-reads"); - state.grant(state.read(), "repository-reads"); - assert.equal(state.allows(state.read()), true); - assert.equal(state.allows(state.read(join(state.repository, "package.json"))), true); - for (const name of [ - ".auth/state.json", ".env", ".npmrc", ".git/config", "secrets.ts", "auth.ts", - "storageState.ts", "state.json", "private.pem", "node_modules/library/index.js", - "test-results/evidence.ts" - ]) { - const path = join(state.repository, ...name.split("/")); - mkdirSync(join(path, ".."), { recursive: true }); - writeFileSync(path, "Synthetic protected fixture.\n"); - assert.equal(state.scope(state.read(path)), null, name); - assert.equal(state.allows(state.read(path)), false, name); - assert.equal(state.policy.isSourceFile(path), false, name); - } - const outside = join(state.directory, "outside.ts"); - writeFileSync(outside, "Synthetic outside fixture.\n"); - for (const path of [state.repository, join(state.repository, "src"), outside, "src/*.ts", "src/missing.ts"]) { - assert.equal(state.allows(state.read(path)), false, path); - } - symlinkSync(state.directory, join(state.repository, "src", "linked"), "junction"); - assert.equal(state.allows(state.read(join(state.repository, "src", "linked", "outside.ts"))), false); - assert.equal(state.allows({ ...state.read(), resolvedPath: outside }), false); - assert.equal(createRuntimePermissionPolicy(state.repository).allows(state.read(), runtimePermissionPrompt(state.read())), false); -}); - -test("reviewed trust distinguishes auth test source from browser state and sensitive paths", (t) => { - const state = fixture(t); - const jobId = randomUUID(); - const fileAccess = validateJobFileAccess({ - mode: "source-read-write", repository: state.repository, jobId - }, state.repository, jobId, "fix-and-validate"); - const policy = createRuntimePermissionPolicy(state.repository, [], fileAccess); - const readOnly = createRuntimePermissionPolicy(state.repository, [], { ...fileAccess, mode: "source-read" }); - const allows = (request, selected = policy) => selected.allows(request, runtimePermissionPrompt(request)); - const source = [ - "rename-target-duplicates.auth.spec.ts", "auth.spec.ts", "sign-in.auth.test.ts", - "AUTH.SPEC.TSX", "scenario-auth.test.mjs", "scenario_auth.spec.cts" - ]; - mkdirSync(join(state.repository, "e2e")); - for (const name of source) { - const path = join(state.repository, "e2e", name); - writeFileSync(path, "export const synthetic = true;\n"); - const read = { ...state.read(path), intention: `Read file: ${path}` }; - const write = { kind: "write", fileName: path, diff: "Synthetic test-source change." }; - assert.equal(allows(read), true, name); - assert.equal(allows(write), true, name); - assert.equal(allows(read, readOnly), true, name); - assert.equal(allows(write, readOnly), false, name); - assert.equal(state.allows(read), false, "authentication tests do not bypass opt-in trust"); - } - const create = { kind: "write", fileName: join(state.repository, "e2e", "new.auth.spec.ts"), diff: "Synthetic new test." }; - assert.equal(allows(create), true); - for (const name of [ - ".auth/user.json", ".auth/scenario.auth.spec.ts", ".edge-profile/scenario.auth.spec.ts", - "auth/scenario.auth.spec.ts", ".auth.spec.ts", "auth.fixture.ts", "auth.setup.ts", - "auth.config.ts", "scenario.auth.spec.ts.json", "auth.spec.ts.bak", - "credentials.auth.spec.ts", "secret.auth.spec.ts", "token.auth.spec.ts", - "private.auth.spec.ts", "storageState.auth.spec.ts", - "node_modules/scenario.auth.spec.ts", "test-results/scenario.auth.spec.ts" - ]) { - const path = join(state.repository, "e2e", ...name.split("/")); - mkdirSync(join(path, ".."), { recursive: true }); - writeFileSync(path, "Synthetic protected fixture.\n"); - assert.equal(allows(state.read(path)), false, name); - assert.equal(allows({ kind: "write", fileName: path, diff: "Synthetic denied change." }), false, name); - } - const path = join(state.repository, "e2e", source[0]); - const read = state.read(path); - const protectedPolicy = createRuntimePermissionPolicy(state.repository, [path], fileAccess); - assert.equal(allows(read, protectedPolicy), false, "configured authentication paths stay protected"); - for (const change of [ - { managedApprovalRequired: true }, { requestSandboxBypass: true }, - { requestSandboxPermissive: true }, { warning: "Review risk" } - ]) { - assert.equal(allows({ ...read, ...change }), false); - } - const outside = join(state.directory, "outside.auth.spec.ts"); - writeFileSync(outside, "Synthetic outside fixture.\n"); - symlinkSync(state.directory, join(state.repository, "e2e", "linked"), "junction"); - linkSync(outside, join(state.repository, "e2e", "hard-linked.auth.spec.ts")); - for (const file of [ - outside, join(state.repository, "e2e", "linked", "outside.auth.spec.ts"), - join(state.repository, "e2e", "hard-linked.auth.spec.ts") - ]) { - assert.equal(allows(state.read(file)), false); - assert.equal(allows({ kind: "write", fileName: file, diff: "Synthetic denied link." }), false); - } -}); - -test("reviewed source-write trust is independent of display size but keeps bounded full-content checks", (t) => { - const state = fixture(t); - const fileAccess = { mode: "source-read-write", repository: state.repository, jobId: randomUUID() }; - const policy = createRuntimePermissionPolicy(state.repository, [], fileAccess); - const write = { - kind: "write", fileName: join(state.repository, "src", "index.ts"), - intention: "Synthetic source edit. ".repeat(35), diff: "+".repeat(7909) - }; - const allows = (request, selected = policy) => selected.allows(request, runtimePermissionPrompt(request)); - assert.equal(runtimePermissionPrompt(write).canApprove, false, "manual display review remains bounded"); - assert.equal(allows(write), true); - assert.equal(state.allows(write), false, "manual default cannot approve undisplayed edits"); - const readOnly = createRuntimePermissionPolicy(state.repository, [], { ...fileAccess, mode: "source-read" }); - assert.equal(allows(write, readOnly), false); - assert.equal(allows({ ...write, diff: "+".repeat(MAX_RUNTIME_FRAME_BYTES - 10000) }), true); - for (const change of [ - { diff: "+".repeat(MAX_RUNTIME_FRAME_BYTES) }, - { diff: "\u00e9".repeat(MAX_RUNTIME_FRAME_BYTES / 2) }, - { diff: `${"+".repeat(8200)}\nAuthorization: [redacted]` }, - { diff: `${"+".repeat(8200)}\nBearer synthetic-placeholder` }, - { diff: `${"+".repeat(8200)}\npassword: synthetic-placeholder` }, - { diff: `${"+".repeat(8200)}\n\u0000` }, - { managedApprovalRequired: true }, { requestSandboxBypass: true }, - { requestSandboxPermissive: true }, { warning: "Review risk" }, - { fileName: join(state.repository, ".env") }, - { fileName: join(state.repository, "src", "auth.ts") }, - { fileName: join(state.repository, "src", "settings.ts") }, - { fileName: join(state.repository, "playwright.config.ts") }, - { fileName: join(state.directory, "outside.ts") }, - { fileName: undefined }, { diff: undefined } - ]) { - assert.equal(allows({ ...write, ...change }), false, JSON.stringify(Object.keys(change))); - } - const protectedPolicy = createRuntimePermissionPolicy(state.repository, [write.fileName], fileAccess); - assert.equal(allows(write, protectedPolicy), false); - assert.equal(allows(write, createRuntimePermissionPolicy(state.repository, [], { - ...fileAccess, repository: state.directory - })), false); -}); - -test("exact test grants bind command, canonical cwd, reported paths and manifests", (t) => { - const state = fixture(t); - const command = state.command(); - assert.equal(state.allows(command), false); - assert.equal(state.scope(command), "exact-test-command"); - state.grant(command, "exact-test-command"); - assert.equal(state.allows(command), true); - mkdirSync(join(state.repository, "other")); - for (const request of [ - state.command("npm test -- --runInBand"), - { ...command, resolvedWorkingDirectory: join(state.repository, "other") }, - { ...command, possiblePaths: [join(state.repository, "src", "index.ts")] }, - { ...command, commandSegments: [{ fullCommandText: "npm test" }] } - ]) { - assert.equal(state.allows(request), false); - } - writeFileSync(join(state.repository, "package.json"), '{"scripts":{"test":"node changed-test.js"}}'); - assert.equal(state.allows(command), false); - assert.equal(state.scope(command), "exact-test-command", "changed command context requires a new review"); - state.grant(command, "exact-test-command"); - assert.equal(state.allows(command), true); -}); - -test("reviewed file trust covers source reads and edits across hosts, never other jobs or safety boundaries", async (t) => { - const state = fixture(t); - const authTest = join(state.repository, "e2e", "rename-target-duplicates.auth.spec.ts"); - mkdirSync(join(state.repository, "e2e")); - writeFileSync(authTest, "export const synthetic = true;\n"); - const jobId = randomUUID(); - const fileAccess = validateJobFileAccess({ - mode: "source-read-write", repository: state.repository, jobId - }, state.repository, jobId, "fix-and-validate"); - const policy = createRuntimePermissionPolicy(state.repository, [], fileAccess); - const write = (path = join(state.repository, "src", "index.ts")) => ({ - kind: "write", fileName: path, diff: "Synthetic non-sensitive source edit." - }); - const allows = (request) => policy.allows(request, runtimePermissionPrompt(request)); - assert.equal(allows(state.read()), true); - assert.equal(allows(state.read(join(state.repository, "src", "not-created.ts"))), true); - for (const name of ["missing-parent/not-created.ts", "src/private.ts"]) { - const request = state.read(join(state.repository, ...name.split("/"))); - assert.equal(allows(request), false, name); - } - assert.equal(allows(write()), true); - assert.equal(allows(write(join(state.repository, "src", "pfam.renameValidation.test.ts"))), true); - assert.equal(policy.scopeFor(write(), runtimePermissionPrompt(write())), null); - assert.equal(state.allows(write()), false, "manual default still asks"); - assert.equal(allows(state.command()), false, "file trust never approves commands"); - const readOnly = createRuntimePermissionPolicy(state.repository, [], { ...fileAccess, mode: "source-read" }); - assert.equal(readOnly.allows(state.read(), runtimePermissionPrompt(state.read())), true); - assert.equal(readOnly.allows(write(), runtimePermissionPrompt(write())), false); - for (const name of [ - ".github/instructions/rules.md", ".github/fixlab/repository-profile.json", - "AGENTS.md", "playwright.config.ts", "project.csproj", "build.ps1", - "src/private.ts", "src/auth.ts", "src/settings.ts", "node_modules/library/index.js" - ]) { - const path = join(state.repository, ...name.split("/")); - mkdirSync(join(path, ".."), { recursive: true }); - writeFileSync(path, "Synthetic protected fixture.\n"); - assert.equal(allows(write(path)), false, name); - } - const outside = join(state.directory, "outside.ts"); - writeFileSync(outside, "Synthetic outside fixture.\n"); - symlinkSync(state.directory, join(state.repository, "linked"), "junction"); - linkSync(outside, join(state.repository, "src", "hard-linked.ts")); - for (const path of [ - outside, join(state.repository, "linked", "outside.ts"), - join(state.repository, "src", "hard-linked.ts"), - join(state.repository, "missing-parent", "new.ts"), state.repository - ]) {assert.equal(allows(write(path)), false, path);} - const protectedPolicy = createRuntimePermissionPolicy(state.repository, [join(state.repository, "src")], fileAccess); - assert.equal(protectedPolicy.allows(write(), runtimePermissionPrompt(write())), false); - for (const change of [ - { managedApprovalRequired: true }, { requestSandboxBypass: true }, - { requestSandboxPermissive: true }, { warning: "Review risk" } - ]) {for (const request of [state.read(), write()]) { - assert.equal(allows({ ...request, ...change }), false); - }} - for (const changed of [ - { ...fileAccess, jobId: randomUUID() }, { ...fileAccess, repository: state.directory }, - { ...fileAccess, mode: "all" } - ]) {assert.throws(() => validateJobFileAccess(changed, state.repository, jobId, "fix-and-validate"), /exact job/);} - assert.throws(() => validateJobFileAccess(fileAccess, state.repository, jobId, "validate-only"), /edits require/); - for (const resume of [false, true]) { - const createSession = async (options) => ({ - rpc: { - managedSettings: { async get() { return { failClosed: false }; } }, - model: { async getCurrent() { return { modelId: "synthetic-model" }; } }, - metadata: { async recomputeContextTokens() { return { messagesTokenCount: 1 }; } } - }, - on() {}, - async sendAndWait() { - for (const request of [ - state.read(), write(), state.read(authTest), write(authTest), - { ...write(), diff: "+".repeat(8500) } - ]) { - assert.deepEqual(await options.onPermissionRequest(request), { - kind: "approve-once", approvedInteractively: false - }); - } - } - }); - await runCopilotSession({ - repository: state.repository, sessionId: jobId, mode: "fix-and-validate", - fileAccess, resume, prompt: "Synthetic reviewed file trust." - }, { - client: { async start() {}, createSession, resumeSession: (_id, options) => createSession(options), async stop() { return []; } }, - async ask() { assert.fail("Reviewed source reads and edits must not prompt again after resume"); }, - output() {} - }); - } -}); - -test("the exact safe repository profile is readable under reviewed trust but never writable", (t) => { - const state = fixture(t); - const path = join(state.repository, ".github", "fixlab", "repository-profile.json"); - mkdirSync(join(path, ".."), { recursive: true }); - const policy = createRuntimePermissionPolicy(state.repository, [], { - mode: "source-read-write", repository: state.repository, jobId: randomUUID() - }); - const read = state.read(path); - const allows = () => policy.allows(read, runtimePermissionPrompt(read)); - const profile = { version: 1, name: "Synthetic profile", browserAutomation: { authentication: { required: true, statusPaths: ["e2e/.auth/user.json"] } } }; - writeFileSync(path, JSON.stringify(profile)); - assert.equal(allows(), true); - assert.equal(state.scope(read), "repository-reads", "executor-local read grants include the exact safe profile too"); - const protectedPolicy = createRuntimePermissionPolicy(state.repository, [path], { - mode: "source-read-write", repository: state.repository - }); - assert.equal(protectedPolicy.allows(read, runtimePermissionPrompt(read)), false); - for (const text of [ - "invalid JSON", "[]", "{}", JSON.stringify({ ...profile, cookies: [] }), - JSON.stringify({ ...profile, password: "synthetic-placeholder" }), - JSON.stringify({ ...profile, description: "x".repeat(256 * 1024) }) - ]) { - writeFileSync(path, text); - assert.equal(allows(), false); - } - writeFileSync(path, JSON.stringify(profile)); - const write = { kind: "write", fileName: path, diff: "Synthetic profile edit." }; - assert.equal(policy.allows(write, runtimePermissionPrompt(write)), false); - const arbitrary = join(state.repository, ".github", "fixlab", "other.json"); - writeFileSync(arbitrary, JSON.stringify(profile)); - assert.equal(policy.allows(state.read(arbitrary), runtimePermissionPrompt(state.read(arbitrary))), false); -}); - -test("reviewed trust reads only bounded non-sensitive command logs with this job's ownership receipt", (t) => { - const state = fixture(t); - const jobId = randomUUID(); - const fileAccess = { mode: "source-read-write", repository: state.repository, jobId }; - const policy = createRuntimePermissionPolicy(state.repository, [], fileAccess); - const directory = join(state.repository, ".git", "fixlab", "command-evidence", `20261020T191939-${randomUUID()}`); - mkdirSync(directory, { recursive: true }); - const log = join(directory, "stdout.log"); - const marker = join(directory, "job.json"); - const request = state.read(log); - const allows = () => policy.allows(request, runtimePermissionPrompt(request)); - writeFileSync(log, "Synthetic failed assertion: expected true, received false.\n"); - assert.equal(allows(), false, "older logs without an ownership receipt remain manual"); - const owner = { jobId, repository: state.repository, gateId: "frontend-tests" }; - writeFileSync(marker, JSON.stringify(owner)); - assert.equal(allows(), true); - writeFileSync(log, "\u001b[32mSynthetic passed assertion\u001b[0m\n"); - assert.equal(allows(), true, "recognized SGR formatting is scanned as plain text"); - for (const content of ["\u001b[2JSynthetic output", "\u001b[31mAuthor\u001b[0mization: synthetic", "\u001b]8;;https://example.invalid\u0007link"]) { - writeFileSync(log, content); - assert.equal(allows(), false, "controls and secrets split by formatting remain manual"); - } - writeFileSync(log, "Synthetic safe test output.\n"); - for (const changed of [null, {}, { ...owner, jobId: randomUUID() }, { ...owner, repository: state.directory }]) { - writeFileSync(marker, JSON.stringify(changed)); - assert.equal(allows(), false); - } - writeFileSync(marker, JSON.stringify(owner)); - for (const content of ["password=synthetic-placeholder", "x".repeat(1024 * 1024 + 1)]) { - writeFileSync(log, content); - assert.equal(allows(), false); - } - writeFileSync(log, "Synthetic safe test output.\n"); - const write = { kind: "write", fileName: log, diff: "Synthetic output tampering." }; - assert.equal(policy.allows(write, runtimePermissionPrompt(write)), false); - assert.equal(policy.allows(state.read(marker), runtimePermissionPrompt(state.read(marker))), false); - const protectedPolicy = createRuntimePermissionPolicy(state.repository, [log], fileAccess); - assert.equal(protectedPolicy.allows(request, runtimePermissionPrompt(request)), false); -}); - -test("reviewed dashboard gate trust binds helper, job, port and checkout without granting arbitrary commands", (t) => { - const state = fixture(t); - const helper = fileURLToPath(new URL("../bin/fixlab.js", import.meta.url)); - const jobId = randomUUID(); - const fileAccess = { mode: "source-read-write", repository: state.repository, jobId, allowValidationGates: true }; - const policy = createRuntimePermissionPolicy(state.repository, [], fileAccess, 4317); - const text = `node '${helper}' gate --port 4317 --job ${jobId} --gate frontend-tests --repository '${state.repository}'`; - const command = (fullCommandText = text) => ({ kind: "shell", fullCommandText, possiblePaths: [], possibleUrls: [] }); - const allows = (request) => policy.allows(request, runtimePermissionPrompt(request)); - assert.equal(allows(command()), true); - assert.equal(allows(command(`node '${helper}' gate --repository '${state.repository}' --gate frontend-tests --job ${jobId} --port 4317`)), true); - assert.equal(createRuntimePermissionPolicy(state.repository, [], { ...fileAccess, allowValidationGates: false }, 4317) - .allows(command(), runtimePermissionPrompt(command())), false); - for (const changed of [ - text.replace("--port 4317", "--port 4320"), - text.replace(jobId, randomUUID()), text.replace(state.repository, state.directory), - text.replace("--gate frontend-tests", "--gate frontend-tests --yes"), - `${text}; whoami`, text.replace(" gate ", " exec "), - "npm test", `node '${join(state.repository, "fake.js")}' gate --port 4317 --job ${jobId} --gate frontend-tests --repository '${state.repository}'` - ]) {assert.equal(allows(command(changed)), false, changed);} - for (const change of [ - { managedApprovalRequired: true }, { requestSandboxBypass: true }, - { warning: "Review risk" }, { possibleUrls: ["https://example.invalid"] } - ]) {assert.equal(allows({ ...command(), ...change }), false);} -}); - -test("configured authentication paths are protected even when their names resemble source files", (t) => { - const state = fixture(t); - const file = join(state.repository, "src", "session.js"); - writeFileSync(file, "Synthetic authentication-state fixture; not real credentials.\n"); - const policy = createRuntimePermissionPolicy(state.repository, [file]); - const prompt = runtimePermissionPrompt(state.read()); - policy.grant(state.read(), prompt, "repository-reads", policy.review(state.read(), prompt)); - const request = state.read(file); - assert.equal(policy.scopeFor(request, runtimePermissionPrompt(request)), null); - assert.equal(policy.allows(request, runtimePermissionPrompt(request)), false); - const directoryPolicy = createRuntimePermissionPolicy(state.repository, [join(state.repository, "src")]); - assert.equal(directoryPolicy.scopeFor(state.read(), runtimePermissionPrompt(state.read())), null); - assert.throws(() => createRuntimePermissionPolicy(state.repository, ["relative"]), /absolute/); -}); -test("unknown, external, ambiguous, managed and escalated commands cannot receive scoped grants", (t) => { - const state = fixture(t); - state.grant(state.read(), "repository-reads"); - state.grant(state.command(), "exact-test-command"); - for (const change of [ - { managedApprovalRequired: true }, { requestSandboxBypass: true }, - { requestSandboxPermissive: true }, { sandboxPathGrant: {} }, - { warning: "Review this risk" }, { hasWriteFileRedirection: true } - ]) { - for (const request of [state.read(), state.command()]) { - assert.equal(state.allows({ ...request, ...change }), false); - assert.equal(state.scope({ ...request, ...change }), null); - } - } - for (const request of [ - state.command("npm run build"), state.command("npm test && npm run publish"), - state.command("npm test; whoami"), state.command("npm test > output.txt"), - state.command("npm test $(whoami)"), state.command("npm test -- --config=../outside.ts"), - state.command("dotnet test .."), state.command("npm test -- /outside.ts"), - { ...state.command(), canOfferSessionApproval: false }, - { ...state.command(), resolvedWorkingDirectory: undefined }, - { ...state.command(), resolvedWorkingDirectory: state.directory }, - { ...state.command(), possibleUrls: [{ url: "https://example.invalid" }] }, - { ...state.command(), possiblePaths: undefined }, - { kind: "url", url: "https://example.invalid" }, - { kind: "write", fileName: join(state.repository, "src", "index.ts"), diff: "Synthetic change" }, - { kind: "mcp", serverName: "test", toolName: "read" } - ]) { - assert.equal(state.scope(request), null, JSON.stringify(request)); - assert.equal(state.allows(request), false); - assert.throws(() => state.grant(request, "exact-test-command"), /cannot receive/); - } -}); - -test("a command context changed while its grant dialog is pending cannot be authorized", (t) => { - const state = fixture(t); - const request = state.command(); - const prompt = runtimePermissionPrompt(request); - const reviewed = state.policy.review(request, prompt); - writeFileSync(join(state.repository, "package.json"), '{"scripts":{"test":"node changed.js"}}'); - assert.throws(() => state.policy.grant(request, prompt, "exact-test-command", reviewed), /cannot receive/); - assert.equal(state.allows(request), false); -}); -test("SDK grants skip only explicitly reviewed matching requests and expire with the executor", async (t) => { - const state = fixture(t); - for (const grantScope of ["repository-reads", "exact-test-command"]) { - const request = grantScope === "repository-reads" ? state.read() : state.command(); - for (const grant of [true, false]) { - let options; - let asked = 0; - const client = { - async start() {}, - async createSession(value) { - options = value; - return { - rpc: { managedSettings: { async get() { return { failClosed: false }; } } }, - on() {}, - async sendAndWait() { - assert.deepEqual(await options.onPermissionRequest(request), { - kind: "approve-once", approvedInteractively: true - }); - assert.deepEqual(await options.onPermissionRequest(request), { - kind: "approve-once", approvedInteractively: !grant - }); - assert.deepEqual(await options.onPermissionRequest({ - ...request, managedApprovalRequired: true - }), { kind: "approve-once", approvedInteractively: true }); - } - }; - }, - async stop() { return []; } - }; - await runCopilotSession({ repository: state.repository, prompt: "Synthetic scope test." }, { - client, - async ask(prompt) { - asked++; - if (asked === 1) { - assert.equal(prompt.grantScope, grantScope); - assert.equal(JSON.parse(prompt.details).jobGrant.repository, state.repository); - return { action: "allow", ...(grant ? { scope: grantScope } : {}) }; - } - return { action: "allow" }; - }, - output() {} - }); - assert.equal(asked, grant ? 2 : 3); - } - } -}); diff --git a/test/dashboard-runtime-requests.test.js b/test/dashboard-runtime-requests.test.js index 9d23e7e..b25616c 100644 --- a/test/dashboard-runtime-requests.test.js +++ b/test/dashboard-runtime-requests.test.js @@ -5,18 +5,13 @@ import { createRuntimeRequestBridge, MAX_RUNTIME_REQUESTS, runtimePermissionPrompt, - runtimeQuestionPrompt, - validateRuntimeUsage + runtimeQuestionPrompt } from "../dashboard/runtime-requests.js"; import { extensionLaunchProvider, runCopilotSession } from "../dashboard/copilot-session.js"; import { join } from "node:path"; function managedPolicyRpc(policy = { failClosed: false }) { - return { - managedSettings: { async get() { return policy; } }, - model: { async getCurrent() { return { modelId: "synthetic-model" }; } }, - metadata: { async recomputeContextTokens() { return { messagesTokenCount: 0 }; } } - }; + return { managedSettings: { async get() { return policy; } } }; } function fixture() { @@ -50,23 +45,6 @@ test("runtime permission approval is exact, single-use and never session-wide", assert.equal(state.requests.length, 0); }); -test("structured SDK usage is monotonic, bounded and independent of rendered token summaries", () => { - const previous = { - inputTokens: 100, cachedInputTokens: 80, cacheWriteTokens: 10, outputTokens: 5 - }; - const next = { inputTokens: 110, cachedInputTokens: 90, cacheWriteTokens: 12, outputTokens: 7 }; - assert.deepEqual(validateRuntimeUsage({ ...next, arbitrary: "Not retained" }, previous), { - ...next, cacheReusePercent: 90 / 110 * 100 - }); - for (const value of [ - null, {}, { ...next, inputTokens: 99 }, { ...next, cachedInputTokens: 79 }, - { ...next, outputTokens: -1 }, { ...next, outputTokens: "7" }, - { ...next, inputTokens: Number.MAX_SAFE_INTEGER + 1 }, - { ...next, cachedInputTokens: 111 } - ]) { - assert.throws(() => validateRuntimeUsage(value, previous), /structured/); - } -}); test("sensitive, malformed and oversized permission details cannot be approved", () => { for (const request of [ { kind: "shell", fullCommandText: "curl -H 'Authorization: Bearer not-a-real-test-token' https://example.invalid" }, @@ -205,14 +183,6 @@ for (const resume of [false, true]) { assert.equal(stopped, true); assert.equal(config.agent, "fixlab:fixlab"); assert.equal(config.enableManagedSettings, true); - assert.deepEqual(config.toolSearch, { enabled: true, deferThreshold: 1 }); - assert.deepEqual(config.largeOutput, { enabled: true, maxSizeBytes: 8192 }); - assert.equal(config.enableConfigDiscovery, true); - assert.equal(config.availableTools, undefined); - assert.equal(config.excludedTools, undefined); - assert.equal(config.customAgents, undefined); - assert.equal(config.systemMessage, undefined); - assert.equal(config.model, "hydrafusion"); assert.equal(config.enableExperimentalMode, true); assert.deepEqual(config.additionalDirectories, ["reviewed-directory"]); assert.deepEqual(output, [{ stream: "stdout", text: "Scenario complete." }]); @@ -298,7 +268,6 @@ for (const resume of [false, true]) { })); assert.equal(stopped, true); } - let stopped = false; const open = async () => ({ rpc: { managedSettings: { async get() { throw new Error("Policy service unavailable"); } } } @@ -315,215 +284,3 @@ for (const resume of [false, true]) { assert.equal(stopped, true); }); } - -for (const mode of ["playwright-only", "validate-only", "fix-and-validate"]) { -test(`oversized ${mode} resumes compact retained history once before executing, preserving safety and usage`, async () => { - for (const messagesTokenCount of [24000, 24001]) { - const calls = []; - let listener; - const output = []; - const session = { - rpc: { - ...managedPolicyRpc(), - model: { async getCurrent() { return { modelId: "synthetic-model" }; } }, - metadata: { async recomputeContextTokens(value) { - assert.deepEqual(value, { modelId: "synthetic-model" }); - return { messagesTokenCount }; - } }, - history: { async compact(value) { - calls.push("compact"); - assert.match(value.customInstructions, /denied and pending permissions/); - assert.match(value.customInstructions, /Never include credentials/); - listener({ type: "assistant.usage", data: { inputTokens: 100, outputTokens: 10 } }); - return { success: true, tokensRemoved: 18000 }; - } } - }, - on(value) { listener = value; }, - async sendAndWait() { calls.push("send"); } - }; - const client = { - async start() {}, - async resumeSession(id, options) { - assert.equal(options.enableManagedSettings, true); - assert.deepEqual(options.infiniteSessions, { - enabled: true, backgroundCompactionThreshold: 0.25, bufferExhaustionThreshold: 0.4 - }); - return session; - }, - async stop() { calls.push("stop"); return []; } - }; - await runCopilotSession({ - mode, resume: true, usage: { inputTokens: 500 } - }, { client, ask() { assert.fail("Compaction cannot grant permission"); }, - output(stream, text) { output.push(text); } }); - assert.deepEqual(calls, messagesTokenCount > 24000 - ? ["compact", "send", "stop"] : ["send", "stop"]); - if (messagesTokenCount > 24000) { - assert.match(output.join(""), /18000 context tokens removed/); - assert.match(output.join(""), /Tokens.*600/); - } - } - }); -} - - test("browser resume blocks on failed compaction without clearing history or sending the job", async () => { - for (const result of [ - { success: false, tokensRemoved: 0 }, { success: true, tokensRemoved: 0 } - ]) { - let stopped = false; - const client = { - async start() {}, - async resumeSession() { - return { - on() {}, - rpc: { - ...managedPolicyRpc(), - model: { async getCurrent() { return { modelId: "synthetic-model" }; } }, - metadata: { async recomputeContextTokens() { return { messagesTokenCount: 80000 }; } }, - history: { async compact() { return result; } } - }, - async sendAndWait() { assert.fail("Failed compaction must not send a prompt"); } - }; - }, - async stop() { stopped = true; return []; } - }; - await assert.rejects(runCopilotSession({ mode: "playwright-only", resume: true }, { - client, ask() {}, output() {} - }), /could not be compacted/); - assert.equal(stopped, true); - } - }); - -test("scoped grants require the offered permission scope and remain outside pending-only batches", () => { - const state = fixture(); - const request = { ...state.permission(), grantScope: "exact-test-command" }; - state.bridge.receive(request); - assert.throws(() => state.bridge.respond(request.id, { action: "allow", scope: "repository-reads" }), /not available/); - assert.throws(() => state.bridge.respond(request.id, { action: "deny", scope: request.grantScope }), /not available/); - state.bridge.respond(request.id, { action: "allow", scope: request.grantScope }); - assert.deepEqual(state.sent[0], { - type: "response", id: request.id, action: "allow", scope: "exact-test-command" - }); - const ordinary = state.permission(); - state.bridge.receive(ordinary); - assert.throws(() => state.bridge.respond(ordinary.id, { action: "allow", scope: "exact-test-command" }), /not available/); - state.bridge.respond(ordinary.id, { action: "allow" }); - assert.equal(state.sent[1].scope, undefined); - assert.throws(() => state.bridge.receive({ - ...state.permission(), grantScope: "all-tools" - }), /invalid/); -}); - -for (const inputTokenLimit of [0, 129, 130, 131]) { - test(`SDK cumulative input ceiling ${inputTokenLimit} includes cached input and preserves exact usage`, async () => { - let listener; - let stopped = false; - let sent = false; - const output = []; - const client = { - async start() {}, - async createSession(options) { - assert.deepEqual(options.infiniteSessions, { - enabled: true, backgroundCompactionThreshold: 0.25, bufferExhaustionThreshold: 0.4 - }); - return { - rpc: managedPolicyRpc(), - on(value) { listener = value; }, - async sendAndWait() { - sent = true; - listener({ type: "assistant.usage", data: { - inputTokens: 30, cacheReadTokens: 25, cacheWriteTokens: 5, outputTokens: 7 - } }); - if (inputTokenLimit && inputTokenLimit <= 130) { - return new Promise(() => {}); - } - } - }; - }, - async stop() { stopped = true; return []; } - }; - const run = runCopilotSession({ - inputTokenLimit, mode: "fix-and-validate", - usage: { inputTokens: 100, cachedInputTokens: 80, cacheWriteTokens: 3, outputTokens: 10 } - }, { client, ask() {}, output(stream, text) { output.push(text); } }); - if (inputTokenLimit && inputTokenLimit <= 130) { - await assert.rejects(run, (error) => error.code === "INPUT_TOKEN_LIMIT"); - } else { - await run; - } - assert.equal(sent, true); - assert.equal(stopped, true); - assert.match(output.join(""), /130 \(105 cached, 8 written\).*17/); - }); -} - -test("SDK exhausted resumed budgets block before opening a runtime and invalid ceilings fail explicitly", async () => { - const client = { - start() { assert.fail("An exhausted budget must not open a runtime"); }, - async stop() { return []; } - }; - await assert.rejects(runCopilotSession({ - resume: true, inputTokenLimit: 100, usage: { inputTokens: 100 } - }, { client, ask() {}, output() {} }), (error) => error.code === "INPUT_TOKEN_LIMIT"); - for (const inputTokenLimit of [-1, 1.5, "100", Number.MAX_SAFE_INTEGER + 1]) { - await assert.rejects(runCopilotSession({ inputTokenLimit }, { - client, ask() {}, output() {} - }), /non-negative safe integer/); - } -}); - -test("compaction usage counts toward the ceiling before any continuation is sent", async () => { - let listener; - const output = []; - const client = { - async start() {}, - async resumeSession() { - return { - on(value) { listener = value; }, - rpc: { - ...managedPolicyRpc(), - metadata: { async recomputeContextTokens() { return { messagesTokenCount: 80000 }; } }, - history: { async compact() { - listener({ type: "assistant.usage", data: { inputTokens: 110, cacheReadTokens: 90 } }); - return { success: true, tokensRemoved: 60000 }; - } } - }, - async sendAndWait() { assert.fail("An exhausted compacted session cannot continue"); } - }; - }, - async stop() { return []; } - }; - await assert.rejects(runCopilotSession({ - resume: true, mode: "validate-only", inputTokenLimit: 200, usage: { inputTokens: 100 } - }, { client, ask() {}, output(stream, text) { output.push(text); } }), - (error) => error.code === "INPUT_TOKEN_LIMIT"); - assert.match(output.join(""), /Tokens.*210 \(90 cached/); -}); - -test("a pending human permission is not approved after the input ceiling is reached", async () => { - let listener; - let respond; - let permission; - const client = { - async start() {}, - async createSession(options) { - return { - rpc: managedPolicyRpc(), - on(value) { listener = value; }, - async sendAndWait() { - permission = options.onPermissionRequest({ kind: "shell", fullCommandText: "npm test" }); - listener({ type: "assistant.usage", data: { inputTokens: 100 } }); - await permission; - } - }; - }, - async stop() { - respond({ action: "allow" }); - return []; - } - }; - await assert.rejects(runCopilotSession({ inputTokenLimit: 100 }, { - client, ask() { return new Promise((resolve) => { respond = resolve; }); }, output() {} - }), (error) => error.code === "INPUT_TOKEN_LIMIT"); - assert.deepEqual(await permission, { kind: "reject" }); -}); diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index 1e28c16..4334cfd 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -27,7 +27,6 @@ import { buildCopilotInvocation, buildJobPrompt, createCacheContext, - createCopilotExecutor, createDashboardServer, configuredTestSynthesis, createProcessCompletion, @@ -42,12 +41,10 @@ import { validateCopilotModel } from "../dashboard/server.js"; import { createExecutorLease } from "../dashboard/executor-lease.js"; -import { createSessionRegistry } from "../dashboard/sessions.js"; import { createRuntimeRequestBridge, runtimePermissionPrompt, runtimeQuestionPrompt } from "../dashboard/runtime-requests.js"; import { runValidationGate } from "../bin/validation-gate-client.js"; import { dashboardControl, dashboardConversation } from "../mcp/dashboard-client.js"; import { compactControlResult, conversationOperations } from "../dashboard/conversation-operations.js"; -import { runJobPreflight } from "../dashboard/preflight.js"; const packageRoot = new URL("..", import.meta.url).pathname.replace( /^\/([A-Za-z]:)/, @@ -312,7 +309,6 @@ function createRepository() { function validProfile(overrides = {}) { return { name: "Dashboard test repository", - workflow: { mode: "strict" }, applications: { frontend: { workingDirectory: ".", @@ -513,7 +509,6 @@ test("connected Copilot recovery reuses reviewed work, preserves the same job an const oldJob = randomUUID(); const branch = `users/fixlab-test/fixlab-${oldJob}`; git(repository, ["switch", "--quiet", "-c", branch]); - const originalTracked = readFileSync(join(repository, "tracked.txt")); writeFileSync(join(repository, "tracked.txt"), "existing fix"); const executor = (input) => { let finish; @@ -573,45 +568,10 @@ test("connected Copilot recovery reuses reviewed work, preserves the same job an assert.match(executions[0].input.prompt, /Continue independent safe checks/); await current.dashboard.close(); current = await startDashboard(repository, executor); - args.port = Number(new URL(current.url).port); const restored = await jsonRequest(current.url, "/api/status"); assert.equal(restored.body.job.sharedWorkspace.branch, branch); assert.equal(restored.body.job.sharedWorkspace.reusedFromJobId, oldJob); assert.equal(restored.body.job.validationIntent, "scoped"); - writeFileSync(join(repository, "tracked.txt"), originalTracked); - writeFileSync(join(repository, "preserved.ts"), "export const preserved = true;\n"); - const blocked = await post("/api/jobs/restart", { jobId: restored.body.job.id }); - assert.equal(blocked.body.job.status, "blocked"); - status = await dashboardConversation(args); - const recovery = { - confirmed: true, instanceId: status.instanceId, jobId: status.job.id, - repository: realpathSync(repository), workspaceRecovery: status.workspaceReview - }; - for (const invalid of [ - { confirmed: false }, { instanceId: "old-instance" }, { repository: source }, { jobId: oldJob } - ]) { - assert.equal((await post("/api/jobs/restart", { ...recovery, ...invalid })).response.status, invalid.jobId ? 404 : 409); - assert.equal(existsSync(join(repository, "preserved.ts")), true); - } - writeFileSync(join(repository, "preserved.ts"), "export const preserved = 2;\n"); - assert.equal((await post("/api/jobs/restart", recovery)).response.status, 409); - status = await dashboardConversation(args); - recovery.workspaceRecovery = status.workspaceReview; - const fresh = await post("/api/jobs/restart", recovery); - assert.equal(fresh.response.status, 202, fresh.body.error); - assert.notEqual(fresh.body.job.id, blocked.body.job.id); - assert.equal(fresh.body.job.sharedWorkspace.branch, `users/fixlab-test/fixlab-${fresh.body.job.id}`); - assert.equal(fresh.body.job.sharedWorkspace.baseCommit, git(source, ["rev-parse", "HEAD"])); - assert.equal(git(repository, ["branch", "--show-current"]), fresh.body.job.sharedWorkspace.branch); - assert.equal(git(repository, ["show", `${fresh.body.workspaceStash.stashRef}^3:preserved.ts`]), "export const preserved = 2;"); - assert.equal(existsSync(join(repository, "preserved.ts")), false); - assert.equal(git(repository, ["rev-parse", branch]), git(source, ["rev-parse", "HEAD"])); - assert.equal(executions.length, 2); - assert.equal(executions[1].input.resume, false); - assert.equal(executions[1].input.sessionId, fresh.body.job.id); - await current.dashboard.close(); - current = await startDashboard(repository, executor); - assert.equal((await jsonRequest(current.url, "/api/status")).body.job.workspaceStash.stashRef, fresh.body.workspaceStash.stashRef); } finally { await current.dashboard.close(); rmSync(repository, { recursive: true, force: true }); @@ -658,7 +618,6 @@ test("connected control starts scoped browser jobs, manages the queue and stops assert.equal(started.state.job.status, "running"); assert.equal(started.state.job.validationIntent, "scoped"); assert.equal(calls.length, 1); - assert.equal(calls[0].mode, "playwright-only"); assert.match(calls[0].prompt, /Playwright-only mode/); assert.match(calls[0].prompt, /Do not edit files/); assert.match(calls[0].prompt, /Full regression is explicitly deferred/); @@ -739,10 +698,7 @@ test("operation summaries omit logs, browser state, credentials and binary artif assert.doesNotMatch(JSON.stringify(compact), /private|storageState|authorization|statusPaths|screenshots|"logs"/); assert.equal(compact.job.id, "job-1"); assert.equal(compact.sessions[0].pid, 42); - assert.equal(Object.keys(conversationOperations).length, 23); - assert.deepEqual(conversationOperations["validation-select"], { - method: "POST", path: "/api/job/checks" - }); + assert.equal(Object.keys(conversationOperations).length, 22); }); test("shared dashboard jobs refresh develop, reuse one checkout and reacquire held branches after restart", async () => { @@ -1041,248 +997,6 @@ test("per-run interception keeps defaults unchanged and never grants live writes assert.throws(() => configuredTestSynthesis(profile, "none"), /permits non-production backend reads/); }); -test("reviewed file trust is exact-job bound, persists on resume/restart and is never inherited by new jobs", async () => { - const repository = createGitRepository(); - let finish; - const launches = []; - const executor = (input) => { - launches.push(input); - return { completion: new Promise((resolve) => { finish = resolve; }) }; - }; - let running = await startDashboard(repository, executor, undefined, { runtime: "copilot" }); - try { - const { url } = running; - const created = await jsonRequest(url, "/api/jobs", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request: "Synthetic reviewed source edits.", mode: "fix-and-validate" }) - }); - assert.equal(created.response.status, 202, created.body.error); - assert.equal(created.body.job.fileAccess, null); - const identity = (await jsonRequest(url, "/api/status")).body.instanceId; - const review = (changes = {}, origin = url) => jsonRequest(url, "/api/job/file-access", { - method: "POST", headers: { "Content-Type": "application/json", ...(origin ? { Origin: origin } : {}) }, - body: JSON.stringify({ - instanceId: identity, jobId: created.body.job.id, repository, - mode: "source-read-write", allowValidationGates: true, confirmed: true, ...changes - }) - }); - assert.equal((await review()).response.status, 409); - finish({ code: 1 }); - await new Promise((resolve) => setTimeout(resolve, 50)); - for (const changes of [{ instanceId: randomUUID() }, { jobId: randomUUID() }]) { - assert.equal((await review(changes)).response.status, 409); - } - for (const origin of ["", "https://example.invalid"]) { - assert.equal((await review({}, origin)).response.status, 403); - } - for (const changes of [ - { confirmed: false }, { mode: "all" }, { repository: dirname(repository) } - ]) {assert.equal((await review(changes)).response.status, 400);} - const reviewed = await review(); - assert.equal(reviewed.response.status, 200, reviewed.body.error); - assert.equal(reviewed.body.job.fileAccess.mode, "source-read-write"); - assert.equal(reviewed.body.job.fileAccess.allowValidationGates, true); - assert.equal(launches.length, 1, "granting trust never resumes a job"); - const expected = reviewed.body.job.fileAccess; - const snapshot = JSON.parse(readFileSync(join(dirname(createCacheContext(repository).cachePath), "dashboard-jobs.json"), "utf8")); - assert.match(JSON.stringify(snapshot), /"fileAccess":\{"mode":"source-read-write"/); - await running.dashboard.close(); - running = await startDashboard(repository, executor, undefined, { runtime: "copilot" }); - const restored = (await jsonRequest(running.url, "/api/job")).body.job; - assert.equal(restored.id, created.body.job.id); - assert.deepEqual(restored.fileAccess, expected); - const resumed = await jsonRequest(running.url, "/api/job/input", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "retry", details: "Resume this explicitly trusted job." }) - }); - assert.equal(resumed.response.status, 202, resumed.body.error); - assert.deepEqual(launches.at(-1).fileAccess, expected); - assert.equal(launches.at(-1).dashboardPort, Number(new URL(running.url).port)); - finish({ code: 1 }); - await new Promise((resolve) => setTimeout(resolve, 50)); - const fresh = await jsonRequest(running.url, "/api/jobs", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request: "A different untrusted job.", mode: "validate-only", fileAccess: expected }) - }); - assert.equal(fresh.response.status, 202, fresh.body.error); - const queued = fresh.body.queue.at(-1); - assert.notEqual(queued.id, created.body.job.id); - assert.equal(queued.fileAccess, null); - assert.equal(launches.length, 2, "creating a queued job does not inherit or execute trusted access"); - const submission = await jsonRequest(running.url, "/api/jobs", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request: "Explicit routine source consent.", mode: "fix-and-validate", - routineFileAccess: true }) - }); - assert.equal(submission.response.status, 202, submission.body.error); - const consented = submission.body.queue.at(-1); - assert.equal(consented.fileAccess.mode, "source-read-write"); - assert.equal(consented.fileAccess.jobId, consented.id); - assert.equal(consented.fileAccess.repository, realpathSync.native(repository)); - assert.equal(consented.fileAccess.allowValidationGates, true); - } finally { - await running.dashboard.close(); - rmSync(repository, { recursive: true, force: true }); - } -}); - -test("input-token budgets pause without automatic continuation, preserve usage and require bound review", async () => { - const repository = createGitRepository(); - let finish; - let launches = 0; - const { dashboard, url } = await startDashboard(repository, (input) => { - launches++; - assert.equal(input.inputTokenLimit, launches === 1 ? 100 : 200); - input.onUsage({ - inputTokens: 110, cachedInputTokens: 90, cacheWriteTokens: 20, - outputTokens: 5, cacheReusePercent: 90 / 110 * 100 - }); - input.onOutput("stdout", "Tokens \u2191 110 (90 cached, 20 written) \u2022 \u2193 5\n"); - input.onOutput("stdout", "Tokens \u2191 0 (0 cached, 0 written) \u2022 \u2193 0\n"); - return { completion: new Promise((resolve) => { finish = resolve; }) }; - }, undefined, { runtime: "copilot" }); - try { - const created = await jsonRequest(url, "/api/jobs", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request: "Synthetic bounded-input scenario.", mode: "validate-only", inputTokenLimit: 100 }) - }); - assert.equal(created.response.status, 202, created.body.error); - assert.equal(created.body.job.inputTokenLimit, 100); - const identity = (await jsonRequest(url, "/api/status")).body; - const review = (body, origin = url) => jsonRequest(url, "/api/job/budget", { - method: "POST", headers: { "Content-Type": "application/json", ...(origin ? { Origin: origin } : {}) }, - body: JSON.stringify({ - instanceId: identity.instanceId, jobId: created.body.job.id, - confirmed: true, inputTokenLimit: 200, ...body - }) - }); - assert.equal((await review({})).response.status, 409, "running budgets cannot be silently changed"); - const commented = await jsonRequest(url, "/api/job/input", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "comment", details: "Retain this pending scoped instruction." }) - }); - assert.equal(commented.response.status, 202); - const error = new Error("Input-token ceiling reached (110/100, cached input included)."); - error.code = "INPUT_TOKEN_LIMIT"; - finish({ code: null, error }); - await new Promise((resolve) => setTimeout(resolve, 50)); - const paused = (await jsonRequest(url, "/api/job")).body.job; - assert.equal(paused.status, "blocked"); - assert.equal(paused.budgetExceeded, true); - assert.equal(paused.canResume, false); - assert.equal(paused.usage.inputTokens, 110); - assert.equal(paused.usage.cachedInputTokens, 90); - assert.equal(paused.pendingInputCount, 1); - assert.equal(launches, 1, "queued comments must not continue exhausted jobs"); - const conversation = (await jsonRequest(url, "/api/conversation")).body; - assert.equal(conversation.question.kind, "budget"); - assert.deepEqual(conversation.question.actions, []); - assert.equal(conversation.job.inputTokenLimit, 100); - assert.equal(conversation.job.inputTokens, 110); - assert.equal(conversation.job.budgetExceeded, true); - const snapshot = JSON.parse(readFileSync(join(dirname(createCacheContext(repository).cachePath), "dashboard-jobs.json"), "utf8")); - assert.match(JSON.stringify(snapshot), /"inputTokenLimit":100/); - assert.match(JSON.stringify(snapshot), /"budgetExceeded":true/); - for (const body of [ - { instanceId: randomUUID() }, { jobId: randomUUID() } - ]) { - assert.equal((await review(body)).response.status, 409); - } - assert.equal((await review({}, "")).response.status, 403); - assert.equal((await review({}, "https://example.invalid")).response.status, 403); - for (const body of [ - { confirmed: false }, { inputTokenLimit: 110 }, { inputTokenLimit: 100 }, - { inputTokenLimit: -1 }, { inputTokenLimit: "200" }, { inputTokenLimit: 1.5 } - ]) { - assert.equal((await review(body)).response.status, 400); - } - const reviewed = await review({}); - assert.equal(reviewed.response.status, 200, reviewed.body.error); - assert.equal(reviewed.body.job.inputTokenLimit, 200); - assert.equal(reviewed.body.job.budgetExceeded, false); - assert.equal(reviewed.body.job.usage.inputTokens, 110); - assert.equal(launches, 1, "budget review never resumes the job"); - const resumed = await jsonRequest(url, "/api/job/input", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "retry", details: "Resume within the newly reviewed budget." }) - }); - assert.equal(resumed.response.status, 202, resumed.body.error); - assert.equal(launches, 2); - } finally { - await dashboard.close(); - rmSync(repository, { recursive: true, force: true }); - } -}); - -test("new input-token ceilings are explicit safe integers and unsupported runtimes reject positive limits", async () => { - const repository = createGitRepository(); - for (const runtime of ["copilot", "agency"]) { - const { dashboard, url } = await startDashboard(repository, () => ({ - completion: new Promise(() => {}) - }), undefined, { runtime }); - try { - for (const inputTokenLimit of [-1, 1.5, "100", Number.MAX_SAFE_INTEGER + 1, ...(runtime === "agency" ? [100] : [])]) { - const result = await jsonRequest(url, "/api/jobs", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ request: "Synthetic ceiling validation.", mode: "validate-only", inputTokenLimit }) - }); - assert.equal(result.response.status, 400, JSON.stringify(inputTokenLimit)); - assert.match(result.body.error, /inputTokenLimit/); - } - } finally { - await dashboard.close(); - } - } - rmSync(repository, { recursive: true, force: true }); -}); - -test("native executor forwards budget/protected-path configuration and retains budget errors through owned termination", { timeout: 30000 }, async (t) => { - const repository = createGitRepository(); - const runtime = mkdtempSync(join(tmpdir(), "fixlab-budget-protocol-")); - const sessionId = randomUUID(); - mkdirSync(join(runtime, "dashboard")); - writeFileSync(join(runtime, "dashboard", "copilot-session.js"), ` -process.stdin.once("data", (chunk) => { - const config = JSON.parse(chunk.toString()); - const valid = config.inputTokenLimit === 100 && - config.fileAccess.mode === "source-read-write" && - config.fileAccess.jobId === config.sessionId && - config.protectedPaths.length === 1 && config.protectedPaths[0].endsWith("session.js"); - process.stdout.write(JSON.stringify({ - type: "usage", usage: { inputTokens: 110, cachedInputTokens: 90, cacheWriteTokens: 20, outputTokens: 5 } - }) + "\\n"); - process.stdout.write(JSON.stringify({ - type: "error", message: "Synthetic input-token ceiling reached.", - code: valid ? "INPUT_TOKEN_LIMIT" : "INVALID_BUDGET_CONFIGURATION" - }) + "\\n"); -}); -setInterval(() => {}, 1000); -`); - let handle; - const usage = []; - t.signal.addEventListener("abort", () => handle?.terminate(), { once: true }); - try { - handle = createCopilotExecutor({ packageRoot: runtime })({ - repository, sessionId, prompt: "Synthetic owned budget protocol.", - fileAccess: { mode: "source-read-write", repository: realpathSync.native(repository), jobId: sessionId }, - inputTokenLimit: 100, protectedPaths: [join(repository, "session.js")], - onUsage(value) { usage.push(value); }, - onOutput() {} - }); - const result = await handle.completion; - assert.equal(result.error?.code, "INPUT_TOKEN_LIMIT"); - assert.match(result.error.message, /Synthetic input-token ceiling/); - assert.equal(handle.hasStopped(), true); - assert.equal(usage[0].inputTokens, 110); - } finally { - if (handle && !handle.hasStopped()) { - handle.terminate(); - } - rmSync(runtime, { recursive: true, force: true }); - rmSync(repository, { recursive: true, force: true }); - } -}); - for (const selection of ["intercepted", "none"]) { test(`per-run ${selection} selection reaches only its job and rejects conflicting evidence`, async () => { const repository = createGitRepository(); @@ -1336,7 +1050,6 @@ for (const selection of ["intercepted", "none"]) { test("live native prompts require current instance/job/request, exact browser origin and confirmation", async () => { const repository = createGitRepository(); - const id = randomUUID(); let bridge; let launches = 0; const sent = []; @@ -1348,7 +1061,6 @@ test("live native prompts require current instance/job/request, exact browser or send: (message) => sent.push(message), onChange: onRuntimeRequests }); - bridge.receive({ ...runtimePermissionPrompt({ kind: "shell", fullCommandText: "npm test" }), id }); return { completion: new Promise(() => {}), @@ -1361,6 +1073,8 @@ test("live native prompts require current instance/job/request, exact browser or method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request: "Synthetic native approval scenario.", mode: "validate-only" }) }); + const id = randomUUID(); + bridge.receive({ ...runtimePermissionPrompt({ kind: "shell", fullCommandText: "npm test" }), id }); const state = (await jsonRequest(url, "/api/status")).body; assert.equal(state.job.runtimeRequests[0].id, id); assert.equal(state.job.status, "running"); @@ -2143,11 +1857,8 @@ test("dashboard reports readiness and serves only known static assets", async () assert.match(pageText, /Load from Azure DevOps/); assert.match(pageText, /Load bugs/); assert.match(pageText, /Continue this job/); - assert.match(pageText, /