diff --git a/README.md b/README.md index 336def3..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`, 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 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). 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 71cbfae..a524463 100644 --- a/src/pipeline/validator-environments/environment-setup.ts +++ b/src/pipeline/validator-environments/environment-setup.ts @@ -1,9 +1,12 @@ 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"; +import { startBrowserSetupDashboard, stopBrowserSetupDashboard } from "./browser-setup-dashboard"; const execFileAsync = promisify(execFile); +const browserSetupDashboardPort = "4848"; export async function openValidatorEnvironmentSetup( environment: ValidatorEnvironment, @@ -31,32 +34,97 @@ 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; + 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, + 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; } - - const browser = process.platform === "win32" ? "chrome" : "google-chrome"; - spawn(browser, [`--user-data-dir=${profilePath}`, appUrl], { - detached: true, - stdio: "ignore", - }).unref(); } async function closeBrowserEnvironmentSetup(environment: ValidatorEnvironment): Promise { const profilePath = environment.browser?.profilePath; if (!profilePath) return; + const env = browserSetupEnvironment(environment); + await stopBrowserSetupDashboard(environment.id); + await closeBrowserSetupSession(env, profilePath); 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, + }; +} + +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"); +} + async function openIosEnvironmentSetup( environment: ValidatorEnvironment, ): Promise { @@ -132,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, "\\$&"); +} diff --git a/src/tui/screens/EnvironmentSetupScreen.tsx b/src/tui/screens/EnvironmentSetupScreen.tsx index 124ec0b..04e0ce0 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 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 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..85fcd04 100644 --- a/src/tui/screens/RunValidationScreen.tsx +++ b/src/tui/screens/RunValidationScreen.tsx @@ -66,7 +66,10 @@ 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 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 66fcfd9..77c9fe9 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,9 @@ 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 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 34cdd83..34acb6f 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 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 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.", }; }