From d0880a162361ae2136584a2379f3b55871e92cb8 Mon Sep 17 00:00:00 2001 From: Kyle Polley Date: Tue, 5 May 2026 10:38:20 -0500 Subject: [PATCH 1/5] Use dashboard for browser setup --- README.md | 4 +- .../environment-setup.ts | 43 ++++++++++++------- src/tui/screens/EnvironmentSetupScreen.tsx | 19 ++++++-- src/tui/screens/RunValidationScreen.tsx | 4 +- .../screens/ValidatorEnvironmentsScreen.tsx | 6 ++- src/validators/web-agent-browser/README.md | 8 +++- src/validators/web-agent-browser/index.ts | 24 ++++++----- 7 files changed, 74 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 336def3..c55f07c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ cd examples/webapp && bun run dev # http://localhost:3000 redai ``` -In RedAI, create a Browser environment pointed at `http://localhost:3000`, sign in once with `exampleuser` / `examplepassword`, mark it ready, then start a scan against `examples/webapp`. Watch the validators drive Chrome to confirm real findings. +In RedAI, create a Browser environment pointed at `http://localhost:3000`, open the `agent-browser` dashboard at `http://127.0.0.1:4848`, sign in once with `exampleuser` / `examplepassword`, mark it ready, then start a scan against `examples/webapp`. Watch the validators drive Chrome to confirm real findings. The full report from a real scan of this app lives at [`examples/webapp/example-report.md`](./examples/webapp/example-report.md) — GitHub renders it inline so you can see what RedAI produces without running it. @@ -104,7 +104,7 @@ New scans can only use environments marked `ready`. Once a scan starts, validato Two environments ship in the box as reference implementations: -- **Browser** — a real Chrome instance driven via [`agent-browser`](https://github.com/vercel-labs/agent-browser). See [`src/validators/web-agent-browser/README.md`](./src/validators/web-agent-browser/README.md). +- **Browser** — a real Chrome instance driven via [`agent-browser`](https://github.com/vercel-labs/agent-browser) and visible through the dashboard, which can be port-forwarded from remote hosts. See [`src/validators/web-agent-browser/README.md`](./src/validators/web-agent-browser/README.md). - **iOS Simulator** — a per-scan template simulator driven via `xcrun simctl`. See [`src/validators/ios-simulator/README.md`](./src/validators/ios-simulator/README.md). Want to validate against a Linux VM, an Android emulator, a remote staging cluster, or something more exotic? Add a plugin — same interface as the bundled two. diff --git a/src/pipeline/validator-environments/environment-setup.ts b/src/pipeline/validator-environments/environment-setup.ts index 71cbfae..c185d69 100644 --- a/src/pipeline/validator-environments/environment-setup.ts +++ b/src/pipeline/validator-environments/environment-setup.ts @@ -1,5 +1,6 @@ import { execFile, spawn } from "node:child_process"; import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import type { ValidatorEnvironment } from "../../domain"; @@ -31,32 +32,44 @@ async function openBrowserEnvironmentSetup(environment: ValidatorEnvironment): P if (!appUrl || !profilePath) throw new Error("Browser environment requires app URL and profile path."); + const env = browserSetupEnvironment(environment); await mkdir(profilePath, { recursive: true }); - if (process.platform === "darwin") { - spawn("open", ["-na", "Google Chrome", "--args", `--user-data-dir=${profilePath}`, appUrl], { - detached: true, - stdio: "ignore", - }).unref(); - return; - } - - const browser = process.platform === "win32" ? "chrome" : "google-chrome"; - spawn(browser, [`--user-data-dir=${profilePath}`, appUrl], { - detached: true, - stdio: "ignore", - }).unref(); + await mkdir(browserSetupHome(profilePath), { recursive: true }); + await execFileAsync("agent-browser", ["dashboard", "start"], { env, timeout: 10_000 }); + await execFileAsync("agent-browser", ["open", appUrl], { env, timeout: 60_000 }); } async function closeBrowserEnvironmentSetup(environment: ValidatorEnvironment): Promise { const profilePath = environment.browser?.profilePath; if (!profilePath) return; + const env = browserSetupEnvironment(environment); + try { + await execFileAsync("agent-browser", ["close"], { env, timeout: 10_000 }); + } catch { + // The setup session may already be closed. + } try { - await execFileAsync("pkill", ["-f", `--user-data-dir=${profilePath}`]); + await execFileAsync("agent-browser", ["dashboard", "stop"], { env, timeout: 10_000 }); } catch { - // Browser may already be closed. + // The dashboard may already be stopped. } } +function browserSetupEnvironment(environment: ValidatorEnvironment): NodeJS.ProcessEnv { + const profilePath = environment.browser?.profilePath ?? ""; + return { + ...process.env, + AGENT_BROWSER_HOME: browserSetupHome(profilePath), + AGENT_BROWSER_PROFILE: profilePath, + AGENT_BROWSER_SESSION: environment.id, + AGENT_BROWSER_SESSION_NAME: environment.id, + }; +} + +function browserSetupHome(profilePath: string): string { + return join(dirname(profilePath), "agent-browser-home"); +} + async function openIosEnvironmentSetup( environment: ValidatorEnvironment, ): Promise { diff --git a/src/tui/screens/EnvironmentSetupScreen.tsx b/src/tui/screens/EnvironmentSetupScreen.tsx index 124ec0b..0672684 100644 --- a/src/tui/screens/EnvironmentSetupScreen.tsx +++ b/src/tui/screens/EnvironmentSetupScreen.tsx @@ -35,6 +35,7 @@ function SetupDetails({ environment }: { environment: ValidatorEnvironment }) { {environment.kind === "browser" ? ( Profile: {environment.browser?.profilePath} ) : null} + {environment.kind === "browser" ? Dashboard: http://127.0.0.1:4848 : null} {environment.kind === "ios-simulator" ? ( App path: {environment.ios?.appPath || "not set"} ) : null} @@ -49,10 +50,20 @@ function SetupDetails({ environment }: { environment: ValidatorEnvironment }) { ) : null} - - A setup {environment.kind === "browser" ? "browser" : "simulator app"} should be open. Log - in and prepare the app state there. - + {environment.kind === "browser" ? ( + <> + + Open the agent-browser dashboard and prepare the app state there. + + + For a remote host, forward it with: ssh -N -L 4848:127.0.0.1:4848 user@remote + + + ) : ( + + A setup simulator app should be open. Prepare the app state there. + + )} Press R when ready. Press O to reopen setup. Press Esc to leave it in setup state. diff --git a/src/tui/screens/RunValidationScreen.tsx b/src/tui/screens/RunValidationScreen.tsx index b5d6c58..adee0b1 100644 --- a/src/tui/screens/RunValidationScreen.tsx +++ b/src/tui/screens/RunValidationScreen.tsx @@ -66,7 +66,9 @@ function ValidationDetails({ state, job }: { state: ScanRunState; job: Validatio {job.simulatorName ? Simulator: {job.simulatorName} : null} {job.simulatorUdid ? Simulator UDID: {job.simulatorUdid} : null} {job.status === "running" && job.agentBrowserHome ? ( - Press B to open the live agent-browser dashboard. + + Press B to open the live dashboard, or forward port 4848 from a remote host. + ) : null}
diff --git a/src/tui/screens/ValidatorEnvironmentsScreen.tsx b/src/tui/screens/ValidatorEnvironmentsScreen.tsx index 66fcfd9..0a55e71 100644 --- a/src/tui/screens/ValidatorEnvironmentsScreen.tsx +++ b/src/tui/screens/ValidatorEnvironmentsScreen.tsx @@ -55,6 +55,7 @@ function EnvironmentDetails({ environment }: { environment: ValidatorEnvironment {environment.kind === "browser" ? ( Profile: {environment.browser?.profilePath} ) : null} + {environment.kind === "browser" ? Dashboard: http://127.0.0.1:4848 : null} {environment.kind === "ios-simulator" ? ( App path: {environment.ios?.appPath || "not set"} ) : null} @@ -66,7 +67,10 @@ function EnvironmentDetails({ environment }: { environment: ValidatorEnvironment Environments are prepared browser or simulator states used during validation. - Interactive setup and per-run cloning comes next. + + Browser setup runs through the agent-browser dashboard, which can be port-forwarded from + remote hosts. + ); diff --git a/src/validators/web-agent-browser/README.md b/src/validators/web-agent-browser/README.md index 34cdd83..48a2254 100644 --- a/src/validators/web-agent-browser/README.md +++ b/src/validators/web-agent-browser/README.md @@ -22,7 +22,13 @@ Browser environments store: - optional auth/setup notes - status: `draft`, `setup`, `ready`, or `failed` -Creating a browser environment opens Chrome with an isolated profile. Use that browser window to log in, seed app state, or otherwise prepare the target application. Return to RedAI and press `R` to mark the environment ready. +Creating a browser environment starts an `agent-browser` session with an isolated profile and serves the live dashboard at `http://127.0.0.1:4848`. Use that dashboard to log in, seed app state, or otherwise prepare the target application. If RedAI is running on a remote host, forward the dashboard to your machine: + +```sh +ssh -N -L 4848:127.0.0.1:4848 user@remote +``` + +Return to RedAI and press `R` to mark the environment ready. ## Validation Behavior diff --git a/src/validators/web-agent-browser/index.ts b/src/validators/web-agent-browser/index.ts index af21f70..d493b26 100644 --- a/src/validators/web-agent-browser/index.ts +++ b/src/validators/web-agent-browser/index.ts @@ -335,13 +335,8 @@ function formatSchemaError(stage: string, error: z.ZodError): string { export async function browserSkillReadiness(): Promise<{ ready: boolean; reason: string }> { const globalSkill = join(homedir(), ".claude/skills/agent-browser/SKILL.md"); - if (!existsSync(".agents/skills/agent-browser/SKILL.md") && !existsSync(globalSkill)) { - return { - ready: false, - reason: - "agent-browser skill is not installed under .agents/skills or ~/.claude/skills.", - }; - } + const hasSkillDocs = + existsSync(".agents/skills/agent-browser/SKILL.md") || existsSync(globalSkill); try { await execFileAsync("agent-browser", ["--version"]); @@ -352,10 +347,19 @@ export async function browserSkillReadiness(): Promise<{ ready: boolean; reason: try { await execFileAsync("agent-browser", ["skills", "get", "agent-browser"], { timeout: 5000 }); } catch { + try { + await execFileAsync("agent-browser", ["--help"], { timeout: 5000 }); + } catch { + return { + ready: false, + reason: "agent-browser CLI is installed, but command help could not be loaded.", + }; + } return { - ready: false, - reason: - "agent-browser CLI is installed, but it does not support `agent-browser skills get agent-browser`; upgrade agent-browser.", + ready: true, + reason: hasSkillDocs + ? "agent-browser CLI is ready; this version does not support `agent-browser skills get`, so agents will fall back to CLI help." + : "agent-browser CLI is ready; skill docs are not installed, so agents will fall back to CLI help.", }; } From 1458e528a4b2456ab09bef430f2077f284317f81 Mon Sep 17 00:00:00 2001 From: Kyle Polley Date: Tue, 5 May 2026 16:29:48 -0500 Subject: [PATCH 2/5] Pin browser setup stream port --- README.md | 2 +- .../validator-environments/environment-setup.ts | 12 +++++++++++- src/tui/screens/EnvironmentSetupScreen.tsx | 3 ++- src/tui/screens/RunValidationScreen.tsx | 3 ++- src/tui/screens/ValidatorEnvironmentsScreen.tsx | 2 +- src/validators/web-agent-browser/README.md | 4 ++-- 6 files changed, 19 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c55f07c..f4aff3f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ cd examples/webapp && bun run dev # http://localhost:3000 redai ``` -In RedAI, create a Browser environment pointed at `http://localhost:3000`, open the `agent-browser` dashboard at `http://127.0.0.1:4848`, sign in once with `exampleuser` / `examplepassword`, mark it ready, then start a scan against `examples/webapp`. Watch the validators drive Chrome to confirm real findings. +In RedAI, create a Browser environment pointed at `http://localhost:3000`, open the `agent-browser` dashboard at `http://127.0.0.1:4848`, sign in once with `exampleuser` / `examplepassword`, mark it ready, then start a scan against `examples/webapp`. On a remote host, forward both dashboard ports with `ssh -N -L 4848:127.0.0.1:4848 -L 4849:127.0.0.1:4849 user@remote`. Watch the validators drive Chrome to confirm real findings. The full report from a real scan of this app lives at [`examples/webapp/example-report.md`](./examples/webapp/example-report.md) — GitHub renders it inline so you can see what RedAI produces without running it. diff --git a/src/pipeline/validator-environments/environment-setup.ts b/src/pipeline/validator-environments/environment-setup.ts index c185d69..f3e08f1 100644 --- a/src/pipeline/validator-environments/environment-setup.ts +++ b/src/pipeline/validator-environments/environment-setup.ts @@ -5,6 +5,8 @@ import { promisify } from "node:util"; import type { ValidatorEnvironment } from "../../domain"; const execFileAsync = promisify(execFile); +const browserSetupDashboardPort = "4848"; +const browserSetupStreamPort = "4849"; export async function openValidatorEnvironmentSetup( environment: ValidatorEnvironment, @@ -35,7 +37,14 @@ async function openBrowserEnvironmentSetup(environment: ValidatorEnvironment): P const env = browserSetupEnvironment(environment); await mkdir(profilePath, { recursive: true }); await mkdir(browserSetupHome(profilePath), { recursive: true }); - await execFileAsync("agent-browser", ["dashboard", "start"], { env, timeout: 10_000 }); + await execFileAsync( + "agent-browser", + ["dashboard", "start", "--port", browserSetupDashboardPort], + { + env, + timeout: 10_000, + }, + ); await execFileAsync("agent-browser", ["open", appUrl], { env, timeout: 60_000 }); } @@ -63,6 +72,7 @@ function browserSetupEnvironment(environment: ValidatorEnvironment): NodeJS.Proc AGENT_BROWSER_PROFILE: profilePath, AGENT_BROWSER_SESSION: environment.id, AGENT_BROWSER_SESSION_NAME: environment.id, + AGENT_BROWSER_STREAM_PORT: browserSetupStreamPort, }; } diff --git a/src/tui/screens/EnvironmentSetupScreen.tsx b/src/tui/screens/EnvironmentSetupScreen.tsx index 0672684..8bbd148 100644 --- a/src/tui/screens/EnvironmentSetupScreen.tsx +++ b/src/tui/screens/EnvironmentSetupScreen.tsx @@ -56,7 +56,8 @@ function SetupDetails({ environment }: { environment: ValidatorEnvironment }) { Open the agent-browser dashboard and prepare the app state there. - For a remote host, forward it with: ssh -N -L 4848:127.0.0.1:4848 user@remote + For a remote host, forward it with: ssh -N -L 4848:127.0.0.1:4848 -L + 4849:127.0.0.1:4849 user@remote ) : ( diff --git a/src/tui/screens/RunValidationScreen.tsx b/src/tui/screens/RunValidationScreen.tsx index adee0b1..684e193 100644 --- a/src/tui/screens/RunValidationScreen.tsx +++ b/src/tui/screens/RunValidationScreen.tsx @@ -67,7 +67,8 @@ function ValidationDetails({ state, job }: { state: ScanRunState; job: Validatio {job.simulatorUdid ? Simulator UDID: {job.simulatorUdid} : null} {job.status === "running" && job.agentBrowserHome ? ( - Press B to open the live dashboard, or forward port 4848 from a remote host. + Press B to open the live dashboard. On remote hosts, forward the dashboard and stream + ports. ) : null} diff --git a/src/tui/screens/ValidatorEnvironmentsScreen.tsx b/src/tui/screens/ValidatorEnvironmentsScreen.tsx index 0a55e71..16c4c72 100644 --- a/src/tui/screens/ValidatorEnvironmentsScreen.tsx +++ b/src/tui/screens/ValidatorEnvironmentsScreen.tsx @@ -68,7 +68,7 @@ function EnvironmentDetails({ environment }: { environment: ValidatorEnvironment Environments are prepared browser or simulator states used during validation. - Browser setup runs through the agent-browser dashboard, which can be port-forwarded from + Browser setup runs through the agent-browser dashboard. Forward ports 4848 and 4849 from remote hosts. diff --git a/src/validators/web-agent-browser/README.md b/src/validators/web-agent-browser/README.md index 48a2254..25d23db 100644 --- a/src/validators/web-agent-browser/README.md +++ b/src/validators/web-agent-browser/README.md @@ -22,10 +22,10 @@ Browser environments store: - optional auth/setup notes - status: `draft`, `setup`, `ready`, or `failed` -Creating a browser environment starts an `agent-browser` session with an isolated profile and serves the live dashboard at `http://127.0.0.1:4848`. Use that dashboard to log in, seed app state, or otherwise prepare the target application. If RedAI is running on a remote host, forward the dashboard to your machine: +Creating a browser environment starts an `agent-browser` session with an isolated profile and serves the live dashboard at `http://127.0.0.1:4848`. The setup session streams its live viewport on port `4849`. Use that dashboard to log in, seed app state, or otherwise prepare the target application. If RedAI is running on a remote host, forward both ports to your machine: ```sh -ssh -N -L 4848:127.0.0.1:4848 user@remote +ssh -N -L 4848:127.0.0.1:4848 -L 4849:127.0.0.1:4849 user@remote ``` Return to RedAI and press `R` to mark the environment ready. From 199d8c1eea92524956b914fd254ec360f9e6db1b Mon Sep 17 00:00:00 2001 From: Kyle Polley Date: Tue, 5 May 2026 16:37:54 -0500 Subject: [PATCH 3/5] Select setup stream in dashboard --- README.md | 2 +- src/tui/screens/EnvironmentSetupScreen.tsx | 4 +++- src/tui/screens/ValidatorEnvironmentsScreen.tsx | 4 +++- src/validators/web-agent-browser/README.md | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f4aff3f..b0d8784 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ cd examples/webapp && bun run dev # http://localhost:3000 redai ``` -In RedAI, create a Browser environment pointed at `http://localhost:3000`, open the `agent-browser` dashboard at `http://127.0.0.1:4848`, sign in once with `exampleuser` / `examplepassword`, mark it ready, then start a scan against `examples/webapp`. On a remote host, forward both dashboard ports with `ssh -N -L 4848:127.0.0.1:4848 -L 4849:127.0.0.1:4849 user@remote`. Watch the validators drive Chrome to confirm real findings. +In RedAI, create a Browser environment pointed at `http://localhost:3000`, open the `agent-browser` dashboard at `http://127.0.0.1:4848/?port=4849`, sign in once with `exampleuser` / `examplepassword`, mark it ready, then start a scan against `examples/webapp`. On a remote host, forward both dashboard ports with `ssh -N -L 4848:127.0.0.1:4848 -L 4849:127.0.0.1:4849 user@remote`. Watch the validators drive Chrome to confirm real findings. The full report from a real scan of this app lives at [`examples/webapp/example-report.md`](./examples/webapp/example-report.md) — GitHub renders it inline so you can see what RedAI produces without running it. diff --git a/src/tui/screens/EnvironmentSetupScreen.tsx b/src/tui/screens/EnvironmentSetupScreen.tsx index 8bbd148..5d0de05 100644 --- a/src/tui/screens/EnvironmentSetupScreen.tsx +++ b/src/tui/screens/EnvironmentSetupScreen.tsx @@ -35,7 +35,9 @@ function SetupDetails({ environment }: { environment: ValidatorEnvironment }) { {environment.kind === "browser" ? ( Profile: {environment.browser?.profilePath} ) : null} - {environment.kind === "browser" ? Dashboard: http://127.0.0.1:4848 : null} + {environment.kind === "browser" ? ( + Dashboard: http://127.0.0.1:4848/?port=4849 + ) : null} {environment.kind === "ios-simulator" ? ( App path: {environment.ios?.appPath || "not set"} ) : null} diff --git a/src/tui/screens/ValidatorEnvironmentsScreen.tsx b/src/tui/screens/ValidatorEnvironmentsScreen.tsx index 16c4c72..1a7f1b2 100644 --- a/src/tui/screens/ValidatorEnvironmentsScreen.tsx +++ b/src/tui/screens/ValidatorEnvironmentsScreen.tsx @@ -55,7 +55,9 @@ function EnvironmentDetails({ environment }: { environment: ValidatorEnvironment {environment.kind === "browser" ? ( Profile: {environment.browser?.profilePath} ) : null} - {environment.kind === "browser" ? Dashboard: http://127.0.0.1:4848 : null} + {environment.kind === "browser" ? ( + Dashboard: http://127.0.0.1:4848/?port=4849 + ) : null} {environment.kind === "ios-simulator" ? ( App path: {environment.ios?.appPath || "not set"} ) : null} diff --git a/src/validators/web-agent-browser/README.md b/src/validators/web-agent-browser/README.md index 25d23db..5f2931e 100644 --- a/src/validators/web-agent-browser/README.md +++ b/src/validators/web-agent-browser/README.md @@ -22,7 +22,7 @@ Browser environments store: - optional auth/setup notes - status: `draft`, `setup`, `ready`, or `failed` -Creating a browser environment starts an `agent-browser` session with an isolated profile and serves the live dashboard at `http://127.0.0.1:4848`. The setup session streams its live viewport on port `4849`. Use that dashboard to log in, seed app state, or otherwise prepare the target application. If RedAI is running on a remote host, forward both ports to your machine: +Creating a browser environment starts an `agent-browser` session with an isolated profile and serves the live dashboard at `http://127.0.0.1:4848/?port=4849`. The setup session streams its live viewport on port `4849`, and the `?port=4849` query selects that RedAI session even when other `agent-browser` sessions are still active. Use that dashboard to log in, seed app state, or otherwise prepare the target application. If RedAI is running on a remote host, forward both ports to your machine: ```sh ssh -N -L 4848:127.0.0.1:4848 -L 4849:127.0.0.1:4849 user@remote From 32f688ba4c0788e41657e4c31d1fdc52c6948597 Mon Sep 17 00:00:00 2001 From: Kyle Polley Date: Tue, 5 May 2026 16:49:08 -0500 Subject: [PATCH 4/5] Add RedAI browser setup dashboard --- README.md | 4 +- .../browser-setup-dashboard.ts | 397 ++++++++++++++++++ .../environment-setup.ts | 29 +- src/tui/screens/EnvironmentSetupScreen.tsx | 9 +- src/tui/screens/RunValidationScreen.tsx | 4 +- .../screens/ValidatorEnvironmentsScreen.tsx | 7 +- src/validators/web-agent-browser/README.md | 4 +- 7 files changed, 427 insertions(+), 27 deletions(-) create mode 100644 src/pipeline/validator-environments/browser-setup-dashboard.ts diff --git a/README.md b/README.md index b0d8784..b7800b9 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ cd examples/webapp && bun run dev # http://localhost:3000 redai ``` -In RedAI, create a Browser environment pointed at `http://localhost:3000`, open the `agent-browser` dashboard at `http://127.0.0.1:4848/?port=4849`, sign in once with `exampleuser` / `examplepassword`, mark it ready, then start a scan against `examples/webapp`. On a remote host, forward both dashboard ports with `ssh -N -L 4848:127.0.0.1:4848 -L 4849:127.0.0.1:4849 user@remote`. Watch the validators drive Chrome to confirm real findings. +In RedAI, create a Browser environment pointed at `http://localhost:3000`, open RedAI's setup dashboard at `http://127.0.0.1:4848`, sign in once with `exampleuser` / `examplepassword`, mark it ready, then start a scan against `examples/webapp`. On a remote host, forward the setup dashboard with `ssh -N -L 4848:127.0.0.1:4848 user@remote`. Watch the validators drive Chrome to confirm real findings. The full report from a real scan of this app lives at [`examples/webapp/example-report.md`](./examples/webapp/example-report.md) — GitHub renders it inline so you can see what RedAI produces without running it. @@ -104,7 +104,7 @@ New scans can only use environments marked `ready`. Once a scan starts, validato Two environments ship in the box as reference implementations: -- **Browser** — a real Chrome instance driven via [`agent-browser`](https://github.com/vercel-labs/agent-browser) and visible through the dashboard, which can be port-forwarded from remote hosts. See [`src/validators/web-agent-browser/README.md`](./src/validators/web-agent-browser/README.md). +- **Browser** — a real Chrome instance driven via [`agent-browser`](https://github.com/vercel-labs/agent-browser) and prepared through RedAI's lightweight setup dashboard, which can be port-forwarded from remote hosts. See [`src/validators/web-agent-browser/README.md`](./src/validators/web-agent-browser/README.md). - **iOS Simulator** — a per-scan template simulator driven via `xcrun simctl`. See [`src/validators/ios-simulator/README.md`](./src/validators/ios-simulator/README.md). Want to validate against a Linux VM, an Android emulator, a remote staging cluster, or something more exotic? Add a plugin — same interface as the bundled two. diff --git a/src/pipeline/validator-environments/browser-setup-dashboard.ts b/src/pipeline/validator-environments/browser-setup-dashboard.ts new file mode 100644 index 0000000..c784b75 --- /dev/null +++ b/src/pipeline/validator-environments/browser-setup-dashboard.ts @@ -0,0 +1,397 @@ +import { execFile } from "node:child_process"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +interface BrowserSetupDashboardInput { + id: string; + appUrl: string; + env: NodeJS.ProcessEnv; + workDir: string; + port: number; +} + +interface BrowserSetupDashboard { + id: string; + close(): Promise; +} + +const dashboards = new Map(); + +export async function startBrowserSetupDashboard(input: BrowserSetupDashboardInput): Promise { + for (const [id, dashboard] of dashboards) { + if (id !== input.id) { + await dashboard.close(); + dashboards.delete(id); + } + } + await dashboards.get(input.id)?.close(); + dashboards.delete(input.id); + + const dashboard = await createBrowserSetupDashboard(input); + dashboards.set(input.id, dashboard); +} + +export async function stopBrowserSetupDashboard(id: string): Promise { + const dashboard = dashboards.get(id); + if (!dashboard) return; + dashboards.delete(id); + await dashboard.close(); +} + +async function createBrowserSetupDashboard( + input: BrowserSetupDashboardInput, +): Promise { + await mkdir(input.workDir, { recursive: true }); + const screenshotPath = join(input.workDir, "screenshot.png"); + const run = createCommandQueue(input.env); + const server = createServer((request, response) => { + void handleRequest({ + request, + response, + input, + run, + screenshotPath, + }); + }); + + await listen(server, input.port); + return { + id: input.id, + close: () => closeServer(server), + }; +} + +function createCommandQueue(env: NodeJS.ProcessEnv) { + let queue = Promise.resolve(); + return async function run(args: string[], timeout = 20_000): Promise { + const work = queue.then(async () => { + const { stdout } = await execFileAsync("agent-browser", args, { + env, + timeout, + maxBuffer: 20 * 1024 * 1024, + }); + return stdout.trim(); + }); + queue = work.then( + () => undefined, + () => undefined, + ); + return work; + }; +} + +async function handleRequest({ + request, + response, + input, + run, + screenshotPath, +}: { + request: IncomingMessage; + response: ServerResponse; + input: BrowserSetupDashboardInput; + run: (args: string[], timeout?: number) => Promise; + screenshotPath: string; +}): Promise { + try { + const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`); + if (request.method === "GET" && url.pathname === "/") { + sendHtml(response, dashboardHtml(input.appUrl)); + return; + } + if (request.method === "GET" && url.pathname === "/screenshot") { + await run(["screenshot", screenshotPath], 30_000); + const image = await readFile(screenshotPath); + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": "image/png", + }); + response.end(image); + return; + } + if (request.method === "GET" && url.pathname === "/api/status") { + const currentUrl = await run(["get", "url"]); + const title = await run(["get", "title"]); + sendJson(response, { ok: true, url: currentUrl, title }); + return; + } + if (request.method === "POST" && url.pathname === "/api/navigate") { + const body = await readJsonBody(request); + const targetUrl = stringBodyValue(body, "url"); + if (!targetUrl) throw new Error("Missing URL."); + await run(["open", targetUrl], 60_000); + sendJson(response, { ok: true }); + return; + } + if (request.method === "POST" && url.pathname === "/api/click") { + const body = await readJsonBody(request); + const x = numberBodyValue(body, "x"); + const y = numberBodyValue(body, "y"); + if (x === undefined || y === undefined) throw new Error("Missing click coordinates."); + await run(["mouse", "move", String(Math.round(x)), String(Math.round(y))]); + await run(["mouse", "down"]); + await run(["mouse", "up"]); + sendJson(response, { ok: true }); + return; + } + if (request.method === "POST" && url.pathname === "/api/type") { + const body = await readJsonBody(request); + const text = stringBodyValue(body, "text"); + if (!text) throw new Error("Missing text."); + await run(["keyboard", "type", text]); + sendJson(response, { ok: true }); + return; + } + if (request.method === "POST" && url.pathname === "/api/key") { + const body = await readJsonBody(request); + const key = stringBodyValue(body, "key"); + if (!key) throw new Error("Missing key."); + await run(["press", key]); + sendJson(response, { ok: true }); + return; + } + if (request.method === "POST" && url.pathname === "/api/scroll") { + const body = await readJsonBody(request); + const dy = numberBodyValue(body, "dy"); + await run(["mouse", "wheel", String(Math.round(dy ?? 0))]); + sendJson(response, { ok: true }); + return; + } + if ( + request.method === "POST" && + ["/api/back", "/api/forward", "/api/reload"].includes(url.pathname) + ) { + await run([url.pathname.replace("/api/", "")]); + sendJson(response, { ok: true }); + return; + } + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found"); + } catch (error) { + sendJson( + response, + { ok: false, error: error instanceof Error ? error.message : String(error) }, + 500, + ); + } +} + +function dashboardHtml(appUrl: string): string { + return ` + + + + + RedAI Browser Setup + + + +
+ + + + + +
+
+
+ + + + + + + +
+
+ Browser screenshot +
+
+ Loading screenshot... + Click the screenshot, then type or press keys from the toolbar. +
+
+ + +`; +} + +async function readJsonBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > 128 * 1024) throw new Error("Request body is too large."); + chunks.push(buffer); + } + if (chunks.length === 0) return {}; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function stringBodyValue(body: unknown, key: string): string | undefined { + if (!body || typeof body !== "object" || !(key in body)) return undefined; + const value = (body as Record)[key]; + return typeof value === "string" ? value : undefined; +} + +function numberBodyValue(body: unknown, key: string): number | undefined { + if (!body || typeof body !== "object" || !(key in body)) return undefined; + const value = (body as Record)[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function sendHtml(response: ServerResponse, html: string): void { + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": "text/html; charset=utf-8", + }); + response.end(html); +} + +function sendJson(response: ServerResponse, value: unknown, status = 200): void { + response.writeHead(status, { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + }); + response.end(JSON.stringify(value)); +} + +function listen(server: Server, port: number): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + if ((error as NodeJS.ErrnoException).code === "EADDRINUSE") { + reject( + new Error( + `RedAI browser setup dashboard port ${port} is already in use. Stop the process using that port and reopen environment setup.`, + ), + ); + return; + } + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, "127.0.0.1"); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/pipeline/validator-environments/environment-setup.ts b/src/pipeline/validator-environments/environment-setup.ts index f3e08f1..9bcb638 100644 --- a/src/pipeline/validator-environments/environment-setup.ts +++ b/src/pipeline/validator-environments/environment-setup.ts @@ -3,10 +3,10 @@ import { mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import type { ValidatorEnvironment } from "../../domain"; +import { startBrowserSetupDashboard, stopBrowserSetupDashboard } from "./browser-setup-dashboard"; const execFileAsync = promisify(execFile); const browserSetupDashboardPort = "4848"; -const browserSetupStreamPort = "4849"; export async function openValidatorEnvironmentSetup( environment: ValidatorEnvironment, @@ -37,21 +37,31 @@ async function openBrowserEnvironmentSetup(environment: ValidatorEnvironment): P const env = browserSetupEnvironment(environment); await mkdir(profilePath, { recursive: true }); await mkdir(browserSetupHome(profilePath), { recursive: true }); - await execFileAsync( - "agent-browser", - ["dashboard", "start", "--port", browserSetupDashboardPort], - { - env, - timeout: 10_000, - }, - ); await execFileAsync("agent-browser", ["open", appUrl], { env, timeout: 60_000 }); + await execFileAsync("agent-browser", ["set", "viewport", "1280", "720"], { + env, + timeout: 10_000, + }); + await execFileAsync("agent-browser", ["open", appUrl], { env, timeout: 60_000 }); + try { + await startBrowserSetupDashboard({ + id: environment.id, + appUrl, + env, + workDir: join(dirname(profilePath), "setup-dashboard"), + port: Number(browserSetupDashboardPort), + }); + } catch (error) { + await execFileAsync("agent-browser", ["close"], { env, timeout: 10_000 }).catch(() => {}); + throw error; + } } async function closeBrowserEnvironmentSetup(environment: ValidatorEnvironment): Promise { const profilePath = environment.browser?.profilePath; if (!profilePath) return; const env = browserSetupEnvironment(environment); + await stopBrowserSetupDashboard(environment.id); try { await execFileAsync("agent-browser", ["close"], { env, timeout: 10_000 }); } catch { @@ -72,7 +82,6 @@ function browserSetupEnvironment(environment: ValidatorEnvironment): NodeJS.Proc AGENT_BROWSER_PROFILE: profilePath, AGENT_BROWSER_SESSION: environment.id, AGENT_BROWSER_SESSION_NAME: environment.id, - AGENT_BROWSER_STREAM_PORT: browserSetupStreamPort, }; } diff --git a/src/tui/screens/EnvironmentSetupScreen.tsx b/src/tui/screens/EnvironmentSetupScreen.tsx index 5d0de05..04e0ce0 100644 --- a/src/tui/screens/EnvironmentSetupScreen.tsx +++ b/src/tui/screens/EnvironmentSetupScreen.tsx @@ -35,9 +35,7 @@ function SetupDetails({ environment }: { environment: ValidatorEnvironment }) { {environment.kind === "browser" ? ( Profile: {environment.browser?.profilePath} ) : null} - {environment.kind === "browser" ? ( - Dashboard: http://127.0.0.1:4848/?port=4849 - ) : null} + {environment.kind === "browser" ? Dashboard: http://127.0.0.1:4848 : null} {environment.kind === "ios-simulator" ? ( App path: {environment.ios?.appPath || "not set"} ) : null} @@ -55,11 +53,10 @@ function SetupDetails({ environment }: { environment: ValidatorEnvironment }) { {environment.kind === "browser" ? ( <> - Open the agent-browser dashboard and prepare the app state there. + Open the RedAI browser setup dashboard and prepare the app state there. - For a remote host, forward it with: ssh -N -L 4848:127.0.0.1:4848 -L - 4849:127.0.0.1:4849 user@remote + For a remote host, forward it with: ssh -N -L 4848:127.0.0.1:4848 user@remote ) : ( diff --git a/src/tui/screens/RunValidationScreen.tsx b/src/tui/screens/RunValidationScreen.tsx index 684e193..85fcd04 100644 --- a/src/tui/screens/RunValidationScreen.tsx +++ b/src/tui/screens/RunValidationScreen.tsx @@ -67,8 +67,8 @@ function ValidationDetails({ state, job }: { state: ScanRunState; job: Validatio {job.simulatorUdid ? Simulator UDID: {job.simulatorUdid} : null} {job.status === "running" && job.agentBrowserHome ? ( - Press B to open the live dashboard. On remote hosts, forward the dashboard and stream - ports. + Press B to open the live agent-browser dashboard. Validation uses agent-browser's + dashboard. ) : null} diff --git a/src/tui/screens/ValidatorEnvironmentsScreen.tsx b/src/tui/screens/ValidatorEnvironmentsScreen.tsx index 1a7f1b2..77c9fe9 100644 --- a/src/tui/screens/ValidatorEnvironmentsScreen.tsx +++ b/src/tui/screens/ValidatorEnvironmentsScreen.tsx @@ -55,9 +55,7 @@ function EnvironmentDetails({ environment }: { environment: ValidatorEnvironment {environment.kind === "browser" ? ( Profile: {environment.browser?.profilePath} ) : null} - {environment.kind === "browser" ? ( - Dashboard: http://127.0.0.1:4848/?port=4849 - ) : null} + {environment.kind === "browser" ? Dashboard: http://127.0.0.1:4848 : null} {environment.kind === "ios-simulator" ? ( App path: {environment.ios?.appPath || "not set"} ) : null} @@ -70,8 +68,7 @@ function EnvironmentDetails({ environment }: { environment: ValidatorEnvironment Environments are prepared browser or simulator states used during validation. - Browser setup runs through the agent-browser dashboard. Forward ports 4848 and 4849 from - remote hosts. + Browser setup runs through RedAI's setup dashboard. Forward port 4848 from remote hosts. diff --git a/src/validators/web-agent-browser/README.md b/src/validators/web-agent-browser/README.md index 5f2931e..34acb6f 100644 --- a/src/validators/web-agent-browser/README.md +++ b/src/validators/web-agent-browser/README.md @@ -22,10 +22,10 @@ Browser environments store: - optional auth/setup notes - status: `draft`, `setup`, `ready`, or `failed` -Creating a browser environment starts an `agent-browser` session with an isolated profile and serves the live dashboard at `http://127.0.0.1:4848/?port=4849`. The setup session streams its live viewport on port `4849`, and the `?port=4849` query selects that RedAI session even when other `agent-browser` sessions are still active. Use that dashboard to log in, seed app state, or otherwise prepare the target application. If RedAI is running on a remote host, forward both ports to your machine: +Creating a browser environment starts an `agent-browser` session with an isolated profile and serves RedAI's lightweight setup dashboard at `http://127.0.0.1:4848`. The setup dashboard uses screenshots and sends clicks, typing, navigation, and key presses through RedAI, so it only needs one forwarded port. Use that dashboard to log in, seed app state, or otherwise prepare the target application. If RedAI is running on a remote host, forward the dashboard to your machine: ```sh -ssh -N -L 4848:127.0.0.1:4848 -L 4849:127.0.0.1:4849 user@remote +ssh -N -L 4848:127.0.0.1:4848 user@remote ``` Return to RedAI and press `R` to mark the environment ready. From 938bcb4815314db2471d429781f77da5e73f0b91 Mon Sep 17 00:00:00 2001 From: Kyle Polley Date: Tue, 5 May 2026 16:51:03 -0500 Subject: [PATCH 5/5] Clear stale browser setup locks --- .../environment-setup.ts | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/pipeline/validator-environments/environment-setup.ts b/src/pipeline/validator-environments/environment-setup.ts index 9bcb638..a524463 100644 --- a/src/pipeline/validator-environments/environment-setup.ts +++ b/src/pipeline/validator-environments/environment-setup.ts @@ -1,5 +1,5 @@ import { execFile, spawn } from "node:child_process"; -import { mkdir } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import type { ValidatorEnvironment } from "../../domain"; @@ -37,6 +37,7 @@ async function openBrowserEnvironmentSetup(environment: ValidatorEnvironment): P const env = browserSetupEnvironment(environment); await mkdir(profilePath, { recursive: true }); await mkdir(browserSetupHome(profilePath), { recursive: true }); + await resetBrowserSetupProfile(environment.id, env, profilePath); await execFileAsync("agent-browser", ["open", appUrl], { env, timeout: 60_000 }); await execFileAsync("agent-browser", ["set", "viewport", "1280", "720"], { env, @@ -62,11 +63,7 @@ async function closeBrowserEnvironmentSetup(environment: ValidatorEnvironment): if (!profilePath) return; const env = browserSetupEnvironment(environment); await stopBrowserSetupDashboard(environment.id); - try { - await execFileAsync("agent-browser", ["close"], { env, timeout: 10_000 }); - } catch { - // The setup session may already be closed. - } + await closeBrowserSetupSession(env, profilePath); try { await execFileAsync("agent-browser", ["dashboard", "stop"], { env, timeout: 10_000 }); } catch { @@ -85,6 +82,45 @@ function browserSetupEnvironment(environment: ValidatorEnvironment): NodeJS.Proc }; } +async function resetBrowserSetupProfile( + environmentId: string, + env: NodeJS.ProcessEnv, + profilePath: string, +): Promise { + await stopBrowserSetupDashboard(environmentId); + await closeBrowserSetupSession(env, profilePath); + await removeChromeSingletonFiles(profilePath); +} + +async function closeBrowserSetupSession( + env: NodeJS.ProcessEnv, + profilePath: string, +): Promise { + try { + await execFileAsync("agent-browser", ["close"], { env, timeout: 10_000 }); + } catch { + // The setup session may already be closed. + } + await killChromeProcessesForProfile(profilePath); +} + +async function killChromeProcessesForProfile(profilePath: string): Promise { + if (process.platform === "win32") return; + try { + await execFileAsync("pkill", ["-f", `--user-data-dir=${escapeRegExp(profilePath)}`]); + } catch { + // Chrome may already be closed. + } +} + +async function removeChromeSingletonFiles(profilePath: string): Promise { + await Promise.all( + ["SingletonLock", "SingletonCookie", "SingletonSocket", "RunningChromeVersion"].map((name) => + rm(join(profilePath, name), { force: true }), + ), + ); +} + function browserSetupHome(profilePath: string): string { return join(dirname(profilePath), "agent-browser-home"); } @@ -164,3 +200,7 @@ async function latestAvailableRuntime(): Promise { function safePathPart(value: string): string { return value.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 48) || "environment"; } + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}