From 42a96cf9062c563c308f1c0735c2c09d5d98c365 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Sun, 26 Jul 2026 15:04:04 +0200 Subject: [PATCH 01/11] feat: add managed release controls --- backend/src/routes/pullRequestRoutes.ts | 23 ++ backend/src/services/pullRequests.ts | 360 +++++++++++++++++- backend/test/multiFactorAuth.test.ts | 8 + backend/test/serviceBehavior.test.ts | 274 ++++++++++++- docs/setup/production-deploy.md | 311 +++------------ src/components/features/chat/ChatHeader.tsx | 9 +- .../pullRequests/ProductionReleasesCard.tsx | 193 ++++++++++ src/components/layout/AppHeader.tsx | 98 ++++- src/hooks/index.ts | 4 + src/hooks/usePullRequests.ts | 83 ++++ src/pages/PullRequests.tsx | 52 ++- src/test/chatHeader.test.tsx | 4 + src/test/frontendBehavior.test.tsx | 60 +++ src/test/pageBehavior.test.tsx | 54 +++ 14 files changed, 1241 insertions(+), 292 deletions(-) create mode 100644 src/components/features/pullRequests/ProductionReleasesCard.tsx diff --git a/backend/src/routes/pullRequestRoutes.ts b/backend/src/routes/pullRequestRoutes.ts index 39e7c7a18..31d4734da 100644 --- a/backend/src/routes/pullRequestRoutes.ts +++ b/backend/src/routes/pullRequestRoutes.ts @@ -1,9 +1,11 @@ import { json, readJson } from "../http.ts"; import { errorMessage, httpStatusCode } from "../lib/errors.ts"; import { + getDashboardReleaseStatus, getProductionCheckoutStatus, listDashboardPullRequests, prepareAndStartDeployLatest, + prepareAndStartRollback, readDeploymentJobs, runPullRequestApproval, runPullRequestBranchUpdate, @@ -117,6 +119,27 @@ export const pullRequestRoutes = { } }, }, + "/api/pull-requests/releases": { + GET: async () => { + try { + return json({ release: await getDashboardReleaseStatus() }); + } catch (error) { + return routeError(error); + } + }, + }, + "/api/pull-requests/releases/rollback": { + POST: async () => { + try { + return json({ + deployment: await prepareAndStartRollback(), + isOk: true, + }); + } catch (error) { + return routeError(error); + } + }, + }, "/api/pull-requests/production-checkout": { GET: async () => { try { diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index 90237689e..edd50142d 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -17,6 +17,7 @@ import { } from "../releaseDeployment.ts"; import { assertDashboardReleaseHostRuntimeCompatible, + type ManagedDashboardRelease, readDashboardReleaseState, resolveDashboardReleasesRoot, } from "../releaseManager.ts"; @@ -181,6 +182,28 @@ interface DeploymentJob { stderr?: string; } +/** Represents one immutable Dashboard release exposed to the operator UI. */ +export interface DashboardReleaseSummary { + builtAt: string; + commitSha: string; + commitTitle: string; + schema: { + maximumCompatible: number; + minimumCompatible: number; + target: number; + }; +} + +/** Represents the active and immediately rollback-capable release slots. */ +export interface DashboardReleaseStatus { + current?: DashboardReleaseSummary; + previous?: DashboardReleaseSummary; + rollback: { + available: boolean; + reason?: string; + }; +} + /** Represents production checkout status. */ interface ProductionCheckoutStatus { root: string; @@ -189,6 +212,7 @@ interface ProductionCheckoutStatus { branch: string; expectedBranch: string; head: string; + headCommit: string; upstream?: string; isClean: boolean; isProductionRoot: boolean; @@ -349,7 +373,7 @@ function readDeploymentLockExecution( WHERE json_valid(payload_json) AND ( ( - action_key = 'dashboard.deploy' + action_key IN ('dashboard.deploy', 'dashboard.rollback') AND json_extract(payload_json, '$.deploymentId') = ? ) OR ( @@ -363,7 +387,7 @@ function readDeploymentLockExecution( .get(lockOwner, lockOwner) as DeploymentLockExecutionRow | undefined; } -/** Releases the active deploy lock if it still belongs to the given job. */ +/** Releases the active release lock if it still belongs to the given job. */ function releaseDeploymentLock(jobId: string): void { try { database @@ -379,7 +403,10 @@ function cleanupTerminatedDeploymentExecution( timestamp: string, note: string ): void { - if (execution.actionKey === "dashboard.deploy") { + if ( + execution.actionKey === "dashboard.deploy" || + execution.actionKey === "dashboard.rollback" + ) { const deploymentId = execution.payload.deploymentId; if (typeof deploymentId !== "string" || deploymentId.trim() === "") { return; @@ -414,7 +441,9 @@ function cleanupQueuedDeploymentCancellation( cleanupTerminatedDeploymentExecution( execution, timestamp, - "Deploy cancelled before execution" + execution.actionKey === "dashboard.rollback" + ? "Rollback cancelled before execution" + : "Deploy cancelled before execution" ); } @@ -422,9 +451,13 @@ function cleanupExpiredDeploymentExecution(execution: JobExecution): void { cleanupTerminatedDeploymentExecution( execution, execution.finishedAt ?? dateToISOString(new Date()), - execution.status === "cancelled" - ? "Deploy cancelled after its worker lease expired" - : "Deploy failed after its worker lease expired" + execution.actionKey === "dashboard.rollback" + ? execution.status === "cancelled" + ? "Rollback cancelled after its worker lease expired" + : "Rollback failed after its worker lease expired" + : execution.status === "cancelled" + ? "Deploy cancelled after its worker lease expired" + : "Deploy failed after its worker lease expired" ); } @@ -434,6 +467,10 @@ export function registerPullRequestJobLifecycleHandlers(): void { "dashboard.deploy", cleanupQueuedDeploymentCancellation ); + registerQueuedJobCancellationHandler( + "dashboard.rollback", + cleanupQueuedDeploymentCancellation + ); registerQueuedJobCancellationHandler( "github.merge", cleanupQueuedDeploymentCancellation @@ -446,6 +483,10 @@ export function registerPullRequestJobLifecycleHandlers(): void { "dashboard.deploy", cleanupExpiredDeploymentExecution ); + registerExpiredJobExecutionHandler( + "dashboard.rollback", + cleanupExpiredDeploymentExecution + ); registerExpiredJobExecutionHandler("github.merge", cleanupExpiredDeploymentExecution); registerExpiredJobExecutionHandler( "github.merge-deploy", @@ -461,7 +502,9 @@ function ensureNoActiveDeployment(): void { const activeJob = readDeploymentJob(activeJobId); const lockExecution = readDeploymentLockExecution(activeJobId); if (lockExecution?.status === "queued" || lockExecution?.status === "running") { - throw new Error(`Dashboard deploy already in progress (${activeJobId})`); + throw new Error( + `Dashboard release action already in progress (${activeJobId})` + ); } if (lockExecution && activeJob?.status === "building") { writeDeploymentJob({ @@ -475,21 +518,25 @@ function ensureNoActiveDeployment(): void { } if (!activeJob) { if (!lockExecution && !isDeploymentLockStale(activeLock)) { - throw new Error(`Dashboard deploy already in progress (${activeJobId})`); + throw new Error( + `Dashboard release action already in progress (${activeJobId})` + ); } database.prepare("DELETE FROM deployment_lock WHERE id = 1").run(); } else if ( ACTIVE_DEPLOYMENT_STATUSES.has(activeJob.status) && !isDeploymentJobStale(activeJob) ) { - throw new Error(`Dashboard deploy already in progress (${activeJob.id})`); + throw new Error( + `Dashboard release action already in progress (${activeJob.id})` + ); } else { database.prepare("DELETE FROM deployment_lock WHERE id = 1").run(); } } } -/** Acquires the active deploy lock for a new deployment job. */ +/** Acquires the active release lock for a deployment or rollback job. */ function acquireDeploymentLock(jobId: string): void { ensureNoActiveDeployment(); try { @@ -500,7 +547,7 @@ function acquireDeploymentLock(jobId: string): void { .run(jobId, dateToISOString(new Date())); } catch (error) { if (error instanceof Error && /constraint/i.test(error.message)) { - throw new Error("Dashboard deploy already in progress", { + throw new Error("Dashboard release action already in progress", { cause: error, }); } @@ -513,7 +560,7 @@ function refreshDeploymentLockOwner(jobId: string): void { .prepare("UPDATE deployment_lock SET updated_at = ? WHERE id = 1 AND job_id = ?") .run(dateToISOString(new Date()), jobId); if (result.changes !== 1) { - throw new Error("Dashboard deploy lock ownership was lost"); + throw new Error("Dashboard release lock ownership was lost"); } } @@ -552,6 +599,45 @@ export function readDeploymentJobs(): DeploymentJob[] { ).map((row) => mapDeploymentJob(row)); } +function dashboardReleaseSummary( + release: ManagedDashboardRelease +): DashboardReleaseSummary { + return { + builtAt: release.manifest.builtAt, + commitSha: release.commitSha, + commitTitle: release.manifest.commitTitle, + schema: { + maximumCompatible: release.manifest.schema.maximumCompatible, + minimumCompatible: release.manifest.schema.minimumCompatible, + target: release.manifest.schema.target, + }, + }; +} + +/** Reads the managed production release slots without exposing host paths. */ +export async function getDashboardReleaseStatus(): Promise { + const state = await readDashboardReleaseState(resolveDashboardReleasesRoot()); + const current = state.current ? dashboardReleaseSummary(state.current) : undefined; + const previous = state.previous ? dashboardReleaseSummary(state.previous) : undefined; + const isRollbackAvailable = + current !== undefined && + previous !== undefined && + current.commitSha !== previous.commitSha; + + return { + current, + previous, + rollback: { + available: isRollbackAvailable, + ...(!isRollbackAvailable && { + reason: current + ? "No distinct previous release is available" + : "No active managed release is available", + }), + }, + }; +} + /** Performs trim output. */ function trimOutput(value: string): string { return value.slice(-20_000); @@ -1302,7 +1388,7 @@ export async function getProductionCheckoutStatus( signal, timeoutMs: 30_000, }), - runCommand("git", ["rev-parse", "--short", "HEAD"], { + runCommand("git", ["rev-parse", "HEAD"], { signal, timeoutMs: 30_000, }), @@ -1338,7 +1424,8 @@ export async function getProductionCheckoutStatus( worktreeRoot: dashboardWorktreeRoot, branch: currentBranch, expectedBranch: DEFAULT_BASE, - head: head.trim(), + head: head.trim().slice(0, 8), + headCommit: head.trim(), upstream, isClean, isProductionRoot, @@ -1660,6 +1747,99 @@ async function scheduleReleaseCutover( ); } +/** Schedules a detached current/previous swap with readiness-bound restoration. */ +async function scheduleReleaseRollback( + job: DeploymentJob, + targetCommit: string, + originalCommit: string, + signal?: AbortSignal +): Promise { + if ( + !job.commit || + !/^[\da-f]{40}$/u.test(job.commit) || + job.commit !== targetCommit + ) { + throw new TypeError("Release rollback requires its matching full target SHA"); + } + if (originalCommit === targetCommit || !/^[\da-f]{40}$/u.test(originalCommit)) { + throw new TypeError( + "Release rollback requires a distinct full original release SHA" + ); + } + + const releasesRoot = resolveDashboardReleasesRoot(); + const lifecycleCommand = path.join( + releasesRoot, + "releases", + originalCommit, + "backend", + "dist", + "releaseLifecycle.js" + ); + const lifecycleEnvironment = releaseLifecycleInvocation( + releasesRoot, + lifecycleCommand + ); + const targetShort = targetCommit.slice(0, 8); + const originalShort = originalCommit.slice(0, 8); + const okJob: DeploymentJob = { + ...job, + status: "isOk", + updatedAt: dateToISOString(new Date()), + note: `Atomic rollback activated ${targetShort}. Web, worker, and commit readiness passed`, + }; + const restoredJob: DeploymentJob = { + ...job, + status: "failed", + updatedAt: dateToISOString(new Date()), + note: `Rollback target failed readiness; original release ${originalShort} was restored automatically`, + }; + const restorationFailedJob: DeploymentJob = { + ...job, + status: "failed", + updatedAt: dateToISOString(new Date()), + note: `Rollback target failed readiness and restoration of ${originalShort} failed`, + }; + const transitionFailedJob: DeploymentJob = { + ...job, + status: "failed", + updatedAt: dateToISOString(new Date()), + note: "Atomic rollback failed before restart; current release was left unchanged", + }; + + const script = [ + "sleep 2", + ...releaseCutoverShellFunctions(), + `if ${lifecycleEnvironment} rollback; then`, + ` if restart_services && ready_for_commit ${shellQuote(targetShort)}; then`, + ` ${deploymentJobUpdateCommand(okJob)}`, + " else", + ` if ${lifecycleEnvironment} rollback && restart_services && ready_for_commit ${shellQuote(originalShort)}; then`, + ` ${deploymentJobUpdateCommand(restoredJob)}`, + " else", + ` ${deploymentJobUpdateCommand(restorationFailedJob)}`, + " fi", + " fi", + "else", + ` ${deploymentJobUpdateCommand(transitionFailedJob)}`, + "fi", + ].join("\n"); + + return runCommand( + "systemd-run", + [ + "--user", + "--collect", + `--unit=mira-dashboard-deploy-${job.id}`, + "--description=Mira Dashboard atomic release rollback", + "/bin/bash", + "-lc", + script, + ], + { signal, timeoutMs: 30_000 } + ); +} + function didScheduleOrphanedReleaseCutoverRecovery( cutover: OrphanedDeploymentCutover ): boolean { @@ -1811,9 +1991,7 @@ async function runDeploymentJob( currentJob = refreshDeploymentHeartbeat(currentJob); const currentState = await readDashboardReleaseState(releasesRoot); if (!currentState.current) { - throw new Error( - "Managed deployment requires a current release from the one-time production cutover" - ); + throw new Error("Managed deployment requires an active current release"); } currentJob = refreshDeploymentHeartbeat(currentJob); const { stdout: commitSha } = await runCommand("git", ["rev-parse", "HEAD"], { @@ -1883,6 +2061,65 @@ async function runDeploymentJob( } } +/** Validates and schedules a managed rollback after the API has returned its job. */ +async function runRollbackJob( + job: DeploymentJob, + signal?: AbortSignal +): Promise { + let currentJob = job; + const releasesRoot = resolveDashboardReleasesRoot(); + try { + currentJob = refreshDeploymentHeartbeat(currentJob); + await assertManagedDashboardServiceContract(signal); + currentJob = refreshDeploymentHeartbeat(currentJob); + const state = await readDashboardReleaseState(releasesRoot); + if (!state.current || !state.previous) { + throw new Error( + "Managed release rollback requires active current and previous releases" + ); + } + if (!job.commit || state.previous.commitSha !== job.commit) { + throw new Error( + "Rollback target changed before execution; refresh release status and try again" + ); + } + if (state.current.commitSha === state.previous.commitSha) { + throw new Error("Managed release rollback requires two distinct releases"); + } + assertDashboardReleaseHostRuntimeCompatible(state.previous); + + const restartScheduled: DeploymentJob = { + ...currentJob, + status: "restart-scheduled", + updatedAt: dateToISOString(new Date()), + commit: state.previous.commitSha, + commitTitle: state.previous.manifest.commitTitle, + note: "Verified rollback target. Detached atomic rollback and readiness check scheduled", + }; + writeDeploymentJob(restartScheduled); + await scheduleReleaseRollback( + restartScheduled, + state.previous.commitSha, + state.current.commitSha, + signal + ); + return true; + } catch (error) { + const failed: DeploymentJob = { + ...currentJob, + status: "failed", + updatedAt: dateToISOString(new Date()), + note: errorMessage(error, "Rollback failed"), + }; + try { + writeDeploymentJob(failed); + } finally { + releaseDeploymentLock(job.id); + } + return false; + } +} + function resumeWorkerClaimsWhenDeploymentRestartSettles( deploymentId: string, resumeWorkerClaims: () => void @@ -1977,6 +2214,67 @@ export async function prepareAndStartDeployLatest(): Promise { return startDeployLatest(); } +/** Validates the current release slots and queues an atomic rollback. */ +export async function prepareAndStartRollback(): Promise { + registerPullRequestJobLifecycleHandlers(); + const now = dateToISOString(new Date()); + const deploymentId = Bun.randomUUIDv7(); + let job: DeploymentJob | undefined; + acquireDeploymentLock(deploymentId); + try { + const state = await readDashboardReleaseState(resolveDashboardReleasesRoot()); + if (!state.current || !state.previous) { + throw Object.assign( + new Error( + "Managed release rollback requires active current and previous releases" + ), + { statusCode: 409 } + ); + } + if (state.current.commitSha === state.previous.commitSha) { + throw Object.assign( + new Error("Managed release rollback requires two distinct releases"), + { statusCode: 409 } + ); + } + assertDashboardReleaseHostRuntimeCompatible(state.previous); + + job = { + id: deploymentId, + status: "building", + startedAt: now, + updatedAt: now, + commit: state.previous.commitSha, + commitTitle: state.previous.manifest.commitTitle, + note: `Rollback to ${state.previous.commitSha.slice(0, 8)} queued`, + }; + writeDeploymentJob(job); + enqueueJobExecution({ + actionKey: "dashboard.rollback", + displayName: `Roll back Mira Dashboard to ${state.previous.commitSha.slice(0, 8)}`, + payload: { deploymentId: job.id }, + resourceClass: "exclusive", + timeoutMs: 15 * 60 * 1000, + }); + return job; + } catch (error) { + releaseDeploymentLock(deploymentId); + if (job) { + try { + writeDeploymentJob({ + ...job, + note: errorMessage(error, "Dashboard rollback failed to queue"), + status: "failed", + updatedAt: dateToISOString(new Date()), + }); + } catch { + // Preserve the original rollback validation or queue error. + } + } + throw error; + } +} + interface PullRequestApprovalExecutionOptions { lockHeldBy?: string; signal?: AbortSignal; @@ -2238,7 +2536,7 @@ async function executePullRequestMerge( return { result }; } -/** Registers every mutating GitHub/deploy action exclusively in the worker. */ +/** Registers every mutating GitHub/release action exclusively in the worker. */ export function registerPullRequestExecutionActions(): void { registerPullRequestJobLifecycleHandlers(); registerDeploymentCutoverRecoveryHandler(didScheduleOrphanedReleaseCutoverRecovery); @@ -2266,6 +2564,30 @@ export function registerPullRequestExecutionActions(): void { resumeWorkerClaimsWhenDeploymentRestartSettles(deploymentId, resumeWorkerClaims); return { deploymentId }; }); + registerScheduledJobAction("dashboard.rollback", async (job, signal, context) => { + const deploymentId = job.actionPayload.deploymentId; + if (typeof deploymentId !== "string" || deploymentId.trim() === "") { + throw Object.assign(new Error("Deployment id is missing"), { + statusCode: 400, + }); + } + const deployment = readDeploymentJob(deploymentId); + if (!deployment) { + throw Object.assign(new Error("Deployment job not found"), { + statusCode: 404, + }); + } + context.protectFromCancellation(); + const isSuccess = await runRollbackJob(deployment, signal); + if (!isSuccess) { + throw new ScheduledJobActionError("Dashboard rollback failed", { + deploymentId, + }); + } + const resumeWorkerClaims = context.pauseWorkerClaims(); + resumeWorkerClaimsWhenDeploymentRestartSettles(deploymentId, resumeWorkerClaims); + return { deploymentId }; + }); registerScheduledJobAction("github.merge", executePullRequestMerge); registerScheduledJobAction("github.merge-deploy", executePullRequestMerge); registerScheduledJobAction("github.review-approval", async (job, signal, context) => { diff --git a/backend/test/multiFactorAuth.test.ts b/backend/test/multiFactorAuth.test.ts index 4f0b50fb9..2fe456a41 100644 --- a/backend/test/multiFactorAuth.test.ts +++ b/backend/test/multiFactorAuth.test.ts @@ -602,6 +602,14 @@ describe("Dashboard multi-factor authentication", () => { }) ) ).toBe(true); + expect( + requiresRecentMfa( + new Request( + "https://dashboard.example/api/pull-requests/releases/rollback", + { method: "POST" } + ) + ) + ).toBe(true); expect( requiresRecentMfa( new Request( diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index adaa2b5fc..f5ea72e57 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -76,8 +76,8 @@ if [[ "$args" == "rev-parse --show-toplevel" ]]; then printf '%s\n' ${JSON.stringify(repoRoot)} elif [[ "$args" == "rev-parse --abbrev-ref HEAD" ]]; then printf 'main\n' -elif [[ "$args" == "rev-parse --short HEAD" ]]; then - printf 'abc1234\n' +elif [[ "$args" == "rev-parse HEAD" ]]; then + printf 'abc1234abc1234abc1234abc1234abc1234abc12\n' elif [[ "$args" == "rev-parse --abbrev-ref --symbolic-full-name @{u}" ]]; then printf 'origin/main\n' elif [[ "$1" == "status" ]]; then @@ -1593,6 +1593,259 @@ describe("backend service behavior", () => { } }); + it("reports managed release slots and queues rollback through the release lock", async () => { + rememberEnvironment("MIRA_DASHBOARD_RELEASES_ROOT"); + const releasesRoot = createTemporaryRoot("mira-release-status-"); + const currentCommit = "a".repeat(40); + const previousCommit = "b".repeat(40); + await ensureDashboardReleaseLayout(releasesRoot); + await createReleaseFixture( + managedReleasePath(releasesRoot, currentCommit), + currentCommit, + { commitTitle: "Current dashboard release" } + ); + await createReleaseFixture( + managedReleasePath(releasesRoot, previousCommit), + previousCommit, + { commitTitle: "Previous dashboard release" } + ); + symlinkSync( + `releases/${currentCommit}`, + path.join(releasesRoot, "current"), + "dir" + ); + symlinkSync( + `releases/${previousCommit}`, + path.join(releasesRoot, "previous"), + "dir" + ); + process.env.MIRA_DASHBOARD_RELEASES_ROOT = releasesRoot; + + const { getDashboardReleaseStatus, prepareAndStartRollback } = + await import("../src/services/pullRequests.ts"); + const { pullRequestRoutes } = await import("../src/routes/pullRequestRoutes.ts"); + const { cancelJobExecution } = + await import("../src/services/jobExecutionQueue.ts"); + + const status = await getDashboardReleaseStatus(); + expect(status).toMatchObject({ + current: { + commitSha: currentCommit, + commitTitle: "Current dashboard release", + }, + previous: { + commitSha: previousCommit, + commitTitle: "Previous dashboard release", + }, + rollback: { available: true }, + }); + + const statusResponse = + await pullRequestRoutes["/api/pull-requests/releases"].GET(); + expect(statusResponse.status).toBe(200); + await expect(statusResponse.json()).resolves.toMatchObject({ + release: { rollback: { available: true } }, + }); + + const rollbackResponse = + await pullRequestRoutes["/api/pull-requests/releases/rollback"].POST(); + expect(rollbackResponse.status).toBe(200); + const rollbackBody = (await rollbackResponse.json()) as { + deployment: Awaited>; + isOk: boolean; + }; + expect(rollbackBody.isOk).toBe(true); + const rollback = rollbackBody.deployment; + try { + expect(rollback).toMatchObject({ + commit: previousCommit, + commitTitle: "Previous dashboard release", + note: "Rollback to bbbbbbbb queued", + status: "building", + }); + expect( + database.prepare("SELECT job_id FROM deployment_lock WHERE id = 1").get() + ).toEqual({ job_id: rollback.id }); + const execution = database + .prepare( + `SELECT id, display_name + FROM job_executions + WHERE action_key = 'dashboard.rollback' + AND json_extract(payload_json, '$.deploymentId') = ?` + ) + .get(rollback.id) as { display_name: string; id: string }; + expect(execution.display_name).toBe("Roll back Mira Dashboard to bbbbbbbb"); + + cancelJobExecution(execution.id); + expect( + database + .prepare("SELECT status, note FROM deployment_jobs WHERE id = ?") + .get(rollback.id) + ).toEqual({ + note: "Rollback cancelled before execution", + status: "failed", + }); + expect( + database.prepare("SELECT job_id FROM deployment_lock WHERE id = 1").get() + ).toBeNull(); + + rmSync(path.join(releasesRoot, "previous")); + await expect(getDashboardReleaseStatus()).resolves.toMatchObject({ + previous: undefined, + rollback: { + available: false, + reason: "No distinct previous release is available", + }, + }); + await expect(prepareAndStartRollback()).rejects.toThrow( + "requires active current and previous releases" + ); + expect( + database.prepare("SELECT job_id FROM deployment_lock WHERE id = 1").get() + ).toBeNull(); + } finally { + database.prepare("DELETE FROM deployment_lock WHERE id = 1").run(); + database + .prepare( + `DELETE FROM job_executions + WHERE action_key = 'dashboard.rollback' + AND json_extract(payload_json, '$.deploymentId') = ?` + ) + .run(rollback.id); + database.prepare("DELETE FROM deployment_jobs WHERE id = ?").run(rollback.id); + } + }); + + it("hands manual rollback to a detached readiness-bound guardian", async () => { + rememberEnvironment("PATH"); + rememberEnvironment("MIRA_DASHBOARD_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_RELEASES_ROOT"); + rememberEnvironment("MIRA_DASHBOARD_OPENCLAW_HOME"); + rememberEnvironment("MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE"); + const fakeRoot = createTemporaryRoot("mira-release-rollback-root-"); + const fakeBin = createTemporaryRoot("mira-release-rollback-bin-"); + const releasesRoot = path.join(fakeRoot, "managed-releases"); + const openClawHome = path.join(fakeRoot, "state", "openclaw-client"); + const logRotationLockFile = path.join(fakeRoot, "state", "log-rotation.lock"); + const systemdScriptLog = path.join(fakeRoot, "rollback-guardian.sh"); + const currentCommit = "c".repeat(40); + const previousCommit = "d".repeat(40); + mkdirSync(path.join(fakeRoot, "backend"), { recursive: true }); + mkdirSync(path.dirname(openClawHome), { recursive: true }); + await ensureDashboardReleaseLayout(releasesRoot); + await createReleaseFixture( + managedReleasePath(releasesRoot, currentCommit), + currentCommit, + { commitTitle: "Current rollback source" } + ); + await createReleaseFixture( + managedReleasePath(releasesRoot, previousCommit), + previousCommit, + { commitTitle: "Verified rollback target" } + ); + symlinkSync( + `releases/${currentCommit}`, + path.join(releasesRoot, "current"), + "dir" + ); + symlinkSync( + `releases/${previousCommit}`, + path.join(releasesRoot, "previous"), + "dir" + ); + writeFileSync( + path.join(fakeBin, "systemctl"), + String.raw`#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" != *"--user show"* ]]; then + echo "unexpected systemctl args: $*" >&2 + exit 2 +fi +if [[ "$*" == *"mira-dashboard-worker.service"* ]]; then + entrypoint="dist/workerStart.js" + execution_role="worker" + scope_owner="mira-dashboard-worker.service" +else + entrypoint="dist/serverStart.js" + execution_role="web" + scope_owner="mira-dashboard.service" +fi +printf '%s\n' \ + "Environment=NODE_ENV=production MIRA_DASHBOARD_EXECUTION_ROLE=$execution_role MIRA_DASHBOARD_ENABLE_JOB_SCOPES=1 MIRA_DASHBOARD_JOB_SCOPE_OWNER=$scope_owner MIRA_DASHBOARD_DB_PATH=${getMiraDatabasePath()} MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE=${logRotationLockFile} MIRA_DASHBOARD_OPENCLAW_HOME=${openClawHome} MIRA_DASHBOARD_RELEASE_ROOT=${releasesRoot}/current MIRA_DASHBOARD_RELEASES_ROOT=${releasesRoot}" \ + "ExecStart={ path=/usr/local/bin/doppler ; argv[]=/usr/local/bin/doppler run --preserve-env=NODE_ENV,MIRA_DASHBOARD_EXECUTION_ROLE,MIRA_DASHBOARD_ENABLE_JOB_SCOPES,MIRA_DASHBOARD_JOB_SCOPE_OWNER,MIRA_DASHBOARD_DB_PATH,MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE,MIRA_DASHBOARD_OPENCLAW_HOME,MIRA_DASHBOARD_RELEASE_ROOT,MIRA_DASHBOARD_RELEASES_ROOT -- bun $entrypoint ; }" \ + "WorkingDirectory=${releasesRoot}/current/backend" +` + ); + writeFileSync( + path.join(fakeBin, "systemd-run"), + String.raw`#!/usr/bin/env bash +set -euo pipefail +script="${"$"}{!#}" +/bin/bash -n <<<"$script" +printf '%s' "$script" > ${JSON.stringify(systemdScriptLog)} +printf 'scheduled\n' +` + ); + chmodSync(path.join(fakeBin, "systemctl"), 0o755); + chmodSync(path.join(fakeBin, "systemd-run"), 0o755); + process.env.PATH = `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.MIRA_DASHBOARD_ROOT = fakeRoot; + process.env.MIRA_DASHBOARD_RELEASES_ROOT = releasesRoot; + process.env.MIRA_DASHBOARD_OPENCLAW_HOME = openClawHome; + process.env.MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE = logRotationLockFile; + + const { prepareAndStartRollback, registerPullRequestExecutionActions } = + await import("../src/services/pullRequests.ts"); + const { getJobExecution } = await import("../src/services/jobExecutionQueue.ts"); + registerPullRequestExecutionActions(); + await startTestScheduledExecutor(); + const rollback = await prepareAndStartRollback(); + const execution = database + .prepare( + `SELECT id + FROM job_executions + WHERE action_key = 'dashboard.rollback' + AND json_extract(payload_json, '$.deploymentId') = ?` + ) + .get(rollback.id) as { id: string }; + + try { + await waitFor(() => { + const row = database + .prepare("SELECT status, note FROM deployment_jobs WHERE id = ?") + .get(rollback.id) as + { note: string | null; status: string } | undefined; + return ( + row?.status === "restart-scheduled" && existsSync(systemdScriptLog) + ); + }, 5000); + await waitFor( + () => getJobExecution(execution.id)?.status === "success", + 5000 + ); + + const guardian = readFileSync(systemdScriptLog, "utf8"); + expect(guardian).toContain( + `${releasesRoot}/releases/${currentCommit}/backend/dist/releaseLifecycle.js` + ); + expect(guardian).toContain(" rollback"); + expect(guardian).toContain( + `ready_for_commit '${previousCommit.slice(0, 8)}'` + ); + expect(guardian).toContain(`ready_for_commit '${currentCommit.slice(0, 8)}'`); + expect(guardian).toContain( + "original release cccccccc was restored automatically" + ); + } finally { + database + .prepare("UPDATE deployment_jobs SET status = 'failed' WHERE id = ?") + .run(rollback.id); + database.prepare("DELETE FROM deployment_lock WHERE id = 1").run(); + database.prepare("DELETE FROM job_executions WHERE id = ?").run(execution.id); + database.prepare("DELETE FROM deployment_jobs WHERE id = ?").run(rollback.id); + } + }); + it("rejects active deployment locks before starting deploy work", async () => { const jobId = `test-deploy-active-${Bun.randomUUIDv7()}`; const staleOwner = `test-deploy-stale-owner-${Bun.randomUUIDv7()}`; @@ -1622,7 +1875,7 @@ describe("backend service behavior", () => { try { const { startDeployLatest } = await import("../src/services/pullRequests.ts"); expect(() => startDeployLatest()).toThrow( - `Dashboard deploy already in progress (${jobId})` + `Dashboard release action already in progress (${jobId})` ); expect(() => startDeployLatest(staleOwner)).toThrow( "Dashboard deploy lock handoff failed" @@ -1648,7 +1901,7 @@ describe("backend service behavior", () => { .run("2026-01-01T00:00:00.000Z", first.id); expect(() => startDeployLatest()).toThrow( - `Dashboard deploy already in progress (${first.id})` + `Dashboard release action already in progress (${first.id})` ); const firstExecution = database @@ -1785,7 +2038,7 @@ describe("backend service behavior", () => { .get() as { id: string }; approvalExecutionId = execution.id; expect(() => startDeployLatest()).toThrow( - "Dashboard deploy already in progress" + "Dashboard release action already in progress" ); cancelJobExecution(execution.id); @@ -2190,7 +2443,8 @@ printf 'scheduled\n' expectedRoot: fakeRoot, branch: "main", expectedBranch: "main", - head: "abc1234", + head: "abc1234a", + headCommit: "abc1234abc1234abc1234abc1234abc1234abc12", upstream: "origin/main", isClean: true, isProductionRoot: true, @@ -2218,8 +2472,8 @@ if [[ "$*" == "rev-parse --show-toplevel" ]]; then printf '%s\n' ${JSON.stringify(actualRoot)} elif [[ "$*" == "rev-parse --abbrev-ref HEAD" ]]; then printf 'feature\n' -elif [[ "$*" == "rev-parse --short HEAD" ]]; then - printf 'badc0de\n' +elif [[ "$*" == "rev-parse HEAD" ]]; then + printf 'badc0debadc0debadc0debadc0debadc0debadc0\n' elif [[ "$*" == "rev-parse --abbrev-ref --symbolic-full-name ${"@{u}"}" ]]; then exit 1 elif [[ "$1" == "status" ]]; then @@ -2486,6 +2740,8 @@ if [[ "$*" == "rev-parse --show-toplevel" ]]; then printf '%s\n' ${JSON.stringify(fakeRoot)} elif [[ "$*" == "rev-parse --abbrev-ref HEAD" ]]; then printf 'main\n' +elif [[ "$*" == "rev-parse HEAD" ]]; then + printf 'abc1234abc1234abc1234abc1234abc1234abc12\n' elif [[ "$*" == "rev-parse --short HEAD" ]]; then printf 'abc1234\n' elif [[ "$*" == "rev-parse --abbrev-ref --symbolic-full-name ${"@{u}"}" ]]; then @@ -2578,6 +2834,8 @@ if [[ "$*" == "rev-parse --show-toplevel" ]]; then printf '%s\n' ${JSON.stringify(fakeRoot)} elif [[ "$*" == "rev-parse --abbrev-ref HEAD" ]]; then printf 'main\n' +elif [[ "$*" == "rev-parse HEAD" ]]; then + printf 'abc1234abc1234abc1234abc1234abc1234abc12\n' elif [[ "$*" == "rev-parse --short HEAD" ]]; then printf 'abc1234\n' elif [[ "$*" == "rev-parse --abbrev-ref --symbolic-full-name ${"@{u}"}" ]]; then diff --git a/docs/setup/production-deploy.md b/docs/setup/production-deploy.md index 4491e1a62..92b0e30c7 100644 --- a/docs/setup/production-deploy.md +++ b/docs/setup/production-deploy.md @@ -86,243 +86,6 @@ The executor fails closed unless both units already run from managed `current/backend` with the exact stable state paths. A deployment never modifies the running release. -## One-Time Managed Cutover - -Run this once immediately after the atomic-executor change has been merged into -the control checkout. Do **not** use the old in-place deploy executor for this -change. The old services stay online while the merged control-checkout scripts -stage the managed releases. The Jobs queue must be idle. PR #333 is the -known-good format-2 bootstrap release. - -### 1. Stage both releases while the old services remain online - -The existing database is still below the control checkout during this step. -Staging is read-safe and creates a restore-verified pre-deploy backup. -Run all four cutover sections in the same interactive shell so the validated -path and commit variables remain available. - -```bash -cd /home/ubuntu/projects/mira-dashboard -git switch main -git pull --ff-only origin main - -RELEASES_ROOT=/home/ubuntu/projects/mira-dashboard-releases -WORKTREE_ROOT=/home/ubuntu/projects/mira-dashboard-worktrees -OLD_STATE_ROOT=/home/ubuntu/projects/mira-dashboard/backend/data -STATE_ROOT=/home/ubuntu/projects/mira-dashboard-state -OLD_DATABASE_PATH="$OLD_STATE_ROOT/mira-dashboard.db" -OLD_OPENCLAW_CLIENT_HOME="$OLD_STATE_ROOT/openclaw-client" -OLD_LOG_ROTATION_LOCK="$OLD_STATE_ROOT/log-rotation.lock" -DATABASE_PATH="$STATE_ROOT/mira-dashboard.db" -OPENCLAW_CLIENT_HOME="$STATE_ROOT/openclaw-client" -LOG_ROTATION_LOCK="$STATE_ROOT/log-rotation.lock" -BOOTSTRAP_SHA=4aca68e0cffed68c42a630c4221e11e725ab294b -CANDIDATE_SHA="$(git rev-parse HEAD)" -DASHBOARD_PORT="$( - /usr/local/bin/doppler run --config prd --project rajohan -- \ - /bin/sh -c 'printf "%s" "${PORT:-3100}"' -)" -DASHBOARD_PORT="$( - printf '%s' "$DASHBOARD_PORT" | - sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -)" -if ! [[ "$DASHBOARD_PORT" =~ ^[0-9]+$ ]] || - (( DASHBOARD_PORT < 1 || DASHBOARD_PORT > 65535 )); then - DASHBOARD_PORT=3100 -fi -install --directory --mode=0700 "$WORKTREE_ROOT" - -env \ - MIRA_DASHBOARD_DB_PATH="$OLD_DATABASE_PATH" \ - MIRA_DASHBOARD_OPENCLAW_HOME="$OLD_OPENCLAW_CLIENT_HOME" \ - MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE="$OLD_LOG_ROTATION_LOCK" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ - NODE_ENV=production \ - bun backend/src/releaseDeployment.ts stage "$BOOTSTRAP_SHA" -env \ - MIRA_DASHBOARD_DB_PATH="$OLD_DATABASE_PATH" \ - MIRA_DASHBOARD_OPENCLAW_HOME="$OLD_OPENCLAW_CLIENT_HOME" \ - MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE="$OLD_LOG_ROTATION_LOCK" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ - NODE_ENV=production \ - bun backend/src/releaseDeployment.ts stage "$CANDIDATE_SHA" -``` - -### 2. Stop both units and atomically move persistent state - -Keep copies of the installed pre-cutover units for the recovery procedure. -Both source and destination are below `/home/ubuntu/projects`, so `mv` is a -same-filesystem directory rename rather than a live database copy. - -```bash -CUTOVER_UNIT_BACKUP="$(mktemp -d)" -cp --preserve=mode,timestamps \ - /home/ubuntu/.config/systemd/user/mira-dashboard.service \ - "$CUTOVER_UNIT_BACKUP/mira-dashboard.service" -cp --preserve=mode,timestamps \ - /home/ubuntu/.config/systemd/user/mira-dashboard-worker.service \ - "$CUTOVER_UNIT_BACKUP/mira-dashboard-worker.service" - -systemctl --user stop mira-dashboard-worker.service mira-dashboard.service -test -d "$OLD_STATE_ROOT" -test ! -e "$STATE_ROOT" -mv --no-target-directory "$OLD_STATE_ROOT" "$STATE_ROOT" -test -f "$DATABASE_PATH" -``` - -Do not leave a compatibility symlink at `backend/data`. Production units use -the new absolute paths and development retains its normal local `backend/data` -default. - -### 3. Activate the bootstrap and install managed units - -```bash -env \ - MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ - MIRA_DASHBOARD_OPENCLAW_HOME="$OPENCLAW_CLIENT_HOME" \ - MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE="$LOG_ROTATION_LOCK" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ - NODE_ENV=production \ - bun backend/src/releaseLifecycle.ts activate "$BOOTSTRAP_SHA" - -install -m 0644 systemd/mira-dashboard.service \ - /home/ubuntu/.config/systemd/user/mira-dashboard.service -install -m 0644 systemd/mira-dashboard-worker.service \ - /home/ubuntu/.config/systemd/user/mira-dashboard-worker.service -systemctl --user daemon-reload -systemctl --user restart mira-dashboard-worker.service mira-dashboard.service -``` - -Use a commit-bound readiness function; exhausting the loop is a failure: - -```bash -worker_identity() { - local properties active substate pid started - properties="$( - systemctl --user show mira-dashboard-worker.service \ - --property=ActiveState \ - --property=SubState \ - --property=MainPID \ - --property=ExecMainStartTimestampMonotonic \ - --no-pager 2>/dev/null - )" || return 1 - active="$(sed -n 's/^ActiveState=//p' <<<"$properties")" - substate="$(sed -n 's/^SubState=//p' <<<"$properties")" - pid="$(sed -n 's/^MainPID=//p' <<<"$properties")" - started="$(sed -n 's/^ExecMainStartTimestampMonotonic=//p' <<<"$properties")" - [[ "$active" == active && "$substate" == running ]] || return 1 - [[ "$pid:$started" =~ ^[1-9][0-9]*:[1-9][0-9]*$ ]] || return 1 - printf '%s:%s' "$pid" "$started" -} - -readiness_matches() { - local expected="$1" - local response - response="$(curl --fail --silent --show-error \ - --connect-timeout 2 --max-time 5 \ - "http://127.0.0.1:${DASHBOARD_PORT}/api/health/ready" || true)" - jq --exit-status --arg expected "$expected" \ - '.status == "isReady" - and .checks.release.ready == true - and .checks.release.backendCommit == $expected - and .checks.release.frontendCommit == $expected - and .checks.worker.ready == true' <<<"$response" >/dev/null -} - -ready_for_commit() { - local full_sha="$1" - local expected="${full_sha:0:8}" - local initial_worker_identity current_worker_identity - initial_worker_identity="" - for attempt in {1..30}; do - if readiness_matches "$expected"; then - initial_worker_identity="$(worker_identity || true)" - [[ -n "$initial_worker_identity" ]] && break - fi - sleep 1 - done - [[ -n "$initial_worker_identity" ]] || return 1 - sleep 31 - current_worker_identity="$(worker_identity || true)" - [[ "$current_worker_identity" == "$initial_worker_identity" ]] || return 1 - readiness_matches "$expected" -} - -recover_legacy_deployment() { - systemctl --user stop \ - mira-dashboard-worker.service mira-dashboard.service || return 1 - install -m 0644 "$CUTOVER_UNIT_BACKUP/mira-dashboard.service" \ - /home/ubuntu/.config/systemd/user/mira-dashboard.service || return 1 - install -m 0644 "$CUTOVER_UNIT_BACKUP/mira-dashboard-worker.service" \ - /home/ubuntu/.config/systemd/user/mira-dashboard-worker.service || return 1 - mv --no-target-directory "$STATE_ROOT" "$OLD_STATE_ROOT" || return 1 - systemctl --user daemon-reload || return 1 - systemctl --user restart \ - mira-dashboard-worker.service mira-dashboard.service -} - -if ! ready_for_commit "$BOOTSTRAP_SHA"; then - echo "Bootstrap readiness failed; restoring the legacy deployment" >&2 - if ! recover_legacy_deployment; then - echo "Legacy deployment recovery also failed; manual recovery is required" >&2 - fi - exit 1 -fi -``` - -Any failed bootstrap check now runs recovery and exits nonzero. Investigate -before retrying; never continue to candidate activation after this branch. - -### 4. Activate and verify the candidate - -```bash -if ! env \ - MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ - NODE_ENV=production \ - bun "$RELEASES_ROOT/releases/$BOOTSTRAP_SHA/backend/dist/releaseLifecycle.js" \ - activate "$CANDIDATE_SHA"; then - echo "Candidate activation failed; bootstrap remains active" >&2 - exit 1 -fi -systemctl --user restart mira-dashboard-worker.service mira-dashboard.service -``` - -```bash -if ! ready_for_commit "$CANDIDATE_SHA"; then - echo "Candidate readiness failed; rolling back to bootstrap" >&2 - if ! env \ - MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ - NODE_ENV=production \ - bun "$RELEASES_ROOT/releases/$BOOTSTRAP_SHA/backend/dist/releaseLifecycle.js" \ - rollback; then - echo "Candidate rollback failed; manual recovery is required" >&2 - exit 1 - fi - systemctl --user restart \ - mira-dashboard-worker.service mira-dashboard.service - if ! ready_for_commit "$BOOTSTRAP_SHA"; then - echo "Bootstrap was restored but did not become ready" >&2 - exit 1 - fi - echo "Bootstrap restored; investigate candidate failure before retrying" >&2 - exit 1 -fi -``` - -The failure branch always exits nonzero, including after a verified rollback. -After a successful candidate check: - -```bash -env \ - MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ - NODE_ENV=production \ - bun "$RELEASES_ROOT/current/backend/dist/releaseLifecycle.js" prune 3 -gio trash -- "$CUTOVER_UNIT_BACKUP" -``` - ## Restart And Smoke Test Normal deploys schedule their own restart. For manual recovery, first confirm @@ -344,32 +107,74 @@ must still return `401`. ## Rollback Normal activation automatically rolls back on restart or commit-bound readiness -failure. Manual rollback is a failure-only operation: +failure. The preferred manual path is **Pull requests → Production releases → +Roll back**, which uses the same exclusive release lock, persistent job, +detached guardian, web/worker restart, commit-bound readiness, and automatic +restoration of the original release if the rollback target fails. + +Use the host-local fallback below only when the Dashboard UI is unavailable. +First confirm no deployment or rollback action is running: ```bash set -euo pipefail RELEASES_ROOT=/home/ubuntu/projects/mira-dashboard-releases DATABASE_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db -PREVIOUS_RELEASE="$( - readlink --canonicalize-existing "$RELEASES_ROOT/previous" +CURRENT_RELEASE="$( + readlink --canonicalize-existing "$RELEASES_ROOT/current" )" -PREVIOUS_SHA="$(basename -- "$PREVIOUS_RELEASE")" -[[ "$PREVIOUS_SHA" =~ ^[0-9a-f]{40}$ ]] -[[ "$PREVIOUS_RELEASE" == "$RELEASES_ROOT/releases/$PREVIOUS_SHA" ]] -PREVIOUS_LIFECYCLE="$PREVIOUS_RELEASE/backend/dist/releaseLifecycle.js" -test -f "$PREVIOUS_LIFECYCLE" -test ! -L "$PREVIOUS_LIFECYCLE" -env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ - MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ - NODE_ENV=production \ - bun "$PREVIOUS_LIFECYCLE" status +CURRENT_SHA="$(basename -- "$CURRENT_RELEASE")" +[[ "$CURRENT_SHA" =~ ^[0-9a-f]{40}$ ]] +[[ "$CURRENT_RELEASE" == "$RELEASES_ROOT/releases/$CURRENT_SHA" ]] +CURRENT_LIFECYCLE="$CURRENT_RELEASE/backend/dist/releaseLifecycle.js" +test -f "$CURRENT_LIFECYCLE" +test ! -L "$CURRENT_LIFECYCLE" +STATUS="$( + env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ + MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ + NODE_ENV=production \ + bun "$CURRENT_LIFECYCLE" status +)" +TARGET_SHA="$(jq --raw-output '.previous.commitSha // empty' <<<"$STATUS")" +[[ "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]] +[[ "$TARGET_SHA" != "$CURRENT_SHA" ]] + +ready_for_commit() { + local expected="${1:0:8}" + local response + for attempt in {1..30}; do + response="$( + curl --fail --silent --show-error \ + --connect-timeout 2 --max-time 5 \ + "http://127.0.0.1:${DASHBOARD_PORT:-3100}/api/health/ready" || true + )" + if jq --exit-status --arg expected "$expected" \ + '.status == "isReady" + and .checks.release.ready == true + and .checks.release.backendCommit == $expected + and .checks.release.frontendCommit == $expected + and .checks.worker.ready == true' <<<"$response" >/dev/null; then + return 0 + fi + sleep 1 + done + return 1 +} + env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ NODE_ENV=production \ - bun "$PREVIOUS_LIFECYCLE" rollback + bun "$CURRENT_LIFECYCLE" rollback systemctl --user restart mira-dashboard-worker.service mira-dashboard.service -curl --fail --silent --show-error \ - "http://127.0.0.1:${DASHBOARD_PORT:-3100}/api/health/ready" | jq +if ! ready_for_commit "$TARGET_SHA"; then + echo "Rollback target failed readiness; restoring $CURRENT_SHA" >&2 + env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ + MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ + NODE_ENV=production \ + bun "$CURRENT_LIFECYCLE" rollback + systemctl --user restart mira-dashboard-worker.service mira-dashboard.service + ready_for_commit "$CURRENT_SHA" + exit 1 +fi ``` Git reset and rebuilding in the control checkout are not production rollback diff --git a/src/components/features/chat/ChatHeader.tsx b/src/components/features/chat/ChatHeader.tsx index 62667c8e2..2353057d4 100644 --- a/src/components/features/chat/ChatHeader.tsx +++ b/src/components/features/chat/ChatHeader.tsx @@ -84,12 +84,15 @@ export function ChatHeader({
-
+

{formatHeaderStatus(selectedSession, referenceTime)}

{selectedSession ? ( - <> +
- +
) : undefined}
diff --git a/src/components/features/pullRequests/ProductionReleasesCard.tsx b/src/components/features/pullRequests/ProductionReleasesCard.tsx new file mode 100644 index 000000000..f8a4ca7f4 --- /dev/null +++ b/src/components/features/pullRequests/ProductionReleasesCard.tsx @@ -0,0 +1,193 @@ +import { RotateCcw } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { + DashboardReleaseStatus, + DashboardReleaseSummary, + ProductionCheckoutStatus, +} from "../../../hooks"; +import { formatDate } from "../../../utils/format"; +import { Badge } from "../../ui/Badge"; +import { Button } from "../../ui/Button"; +import { Card, CardTitle } from "../../ui/Card"; + +function areCommitsEquivalent( + left: string | undefined, + right: string | undefined +): boolean { + if (!left || !right) return false; + return left.startsWith(right) || right.startsWith(left); +} + +/** Renders one managed release slot. */ +function ReleaseSlot({ + badge, + label, + release, +}: { + badge: ReactNode; + label: string; + release: DashboardReleaseSummary | undefined; +}) { + return ( +
+
+
+ {label} +
+ {badge} +
+ {release ? ( + <> + + {release.commitTitle} + +
+ {release.commitSha.slice(0, 8)} + Built {formatDate(release.builtAt)} + Schema v{release.schema.target} +
+ + ) : ( +

No release available.

+ )} +
+ ); +} + +interface ProductionReleasesCardProperties { + baseBranch: string; + checkout: ProductionCheckoutStatus | undefined; + error: Error | undefined; + isActionPending: boolean; + onRollback: (release: DashboardReleaseSummary) => void; + release: DashboardReleaseStatus | undefined; +} + +/** Renders managed release state and the manual rollback control. */ +export function ProductionReleasesCard({ + baseBranch, + checkout, + error, + isActionPending, + onRollback, + release, +}: ProductionReleasesCardProperties) { + const checkoutCommit = checkout?.headCommit || checkout?.head; + const isMainCheckoutActive = areCommitsEquivalent( + release?.current?.commitSha, + checkoutCommit + ); + const rollbackReason = + error?.message || + (isActionPending ? "Another Dashboard action is in progress" : undefined) || + release?.rollback.reason || + (release ? undefined : "Managed release status is still loading"); + const rollbackReasonId = rollbackReason ? "rollback-disabled-reason" : undefined; + + return ( + +
+
+ Production releases +

+ Active and previous are verified immutable releases. Rollback + eligibility is revalidated immediately before the atomic swap. +

+
+ + {error + ? "Status unavailable" + : release?.current + ? "Managed release active" + : "Checking releases"} + +
+ + {error ?

{error.message}

: undefined} + +
+ + {release?.current ? "Current" : "Unavailable"} + + } + /> + + {release?.previous ? "Previous" : "Unavailable"} + + } + /> +
+
+
+ Main checkout +
+ + {checkoutCommit + ? isMainCheckoutActive + ? "Active" + : "Deploy available" + : "Checking"} + +
+
+ {checkoutCommit?.slice(0, 8) || "Checking…"} +
+

+ Control checkout; deploy syncs latest {baseBranch} first +

+
+
+ +
+

+ {rollbackReason || + `Ready to roll back to ${release?.previous?.commitSha.slice(0, 8)}.`} +

+ +
+
+ ); +} diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index 70f3a8afd..a315e7315 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -1,10 +1,11 @@ import { useNavigate } from "@tanstack/react-router"; -import { LogOut, Menu } from "lucide-react"; +import { Activity, LogOut, Menu } from "lucide-react"; import { useHealth } from "../../hooks"; import { useOpenClawSocket } from "../../hooks/useOpenClawSocket"; import { authActions } from "../../stores/authStore"; import { Button } from "../ui/Button"; +import { Dropdown } from "../ui/Dropdown"; import { NotificationBell } from "./NotificationBell"; /** Provides props for app header. */ @@ -58,6 +59,25 @@ export function AppHeader({ const navigationToggleLabel = isSidebarOpen ? "Close navigation menu" : "Open navigation menu"; + const isOverallHealthy = + isConnected && + isBackendConnected && + workerState === "ready" && + !hasVersionMismatch; + const hasSystemError = + !isConnected || !isBackendConnected || workerState === "offline"; + const mobileStatusLabel = isOverallHealthy + ? "all systems online" + : hasSystemError + ? "one or more systems need attention" + : hasVersionMismatch + ? "version mismatch" + : "status unavailable"; + const mobileStatusClassName = isOverallHealthy + ? "border-green-500/40 bg-green-500/10 text-green-300" + : hasSystemError + ? "border-red-500/40 bg-red-500/10 text-red-300" + : "border-amber-500/40 bg-amber-500/10 text-amber-200"; return (
@@ -84,6 +104,79 @@ export function AppHeader({ Version mismatch (FE {frontendCommit} / BE {backendCommit}) )} +
+ +
{ void (async () => { await authActions.logout(); @@ -139,7 +233,7 @@ export function AppHeader({ }} > - Log out + Log out
diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 396d5adad..6e862f852 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -109,6 +109,8 @@ export type { } from "./useOpsActions"; export { OPS_ACTIONS, useExecJob, useStartOpsAction } from "./useOpsActions"; export type { + DashboardReleaseStatus, + DashboardReleaseSummary, DeploymentJob, ProductionCheckoutStatus, PullRequestSummary, @@ -120,11 +122,13 @@ export { pullRequestKeys, useApprovePullRequest, useApprovePullRequestReview, + useDashboardReleaseStatus, useDeployDashboard, useProductionCheckout, usePullRequestDeployments, usePullRequests, useRejectPullRequest, + useRollbackDashboard, useUpdatePullRequestBranch, } from "./usePullRequests"; export { hasQuotaStatus, useQuotas } from "./useQuotas"; diff --git a/src/hooks/usePullRequests.ts b/src/hooks/usePullRequests.ts index 0e29895b1..f8f3c763f 100644 --- a/src/hooks/usePullRequests.ts +++ b/src/hooks/usePullRequests.ts @@ -47,6 +47,28 @@ export interface DeploymentJob { stderr?: string; } +/** Represents an immutable managed Dashboard release. */ +export interface DashboardReleaseSummary { + builtAt: string; + commitSha: string; + commitTitle: string; + schema: { + maximumCompatible: number; + minimumCompatible: number; + target: number; + }; +} + +/** Represents the active and immediately previous production releases. */ +export interface DashboardReleaseStatus { + current?: DashboardReleaseSummary; + previous?: DashboardReleaseSummary; + rollback: { + available: boolean; + reason?: string; + }; +} + /** Represents production checkout status. */ export interface ProductionCheckoutStatus { root: string; @@ -55,6 +77,7 @@ export interface ProductionCheckoutStatus { branch: string; expectedBranch: string; head: string; + headCommit?: string; upstream?: string; isClean: boolean; isProductionRoot: boolean; @@ -80,6 +103,11 @@ interface DeploymentsResponse { deployments: DeploymentJob[]; } +/** Represents the managed release status API response. */ +interface DashboardReleaseStatusResponse { + release: DashboardReleaseStatus; +} + /** Represents the production checkout API response. */ interface ProductionCheckoutResponse { checkout: ProductionCheckoutStatus; @@ -101,6 +129,7 @@ export const pullRequestKeys = { list: () => [...pullRequestKeys.all, "list"] as const, deployments: () => [...pullRequestKeys.all, "deployments"] as const, productionCheckout: () => [...pullRequestKeys.all, "production-checkout"] as const, + releaseStatus: () => [...pullRequestKeys.all, "releases"] as const, }; export const PULL_REQUEST_NAV_REFRESH_MS = 60_000; @@ -128,6 +157,14 @@ async function fetchProductionCheckout(): Promise { return response.checkout; } +/** Fetches active and previous managed Dashboard releases. */ +async function fetchDashboardReleaseStatus(): Promise { + const response = await apiFetchRequired( + "/pull-requests/releases" + ); + return response.release; +} + /** Performs approve pull request. */ async function approvePullRequest( number: number, @@ -178,6 +215,16 @@ async function deployDashboard(): Promise<{ isOk: boolean; deployment: Deploymen ); } +/** Queues an atomic rollback to the previous managed release. */ +async function rollbackDashboard(): Promise<{ + isOk: boolean; + deployment: DeploymentJob; +}> { + return apiPostRequired<{ isOk: boolean; deployment: DeploymentJob }>( + "/pull-requests/releases/rollback" + ); +} + /** Provides pull requests. */ export function usePullRequests(refreshInterval = PULL_REQUEST_PAGE_REFRESH_MS) { return useQuery({ @@ -208,6 +255,16 @@ export function useProductionCheckout() { }); } +/** Provides active and previous managed release status. */ +export function useDashboardReleaseStatus() { + return useQuery({ + queryKey: pullRequestKeys.releaseStatus(), + queryFn: fetchDashboardReleaseStatus, + staleTime: 5000, + refetchInterval: AUTO_REFRESH_MS, + }); +} + /** Provides approve pull request. */ export function useApprovePullRequest() { const queryClient = useQueryClient(); @@ -223,6 +280,9 @@ export function useApprovePullRequest() { void queryClient.invalidateQueries({ queryKey: pullRequestKeys.productionCheckout(), }); + void queryClient.invalidateQueries({ + queryKey: pullRequestKeys.releaseStatus(), + }); }, }); } @@ -301,6 +361,29 @@ export function useDeployDashboard() { void queryClient.invalidateQueries({ queryKey: pullRequestKeys.productionCheckout(), }); + void queryClient.invalidateQueries({ + queryKey: pullRequestKeys.releaseStatus(), + }); + }, + }); +} + +/** Provides atomic rollback to the previous managed release. */ +export function useRollbackDashboard() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: rollbackDashboard, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: pullRequestKeys.deployments(), + }); + void queryClient.invalidateQueries({ + queryKey: pullRequestKeys.productionCheckout(), + }); + void queryClient.invalidateQueries({ + queryKey: pullRequestKeys.releaseStatus(), + }); }, }); } diff --git a/src/pages/PullRequests.tsx b/src/pages/PullRequests.tsx index 059780982..6c8e8486c 100644 --- a/src/pages/PullRequests.tsx +++ b/src/pages/PullRequests.tsx @@ -12,6 +12,7 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize from "rehype-sanitize"; import remarkGfm from "remark-gfm"; +import { ProductionReleasesCard } from "../components/features/pullRequests/ProductionReleasesCard"; import { Badge } from "../components/ui/Badge"; import { Button } from "../components/ui/Button"; import { Card, CardTitle } from "../components/ui/Card"; @@ -20,6 +21,7 @@ import { LoadingState } from "../components/ui/LoadingState"; import { PageState } from "../components/ui/PageState"; import { RefreshButton } from "../components/ui/RefreshButton"; import type { + DashboardReleaseSummary, DeploymentJob, ProductionCheckoutStatus, PullRequestSummary, @@ -28,11 +30,13 @@ import type { import { useApprovePullRequest, useApprovePullRequestReview, + useDashboardReleaseStatus, useDeployDashboard, useProductionCheckout, usePullRequestDeployments, usePullRequests, useRejectPullRequest, + useRollbackDashboard, useUpdatePullRequestBranch, } from "../hooks"; import { formatDate } from "../utils/format"; @@ -44,11 +48,12 @@ type PendingAction = | { type: "merge-deploy"; pr: PullRequestSummary } | { type: "review-approve"; pr: PullRequestSummary } | { type: "reject"; pr: PullRequestSummary } + | { release: DashboardReleaseSummary; type: "rollback" } | { type: "deploy" }; type PendingActionType = Exclude["type"]; type UnhandledPendingActionType = Exclude< PendingActionType, - "deploy" | "merge" | "merge-deploy" | "reject" | "review-approve" + "deploy" | "merge" | "merge-deploy" | "reject" | "review-approve" | "rollback" >; const PENDING_ACTION_SWITCH_IS_EXHAUSTIVE: UnhandledPendingActionType extends never @@ -401,6 +406,9 @@ function actionLabel(action: Exclude) { case "deploy": { return `Deploy latest ${DEFAULT_BASE}`; } + case "rollback": { + return `Roll back to ${action.release.commitSha.slice(0, 8)}`; + } } } @@ -411,7 +419,7 @@ function actionMessage(action: Exclude) { return `Merge PR #${action.pr.number}: ${action.pr.title}?\n\nThis will squash-merge the PR and delete the remote branch. It will not deploy.`; } case "merge-deploy": { - return `Merge and deploy PR #${action.pr.number}: ${action.pr.title}?\n\nThis will squash-merge, sync the production checkout to ${DEFAULT_BASE}, build frontend/backend from there, schedule a service restart, and run a health check.`; + return `Merge and deploy PR #${action.pr.number}: ${action.pr.title}?\n\nThis will squash-merge, sync ${DEFAULT_BASE}, publish an immutable release, atomically activate it, restart web and worker, and verify commit-bound readiness. A failed release is rolled back automatically.`; } case "review-approve": { return `Approve PR #${action.pr.number}: ${action.pr.title}?\n\nThis approves the PR on GitHub. It does not merge or deploy.`; @@ -420,7 +428,10 @@ function actionMessage(action: Exclude) { return `Reject PR #${action.pr.number}: ${action.pr.title}?\n\nThis closes the PR with a dashboard rejection comment. It does not delete the branch.`; } case "deploy": { - return `Deploy latest ${DEFAULT_BASE}?\n\nThis will sync the production checkout to ${DEFAULT_BASE}, build frontend/backend from there, schedule a mira-dashboard.service restart, and run a health check.`; + return `Deploy latest ${DEFAULT_BASE}?\n\nThis will sync ${DEFAULT_BASE}, publish an immutable release, atomically activate it, restart web and worker, and verify commit-bound readiness. A failed release is rolled back automatically.`; + } + case "rollback": { + return `Roll back to ${action.release.commitSha.slice(0, 8)}: ${action.release.commitTitle}?\n\nThis atomically swaps the active and previous releases, restarts web and worker, and verifies commit-bound readiness. If the rollback target fails, the current release is restored automatically.`; } } } @@ -531,10 +542,10 @@ function PullRequestCard({ function RecentDeploysCard({ deployments }: { deployments: DeploymentJob[] }) { return ( - Recent deploys + Recent release jobs {deployments.length === 0 ? (

- No dashboard deploy jobs recorded yet. + No dashboard release jobs recorded yet.

) : (
@@ -596,11 +607,14 @@ export function PullRequests() { const { data: deployments = [] } = usePullRequestDeployments(); const { data: productionCheckout, error: productionCheckoutError } = useProductionCheckout(); + const { data: releaseStatus, error: releaseStatusError } = + useDashboardReleaseStatus(); const approvePullRequest = useApprovePullRequest(); const approvePullRequestReview = useApprovePullRequestReview(); const rejectPullRequest = useRejectPullRequest(); const updatePullRequestBranch = useUpdatePullRequestBranch(); const deployDashboard = useDeployDashboard(); + const rollbackDashboard = useRollbackDashboard(); const [pendingAction, setPendingAction] = useState(undefined); const [lastResult, setLastResult] = useState(undefined); const [actionError, setActionError] = useState(undefined); @@ -609,7 +623,8 @@ export function PullRequests() { approvePullRequestReview.isPending || rejectPullRequest.isPending || updatePullRequestBranch.isPending || - deployDashboard.isPending; + deployDashboard.isPending || + rollbackDashboard.isPending; const isProductionActionBlocked = !productionCheckout?.isSafeForDeploy; const productionActionBlockedMessage = isProductionActionBlocked ? checkoutMessage(productionCheckout, productionCheckoutError ?? undefined) @@ -668,6 +683,15 @@ export function PullRequests() { setLastResult(result?.deployment?.note ?? "Deploy scheduled"); break; } + + case "rollback": { + const result = await rollbackDashboard.mutateAsync(); + setLastResult( + result?.deployment?.note ?? + `Rollback to ${action.release.commitSha.slice(0, 8)} scheduled` + ); + break; + } } setPendingAction(undefined); @@ -856,6 +880,17 @@ export function PullRequests() { ) : undefined} + { + setPendingAction({ release, type: "rollback" }); + }} + release={releaseStatus} + /> +
@@ -998,7 +1033,10 @@ export function PullRequests() { confirmLabel={actionLabel(pendingAction)} confirmLoadingLabel="Working" loading={isActionPending} - danger={pendingAction.type === "reject"} + danger={ + pendingAction.type === "reject" || + pendingAction.type === "rollback" + } onCancel={() => { if (isActionPending) { return; diff --git a/src/test/chatHeader.test.tsx b/src/test/chatHeader.test.tsx index 4a20943e8..c2cacd828 100644 --- a/src/test/chatHeader.test.tsx +++ b/src/test/chatHeader.test.tsx @@ -62,6 +62,10 @@ describe("ChatHeader", () => { ); expect(screen.getByLabelText("Thinking: medium")).toHaveTextContent("medium"); expect(screen.getByLabelText("Speed: Auto")).toHaveTextContent("Auto"); + expect(screen.getByTestId("chat-session-badges").parentElement).toHaveClass( + "flex-col", + "sm:flex-row" + ); expect(screen.queryByText(/MAIN/u)).not.toBeInTheDocument(); expect(screen.queryByText(/gpt-5\.6-sol · Context:/u)).not.toBeInTheDocument(); diff --git a/src/test/frontendBehavior.test.tsx b/src/test/frontendBehavior.test.tsx index c897255e9..6c444a7b0 100644 --- a/src/test/frontendBehavior.test.tsx +++ b/src/test/frontendBehavior.test.tsx @@ -147,11 +147,13 @@ import { OPS_ACTIONS, useExecJob, useStartOpsAction } from "../hooks/useOpsActio import { useApprovePullRequest, useApprovePullRequestReview, + useDashboardReleaseStatus, useDeployDashboard, useProductionCheckout, usePullRequestDeployments, usePullRequests, useRejectPullRequest, + useRollbackDashboard, useUpdatePullRequestBranch, } from "../hooks/usePullRequests"; import { hasQuotaStatus, useQuotas } from "../hooks/useQuotas"; @@ -897,6 +899,14 @@ describe("Mira Dashboard frontend behavior", () => { expect(screen.getByTitle("Worker online")).toBeInTheDocument(); expect(screen.getByText("WK")).toBeInTheDocument(); expect(screen.getByText("v2026.6.9")).toBeInTheDocument(); + const mobileStatus = screen.getByRole("button", { + name: /System status: .+\. Open details/u, + }); + await userEvent.click(mobileStatus); + expect(screen.getByText("System status")).toBeInTheDocument(); + expect(screen.getByText("WebSocket")).toBeInTheDocument(); + expect(screen.getByText("Backend")).toBeInTheDocument(); + expect(screen.getByText("Worker")).toBeInTheDocument(); const readyHealth = queryClient.getQueryData(["health"]); expect(readyHealth).toBeDefined(); @@ -3116,6 +3126,34 @@ describe("Mira Dashboard frontend behavior", () => { }); } + if (url === "/api/pull-requests/releases" && method === "GET") { + return Response.json({ + release: { + current: { + builtAt: "2026-06-23T08:00:00.000Z", + commitSha: "a".repeat(40), + commitTitle: "Current release", + schema: { + maximumCompatible: 31, + minimumCompatible: 1, + target: 31, + }, + }, + previous: { + builtAt: "2026-06-22T08:00:00.000Z", + commitSha: "b".repeat(40), + commitTitle: "Previous release", + schema: { + maximumCompatible: 31, + minimumCompatible: 1, + target: 31, + }, + }, + rollback: { available: true }, + }, + }); + } + throw new Error(`Unexpected hook API call: ${method} ${url}`); } ); @@ -3265,6 +3303,11 @@ describe("Mira Dashboard frontend behavior", () => { await waitFor(() => expect(production.result.current.data?.isSafeForDeploy).toBe(true) ); + + const releases = renderHookWithQueryClient(() => useDashboardReleaseStatus()); + await waitFor(() => + expect(releases.result.current.data?.previous?.commitSha).toBe("b".repeat(40)) + ); }); it("fetches health and metrics through dashboard hooks", async () => { @@ -4577,6 +4620,18 @@ describe("Mira Dashboard frontend behavior", () => { }); } + if (url === "/api/pull-requests/releases/rollback" && method === "POST") { + return Response.json({ + isOk: true, + deployment: { + id: "rollback-1", + status: "building", + startedAt: "2026-06-23T08:00:00.000Z", + updatedAt: "2026-06-23T08:00:00.000Z", + }, + }); + } + throw new Error(`Unexpected pull request API call: ${method} ${url}`); } ); @@ -4618,6 +4673,11 @@ describe("Mira Dashboard frontend behavior", () => { await expect(deploy.result.current.mutateAsync()).resolves.toMatchObject({ deployment: { id: "deploy-2" }, }); + + const rollback = renderHookWithQueryClient(() => useRollbackDashboard()); + await expect(rollback.result.current.mutateAsync()).resolves.toMatchObject({ + deployment: { id: "rollback-1" }, + }); }); it("drives task update, move, assignment, deletion, and progress update hooks", async () => { diff --git a/src/test/pageBehavior.test.tsx b/src/test/pageBehavior.test.tsx index 0e19e4b77..fbd3f8b50 100644 --- a/src/test/pageBehavior.test.tsx +++ b/src/test/pageBehavior.test.tsx @@ -1733,6 +1733,34 @@ function apiResponse(url: string, method: string, init?: RequestInit) { }); } + if (url === "/api/pull-requests/releases") { + return Response.json({ + release: { + current: { + builtAt: "2026-06-24T08:00:00.000Z", + commitSha: "abc12345".repeat(5), + commitTitle: "Current dashboard release", + schema: { + maximumCompatible: 31, + minimumCompatible: 1, + target: 31, + }, + }, + previous: { + builtAt: "2026-06-23T08:00:00.000Z", + commitSha: "def45678".repeat(5), + commitTitle: "Previous dashboard release", + schema: { + maximumCompatible: 31, + minimumCompatible: 1, + target: 31, + }, + }, + rollback: { available: true }, + }, + }); + } + if (method === "POST" && url === "/api/pull-requests/190/approve") { expect(parseRequestBody(init)).toEqual({ deploy: false }); return Response.json({ @@ -1773,6 +1801,19 @@ function apiResponse(url: string, method: string, init?: RequestInit) { }); } + if (method === "POST" && url === "/api/pull-requests/releases/rollback") { + return Response.json({ + isOk: true, + deployment: { + id: "rollback-1", + commit: "def45678".repeat(5), + status: "building", + updatedAt: "2026-06-24T08:16:00.000Z", + note: "Rollback to def45678 queued", + }, + }); + } + if (method === "GET" && url === "/api/account/security") { return Response.json({ factors: { @@ -2747,6 +2788,8 @@ describe("Mira Dashboard pages", () => { expect(screen.getByText("Expand backend coverage")).toBeInTheDocument(); expect(screen.getByText("Bump dashboard dependency")).toBeInTheDocument(); expect(screen.getByText("Deploy dashboard")).toBeInTheDocument(); + expect(screen.getByText("Current dashboard release")).toBeInTheDocument(); + expect(screen.getByText("Previous dashboard release")).toBeInTheDocument(); }); expect(screen.getAllByText("1 PR")).toHaveLength(2); expect(screen.getByText("Coverage body")).toBeInTheDocument(); @@ -2774,6 +2817,17 @@ describe("Mira Dashboard pages", () => { expect(screen.getByText("Deploy scheduled")).toBeInTheDocument(); }); + await user.click(screen.getByRole("button", { name: "Roll back to def45678" })); + expect( + screen.getByRole("heading", { name: "Roll back to def45678" }) + ).toBeInTheDocument(); + await user.click( + screen.getAllByRole("button", { name: "Roll back to def45678" }).at(-1)! + ); + await waitFor(() => { + expect(screen.getByText("Rollback to def45678 queued")).toBeInTheDocument(); + }); + await user.click(screen.getAllByRole("button", { name: "Merge only" })[0]!); expect(screen.getByRole("heading", { name: "Merge PR" })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Merge PR" })); From ec15c016b0a0d92a8da1b6ec8d1934f2032b08ea Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Sun, 26 Jul 2026 21:06:06 +0200 Subject: [PATCH 02/11] feat: add atomic release operations and trusted PR dev --- .github/CONTRIBUTING.md | 15 +- .github/pull_request_template.md | 8 +- .github/workflows/dashboard-checks.yml | 10 +- README.md | 39 +- backend/package.json | 16 +- .../src/development/developmentOpenClaw.ts | 228 +++ backend/src/development/developmentStack.ts | 853 +++++++++++ backend/src/http.ts | 24 +- backend/src/lib/values.ts | 23 + backend/src/releaseLifecycle.ts | 10 +- backend/src/releaseManager.ts | 255 ++-- backend/src/requestPolicy.ts | 73 + backend/src/routes/pullRequestRoutes.ts | 53 +- backend/src/server.ts | 41 +- backend/src/services/jobWorker.ts | 24 +- .../src/services/pullRequestPreviewHost.ts | 1291 +++++++++++++++++ backend/src/services/pullRequestPreviews.ts | 189 +++ backend/src/services/pullRequests.ts | 195 ++- backend/test/developmentStack.test.ts | 513 +++++++ backend/test/pullRequestPreview.test.ts | 723 +++++++++ backend/test/releaseManager.test.ts | 29 +- backend/test/serverStartupPolicy.test.ts | 70 +- backend/test/serviceBehavior.test.ts | 187 ++- backend/test/utilityBehavior.test.ts | 222 ++- docs/api/endpoints.md | 29 +- docs/architecture/frontend-feature-map.md | 36 +- docs/architecture/overview.md | 41 +- docs/development/local-dev.md | 197 ++- docs/development/testing-and-prs.md | 23 +- docs/operations/scheduler-cache-backups.md | 6 +- docs/operations/troubleshooting.md | 10 +- docs/setup/production-deploy.md | 29 +- docs/setup/secrets-and-env.md | 57 +- eslint.config.js | 2 +- package.json | 38 +- public/vite.svg | 1 - scripts/developmentFrontend.ts | 25 +- scripts/developmentStack.ts | 60 + scripts/developmentTailscale.ts | 184 +++ src/assets/react.svg | 1 - .../pullRequests/ProductionReleasesCard.tsx | 4 +- .../pullRequests/PullRequestPreviewCard.tsx | 121 ++ src/components/layout/AppHeader.tsx | 214 ++- src/components/ui/Dropdown.tsx | 5 +- src/hooks/index.ts | 5 + src/hooks/usePullRequests.ts | 106 +- src/lib/developmentProxyHeaders.ts | 14 + src/pages/PullRequests.tsx | 171 ++- src/test/developmentProxyHeaders.test.ts | 27 +- src/test/developmentTailscale.test.ts | 60 + src/test/frontendBehavior.test.tsx | 100 +- src/test/pageBehavior.test.tsx | 11 + src/test/pullRequestPreviewCard.test.tsx | 83 ++ 53 files changed, 6229 insertions(+), 522 deletions(-) create mode 100644 backend/src/development/developmentOpenClaw.ts create mode 100644 backend/src/development/developmentStack.ts create mode 100644 backend/src/services/pullRequestPreviewHost.ts create mode 100644 backend/src/services/pullRequestPreviews.ts create mode 100644 backend/test/developmentStack.test.ts create mode 100644 backend/test/pullRequestPreview.test.ts delete mode 100644 public/vite.svg create mode 100644 scripts/developmentStack.ts create mode 100644 scripts/developmentTailscale.ts delete mode 100644 src/assets/react.svg create mode 100644 src/components/features/pullRequests/PullRequestPreviewCard.tsx create mode 100644 src/test/developmentTailscale.test.ts create mode 100644 src/test/pullRequestPreviewCard.test.tsx diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 59f4be877..e09c19315 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -17,17 +17,10 @@ Run the relevant checks locally when possible: ```bash bun run lint:frontend bun run lint:backend -bun run build -bun run test:coverage -``` - -Backend changes use the same checks from `backend/`: - -```bash -cd backend -bun run lint:backend -bun run build -bun run test:coverage +bun run build:frontend +bun run build:backend +bun run test:frontend:coverage +bun run test:backend:coverage ``` Run focused tests while iterating, then run the applicable coverage suite before diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 237a44e7e..22095854a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,11 +11,11 @@ - [ ] Frontend lint: `bun run lint:frontend` -- [ ] Frontend build: `bun run build` -- [ ] Frontend tests/coverage: `bun run test:coverage` +- [ ] Frontend build: `bun run build:frontend` +- [ ] Frontend tests/coverage: `bun run test:frontend:coverage` - [ ] Backend lint: `bun run lint:backend` -- [ ] Backend build: `bun run build` from `backend/` -- [ ] Backend tests/coverage: `bun run test:coverage` from `backend/` +- [ ] Backend build: `bun run build:backend` +- [ ] Backend tests/coverage: `bun run test:backend:coverage` - [ ] Focused regression tests: - [ ] Manual UI/API smoke check, if relevant diff --git a/.github/workflows/dashboard-checks.yml b/.github/workflows/dashboard-checks.yml index 09bc8a983..f9bd82d76 100644 --- a/.github/workflows/dashboard-checks.yml +++ b/.github/workflows/dashboard-checks.yml @@ -38,10 +38,10 @@ jobs: run: bun run lint:frontend - name: Build frontend - run: bun run build + run: bun run build:frontend - name: Test frontend coverage - run: bun run test:coverage + run: bun run test:frontend:coverage - name: Upload frontend coverage artifact uses: actions/upload-artifact@v7 @@ -92,12 +92,10 @@ jobs: run: bun run lint:backend - name: Build backend - run: bun run build - working-directory: backend + run: bun run build:backend - name: Test backend coverage - run: bun run test:coverage - working-directory: backend + run: bun run test:backend:coverage - name: Prefix backend LCOV paths if: always() diff --git a/README.md b/README.md index a5deb32f9..0a3c21907 100644 --- a/README.md +++ b/README.md @@ -52,23 +52,31 @@ bun install Install backend dependencies separately: ```bash -cd backend -bun install +bun --cwd backend install ``` -Run the frontend dev server: +Run the complete local dev stack: ```bash bun run dev ``` -Run the backend dev server from `backend/`: +For WebAuthn and access from another Tailscale device, use the HTTPS route: ```bash -bun run dev +bun run dev:remote ``` -The backend scripts use Doppler (`rajohan` / `prd`) for runtime secrets. Do not commit `.environment` files, tokens, database dumps, or generated runtime state. +Both commands start frontend and backend hot reload, React Compiler, an isolated +Dashboard database/workspace snapshot, and a dev-only scheduler/worker. Dev +connects to the live OpenClaw Gateway, so chat and session changes can affect +production data. Production host, backup, config, cron, destructive session, +and PR actions remain blocked. + +Only the Gateway token and production auth timing values are selected from +Doppler (`rajohan` / `prd`) at runtime. No secret values are stored in scripts +or tracked files. See [Local development](docs/development/local-dev.md) for +state paths, reset commands, and the trusted PR-dev flow. ## Verification commands @@ -78,18 +86,10 @@ From the repo root: bun run lint:frontend bun run lint:backend bun run build -bun run test -bun run test:coverage -bun run format:check -``` - -From `backend/`: - -```bash -bun run lint:backend -bun run build -bun run test -bun run test:coverage +bun run test:frontend +bun run test:backend +bun run test:frontend:coverage +bun run test:backend:coverage bun run format:check ``` @@ -119,7 +119,8 @@ CI and local verification. restrictive storage modes, deploy/maintenance snapshots, and automated restore checks. - Frontend builds and the local frontend dev server use Bun's HTML bundler with Babel React Compiler and Bun Tailwind plugins. -- Dev server listens on all addresses so the dashboard can be reached over Tailscale when needed. +- Dev servers bind to loopback. `bun run dev:remote` publishes the frontend + through an explicit Tailscale Serve HTTPS route. - Auth is enforced by the backend request policy for every API route except `GET|HEAD /api/health/live`, `GET|HEAD /api/health/ready`, `GET|HEAD /api/auth/bootstrap`, diff --git a/backend/package.json b/backend/package.json index e2b8014eb..02604459a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,20 +6,18 @@ "type": "module", "packageManager": "bun@1.3.14", "scripts": { - "build": "bun node_modules/@typescript/native/bin/tsc --noEmit && bun scripts/build.ts", + "build:backend": "bun node_modules/@typescript/native/bin/tsc --noEmit && bun scripts/build.ts", "db:preflight": "bun dist/databasePreflight.js", - "deploy:prepare": "bun run build && bun run db:preflight", + "deploy:prepare:backend": "bun run build:backend && bun run db:preflight", "auth:reset-password": "MIRA_DASHBOARD_DB_PATH=${MIRA_DASHBOARD_DB_PATH:-/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db} NODE_ENV=production doppler run --config prd --project rajohan --preserve-env=MIRA_DASHBOARD_DB_PATH -- bun dist/resetDashboardPassword.js", - "start": "NODE_ENV=production doppler run --config prd --project rajohan -- bun dist/serverStart.js", + "start:backend": "NODE_ENV=production doppler run --config prd --project rajohan -- bun dist/serverStart.js", "start:worker": "NODE_ENV=production doppler run --config prd --project rajohan -- bun dist/workerStart.js", - "dev": "MIRA_DASHBOARD_DISABLE_SCHEDULER=1 doppler run --config prd --project rajohan -- bun --watch src/serverStart.ts", - "dev:worker": "doppler run --config prd --project rajohan -- bun --watch src/workerStart.ts", "lint:backend": "eslint .", "lint:backend:fix": "eslint . --fix", - "format": "prettier --write '**/*.{ts,js}'", - "format:check": "prettier --check '**/*.{ts,js}'", - "test": "bun test", - "test:coverage": "bun ../scripts/runCoverage.ts 85 src/" + "format:backend": "prettier --write '**/*.{ts,js}'", + "format:backend:check": "prettier --check '**/*.{ts,js}'", + "test:backend": "bun test", + "test:backend:coverage": "bun ../scripts/runCoverage.ts 85 src/" }, "dependencies": { "@simplewebauthn/server": "13.3.2", diff --git a/backend/src/development/developmentOpenClaw.ts b/backend/src/development/developmentOpenClaw.ts new file mode 100644 index 000000000..e1951ffaa --- /dev/null +++ b/backend/src/development/developmentOpenClaw.ts @@ -0,0 +1,228 @@ +import { + chmodSync, + cpSync, + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +const MAX_OPENCLAW_CONFIG_BYTES = 2 * 1024 * 1024; +const OMITTED_WORKSPACE_DIRECTORIES = new Set([ + ".aws", + ".azure", + ".credentials", + ".git", + ".gnupg", + ".secrets", + ".ssh", + "credentials", + "secrets", +]); +const SAFE_ENVIRONMENT_TEMPLATE_SUFFIXES = new Set(["example", "sample", "template"]); +const SENSITIVE_WORKSPACE_FILE_NAMES = new Set([ + ".env", + "credentials.json", + "id_ed25519", + "id_rsa", + "private.key", + "secrets.json", + "secrets.yaml", + "secrets.yml", +]); +const SENSITIVE_AGENT_CONFIG_KEY = + /(?:^|[._-])(?:api[._-]?key|credential|credentials|password|secret|secrets|token)(?:$|[._-])/iu; + +export type DevelopmentWorkspaceState = "copied" | "empty" | "reused"; + +export interface DevelopmentOpenClawSnapshotConfig { + configSource?: string; + openClawHome: string; + workspaceSource?: string; +} + +function isRealDirectory(directoryPath: string): boolean { + try { + const stat = lstatSync(directoryPath); + return stat.isDirectory() && !stat.isSymbolicLink(); + } catch { + return false; + } +} + +function isRealRegularFile(filePath: string): boolean { + try { + const stat = lstatSync(filePath); + return stat.isFile() && !stat.isSymbolicLink(); + } catch { + return false; + } +} + +function ensurePrivateDirectory(directoryPath: string): void { + mkdirSync(directoryPath, { mode: 0o700, recursive: true }); + if (!isRealDirectory(directoryPath)) { + throw new Error(`Development path must be a real directory: ${directoryPath}`); + } + chmodSync(directoryPath, 0o700); +} + +function writePrivateJson(filePath: string, value: unknown): void { + const stagingPath = `${filePath}.partial-${Bun.randomUUIDv7()}`; + try { + writeFileSync(stagingPath, `${JSON.stringify(value, undefined, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + renameSync(stagingPath, filePath); + } catch (error) { + rmSync(stagingPath, { force: true }); + throw error; + } +} + +function defaultAgentsConfig(openClawHome: string) { + return { + defaults: { + model: { primary: "unknown" }, + models: {}, + workspace: path.join(openClawHome, "workspace"), + }, + list: [{ default: true, id: "main" }], + }; +} + +function sanitizedAgentConfigValue(value: unknown, openClawHome: string): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizedAgentConfigValue(item, openClawHome)); + } + if (!value || typeof value !== "object") { + return value; + } + const sanitized: Record = {}; + for (const [key, child] of Object.entries(value)) { + const normalizedKey = key.replaceAll(/([a-z\d])([A-Z])/gu, "$1_$2"); + if (SENSITIVE_AGENT_CONFIG_KEY.test(normalizedKey)) { + continue; + } + sanitized[key] = + key === "workspace" + ? path.join(openClawHome, "workspace") + : sanitizedAgentConfigValue(child, openClawHome); + } + return sanitized; +} + +function snapshotAgentsConfig(config: DevelopmentOpenClawSnapshotConfig): unknown { + if (!config.configSource) { + return defaultAgentsConfig(config.openClawHome); + } + if (!isRealRegularFile(config.configSource)) { + throw new Error( + `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE must be a real regular file: ${config.configSource}` + ); + } + const stat = lstatSync(config.configSource); + if (stat.size > MAX_OPENCLAW_CONFIG_BYTES) { + throw new Error("Development OpenClaw config source is too large"); + } + const parsed = Bun.JSON5.parse(readFileSync(config.configSource, "utf8")) as { + agents?: unknown; + }; + if (!parsed.agents || typeof parsed.agents !== "object") { + return defaultAgentsConfig(config.openClawHome); + } + return sanitizedAgentConfigValue(parsed.agents, config.openClawHome); +} + +function isEnvironmentTemplate(fileName: string): boolean { + if (!fileName.startsWith(".env.")) return false; + const suffix = fileName.slice(".env.".length).toLowerCase(); + return SAFE_ENVIRONMENT_TEMPLATE_SUFFIXES.has(suffix); +} + +function shouldCopyWorkspacePath(sourceRoot: string, candidate: string): boolean { + const relativePath = path.relative(sourceRoot, candidate); + if (!relativePath) return true; + const segments = relativePath.split(path.sep); + if ( + segments.some((segment) => + OMITTED_WORKSPACE_DIRECTORIES.has(segment.toLowerCase()) + ) + ) { + return false; + } + const fileName = segments.at(-1)?.toLowerCase() || ""; + return !( + SENSITIVE_WORKSPACE_FILE_NAMES.has(fileName) || + (fileName.startsWith(".env.") && !isEnvironmentTemplate(fileName)) || + fileName.endsWith(".token") || + fileName.endsWith(".secret") + ); +} + +function copyWorkspaceSnapshot(sourcePath: string, targetPath: string): void { + if (!isRealDirectory(sourcePath)) { + throw new Error( + `MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE must be a real directory: ${sourcePath}` + ); + } + if (path.resolve(sourcePath) === path.resolve(targetPath)) { + throw new Error("Development workspace source and target must be distinct"); + } + const stagingPath = `${targetPath}.partial-${Bun.randomUUIDv7()}`; + try { + cpSync(sourcePath, stagingPath, { + errorOnExist: true, + filter(source) { + const stat = lstatSync(source); + if (stat.isSymbolicLink()) { + throw new Error( + `Development workspace source contains a symlink: ${source}` + ); + } + return shouldCopyWorkspacePath(sourcePath, source); + }, + force: false, + preserveTimestamps: true, + recursive: true, + }); + renameSync(stagingPath, targetPath); + } catch (error) { + rmSync(stagingPath, { force: true, recursive: true }); + throw error; + } +} + +/** Creates a writable workspace snapshot and a secret-free agent config for dev. */ +export function prepareDevelopmentOpenClawSnapshot( + config: DevelopmentOpenClawSnapshotConfig +): DevelopmentWorkspaceState { + ensurePrivateDirectory(config.openClawHome); + ensurePrivateDirectory(path.join(config.openClawHome, "agents")); + const targetWorkspace = path.join(config.openClawHome, "workspace"); + let workspaceState: DevelopmentWorkspaceState; + if (isRealDirectory(targetWorkspace)) { + workspaceState = "reused"; + } else if (config.workspaceSource) { + copyWorkspaceSnapshot(config.workspaceSource, targetWorkspace); + workspaceState = "copied"; + } else { + ensurePrivateDirectory(targetWorkspace); + workspaceState = "empty"; + } + chmodSync(targetWorkspace, 0o700); + + const visibleConfigPath = path.join(config.openClawHome, "openclaw.json"); + if (isRealRegularFile(visibleConfigPath)) { + chmodSync(visibleConfigPath, 0o600); + } else { + writePrivateJson(visibleConfigPath, { + agents: snapshotAgentsConfig(config), + }); + } + return workspaceState; +} diff --git a/backend/src/development/developmentStack.ts b/backend/src/development/developmentStack.ts new file mode 100644 index 000000000..49820f11e --- /dev/null +++ b/backend/src/development/developmentStack.ts @@ -0,0 +1,853 @@ +import { randomBytes } from "node:crypto"; +import { + chmodSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { isIP } from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { Database } from "bun:sqlite"; + +import { + type DevelopmentWorkspaceState, + prepareDevelopmentOpenClawSnapshot, +} from "./developmentOpenClaw.ts"; + +const DEVELOPMENT_STATE_MARKER = ".mira-dashboard-development-state.json"; +const DEVELOPMENT_SECRET_FILE = ".secret-encryption-key"; +const RELEASE_SHA_PATTERN = /^[\da-f]{40}$/u; +const HOST_PATTERN = /^(?:localhost|[\da-f:.]+|[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)$/iu; +const DEFAULT_FRONTEND_PORT = 5173; +const DEFAULT_BACKEND_PORT = 3101; +const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; +const SECRET_KEY_BYTES = 32; +const ISOLATED_JOB_ACTION_KEYS = ["cache.refresh", "database.maintenance"] as const; +const INHERITED_ENVIRONMENT_KEYS = [ + "COLORTERM", + "DBUS_SESSION_BUS_ADDRESS", + "FORCE_COLOR", + "LANG", + "LC_ALL", + "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + "NO_COLOR", + "PATH", + "TERM", + "TMPDIR", + "TZ", + "XDG_RUNTIME_DIR", +] as const; + +export interface DevelopmentStackConfig { + apiTarget: string; + backendHost: string; + backendPort: number; + databasePath: string; + databaseSource?: string; + frontendHost: string; + frontendPort: number; + gatewayTokenFile?: string; + gatewayUrl: string; + openClawClientHome: string; + openClawConfigSource?: string; + openClawHome: string; + publicOrigin: string; + releaseRoot: string; + releaseSource?: string; + repositoryRoot: string; + rpId: string; + secretEncryptionKeyPath: string; + stateOwner: string; + stateRoot: string; + workspaceSource?: string; +} + +export interface DevelopmentStateResult { + database: "created-empty" | "reused" | "snapshot-created"; + releases: "copied" | "empty" | "reused"; + workspace: DevelopmentWorkspaceState; +} + +interface DevelopmentStateMarker { + formatVersion: 1; + owner: string; +} + +function configuredPort( + name: string, + value: string | undefined, + fallback: number +): number { + const rawValue = value?.trim(); + if (!rawValue) { + return fallback; + } + if (!/^\d+$/u.test(rawValue)) { + throw new TypeError(`${name} must be an integer between 1 and 65535`); + } + const port = Number(rawValue); + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new TypeError(`${name} must be an integer between 1 and 65535`); + } + return port; +} + +function configuredHost( + name: string, + value: string | undefined, + fallback: string +): string { + const host = value?.trim() || fallback; + if (host.length > 253 || !HOST_PATTERN.test(host) || /[\s/\\\0]/u.test(host)) { + throw new TypeError(`${name} must be a valid listen hostname or IP address`); + } + return host; +} + +function configuredStateOwner(value: string | undefined, fallback: string): string { + const owner = value?.trim() || fallback; + if (!owner || owner.length > 512 || /[\r\n\0]/u.test(owner)) { + throw new TypeError( + "MIRA_DASHBOARD_DEV_STATE_OWNER must be a non-empty stable identifier" + ); + } + return owner; +} + +function absoluteNonRootPath( + name: string, + value: string | undefined, + fallback?: string +): string | undefined { + const configured = value?.trim() || fallback; + if (!configured) { + return undefined; + } + if (!path.isAbsolute(configured)) { + throw new TypeError(`${name} must be an absolute path`); + } + const resolved = path.resolve(configured); + if (resolved === path.parse(resolved).root) { + throw new TypeError(`${name} must not be a filesystem root`); + } + return resolved; +} + +function normalizedPublicOrigin(value: string | undefined, frontendPort: number): URL { + let origin: URL; + try { + origin = new URL(value?.trim() || `http://localhost:${frontendPort}`); + } catch { + throw new TypeError("MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN must be a valid URL"); + } + const isLocalhost = + origin.hostname === "localhost" || origin.hostname.endsWith(".localhost"); + const hasValidProtocol = + origin.protocol === "https:" || (origin.protocol === "http:" && isLocalhost); + if ( + !hasValidProtocol || + origin.username || + origin.password || + (origin.pathname !== "/" && origin.pathname !== "") || + origin.search || + origin.hash + ) { + throw new TypeError( + "Development public origin must be HTTPS or local HTTP without credentials, path, query, or fragment" + ); + } + if (isIP(origin.hostname)) { + throw new TypeError( + "Development public origin must use localhost or a stable DNS hostname for WebAuthn" + ); + } + return origin; +} + +function normalizedGatewayUrl(value: string | undefined): string | undefined { + const configured = value?.trim(); + if (!configured) return undefined; + let gatewayUrl: URL; + try { + gatewayUrl = new URL(configured); + } catch { + throw new TypeError("MIRA_DASHBOARD_DEV_GATEWAY_URL must be a valid URL"); + } + if ( + !["ws:", "wss:"].includes(gatewayUrl.protocol) || + gatewayUrl.username || + gatewayUrl.password || + gatewayUrl.hash + ) { + throw new TypeError( + "MIRA_DASHBOARD_DEV_GATEWAY_URL must be a ws:// or wss:// URL without credentials or a fragment" + ); + } + return gatewayUrl.href; +} + +/** Resolves one isolated frontend/backend development stack. */ +export function resolveDevelopmentStackConfig( + environment: Record, + root: string +): DevelopmentStackConfig { + const resolvedRepoRoot = path.resolve(root); + const frontendPort = configuredPort( + "MIRA_DASHBOARD_DEV_FRONTEND_PORT", + environment.MIRA_DASHBOARD_DEV_FRONTEND_PORT, + DEFAULT_FRONTEND_PORT + ); + const backendPort = configuredPort( + "MIRA_DASHBOARD_DEV_BACKEND_PORT", + environment.MIRA_DASHBOARD_DEV_BACKEND_PORT, + DEFAULT_BACKEND_PORT + ); + if (frontendPort === backendPort) { + throw new TypeError("Frontend and backend development ports must be distinct"); + } + const hostHome = absoluteNonRootPath( + "MIRA_DASHBOARD_DEV_HOST_HOME", + environment.MIRA_DASHBOARD_DEV_HOST_HOME, + environment.HOME?.trim() || os.homedir() + ); + if (!hostHome) { + throw new Error("Could not resolve the host home for development snapshots"); + } + const stateRoot = absoluteNonRootPath( + "MIRA_DASHBOARD_DEV_STATE_ROOT", + environment.MIRA_DASHBOARD_DEV_STATE_ROOT, + path.join(hostHome, "projects", "mira-dashboard-dev-state", "local") + ); + if (!stateRoot) { + throw new Error("Development state root could not be resolved"); + } + const publicOrigin = normalizedPublicOrigin( + environment.MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN, + frontendPort + ); + const gatewayTokenFile = absoluteNonRootPath( + "MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE", + environment.MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE + ); + const gatewayUrl = normalizedGatewayUrl(environment.MIRA_DASHBOARD_DEV_GATEWAY_URL); + const openClawSourceRoot = absoluteNonRootPath( + "MIRA_DASHBOARD_DEV_OPENCLAW_SOURCE_ROOT", + environment.MIRA_DASHBOARD_DEV_OPENCLAW_SOURCE_ROOT, + path.join(hostHome, ".openclaw") + ); + + return { + apiTarget: `http://127.0.0.1:${backendPort}`, + backendHost: configuredHost( + "MIRA_DASHBOARD_DEV_BACKEND_HOST", + environment.MIRA_DASHBOARD_DEV_BACKEND_HOST, + "127.0.0.1" + ), + backendPort, + databasePath: path.join(stateRoot, "mira-dashboard.db"), + databaseSource: absoluteNonRootPath( + "MIRA_DASHBOARD_DEV_DB_SOURCE", + environment.MIRA_DASHBOARD_DEV_DB_SOURCE, + path.join(hostHome, "projects", "mira-dashboard-state", "mira-dashboard.db") + ), + frontendHost: configuredHost( + "MIRA_DASHBOARD_DEV_FRONTEND_HOST", + environment.MIRA_DASHBOARD_DEV_FRONTEND_HOST, + "127.0.0.1" + ), + frontendPort, + gatewayTokenFile, + gatewayUrl: gatewayUrl || DEFAULT_GATEWAY_URL, + openClawClientHome: path.join(stateRoot, "openclaw-client"), + openClawConfigSource: absoluteNonRootPath( + "MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE", + environment.MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE, + openClawSourceRoot + ? path.join(openClawSourceRoot, "openclaw.json") + : undefined + ), + openClawHome: path.join(stateRoot, "openclaw-home"), + publicOrigin: publicOrigin.origin, + releaseRoot: path.join(stateRoot, "releases-root"), + releaseSource: absoluteNonRootPath( + "MIRA_DASHBOARD_DEV_RELEASES_SOURCE", + environment.MIRA_DASHBOARD_DEV_RELEASES_SOURCE, + path.join(hostHome, "projects", "mira-dashboard-releases") + ), + repositoryRoot: resolvedRepoRoot, + rpId: publicOrigin.hostname.toLowerCase(), + secretEncryptionKeyPath: path.join(stateRoot, DEVELOPMENT_SECRET_FILE), + stateOwner: configuredStateOwner( + environment.MIRA_DASHBOARD_DEV_STATE_OWNER, + "local-dashboard-dev" + ), + stateRoot, + workspaceSource: absoluteNonRootPath( + "MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE", + environment.MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE, + openClawSourceRoot ? path.join(openClawSourceRoot, "workspace") : undefined + ), + }; +} + +function isRealRegularFile(filePath: string): boolean { + try { + const stat = lstatSync(filePath); + return stat.isFile() && !stat.isSymbolicLink(); + } catch { + return false; + } +} + +function isRealDirectory(directoryPath: string): boolean { + try { + const stat = lstatSync(directoryPath); + return stat.isDirectory() && !stat.isSymbolicLink(); + } catch { + return false; + } +} + +function isPathPresentNoFollow(filePath: string): boolean { + try { + lstatSync(filePath); + return true; + } catch { + return false; + } +} + +function hasTable(database: Database, tableName: string): boolean { + return Boolean( + database + .query( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1" + ) + .get(tableName) + ); +} + +function runIfTableExists( + database: Database, + tableName: string, + statement: string +): void { + if (hasTable(database, tableName)) { + database.run(statement); + } +} + +function scrubDevelopmentDatabase(databasePath: string): void { + const database = new Database(databasePath); + database.run("PRAGMA foreign_keys = ON"); + database.run("PRAGMA busy_timeout = 5000"); + database.run("BEGIN IMMEDIATE"); + try { + runIfTableExists( + database, + "auth_webauthn_challenges", + "DELETE FROM auth_webauthn_challenges" + ); + runIfTableExists(database, "auth_sessions", "DELETE FROM auth_sessions"); + runIfTableExists( + database, + "auth_pending_logins", + "DELETE FROM auth_pending_logins" + ); + runIfTableExists(database, "user_totp_factors", "DELETE FROM user_totp_factors"); + runIfTableExists( + database, + "user_recovery_codes", + "DELETE FROM user_recovery_codes" + ); + if ( + hasTable(database, "users") && + hasTable(database, "user_webauthn_credentials") + ) { + database.run( + `UPDATE users + SET mfa_enabled_at = NULL + WHERE NOT EXISTS ( + SELECT 1 + FROM user_webauthn_credentials credential + WHERE credential.user_id = users.id + )` + ); + } + if (hasTable(database, "app_config")) { + database.run("DELETE FROM app_config WHERE key = 'gateway_token'"); + } + runIfTableExists(database, "deployment_lock", "DELETE FROM deployment_lock"); + runIfTableExists(database, "deployment_jobs", "DELETE FROM deployment_jobs"); + runIfTableExists(database, "job_executions", "DELETE FROM job_executions"); + runIfTableExists( + database, + "scheduled_job_runs", + "DELETE FROM scheduled_job_runs" + ); + runIfTableExists(database, "job_workers", "DELETE FROM job_workers"); + if (hasTable(database, "scheduled_jobs")) { + const placeholders = ISOLATED_JOB_ACTION_KEYS.map(() => "?").join(", "); + database + .prepare( + `UPDATE scheduled_jobs + SET enabled = 0, next_run_at = NULL + WHERE action_key NOT IN (${placeholders})` + ) + .run(...ISOLATED_JOB_ACTION_KEYS); + } + runIfTableExists( + database, + "chat_runtime_snapshot_events", + "DELETE FROM chat_runtime_snapshot_events" + ); + runIfTableExists( + database, + "chat_runtime_snapshots", + "DELETE FROM chat_runtime_snapshots" + ); + database.run("COMMIT"); + const quickCheck = database.query("PRAGMA quick_check").get() as + Record | undefined; + if ( + !quickCheck || + Object.values(quickCheck).every( + (value) => !(typeof value === "string" && value.toLowerCase() === "ok") + ) + ) { + throw new Error("Development database snapshot failed SQLite quick_check"); + } + } catch (error) { + if (database.inTransaction) { + database.run("ROLLBACK"); + } + throw error; + } finally { + database.close(); + } +} + +function createDevelopmentDatabaseSnapshot(sourcePath: string, targetPath: string): void { + if (!isRealRegularFile(sourcePath)) { + throw new Error( + `MIRA_DASHBOARD_DEV_DB_SOURCE must be a real regular file: ${sourcePath}` + ); + } + if (path.resolve(sourcePath) === path.resolve(targetPath)) { + throw new Error("Development database source and target must be distinct"); + } + const stagingPath = `${targetPath}.partial-${Bun.randomUUIDv7()}`; + try { + const source = new Database(sourcePath, { readonly: true }); + try { + source.run("PRAGMA busy_timeout = 5000"); + source.prepare("VACUUM INTO ?").run(stagingPath); + } finally { + source.close(); + } + chmodSync(stagingPath, 0o600); + scrubDevelopmentDatabase(stagingPath); + renameSync(stagingPath, targetPath); + } catch (error) { + rmSync(stagingPath, { force: true }); + throw error; + } +} + +function releaseCommitForSlot( + sourceRoot: string, + slot: "current" | "previous" +): string | undefined { + const linkPath = path.join(sourceRoot, slot); + if (!isPathPresentNoFollow(linkPath)) { + return undefined; + } + const stat = lstatSync(linkPath); + if (!stat.isSymbolicLink()) { + throw new Error(`Development release source ${slot} must be a symlink`); + } + const target = readlinkSync(linkPath); + const expectedPrefix = "releases/"; + if (!target.startsWith(expectedPrefix)) { + throw new Error(`Development release source ${slot} target is invalid`); + } + const commitSha = target.slice(expectedPrefix.length); + if ( + target !== path.posix.join("releases", commitSha) || + !RELEASE_SHA_PATTERN.test(commitSha) + ) { + throw new Error(`Development release source ${slot} target is invalid`); + } + const realSourceRoot = realpathSync(sourceRoot); + const releasePath = path.join(realSourceRoot, "releases", commitSha); + if (!isRealDirectory(releasePath) || realpathSync(releasePath) !== releasePath) { + throw new Error(`Development release ${commitSha} must be a real directory`); + } + return commitSha; +} + +function didCopyDevelopmentReleases(sourceRoot: string, targetRoot: string): boolean { + if (!isRealDirectory(sourceRoot)) { + throw new Error( + `MIRA_DASHBOARD_DEV_RELEASES_SOURCE must be a real directory: ${sourceRoot}` + ); + } + const currentCommit = releaseCommitForSlot(sourceRoot, "current"); + if (!currentCommit) { + return false; + } + const previousCommit = releaseCommitForSlot(sourceRoot, "previous"); + const releaseDirectory = path.join(targetRoot, "releases"); + mkdirSync(releaseDirectory, { mode: 0o700, recursive: true }); + const copiedPaths: string[] = []; + const commits = new Set( + [currentCommit, previousCommit].filter((value): value is string => Boolean(value)) + ); + try { + for (const commitSha of commits) { + const sourcePath = path.join(sourceRoot, "releases", commitSha); + const targetPath = path.join(releaseDirectory, commitSha); + cpSync(sourcePath, targetPath, { + errorOnExist: true, + force: false, + preserveTimestamps: true, + recursive: true, + }); + copiedPaths.push(targetPath); + } + symlinkSync( + path.posix.join("releases", currentCommit), + path.join(targetRoot, "current") + ); + if (previousCommit) { + symlinkSync( + path.posix.join("releases", previousCommit), + path.join(targetRoot, "previous") + ); + } + } catch (error) { + rmSync(path.join(targetRoot, "current"), { force: true }); + rmSync(path.join(targetRoot, "previous"), { force: true }); + for (const copiedPath of copiedPaths) { + rmSync(copiedPath, { force: true, recursive: true }); + } + throw error; + } + return true; +} + +function markerPath(config: DevelopmentStackConfig): string { + return path.join(config.stateRoot, DEVELOPMENT_STATE_MARKER); +} + +function expectedStateMarker(config: DevelopmentStackConfig): DevelopmentStateMarker { + return { + formatVersion: 1, + owner: config.stateOwner, + }; +} + +function readDevelopmentStateMarker( + config: DevelopmentStackConfig +): DevelopmentStateMarker { + let marker: Partial; + try { + marker = JSON.parse( + readFileSync(markerPath(config), "utf8") + ) as Partial; + } catch { + throw new Error(`Development state marker is invalid: ${markerPath(config)}`); + } + if (marker.formatVersion !== 1 || marker.owner !== config.stateOwner) { + throw new Error( + `Development state belongs to another checkout: ${config.stateRoot}` + ); + } + return marker as DevelopmentStateMarker; +} + +function assertOrCreateStateOwnership(config: DevelopmentStackConfig): void { + if (!existsSync(config.stateRoot)) { + mkdirSync(config.stateRoot, { mode: 0o700, recursive: true }); + } else if (!isRealDirectory(config.stateRoot)) { + throw new Error("Development state root must be a real directory"); + } + chmodSync(config.stateRoot, 0o700); + const configuredMarkerPath = markerPath(config); + if (isRealRegularFile(configuredMarkerPath)) { + readDevelopmentStateMarker(config); + return; + } + if (isPathPresentNoFollow(configuredMarkerPath)) { + throw new Error("Development state marker must be a real regular file"); + } + if (readdirSync(config.stateRoot).length > 0) { + throw new Error( + `Refusing to claim non-empty unmarked development state: ${config.stateRoot}` + ); + } + writeFileSync( + configuredMarkerPath, + `${JSON.stringify(expectedStateMarker(config), undefined, 2)}\n`, + { encoding: "utf8", mode: 0o600 } + ); +} + +function developmentSecretEncryptionKey(config: DevelopmentStackConfig): string { + if (!isPathPresentNoFollow(config.secretEncryptionKeyPath)) { + writeFileSync( + config.secretEncryptionKeyPath, + `${randomBytes(SECRET_KEY_BYTES).toString("base64")}\n`, + { encoding: "utf8", mode: 0o600 } + ); + } + if (!isRealRegularFile(config.secretEncryptionKeyPath)) { + throw new Error("Development encryption key must be a real regular file"); + } + chmodSync(config.secretEncryptionKeyPath, 0o600); + const encodedKey = readFileSync(config.secretEncryptionKeyPath, "utf8").trim(); + let decodedKey: Buffer; + try { + decodedKey = Buffer.from(encodedKey, "base64"); + } catch { + throw new Error("Development encryption key is not valid base64"); + } + if ( + decodedKey.byteLength !== SECRET_KEY_BYTES || + decodedKey.toString("base64") !== encodedKey + ) { + throw new Error( + `Development encryption key must encode ${SECRET_KEY_BYTES} bytes` + ); + } + return encodedKey; +} + +/** Creates or reuses isolated, ignored development state. */ +export function prepareDevelopmentState( + config: DevelopmentStackConfig +): DevelopmentStateResult { + assertOrCreateStateOwnership(config); + mkdirSync(config.openClawClientHome, { mode: 0o700, recursive: true }); + mkdirSync(config.openClawHome, { mode: 0o700, recursive: true }); + mkdirSync(path.join(config.releaseRoot, "releases"), { + mode: 0o700, + recursive: true, + }); + developmentSecretEncryptionKey(config); + const workspace = prepareDevelopmentOpenClawSnapshot({ + configSource: config.openClawConfigSource, + openClawHome: config.openClawHome, + workspaceSource: config.workspaceSource, + }); + + let database: DevelopmentStateResult["database"]; + if (isPathPresentNoFollow(config.databasePath)) { + if (!isRealRegularFile(config.databasePath)) { + throw new Error("Development database must be a real regular file"); + } + database = "reused"; + } else if (config.databaseSource) { + createDevelopmentDatabaseSnapshot(config.databaseSource, config.databasePath); + database = "snapshot-created"; + } else { + database = "created-empty"; + } + + let releases: DevelopmentStateResult["releases"]; + if (isPathPresentNoFollow(path.join(config.releaseRoot, "current"))) { + releases = "reused"; + } else if (config.releaseSource) { + releases = didCopyDevelopmentReleases(config.releaseSource, config.releaseRoot) + ? "copied" + : "empty"; + } else { + releases = "empty"; + } + return { database, releases, workspace }; +} + +/** Deletes only state carrying the exact development marker for this checkout. */ +export function resetDevelopmentState(config: DevelopmentStackConfig): void { + const configuredMarkerPath = markerPath(config); + if (!isRealRegularFile(configuredMarkerPath)) { + throw new Error( + `Refusing to reset unmarked development state: ${config.stateRoot}` + ); + } + readDevelopmentStateMarker(config); + rmSync(config.stateRoot, { force: true, recursive: true }); +} + +function inheritedChildEnvironment(): Record { + const environment: Record = {}; + for (const key of INHERITED_ENVIRONMENT_KEYS) { + const value = process.env[key]; + if (value !== undefined) { + environment[key] = value; + } + } + return environment; +} + +function developmentGatewayToken( + config: DevelopmentStackConfig, + environment: Record = process.env +): string { + let token: string | undefined; + if (config.gatewayTokenFile) { + if (!isRealRegularFile(config.gatewayTokenFile)) { + throw new Error( + `MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE must be a real regular file: ${config.gatewayTokenFile}` + ); + } + token = readFileSync(config.gatewayTokenFile, "utf8").trim(); + } else { + token = + environment.OPENCLAW_GATEWAY_TOKEN?.trim() || + environment.OPENCLAW_TOKEN?.trim(); + } + if (!token || token.length > 16_384 || /[\r\n\0]/u.test(token)) { + throw new Error( + "Dashboard dev requires OPENCLAW_GATEWAY_TOKEN or MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE" + ); + } + return token; +} + +/** Produces the explicit, secret-minimized backend development environment. */ +export function developmentBackendEnvironment( + config: DevelopmentStackConfig +): Record { + const gatewayToken = developmentGatewayToken(config); + return { + ...inheritedChildEnvironment(), + HOME: config.openClawHome, + MIRA_DASHBOARD_ALLOWED_ORIGINS: config.publicOrigin, + MIRA_DASHBOARD_COOKIE_NAMESPACE: `mira_dashboard_dev_${config.frontendPort}`, + MIRA_DASHBOARD_DB_PATH: config.databasePath, + MIRA_DASHBOARD_DEV_SAFE_MODE: "1", + MIRA_DASHBOARD_DISABLE_SCHEDULER: "0", + MIRA_DASHBOARD_EXECUTION_ROLE: "combined", + MIRA_DASHBOARD_FRONTEND_PATH: path.join(config.repositoryRoot, "dist"), + MIRA_DASHBOARD_HOST: config.backendHost, + MIRA_DASHBOARD_JOB_PROFILE: "isolated", + MIRA_DASHBOARD_LOG_ROTATION_LOCK_FILE: path.join( + config.stateRoot, + "log-rotation.lock" + ), + MIRA_DASHBOARD_OPENCLAW_HOME: config.openClawClientHome, + MIRA_DASHBOARD_RELEASE_ROOT: config.repositoryRoot, + MIRA_DASHBOARD_RELEASES_ROOT: config.releaseRoot, + MIRA_DASHBOARD_ROOT: config.repositoryRoot, + MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY: developmentSecretEncryptionKey(config), + MIRA_DASHBOARD_WEBAUTHN_ORIGINS: config.publicOrigin, + MIRA_DASHBOARD_WEBAUTHN_RP_ID: config.rpId, + MIRA_DASHBOARD_WORKTREE_ROOT: path.dirname(config.repositoryRoot), + NODE_ENV: "development", + OPENCLAW_HOME: config.openClawHome, + PORT: String(config.backendPort), + OPENCLAW_GATEWAY_URL: config.gatewayUrl, + OPENCLAW_GATEWAY_TOKEN: gatewayToken, + }; +} + +function frontendEnvironment(config: DevelopmentStackConfig): Record { + return { + ...inheritedChildEnvironment(), + DASHBOARD_API_TARGET: config.apiTarget, + HOST: config.frontendHost, + MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE: `mira_dashboard_dev_${config.frontendPort}`, + PORT: String(config.frontendPort), + }; +} + +type DevelopmentChild = ReturnType; + +async function developmentChildExit( + child: DevelopmentChild, + processName: "backend" | "frontend" +): Promise<{ code: number; process: "backend" | "frontend" }> { + return { code: await child.exited, process: processName }; +} + +function stopChild(child: DevelopmentChild): void { + if (child.exitCode === null) { + child.kill("SIGTERM"); + } +} + +/** Starts watched frontend/backend children and keeps their lifecycle coupled. */ +export async function runDevelopmentStack( + config: DevelopmentStackConfig +): Promise { + const state = prepareDevelopmentState(config); + const bun = Bun.which("bun") || process.execPath; + const backend = Bun.spawn([bun, "--watch", "src/serverStart.ts"], { + cwd: path.join(config.repositoryRoot, "backend"), + env: developmentBackendEnvironment(config), + stderr: "inherit", + stdin: "inherit", + stdout: "inherit", + }); + const frontend = Bun.spawn([bun, "--watch", "scripts/developmentFrontend.ts"], { + cwd: config.repositoryRoot, + env: frontendEnvironment(config), + stderr: "inherit", + stdin: "inherit", + stdout: "inherit", + }); + let isStopRequested = false; + let isChildrenStopping = false; + const stopChildren = () => { + if (isChildrenStopping) return; + isChildrenStopping = true; + stopChild(frontend); + stopChild(backend); + }; + const handleSignal = () => { + isStopRequested = true; + stopChildren(); + }; + process.once("SIGINT", handleSignal); + process.once("SIGTERM", handleSignal); + + console.log( + [ + `Mira Dashboard development stack: ${config.publicOrigin}`, + `Frontend HMR: ${config.frontendHost}:${config.frontendPort}`, + `Backend HMR: ${config.backendHost}:${config.backendPort}`, + `State: ${config.stateRoot} (database ${state.database}, workspace ${state.workspace}, releases ${state.releases})`, + `Gateway: ${config.gatewayUrl}`, + "Isolated scheduler/worker enabled.", + "Host-control and backup jobs are disabled.", + ].join("\n") + ); + + const childExits = [ + developmentChildExit(backend, "backend"), + developmentChildExit(frontend, "frontend"), + ]; + const exited = await Promise.race(childExits); + stopChildren(); + await Promise.allSettled([backend.exited, frontend.exited]); + process.removeListener("SIGINT", handleSignal); + process.removeListener("SIGTERM", handleSignal); + if (isStopRequested) { + return 0; + } + console.error(`Development ${exited.process} exited with code ${exited.code}`); + return exited.code || 1; +} diff --git a/backend/src/http.ts b/backend/src/http.ts index acba9e019..3ad0809aa 100644 --- a/backend/src/http.ts +++ b/backend/src/http.ts @@ -2,8 +2,28 @@ import type { Server } from "bun"; import { type AuthSession, type AuthUser, getAuthSessionFromSessionId } from "./auth.ts"; -const SESSION_COOKIE = "mira_dashboard_session"; -const PENDING_LOGIN_COOKIE = "mira_dashboard_pending_login"; +const DEFAULT_COOKIE_NAMESPACE = "mira_dashboard"; +const COOKIE_NAMESPACE_PATTERN = /^[a-z0-9_]{1,48}$/u; + +/** Resolves stable cookie names so dev and production sessions can share a host safely. */ +export function resolveDashboardCookieNames( + environment: Record = process.env +): { pendingLogin: string; session: string } { + const namespace = + environment.MIRA_DASHBOARD_COOKIE_NAMESPACE?.trim() || DEFAULT_COOKIE_NAMESPACE; + if (!COOKIE_NAMESPACE_PATTERN.test(namespace)) { + throw new TypeError( + "MIRA_DASHBOARD_COOKIE_NAMESPACE must contain 1-48 lowercase letters, digits, or underscores" + ); + } + return { + pendingLogin: `${namespace}_pending_login`, + session: `${namespace}_session`, + }; +} + +const { pendingLogin: PENDING_LOGIN_COOKIE, session: SESSION_COOKIE } = + resolveDashboardCookieNames(); const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30; const PENDING_LOGIN_TTL_MS = 5 * 60_000; const DEFAULT_JSON_BODY_LIMIT = 2 * 1024 * 1024; diff --git a/backend/src/lib/values.ts b/backend/src/lib/values.ts index 680670bba..04981a489 100644 --- a/backend/src/lib/values.ts +++ b/backend/src/lib/values.ts @@ -20,6 +20,29 @@ export function resolveDashboardPort(value = process.env.PORT): number { return port > 0 && port <= 65_535 ? port : 3100; } +/** Returns the explicit Dashboard bind host or the production-compatible default. */ +export function resolveDashboardHost(value = process.env.MIRA_DASHBOARD_HOST): string { + const host = value?.trim(); + if (!host) { + return "0.0.0.0"; + } + if (host.length > 253) { + throw new TypeError("MIRA_DASHBOARD_HOST must be a valid bind host"); + } + for (const character of host) { + const codePoint = character.codePointAt(0); + if ( + character === "/" || + character === "\\" || + codePoint === undefined || + codePoint <= 0x20 + ) { + throw new TypeError("MIRA_DASHBOARD_HOST must be a valid bind host"); + } + } + return host; +} + /** Converts optional values to strings while preserving empty/undefined fallback behavior. */ export function stringFallback(value?: unknown, fallback = ""): string { return String(value ?? fallback); diff --git a/backend/src/releaseLifecycle.ts b/backend/src/releaseLifecycle.ts index ff7de4d5c..17d2d9239 100644 --- a/backend/src/releaseLifecycle.ts +++ b/backend/src/releaseLifecycle.ts @@ -11,6 +11,7 @@ import { } from "./releaseManager.ts"; const COORDINATED_SCHEMA_CUTOVER_FLAG = "--coordinated-schema-cutover"; +const RELEASE_TRANSITION_LOCK_WAIT_MS = 30_000; function releaseSummary(state: DashboardReleaseState) { const summarize = (release: DashboardReleaseState["current"]) => @@ -44,13 +45,18 @@ export async function runReleaseLifecycleCommand( } let state: DashboardReleaseState; + const transitionOptions: DashboardReleaseManagerOptions = { + ...options, + transitionLockWaitMs: + options.transitionLockWaitMs ?? RELEASE_TRANSITION_LOCK_WAIT_MS, + }; switch (command) { case "activate": { if (!commitSha) { throw new TypeError("Release lifecycle activate requires a commit SHA"); } state = await activateDashboardRelease(commitSha, releasesRoot, { - ...options, + ...transitionOptions, ...(isCoordinatedSchemaCutover && { schemaCutoverMode: "coordinated" as const, }), @@ -61,7 +67,7 @@ export async function runReleaseLifecycleCommand( if (commitSha !== undefined) { throw new TypeError("Release lifecycle rollback takes no commit SHA"); } - state = await rollbackDashboardRelease(releasesRoot, options); + state = await rollbackDashboardRelease(releasesRoot, transitionOptions); break; } case "status": { diff --git a/backend/src/releaseManager.ts b/backend/src/releaseManager.ts index 46da87314..bc840d314 100644 --- a/backend/src/releaseManager.ts +++ b/backend/src/releaseManager.ts @@ -73,6 +73,7 @@ export interface DashboardReleaseManagerOptions { maximumCompatibleVersion: number ) => DashboardLiveSchemaState | Promise; schemaCutoverMode?: "coordinated"; + transitionLockWaitMs?: number; } export interface DashboardReleasePublicationOptions { @@ -1213,74 +1214,85 @@ export async function activateDashboardRelease( options: DashboardReleaseManagerOptions = {} ): Promise { const layout = await ensureDashboardReleaseLayout(releasesRoot); - return withReleaseTransitionLock(layout, "exclusive", async () => { - await recoverInterruptedReleaseTransition(layout); - const candidate = await loadManagedReleaseFromLayout(layout, commitSha); - assertDashboardReleaseHostRuntimeCompatible(candidate); - const state = await readActivationReleaseStateFromLayout(layout); - if (state.current) { - assertReleaseActivationCompatible( + return withReleaseTransitionLock( + layout, + "exclusive", + async () => { + await recoverInterruptedReleaseTransition(layout); + const candidate = await loadManagedReleaseFromLayout(layout, commitSha); + assertDashboardReleaseHostRuntimeCompatible(candidate); + const state = await readActivationReleaseStateFromLayout(layout); + if (state.current) { + assertReleaseActivationCompatible( + candidate.manifest, + state.current.manifest, + options.schemaCutoverMode + ); + } else if (options.schemaCutoverMode === "coordinated") { + throw new Error( + "Coordinated schema cutover mode requires an active current release" + ); + } + const maximumInspectableSchemaVersion = Math.max( + DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum, + candidate.manifest.schema.maximumCompatible, + state.current?.manifest.schema.maximumCompatible ?? 0 + ); + const liveSchemaState = await resolveLiveSchemaState( + options, + maximumInspectableSchemaVersion + ); + const requiresCoordinatedCutover = + requiresLiveSchemaCutover(candidate.manifest, liveSchemaState.version) || + (state.current !== undefined && + requiresCurrentSchemaCutover( + candidate.manifest, + state.current.manifest + )); + if ( + !requiresCoordinatedCutover && + options.schemaCutoverMode === "coordinated" + ) { + throw new Error( + "Coordinated schema cutover mode requires an incompatible schema boundary" + ); + } + assertReleaseCanActivateLiveSchema( candidate.manifest, - state.current.manifest, + liveSchemaState.version, options.schemaCutoverMode ); - } else if (options.schemaCutoverMode === "coordinated") { - throw new Error( - "Coordinated schema cutover mode requires an active current release" - ); - } - const maximumInspectableSchemaVersion = Math.max( - DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum, - candidate.manifest.schema.maximumCompatible, - state.current?.manifest.schema.maximumCompatible ?? 0 - ); - const liveSchemaState = await resolveLiveSchemaState( - options, - maximumInspectableSchemaVersion - ); - const requiresCoordinatedCutover = - requiresLiveSchemaCutover(candidate.manifest, liveSchemaState.version) || - (state.current !== undefined && - requiresCurrentSchemaCutover(candidate.manifest, state.current.manifest)); - if (!requiresCoordinatedCutover && options.schemaCutoverMode === "coordinated") { - throw new Error( - "Coordinated schema cutover mode requires an incompatible schema boundary" + assertReleaseMigrationHistoryCompatible( + candidate.manifest, + liveSchemaState, + "Activation" ); - } - assertReleaseCanActivateLiveSchema( - candidate.manifest, - liveSchemaState.version, - options.schemaCutoverMode - ); - assertReleaseMigrationHistoryCompatible( - candidate.manifest, - liveSchemaState, - "Activation" - ); - if (state.current?.commitSha === candidate.commitSha) { - return state; - } - - const before = releaseLinkStateFromDashboardState(state); - const journal: ReleaseTransitionJournal = { - after: { - current: candidate.commitSha, - previous: before.current, - }, - before, - formatVersion: RELEASE_TRANSITION_FORMAT_VERSION, - operation: "activate", - }; - return await executeReleaseTransition(layout, journal, async () => { - const expectedReleases = new Map([ - [candidate.commitSha, candidate], - ]); - if (state.current) { - expectedReleases.set(state.current.commitSha, state.current); + if (state.current?.commitSha === candidate.commitSha) { + return state; } - await applyReleaseLinkState(layout, journal.after, expectedReleases); - }); - }); + + const before = releaseLinkStateFromDashboardState(state); + const journal: ReleaseTransitionJournal = { + after: { + current: candidate.commitSha, + previous: before.current, + }, + before, + formatVersion: RELEASE_TRANSITION_FORMAT_VERSION, + operation: "activate", + }; + return await executeReleaseTransition(layout, journal, async () => { + const expectedReleases = new Map([ + [candidate.commitSha, candidate], + ]); + if (state.current) { + expectedReleases.set(state.current.commitSha, state.current); + } + await applyReleaseLinkState(layout, journal.after, expectedReleases); + }); + }, + options.transitionLockWaitMs + ); } export async function rollbackDashboardRelease( @@ -1288,64 +1300,71 @@ export async function rollbackDashboardRelease( options: DashboardReleaseManagerOptions = {} ): Promise { const layout = await ensureDashboardReleaseLayout(releasesRoot); - return withReleaseTransitionLock(layout, "exclusive", async () => { - await recoverInterruptedReleaseTransition(layout); - const state = await readDashboardReleaseStateFromLayout(layout); - if (!state.current || !state.previous) { - throw new Error("Managed release rollback requires current and previous"); - } - if (state.current.commitSha === state.previous.commitSha) { - throw new Error("Managed release rollback requires two distinct releases"); - } - - const activeRelease = state.current; - const rollbackRelease = state.previous; - assertDashboardReleaseHostRuntimeCompatible(rollbackRelease); - const maximumInspectableSchemaVersion = Math.max( - DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum, - activeRelease.manifest.schema.maximumCompatible, - rollbackRelease.manifest.schema.maximumCompatible - ); - const liveSchemaState = await resolveLiveSchemaState( - options, - maximumInspectableSchemaVersion - ); - assertReleaseRollbackCompatible( - activeRelease.manifest, - rollbackRelease.manifest, - liveSchemaState.version - ); - assertReleaseMigrationHistoryCompatible( - rollbackRelease.manifest, - liveSchemaState, - "Rollback" - ); + return withReleaseTransitionLock( + layout, + "exclusive", + async () => { + await recoverInterruptedReleaseTransition(layout); + const state = await readDashboardReleaseStateFromLayout(layout); + if (!state.current || !state.previous) { + throw new Error("Managed release rollback requires current and previous"); + } + if (state.current.commitSha === state.previous.commitSha) { + throw new Error( + "Managed release rollback requires two distinct releases" + ); + } - const before = releaseLinkStateFromDashboardState(state); - const journal: ReleaseTransitionJournal = { - after: { - current: rollbackRelease.commitSha, - previous: activeRelease.commitSha, - }, - before, - formatVersion: RELEASE_TRANSITION_FORMAT_VERSION, - operation: "rollback", - }; - return await executeReleaseTransition(layout, journal, async () => { - await replaceReleaseLink( - layout, - "current", - rollbackRelease.commitSha, - rollbackRelease + const activeRelease = state.current; + const rollbackRelease = state.previous; + assertDashboardReleaseHostRuntimeCompatible(rollbackRelease); + const maximumInspectableSchemaVersion = Math.max( + DASHBOARD_DATABASE_SCHEMA_COMPATIBILITY.maximum, + activeRelease.manifest.schema.maximumCompatible, + rollbackRelease.manifest.schema.maximumCompatible ); - await replaceReleaseLink( - layout, - "previous", - activeRelease.commitSha, - activeRelease + const liveSchemaState = await resolveLiveSchemaState( + options, + maximumInspectableSchemaVersion ); - }); - }); + assertReleaseRollbackCompatible( + activeRelease.manifest, + rollbackRelease.manifest, + liveSchemaState.version + ); + assertReleaseMigrationHistoryCompatible( + rollbackRelease.manifest, + liveSchemaState, + "Rollback" + ); + + const before = releaseLinkStateFromDashboardState(state); + const journal: ReleaseTransitionJournal = { + after: { + current: rollbackRelease.commitSha, + previous: activeRelease.commitSha, + }, + before, + formatVersion: RELEASE_TRANSITION_FORMAT_VERSION, + operation: "rollback", + }; + return await executeReleaseTransition(layout, journal, async () => { + await replaceReleaseLink( + layout, + "current", + rollbackRelease.commitSha, + rollbackRelease + ); + await replaceReleaseLink( + layout, + "previous", + activeRelease.commitSha, + activeRelease + ); + }); + }, + options.transitionLockWaitMs + ); } export async function pruneDashboardReleases( diff --git a/backend/src/requestPolicy.ts b/backend/src/requestPolicy.ts index 317edab29..967a5d258 100644 --- a/backend/src/requestPolicy.ts +++ b/backend/src/requestPolicy.ts @@ -103,6 +103,26 @@ const READ_ONLY_GATEWAY_METHODS = new Set([ "subscribe", "unsubscribe", ]); +const DEVELOPMENT_ALLOWED_GATEWAY_METHODS = new Set([ + ...READ_ONLY_GATEWAY_METHODS, + "chat.abort", + "chat.send", + "sessions.patch", +]); +const DEVELOPMENT_BLOCKED_HOST_MUTATION_PATHS = [ + "/api/backup", + "/api/backups", + "/api/config", + "/api/cron", + "/api/docker", + "/api/exec", + "/api/ops", + "/api/pull-requests", + "/api/restart", + "/api/sessions", + "/api/skills", + "/api/terminal", +] as const; const rateLimitState: { bucketCleanupTimer: Timer | undefined } = { bucketCleanupTimer: undefined, }; @@ -138,6 +158,38 @@ function isPublicApiRoute(request: Request): boolean { return PUBLIC_API_METHODS.get(pathname)?.has(request.method.toUpperCase()) === true; } +function isPathAtOrBelow(pathname: string, prefix: string): boolean { + return pathname === prefix || pathname.startsWith(`${prefix}/`); +} + +/** Blocks host and external-service mutations while preserving isolated dev data. */ +export function isDevelopmentHostMutationBlocked( + request: Request, + environment: Record = process.env +): boolean { + if ( + environment.MIRA_DASHBOARD_DEV_SAFE_MODE !== "1" || + SAFE_REQUEST_METHODS.has(request.method.toUpperCase()) + ) { + return false; + } + const pathname = new URL(request.url).pathname; + return DEVELOPMENT_BLOCKED_HOST_MUTATION_PATHS.some((prefix) => + isPathAtOrBelow(pathname, prefix) + ); +} + +/** Allows only the live Gateway calls required by trusted PR dev chat. */ +export function isDevelopmentGatewayMethodBlocked( + method: string, + environment: Record = process.env +): boolean { + return ( + environment.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" && + !DEVELOPMENT_ALLOWED_GATEWAY_METHODS.has(method) + ); +} + function rateLimitKey( rule: RateLimitRule, request: Request, @@ -444,6 +496,27 @@ function secureHandler( ? { id: session.id, username: session.username } : undefined; const actor = requestActor(user, automationPrincipal); + if (isApi && isDevelopmentHostMutationBlocked(request)) { + const didRecordDenial = didWriteRequestAudit( + actor, + "denied", + request, + requestIdentifier, + routePath, + 403, + automationScope, + persistAuditEvent + ); + if (!didRecordDenial) { + return json({ error: "Audit trail unavailable" }, { status: 503 }); + } + return json( + { + error: "Host-control actions are disabled in Dashboard dev", + }, + { status: 403 } + ); + } const isPrivilegedRequest = Boolean(session) && !automationPrincipal && requiresRecentMfa(request); if ( diff --git a/backend/src/routes/pullRequestRoutes.ts b/backend/src/routes/pullRequestRoutes.ts index 31d4734da..feef782f3 100644 --- a/backend/src/routes/pullRequestRoutes.ts +++ b/backend/src/routes/pullRequestRoutes.ts @@ -1,5 +1,10 @@ import { json, readJson } from "../http.ts"; import { errorMessage, httpStatusCode } from "../lib/errors.ts"; +import { + getPullRequestPreviewStatus, + prepareAndStartPullRequestPreview, + prepareAndStopPullRequestPreview, +} from "../services/pullRequestPreviews.ts"; import { getDashboardReleaseStatus, getProductionCheckoutStatus, @@ -98,6 +103,34 @@ export const pullRequestRoutes = { } }, }, + "/api/pull-requests/:number/preview/start": { + POST: async (request: ParametersRequest<"number">) => { + const number = parsePullRequestNumber(request.params.number); + if (number instanceof Response) return number; + try { + return json({ + isOk: true, + preview: await prepareAndStartPullRequestPreview(number), + }); + } catch (error) { + return routeError(error, "PR preview startup failed"); + } + }, + }, + "/api/pull-requests/:number/preview/stop": { + POST: async (request: ParametersRequest<"number">) => { + const number = parsePullRequestNumber(request.params.number); + if (number instanceof Response) return number; + try { + return json({ + isOk: true, + preview: await prepareAndStopPullRequestPreview(number), + }); + } catch (error) { + return routeError(error, "PR preview stop failed"); + } + }, + }, "/api/pull-requests/deploy": { POST: async () => { try { @@ -129,10 +162,17 @@ export const pullRequestRoutes = { }, }, "/api/pull-requests/releases/rollback": { - POST: async () => { + POST: async (request: Request) => { try { + const body = await readJson<{ targetCommit?: unknown }>(request); + if (typeof body.targetCommit !== "string") { + return json( + { error: "Rollback target commit is required" }, + { status: 400 } + ); + } return json({ - deployment: await prepareAndStartRollback(), + deployment: await prepareAndStartRollback(body.targetCommit), isOk: true, }); } catch (error) { @@ -149,4 +189,13 @@ export const pullRequestRoutes = { } }, }, + "/api/pull-requests/preview": { + GET: async () => { + try { + return json({ preview: await getPullRequestPreviewStatus() }); + } catch (error) { + return routeError(error, "PR preview status failed"); + } + }, + }, } as const; diff --git a/backend/src/server.ts b/backend/src/server.ts index d27bb15f0..49b469c15 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -15,8 +15,11 @@ import type { DashboardSocket } from "./dashboardSocket.ts"; import { resolveFrontendPath } from "./frontendAssets.ts"; import gateway from "./gateway.ts"; import { isAllowedDashboardOrigin, sessionIdFromCookie } from "./http.ts"; -import { resolveDashboardPort } from "./lib/values.ts"; -import { requiresRecentMfaForGatewayMethod } from "./requestPolicy.ts"; +import { resolveDashboardHost, resolveDashboardPort } from "./lib/values.ts"; +import { + isDevelopmentGatewayMethodBlocked, + requiresRecentMfaForGatewayMethod, +} from "./requestPolicy.ts"; import { withRequestSecurity } from "./requestSecurity.ts"; import { routes } from "./routes.ts"; import { validateTotpStorageConfig } from "./services/multiFactorAuth.ts"; @@ -73,11 +76,29 @@ function sendSocketAuthenticationError( ); } +function sendSocketDevelopmentError( + ws: ServerWebSocket, + request: DashboardSocketRequest +): void { + ws.send( + JSON.stringify({ + code: "development_method_blocked", + error: "This Gateway action is disabled in Dashboard dev", + id: request.id, + isOk: false, + type: "response", + }) + ); +} + function hasHiddenStaticSegment(relativePath: string): boolean { return relativePath.split(path.sep).some((segment) => segment.startsWith(".")); } -export { resolveDashboardPort as resolveListenPort } from "./lib/values.ts"; +export { + resolveDashboardHost as resolveListenHost, + resolveDashboardPort as resolveListenPort, +} from "./lib/values.ts"; function dashboardSocketFromBun( ws: ServerWebSocket @@ -100,7 +121,10 @@ function dashboardSocketFromBun( }; } -export function createServer(port = resolveDashboardPort()): Server { +export function createServer( + port = resolveDashboardPort(), + hostname = resolveDashboardHost() +): Server { validateAuthenticationConfig(); validateStoredSecretConfig(); validateAutomationCredentials(); @@ -135,6 +159,14 @@ export function createServer(port = resolveDashboardPort()): Server({ + hostname, idleTimeout: SERVER_IDLE_TIMEOUT_SECONDS, port, routes, diff --git a/backend/src/services/jobWorker.ts b/backend/src/services/jobWorker.ts index 447c6425d..e5dc65582 100644 --- a/backend/src/services/jobWorker.ts +++ b/backend/src/services/jobWorker.ts @@ -9,6 +9,7 @@ import { registerExecExecutionActions } from "./execJobs.ts"; import { registerGitHygieneScheduledJobs } from "./gitHygiene.ts"; import { registerLogRotationScheduledJobs } from "./logRotation.ts"; import { registerOpenClawExecutionActions } from "./openclawActions.ts"; +import { registerPullRequestPreviewExecutionActions } from "./pullRequestPreviews.ts"; import { registerPullRequestExecutionActions } from "./pullRequests.ts"; import { startScheduledJobExecutor, @@ -27,6 +28,15 @@ const workerState: { stopGeneration: 0, }; +export type DashboardJobProfile = "full" | "isolated"; + +/** Selects whether the worker may register host-control execution actions. */ +export function dashboardJobProfile( + environment: Record = process.env +): DashboardJobProfile { + return environment.MIRA_DASHBOARD_JOB_PROFILE === "isolated" ? "isolated" : "full"; +} + function trackWorkerStop(operation: () => Promise): Promise { const generation = ++workerState.stopGeneration; const pendingStop = (async () => { @@ -42,12 +52,18 @@ function trackWorkerStop(operation: () => Promise): Promise { return pendingStop; } -function registerScheduledActions(): void { - registerBackupScheduledJobs(); +function registerScheduledActions(profile = dashboardJobProfile()): void { registerCacheRefreshScheduledJobs({ refreshDatabaseOnStartup: true, seedStrategy: "queue", }); + registerSqliteMaintenanceScheduledJob({ + enqueueDatabaseSummaryRefresh, + }); + if (profile === "isolated") { + return; + } + registerBackupScheduledJobs(); registerDockerExecutionActions(); registerDockerUpdaterScheduledJobs(); registerExecExecutionActions(); @@ -55,9 +71,7 @@ function registerScheduledActions(): void { registerLogRotationScheduledJobs(); registerOpenClawExecutionActions(); registerPullRequestExecutionActions(); - registerSqliteMaintenanceScheduledJob({ - enqueueDatabaseSummaryRefresh, - }); + registerPullRequestPreviewExecutionActions(); } /** Starts the persistent queue scheduler and its single-concurrency executor. */ diff --git a/backend/src/services/pullRequestPreviewHost.ts b/backend/src/services/pullRequestPreviewHost.ts new file mode 100644 index 000000000..a2656b770 --- /dev/null +++ b/backend/src/services/pullRequestPreviewHost.ts @@ -0,0 +1,1291 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +import { getPersistedGatewayToken } from "../auth.ts"; +import { + prepareDevelopmentState, + resolveDevelopmentStackConfig, +} from "../development/developmentStack.ts"; +import { errorMessage } from "../lib/errors.ts"; +import { runProcess } from "../lib/processes.ts"; + +const PREVIEW_UNIT = "mira-dashboard-pr-preview.service"; +const PREVIEW_RECORD_FILE = "active-preview.json"; +const PREVIEW_RECORD_FORMAT_VERSION = 1; +const PREVIEW_READY_TIMEOUT_MS = 90_000; +const PREVIEW_READY_POLL_MS = 500; +const MAX_COMMAND_BUFFER = 10 * 1024 * 1024; +const COMMIT_PATTERN = /^[\da-f]{40}$/u; +const UNIT_NAME_PATTERN = /^[A-Za-z0-9_.@-]+\.service$/u; +const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; +const MAX_GATEWAY_TOKEN_BYTES = 16 * 1024; +const SAFE_INSTALL_ENVIRONMENT_KEYS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "LANG", + "LC_ALL", + "NO_PROXY", + "PATH", + "TZ", +] as const; + +export type PullRequestPreviewLifecycle = + "failed" | "running" | "starting" | "stopped" | "stopping"; + +export interface PullRequestPreviewStatus { + backendPort?: number; + commitSha?: string; + frontendPort?: number; + message?: string; + number?: number; + startedAt?: string; + status: PullRequestPreviewLifecycle; + title?: string; + updatedAt?: string; + url?: string; +} + +export interface PullRequestPreviewCandidate { + authorLogin?: string; + baseRefName: string; + commitSha: string; + number: number; + title: string; +} + +export interface PullRequestPreviewConfig { + allowedAuthors: ReadonlySet; + backendPort: number; + bunExecutable: string; + dashboardRoot: string; + databaseTemplate?: string; + frontendPort: number; + gatewayTokenFile: string; + gatewayUrl: string; + gitCommonDirectory: string; + openClawConfigSource?: string; + previewRoot: string; + recentAuthMinutes?: string; + releaseSource?: string; + sessionIdleMinutes?: string; + stateFile: string; + unitName: string; + workspaceSource?: string; + worktreeRoot: string; +} + +interface PullRequestPreviewRecord { + backendPort: number; + commitSha: string; + formatVersion: 1; + frontendPort: number; + message?: string; + number: number; + ownsTailscaleServe: boolean; + startedAt?: string; + status: PullRequestPreviewLifecycle; + title: string; + updatedAt: string; + url: string; + worktreePath: string; +} + +interface SystemdUnitState { + activeState?: string; + result?: string; + subState?: string; +} + +interface TailscaleStatus { + Self?: { + DNSName?: string; + }; +} + +interface TailscaleServeStatus { + TCP?: Record; + Web?: Record< + string, + { + Handlers?: Record; + } + >; +} + +interface CommandOptions { + cwd?: string; + env?: Record; + signal?: AbortSignal; + timeoutMs?: number; +} + +function absoluteNonRootPath(name: string, value: string): string { + if (!path.isAbsolute(value)) { + throw new TypeError(`${name} must be an absolute non-root path`); + } + const resolved = path.resolve(value); + if (resolved === path.parse(resolved).root) { + throw new TypeError(`${name} must be an absolute non-root path`); + } + return resolved; +} + +function optionalAbsoluteNonRootPath( + name: string, + value: string | undefined +): string | undefined { + const configured = value?.trim(); + return configured ? absoluteNonRootPath(name, configured) : undefined; +} + +function configuredPort( + name: string, + value: string | undefined, + fallback: number +): number { + const normalized = value?.trim(); + if (!normalized) return fallback; + if (!/^\d+$/u.test(normalized)) { + throw new TypeError(`${name} must be an integer between 1 and 65535`); + } + const port = Number(normalized); + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new TypeError(`${name} must be an integer between 1 and 65535`); + } + return port; +} + +function configuredGatewayUrl(value: string | undefined): string | undefined { + const configured = value?.trim(); + if (!configured) return undefined; + let url: URL; + try { + url = new URL(configured); + } catch { + throw new TypeError("MIRA_DASHBOARD_PREVIEW_GATEWAY_URL must be a valid URL"); + } + if ( + !["ws:", "wss:"].includes(url.protocol) || + url.username || + url.password || + url.hash + ) { + throw new TypeError( + "MIRA_DASHBOARD_PREVIEW_GATEWAY_URL must be ws:// or wss:// without credentials or a fragment" + ); + } + return url.href; +} + +function optionalEnvironmentValue( + name: string, + value: string | undefined +): string | undefined { + const configured = value?.trim(); + if (!configured) return undefined; + if (/[\r\n\0]/u.test(configured)) { + throw new TypeError(`${name} must be a single environment value`); + } + return configured; +} + +function resolveExecutable(value: string | undefined, fallback: string): string { + const configured = value?.trim() || Bun.which(fallback); + if (!configured || !path.isAbsolute(configured)) { + throw new TypeError(`${fallback} executable must resolve to an absolute path`); + } + return path.resolve(configured); +} + +function gitCommonDirectory( + dashboardRoot: string, + configured: string | undefined +): string { + const explicit = optionalAbsoluteNonRootPath( + "MIRA_DASHBOARD_PREVIEW_GIT_COMMON_DIR", + configured + ); + return explicit || path.join(dashboardRoot, ".git"); +} + +/** Resolves the single-slot managed PR preview host contract. */ +export function resolvePullRequestPreviewConfig( + environment: Record = process.env +): PullRequestPreviewConfig { + const dashboardRoot = absoluteNonRootPath( + "MIRA_DASHBOARD_ROOT", + environment.MIRA_DASHBOARD_ROOT?.trim() || "/home/ubuntu/projects/mira-dashboard" + ); + const worktreeRoot = absoluteNonRootPath( + "MIRA_DASHBOARD_WORKTREE_ROOT", + environment.MIRA_DASHBOARD_WORKTREE_ROOT?.trim() || + "/home/ubuntu/projects/mira-dashboard-worktrees" + ); + const previewRoot = absoluteNonRootPath( + "MIRA_DASHBOARD_PREVIEW_ROOT", + environment.MIRA_DASHBOARD_PREVIEW_ROOT?.trim() || + "/home/ubuntu/projects/mira-dashboard-preview-state/managed" + ); + const frontendPort = configuredPort( + "MIRA_DASHBOARD_PREVIEW_FRONTEND_PORT", + environment.MIRA_DASHBOARD_PREVIEW_FRONTEND_PORT, + 5173 + ); + const backendPort = configuredPort( + "MIRA_DASHBOARD_PREVIEW_BACKEND_PORT", + environment.MIRA_DASHBOARD_PREVIEW_BACKEND_PORT, + 3101 + ); + if (frontendPort === backendPort) { + throw new TypeError("Dashboard preview frontend and backend ports must differ"); + } + const unitName = environment.MIRA_DASHBOARD_PREVIEW_UNIT?.trim() || PREVIEW_UNIT; + if (!UNIT_NAME_PATTERN.test(unitName)) { + throw new TypeError( + "MIRA_DASHBOARD_PREVIEW_UNIT must be a valid .service unit name" + ); + } + const allowedAuthors = new Set( + (environment.MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS || "mira-2026,rajohan") + .split(",") + .map((author) => author.trim().toLowerCase()) + .filter(Boolean) + ); + if (allowedAuthors.size === 0) { + throw new TypeError( + "MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS must contain at least one author" + ); + } + const openClawSourceRoot = optionalAbsoluteNonRootPath( + "MIRA_DASHBOARD_PREVIEW_OPENCLAW_SOURCE_ROOT", + environment.MIRA_DASHBOARD_PREVIEW_OPENCLAW_SOURCE_ROOT?.trim() || + "/home/ubuntu/.openclaw" + ); + return { + allowedAuthors, + backendPort, + bunExecutable: resolveExecutable(environment.BUN_BINARY, "bun"), + dashboardRoot, + databaseTemplate: optionalAbsoluteNonRootPath( + "MIRA_DASHBOARD_PREVIEW_DB_TEMPLATE", + environment.MIRA_DASHBOARD_PREVIEW_DB_TEMPLATE?.trim() || + "/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db" + ), + frontendPort, + gatewayTokenFile: + optionalAbsoluteNonRootPath( + "MIRA_DASHBOARD_PREVIEW_GATEWAY_TOKEN_FILE", + environment.MIRA_DASHBOARD_PREVIEW_GATEWAY_TOKEN_FILE + ) || path.join(previewRoot, "gateway.token"), + gatewayUrl: + configuredGatewayUrl(environment.MIRA_DASHBOARD_PREVIEW_GATEWAY_URL) || + DEFAULT_GATEWAY_URL, + gitCommonDirectory: gitCommonDirectory( + dashboardRoot, + environment.MIRA_DASHBOARD_PREVIEW_GIT_COMMON_DIR + ), + openClawConfigSource: openClawSourceRoot + ? path.join(openClawSourceRoot, "openclaw.json") + : undefined, + previewRoot, + recentAuthMinutes: optionalEnvironmentValue( + "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + environment.MIRA_DASHBOARD_RECENT_AUTH_MINUTES + ), + releaseSource: optionalAbsoluteNonRootPath( + "MIRA_DASHBOARD_PREVIEW_RELEASES_SOURCE", + environment.MIRA_DASHBOARD_PREVIEW_RELEASES_SOURCE?.trim() || + "/home/ubuntu/projects/mira-dashboard-releases" + ), + sessionIdleMinutes: optionalEnvironmentValue( + "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + environment.MIRA_DASHBOARD_SESSION_IDLE_MINUTES + ), + stateFile: path.join(previewRoot, PREVIEW_RECORD_FILE), + unitName, + workspaceSource: openClawSourceRoot + ? path.join(openClawSourceRoot, "workspace") + : undefined, + worktreeRoot, + }; +} + +function isRealDirectory(directoryPath: string): boolean { + try { + const stat = lstatSync(directoryPath); + return stat.isDirectory() && !stat.isSymbolicLink(); + } catch { + return false; + } +} + +function isRealRegularFile(filePath: string): boolean { + try { + const stat = lstatSync(filePath); + return stat.isFile() && !stat.isSymbolicLink(); + } catch { + return false; + } +} + +function ensureRealDirectory(directoryPath: string): void { + mkdirSync(directoryPath, { mode: 0o700, recursive: true }); + if (!isRealDirectory(directoryPath)) { + throw new Error(`Preview path must be a real directory: ${directoryPath}`); + } + chmodSync(directoryPath, 0o700); +} + +function materializeGatewayToken( + config: PullRequestPreviewConfig, + tokenValue: string | undefined +): void { + const token = tokenValue?.trim(); + if ( + !token || + Buffer.byteLength(token) > MAX_GATEWAY_TOKEN_BYTES || + /[\r\n\0]/u.test(token) + ) { + throw new Error("A valid persisted Gateway token is required for PR dev"); + } + const tokenDirectory = path.dirname(config.gatewayTokenFile); + ensureRealDirectory(tokenDirectory); + if ( + existsSync(config.gatewayTokenFile) && + !isRealRegularFile(config.gatewayTokenFile) + ) { + throw new Error("PR dev Gateway token path must be a real regular file"); + } + const temporaryPath = path.join( + tokenDirectory, + `.gateway-token-${Bun.randomUUIDv7()}.tmp` + ); + try { + writeFileSync(temporaryPath, `${token}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + renameSync(temporaryPath, config.gatewayTokenFile); + chmodSync(config.gatewayTokenFile, 0o600); + } finally { + rmSync(temporaryPath, { force: true }); + } +} + +function isPathStrictlyWithin(candidate: string, root: string): boolean { + const relative = path.relative(path.resolve(root), path.resolve(candidate)); + return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +function previewRecordFromJson(value: unknown): PullRequestPreviewRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("Preview record must be an object"); + } + const record = value as Partial; + if ( + record.formatVersion !== PREVIEW_RECORD_FORMAT_VERSION || + typeof record.number !== "number" || + !Number.isSafeInteger(record.number) || + record.number <= 0 || + typeof record.commitSha !== "string" || + !COMMIT_PATTERN.test(record.commitSha) || + typeof record.title !== "string" || + typeof record.updatedAt !== "string" || + typeof record.url !== "string" || + typeof record.worktreePath !== "string" || + !["failed", "running", "starting", "stopped", "stopping"].includes( + record.status || "" + ) || + (record.ownsTailscaleServe !== undefined && + typeof record.ownsTailscaleServe !== "boolean") || + typeof record.frontendPort !== "number" || + typeof record.backendPort !== "number" + ) { + throw new TypeError("Preview record is invalid"); + } + return { + ...record, + ownsTailscaleServe: record.ownsTailscaleServe === true, + } as PullRequestPreviewRecord; +} + +function readPreviewRecord( + config: PullRequestPreviewConfig +): PullRequestPreviewRecord | undefined { + if (!existsSync(config.stateFile)) return undefined; + if (!isRealRegularFile(config.stateFile)) { + throw new Error("Dashboard preview state must be a real regular file"); + } + const content = readFileSync(config.stateFile, "utf8"); + if (Buffer.byteLength(content) > 256 * 1024) { + throw new Error("Dashboard preview state is too large"); + } + try { + return previewRecordFromJson(JSON.parse(content) as unknown); + } catch (error) { + throw new Error( + `Dashboard preview state is invalid: ${errorMessage(error, "invalid state")}`, + { cause: error } + ); + } +} + +function writePreviewRecord( + config: PullRequestPreviewConfig, + record: PullRequestPreviewRecord +): void { + ensureRealDirectory(config.previewRoot); + const temporaryPath = path.join( + config.previewRoot, + `.${PREVIEW_RECORD_FILE}.${Bun.randomUUIDv7()}.tmp` + ); + try { + writeFileSync(temporaryPath, `${JSON.stringify(record, undefined, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + renameSync(temporaryPath, config.stateFile); + chmodSync(config.stateFile, 0o600); + } finally { + rmSync(temporaryPath, { force: true }); + } +} + +async function runCommand( + executable: string, + arguments_: string[], + options: CommandOptions = {} +): Promise<{ stderr: string; stdout: string }> { + const result = await runProcess(executable, arguments_, { + cwd: options.cwd, + env: options.env, + maxBuffer: MAX_COMMAND_BUFFER, + signal: options.signal, + timeoutMs: options.timeoutMs ?? 120_000, + }); + if (result.code !== 0) { + throw new Error( + `${path.basename(executable)} exited ${result.code}: ${ + result.stderr.trim() || result.stdout.trim() + }` + ); + } + return { stderr: result.stderr, stdout: result.stdout }; +} + +async function runJsonCommand( + executable: string, + arguments_: string[], + options: CommandOptions = {} +): Promise { + const { stdout } = await runCommand(executable, arguments_, options); + try { + return JSON.parse(stdout) as T; + } catch { + throw new Error(`${path.basename(executable)} returned invalid JSON`); + } +} + +function safeInstallEnvironment( + config: PullRequestPreviewConfig +): Record { + const environment: Record = {}; + for (const key of SAFE_INSTALL_ENVIRONMENT_KEYS) { + const value = process.env[key]; + if (value !== undefined) environment[key] = value; + } + const installerHome = path.join(config.previewRoot, "installer-home"); + const cacheDirectory = path.join(config.previewRoot, "bun-cache"); + ensureRealDirectory(installerHome); + ensureRealDirectory(cacheDirectory); + environment.BUN_INSTALL_CACHE_DIR = cacheDirectory; + environment.HOME = installerHome; + return environment; +} + +function githubCommandEnvironment(): Record { + const environment = { ...process.env }; + const githubToken = + process.env.MIRA_GITHUB_TOKEN?.trim() || + process.env.GH_TOKEN?.trim() || + process.env.GITHUB_TOKEN?.trim(); + for (const key of Object.keys(environment)) { + if ( + key === "MIRA_GITHUB_TOKEN" || + key === "RAJOHAN_GITHUB_TOKEN" || + key.startsWith("MIRA_GITHUB_TOKEN_") || + key.startsWith("RAJOHAN_GITHUB_TOKEN_") + ) { + delete environment[key]; + } + } + delete environment.GITHUB_TOKEN; + if (githubToken) { + environment.GH_TOKEN = githubToken; + } else { + delete environment.GH_TOKEN; + } + return environment; +} + +function previewWorktreePath(config: PullRequestPreviewConfig, number: number): string { + return path.join(config.worktreeRoot, `preview-pr-${number}`); +} + +async function ensurePreviewWorktree( + config: PullRequestPreviewConfig, + number: number, + commitSha: string, + signal?: AbortSignal +): Promise { + ensureRealDirectory(config.worktreeRoot); + const worktreePath = previewWorktreePath(config, number); + if (!isPathStrictlyWithin(worktreePath, config.worktreeRoot)) { + throw new Error("Preview worktree escaped the configured worktree root"); + } + const previewReference = `refs/mira-dashboard/previews/pr-${number}`; + await runCommand( + "git", + [ + "-C", + config.dashboardRoot, + "fetch", + "--force", + "--no-tags", + "origin", + `pull/${number}/head:${previewReference}`, + ], + { + env: githubCommandEnvironment(), + signal, + timeoutMs: 180_000, + } + ); + const { stdout: fetchedCommit } = await runCommand( + "git", + ["-C", config.dashboardRoot, "rev-parse", previewReference], + { env: githubCommandEnvironment(), signal } + ); + if (fetchedCommit.trim() !== commitSha) { + throw new Error("Fetched pull request commit changed during preview startup"); + } + if (existsSync(worktreePath)) { + if (!isRealDirectory(worktreePath)) { + throw new Error("Preview worktree path must be a real directory"); + } + const { stdout: registeredRoot } = await runCommand( + "git", + ["-C", worktreePath, "rev-parse", "--show-toplevel"], + { signal } + ); + if (realpathSync(registeredRoot.trim()) !== realpathSync(worktreePath)) { + throw new Error("Preview path is not the expected registered worktree"); + } + const { stdout: status } = await runCommand( + "git", + ["-C", worktreePath, "status", "--porcelain", "--untracked-files=no"], + { signal } + ); + if (status.trim()) { + throw Object.assign( + new Error("Managed preview worktree has tracked local changes"), + { statusCode: 409 } + ); + } + await runCommand("git", ["-C", worktreePath, "checkout", "--detach", commitSha], { + signal, + }); + } else { + await runCommand( + "git", + [ + "-C", + config.dashboardRoot, + "worktree", + "add", + "--detach", + worktreePath, + commitSha, + ], + { signal, timeoutMs: 180_000 } + ); + } + const { stdout: checkedOutCommit } = await runCommand( + "git", + ["-C", worktreePath, "rev-parse", "HEAD"], + { signal } + ); + if (checkedOutCommit.trim() !== commitSha) { + throw new Error("Preview worktree commit verification failed"); + } + return worktreePath; +} + +async function installPreviewDependencies( + config: PullRequestPreviewConfig, + worktreePath: string, + signal?: AbortSignal +): Promise { + const environment = safeInstallEnvironment(config); + for (const cwd of [worktreePath, path.join(worktreePath, "backend")]) { + await runCommand( + config.bunExecutable, + ["install", "--frozen-lockfile", "--ignore-scripts"], + { + cwd, + env: environment, + signal, + timeoutMs: 5 * 60 * 1000, + } + ); + } +} + +function tailscaleDnsName(status: TailscaleStatus): string { + const dnsName = status.Self?.DNSName?.trim().replace(/\.$/u, ""); + if (!dnsName || !/^[a-z0-9.-]+$/iu.test(dnsName)) { + throw new Error("Tailscale did not report a stable MagicDNS hostname"); + } + return dnsName.toLowerCase(); +} + +async function ensureTailscaleServe( + config: PullRequestPreviewConfig, + signal?: AbortSignal +): Promise<{ created: boolean; url: string }> { + const [status, serveStatus] = await Promise.all([ + runJsonCommand("tailscale", ["status", "--json"], { + signal, + }), + runJsonCommand("tailscale", ["serve", "status", "--json"], { + signal, + }), + ]); + const dnsName = tailscaleDnsName(status); + const port = config.frontendPort; + const proxyTarget = `http://127.0.0.1:${port}`; + const web = serveStatus.Web?.[`${dnsName}:${port}`]; + const configuredProxy = web?.Handlers?.["/"]?.Proxy; + const hasHttpsListener = serveStatus.TCP?.[String(port)]?.HTTPS === true; + if ( + (configuredProxy || hasHttpsListener) && + (!hasHttpsListener || configuredProxy !== proxyTarget) + ) { + throw Object.assign( + new Error(`Tailscale Serve port ${port} is configured for another target`), + { statusCode: 409 } + ); + } + if (!hasHttpsListener) { + await runCommand( + "sudo", + ["-n", "tailscale", "serve", "--bg", `--https=${port}`, proxyTarget], + { signal } + ); + } + return { + created: !hasHttpsListener, + url: `https://${dnsName}:${port}`, + }; +} + +async function disableOwnedTailscaleServe( + config: PullRequestPreviewConfig, + isOwned: boolean +): Promise { + if (!isOwned) return; + const [status, serveStatus] = await Promise.all([ + runJsonCommand("tailscale", ["status", "--json"]), + runJsonCommand("tailscale", ["serve", "status", "--json"]), + ]); + const dnsName = tailscaleDnsName(status); + const port = config.frontendPort; + const proxyTarget = `http://127.0.0.1:${port}`; + const web = serveStatus.Web?.[`${dnsName}:${port}`]; + const configuredProxy = web?.Handlers?.["/"]?.Proxy; + const hasHttpsListener = serveStatus.TCP?.[String(port)]?.HTTPS === true; + if (!configuredProxy && !hasHttpsListener) return; + if (!hasHttpsListener || configuredProxy !== proxyTarget) { + throw Object.assign( + new Error( + `Refusing to remove Tailscale Serve port ${port} because it is configured for another target` + ), + { statusCode: 409 } + ); + } + await runCommand("sudo", ["-n", "tailscale", "serve", `--https=${port}`, "off"]); +} + +function removeMaterializedGatewayToken(config: PullRequestPreviewConfig): void { + if (!existsSync(config.gatewayTokenFile)) return; + if (!isRealRegularFile(config.gatewayTokenFile)) { + throw new Error("PR dev Gateway token path must be a real regular file"); + } + rmSync(config.gatewayTokenFile, { force: true }); +} + +function managedStateRoot(config: PullRequestPreviewConfig, number: number): string { + const stateRoot = path.join(config.previewRoot, "states", `pr-${number}`); + if (!isPathStrictlyWithin(stateRoot, config.previewRoot)) { + throw new Error("Preview state escaped the configured preview root"); + } + return stateRoot; +} + +async function preparePreviewState( + config: PullRequestPreviewConfig, + number: number, + publicOrigin: string +): Promise { + const stateRoot = managedStateRoot(config, number); + const environment = { + ...safeInstallEnvironment(config), + MIRA_DASHBOARD_DEV_BACKEND_PORT: String(config.backendPort), + MIRA_DASHBOARD_DEV_FRONTEND_PORT: String(config.frontendPort), + MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE: config.gatewayTokenFile, + MIRA_DASHBOARD_DEV_GATEWAY_URL: config.gatewayUrl, + MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: publicOrigin, + MIRA_DASHBOARD_DEV_STATE_OWNER: `managed-pr-${number}`, + MIRA_DASHBOARD_DEV_STATE_ROOT: stateRoot, + ...(config.openClawConfigSource && { + MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE: config.openClawConfigSource, + }), + ...(config.recentAuthMinutes && { + MIRA_DASHBOARD_RECENT_AUTH_MINUTES: config.recentAuthMinutes, + }), + ...(config.databaseTemplate && { + MIRA_DASHBOARD_DEV_DB_SOURCE: config.databaseTemplate, + }), + ...(config.releaseSource && { + MIRA_DASHBOARD_DEV_RELEASES_SOURCE: config.releaseSource, + }), + ...(config.sessionIdleMinutes && { + MIRA_DASHBOARD_SESSION_IDLE_MINUTES: config.sessionIdleMinutes, + }), + ...(config.workspaceSource && { + MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE: config.workspaceSource, + }), + }; + const developmentConfig = resolveDevelopmentStackConfig( + environment, + config.dashboardRoot + ); + prepareDevelopmentState(developmentConfig); + return stateRoot; +} + +function sandboxDirectories(worktreePath: string, dashboardRoot: string): string[] { + const directories = new Set(); + for (const target of [worktreePath, dashboardRoot]) { + let current = path.dirname(target); + const ancestors: string[] = []; + while (current !== path.parse(current).root) { + ancestors.push(current); + current = path.dirname(current); + } + for (const ancestor of ancestors.toReversed()) directories.add(ancestor); + } + return [...directories]; +} + +/** Builds the filesystem-isolated process used by the transient preview unit. */ +export function buildPullRequestPreviewSandboxCommand(input: { + config: PullRequestPreviewConfig; + number: number; + publicOrigin: string; + stateRoot: string; + worktreePath: string; +}): string[] { + const { config, number, publicOrigin, stateRoot, worktreePath } = input; + const arguments_ = [ + "bwrap", + "--unshare-all", + "--share-net", + "--die-with-parent", + "--new-session", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind-try", + "/lib64", + "/lib64", + "--ro-bind", + config.bunExecutable, + "/bun", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--dir", + "/home", + "--dir", + "/home/dev", + "--dir", + "/run", + "--dir", + "/run/mira-dashboard-preview", + ]; + for (const directory of sandboxDirectories(worktreePath, config.dashboardRoot)) { + arguments_.push("--dir", directory); + } + const sandboxGatewayTokenFile = "/run/mira-dashboard-preview/gateway.token"; + arguments_.push( + "--ro-bind", + worktreePath, + worktreePath, + "--dir", + config.dashboardRoot, + "--ro-bind", + config.gitCommonDirectory, + config.gitCommonDirectory, + "--bind", + stateRoot, + "/state", + "--ro-bind", + config.gatewayTokenFile, + sandboxGatewayTokenFile, + "--clearenv", + "--setenv", + "HOME", + "/home/dev", + "--setenv", + "PATH", + "/usr/bin:/bin", + "--setenv", + "MIRA_DASHBOARD_DEV_BACKEND_HOST", + "127.0.0.1", + "--setenv", + "MIRA_DASHBOARD_DEV_BACKEND_PORT", + String(config.backendPort), + "--setenv", + "MIRA_DASHBOARD_DEV_FRONTEND_HOST", + "127.0.0.1", + "--setenv", + "MIRA_DASHBOARD_DEV_FRONTEND_PORT", + String(config.frontendPort), + "--setenv", + "MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE", + sandboxGatewayTokenFile, + "--setenv", + "MIRA_DASHBOARD_DEV_GATEWAY_URL", + config.gatewayUrl, + "--setenv", + "MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN", + publicOrigin, + "--setenv", + "MIRA_DASHBOARD_DEV_STATE_OWNER", + `managed-pr-${number}`, + "--setenv", + "MIRA_DASHBOARD_DEV_STATE_ROOT", + "/state" + ); + if (config.recentAuthMinutes) { + arguments_.push( + "--setenv", + "MIRA_DASHBOARD_RECENT_AUTH_MINUTES", + config.recentAuthMinutes + ); + } + if (config.sessionIdleMinutes) { + arguments_.push( + "--setenv", + "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", + config.sessionIdleMinutes + ); + } + arguments_.push( + "--chdir", + worktreePath, + "--", + "/bun", + path.join(worktreePath, "scripts", "developmentStack.ts") + ); + return arguments_; +} + +async function startPreviewUnit( + config: PullRequestPreviewConfig, + sandboxCommand: string[], + signal?: AbortSignal +): Promise { + await runCommand( + "systemd-run", + [ + "--user", + `--unit=${config.unitName}`, + "--collect", + "--quiet", + "--property=CPUWeight=30", + "--property=IOWeight=30", + "--property=MemoryHigh=2G", + "--property=MemoryMax=3G", + "--property=TasksMax=256", + "--property=KillMode=control-group", + "--property=NoNewPrivileges=yes", + "--property=RuntimeMaxSec=4h", + "--property=TimeoutStopSec=20s", + "--", + ...sandboxCommand, + ], + { + env: process.env, + signal, + timeoutMs: 30_000, + } + ); +} + +/** Parses the bounded systemctl property format used for preview status. */ +export function parsePreviewUnitState(output: string): SystemdUnitState { + const properties = new Map(); + for (const line of output.split("\n")) { + const separator = line.indexOf("="); + if (separator <= 0) continue; + properties.set(line.slice(0, separator), line.slice(separator + 1)); + } + return { + activeState: properties.get("ActiveState") || undefined, + result: properties.get("Result") || undefined, + subState: properties.get("SubState") || undefined, + }; +} + +async function previewUnitState( + config: PullRequestPreviewConfig +): Promise { + const result = await runProcess( + "systemctl", + [ + "--user", + "show", + config.unitName, + "--property=ActiveState", + "--property=SubState", + "--property=Result", + "--no-pager", + ], + { + env: process.env, + maxBuffer: 64 * 1024, + timeoutMs: 10_000, + } + ); + return result.code === 0 ? parsePreviewUnitState(result.stdout) : undefined; +} + +function lifecycleFromUnit( + state: SystemdUnitState | undefined, + fallback: PullRequestPreviewLifecycle +): PullRequestPreviewLifecycle { + switch (state?.activeState) { + case "active": { + return "running"; + } + case "activating": { + return "starting"; + } + case "deactivating": { + return "stopping"; + } + case "failed": { + return "failed"; + } + case "inactive": { + return state.result && state.result !== "success" ? "failed" : "stopped"; + } + default: { + return fallback === "running" || fallback === "starting" + ? "failed" + : fallback; + } + } +} + +function publicPreviewStatus( + record: PullRequestPreviewRecord, + unitState?: SystemdUnitState +): PullRequestPreviewStatus { + const status = lifecycleFromUnit(unitState, record.status); + const unitMessage = + status === "failed" && unitState?.result && unitState.result !== "success" + ? `Preview service result: ${unitState.result}` + : undefined; + return { + backendPort: record.backendPort, + commitSha: record.commitSha, + frontendPort: record.frontendPort, + message: unitMessage || record.message, + number: record.number, + startedAt: record.startedAt, + status, + title: record.title, + updatedAt: record.updatedAt, + url: record.url, + }; +} + +/** Reads the active single-slot PR preview without mutating host state. */ +export async function getPullRequestPreviewStatus( + config = resolvePullRequestPreviewConfig() +): Promise { + const record = readPreviewRecord(config); + if (!record) return { status: "stopped" }; + return publicPreviewStatus(record, await previewUnitState(config)); +} + +async function stopUnit(config: PullRequestPreviewConfig): Promise { + const state = await previewUnitState(config); + if (!state || ["inactive", "failed"].includes(state.activeState || "")) { + return; + } + await runCommand("systemctl", ["--user", "stop", config.unitName], { + env: process.env, + timeoutMs: 30_000, + }); +} + +async function waitForPreviewReady( + config: PullRequestPreviewConfig, + signal?: AbortSignal +): Promise { + const deadline = Date.now() + PREVIEW_READY_TIMEOUT_MS; + const healthUrl = `http://127.0.0.1:${config.frontendPort}/api/health/ready`; + while (Date.now() < deadline) { + if (signal?.aborted) { + throw new DOMException("Preview startup aborted", "AbortError"); + } + try { + const response = await fetch(healthUrl, { + signal: AbortSignal.timeout(2000), + }); + if (response.ok) return; + } catch { + // The watched frontend/backend pair is still starting. + } + const state = await previewUnitState(config); + if (state && ["failed", "inactive"].includes(state.activeState || "")) { + throw new Error( + `Preview service stopped during startup (${state.result || state.activeState})` + ); + } + await Bun.sleep(PREVIEW_READY_POLL_MS); + } + throw Object.assign(new Error("Timed out waiting for PR preview readiness"), { + statusCode: 504, + }); +} + +function validatePreviewPullRequest( + pullRequest: PullRequestPreviewCandidate, + config: PullRequestPreviewConfig +): PullRequestPreviewCandidate { + if ( + !Number.isSafeInteger(pullRequest.number) || + pullRequest.number <= 0 || + pullRequest.number > 2_147_483_647 + ) { + throw new TypeError("Preview pull request number is invalid"); + } + if (pullRequest.baseRefName !== "main") { + throw Object.assign( + new Error("Only main-targeted pull requests can be previewed"), + { statusCode: 409 } + ); + } + if ( + !pullRequest.authorLogin || + !config.allowedAuthors.has(pullRequest.authorLogin.toLowerCase()) + ) { + throw Object.assign( + new Error("Pull request author is not allowed to run host previews"), + { statusCode: 403 } + ); + } + if (!COMMIT_PATTERN.test(pullRequest.commitSha)) { + throw new Error("Pull request does not expose a valid head commit"); + } + if ( + !pullRequest.title.trim() || + pullRequest.title.length > 1024 || + /[\r\n\0]/u.test(pullRequest.title) + ) { + throw new TypeError("Pull request title is invalid"); + } + return pullRequest; +} + +/** Starts or updates the single managed preview slot for one validated PR. */ +export async function startPullRequestPreview( + candidate: PullRequestPreviewCandidate, + options: { + config?: PullRequestPreviewConfig; + protectFromCancellation?: () => void; + readGatewayToken?: () => string | undefined; + signal?: AbortSignal; + } = {} +): Promise { + const config = options.config ?? resolvePullRequestPreviewConfig(); + const signal = options.signal; + const pullRequest = validatePreviewPullRequest(candidate, config); + const { number } = pullRequest; + ensureRealDirectory(config.previewRoot); + const existingRecord = readPreviewRecord(config); + const current = await getPullRequestPreviewStatus(config); + if ( + ["running", "starting", "stopping"].includes(current.status) && + current.number !== number + ) { + throw Object.assign( + new Error( + `PR #${current.number} already owns the preview slot; stop it first` + ), + { statusCode: 409 } + ); + } + if ( + current.status === "running" && + current.number === number && + current.commitSha === pullRequest.commitSha + ) { + return current; + } + const timestamp = new Date().toISOString(); + const tailscaleServe = await ensureTailscaleServe(config, signal); + const publicOrigin = tailscaleServe.url; + const ownsTailscaleServe = + tailscaleServe.created || existingRecord?.ownsTailscaleServe === true; + const worktreePath = previewWorktreePath(config, number); + const startingRecord: PullRequestPreviewRecord = { + backendPort: config.backendPort, + commitSha: pullRequest.commitSha, + formatVersion: PREVIEW_RECORD_FORMAT_VERSION, + frontendPort: config.frontendPort, + number, + ownsTailscaleServe, + status: "starting", + title: pullRequest.title, + updatedAt: timestamp, + url: publicOrigin, + worktreePath, + }; + writePreviewRecord(config, startingRecord); + try { + await stopUnit(config); + const preparedWorktree = await ensurePreviewWorktree( + config, + number, + pullRequest.commitSha, + signal + ); + await installPreviewDependencies(config, preparedWorktree, signal); + const stateRoot = await preparePreviewState(config, number, publicOrigin); + materializeGatewayToken( + config, + (options.readGatewayToken || getPersistedGatewayToken)() + ); + const sandboxCommand = buildPullRequestPreviewSandboxCommand({ + config, + number, + publicOrigin, + stateRoot, + worktreePath: preparedWorktree, + }); + options.protectFromCancellation?.(); + await startPreviewUnit(config, sandboxCommand, signal); + await waitForPreviewReady(config, signal); + const startedAt = new Date().toISOString(); + const runningRecord: PullRequestPreviewRecord = { + ...startingRecord, + startedAt, + status: "running", + updatedAt: startedAt, + }; + writePreviewRecord(config, runningRecord); + return publicPreviewStatus(runningRecord, await previewUnitState(config)); + } catch (error) { + const cleanupErrors: string[] = []; + try { + await stopUnit(config); + } catch (cleanupError) { + cleanupErrors.push(errorMessage(cleanupError, "service stop failed")); + } + try { + removeMaterializedGatewayToken(config); + } catch (cleanupError) { + cleanupErrors.push(errorMessage(cleanupError, "token cleanup failed")); + } + let didCleanupRoute = false; + try { + await disableOwnedTailscaleServe(config, ownsTailscaleServe); + didCleanupRoute = true; + } catch (cleanupError) { + cleanupErrors.push(errorMessage(cleanupError, "Serve cleanup failed")); + } + const startupMessage = errorMessage(error, "PR preview startup failed"); + const failedRecord: PullRequestPreviewRecord = { + ...startingRecord, + message: + cleanupErrors.length > 0 + ? `${startupMessage}. Cleanup: ${cleanupErrors.join(". ")}` + : startupMessage, + ownsTailscaleServe: ownsTailscaleServe && !didCleanupRoute, + status: "failed", + updatedAt: new Date().toISOString(), + }; + writePreviewRecord(config, failedRecord); + throw error; + } +} + +/** Stops the managed preview slot, optionally enforcing its owning PR number. */ +export async function stopPullRequestPreview( + number: number | undefined, + options: { + config?: PullRequestPreviewConfig; + protectFromCancellation?: () => void; + } = {} +): Promise { + const config = options.config ?? resolvePullRequestPreviewConfig(); + const record = readPreviewRecord(config); + if (!record) return { status: "stopped" }; + if (number !== undefined && record.number !== number) { + throw Object.assign( + new Error(`PR #${number} does not own the active preview slot`), + { statusCode: 409 } + ); + } + options.protectFromCancellation?.(); + writePreviewRecord(config, { + ...record, + status: "stopping", + updatedAt: new Date().toISOString(), + }); + await stopUnit(config); + removeMaterializedGatewayToken(config); + await disableOwnedTailscaleServe(config, record.ownsTailscaleServe); + const stoppedRecord: PullRequestPreviewRecord = { + ...record, + ownsTailscaleServe: false, + status: "stopped", + updatedAt: new Date().toISOString(), + }; + writePreviewRecord(config, stoppedRecord); + return publicPreviewStatus(stoppedRecord); +} diff --git a/backend/src/services/pullRequestPreviews.ts b/backend/src/services/pullRequestPreviews.ts new file mode 100644 index 000000000..f9b25b56f --- /dev/null +++ b/backend/src/services/pullRequestPreviews.ts @@ -0,0 +1,189 @@ +import { enqueueJobExecution, type JobExecution } from "./jobExecutionQueue.ts"; +import { + getPullRequestPreviewStatus as readPullRequestPreviewStatus, + type PullRequestPreviewCandidate, + type PullRequestPreviewLifecycle, + type PullRequestPreviewStatus, + startPullRequestPreview, + stopPullRequestPreview, +} from "./pullRequestPreviewHost.ts"; +import { + listDashboardPullRequests, + type PullRequestSummary, + validatePrNumber, +} from "./pullRequests.ts"; +import { + successfulJobExecutionOutput, + waitForJobExecution, +} from "./queuedJobExecution.ts"; +import { registerScheduledJobAction } from "./scheduledJobs.ts"; + +export type { + PullRequestPreviewLifecycle, + PullRequestPreviewStatus, +} from "./pullRequestPreviewHost.ts"; + +const PREVIEW_START_TIMEOUT_MS = 10 * 60 * 1000; +const PREVIEW_STOP_TIMEOUT_MS = 60_000; +const PREVIEW_WAIT_GRACE_MS = 5 * 60 * 1000; +const PREVIEW_LIFECYCLES = new Set([ + "failed", + "running", + "starting", + "stopped", + "stopping", +]); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function executionPreviewNumber(value: unknown): number { + return validatePrNumber(String(value)); +} + +/** Converts a GitHub PR summary into the constrained host-preview contract. */ +export function pullRequestPreviewCandidate( + pullRequest: PullRequestSummary +): PullRequestPreviewCandidate { + return { + authorLogin: pullRequest.author?.login, + baseRefName: pullRequest.baseRefName, + commitSha: pullRequest.headRefOid || "", + number: pullRequest.number, + title: pullRequest.title, + }; +} + +async function findPullRequest(number: number): Promise { + const pullRequests = await listDashboardPullRequests(); + const pullRequest = pullRequests.find((candidate) => candidate.number === number); + if (!pullRequest) { + throw Object.assign(new Error(`Open pull request #${number} was not found`), { + statusCode: 404, + }); + } + return pullRequestPreviewCandidate(pullRequest); +} + +/** Validates preview output before it crosses the queued-execution boundary. */ +export function parsePullRequestPreviewStatus(value: unknown): PullRequestPreviewStatus { + if (!isRecord(value) || !PREVIEW_LIFECYCLES.has(value.status as never)) { + throw new Error("Preview execution returned an invalid status"); + } + const status = value.status as PullRequestPreviewLifecycle; + const number = value.number; + if (number !== undefined && (!Number.isSafeInteger(number) || Number(number) <= 0)) { + throw new Error("Preview execution returned an invalid PR number"); + } + for (const key of [ + "commitSha", + "message", + "startedAt", + "title", + "updatedAt", + "url", + ]) { + if (value[key] !== undefined && typeof value[key] !== "string") { + throw new Error(`Preview execution returned an invalid ${key}`); + } + } + return { + ...(typeof value.backendPort === "number" && { + backendPort: value.backendPort, + }), + ...(typeof value.commitSha === "string" && { + commitSha: value.commitSha, + }), + ...(typeof value.frontendPort === "number" && { + frontendPort: value.frontendPort, + }), + ...(typeof value.message === "string" && { message: value.message }), + ...(typeof number === "number" && { number }), + ...(typeof value.startedAt === "string" && { + startedAt: value.startedAt, + }), + status, + ...(typeof value.title === "string" && { title: value.title }), + ...(typeof value.updatedAt === "string" && { + updatedAt: value.updatedAt, + }), + ...(typeof value.url === "string" && { url: value.url }), + }; +} + +function previewFromExecution(execution: JobExecution): PullRequestPreviewStatus { + const output = successfulJobExecutionOutput(execution); + return parsePullRequestPreviewStatus(output.preview); +} + +/** Reads the current single-slot preview state without changing host state. */ +export async function getPullRequestPreviewStatus(): Promise { + return readPullRequestPreviewStatus(); +} + +/** Queues one managed preview startup in the dedicated production worker. */ +export async function prepareAndStartPullRequestPreview( + number: number +): Promise { + const execution = enqueueJobExecution({ + actionKey: "dashboard.preview.start", + displayName: `Start PR #${number} preview`, + payload: { number }, + resourceClass: "exclusive", + timeoutMs: PREVIEW_START_TIMEOUT_MS, + }); + return previewFromExecution( + await waitForJobExecution(execution.id, { + timeoutMs: PREVIEW_START_TIMEOUT_MS + PREVIEW_WAIT_GRACE_MS, + }) + ); +} + +/** Queues a managed preview stop in the dedicated production worker. */ +export async function prepareAndStopPullRequestPreview( + number?: number +): Promise { + const execution = enqueueJobExecution({ + actionKey: "dashboard.preview.stop", + displayName: number ? `Stop PR #${number} preview` : "Stop PR preview", + payload: { number }, + resourceClass: "exclusive", + timeoutMs: PREVIEW_STOP_TIMEOUT_MS, + }); + return previewFromExecution( + await waitForJobExecution(execution.id, { + timeoutMs: PREVIEW_STOP_TIMEOUT_MS + PREVIEW_WAIT_GRACE_MS, + }) + ); +} + +/** Registers host preview start/stop actions only in the full production worker. */ +export function registerPullRequestPreviewExecutionActions(): void { + registerScheduledJobAction( + "dashboard.preview.start", + async (job, signal, context) => { + const number = executionPreviewNumber(job.actionPayload.number); + return { + preview: await startPullRequestPreview(await findPullRequest(number), { + protectFromCancellation: () => context.protectFromCancellation(), + signal, + }), + }; + } + ); + registerScheduledJobAction( + "dashboard.preview.stop", + async (job, _signal, context) => { + const value = job.actionPayload.number; + return { + preview: await stopPullRequestPreview( + value === undefined ? undefined : executionPreviewNumber(value), + { + protectFromCancellation: () => context.protectFromCancellation(), + } + ), + }; + } + ); +} diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index edd50142d..4f7a47f41 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -69,6 +69,8 @@ const RECENT_DEPLOYMENTS_LIMIT = 10; const MAX_BUFFER = 20 * 1024 * 1024; const MAX_JSON_LINE_LENGTH = 1024 * 1024; const PR_LIST_TIMEOUT_MS = 180_000; +const PUBLIC_PR_CACHE_MS = 2 * 60 * 1000; +const PUBLIC_GITHUB_API_TIMEOUT_MS = 15_000; const DEPLOYMENT_RESTART_STATUS_POLL_MS = 1000; const DEPLOYMENT_RESTART_CLAIM_PAUSE_TIMEOUT_MS = 2 * 60 * 1000; const DEPLOYMENT_WORKER_STABILITY_SECONDS = @@ -76,7 +78,11 @@ const DEPLOYMENT_WORKER_STABILITY_SECONDS = const PASSING_CHECK_VALUES = new Set(["success", "successful", "neutral", "skipped"]); const OPINIONATED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"]); const ACTIVE_DEPLOYMENT_STATUSES = new Set(["building", "restart-scheduled"]); +const FULL_COMMIT_SHA_PATTERN = /^[\da-f]{40}$/u; const BUN_EXECUTABLE = process.env.BUN_BINARY || "bun"; +const publicPullRequestCache: { + value?: { expiresAt: number; pullRequests: PullRequestSummary[] }; +} = {}; function resolveExecutableFromPath(executable: string): string | undefined { if (path.isAbsolute(executable)) { @@ -131,7 +137,7 @@ interface PullRequestAuthor { } /** Represents pull request summary. */ -interface PullRequestSummary { +export interface PullRequestSummary { number: number; title: string; body?: string; @@ -168,6 +174,19 @@ interface PullRequestReviewConnection { nodes?: PullRequestReview[]; } +interface PublicGitHubPullRequest { + base?: { ref?: unknown }; + body?: unknown; + created_at?: unknown; + draft?: unknown; + head?: { ref?: unknown; sha?: unknown }; + html_url?: unknown; + number?: unknown; + title?: unknown; + updated_at?: unknown; + user?: { login?: unknown }; +} + /** Represents deployment job. */ interface DeploymentJob { id: string; @@ -187,6 +206,7 @@ export interface DashboardReleaseSummary { builtAt: string; commitSha: string; commitTitle: string; + commitUrl: string; schema: { maximumCompatible: number; minimumCompatible: number; @@ -288,6 +308,10 @@ interface DeploymentJobRow { stderr: string | null; } +function dashboardCommitUrl(commitSha: string): string { + return `https://github.com/${DASHBOARD_REPO}/commit/${encodeURIComponent(commitSha)}`; +} + function mapDeploymentJob(row: DeploymentJobRow): DeploymentJob { const commit = row.commit_sha ?? undefined; return { @@ -297,9 +321,7 @@ function mapDeploymentJob(row: DeploymentJobRow): DeploymentJob { updatedAt: row.updated_at, commit, commitTitle: row.commit_title ?? undefined, - commitUrl: commit - ? `https://github.com/${DASHBOARD_REPO}/commit/${encodeURIComponent(commit)}` - : undefined, + commitUrl: commit ? dashboardCommitUrl(commit) : undefined, note: row.note ?? undefined, stdout: row.stdout ?? undefined, stderr: row.stderr ?? undefined, @@ -344,6 +366,7 @@ interface DeploymentLockRow { } interface DeploymentLockExecutionRow { + action_key: string; status: JobExecution["status"]; } @@ -368,7 +391,7 @@ function readDeploymentLockExecution( ): DeploymentLockExecutionRow | undefined { return database .prepare( - `SELECT status + `SELECT action_key, status FROM job_executions WHERE json_valid(payload_json) AND ( @@ -509,7 +532,10 @@ function ensureNoActiveDeployment(): void { if (lockExecution && activeJob?.status === "building") { writeDeploymentJob({ ...activeJob, - note: "Deploy execution ended before build completion", + note: + lockExecution.action_key === "dashboard.rollback" + ? "Rollback execution ended before build completion" + : "Deploy execution ended before build completion", status: "failed", updatedAt: dateToISOString(new Date()), }); @@ -606,6 +632,7 @@ function dashboardReleaseSummary( builtAt: release.manifest.builtAt, commitSha: release.commitSha, commitTitle: release.manifest.commitTitle, + commitUrl: dashboardCommitUrl(release.commitSha), schema: { maximumCompatible: release.manifest.schema.maximumCompatible, minimumCompatible: release.manifest.schema.minimumCompatible, @@ -677,11 +704,7 @@ function buildGithubCommandEnvironment(githubToken: string): NodeJS.ProcessEnv { /** Builds command environment. */ function buildCommandEnvironment(): NodeJS.ProcessEnv { - const githubToken = - process.env.MIRA_GITHUB_TOKEN?.trim() || - process.env.GH_TOKEN?.trim() || - process.env.GITHUB_TOKEN?.trim() || - ""; + const githubToken = configuredGithubReadToken(); const environment = buildGithubCommandEnvironment(githubToken); const bunBinDirectory = path.join( nonEmptyEnvironmentFallback("HOME", "/home/ubuntu"), @@ -694,6 +717,15 @@ function buildCommandEnvironment(): NodeJS.ProcessEnv { return environment; } +function configuredGithubReadToken(): string { + return ( + process.env.MIRA_GITHUB_TOKEN?.trim() || + process.env.GH_TOKEN?.trim() || + process.env.GITHUB_TOKEN?.trim() || + "" + ); +} + /** Builds reviewer command environment. */ function buildReviewCommandEnvironment(): NodeJS.ProcessEnv { const githubToken = process.env.RAJOHAN_GITHUB_TOKEN?.trim() || ""; @@ -757,6 +789,113 @@ function normalizePullRequest(pr: PullRequestSummary): PullRequestSummary { }; } +/** Parses the bounded public REST shape used only by credential-free dev previews. */ +export function parsePublicGithubPullRequests(value: unknown): PullRequestSummary[] { + if (!Array.isArray(value) || value.length > 100) { + throw new Error("GitHub public pull request response is invalid"); + } + return value.map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error("GitHub public pull request response is invalid"); + } + const pullRequest = entry as PublicGitHubPullRequest; + if ( + !Number.isSafeInteger(pullRequest.number) || + Number(pullRequest.number) <= 0 || + typeof pullRequest.title !== "string" || + typeof pullRequest.html_url !== "string" || + typeof pullRequest.head?.ref !== "string" || + typeof pullRequest.head.sha !== "string" || + !FULL_COMMIT_SHA_PATTERN.test(pullRequest.head.sha) || + typeof pullRequest.base?.ref !== "string" || + typeof pullRequest.user?.login !== "string" || + typeof pullRequest.created_at !== "string" || + typeof pullRequest.updated_at !== "string" || + typeof pullRequest.draft !== "boolean" + ) { + throw new Error("GitHub public pull request response is invalid"); + } + return normalizePullRequest({ + author: { login: pullRequest.user.login }, + baseRefName: pullRequest.base.ref, + body: typeof pullRequest.body === "string" ? pullRequest.body : undefined, + createdAt: pullRequest.created_at, + headRefName: pullRequest.head.ref, + headRefOid: pullRequest.head.sha, + isDraft: pullRequest.draft, + number: Number(pullRequest.number), + statusCheckRollup: [], + title: pullRequest.title, + updatedAt: pullRequest.updated_at, + url: pullRequest.html_url, + }); + }); +} + +async function readBoundedJsonResponse( + response: Response, + maximumBytes: number +): Promise { + if (!response.body) { + throw new Error("GitHub public pull request response was empty"); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let receivedBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedBytes += value.byteLength; + if (receivedBytes > maximumBytes) { + await reader.cancel(); + throw new Error("GitHub public pull request response was too large"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const body = Buffer.concat(chunks, receivedBytes).toString("utf8"); + return JSON.parse(body) as unknown; +} + +async function listPublicDashboardPullRequests(): Promise { + const now = Date.now(); + const cachedPullRequests = publicPullRequestCache.value; + if (cachedPullRequests && cachedPullRequests.expiresAt > now) { + return cachedPullRequests.pullRequests; + } + const response = await fetch( + `https://api.github.com/repos/${DASHBOARD_REPO}/pulls?state=open&base=${DEFAULT_BASE}&per_page=100`, + { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "Mira-Dashboard-development-preview", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(PUBLIC_GITHUB_API_TIMEOUT_MS), + } + ); + if (!response.ok) { + throw new Error( + `GitHub public pull request request failed with status ${response.status}` + ); + } + const contentLength = Number(response.headers.get("content-length") || 0); + if (contentLength > MAX_BUFFER) { + throw new Error("GitHub public pull request response was too large"); + } + const pullRequests = parsePublicGithubPullRequests( + await readBoundedJsonResponse(response, MAX_BUFFER) + ); + publicPullRequestCache.value = { + expiresAt: now + PUBLIC_PR_CACHE_MS, + pullRequests, + }; + return pullRequests; +} + /** Performs run command. */ async function runCommand( command: string, @@ -992,6 +1131,12 @@ async function runGhJsonLines( /** Lists open pull requests targeting the dashboard production branch. */ export async function listDashboardPullRequests(): Promise { + if ( + process.env.MIRA_DASHBOARD_DEV_SAFE_MODE === "1" && + !configuredGithubReadToken() + ) { + return listPublicDashboardPullRequests(); + } const repo = parseRepoParts(DASHBOARD_REPO); const pullRequests = await runGhJsonLines( [ @@ -1792,7 +1937,7 @@ async function scheduleReleaseRollback( ...job, status: "failed", updatedAt: dateToISOString(new Date()), - note: `Rollback target failed readiness; original release ${originalShort} was restored automatically`, + note: `Rollback target failed readiness. Original release ${originalShort} was restored automatically`, }; const restorationFailedJob: DeploymentJob = { ...job, @@ -1804,7 +1949,7 @@ async function scheduleReleaseRollback( ...job, status: "failed", updatedAt: dateToISOString(new Date()), - note: "Atomic rollback failed before restart; current release was left unchanged", + note: "Atomic rollback failed before restart. Current release was left unchanged", }; const script = [ @@ -1830,7 +1975,7 @@ async function scheduleReleaseRollback( [ "--user", "--collect", - `--unit=mira-dashboard-deploy-${job.id}`, + `--unit=mira-dashboard-rollback-${job.id}`, "--description=Mira Dashboard atomic release rollback", "/bin/bash", "-lc", @@ -2080,7 +2225,7 @@ async function runRollbackJob( } if (!job.commit || state.previous.commitSha !== job.commit) { throw new Error( - "Rollback target changed before execution; refresh release status and try again" + "Rollback target changed before execution. Refresh release status and try again" ); } if (state.current.commitSha === state.previous.commitSha) { @@ -2214,8 +2359,16 @@ export async function prepareAndStartDeployLatest(): Promise { return startDeployLatest(); } -/** Validates the current release slots and queues an atomic rollback. */ -export async function prepareAndStartRollback(): Promise { +/** Validates the confirmed target against current release slots and queues rollback. */ +export async function prepareAndStartRollback( + expectedTargetCommit: string +): Promise { + if (!FULL_COMMIT_SHA_PATTERN.test(expectedTargetCommit)) { + throw Object.assign( + new TypeError("Rollback target must be a full lowercase commit SHA"), + { statusCode: 400 } + ); + } registerPullRequestJobLifecycleHandlers(); const now = dateToISOString(new Date()); const deploymentId = Bun.randomUUIDv7(); @@ -2237,6 +2390,14 @@ export async function prepareAndStartRollback(): Promise { { statusCode: 409 } ); } + if (state.previous.commitSha !== expectedTargetCommit) { + throw Object.assign( + new Error( + "Rollback target changed. Refresh release status and confirm the current previous release" + ), + { statusCode: 409 } + ); + } assertDashboardReleaseHostRuntimeCompatible(state.previous); job = { diff --git a/backend/test/developmentStack.test.ts b/backend/test/developmentStack.test.ts new file mode 100644 index 000000000..f2ca817e0 --- /dev/null +++ b/backend/test/developmentStack.test.ts @@ -0,0 +1,513 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { Database } from "bun:sqlite"; +import { describe, expect, it, jest } from "bun:test"; + +import { + developmentBackendEnvironment, + prepareDevelopmentState, + resetDevelopmentState, + resolveDevelopmentStackConfig, + runDevelopmentStack, +} from "../src/development/developmentStack.ts"; + +const CURRENT_COMMIT = "a".repeat(40); +const PREVIOUS_COMMIT = "b".repeat(40); +const SQL_NULL = JSON.parse("null") as null; +const RUNNING_PROCESS_EXIT_CODE = JSON.parse("null") as null; + +function temporaryRoot(label: string): string { + return mkdtempSync(path.join(tmpdir(), label)); +} + +function controllableDevelopmentChild() { + const { promise, resolve } = Promise.withResolvers(); + let exitCode: number | null = RUNNING_PROCESS_EXIT_CODE; + const kill = jest.fn(() => { + if (exitCode !== RUNNING_PROCESS_EXIT_CODE) return; + exitCode = 0; + resolve(0); + }); + return { + child: { + exited: promise, + get exitCode() { + return exitCode; + }, + kill, + } as unknown as ReturnType, + complete(code: number) { + exitCode = code; + resolve(code); + }, + kill, + }; +} + +function createSnapshotSource(databasePath: string): void { + const database = new Database(databasePath); + database.run(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + mfa_enabled_at TEXT + ); + CREATE TABLE user_webauthn_credentials ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL + ); + CREATE TABLE user_totp_factors ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + encrypted_secret TEXT NOT NULL + ); + CREATE TABLE user_recovery_codes ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL + ); + CREATE TABLE auth_webauthn_challenges (id TEXT PRIMARY KEY); + CREATE TABLE auth_sessions (id TEXT PRIMARY KEY); + CREATE TABLE auth_pending_logins (id TEXT PRIMARY KEY); + CREATE TABLE app_config (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE deployment_lock (id INTEGER PRIMARY KEY); + CREATE TABLE deployment_jobs (id TEXT PRIMARY KEY); + CREATE TABLE scheduled_jobs ( + id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL, + action_key TEXT NOT NULL, + next_run_at TEXT + ); + CREATE TABLE scheduled_job_runs (id INTEGER PRIMARY KEY); + CREATE TABLE job_executions (id TEXT PRIMARY KEY); + CREATE TABLE job_workers (id TEXT PRIMARY KEY); + CREATE TABLE chat_runtime_snapshots (gateway_scope TEXT PRIMARY KEY); + CREATE TABLE chat_runtime_snapshot_events (gateway_scope TEXT PRIMARY KEY); + `); + database.run( + "INSERT INTO users (id, username, mfa_enabled_at) VALUES (1, 'key-user', 'now'), (2, 'totp-user', 'now')" + ); + database.run( + "INSERT INTO user_webauthn_credentials (id, user_id) VALUES ('credential', 1)" + ); + database.run( + "INSERT INTO user_totp_factors (id, user_id, encrypted_secret) VALUES ('totp', 2, 'production-secret')" + ); + database.run("INSERT INTO user_recovery_codes (id, user_id) VALUES ('recovery', 2)"); + for (const [tableName, id] of [ + ["auth_webauthn_challenges", "challenge"], + ["auth_sessions", "session"], + ["auth_pending_logins", "pending"], + ["deployment_jobs", "deployment"], + ["job_executions", "execution"], + ["job_workers", "worker"], + ["chat_runtime_snapshots", "scope"], + ["chat_runtime_snapshot_events", "scope"], + ] as const) { + database.run(`INSERT INTO ${tableName} VALUES (?)`, [id]); + } + database.run( + "INSERT INTO app_config (key, value) VALUES ('gateway_token', 'encrypted'), ('theme', 'dark')" + ); + database.run("INSERT INTO deployment_lock (id) VALUES (1)"); + database.run(` + INSERT INTO scheduled_jobs (id, enabled, action_key, next_run_at) + VALUES + ('cache', 1, 'cache.refresh', '2026-01-01T00:00:00.000Z'), + ('database', 0, 'database.maintenance', NULL), + ('backup', 1, 'backup.run', '2026-01-01T00:00:00.000Z') + `); + database.run("INSERT INTO scheduled_job_runs (id) VALUES (1)"); + database.close(); +} + +function createReleaseSource(root: string): string { + const releaseRoot = path.join(root, "release-source"); + for (const commit of [CURRENT_COMMIT, PREVIOUS_COMMIT]) { + const releasePath = path.join(releaseRoot, "releases", commit); + mkdirSync(releasePath, { recursive: true }); + writeFileSync(path.join(releasePath, "release-manifest.json"), commit); + } + symlinkSync( + path.posix.join("releases", CURRENT_COMMIT), + path.join(releaseRoot, "current") + ); + symlinkSync( + path.posix.join("releases", PREVIOUS_COMMIT), + path.join(releaseRoot, "previous") + ); + return releaseRoot; +} + +describe("development stack", () => { + it("resolves one prod-like development mode defensively", () => { + const root = temporaryRoot("mira-development-config-"); + try { + const development = resolveDevelopmentStackConfig({ HOME: root }, root); + expect(development).toMatchObject({ + backendHost: "127.0.0.1", + backendPort: 3101, + databaseSource: path.join( + root, + "projects", + "mira-dashboard-state", + "mira-dashboard.db" + ), + frontendHost: "127.0.0.1", + frontendPort: 5173, + gatewayUrl: "ws://127.0.0.1:18789", + publicOrigin: "http://localhost:5173", + releaseSource: path.join(root, "projects", "mira-dashboard-releases"), + rpId: "localhost", + stateRoot: path.join( + root, + "projects", + "mira-dashboard-dev-state", + "local" + ), + }); + + const tokenFile = path.join(root, "gateway.token"); + const custom = resolveDevelopmentStackConfig( + { + HOME: root, + MIRA_DASHBOARD_DEV_BACKEND_PORT: "4101", + MIRA_DASHBOARD_DEV_FRONTEND_PORT: "4173", + MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE: tokenFile, + MIRA_DASHBOARD_DEV_GATEWAY_URL: "wss://gateway.example/ws", + MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: "https://dashboard.example:4173", + MIRA_DASHBOARD_DEV_STATE_ROOT: path.join(root, "state"), + }, + root + ); + expect(custom).toMatchObject({ + backendPort: 4101, + frontendPort: 4173, + gatewayTokenFile: tokenFile, + gatewayUrl: "wss://gateway.example/ws", + rpId: "dashboard.example", + }); + + for (const environment of [ + { + HOME: root, + MIRA_DASHBOARD_DEV_BACKEND_PORT: "5173", + MIRA_DASHBOARD_DEV_FRONTEND_PORT: "5173", + }, + { + HOME: root, + // eslint-disable-next-line unicorn/prefer-https -- Verifies that remote plain HTTP is rejected. + MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: "http://dashboard.example", + }, + { + HOME: root, + MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: "https://127.0.0.1:5173", + }, + { + HOME: root, + MIRA_DASHBOARD_DEV_GATEWAY_URL: "https://gateway.example", + }, + ]) { + expect(() => resolveDevelopmentStackConfig(environment, root)).toThrow(); + } + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("creates a scrubbed snapshot, copied release slots, and secret-minimized env", () => { + const root = temporaryRoot("mira-development-state-"); + const sourceDatabase = path.join(root, "source.db"); + const stateRoot = path.join(root, "state"); + const releaseSource = createReleaseSource(root); + createSnapshotSource(sourceDatabase); + const gatewayTokenFile = path.join(root, "gateway.token"); + writeFileSync(gatewayTokenFile, "development-gateway-token\n", { + mode: 0o600, + }); + const workspaceSource = path.join(root, "workspace-source"); + mkdirSync(path.join(workspaceSource, "credentials"), { recursive: true }); + writeFileSync(path.join(workspaceSource, "README.md"), "development snapshot"); + writeFileSync(path.join(workspaceSource, ".env"), "SECRET=value"); + writeFileSync(path.join(workspaceSource, ".env.example"), "SAFE=example"); + writeFileSync( + path.join(workspaceSource, "credentials", "service.token"), + "secret" + ); + const openClawConfigSource = path.join(root, "openclaw.json"); + writeFileSync( + openClawConfigSource, + JSON.stringify({ + agents: { + defaults: { + gatewayToken: "must-not-copy", + model: { primary: "codex" }, + workspace: "/production/workspace", + }, + list: [{ default: true, id: "main" }], + }, + gateway: { auth: { token: "must-not-copy" } }, + }) + ); + const config = resolveDevelopmentStackConfig( + { + HOME: root, + MIRA_DASHBOARD_DEV_DB_SOURCE: sourceDatabase, + MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE: gatewayTokenFile, + MIRA_DASHBOARD_DEV_GATEWAY_URL: "ws://127.0.0.1:18789", + MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE: openClawConfigSource, + MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: "https://dashboard.example:5173", + MIRA_DASHBOARD_DEV_RELEASES_SOURCE: releaseSource, + MIRA_DASHBOARD_DEV_STATE_ROOT: stateRoot, + MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE: workspaceSource, + }, + root + ); + const originalGitHubToken = process.env.MIRA_GITHUB_TOKEN; + process.env.MIRA_GITHUB_TOKEN = "must-not-leak"; + try { + expect(prepareDevelopmentState(config)).toEqual({ + database: "snapshot-created", + releases: "copied", + workspace: "copied", + }); + const snapshot = new Database(config.databasePath, { readonly: true }); + expect( + JSON.stringify( + snapshot + .query("SELECT id, mfa_enabled_at FROM users ORDER BY id") + .all() + ) + ).toBe('[{"id":1,"mfa_enabled_at":"now"},{"id":2,"mfa_enabled_at":null}]'); + for (const tableName of [ + "auth_webauthn_challenges", + "auth_sessions", + "auth_pending_logins", + "user_totp_factors", + "user_recovery_codes", + "deployment_lock", + "deployment_jobs", + "job_executions", + "scheduled_job_runs", + "job_workers", + "chat_runtime_snapshots", + "chat_runtime_snapshot_events", + ]) { + expect( + snapshot.query(`SELECT COUNT(*) AS count FROM ${tableName}`).get() + ).toEqual({ count: 0 }); + } + expect( + snapshot.query("SELECT key, value FROM app_config ORDER BY key").all() + ).toEqual([{ key: "theme", value: "dark" }]); + expect( + snapshot + .query( + `SELECT id, enabled, action_key, next_run_at + FROM scheduled_jobs + ORDER BY id` + ) + .all() + ).toEqual([ + { + action_key: "backup.run", + enabled: 0, + id: "backup", + next_run_at: SQL_NULL, + }, + { + action_key: "cache.refresh", + enabled: 1, + id: "cache", + next_run_at: "2026-01-01T00:00:00.000Z", + }, + { + action_key: "database.maintenance", + enabled: 0, + id: "database", + next_run_at: SQL_NULL, + }, + ]); + snapshot.close(); + + expect(readlinkSync(path.join(config.releaseRoot, "current"))).toBe( + path.posix.join("releases", CURRENT_COMMIT) + ); + expect(readlinkSync(path.join(config.releaseRoot, "previous"))).toBe( + path.posix.join("releases", PREVIOUS_COMMIT) + ); + expect( + Buffer.from( + readFileSync(config.secretEncryptionKeyPath, "utf8").trim(), + "base64" + ) + ).toHaveLength(32); + const developmentWorkspace = path.join(config.openClawHome, "workspace"); + expect( + readFileSync(path.join(developmentWorkspace, "README.md"), "utf8") + ).toBe("development snapshot"); + expect( + readFileSync(path.join(developmentWorkspace, ".env.example"), "utf8") + ).toBe("SAFE=example"); + expect(existsSync(path.join(developmentWorkspace, ".env"))).toBe(false); + expect(existsSync(path.join(developmentWorkspace, "credentials"))).toBe( + false + ); + const openClawConfigText = readFileSync( + path.join(config.openClawHome, "openclaw.json"), + "utf8" + ); + expect(JSON.parse(openClawConfigText)).toEqual({ + agents: { + defaults: { + model: { primary: "codex" }, + workspace: developmentWorkspace, + }, + list: [{ default: true, id: "main" }], + }, + }); + + const environment = developmentBackendEnvironment(config); + expect(environment).toMatchObject({ + MIRA_DASHBOARD_COOKIE_NAMESPACE: "mira_dashboard_dev_5173", + MIRA_DASHBOARD_DB_PATH: config.databasePath, + MIRA_DASHBOARD_DEV_SAFE_MODE: "1", + MIRA_DASHBOARD_DISABLE_SCHEDULER: "0", + MIRA_DASHBOARD_EXECUTION_ROLE: "combined", + MIRA_DASHBOARD_JOB_PROFILE: "isolated", + OPENCLAW_GATEWAY_TOKEN: "development-gateway-token", + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789/", + }); + expect(environment).not.toHaveProperty("MIRA_GITHUB_TOKEN"); + expect(environment).not.toHaveProperty( + "MIRA_DASHBOARD_AUTOMATION_CREDENTIALS" + ); + expect(prepareDevelopmentState(config)).toEqual({ + database: "reused", + releases: "reused", + workspace: "reused", + }); + + resetDevelopmentState(config); + expect(existsSync(stateRoot)).toBe(false); + } finally { + if (originalGitHubToken === undefined) { + delete process.env.MIRA_GITHUB_TOKEN; + } else { + process.env.MIRA_GITHUB_TOKEN = originalGitHubToken; + } + rmSync(root, { force: true, recursive: true }); + } + }); + + it("refuses to claim or reset state without the exact checkout marker", () => { + const root = temporaryRoot("mira-development-marker-"); + const stateRoot = path.join(root, "state"); + mkdirSync(stateRoot); + writeFileSync(path.join(stateRoot, "unrelated.txt"), "keep"); + const config = resolveDevelopmentStackConfig( + { + HOME: root, + MIRA_DASHBOARD_DEV_STATE_ROOT: stateRoot, + }, + root + ); + try { + expect(() => prepareDevelopmentState(config)).toThrow( + "Refusing to claim non-empty unmarked development state" + ); + expect(() => resetDevelopmentState(config)).toThrow( + "Refusing to reset unmarked development state" + ); + expect(readFileSync(path.join(stateRoot, "unrelated.txt"), "utf8")).toBe( + "keep" + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("couples watched frontend and backend child lifecycles", async () => { + const root = temporaryRoot("mira-development-processes-"); + const gatewayTokenFile = path.join(root, "gateway.token"); + writeFileSync(gatewayTokenFile, "development-gateway-token\n", { + mode: 0o600, + }); + mkdirSync(path.join(root, ".openclaw", "workspace"), { + recursive: true, + }); + writeFileSync(path.join(root, ".openclaw", "openclaw.json"), "{}"); + const databaseSource = path.join( + root, + "projects", + "mira-dashboard-state", + "mira-dashboard.db" + ); + mkdirSync(path.dirname(databaseSource), { recursive: true }); + new Database(databaseSource).close(); + mkdirSync(path.join(root, "projects", "mira-dashboard-releases"), { + recursive: true, + }); + const config = resolveDevelopmentStackConfig( + { + HOME: root, + MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE: gatewayTokenFile, + MIRA_DASHBOARD_DEV_STATE_ROOT: path.join(root, "state"), + }, + root + ); + const backend = controllableDevelopmentChild(); + const frontend = controllableDevelopmentChild(); + const spawnSpy = jest + .spyOn(Bun, "spawn") + .mockImplementationOnce(() => backend.child) + .mockImplementationOnce(() => frontend.child); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + try { + const running = runDevelopmentStack(config); + await Bun.sleep(0); + backend.complete(7); + await expect(running).resolves.toBe(7); + expect(spawnSpy).toHaveBeenCalledTimes(2); + expect(spawnSpy.mock.calls[0]?.[1]).toMatchObject({ + cwd: path.join(root, "backend"), + env: expect.objectContaining({ + MIRA_DASHBOARD_DEV_SAFE_MODE: "1", + MIRA_DASHBOARD_JOB_PROFILE: "isolated", + }), + }); + expect(spawnSpy.mock.calls[1]?.[1]).toMatchObject({ + cwd: root, + env: expect.objectContaining({ + DASHBOARD_API_TARGET: "http://127.0.0.1:3101", + PORT: "5173", + }), + }); + expect(frontend.kill).toHaveBeenCalledWith("SIGTERM"); + expect(backend.kill).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Isolated scheduler/worker enabled.") + ); + expect(errorSpy).toHaveBeenCalledWith( + "Development backend exited with code 7" + ); + } finally { + spawnSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + rmSync(root, { force: true, recursive: true }); + } + }); +}); diff --git a/backend/test/pullRequestPreview.test.ts b/backend/test/pullRequestPreview.test.ts new file mode 100644 index 000000000..08f5b563c --- /dev/null +++ b/backend/test/pullRequestPreview.test.ts @@ -0,0 +1,723 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it, jest } from "bun:test"; + +import * as developmentStack from "../src/development/developmentStack.ts"; +import * as processModule from "../src/lib/processes.ts"; +import type { JobExecution } from "../src/services/jobExecutionQueue.ts"; +import * as jobExecutionQueue from "../src/services/jobExecutionQueue.ts"; +import * as previewHost from "../src/services/pullRequestPreviewHost.ts"; +import { + buildPullRequestPreviewSandboxCommand, + getPullRequestPreviewStatus, + parsePreviewUnitState, + type PullRequestPreviewConfig, + resolvePullRequestPreviewConfig, + startPullRequestPreview, + stopPullRequestPreview, +} from "../src/services/pullRequestPreviewHost.ts"; +import { + prepareAndStartPullRequestPreview, + prepareAndStopPullRequestPreview, + registerPullRequestPreviewExecutionActions, +} from "../src/services/pullRequestPreviews.ts"; +import type { PullRequestSummary } from "../src/services/pullRequests.ts"; +import * as pullRequests from "../src/services/pullRequests.ts"; +import * as queuedJobExecution from "../src/services/queuedJobExecution.ts"; +import type { + ScheduledJob, + ScheduledJobActionContext, + ScheduledJobActionHandler, +} from "../src/services/scheduledJobs.ts"; +import * as scheduledJobs from "../src/services/scheduledJobs.ts"; + +const COMMIT = "a".repeat(40); + +function noOperation(): void {} + +function previewRouteRequest(number: string) { + return Object.assign( + new Request(`https://dashboard.test/api/pull-requests/${number}/preview`, { + method: "POST", + }), + { params: { number } } + ); +} + +function pausePreviewWorkerClaims(): () => void { + return noOperation; +} + +function previewConfig(root: string): PullRequestPreviewConfig { + return { + allowedAuthors: new Set(["mira-2026", "rajohan"]), + backendPort: 3101, + bunExecutable: "/home/ubuntu/.bun/bin/bun", + dashboardRoot: path.join(root, "dashboard"), + frontendPort: 5173, + gatewayTokenFile: path.join(root, "preview", "gateway.token"), + gatewayUrl: "ws://127.0.0.1:18789", + gitCommonDirectory: path.join(root, "dashboard", ".git"), + previewRoot: path.join(root, "preview"), + stateFile: path.join(root, "preview", "active-preview.json"), + unitName: "mira-dashboard-pr-preview.service", + worktreeRoot: path.join(root, "worktrees"), + }; +} + +function previewExecution( + id: string, + status: "queued" | "success", + previewStatus: "running" | "stopped" +): JobExecution { + return { + actionKey: "dashboard.preview.start", + attempt: status === "queued" ? 0 : 1, + availableAt: "2026-07-26T00:00:00.000Z", + cancelRequestedAt: undefined, + cancellable: true, + displayName: "PR preview", + finishedAt: status === "success" ? "2026-07-26T00:00:01.000Z" : undefined, + heartbeatAt: undefined, + id, + leaseExpiresAt: undefined, + leaseOwner: undefined, + message: undefined, + output: { preview: { number: 335, status: previewStatus } }, + payload: { number: 335 }, + priority: 0, + queuedAt: "2026-07-26T00:00:00.000Z", + resourceClass: "exclusive", + scheduledJobId: undefined, + scheduledRunId: undefined, + startedAt: status === "success" ? "2026-07-26T00:00:00.500Z" : undefined, + status, + timeoutMs: 600_000, + triggerType: "manual", + }; +} + +function previewScheduledJob(number: unknown): ScheduledJob { + return { + actionKey: "dashboard.preview.start", + actionPayload: { number }, + cronExpression: undefined, + createdAt: "2026-07-26T00:00:00.000Z", + description: "PR preview", + disableIntent: undefined, + enabled: true, + id: "preview", + intervalSeconds: 60, + isQueued: false, + isRunning: false, + lastRun: undefined, + name: "PR preview", + nextRunAt: undefined, + resourceClass: "exclusive", + scheduleType: "interval", + timeOfDay: undefined, + timeoutMs: 600_000, + updatedAt: "2026-07-26T00:00:00.000Z", + }; +} + +describe("managed pull request preview", () => { + it("resolves a single-slot host contract without accepting ambiguous config", () => { + const root = mkdtempSync(path.join(tmpdir(), "mira-preview-config-")); + try { + const config = resolvePullRequestPreviewConfig({ + BUN_BINARY: "/home/ubuntu/.bun/bin/bun", + MIRA_DASHBOARD_PREVIEW_BACKEND_PORT: "4101", + MIRA_DASHBOARD_PREVIEW_FRONTEND_PORT: "4173", + MIRA_DASHBOARD_PREVIEW_ROOT: path.join(root, "state"), + MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), + MIRA_DASHBOARD_WORKTREE_ROOT: path.join(root, "worktrees"), + }); + expect(config).toMatchObject({ + backendPort: 4101, + frontendPort: 4173, + gatewayTokenFile: path.join(root, "state", "gateway.token"), + gatewayUrl: "ws://127.0.0.1:18789", + previewRoot: path.join(root, "state"), + unitName: "mira-dashboard-pr-preview.service", + }); + expect(config.allowedAuthors).toEqual(new Set(["mira-2026", "rajohan"])); + + for (const environment of [ + { + BUN_BINARY: "/home/ubuntu/.bun/bin/bun", + MIRA_DASHBOARD_PREVIEW_BACKEND_PORT: "5173", + MIRA_DASHBOARD_PREVIEW_FRONTEND_PORT: "5173", + MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), + MIRA_DASHBOARD_WORKTREE_ROOT: path.join(root, "worktrees"), + }, + { + BUN_BINARY: "/home/ubuntu/.bun/bin/bun", + MIRA_DASHBOARD_PREVIEW_GATEWAY_URL: "https://gateway.example/ws", + MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), + MIRA_DASHBOARD_WORKTREE_ROOT: path.join(root, "worktrees"), + }, + { + BUN_BINARY: "/home/ubuntu/.bun/bin/bun", + MIRA_DASHBOARD_PREVIEW_UNIT: "../preview.service", + MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), + MIRA_DASHBOARD_WORKTREE_ROOT: path.join(root, "worktrees"), + }, + ]) { + expect(() => resolvePullRequestPreviewConfig(environment)).toThrow(); + } + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("builds a read-only source sandbox with isolated writable state", () => { + const root = mkdtempSync(path.join(tmpdir(), "mira-preview-sandbox-")); + try { + const config = { + ...previewConfig(root), + gatewayTokenFile: path.join(root, "gateway.token"), + gatewayUrl: "wss://gateway.example/ws", + }; + const worktreePath = path.join(config.worktreeRoot, "preview-pr-335"); + const stateRoot = path.join(config.previewRoot, "states", "pr-335"); + const command = buildPullRequestPreviewSandboxCommand({ + config, + number: 335, + publicOrigin: "https://dashboard.example:5173", + stateRoot, + worktreePath, + }); + expect(command.slice(0, 4)).toEqual([ + "bwrap", + "--unshare-all", + "--share-net", + "--die-with-parent", + ]); + for (const value of [ + "--clearenv", + "--ro-bind", + "--bind", + "/state", + "MIRA_DASHBOARD_DEV_STATE_OWNER", + "managed-pr-335", + "MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE", + "/run/mira-dashboard-preview/gateway.token", + ]) { + expect(command).toContain(value); + } + expect(command).not.toContain("MIRA_GITHUB_TOKEN"); + expect(command).not.toContain("OPENCLAW_GATEWAY_TOKEN"); + expect(command.at(-1)).toBe( + path.join(worktreePath, "scripts", "developmentStack.ts") + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("starts, reuses, updates, reports, and stops one trusted preview slot", async () => { + const root = mkdtempSync(path.join(tmpdir(), "mira-preview-lifecycle-")); + const config = { + ...previewConfig(root), + recentAuthMinutes: "10", + sessionIdleMinutes: "60", + }; + const worktreePath = path.join(config.worktreeRoot, "preview-pr-335"); + let expectedCommit = COMMIT; + let isServeEnabled = false; + let isUnitActive = false; + const commands: string[] = []; + mkdirSync(config.dashboardRoot, { recursive: true }); + mkdirSync(config.gitCommonDirectory, { recursive: true }); + + const processSpy = jest + .spyOn(processModule, "runProcess") + .mockImplementation(async (executable, arguments_) => { + const commandArguments = [...arguments_]; + commands.push([executable, ...commandArguments].join(" ")); + if (executable === "tailscale" && commandArguments[0] === "status") { + return { + code: 0, + stderr: "", + stdout: JSON.stringify({ + Self: { DNSName: "Preview-Node.ts.net." }, + }), + }; + } + if ( + executable === "tailscale" && + commandArguments[0] === "serve" && + commandArguments[1] === "status" + ) { + return { + code: 0, + stderr: "", + stdout: JSON.stringify( + isServeEnabled + ? { + TCP: { "5173": { HTTPS: true } }, + Web: { + "preview-node.ts.net:5173": { + Handlers: { + "/": { + Proxy: "http://127.0.0.1:5173", + }, + }, + }, + }, + } + : {} + ), + }; + } + if (executable === "sudo" && commandArguments.includes("serve")) { + isServeEnabled = !commandArguments.includes("off"); + } + if (executable === "systemd-run") { + isUnitActive = true; + } + if (executable === "systemctl" && commandArguments.includes("stop")) { + isUnitActive = false; + } + if (executable === "systemctl" && commandArguments.includes("show")) { + return { + code: 0, + stderr: "", + stdout: isUnitActive + ? "ActiveState=active\nSubState=running\nResult=success\n" + : "ActiveState=inactive\nSubState=dead\nResult=success\n", + }; + } + if ( + executable === "git" && + commandArguments.includes("--show-toplevel") + ) { + return { code: 0, stderr: "", stdout: `${worktreePath}\n` }; + } + if (executable === "git" && commandArguments.includes("status")) { + return { code: 0, stderr: "", stdout: "" }; + } + if ( + executable === "git" && + commandArguments.includes("worktree") && + commandArguments.includes("add") + ) { + mkdirSync(path.join(worktreePath, "backend"), { + recursive: true, + }); + } + if (executable === "git" && commandArguments.includes("rev-parse")) { + return { + code: 0, + stderr: "", + stdout: `${expectedCommit}\n`, + }; + } + return { code: 0, stderr: "", stdout: "" }; + }); + const prepareStateSpy = jest + .spyOn(developmentStack, "prepareDevelopmentState") + .mockReturnValue({ + database: "created-empty", + releases: "empty", + workspace: "empty", + }); + const fetchSpy = jest + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("ready")); + const protectFromCancellation = jest.fn(); + + try { + const candidate = { + authorLogin: "mira-2026", + baseRefName: "main", + commitSha: COMMIT, + number: 335, + title: "Trusted preview", + }; + const running = await startPullRequestPreview(candidate, { + config, + protectFromCancellation, + readGatewayToken: () => "persisted-gateway-token", + }); + expect(running).toMatchObject({ + commitSha: COMMIT, + number: 335, + status: "running", + url: "https://preview-node.ts.net:5173", + }); + expect(prepareStateSpy).toHaveBeenCalledTimes(1); + expect(protectFromCancellation).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + "http://127.0.0.1:5173/api/health/ready", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(readFileSync(config.gatewayTokenFile, "utf8")).toBe( + "persisted-gateway-token\n" + ); + expect(statSync(config.gatewayTokenFile).mode & 0o777).toBe(0o600); + expect(commands).toContain( + "sudo -n tailscale serve --bg --https=5173 http://127.0.0.1:5173" + ); + expect(commands.some((command) => command.startsWith("systemd-run "))).toBe( + true + ); + await expect(getPullRequestPreviewStatus(config)).resolves.toMatchObject({ + number: 335, + status: "running", + }); + await expect( + startPullRequestPreview(candidate, { config }) + ).resolves.toMatchObject({ + commitSha: COMMIT, + status: "running", + }); + await expect( + startPullRequestPreview( + { ...candidate, number: 336, title: "Other trusted preview" }, + { config } + ) + ).rejects.toMatchObject({ statusCode: 409 }); + await expect(stopPullRequestPreview(336, { config })).rejects.toMatchObject({ + statusCode: 409, + }); + await expect( + stopPullRequestPreview(335, { + config, + protectFromCancellation, + }) + ).resolves.toMatchObject({ + number: 335, + status: "stopped", + }); + expect(isServeEnabled).toBe(false); + expect(existsSync(config.gatewayTokenFile)).toBe(false); + expect(commands).toContain("sudo -n tailscale serve --https=5173 off"); + + expectedCommit = "b".repeat(40); + await expect( + startPullRequestPreview( + { + ...candidate, + commitSha: expectedCommit, + title: "Updated trusted preview", + }, + { + config, + readGatewayToken: () => "rotated-gateway-token", + } + ) + ).resolves.toMatchObject({ + commitSha: expectedCommit, + status: "running", + }); + expect( + commands.some((command) => + command.includes(`checkout --detach ${expectedCommit}`) + ) + ).toBe(true); + await stopPullRequestPreview(undefined, { config }); + expect(isServeEnabled).toBe(false); + expect(existsSync(config.gatewayTokenFile)).toBe(false); + } finally { + processSpy.mockRestore(); + prepareStateSpy.mockRestore(); + fetchSpy.mockRestore(); + rmSync(root, { force: true, recursive: true }); + } + }); + + it("queues preview operations and registers guarded worker actions", async () => { + const queuedStart = previewExecution("preview-start", "queued", "running"); + const queuedStop = previewExecution("preview-stop", "queued", "stopped"); + const completedStart = previewExecution("preview-start", "success", "running"); + const completedStop = previewExecution("preview-stop", "success", "stopped"); + const enqueueSpy = jest + .spyOn(jobExecutionQueue, "enqueueJobExecution") + .mockImplementation((input) => + input.actionKey === "dashboard.preview.start" ? queuedStart : queuedStop + ); + const waitSpy = jest + .spyOn(queuedJobExecution, "waitForJobExecution") + .mockImplementation(async (id) => + id === queuedStart.id ? completedStart : completedStop + ); + const handlers = new Map(); + const registerSpy = jest + .spyOn(scheduledJobs, "registerScheduledJobAction") + .mockImplementation((actionKey, handler) => { + handlers.set(actionKey, handler); + }); + const pullRequest: PullRequestSummary = { + additions: 1, + author: { login: "mira-2026" }, + baseRefName: "main", + body: "", + changedFiles: 1, + createdAt: "2026-07-26T00:00:00.000Z", + deletions: 0, + headRefName: "preview", + headRefOid: COMMIT, + isDraft: false, + latestOpinionatedReviews: { nodes: [] }, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + number: 335, + reviewDecision: "APPROVED", + title: "Trusted preview", + updatedAt: "2026-07-26T00:00:00.000Z", + url: "https://github.test/pull/335", + }; + const listSpy = jest + .spyOn(pullRequests, "listDashboardPullRequests") + .mockResolvedValue([pullRequest]); + const startSpy = jest + .spyOn(previewHost, "startPullRequestPreview") + .mockResolvedValue({ number: 335, status: "running" }); + const stopSpy = jest + .spyOn(previewHost, "stopPullRequestPreview") + .mockResolvedValue({ number: 335, status: "stopped" }); + const statusSpy = jest + .spyOn(previewHost, "getPullRequestPreviewStatus") + .mockResolvedValue({ + commitSha: COMMIT, + number: 335, + status: "running", + }); + const protectFromCancellation = jest.fn(); + const context: ScheduledJobActionContext = { + executionId: "execution", + pauseWorkerClaims: pausePreviewWorkerClaims, + protectFromCancellation, + updateOutput: () => {}, + }; + + try { + await expect(prepareAndStartPullRequestPreview(335)).resolves.toEqual({ + number: 335, + status: "running", + }); + await expect(prepareAndStopPullRequestPreview(335)).resolves.toEqual({ + number: 335, + status: "stopped", + }); + await expect(prepareAndStopPullRequestPreview()).resolves.toEqual({ + number: 335, + status: "stopped", + }); + expect(enqueueSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + actionKey: "dashboard.preview.start", + payload: { number: 335 }, + resourceClass: "exclusive", + }) + ); + expect(enqueueSpy).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + actionKey: "dashboard.preview.stop", + displayName: "Stop PR preview", + payload: { number: undefined }, + }) + ); + expect(waitSpy).toHaveBeenCalledTimes(3); + + const { pullRequestRoutes } = + await import("../src/routes/pullRequestRoutes.ts"); + const startResponse = await pullRequestRoutes[ + "/api/pull-requests/:number/preview/start" + ].POST(previewRouteRequest("335")); + expect(startResponse.status).toBe(200); + await expect(startResponse.json()).resolves.toEqual({ + isOk: true, + preview: { number: 335, status: "running" }, + }); + + const stopResponse = await pullRequestRoutes[ + "/api/pull-requests/:number/preview/stop" + ].POST(previewRouteRequest("335")); + expect(stopResponse.status).toBe(200); + await expect(stopResponse.json()).resolves.toEqual({ + isOk: true, + preview: { number: 335, status: "stopped" }, + }); + + const statusResponse = + await pullRequestRoutes["/api/pull-requests/preview"].GET(); + expect(statusResponse.status).toBe(200); + await expect(statusResponse.json()).resolves.toEqual({ + preview: { + commitSha: COMMIT, + number: 335, + status: "running", + }, + }); + + for (const route of [ + "/api/pull-requests/:number/preview/start", + "/api/pull-requests/:number/preview/stop", + ] as const) { + const invalidResponse = await pullRequestRoutes[route].POST( + previewRouteRequest("invalid") + ); + expect(invalidResponse.status).toBe(400); + await expect(invalidResponse.json()).resolves.toEqual({ + error: "Invalid pull request number", + }); + } + + waitSpy.mockRejectedValueOnce( + Object.assign(new Error("preview startup unavailable"), { + statusCode: 503, + }) + ); + const failedStartResponse = await pullRequestRoutes[ + "/api/pull-requests/:number/preview/start" + ].POST(previewRouteRequest("335")); + expect(failedStartResponse.status).toBe(503); + await expect(failedStartResponse.json()).resolves.toEqual({ + error: "preview startup unavailable", + }); + + waitSpy.mockRejectedValueOnce( + Object.assign(new Error("preview stop unavailable"), { + statusCode: 503, + }) + ); + const failedStopResponse = await pullRequestRoutes[ + "/api/pull-requests/:number/preview/stop" + ].POST(previewRouteRequest("335")); + expect(failedStopResponse.status).toBe(503); + await expect(failedStopResponse.json()).resolves.toEqual({ + error: "preview stop unavailable", + }); + + statusSpy.mockRejectedValueOnce( + Object.assign(new Error("preview status unavailable"), { + statusCode: 503, + }) + ); + const failedStatusResponse = + await pullRequestRoutes["/api/pull-requests/preview"].GET(); + expect(failedStatusResponse.status).toBe(503); + await expect(failedStatusResponse.json()).resolves.toEqual({ + error: "preview status unavailable", + }); + + registerPullRequestPreviewExecutionActions(); + const startHandler = handlers.get("dashboard.preview.start"); + const stopHandler = handlers.get("dashboard.preview.stop"); + expect(startHandler).toBeDefined(); + expect(stopHandler).toBeDefined(); + if (!startHandler || !stopHandler) { + throw new Error("Preview handlers were not registered"); + } + await expect( + startHandler( + previewScheduledJob("335"), + new AbortController().signal, + context + ) + ).resolves.toEqual({ + preview: { number: 335, status: "running" }, + }); + expect(startSpy).toHaveBeenCalledWith( + expect.objectContaining({ + authorLogin: "mira-2026", + commitSha: COMMIT, + number: 335, + }), + expect.objectContaining({ + protectFromCancellation: expect.any(Function), + signal: expect.any(AbortSignal), + }) + ); + startSpy.mock.calls[0]?.[1]?.protectFromCancellation?.(); + expect(protectFromCancellation).toHaveBeenCalledTimes(1); + await expect( + stopHandler(previewScheduledJob(undefined), undefined, context) + ).resolves.toEqual({ + preview: { number: 335, status: "stopped" }, + }); + expect(stopSpy).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + protectFromCancellation: expect.any(Function), + }) + ); + + listSpy.mockResolvedValue([]); + await expect( + startHandler(previewScheduledJob(336), undefined, context) + ).rejects.toMatchObject({ statusCode: 404 }); + } finally { + enqueueSpy.mockRestore(); + waitSpy.mockRestore(); + registerSpy.mockRestore(); + listSpy.mockRestore(); + startSpy.mockRestore(); + stopSpy.mockRestore(); + statusSpy.mockRestore(); + } + }); + + it("parses unit status and rejects untrusted PR metadata before host work", async () => { + expect( + parsePreviewUnitState( + "ActiveState=active\nSubState=running\nResult=success\n" + ) + ).toEqual({ + activeState: "active", + result: "success", + subState: "running", + }); + + const root = mkdtempSync(path.join(tmpdir(), "mira-preview-guard-")); + const config = previewConfig(root); + try { + expect(await getPullRequestPreviewStatus(config)).toEqual({ + status: "stopped", + }); + expect(await stopPullRequestPreview(undefined, { config })).toEqual({ + status: "stopped", + }); + await expect( + startPullRequestPreview( + { + authorLogin: "external", + baseRefName: "main", + commitSha: COMMIT, + number: 335, + title: "Untrusted PR", + }, + { config } + ) + ).rejects.toThrow("Pull request author is not allowed to run host previews"); + await expect( + startPullRequestPreview( + { + authorLogin: "mira-2026", + baseRefName: "release", + commitSha: COMMIT, + number: 335, + title: "Wrong base", + }, + { config } + ) + ).rejects.toThrow("Only main-targeted pull requests can be previewed"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); +}); diff --git a/backend/test/releaseManager.test.ts b/backend/test/releaseManager.test.ts index 9d33eec54..655cbf292 100644 --- a/backend/test/releaseManager.test.ts +++ b/backend/test/releaseManager.test.ts @@ -90,14 +90,17 @@ const SCHEMA_6_OPTIONS = { readLiveSchemaState: () => testLiveSchemaState(6), }; -function holdTransitionLock(releasesRoot: string): number { +function holdTransitionLock( + releasesRoot: string, + mode: "exclusive" | "shared" = "exclusive" +): number { const lockFileDescriptor = openSync( path.join(releasesRoot, RELEASE_TRANSITION_LOCK_FILE_NAME), "r+" ); const result = spawnSync( RELEASE_TRANSITION_LOCK_PROGRAM, - ["--exclusive", "--nonblock", "3"], + [mode === "exclusive" ? "--exclusive" : "--shared", "--nonblock", "3"], { stdio: ["ignore", "ignore", "pipe", lockFileDescriptor], } @@ -834,6 +837,28 @@ describe("Dashboard immutable release manager", () => { } ); + it.skipIf(!isReleaseTransitionLockAvailable())( + "lets lifecycle transitions wait for an in-flight status reader", + async () => { + const root = temporaryReleasesRoot(); + await createManagedRelease(root, FIRST_COMMIT); + await readDashboardReleaseState(root); + const lockFileDescriptor = holdTransitionLock(root, "shared"); + const activation = runReleaseLifecycleCommand( + ["activate", FIRST_COMMIT], + root, + SCHEMA_6_OPTIONS + ); + + await Bun.sleep(125); + closeSync(lockFileDescriptor); + + await expect(activation).resolves.toMatchObject({ + current: { commitSha: FIRST_COMMIT }, + }); + } + ); + it("restores both prior slots when activation fails after changing a link", async () => { const root = temporaryReleasesRoot(); await createManagedRelease(root, FIRST_COMMIT); diff --git a/backend/test/serverStartupPolicy.test.ts b/backend/test/serverStartupPolicy.test.ts index 7ac4e60a5..e699d204d 100644 --- a/backend/test/serverStartupPolicy.test.ts +++ b/backend/test/serverStartupPolicy.test.ts @@ -870,6 +870,7 @@ describe("server start scheduler policy", () => { it("wires Bun server websocket hooks and static fallbacks", async () => { const originalFrontendPath = process.env.MIRA_DASHBOARD_FRONTEND_PATH; + const originalDevelopmentSafeMode = process.env.MIRA_DASHBOARD_DEV_SAFE_MODE; const temporaryRoot = mkdtempSync(path.join(tmpdir(), "mira-server-hooks-")); const frontendRoot = path.join(temporaryRoot, "frontend"); mkdirSync(path.join(frontendRoot, "assets"), { recursive: true }); @@ -888,19 +889,37 @@ describe("server start scheduler policy", () => { }) as unknown as Server) as typeof Bun.serve ); let handleDashboardClientSpy: { mockRestore: () => void } | undefined; + let getAuthSessionSpy: { mockRestore: () => void } | undefined; try { + const now = new Date().toISOString(); + const authModule = await import("../src/auth.ts"); + getAuthSessionSpy = jest + .spyOn(authModule, "getAuthSessionFromSessionId") + .mockReturnValue({ + authMethod: "webauthn", + authenticatedAt: now, + createdAt: now, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + id: 7, + lastSeenAt: now, + mfaEnabled: true, + mfaVerifiedAt: now, + sessionId: "dev-session", + username: "mira", + }); const gatewayModule = await import("../src/gateway.ts"); handleDashboardClientSpy = jest .spyOn(gatewayModule.default, "handleDashboardClient") .mockImplementation(() => {}); const { createServer } = await import("../src/server.ts"); const optionsSymbol = Symbol.for("mira.test.options"); - const server = createServer(0) as Server & { + const server = createServer(0, "127.0.0.1") as Server & { [optionsSymbol]: { fetch: ( request: Request, server: Server ) => Promise | Response; + hostname?: string; websocket: { close: (ws: { data: { closeHandlers: Array<() => void> }; @@ -929,7 +948,9 @@ describe("server start scheduler policy", () => { closeHandlers: Array<() => void>; errorHandlers: Array<(error: unknown) => void>; messageHandlers: Array<(data: string | Buffer) => void>; + sessionToken?: string; socket?: unknown; + userId?: number; }; readyState: number; send: (data: string) => void; @@ -938,6 +959,7 @@ describe("server start scheduler policy", () => { }; }; const options = server[optionsSymbol]; + expect(options.hostname).toBe("127.0.0.1"); const apiFallback = await options.fetch( new Request("https://test.local/api/missing"), @@ -990,10 +1012,12 @@ describe("server start scheduler policy", () => { closeHandlers: Array<() => void>; errorHandlers: Array<(error: unknown) => void>; messageHandlers: Array<(data: string | Buffer) => void>; + sessionToken?: string; socket?: { close: (code?: number, reason?: string) => void; send: (data: string) => void; }; + userId?: number; }; readyState: number; send: (data: string) => void; @@ -1019,12 +1043,49 @@ describe("server start scheduler policy", () => { 4401, "Dashboard session is no longer valid" ); + + closeSpy.mockClear(); + messageHandler.mockClear(); + sendSpy.mockClear(); + process.env.MIRA_DASHBOARD_DEV_SAFE_MODE = "1"; + ws.data.sessionToken = "dev-session"; + ws.data.userId = 7; + options.websocket.message( + ws, + JSON.stringify({ + id: "blocked-request", + method: "config.patch", + type: "request", + }) + ); + expect(closeSpy).not.toHaveBeenCalled(); + expect(messageHandler).not.toHaveBeenCalled(); + expect(JSON.parse(String(sendSpy.mock.calls[0]?.[0]))).toEqual({ + code: "development_method_blocked", + error: "This Gateway action is disabled in Dashboard dev", + id: "blocked-request", + isOk: false, + type: "response", + }); + + options.websocket.message( + ws, + JSON.stringify({ + id: "allowed-request", + method: "chat.send", + type: "request", + }) + ); + expect(messageHandler).toHaveBeenCalledWith( + expect.stringContaining('"method":"chat.send"') + ); + options.websocket.error(ws, new Error("boom")); options.websocket.close(ws); - expect(messageHandler).not.toHaveBeenCalled(); expect(errorHandler).toHaveBeenCalledWith(expect.any(Error)); expect(closeHandler).toHaveBeenCalled(); } finally { + getAuthSessionSpy?.mockRestore(); handleDashboardClientSpy?.mockRestore(); serveSpy.mockRestore(); if (originalFrontendPath === undefined) { @@ -1032,6 +1093,11 @@ describe("server start scheduler policy", () => { } else { process.env.MIRA_DASHBOARD_FRONTEND_PATH = originalFrontendPath; } + if (originalDevelopmentSafeMode === undefined) { + delete process.env.MIRA_DASHBOARD_DEV_SAFE_MODE; + } else { + process.env.MIRA_DASHBOARD_DEV_SAFE_MODE = originalDevelopmentSafeMode; + } rmSync(temporaryRoot, { force: true, recursive: true }); } }); diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index f5ea72e57..1b754a9b6 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -66,6 +66,14 @@ function routeRequest( }); } +function rollbackRouteRequest(targetCommit: unknown): Request { + return new Request("https://dashboard.test/api/pull-requests/releases/rollback", { + body: JSON.stringify({ targetCommit }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); +} + function writeFakeGit(binaryPath: string, repoRoot: string): void { writeFileSync( binaryPath, @@ -1647,8 +1655,9 @@ describe("backend service behavior", () => { release: { rollback: { available: true } }, }); - const rollbackResponse = - await pullRequestRoutes["/api/pull-requests/releases/rollback"].POST(); + const rollbackResponse = await pullRequestRoutes[ + "/api/pull-requests/releases/rollback" + ].POST(rollbackRouteRequest(previousCommit)); expect(rollbackResponse.status).toBe(200); const rollbackBody = (await rollbackResponse.json()) as { deployment: Awaited>; @@ -1689,6 +1698,25 @@ describe("backend service behavior", () => { database.prepare("SELECT job_id FROM deployment_lock WHERE id = 1").get() ).toBeNull(); + await expect(prepareAndStartRollback(currentCommit)).rejects.toThrow( + "Rollback target changed" + ); + const changedTargetResponse = await pullRequestRoutes[ + "/api/pull-requests/releases/rollback" + ].POST(rollbackRouteRequest(currentCommit)); + expect(changedTargetResponse.status).toBe(409); + await expect(changedTargetResponse.json()).resolves.toMatchObject({ + error: "Rollback target changed. Refresh release status and confirm the current previous release", + }); + + const missingTargetResponse = await pullRequestRoutes[ + "/api/pull-requests/releases/rollback" + ].POST(rollbackRouteRequest(undefined)); + expect(missingTargetResponse.status).toBe(400); + await expect(missingTargetResponse.json()).resolves.toMatchObject({ + error: "Rollback target commit is required", + }); + rmSync(path.join(releasesRoot, "previous")); await expect(getDashboardReleaseStatus()).resolves.toMatchObject({ previous: undefined, @@ -1697,12 +1725,37 @@ describe("backend service behavior", () => { reason: "No distinct previous release is available", }, }); - await expect(prepareAndStartRollback()).rejects.toThrow( + await expect(prepareAndStartRollback(previousCommit)).rejects.toThrow( "requires active current and previous releases" ); + const unavailableRollbackResponse = await pullRequestRoutes[ + "/api/pull-requests/releases/rollback" + ].POST(rollbackRouteRequest(previousCommit)); + expect(unavailableRollbackResponse.status).toBe(409); + await expect(unavailableRollbackResponse.json()).resolves.toMatchObject({ + error: "Managed release rollback requires active current and previous releases", + }); expect( database.prepare("SELECT job_id FROM deployment_lock WHERE id = 1").get() ).toBeNull(); + + symlinkSync( + `releases/${currentCommit}`, + path.join(releasesRoot, "previous"), + "dir" + ); + await expect(prepareAndStartRollback(currentCommit)).rejects.toThrow( + "requires two distinct releases" + ); + expect( + database.prepare("SELECT job_id FROM deployment_lock WHERE id = 1").get() + ).toBeNull(); + + process.env.MIRA_DASHBOARD_RELEASES_ROOT = "relative-release-root"; + const unavailableStatusResponse = + await pullRequestRoutes["/api/pull-requests/releases"].GET(); + expect(unavailableStatusResponse.status).toBe(500); + process.env.MIRA_DASHBOARD_RELEASES_ROOT = releasesRoot; } finally { database.prepare("DELETE FROM deployment_lock WHERE id = 1").run(); database @@ -1716,6 +1769,52 @@ describe("backend service behavior", () => { } }); + it("rejects malformed and missing rollback worker executions", async () => { + const { registerPullRequestExecutionActions } = + await import("../src/services/pullRequests.ts"); + const { enqueueJobExecution, getJobExecution } = + await import("../src/services/jobExecutionQueue.ts"); + registerPullRequestExecutionActions(); + await startTestScheduledExecutor(); + + const missingIdExecution = enqueueJobExecution({ + actionKey: "dashboard.rollback", + displayName: "Rollback without deployment id", + payload: {}, + resourceClass: "exclusive", + timeoutMs: 1000, + }); + const absentDeploymentId = `missing-rollback-${Bun.randomUUIDv7()}`; + const missingDeploymentExecution = enqueueJobExecution({ + actionKey: "dashboard.rollback", + displayName: "Rollback with missing deployment", + payload: { deploymentId: absentDeploymentId }, + resourceClass: "exclusive", + timeoutMs: 1000, + }); + + try { + await waitFor( + () => + getJobExecution(missingIdExecution.id)?.status === "failed" && + getJobExecution(missingDeploymentExecution.id)?.status === "failed", + 5000 + ); + expect(getJobExecution(missingIdExecution.id)).toMatchObject({ + message: "Deployment id is missing", + status: "failed", + }); + expect(getJobExecution(missingDeploymentExecution.id)).toMatchObject({ + message: "Deployment job not found", + status: "failed", + }); + } finally { + database + .prepare("DELETE FROM job_executions WHERE id IN (?, ?)") + .run(missingIdExecution.id, missingDeploymentExecution.id); + } + }); + it("hands manual rollback to a detached readiness-bound guardian", async () => { rememberEnvironment("PATH"); rememberEnvironment("MIRA_DASHBOARD_ROOT"); @@ -1728,6 +1827,7 @@ describe("backend service behavior", () => { const openClawHome = path.join(fakeRoot, "state", "openclaw-client"); const logRotationLockFile = path.join(fakeRoot, "state", "log-rotation.lock"); const systemdScriptLog = path.join(fakeRoot, "rollback-guardian.sh"); + const systemdArgumentsLog = path.join(fakeRoot, "rollback-systemd-run.args"); const currentCommit = "c".repeat(40); const previousCommit = "d".repeat(40); mkdirSync(path.join(fakeRoot, "backend"), { recursive: true }); @@ -1783,6 +1883,7 @@ set -euo pipefail script="${"$"}{!#}" /bin/bash -n <<<"$script" printf '%s' "$script" > ${JSON.stringify(systemdScriptLog)} +printf '%s\n' "$@" > ${JSON.stringify(systemdArgumentsLog)} printf 'scheduled\n' ` ); @@ -1799,7 +1900,7 @@ printf 'scheduled\n' const { getJobExecution } = await import("../src/services/jobExecutionQueue.ts"); registerPullRequestExecutionActions(); await startTestScheduledExecutor(); - const rollback = await prepareAndStartRollback(); + const rollback = await prepareAndStartRollback(previousCommit); const execution = database .prepare( `SELECT id @@ -1834,7 +1935,10 @@ printf 'scheduled\n' ); expect(guardian).toContain(`ready_for_commit '${currentCommit.slice(0, 8)}'`); expect(guardian).toContain( - "original release cccccccc was restored automatically" + "Original release cccccccc was restored automatically" + ); + expect(readFileSync(systemdArgumentsLog, "utf8")).toContain( + `--unit=mira-dashboard-rollback-${rollback.id}\n` ); } finally { database @@ -1886,6 +1990,79 @@ printf 'scheduled\n' } }); + it("labels stale rollback executions as rollback failures", async () => { + const staleRollbackId = `test-rollback-stale-${Bun.randomUUIDv7()}`; + const { enqueueJobExecution } = + await import("../src/services/jobExecutionQueue.ts"); + const { startDeployLatest } = await import("../src/services/pullRequests.ts"); + database + .prepare( + `INSERT INTO deployment_jobs + (id, status, started_at, updated_at, commit_sha, commit_title, note, stdout, stderr) + VALUES (?, 'building', ?, ?, ?, ?, 'rollback queued', '', '')` + ) + .run( + staleRollbackId, + new Date().toISOString(), + new Date().toISOString(), + "b".repeat(40), + "Previous release" + ); + database + .prepare( + "INSERT INTO deployment_lock (id, job_id, updated_at) VALUES (1, ?, ?)" + ) + .run(staleRollbackId, new Date().toISOString()); + const staleExecution = enqueueJobExecution({ + actionKey: "dashboard.rollback", + displayName: "Stale rollback", + payload: { deploymentId: staleRollbackId }, + resourceClass: "exclusive", + timeoutMs: 1000, + }); + database + .prepare( + `UPDATE job_executions + SET status = 'failed', finished_at = ?, message = 'worker stopped' + WHERE id = ?` + ) + .run(new Date().toISOString(), staleExecution.id); + + let replacementId: string | undefined; + try { + const replacement = startDeployLatest(); + replacementId = replacement.id; + expect( + database + .prepare("SELECT status, note FROM deployment_jobs WHERE id = ?") + .get(staleRollbackId) + ).toEqual({ + note: "Rollback execution ended before build completion", + status: "failed", + }); + } finally { + database.prepare("DELETE FROM deployment_lock WHERE id = 1").run(); + database + .prepare("DELETE FROM job_executions WHERE id = ?") + .run(staleExecution.id); + database + .prepare("DELETE FROM deployment_jobs WHERE id = ?") + .run(staleRollbackId); + if (replacementId) { + database + .prepare( + `DELETE FROM job_executions + WHERE action_key = 'dashboard.deploy' + AND json_extract(payload_json, '$.deploymentId') = ?` + ) + .run(replacementId); + database + .prepare("DELETE FROM deployment_jobs WHERE id = ?") + .run(replacementId); + } + } + }); + it("keeps queued deployment locks active beyond the legacy stale window", async () => { const { startDeployLatest } = await import("../src/services/pullRequests.ts"); const { cancelJobExecution } = diff --git a/backend/test/utilityBehavior.test.ts b/backend/test/utilityBehavior.test.ts index 94a08579c..5178ac1e3 100644 --- a/backend/test/utilityBehavior.test.ts +++ b/backend/test/utilityBehavior.test.ts @@ -10,6 +10,7 @@ import { isAllowedDashboardOrigin, readJson, readRequestBytes, + resolveDashboardCookieNames, sessionIdFromCookie, text, withCookie, @@ -29,10 +30,16 @@ import { nonEmptyEnvironmentFallback, nullableString, objectFallback, + resolveDashboardHost, resolveDashboardPort, stringFallback, } from "../src/lib/values.ts"; -import { resetRequestPolicyForTests, withRequestPolicy } from "../src/requestPolicy.ts"; +import { + isDevelopmentGatewayMethodBlocked, + isDevelopmentHostMutationBlocked, + resetRequestPolicyForTests, + withRequestPolicy, +} from "../src/requestPolicy.ts"; import { isAllowedMutationSource, withRequestSecurity } from "../src/requestSecurity.ts"; import { routes as appRoutes } from "../src/routes.ts"; import { compactHeartbeatData } from "../src/routes/cacheRoutes.ts"; @@ -40,7 +47,16 @@ import { isValidAgentId } from "../src/services/agents.ts"; import { listAuditEvents } from "../src/services/auditEvents.ts"; import { mapBackupJob } from "../src/services/backups.ts"; import * as jobExecutionQueueModule from "../src/services/jobExecutionQueue.ts"; -import { getResolvedRoots, validatePrNumber } from "../src/services/pullRequests.ts"; +import { dashboardJobProfile } from "../src/services/jobWorker.ts"; +import { + parsePullRequestPreviewStatus, + pullRequestPreviewCandidate, +} from "../src/services/pullRequestPreviews.ts"; +import { + getResolvedRoots, + parsePublicGithubPullRequests, + validatePrNumber, +} from "../src/services/pullRequests.ts"; function serverWithAddress(address: string): Server { return { @@ -72,6 +88,40 @@ async function callTestRoute( } describe("backend service utilities", () => { + it("maps credential-free public GitHub pull request metadata for dev previews", () => { + const commitSha = "a".repeat(40); + expect( + parsePublicGithubPullRequests([ + { + base: { ref: "main" }, + body: "Preview body", + created_at: "2026-07-26T10:00:00.000Z", + draft: false, + head: { ref: "mira/preview", sha: commitSha }, + html_url: "https://github.com/rajohan/Mira-Dashboard/pull/335", + number: 335, + title: "Preview PR", + updated_at: "2026-07-26T11:00:00.000Z", + user: { login: "mira-2026" }, + }, + ]) + ).toEqual([ + expect.objectContaining({ + author: { login: "mira-2026" }, + baseRefName: "main", + canReviewerApprove: true, + headRefName: "mira/preview", + headRefOid: commitSha, + number: 335, + reviewerApproved: false, + statusCheckRollup: [], + }), + ]); + expect(() => parsePublicGithubPullRequests([{ number: 335 }])).toThrow( + "GitHub public pull request response is invalid" + ); + }); + it("compacts every heartbeat cache payload without dropping health failures", () => { const kopia = compactHeartbeatData("backup.kopia.status", { checkedAt: "checked", @@ -411,6 +461,11 @@ describe("backend service utilities", () => { expect(resolveDashboardPort("0")).toBe(3100); expect(resolveDashboardPort("65536")).toBe(3100); expect(resolveDashboardPort("not-a-port")).toBe(3100); + expect(resolveDashboardHost(" 127.0.0.1 ")).toBe("127.0.0.1"); + expect(resolveDashboardHost("")).toBe("0.0.0.0"); + expect(() => resolveDashboardHost("bad host")).toThrow( + "MIRA_DASHBOARD_HOST must be a valid bind host" + ); } finally { if (originalValue === undefined) { delete process.env.MIRA_TEST_OPTIONAL_VALUE; @@ -420,6 +475,97 @@ describe("backend service utilities", () => { } }); + it("keeps dev host and Gateway controls guarded while isolated data remains mutable", () => { + const safeEnvironment = { MIRA_DASHBOARD_DEV_SAFE_MODE: "1" }; + expect( + isDevelopmentHostMutationBlocked( + new Request("http://localhost/api/docker/update", { + method: "POST", + }), + safeEnvironment + ) + ).toBe(true); + expect( + isDevelopmentHostMutationBlocked( + new Request("http://localhost/api/pull-requests/335/approve", { + method: "POST", + }), + safeEnvironment + ) + ).toBe(true); + expect( + isDevelopmentHostMutationBlocked( + new Request("http://localhost/api/config", { + method: "PUT", + }), + safeEnvironment + ) + ).toBe(true); + expect( + isDevelopmentHostMutationBlocked( + new Request("http://localhost/api/cron/jobs/id/run", { + method: "POST", + }), + safeEnvironment + ) + ).toBe(true); + expect( + isDevelopmentHostMutationBlocked( + new Request("http://localhost/api/sessions/id", { + method: "DELETE", + }), + safeEnvironment + ) + ).toBe(true); + expect( + isDevelopmentHostMutationBlocked( + new Request("http://localhost/api/tasks", { method: "POST" }), + safeEnvironment + ) + ).toBe(false); + expect( + isDevelopmentHostMutationBlocked( + new Request("http://localhost/api/docker"), + safeEnvironment + ) + ).toBe(false); + expect( + isDevelopmentHostMutationBlocked( + new Request("http://localhost/api/docker/update", { + method: "POST", + }), + {} + ) + ).toBe(false); + for (const method of [ + "chat.abort", + "chat.history", + "chat.send", + "models.list", + "sessions.list", + "sessions.patch", + ]) { + expect(isDevelopmentGatewayMethodBlocked(method, safeEnvironment)).toBe( + false + ); + } + for (const method of [ + "config.patch", + "cron.remove", + "sessions.compact", + "sessions.delete", + ]) { + expect(isDevelopmentGatewayMethodBlocked(method, safeEnvironment)).toBe(true); + } + expect(isDevelopmentGatewayMethodBlocked("config.patch", {})).toBe(false); + expect(dashboardJobProfile({ MIRA_DASHBOARD_JOB_PROFILE: "isolated" })).toBe( + "isolated" + ); + expect(dashboardJobProfile({ MIRA_DASHBOARD_JOB_PROFILE: "unknown" })).toBe( + "full" + ); + }); + it("maps operational errors without leaking unknown values", () => { const blankError = new Error(" ".repeat(3)); expect(errorMessage(new Error(" failed "), "fallback")).toBe("failed"); @@ -522,6 +668,56 @@ describe("backend service utilities", () => { } }); + it("validates queued pull request preview status payloads", () => { + expect( + pullRequestPreviewCandidate({ + author: { login: "mira-2026" }, + baseRefName: "main", + headRefOid: "a".repeat(40), + number: 335, + title: "Managed preview", + } as never) + ).toEqual({ + authorLogin: "mira-2026", + baseRefName: "main", + commitSha: "a".repeat(40), + number: 335, + title: "Managed preview", + }); + + expect( + parsePullRequestPreviewStatus({ + backendPort: 3101, + commitSha: "a".repeat(40), + frontendPort: 5173, + number: 335, + startedAt: "2026-07-26T12:00:00.000Z", + status: "running", + title: "Managed preview", + updatedAt: "2026-07-26T12:00:00.000Z", + url: "https://dashboard.example:5173", + }) + ).toEqual({ + backendPort: 3101, + commitSha: "a".repeat(40), + frontendPort: 5173, + number: 335, + startedAt: "2026-07-26T12:00:00.000Z", + status: "running", + title: "Managed preview", + updatedAt: "2026-07-26T12:00:00.000Z", + url: "https://dashboard.example:5173", + }); + for (const value of [ + undefined, + { status: "unknown" }, + { number: 0, status: "running" }, + { status: "failed", title: 42 }, + ]) { + expect(() => parsePullRequestPreviewStatus(value)).toThrow(); + } + }); + it("serializes backup jobs without exposing live process handles", () => { expect(mapBackupJob(undefined)).toBeUndefined(); const completed = Promise.resolve(undefined); @@ -660,6 +856,28 @@ describe("backend service utilities", () => { ).toBeUndefined(); }); + it("isolates configurable development cookie namespaces", () => { + expect(resolveDashboardCookieNames({})).toEqual({ + pendingLogin: "mira_dashboard_pending_login", + session: "mira_dashboard_session", + }); + expect( + resolveDashboardCookieNames({ + MIRA_DASHBOARD_COOKIE_NAMESPACE: "mira_dashboard_dev_5173", + }) + ).toEqual({ + pendingLogin: "mira_dashboard_dev_5173_pending_login", + session: "mira_dashboard_dev_5173_session", + }); + for (const namespace of ["Prod", "dev-cookie", "a".repeat(49)]) { + expect(() => + resolveDashboardCookieNames({ + MIRA_DASHBOARD_COOKIE_NAMESPACE: namespace, + }) + ).toThrow("MIRA_DASHBOARD_COOKIE_NAMESPACE"); + } + }); + it("validates allowed dashboard origins", () => { expect(isAllowedDashboardOrigin(new Request("http://localhost:3100/api"))).toBe( true diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index d7fcc3bca..1ea5bdb55 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -238,16 +238,25 @@ upstream download metadata. ## Pull Requests And Deployments -| Method | Path | Purpose | -| ------ | -------------------------------------------- | -------------------------------------------- | -| `GET` | `/api/pull-requests` | Lists Dashboard PRs. | -| `POST` | `/api/pull-requests/:number/approve` | Queues merge, optionally followed by deploy. | -| `POST` | `/api/pull-requests/:number/reject` | Queues reject/close. | -| `POST` | `/api/pull-requests/:number/review-approval` | Queues review approval. | -| `POST` | `/api/pull-requests/:number/update-branch` | Queues branch update. | -| `POST` | `/api/pull-requests/deploy` | Queues deploy latest. | -| `GET` | `/api/pull-requests/deployments` | Lists deploy jobs. | -| `GET` | `/api/pull-requests/production-checkout` | Reads production checkout status. | +| Method | Path | Purpose | +| ------ | -------------------------------------------- | ---------------------------------------------------------- | +| `GET` | `/api/pull-requests` | Lists Dashboard PRs. | +| `POST` | `/api/pull-requests/:number/approve` | Queues merge, optionally followed by deploy. | +| `POST` | `/api/pull-requests/:number/reject` | Queues reject/close. | +| `POST` | `/api/pull-requests/:number/review-approval` | Queues review approval. | +| `POST` | `/api/pull-requests/:number/update-branch` | Queues branch update. | +| `GET` | `/api/pull-requests/preview` | Reads the single managed PR-dev slot. | +| `POST` | `/api/pull-requests/:number/preview/start` | Starts/updates trusted PR dev in the managed slot. | +| `POST` | `/api/pull-requests/:number/preview/stop` | Stops PR dev while retaining isolated state. | +| `POST` | `/api/pull-requests/deploy` | Queues an atomic deploy of latest `main`. | +| `GET` | `/api/pull-requests/deployments` | Lists deploy and rollback jobs. | +| `GET` | `/api/pull-requests/releases` | Reads immutable `current`/`previous` release status. | +| `POST` | `/api/pull-requests/releases/rollback` | Queues atomic rollback to the confirmed previous full SHA. | +| `GET` | `/api/pull-requests/production-checkout` | Reads production checkout status. | + +Managed PR dev accepts only `main`-targeted PRs from the configured trusted +authors. It uses isolated Dashboard state but the live production Gateway; see +[Local development](../development/local-dev.md#managed-pr-dev). ## Backups, Cache, Metrics, Ops diff --git a/docs/architecture/frontend-feature-map.md b/docs/architecture/frontend-feature-map.md index 50a090b46..197294d72 100644 --- a/docs/architecture/frontend-feature-map.md +++ b/docs/architecture/frontend-feature-map.md @@ -10,24 +10,24 @@ cannot infer. ## Route Map -| Route | Page | Main data source | -| ---------------- | ----------------------- | ------------------------------------------ | -| `/` | `Dashboard.tsx` | health, agents, tasks, jobs, notifications | -| `/tasks` | `Tasks.tsx` | task APIs and TanStack DB collections | -| `/agents` | `Agents.tsx` | agent metadata/status/history APIs | -| `/sessions` | `Sessions.tsx` | Gateway session list/actions | -| `/chat` | `Chat.tsx` | Gateway sessions, `/ws` runtime events | -| `/jobs` | `Jobs.tsx` | scheduled jobs and run history | -| `/reports` | `Reports.tsx` | reports list/detail APIs | -| `/notifications` | notification bell/modal | notification APIs | -| `/pull-requests` | `PullRequests.tsx` | GitHub/PR backend services | -| `/docker` | `Docker.tsx` | Docker inventory/updater APIs | -| `/files` | `Files.tsx` | workspace file APIs | -| `/logs` | `Logs.tsx` | log file APIs | -| `/database` | `Database.tsx` | Postgres/PgBouncer + Dashboard SQLite | -| `/moltbook` | `Moltbook.tsx` | Moltbook cache/API data | +| Route | Page | Main data source | +| ---------------- | ----------------------- | ------------------------------------------- | +| `/` | `Dashboard.tsx` | health, agents, tasks, jobs, notifications | +| `/tasks` | `Tasks.tsx` | task APIs and TanStack DB collections | +| `/agents` | `Agents.tsx` | agent metadata/status/history APIs | +| `/sessions` | `Sessions.tsx` | Gateway session list/actions | +| `/chat` | `Chat.tsx` | Gateway sessions, `/ws` runtime events | +| `/jobs` | `Jobs.tsx` | scheduled jobs and run history | +| `/reports` | `Reports.tsx` | reports list/detail APIs | +| `/notifications` | notification bell/modal | notification APIs | +| `/pull-requests` | `PullRequests.tsx` | GitHub, PR dev, release/deploy services | +| `/docker` | `Docker.tsx` | Docker inventory/updater APIs | +| `/files` | `Files.tsx` | workspace file APIs | +| `/logs` | `Logs.tsx` | log file APIs | +| `/database` | `Database.tsx` | Postgres/PgBouncer + Dashboard SQLite | +| `/moltbook` | `Moltbook.tsx` | Moltbook cache/API data | | `/settings` | `Settings.tsx` | OpenClaw config and Dashboard security tabs | -| `/terminal` | `Terminal.tsx` | terminal helper APIs | +| `/terminal` | `Terminal.tsx` | terminal helper APIs | ## Data Fetching Expectations @@ -127,7 +127,7 @@ frontend gates: ```bash bun test ./src/test/frontendBehavior.test.tsx --test-name-pattern "" bun run lint:frontend -bun run build +bun run build:frontend bun run format:check git diff --check ``` diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 7092fa2ed..61829591d 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -38,23 +38,23 @@ Mira Dashboard worker | Primary pages: -| Path | Purpose | -| ---------------- | -------------------------------------------------------- | -| `/` | Operations dashboard overview. | -| `/tasks` | Local task board and task updates. | -| `/agents` | Agent status and task history. | -| `/sessions` | OpenClaw session table and actions. | -| `/chat` | Gateway-backed chat UI. | -| `/logs` | Log file browsing/tailing. | -| `/jobs` | Dashboard scheduled jobs. | -| `/reports` | Daily briefs, summaries, heartbeats, and custom reports. | -| `/pull-requests` | Dashboard PR review/deploy operations. | -| `/files` | Workspace file browser/editor. | -| `/docker` | Docker state and managed updater. | -| `/database` | Postgres/PgBouncer and Dashboard SQLite overview. | -| `/moltbook` | Moltbook dashboard. | -| `/settings` | OpenClaw/Dashboard settings. | -| `/terminal` | Terminal helper/completion UI. | +| Path | Purpose | +| ---------------- | ---------------------------------------------------------- | +| `/` | Operations dashboard overview. | +| `/tasks` | Local task board and task updates. | +| `/agents` | Agent status and task history. | +| `/sessions` | OpenClaw session table and actions. | +| `/chat` | Gateway-backed chat UI. | +| `/logs` | Log file browsing/tailing. | +| `/jobs` | Dashboard scheduled jobs. | +| `/reports` | Daily briefs, summaries, heartbeats, and custom reports. | +| `/pull-requests` | PR review, trusted PR dev, releases, deploy, and rollback. | +| `/files` | Workspace file browser/editor. | +| `/docker` | Docker state and managed updater. | +| `/database` | Postgres/PgBouncer and Dashboard SQLite overview. | +| `/moltbook` | Moltbook dashboard. | +| `/settings` | OpenClaw/Dashboard settings. | +| `/terminal` | Terminal helper/completion UI. | ## Backend @@ -165,9 +165,10 @@ Worker startup registers scheduled jobs for: - scheduled job runner. Production uses `MIRA_DASHBOARD_EXECUTION_ROLE=web` and `worker` in separate -systemd services. The default `combined` role exists for compatibility and -tests; local backend development disables it through -`MIRA_DASHBOARD_DISABLE_SCHEDULER=1`. +systemd services. Local development uses the `combined` role with the +`isolated` job profile: scheduler/worker behavior remains testable, while +backup, deploy, Docker, exec, log-rotation, PR, and OpenClaw-restart adapters +are not registered. See [Scheduler, cache, and backups](../operations/scheduler-cache-backups.md) for job tables, cache entries, backup scripts, and inspection commands. diff --git a/docs/development/local-dev.md b/docs/development/local-dev.md index 6a9d998ab..6da2e1220 100644 --- a/docs/development/local-dev.md +++ b/docs/development/local-dev.md @@ -1,95 +1,192 @@ # Local Development +Dashboard has one development mode. It is intentionally close to production: +frontend, backend, scheduler/worker, authentication, files, agents, sessions, +and chat all run through the same application paths. + ## Install -Root/frontend: +From the repository or PR worktree: ```bash -cd /home/ubuntu/projects/mira-dashboard bun install --frozen-lockfile +bun --cwd backend install --frozen-lockfile ``` -Backend: +## Start + +Localhost HTTP is sufficient for local WebAuthn: ```bash -cd /home/ubuntu/projects/mira-dashboard/backend -bun install --frozen-lockfile +bun run dev ``` -## Run +For a stable HTTPS origin and access from another Tailscale device: -Backend dev server: +```bash +bun run dev:remote +``` + +The remote command creates or reuses an exact Tailscale Serve route and prints +the HTTPS URL. On a clean exit it removes a route that it created itself. A +route enabled explicitly remains until it is disabled. Related commands are: ```bash -cd /home/ubuntu/projects/mira-dashboard/backend -bun run dev +bun run dev:remote:status +bun run dev:remote:enable +bun run dev:remote:disable ``` -The backend dev script runs through Doppler and disables the scheduler: +Both start commands run: + +- Bun frontend hot reload on `127.0.0.1:5173`; +- Bun backend restart-on-change on `127.0.0.1:3101`; +- the normal React Compiler and Tailwind development pipeline; +- one isolated Dashboard SQLite database; +- one writable snapshot of the production OpenClaw workspace; +- a combined, isolated scheduler/worker; +- the live production OpenClaw Gateway. + +There is no separate reduced preview mode and no permanent development systemd +unit. + +## Isolated State + +Local state defaults to: ```text -MIRA_DASHBOARD_DISABLE_SCHEDULER=1 doppler run --config prd --project rajohan -- bun --watch src/serverStart.ts +/home/ubuntu/projects/mira-dashboard-dev-state/local/ ``` -Frontend dev server: +It contains: -```bash -cd /home/ubuntu/projects/mira-dashboard -bun run dev +```text +mira-dashboard.db +openclaw-home/workspace/ +openclaw-client/ +releases-root/ ``` -Defaults: +The first start creates WAL-consistent snapshots from the production Dashboard +database, OpenClaw workspace, and managed releases. Later starts reuse them so +changes made while testing remain available. -- frontend: `0.0.0.0:5173` -- backend: `127.0.0.1:3100` -- frontend `/api/*` proxy target: `http://localhost:3100` +The database snapshot removes active sessions and pending logins, WebAuthn +challenges, TOTP/recovery secrets, the persisted Gateway token, deployment/job +runtime state, and chat replay snapshots. Existing Dashboard users, password +hashes, and WebAuthn public credentials remain available. Cache refresh and +SQLite maintenance jobs retain their enabled state and schedule. Backup, +Docker, deploy, workspace-sync, and log-rotation jobs are forced disabled. -Override frontend proxy: +The workspace copy rejects symlinks and excludes Git metadata, credential/secret +directories, private-key names, `.env` files, and token/secret files. Safe +templates such as `.env.example` remain available. The generated +`openclaw.json` contains only sanitized agent configuration and points workspace +paths at the snapshot. + +All state roots are owner-only and untracked. Refresh the snapshots by stopping +dev and running: ```bash -DASHBOARD_API_TARGET=http://127.0.0.1:3100 bun run dev +bun run dev:state:reset +bun run dev:state:prepare ``` -## Build +Reset refuses to remove a directory unless it carries the exact development +ownership marker. -```bash -cd /home/ubuntu/projects/mira-dashboard -bun run build -cd backend -bun run build -``` +## Gateway And Safety Boundary -## Important Local Rules +Dev reads `OPENCLAW_GATEWAY_TOKEN` from Doppler `rajohan/prd` at process start. +`MIRA_DASHBOARD_SESSION_IDLE_MINUTES` and +`MIRA_DASHBOARD_RECENT_AUTH_MINUTES` are selected from the same config and +forwarded unchanged, so login and elevated-auth timing matches production. -- Use Bun; do not add Node/Express runtime fallbacks. -- Backend imports use `.ts` extension. -- React Compiler is enabled; avoid routine `useMemo`/`useCallback`. -- Reuse shared UI components under `src/components/ui`. -- Use shared date/time helpers in `src/utils/date.ts` and `src/utils/format.ts`. -- Do not run backend tests against the live SQLite database. -- Do not commit `dist/`, runtime DB changes, local env files, or token output. +The child backend receives only an explicit environment allowlist. Secret values +do not appear in tracked code, package arguments, logs, snapshots, or browser +responses. -## Worktrees +The shared Gateway makes agents, sessions, chat, and runtime status realistic. +It also means these allowed dev operations affect production Gateway data: -Production checkout must stay on `main`: +- send or abort chat runs; +- change model, thinking, or speed through `sessions.patch`; +- read live Gateway state. -```text -/home/ubuntu/projects/mira-dashboard -``` +Dev blocks Gateway config/cron/destructive-session RPCs and HTTP mutations for +production config, cron, sessions, host operations, backups, Docker, terminal, +exec, PR actions, and restarts. File and Dashboard-record mutations target only +the isolated snapshots. + +The isolated worker registers the scheduler and safe local maintenance/cache +adapters. It does not register Kopia, WAL-G, Docker, deploy, PR, exec, log +rotation, or OpenClaw restart actions. + +Dev cookies use a port-specific namespace. Logging into dev therefore does not +replace the production Dashboard session on the same Tailscale hostname, and +the frontend proxy strips non-dev Dashboard cookies before forwarding requests. + +## Managed PR Dev -Feature/PR work should use: +The Pull requests page exposes one shared **PR dev** slot: + +- only PRs targeting `main` from the configured trusted-author allowlist can + start; +- dependencies install with frozen lockfiles and lifecycle scripts disabled; +- source and Git metadata are read-only inside a Bubblewrap sandbox; +- state is stored under + `/home/ubuntu/projects/mira-dashboard-preview-state/managed/states/pr-/`; +- Tailscale provides HTTPS; +- a transient user unit enforces CPU, IO, memory, task, and four-hour runtime + limits; +- stop removes the owned Tailscale route and materialized Gateway-token file, + while keeping the worktree and isolated state for a faster restart. + +The production backend decrypts its persisted Gateway token only when starting +trusted PR dev. It atomically writes an owner-only `0600` file outside the +repository and mounts that file read-only into the sandbox. The value is never +sent to the browser or included in the unit command. Trusted PR code can read +the token inside its sandbox because direct production-Gateway compatibility +requires it; this is why untrusted authors are rejected rather than given a +partial mode. + +## Overrides + +The stack validates all configured ports, origins, URLs, and paths. Useful +overrides include: ```text -/home/ubuntu/projects/mira-dashboard-worktrees +MIRA_DASHBOARD_DEV_FRONTEND_PORT +MIRA_DASHBOARD_DEV_BACKEND_PORT +MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN +MIRA_DASHBOARD_DEV_STATE_ROOT +MIRA_DASHBOARD_DEV_DB_SOURCE +MIRA_DASHBOARD_DEV_RELEASES_SOURCE +MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE +MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE +MIRA_DASHBOARD_DEV_GATEWAY_URL +MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE ``` -Create a worktree: +Use overrides only with absolute, non-root state/source paths. The ordinary +commands already select the host's production snapshots and runtime Gateway. + +## Verification + +Run commands from the repository root: ```bash -mkdir -p /home/ubuntu/projects/mira-dashboard-worktrees -git -C /home/ubuntu/projects/mira-dashboard fetch --prune origin -git -C /home/ubuntu/projects/mira-dashboard worktree add \ - -b \ - /home/ubuntu/projects/mira-dashboard-worktrees/ \ - main +bun run lint:frontend +bun run lint:backend +bun run build:frontend +bun run build:backend +bun run test:frontend +bun run test:backend +bun run test:frontend:coverage +bun run test:backend:coverage +bun run format:check ``` + +Use Bun, keep backend imports on `.ts`, reuse shared frontend components, and do +not commit generated state, build output, database files, environment files, or +token output. diff --git a/docs/development/testing-and-prs.md b/docs/development/testing-and-prs.md index 763a92dda..db5eda66a 100644 --- a/docs/development/testing-and-prs.md +++ b/docs/development/testing-and-prs.md @@ -7,22 +7,19 @@ Root/frontend: ```bash bun run lint:frontend bun run lint:backend -bun run build -bun run test -bun run test:coverage +bun run build:frontend +bun run build:backend +bun run test:frontend +bun run test:backend +bun run test:frontend:coverage +bun run test:backend:coverage bun run format:check ``` -Backend: - -```bash -cd backend -bun run lint:backend -bun run build -bun run test -bun run test:coverage -bun run format:check -``` +`bun run build`, `bun run test`, and `bun run test:coverage` remain aggregate +shortcuts when both applications are in scope. The explicit names make +single-surface verification unambiguous and can be run from every worktree +without changing directories. Every documentation change, whether docs-only or accompanying code, must run the Markdown formatter check and validate local Markdown links: diff --git a/docs/operations/scheduler-cache-backups.md b/docs/operations/scheduler-cache-backups.md index 1fb397b80..79e4e519f 100644 --- a/docs/operations/scheduler-cache-backups.md +++ b/docs/operations/scheduler-cache-backups.md @@ -3,8 +3,10 @@ Dashboard runs background jobs from `backend/src/workerStart.ts`. Production sets `MIRA_DASHBOARD_EXECUTION_ROLE=web` on the web unit and `MIRA_DASHBOARD_EXECUTION_ROLE=worker` on the worker unit. The backward- -compatible default is `combined`; `MIRA_DASHBOARD_DISABLE_SCHEDULER=1` still -disables the in-process worker during local development. +disables the in-process worker when explicitly requested. Ordinary local +development uses `combined` with `MIRA_DASHBOARD_JOB_PROFILE=isolated`, keeping +the scheduler/worker active without registering host backup, deploy, Docker, +exec, log-rotation, PR, or OpenClaw-restart actions. ## Scheduled Jobs diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md index f37d40634..c90e0d688 100644 --- a/docs/operations/troubleshooting.md +++ b/docs/operations/troubleshooting.md @@ -118,18 +118,16 @@ Run the same local gates before pushing another fix: ```bash bun run lint:frontend bun run lint:backend -bun run build +bun run build:frontend +bun run build:backend bun run format:check git diff --check ``` -Backend: +Backend coverage: ```bash -cd backend -bun run lint:backend -bun run build -bun run test:coverage +bun run test:backend:coverage ``` Remember that Codecov PR comments often discuss patch coverage, not total local diff --git a/docs/setup/production-deploy.md b/docs/setup/production-deploy.md index 92b0e30c7..a49c72ffd 100644 --- a/docs/setup/production-deploy.md +++ b/docs/setup/production-deploy.md @@ -119,6 +119,32 @@ First confirm no deployment or rollback action is running: set -euo pipefail RELEASES_ROOT=/home/ubuntu/projects/mira-dashboard-releases DATABASE_PATH=/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db + +assert_no_active_release_action() { + local active_action + active_action="$( + sqlite3 -batch -noheader "$DATABASE_PATH" " + SELECT action + FROM ( + SELECT 'deployment_lock:' || job_id AS action + FROM deployment_lock + WHERE id = 1 + UNION ALL + SELECT 'job_execution:' || id AS action + FROM job_executions + WHERE action_key IN ('dashboard.deploy', 'dashboard.rollback') + AND status IN ('queued', 'running') + ) + LIMIT 1; + " + )" + if [[ -n "$active_action" ]]; then + echo "Dashboard release action is already active ($active_action); aborting." >&2 + return 1 + fi +} + +assert_no_active_release_action CURRENT_RELEASE="$( readlink --canonicalize-existing "$RELEASES_ROOT/current" )" @@ -160,13 +186,14 @@ ready_for_commit() { return 1 } +assert_no_active_release_action env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ NODE_ENV=production \ bun "$CURRENT_LIFECYCLE" rollback systemctl --user restart mira-dashboard-worker.service mira-dashboard.service if ! ready_for_commit "$TARGET_SHA"; then - echo "Rollback target failed readiness; restoring $CURRENT_SHA" >&2 + echo "Rollback target failed readiness. Restoring $CURRENT_SHA" >&2 env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ NODE_ENV=production \ diff --git a/docs/setup/secrets-and-env.md b/docs/setup/secrets-and-env.md index 6b1f897c1..25e94a20c 100644 --- a/docs/setup/secrets-and-env.md +++ b/docs/setup/secrets-and-env.md @@ -52,17 +52,18 @@ workspace, or media. ## Network, Auth, And Browser Access -| Variable | Required | Default | Purpose | -| --------------------------------------- | --------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | -| `MIRA_DASHBOARD_ALLOWED_ORIGINS` | Production browser access | same-origin/localhost behavior | Comma-separated allowed origins for browser/WebSocket checks. | -| `MIRA_DASHBOARD_AUTOMATION_CREDENTIALS` | Local non-browser callers | none | Strict JSON list of hash-only, minimum-scope automation credentials. There is no loopback auth bypass. | -| `MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY` | Always | none | Base64 that decodes to exactly 32 bytes. External AES-256-GCM key for persisted Gateway token and TOTP seeds; preserve it with backups. | -| `MIRA_DASHBOARD_WEBAUTHN_RP_ID` | Security-key enrollment/use | none | Stable DNS relying-party id, for example `dashboard.example.com`. Raw IP addresses are rejected. | -| `MIRA_DASHBOARD_WEBAUTHN_ORIGINS` | Security-key enrollment/use | none | Explicit comma-separated HTTPS origins belonging to the RP ID. `http://localhost` is allowed for dev only. | -| `MIRA_DASHBOARD_SESSION_IDLE_MINUTES` | Optional | `30` | Idle session lifetime, integer `5`–`1440`. Polling alone does not refresh it. | -| `MIRA_DASHBOARD_RECENT_AUTH_MINUTES` | Optional | `10` | Fresh password/MFA verification window, integer `1`–`60`. | -| `MIRA_DASHBOARD_TRUSTED_PROXY_IPS` | Optional | none | Trusted proxy IPs. Only use if the proxy strips or overwrites untrusted forwarding headers. | -| `OPENCLAW_GATEWAY_URL` | Optional | `ws://127.0.0.1:18789` | Gateway WebSocket URL. | +| Variable | Required | Default | Purpose | +| --------------------------------------- | --------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MIRA_DASHBOARD_ALLOWED_ORIGINS` | Production browser access | same-origin/localhost behavior | Comma-separated allowed origins for browser/WebSocket checks. | +| `MIRA_DASHBOARD_AUTOMATION_CREDENTIALS` | Local non-browser callers | none | Strict JSON list of hash-only, minimum-scope automation credentials. There is no loopback auth bypass. | +| `MIRA_DASHBOARD_SECRET_ENCRYPTION_KEY` | Always | none | Base64 that decodes to exactly 32 bytes. External AES-256-GCM key for persisted Gateway token and TOTP seeds; preserve it with backups. | +| `MIRA_DASHBOARD_COOKIE_NAMESPACE` | Optional | `mira_dashboard` | Prefix for session and pending-login cookies. Dev stacks set a port-specific namespace so login cannot replace the production cookie on the same hostname. | +| `MIRA_DASHBOARD_WEBAUTHN_RP_ID` | Security-key enrollment/use | none | Stable DNS relying-party id, for example `dashboard.example.com`. Raw IP addresses are rejected. | +| `MIRA_DASHBOARD_WEBAUTHN_ORIGINS` | Security-key enrollment/use | none | Explicit comma-separated HTTPS origins belonging to the RP ID. `http://localhost` is allowed for dev only. | +| `MIRA_DASHBOARD_SESSION_IDLE_MINUTES` | Optional | `30` | Idle session lifetime, integer `5`–`1440`. Polling alone does not refresh it. | +| `MIRA_DASHBOARD_RECENT_AUTH_MINUTES` | Optional | `10` | Fresh password/MFA verification window, integer `1`–`60`. | +| `MIRA_DASHBOARD_TRUSTED_PROXY_IPS` | Optional | none | Trusted proxy IPs. Only use if the proxy strips or overwrites untrusted forwarding headers. | +| `OPENCLAW_GATEWAY_URL` | Optional | `ws://127.0.0.1:18789` | Gateway WebSocket URL. | See [Auth and trust boundaries](../security/auth-and-trust-boundaries.md) for route auth, scope names, token generation, two-step login, proxy trust, @@ -145,13 +146,33 @@ The Database page probes Postgres/PgBouncer using these values: | `PGBOUNCER_HOST` | `pgbouncer` | PgBouncer host. | | `PGBOUNCER_PORT` | PgBouncer default | PgBouncer port. | -## Frontend Development - -| Variable | Default | Purpose | -| ---------------------- | ----------------------- | ------------------------------ | -| `HOST` | `0.0.0.0` | Frontend dev server bind host. | -| `PORT` | `5173` for frontend dev | Frontend dev server port. | -| `DASHBOARD_API_TARGET` | `http://localhost:3100` | Dev proxy target for `/api/*`. | +## Development Stack + +`bun run dev` and `bun run dev:remote` select only +`OPENCLAW_GATEWAY_TOKEN`, `MIRA_DASHBOARD_SESSION_IDLE_MINUTES`, and +`MIRA_DASHBOARD_RECENT_AUTH_MINUTES` from Doppler `rajohan/prd`. The explicit +backend child environment forwards the two auth timing values unchanged and +does not inherit other provider or host credentials. + +| Variable | Default | Purpose | +| --------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------- | +| `MIRA_DASHBOARD_DEV_FRONTEND_PORT` | `5173` | Frontend hot-reload port. | +| `MIRA_DASHBOARD_DEV_BACKEND_PORT` | `3101` | Backend restart-on-change port. | +| `MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN` | `http://localhost:5173` | Cookie/WebAuthn origin; remote dev derives the Tailscale HTTPS origin. | +| `MIRA_DASHBOARD_DEV_STATE_ROOT` | `~/projects/mira-dashboard-dev-state/local` | Owner-only isolated development state. | +| `MIRA_DASHBOARD_DEV_DB_SOURCE` | `~/projects/mira-dashboard-state/mira-dashboard.db` | Production database used only to create a scrubbed WAL-consistent snapshot. | +| `MIRA_DASHBOARD_DEV_RELEASES_SOURCE` | `~/projects/mira-dashboard-releases` | Managed releases copied into isolated state. | +| `MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE` | `~/.openclaw/workspace` | Workspace copied with secret and symlink filtering. | +| `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE` | `~/.openclaw/openclaw.json` | Source for sanitized agent-only development config. | +| `MIRA_DASHBOARD_DEV_GATEWAY_URL` | `ws://127.0.0.1:18789` | Live production Gateway used by trusted dev. | +| `MIRA_DASHBOARD_DEV_GATEWAY_TOKEN_FILE` | none | Optional owner-only token file; local commands normally use Doppler environment. | +| `MIRA_DASHBOARD_PREVIEW_GATEWAY_TOKEN_FILE` | `/gateway.token` | Host-local `0600` token materialized by prod backend for trusted PR dev. | +| `MIRA_DASHBOARD_PREVIEW_OPENCLAW_SOURCE_ROOT` | `/home/ubuntu/.openclaw` | Source root for managed PR workspace/config snapshots. | +| `HOST` / `PORT` / `DASHBOARD_API_TARGET` | `127.0.0.1` / `5173` / `http://127.0.0.1:3101` | Child frontend bind and exact backend proxy target. | + +See [Local development](../development/local-dev.md) for snapshot contents, +blocked production mutations, cookie isolation, and the managed trusted-PR +flow. ## CI diff --git a/eslint.config.js b/eslint.config.js index 88c17ee33..318999b97 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -33,7 +33,7 @@ const eslintConfig = defineConfig( "*.log", "*.tsbuildinfo", ".DS_Store", - "backend/**", // CommonJS, separate tooling + "backend/**", // Separate backend tooling ], }, eslintConfigs.configs.recommended, diff --git a/package.json b/package.json index 6cf86fc1a..c666f0f88 100644 --- a/package.json +++ b/package.json @@ -10,19 +10,37 @@ "Safari >= 18.4" ], "scripts": { - "dev": "bun --watch scripts/developmentFrontend.ts", - "build": "bun node_modules/@typescript/native/bin/tsc -b && bun scripts/buildFrontend.ts", - "deploy:prepare": "bun run build && cd backend && bun run deploy:prepare && cd .. && bun run release:manifest", + "dev": "doppler run --project rajohan --config prd --only-secrets OPENCLAW_GATEWAY_TOKEN,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES --no-exit-on-missing-only-secrets -- bun scripts/developmentStack.ts", + "dev:remote": "doppler run --project rajohan --config prd --only-secrets OPENCLAW_GATEWAY_TOKEN,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES --no-exit-on-missing-only-secrets -- bun scripts/developmentTailscale.ts run", + "dev:remote:disable": "bun scripts/developmentTailscale.ts disable", + "dev:remote:enable": "bun scripts/developmentTailscale.ts enable", + "dev:remote:status": "bun scripts/developmentTailscale.ts status", + "dev:state:prepare": "bun scripts/developmentStack.ts --prepare-state", + "dev:state:reset": "bun scripts/developmentStack.ts --reset-state", + "build": "bun run build:frontend && bun run build:backend", + "build:frontend": "bun node_modules/@typescript/native/bin/tsc -b && bun scripts/buildFrontend.ts", + "build:backend": "bun run --cwd backend build:backend", + "deploy:prepare": "bun run build:frontend && bun run --cwd backend deploy:prepare:backend && bun run release:manifest", "release:manifest": "bun scripts/writeReleaseManifest.ts", + "lint": "bun run lint:frontend && bun run lint:backend", "lint:frontend": "eslint . --max-warnings=0", "lint:frontend:fix": "eslint . --fix", - "lint:backend": "cd backend && bun run lint:backend", - "lint:backend:fix": "cd backend && bun run lint:backend:fix", - "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\" \"backend/**/*.{ts,js}\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\" \"backend/**/*.{ts,js}\"", - "preview": "bun scripts/developmentFrontend.ts", - "test": "bun test", - "test:coverage": "bun scripts/runCoverage.ts 85 src/" + "lint:backend": "bun run --cwd backend lint:backend", + "lint:backend:fix": "bun run --cwd backend lint:backend:fix", + "format": "bun run format:frontend && bun run format:backend && bun run format:docs", + "format:check": "bun run format:frontend:check && bun run format:backend:check && bun run format:docs:check", + "format:frontend": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\" \"scripts/**/*.{ts,js}\"", + "format:frontend:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\" \"scripts/**/*.{ts,js}\"", + "format:backend": "bun run --cwd backend format:backend", + "format:backend:check": "bun run --cwd backend format:backend:check", + "format:docs": "prettier --write README.md \"docs/**/*.md\"", + "format:docs:check": "prettier --check README.md \"docs/**/*.md\"", + "test": "bun run test:frontend && bun run test:backend", + "test:coverage": "bun run test:frontend:coverage && bun run test:backend:coverage", + "test:frontend": "bun test", + "test:frontend:coverage": "bun scripts/runCoverage.ts 85 src/", + "test:backend": "bun run --cwd backend test:backend", + "test:backend:coverage": "bun run --cwd backend test:backend:coverage" }, "dependencies": { "@daypicker/react": "10.0.1", diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb1b..000000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/developmentFrontend.ts b/scripts/developmentFrontend.ts index 81fbeca5e..01553a3ba 100644 --- a/scripts/developmentFrontend.ts +++ b/scripts/developmentFrontend.ts @@ -1,12 +1,17 @@ import type { Server } from "bun"; import dashboard from "../index.html"; -import { addForwardedClientHeaders } from "../src/lib/developmentProxyHeaders.ts"; +import { + addForwardedClientHeaders, + developmentCookieHeader, +} from "../src/lib/developmentProxyHeaders.ts"; -const host = process.env.HOST || "0.0.0.0"; +const host = process.env.HOST || "127.0.0.1"; const port = Number(process.env.PORT || "5173"); -const apiTarget = process.env.DASHBOARD_API_TARGET || "http://localhost:3100"; +const apiTarget = process.env.DASHBOARD_API_TARGET || "http://127.0.0.1:3101"; const backendWebSocketTarget = apiTarget.replace(/^http/u, "ws"); +const cookieNamespace = + process.env.MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE || "mira_dashboard_dev_5173"; interface WebSocketProxyData { backend?: WebSocket; @@ -34,6 +39,15 @@ async function proxyApi( const targetUrl = new URL(`${sourceUrl.pathname}${sourceUrl.search}`, apiTarget); const headers = new Headers(request.headers); headers.set("host", targetUrl.host); + const cookie = developmentCookieHeader( + request.headers.get("cookie"), + cookieNamespace + ); + if (cookie) { + headers.set("cookie", cookie); + } else { + headers.delete("cookie"); + } const clientAddress = server.requestIP(request)?.address; addForwardedClientHeaders(headers, clientAddress, sourceUrl.protocol.slice(0, -1)); const forwardedOrigin = forwardedBrowserOrigin(request, targetUrl.origin); @@ -59,7 +73,10 @@ function upgradeWebSocket( server.upgrade(request, { data: { clientAddress: server.requestIP(request)?.address, - cookie: request.headers.get("cookie") || undefined, + cookie: developmentCookieHeader( + request.headers.get("cookie"), + cookieNamespace + ), origin: forwardedBrowserOrigin(request, new URL(apiTarget).origin), pendingMessages: [], protocol: sourceUrl.protocol.slice(0, -1), diff --git a/scripts/developmentStack.ts b/scripts/developmentStack.ts new file mode 100644 index 000000000..ed146ec1a --- /dev/null +++ b/scripts/developmentStack.ts @@ -0,0 +1,60 @@ +import path from "node:path"; + +import { + type DevelopmentStackConfig as StackConfig, + prepareDevelopmentState as prepareState, + resetDevelopmentState as resetState, + resolveDevelopmentStackConfig as resolveDevelopmentStackConfigForRoot, + runDevelopmentStack as runStack, +} from "../backend/src/development/developmentStack.ts"; + +export type { + DevelopmentStackConfig, + DevelopmentStateResult, +} from "../backend/src/development/developmentStack.ts"; +export { + developmentBackendEnvironment, + prepareDevelopmentState, + resetDevelopmentState, + runDevelopmentStack, +} from "../backend/src/development/developmentStack.ts"; + +const repoRoot = path.resolve(import.meta.dir, ".."); + +/** Resolves the development stack for this repository unless a test root is supplied. */ +export function resolveDevelopmentStackConfig( + environment: Record = process.env, + root = repoRoot +): StackConfig { + return resolveDevelopmentStackConfigForRoot(environment, root); +} + +async function main(): Promise { + const config = resolveDevelopmentStackConfig(); + const [command] = Bun.argv.slice(2); + if (command === "--prepare-state") { + const result = prepareState(config); + console.log(JSON.stringify({ ...result, stateRoot: config.stateRoot })); + return 0; + } + if (command === "--reset-state") { + resetState(config); + console.log(`Removed development state: ${config.stateRoot}`); + return 0; + } + if (command) { + throw new TypeError("Usage: developmentStack.ts [--prepare-state|--reset-state]"); + } + return runStack(config); +} + +if (import.meta.main) { + try { + process.exitCode = await main(); + } catch (error) { + console.error( + error instanceof Error ? error.message : "Development stack failed" + ); + process.exitCode = 1; + } +} diff --git a/scripts/developmentTailscale.ts b/scripts/developmentTailscale.ts new file mode 100644 index 000000000..9e3c469f5 --- /dev/null +++ b/scripts/developmentTailscale.ts @@ -0,0 +1,184 @@ +import { + resolveDevelopmentStackConfig, + runDevelopmentStack, +} from "./developmentStack.ts"; + +interface TailscaleStatus { + Self?: { + DNSName?: string; + }; +} + +interface TailscaleServeStatus { + TCP?: Record; + Web?: Record< + string, + { + Handlers?: Record; + } + >; +} + +interface DevelopmentTailscaleStatus { + enabled: boolean; + origin: string; + proxyTarget: string; +} + +async function commandOutput(command: string[]): Promise { + const process_ = Bun.spawn(command, { + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + }); + const [exitCode, stderr, stdout] = await Promise.all([ + process_.exited, + new Response(process_.stderr).text(), + new Response(process_.stdout).text(), + ]); + if (exitCode !== 0) { + throw new Error( + `${command[0]} exited ${exitCode}: ${stderr.trim() || stdout.trim()}` + ); + } + return stdout; +} + +async function commandJson(command: string[]): Promise { + const output = await commandOutput(command); + try { + return JSON.parse(output) as T; + } catch { + throw new Error(`${command[0]} returned invalid JSON`); + } +} + +/** Resolves the stable MagicDNS hostname used as the WebAuthn RP ID. */ +export function tailscaleDnsName(status: TailscaleStatus): string { + const dnsName = status.Self?.DNSName?.trim().replace(/\.$/u, ""); + if (!dnsName || !/^[a-z0-9.-]+$/iu.test(dnsName)) { + throw new Error("Tailscale did not report a stable MagicDNS hostname"); + } + return dnsName.toLowerCase(); +} + +function publicOrigin(dnsName: string, port: number): string { + return `https://${dnsName}:${port}`; +} + +function expectedProxyTarget(port: number): string { + return `http://127.0.0.1:${port}`; +} + +/** Maps Tailscale Serve JSON into one exact development route status. */ +export function developmentServeStatus( + status: TailscaleServeStatus, + dnsName: string, + port: number +): DevelopmentTailscaleStatus { + const origin = publicOrigin(dnsName, port); + const proxyTarget = expectedProxyTarget(port); + const web = status.Web?.[`${dnsName}:${port}`]; + const configuredProxy = web?.Handlers?.["/"]?.Proxy; + const hasHttpsListener = status.TCP?.[String(port)]?.HTTPS === true; + if ( + (configuredProxy || hasHttpsListener) && + (!hasHttpsListener || configuredProxy !== proxyTarget) + ) { + throw new Error( + `Tailscale Serve port ${port} is already configured for another target` + ); + } + return { + enabled: hasHttpsListener && configuredProxy === proxyTarget, + origin, + proxyTarget, + }; +} + +async function currentDevelopmentServeStatus( + port: number +): Promise { + const [status, serveStatus] = await Promise.all([ + commandJson(["tailscale", "status", "--json"]), + commandJson(["tailscale", "serve", "status", "--json"]), + ]); + return developmentServeStatus(serveStatus, tailscaleDnsName(status), port); +} + +async function enableDevelopmentServe( + port: number +): Promise<{ didCreate: boolean; status: DevelopmentTailscaleStatus }> { + const current = await currentDevelopmentServeStatus(port); + if (current.enabled) return { didCreate: false, status: current }; + await commandOutput([ + "sudo", + "-n", + "tailscale", + "serve", + "--bg", + `--https=${port}`, + current.proxyTarget, + ]); + const enabled = await currentDevelopmentServeStatus(port); + if (!enabled.enabled) { + throw new Error(`Tailscale Serve did not activate ${enabled.origin}`); + } + return { didCreate: true, status: enabled }; +} + +async function disableDevelopmentServe( + port: number +): Promise { + const current = await currentDevelopmentServeStatus(port); + if (!current.enabled) return current; + await commandOutput(["sudo", "-n", "tailscale", "serve", `--https=${port}`, "off"]); + return currentDevelopmentServeStatus(port); +} + +async function main(): Promise { + const [command = "run"] = Bun.argv.slice(2); + const initialConfig = resolveDevelopmentStackConfig(); + const port = initialConfig.frontendPort; + if (command === "status") { + console.log(JSON.stringify(await currentDevelopmentServeStatus(port))); + return 0; + } + if (command === "enable") { + const enabled = await enableDevelopmentServe(port); + console.log(JSON.stringify(enabled.status)); + return 0; + } + if (command === "disable") { + console.log(JSON.stringify(await disableDevelopmentServe(port))); + return 0; + } + if (command !== "run") { + throw new TypeError("Usage: developmentTailscale.ts [run|status|enable|disable]"); + } + const route = await enableDevelopmentServe(port); + const config = resolveDevelopmentStackConfig({ + ...process.env, + MIRA_DASHBOARD_DEV_BACKEND_HOST: "127.0.0.1", + MIRA_DASHBOARD_DEV_FRONTEND_HOST: "127.0.0.1", + MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: route.status.origin, + }); + try { + return await runDevelopmentStack(config); + } finally { + if (route.didCreate) { + await disableDevelopmentServe(port); + } + } +} + +if (import.meta.main) { + try { + process.exitCode = await main(); + } catch (error) { + console.error( + error instanceof Error ? error.message : "Tailscale dev preview failed" + ); + process.exitCode = 1; + } +} diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9bb..000000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/features/pullRequests/ProductionReleasesCard.tsx b/src/components/features/pullRequests/ProductionReleasesCard.tsx index f8a4ca7f4..90ead0053 100644 --- a/src/components/features/pullRequests/ProductionReleasesCard.tsx +++ b/src/components/features/pullRequests/ProductionReleasesCard.tsx @@ -40,7 +40,7 @@ function ReleaseSlot({ {release ? ( <>

- Control checkout; deploy syncs latest {baseBranch} first + Control checkout. Deploy syncs latest {baseBranch} first

diff --git a/src/components/features/pullRequests/PullRequestPreviewCard.tsx b/src/components/features/pullRequests/PullRequestPreviewCard.tsx new file mode 100644 index 000000000..4251beb32 --- /dev/null +++ b/src/components/features/pullRequests/PullRequestPreviewCard.tsx @@ -0,0 +1,121 @@ +import { ExternalLink, MonitorPlay } from "lucide-react"; + +import type { PullRequestPreviewStatus } from "../../../hooks"; +import { formatDate } from "../../../utils/format"; +import { Badge } from "../../ui/Badge"; +import { Card, CardTitle } from "../../ui/Card"; + +function previewVariant(status: PullRequestPreviewStatus["status"]) { + switch (status) { + case "running": { + return "success" as const; + } + case "starting": + case "stopping": { + return "warning" as const; + } + case "failed": { + return "error" as const; + } + case "stopped": { + return "default" as const; + } + } +} + +function previewLabel(status: PullRequestPreviewStatus["status"]): string { + switch (status) { + case "running": { + return "Running"; + } + case "starting": { + return "Starting"; + } + case "stopping": { + return "Stopping"; + } + case "failed": { + return "Failed"; + } + case "stopped": { + return "Available"; + } + } +} + +/** Renders the global single-slot trusted PR development status. */ +export function PullRequestPreviewCard({ + error, + preview, +}: { + error?: Error; + preview: PullRequestPreviewStatus | undefined; +}) { + const status = preview?.status ?? "stopped"; + const hasPreview = preview?.number !== undefined; + + return ( + +
+
+ + + PR dev + +

+ One prod-like HTTPS dev slot with hot reload, isolated Dashboard + data, and the live production Gateway. +

+
+
+ + {error ? "Status unavailable" : previewLabel(status)} + +
+
+ + {error ? ( +

{error.message}

+ ) : hasPreview ? ( +
+ ) : ( +
+

Run an eligible trusted PR in dev from its card below.

+

+ Chat and session changes use production Gateway data. Host and + backup actions stay blocked. +

+
+ )} +
+ ); +} diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index a315e7315..ef817946e 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -35,17 +35,14 @@ export function AppHeader({ : "unknown"; const workerStatus = { offline: { - className: "border-red-500/40 bg-red-500/10 text-red-300", label: "Worker offline", symbol: "○", }, ready: { - className: "border-green-500/40 bg-green-500/10 text-green-300", label: "Worker online", symbol: "●", }, unknown: { - className: "border-primary-600 bg-primary-800 text-primary-300", label: "Worker status unavailable", symbol: "?", }, @@ -66,18 +63,18 @@ export function AppHeader({ !hasVersionMismatch; const hasSystemError = !isConnected || !isBackendConnected || workerState === "offline"; - const mobileStatusLabel = isOverallHealthy + const systemStatusLabel = isOverallHealthy ? "all systems online" : hasSystemError ? "one or more systems need attention" : hasVersionMismatch ? "version mismatch" : "status unavailable"; - const mobileStatusClassName = isOverallHealthy - ? "border-green-500/40 bg-green-500/10 text-green-300" + const systemStatusClassName = isOverallHealthy + ? "border-green-500/40 bg-green-500/10 text-green-300 hover:bg-green-500/20" : hasSystemError - ? "border-red-500/40 bg-red-500/10 text-red-300" - : "border-amber-500/40 bg-amber-500/10 text-amber-200"; + ? "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20" + : "border-amber-500/40 bg-amber-500/10 text-amber-200 hover:bg-amber-500/20"; return (
@@ -98,129 +95,7 @@ export function AppHeader({
-
- {hasVersionMismatch && ( - - Version mismatch (FE {frontendCommit} / BE {backendCommit}) - - )} -
- -
-
- - WS - {isConnected ? "●" : "○"} - - - BE - {isBackendConnected ? "●" : "○"} - - - WK - {workerStatus.symbol} - -
+
+ +
diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index 76c5b2e15..55b912c9d 100644 --- a/src/components/ui/Dropdown.tsx +++ b/src/components/ui/Dropdown.tsx @@ -22,6 +22,7 @@ interface DropdownProperties { align?: "left" | "right"; variant?: "primary" | "secondary" | "ghost"; size?: "sm" | "md"; + triggerClassName?: string; } /** Renders the dropdown UI. */ @@ -34,6 +35,7 @@ export function Dropdown({ align = "right", variant = "secondary", size = "sm", + triggerClassName, }: DropdownProperties) { const variantStyles = { primary: "bg-accent-500 text-white hover:bg-accent-600", @@ -56,7 +58,8 @@ export function Dropdown({ "focus:outline-none data-focus:outline-none", "disabled:cursor-not-allowed disabled:opacity-50", variantStyles[variant], - sizeStyles[size] + sizeStyles[size], + triggerClassName )} > {icon} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 6e862f852..4a6a24a50 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -113,6 +113,8 @@ export type { DashboardReleaseSummary, DeploymentJob, ProductionCheckoutStatus, + PullRequestPreviewLifecycle, + PullRequestPreviewStatus, PullRequestSummary, WorktreeCleanupResult, } from "./usePullRequests"; @@ -126,9 +128,12 @@ export { useDeployDashboard, useProductionCheckout, usePullRequestDeployments, + usePullRequestPreview, usePullRequests, useRejectPullRequest, useRollbackDashboard, + useStartPullRequestPreview, + useStopPullRequestPreview, useUpdatePullRequestBranch, } from "./usePullRequests"; export { hasQuotaStatus, useQuotas } from "./useQuotas"; diff --git a/src/hooks/usePullRequests.ts b/src/hooks/usePullRequests.ts index f8f3c763f..5c7b224df 100644 --- a/src/hooks/usePullRequests.ts +++ b/src/hooks/usePullRequests.ts @@ -52,6 +52,7 @@ export interface DashboardReleaseSummary { builtAt: string; commitSha: string; commitTitle: string; + commitUrl: string; schema: { maximumCompatible: number; minimumCompatible: number; @@ -85,6 +86,23 @@ export interface ProductionCheckoutStatus { statusShort?: string; } +export type PullRequestPreviewLifecycle = + "failed" | "running" | "starting" | "stopped" | "stopping"; + +/** Represents the managed single-slot PR preview. */ +export interface PullRequestPreviewStatus { + backendPort?: number; + commitSha?: string; + frontendPort?: number; + message?: string; + number?: number; + startedAt?: string; + status: PullRequestPreviewLifecycle; + title?: string; + updatedAt?: string; + url?: string; +} + /** Represents worktree cleanup result. */ export interface WorktreeCleanupResult { status: "removed" | "skipped" | "warning"; @@ -113,6 +131,12 @@ interface ProductionCheckoutResponse { checkout: ProductionCheckoutStatus; } +/** Represents the managed pull request preview API response. */ +interface PullRequestPreviewResponse { + isOk?: boolean; + preview: PullRequestPreviewStatus; +} + /** Represents the pull request action API response. */ interface PullRequestActionResponse { isOk: boolean; @@ -128,6 +152,7 @@ export const pullRequestKeys = { all: ["pull-requests"] as const, list: () => [...pullRequestKeys.all, "list"] as const, deployments: () => [...pullRequestKeys.all, "deployments"] as const, + preview: () => [...pullRequestKeys.all, "preview"] as const, productionCheckout: () => [...pullRequestKeys.all, "production-checkout"] as const, releaseStatus: () => [...pullRequestKeys.all, "releases"] as const, }; @@ -165,6 +190,14 @@ async function fetchDashboardReleaseStatus(): Promise { return response.release; } +/** Fetches the current managed PR preview slot. */ +async function fetchPullRequestPreview(): Promise { + const response = await apiFetchRequired( + "/pull-requests/preview" + ); + return response.preview; +} + /** Performs approve pull request. */ async function approvePullRequest( number: number, @@ -216,13 +249,33 @@ async function deployDashboard(): Promise<{ isOk: boolean; deployment: Deploymen } /** Queues an atomic rollback to the previous managed release. */ -async function rollbackDashboard(): Promise<{ - isOk: boolean; - deployment: DeploymentJob; -}> { +async function rollbackDashboard( + targetCommit: string +): Promise<{ isOk: boolean; deployment: DeploymentJob }> { return apiPostRequired<{ isOk: boolean; deployment: DeploymentJob }>( - "/pull-requests/releases/rollback" + "/pull-requests/releases/rollback", + { targetCommit } + ); +} + +/** Starts or updates the managed preview slot. */ +async function startPullRequestPreview( + number: number +): Promise { + const response = await apiPostRequired( + `/pull-requests/${number}/preview/start`, + {} + ); + return response.preview; +} + +/** Stops the managed preview slot owned by one PR. */ +async function stopPullRequestPreview(number: number): Promise { + const response = await apiPostRequired( + `/pull-requests/${number}/preview/stop`, + {} ); + return response.preview; } /** Provides pull requests. */ @@ -265,6 +318,16 @@ export function useDashboardReleaseStatus() { }); } +/** Provides the managed single-slot PR preview status. */ +export function usePullRequestPreview() { + return useQuery({ + queryKey: pullRequestKeys.preview(), + queryFn: fetchPullRequestPreview, + staleTime: 2000, + refetchInterval: 5000, + }); +} + /** Provides approve pull request. */ export function useApprovePullRequest() { const queryClient = useQueryClient(); @@ -373,7 +436,8 @@ export function useRollbackDashboard() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: rollbackDashboard, + mutationFn: ({ targetCommit }: { targetCommit: string }) => + rollbackDashboard(targetCommit), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: pullRequestKeys.deployments(), @@ -387,3 +451,33 @@ export function useRollbackDashboard() { }, }); } + +/** Provides managed PR preview startup. */ +export function useStartPullRequestPreview() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ number }: { number: number }) => startPullRequestPreview(number), + onSuccess: (preview) => { + queryClient.setQueryData(pullRequestKeys.preview(), preview); + void queryClient.invalidateQueries({ + queryKey: pullRequestKeys.preview(), + }); + }, + }); +} + +/** Provides managed PR preview shutdown. */ +export function useStopPullRequestPreview() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ number }: { number: number }) => stopPullRequestPreview(number), + onSuccess: (preview) => { + queryClient.setQueryData(pullRequestKeys.preview(), preview); + void queryClient.invalidateQueries({ + queryKey: pullRequestKeys.preview(), + }); + }, + }); +} diff --git a/src/lib/developmentProxyHeaders.ts b/src/lib/developmentProxyHeaders.ts index f4b547987..99927e532 100644 --- a/src/lib/developmentProxyHeaders.ts +++ b/src/lib/developmentProxyHeaders.ts @@ -1,5 +1,19 @@ const UNKNOWN_FORWARDED_CLIENT = "unknown"; +/** Keeps only the isolated dev session cookies before proxying into PR backend code. */ +export function developmentCookieHeader( + cookieHeader: string | null, + namespace: string +): string | undefined { + if (!cookieHeader) return undefined; + const allowedNames = new Set([`${namespace}_pending_login`, `${namespace}_session`]); + const cookies = cookieHeader + .split(";") + .map((cookie) => cookie.trim()) + .filter((cookie) => allowedNames.has(cookie.split("=", 1)[0] || "")); + return cookies.length > 0 ? cookies.join("; ") : undefined; +} + export function addForwardedClientHeaders( headers: Headers, clientAddress: string | undefined, diff --git a/src/pages/PullRequests.tsx b/src/pages/PullRequests.tsx index 6c8e8486c..7f6723d72 100644 --- a/src/pages/PullRequests.tsx +++ b/src/pages/PullRequests.tsx @@ -1,9 +1,12 @@ import { CheckCircle, + ExternalLink, GitBranch, GitMerge, GitPullRequest, + Play, Rocket, + Square, XCircle, } from "lucide-react"; import { type ReactNode, useState } from "react"; @@ -13,6 +16,7 @@ import rehypeSanitize from "rehype-sanitize"; import remarkGfm from "remark-gfm"; import { ProductionReleasesCard } from "../components/features/pullRequests/ProductionReleasesCard"; +import { PullRequestPreviewCard } from "../components/features/pullRequests/PullRequestPreviewCard"; import { Badge } from "../components/ui/Badge"; import { Button } from "../components/ui/Button"; import { Card, CardTitle } from "../components/ui/Card"; @@ -24,6 +28,7 @@ import type { DashboardReleaseSummary, DeploymentJob, ProductionCheckoutStatus, + PullRequestPreviewStatus, PullRequestSummary, WorktreeCleanupResult, } from "../hooks"; @@ -34,9 +39,12 @@ import { useDeployDashboard, useProductionCheckout, usePullRequestDeployments, + usePullRequestPreview, usePullRequests, useRejectPullRequest, useRollbackDashboard, + useStartPullRequestPreview, + useStopPullRequestPreview, useUpdatePullRequestBranch, } from "../hooks"; import { formatDate } from "../utils/format"; @@ -47,13 +55,22 @@ type PendingAction = | { type: "merge"; pr: PullRequestSummary } | { type: "merge-deploy"; pr: PullRequestSummary } | { type: "review-approve"; pr: PullRequestSummary } + | { type: "preview-start"; pr: PullRequestSummary } + | { type: "preview-stop"; pr: PullRequestSummary } | { type: "reject"; pr: PullRequestSummary } | { release: DashboardReleaseSummary; type: "rollback" } | { type: "deploy" }; type PendingActionType = Exclude["type"]; type UnhandledPendingActionType = Exclude< PendingActionType, - "deploy" | "merge" | "merge-deploy" | "reject" | "review-approve" | "rollback" + | "deploy" + | "merge" + | "merge-deploy" + | "preview-start" + | "preview-stop" + | "reject" + | "review-approve" + | "rollback" >; const PENDING_ACTION_SWITCH_IS_EXHAUSTIVE: UnhandledPendingActionType extends never @@ -65,6 +82,12 @@ const MIRA_AUTHOR = "mira-2026"; const DEFAULT_REVIEWER_AUTHOR = "rajohan"; const DEPENDABOT_AUTHOR = "app/dependabot"; const DEFAULT_BASE = "main"; +const PREVIEW_AUTHORS = new Set([MIRA_AUTHOR, DEFAULT_REVIEWER_AUTHOR]); +const ACTIVE_PREVIEW_STATUSES = new Set([ + "running", + "starting", + "stopping", +]); const PASSING_CHECK_VALUES = new Set(["success", "successful", "neutral", "skipped"]); const FAILED_CHECK_VALUES = new Set([ "error", @@ -400,6 +423,12 @@ function actionLabel(action: Exclude) { case "review-approve": { return "Approve PR"; } + case "preview-start": { + return "Run PR in dev"; + } + case "preview-stop": { + return "Stop PR dev"; + } case "reject": { return "Reject PR"; } @@ -424,6 +453,12 @@ function actionMessage(action: Exclude) { case "review-approve": { return `Approve PR #${action.pr.number}: ${action.pr.title}?\n\nThis approves the PR on GitHub. It does not merge or deploy.`; } + case "preview-start": { + return `Run PR #${action.pr.number} in dev: ${action.pr.title}?\n\nThis runs the trusted PR over Tailscale HTTPS with hot reload, an isolated Dashboard database, a writable workspace snapshot, and an isolated scheduler/worker without host or backup jobs. It connects to the live production Gateway so chat and session changes can affect production data. The dev environment stops automatically after four hours.`; + } + case "preview-stop": { + return `Stop PR dev for #${action.pr.number}: ${action.pr.title}?\n\nIts isolated database, workspace snapshot, and worktree are kept for a faster later restart.`; + } case "reject": { return `Reject PR #${action.pr.number}: ${action.pr.title}?\n\nThis closes the PR with a dashboard rejection comment. It does not delete the branch.`; } @@ -609,12 +644,19 @@ export function PullRequests() { useProductionCheckout(); const { data: releaseStatus, error: releaseStatusError } = useDashboardReleaseStatus(); + const { + data: previewStatus, + error: previewStatusError, + isLoading: isPreviewStatusLoading, + } = usePullRequestPreview(); const approvePullRequest = useApprovePullRequest(); const approvePullRequestReview = useApprovePullRequestReview(); const rejectPullRequest = useRejectPullRequest(); const updatePullRequestBranch = useUpdatePullRequestBranch(); const deployDashboard = useDeployDashboard(); const rollbackDashboard = useRollbackDashboard(); + const startPullRequestPreview = useStartPullRequestPreview(); + const stopPullRequestPreview = useStopPullRequestPreview(); const [pendingAction, setPendingAction] = useState(undefined); const [lastResult, setLastResult] = useState(undefined); const [actionError, setActionError] = useState(undefined); @@ -624,7 +666,9 @@ export function PullRequests() { rejectPullRequest.isPending || updatePullRequestBranch.isPending || deployDashboard.isPending || - rollbackDashboard.isPending; + rollbackDashboard.isPending || + startPullRequestPreview.isPending || + stopPullRequestPreview.isPending; const isProductionActionBlocked = !productionCheckout?.isSafeForDeploy; const productionActionBlockedMessage = isProductionActionBlocked ? checkoutMessage(productionCheckout, productionCheckoutError ?? undefined) @@ -670,6 +714,26 @@ export function PullRequests() { return; } + case "preview-start": { + const preview = await startPullRequestPreview.mutateAsync({ + number: action.pr.number, + }); + setLastResult( + preview.url + ? `PR #${action.pr.number} dev is running at ${preview.url}` + : `PR #${action.pr.number} dev started` + ); + break; + } + + case "preview-stop": { + await stopPullRequestPreview.mutateAsync({ + number: action.pr.number, + }); + setLastResult(`PR #${action.pr.number} dev stopped`); + break; + } + case "reject": { const result = await rejectPullRequest.mutateAsync({ number: action.pr.number, @@ -685,7 +749,9 @@ export function PullRequests() { } case "rollback": { - const result = await rollbackDashboard.mutateAsync(); + const result = await rollbackDashboard.mutateAsync({ + targetCommit: action.release.commitSha, + }); setLastResult( result?.deployment?.note ?? `Rollback to ${action.release.commitSha.slice(0, 8)} scheduled` @@ -700,6 +766,93 @@ export function PullRequests() { } } + /** Renders trusted PR dev controls for an eligible pull request. */ + function renderPullRequestPreviewActions(pr: PullRequestSummary) { + const author = pr.author?.login; + if (!author || pr.baseRefName !== DEFAULT_BASE || !PREVIEW_AUTHORS.has(author)) { + return; + } + const isPreviewSlotActive = + previewStatus !== undefined && + ACTIVE_PREVIEW_STATUSES.has(previewStatus.status); + const hasPullRequestPreviewSlot = previewStatus?.number === pr.number; + const isPreviewSlotBusy = isPreviewSlotActive && !hasPullRequestPreviewSlot; + const isPreviewTransitionInProgress = + hasPullRequestPreviewSlot && + (previewStatus.status === "starting" || previewStatus.status === "stopping"); + const isPreviewCommitCurrent = + previewStatus?.commitSha !== undefined && + previewStatus.commitSha === pr.headRefOid; + const hasCurrentDevelopment = + isPreviewSlotActive && hasPullRequestPreviewSlot && isPreviewCommitCurrent; + const canStartDevelopment = !hasCurrentDevelopment; + const isPreviewActionDisabled = + isActionPending || + isPreviewStatusLoading || + Boolean(previewStatusError) || + isPreviewSlotBusy || + isPreviewTransitionInProgress; + let blockedMessage: string | undefined; + if (isPreviewStatusLoading) { + blockedMessage = "Loading PR dev status."; + } else if (previewStatusError) { + blockedMessage = `PR dev status is unavailable: ${previewStatusError.message}`; + } else if (isPreviewSlotBusy) { + blockedMessage = `PR #${previewStatus?.number} currently owns the dev slot. Stop it before starting another PR.`; + } else if (isPreviewTransitionInProgress) { + blockedMessage = "PR dev is currently changing state."; + } + + return ( + <> + {blockedMessage ? ( +

+ {blockedMessage} +

+ ) : undefined} + {hasPullRequestPreviewSlot && + previewStatus.status === "running" && + previewStatus.url ? ( + + + Open dev + + ) : undefined} + {canStartDevelopment ? ( + + ) : undefined} + {hasPullRequestPreviewSlot && previewStatus.status !== "stopped" ? ( + + ) : undefined} + + ); + } + /** Renders merge controls for a pull request. */ function renderPullRequestActions(pr: PullRequestSummary) { const isChecksPassed = hasPullRequestChecksPassed(pr.statusCheckRollup); @@ -784,6 +937,7 @@ export function PullRequests() { : "Update branch"} ) : undefined} + {renderPullRequestPreviewActions(pr)}
@@ -880,6 +1034,11 @@ export function PullRequests() { ) : undefined} + + { + it("forwards only the isolated dev cookies", () => { + expect( + developmentCookieHeader( + [ + "mira_dashboard_session=production", + "mira_dashboard_dev_5173_session=development", + "unrelated=value", + "mira_dashboard_dev_5173_pending_login=pending", + ].join("; "), + "mira_dashboard_dev_5173" + ) + ).toBe( + "mira_dashboard_dev_5173_session=development; mira_dashboard_dev_5173_pending_login=pending" + ); + expect( + developmentCookieHeader( + "mira_dashboard_session=production", + "mira_dashboard_dev_5173" + ) + ).toBeUndefined(); + }); + it("overwrites spoofed identity and fails closed when Bun has no client IP", () => { const headers = new Headers({ "x-forwarded-for": "127.0.0.1", diff --git a/src/test/developmentTailscale.test.ts b/src/test/developmentTailscale.test.ts new file mode 100644 index 000000000..bc9ae8f2d --- /dev/null +++ b/src/test/developmentTailscale.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test"; + +import { + developmentServeStatus, + tailscaleDnsName, +} from "../../scripts/developmentTailscale.ts"; + +describe("development Tailscale helper", () => { + it("normalizes MagicDNS names and recognizes only the exact HTTPS proxy", () => { + const dnsName = tailscaleDnsName({ + Self: { DNSName: "Dashboard.Example.TS.NET." }, + }); + expect(dnsName).toBe("dashboard.example.ts.net"); + expect( + developmentServeStatus( + { + TCP: { "5173": { HTTPS: true } }, + Web: { + "dashboard.example.ts.net:5173": { + Handlers: { + "/": { Proxy: "http://127.0.0.1:5173" }, + }, + }, + }, + }, + dnsName, + 5173 + ) + ).toEqual({ + enabled: true, + origin: "https://dashboard.example.ts.net:5173", + proxyTarget: "http://127.0.0.1:5173", + }); + }); + + it("fails closed for missing DNS identity and conflicting Serve routes", () => { + expect(() => tailscaleDnsName({})).toThrow( + "Tailscale did not report a stable MagicDNS hostname" + ); + expect(() => + developmentServeStatus( + { + TCP: { "5173": { HTTPS: true } }, + Web: { + "dashboard.example.ts.net:5173": { + Handlers: { + "/": { Proxy: "http://127.0.0.1:9999" }, + }, + }, + }, + }, + "dashboard.example.ts.net", + 5173 + ) + ).toThrow("already configured for another target"); + expect(developmentServeStatus({}, "dashboard.example.ts.net", 5173).enabled).toBe( + false + ); + }); +}); diff --git a/src/test/frontendBehavior.test.tsx b/src/test/frontendBehavior.test.tsx index 6c444a7b0..4563e329d 100644 --- a/src/test/frontendBehavior.test.tsx +++ b/src/test/frontendBehavior.test.tsx @@ -145,15 +145,19 @@ import { import { OpenClawSocketProvider, useOpenClawSocket } from "../hooks/useOpenClawSocket"; import { OPS_ACTIONS, useExecJob, useStartOpsAction } from "../hooks/useOpsActions"; import { + pullRequestKeys, useApprovePullRequest, useApprovePullRequestReview, useDashboardReleaseStatus, useDeployDashboard, useProductionCheckout, usePullRequestDeployments, + usePullRequestPreview, usePullRequests, useRejectPullRequest, useRollbackDashboard, + useStartPullRequestPreview, + useStopPullRequestPreview, useUpdatePullRequestBranch, } from "../hooks/usePullRequests"; import { hasQuotaStatus, useQuotas } from "../hooks/useQuotas"; @@ -895,18 +899,19 @@ describe("Mira Dashboard frontend behavior", () => { expect(screen.getByLabelText("1 open pull requests")).toBeInTheDocument(); }); - expect(screen.getByTitle("Backend connected")).toBeInTheDocument(); - expect(screen.getByTitle("Worker online")).toBeInTheDocument(); - expect(screen.getByText("WK")).toBeInTheDocument(); expect(screen.getByText("v2026.6.9")).toBeInTheDocument(); - const mobileStatus = screen.getByRole("button", { + const systemStatus = screen.getByRole("button", { name: /System status: .+\. Open details/u, }); - await userEvent.click(mobileStatus); + expect(screen.queryByText("WK")).not.toBeInTheDocument(); + await userEvent.click(systemStatus); expect(screen.getByText("System status")).toBeInTheDocument(); expect(screen.getByText("WebSocket")).toBeInTheDocument(); - expect(screen.getByText("Backend")).toBeInTheDocument(); + expect(screen.getAllByText("Backend")).toHaveLength(2); expect(screen.getByText("Worker")).toBeInTheDocument(); + expect(screen.getByText("Frontend")).toBeInTheDocument(); + expect(screen.getByText("Version mismatch")).toBeInTheDocument(); + expect(screen.queryByText(/Version mismatch \(FE/u)).not.toBeInTheDocument(); const readyHealth = queryClient.getQueryData(["health"]); expect(readyHealth).toBeDefined(); @@ -924,7 +929,7 @@ describe("Mira Dashboard frontend behavior", () => { }); }); await waitFor(() => { - expect(screen.getByTitle("Worker offline")).toBeInTheDocument(); + expect(screen.getByText(/Worker offline/u)).toBeInTheDocument(); }); const healthQuery = queryClient @@ -939,7 +944,7 @@ describe("Mira Dashboard frontend behavior", () => { }); await waitFor(() => { expect( - screen.getByTitle("Worker status unavailable") + screen.getByText(/Worker status unavailable/u) ).toBeInTheDocument(); }); @@ -3133,6 +3138,7 @@ describe("Mira Dashboard frontend behavior", () => { builtAt: "2026-06-23T08:00:00.000Z", commitSha: "a".repeat(40), commitTitle: "Current release", + commitUrl: `https://github.com/rajohan/Mira-Dashboard/commit/${"a".repeat(40)}`, schema: { maximumCompatible: 31, minimumCompatible: 1, @@ -3143,6 +3149,7 @@ describe("Mira Dashboard frontend behavior", () => { builtAt: "2026-06-22T08:00:00.000Z", commitSha: "b".repeat(40), commitTitle: "Previous release", + commitUrl: `https://github.com/rajohan/Mira-Dashboard/commit/${"b".repeat(40)}`, schema: { maximumCompatible: 31, minimumCompatible: 1, @@ -3154,6 +3161,16 @@ describe("Mira Dashboard frontend behavior", () => { }); } + if (url === "/api/pull-requests/preview" && method === "GET") { + return Response.json({ + preview: { + number: 189, + status: "running", + url: "https://dashboard.test:5173", + }, + }); + } + throw new Error(`Unexpected hook API call: ${method} ${url}`); } ); @@ -3308,6 +3325,14 @@ describe("Mira Dashboard frontend behavior", () => { await waitFor(() => expect(releases.result.current.data?.previous?.commitSha).toBe("b".repeat(40)) ); + + const preview = renderHookWithQueryClient(() => usePullRequestPreview()); + await waitFor(() => + expect(preview.result.current.data).toMatchObject({ + number: 189, + status: "running", + }) + ); }); it("fetches health and metrics through dashboard hooks", async () => { @@ -4621,6 +4646,9 @@ describe("Mira Dashboard frontend behavior", () => { } if (url === "/api/pull-requests/releases/rollback" && method === "POST") { + expect(JSON.parse(String(init?.body))).toEqual({ + targetCommit: "b".repeat(40), + }); return Response.json({ isOk: true, deployment: { @@ -4632,6 +4660,26 @@ describe("Mira Dashboard frontend behavior", () => { }); } + if (url === "/api/pull-requests/189/preview/start" && method === "POST") { + expect(JSON.parse(String(init?.body))).toEqual({}); + return Response.json({ + isOk: true, + preview: { + number: 189, + status: "running", + url: "https://dashboard.test:5173", + }, + }); + } + + if (url === "/api/pull-requests/189/preview/stop" && method === "POST") { + expect(JSON.parse(String(init?.body))).toEqual({}); + return Response.json({ + isOk: true, + preview: { number: 189, status: "stopped" }, + }); + } + throw new Error(`Unexpected pull request API call: ${method} ${url}`); } ); @@ -4675,8 +4723,40 @@ describe("Mira Dashboard frontend behavior", () => { }); const rollback = renderHookWithQueryClient(() => useRollbackDashboard()); - await expect(rollback.result.current.mutateAsync()).resolves.toMatchObject({ - deployment: { id: "rollback-1" }, + await expect( + rollback.result.current.mutateAsync({ + targetCommit: "b".repeat(40), + }) + ).resolves.toMatchObject({ deployment: { id: "rollback-1" } }); + + const startPreview = renderHookWithQueryClient(() => + useStartPullRequestPreview() + ); + await expect( + startPreview.result.current.mutateAsync({ number: 189 }) + ).resolves.toMatchObject({ + number: 189, + status: "running", + }); + expect( + startPreview.queryClient.getQueryData(pullRequestKeys.preview()) + ).toMatchObject({ + number: 189, + status: "running", + }); + + const stopPreview = renderHookWithQueryClient(() => useStopPullRequestPreview()); + await expect( + stopPreview.result.current.mutateAsync({ number: 189 }) + ).resolves.toMatchObject({ + number: 189, + status: "stopped", + }); + expect( + stopPreview.queryClient.getQueryData(pullRequestKeys.preview()) + ).toMatchObject({ + number: 189, + status: "stopped", }); }); diff --git a/src/test/pageBehavior.test.tsx b/src/test/pageBehavior.test.tsx index fbd3f8b50..f051c3c04 100644 --- a/src/test/pageBehavior.test.tsx +++ b/src/test/pageBehavior.test.tsx @@ -1740,6 +1740,7 @@ function apiResponse(url: string, method: string, init?: RequestInit) { builtAt: "2026-06-24T08:00:00.000Z", commitSha: "abc12345".repeat(5), commitTitle: "Current dashboard release", + commitUrl: `https://github.com/rajohan/Mira-Dashboard/commit/${"abc12345".repeat(5)}`, schema: { maximumCompatible: 31, minimumCompatible: 1, @@ -1750,6 +1751,7 @@ function apiResponse(url: string, method: string, init?: RequestInit) { builtAt: "2026-06-23T08:00:00.000Z", commitSha: "def45678".repeat(5), commitTitle: "Previous dashboard release", + commitUrl: `https://github.com/rajohan/Mira-Dashboard/commit/${"def45678".repeat(5)}`, schema: { maximumCompatible: 31, minimumCompatible: 1, @@ -1802,6 +1804,9 @@ function apiResponse(url: string, method: string, init?: RequestInit) { } if (method === "POST" && url === "/api/pull-requests/releases/rollback") { + expect(parseRequestBody(init)).toEqual({ + targetCommit: "def45678".repeat(5), + }); return Response.json({ isOk: true, deployment: { @@ -1814,6 +1819,12 @@ function apiResponse(url: string, method: string, init?: RequestInit) { }); } + if (method === "GET" && url === "/api/pull-requests/preview") { + return Response.json({ + preview: { status: "stopped" }, + }); + } + if (method === "GET" && url === "/api/account/security") { return Response.json({ factors: { diff --git a/src/test/pullRequestPreviewCard.test.tsx b/src/test/pullRequestPreviewCard.test.tsx new file mode 100644 index 000000000..f91170c23 --- /dev/null +++ b/src/test/pullRequestPreviewCard.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "bun:test"; + +import { PullRequestPreviewCard } from "../components/features/pullRequests/PullRequestPreviewCard"; +import type { PullRequestPreviewStatus } from "../hooks"; + +function preview( + status: PullRequestPreviewStatus["status"], + overrides: Partial = {} +): PullRequestPreviewStatus { + return { + commitSha: "a".repeat(40), + number: 335, + status, + title: "Preview design", + updatedAt: "2026-07-26T12:00:00.000Z", + ...overrides, + }; +} + +describe("PullRequestPreviewCard", () => { + it("renders the available and unavailable preview states", () => { + const { rerender } = render(); + + expect(screen.getByText("Available")).toBeInTheDocument(); + expect( + screen.getByText("Run an eligible trusted PR in dev from its card below.") + ).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByText("Status unavailable")).toBeInTheDocument(); + expect(screen.getByText("Preview status failed")).toBeInTheDocument(); + }); + + it("renders every managed lifecycle with bounded preview details", () => { + const { rerender } = render( + + ); + + expect(screen.getByText("Running")).toBeInTheDocument(); + expect(screen.getByText(/PR #335: Preview design/u)).toBeInTheDocument(); + expect(screen.getByText(/aaaaaaaa · Updated/u)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Open dev/u })).toHaveAttribute( + "href", + "https://preview.example:5173" + ); + + rerender(); + expect(screen.getByText("Starting")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /Open dev/u })).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Stopping")).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect(screen.getByText(/Untitled dev environment/u)).toBeInTheDocument(); + expect(screen.getByText("commit pending")).toBeInTheDocument(); + expect(screen.getByText("Preview worker failed")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Available")).toBeInTheDocument(); + }); +}); From 3b5ac6aebc96b4152b230171a3e744fdb40fcfd7 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Sun, 26 Jul 2026 21:14:35 +0200 Subject: [PATCH 03/11] fix: close dev config file race --- .../src/development/developmentOpenClaw.ts | 59 +++++++++++++++---- backend/test/developmentStack.test.ts | 30 ++++++++++ 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/backend/src/development/developmentOpenClaw.ts b/backend/src/development/developmentOpenClaw.ts index e1951ffaa..70abae15f 100644 --- a/backend/src/development/developmentOpenClaw.ts +++ b/backend/src/development/developmentOpenClaw.ts @@ -1,9 +1,13 @@ import { chmodSync, + closeSync, + constants, cpSync, + fstatSync, lstatSync, mkdirSync, - readFileSync, + openSync, + readSync, renameSync, rmSync, writeFileSync, @@ -95,6 +99,48 @@ function defaultAgentsConfig(openClawHome: string) { }; } +function readOpenClawConfigSource(filePath: string): string { + let descriptor: number; + try { + descriptor = openSync( + filePath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + } catch (error) { + throw new Error( + `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE must be a real regular file: ${filePath}`, + { cause: error } + ); + } + + try { + if (!fstatSync(descriptor).isFile()) { + throw new Error( + `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE must be a real regular file: ${filePath}` + ); + } + const content = Buffer.allocUnsafe(MAX_OPENCLAW_CONFIG_BYTES + 1); + let bytesRead = 0; + while (bytesRead < content.length) { + const chunkLength = readSync( + descriptor, + content, + bytesRead, + content.length - bytesRead, + undefined + ); + if (chunkLength === 0) break; + bytesRead += chunkLength; + } + if (bytesRead > MAX_OPENCLAW_CONFIG_BYTES) { + throw new Error("Development OpenClaw config source is too large"); + } + return content.toString("utf8", 0, bytesRead); + } finally { + closeSync(descriptor); + } +} + function sanitizedAgentConfigValue(value: unknown, openClawHome: string): unknown { if (Array.isArray(value)) { return value.map((item) => sanitizedAgentConfigValue(item, openClawHome)); @@ -120,16 +166,7 @@ function snapshotAgentsConfig(config: DevelopmentOpenClawSnapshotConfig): unknow if (!config.configSource) { return defaultAgentsConfig(config.openClawHome); } - if (!isRealRegularFile(config.configSource)) { - throw new Error( - `MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE must be a real regular file: ${config.configSource}` - ); - } - const stat = lstatSync(config.configSource); - if (stat.size > MAX_OPENCLAW_CONFIG_BYTES) { - throw new Error("Development OpenClaw config source is too large"); - } - const parsed = Bun.JSON5.parse(readFileSync(config.configSource, "utf8")) as { + const parsed = Bun.JSON5.parse(readOpenClawConfigSource(config.configSource)) as { agents?: unknown; }; if (!parsed.agents || typeof parsed.agents !== "object") { diff --git a/backend/test/developmentStack.test.ts b/backend/test/developmentStack.test.ts index f2ca817e0..b35a5868f 100644 --- a/backend/test/developmentStack.test.ts +++ b/backend/test/developmentStack.test.ts @@ -14,6 +14,7 @@ import path from "node:path"; import { Database } from "bun:sqlite"; import { describe, expect, it, jest } from "bun:test"; +import { prepareDevelopmentOpenClawSnapshot } from "../src/development/developmentOpenClaw.ts"; import { developmentBackendEnvironment, prepareDevelopmentState, @@ -410,6 +411,35 @@ describe("development stack", () => { } }); + it("reads bounded OpenClaw config through one no-follow descriptor", () => { + const root = temporaryRoot("mira-development-openclaw-config-"); + const configSource = path.join(root, "openclaw.json"); + const linkedConfigSource = path.join(root, "linked-openclaw.json"); + writeFileSync(configSource, JSON.stringify({ agents: {} })); + symlinkSync(configSource, linkedConfigSource); + + try { + expect(() => + prepareDevelopmentOpenClawSnapshot({ + configSource: linkedConfigSource, + openClawHome: path.join(root, "linked-state"), + }) + ).toThrow( + "MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE must be a real regular file" + ); + + writeFileSync(configSource, " ".repeat(2 * 1024 * 1024 + 1)); + expect(() => + prepareDevelopmentOpenClawSnapshot({ + configSource, + openClawHome: path.join(root, "oversized-state"), + }) + ).toThrow("Development OpenClaw config source is too large"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + it("refuses to claim or reset state without the exact checkout marker", () => { const root = temporaryRoot("mira-development-marker-"); const stateRoot = path.join(root, "state"); From 2f1327df74d918384f7fc57aaa000ff327a5b471 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Sun, 26 Jul 2026 21:19:49 +0200 Subject: [PATCH 04/11] fix: harden trusted dev boundaries --- backend/src/development/developmentOpenClaw.ts | 4 ++-- backend/src/development/developmentStack.ts | 6 +++--- backend/src/routes/pullRequestRoutes.ts | 4 ++-- backend/test/developmentStack.test.ts | 4 ++++ backend/test/serviceBehavior.test.ts | 17 +++++++++++++++++ 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/backend/src/development/developmentOpenClaw.ts b/backend/src/development/developmentOpenClaw.ts index 70abae15f..465ebe45c 100644 --- a/backend/src/development/developmentOpenClaw.ts +++ b/backend/src/development/developmentOpenClaw.ts @@ -38,7 +38,7 @@ const SENSITIVE_WORKSPACE_FILE_NAMES = new Set([ "secrets.yml", ]); const SENSITIVE_AGENT_CONFIG_KEY = - /(?:^|[._-])(?:api[._-]?key|credential|credentials|password|secret|secrets|token)(?:$|[._-])/iu; + /(?:^|[._-])(?:api[._-]?keys?|credentials?|passwords?|secrets?|tokens?)(?:$|[._-]|\d)/iu; export type DevelopmentWorkspaceState = "copied" | "empty" | "reused"; @@ -127,7 +127,7 @@ function readOpenClawConfigSource(filePath: string): string { content, bytesRead, content.length - bytesRead, - undefined + bytesRead ); if (chunkLength === 0) break; bytesRead += chunkLength; diff --git a/backend/src/development/developmentStack.ts b/backend/src/development/developmentStack.ts index 49820f11e..4f9bfa1c3 100644 --- a/backend/src/development/developmentStack.ts +++ b/backend/src/development/developmentStack.ts @@ -351,10 +351,10 @@ function runIfTableExists( function scrubDevelopmentDatabase(databasePath: string): void { const database = new Database(databasePath); - database.run("PRAGMA foreign_keys = ON"); - database.run("PRAGMA busy_timeout = 5000"); - database.run("BEGIN IMMEDIATE"); try { + database.run("PRAGMA foreign_keys = ON"); + database.run("PRAGMA busy_timeout = 5000"); + database.run("BEGIN IMMEDIATE"); runIfTableExists( database, "auth_webauthn_challenges", diff --git a/backend/src/routes/pullRequestRoutes.ts b/backend/src/routes/pullRequestRoutes.ts index feef782f3..cf6ca06a1 100644 --- a/backend/src/routes/pullRequestRoutes.ts +++ b/backend/src/routes/pullRequestRoutes.ts @@ -164,8 +164,8 @@ export const pullRequestRoutes = { "/api/pull-requests/releases/rollback": { POST: async (request: Request) => { try { - const body = await readJson<{ targetCommit?: unknown }>(request); - if (typeof body.targetCommit !== "string") { + const body = await readJson<{ targetCommit?: unknown } | null>(request); + if (typeof body?.targetCommit !== "string") { return json( { error: "Rollback target commit is required" }, { status: 400 } diff --git a/backend/test/developmentStack.test.ts b/backend/test/developmentStack.test.ts index b35a5868f..9ed6433d6 100644 --- a/backend/test/developmentStack.test.ts +++ b/backend/test/developmentStack.test.ts @@ -251,8 +251,12 @@ describe("development stack", () => { JSON.stringify({ agents: { defaults: { + apiKeys: ["must-not-copy"], + authToken2: "must-not-copy", gatewayToken: "must-not-copy", model: { primary: "codex" }, + passwords: ["must-not-copy"], + tokens: ["must-not-copy"], workspace: "/production/workspace", }, list: [{ default: true, id: "main" }], diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index 1b754a9b6..53e92568c 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -1717,6 +1717,23 @@ describe("backend service behavior", () => { error: "Rollback target commit is required", }); + const nullBodyResponse = await pullRequestRoutes[ + "/api/pull-requests/releases/rollback" + ].POST( + new Request( + "https://dashboard.test/api/pull-requests/releases/rollback", + { + body: "null", + headers: { "Content-Type": "application/json" }, + method: "POST", + } + ) + ); + expect(nullBodyResponse.status).toBe(400); + await expect(nullBodyResponse.json()).resolves.toMatchObject({ + error: "Rollback target commit is required", + }); + rmSync(path.join(releasesRoot, "previous")); await expect(getDashboardReleaseStatus()).resolves.toMatchObject({ previous: undefined, From 42bbbed0a5d44548cf1d649903191a1b3842894d Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Sun, 26 Jul 2026 23:55:36 +0200 Subject: [PATCH 05/11] fix: harden release and PR dev lifecycle --- .github/dependabot.yml | 174 +++++++-------- .gitignore | 1 + .prettierignore | 6 +- README.md | 10 +- backend/bun.lock | 12 +- backend/config/log-rotation.json | 114 +++++----- backend/eslint.config.js | 8 +- backend/package.json | 9 +- backend/src/development/developmentStack.ts | 46 +++- backend/src/releaseLifecycle.ts | 41 +++- backend/src/releaseManager.ts | 54 ++++- backend/src/requestPolicy.ts | 60 +++--- backend/src/routes/pullRequestRoutes.ts | 11 +- .../src/services/pullRequestPreviewHost.ts | 204 ++++++++++++++---- .../src/services/pullRequestPreviewPolicy.ts | 27 +++ backend/src/services/pullRequestPreviews.ts | 68 +++++- backend/src/services/pullRequests.ts | 116 ++++++---- backend/test/developmentStack.test.ts | 52 +++++ backend/test/pullRequestPreview.test.ts | 107 +++++++-- backend/test/releaseManager.test.ts | 73 ++++++- backend/test/serviceBehavior.test.ts | 9 +- backend/test/utilityBehavior.test.ts | 1 + backend/tsconfig.json | 2 + bun.lock | 30 +-- docs/api/endpoints.md | 2 +- docs/development/local-dev.md | 39 ++-- docs/setup/production-deploy.md | 4 +- docs/setup/secrets-and-env.md | 9 +- eslint.config.js | 1 + package.json | 33 ++- scripts/developmentFrontend.ts | 2 +- scripts/developmentTailscale.ts | 53 ++++- .../pullRequests/PullRequestPreviewCard.tsx | 18 +- src/components/layout/AppHeader.tsx | 3 +- src/hooks/usePullRequests.ts | 1 + src/pages/PullRequests.tsx | 41 +++- src/test/frontendBehavior.test.tsx | 6 +- src/test/pullRequestPreviewCard.test.tsx | 21 +- 38 files changed, 1053 insertions(+), 415 deletions(-) create mode 100644 backend/src/services/pullRequestPreviewPolicy.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d08283679..95eeb2bdb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,92 +1,92 @@ version: 2 updates: - - package-ecosystem: "bun" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "06:00" - timezone: "Europe/Oslo" - open-pull-requests-limit: 5 - labels: - - "type: dependencies" - - "area: frontend" - commit-message: - prefix: "deps" - ignore: - # TypeScript 7 is already used for builds through @typescript/native. - # Keep TypeScript 6 available for typescript-eslint's compiler API. - - dependency-name: "typescript" - update-types: - - "version-update:semver-major" - groups: - frontend-major: - patterns: - - "*" - update-types: - - "major" - frontend-minor-and-patch: - patterns: - - "*" - update-types: - - "minor" - - "patch" + - package-ecosystem: "bun" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Oslo" + open-pull-requests-limit: 5 + labels: + - "type: dependencies" + - "area: frontend" + commit-message: + prefix: "deps" + ignore: + # TypeScript 7 is already used for builds through @typescript/native. + # Keep TypeScript 6 available for typescript-eslint's compiler API. + - dependency-name: "typescript" + update-types: + - "version-update:semver-major" + groups: + frontend-major: + patterns: + - "*" + update-types: + - "major" + frontend-minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" - - package-ecosystem: "bun" - directory: "/backend" - schedule: - interval: "weekly" - day: "monday" - time: "06:15" - timezone: "Europe/Oslo" - open-pull-requests-limit: 5 - labels: - - "type: dependencies" - - "area: backend" - commit-message: - prefix: "deps" - ignore: - # TypeScript 7 is already used for builds through @typescript/native. - # Keep TypeScript 6 available for typescript-eslint's compiler API. - - dependency-name: "typescript" - update-types: - - "version-update:semver-major" - groups: - backend-major: - patterns: - - "*" - update-types: - - "major" - backend-minor-and-patch: - patterns: - - "*" - update-types: - - "minor" - - "patch" + - package-ecosystem: "bun" + directory: "/backend" + schedule: + interval: "weekly" + day: "monday" + time: "06:15" + timezone: "Europe/Oslo" + open-pull-requests-limit: 5 + labels: + - "type: dependencies" + - "area: backend" + commit-message: + prefix: "deps" + ignore: + # TypeScript 7 is already used for builds through @typescript/native. + # Keep TypeScript 6 available for typescript-eslint's compiler API. + - dependency-name: "typescript" + update-types: + - "version-update:semver-major" + groups: + backend-major: + patterns: + - "*" + update-types: + - "major" + backend-minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "06:30" - timezone: "Europe/Oslo" - open-pull-requests-limit: 4 - labels: - - "type: dependencies" - - "area: ci" - commit-message: - prefix: "ci" - groups: - github-actions-major: - patterns: - - "*" - update-types: - - "major" - github-actions-minor-and-patch: - patterns: - - "*" - update-types: - - "minor" - - "patch" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:30" + timezone: "Europe/Oslo" + open-pull-requests-limit: 4 + labels: + - "type: dependencies" + - "area: ci" + commit-message: + prefix: "ci" + groups: + github-actions-major: + patterns: + - "*" + update-types: + - "major" + github-actions-minor-and-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" diff --git a/.gitignore b/.gitignore index 3a01ebc38..c9e6774a3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* .env.local node_modules +.eslintcache dist release-manifest.json backend/data/ diff --git a/.prettierignore b/.prettierignore index c429c3eca..d5b53e113 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,8 @@ +.eslintcache +backend/data +coverage +data dist node_modules *.min.js -*.min.css \ No newline at end of file +*.min.css diff --git a/README.md b/README.md index 0a3c21907..9b4dc3816 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,12 @@ connects to the live OpenClaw Gateway, so chat and session changes can affect production data. Production host, backup, config, cron, destructive session, and PR actions remain blocked. -Only the Gateway token and production auth timing values are selected from -Doppler (`rajohan` / `prd`) at runtime. No secret values are stored in scripts -or tracked files. See [Local development](docs/development/local-dev.md) for -state paths, reset commands, and the trusted PR-dev flow. +Only the Gateway token, production auth timing values, and non-secret WebAuthn +RP ID are selected from Doppler (`rajohan` / `prd`) at runtime. The RP ID is +used to discard incompatible copied WebAuthn credentials for local development. +No secret values are stored in scripts or tracked files. See +[Local development](docs/development/local-dev.md) for state paths, reset +commands, and the trusted PR-dev flow. ## Verification commands diff --git a/backend/bun.lock b/backend/bun.lock index 72c555ec6..dd4e42665 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -13,7 +13,7 @@ "@types/bun": "^1.3.14", "@types/node": "26.1.1", "@typescript/native": "npm:typescript@^7.0.2", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-simple-import-sort": "^14.0.0", @@ -31,7 +31,7 @@ "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], @@ -219,7 +219,7 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="], + "eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": "bin/cli.js" }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], @@ -305,7 +305,7 @@ "mdn-data": ["mdn-data@2.28.1", "", {}, "sha512-U9w+PzSZ00Z5m9rZ5ARVFL5xOfuCHdKYi/1RRwDCJsboFgJDNT3zT6PIPD7mZQYaQLhsZM3GfDRgSMRHhSmVng=="], - "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -405,8 +405,12 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@eslint/config-array/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "bun-types/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "core-js-compat/browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], diff --git a/backend/config/log-rotation.json b/backend/config/log-rotation.json index 8cb563caa..7fce65c4d 100644 --- a/backend/config/log-rotation.json +++ b/backend/config/log-rotation.json @@ -1,63 +1,57 @@ { - "version": 1, - "approvedRoots": [ - "/opt/docker/data" - ], - "excludePaths": [ - "/opt/docker/data/jackett/Jackett/log.txt" - ], - "defaults": { - "shouldCompress": true, - "skipEmpty": true, - "missingOk": true, - "maxSizeMb": 10, - "keep": 3, - "strategy": "copytruncate" - }, - "groups": [ - { - "name": "docker-file-logs", - "enabled": true, - "paths": [ - "/opt/docker/data/*/logs/*.log", - "/opt/docker/data/*/logs/*.txt", - "/opt/docker/data/*/*.log", - "/opt/docker/data/*/log.txt", - "/opt/docker/data/*/*/log.txt" - ], - "daily": true, - "maxSizeMb": 10, - "keep": 3, - "strategy": "copytruncate" + "version": 1, + "approvedRoots": ["/opt/docker/data"], + "excludePaths": ["/opt/docker/data/jackett/Jackett/log.txt"], + "defaults": { + "shouldCompress": true, + "skipEmpty": true, + "missingOk": true, + "maxSizeMb": 10, + "keep": 3, + "strategy": "copytruncate" }, - { - "name": "kopia-generated-logs", - "enabled": true, - "paths": [], - "archiveOnly": true, - "archivePaths": [ - "/opt/docker/data/kopia/logs/*/*.log*" - ], - "archiveRetentionScope": "directory", - "archiveMinAgeMinutes": 60, - "shouldCompress": true, - "keep": 25, - "keepDays": 7 - }, - { - "name": "jackett-generated-logs", - "enabled": true, - "paths": [], - "archiveOnly": true, - "archivePaths": [ - "/opt/docker/data/jackett/Jackett/log.txt.[0-9]*.txt", - "/opt/docker/data/jackett/Jackett/log.txt.[0-9]*.txt.gz" - ], - "archiveRetentionScope": "directory", - "archiveMinAgeMinutes": 60, - "shouldCompress": true, - "keep": 3, - "keepDays": 7 - } - ] + "groups": [ + { + "name": "docker-file-logs", + "enabled": true, + "paths": [ + "/opt/docker/data/*/logs/*.log", + "/opt/docker/data/*/logs/*.txt", + "/opt/docker/data/*/*.log", + "/opt/docker/data/*/log.txt", + "/opt/docker/data/*/*/log.txt" + ], + "daily": true, + "maxSizeMb": 10, + "keep": 3, + "strategy": "copytruncate" + }, + { + "name": "kopia-generated-logs", + "enabled": true, + "paths": [], + "archiveOnly": true, + "archivePaths": ["/opt/docker/data/kopia/logs/*/*.log*"], + "archiveRetentionScope": "directory", + "archiveMinAgeMinutes": 60, + "shouldCompress": true, + "keep": 25, + "keepDays": 7 + }, + { + "name": "jackett-generated-logs", + "enabled": true, + "paths": [], + "archiveOnly": true, + "archivePaths": [ + "/opt/docker/data/jackett/Jackett/log.txt.[0-9]*.txt", + "/opt/docker/data/jackett/Jackett/log.txt.[0-9]*.txt.gz" + ], + "archiveRetentionScope": "directory", + "archiveMinAgeMinutes": 60, + "shouldCompress": true, + "keep": 3, + "keepDays": 7 + } + ] } diff --git a/backend/eslint.config.js b/backend/eslint.config.js index ccbaa0c44..5e341c045 100644 --- a/backend/eslint.config.js +++ b/backend/eslint.config.js @@ -19,7 +19,13 @@ const tsEslintRecommended = tsEslint.configs.recommended.map((config) => ({ const eslintConfig = defineConfig( { - ignores: ["node_modules/**", "coverage/**", "dist/**", "eslint.config.js"], + ignores: [ + "node_modules/**", + "coverage/**", + "data/**", + "dist/**", + "eslint.config.js", + ], }, eslintConfigs.configs.recommended, tsEslintRecommended, diff --git a/backend/package.json b/backend/package.json index 02604459a..ffdcc89a4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,11 +12,10 @@ "auth:reset-password": "MIRA_DASHBOARD_DB_PATH=${MIRA_DASHBOARD_DB_PATH:-/home/ubuntu/projects/mira-dashboard-state/mira-dashboard.db} NODE_ENV=production doppler run --config prd --project rajohan --preserve-env=MIRA_DASHBOARD_DB_PATH -- bun dist/resetDashboardPassword.js", "start:backend": "NODE_ENV=production doppler run --config prd --project rajohan -- bun dist/serverStart.js", "start:worker": "NODE_ENV=production doppler run --config prd --project rajohan -- bun dist/workerStart.js", - "lint:backend": "eslint .", - "lint:backend:fix": "eslint . --fix", - "format:backend": "prettier --write '**/*.{ts,js}'", - "format:backend:check": "prettier --check '**/*.{ts,js}'", + "lint:backend": "eslint . --cache --cache-strategy content", + "lint:backend:fix": "eslint . --cache --cache-strategy content --fix", "test:backend": "bun test", + "test:backend:changed": "bun test --changed", "test:backend:coverage": "bun ../scripts/runCoverage.ts 85 src/" }, "dependencies": { @@ -28,7 +27,7 @@ "@types/bun": "^1.3.14", "@types/node": "26.1.1", "@typescript/native": "npm:typescript@^7.0.2", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-simple-import-sort": "^14.0.0", diff --git a/backend/src/development/developmentStack.ts b/backend/src/development/developmentStack.ts index 4f9bfa1c3..3839a241a 100644 --- a/backend/src/development/developmentStack.ts +++ b/backend/src/development/developmentStack.ts @@ -29,6 +29,8 @@ const DEVELOPMENT_STATE_MARKER = ".mira-dashboard-development-state.json"; const DEVELOPMENT_SECRET_FILE = ".secret-encryption-key"; const RELEASE_SHA_PATTERN = /^[\da-f]{40}$/u; const HOST_PATTERN = /^(?:localhost|[\da-f:.]+|[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)$/iu; +const RP_ID_PATTERN = + /^(?:localhost|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*)$/u; const DEFAULT_FRONTEND_PORT = 5173; const DEFAULT_BACKEND_PORT = 3101; const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; @@ -69,6 +71,7 @@ export interface DevelopmentStackConfig { repositoryRoot: string; rpId: string; secretEncryptionKeyPath: string; + sourceWebAuthnRpId?: string; stateOwner: string; stateRoot: string; workspaceSource?: string; @@ -198,6 +201,18 @@ function normalizedGatewayUrl(value: string | undefined): string | undefined { return gatewayUrl.href; } +function normalizedOptionalRpId( + name: string, + value: string | undefined +): string | undefined { + const rpId = value?.trim().toLowerCase(); + if (!rpId) return undefined; + if (rpId.length > 253 || !RP_ID_PATTERN.test(rpId) || isIP(rpId)) { + throw new TypeError(`${name} must be a stable DNS relying-party id`); + } + return rpId; +} + /** Resolves one isolated frontend/backend development stack. */ export function resolveDevelopmentStackConfig( environment: Record, @@ -289,6 +304,11 @@ export function resolveDevelopmentStackConfig( repositoryRoot: resolvedRepoRoot, rpId: publicOrigin.hostname.toLowerCase(), secretEncryptionKeyPath: path.join(stateRoot, DEVELOPMENT_SECRET_FILE), + sourceWebAuthnRpId: normalizedOptionalRpId( + "MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID", + environment.MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID || + environment.MIRA_DASHBOARD_WEBAUTHN_RP_ID + ), stateOwner: configuredStateOwner( environment.MIRA_DASHBOARD_DEV_STATE_OWNER, "local-dashboard-dev" @@ -349,7 +369,10 @@ function runIfTableExists( } } -function scrubDevelopmentDatabase(databasePath: string): void { +function scrubDevelopmentDatabase( + databasePath: string, + shouldPreserveWebAuthnCredentials: boolean +): void { const database = new Database(databasePath); try { database.run("PRAGMA foreign_keys = ON"); @@ -372,6 +395,13 @@ function scrubDevelopmentDatabase(databasePath: string): void { "user_recovery_codes", "DELETE FROM user_recovery_codes" ); + if (!shouldPreserveWebAuthnCredentials) { + runIfTableExists( + database, + "user_webauthn_credentials", + "DELETE FROM user_webauthn_credentials" + ); + } if ( hasTable(database, "users") && hasTable(database, "user_webauthn_credentials") @@ -439,7 +469,11 @@ function scrubDevelopmentDatabase(databasePath: string): void { } } -function createDevelopmentDatabaseSnapshot(sourcePath: string, targetPath: string): void { +function createDevelopmentDatabaseSnapshot( + sourcePath: string, + targetPath: string, + shouldPreserveWebAuthnCredentials: boolean +): void { if (!isRealRegularFile(sourcePath)) { throw new Error( `MIRA_DASHBOARD_DEV_DB_SOURCE must be a real regular file: ${sourcePath}` @@ -458,7 +492,7 @@ function createDevelopmentDatabaseSnapshot(sourcePath: string, targetPath: strin source.close(); } chmodSync(stagingPath, 0o600); - scrubDevelopmentDatabase(stagingPath); + scrubDevelopmentDatabase(stagingPath, shouldPreserveWebAuthnCredentials); renameSync(stagingPath, targetPath); } catch (error) { rmSync(stagingPath, { force: true }); @@ -660,7 +694,11 @@ export function prepareDevelopmentState( } database = "reused"; } else if (config.databaseSource) { - createDevelopmentDatabaseSnapshot(config.databaseSource, config.databasePath); + createDevelopmentDatabaseSnapshot( + config.databaseSource, + config.databasePath, + config.sourceWebAuthnRpId === config.rpId + ); database = "snapshot-created"; } else { database = "created-empty"; diff --git a/backend/src/releaseLifecycle.ts b/backend/src/releaseLifecycle.ts index 17d2d9239..622bccb41 100644 --- a/backend/src/releaseLifecycle.ts +++ b/backend/src/releaseLifecycle.ts @@ -35,14 +35,12 @@ export async function runReleaseLifecycleCommand( releasesRoot = resolveDashboardReleasesRoot(), options: DashboardReleaseManagerOptions = {} ) { - const [command, commitSha, ...extra] = arguments_; + const [command, ...commandArguments] = arguments_; + const [commitSha, ...extra] = commandArguments; const isCoordinatedSchemaCutover = command === "activate" && extra.length === 1 && extra[0] === COORDINATED_SCHEMA_CUTOVER_FLAG; - if (!isCoordinatedSchemaCutover && extra.length > 0) { - throw new TypeError("Release lifecycle command received unexpected arguments"); - } let state: DashboardReleaseState; const transitionOptions: DashboardReleaseManagerOptions = { @@ -52,6 +50,11 @@ export async function runReleaseLifecycleCommand( }; switch (command) { case "activate": { + if (!isCoordinatedSchemaCutover && extra.length > 0) { + throw new TypeError( + "Release lifecycle command received unexpected arguments" + ); + } if (!commitSha) { throw new TypeError("Release lifecycle activate requires a commit SHA"); } @@ -64,26 +67,44 @@ export async function runReleaseLifecycleCommand( break; } case "rollback": { - if (commitSha !== undefined) { - throw new TypeError("Release lifecycle rollback takes no commit SHA"); + const [expectedCurrentCommitSha, expectedTargetCommitSha] = commandArguments; + if ( + !expectedCurrentCommitSha || + !expectedTargetCommitSha || + commandArguments.length !== 2 + ) { + throw new TypeError( + "Release lifecycle rollback requires expected current and target commit SHAs" + ); } - state = await rollbackDashboardRelease(releasesRoot, transitionOptions); + state = await rollbackDashboardRelease(releasesRoot, { + ...transitionOptions, + expected: { + currentCommitSha: expectedCurrentCommitSha, + targetCommitSha: expectedTargetCommitSha, + }, + }); break; } case "status": { - if (commitSha !== undefined) { + if (commandArguments.length > 0) { throw new TypeError("Release lifecycle status takes no commit SHA"); } - state = await readDashboardReleaseState(releasesRoot); + state = await readDashboardReleaseState(releasesRoot, transitionOptions); break; } case "prune": { + if (extra.length > 0) { + throw new TypeError( + "Release lifecycle command received unexpected arguments" + ); + } const retainCount = commitSha === undefined ? 3 : Number(commitSha); return pruneDashboardReleases(retainCount, releasesRoot); } default: { throw new TypeError( - "Usage: releaseLifecycle.js " + "Usage: releaseLifecycle.js " ); } } diff --git a/backend/src/releaseManager.ts b/backend/src/releaseManager.ts index bc840d314..47d506a55 100644 --- a/backend/src/releaseManager.ts +++ b/backend/src/releaseManager.ts @@ -76,6 +76,15 @@ export interface DashboardReleaseManagerOptions { transitionLockWaitMs?: number; } +export interface DashboardReleaseRollbackExpectation { + currentCommitSha: string; + targetCommitSha: string; +} + +export interface DashboardReleaseRollbackOptions extends DashboardReleaseManagerOptions { + expected?: DashboardReleaseRollbackExpectation; +} + export interface DashboardReleasePublicationOptions { onTransitionLockContention?: () => void; } @@ -1195,17 +1204,23 @@ async function executeReleaseTransition( } export async function readDashboardReleaseState( - releasesRoot = resolveDashboardReleasesRoot() + releasesRoot = resolveDashboardReleasesRoot(), + options: Pick = {} ): Promise { const layout = await ensureDashboardReleaseLayout(releasesRoot); - return withReleaseTransitionLock(layout, "shared", async () => { - if (await readReleaseTransitionJournal(layout)) { - throw new Error( - "Managed release status requires activate or rollback to recover an interrupted transition" - ); - } - return readDashboardReleaseStateFromLayout(layout); - }); + return withReleaseTransitionLock( + layout, + "shared", + async () => { + if (await readReleaseTransitionJournal(layout)) { + throw new Error( + "Managed release status requires activate or rollback to recover an interrupted transition" + ); + } + return readDashboardReleaseStateFromLayout(layout); + }, + options.transitionLockWaitMs + ); } export async function activateDashboardRelease( @@ -1297,8 +1312,18 @@ export async function activateDashboardRelease( export async function rollbackDashboardRelease( releasesRoot = resolveDashboardReleasesRoot(), - options: DashboardReleaseManagerOptions = {} + options: DashboardReleaseRollbackOptions = {} ): Promise { + const expectation = options.expected; + if (expectation) { + assertReleaseCommitSha(expectation.currentCommitSha); + assertReleaseCommitSha(expectation.targetCommitSha); + if (expectation.currentCommitSha === expectation.targetCommitSha) { + throw new TypeError( + "Managed release rollback expectation requires distinct releases" + ); + } + } const layout = await ensureDashboardReleaseLayout(releasesRoot); return withReleaseTransitionLock( layout, @@ -1314,6 +1339,15 @@ export async function rollbackDashboardRelease( "Managed release rollback requires two distinct releases" ); } + if ( + expectation && + (state.current.commitSha !== expectation.currentCommitSha || + state.previous.commitSha !== expectation.targetCommitSha) + ) { + throw new Error( + "Managed release rollback slots changed before the guarded transition" + ); + } const activeRelease = state.current; const rollbackRelease = state.previous; diff --git a/backend/src/requestPolicy.ts b/backend/src/requestPolicy.ts index 967a5d258..0bb955d1a 100644 --- a/backend/src/requestPolicy.ts +++ b/backend/src/requestPolicy.ts @@ -417,6 +417,30 @@ function didWriteRequestAudit( } } +function auditedForbiddenResponse( + actor: AuditActor, + request: Request, + requestId: string, + routePath: string, + automationScope: AutomationScope | undefined, + payload: Record, + persistAuditEvent: typeof writeAuditEvent +): Response { + const didRecordDenial = didWriteRequestAudit( + actor, + "denied", + request, + requestId, + routePath, + 403, + automationScope, + persistAuditEvent + ); + return didRecordDenial + ? json(payload, { status: 403 }) + : json({ error: "Audit trail unavailable" }, { status: 503 }); +} + function secureHandler( routePath: string, handler: BunHandler | Response, @@ -460,22 +484,14 @@ function secureHandler( automationPrincipal && (!automationScope || !automationPrincipal.scopes.has(automationScope)) ) { - const didRecordDenial = didWriteRequestAudit( + return auditedForbiddenResponse( requestActor(undefined, automationPrincipal), - "denied", request, requestIdentifier, routePath, - 403, automationScope, - persistAuditEvent - ); - if (!didRecordDenial) { - return json({ error: "Audit trail unavailable" }, { status: 503 }); - } - return json( { error: "Automation credential scope denied" }, - { status: 403 } + persistAuditEvent ); } const isAuditedMutationRequest = isAuditedMutation( @@ -497,24 +513,16 @@ function secureHandler( : undefined; const actor = requestActor(user, automationPrincipal); if (isApi && isDevelopmentHostMutationBlocked(request)) { - const didRecordDenial = didWriteRequestAudit( + return auditedForbiddenResponse( actor, - "denied", request, requestIdentifier, routePath, - 403, automationScope, - persistAuditEvent - ); - if (!didRecordDenial) { - return json({ error: "Audit trail unavailable" }, { status: 503 }); - } - return json( { error: "Host-control actions are disabled in Dashboard dev", }, - { status: 403 } + persistAuditEvent ); } const isPrivilegedRequest = @@ -524,20 +532,12 @@ function secureHandler( session && (!session.mfaEnabled || !hasRecentMfaVerification(session)) ) { - const didRecordDenial = didWriteRequestAudit( + return auditedForbiddenResponse( actor, - "denied", request, requestIdentifier, routePath, - 403, automationScope, - persistAuditEvent - ); - if (!didRecordDenial) { - return json({ error: "Audit trail unavailable" }, { status: 503 }); - } - return json( { code: session.mfaEnabled ? "step_up_required" @@ -546,7 +546,7 @@ function secureHandler( ? "Recent MFA verification is required" : "Multi-factor authentication must be enabled", }, - { status: 403 } + persistAuditEvent ); } const isMutation = isAuditedMutationRequest || isPrivilegedRequest; diff --git a/backend/src/routes/pullRequestRoutes.ts b/backend/src/routes/pullRequestRoutes.ts index cf6ca06a1..48eaecbf4 100644 --- a/backend/src/routes/pullRequestRoutes.ts +++ b/backend/src/routes/pullRequestRoutes.ts @@ -108,10 +108,13 @@ export const pullRequestRoutes = { const number = parsePullRequestNumber(request.params.number); if (number instanceof Response) return number; try { - return json({ - isOk: true, - preview: await prepareAndStartPullRequestPreview(number), - }); + return json( + { + isOk: true, + preview: await prepareAndStartPullRequestPreview(number), + }, + { status: 202 } + ); } catch (error) { return routeError(error, "PR preview startup failed"); } diff --git a/backend/src/services/pullRequestPreviewHost.ts b/backend/src/services/pullRequestPreviewHost.ts index a2656b770..14ce06a32 100644 --- a/backend/src/services/pullRequestPreviewHost.ts +++ b/backend/src/services/pullRequestPreviewHost.ts @@ -18,6 +18,10 @@ import { } from "../development/developmentStack.ts"; import { errorMessage } from "../lib/errors.ts"; import { runProcess } from "../lib/processes.ts"; +import { + isPullRequestPreviewAuthorAllowed, + resolvePullRequestPreviewAllowedAuthors, +} from "./pullRequestPreviewPolicy.ts"; const PREVIEW_UNIT = "mira-dashboard-pr-preview.service"; const PREVIEW_RECORD_FILE = "active-preview.json"; @@ -78,6 +82,7 @@ export interface PullRequestPreviewConfig { recentAuthMinutes?: string; releaseSource?: string; sessionIdleMinutes?: string; + sourceWebAuthnRpId?: string; stateFile: string; unitName: string; workspaceSource?: string; @@ -122,6 +127,11 @@ interface TailscaleServeStatus { >; } +interface PreviewTailscaleRoute { + enabled: boolean; + url: string; +} + interface CommandOptions { cwd?: string; env?: Record; @@ -255,17 +265,9 @@ export function resolvePullRequestPreviewConfig( "MIRA_DASHBOARD_PREVIEW_UNIT must be a valid .service unit name" ); } - const allowedAuthors = new Set( - (environment.MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS || "mira-2026,rajohan") - .split(",") - .map((author) => author.trim().toLowerCase()) - .filter(Boolean) + const allowedAuthors = resolvePullRequestPreviewAllowedAuthors( + environment.MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS ); - if (allowedAuthors.size === 0) { - throw new TypeError( - "MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS must contain at least one author" - ); - } const openClawSourceRoot = optionalAbsoluteNonRootPath( "MIRA_DASHBOARD_PREVIEW_OPENCLAW_SOURCE_ROOT", environment.MIRA_DASHBOARD_PREVIEW_OPENCLAW_SOURCE_ROOT?.trim() || @@ -311,6 +313,10 @@ export function resolvePullRequestPreviewConfig( "MIRA_DASHBOARD_SESSION_IDLE_MINUTES", environment.MIRA_DASHBOARD_SESSION_IDLE_MINUTES ), + sourceWebAuthnRpId: optionalEnvironmentValue( + "MIRA_DASHBOARD_WEBAUTHN_RP_ID", + environment.MIRA_DASHBOARD_WEBAUTHN_RP_ID + ), stateFile: path.join(previewRoot, PREVIEW_RECORD_FILE), unitName, workspaceSource: openClawSourceRoot @@ -427,17 +433,29 @@ function readPreviewRecord( if (!isRealRegularFile(config.stateFile)) { throw new Error("Dashboard preview state must be a real regular file"); } - const content = readFileSync(config.stateFile, "utf8"); - if (Buffer.byteLength(content) > 256 * 1024) { - throw new Error("Dashboard preview state is too large"); - } try { + const content = readFileSync(config.stateFile, "utf8"); + if (Buffer.byteLength(content) > 256 * 1024) { + throw new Error("Dashboard preview state is too large"); + } return previewRecordFromJson(JSON.parse(content) as unknown); } catch (error) { - throw new Error( - `Dashboard preview state is invalid: ${errorMessage(error, "invalid state")}`, - { cause: error } + const quarantinePath = path.join( + config.previewRoot, + `active-preview.corrupt-${Date.now()}-${Bun.randomUUIDv7()}.json` ); + try { + renameSync(config.stateFile, quarantinePath); + chmodSync(quarantinePath, 0o600); + console.error( + `[PullRequestPreview] Quarantined invalid state at ${quarantinePath}: ${errorMessage(error, "invalid state")}` + ); + } catch (quarantineError) { + console.error( + `[PullRequestPreview] Invalid state could not be quarantined: ${errorMessage(error, "invalid state")}. ${errorMessage(quarantineError, "quarantine failed")}` + ); + } + return undefined; } } @@ -661,10 +679,10 @@ function tailscaleDnsName(status: TailscaleStatus): string { return dnsName.toLowerCase(); } -async function ensureTailscaleServe( +async function inspectTailscaleServe( config: PullRequestPreviewConfig, signal?: AbortSignal -): Promise<{ created: boolean; url: string }> { +): Promise { const [status, serveStatus] = await Promise.all([ runJsonCommand("tailscale", ["status", "--json"], { signal, @@ -688,19 +706,60 @@ async function ensureTailscaleServe( { statusCode: 409 } ); } - if (!hasHttpsListener) { - await runCommand( - "sudo", - ["-n", "tailscale", "serve", "--bg", `--https=${port}`, proxyTarget], - { signal } - ); - } return { - created: !hasHttpsListener, + enabled: hasHttpsListener, url: `https://${dnsName}:${port}`, }; } +async function enableTailscaleServe( + config: PullRequestPreviewConfig, + expectedUrl: string, + signal?: AbortSignal +): Promise { + const current = await inspectTailscaleServe(config, signal); + if (current.url !== expectedUrl) { + throw new Error("Tailscale MagicDNS hostname changed during preview startup"); + } + if (current.enabled) { + throw Object.assign( + new Error( + `Tailscale Serve port ${config.frontendPort} became active during preview startup` + ), + { statusCode: 409 } + ); + } + await runCommand( + "sudo", + [ + "-n", + "tailscale", + "serve", + "--bg", + `--https=${config.frontendPort}`, + `http://127.0.0.1:${config.frontendPort}`, + ], + { signal } + ); + try { + const enabled = await inspectTailscaleServe(config, signal); + if (!enabled.enabled || enabled.url !== expectedUrl) { + throw new Error("Tailscale Serve did not expose the ready preview service"); + } + } catch (error) { + try { + await disableOwnedTailscaleServe(config, true); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Tailscale Serve activation failed and its route could not be removed", + { cause: cleanupError } + ); + } + throw error; + } +} + async function disableOwnedTailscaleServe( config: PullRequestPreviewConfig, isOwned: boolean @@ -759,6 +818,9 @@ async function preparePreviewState( MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: publicOrigin, MIRA_DASHBOARD_DEV_STATE_OWNER: `managed-pr-${number}`, MIRA_DASHBOARD_DEV_STATE_ROOT: stateRoot, + ...(config.sourceWebAuthnRpId && { + MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID: config.sourceWebAuthnRpId, + }), ...(config.openClawConfigSource && { MIRA_DASHBOARD_DEV_OPENCLAW_CONFIG_SOURCE: config.openClawConfigSource, }), @@ -1041,13 +1103,50 @@ function publicPreviewStatus( }; } -/** Reads the active single-slot PR preview without mutating host state. */ +/** Reads the active preview and reconciles resources left by a stopped unit. */ export async function getPullRequestPreviewStatus( config = resolvePullRequestPreviewConfig() ): Promise { const record = readPreviewRecord(config); if (!record) return { status: "stopped" }; - return publicPreviewStatus(record, await previewUnitState(config)); + const unitState = await previewUnitState(config); + if ( + unitState && + record.status === "running" && + ["failed", "inactive"].includes(unitState.activeState || "") + ) { + const cleanupErrors: string[] = []; + let isTailscaleServeOwned = record.ownsTailscaleServe; + try { + removeMaterializedGatewayToken(config); + } catch (error) { + cleanupErrors.push(errorMessage(error, "token cleanup failed")); + } + try { + await disableOwnedTailscaleServe(config, isTailscaleServeOwned); + isTailscaleServeOwned = false; + } catch (error) { + cleanupErrors.push(errorMessage(error, "Serve cleanup failed")); + } + const reconciledRecord: PullRequestPreviewRecord = { + ...record, + ...(cleanupErrors.length > 0 && { + message: `Preview stopped outside the managed workflow. Cleanup: ${cleanupErrors.join(". ")}`, + }), + ownsTailscaleServe: isTailscaleServeOwned, + status: + cleanupErrors.length > 0 + ? "failed" + : lifecycleFromUnit(unitState, record.status), + updatedAt: new Date().toISOString(), + }; + writePreviewRecord(config, reconciledRecord); + return publicPreviewStatus( + reconciledRecord, + cleanupErrors.length > 0 ? undefined : unitState + ); + } + return publicPreviewStatus(record, unitState); } async function stopUnit(config: PullRequestPreviewConfig): Promise { @@ -1071,20 +1170,20 @@ async function waitForPreviewReady( if (signal?.aborted) { throw new DOMException("Preview startup aborted", "AbortError"); } + const state = await previewUnitState(config); + if (state && ["failed", "inactive"].includes(state.activeState || "")) { + throw new Error( + `Preview service stopped during startup (${state.result || state.activeState})` + ); + } try { const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2000), }); - if (response.ok) return; + if (response.ok && state?.activeState === "active") return; } catch { // The watched frontend/backend pair is still starting. } - const state = await previewUnitState(config); - if (state && ["failed", "inactive"].includes(state.activeState || "")) { - throw new Error( - `Preview service stopped during startup (${state.result || state.activeState})` - ); - } await Bun.sleep(PREVIEW_READY_POLL_MS); } throw Object.assign(new Error("Timed out waiting for PR preview readiness"), { @@ -1110,8 +1209,7 @@ function validatePreviewPullRequest( ); } if ( - !pullRequest.authorLogin || - !config.allowedAuthors.has(pullRequest.authorLogin.toLowerCase()) + !isPullRequestPreviewAuthorAllowed(pullRequest.authorLogin, config.allowedAuthors) ) { throw Object.assign( new Error("Pull request author is not allowed to run host previews"), @@ -1146,7 +1244,6 @@ export async function startPullRequestPreview( const pullRequest = validatePreviewPullRequest(candidate, config); const { number } = pullRequest; ensureRealDirectory(config.previewRoot); - const existingRecord = readPreviewRecord(config); const current = await getPullRequestPreviewStatus(config); if ( ["running", "starting", "stopping"].includes(current.status) && @@ -1166,11 +1263,23 @@ export async function startPullRequestPreview( ) { return current; } + const existingRecord = readPreviewRecord(config); const timestamp = new Date().toISOString(); - const tailscaleServe = await ensureTailscaleServe(config, signal); - const publicOrigin = tailscaleServe.url; - const ownsTailscaleServe = - tailscaleServe.created || existingRecord?.ownsTailscaleServe === true; + const tailscaleRoute = await inspectTailscaleServe(config, signal); + if (tailscaleRoute.enabled && existingRecord?.ownsTailscaleServe !== true) { + throw Object.assign( + new Error( + `Tailscale Serve port ${config.frontendPort} is active outside the managed preview` + ), + { statusCode: 409 } + ); + } + let isTailscaleServeOwned = existingRecord?.ownsTailscaleServe === true; + if (isTailscaleServeOwned && tailscaleRoute.enabled) { + await disableOwnedTailscaleServe(config, true); + isTailscaleServeOwned = false; + } + const publicOrigin = tailscaleRoute.url; const worktreePath = previewWorktreePath(config, number); const startingRecord: PullRequestPreviewRecord = { backendPort: config.backendPort, @@ -1178,7 +1287,7 @@ export async function startPullRequestPreview( formatVersion: PREVIEW_RECORD_FORMAT_VERSION, frontendPort: config.frontendPort, number, - ownsTailscaleServe, + ownsTailscaleServe: false, status: "starting", title: pullRequest.title, updatedAt: timestamp, @@ -1210,9 +1319,12 @@ export async function startPullRequestPreview( options.protectFromCancellation?.(); await startPreviewUnit(config, sandboxCommand, signal); await waitForPreviewReady(config, signal); + await enableTailscaleServe(config, publicOrigin, signal); + isTailscaleServeOwned = true; const startedAt = new Date().toISOString(); const runningRecord: PullRequestPreviewRecord = { ...startingRecord, + ownsTailscaleServe: isTailscaleServeOwned, startedAt, status: "running", updatedAt: startedAt, @@ -1233,7 +1345,7 @@ export async function startPullRequestPreview( } let didCleanupRoute = false; try { - await disableOwnedTailscaleServe(config, ownsTailscaleServe); + await disableOwnedTailscaleServe(config, isTailscaleServeOwned); didCleanupRoute = true; } catch (cleanupError) { cleanupErrors.push(errorMessage(cleanupError, "Serve cleanup failed")); @@ -1245,7 +1357,7 @@ export async function startPullRequestPreview( cleanupErrors.length > 0 ? `${startupMessage}. Cleanup: ${cleanupErrors.join(". ")}` : startupMessage, - ownsTailscaleServe: ownsTailscaleServe && !didCleanupRoute, + ownsTailscaleServe: isTailscaleServeOwned && !didCleanupRoute, status: "failed", updatedAt: new Date().toISOString(), }; diff --git a/backend/src/services/pullRequestPreviewPolicy.ts b/backend/src/services/pullRequestPreviewPolicy.ts new file mode 100644 index 000000000..06f219d5e --- /dev/null +++ b/backend/src/services/pullRequestPreviewPolicy.ts @@ -0,0 +1,27 @@ +const DEFAULT_ALLOWED_AUTHORS = "mira-2026,rajohan"; + +/** Resolves the single backend-owned allowlist used by preview auth and UI metadata. */ +export function resolvePullRequestPreviewAllowedAuthors( + configuredValue: string | undefined +): ReadonlySet { + const allowedAuthors = new Set( + (configuredValue === undefined ? DEFAULT_ALLOWED_AUTHORS : configuredValue) + .split(",") + .map((author) => author.trim().toLowerCase()) + .filter(Boolean) + ); + if (allowedAuthors.size === 0) { + throw new TypeError( + "MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS must contain at least one author" + ); + } + return allowedAuthors; +} + +/** Checks one GitHub login against the normalized backend preview allowlist. */ +export function isPullRequestPreviewAuthorAllowed( + authorLogin: string | undefined, + allowedAuthors: ReadonlySet +): boolean { + return Boolean(authorLogin && allowedAuthors.has(authorLogin.trim().toLowerCase())); +} diff --git a/backend/src/services/pullRequestPreviews.ts b/backend/src/services/pullRequestPreviews.ts index f9b25b56f..ed21d083e 100644 --- a/backend/src/services/pullRequestPreviews.ts +++ b/backend/src/services/pullRequestPreviews.ts @@ -1,4 +1,8 @@ -import { enqueueJobExecution, type JobExecution } from "./jobExecutionQueue.ts"; +import { + enqueueJobExecution, + type JobExecution, + listJobExecutions, +} from "./jobExecutionQueue.ts"; import { getPullRequestPreviewStatus as readPullRequestPreviewStatus, type PullRequestPreviewCandidate, @@ -117,15 +121,61 @@ function previewFromExecution(execution: JobExecution): PullRequestPreviewStatus return parsePullRequestPreviewStatus(output.preview); } -/** Reads the current single-slot preview state without changing host state. */ +/** Reads the current preview state, including queued lifecycle transitions. */ export async function getPullRequestPreviewStatus(): Promise { - return readPullRequestPreviewStatus(); + const preview = await readPullRequestPreviewStatus(); + const activeExecution = listJobExecutions(200).find( + (execution) => + ["queued", "running"].includes(execution.status) && + ["dashboard.preview.start", "dashboard.preview.stop"].includes( + execution.actionKey + ) + ); + if (!activeExecution) return preview; + + const value = activeExecution.payload.number; + const number = value === undefined ? preview.number : executionPreviewNumber(value); + const isSamePreview = number !== undefined && preview.number === number; + return { + ...(isSamePreview && preview), + ...(number !== undefined && { number }), + status: + activeExecution.actionKey === "dashboard.preview.start" + ? "starting" + : "stopping", + updatedAt: activeExecution.startedAt || activeExecution.queuedAt, + }; } /** Queues one managed preview startup in the dedicated production worker. */ export async function prepareAndStartPullRequestPreview( number: number ): Promise { + const candidate = await findPullRequest(number); + const current = await getPullRequestPreviewStatus(); + if ( + ["running", "starting", "stopping"].includes(current.status) && + current.number !== number + ) { + throw Object.assign( + new Error( + `PR #${current.number} already owns the preview slot; stop it first` + ), + { statusCode: 409 } + ); + } + if ( + current.status === "running" && + current.number === number && + current.commitSha === candidate.commitSha + ) { + return current; + } + if (["starting", "stopping"].includes(current.status)) { + throw Object.assign(new Error("PR preview is already changing state"), { + statusCode: 409, + }); + } const execution = enqueueJobExecution({ actionKey: "dashboard.preview.start", displayName: `Start PR #${number} preview`, @@ -133,11 +183,13 @@ export async function prepareAndStartPullRequestPreview( resourceClass: "exclusive", timeoutMs: PREVIEW_START_TIMEOUT_MS, }); - return previewFromExecution( - await waitForJobExecution(execution.id, { - timeoutMs: PREVIEW_START_TIMEOUT_MS + PREVIEW_WAIT_GRACE_MS, - }) - ); + return { + commitSha: candidate.commitSha, + number, + status: "starting", + title: candidate.title, + updatedAt: execution.queuedAt, + }; } /** Queues a managed preview stop in the dedicated production worker. */ diff --git a/backend/src/services/pullRequests.ts b/backend/src/services/pullRequests.ts index 4f7a47f41..a7ec965b6 100644 --- a/backend/src/services/pullRequests.ts +++ b/backend/src/services/pullRequests.ts @@ -28,6 +28,10 @@ import { registerExpiredJobExecutionHandler, registerQueuedJobCancellationHandler, } from "./jobExecutionQueue.ts"; +import { + isPullRequestPreviewAuthorAllowed, + resolvePullRequestPreviewAllowedAuthors, +} from "./pullRequestPreviewPolicy.ts"; import { successfulJobExecutionOutput, waitForJobExecution, @@ -70,6 +74,7 @@ const MAX_BUFFER = 20 * 1024 * 1024; const MAX_JSON_LINE_LENGTH = 1024 * 1024; const PR_LIST_TIMEOUT_MS = 180_000; const PUBLIC_PR_CACHE_MS = 2 * 60 * 1000; +const PUBLIC_PR_FAILURE_CACHE_MS = 30_000; const PUBLIC_GITHUB_API_TIMEOUT_MS = 15_000; const DEPLOYMENT_RESTART_STATUS_POLL_MS = 1000; const DEPLOYMENT_RESTART_CLAIM_PAUSE_TIMEOUT_MS = 2 * 60 * 1000; @@ -81,6 +86,7 @@ const ACTIVE_DEPLOYMENT_STATUSES = new Set(["building", "restart-scheduled"]); const FULL_COMMIT_SHA_PATTERN = /^[\da-f]{40}$/u; const BUN_EXECUTABLE = process.env.BUN_BINARY || "bun"; const publicPullRequestCache: { + failure?: { expiresAt: number; message: string }; value?: { expiresAt: number; pullRequests: PullRequestSummary[] }; } = {}; @@ -151,6 +157,7 @@ export interface PullRequestSummary { headRefOid?: string; mergeable?: string; mergeStateStatus?: string; + previewEligible?: boolean; reviewDecision?: string; reviewerApproved?: boolean; canReviewerApprove?: boolean; @@ -781,11 +788,19 @@ function normalizePullRequest(pr: PullRequestSummary): PullRequestSummary { const rest = { ...pr }; delete rest.latestOpinionatedReviews; delete rest.reviews; + const previewAllowedAuthors = resolvePullRequestPreviewAllowedAuthors( + process.env.MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS + ); return { ...rest, - reviewerApproved: isPullRequestReviewApproved(pr), canReviewerApprove: canReviewerApprove(pr), + previewEligible: + pr.baseRefName === DEFAULT_BASE && + isPullRequestPreviewAuthorAllowed(pr.author?.login, previewAllowedAuthors) && + typeof pr.headRefOid === "string" && + FULL_COMMIT_SHA_PATTERN.test(pr.headRefOid), + reviewerApproved: isPullRequestReviewApproved(pr), }; } @@ -866,34 +881,52 @@ async function listPublicDashboardPullRequests(): Promise if (cachedPullRequests && cachedPullRequests.expiresAt > now) { return cachedPullRequests.pullRequests; } - const response = await fetch( - `https://api.github.com/repos/${DASHBOARD_REPO}/pulls?state=open&base=${DEFAULT_BASE}&per_page=100`, - { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "Mira-Dashboard-development-preview", - "X-GitHub-Api-Version": "2022-11-28", - }, - signal: AbortSignal.timeout(PUBLIC_GITHUB_API_TIMEOUT_MS), + const cachedFailure = publicPullRequestCache.failure; + if (cachedFailure && cachedFailure.expiresAt > now) { + throw new Error(cachedFailure.message); + } + try { + const response = await fetch( + `https://api.github.com/repos/${DASHBOARD_REPO}/pulls?state=open&base=${DEFAULT_BASE}&per_page=100`, + { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "Mira-Dashboard-development-preview", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(PUBLIC_GITHUB_API_TIMEOUT_MS), + } + ); + if (!response.ok) { + throw new Error( + `GitHub public pull request request failed with status ${response.status}` + ); } - ); - if (!response.ok) { - throw new Error( - `GitHub public pull request request failed with status ${response.status}` + const contentLength = Number(response.headers.get("content-length") || 0); + if (contentLength > MAX_BUFFER) { + throw new Error("GitHub public pull request response was too large"); + } + const pullRequests = parsePublicGithubPullRequests( + await readBoundedJsonResponse(response, MAX_BUFFER) ); + publicPullRequestCache.value = { + expiresAt: now + PUBLIC_PR_CACHE_MS, + pullRequests, + }; + publicPullRequestCache.failure = undefined; + return pullRequests; + } catch (error) { + if (cachedPullRequests) { + cachedPullRequests.expiresAt = now + PUBLIC_PR_FAILURE_CACHE_MS; + return cachedPullRequests.pullRequests; + } + const message = errorMessage(error, "GitHub public pull request request failed"); + publicPullRequestCache.failure = { + expiresAt: now + PUBLIC_PR_FAILURE_CACHE_MS, + message, + }; + throw new Error(message, { cause: error }); } - const contentLength = Number(response.headers.get("content-length") || 0); - if (contentLength > MAX_BUFFER) { - throw new Error("GitHub public pull request response was too large"); - } - const pullRequests = parsePublicGithubPullRequests( - await readBoundedJsonResponse(response, MAX_BUFFER) - ); - publicPullRequestCache.value = { - expiresAt: now + PUBLIC_PR_CACHE_MS, - pullRequests, - }; - return pullRequests; } /** Performs run command. */ @@ -1799,16 +1832,22 @@ async function scheduleReleaseCutover( rollbackCommit: string, signal?: AbortSignal ): Promise { - if (!job.commit || !/^[\da-f]{40}$/u.test(job.commit)) { + if (!job.commit || !FULL_COMMIT_SHA_PATTERN.test(job.commit)) { throw new TypeError("Release cutover requires a full candidate commit"); } - if (!/^[\da-f]{40}$/u.test(candidateCommit) || candidateCommit !== job.commit) { + if ( + !FULL_COMMIT_SHA_PATTERN.test(candidateCommit) || + candidateCommit !== job.commit + ) { throw new TypeError("Release cutover requires the matching full candidate SHA"); } - if (!/^[\da-f]{40}$/u.test(preActivationCommit)) { + if (!FULL_COMMIT_SHA_PATTERN.test(preActivationCommit)) { throw new TypeError("Release cutover requires a full pre-activation commit"); } - if (rollbackCommit === candidateCommit || !/^[\da-f]{40}$/u.test(rollbackCommit)) { + if ( + rollbackCommit === candidateCommit || + !FULL_COMMIT_SHA_PATTERN.test(rollbackCommit) + ) { throw new TypeError("Release cutover requires a distinct full rollback commit"); } const releasesRoot = resolveDashboardReleasesRoot(); @@ -1866,7 +1905,7 @@ async function scheduleReleaseCutover( ` ${deploymentJobUpdateCommand(okWithRetentionWarningJob)}`, " fi", " else", - ` if ${lifecycleEnvironment} rollback && restart_services && ready_for_commit ${shellQuote(rollbackShort)}; then`, + ` if ${lifecycleEnvironment} rollback ${shellQuote(candidateCommit)} ${shellQuote(rollbackCommit)} && restart_services && ready_for_commit ${shellQuote(rollbackShort)}; then`, ` ${deploymentJobUpdateCommand(rolledBackJob)}`, " else", ` ${deploymentJobUpdateCommand(rollbackFailedJob)}`, @@ -1901,12 +1940,15 @@ async function scheduleReleaseRollback( ): Promise { if ( !job.commit || - !/^[\da-f]{40}$/u.test(job.commit) || + !FULL_COMMIT_SHA_PATTERN.test(job.commit) || job.commit !== targetCommit ) { throw new TypeError("Release rollback requires its matching full target SHA"); } - if (originalCommit === targetCommit || !/^[\da-f]{40}$/u.test(originalCommit)) { + if ( + originalCommit === targetCommit || + !FULL_COMMIT_SHA_PATTERN.test(originalCommit) + ) { throw new TypeError( "Release rollback requires a distinct full original release SHA" ); @@ -1955,11 +1997,11 @@ async function scheduleReleaseRollback( const script = [ "sleep 2", ...releaseCutoverShellFunctions(), - `if ${lifecycleEnvironment} rollback; then`, + `if ${lifecycleEnvironment} rollback ${shellQuote(originalCommit)} ${shellQuote(targetCommit)}; then`, ` if restart_services && ready_for_commit ${shellQuote(targetShort)}; then`, ` ${deploymentJobUpdateCommand(okJob)}`, " else", - ` if ${lifecycleEnvironment} rollback && restart_services && ready_for_commit ${shellQuote(originalShort)}; then`, + ` if ${lifecycleEnvironment} rollback ${shellQuote(targetCommit)} ${shellQuote(originalCommit)} && restart_services && ready_for_commit ${shellQuote(originalShort)}; then`, ` ${deploymentJobUpdateCommand(restoredJob)}`, " else", ` ${deploymentJobUpdateCommand(restorationFailedJob)}`, @@ -1995,7 +2037,7 @@ function didScheduleOrphanedReleaseCutoverRecovery( const candidateCommit = cutover.candidateCommit ?? job.commit; if ( !candidateCommit || - !/^[\da-f]{40}$/u.test(candidateCommit) || + !FULL_COMMIT_SHA_PATTERN.test(candidateCommit) || job.commit !== candidateCommit ) { throw new Error( @@ -2067,7 +2109,7 @@ function didScheduleOrphanedReleaseCutoverRecovery( ' rollback_commit="$(printf "%s" "$activation_output" | /usr/bin/jq --raw-output \'.previous.commitSha // empty\')"', ' [[ "$rollback_commit" =~ ^[0-9a-f]{40}$ ]] || exit 1', ' [ "$rollback_commit" != "$candidate_commit" ] || exit 1', - ' if run_lifecycle rollback && restart_services && ready_for_commit "${rollback_commit:0:8}"; then', + ' if run_lifecycle rollback "$candidate_commit" "$rollback_commit" && restart_services && ready_for_commit "${rollback_commit:0:8}"; then', ` ${deploymentJobUpdateCommand(rolledBackJob)}`, " else", " exit 1", diff --git a/backend/test/developmentStack.test.ts b/backend/test/developmentStack.test.ts index 9ed6433d6..51a8ce432 100644 --- a/backend/test/developmentStack.test.ts +++ b/backend/test/developmentStack.test.ts @@ -275,6 +275,7 @@ describe("development stack", () => { MIRA_DASHBOARD_DEV_RELEASES_SOURCE: releaseSource, MIRA_DASHBOARD_DEV_STATE_ROOT: stateRoot, MIRA_DASHBOARD_DEV_WORKSPACE_SOURCE: workspaceSource, + MIRA_DASHBOARD_WEBAUTHN_RP_ID: "dashboard.example", }, root ); @@ -294,6 +295,11 @@ describe("development stack", () => { .all() ) ).toBe('[{"id":1,"mfa_enabled_at":"now"},{"id":2,"mfa_enabled_at":null}]'); + expect( + snapshot + .query("SELECT COUNT(*) AS count FROM user_webauthn_credentials") + .get() + ).toEqual({ count: 1 }); for (const tableName of [ "auth_webauthn_challenges", "auth_sessions", @@ -415,6 +421,52 @@ describe("development stack", () => { } }); + it("disables copied MFA when the development WebAuthn RP differs", () => { + const root = temporaryRoot("mira-development-rp-snapshot-"); + const sourceDatabase = path.join(root, "production.db"); + const stateRoot = path.join(root, "state"); + createSnapshotSource(sourceDatabase); + mkdirSync(path.join(root, ".openclaw", "workspace"), { recursive: true }); + mkdirSync(path.join(root, "projects", "mira-dashboard-releases"), { + recursive: true, + }); + writeFileSync(path.join(root, ".openclaw", "openclaw.json"), "{}"); + const config = resolveDevelopmentStackConfig( + { + HOME: root, + MIRA_DASHBOARD_DEV_DB_SOURCE: sourceDatabase, + MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: "http://localhost:5173", + MIRA_DASHBOARD_DEV_STATE_ROOT: stateRoot, + MIRA_DASHBOARD_WEBAUTHN_RP_ID: "dashboard.example", + }, + root + ); + + try { + expect(prepareDevelopmentState(config).database).toBe("snapshot-created"); + const snapshot = new Database(config.databasePath, { readonly: true }); + try { + expect( + snapshot + .query("SELECT id, mfa_enabled_at FROM users ORDER BY id") + .all() + ).toEqual([ + { id: 1, mfa_enabled_at: SQL_NULL }, + { id: 2, mfa_enabled_at: SQL_NULL }, + ]); + expect( + snapshot + .query("SELECT COUNT(*) AS count FROM user_webauthn_credentials") + .get() + ).toEqual({ count: 0 }); + } finally { + snapshot.close(); + } + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + it("reads bounded OpenClaw config through one no-follow descriptor", () => { const root = temporaryRoot("mira-development-openclaw-config-"); const configSource = path.join(root, "openclaw.json"); diff --git a/backend/test/pullRequestPreview.test.ts b/backend/test/pullRequestPreview.test.ts index 08f5b563c..3de644b7b 100644 --- a/backend/test/pullRequestPreview.test.ts +++ b/backend/test/pullRequestPreview.test.ts @@ -2,9 +2,11 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, statSync, + writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -151,6 +153,16 @@ describe("managed pull request preview", () => { unitName: "mira-dashboard-pr-preview.service", }); expect(config.allowedAuthors).toEqual(new Set(["mira-2026", "rajohan"])); + expect(() => + resolvePullRequestPreviewConfig({ + BUN_BINARY: "/home/ubuntu/.bun/bin/bun", + MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS: " , ", + MIRA_DASHBOARD_ROOT: path.join(root, "dashboard"), + MIRA_DASHBOARD_WORKTREE_ROOT: path.join(root, "worktrees"), + }) + ).toThrow( + "MIRA_DASHBOARD_PREVIEW_ALLOWED_AUTHORS must contain at least one author" + ); for (const environment of [ { @@ -225,6 +237,32 @@ describe("managed pull request preview", () => { } }); + it("quarantines an invalid preview record instead of blocking the dev slot", async () => { + const root = mkdtempSync(path.join(tmpdir(), "mira-preview-corrupt-state-")); + const config = previewConfig(root); + mkdirSync(config.previewRoot, { recursive: true }); + writeFileSync(config.stateFile, "{not-json\n", { mode: 0o600 }); + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(getPullRequestPreviewStatus(config)).resolves.toEqual({ + status: "stopped", + }); + expect(existsSync(config.stateFile)).toBe(false); + expect( + readdirSync(config.previewRoot).filter((entry) => + entry.startsWith("active-preview.corrupt-") + ) + ).toHaveLength(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Quarantined invalid state") + ); + } finally { + errorSpy.mockRestore(); + rmSync(root, { force: true, recursive: true }); + } + }); + it("starts, reuses, updates, reports, and stops one trusted preview slot", async () => { const root = mkdtempSync(path.join(tmpdir(), "mira-preview-lifecycle-")); const config = { @@ -372,6 +410,13 @@ describe("managed pull request preview", () => { expect(commands.some((command) => command.startsWith("systemd-run "))).toBe( true ); + expect( + commands.findIndex((command) => command.startsWith("systemd-run ")) + ).toBeLessThan( + commands.findIndex((command) => + command.startsWith("sudo -n tailscale serve --bg --https=5173") + ) + ); await expect(getPullRequestPreviewStatus(config)).resolves.toMatchObject({ number: 335, status: "running", @@ -404,6 +449,12 @@ describe("managed pull request preview", () => { expect(existsSync(config.gatewayTokenFile)).toBe(false); expect(commands).toContain("sudo -n tailscale serve --https=5173 off"); + isServeEnabled = true; + await expect( + startPullRequestPreview(candidate, { config }) + ).rejects.toMatchObject({ statusCode: 409 }); + isServeEnabled = false; + expectedCommit = "b".repeat(40); await expect( startPullRequestPreview( @@ -426,7 +477,11 @@ describe("managed pull request preview", () => { command.includes(`checkout --detach ${expectedCommit}`) ) ).toBe(true); - await stopPullRequestPreview(undefined, { config }); + isUnitActive = false; + await expect(getPullRequestPreviewStatus(config)).resolves.toMatchObject({ + number: 335, + status: "stopped", + }); expect(isServeEnabled).toBe(false); expect(existsSync(config.gatewayTokenFile)).toBe(false); } finally { @@ -447,6 +502,9 @@ describe("managed pull request preview", () => { .mockImplementation((input) => input.actionKey === "dashboard.preview.start" ? queuedStart : queuedStop ); + const executionsSpy = jest + .spyOn(jobExecutionQueue, "listJobExecutions") + .mockReturnValue([]); const waitSpy = jest .spyOn(queuedJobExecution, "waitForJobExecution") .mockImplementation(async (id) => @@ -489,11 +547,12 @@ describe("managed pull request preview", () => { .mockResolvedValue({ number: 335, status: "stopped" }); const statusSpy = jest .spyOn(previewHost, "getPullRequestPreviewStatus") - .mockResolvedValue({ - commitSha: COMMIT, - number: 335, - status: "running", - }); + .mockResolvedValue({ status: "stopped" }); + const runningPreview = { + commitSha: COMMIT, + number: 335, + status: "running" as const, + }; const protectFromCancellation = jest.fn(); const context: ScheduledJobActionContext = { executionId: "execution", @@ -503,10 +562,20 @@ describe("managed pull request preview", () => { }; try { - await expect(prepareAndStartPullRequestPreview(335)).resolves.toEqual({ + await expect(prepareAndStartPullRequestPreview(335)).resolves.toMatchObject({ + commitSha: COMMIT, number: 335, + status: "starting", + title: "Trusted preview", + updatedAt: expect.any(String), + }); + statusSpy.mockResolvedValueOnce({ + number: 334, status: "running", }); + await expect(prepareAndStartPullRequestPreview(335)).rejects.toMatchObject({ + statusCode: 409, + }); await expect(prepareAndStopPullRequestPreview(335)).resolves.toEqual({ number: 335, status: "stopped", @@ -531,17 +600,23 @@ describe("managed pull request preview", () => { payload: { number: undefined }, }) ); - expect(waitSpy).toHaveBeenCalledTimes(3); + expect(waitSpy).toHaveBeenCalledTimes(2); const { pullRequestRoutes } = await import("../src/routes/pullRequestRoutes.ts"); const startResponse = await pullRequestRoutes[ "/api/pull-requests/:number/preview/start" ].POST(previewRouteRequest("335")); - expect(startResponse.status).toBe(200); - await expect(startResponse.json()).resolves.toEqual({ + expect(startResponse.status).toBe(202); + await expect(startResponse.json()).resolves.toMatchObject({ isOk: true, - preview: { number: 335, status: "running" }, + preview: { + commitSha: COMMIT, + number: 335, + status: "starting", + title: "Trusted preview", + updatedAt: expect.any(String), + }, }); const stopResponse = await pullRequestRoutes[ @@ -553,14 +628,17 @@ describe("managed pull request preview", () => { preview: { number: 335, status: "stopped" }, }); + statusSpy.mockResolvedValue(runningPreview); + executionsSpy.mockReturnValueOnce([queuedStart]); const statusResponse = await pullRequestRoutes["/api/pull-requests/preview"].GET(); expect(statusResponse.status).toBe(200); - await expect(statusResponse.json()).resolves.toEqual({ + await expect(statusResponse.json()).resolves.toMatchObject({ preview: { commitSha: COMMIT, number: 335, - status: "running", + status: "starting", + updatedAt: queuedStart.queuedAt, }, }); @@ -577,7 +655,7 @@ describe("managed pull request preview", () => { }); } - waitSpy.mockRejectedValueOnce( + listSpy.mockRejectedValueOnce( Object.assign(new Error("preview startup unavailable"), { statusCode: 503, }) @@ -663,6 +741,7 @@ describe("managed pull request preview", () => { ).rejects.toMatchObject({ statusCode: 404 }); } finally { enqueueSpy.mockRestore(); + executionsSpy.mockRestore(); waitSpy.mockRestore(); registerSpy.mockRestore(); listSpy.mockRestore(); diff --git a/backend/test/releaseManager.test.ts b/backend/test/releaseManager.test.ts index 655cbf292..0329a4c71 100644 --- a/backend/test/releaseManager.test.ts +++ b/backend/test/releaseManager.test.ts @@ -426,8 +426,19 @@ describe("Dashboard immutable release manager", () => { root, SCHEMA_6_OPTIONS ); + await expect( + runReleaseLifecycleCommand( + ["rollback", FIRST_COMMIT, SECOND_COMMIT], + root, + SCHEMA_6_OPTIONS + ) + ).rejects.toThrow("rollback slots changed"); + await expect(readDashboardReleaseState(root)).resolves.toMatchObject({ + current: { commitSha: SECOND_COMMIT }, + previous: { commitSha: FIRST_COMMIT }, + }); const rolledBack = await runReleaseLifecycleCommand( - ["rollback"], + ["rollback", SECOND_COMMIT, FIRST_COMMIT], root, SCHEMA_6_OPTIONS ); @@ -462,10 +473,16 @@ describe("Dashboard immutable release manager", () => { ).rejects.toThrow("unexpected arguments"); await expect( runReleaseLifecycleCommand(["rollback", FIRST_COMMIT], root) - ).rejects.toThrow("takes no commit SHA"); - await expect(runReleaseLifecycleCommand(["rollback", ""], root)).rejects.toThrow( - "takes no commit SHA" - ); + ).rejects.toThrow("requires expected current and target"); + await expect( + runReleaseLifecycleCommand( + ["rollback", FIRST_COMMIT, SECOND_COMMIT, "extra"], + root + ) + ).rejects.toThrow("requires expected current and target"); + await expect( + runReleaseLifecycleCommand(["rollback", "", FIRST_COMMIT], root) + ).rejects.toThrow("requires expected current and target"); await expect(runReleaseLifecycleCommand(["status", ""], root)).rejects.toThrow( "takes no commit SHA" ); @@ -849,9 +866,21 @@ describe("Dashboard immutable release manager", () => { root, SCHEMA_6_OPTIONS ); + let isSettled = false; + void activation + .then(() => { + isSettled = true; + }) + .catch(() => { + isSettled = true; + }); - await Bun.sleep(125); - closeSync(lockFileDescriptor); + try { + await Bun.sleep(125); + expect(isSettled).toBe(false); + } finally { + closeSync(lockFileDescriptor); + } await expect(activation).resolves.toMatchObject({ current: { commitSha: FIRST_COMMIT }, @@ -859,6 +888,36 @@ describe("Dashboard immutable release manager", () => { } ); + it.skipIf(!isReleaseTransitionLockAvailable())( + "lets lifecycle status wait for an in-flight transition", + async () => { + const root = temporaryReleasesRoot(); + await createManagedRelease(root, FIRST_COMMIT); + await activateDashboardRelease(FIRST_COMMIT, root, SCHEMA_6_OPTIONS); + const lockFileDescriptor = holdTransitionLock(root); + const status = runReleaseLifecycleCommand(["status"], root); + let isSettled = false; + void status + .then(() => { + isSettled = true; + }) + .catch(() => { + isSettled = true; + }); + + try { + await Bun.sleep(125); + expect(isSettled).toBe(false); + } finally { + closeSync(lockFileDescriptor); + } + + await expect(status).resolves.toMatchObject({ + current: { commitSha: FIRST_COMMIT }, + }); + } + ); + it("restores both prior slots when activation fails after changing a link", async () => { const root = temporaryReleasesRoot(); await createManagedRelease(root, FIRST_COMMIT); diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index 53e92568c..b4cb6a0d4 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -1946,7 +1946,8 @@ printf 'scheduled\n' expect(guardian).toContain( `${releasesRoot}/releases/${currentCommit}/backend/dist/releaseLifecycle.js` ); - expect(guardian).toContain(" rollback"); + expect(guardian).toContain(`rollback '${currentCommit}' '${previousCommit}'`); + expect(guardian).toContain(`rollback '${previousCommit}' '${currentCommit}'`); expect(guardian).toContain( `ready_for_commit '${previousCommit.slice(0, 8)}'` ); @@ -2505,7 +2506,9 @@ printf 'scheduled\n' expect(restartCommand.indexOf(`activate '${candidateCommit}'`)).toBeLessThan( restartCommand.indexOf("if restart_services") ); - expect(restartCommand).toContain("rollback"); + expect(restartCommand).toContain( + `rollback '${candidateCommit}' '${oldCommit}'` + ); expect(restartCommand).toContain("prune 3"); expect(restartCommand).not.toContain("/api/job-executions"); expect(readlinkSync(path.join(releasesRoot, "current"))).toBe( @@ -2572,7 +2575,7 @@ printf 'scheduled\n' ) ); expect(recoveryCommand).toContain( - "run_lifecycle rollback && restart_services" + 'run_lifecycle rollback "$candidate_commit" "$rollback_commit" && restart_services' ); expect( database diff --git a/backend/test/utilityBehavior.test.ts b/backend/test/utilityBehavior.test.ts index 5178ac1e3..6b2e7e005 100644 --- a/backend/test/utilityBehavior.test.ts +++ b/backend/test/utilityBehavior.test.ts @@ -113,6 +113,7 @@ describe("backend service utilities", () => { headRefName: "mira/preview", headRefOid: commitSha, number: 335, + previewEligible: true, reviewerApproved: false, statusCheckRollup: [], }), diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 6dde91c12..bd58b8d87 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -8,6 +8,8 @@ "types": ["bun", "node"], "outDir": "./dist", "rootDir": ".", + "incremental": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", "noEmit": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, diff --git a/bun.lock b/bun.lock index e706c948d..e4baa7681 100644 --- a/bun.lock +++ b/bun.lock @@ -10,12 +10,12 @@ "@dnd-kit/react": "^0.5.0", "@dnd-kit/sortable": "^10.0.0", "@headlessui/react": "^2.2.10", - "@microlink/react-json-view": "^1.31.23", + "@microlink/react-json-view": "^1.31.24", "@simplewebauthn/browser": "13.3.0", "@tailwindcss/typography": "^0.5.20", "@tanstack/query-core": "5.101.4", - "@tanstack/query-db-collection": "^1.2.0", - "@tanstack/react-db": "^0.1.94", + "@tanstack/query-db-collection": "^1.2.1", + "@tanstack/react-db": "^0.1.95", "@tanstack/react-form": "^1.33.2", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", @@ -25,7 +25,7 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "json5": "^2.2.3", - "lucide-react": "^1.25.0", + "lucide-react": "^1.27.0", "qrcode.react": "4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -59,7 +59,7 @@ "@typescript/native": "npm:typescript@^7.0.2", "babel-plugin-react-compiler": "^1.0.0", "bun-plugin-tailwind": "^0.1.2", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.5.6", @@ -69,7 +69,7 @@ "eslint-plugin-simple-import-sort": "^14.0.0", "eslint-plugin-tailwindcss": "^4.2.0", "eslint-plugin-unicorn": "^72.0.0", - "globals": "^17.7.0", + "globals": "^17.8.0", "happy-dom": "^20.11.1", "prettier": "^3.9.6", "tailwindcss": "^4.3.3", @@ -145,7 +145,7 @@ "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], @@ -189,7 +189,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@microlink/react-json-view": ["@microlink/react-json-view@1.31.23", "", { "dependencies": { "react-base16-styling": "~0.10.0", "react-lifecycles-compat": "~3.0.4", "react-textarea-autosize": "~8.5.9" }, "peerDependencies": { "react": ">= 15", "react-dom": ">= 15" } }, "sha512-Qjt7uUpYPDAQiu0LgxyRSuwAgMBwIXtJZ6HSbjTAx6TXu0DdZ/BVIFD5A27z/NcXS2q2zVQgAIxut3WbUdDwRg=="], + "@microlink/react-json-view": ["@microlink/react-json-view@1.31.24", "", { "dependencies": { "react-base16-styling": "~0.10.0", "react-lifecycles-compat": "~3.0.4", "react-textarea-autosize": "~8.5.9" }, "peerDependencies": { "react": ">= 15", "react-dom": ">= 15" } }, "sha512-46ADyAxSA5bxXbdZ7VaXl8oZpbai5XByl7/loNcSWzRAW/qGntQG8XkTDDj2okW1Gg7nv1QDwik2yrTP4Mzhfw=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], @@ -261,7 +261,7 @@ "@tailwindcss/typography": ["@tailwindcss/typography@0.5.20", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="], - "@tanstack/db": ["@tanstack/db@0.6.16", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@tanstack/db-ivm": "0.1.18", "@tanstack/pacer-lite": "^0.2.1" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-6tZ4sAyh7g4G5awO4Q4D/UQVPoPEBsqqDBImjCZNTRJznrpwGqFJZxozov197k8ty8QxZC++eGcbo+BBKF3nzw=="], + "@tanstack/db": ["@tanstack/db@0.6.17", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@tanstack/db-ivm": "0.1.18", "@tanstack/pacer-lite": "^0.2.1" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-/i6+dedEkOCVQbTQtCjQHx3Nqlkqe6zvi+9/JPnSgm47o+akoaPqcSwxTmesDZxc/efHokUtzeD2ocow257RdQ=="], "@tanstack/db-ivm": ["@tanstack/db-ivm@0.1.18", "", { "dependencies": { "fractional-indexing": "^3.2.0", "sorted-btree": "^1.8.1" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-+pZJiRKdoKRM5Epq9T7otD9ZJl82pRFauo7LKuJGrarjVKQ7r+QQlPe3kGdN9LEKSnuNGIWjX9OOY4M8kH4eLw=="], @@ -287,11 +287,11 @@ "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="], - "@tanstack/query-db-collection": ["@tanstack/query-db-collection@1.2.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@tanstack/db": "0.6.16" }, "peerDependencies": { "@tanstack/query-core": "^5.0.0", "typescript": ">=4.7" } }, "sha512-WPVj8TW5/qtGWkvBz/ru/odYm+QnD6ZuWmk59xD2biq2T+/cIssRgHuNIOvVw4texGrUiROzF8NUcuNICijXkg=="], + "@tanstack/query-db-collection": ["@tanstack/query-db-collection@1.2.1", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@tanstack/db": "0.6.17" }, "peerDependencies": { "@tanstack/query-core": "^5.0.0", "typescript": ">=4.7" } }, "sha512-2IwtxdolgPMwLoV7TKaB+1qVGU7ukulacCQqhCt1/50+x/r9sLXF2668fy6OnLd0mEkkEthzCa+J3ZbiA4kNbg=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.101.4", "", {}, "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA=="], - "@tanstack/react-db": ["@tanstack/react-db@0.1.94", "", { "dependencies": { "@tanstack/db": "0.6.16", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-ZtSMBOCdR/ElcBxg84uboXrqQ+kqiOwlq6kTy+o54UDywYr/by02jmWKHqayrnoiPVlrx83WNHDaomfe141f4A=="], + "@tanstack/react-db": ["@tanstack/react-db@0.1.95", "", { "dependencies": { "@tanstack/db": "0.6.17", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-Om2qgKtoK+iTcE3nR+MaPQsM4JUsDWB8LqFt4yFjwuSawwMJ7bcPnc7gP0V06YKOtm5MzrIu8IV/OexMJbraKQ=="], "@tanstack/react-devtools": ["@tanstack/react-devtools@0.10.9", "", { "dependencies": { "@tanstack/devtools": "0.13.0" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw=="], @@ -621,7 +621,7 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="], + "eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": "bin/cli.js" }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], @@ -709,7 +709,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], + "globals": ["globals@17.8.0", "", {}, "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -885,7 +885,7 @@ "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], - "lucide-react": ["lucide-react@1.25.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw=="], + "lucide-react": ["lucide-react@1.27.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw=="], "lz-string": ["lz-string@1.5.0", "", { "bin": "bin/bin.js" }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -1397,6 +1397,8 @@ "eslint-plugin-react-hooks/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "eslint-plugin-unicorn/globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], + "eslint-plugin-unicorn/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 1ea5bdb55..1bf7ac9fd 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -246,7 +246,7 @@ upstream download metadata. | `POST` | `/api/pull-requests/:number/review-approval` | Queues review approval. | | `POST` | `/api/pull-requests/:number/update-branch` | Queues branch update. | | `GET` | `/api/pull-requests/preview` | Reads the single managed PR-dev slot. | -| `POST` | `/api/pull-requests/:number/preview/start` | Starts/updates trusted PR dev in the managed slot. | +| `POST` | `/api/pull-requests/:number/preview/start` | Validates and queues trusted PR dev (`202 Starting`). | | `POST` | `/api/pull-requests/:number/preview/stop` | Stops PR dev while retaining isolated state. | | `POST` | `/api/pull-requests/deploy` | Queues an atomic deploy of latest `main`. | | `GET` | `/api/pull-requests/deployments` | Lists deploy and rollback jobs. | diff --git a/docs/development/local-dev.md b/docs/development/local-dev.md index 6da2e1220..af6505470 100644 --- a/docs/development/local-dev.md +++ b/docs/development/local-dev.md @@ -73,10 +73,14 @@ changes made while testing remain available. The database snapshot removes active sessions and pending logins, WebAuthn challenges, TOTP/recovery secrets, the persisted Gateway token, deployment/job -runtime state, and chat replay snapshots. Existing Dashboard users, password -hashes, and WebAuthn public credentials remain available. Cache refresh and -SQLite maintenance jobs retain their enabled state and schedule. Backup, -Docker, deploy, workspace-sync, and log-rotation jobs are forced disabled. +runtime state, and chat replay snapshots. Existing Dashboard users and password +hashes remain available. WebAuthn public credentials remain available only when +the source and development relying-party IDs match. For a different RP, such as +the default `localhost`, the snapshot removes incompatible credentials and +disables MFA so password login and local factor enrollment remain possible. +Cache refresh and SQLite maintenance jobs retain their enabled state and +schedule. Backup, Docker, deploy, workspace-sync, and log-rotation jobs are +forced disabled. The workspace copy rejects symlinks and excludes Git metadata, credential/secret directories, private-key names, `.env` files, and token/secret files. Safe @@ -130,17 +134,20 @@ the frontend proxy strips non-dev Dashboard cookies before forwarding requests. The Pull requests page exposes one shared **PR dev** slot: -- only PRs targeting `main` from the configured trusted-author allowlist can - start; -- dependencies install with frozen lockfiles and lifecycle scripts disabled; -- source and Git metadata are read-only inside a Bubblewrap sandbox; -- state is stored under - `/home/ubuntu/projects/mira-dashboard-preview-state/managed/states/pr-/`; -- Tailscale provides HTTPS; -- a transient user unit enforces CPU, IO, memory, task, and four-hour runtime - limits; -- stop removes the owned Tailscale route and materialized Gateway-token file, +- Only PRs targeting `main` from the configured trusted-author allowlist can + start. +- Dependencies install with frozen lockfiles and lifecycle scripts disabled. +- Source and Git metadata are read-only inside a Bubblewrap sandbox. +- State is stored under + `/home/ubuntu/projects/mira-dashboard-preview-state/managed/states/pr-/`. +- Tailscale publishes HTTPS only after the managed frontend/backend pair is + locally ready. +- A transient user unit enforces CPU, IO, memory, task, and four-hour runtime + limits. +- Stop removes the owned Tailscale route and materialized Gateway-token file, while keeping the worktree and isolated state for a faster restart. +- Status reconciliation performs the same route/token cleanup if the transient + unit exits or reaches its four-hour limit. The production backend decrypts its persisted Gateway token only when starting trusted PR dev. It atomically writes an owner-only `0600` file outside the @@ -187,6 +194,10 @@ bun run test:backend:coverage bun run format:check ``` +During development, `bun run test:changed` runs only frontend and backend tests +affected by uncommitted changes. Run the full test and coverage commands before +push. + Use Bun, keep backend imports on `.ts`, reuse shared frontend components, and do not commit generated state, build output, database files, environment files, or token output. diff --git a/docs/setup/production-deploy.md b/docs/setup/production-deploy.md index a49c72ffd..fd3c5d54c 100644 --- a/docs/setup/production-deploy.md +++ b/docs/setup/production-deploy.md @@ -190,14 +190,14 @@ assert_no_active_release_action env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ NODE_ENV=production \ - bun "$CURRENT_LIFECYCLE" rollback + bun "$CURRENT_LIFECYCLE" rollback "$CURRENT_SHA" "$TARGET_SHA" systemctl --user restart mira-dashboard-worker.service mira-dashboard.service if ! ready_for_commit "$TARGET_SHA"; then echo "Rollback target failed readiness. Restoring $CURRENT_SHA" >&2 env MIRA_DASHBOARD_DB_PATH="$DATABASE_PATH" \ MIRA_DASHBOARD_RELEASES_ROOT="$RELEASES_ROOT" \ NODE_ENV=production \ - bun "$CURRENT_LIFECYCLE" rollback + bun "$CURRENT_LIFECYCLE" rollback "$TARGET_SHA" "$CURRENT_SHA" systemctl --user restart mira-dashboard-worker.service mira-dashboard.service ready_for_commit "$CURRENT_SHA" exit 1 diff --git a/docs/setup/secrets-and-env.md b/docs/setup/secrets-and-env.md index 25e94a20c..1f1da88a4 100644 --- a/docs/setup/secrets-and-env.md +++ b/docs/setup/secrets-and-env.md @@ -150,15 +150,18 @@ The Database page probes Postgres/PgBouncer using these values: `bun run dev` and `bun run dev:remote` select only `OPENCLAW_GATEWAY_TOKEN`, `MIRA_DASHBOARD_SESSION_IDLE_MINUTES`, and -`MIRA_DASHBOARD_RECENT_AUTH_MINUTES` from Doppler `rajohan/prd`. The explicit -backend child environment forwards the two auth timing values unchanged and -does not inherit other provider or host credentials. +`MIRA_DASHBOARD_RECENT_AUTH_MINUTES`, plus the non-secret +`MIRA_DASHBOARD_WEBAUTHN_RP_ID`, from Doppler `rajohan/prd`. The explicit backend +child environment forwards the two auth timing values unchanged, uses the +production RP ID only to decide whether copied WebAuthn public credentials are +compatible, and does not inherit other provider or host credentials. | Variable | Default | Purpose | | --------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------- | | `MIRA_DASHBOARD_DEV_FRONTEND_PORT` | `5173` | Frontend hot-reload port. | | `MIRA_DASHBOARD_DEV_BACKEND_PORT` | `3101` | Backend restart-on-change port. | | `MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN` | `http://localhost:5173` | Cookie/WebAuthn origin; remote dev derives the Tailscale HTTPS origin. | +| `MIRA_DASHBOARD_DEV_SOURCE_WEBAUTHN_RP_ID` | production `MIRA_DASHBOARD_WEBAUTHN_RP_ID` | Source snapshot RP used to retain or remove copied WebAuthn public credentials. | | `MIRA_DASHBOARD_DEV_STATE_ROOT` | `~/projects/mira-dashboard-dev-state/local` | Owner-only isolated development state. | | `MIRA_DASHBOARD_DEV_DB_SOURCE` | `~/projects/mira-dashboard-state/mira-dashboard.db` | Production database used only to create a scrubbed WAL-consistent snapshot. | | `MIRA_DASHBOARD_DEV_RELEASES_SOURCE` | `~/projects/mira-dashboard-releases` | Managed releases copied into isolated state. | diff --git a/eslint.config.js b/eslint.config.js index 318999b97..f135e7698 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,6 +30,7 @@ const eslintConfig = defineConfig( "dist/**", "build/**", "coverage/**", + "data/**", "*.log", "*.tsbuildinfo", ".DS_Store", diff --git a/package.json b/package.json index c666f0f88..937d89fe1 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "Safari >= 18.4" ], "scripts": { - "dev": "doppler run --project rajohan --config prd --only-secrets OPENCLAW_GATEWAY_TOKEN,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES --no-exit-on-missing-only-secrets -- bun scripts/developmentStack.ts", - "dev:remote": "doppler run --project rajohan --config prd --only-secrets OPENCLAW_GATEWAY_TOKEN,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES --no-exit-on-missing-only-secrets -- bun scripts/developmentTailscale.ts run", + "dev": "doppler run --project rajohan --config prd --only-secrets OPENCLAW_GATEWAY_TOKEN,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES,MIRA_DASHBOARD_WEBAUTHN_RP_ID --no-exit-on-missing-only-secrets -- bun scripts/developmentStack.ts", + "dev:remote": "doppler run --project rajohan --config prd --only-secrets OPENCLAW_GATEWAY_TOKEN,MIRA_DASHBOARD_SESSION_IDLE_MINUTES,MIRA_DASHBOARD_RECENT_AUTH_MINUTES,MIRA_DASHBOARD_WEBAUTHN_RP_ID --no-exit-on-missing-only-secrets -- bun scripts/developmentTailscale.ts run", "dev:remote:disable": "bun scripts/developmentTailscale.ts disable", "dev:remote:enable": "bun scripts/developmentTailscale.ts enable", "dev:remote:status": "bun scripts/developmentTailscale.ts status", @@ -23,23 +23,20 @@ "deploy:prepare": "bun run build:frontend && bun run --cwd backend deploy:prepare:backend && bun run release:manifest", "release:manifest": "bun scripts/writeReleaseManifest.ts", "lint": "bun run lint:frontend && bun run lint:backend", - "lint:frontend": "eslint . --max-warnings=0", - "lint:frontend:fix": "eslint . --fix", + "lint:frontend": "eslint . --cache --cache-strategy content --max-warnings=0", + "lint:frontend:fix": "eslint . --cache --cache-strategy content --fix", "lint:backend": "bun run --cwd backend lint:backend", "lint:backend:fix": "bun run --cwd backend lint:backend:fix", - "format": "bun run format:frontend && bun run format:backend && bun run format:docs", - "format:check": "bun run format:frontend:check && bun run format:backend:check && bun run format:docs:check", - "format:frontend": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\" \"scripts/**/*.{ts,js}\"", - "format:frontend:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\" \"scripts/**/*.{ts,js}\"", - "format:backend": "bun run --cwd backend format:backend", - "format:backend:check": "bun run --cwd backend format:backend:check", - "format:docs": "prettier --write README.md \"docs/**/*.md\"", - "format:docs:check": "prettier --check README.md \"docs/**/*.md\"", + "format": "prettier --write . --cache --cache-strategy content", + "format:check": "prettier --check . --cache --cache-strategy content", "test": "bun run test:frontend && bun run test:backend", + "test:changed": "bun run test:frontend:changed && bun run test:backend:changed", "test:coverage": "bun run test:frontend:coverage && bun run test:backend:coverage", "test:frontend": "bun test", + "test:frontend:changed": "bun test --changed", "test:frontend:coverage": "bun scripts/runCoverage.ts 85 src/", "test:backend": "bun run --cwd backend test:backend", + "test:backend:changed": "bun run --cwd backend test:backend:changed", "test:backend:coverage": "bun run --cwd backend test:backend:coverage" }, "dependencies": { @@ -48,12 +45,12 @@ "@dnd-kit/react": "^0.5.0", "@dnd-kit/sortable": "^10.0.0", "@headlessui/react": "^2.2.10", - "@microlink/react-json-view": "^1.31.23", + "@microlink/react-json-view": "^1.31.24", "@simplewebauthn/browser": "13.3.0", "@tailwindcss/typography": "^0.5.20", "@tanstack/query-core": "5.101.4", - "@tanstack/query-db-collection": "^1.2.0", - "@tanstack/react-db": "^0.1.94", + "@tanstack/query-db-collection": "^1.2.1", + "@tanstack/react-db": "^0.1.95", "@tanstack/react-form": "^1.33.2", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", @@ -63,7 +60,7 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "json5": "^2.2.3", - "lucide-react": "^1.25.0", + "lucide-react": "^1.27.0", "qrcode.react": "4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -97,7 +94,7 @@ "@typescript/native": "npm:typescript@^7.0.2", "babel-plugin-react-compiler": "^1.0.0", "bun-plugin-tailwind": "^0.1.2", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.5.6", @@ -107,7 +104,7 @@ "eslint-plugin-simple-import-sort": "^14.0.0", "eslint-plugin-tailwindcss": "^4.2.0", "eslint-plugin-unicorn": "^72.0.0", - "globals": "^17.7.0", + "globals": "^17.8.0", "happy-dom": "^20.11.1", "prettier": "^3.9.6", "tailwindcss": "^4.3.3", diff --git a/scripts/developmentFrontend.ts b/scripts/developmentFrontend.ts index 01553a3ba..eb79b8d3e 100644 --- a/scripts/developmentFrontend.ts +++ b/scripts/developmentFrontend.ts @@ -11,7 +11,7 @@ const port = Number(process.env.PORT || "5173"); const apiTarget = process.env.DASHBOARD_API_TARGET || "http://127.0.0.1:3101"; const backendWebSocketTarget = apiTarget.replace(/^http/u, "ws"); const cookieNamespace = - process.env.MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE || "mira_dashboard_dev_5173"; + process.env.MIRA_DASHBOARD_DEV_COOKIE_NAMESPACE || `mira_dashboard_dev_${port}`; interface WebSocketProxyData { backend?: WebSocket; diff --git a/scripts/developmentTailscale.ts b/scripts/developmentTailscale.ts index 9e3c469f5..fc32e6089 100644 --- a/scripts/developmentTailscale.ts +++ b/scripts/developmentTailscale.ts @@ -25,17 +25,50 @@ interface DevelopmentTailscaleStatus { proxyTarget: string; } +const COMMAND_TIMEOUT_MS = 15_000; +const COMMAND_FORCE_KILL_GRACE_MS = 2000; + async function commandOutput(command: string[]): Promise { const process_ = Bun.spawn(command, { stderr: "pipe", stdin: "ignore", stdout: "pipe", }); - const [exitCode, stderr, stdout] = await Promise.all([ - process_.exited, - new Response(process_.stderr).text(), - new Response(process_.stdout).text(), - ]); + let didTimeout = false; + let forceKillTimer: Timer | undefined; + const timeout = setTimeout(() => { + didTimeout = true; + try { + process_.kill("SIGTERM"); + } catch { + // The command may already have exited at the timeout boundary. + } + forceKillTimer = setTimeout(() => { + try { + process_.kill("SIGKILL"); + } catch { + // The command may have exited during the force-kill grace period. + } + }, COMMAND_FORCE_KILL_GRACE_MS); + forceKillTimer.unref(); + }, COMMAND_TIMEOUT_MS); + timeout.unref(); + let exitCode: number; + let stderr: string; + let stdout: string; + try { + [exitCode, stderr, stdout] = await Promise.all([ + process_.exited, + new Response(process_.stderr).text(), + new Response(process_.stdout).text(), + ]); + } finally { + clearTimeout(timeout); + if (forceKillTimer) clearTimeout(forceKillTimer); + } + if (didTimeout) { + throw new Error(`${command[0]} timed out after ${COMMAND_TIMEOUT_MS}ms`); + } if (exitCode !== 0) { throw new Error( `${command[0]} exited ${exitCode}: ${stderr.trim() || stdout.trim()}` @@ -167,7 +200,15 @@ async function main(): Promise { return await runDevelopmentStack(config); } finally { if (route.didCreate) { - await disableDevelopmentServe(port); + try { + await disableDevelopmentServe(port); + } catch (error) { + console.error( + `Failed to remove the temporary Tailscale Serve route: ${ + error instanceof Error ? error.message : "unknown error" + }` + ); + } } } } diff --git a/src/components/features/pullRequests/PullRequestPreviewCard.tsx b/src/components/features/pullRequests/PullRequestPreviewCard.tsx index 4251beb32..c243d0d6e 100644 --- a/src/components/features/pullRequests/PullRequestPreviewCard.tsx +++ b/src/components/features/pullRequests/PullRequestPreviewCard.tsx @@ -1,8 +1,9 @@ -import { ExternalLink, MonitorPlay } from "lucide-react"; +import { ExternalLink, MonitorPlay, Square } from "lucide-react"; import type { PullRequestPreviewStatus } from "../../../hooks"; import { formatDate } from "../../../utils/format"; import { Badge } from "../../ui/Badge"; +import { Button } from "../../ui/Button"; import { Card, CardTitle } from "../../ui/Card"; function previewVariant(status: PullRequestPreviewStatus["status"]) { @@ -46,9 +47,13 @@ function previewLabel(status: PullRequestPreviewStatus["status"]): string { /** Renders the global single-slot trusted PR development status. */ export function PullRequestPreviewCard({ error, + isStopPending = false, + onStop, preview, }: { error?: Error; + isStopPending?: boolean; + onStop?: () => void; preview: PullRequestPreviewStatus | undefined; }) { const status = preview?.status ?? "stopped"; @@ -71,6 +76,17 @@ export function PullRequestPreviewCard({ {error ? "Status unavailable" : previewLabel(status)} + {hasPreview && status !== "stopped" && onStop ? ( + + ) : undefined}
diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index ef817946e..397bab0c1 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -162,7 +162,8 @@ export function AppHeader({ : "text-primary-400" } > - {workerStatus.label} {workerStatus.symbol} + {workerStatus.label.replace(/^Worker /u, "")}{" "} + {workerStatus.symbol}
{hasVersionMismatch ? ( diff --git a/src/hooks/usePullRequests.ts b/src/hooks/usePullRequests.ts index 5c7b224df..9392f85c0 100644 --- a/src/hooks/usePullRequests.ts +++ b/src/hooks/usePullRequests.ts @@ -24,6 +24,7 @@ export interface PullRequestSummary { headRefOid?: string; mergeable?: string; mergeStateStatus?: string; + previewEligible?: boolean; reviewDecision?: string; reviewerApproved?: boolean; canReviewerApprove?: boolean; diff --git a/src/pages/PullRequests.tsx b/src/pages/PullRequests.tsx index 7f6723d72..cc7608560 100644 --- a/src/pages/PullRequests.tsx +++ b/src/pages/PullRequests.tsx @@ -56,7 +56,7 @@ type PendingAction = | { type: "merge-deploy"; pr: PullRequestSummary } | { type: "review-approve"; pr: PullRequestSummary } | { type: "preview-start"; pr: PullRequestSummary } - | { type: "preview-stop"; pr: PullRequestSummary } + | { number: number; title?: string; type: "preview-stop" } | { type: "reject"; pr: PullRequestSummary } | { release: DashboardReleaseSummary; type: "rollback" } | { type: "deploy" }; @@ -82,7 +82,6 @@ const MIRA_AUTHOR = "mira-2026"; const DEFAULT_REVIEWER_AUTHOR = "rajohan"; const DEPENDABOT_AUTHOR = "app/dependabot"; const DEFAULT_BASE = "main"; -const PREVIEW_AUTHORS = new Set([MIRA_AUTHOR, DEFAULT_REVIEWER_AUTHOR]); const ACTIVE_PREVIEW_STATUSES = new Set([ "running", "starting", @@ -457,7 +456,8 @@ function actionMessage(action: Exclude) { return `Run PR #${action.pr.number} in dev: ${action.pr.title}?\n\nThis runs the trusted PR over Tailscale HTTPS with hot reload, an isolated Dashboard database, a writable workspace snapshot, and an isolated scheduler/worker without host or backup jobs. It connects to the live production Gateway so chat and session changes can affect production data. The dev environment stops automatically after four hours.`; } case "preview-stop": { - return `Stop PR dev for #${action.pr.number}: ${action.pr.title}?\n\nIts isolated database, workspace snapshot, and worktree are kept for a faster later restart.`; + const title = action.title ? `: ${action.title}` : ""; + return `Stop PR dev for #${action.number}${title}?\n\nIts isolated database, workspace snapshot, and worktree are kept for a faster later restart.`; } case "reject": { return `Reject PR #${action.pr.number}: ${action.pr.title}?\n\nThis closes the PR with a dashboard rejection comment. It does not delete the branch.`; @@ -676,6 +676,13 @@ export function PullRequests() { const deployBlockedReasonId = productionActionBlockedMessage ? "deploy-checkout-disabled-reason" : undefined; + const previewStopTarget = + previewStatus?.number === undefined + ? undefined + : { + number: previewStatus.number, + title: previewStatus.title, + }; const miraPullRequests = pullRequests.filter((pr) => isMiraPullRequest(pr)); const externalPullRequests = pullRequests.filter((pr) => !isMiraPullRequest(pr)); @@ -728,9 +735,9 @@ export function PullRequests() { case "preview-stop": { await stopPullRequestPreview.mutateAsync({ - number: action.pr.number, + number: action.number, }); - setLastResult(`PR #${action.pr.number} dev stopped`); + setLastResult(`PR #${action.number} dev stopped`); break; } @@ -768,10 +775,7 @@ export function PullRequests() { /** Renders trusted PR dev controls for an eligible pull request. */ function renderPullRequestPreviewActions(pr: PullRequestSummary) { - const author = pr.author?.login; - if (!author || pr.baseRefName !== DEFAULT_BASE || !PREVIEW_AUTHORS.has(author)) { - return; - } + if (pr.previewEligible !== true) return; const isPreviewSlotActive = previewStatus !== undefined && ACTIVE_PREVIEW_STATUSES.has(previewStatus.status); @@ -842,7 +846,13 @@ export function PullRequests() { {hasPullRequestPreviewSlot && previewStatus.status !== "stopped" ? (