diff --git a/.changeset/agent-manager-streamed-browser.md b/.changeset/agent-manager-streamed-browser.md new file mode 100644 index 000000000000..fb7762a5734d --- /dev/null +++ b/.changeset/agent-manager-streamed-browser.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/cli": minor +--- + +Support public HTTPS pages and public CDN resources in localhost previews with a high-resolution streamed Agent Manager browser. Explain missing browser installations and provide download, retry, and settings actions. Keep clipboard actions ordered, honor page clipboard handlers, and close stalled browser sessions without waiting on renderer input. Preserve native cross-origin request checks while blocking unowned browser pages. diff --git a/bun.lock b/bun.lock index 3207d1bd3650..fa619c60f0a1 100644 --- a/bun.lock +++ b/bun.lock @@ -450,6 +450,7 @@ "@kilocode/kilo-memory": "workspace:*", "@kilocode/kilo-ui": "workspace:*", "@kilocode/plugin": "workspace:*", + "@kilocode/sandbox": "workspace:*", "@kilocode/sdk": "workspace:*", "@lottiefiles/dotlottie-web": "0.63.0", "@opencode-ai/core": "workspace:*", diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index f8cc61caea36..112de917a040 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -199,6 +199,8 @@ - +- + - - diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 0b90d4ab302f..265bef87eb84 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -1211,6 +1211,8 @@ class AgentManagerPanelTest : BasePlatformTestCase() { val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x") rpc.listed += item rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(item.path, 7, GhState.OPEN, "https://example.test/pr/7"))) + val gate = CompletableDeferred() + rpc.beforePrStatus = { gate.await() } val timers = TestUiTimers() ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, service, testRootDisposable) project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) @@ -1220,6 +1222,13 @@ class AgentManagerPanelTest : BasePlatformTestCase() { timers.advanceBy(300) flush() + assertFalse(edt { panel.canOpenPr(item) }) + assertTrue(edt { panel.canRename(item) }) + + gate.complete(Unit) + // The status binding uses Dispatchers.Default, which flush() does not drain. + waitUntil { panel.canOpenPr(item) } + assertTrue(edt { panel.canOpenPr(item) }) assertFalse(edt { panel.canOpenPr(null) }) assertFalse(edt { panel.canRename(item) }) diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index 0637b0fdd0e8..11add48a866b 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -7,7 +7,8 @@ "private": true, "description": "OS-neutral sandbox profiles and launch preparation for Kilo Code", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./destination": "./src/destination.ts" }, "files": [ "src" diff --git a/packages/kilo-sandbox/src/destination.ts b/packages/kilo-sandbox/src/destination.ts index fc92c023dbcc..36215f77bc22 100644 --- a/packages/kilo-sandbox/src/destination.ts +++ b/packages/kilo-sandbox/src/destination.ts @@ -63,6 +63,10 @@ export function isPublicAddress(input: string) { return address.range() === "unicast" } +export function isIpAddress(input: string) { + return isIP(input) !== 0 +} + export async function resolveDestination(dest: Destination) { const addresses = await lookup(dest.host, { all: true, verbatim: true }) if (addresses.length === 0 || addresses.some((entry) => !isPublicAddress(entry.address))) { diff --git a/packages/kilo-sandbox/test/destination.test.ts b/packages/kilo-sandbox/test/destination.test.ts index f461e153bb5d..9ba795c91aa1 100644 --- a/packages/kilo-sandbox/test/destination.test.ts +++ b/packages/kilo-sandbox/test/destination.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { isPublicAddress, normalizeDestinations, parseDestination } from "../src/destination" +import { isIpAddress, isPublicAddress, normalizeDestinations, parseDestination } from "../src/destination" describe("sandbox network destinations", () => { test("normalizes exact DNS hosts and ports", () => { @@ -72,4 +72,10 @@ describe("sandbox network destinations", () => { expect(isPublicAddress(input), `${note}: ${input}`).toBe(expected) } }) + + test("classifies IP literals without treating hostnames as addresses", () => { + expect(isIpAddress("8.8.8.8")).toBe(true) + expect(isIpAddress("2001:4860:4860::8888")).toBe(true) + expect(isIpAddress("example.com")).toBe(false) + }) }) diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 4de194b9550e..724ae2e174fb 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1366,6 +1366,7 @@ "@kilocode/kilo-memory": "workspace:*", "@kilocode/kilo-ui": "workspace:*", "@kilocode/plugin": "workspace:*", + "@kilocode/sandbox": "workspace:*", "@kilocode/sdk": "workspace:*", "@lottiefiles/dotlottie-web": "0.63.0", "@opencode-ai/core": "workspace:*", diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 3057f06eb62e..102e9a0356da 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -414,7 +414,7 @@ export class AgentManagerProvider implements Disposable { panel.dispose() } this.panel = ctx - this.browserLifecycle.replay() + this.browserLifecycle.attach(ctx) for (const poller of [this.statsPoller, this.projectPollers]) poller.setVisible(ctx.visible) this.diffs.setVisible(ctx.visible).catch((err) => this.log("Failed to update diff visibility:", err)) diff --git a/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts index 62dcc454b79c..bfc71ca42a47 100644 --- a/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts +++ b/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts @@ -1,10 +1,12 @@ import { BrowserBroker, type BrowserRoute, type BrowserState } from "../services/browser-automation" -import type { Host } from "./host" +import type { Host, PanelContext } from "./host" import type { AgentManagerInMessage, AgentManagerOutMessage } from "./types" import type { ProjectContexts } from "./project/contexts" import { canonicalizePath, samePath } from "./project/paths" import { browserMessage, handleBrowserMessage } from "./browser-message" +type Panel = Pick + export function createBrowserLifecycle(input: { browser?: BrowserBroker host: Host @@ -14,19 +16,25 @@ export function createBrowserLifecycle(input: { log: (...args: unknown[]) => void }) { const browser = input.browser ?? new BrowserBroker({ log: input.log }) - browser.bind((route: BrowserRoute) => { - const directory = canonicalizePath(route.directory) - const ctx = input.contexts().byDirectory(directory) - if (!ctx || (route.projectId && route.projectId !== ctx.id)) return undefined - const state = ctx.peekState() - const stored = state?.getSession(route.sessionId) - const live = ctx.sessions().find((session) => session.id === route.sessionId) - if (!stored && !live) return undefined - const worktree = stored?.worktreeId ?? live?.worktreeId - const expected = worktree ? state?.getWorktree(worktree)?.path : ctx.root - if (!expected || !samePath(canonicalizePath(expected), directory)) return undefined - return { projectId: ctx.id, sessionId: route.sessionId, directory } - }) + browser.bind( + (route: BrowserRoute) => { + const directory = canonicalizePath(route.directory) + const ctx = input.contexts().byDirectory(directory) + if (!ctx || (route.projectId && route.projectId !== ctx.id)) return undefined + const state = ctx.peekState() + const stored = state?.getSession(route.sessionId) + const live = ctx.sessions().find((session) => session.id === route.sessionId) + if (!stored && !live) return undefined + const worktree = stored?.worktreeId ?? live?.worktreeId + const expected = worktree ? state?.getWorktree(worktree)?.path : ctx.root + if (!expected || !samePath(canonicalizePath(expected), directory)) return undefined + return { projectId: ctx.id, sessionId: route.sessionId, directory } + }, + async (_route, url) => { + if (!input.host.isTrusted() || !input.host.browserAutomation()) return false + return (await input.host.approveBrowserNavigation?.(url.origin)) === true + }, + ) const post = (state: BrowserState) => { const active = input.contexts().active() if ((state.status === "starting" || state.status === "loading") && state.projectId === active?.id) { @@ -35,7 +43,22 @@ export function createBrowserLifecycle(input: { input.post(browserMessage(state)) } const off = browser.subscribe(post) + const frames = browser.frames((frame) => input.post({ type: "agentManager.browserFrame", ...frame })) + let current: Panel | undefined return { + attach(panel: Panel): void { + current = panel + panel.onDidChangeVisibility((visible) => { + if (current === panel && !visible) browser.suspend() + }) + panel.onDidDispose(() => { + if (current !== panel) return + current = undefined + browser.suspend() + }) + if (!panel.visible) browser.suspend() + browser.replay(post) + }, handle(message: AgentManagerInMessage): boolean { return handleBrowserMessage(message, { host: input.host, @@ -52,15 +75,15 @@ export function createBrowserLifecycle(input: { void browser.close(sessionId, projectId).catch((error) => input.log("Failed to close browser session:", error)) }, closeProject(projectId: string): void { - for (const session of browser.sessions()) { - if (browser.get(session, projectId)) this.close(session, projectId) - } + for (const session of browser.sessions()) this.close(session, projectId) }, closeAll(): Promise { return Promise.all([...browser.sessions()].map((sessionId) => browser.close(sessionId))).then(() => undefined) }, dispose(): Promise { + current = undefined off() + frames() return browser.disposeAsync() }, } diff --git a/packages/kilo-vscode/src/agent-manager/browser-message.ts b/packages/kilo-vscode/src/agent-manager/browser-message.ts index 81f822c6a534..ab3b339fe9d4 100644 --- a/packages/kilo-vscode/src/agent-manager/browser-message.ts +++ b/packages/kilo-vscode/src/agent-manager/browser-message.ts @@ -1,9 +1,16 @@ -import { diagnostic, type BrowserBroker, type BrowserState } from "../services/browser-automation" +import { BrowserLaunchError, diagnostic, type BrowserBroker, type BrowserState } from "../services/browser-automation" import type { AgentManagerInMessage, AgentManagerOutMessage } from "./types" import type { ProjectContexts } from "./project/contexts" import type { Host } from "./host" type BrowserMessage = Extract +type Dependencies = { + host: Host + contexts: ProjectContexts + browser: BrowserBroker + post: (message: AgentManagerOutMessage) => void + log: (...args: unknown[]) => void +} function position(message: BrowserMessage): { x: number; y: number; width: number; height: number } | undefined { if ( @@ -22,6 +29,7 @@ function fail( m: BrowserMessage, error: string, state?: BrowserState, + missing?: BrowserState["missing"], ): void { if (m.type === "agentManager.browser.inspect") { deps.post({ @@ -47,6 +55,7 @@ function fail( status: "error", errors: 0, error, + missing, }) } @@ -62,16 +71,47 @@ function route(contexts: ProjectContexts, message: BrowserMessage): { project: s return directory ? { project: ctx.id, directory } : undefined } -function action( - m: BrowserMessage, - deps: { - host: Host - contexts: ProjectContexts - browser: BrowserBroker - post: (message: AgentManagerOutMessage) => void - log: (...args: unknown[]) => void - }, -): boolean { +function streaming(m: BrowserMessage, project: string, deps: Dependencies): boolean { + if (m.type === "agentManager.browser.viewport") { + if (!m.browserId || typeof m.navigation !== "number" || !Number.isInteger(m.navigation) || !m.viewport) return true + void deps.browser.viewport(m.sessionId, project, m.browserId, m.navigation, m.viewport).catch((error: unknown) => { + deps.log("Browser stream failed:", error) + fail(deps, m, diagnostic(error), deps.browser.get(m.sessionId, project)) + }) + return true + } + if (m.type === "agentManager.browser.acknowledge") { + if (m.identity && typeof m.sequence === "number") { + deps.browser.acknowledge(m.sessionId, project, m.identity, m.sequence) + } + return true + } + if (m.type !== "agentManager.browser.interact") return false + const identity = m.identity + const event = m.event + if (!identity || !event) return true + void (async () => { + if (!deps.browser.accepts(m.sessionId, project, identity)) return + if (event.kind === "clipboard") { + await deps.browser.interact( + m.sessionId, + project, + identity, + event, + deps.host.readClipboard?.bind(deps.host), + (text) => deps.host.copyToClipboard(text), + ) + return + } + await deps.browser.interact(m.sessionId, project, identity, event) + })().catch((error: unknown) => { + deps.log("Browser input failed:", error) + fail(deps, m, diagnostic(error), deps.browser.get(m.sessionId, project)) + }) + return true +} + +function action(m: BrowserMessage, deps: Dependencies): boolean { const scope = route(deps.contexts, m) if (!scope) { fail(deps, m, "Browser session is not available in the selected project.") @@ -82,6 +122,7 @@ function action( if (current) deps.post(browserMessage(current)) return true } + if (streaming(m, scope.project, deps)) return true if (m.type === "agentManager.browser.devtools") { void deps.browser .devtools(m.sessionId, scope.project, m.theme === "light" ? "light" : "dark") @@ -102,20 +143,30 @@ function action( if (m.type === "agentManager.browser.open") { if (!m.url) return true void deps.browser - .open({ projectId: scope.project, sessionId: m.sessionId, directory: scope.directory }, m.url) + .open({ projectId: scope.project, sessionId: m.sessionId, directory: scope.directory }, m.url, false) .catch((error: unknown) => { deps.log("Browser open failed:", error) - const current = deps.browser.get(m.sessionId, scope.project) - if (current) { - deps.post(browserMessage(current)) - return - } - fail(deps, m, diagnostic(error, m.url)) + fail( + deps, + m, + diagnostic(error, m.url), + deps.browser.get(m.sessionId, scope.project), + error instanceof BrowserLaunchError ? error.missing : undefined, + ) }) return true } + if (m.type === "agentManager.browser.back" || m.type === "agentManager.browser.forward") { + const direction = m.type === "agentManager.browser.back" ? "back" : "forward" + void deps.browser.history(m.sessionId, scope.project, direction).catch((error: unknown) => { + deps.log("Browser history navigation failed:", error) + const current = deps.browser.get(m.sessionId, scope.project) + if (current) deps.post(browserMessage(current)) + }) + return true + } if (m.type === "agentManager.browser.refresh") { - void deps.browser.refresh(m.sessionId, scope.project).catch((error: unknown) => { + void deps.browser.refresh(m.sessionId, scope.project, false).catch((error: unknown) => { deps.log("Browser refresh failed:", error) const current = deps.browser.get(m.sessionId, scope.project) if (current) deps.post(browserMessage(current)) @@ -153,22 +204,14 @@ function action( }) return true } + if (m.type !== "agentManager.browser.close") return false void deps.browser .close(m.sessionId, scope.project) .catch((error: unknown) => deps.log("Browser close failed:", error)) return true } -export function handleBrowserMessage( - message: AgentManagerInMessage, - deps: { - host: Host - contexts: ProjectContexts - browser: BrowserBroker - post: (message: AgentManagerOutMessage) => void - log: (...args: unknown[]) => void - }, -): boolean { +export function handleBrowserMessage(message: AgentManagerInMessage, deps: Dependencies): boolean { if (!message.type.startsWith("agentManager.browser.")) return false const m = message as BrowserMessage if (!deps.host.isTrusted()) { @@ -196,6 +239,9 @@ export function browserMessage(state: BrowserState): AgentManagerOutMessage { errors: state.errors, logs: state.logs, error: state.error, + missing: state.missing, frameError: state.frameError, + back: state.back, + forward: state.forward, } } diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index c3b204d34036..4687c5d648af 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -152,6 +152,7 @@ export interface Host { /** Whether the experimental multi-project Agent Manager mode is enabled. */ multiProject(): boolean browserAutomation(): boolean + approveBrowserNavigation?(origin: string): Promise /** Whether background worktree pre-warming is enabled. */ worktreePool(): boolean @@ -210,7 +211,8 @@ export interface Host { extensionKeybindings(): Array<{ command: string; key?: string; mac?: string; when?: string }> /** Copy text to the system clipboard. */ - copyToClipboard(text: string): void + copyToClipboard(text: string): void | Promise + readClipboard?(): Promise /** Capture a telemetry event. */ capture(event: string, properties?: Record): void diff --git a/packages/kilo-vscode/src/agent-manager/project/state-gate.ts b/packages/kilo-vscode/src/agent-manager/project/state-gate.ts index 2c064539a1cc..6cce802bee0a 100644 --- a/packages/kilo-vscode/src/agent-manager/project/state-gate.ts +++ b/packages/kilo-vscode/src/agent-manager/project/state-gate.ts @@ -39,6 +39,8 @@ export const STATE_GATED = new Set([ "agentManager.moveSection", "agentManager.browser.open", "agentManager.browser.refresh", + "agentManager.browser.back", + "agentManager.browser.forward", "agentManager.browser.close", "agentManager.browser.state", "agentManager.browser.inspect", diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 13ee22c8aa2c..d58dd06a048c 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -22,6 +22,7 @@ import type { SidebarTarget } from "./project/route" import type { TerminalDestination } from "./terminal-destination" import type { ScriptTerminalView } from "./ScriptTerminalManager" import type { BrowserFeedbackData } from "../shared/browser-feedback" +import type { BrowserFrame, BrowserInteraction, BrowserViewport, BrowserViewIdentity } from "../shared/browser-stream" export type { TerminalFont } export type { ProjectSnapshot } @@ -483,7 +484,10 @@ interface BrowserStateMessage { errors: number logs?: string[] error?: string + missing?: "chrome" | "chromium" frameError?: string + back?: boolean + forward?: boolean } interface BrowserInspectionMessage { @@ -499,6 +503,12 @@ interface BrowserInspectionMessage { hover?: boolean } +interface BrowserFrameMessage extends BrowserFrame { + type: "agentManager.browserFrame" + projectId?: string + sessionId: string +} + interface BrowserDevtoolsMessage { type: "agentManager.browserDevtools" browserId: string @@ -553,6 +563,7 @@ export type AgentManagerOutMessage = | BrowserStateMessage | BrowserInspectionMessage | BrowserDevtoolsMessage + | BrowserFrameMessage | RunStatusMessage | TerminalCreatedMessage | TerminalRestartedMessage @@ -1220,14 +1231,25 @@ interface BrowserRequestIn { type: | "agentManager.browser.open" | "agentManager.browser.refresh" + | "agentManager.browser.back" + | "agentManager.browser.forward" | "agentManager.browser.close" | "agentManager.browser.state" | "agentManager.browser.inspect" | "agentManager.browser.input" | "agentManager.browser.devtools" + | "agentManager.browser.viewport" + | "agentManager.browser.interact" + | "agentManager.browser.acknowledge" sessionId: string requestId?: string projectId?: string + browserId?: string + navigation?: number + viewport?: BrowserViewport + identity?: BrowserViewIdentity + event?: BrowserInteraction + sequence?: number url?: string x?: number y?: number diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index dfc6ec61510f..076f89f85b9b 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -418,6 +418,15 @@ export class VscodeHost implements Host { return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("browserAutomation", false) } + async approveBrowserNavigation(origin: string): Promise { + const answer = await vscode.window.showWarningMessage( + `Allow the Agent Manager browser to navigate to ${origin}?`, + { modal: true }, + "Allow", + ) + return answer === "Allow" + } + worktreePool(): boolean { return vscode.workspace.getConfiguration("kilo-code.new.agentManager").get("worktreePool", true) } @@ -531,8 +540,12 @@ export class VscodeHost implements Host { return ext?.packageJSON?.contributes?.keybindings ?? [] } - copyToClipboard(text: string): void { - void vscode.env.clipboard.writeText(text) + async copyToClipboard(text: string): Promise { + await vscode.env.clipboard.writeText(text) + } + + async readClipboard(): Promise { + return vscode.env.clipboard.readText() } capture(event: string, properties?: Record): void { diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts b/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts index fd533634577d..6d504ac91c48 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts @@ -3,10 +3,27 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import type { Socket } from "node:net" import { URL } from "node:url" import { stripVTControlCharacters } from "node:util" -import { chromium, type BrowserContext, type LaunchOptions, type Page } from "playwright-core" +import { + chromium, + type BrowserContext, + type BrowserContextOptions, + type CDPSession, + type LaunchOptions, + type Page, +} from "playwright-core" import { BrowserDevtools } from "./browser-devtools" import { capture as element, locate } from "./browser-element" import { options } from "./browser-runtime" +import { BrowserStream } from "./browser-stream" +import { BrowserNetwork } from "./browser-network" +import { BrowserProxy, UNREACHABLE } from "./browser-proxy" +import { parse } from "./browser-policy" +import type { + BrowserFrame, + BrowserInteraction, + BrowserViewport, + BrowserViewIdentity, +} from "../../shared/browser-stream" export type BrowserStatus = "starting" | "ready" | "loading" | "error" | "closed" @@ -30,7 +47,10 @@ export interface BrowserState { errors: number logs?: string[] error?: string + missing?: "chrome" | "chromium" frameError?: string + back?: boolean + forward?: boolean } export interface BrowserElement { @@ -64,12 +84,17 @@ export interface BrowserBrokerOptions { trusted?: () => boolean launch?: (options: LaunchOptions) => Promise useSystemChrome?: () => boolean + network?: ( + page: Page, + options: Parameters[1], + ) => Promise> } export interface BrowserContextFactory { debugging?: number - newContext(options: { serviceWorkers: "block"; viewport: { width: number; height: number } }): Promise + newContext(options: BrowserContextOptions): Promise close(): Promise + on?(event: "disconnected", listener: () => void): unknown } interface Entry { @@ -77,9 +102,37 @@ interface Entry { browserId: string context: BrowserContext page: Page + proxy: BrowserProxy + network?: Pick + waiting: Set + local: boolean origin: string state: BrowserState response?: number + stream?: BrowserStream + viewport?: BrowserViewport + navigating?: boolean + dead?: boolean + unreachable?: boolean + history?: Promise +} + +export class BrowserLaunchError extends Error { + constructor( + readonly missing: "chrome" | "chromium" | undefined, + cause: unknown, + ) { + const detail = cause instanceof Error ? cause.message : String(cause) + super( + missing === "chrome" + ? "Google Chrome was not found. Install Chrome or disable Use System Chrome in Kilo Settings > Experimental for the Integrated Browser to use an installed Playwright Chromium browser." + : missing === "chromium" + ? "A compatible Playwright Chromium browser was not found. Install one or enable Use System Chrome in Kilo Settings > Experimental for the Integrated Browser." + : `The browser could not start. ${detail}`, + { cause }, + ) + this.name = "BrowserLaunchError" + } } class BrowserNavigationError extends Error { @@ -101,16 +154,32 @@ type RequestBody = { const MAX_BODY = 32 * 1024 const MAX_SCREENSHOT = 2 * 1024 * 1024 +const TIMEOUT = /ERR_CONNECTION_TIMED_OUT|ETIMEDOUT|Timeout \d+ms exceeded/i +const CRASHED = "The browser stopped unexpectedly. Refresh to start it again." + +function unreachable(url?: string): string { + return `Cannot connect to ${url ?? "the local application"}. Make sure the local server is running.` +} + +function reason(entry: Entry, error: unknown, url: URL): unknown { + if (entry.dead) return new BrowserNavigationError(entry.state.error ?? CRASHED) + if (entry.unreachable) return new BrowserNavigationError(unreachable(url.href), entry.response) + if (entry.waiting.size === 0 || !TIMEOUT.test(error instanceof Error ? error.message : String(error))) return error + return new BrowserNavigationError( + "Browser navigation stopped while waiting for approval in VS Code. Dismiss the approval prompt and retry.", + ) +} export function diagnostic(error: unknown, url?: string): string { const text = stripVTControlCharacters(error instanceof Error ? error.message : String(error)) - if (/ERR_CONNECTION_REFUSED|ECONNREFUSED/i.test(text)) { - return `Cannot connect to ${url ?? "the local application"}. Make sure the local server is running.` - } - if (/ERR_CONNECTION_TIMED_OUT|ETIMEDOUT|Timeout \d+ms exceeded/i.test(text)) { - return "The local application did not respond in time. Check the server and try again." + const launch = error instanceof BrowserLaunchError + if (!launch && /ERR_CONNECTION_REFUSED|ECONNREFUSED/i.test(text)) return unreachable(url) + if (!launch && TIMEOUT.test(text)) { + return url && URL.parse(url)?.protocol === "https:" + ? "The website did not respond in time. Check the URL and network connection, then try again." + : "The local application did not respond in time. Check the server and try again." } - if (/Target page, context or browser has been closed|Browser has been closed/i.test(text)) { + if (!launch && /Target page, context or browser has been closed|Browser has been closed/i.test(text)) { return "The browser session was closed. Reopen the browser and try again." } return text @@ -139,26 +208,6 @@ function reserve(): Promise { }) } -function framing(headers: Record | undefined): string | undefined { - if (!headers) return undefined - const options = headers["x-frame-options"]?.trim().toLowerCase() - if (options && (options === "deny" || options === "sameorigin" || options.startsWith("allow-from"))) { - return `This application blocks embedded browser previews with X-Frame-Options: ${options.toUpperCase()}.` - } - const policy = headers["content-security-policy"] - ?.split(";") - .map((part) => part.trim()) - .find((part) => part.toLowerCase().startsWith("frame-ancestors")) - if (policy && /(?:^|\s)'(?:none|self)'(?:\s|$)/i.test(policy)) { - return `This application blocks embedded browser previews with Content-Security-Policy: ${policy}.` - } - return undefined -} - -function loopback(url: URL): boolean { - return url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname) -} - function json(res: ServerResponse, status: number, value: unknown): void { const body = JSON.stringify(value) res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body) }) @@ -187,8 +236,12 @@ export class BrowserBroker { private readonly entries = new Map() private readonly pending = new Map>() private readonly listeners = new Set<(state: BrowserState) => void>() + private readonly viewers = new Set<(frame: BrowserFrame & Pick) => void>() private readonly token = randomBytes(32).toString("hex") + private readonly proxies = new Set() + private gateway: BrowserProxy | undefined private owner: ((route: BrowserRoute) => BrowserRoute | undefined) | undefined + private approval: ((route: BrowserRoute, url: URL) => Promise) | undefined private server: Server | undefined private readonly sockets = new Set() private port: number | undefined @@ -244,8 +297,12 @@ export class BrowserBroker { } } - bind(owner: (route: BrowserRoute) => BrowserRoute | undefined): void { + bind( + owner: (route: BrowserRoute) => BrowserRoute | undefined, + approve?: (route: BrowserRoute, url: URL) => Promise, + ): void { this.owner = owner + this.approval = approve } subscribe(listener: (state: BrowserState) => void): () => void { @@ -257,6 +314,102 @@ export class BrowserBroker { for (const entry of this.entries.values()) listener(this.copy(entry.state)) } + frames(listener: (frame: BrowserFrame & Pick) => void): () => void { + this.viewers.add(listener) + return () => this.viewers.delete(listener) + } + + async viewport( + sessionId: string, + projectId: string | undefined, + browserId: string, + navigation: number, + viewport: BrowserViewport, + ): Promise { + this.available() + const entry = this.view(sessionId, projectId, { browserId, navigation }) + if (!entry || !Number.isSafeInteger(viewport.revision) || viewport.revision < 1) return + if (entry.viewport && viewport.revision < entry.viewport.revision) return + entry.viewport = { ...viewport } + await this.cast(entry).configure(viewport) + } + + private cast(entry: Entry): BrowserStream { + if (entry.stream) return entry.stream + const route = entry.route + entry.stream = new BrowserStream( + entry.page, + () => ({ browserId: entry.browserId, navigation: entry.state.navigation }), + (frame) => { + if (!this.view(route.sessionId, route.projectId, frame) || entry.viewport?.active !== true) return + for (const viewer of this.viewers) viewer({ ...frame, projectId: route.projectId, sessionId: route.sessionId }) + }, + this.opts.log, + ) + return entry.stream + } + + accepts(sessionId: string, projectId: string | undefined, identity: BrowserViewIdentity): boolean { + const entry = this.view(sessionId, projectId, identity) + return !!entry?.stream && entry.viewport?.active === true && entry.viewport.revision === identity.revision + } + + acknowledge(sessionId: string, projectId: string | undefined, identity: BrowserViewIdentity, sequence: number): void { + const entry = this.view(sessionId, projectId, identity) + if (entry?.viewport?.revision !== identity.revision) return + if (Number.isSafeInteger(sequence) && sequence > 0) entry.stream?.acknowledge(sequence) + } + + async interact( + sessionId: string, + projectId: string | undefined, + identity: BrowserViewIdentity, + event: BrowserInteraction, + read?: () => Promise, + write?: (text: string) => void | Promise, + ): Promise { + if (!this.accepts(sessionId, projectId, identity)) return + const entry = this.view(sessionId, projectId, identity) + if (!entry?.stream) return + const copied = await entry.stream.interact( + event, + read, + write && + ((text) => { + if (this.view(sessionId, projectId, identity)) return write(text) + }), + ) + return this.view(sessionId, projectId, identity) ? copied : undefined + } + + suspend(): void { + for (const entry of this.entries.values()) { + if (!entry.viewport || !entry.stream) continue + entry.viewport = { ...entry.viewport, active: false } + void entry.stream + .configure(entry.viewport) + .catch((error: unknown) => this.opts.log("Browser stream pause failed", error)) + } + } + + private view( + sessionId: string, + projectId: string | undefined, + identity: Pick, + ): Entry | undefined { + if (this.closed || this.opts.enabled?.() === false || this.opts.trusted?.() === false) return + const entry = this.entries.get(this.key(sessionId, projectId)) + if ( + !entry || + entry.dead || + entry.browserId !== identity.browserId || + entry.state.navigation !== identity.navigation + ) + return + const scope = this.owner ? this.owner(entry.route) : entry.route + return scope?.directory === entry.route.directory && scope.projectId === entry.route.projectId ? entry : undefined + } + get(sessionId: string, projectId?: string): BrowserState | undefined { const entries = [...this.entries.values()].filter( (entry) => @@ -266,51 +419,74 @@ export class BrowserBroker { } sessions(): string[] { - return [...new Set([...this.entries.values()].map((entry) => entry.route.sessionId))] + return [...new Set([...this.entries.keys(), ...this.pending.keys()].map((key) => key.slice(key.indexOf("\0") + 1)))] } - open(route: BrowserRoute, target: string): Promise { + open(route: BrowserRoute, target: string, capture = true): Promise { const scope = this.owner ? this.owner(route) : route if (!scope) return Promise.reject(new Error("Browser session does not belong to the requested project or directory")) - return this.serial(this.key(scope.sessionId, scope.projectId), () => this.create(scope, target)) + return this.serial(this.key(scope.sessionId, scope.projectId), () => this.create(scope, target, capture)) } - private async create(scope: BrowserRoute, target: string): Promise { + private async create(scope: BrowserRoute, target: string, capture: boolean): Promise { this.available() const url = this.validate(target) const existing = this.entries.get(this.key(scope.sessionId, scope.projectId)) if (existing) { if (existing.route.directory !== scope.directory) throw new Error("Browser session directory cannot change") - if (scope.projectId && existing.route.projectId && existing.route.projectId !== scope.projectId) { + if (existing.route.projectId !== scope.projectId) { throw new Error("Browser session project cannot change") } - existing.route.projectId = scope.projectId ?? existing.route.projectId - existing.state.projectId = existing.route.projectId - const reload = existing.state.status === "ready" && existing.page.url() === url.href - await this.goto(existing, url, reload) - return this.copy(existing.state) + const replace = + existing.dead === true || + existing.network?.active === false || + existing.local !== (url.protocol === "http:") || + (existing.local && existing.origin !== url.origin) + if (!replace) { + existing.route.projectId = scope.projectId ?? existing.route.projectId + existing.state.projectId = existing.route.projectId + existing.network?.authorize(url) + const reload = existing.state.status === "ready" && existing.page.url() === url.href + await this.goto(existing, url, reload, capture) + return this.copy(existing.state) + } + await this.retire(existing) } const browser = await this.ensureBrowser() this.available() - const context = await browser.newContext({ - serviceWorkers: "block", - viewport: { width: 1280, height: 720 }, + const proxy = await BrowserProxy.start(url.protocol === "http:" ? url : "public") + this.proxies.add(proxy) + const context = await (async () => { + this.available() + return browser.newContext({ + serviceWorkers: "block", + viewport: { width: 1280, height: 720 }, + deviceScaleFactor: 2, + proxy: proxy.proxy, + ignoreHTTPSErrors: false, + acceptDownloads: false, + }) + })().catch(async (error: unknown) => { + await proxy.close() + this.proxies.delete(proxy) + throw error }) const page = await context.newPage().catch(async (error: unknown) => { + await proxy.close() + this.proxies.delete(proxy) await context.close().catch((failure: unknown) => this.opts.log("Browser context close failed", failure)) throw error }) - if (this.closed) { - await context.close().catch((error: unknown) => this.opts.log("Browser context close failed", error)) - throw new Error("Browser broker is closed") - } const entry: Entry = { route: { ...scope }, browserId: randomUUID(), context, page, + proxy, + waiting: new Set(), + local: url.protocol === "http:", origin: url.origin, state: { browserId: "", @@ -326,13 +502,41 @@ export class BrowserBroker { const key = this.key(scope.sessionId, scope.projectId) this.entries.set(key, entry) this.attach(entry) - await this.route(entry).catch(async (error: unknown) => { - this.entries.delete(key) - await context.close().catch((failure: unknown) => this.opts.log("Browser context close failed", failure)) + try { + this.available() + if (this.owner && !this.owner(scope)) throw new Error("Browser session is no longer available") + entry.network = await (this.opts.network ?? BrowserNetwork.attach)(page, { + url, + proxy, + approve: async (target) => { + const identity = { browserId: entry.browserId, navigation: entry.state.navigation } + if (!this.view(scope.sessionId, scope.projectId, identity)) return false + entry.waiting.add(target.origin) + try { + const approved = await this.approval?.(entry.route, target) + return ( + approved === true && + entry.network?.active !== false && + !!this.view(scope.sessionId, scope.projectId, identity) + ) + } finally { + entry.waiting.delete(target.origin) + } + }, + blocked: (message) => { + entry.state.error = message + this.emit(entry.state) + }, + log: this.opts.log, + }) + this.available() + if (this.owner && !this.owner(scope)) throw new Error("Browser session is no longer available") + } catch (error) { + await this.retire(entry) throw error - }) + } this.emit(entry.state) - await this.goto(entry, url) + await this.goto(entry, url, false, capture) return this.copy(entry.state) } @@ -398,43 +602,86 @@ export class BrowserBroker { }) } - refresh(sessionId: string, projectId?: string): Promise { + history(sessionId: string, projectId: string | undefined, direction: "back" | "forward"): Promise { + return this.serial(this.key(sessionId, projectId), async () => { + this.available() + const entry = this.require(sessionId, undefined, projectId) + if (entry.dead || !(direction === "back" ? entry.state.back : entry.state.forward)) return this.copy(entry.state) + const opts = { waitUntil: "domcontentloaded" as const, timeout: 30_000 } + await (direction === "back" ? entry.page.goBack(opts) : entry.page.goForward(opts)).catch((error: unknown) => { + this.fail(entry, error) + throw error + }) + await this.update(entry) + this.emit(entry.state) + return this.copy(entry.state) + }) + } + + refresh(sessionId: string, projectId?: string, capture = true): Promise { return this.serial(this.key(sessionId, projectId), async () => { this.available() const entry = this.require(sessionId, undefined, projectId) const url = this.validate(entry.state.url ?? entry.origin) - await this.goto(entry, url, true) + if (entry.dead) { + const scope = this.owner ? this.owner(entry.route) : entry.route + if (!scope) throw new Error("Browser session does not belong to the requested project or directory") + return this.create(scope, url.href, capture) + } + await this.goto(entry, url, true, capture) return this.copy(entry.state) }) } close(sessionId: string, projectId?: string): Promise { - const entries = [...this.entries.values()].filter( - (entry) => - entry.route.sessionId === sessionId && (projectId === undefined || entry.route.projectId === projectId), - ) + const keys = new Set([...this.entries.keys(), ...this.pending.keys()]) return Promise.all( - entries.map((entry) => - this.serial(this.key(entry.route.sessionId, entry.route.projectId), async () => { - const key = this.key(entry.route.sessionId, entry.route.projectId) - if (this.entries.get(key) !== entry) return - this.entries.delete(key) - this.tools?.revoke(entry.browserId) - await entry.context.close().catch((error: unknown) => this.opts.log("Browser context close failed", error)) - entry.state.status = "closed" - entry.state.screenshot = undefined - this.emit(entry.state) - }), - ), + [...keys] + .filter((key) => + projectId === undefined + ? key.slice(key.indexOf("\0") + 1) === sessionId + : key === this.key(sessionId, projectId), + ) + .map((key) => this.stop(key)), ).then(() => undefined) } + private stop(key: string): Promise { + const entry = this.entries.get(key) + void entry?.proxy.close().catch((error: unknown) => this.opts.log("Browser proxy close failed", error)) + return this.serial(key, async () => { + const current = this.entries.get(key) + if (current) await this.retire(current) + }) + } + + private async retire(entry: Entry): Promise { + const key = this.key(entry.route.sessionId, entry.route.projectId) + if (this.entries.get(key) === entry) this.entries.delete(key) + this.tools?.revoke(entry.browserId) + void entry.history + ?.then((session) => session?.detach()) + .catch((error: unknown) => this.opts.log("Browser history session close failed", error)) + const stream = entry.stream?.close().catch((error: unknown) => this.opts.log("Browser stream close failed", error)) + const network = entry.network + ?.close() + .catch((error: unknown) => this.opts.log("Browser network close failed", error)) + await entry.proxy.close().catch((error: unknown) => this.opts.log("Browser proxy close failed", error)) + this.proxies.delete(entry.proxy) + await entry.context.close().catch((error: unknown) => this.opts.log("Browser context close failed", error)) + await Promise.all([stream, network]) + entry.state.status = "closed" + entry.state.screenshot = undefined + this.emit(entry.state) + } + async disposeAsync(): Promise { if (this.closed) return this.closed = true - await Promise.all( - [...this.entries.values()].map((entry) => this.close(entry.route.sessionId, entry.route.projectId)), - ) + await Promise.all([...this.proxies].map((proxy) => proxy.close())) + await Promise.all([...new Set([...this.entries.keys(), ...this.pending.keys()])].map((key) => this.stop(key))) + this.proxies.clear() + this.gateway = undefined await this.browserStarting?.catch((error: unknown) => this.opts.log("Browser startup failed", error)) await this.browser?.close().catch((error: unknown) => this.opts.log("Browser close failed", error)) this.browser = undefined @@ -451,36 +698,97 @@ export class BrowserBroker { this.server = undefined this.port = undefined this.listeners.clear() + this.viewers.clear() } dispose(): void { void this.disposeAsync().catch((error: unknown) => this.opts.log("Browser broker dispose failed", error)) } - private async ensureBrowser(): Promise { - if (this.closed) throw new Error("Browser broker is closed") - if (this.browser) return this.browser + private ensureBrowser(): Promise { + if (this.closed) return Promise.reject(new Error("Browser broker is closed")) + if (this.browser) return Promise.resolve(this.browser) if (this.browserStarting) return this.browserStarting - this.browserStarting = (async () => { + const system = this.opts.useSystemChrome?.() !== false + const starting = (async () => { const port = this.opts.launch ? undefined : await reserve() - const config = options(this.opts.useSystemChrome?.() !== false, port) + const base = options(system, port) + const gateway = await BrowserProxy.start("deny") + this.proxies.add(gateway) + this.gateway = gateway + this.available() + const config: LaunchOptions = { + ...base, + proxy: gateway.proxy, + ignoreDefaultArgs: ["--disable-popup-blocking"], + args: [ + ...(base.args ?? []).filter((arg) => arg !== "--no-proxy-server"), + "--force-device-scale-factor=2", + "--disable-quic", + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1", + "--webrtc-ip-handling-policy=disable_non_proxied_udp", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", + ], + } const browser = await (this.opts.launch?.(config) ?? chromium.launch(config)) this.debugging = ("debugging" in browser ? browser.debugging : undefined) ?? port this.browser = browser + browser.on?.("disconnected", () => this.lost(browser)) return browser })() - try { - return await this.browserStarting - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - const action = - this.opts.useSystemChrome?.() === false - ? "Install a compatible Playwright Chromium browser or enable Use System Chrome in Kilo Settings > Experimental for the Integrated Browser." - : "Install Google Chrome or disable Use System Chrome in Kilo Settings > Experimental for the Integrated Browser to use an installed Playwright Chromium browser." - throw new Error(`${action} ${detail}`.slice(0, 1000), { cause: error }) - } finally { - this.browserStarting = undefined - } + .catch(async (error: unknown) => { + await this.release() + const detail = error instanceof Error ? error.message : String(error) + const missing = /Chromium distribution ['"]chrome['"] is not found\b|Executable doesn't exist at\b/i.test( + detail, + ) + ? system + ? "chrome" + : "chromium" + : undefined + throw new BrowserLaunchError(missing, error) + }) + .finally(() => { + if (this.browserStarting === starting) this.browserStarting = undefined + }) + this.browserStarting = starting + return starting + } + + private async release(): Promise { + const gateway = this.gateway + this.gateway = undefined + if (!gateway) return + await gateway.close().catch((failure: unknown) => this.opts.log("Browser proxy close failed", failure)) + this.proxies.delete(gateway) + } + + // Chromium exited or crashed. Keep each entry so its panel shows the failure, and let the next open or + // refresh replace it with a new browser. + private lost(browser: BrowserContextFactory): void { + if (this.closed || this.browser !== browser) return + this.opts.log("Browser disconnected") + this.browser = undefined + this.debugging = undefined + void this.release() + for (const entry of this.entries.values()) this.kill(entry, CRASHED) + } + + private kill(entry: Entry, message: string): void { + if (entry.dead) return + entry.dead = true + this.tools?.revoke(entry.browserId) + void entry.stream?.close().catch((error: unknown) => this.opts.log("Browser stream close failed", error)) + void entry.network?.close().catch((error: unknown) => this.opts.log("Browser network close failed", error)) + void entry.proxy.close().catch((error: unknown) => this.opts.log("Browser proxy close failed", error)) + entry.stream = undefined + entry.viewport = undefined + entry.state.status = "error" + entry.state.error = message + entry.state.screenshot = undefined + entry.state.mime = undefined + entry.state.inspecting = false + this.emit(entry.state) } private record(entry: Entry, message: string): void { @@ -493,6 +801,7 @@ export class BrowserBroker { entry.page.on("response", (response) => { if (response.request().isNavigationRequest() && response.frame() === entry.page.mainFrame()) { entry.response = response.status() + entry.unreachable = response.headers()[UNREACHABLE] === "1" } }) entry.page.on("console", (message) => { @@ -506,6 +815,7 @@ export class BrowserBroker { this.record(entry, error.message) this.emit(entry.state) }) + entry.page.on("crash", () => this.kill(entry, "The page crashed. Refresh to load it again.")) entry.page.on("popup", (page) => { entry.state.errors++ this.record(entry, "Blocked browser popup") @@ -514,46 +824,29 @@ export class BrowserBroker { }) entry.page.on("framenavigated", (frame) => { if (frame !== entry.page.mainFrame()) return - void this.update(entry).catch((error: unknown) => this.fail(entry, error)) - }) - } - - private async route(entry: Entry): Promise { - await entry.context.route("**/*", async (route, request) => { - const target = request.url() - if (this.allowed(entry, target, Boolean(request.isNavigationRequest?.()))) { - await route.continue() - return - } - const origin = URL.canParse(target) ? new URL(target).origin : "invalid" - this.opts.log("Blocked browser request", { sessionId: entry.route.sessionId, origin }) - entry.state.errors++ - this.record(entry, `Blocked browser request: ${origin}`) - this.emit(entry.state) - await route.abort("blockedbyclient") + if (!entry.navigating) entry.state.navigation++ + void this.update(entry) + .then(() => this.emit(entry.state)) + .catch((error: unknown) => this.fail(entry, error)) }) - await entry.context.routeWebSocket("**/*", async (socket) => { - const target = socket.url() - if (this.allowed(entry, target)) { - socket.connectToServer() - return - } - const origin = URL.canParse(target) ? new URL(target).origin : "invalid" - this.opts.log("Blocked browser WebSocket", { sessionId: entry.route.sessionId, origin }) - entry.state.errors++ - this.record(entry, `Blocked browser request: ${origin}`) - this.emit(entry.state) - await socket.close({ code: 1008, reason: "Blocked browser origin" }) + entry.page.on("domcontentloaded", () => { + void this.update(entry) + .then(() => this.emit(entry.state)) + .catch((error: unknown) => this.fail(entry, error)) }) } - private async goto(entry: Entry, url: URL, reload = false): Promise { + private async goto(entry: Entry, url: URL, reload = false, capture = true): Promise { + entry.navigating = true entry.origin = url.origin entry.response = undefined + entry.unreachable = false entry.state.navigation++ entry.state.status = "loading" entry.state.url = url.href entry.state.title = undefined + entry.state.screenshot = undefined + entry.state.mime = undefined entry.state.error = undefined entry.state.frameError = undefined entry.state.errors = 0 @@ -564,20 +857,24 @@ export class BrowserBroker { ? await entry.page.reload({ waitUntil: "domcontentloaded", timeout: 30_000 }) : await entry.page.goto(url.href, { waitUntil: "domcontentloaded", timeout: 30_000 }) entry.response = response?.status() ?? entry.response - entry.state.frameError = framing(response?.headers?.()) if (entry.response !== undefined && entry.response >= 400) { await this.update(entry) - await this.capture(entry) + if (capture) await this.capture(entry) throw new BrowserNavigationError(`Local application returned HTTP ${entry.response}`, entry.response) } await this.update(entry) - await this.capture(entry) + if (capture) await this.capture(entry) entry.state.status = "ready" this.emit(entry.state) } catch (error) { + const failure = reason(entry, error, url) + if (failure !== error && !entry.dead && !entry.unreachable) + await entry.network?.close().catch((error: unknown) => this.opts.log("Browser network close failed", error)) entry.state.url = url.href - this.fail(entry, error) - throw error + this.fail(entry, failure) + throw failure + } finally { + entry.navigating = false } } @@ -599,9 +896,10 @@ export class BrowserBroker { ) { throw new Error("Browser element coordinates are invalid") } - const width = Math.max(1, Math.min(1920, Math.round(position.width))) - const height = Math.max(1, Math.min(1440, Math.round(position.height))) const viewport = entry.page.viewportSize?.() + const width = entry.viewport && viewport ? viewport.width : Math.max(1, Math.min(1920, Math.round(position.width))) + const height = + entry.viewport && viewport ? viewport.height : Math.max(1, Math.min(1440, Math.round(position.height))) if (viewport?.width !== width || viewport.height !== height) { await entry.page.setViewportSize({ width, height }) } @@ -609,12 +907,37 @@ export class BrowserBroker { } private async update(entry: Entry): Promise { - entry.state.url = entry.page.url() - entry.state.title = await entry.page.title().catch(() => undefined) + const navigation = entry.state.navigation + const url = entry.page.url() + const parsed = URL.parse(url) + if (!parsed || !["http:", "https:"].includes(parsed.protocol)) return + const title = await entry.page.title().catch(() => undefined) + const steps = await this.steps(entry) + if (entry.state.navigation !== navigation || entry.page.url() !== url) return + entry.state.url = url + entry.state.title = title + entry.state.back = steps.back + entry.state.forward = steps.forward + } + + // Reads the page history. The first entry of a new context is about:blank, so only web pages count as steps. + private async steps(entry: Entry): Promise<{ back: boolean; forward: boolean }> { + if (typeof entry.context.newCDPSession !== "function") return { back: false, forward: false } + entry.history ??= entry.context.newCDPSession(entry.page).catch((error: unknown) => { + this.opts.log("Browser history session failed", error) + return undefined + }) + const session = await entry.history + const result = await session?.send("Page.getNavigationHistory").catch(() => undefined) + if (!Array.isArray(result?.entries)) return { back: false, forward: false } + const web = (index: number) => /^https?:/i.test(result.entries.at(index)?.url ?? "") && index >= 0 + return { back: web(result.currentIndex - 1), forward: web(result.currentIndex + 1) } } private async capture(entry: Entry): Promise { - const data = await entry.page.screenshot({ type: "jpeg", quality: 70 }) + // Playwright restores its viewport after a screenshot. Run the screenshot in the stream queue, so it cannot + // overwrite a concurrent stream resize and leave Chromium at a size that the stream rejects. + const data = await this.cast(entry).exclusive(() => entry.page.screenshot({ type: "jpeg", quality: 70 })) if (data.byteLength > MAX_SCREENSHOT) throw new Error("Browser screenshot is too large") entry.state.screenshot = `data:image/jpeg;base64,${data.toString("base64")}` entry.state.mime = "image/jpeg" @@ -667,32 +990,7 @@ export class BrowserBroker { } validate(target: string): URL { - let url: URL - try { - url = new URL(target) - } catch { - throw new Error("Browser URL is invalid") - } - if (!loopback(url) || url.username || url.password) { - throw new Error( - "Browser URLs must use HTTP localhost or 127.0.0.1 without credentials. Use localhost for IPv6 loopback servers.", - ) - } - return url - } - - private allowed(entry: Entry, target: string, navigation = false): boolean { - let url: URL - try { - url = new URL(target) - } catch { - return false - } - if (["about:", "blob:", "data:"].includes(url.protocol)) return !navigation - if (url.protocol === "ws:" || url.protocol === "wss:") - return `${url.protocol === "ws:" ? "http:" : "https:"}//${url.host}` === entry.origin - if (url.protocol !== "http:" && url.protocol !== "https:") return false - return url.origin === entry.origin + return parse(target) } private copy(state: BrowserState): BrowserState { diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-network.ts b/packages/kilo-vscode/src/services/browser-automation/browser-network.ts new file mode 100644 index 000000000000..90cb25344f9f --- /dev/null +++ b/packages/kilo-vscode/src/services/browser-automation/browser-network.ts @@ -0,0 +1,437 @@ +import type { Browser, CDPSession, Page } from "playwright-core" +import type { BrowserProxy } from "./browser-proxy" + +type Command = Parameters[0] +type Params = Parameters[1] +type Send = (method: Command, params?: Params) => Promise +type Deferred = ReturnType> +type Attachment = { + sessionId: string + targetInfo: { targetId: string; type: string; browserContextId?: string } +} +type Frame = { send: Send; pending: Map; parent?: string } +type Owner = { + frames: ReadonlyMap + active: () => boolean + request: (event: Request, send: Send) => Promise + authenticate: (event: Challenge, send: Send) => Promise + fail: () => void +} +type Paused = { owner: Owner; auth: boolean } + +const guards = new WeakMap>() + +class Guard { + private readonly owners = new Map() + private readonly pending = new Map() + private readonly send: Send = (method, params) => + this.session.send(method, params).catch((error: unknown) => { + if (!(error instanceof Error && error.message.includes("Invalid InterceptionId"))) this.lost() + throw error + }) + private closed = false + + private constructor( + private readonly browser: Browser, + private readonly session: CDPSession, + ) { + session.on("Fetch.requestPaused", this.request) + session.on("Fetch.authRequired", this.request) + session.on("Inspector.detached", this.lost) + browser.on("disconnected", this.lost) + } + + static async acquire(browser: Browser): Promise { + const existing = guards.get(browser) + if (existing) return existing + const pending = (async () => { + const session = await browser.newBrowserCDPSession() + const guard = new Guard(browser, session) + try { + await session.send("Fetch.enable", { + patterns: [{ urlPattern: "*", requestStage: "Request" }], + handleAuthRequests: true, + }) + return guard + } catch (error) { + guard.lost() + throw error + } + })() + guards.set(browser, pending) + try { + return await pending + } catch (error) { + if (guards.get(browser) === pending) guards.delete(browser) + throw error + } + } + + bind(context: string, owner: Owner): () => Promise { + if (this.closed || this.owners.has(context)) throw new Error("Browser context network ownership is unavailable") + this.owners.set(context, owner) + return async () => { + if (this.owners.get(context) === owner) this.owners.delete(context) + await Promise.all( + [...this.pending] + .filter(([, paused]) => paused.owner === owner) + .map(async ([id, paused]) => { + this.pending.delete(id) + await this.cancel(id, paused.auth) + }), + ) + } + } + + private readonly request = (event: Request | Challenge) => { + const auth = "authChallenge" in event + const owner = [...this.owners.values()].find((owner) => owner.frames.has(event.frameId)) + if (this.closed || !owner?.active()) { + void this.cancel(event.requestId, auth) + return + } + const paused = { owner, auth } + this.pending.set(event.requestId, paused) + const operation = auth ? owner.authenticate(event, this.send) : owner.request(event, this.send) + void operation + .catch(() => this.lost()) + .finally(() => { + if (this.pending.get(event.requestId) === paused) this.pending.delete(event.requestId) + }) + } + + private async cancel(requestId: string, auth: boolean): Promise { + const operation = auth + ? this.session.send("Fetch.continueWithAuth", { requestId, authChallengeResponse: { response: "CancelAuth" } }) + : this.session.send("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }) + await operation.catch((error: unknown) => { + if (error instanceof Error && error.message.includes("Invalid InterceptionId")) return + this.lost() + }) + } + + private readonly lost = () => { + if (this.closed) return + this.closed = true + const pending = guards.get(this.browser) + for (const owner of this.owners.values()) owner.fail() + this.owners.clear() + const requests = [...this.pending] + this.pending.clear() + void Promise.all(requests.map(([id, paused]) => this.cancel(id, paused.auth))) + .then(() => this.session.detach()) + .catch(() => this.lost()) + .finally(() => { + if (guards.get(this.browser) === pending) guards.delete(this.browser) + this.session.off("Fetch.requestPaused", this.request) + this.session.off("Fetch.authRequired", this.request) + this.session.off("Inspector.detached", this.lost) + this.browser.off("disconnected", this.lost) + }) + } +} + +interface Request { + requestId: string + frameId: string + resourceType: string + request: { url: string } +} + +interface Challenge { + requestId: string + frameId: string + authChallenge: { source?: string; origin: string; realm: string; scheme: string } +} + +interface Options { + url: URL + proxy: BrowserProxy + approve: (url: URL) => Promise + blocked: (message: string) => void + log: (...args: unknown[]) => void +} + +export class BrowserNetwork { + private readonly origins = new Set() + private readonly challenges = new Set() + private readonly approvals = new Map>() + private readonly frames = new Map() + private readonly documents = new Map() + private readonly send: Send = (method, params) => this.session.send(method, params) + private release: (() => Promise) | undefined + private stopping: Promise | undefined + private sequence = 0 + private root = "" + private closed = false + private failed = false + + get active(): boolean { + return !this.closed && !this.failed + } + + private constructor( + private readonly page: Page, + private readonly session: CDPSession, + private readonly opts: Options, + ) { + this.origins.add(opts.url.origin) + } + + static async attach(page: Page, opts: Options): Promise { + const session = await page.context().newCDPSession(page) + const network = new BrowserNetwork(page, session, opts) + session.on("Page.frameNavigated", network.navigated) + session.on("Page.frameAttached", network.inserted) + session.on("Page.frameDetached", network.removed) + session.on("Inspector.detached", network.lost) + session.on("Target.attachedToTarget", network.attached) + session.on("Target.receivedMessageFromTarget", network.received) + session.on("Target.detachedFromTarget", network.detached) + try { + const browser = page.context().browser() + if (!browser) throw new Error("Browser network ownership requires a browser connection") + const { targetInfo: target } = await session.send("Target.getTargetInfo") + if (!target.browserContextId) throw new Error("Browser network ownership requires an isolated context") + await session.send("Page.enable") + const tree = await session.send("Page.getFrameTree") + network.root = tree.frameTree.frame.id + network.documents.set(network.root, undefined) + const guard = await Guard.acquire(browser) + network.release = guard.bind(target.browserContextId, { + frames: network.documents, + active: () => network.active, + request: network.request, + authenticate: network.authenticate, + fail: network.lost, + }) + await network.enable(network.send) + return network + } catch (error) { + await network.close() + throw error + } + } + + authorize(url: URL): void { + this.origins.add(url.origin) + } + + private async enable(send: Send): Promise { + await send("Page.enable") + await send("Network.setCacheDisabled", { cacheDisabled: true }) + await send("Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: false, + filter: [{ type: "iframe" }], + }) + } + + private readonly attached = (event: Attachment, parent?: string) => { + const pending = new Map() + const upstream = parent ? this.frames.get(parent)?.send : this.send + if (!upstream) return this.lost() + const send: Send = (method, params) => { + const deferred = Promise.withResolvers() + const id = ++this.sequence + pending.set(id, deferred) + void upstream("Target.sendMessageToTarget", { + sessionId: event.sessionId, + message: JSON.stringify({ id, method, params }), + }).catch(deferred.reject) + return deferred.promise.finally(() => pending.delete(id)) + } + this.frames.set(event.sessionId, { send, pending, parent }) + void (async () => { + if (!this.active || event.targetInfo.type !== "iframe") throw new Error("Browser frame is not available") + await this.enable(send) + await send("Runtime.runIfWaitingForDebugger") + })().catch((error: unknown) => { + if (!this.active || !this.frames.has(event.sessionId)) return + this.opts.log("Browser frame network controls failed", error) + this.lost() + }) + } + + private readonly received = (event: { sessionId: string; message: string }) => { + const frame = this.frames.get(event.sessionId) + if (!frame) return + const message = JSON.parse(event.message) as { + id?: number + result?: unknown + error?: { message: string } + method?: string + params: unknown + } + if (message.id !== undefined) { + const deferred = frame.pending.get(message.id) + if (message.error) return deferred?.reject(new Error(message.error.message)) + deferred?.resolve(message.result) + return + } + if (message.method === "Page.frameAttached") + this.inserted(message.params as { frameId: string; parentFrameId: string }) + if (message.method === "Page.frameDetached") this.removed(message.params as { frameId: string; reason: string }) + if (message.method === "Inspector.detached") this.lost() + if (message.method === "Target.attachedToTarget") this.attached(message.params as Attachment, event.sessionId) + if (message.method === "Target.receivedMessageFromTarget") { + this.received(message.params as { sessionId: string; message: string }) + } + if (message.method === "Target.detachedFromTarget") this.detached(message.params as { sessionId: string }) + } + + private readonly detached = (event: { sessionId: string }) => { + const frame = this.frames.get(event.sessionId) + if (!frame) return + this.frames.delete(event.sessionId) + for (const deferred of frame.pending.values()) deferred.reject(new Error("Browser frame was detached")) + for (const [id, child] of this.frames) { + if (child.parent === event.sessionId) this.detached({ sessionId: id }) + } + } + + private readonly inserted = (event: { frameId: string; parentFrameId: string }) => { + if (this.documents.has(event.parentFrameId)) this.documents.set(event.frameId, event.parentFrameId) + } + + private readonly removed = (event: { frameId: string; reason: string }) => { + if (event.reason !== "remove") return + this.documents.delete(event.frameId) + for (const [frameId, parent] of this.documents) { + if (parent === event.frameId) this.removed({ frameId, reason: "remove" }) + } + } + + private readonly navigated = (event: { frame: { id: string; parentId?: string } }) => { + if (event.frame.parentId) return + if (this.root !== event.frame.id) this.removed({ frameId: this.root, reason: "remove" }) + this.root = event.frame.id + this.documents.set(this.root, undefined) + } + + private readonly lost = () => { + if (!this.active) return + this.failed = true + this.opts.blocked("Browser network controls disconnected. Close and reopen the browser.") + void this.opts.proxy.close().catch((error: unknown) => this.opts.log("Browser proxy close failed", error)) + } + + private readonly authenticate = async (event: Challenge, send: Send): Promise => { + const credentials = this.opts.proxy.credentials + const challenge = event.authChallenge + const allowed = + this.active && + !this.challenges.has(event.requestId) && + challenge.source === "Proxy" && + challenge.origin === credentials.origin && + challenge.realm === credentials.realm && + challenge.scheme.toLowerCase() === "basic" + this.challenges.add(event.requestId) + if (this.challenges.size > 256) { + const first = this.challenges.values().next().value + if (first) this.challenges.delete(first) + } + await send("Fetch.continueWithAuth", { + requestId: event.requestId, + authChallengeResponse: allowed + ? { response: "ProvideCredentials", username: credentials.username, password: credentials.password } + : { response: "CancelAuth" }, + }).catch((error: unknown) => { + if (!this.closed) this.opts.log("Browser proxy authentication ended", error) + }) + } + + private readonly request = (event: Request, send: Send) => { + return this.continue(event, send).catch(async (error: unknown) => { + if (!this.closed) this.opts.log("Browser request policy failed", error) + await this.reject(event.requestId, send) + }) + } + + private async continue(event: Request, send: Send): Promise { + if (!this.active) return this.reject(event.requestId, send) + const url = URL.parse(event.request.url) + if (!url || url.username || url.password) return this.reject(event.requestId, send) + if (url.protocol === "data:" || url.protocol === "blob:" || url.protocol === "about:") { + if (event.resourceType === "Document" && event.frameId === this.root) return this.reject(event.requestId, send) + return this.resume(event.requestId, send) + } + if (!this.allowed(url, event.resourceType === "Document")) { + if (event.resourceType === "Document" && event.frameId === this.root) { + this.opts.blocked( + this.opts.url.protocol === "http:" + ? "Local browser navigation must stay on the approved origin." + : "Remote browser pages must use public HTTPS.", + ) + } + return this.reject(event.requestId, send) + } + if (event.resourceType !== "Document" || event.frameId !== this.root || this.origins.has(url.origin)) { + return this.resume(event.requestId, send) + } + const approval = this.approvals.get(url.origin) ?? this.opts.approve(url) + this.approvals.set(url.origin, approval) + const allowed = await approval.finally(() => { + if (this.approvals.get(url.origin) === approval) this.approvals.delete(url.origin) + }) + if (!this.active) return this.reject(event.requestId, send) + if (!allowed) { + this.opts.blocked(`Navigation to ${url.origin} was not approved.`) + return this.reject(event.requestId, send) + } + this.origins.add(url.origin) + return this.resume(event.requestId, send) + } + + private allowed(url: URL, document: boolean): boolean { + if (this.opts.url.protocol !== "http:") return url.protocol === "https:" || url.protocol === "wss:" + const origin = url.protocol === "ws:" ? `http://${url.host}` : url.origin + if (["http:", "ws:"].includes(url.protocol) && origin === this.opts.url.origin) return true + return !document && ["https:", "wss:"].includes(url.protocol) + } + + private async resume(requestId: string, send: Send): Promise { + if (!this.active) return this.reject(requestId, send) + await send("Fetch.continueRequest", { requestId }) + } + + private async reject(requestId: string, send: Send): Promise { + await send("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }).catch((error: unknown) => { + if (!this.closed) this.opts.log("Browser request already ended", error) + }) + } + + close(): Promise { + return (this.stopping ??= this.stop()) + } + + private async stop(): Promise { + if (this.closed) return + this.closed = true + await this.opts.proxy.close().catch((error: unknown) => this.opts.log("Browser proxy close failed", error)) + this.approvals.clear() + this.challenges.clear() + this.session.off("Page.frameNavigated", this.navigated) + this.session.off("Page.frameAttached", this.inserted) + this.session.off("Page.frameDetached", this.removed) + this.documents.clear() + await this.release?.() + this.release = undefined + this.session.off("Inspector.detached", this.lost) + this.session.off("Target.attachedToTarget", this.attached) + this.session.off("Target.receivedMessageFromTarget", this.received) + this.session.off("Target.detachedFromTarget", this.detached) + for (const sessionId of this.frames.keys()) this.detached({ sessionId }) + if (this.page.isClosed()) return + await this.session + .send("Target.setAutoAttach", { autoAttach: false, waitForDebuggerOnStart: false, flatten: false }) + .catch((error: unknown) => { + if (!this.page.isClosed()) this.opts.log("Browser frame network controls ended", error) + }) + if (this.page.isClosed()) return + await this.session.detach().catch((error: unknown) => { + if (!this.page.isClosed()) this.opts.log("Browser network session ended", error) + }) + } +} diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts b/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts new file mode 100644 index 000000000000..279f1d1f0476 --- /dev/null +++ b/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts @@ -0,0 +1,33 @@ +import { isPublicAddress, parseDestination } from "@kilocode/sandbox/destination" +import { isIP } from "node:net" + +export function parse(value: string): URL { + if (value !== value.trim() || /[\u0000-\u001f\u007f\\]/.test(value) || !/^https?:\/\//i.test(value)) { + throw new TypeError("Browser URL is invalid") + } + const url = new URL(value) + if (url.port === "0") throw new TypeError("Browser URL port is invalid") + if (url.username || url.password || /^https?:\/\/[^/?#]*@/i.test(value)) { + throw new TypeError("Browser URLs must not contain credentials") + } + // Dev servers print 0.0.0.0 when they listen on every interface. It is not a destination, so use localhost, + // the origin that dev tools usually configure. + if (url.protocol === "http:" && url.hostname === "0.0.0.0") url.hostname = "localhost" + if (url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)) { + return url + } + if (url.protocol !== "https:") { + throw new TypeError("Browser URLs must use public HTTPS or HTTP localhost/127.0.0.1") + } + const host = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname + if (isIP(host)) { + if (!isPublicAddress(host)) throw new TypeError("Browser HTTPS destinations must be public") + return url + } + const dest = parseDestination(url.host) + if (dest.host === "localhost" || dest.host.endsWith(".localhost")) { + throw new TypeError("Browser HTTPS destinations must be public") + } + url.hostname = dest.host + return url +} diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-proxy.ts b/packages/kilo-vscode/src/services/browser-automation/browser-proxy.ts new file mode 100644 index 000000000000..e92c60fa7d83 --- /dev/null +++ b/packages/kilo-vscode/src/services/browser-automation/browser-proxy.ts @@ -0,0 +1,562 @@ +import { isPublicAddress } from "@kilocode/sandbox/destination" +import { createHash, randomBytes, timingSafeEqual } from "node:crypto" +import { lookup } from "node:dns/promises" +import { once } from "node:events" +import { + createServer, + request as send, + validateHeaderName, + validateHeaderValue, + type IncomingHttpHeaders, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http" +import { connect, isIP, type Socket } from "node:net" +import type { Duplex } from "node:stream" +import { parse } from "./browser-policy" + +interface Options { + lookup?: (host: string, signal: AbortSignal) => Promise + connect?: (options: { host: string; port: number; family: 4 | 6 }) => Socket + timeout?: number +} + +const LIMIT = 16 * 1024 + +// Marks a 502 response that the proxy creates because no connection to the local application was possible. +export const UNREACHABLE = "x-kilo-browser-unreachable" + +class Unreachable extends Error {} + +function authority(value: string, scheme: "http" | "https") { + if (!/^(?:\[[\da-f:]+\]|[^\s:/?#@\\]+):\d+$/i.test(value)) { + throw new TypeError("Invalid proxy authority") + } + return parse(`${scheme}://${value}/`) +} + +function headers(input: IncomingHttpHeaders, host?: string) { + const blocked = new Set([ + "connection", + "proxy-connection", + "proxy-authorization", + "proxy-authenticate", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + ...(input.connection ?? "").split(",").map((value) => value.trim().toLowerCase()), + ]) + return { + ...Object.fromEntries( + Object.entries(input).filter(([name, value]) => value !== undefined && !blocked.has(name.toLowerCase())), + ), + ...(host ? { host } : {}), + } +} + +function local(request: IncomingMessage, url: URL) { + const fields = request.headers + if (fields.origin !== undefined && fields.origin !== url.origin) { + throw new Error("Local browser request origin is not approved") + } + if (fields["sec-fetch-site"] === "same-origin") return + if ( + fields["sec-fetch-site"] === "none" && + fields["sec-fetch-mode"] === "navigate" && + fields["sec-fetch-dest"] === "document" && + ["GET", "HEAD"].includes(request.method ?? "") + ) + return + throw new Error("Local browser request requires an approved document origin") +} + +function websocket(input: IncomingHttpHeaders) { + return ( + input.upgrade?.toLowerCase() === "websocket" && + input.connection?.split(",").some((value) => value.trim().toLowerCase() === "upgrade") === true + ) +} + +function key(input: IncomingHttpHeaders) { + const value = input["sec-websocket-key"] + if ( + !websocket(input) || + input["sec-websocket-version"] !== "13" || + typeof value !== "string" || + !/^[A-Za-z0-9+/]{22}==$/.test(value) || + Buffer.from(value, "base64").length !== 16 || + input["content-length"] !== undefined || + input["transfer-encoding"] !== undefined + ) { + throw new TypeError("Invalid WebSocket upgrade") + } + return value +} + +function opening(input: Buffer, url: URL) { + const end = input.indexOf("\r\n\r\n") + if (end < 0) return false + if (end + 4 !== input.length) throw new TypeError("Unexpected WebSocket handshake data") + const lines = input.subarray(0, end).toString("latin1").split("\r\n") + const line = /^GET (\/[^\s#]*) HTTP\/1\.1$/.exec(lines.shift() ?? "") + if (!line || parse(`${url.origin}${line[1]}`).origin !== url.origin) { + throw new TypeError("Invalid WebSocket request") + } + const fields: IncomingHttpHeaders = Object.create(null) + for (const line of lines) { + const colon = line.indexOf(":") + const name = line.slice(0, colon).toLowerCase() + const value = line.slice(colon + 1) + validateHeaderName(name) + validateHeaderValue(name, value) + if (colon < 1 || fields[name] !== undefined) throw new TypeError("Invalid WebSocket headers") + fields[name] = value.trim() + } + const host = fields.host ?? "" + if (/[\s/?#@\\]/.test(host)) throw new TypeError("Invalid WebSocket host") + const target = parse(`http://${host}/`) + if (target.origin !== url.origin || fields.origin !== url.origin || fields["proxy-authorization"] !== undefined) { + throw new TypeError("WebSocket origin is not approved") + } + key(fields) + return true +} + +function validate(input: Buffer) { + let offset = 0 + const take = (size: number) => { + if (offset + size > input.length) throw new TypeError("Truncated TLS ClientHello") + const value = input.subarray(offset, offset + size) + offset += size + return value + } + const version = take(2) + if (version[0] !== 3 || version[1] < 1 || version[1] > 3) throw new TypeError("Invalid TLS version") + take(32) + const session = take(1)[0] + if (session > 32) throw new TypeError("Invalid TLS session") + take(session) + const ciphers = take(2).readUInt16BE() + if (ciphers < 2 || ciphers % 2) throw new TypeError("Invalid TLS ciphers") + take(ciphers) + const compression = take(take(1)[0]) + if (!compression.includes(0)) throw new TypeError("Invalid TLS compression") + const length = take(2).readUInt16BE() + if (length !== input.length - offset) throw new TypeError("Invalid TLS extensions") + const seen = new Set() + while (offset < input.length) { + const type = take(2).readUInt16BE() + const size = take(2).readUInt16BE() + if (seen.has(type)) throw new TypeError("Duplicate TLS extension") + seen.add(type) + take(size) + } +} + +class Preface { + private readonly input: Buffer + private readonly body = Buffer.alloc(64 * 1024 + 4) + private size = 0 + private offset = 0 + private count = 0 + private ready = false + + constructor(private readonly url: URL) { + this.input = Buffer.alloc(url.protocol === "http:" ? LIMIT : 128 * 1024) + } + + push(chunk: Buffer) { + if (this.size + chunk.length > this.input.length) throw new TypeError("Proxy handshake is too large") + chunk.copy(this.input, this.size) + this.size += chunk.length + if (this.url.protocol === "http:") return opening(this.bytes(), this.url) + if (this.ready) return true + while (this.offset + 5 <= this.size) { + const start = this.offset + const length = this.input.readUInt16BE(start + 3) + if ( + this.input[start] !== 22 || + this.input[start + 1] !== 3 || + this.input[start + 2] < 1 || + this.input[start + 2] > 3 || + length === 0 || + length > LIMIT + ) { + throw new TypeError("CONNECT requires TLS") + } + if (start + 5 + length > this.size) return false + if (this.count + length > this.body.length) throw new TypeError("TLS ClientHello is too large") + this.input.copy(this.body, this.count, start + 5, start + 5 + length) + this.count += length + this.offset += length + 5 + if (this.count < 4) continue + const size = this.body.readUIntBE(1, 3) + if (this.body[0] !== 1 || size > 65535) throw new TypeError("Invalid TLS ClientHello") + if (this.count < size + 4) continue + if (this.count !== size + 4) throw new TypeError("Invalid TLS handshake length") + validate(this.body.subarray(4, this.count)) + this.ready = true + return true + } + return false + } + + bytes() { + return this.input.subarray(0, this.size) + } +} + +function scope(client: Duplex, parent: AbortSignal, timeout: number) { + const controller = new AbortController() + const abort = () => controller.abort() + const timer = setTimeout(abort, timeout) + timer.unref() + client.once("close", abort) + client.once("error", abort) + return { + signal: AbortSignal.any([parent, controller.signal]), + abort, + ready: () => clearTimeout(timer), + close: () => { + clearTimeout(timer) + client.off("close", abort) + client.off("error", abort) + }, + } +} + +function waiting(promise: Promise, signal: AbortSignal) { + const pending = Promise.withResolvers() + const abort = () => pending.reject(new Error("Proxy request cancelled")) + signal.addEventListener("abort", abort, { once: true }) + if (signal.aborted) abort() + return Promise.race([promise, pending.promise]).finally(() => signal.removeEventListener("abort", abort)) +} + +function pipe(client: Duplex, upstream: Socket, head: Buffer) { + client.pause() + client.once("close", () => upstream.destroy()) + upstream.once("close", () => client.destroy()) + upstream.pipe(client) + const resume = () => client.pipe(upstream) + if (head.length === 0 || upstream.write(head)) resume() + else upstream.once("drain", resume) +} + +export class BrowserProxy { + readonly proxy: Readonly<{ server: string; bypass: "<-loopback>" }> + readonly credentials: Readonly<{ username: string; password: string; origin: string; realm: string }> + private readonly sockets = new Set() + private readonly stop = new AbortController() + private closing: Promise | undefined + + private constructor( + private readonly policy: string, + private readonly opts: Options, + private readonly server: Server, + origin: string, + private readonly timeout: number, + ) { + this.proxy = Object.freeze({ server: origin, bypass: "<-loopback>" }) + this.credentials = Object.freeze({ + username: randomBytes(16).toString("hex"), + password: randomBytes(32).toString("base64url"), + origin, + realm: `kilo-browser-${randomBytes(16).toString("hex")}`, + }) + server.on("connection", (socket) => this.track(socket)) + server.on("request", (request, response) => void this.forward(request, response)) + server.on("upgrade", (request, client, head) => void this.upgrade(request, client, head)) + server.on("connect", (request, client, head) => this.tunnel(request, client, head)) + server.on("clientError", (_error, socket) => { + socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n") + }) + } + + static async start(policy: "deny" | "public" | URL, opts: Options = {}) { + const target = policy instanceof URL ? parse(policy.href) : policy + if (target instanceof URL ? target.protocol !== "http:" : !["deny", "public"].includes(target)) { + throw new TypeError("Invalid browser proxy policy") + } + const timeout = opts.timeout ?? 30_000 + if (!Number.isInteger(timeout) || timeout < 1 || timeout > 30_000) { + throw new TypeError("Invalid browser proxy timeout") + } + const server = createServer({ + maxHeaderSize: LIMIT, + headersTimeout: Math.min(timeout, 10_000), + requestTimeout: timeout, + connectionsCheckingInterval: Math.min(timeout, 1_000), + keepAliveTimeout: 5_000, + }) + server.maxConnections = 128 + const ready = once(server, "listening") + server.listen(0, "127.0.0.1") + await ready + const address = server.address() + if (!address || typeof address === "string") { + server.close() + throw new Error("Browser proxy did not bind a local port") + } + return new BrowserProxy( + target instanceof URL ? target.origin : target, + opts, + server, + `http://127.0.0.1:${address.port}`, + timeout, + ) + } + + close(): Promise { + if (this.closing) return this.closing + const closed = Promise.withResolvers() + this.closing = closed.promise + this.stop.abort() + for (const socket of this.sockets) socket.destroy() + this.server.close(() => closed.resolve()) + return this.closing + } + + private track(socket: Socket) { + this.sockets.add(socket) + socket.once("close", () => this.sockets.delete(socket)) + socket.on("error", () => socket.destroy()) + if (this.stop.signal.aborted) socket.destroy() + return socket + } + + private authenticated(request: IncomingMessage) { + const value = /^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(request.headers["proxy-authorization"] ?? "") + if (!value) return false + const actual = Buffer.from(value[1], "base64") + const expected = Buffer.from(`${this.credentials.username}:${this.credentials.password}`) + return actual.length === expected.length && timingSafeEqual(actual, expected) + } + + private challenge() { + return { "Proxy-Authenticate": `Basic realm="${this.credentials.realm}"` } + } + + private reject(client: Duplex, status = 403) { + const challenge = status === 407 ? `Proxy-Authenticate: ${this.challenge()["Proxy-Authenticate"]}\r\n` : "" + client.end( + `HTTP/1.1 ${status} Proxy Request Rejected\r\n${challenge}Connection: close\r\nContent-Length: 0\r\n\r\n`, + ) + } + + private target(value: string, tunnel = false) { + if (this.policy === "deny" || (this.policy === "public" && !tunnel)) { + throw new Error("Browser proxy destination is not approved") + } + const local = tunnel && this.policy !== "public" && new URL(`http://${value}/`).origin === this.policy + const url = tunnel ? authority(value, local ? "http" : "https") : parse(value.replace(/^ws:/i, "http:")) + if (!tunnel && (url.protocol !== "http:" || url.origin !== this.policy)) { + throw new Error("Browser proxy origin is not approved") + } + if (url.hash) throw new TypeError("Invalid proxy request URL") + return url + } + + private async dial(url: URL, signal: AbortSignal) { + signal.throwIfAborted() + const host = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname + const family = isIP(host) + const answers = family + ? [{ address: host, family }] + : await waiting( + (this.opts.lookup ?? ((host) => lookup(host, { all: true, verbatim: true })))(host, signal), + signal, + ) + signal.throwIfAborted() + const addresses = answers.map((entry) => ({ address: entry.address, family: entry.family })) + if ( + addresses.length === 0 || + addresses.some( + (entry) => + !isIP(entry.address) || + isIP(entry.address) !== entry.family || + (url.protocol === "https:" ? !isPublicAddress(entry.address) : !["127.0.0.1", "::1"].includes(entry.address)), + ) + ) { + throw new Error("Browser proxy denied a non-public or unapproved address") + } + const timeout = addresses.length > 1 ? Math.min(5_000, this.timeout / addresses.length) : this.timeout + let err: unknown = new Error("Browser proxy could not connect") + for (const address of addresses) { + signal.throwIfAborted() + const family = isIP(address.address) + if (family !== 4 && family !== 6) throw new Error("Invalid proxy address family") + const socket = this.track( + (this.opts.connect ?? connect)({ + host: address.address, + port: Number(url.port || (url.protocol === "https:" ? 443 : 80)), + family, + }), + ) + const attempt = scope(socket, signal, timeout) + try { + await once(socket, "connect", { signal: attempt.signal }) + attempt.signal.throwIfAborted() + return { socket, host: address.address, family } + } catch (cause) { + socket.destroy() + err = cause + } finally { + attempt.close() + } + } + throw new Unreachable("Browser proxy could not connect", { cause: err }) + } + + private tunnel(request: IncomingMessage, client: Duplex, head: Buffer) { + if (!this.authenticated(request)) return this.reject(client, 407) + const url = (() => { + try { + return this.target(request.url ?? "", true) + } catch { + this.reject(client) + } + })() + if (!url) return + const state = scope(client, this.stop.signal, this.timeout) + const preface = new Preface(url) + let pending = false + const cleanup = () => { + client.off("data", inspect) + client.off("end", fail) + state.signal.removeEventListener("abort", fail) + state.close() + } + const fail = () => { + cleanup() + state.abort() + client.destroy() + } + const inspect = (chunk: Buffer) => { + try { + if (!preface.push(chunk) || pending) return + pending = true + void this.dial(url, state.signal).then((peer) => { + if (state.signal.aborted || client.destroyed) { + peer.socket.destroy() + fail() + return + } + cleanup() + pipe(client, peer.socket, preface.bytes()) + }, fail) + } catch { + fail() + } + } + state.signal.addEventListener("abort", fail, { once: true }) + client.once("end", fail) + client.on("data", inspect) + client.write("HTTP/1.1 200 Connection Established\r\n\r\n") + if (head.length) inspect(head) + client.resume() + } + + private async forward(request: IncomingMessage, response: ServerResponse) { + if (!this.authenticated(request)) { + response.writeHead(407, { ...this.challenge(), connection: "close" }).end() + return + } + const state = scope(request.socket, this.stop.signal, this.timeout) + response.once("close", () => { + state.abort() + state.close() + }) + response.once("finish", state.close) + try { + const url = this.target(request.url ?? "") + local(request, url) + const peer = await this.dial(url, state.signal) + const upstream = send({ + hostname: peer.host, + family: peer.family, + port: Number(url.port || 80), + method: request.method, + path: `${url.pathname}${url.search}`, + headers: headers(request.headers, url.host), + createConnection: () => peer.socket, + signal: state.signal, + }) + upstream.on("response", (incoming) => { + state.ready() + response.writeHead(incoming.statusCode ?? 502, headers(incoming.headers)) + incoming.on("error", () => response.destroy()) + incoming.pipe(response) + }) + upstream.on("error", () => { + if (!response.headersSent) response.writeHead(502) + response.end() + }) + request.pipe(upstream) + } catch (error) { + state.close() + if (!response.headersSent) { + response.writeHead( + error instanceof Unreachable ? 502 : 403, + error instanceof Unreachable ? { connection: "close", [UNREACHABLE]: "1" } : { connection: "close" }, + ) + } + response.end() + } + } + + private async upgrade(request: IncomingMessage, client: Duplex, head: Buffer) { + if (!this.authenticated(request)) return this.reject(client, 407) + const state = scope(client, this.stop.signal, this.timeout) + try { + const url = this.target(request.url ?? "") + if (request.headers.origin !== url.origin) throw new Error("Local WebSocket origin is not approved") + if (request.method !== "GET") throw new TypeError("Invalid WebSocket method") + const accept = createHash("sha1") + .update(`${key(request.headers)}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64") + const peer = await this.dial(url, state.signal) + const upstream = send({ + hostname: peer.host, + family: peer.family, + port: Number(url.port || 80), + path: `${url.pathname}${url.search}`, + headers: { ...headers(request.headers, url.host), connection: "Upgrade", upgrade: "websocket" }, + createConnection: () => peer.socket, + signal: state.signal, + }) + upstream.on("response", (incoming) => { + incoming.destroy() + state.close() + this.reject(client, 502) + }) + upstream.on("error", () => { + state.close() + client.destroy() + }) + upstream.on("upgrade", (incoming, socket, data) => { + state.close() + if (!websocket(incoming.headers) || incoming.headers["sec-websocket-accept"] !== accept) { + socket.destroy() + this.reject(client, 502) + return + } + const fields = { ...headers(incoming.headers), connection: "Upgrade", upgrade: "websocket" } + client.write("HTTP/1.1 101 Switching Protocols\r\n") + for (const [name, value] of Object.entries(fields)) { + for (const item of Array.isArray(value) ? value : [value]) client.write(`${name}: ${item}\r\n`) + } + client.write("\r\n") + if (data.length) client.write(data) + pipe(client, peer.socket, head) + }) + upstream.end() + } catch { + state.close() + this.reject(client) + } + } +} diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts b/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts new file mode 100644 index 000000000000..96ed4815622d --- /dev/null +++ b/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts @@ -0,0 +1,650 @@ +import type { CDPSession, Frame, Page } from "playwright-core" +import type { BrowserFrame, BrowserInteraction, BrowserViewport } from "../../shared/browser-stream" + +type Scope = { browserId: string; navigation: number } +type Key = Extract +type Pointer = Extract +type Wheel = Extract +type Clipboard = Extract["action"] +type Cast = { + sessionId: number + data: string + metadata: { deviceWidth: number; deviceHeight: number } +} + +const PAYLOAD = 2 * 1024 * 1024 +const TEXT = 64 * 1024 +const BUTTONS = { left: 1, right: 2, middle: 4 } as const +const MODIFIERS = [ + { key: "Alt", code: "AltLeft", keyCode: 18, mask: 1 }, + { key: "Control", code: "ControlLeft", keyCode: 17, mask: 2 }, + { key: "Meta", code: "MetaLeft", keyCode: 91, mask: 4 }, + { key: "Shift", code: "ShiftLeft", keyCode: 16, mask: 8 }, +] as const +const EDITING: Record = { + "4:KeyA": "selectAll", + "4:KeyZ": "undo", + "12:KeyZ": "redo", + "1:Backspace": "deleteWordBackward", + "1:Delete": "deleteWordForward", + "4:Backspace": "deleteToBeginningOfLine", + "1:ArrowLeft": "moveWordLeft", + "1:ArrowRight": "moveWordRight", + "4:ArrowLeft": "moveToLeftEndOfLine", + "4:ArrowRight": "moveToRightEndOfLine", + "4:ArrowUp": "moveToBeginningOfDocument", + "4:ArrowDown": "moveToEndOfDocument", + "9:ArrowLeft": "moveWordLeftAndModifySelection", + "9:ArrowRight": "moveWordRightAndModifySelection", + "12:ArrowLeft": "moveToLeftEndOfLineAndModifySelection", + "12:ArrowRight": "moveToRightEndOfLineAndModifySelection", + "12:ArrowUp": "moveToBeginningOfDocumentAndModifySelection", + "12:ArrowDown": "moveToEndOfDocumentAndModifySelection", +} + +function range(value: number, min: number, max: number, integer = false): boolean { + return Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isSafeInteger(value)) +} + +function text(value: string, limit: number): boolean { + return typeof value === "string" && value.length <= limit +} + +function dimensions(value: string): { width: number; height: number } | undefined { + const data = Buffer.from(value, "base64") + if (data.length < 4 || data.readUInt16BE(0) !== 0xffd8) return + for (let offset = 2; offset + 4 <= data.length; ) { + const marker = data.readUInt16BE(offset) + if ((marker & 0xff00) !== 0xff00 || marker === 0xffda || marker === 0xffd9) return + const length = data.readUInt16BE(offset + 2) + if (length < 2 || offset + 2 + length > data.length) return + if (marker >= 0xffc0 && marker <= 0xffcf && ![0xffc4, 0xffc8, 0xffcc].includes(marker)) { + if (length < 8) return + return { width: data.readUInt16BE(offset + 7), height: data.readUInt16BE(offset + 5) } + } + offset += length + 2 + } +} + +function position(event: { x: number; y: number; modifiers: number }): boolean { + return range(event.x, 0, 1) && range(event.y, 0, 1) && range(event.modifiers, 0, 15, true) +} + +function merge(current: Wheel, next: Wheel): boolean { + if (current.x !== next.x || current.y !== next.y || current.modifiers !== next.modifiers) return false + for (const axis of ["deltaX", "deltaY"] as const) { + if (Math.sign(current[axis]) !== Math.sign(next[axis])) return false + if (!range(current[axis] + next[axis], -10000, 10000)) return false + } + current.deltaX += next.deltaX + current.deltaY += next.deltaY + return true +} + +function pointer(event: Pointer): boolean { + return ( + position(event) && + ["move", "down", "up"].includes(event.action) && + ["left", "middle", "right"].includes(event.button) && + range(event.buttons, 0, 7, true) && + range(event.clicks, 0, 3, true) + ) +} + +function key(event: Key): boolean { + return ( + ["down", "up"].includes(event.action) && + text(event.key, 64) && + event.key.length > 0 && + text(event.code, 64) && + range(event.keyCode, 0, 255, true) && + range(event.modifiers, 0, 15, true) && + typeof event.repeat === "boolean" && + (event.text === undefined || text(event.text, 64)) + ) +} + +function location(event: Key): number { + if (!MODIFIERS.some((modifier) => modifier.key === event.key)) return 0 + return event.code.endsWith("Right") ? 2 : 1 +} + +function valid(event: BrowserInteraction): boolean { + if (!event || typeof event !== "object") return false + switch (event.kind) { + case "pointer": + return pointer(event) + case "wheel": + return position(event) && range(event.deltaX, -10000, 10000) && range(event.deltaY, -10000, 10000) + case "key": + return key(event) + case "text": + return text(event.text, TEXT) + case "composition": + return ( + text(event.text, TEXT) && + range(event.start, 0, event.text.length, true) && + range(event.end, event.start, event.text.length, true) + ) + case "clipboard": + return ["copy", "cut", "paste"].includes(event.action) + case "release": + return true + default: + return false + } +} + +function selection(opts: { action: Clipboard; limit: number; text?: string }): { focused: boolean; text?: string } { + let node = document.activeElement + while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement + if (node instanceof HTMLIFrameElement || node instanceof HTMLFrameElement) return { focused: false } + const transfer = (): { handled: boolean; text?: string } => { + if (opts.action !== "paste" && node instanceof HTMLInputElement && node.type === "password") + return { handled: true } + const data = new DataTransfer() + if (opts.action === "paste") data.setData("text/plain", opts.text ?? "") + const allowed = (node ?? document).dispatchEvent( + new ClipboardEvent(opts.action, { bubbles: true, cancelable: true, composed: true, clipboardData: data }), + ) + if (opts.action === "paste") return { handled: true, text: allowed ? opts.text : undefined } + if (allowed) return { handled: false } + const value = data.types.includes("text/plain") ? data.getData("text/plain") : undefined + if (value !== undefined && value.length > opts.limit) throw new Error("Browser selection exceeds the text limit") + return { handled: true, text: value } + } + const event = transfer() + if (event.handled) return { focused: true, text: event.text } + + const field = (node: HTMLInputElement | HTMLTextAreaElement) => { + if (node instanceof HTMLInputElement && node.type === "password") return undefined + const start = node.selectionStart + const end = node.selectionEnd + if (start === null || end === null || start === end) return undefined + if (end - start > opts.limit) throw new Error("Browser selection exceeds the text limit") + const value = node.value.slice(start, end) + if (opts.action === "cut" && !node.readOnly && !node.disabled) document.execCommand("delete") + return value + } + + const editable = (node: Node) => { + let element = node instanceof HTMLElement ? node : node.parentElement + if (!element?.isContentEditable) return undefined + while (element.parentElement?.isContentEditable) element = element.parentElement + return element + } + + const remove = (range: Range) => { + const root = editable(range.startContainer) + if (!root || root !== editable(range.endContainer)) return + const nodes = root.querySelectorAll('[contenteditable="false"], input, textarea') + if ([...nodes].some((node) => range.intersectsNode(node))) return + document.execCommand("delete") + } + + if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { + return { focused: true, text: field(node) } + } + const value = document.getSelection() + if (!value || value.isCollapsed || value.rangeCount !== 1) return { focused: true } + const result = value.toString() + if (result.length > opts.limit) throw new Error("Browser selection exceeds the text limit") + if (opts.action === "cut") remove(value.getRangeAt(0)) + return { focused: true, text: result } +} + +export class BrowserStream { + private session?: CDPSession + private view?: BrowserViewport + private casting?: BrowserViewport + private scope: Scope + private epoch = 0 + private sequence = 0 + private outstanding?: number + private buffered?: BrowserFrame + private pending: Promise = Promise.resolve() + private cancel?: () => void + private wheel?: { event: Wheel; result: Promise } + private closing?: Promise + private closed = false + private started = false + private buttons = 0 + private modifiers = 0 + private x = 0 + private y = 0 + private composing = false + private readonly keys = new Map() + + constructor( + private readonly page: Page, + private readonly identity: () => Scope, + private readonly emit: (frame: BrowserFrame) => void, + private readonly log: (...args: unknown[]) => void, + ) { + this.scope = { ...identity() } + page.on("close", this.ended) + page.on("framenavigated", this.navigated) + } + + async configure(view: BrowserViewport): Promise { + if (this.closed) return + if ( + !view || + !range(view.width, Number.MIN_VALUE, Number.MAX_VALUE) || + !range(view.height, Number.MIN_VALUE, Number.MAX_VALUE) || + (view.scale !== undefined && !range(view.scale, Number.MIN_VALUE, Number.MAX_VALUE)) || + !range(view.revision, 0, Number.MAX_SAFE_INTEGER, true) || + typeof view.active !== "boolean" + ) { + throw new Error("Invalid browser viewport") + } + const current = this.view + const suspend = current && current.active && !view.active && view.revision === current.revision + if (current && view.revision <= current.revision && !suspend) return + const width = Math.max(32, Math.min(4096, Math.round(view.width))) + const height = Math.max(32, Math.min(2160, Math.round(view.height))) + const next = suspend + ? { ...current, active: false } + : { + width, + height, + scale: Math.max(1, Math.min(view.scale ?? 1, 2, 4096 / width, 2160 / height)), + revision: view.revision, + active: view.active, + } + this.view = next + this.reset() + await this.serial(async () => { + if (this.closed || this.view !== next) return + const session = await this.connect() + if (this.closed || this.view !== next) return + await this.stop() + await this.release() + if (this.closed || this.view !== next || suspend) return + await this.page.setViewportSize({ width: next.width, height: next.height }) + if (this.closed || this.view !== next || !next.active) return + this.casting = next + this.started = true + await session.send("Page.startScreencast", { + format: "jpeg", + quality: 90, + maxWidth: Math.round(next.width * (next.scale ?? 1)), + maxHeight: Math.round(next.height * (next.scale ?? 1)), + everyNthFrame: 1, + }) + }).catch((error: unknown) => { + this.casting = undefined + if (!this.closed) throw error + }) + } + + acknowledge(sequence: number): void { + if (this.closed || !range(sequence, 1, Number.MAX_SAFE_INTEGER, true)) return + this.synchronize() + if (this.outstanding !== sequence) return + this.outstanding = undefined + const frame = this.buffered + this.buffered = undefined + if (frame) this.deliver(frame) + } + + async interact( + event: BrowserInteraction, + read?: () => Promise, + write?: (text: string) => void | Promise, + ): Promise { + if (this.closed) return + if (!valid(event)) throw new Error("Invalid browser interaction") + const input = { ...event } + this.synchronize() + const queued = this.coalesce(input) + if (queued) return queued + const epoch = this.epoch + const view = this.view + const result = this.serial(async () => { + if (this.wheel?.event === input) this.wheel = undefined + if (this.closed) return + this.synchronize() + if (input.kind === "release") { + await this.release() + return + } + if (epoch !== this.epoch || !view?.active || this.view !== view || this.casting !== view) return + const session = this.session + if (!session) return + switch (input.kind) { + case "pointer": + await this.point(session, input, view) + return + case "wheel": + this.coordinates(input, view) + await session.send("Input.dispatchMouseEvent", { + type: "mouseWheel", + x: this.x, + y: this.y, + modifiers: this.modifiers, + buttons: this.buttons, + deltaX: input.deltaX, + deltaY: input.deltaY, + }) + return + case "key": + await this.keyboard(session, input) + return + case "text": + await session.send("Input.insertText", { text: input.text }) + this.composing = false + return + case "composition": + this.composing = input.text.length > 0 + await session.send("Input.imeSetComposition", { + text: input.text, + selectionStart: input.start, + selectionEnd: input.end, + }) + return + case "clipboard": + if (input.action === "paste") { + await this.paste(read, epoch) + return + } + return this.clipboard(input.action, epoch, undefined, write) + } + }).catch((error: unknown) => { + if (!this.closed) throw error + return undefined + }) + if (input.kind === "wheel") this.wheel = { event: input, result } + return result + } + + private async paste(read: (() => Promise) | undefined, epoch: number): Promise { + if (!read) throw new Error("Clipboard access is not available.") + const value = await this.cancellable(read) + this.synchronize() + if (value === undefined || this.closed || epoch !== this.epoch) return + if (!text(value, TEXT)) throw new Error("Browser clipboard exceeds the text limit") + const pasted = await this.clipboard("paste", epoch, value) + this.synchronize() + if (pasted === undefined || this.closed || epoch !== this.epoch) return + await this.session?.send("Input.insertText", { text: pasted }) + this.composing = false + } + + private async cancellable(run: () => T | Promise): Promise { + const stopped = Promise.withResolvers() + const cancel = () => stopped.resolve(undefined) + this.cancel = cancel + try { + return await Promise.race([run(), stopped.promise]) + } finally { + if (this.cancel === cancel) this.cancel = undefined + } + } + + private coalesce(event: BrowserInteraction): Promise | undefined { + const queued = this.wheel + this.wheel = undefined + if (event.kind !== "wheel" || !queued || !merge(queued.event, event)) return + this.wheel = queued + return queued.result + } + + // Runs a page operation that must not overlap viewport changes or input. + exclusive(run: () => Promise): Promise { + return this.serial(run) + } + + close(): Promise { + if (this.closing) return this.closing + this.closed = true + this.reset() + this.page.off("close", this.ended) + this.page.off("framenavigated", this.navigated) + this.closing = this.serial(async () => { + await this.release() + const session = this.session + if (!session) return + await this.stop().catch(() => this.report("stop failed")) + session.off("Page.screencastFrame", this.receive) + this.session = undefined + await session.detach().catch(() => this.report("detach failed")) + }) + return this.closing + } + + private readonly ended = (): void => { + void this.close().catch(() => this.report("close failed")) + } + + private readonly navigated = (frame: Frame): void => { + if (this.closed || frame !== this.page.mainFrame()) return + this.synchronize() + this.reset() + void this.serial(() => this.release()).catch(() => this.report("navigation release failed")) + } + + private serial(run: () => Promise): Promise { + const result = this.pending.then(run) + this.pending = result.then( + () => undefined, + () => undefined, + ) + return result + } + + private report(message: string): void { + this.log(`[Kilo New] Browser stream ${message}`) + } + + private reset(): void { + this.epoch++ + this.cancel?.() + this.cancel = undefined + this.wheel = undefined + this.outstanding = undefined + this.buffered = undefined + } + + private synchronize(): void { + const scope = this.identity() + if (scope.browserId === this.scope.browserId && scope.navigation === this.scope.navigation) return + this.scope = { ...scope } + this.reset() + } + + private async connect(): Promise { + if (this.session) return this.session + const session = await this.page.context().newCDPSession(this.page) + this.session = session + session.on("Page.screencastFrame", this.receive) + if (!this.closed) await session.send("Page.enable") + return session + } + + private async stop(): Promise { + this.casting = undefined + if (!this.started || !this.session) return + this.started = false + await this.session.send("Page.stopScreencast") + } + + private readonly receive = (event: Cast): void => { + const session = this.session + if (!session) return + void session.send("Page.screencastFrameAck", { sessionId: event.sessionId }).catch(() => { + if (!this.closed) this.report("frame acknowledgement failed") + }) + const view = this.casting + if (this.closed || !view || this.view !== view) return + this.synchronize() + if ( + typeof event.data !== "string" || + !event.data.length || + event.data.length > Math.ceil(PAYLOAD / 3) * 4 || + Buffer.byteLength(event.data, "base64") > PAYLOAD || + event.metadata.deviceWidth !== view.width || + event.metadata.deviceHeight !== view.height + ) + return + const size = dimensions(event.data) + if ( + !size || + !size.width || + !size.height || + Math.abs(size.width - Math.round(view.width * (view.scale ?? 1))) > 1 || + Math.abs(size.height - Math.round(view.height * (view.scale ?? 1))) > 1 + ) + return + const frame: BrowserFrame = { + ...this.scope, + revision: view.revision, + sequence: ++this.sequence, + width: size.width, + height: size.height, + data: event.data, + } + if (this.outstanding !== undefined) { + this.buffered = frame + return + } + this.deliver(frame) + } + + private deliver(frame: BrowserFrame): void { + this.outstanding = frame.sequence + try { + this.emit(frame) + } catch { + if (this.outstanding === frame.sequence) this.outstanding = undefined + this.report("frame delivery failed") + } + } + + private coordinates(event: { x: number; y: number; modifiers: number }, view: BrowserViewport): void { + this.x = Math.min(view.width - 1, event.x * view.width) + this.y = Math.min(view.height - 1, event.y * view.height) + this.modifiers = event.modifiers + } + + private async point(session: CDPSession, event: Pointer, view: BrowserViewport): Promise { + this.coordinates(event, view) + this.buttons |= event.buttons + if (event.action === "down") this.buttons |= BUTTONS[event.button] + if (event.action === "up") this.buttons &= ~BUTTONS[event.button] + await session.send("Input.dispatchMouseEvent", { + type: event.action === "move" ? "mouseMoved" : event.action === "down" ? "mousePressed" : "mouseReleased", + x: this.x, + y: this.y, + button: event.action === "move" && !this.buttons ? "none" : event.button, + buttons: this.buttons, + clickCount: event.clicks, + modifiers: this.modifiers, + }) + } + + private async clipboard( + action: Clipboard, + epoch: number, + value?: string, + write?: (text: string) => void | Promise, + ): Promise { + let frame = this.page.mainFrame() + while (!this.closed) { + this.synchronize() + if (this.epoch !== epoch) return + const result = await frame.evaluate(selection, { action, limit: TEXT, text: value }) + this.synchronize() + if (this.closed || this.epoch !== epoch) return + if (result.focused) { + const copied = result.text + if (copied !== undefined && !text(copied, TEXT)) throw new Error("Browser selection exceeds the text limit") + if (copied === undefined || !write) return copied + await this.cancellable(() => write(copied)) + this.synchronize() + if (this.closed || epoch !== this.epoch) return + return copied + } + const handle = await frame.evaluateHandle(() => { + let node = document.activeElement + while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement + return node + }) + const child = await (handle.asElement()?.contentFrame() ?? Promise.resolve(null)).finally(() => handle.dispose()) + if (!child) return + frame = child + } + } + + private async keyboard(session: CDPSession, event: Key): Promise { + const id = event.code || event.key + if (event.action === "down") { + if (this.keys.size >= 256 && !this.keys.has(id)) throw new Error("Too many pressed browser keys") + this.keys.set(id, event) + } + if (event.action === "up") this.keys.delete(id) + this.modifiers = event.modifiers + const enter = event.key === "Enter" && !(event.modifiers & 7) + const value = event.action === "down" ? (event.text ?? (enter ? "\r" : undefined)) : undefined + const command = process.platform === "darwin" ? EDITING[`${event.modifiers}:${event.code}`] : undefined + await session.send("Input.dispatchKeyEvent", { + type: event.action === "up" ? "keyUp" : value ? "keyDown" : "rawKeyDown", + key: event.key, + code: event.code, + windowsVirtualKeyCode: event.keyCode, + modifiers: event.modifiers, + autoRepeat: event.repeat, + location: location(event), + isKeypad: event.code.startsWith("Numpad"), + text: value, + unmodifiedText: value, + commands: event.action === "down" && command ? [command] : undefined, + }) + } + + private async release(): Promise { + const session = this.session + if (!session) return + for (const button of ["left", "middle", "right"] as const) { + if (!(this.buttons & BUTTONS[button])) continue + this.buttons &= ~BUTTONS[button] + await session + .send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: this.x, + y: this.y, + button, + buttons: this.buttons, + modifiers: this.modifiers, + clickCount: 1, + }) + .catch(() => this.report("mouse release failed")) + } + for (const modifier of MODIFIERS) { + if (!(this.modifiers & modifier.mask) || [...this.keys.values()].some((key) => key.key === modifier.key)) continue + this.keys.set(modifier.code, { ...modifier, kind: "key", action: "up", modifiers: 0, repeat: false }) + } + const keys = [...this.keys.values()] + this.keys.clear() + for (const key of keys) { + this.modifiers &= ~(MODIFIERS.find((modifier) => modifier.key === key.key)?.mask ?? 0) + await session + .send("Input.dispatchKeyEvent", { + type: "keyUp", + key: key.key, + code: key.code, + windowsVirtualKeyCode: key.keyCode, + modifiers: this.modifiers, + location: location(key), + isKeypad: key.code.startsWith("Numpad"), + }) + .catch(() => this.report("key release failed")) + } + this.modifiers = 0 + if (!this.composing) return + this.composing = false + await session + .send("Input.imeSetComposition", { text: "", selectionStart: 0, selectionEnd: 0 }) + .catch(() => this.report("composition release failed")) + } +} diff --git a/packages/kilo-vscode/src/services/browser-automation/index.ts b/packages/kilo-vscode/src/services/browser-automation/index.ts index 27cd74ff024b..d7f7f06c8a2c 100644 --- a/packages/kilo-vscode/src/services/browser-automation/index.ts +++ b/packages/kilo-vscode/src/services/browser-automation/index.ts @@ -1,5 +1,5 @@ export { BrowserAutomationService } from "./browser-automation-service" -export { BrowserBroker, diagnostic } from "./browser-broker" +export { BrowserBroker, BrowserLaunchError, diagnostic } from "./browser-broker" export type { BrowserContextFactory, BrowserElement, diff --git a/packages/kilo-vscode/src/shared/browser-stream.ts b/packages/kilo-vscode/src/shared/browser-stream.ts new file mode 100644 index 000000000000..b4d17fa65fd8 --- /dev/null +++ b/packages/kilo-vscode/src/shared/browser-stream.ts @@ -0,0 +1,52 @@ +export function source(data: string): string | undefined { + if (!data.startsWith("data:")) return `data:image/jpeg;base64,${data}` + return /^data:image\/(?:jpeg|png|webp);base64,/.test(data) ? data : undefined +} + +export interface BrowserViewIdentity { + browserId: string + navigation: number + revision: number +} + +export interface BrowserViewport { + width: number + height: number + scale?: number + revision: number + active: boolean +} + +export interface BrowserFrame extends BrowserViewIdentity { + sequence: number + width: number + height: number + data: string +} + +export type BrowserInteraction = + | { + kind: "pointer" + action: "move" | "down" | "up" + x: number + y: number + button: "left" | "middle" | "right" + buttons: number + clicks: number + modifiers: number + } + | { kind: "wheel"; x: number; y: number; deltaX: number; deltaY: number; modifiers: number } + | { + kind: "key" + action: "down" | "up" + key: string + code: string + keyCode: number + modifiers: number + repeat: boolean + text?: string + } + | { kind: "text"; text: string } + | { kind: "composition"; text: string; start: number; end: number } + | { kind: "clipboard"; action: "copy" | "cut" | "paste" } + | { kind: "release" } diff --git a/packages/kilo-vscode/tests/fixtures/browser-network-tls.ts b/packages/kilo-vscode/tests/fixtures/browser-network-tls.ts new file mode 100644 index 000000000000..846f1327e96b --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/browser-network-tls.ts @@ -0,0 +1,16 @@ +export const cert = `-----BEGIN CERTIFICATE----- +MIIBTjCB9qADAgECAgkAzHzw5jNdvXYwCgYIKoZIzj0EAwIwFzEVMBMGA1UEAwwM +YnJvd3Nlci50ZXN0MB4XDTI2MDgzMTIzNTQwNFoXDTM2MDgyODIzNTQwNFowFzEV +MBMGA1UEAwwMYnJvd3Nlci50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +EKAOH2JIkTDfZAfmCp+aN8NPoceiQAh/xLK5nwRo9n+kM2PjPAlcMHgFlRP08+yC +xq2LNoFfa3a8ayUQtQtwbKMrMCkwJwYDVR0RBCAwHoIMYnJvd3Nlci50ZXN0gg4q +LmJyb3dzZXIudGVzdDAKBggqhkjOPQQDAgNHADBEAiA/7flxJI8949DYN55Xf5OT +1KJUCJu0ZTqrV0k0sLQkPQIgUZZKgPFqEovDx9lKCDu7OAmUP6fl7/qqGMRw9kNE +xxY= +-----END CERTIFICATE-----` + +export const key = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgbGEY5MJBMVr4EnOX +Dkf7qTOluKhKo9B3+Ljb64wckzGhRANCAAQQoA4fYkiRMN9kB+YKn5o3w0+hx6JA +CH/EsrmfBGj2f6QzY+M8CVwweAWVE/Tz7ILGrYs2gV9rdrxrJRC1C3Bs +-----END PRIVATE KEY-----` diff --git a/packages/kilo-vscode/tests/fixtures/browser-network.ts b/packages/kilo-vscode/tests/fixtures/browser-network.ts new file mode 100644 index 000000000000..9feca0dba5a7 --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/browser-network.ts @@ -0,0 +1,1113 @@ +import { after, afterEach, before, describe, test } from "node:test" +import { expect } from "@playwright/test" +import { createHash, X509Certificate } from "node:crypto" +import { createSocket } from "node:dgram" +import { once } from "node:events" +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" +import { createServer as https } from "node:https" +import { connect, createServer as tcp, type Server, type Socket } from "node:net" +import { chromium, type Browser, type BrowserContext, type Request } from "playwright-core" +import { WebSocketServer } from "ws" +import { BrowserBroker } from "../../src/services/browser-automation/browser-broker" +import { BrowserNetwork } from "../../src/services/browser-automation/browser-network" +import { BrowserProxy } from "../../src/services/browser-automation/browser-proxy" +import { options } from "../../src/services/browser-automation/browser-runtime" +import { cert, key } from "./browser-network-tls" + +const executable = process.env.CHROME_PATH +if (!executable) throw new Error("The browser-network wrapper must provide CHROME_PATH") +const proxies: BrowserProxy[] = [] +const networks: BrowserNetwork[] = [] +const contexts: BrowserContext[] = [] +const cleanup: Array<() => void | Promise> = [] + +async function listen(server: Server) { + const sockets = new Set() + let contacts = 0 + server.on("connection", (socket) => { + contacts++ + sockets.add(socket) + socket.once("close", () => sockets.delete(socket)) + socket.on("error", () => socket.destroy()) + }) + const ready = once(server, "listening") + server.listen(0, "127.0.0.1") + await ready + const address = server.address() + if (!address || typeof address === "string") throw new Error("Missing fixture port") + cleanup.push(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + }) + return { port: address.port, contacts: () => contacts } +} + +function websocket(server: ReturnType) { + const sockets = new WebSocketServer({ server }) + const requests: IncomingMessage[] = [] + sockets.on("connection", (socket, request) => { + requests.push(request) + socket.on("message", (data) => socket.send(data.toString())) + socket.on("error", () => socket.terminate()) + }) + cleanup.push(() => { + for (const socket of sockets.clients) socket.terminate() + sockets.close() + }) + return requests +} + +async function secure(name: string, handle: (request: IncomingMessage, response: ServerResponse) => void) { + const requests: IncomingMessage[] = [] + const server = https({ cert, key }, (request, response) => { + requests.push(request) + handle(request, response) + }) + const target = await listen(server) + return { ...target, server, requests, url: new URL(`https://${name}.browser.test:${target.port}/`) } +} + +async function transport(ports: number[], policy: "public" | URL = "public") { + return BrowserProxy.start(policy, { + lookup: async (host) => { + if (host === "localhost" || host === "loop.browser.test") return [{ address: "127.0.0.1", family: 4 }] + if (!host.endsWith(".browser.test")) throw new Error("Unexpected fixture hostname") + return [{ address: "8.8.8.8", family: 4 }] + }, + connect: (options) => { + if (options.host !== "8.8.8.8") return connect(options) + if (options.family !== 4 || !ports.includes(options.port)) throw new Error("Unmapped fixture address") + return connect({ ...options, host: "127.0.0.1" }) + }, + }) +} + +describe("BrowserNetwork Chromium", () => { + let browser: Browser + let gateway: BrowserProxy + + async function launch(pinned = true, flags: string[] = []) { + const base = options(false) + const pin = createHash("sha256") + .update(new X509Certificate(cert).publicKey.export({ format: "der", type: "spki" })) + .digest("base64") + return chromium.launch({ + ...base, + executablePath: executable, + proxy: gateway.proxy, + ignoreDefaultArgs: ["--disable-popup-blocking"], + args: [ + ...(base.args ?? []).filter((arg) => arg !== "--no-proxy-server"), + ...flags, + "--disable-quic", + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1", + "--webrtc-ip-handling-policy=disable_non_proxied_udp", + "--force-webrtc-ip-handling-policy=disable_non_proxied_udp", + ...(pinned ? [`--ignore-certificate-errors-spki-list=${pin}`] : []), + ], + }) + } + + before( + async () => { + gateway = await BrowserProxy.start("deny") + browser = await launch() + }, + { timeout: 20000 }, + ) + + afterEach(async () => { + await Promise.all(proxies.splice(0).map((proxy) => proxy.close())) + await Promise.all(networks.splice(0).map((network) => network.close())) + await Promise.all(contexts.splice(0).map((context) => context.close())) + for (const close of cleanup.splice(0).reverse()) await close() + }) + + after(async () => { + await browser?.close() + await gateway?.close() + }) + + async function session( + url: URL, + opts: { proxy?: BrowserProxy; approve?: (url: URL) => Promise; browser?: Browser } = {}, + ) { + const dials: Array<{ host: string; port: number; family: number }> = [] + const proxy = + opts.proxy ?? + (await BrowserProxy.start(url.protocol === "http:" ? url : "public", { + connect: (options) => { + dials.push(options) + return connect(options) + }, + })) + proxies.push(proxy) + const context = await (opts.browser ?? browser).newContext({ + proxy: proxy.proxy, + serviceWorkers: "block", + ignoreHTTPSErrors: false, + acceptDownloads: false, + viewport: { width: 640, height: 480 }, + }) + contexts.push(context) + const page = await context.newPage() + page.setDefaultNavigationTimeout(5000) + page.setDefaultTimeout(5000) + const blocked: string[] = [] + const approvals: string[] = [] + const logs: unknown[][] = [] + const network = await BrowserNetwork.attach(page, { + url, + proxy, + approve: async (target) => { + approvals.push(target.href) + return opts.approve?.(target) ?? false + }, + blocked: (message) => blocked.push(message), + log: (...args) => logs.push(args), + }) + networks.push(network) + return { page, context, network, proxy, dials, blocked, approvals, logs } + } + + test( + "authenticates local HTTP and native WebSockets without exposing proxy credentials", + { timeout: 20000 }, + async () => { + const requests: IncomingMessage[] = [] + const server = createServer((request, response) => { + requests.push(request) + response.writeHead(200, { "content-type": "text/html" }).end("Local fixture

Ready

") + }) + const target = await listen(server) + const sockets = websocket(server) + const url = new URL(`http://localhost:${target.port}/`) + const current = await session(url) + expect((await current.page.goto(url.href))?.status()).toBe(200) + const echoed = await current.page.evaluate( + (url) => + new Promise((resolve, reject) => { + const socket = new WebSocket(url) + socket.onopen = () => socket.send("browser-network") + socket.onerror = () => reject(new Error("Local WebSocket failed")) + socket.onmessage = (event) => { + socket.close() + resolve(String(event.data)) + } + }), + `ws://localhost:${target.port}/socket`, + ) + expect(echoed).toBe("browser-network") + expect(current.dials.length).toBeGreaterThan(0) + expect(current.approvals).toEqual([]) + expect(current.blocked).toEqual([]) + expect(current.network.active).toBe(true) + expect(requests.length).toBeGreaterThan(0) + expect(sockets).toHaveLength(1) + expect([...requests, ...sockets].every((request) => request.headers["proxy-authorization"] === undefined)).toBe( + true, + ) + }, + ) + + test( + "loads public HTTPS modules from a localhost document without granting public navigation", + { timeout: 20000 }, + async () => { + const assets = await secure("assets", (request, response) => { + response.writeHead(200, { + "content-type": "application/javascript", + "access-control-allow-origin": "*", + }) + response.end( + request.url === "/entry.js" ? "export { value } from './nested.js'" : "export const value = 'loaded'", + ) + }) + const requests: string[] = [] + const source = await listen( + createServer((request, response) => { + requests.push(request.url ?? "") + if (request.url === "/module-api") return response.end("local authority") + response + .writeHead(200, { "content-type": "text/html" }) + .end( + `Local modules

Local page

`, + ) + }), + ) + const url = new URL(`http://localhost:${source.port}/`) + const current = await session(url, { proxy: await transport([assets.port], url) }) + await current.page.goto(url.href) + await current.page.waitForFunction( + () => document.documentElement.dataset.local === "local authority", + undefined, + { + timeout: 2000, + }, + ) + expect(await current.page.locator("html").getAttribute("data-resource")).toBe("loaded") + expect(requests).toContain("/module-api") + expect(assets.requests.map((request) => request.url)).toEqual(["/entry.js", "/nested.js"]) + expect(current.approvals).toEqual([]) + expect(current.blocked).toEqual([]) + const frame = new URL("/frame", assets.url).href + const denied = current.page.waitForEvent("requestfailed", (request) => request.url() === frame) + await current.page.evaluate((url) => { + const frame = document.createElement("iframe") + frame.src = url + document.body.append(frame) + }, frame) + expect((await denied).failure()?.errorText).toBe("net::ERR_BLOCKED_BY_CLIENT") + expect(assets.requests.some((request) => request.url === "/frame")).toBe(false) + await expect(current.page.goto(new URL("/document", assets.url).href)).rejects.toThrow("ERR_BLOCKED_BY_CLIENT") + expect(assets.requests.some((request) => request.url === "/document")).toBe(false) + }, + ) + + test("a public module redirect cannot contact the approved local origin", { timeout: 20000 }, async () => { + const requests: string[] = [] + const source = await listen( + createServer((request, response) => { + requests.push(request.url ?? "") + response.writeHead(200, { "content-type": "text/html", "access-control-allow-origin": "*" }) + response.end('Local redirect test') + }), + ) + const url = new URL(`http://localhost:${source.port}/`) + const asset = await secure("redirect", (_request, response) => { + response.writeHead(302, { location: new URL("/from-cdn", url).href, "access-control-allow-origin": "*" }).end() + }) + const current = await session(url, { proxy: await transport([asset.port], url) }) + await current.page.goto(url.href) + const contacts = source.contacts() + expect( + await current.page.evaluate( + (url) => + import(url).then( + () => true, + () => false, + ), + asset.url.href, + ), + ).toBe(false) + expect(asset.requests).toHaveLength(1) + expect(requests).not.toContain("/from-cdn") + expect(source.contacts()).toBe(contacts) + }) + + for (const mode of ["frame", "data-worker", "blob-worker"]) { + test( + `an opaque ${mode} cannot contact the approved local HTTP or WebSocket endpoint`, + { timeout: 15000 }, + async () => { + const requests: string[] = [] + const server = createServer((request, response) => { + requests.push(request.url ?? "") + response + .writeHead(200, { "content-type": "text/html" }) + .end('Local origin') + }) + const source = await listen(server) + const sockets = websocket(server) + const url = new URL(`http://localhost:${source.port}/`) + const current = await session(url) + await current.page.goto(url.href) + const contacts = source.contacts() + const result = await current.page.evaluate( + async ({ mode, http, ws }) => { + const probe = async (urls: { http: string; ws: string }) => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 1000) + const fetched = fetch(urls.http, { mode: "no-cors", signal: controller.signal }) + .then( + () => "response", + () => "blocked", + ) + .finally(() => clearTimeout(timeout)) + const result = Promise.withResolvers() + const socket = new WebSocket(urls.ws) + const timer = setTimeout(() => result.resolve("timeout"), 1000) + socket.onopen = () => result.resolve("open") + socket.onerror = () => result.resolve("blocked") + const connected = await result.promise + clearTimeout(timer) + socket.close() + return { origin: self.origin, fetched: await fetched, connected } + } + const call = `(${probe.toString()})(${JSON.stringify({ http, ws })})` + const script = + mode === "frame" + ? `${call}.then(value => parent.postMessage(value, '*'))` + : `const code = ${JSON.stringify(`${call}.then(value => postMessage(value))`)}; const worker = new Worker(${mode === "data-worker" ? "'data:text/javascript,' + encodeURIComponent(code)" : "URL.createObjectURL(new Blob([code], {type:'application/javascript'}))"}); worker.onmessage = event => { parent.postMessage(event.data, '*'); worker.terminate(); };` + const frame = document.createElement("iframe") + frame.sandbox.add("allow-scripts") + frame.srcdoc = `` + const done = Promise.withResolvers<{ origin: string; connected: string }>() + const timer = setTimeout(() => done.reject(new Error("Opaque context probe timed out")), 5000) + const receive = (event: MessageEvent) => { + if (event.source === frame.contentWindow) done.resolve(event.data) + } + window.addEventListener("message", receive) + document.body.append(frame) + return done.promise.finally(() => { + clearTimeout(timer) + window.removeEventListener("message", receive) + frame.remove() + }) + }, + { mode, http: new URL("/private-fetch", url).href, ws: `ws://${url.host}/private-ws` }, + ) + expect(result.origin).toBe("null") + expect(result.connected).not.toBe("open") + expect(requests).not.toContain("/private-fetch") + expect(sockets).toHaveLength(0) + expect(source.contacts()).toBe(contacts) + }, + ) + } + + for (const scheme of ["http", "https"]) { + test( + `${scheme} origin 401 never receives cached proxy credentials, even with a matching realm`, + { timeout: 20000 }, + async () => { + const authorizations: Array = [] + const state = { realm: "origin" } + const handle = (request: IncomingMessage, response: ServerResponse) => { + if (request.url !== "/challenge") { + response.writeHead(200, { "content-type": "text/html" }).end("Warm proxy authentication") + return + } + authorizations.push(request.headers.authorization) + response.writeHead(401, { "www-authenticate": `Basic realm="${state.realm}"` }).end("Authentication required") + } + const server = scheme === "https" ? https({ key, cert }, handle) : createServer(handle) + const target = await listen(server) + const host = scheme === "https" ? "auth.browser.test" : "localhost" + const url = new URL(`${scheme}://${host}:${target.port}/`) + const current = await session(url, scheme === "https" ? { proxy: await transport([target.port]) } : {}) + state.realm = current.proxy.credentials.realm + expect((await current.page.goto(url.href))?.status()).toBe(200) + const result = await current.page.goto(new URL("/challenge", url).href).then( + (response) => String(response?.status()), + (error: Error) => error.message, + ) + expect(result).toMatch(/401|ERR_INVALID_AUTH_CREDENTIALS|ERR_HTTP_RESPONSE_CODE_FAILURE/) + expect(authorizations.length).toBeGreaterThan(0) + expect(authorizations.every((value) => value === undefined)).toBe(true) + expect(current.network.active).toBe(true) + }, + ) + } + + for (const [mode, status] of [ + ["allow", 301], + ["deny", 301], + ["allow", 302], + ["deny", 302], + ] as const) { + test(`${mode} checks approval at every cross-origin ${status} redirect hop`, { timeout: 20000 }, async () => { + const final = await secure("final", (_request, response) => { + response.writeHead(200, { "content-type": "text/html" }).end("Final origin") + }) + const destination = new URL("/final", final.url) + const middle = await secure("middle", (_request, response) => { + response.writeHead(status, { location: destination.href }).end() + }) + const hop = new URL("/hop", middle.url) + const source = await secure("source", (_request, response) => { + response.writeHead(status, { location: hop.href }).end() + }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers() + const current = await session(source.url, { + proxy: await transport([source.port, middle.port, final.port]), + approve: async (url) => { + if (url.origin === middle.url.origin) return true + entered.resolve(url) + return decision.promise + }, + }) + const navigation = current.page.goto(source.url.href).then( + (response) => ({ status: response?.status(), error: "" }), + (error: Error) => ({ status: undefined, error: error.message }), + ) + const requested = await Promise.race([ + entered.promise, + navigation.then((result) => { + throw new Error(`Redirect did not pause: ${result.error || result.status}`) + }), + ]) + expect(requested.href).toBe(destination.href) + expect(current.approvals).toEqual([hop.href, destination.href]) + expect(final.requests).toHaveLength(0) + const committed = current.page.waitForEvent("framenavigated", (frame) => frame === current.page.mainFrame()) + decision.resolve(mode === "allow") + const result = await navigation + await committed + expect(middle.requests.filter((request) => request.url === "/hop")).toHaveLength(1) + if (mode === "allow") { + expect(result.status).toBe(200) + expect(current.page.url()).toBe(destination.href) + expect(final.requests.filter((request) => request.url === "/final")).toHaveLength(1) + expect((await current.page.goto(source.url.href))?.status()).toBe(200) + expect(current.approvals).toEqual([hop.href, destination.href]) + return + } + expect(result.error).toContain("ERR_BLOCKED_BY_CLIENT") + expect(final.requests).toHaveLength(0) + expect(current.blocked).toEqual([`Navigation to ${final.url.origin} was not approved.`]) + current.network.authorize(destination) + expect((await current.page.goto(source.url.href))?.status()).toBe(200) + expect(current.approvals).toEqual([hop.href, destination.href]) + }) + } + + for (const mode of ["deny", "allow"] as const) { + test( + `${mode} preserves the server's native CORS preflight decision before a credentialed mutation`, + { timeout: 20000 }, + async () => { + const source = await secure("source", (_request, response) => { + response + .writeHead(200, { "content-type": "text/html" }) + .end('Source') + }) + const target = await secure("target", (request, response) => { + if (request.method === "OPTIONS" && mode === "deny") return void response.writeHead(403).end() + response.writeHead(request.method === "OPTIONS" ? 204 : 200, { + "access-control-allow-origin": source.url.origin, + "access-control-allow-credentials": "true", + "access-control-allow-methods": "POST", + "access-control-allow-headers": "content-type", + }) + response.end(request.method === "OPTIONS" ? undefined : "mutated") + }) + const current = await session(source.url, { proxy: await transport([source.port, target.port]) }) + await current.context.addCookies([ + { url: target.url.href, name: "auth", value: "fixture", httpOnly: true, secure: true, sameSite: "Lax" }, + ]) + await current.page.goto(source.url.href) + const result = await current.page.evaluate( + (url) => + fetch(url, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ change: true }), + }).then( + (response) => response.text(), + () => "blocked", + ), + new URL("/mutation", target.url).href, + ) + expect(result).toBe(mode === "allow" ? "mutated" : "blocked") + const requests = target.requests.filter((request) => request.url === "/mutation") + expect(requests.map((request) => request.method)).toEqual(mode === "allow" ? ["OPTIONS", "POST"] : ["OPTIONS"]) + expect(requests.at(0)?.headers.cookie).toBeUndefined() + if (mode === "allow") expect(requests.at(1)?.headers.cookie).toBe("auth=fixture") + expect(current.approvals).toEqual([]) + expect(current.logs).toEqual([]) + }, + ) + } + + test( + "public cross-origin scripts, frames, fetch and WSS do not grant navigation permission", + { timeout: 20000 }, + async () => { + const assets = await secure("assets", (request, response) => { + if (request.url === "/script.js") { + response + .writeHead(200, { "content-type": "application/javascript" }) + .end("document.documentElement.dataset.resource = 'loaded'") + return + } + if (request.url === "/fetch") { + response.writeHead(200, { "access-control-allow-origin": "*" }).end("public resource") + return + } + response.writeHead(200, { "content-type": "text/html" }).end("Public resource frame") + }) + const sockets = websocket(assets.server) + const source = await secure("source", (_request, response) => { + response + .writeHead(200, { "content-type": "text/html" }) + .end( + `Source origin`, + ) + }) + const current = await session(source.url, { proxy: await transport([source.port, assets.port]) }) + await current.page.goto(source.url.href) + await current.page.waitForFunction(() => document.documentElement.dataset.resource === "loaded") + expect( + await current.page.evaluate( + (url) => fetch(url).then((response) => response.text()), + new URL("/fetch", assets.url).href, + ), + ).toBe("public resource") + const echoed = await current.page.evaluate( + (url) => + new Promise((resolve, reject) => { + const socket = new WebSocket(url) + socket.onopen = () => socket.send("public websocket") + socket.onerror = () => reject(new Error("Public WSS failed")) + socket.onmessage = (event) => { + socket.close() + resolve(String(event.data)) + } + }), + `wss://${assets.url.host}/socket`, + ) + expect(echoed).toBe("public websocket") + expect(assets.requests.some((request) => request.url === "/frame")).toBe(true) + expect(sockets).toHaveLength(1) + expect(current.approvals).toEqual([]) + const destination = new URL("/document", assets.url) + await expect(current.page.goto(destination.href)).rejects.toThrow("ERR_BLOCKED_BY_CLIENT") + expect(current.approvals).toEqual([destination.href]) + expect(assets.requests.some((request) => request.url === "/document")).toBe(false) + }, + ) + + test( + "tracks nested out-of-process frames and closes one context without changing another", + { timeout: 20000 }, + async () => { + const isolated = await launch(true, ["--site-per-process"]) + cleanup.push(() => isolated.close()) + const leaf = await secure("leaf", (request, response) => { + response.writeHead(200, { "content-type": "text/html", "access-control-allow-origin": "*" }) + response.end( + request.url === "/fetch" + ? "worker resource" + : request.url === "/container" + ? '' + : "

Nested frame

", + ) + }) + const outer = await secure("outer", (_request, response) => { + response.writeHead(200, { "content-type": "text/html" }).end(``) + }) + outer.url.hostname = "8.8.8.8" + const source = await secure("source", (_request, response) => { + response.writeHead(200, { "content-type": "text/html" }).end(``) + }) + const ports = [source.port, outer.port, leaf.port] + const [current, other] = await Promise.all([ + session(source.url, { browser: isolated, proxy: await transport(ports) }), + session(source.url, { browser: isolated, proxy: await transport(ports) }), + ]) + await Promise.all([current.page.goto(source.url.href), other.page.goto(source.url.href)]) + const nested = current.page.frameLocator("iframe").frameLocator("iframe").locator("h1") + await nested.waitFor() + expect(await nested.textContent()).toBe("Nested frame") + const frame = current.page.frames().find((frame) => frame.url() === outer.url.href) + if (!frame) throw new Error("Missing out-of-process fixture frame") + const protocol = await current.context.newCDPSession(frame) + expect((await protocol.send("Target.getTargetInfo")).targetInfo.type).toBe("iframe") + await protocol.detach() + const result = await frame.evaluate(async (url) => { + const code = `fetch(${JSON.stringify(url)}).then(response => response.text()).then(value => postMessage(value), () => postMessage('blocked'))` + const source = URL.createObjectURL(new Blob([code], { type: "text/javascript" })) + const worker = new Worker(source) + const result = Promise.withResolvers() + worker.onmessage = (event) => result.resolve(String(event.data)) + worker.onerror = () => result.resolve("failed") + return result.promise.finally(() => { + worker.terminate() + URL.revokeObjectURL(source) + }) + }, new URL("/fetch", leaf.url).href) + expect(result).toBe("worker resource") + for (const url of [new URL("/container", leaf.url).href, outer.url.href]) { + const count = leaf.requests.filter((request) => request.url === "/").length + const navigated = current.page.waitForEvent("framenavigated", (frame) => frame.url() === url) + await current.page.locator("iframe").evaluate((frame, url) => frame.setAttribute("src", url), url) + await navigated + if (url === outer.url.href) await nested.waitFor() + if (url !== outer.url.href) { + const parent = current.page.frames().find((frame) => frame.url() === url) + if (!parent) throw new Error("Missing fixture frame after the process swap") + await parent.waitForFunction(() => { + const doc = document.querySelector("iframe")?.contentDocument + const heading = doc?.querySelector("h1") + if (!heading || doc?.readyState !== "complete") return false + const rect = heading.getBoundingClientRect() + return ( + heading.textContent === "Nested frame" && + getComputedStyle(heading).visibility === "visible" && + rect.width > 0 && + rect.height > 0 + ) + }) + } + expect(leaf.requests.filter((request) => request.url === "/")).toHaveLength(count + 1) + } + expect( + await frame.evaluate((url) => fetch(url).then((response) => response.text()), new URL("/fetch", leaf.url).href), + ).toBe("worker resource") + const detached = current.page.waitForEvent("framedetached", (value) => value === frame) + await current.page.evaluate(() => document.querySelector("iframe")?.remove()) + await detached + expect(current.network.active).toBe(true) + await current.page.reload() + await nested.waitFor() + await Promise.all([current.network.close(), current.context.close()]) + expect(current.network.active).toBe(false) + expect((await other.page.reload())?.status()).toBe(200) + await other.page.frameLocator("iframe").frameLocator("iframe").locator("h1").waitFor() + expect(other.network.active).toBe(true) + expect(current.approvals).toEqual([]) + expect(other.approvals).toEqual([]) + expect(current.logs).toEqual([]) + expect(other.logs).toEqual([]) + await isolated.close() + expect(other.network.active).toBe(false) + await expect(fetch(other.proxy.proxy.server)).rejects.toThrow() + }, + ) + + for (const mode of ["direct", "noopener", "blank", "new-page"]) { + test( + `blocks the first ${mode} popup HTTPS request with cached proxy auth and a warm connection`, + { timeout: 20000 }, + async () => { + const target = await secure("popup", (_request, response) => { + response.writeHead(200, { "content-type": "text/html", "access-control-allow-origin": "*" }) + response.end("Unapproved popup") + }) + const destination = new URL("/first-request", target.url) + const script = + mode === "blank" + ? `window.open('about:blank').location = '${destination.href}'` + : `window.open('${destination.href}', '_blank', '${mode === "noopener" ? "noopener" : ""}')` + const source = await secure("source", (_request, response) => { + response + .writeHead(200, { "content-type": "text/html" }) + .end( + ``, + ) + }) + const current = await session(source.url, { proxy: await transport([source.port, target.port]) }) + await current.context.addCookies([ + { url: target.url.href, name: "popup", value: "fixture", httpOnly: true, secure: true, sameSite: "Lax" }, + ]) + expect((await current.page.goto(source.url.href))?.status()).toBe(200) + await current.page.evaluate( + (url) => fetch(url).then((response) => response.text()), + new URL("/warm", target.url).href, + ) + const finished = Promise.withResolvers() + const complete = (request: Request) => { + if (request.url() === destination.href) finished.resolve(request.failure()?.errorText) + } + current.context.on("requestfinished", complete) + current.context.on("requestfailed", complete) + const opened = current.context.waitForEvent("page") + if (mode === "new-page") { + const page = await current.context.newCDPSession(current.page) + const { targetInfo: owner } = await page.send("Target.getTargetInfo") + await page.detach() + const monitor = await browser.newBrowserCDPSession() + await monitor.send("Target.createTarget", { url: destination.href, browserContextId: owner.browserContextId }) + await monitor.detach() + } + if (mode !== "new-page") await current.page.getByRole("button", { name: "Open popup" }).click() + const popup = await opened + if (mode === "new-page") { + await popup.waitForLoadState("domcontentloaded") + const page = await current.context.newCDPSession(popup) + const tree = await page.send("Page.getFrameTree") + expect(tree.frameTree.frame.unreachableUrl).toBe(destination.href) + await page.detach() + } + if (mode !== "new-page") expect(await finished.promise).toBe("net::ERR_BLOCKED_BY_CLIENT") + await popup.close() + current.context.off("requestfinished", complete) + current.context.off("requestfailed", complete) + expect(target.requests.map((request) => request.url)).toEqual(["/warm"]) + expect(current.approvals).toEqual([]) + expect(current.network.active).toBe(true) + expect(current.logs).toEqual([]) + expect((await current.page.reload())?.status()).toBe(200) + }, + ) + } + + test( + "expires pending broker approval without blaming the server or allowing a late response", + { timeout: 45000 }, + async () => { + const target = await secure("approval", (_request, response) => { + response.writeHead(200, { "content-type": "text/html" }).end("Unapproved target") + }) + const source = await secure("source", (_request, response) => { + response.writeHead(301, { location: target.url.href }).end() + }) + const proxy = await transport([source.port, target.port]) + proxies.push(proxy) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers() + const settled = Promise.withResolvers() + const logs: unknown[][] = [] + const broker = new BrowserBroker({ + log: (...args) => logs.push(args), + launch: async () => ({ + newContext: async (opts) => { + const context = await browser.newContext({ ...opts, proxy: proxy.proxy }) + contexts.push(context) + return context + }, + close: async () => undefined, + }), + network: async (page, opts) => { + const network = await BrowserNetwork.attach(page, { + ...opts, + proxy, + approve: async (url) => { + const allowed = await opts.approve(url) + settled.resolve(allowed) + return allowed + }, + }) + networks.push(network) + return network + }, + }) + cleanup.push(() => broker.disposeAsync()) + broker.bind( + (route) => route, + async () => { + entered.resolve() + return decision.promise + }, + ) + const route = { projectId: "project", sessionId: "approval", directory: "/tmp/project" } + const navigation = broker.open(route, source.url.href).then( + () => "loaded", + (error: Error) => error.message, + ) + await entered.promise + expect(target.requests).toHaveLength(0) + const error = await navigation + expect(error).toBe( + "Browser navigation stopped while waiting for approval in VS Code. Dismiss the approval prompt and retry.", + ) + expect(broker.get(route.sessionId, route.projectId)).toMatchObject({ status: "error", error }) + expect(networks.at(-1)?.active).toBe(false) + decision.resolve(true) + expect(await settled.promise).toBe(false) + expect(target.requests).toHaveLength(0) + await expect(fetch(proxy.proxy.server)).rejects.toThrow() + expect(logs).toEqual([]) + }, + ) + + test("retires routing and pending approvals without leaving cached proxy access", { timeout: 20000 }, async () => { + const target = await secure("retired", (_request, response) => { + response.writeHead(200, { "content-type": "text/html" }).end("Unapproved destination") + }) + const source = await secure("source", (_request, response) => { + response.writeHead(200, { "content-type": "text/html" }).end("Source") + }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers() + const current = await session(source.url, { + proxy: await transport([source.port, target.port]), + approve: async () => { + entered.resolve() + return decision.promise + }, + }) + const waiting = Promise.withResolvers() + const permission = Promise.withResolvers() + const other = await session(source.url, { + proxy: await transport([source.port, target.port]), + approve: async () => { + waiting.resolve() + return permission.promise + }, + }) + expect((await current.page.goto(source.url.href))?.status()).toBe(200) + expect((await other.page.goto(source.url.href))?.status()).toBe(200) + const navigation = current.page.goto(target.url.href).then( + () => "loaded", + (error: Error) => error.message, + ) + await Promise.race([ + entered.promise, + navigation.then((result) => { + throw new Error(`Navigation did not pause: ${result}`) + }), + ]) + const pending = other.page.goto(new URL("/survivor", target.url).href).then( + (response) => response?.status(), + (error: Error) => error.message, + ) + await waiting.promise + await current.network.close() + expect(await navigation).toContain("ERR_BLOCKED_BY_CLIENT") + expect(current.network.active).toBe(false) + expect(target.requests).toHaveLength(0) + permission.resolve(true) + expect(await pending).toBe(200) + decision.resolve(true) + expect(target.requests.some((request) => request.url === "/")).toBe(false) + const contacts = source.contacts() + await expect(current.page.goto(new URL("/after-close", source.url).href)).rejects.toThrow() + expect(source.requests.some((request) => request.url === "/after-close")).toBe(false) + expect(source.contacts()).toBe(contacts) + expect((await other.page.reload())?.status()).toBe(200) + expect(other.network.active).toBe(true) + expect(current.logs).toEqual([]) + }) + + test("certificate errors remain errors without the fixture-only certificate pin", { timeout: 20000 }, async () => { + const source = await secure("untrusted", (_request, response) => response.end("must not load")) + const untrusted = await launch(false) + cleanup.push(() => untrusted.close()) + const current = await session(source.url, { browser: untrusted, proxy: await transport([source.port]) }) + await expect(current.page.goto(source.url.href)).rejects.toThrow("ERR_CERT_") + expect(source.requests).toHaveLength(0) + }) + + for (const host of ["google.com", "www.google.com"]) { + test( + `Google HTTPS manual probe: ${host}`, + { skip: process.env.KILO_BROWSER_GOOGLE_SMOKE !== "1", timeout: 30000 }, + async () => { + const external = await launch(false) + cleanup.push(() => external.close()) + const url = new URL(`https://${host}/`) + const current = await session(url, { + browser: external, + approve: async (target) => ["https://www.google.com", "https://consent.google.com"].includes(target.origin), + }) + const response = await current.page.goto(url.href, { waitUntil: "domcontentloaded", timeout: 20000 }).then( + (response) => ({ status: response?.status(), error: undefined }), + (error: Error) => ({ status: undefined, error: error.message }), + ) + expect({ + ...response, + approvals: current.approvals, + blocked: current.blocked, + logs: current.logs, + }).toMatchObject({ + status: 200, + error: undefined, + blocked: [], + logs: [], + }) + if (host === "google.com") { + expect(current.approvals.some((url) => new URL(url).origin === "https://www.google.com")).toBe(true) + } + expect(await current.page.title()).toContain("Google") + expect(current.network.active).toBe(true) + }, + ) + } + + test( + "Google broker navigation probe", + { skip: process.env.KILO_BROWSER_GOOGLE_SMOKE !== "1", timeout: 60000 }, + async () => { + const logs: unknown[][] = [] + const approvals: string[] = [] + const broker = new BrowserBroker({ + log: (...args) => logs.push(args), + useSystemChrome: () => false, + launch: (config) => chromium.launch({ ...config, executablePath: executable }), + }) + cleanup.push(() => broker.disposeAsync()) + const views = new Set() + const streaming: Promise[] = [] + broker.subscribe((state) => { + if (state.status !== "loading" || views.has(state.browserId)) return + views.add(state.browserId) + streaming.push( + broker.viewport(state.sessionId, state.projectId, state.browserId, state.navigation, { + width: 640, + height: 480, + revision: 1, + active: true, + }), + ) + }) + const route = { projectId: "project", sessionId: "google", directory: "/tmp/project" } + broker.bind( + (scope) => scope, + async (_scope, url) => { + approvals.push(url.origin) + return ["https://www.google.com", "https://consent.google.com"].includes(url.origin) + }, + ) + const local = await listen( + createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html" }).end("Local fixture") + }), + ) + expect((await broker.open(route, `http://127.0.0.1:${local.port}/`)).status).toBe("ready") + for (const url of ["https://google.com/", "https://www.google.com/", "https://google.com/"]) { + const state = await broker.open(route, url) + expect(state.status).toBe("ready") + expect(state.title).toContain("Google") + expect(state.screenshot).toMatch(/^data:image\/jpeg;base64,/) + expect(state.error).toBeUndefined() + } + await Promise.all(streaming) + expect(approvals).toContain("https://www.google.com") + expect(logs).toEqual([]) + }, + ) + + test("remote pages and workers make no forbidden loopback TCP or UDP contact", { timeout: 20000 }, async () => { + const sentinel = await listen(tcp((socket) => socket.destroy())) + const datagrams = createSocket("udp4") + const traffic = { packets: 0 } + datagrams.on("message", () => traffic.packets++) + const ready = once(datagrams, "listening") + datagrams.bind(0, "127.0.0.1") + await ready + const port = datagrams.address().port + cleanup.push(() => new Promise((resolve) => datagrams.close(() => resolve()))) + const source = await secure("source", (_request, response) => { + response.writeHead(200, { "content-type": "text/html" }).end("Remote source") + }) + const current = await session(source.url, { proxy: await transport([source.port]) }) + await current.page.goto(source.url.href) + expect(await current.page.evaluate(() => isSecureContext)).toBe(true) + for (const offline of [false, true]) { + if (offline) await current.proxy.close() + const result = await current.page.evaluate( + async ({ tcp, udp }) => { + const bounded = async (promise: Promise) => { + const expired = Promise.withResolvers() + const timer = setTimeout(() => expired.resolve("timeout"), 1500) + return Promise.race([promise, expired.promise]).finally(() => clearTimeout(timer)) + } + const fetches = async (url: string) => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 1500) + return fetch(url, { mode: "no-cors", signal: controller.signal }) + .then( + () => "loaded", + () => "blocked", + ) + .finally(() => clearTimeout(timer)) + } + const socket = async (url: string) => { + const result = Promise.withResolvers() + const value = new WebSocket(url) + value.onopen = () => result.resolve("open") + value.onerror = () => result.resolve("blocked") + return bounded(result.promise).finally(() => value.close()) + } + const worker = async () => { + const url = URL.createObjectURL( + new Blob( + [ + "onmessage = event => fetch(event.data, { mode: 'no-cors' }).then(() => postMessage('loaded'), () => postMessage('blocked'))", + ], + { type: "application/javascript" }, + ), + ) + const value = new Worker(url) + const result = Promise.withResolvers() + value.onmessage = (event) => result.resolve(String(event.data)) + value.onerror = () => result.resolve("blocked") + value.postMessage(`https://loop.browser.test:${tcp}/worker`) + return bounded(result.promise).finally(() => { + value.terminate() + URL.revokeObjectURL(url) + }) + } + const rtc = async () => { + if (typeof RTCPeerConnection === "undefined") return "unavailable" + const result = Promise.withResolvers() + const peer = new RTCPeerConnection({ + iceServers: [ + { urls: `stun:127.0.0.1:${udp}` }, + { + urls: [`turn:127.0.0.1:${tcp}?transport=tcp`, `turns:127.0.0.1:${tcp}?transport=tcp`], + username: "fixture", + credential: "fixture", + }, + ], + }) + peer.onicegatheringstatechange = () => { + if (peer.iceGatheringState === "complete") result.resolve("complete") + } + peer.createDataChannel("probe") + await peer.setLocalDescription(await peer.createOffer()) + return bounded(result.promise).finally(() => peer.close()) + } + const transport = async () => { + if (typeof WebTransport === "undefined") return "unavailable" + const value = new WebTransport(`https://127.0.0.1:${udp}/probe`) + void value.closed.catch(() => "blocked") + return bounded( + value.ready.then( + () => "open", + () => "blocked", + ), + ).finally(() => value.close()) + } + return Promise.all([ + fetches(`http://127.0.0.1:${tcp}/http`), + fetches(`https://127.0.0.1:${tcp}/https`), + fetches(`https://loop.browser.test:${tcp}/dns`), + socket(`ws://127.0.0.1:${tcp}/ws`), + socket(`wss://127.0.0.1:${tcp}/wss`), + worker(), + rtc(), + transport(), + ]) + }, + { tcp: sentinel.port, udp: port }, + ) + await new Promise((resolve) => setImmediate(resolve)) + expect(result).not.toContain("loaded") + expect(result).not.toContain("open") + expect(result).not.toContain("unavailable") + expect(sentinel.contacts()).toBe(0) + expect(traffic.packets).toBe(0) + } + }) + + test( + "a failed proxy does not fall back to direct loopback HTTP or WebSocket connections", + { timeout: 20000 }, + async () => { + const requests: string[] = [] + const server = createServer((request, response) => { + requests.push(request.url ?? "") + response.writeHead(200, { "content-type": "text/html" }).end("Local fixture") + }) + const target = await listen(server) + const sockets = websocket(server) + const url = new URL(`http://localhost:${target.port}/`) + const current = await session(url) + await current.page.goto(url.href) + await current.proxy.close() + const contacts = target.contacts() + const outcome = await current.page.evaluate( + (url) => + new Promise((resolve) => { + const socket = new WebSocket(url) + socket.onopen = () => { + socket.close() + resolve("open") + } + socket.onerror = () => resolve("error") + }), + `ws://localhost:${target.port}/after-close`, + ) + expect(outcome).toBe("error") + await expect(current.page.goto(new URL("/after-close", url).href)).rejects.toThrow() + expect(requests).not.toContain("/after-close") + expect(sockets).toHaveLength(0) + expect(target.contacts()).toBe(contacts) + }, + ) +}) diff --git a/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx b/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx index c3ad9695fbc0..36f868ebaade 100644 --- a/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx +++ b/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx @@ -14,6 +14,7 @@ Object.assign(globalThis, { SVGElement: window.SVGElement, MutationObserver: window.MutationObserver, ResizeObserver: window.ResizeObserver, + IntersectionObserver: window.IntersectionObserver, Event: window.Event, MouseEvent: window.MouseEvent, getComputedStyle: window.getComputedStyle.bind(window), @@ -32,20 +33,33 @@ document.body.append(root) const scope = { sessionId: "standalone", projectId: "fixture" } const sent: BrowserCommand[] = [] const references: BrowserReference[] = [] -let receive: ((event: BrowserEvent) => void) | undefined +const listeners = new Set<(event: BrowserEvent) => void>() +const receive = (event: BrowserEvent) => { + for (const listener of [...listeners]) listener(event) +} let closed = 0 +const actions = { download: 0, settings: 0 } const [labels, update] = createSignal({ title: "Browser", url: "Address", urlPlaceholder: "Local URL", open: "Go", refresh: "Reload", + back: "Back", + forward: "Forward", close: "Close", inspect: "Select element", devtoolsTitle: "Developer tools", diagnostics: "Browser diagnostics", diagnosticsHint: "Recent events from the automation browser. Security blocks are not console errors.", empty: "Open a local page", + requirement: "Requires an installed browser", + missingTitle: "Browser not found", + missingChrome: "Install Google Chrome, then retry.", + missingChromium: "Install compatible Playwright Chromium or change Browser Settings.", + download: "Download Chrome", + retry: "Retry", + settings: "Browser Settings", noSession: "Choose a session", screenshotAlt: "Preview", errors: (count) => `${count} errors`, @@ -59,12 +73,12 @@ const dispose = render( transport={{ send: (command) => sent.push(command), subscribe: (handler) => { - receive = handler - return () => { - receive = undefined - } + listeners.add(handler) + return () => listeners.delete(handler) }, }} + download={() => actions.download++} + settings={() => actions.settings++} onReference={(reference) => references.push(reference)} onClose={() => closed++} /> @@ -72,40 +86,83 @@ const dispose = render( root, ) assert.deepEqual(sent[0], { type: "state", scope }) -const state = { scope, browserId: "browser", status: "ready" as const, errors: 0, url: "about:blank" } +assert.ok(root.querySelector(".am-browser-empty")?.textContent?.includes(labels().requirement)) +const state = { + scope, + browserId: "browser", + navigation: 0, + status: "ready" as const, + errors: 0, + url: "https://example.com/", +} +receive({ + type: "state", + value: { ...state, browserId: "", status: "error", missing: "chrome", error: "Internal runtime details" }, +}) +const warning = root.querySelector('[role="alert"][data-component="card"][data-variant="warning"]') +assert.ok(warning) +assert.equal(warning.querySelector('[data-slot="card-title"]')?.textContent, labels().missingTitle) +assert.equal(warning.querySelector('[data-slot="card-description"]')?.textContent, labels().missingChrome) +assert.equal(warning.textContent?.includes("Internal runtime details"), false) +const button = (label: string) => + [...root.querySelectorAll("button")].find((node) => node.textContent?.trim() === label) +button(labels().download)!.click() +button(labels().settings)!.click() +assert.deepEqual(actions, { download: 1, settings: 1 }) +button(labels().retry)!.click() +assert.deepEqual(sent.at(-1), { type: "open", scope, url: state.url }) +receive({ type: "state", value: { ...state, browserId: "", status: "error", missing: "chromium" } }) +assert.equal(warning.querySelector('[data-slot="card-description"]')?.textContent, labels().missingChromium) +assert.equal(button(labels().download), undefined) +update((value) => ({ ...value, missingTitle: "Navigateur introuvable" })) +assert.equal(warning.querySelector('[data-slot="card-title"]')?.textContent, "Navigateur introuvable") receive?.({ type: "state", value: { ...state, status: "error", error: "Cannot connect to the local server" } }) +assert.equal(root.querySelector('[data-variant="warning"]'), null) const failure = root.querySelector('[role="alert"][data-component="card"][data-variant="error"]') assert.ok(failure) assert.equal(failure.querySelector(".error-card-message")?.textContent, "Cannot connect to the local server") assert.ok(failure.querySelector('[data-component="icon"]')) -assert.equal(root.querySelector(".am-browser-frame"), null) +assert.equal(root.querySelector(".am-browser-stream canvas"), null) assert.equal(root.querySelector(".am-browser-empty"), null) receive?.({ type: "state", value: state }) assert.equal(root.querySelector('[role="alert"]'), null) await window.happyDOM.waitUntilComplete() -const frame = root.querySelector(".am-browser-frame") +const frame = root.querySelector(".am-browser-stream canvas") assert.ok(frame) assert.equal(root.querySelector(".am-browser-site"), null) receive?.({ type: "state", value: { ...state, logs: ["[info] Updated"] } }) -assert.equal(root.querySelector(".am-browser-frame"), frame) +assert.equal(root.querySelector(".am-browser-stream canvas"), frame) assert.equal(root.querySelector(".am-browser-diagnostics button")?.textContent, "Browser diagnostics") assert.equal(root.querySelector(".am-browser-console"), null) +const back = root.querySelector("button[aria-label=Back]") as HTMLButtonElement +const forward = root.querySelector("button[aria-label=Forward]") as HTMLButtonElement +assert.equal(back.disabled, true) +assert.equal(forward.disabled, true) +receive?.({ type: "state", value: { ...state, back: true } }) +assert.equal(back.disabled, false) +assert.equal(forward.disabled, true) +back.click() +assert.deepEqual(sent.at(-1), { type: "back", scope }) +receive?.({ type: "state", value: { ...state, forward: true } }) +assert.equal(back.disabled, true) +forward.click() +assert.deepEqual(sent.at(-1), { type: "forward", scope }) ;(root.querySelector("button[aria-label=Reload]") as HTMLButtonElement).click() assert.deepEqual(sent.at(-1), { type: "refresh", scope }) receive?.({ type: "state", value: { ...state, navigation: 1 } }) await window.happyDOM.waitUntilComplete() -assert.equal(frame.isConnected, false) -const refreshed = root.querySelector(".am-browser-frame") -assert.ok(refreshed) -assert.equal(refreshed.getAttribute("src"), state.url) -assert.equal(refreshed.getAttribute("sandbox"), "allow-scripts allow-forms allow-same-origin") +assert.equal(frame.isConnected, true) +const refreshed = root.querySelector(".am-browser-stream canvas") +assert.equal(refreshed, frame) +assert.equal(refreshed.getAttribute("src"), null) +assert.equal(root.querySelector(".am-browser-viewport iframe"), null) receive?.({ type: "state", value: { ...state, navigation: 1, errors: 1 } }) -assert.equal(root.querySelector(".am-browser-frame"), refreshed) +assert.equal(root.querySelector(".am-browser-stream canvas"), refreshed) receive?.({ type: "state", value: { ...state, navigation: 1, title: "", error: "Developer tools are unavailable" }, }) -assert.equal(root.querySelector(".am-browser-frame"), refreshed) +assert.equal(root.querySelector(".am-browser-stream canvas"), refreshed) assert.equal( root.querySelector('[role="alert"][data-variant="error"] .error-card-message')?.textContent, "Developer tools are unavailable", @@ -162,7 +219,7 @@ assert.equal(entries.length, 3, root.querySelector(".am-browser-diagnostics")?.o assert.equal(entries.at(0)?.textContent, `${blocked}×3`) assert.equal(entries.at(1)?.textContent, other) assert.equal(entries.at(2)?.textContent, "[error] Failed to load") -assert.equal(root.querySelector(".am-browser-frame"), refreshed) +assert.equal(root.querySelector(".am-browser-stream canvas"), refreshed) ;(root.querySelector(".am-browser-tools-action button") as HTMLButtonElement).click() assert.deepEqual(sent.at(-1), { type: "devtools", scope, theme: "light" }) receive?.({ type: "devtools", value: { scope, browserId: state.browserId, url: "about:blank" } }) @@ -176,5 +233,5 @@ assert.equal(root.querySelector(".am-browser-diagnostics"), null) assert.equal(closed, 1) assert.deepEqual(sent.at(-1), { type: "close", scope }) dispose() -assert.equal(receive, undefined) +assert.equal(listeners.size, 0) await window.happyDOM.close() diff --git a/packages/kilo-vscode/tests/fixtures/browser-proxy.ts b/packages/kilo-vscode/tests/fixtures/browser-proxy.ts new file mode 100644 index 000000000000..3af2dbb40af7 --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/browser-proxy.ts @@ -0,0 +1,799 @@ +import { afterEach, describe, test } from "node:test" +import { expect } from "@playwright/test" +import { once } from "node:events" +import { + createServer as http, + request, + type IncomingHttpHeaders, + type Server as HttpServer, + type ServerResponse, +} from "node:http" +import { connect, createServer, type Server, Socket } from "node:net" +import WebSocket, { WebSocketServer } from "ws" +import { BrowserProxy, UNREACHABLE } from "../../src/services/browser-automation/browser-proxy" + +const cleanup: Array<() => Promise | void> = [] + +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() +}) + +async function listen(server: Server | HttpServer) { + const sockets = new Set() + server.on("connection", (socket) => { + sockets.add(socket) + socket.on("error", () => socket.destroy()) + socket.once("close", () => sockets.delete(socket)) + }) + const ready = once(server, "listening") + server.listen(0, "127.0.0.1") + await ready + const address = server.address() + if (!address || typeof address === "string") throw new Error("Missing test server port") + cleanup.push(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + }) + return address.port +} + +async function proxy(...args: Parameters) { + const value = await BrowserProxy.start(...args) + cleanup.push(() => value.close()) + return value +} + +function auth(proxy: BrowserProxy) { + return `Basic ${Buffer.from(`${proxy.credentials.username}:${proxy.credentials.password}`).toString("base64")}` +} + +async function client(proxy: BrowserProxy) { + const socket = connect(Number(new URL(proxy.proxy.server).port), "127.0.0.1") + socket.on("error", () => socket.destroy()) + socket.setTimeout(2_000, () => socket.destroy(new Error("Test socket timed out"))) + cleanup.push(() => { + socket.destroy() + }) + await once(socket, "connect") + return socket +} + +function receive(socket: Socket, complete: (input: Buffer) => boolean) { + const result = Promise.withResolvers() + const chunks: Buffer[] = [] + const clean = () => { + socket.off("data", data) + socket.off("close", close) + socket.off("error", error) + } + const error = (error: Error) => { + clean() + result.reject(error) + } + const close = () => error(new Error(`Socket closed: ${Buffer.concat(chunks).toString()}`)) + const data = (chunk: Buffer) => { + chunks.push(chunk) + const input = Buffer.concat(chunks) + if (!complete(input)) return + socket.pause() + clean() + result.resolve(input) + } + socket.on("data", data) + socket.once("close", close) + socket.once("error", error) + socket.resume() + return result.promise +} + +function closed(socket: Socket) { + return new Promise((resolve) => { + if (socket.closed) return resolve() + socket.once("close", () => resolve()) + socket.resume() + }) +} + +function command(proxy: BrowserProxy, target: string) { + return `CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\nProxy-Authorization: ${auth(proxy)}\r\n\r\n` +} + +async function tunnel(proxy: BrowserProxy, target = "public.example:443") { + const socket = await client(proxy) + const response = receive(socket, (input) => input.includes("\r\n\r\n")) + socket.write(command(proxy, target)) + expect((await response).toString()).toMatch(/^HTTP\/1\.1 200/) + return socket +} + +function word(value: number) { + const bytes = Buffer.alloc(2) + bytes.writeUInt16BE(value) + return bytes +} + +function record(input: Buffer) { + return Buffer.concat([Buffer.from([22, 3, 1]), word(input.length), input]) +} + +function hello(encrypted = false, named = true) { + const name = Buffer.from("public.example") + const names = Buffer.concat([Buffer.from([0]), word(name.length), name]) + const sni = named ? Buffer.concat([word(0), word(names.length + 2), word(names.length), names]) : Buffer.alloc(0) + const extensions = Buffer.concat([sni, ...(encrypted ? [Buffer.from([0xfe, 0x0d, 0, 1, 0])] : [])]) + const body = Buffer.concat([ + Buffer.from([3, 3]), + Buffer.alloc(32), + Buffer.from([0, 0, 2, 0x13, 1, 1, 0]), + word(extensions.length), + extensions, + ]) + const header = Buffer.alloc(4) + header[0] = 1 + header.writeUIntBE(body.length, 1, 3) + return record(Buffer.concat([header, body])) +} + +async function target() { + let accepted = 0 + const bytes: Buffer[] = [] + const server = createServer((socket) => { + accepted++ + socket.on("data", (chunk) => { + bytes.push(chunk) + socket.write(chunk) + }) + }) + return { port: await listen(server), accepted: () => accepted, bytes: () => Buffer.concat(bytes) } +} + +function forward(proxy: BrowserProxy, url: string, headers: IncomingHttpHeaders = {}, body = "") { + const result = Promise.withResolvers<{ status: number; headers: IncomingHttpHeaders; body: string }>() + const fields = { "sec-fetch-site": "same-origin", ...headers, "proxy-authorization": auth(proxy) } + const req = request(proxy.proxy.server, { method: body ? "POST" : "GET", path: url, headers: fields }, (res) => { + const chunks: Buffer[] = [] + res.on("data", (chunk) => chunks.push(chunk)) + res.once("end", () => + result.resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString() }), + ) + res.once("error", result.reject) + }) + req.once("error", result.reject) + req.end(body) + return result.promise +} + +describe("browser proxy", { concurrency: false, timeout: 15_000 }, () => { + test("isolates proxy capabilities and authenticates every protocol before DNS", async () => { + const calls: string[] = [] + const first = await proxy("public", { + lookup: async (host) => { + calls.push(host) + return [] + }, + }) + const second = await proxy("public") + expect(first.credentials.realm).not.toBe(second.credentials.realm) + expect(first.credentials.password).not.toBe(second.credentials.password) + expect(first.credentials.origin).toBe(first.proxy.server) + expect(first.proxy.bypass).toBe("<-loopback>") + expect(Object.keys(first.proxy)).toEqual(["server", "bypass"]) + for (const line of [ + "GET http://public.example/ HTTP/1.1", + "CONNECT public.example:443 HTTP/1.1", + "GET http://public.example/socket HTTP/1.1\r\nConnection: Upgrade\r\nUpgrade: websocket", + ]) { + for (const value of [ + "", + auth(second), + `Basic ${Buffer.from(`wrong:${first.credentials.password}`).toString("base64")}`, + ]) { + const socket = await client(first) + const response = receive(socket, (input) => input.includes("\r\n\r\n")) + socket.write(`${line}\r\nHost: public.example\r\nProxy-Authorization: ${value}\r\n\r\n`) + const text = (await response).toString() + expect(text).toMatch(/^HTTP\/1\.1 407 /) + expect(text).toContain(`Proxy-Authenticate: Basic realm="${first.credentials.realm}"`) + } + } + expect(calls).toEqual([]) + }) + + test("denies public plaintext and deny-policy requests without resolution", async () => { + const calls: string[] = [] + for (const policy of ["deny", "public"] as const) { + const value = await proxy(policy, { + lookup: async (host) => { + calls.push(host) + return [] + }, + }) + expect((await forward(value, "http://public.example/")).status).toBe(403) + const socket = await client(value) + const response = receive(socket, (input) => input.includes("\r\n\r\n")) + socket.write(command(value, policy === "deny" ? "public.example:443" : "127.0.0.1:443")) + expect((await response).toString()).toMatch(/^HTTP\/1\.1 403/) + } + expect(calls).toEqual([]) + }) + + test("pins public numeric addresses and preserves fragmented TLS including ECH GREASE", async () => { + const server = await target() + const calls: Array<{ host: string; port: number; family: number }> = [] + const names: string[] = [] + const value = await proxy("public", { + lookup: async (host) => { + names.push(host) + return [{ address: "8.8.8.8", family: 4 }] + }, + connect: (options) => { + calls.push(options) + return connect(server.port, "127.0.0.1") + }, + }) + for (const host of ["public.example:8443", "third-party.example:443"]) { + const socket = await tunnel(value, host) + const input = hello(true).subarray(5) + const fragments = Buffer.concat([ + record(input.subarray(0, 2)), + record(input.subarray(2, 17)), + record(input.subarray(17)), + ]) + const response = receive(socket, (input) => input.length >= fragments.length) + socket.write(fragments.subarray(0, 4)) + socket.write(fragments.subarray(4)) + expect(await response).toEqual(fragments) + } + expect(names).toEqual(["public.example", "third-party.example"]) + expect(calls).toEqual([ + { host: "8.8.8.8", port: 8443, family: 4 }, + { host: "8.8.8.8", port: 443, family: 4 }, + ]) + expect(server.accepted()).toBe(2) + }) + + for (const [host, address, family] of [ + ["8.8.8.8:443", "8.8.8.8", 4], + ["[2606:4700:4700::1111]:443", "2606:4700:4700::1111", 6], + ] as const) { + test(`pins literal ${host} and preserves coalesced CONNECT data without SNI`, async () => { + const server = await target() + const calls: Array<{ host: string; port: number; family: number }> = [] + const value = await proxy("public", { + lookup: async () => { + throw new Error("IP literals must not use DNS") + }, + connect: (options) => { + calls.push(options) + return connect(server.port, "127.0.0.1") + }, + }) + const socket = await client(value) + const input = hello(false, false) + const response = receive(socket, (data) => { + const end = data.indexOf("\r\n\r\n") + return end >= 0 && data.length >= end + 4 + input.length + }) + socket.write(Buffer.concat([Buffer.from(command(value, host)), input])) + const data = await response + expect(data.subarray(data.indexOf("\r\n\r\n") + 4)).toEqual(input) + expect(calls).toEqual([{ host: address, port: 443, family }]) + }) + } + + test("validates every DNS candidate and address family before any socket is opened", async () => { + const server = await target() + let answers: { address: string; family: number }[] = [] + let dials = 0 + const value = await proxy("public", { + lookup: async () => answers, + connect: () => { + dials++ + return connect(server.port, "127.0.0.1") + }, + }) + for (const entries of [ + [], + [{ address: "127.0.0.1", family: 4 }], + [{ address: "10.0.0.1", family: 4 }], + [{ address: "169.254.169.254", family: 4 }], + [{ address: "::1", family: 6 }], + [{ address: "fe80::1", family: 6 }], + [{ address: "::ffff:8.8.8.8", family: 6 }], + [{ address: "8.8.8.8", family: 6 }], + [{ address: "not-an-address", family: 4 }], + [ + { address: "8.8.8.8", family: 4 }, + { address: "192.168.1.1", family: 4 }, + ], + ]) { + answers = entries + const socket = await tunnel(value) + const end = closed(socket) + socket.write(hello()) + await end + } + expect(dials).toBe(0) + expect(server.accepted()).toBe(0) + expect(server.bytes()).toHaveLength(0) + }) + + test("tries the next validated numeric address when an earlier dial stalls", async () => { + const server = await target() + const stalled = new Socket() + const calls: Array<{ host: string; port: number; family: number }> = [] + const value = await proxy("public", { + timeout: 100, + lookup: async () => [ + { address: "2606:4700:4700::1111", family: 6 }, + { address: "8.8.8.8", family: 4 }, + ], + connect: (options) => { + calls.push(options) + return calls.length === 1 ? stalled : connect(server.port, "127.0.0.1") + }, + }) + const socket = await tunnel(value) + const input = hello() + const response = receive(socket, (data) => data.length >= input.length) + socket.write(input) + expect(await response).toEqual(input) + expect(calls).toEqual([ + { host: "2606:4700:4700::1111", port: 443, family: 6 }, + { host: "8.8.8.8", port: 443, family: 4 }, + ]) + expect(stalled.destroyed).toBe(true) + expect(server.accepted()).toBe(1) + }) + + for (const mode of ["disposal", "disconnect"]) { + test(`${mode} cancels a stalled dial without trying later candidates`, async () => { + const started = Promise.withResolvers() + const calls: string[] = [] + const value = await proxy("public", { + timeout: 100, + lookup: async () => [ + { address: "8.8.8.8", family: 4 }, + { address: "1.1.1.1", family: 4 }, + ], + connect: (options) => { + calls.push(options.host) + const socket = new Socket() + started.resolve(socket) + return socket + }, + }) + const socket = await tunnel(value) + socket.write(hello()) + const upstream = await started.promise + const end = Promise.all([closed(socket), closed(upstream)]) + if (mode === "disposal") await value.close() + if (mode === "disconnect") socket.destroy() + await end + expect(upstream.destroyed).toBe(true) + expect(calls).toEqual(["8.8.8.8"]) + }) + } + + test("rechecks DNS on the next tunnel instead of reusing an earlier public answer", async () => { + const server = await target() + let count = 0 + let dials = 0 + const value = await proxy("public", { + lookup: async () => [{ address: ++count === 1 ? "8.8.8.8" : "127.0.0.1", family: 4 }], + connect: () => { + dials++ + return connect(server.port, "127.0.0.1") + }, + }) + const first = await tunnel(value) + const input = hello() + const response = receive(first, (input) => input.length >= hello().length) + first.write(input) + expect(await response).toEqual(input) + const second = await tunnel(value) + const end = closed(second) + second.write(input) + await end + expect(count).toBe(2) + expect(dials).toBe(1) + expect(server.accepted()).toBe(1) + }) + + test("rejects plaintext, malformed and oversized TLS before DNS or upstream contact", async () => { + const server = await target() + let lookups = 0 + let dials = 0 + const value = await proxy("public", { + lookup: async () => { + lookups++ + return [{ address: "8.8.8.8", family: 4 }] + }, + connect: () => { + dials++ + return connect(server.port, "127.0.0.1") + }, + }) + for (const input of [ + Buffer.from("GET / HTTP/1.1\r\n\r\n"), + Buffer.from([22, 3, 1, 0xff, 0xff]), + record(Buffer.from([2, 0, 0, 1, 0])), + record(Buffer.from([1, 1, 0, 0])), + record(Buffer.from([1, 0, 0, 1, 0])), + ]) { + const socket = await tunnel(value) + const end = closed(socket) + socket.write(input) + await end + } + expect(lookups).toBe(0) + expect(dials).toBe(0) + expect(server.accepted()).toBe(0) + }) + + for (const mode of ["disposal", "disconnect"]) { + test(`${mode} prevents delayed DNS from opening a socket`, async () => { + const server = await target() + const started = Promise.withResolvers() + const cancelled = Promise.withResolvers() + const answers = Promise.withResolvers<{ address: string; family: number }[]>() + let dials = 0 + const value = await proxy("public", { + lookup: (_host, signal) => { + signal.addEventListener("abort", () => cancelled.resolve(), { once: true }) + started.resolve() + return answers.promise + }, + connect: () => { + dials++ + return connect(server.port, "127.0.0.1") + }, + }) + const socket = await tunnel(value) + const end = closed(socket) + socket.write(hello()) + await started.promise + if (mode === "disposal") await value.close() + if (mode === "disconnect") socket.destroy() + await cancelled.promise + answers.resolve([{ address: "8.8.8.8", family: 4 }]) + await end + await new Promise((resolve) => setImmediate(resolve)) + expect(dials).toBe(0) + expect(server.accepted()).toBe(0) + await value.close() + }) + } + + test("closes established sockets without affecting another context proxy", async () => { + const server = await target() + const opts = { + lookup: async () => [{ address: "8.8.8.8", family: 4 }], + connect: () => connect(server.port, "127.0.0.1"), + } + const first = await proxy("public", opts) + const second = await proxy("public", opts) + const input = hello() + const socket = await tunnel(first) + const response = receive(socket, (data) => data.length >= input.length) + socket.write(input) + expect(await response).toEqual(input) + const end = closed(socket) + await first.close() + await end + const other = await tunnel(second) + const output = receive(other, (data) => data.length >= input.length) + other.write(input) + expect(await output).toEqual(input) + }) + + test("bounds HTTP headers before any DNS lookup", async () => { + let lookups = 0 + const value = await proxy("public", { + lookup: async () => { + lookups++ + return [] + }, + }) + const socket = await client(value) + const response = receive(socket, (input) => input.includes("\r\n\r\n")) + socket.write( + `CONNECT public.example:443 HTTP/1.1\r\nHost: public.example\r\nProxy-Authorization: ${auth(value)}\r\nX-Large: ${"a".repeat(20 * 1024)}\r\n\r\n`, + ) + expect((await response).toString()).toMatch(/^HTTP\/1\.1 4\d\d/) + expect(lookups).toBe(0) + }) + + test("bounds stalled handshakes and DNS resolution", async () => { + let dials = 0 + const value = await proxy("public", { + timeout: 30, + lookup: () => new Promise(() => undefined), + connect: () => { + dials++ + throw new Error("Unexpected connection") + }, + }) + for (const input of [Buffer.from([22, 3]), hello()]) { + const socket = await tunnel(value) + const end = closed(socket) + socket.write(input) + await end + } + expect(dials).toBe(0) + }) + + test("local profiles allow pinned public TLS but reject private answers and plaintext public requests", async () => { + const server = await target() + let answers = [{ address: "8.8.8.8", family: 4 }] + const calls: Array<{ host: string; port: number; family: number }> = [] + const value = await proxy(new URL("http://localhost:3000"), { + lookup: async () => answers, + connect: (options) => { + calls.push(options) + return connect(server.port, "127.0.0.1") + }, + }) + const socket = await tunnel(value) + const input = hello() + const echoed = receive(socket, (data) => data.length >= input.length) + socket.write(input) + expect(await echoed).toEqual(input) + expect(calls).toEqual([{ host: "8.8.8.8", port: 443, family: 4 }]) + answers = [ + { address: "8.8.8.8", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ] + const denied = await tunnel(value, "rebound.example:443") + const done = closed(denied) + denied.write(input) + await done + expect((await forward(value, "http://public.example/")).status).toBe(403) + expect((await forward(value, "http://localhost:3001/")).status).toBe(403) + expect(calls).toHaveLength(1) + expect(server.accepted()).toBe(1) + }) + + test("rejects cross-site and opaque local HTTP requests before DNS or TCP", async () => { + const server = http((_request, response) => response.end("local")) + const port = await listen(server) + const url = new URL(`http://localhost:${port}/`) + let lookups = 0 + const value = await proxy(url, { + lookup: async () => { + lookups++ + return [{ address: "127.0.0.1", family: 4 }] + }, + }) + for (const headers of [ + { "sec-fetch-site": "" }, + { "sec-fetch-site": "cross-site" }, + { "sec-fetch-site": "same-site" }, + { origin: "null" }, + { origin: "https://public.example" }, + { "sec-fetch-site": "none", "sec-fetch-mode": "cors", "sec-fetch-dest": "empty" }, + { "sec-fetch-site": "none", "sec-fetch-mode": "navigate", "sec-fetch-dest": "iframe" }, + ]) { + expect((await forward(value, url.href, headers)).status).toBe(403) + } + const navigation = { "sec-fetch-site": "none", "sec-fetch-mode": "navigate", "sec-fetch-dest": "document" } + expect((await forward(value, url.href, navigation, "unapproved POST")).status).toBe(403) + expect(lookups).toBe(0) + expect((await forward(value, url.href, navigation)).status).toBe(200) + expect((await forward(value, url.href, { origin: url.origin })).status).toBe(200) + expect(lookups).toBe(2) + }) + + test("forwards local HTTP with origin headers intact and proxy credentials removed", async () => { + const seen: Array<{ url?: string; host?: string; auth?: string; secret?: string; body: string }> = [] + const server = http((req, res) => { + const chunks: Buffer[] = [] + req.on("data", (chunk) => chunks.push(chunk)) + req.on("end", () => { + seen.push({ + url: req.url, + host: req.headers.host, + auth: req.headers.authorization, + secret: req.headers["proxy-authorization"], + body: Buffer.concat(chunks).toString(), + }) + res + .writeHead(302, { + location: "http://127.0.0.1:1/private", + "content-security-policy": "default-src 'self'", + "x-frame-options": "DENY", + }) + .end("local response") + }) + }) + const port = await listen(server) + const origin = new URL(`http://localhost:${port}`) + const value = await proxy(origin) + origin.port = "1" + const response = await forward( + value, + `http://localhost:${port}/path?q=1`, + { authorization: "Bearer application", host: "wrong.example" }, + "body", + ) + expect(response).toMatchObject({ + status: 302, + body: "local response", + headers: { "content-security-policy": "default-src 'self'", "x-frame-options": "DENY" }, + }) + expect(seen).toEqual([ + { url: "/path?q=1", host: `localhost:${port}`, auth: "Bearer application", secret: undefined, body: "body" }, + ]) + expect((await forward(value, `http://127.0.0.1:${port}/other-origin`)).status).toBe(403) + expect(seen).toHaveLength(1) + }) + + for (const mode of ["end", "disconnect", "disposal", "error"]) { + test(`${mode} closes a local HTTP stream after it outlives the setup deadline`, async () => { + const timeout = 50 + const pending = Promise.withResolvers() + const server = http((_req, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }) + res.flushHeaders() + const timer = setTimeout(() => res.write("data: alive\n\n"), timeout * 3) + res.once("close", () => clearTimeout(timer)) + pending.resolve(res) + }) + const port = await listen(server) + const url = `http://127.0.0.1:${port}/events` + const value = await proxy(new URL(url), { timeout }) + const socket = await client(value) + const response = receive(socket, (input) => input.includes("data: alive\n\n")) + socket.write( + `GET ${url} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nProxy-Authorization: ${auth(value)}\r\nSec-Fetch-Site: same-origin\r\nConnection: close\r\n\r\n`, + ) + const upstream = await pending.promise + const end = once(upstream, "close") + expect((await response).toString()).toMatch(/^HTTP\/1\.1 200/) + expect(upstream.destroyed).toBe(false) + const done = closed(socket) + if (mode === "end") upstream.end() + if (mode === "disconnect") socket.destroy() + if (mode === "disposal") await value.close() + if (mode === "error") upstream.destroy() + await Promise.all([end, done]) + expect(upstream.destroyed).toBe(true) + }) + } + + test("still bounds local HTTP setup when upstream headers never arrive", async () => { + const pending = Promise.withResolvers() + const port = await listen(createServer((socket) => pending.resolve(socket))) + const url = `http://127.0.0.1:${port}/` + const value = await proxy(new URL(url), { timeout: 50 }) + const response = forward(value, url) + const upstream = await pending.promise + const end = closed(upstream) + expect((await response).status).toBe(502) + await end + expect(upstream.destroyed).toBe(true) + }) + + test("reports an unreachable local application as a marked bad gateway, not a policy denial", async () => { + const value = await proxy(new URL("http://127.0.0.1:3000"), { + connect: () => { + const socket = new Socket() + queueMicrotask(() => socket.destroy(Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }))) + return socket + }, + }) + const response = await forward(value, "http://127.0.0.1:3000/") + expect(response.status).toBe(502) + expect(response.headers[UNREACHABLE]).toBe("1") + const denied = await forward(value, "http://127.0.0.1:3001/") + expect(denied.status).toBe(403) + expect(denied.headers[UNREACHABLE]).toBeUndefined() + }) + + test("does not contact a non-loopback answer for an approved localhost origin", async () => { + let dials = 0 + const value = await proxy(new URL("http://localhost:3000"), { + lookup: async () => [{ address: "8.8.8.8", family: 4 }], + connect: () => { + dials++ + throw new Error("Unexpected connection") + }, + }) + expect((await forward(value, "http://localhost:3000/")).status).toBe(403) + expect(dials).toBe(0) + }) + + test("proxies exact-origin WebSocket upgrades and preserves bidirectional frames", async () => { + const server = http() + const sockets = new WebSocketServer({ server }) + const seen: IncomingHttpHeaders[] = [] + sockets.on("connection", (socket, request) => { + seen.push(request.headers) + socket.on("message", (data) => socket.send(data.toString())) + }) + const port = await listen(server) + cleanup.push(() => { + for (const socket of sockets.clients) socket.terminate() + sockets.close() + }) + const value = await proxy(new URL(`http://localhost:${port}`)) + const pending = Promise.withResolvers() + const req = request(value.proxy.server, { + path: `http://localhost:${port}/socket`, + headers: { + host: `localhost:${port}`, + origin: `http://localhost:${port}`, + "proxy-authorization": auth(value), + connection: "Upgrade", + upgrade: "websocket", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + "sec-websocket-version": "13", + }, + }) + req.on("upgrade", (_response, socket) => pending.resolve(socket)) + req.on("response", (response) => pending.reject(new Error(`Upgrade failed: ${response.statusCode}`))) + req.on("error", pending.reject) + req.end() + const socket = await pending.promise + cleanup.push(() => { + socket.destroy() + }) + const response = receive(socket, (input) => input.length >= 4) + socket.write(Buffer.from([0x81, 0x82, 1, 2, 3, 4, 0x68 ^ 1, 0x69 ^ 2])) + expect(await response).toEqual(Buffer.from([0x81, 2, 0x68, 0x69])) + expect(seen.at(0)?.["proxy-authorization"]).toBeUndefined() + }) + + test("rejects unapproved or malformed local CONNECT handshakes without contact", async () => { + const server = await target() + let dials = 0 + const value = await proxy(new URL(`http://localhost:${server.port}`), { + connect: () => { + dials++ + return connect(server.port, "127.0.0.1") + }, + }) + const fields = + "Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n" + const suffix = `Origin: http://localhost:${server.port}\r\n${fields}` + for (const input of [ + `GET / HTTP/1.1\r\nHost: localhost:${server.port}\r\n\r\n`, + `GET / HTTP/1.1\r\nHost: localhost:${server.port}\r\n${fields}`, + `GET / HTTP/1.1\r\nHost: localhost:${server.port}\r\nOrigin: null\r\n${fields}`, + `GET / HTTP/1.1\r\nHost: localhost:${server.port}\r\nOrigin: https://public.example\r\n${fields}`, + `GET / HTTP/1.1\r\nHost: 127.0.0.1:${server.port}\r\n${suffix}`, + `GET / HTTP/1.1\r\nHost: localhost:${server.port}\r\nProxy-Authorization: secret\r\n${suffix}`, + `GET / HTTP/1.1\r\nHost: localhost:${server.port}\r\n${suffix}GET /second HTTP/1.1\r\n\r\n`, + ]) { + const socket = await tunnel(value, `localhost:${server.port}`) + const end = closed(socket) + socket.write(input) + await end + } + expect(dials).toBe(0) + expect(server.accepted()).toBe(0) + }) + + test("supports exact-origin local WebSockets inside CONNECT", async () => { + const server = http() + const sockets = new WebSocketServer({ server }) + sockets.on("connection", (socket) => socket.on("message", (data) => socket.send(data.toString()))) + const port = await listen(server) + cleanup.push(() => { + for (const socket of sockets.clients) socket.terminate() + sockets.close() + }) + const value = await proxy(new URL(`http://localhost:${port}`)) + const socket = await tunnel(value, `localhost:${port}`) + const websocket = new WebSocket(`ws://localhost:${port}/socket`, { + origin: `http://localhost:${port}`, + createConnection: () => socket, + }) + cleanup.push(() => websocket.terminate()) + socket.resume() + await once(websocket, "open") + const response = once(websocket, "message") + websocket.send("local websocket") + expect((await response).at(0).toString()).toBe("local websocket") + }) +}) diff --git a/packages/kilo-vscode/tests/fixtures/node.ts b/packages/kilo-vscode/tests/fixtures/node.ts new file mode 100644 index 000000000000..6ed1e1f9569c --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/node.ts @@ -0,0 +1,28 @@ +import { build } from "esbuild" +import { mkdtemp, rm } from "node:fs/promises" +import path from "node:path" +import { exec } from "../../src/util/process" + +export async function node(name: string, opts: { timeout: number; env?: NodeJS.ProcessEnv }) { + const root = path.resolve(import.meta.dir, "../..") + const dir = await mkdtemp(path.join(root, `.${name}-`)) + try { + const file = path.join(dir, "test.cjs") + await build({ + entryPoints: [path.join(root, `tests/fixtures/${name}.ts`)], + outfile: file, + bundle: true, + platform: "node", + format: "cjs", + external: ["@playwright/test", "playwright-core"], + logLevel: "silent", + }) + return await exec("node", ["--test-reporter=tap", file], { ...opts, killSignal: "SIGKILL" }).catch( + (error: Error & { stdout?: string; stderr?: string }) => { + throw new Error([error.message, error.stdout, error.stderr].filter(Boolean).join("\n"), { cause: error }) + }, + ) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-tab-bar.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tab-bar.test.ts index e9341811abbd..5563b82cbfa0 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-tab-bar.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-tab-bar.test.ts @@ -19,17 +19,18 @@ describe("Agent Manager diff toggle", () => { expect(button).toContain('aria-label={props.t("agentManager.browser.title")}') }) - it("renders a real sandboxed browser document instead of an image", () => { + it("uses a reusable streamed viewport and keeps developer tools sandboxed", () => { const source = fs.readFileSync(BROWSER_PANEL, "utf-8") - expect(source).toContain(" { + it("passes navigation state to the stream and bridges element inspection", () => { const source = fs.readFileSync(BROWSER_PANEL, "utf-8") - expect(source).toContain("props.state?.navigation") + expect(source).toContain("state={() => props.state}") expect(source).toContain("when={identity()}") expect(source).not.toContain("contentWindow") expect(source).toContain("onMouseMove={(event) => props.controller.move(position(event))}") diff --git a/packages/kilo-vscode/tests/unit/browser-broker.test.ts b/packages/kilo-vscode/tests/unit/browser-broker.test.ts index 1ee50beadbaa..b9555506c105 100644 --- a/packages/kilo-vscode/tests/unit/browser-broker.test.ts +++ b/packages/kilo-vscode/tests/unit/browser-broker.test.ts @@ -1,22 +1,34 @@ import { afterEach, describe, expect, test } from "bun:test" +import { EventEmitter } from "node:events" import { createServer, request, type IncomingMessage } from "node:http" import { connect } from "node:net" import { PassThrough } from "node:stream" import { runInNewContext } from "node:vm" import WebSocket, { WebSocketServer } from "ws" -import { BrowserBroker, diagnostic } from "../../src/services/browser-automation/browser-broker" +import { + BrowserBroker, + BrowserLaunchError, + diagnostic, + type BrowserBrokerOptions, +} from "../../src/services/browser-automation/browser-broker" import { BrowserDevtools } from "../../src/services/browser-automation/browser-devtools" +import type { BrowserFrame } from "../../src/shared/browser-stream" const brokers: BrowserBroker[] = [] +type Network = NonNullable +const network: Network = async () => ({ + active: true, + authorize: () => undefined, + close: async () => undefined, +}) -function fixture(page: T) { +function fixture(page: T, attach: Network = network) { const broker = new BrowserBroker({ log: () => {}, + network: attach, launch: async () => ({ newContext: async () => ({ close: async () => undefined, - route: async () => undefined, - routeWebSocket: async () => undefined, newPage: async () => page, }), close: async () => undefined, @@ -31,13 +43,16 @@ afterEach(async () => { }) describe("BrowserBroker", () => { - test("accepts only HTTP loopback URLs", () => { + test("accepts HTTP loopback and public HTTPS URLs", () => { const broker = new BrowserBroker({ log: () => {} }) expect(broker.validate("http://localhost:3000/path").origin).toBe("http://localhost:3000") + expect(broker.validate("https://www.google.com/search?q=typescript").href).toBe( + "https://www.google.com/search?q=typescript", + ) expect(() => broker.validate("https://localhost:3000")).toThrow() expect(() => broker.validate("http://127.0.0.1:3000")).not.toThrow() - expect(() => broker.validate("http://[::1]:3000")).toThrow("Use localhost for IPv6 loopback servers") - expect(() => broker.validate("http://0.0.0.0:3000")).toThrow() + expect(() => broker.validate("http://[::1]:3000")).toThrow() + expect(broker.validate("http://0.0.0.0:3000/path?q=1").href).toBe("http://localhost:3000/path?q=1") expect(() => broker.validate("http://example.com")).toThrow() expect(() => broker.validate("http://username:password@localhost:3000")).toThrow() expect(() => broker.validate("file:///tmp/example.html")).toThrow() @@ -53,6 +68,9 @@ describe("BrowserBroker", () => { expect(diagnostic(new Error("page.goto: Timeout 30000ms exceeded"))).toBe( "The local application did not respond in time. Check the server and try again.", ) + expect(diagnostic(new Error("page.goto: Timeout 15000ms exceeded"), "https://google.com/")).toBe( + "The website did not respond in time. Check the URL and network connection, then try again.", + ) expect(diagnostic(new Error("\u001b[31mpage.goto: Custom navigation failure\u001b[0m\nCall log:\n- details"))).toBe( "Custom navigation failure", ) @@ -63,6 +81,7 @@ describe("BrowserBroker", () => { url: () => "about:blank", title: async () => "", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: (_type: string, _listener: (...args: never[]) => void) => undefined, mainFrame: () => undefined, goto: async () => { @@ -85,6 +104,97 @@ describe("BrowserBroker", () => { expect(broker.get("refused")?.error).toBe(message) }) + test.each(["pending", "approved"])("distinguishes %s human approval from a network timeout", async (mode) => { + const decision = Promise.withResolvers() + const callbacks: Array[1]["approve"]> = [] + const approvals: Array> = [] + const control = { + active: true, + authorize: () => undefined, + async close() { + this.active = false + }, + } + const broker = fixture( + { + url: () => "about:blank", + title: async () => "", + screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, + on: () => undefined, + mainFrame: () => undefined, + goto: async () => { + const approval = callbacks.at(0)!(new URL("https://www.google.com/")) + approvals.push(approval) + if (mode === "approved") { + decision.resolve(true) + await approval + } + throw new Error("page.goto: Timeout 15000ms exceeded\nCall log:\n- navigating") + }, + }, + async (_page, opts) => { + callbacks.push(opts.approve) + return control + }, + ) + broker.bind( + (route) => route, + () => decision.promise, + ) + const env = await broker.env() + const response = await fetch(`${env.KILO_BROWSER_BROKER_URL}/browser/open`, { + method: "POST", + headers: { + authorization: `Bearer ${env.KILO_BROWSER_BROKER_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify({ sessionID: "approval", directory: "/tmp/project", url: "https://google.com/" }), + }) + const message = + mode === "pending" + ? "Browser navigation stopped while waiting for approval in VS Code. Dismiss the approval prompt and retry." + : "The website did not respond in time. Check the URL and network connection, then try again." + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: message }) + expect(broker.get("approval")).toMatchObject({ status: "error", error: message }) + expect(control.active).toBe(mode === "approved") + decision.resolve(true) + expect(await approvals.at(0)).toBe(mode === "approved") + }) + + test("skips snapshots for streamed UI navigation but retains fresh agent screenshots", async () => { + let url = "about:blank" + let shots = 0 + const page = { + url: () => url, + title: async () => "Local fixture", + screenshot: async () => Buffer.from(`shot-${++shots}`), + off: () => undefined, + on: () => undefined, + mainFrame: () => undefined, + goto: async (value: string) => { + url = value + }, + reload: async () => undefined, + } + const broker = fixture(page) + const route = { sessionId: "streamed", projectId: "project", directory: "/tmp/project" } + const target = "http://localhost:3000/" + expect(await broker.open(route, target, false)).toMatchObject({ status: "ready", screenshot: undefined }) + expect(await broker.refresh(route.sessionId, route.projectId, false)).toMatchObject({ + status: "ready", + screenshot: undefined, + }) + expect(shots).toBe(0) + expect((await broker.open(route, target)).screenshot).toBe( + `data:image/jpeg;base64,${Buffer.from("shot-1").toString("base64")}`, + ) + expect(shots).toBe(1) + expect((await broker.open(route, `${target}next`, false)).screenshot).toBeUndefined() + expect(shots).toBe(1) + }) + test("reloads repeated agent opens and returns fresh page diagnostics", async () => { const listeners = new Map void>() const loading: number[] = [] @@ -93,6 +203,7 @@ describe("BrowserBroker", () => { url: () => "http://localhost:3000/", title: async () => `Application version ${version}`, screenshot: async () => Buffer.from(`version-${version}`), + off: () => undefined, on: (type: string, listener: (value: unknown) => void) => { listeners.set(type, listener) }, @@ -237,6 +348,7 @@ describe("BrowserBroker", () => { url: () => "http://localhost:3000/", title: async () => "Developer tools test", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: (_type: string, _listener: (...args: never[]) => void) => undefined, mainFrame: () => undefined, goto: async () => undefined, @@ -257,8 +369,6 @@ describe("BrowserBroker", () => { } return { close: async () => undefined, - route: async () => undefined, - routeWebSocket: async () => undefined, newPage: async () => page, newCDPSession: async () => ({ send: async () => ({ targetInfo: { targetId: id } }), @@ -268,7 +378,7 @@ describe("BrowserBroker", () => { }, close: async () => undefined, } - const broker = new BrowserBroker({ log: () => {}, launch: async () => browser }) + const broker = new BrowserBroker({ log: () => {}, network, launch: async () => browser }) brokers.push(broker) try { const env = await broker.env() @@ -415,34 +525,25 @@ describe("BrowserBroker", () => { expect(broker.sessions()).toEqual([]) }) - test("keeps browser contexts and HTTP rejection diagnostics isolated by session", async () => { + test("keeps injected network diagnostics and context cleanup isolated by session", async () => { const contexts: Array<{ close: () => Promise }> = [] - const listeners = new Map void>() - let routeHandler: - | (( - route: { continue: () => Promise; abort: () => Promise }, - request: { url: () => string; isNavigationRequest?: () => boolean }, - ) => Promise) - | undefined - let aborted = false + const callbacks: Array[1]["blocked"]> = [] + const closed: number[] = [] + const retired: number[] = [] const browser = { newContext: async () => { + const id = contexts.length const page = { - url: () => "http://localhost:3000", + url: () => "http://localhost:3000/", title: async () => "Local app", screenshot: async () => Buffer.from("jpeg"), - on: (type: string, listener: (value: unknown) => void) => { - listeners.set(type, listener) - }, + off: () => undefined, + on: () => undefined, mainFrame: () => undefined, goto: async () => undefined, } const context = { - close: async () => undefined, - route: async (_pattern: string, handler: typeof routeHandler) => { - routeHandler = handler - }, - routeWebSocket: async () => undefined, + close: async () => void closed.push(id), newPage: async () => page, } contexts.push(context) @@ -450,38 +551,33 @@ describe("BrowserBroker", () => { }, close: async () => undefined, } - const broker = new BrowserBroker({ log: () => {}, launch: async () => browser }) + const broker = new BrowserBroker({ + log: () => {}, + launch: async () => browser, + network: async (_page, opts) => { + const id = callbacks.length + callbacks.push(opts.blocked) + return { active: true, authorize: () => undefined, close: async () => void retired.push(id) } + }, + }) brokers.push(broker) - await broker.open({ sessionId: "one", directory: "/tmp/project" }, "http://localhost:3000") - await broker.open({ sessionId: "two", directory: "/tmp/project" }, "http://localhost:3000") + await broker.open({ sessionId: "one", directory: "/tmp/project" }, "http://localhost:3000/") + await broker.open({ sessionId: "two", directory: "/tmp/project" }, "http://localhost:3000/") expect(contexts).toHaveLength(2) + expect(callbacks).toHaveLength(2) expect(broker.get("one")?.browserId).not.toBe(broker.get("two")?.browserId) - await routeHandler!( - { continue: async () => undefined, abort: async () => void (aborted = true) }, - { url: () => "http://example.com" }, - ) - expect(aborted).toBe(true) - const blocked = broker.get("two") - expect(blocked).toMatchObject({ - errors: 1, - logs: ["Blocked browser request: http://example.com"], - error: undefined, - }) - listeners.get("requestfailed")?.({ url: () => "http://example.com" }) - expect(broker.get("two")).toEqual(blocked) - expect(broker.get("one")).toMatchObject({ errors: 0, logs: [], error: undefined }) - aborted = false - await routeHandler!( - { continue: async () => undefined, abort: async () => void (aborted = true) }, - { url: () => "data:text/html,", isNavigationRequest: () => true }, - ) - expect(aborted).toBe(true) + callbacks.at(1)!("Browser navigation was not approved") expect(broker.get("two")).toMatchObject({ - errors: 2, - logs: ["Blocked browser request: http://example.com", "Blocked browser request: null"], - error: undefined, + status: "ready", + errors: 0, + logs: [], + error: "Browser navigation was not approved", }) + expect(broker.get("one")).toMatchObject({ errors: 0, logs: [], error: undefined }) await broker.close("one") + expect(closed).toEqual([0]) + expect(retired).toEqual([0]) + expect(broker.get("one")).toBeUndefined() expect(broker.get("two")?.status).toBe("ready") }) @@ -495,25 +591,316 @@ describe("BrowserBroker", () => { expect(untrusted.sessions()).toEqual([]) }) - test.each([false, true])( - "points at Agent Manager settings when the browser is missing (Chrome: %s)", - async (chrome) => { - const broker = new BrowserBroker({ - log: () => {}, - useSystemChrome: () => chrome, - launch: async () => { - throw new Error("Browser executable does not exist") + test.each([ + ["macOS", true, "Chromium distribution 'chrome' is not found at /Applications/Google Chrome.app", "chrome"], + [ + "Windows", + true, + "Chromium distribution 'chrome' is not found at C:\\Program Files\\Google\\Chrome\\chrome.exe", + "chrome", + ], + ["Linux", true, "Chromium distribution 'chrome' is not found at /opt/google/chrome/chrome", "chrome"], + ["Playwright", false, "Executable doesn't exist at /cache/chromium_headless_shell/chrome", "chromium"], + ] as const)("identifies a missing browser on %s and allows retries", async (_platform, system, message, missing) => { + const cause = new Error(`browserType.launch: ${message}`) + let attempts = 0 + const broker = new BrowserBroker({ + log: () => {}, + useSystemChrome: () => system, + launch: async () => { + attempts++ + throw cause + }, + }) + brokers.push(broker) + const open = () => + broker.open({ sessionId: "missing-runtime", directory: "/tmp/project" }, "http://localhost:3000/") + await expect(open()).rejects.toMatchObject({ + name: "BrowserLaunchError", + missing, + cause, + message: expect.stringContaining( + `${system ? "disable" : "enable"} Use System Chrome in Kilo Settings > Experimental for the Integrated Browser`, + ), + }) + await expect(open()).rejects.toThrow(system ? "Install Chrome" : "enable Use System Chrome") + expect(attempts).toBe(2) + expect(broker.sessions()).toEqual([]) + }) + + test.each([ + "No usable sandbox!", + "error while loading shared libraries: libnss3.so: cannot open shared object file", + "Timeout 30000ms exceeded", + "Target page, context or browser has been closed", + ])("does not misreport a browser startup failure as a missing installation: %s", async (message) => { + const broker = new BrowserBroker({ + log: () => {}, + launch: async () => { + throw new Error(message) + }, + }) + brokers.push(broker) + const error = await broker + .open({ sessionId: "startup", directory: "/tmp/project" }, "http://localhost:3000/") + .catch((err: unknown) => err) + expect(error).toBeInstanceOf(BrowserLaunchError) + expect(error).toMatchObject({ missing: undefined }) + expect(diagnostic(error, "http://localhost:3000/")).toBe(`The browser could not start. ${message}`) + }) + + test("reports a missing browser to every concurrent open", async () => { + const cause = new Error( + "browserType.launch: Chromium distribution 'chrome' is not found at /opt/google/chrome/chrome", + ) + const gate = Promise.withResolvers() + let attempts = 0 + const broker = new BrowserBroker({ + log: () => {}, + launch: async () => { + attempts++ + await gate.promise + throw cause + }, + }) + brokers.push(broker) + const opens = ["one", "two"].map((id) => + broker.open({ sessionId: id, directory: "/tmp/project" }, "http://localhost:3000/").catch((err: unknown) => err), + ) + gate.resolve() + const errors = await Promise.all(opens) + expect(attempts).toBe(1) + for (const error of errors) expect(error).toMatchObject({ name: "BrowserLaunchError", missing: "chrome", cause }) + }) + + test("restarts Chromium for every session after it disconnects", async () => { + const listeners: Array<() => void> = [] + let launches = 0 + let contexts = 0 + const page = () => { + let target = "about:blank" + return { + url: () => target, + title: async () => "Local app", + screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, + on: (_type: string, _listener: (...args: never[]) => void) => undefined, + mainFrame: () => undefined, + goto: async (url: string) => { + target = url + return { status: () => 200 } }, - }) - brokers.push(broker) - await expect( - broker.open({ sessionId: "missing-runtime", directory: "/tmp/project" }, "http://localhost:3000/"), - ).rejects.toThrow( - `${chrome ? "disable" : "enable"} Use System Chrome in Kilo Settings > Experimental for the Integrated Browser`, - ) - expect(broker.sessions()).toEqual([]) - }, - ) + reload: async () => ({ status: () => 200 }), + } + } + const broker = new BrowserBroker({ + log: () => {}, + network, + launch: async () => { + launches++ + return { + newContext: async () => { + contexts++ + return { close: async () => undefined, newPage: async () => page() } + }, + close: async () => undefined, + on: (_event: "disconnected", listener: () => void) => listeners.push(listener), + } + }, + }) + brokers.push(broker) + const one = { sessionId: "one", directory: "/tmp/one" } + const two = { sessionId: "two", directory: "/tmp/two" } + const first = await broker.open(one, "http://localhost:3000/", false) + await broker.open(two, "http://localhost:3001/", false) + listeners.at(0)?.() + const crashed = "The browser stopped unexpectedly. Refresh to start it again." + expect(broker.get("one")).toMatchObject({ browserId: first.browserId, status: "error", error: crashed }) + expect(broker.get("two")).toMatchObject({ status: "error", error: crashed }) + const refreshed = await broker.refresh("one", undefined, false) + expect(refreshed).toMatchObject({ status: "ready", url: "http://localhost:3000/" }) + expect(refreshed.browserId).not.toBe(first.browserId) + expect((await broker.open(two, "http://localhost:3001/", false)).status).toBe("ready") + expect(launches).toBe(2) + expect(contexts).toBe(4) + }) + + test("replaces a crashed page on refresh", async () => { + const crashes: Array<() => void> = [] + let contexts = 0 + let target = "about:blank" + const page = { + url: () => target, + title: async () => "Local app", + screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, + on: (type: string, listener: () => void) => { + if (type === "crash") crashes.push(listener) + }, + mainFrame: () => undefined, + goto: async (url: string) => { + target = url + return { status: () => 200 } + }, + reload: async () => ({ status: () => 200 }), + } + const broker = new BrowserBroker({ + log: () => {}, + network, + launch: async () => ({ + newContext: async () => { + contexts++ + return { close: async () => undefined, newPage: async () => page } + }, + close: async () => undefined, + }), + }) + brokers.push(broker) + const route = { sessionId: "crash", directory: "/tmp/project" } + const opened = await broker.open(route, "http://localhost:3000/", false) + crashes.at(-1)?.() + expect(broker.get("crash")).toMatchObject({ + browserId: opened.browserId, + status: "error", + error: "The page crashed. Refresh to load it again.", + }) + const refreshed = await broker.refresh("crash", undefined, false) + expect(refreshed.status).toBe("ready") + expect(refreshed.browserId).not.toBe(opened.browserId) + expect(contexts).toBe(2) + }) + + test("reports an unreachable local application instead of a proxy status", async () => { + let target = "about:blank" + const listeners = new Map void>() + const main = {} + const page = { + url: () => target, + title: async () => "", + screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, + on: (type: string, listener: (value: unknown) => void) => listeners.set(type, listener), + mainFrame: () => main, + goto: async (url: string) => { + target = url + listeners.get("response")?.({ + request: () => ({ isNavigationRequest: () => true }), + frame: () => main, + status: () => 502, + headers: () => ({ "x-kilo-browser-unreachable": "1" }), + }) + throw new Error("page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at http://localhost:3000/") + }, + } + const broker = fixture(page) + const message = "Cannot connect to http://localhost:3000/. Make sure the local server is running." + await expect( + broker.open({ sessionId: "down", directory: "/tmp/project" }, "http://localhost:3000/"), + ).rejects.toThrow(message) + expect(broker.get("down")).toMatchObject({ status: "error", error: message }) + }) + + test("does not take a screenshot while the stream changes the viewport", async () => { + const order: string[] = [] + const resize = Promise.withResolvers() + const entered = Promise.withResolvers() + let target = "about:blank" + const session = Object.assign(new EventEmitter(), { + send: async () => ({}), + detach: async () => undefined, + }) + const page = Object.assign(new EventEmitter(), { + url: () => target, + title: async () => "Local app", + mainFrame: () => page, + context: () => ({ newCDPSession: async () => session }), + setViewportSize: async () => { + order.push("resize") + entered.resolve() + await resize.promise + order.push("resized") + }, + screenshot: async () => { + order.push("screenshot") + return Buffer.from("jpeg") + }, + goto: async (url: string) => { + target = url + return { status: () => 200 } + }, + reload: async () => ({ status: () => 200 }), + }) + const broker = fixture(page) + const route = { sessionId: "stream", directory: "/tmp/project" } + const opened = await broker.open(route, "http://localhost:3000/", false) + const viewport = { width: 393, height: 689, scale: 2, revision: 1, active: true } + const streaming = broker.viewport("stream", undefined, opened.browserId, opened.navigation, viewport) + await entered.promise + const refreshing = broker.refresh("stream", undefined, true) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(order).toEqual(["resize"]) + resize.resolve() + await Promise.all([streaming, refreshing]) + expect(order).toEqual(["resize", "resized", "screenshot"]) + }) + + test("navigates back and forward only through web page history", async () => { + const pages = ["about:blank"] + let index = 0 + const calls: string[] = [] + const page = { + url: () => pages[index], + title: async () => `Page ${index}`, + screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, + on: () => undefined, + mainFrame: () => undefined, + goto: async (url: string) => { + pages.splice(index + 1, Infinity, url) + index++ + return { status: () => 200 } + }, + goBack: async () => { + calls.push("back") + index-- + return { status: () => 200 } + }, + goForward: async () => { + calls.push("forward") + index++ + return { status: () => 200 } + }, + } + const session = { + send: async () => ({ currentIndex: index, entries: pages.map((url) => ({ url })) }), + detach: async () => undefined, + } + const broker = new BrowserBroker({ + log: () => {}, + network, + launch: async () => ({ + newContext: async () => ({ + close: async () => undefined, + newPage: async () => page, + newCDPSession: async () => session, + }), + close: async () => undefined, + }), + }) + brokers.push(broker) + const route = { sessionId: "history", directory: "/tmp/project" } + const first = await broker.open(route, "http://localhost:3000/one", false) + expect([first.back, first.forward]).toEqual([false, false]) + expect((await broker.history("history", undefined, "back")).url).toBe("http://localhost:3000/one") + expect(calls).toEqual([]) + const second = await broker.open(route, "http://localhost:3000/two", false) + expect([second.back, second.forward]).toEqual([true, false]) + const back = await broker.history("history", undefined, "back") + expect([back.url, back.back, back.forward]).toEqual(["http://localhost:3000/one", false, true]) + const forward = await broker.history("history", undefined, "forward") + expect([forward.url, forward.back, forward.forward]).toEqual(["http://localhost:3000/two", true, false]) + expect(calls).toEqual(["back", "forward"]) + }) test("rejects unregistered browser sessions before launching Chrome", async () => { const broker = new BrowserBroker({ log: () => {} }) @@ -533,6 +920,7 @@ describe("BrowserBroker", () => { url: () => target, title: async () => (status === 404 ? "Missing page" : "Local app"), screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: (_type: string, _listener: (...args: never[]) => void) => undefined, mainFrame: () => undefined, goto: async (url: string) => { @@ -582,13 +970,14 @@ describe("BrowserBroker", () => { expect(recovered.error).toBeUndefined() }) - test("reports response headers that prevent embedding the real application document", async () => { + test("does not report iframe restrictions as errors for streamed pages", async () => { let headers: Record = {} let target = "about:blank" const page = { url: () => target, title: async () => "Local app", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: (_type: string, _listener: (...args: never[]) => void) => undefined, mainFrame: () => undefined, goto: async (url: string) => { @@ -600,13 +989,12 @@ describe("BrowserBroker", () => { const route = { sessionId: "framed", directory: "/tmp/project" } expect((await broker.open(route, "http://localhost:3000/open")).frameError).toBeUndefined() headers = { "x-frame-options": "DENY" } - expect((await broker.open(route, "http://localhost:3000/deny")).frameError).toContain("X-Frame-Options: DENY") + expect((await broker.open(route, "http://localhost:3000/deny")).frameError).toBeUndefined() headers = { "x-frame-options": "SAMEORIGIN" } - expect((await broker.open(route, "http://localhost:3000/same-origin")).frameError).toContain("SAMEORIGIN") + expect((await broker.open(route, "http://localhost:3000/same-origin")).frameError).toBeUndefined() headers = { "content-security-policy": "default-src 'self'; frame-ancestors 'none'" } - expect((await broker.open(route, "http://localhost:3000/csp")).frameError).toContain("frame-ancestors 'none'") - headers = {} - expect((await broker.open(route, "http://localhost:3000/recovered")).frameError).toBeUndefined() + expect((await broker.open(route, "http://localhost:3000/csp")).frameError).toBeUndefined() + expect(broker.get(route.sessionId)).toMatchObject({ status: "ready", error: undefined }) }) test("serializes concurrent navigation and close without reviving a stale session", async () => { @@ -624,6 +1012,7 @@ describe("BrowserBroker", () => { url: () => target, title: async () => "Local app", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: (_type: string, _listener: (...args: never[]) => void) => undefined, mainFrame: () => undefined, goto: async (url: string) => { @@ -637,14 +1026,12 @@ describe("BrowserBroker", () => { contexts++ return { close: async () => undefined, - route: async () => undefined, - routeWebSocket: async () => undefined, newPage: async () => page, } }, close: async () => undefined, } - const broker = new BrowserBroker({ log: () => {}, launch: async () => browser }) + const broker = new BrowserBroker({ log: () => {}, network, launch: async () => browser }) brokers.push(broker) const states: string[] = [] broker.subscribe((state) => states.push(state.status)) @@ -660,6 +1047,128 @@ describe("BrowserBroker", () => { expect(states.at(-1)).toBe("closed") }) + test.each(["creation", "replacement", "queued replacement", "disposal"])( + "closes the current entry during %s", + async (phase) => { + const entered = Promise.withResolvers() + const deferred = Promise.withResolvers() + const contexts: Array<{ proxy: string; closed: number }> = [] + const states: string[] = [] + const stopped: number[] = [] + const replace = phase.includes("replacement") + const broker = new BrowserBroker({ + log: () => undefined, + network, + launch: async () => ({ + newContext: async (opts) => { + const id = contexts.length + const context = { proxy: opts.proxy!.server, closed: 0 } + contexts.push(context) + if (id === (replace ? 1 : 0)) { + entered.resolve() + await deferred.promise + } + let target = "about:blank" + return { + close: async () => void context.closed++, + newPage: async () => ({ + url: () => target, + title: async () => "Application", + screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, + on: () => undefined, + mainFrame: () => undefined, + goto: async (url: string) => void (target = url), + }), + } + }, + close: async () => void stopped.push(...contexts.map((context) => context.closed)), + }), + }) + brokers.push(broker) + broker.subscribe((state) => states.push(state.status)) + const route = { projectId: "project", sessionId: "pending", directory: "/tmp/project" } + if (replace) await broker.open(route, "http://localhost:3000/") + const opened = broker.open(route, "https://example.com/") + if (phase !== "queued replacement") await entered.promise + const sessions = broker.sessions() + const closed = + phase === "disposal" + ? broker.disposeAsync() + : Promise.all(sessions.map((session) => broker.close(session, route.projectId))) + await entered.promise + const offline = + phase === "disposal" + ? await fetch(contexts.at(0)!.proxy).then( + () => false, + () => true, + ) + : undefined + deferred.resolve() + await Promise.all([ + phase === "disposal" ? expect(opened).rejects.toThrow("Browser broker is closed") : opened, + closed, + ]) + expect(sessions).toEqual([route.sessionId]) + expect(contexts).toHaveLength(replace ? 2 : 1) + expect(contexts.map((context) => context.closed)).toEqual(contexts.map(() => 1)) + expect(broker.get(route.sessionId, route.projectId)).toBeUndefined() + expect(states.at(-1)).toBe("closed") + if (phase === "disposal") { + expect(offline).toBe(true) + expect(stopped).toEqual([1]) + } + for (const context of contexts) await expect(fetch(context.proxy)).rejects.toThrow() + }, + ) + + test.each(["project", "workspace"])("closes enumerated pending creations during %s shutdown", async (mode) => { + const scoped = mode === "project" + const entered = Promise.withResolvers() + const deferred = Promise.withResolvers() + let contexts = 0 + const broker = new BrowserBroker({ + log: () => undefined, + network, + launch: async () => ({ + newContext: async () => { + const id = ++contexts + if (id === 3) entered.resolve() + if (id > 1) await deferred.promise + let target = "about:blank" + return { + close: async () => undefined, + newPage: async () => ({ + url: () => target, + title: async () => "Application", + screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, + on: () => undefined, + mainFrame: () => undefined, + goto: async (url: string) => void (target = url), + }), + } + }, + close: async () => undefined, + }), + }) + brokers.push(broker) + const route = { projectId: "one", sessionId: "pending", directory: "/tmp/project" } + await broker.open({ ...route, projectId: "two", sessionId: "other" }, "http://localhost:3000/") + const first = broker.open(route, "http://localhost:3000/") + const second = broker.open({ ...route, projectId: "two" }, "http://localhost:3000/") + await entered.promise + const sessions = broker.sessions().sort() + const closed = Promise.all(sessions.map((session) => broker.close(session, scoped ? route.projectId : undefined))) + deferred.resolve() + await Promise.all([first, second, closed]) + expect(sessions).toEqual(["other", "pending"]) + expect(broker.get(route.sessionId, "one")).toBeUndefined() + expect(broker.get(route.sessionId, "two")?.status).toBe(scoped ? "ready" : undefined) + expect(broker.get("other", "two")?.status).toBe(scoped ? "ready" : undefined) + expect(broker.sessions().sort()).toEqual(scoped ? ["other", "pending"] : []) + }) + test("does not create a context after disposal interrupts browser launch", async () => { let resume: (() => void) | undefined let launched: (() => void) | undefined @@ -703,6 +1212,7 @@ describe("BrowserBroker", () => { url: () => "http://localhost:3000/", title: async () => "Feedback demo", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: (type: string, listener: (value: unknown) => void) => { listeners.set(type, listener) }, @@ -774,6 +1284,7 @@ describe("BrowserBroker", () => { url: () => "http://localhost:3000/", title: async () => "Local app", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: (type: string, listener: (...args: never[]) => void) => { listeners.set(type, listener) }, @@ -801,54 +1312,252 @@ describe("BrowserBroker", () => { }) }) - test("allows only same-origin WebSockets", async () => { - let handler: - | ((socket: { url: () => string; connectToServer: () => void; close: () => Promise }) => Promise) - | undefined - const page = { + test("rejects stale browser, navigation, and revision identities for streams", async () => { + const calls: Array<{ method: string; params?: Record }> = [] + const frames: BrowserFrame[] = [] + const protocol = Object.assign(new EventEmitter(), { + send: async (method: string, params?: Record) => { + calls.push({ method, params }) + return {} + }, + detach: async () => void calls.push({ method: "detach" }), + }) + const page = Object.assign(new EventEmitter(), { url: () => "http://localhost:3000/", - title: async () => "Local app", + title: async () => "Application", screenshot: async () => Buffer.from("jpeg"), - on: (_type: string, _listener: (...args: never[]) => void) => undefined, - mainFrame: () => undefined, + context: () => ({ newCDPSession: async () => protocol }), + mainFrame: () => page, goto: async () => undefined, + reload: async () => undefined, + setViewportSize: async (size: { width: number; height: number }) => { + calls.push({ method: "resize", params: size }) + }, + }) + const broker = fixture(page) + const route = { projectId: "project", sessionId: "session", directory: "/tmp/project" } + const opened = await broker.open(route, "http://localhost:3000/") + const viewport = { width: 640, height: 480, revision: 2, active: true } + const identity = { browserId: opened.browserId, navigation: opened.navigation, revision: viewport.revision } + const off = broker.frames((frame) => frames.push(frame)) + await broker.viewport(route.sessionId, route.projectId, "retired", identity.navigation, viewport) + await broker.viewport(route.sessionId, route.projectId, identity.browserId, identity.navigation - 1, viewport) + expect(calls).toEqual([]) + await broker.viewport(route.sessionId, route.projectId, identity.browserId, identity.navigation, viewport) + const jpeg = Buffer.from([255, 216, 255, 192, 0, 11, 8, 0, 0, 0, 0, 1, 1, 17, 0, 255, 217]) + jpeg.writeUInt16BE(viewport.height, 7) + jpeg.writeUInt16BE(viewport.width, 9) + const frame = (id: number) => + protocol.emit("Page.screencastFrame", { + sessionId: id, + data: jpeg.toString("base64"), + metadata: { deviceWidth: viewport.width, deviceHeight: viewport.height }, + }) + frame(1) + frame(2) + expect(frames).toHaveLength(1) + expect(frames.at(0)).toMatchObject({ ...identity, sessionId: route.sessionId, projectId: route.projectId }) + const count = calls.length + for (const stale of [ + { ...identity, browserId: "retired" }, + { ...identity, navigation: identity.navigation - 1 }, + { ...identity, revision: identity.revision - 1 }, + ]) { + await broker.viewport(route.sessionId, route.projectId, stale.browserId, stale.navigation, { + ...viewport, + revision: stale.revision, + }) + expect(broker.accepts(route.sessionId, route.projectId, stale)).toBe(false) + await broker.interact(route.sessionId, route.projectId, stale, { kind: "text", text: "stale" }) + broker.acknowledge(route.sessionId, route.projectId, stale, frames.at(0)!.sequence) } - const browser = { - newContext: async () => ({ - close: async () => undefined, - route: async () => undefined, - routeWebSocket: async (_pattern: string, next: typeof handler) => { - handler = next + expect(calls).toHaveLength(count) + expect(frames).toHaveLength(1) + expect(broker.accepts(route.sessionId, route.projectId, identity)).toBe(true) + broker.acknowledge(route.sessionId, route.projectId, identity, frames.at(0)!.sequence) + expect(frames).toHaveLength(2) + await broker.interact(route.sessionId, route.projectId, identity, { kind: "text", text: "current" }) + const refreshed = await broker.refresh(route.sessionId, route.projectId) + expect(broker.accepts(route.sessionId, route.projectId, identity)).toBe(false) + await broker.interact(route.sessionId, route.projectId, identity, { kind: "text", text: "stale" }) + expect(calls.filter((call) => call.method === "Input.insertText")).toEqual([ + { method: "Input.insertText", params: { text: "current" } }, + ]) + frame(3) + expect(frames.at(-1)).toMatchObject({ navigation: refreshed.navigation, revision: viewport.revision }) + const current = { ...identity, navigation: refreshed.navigation, revision: 3 } + await broker.viewport(route.sessionId, route.projectId, current.browserId, current.navigation, { + ...viewport, + revision: current.revision, + }) + expect(broker.accepts(route.sessionId, route.projectId, { ...current, revision: 2 })).toBe(false) + expect(broker.accepts(route.sessionId, route.projectId, current)).toBe(true) + frame(4) + expect(frames.at(-1)).toMatchObject(current) + await broker.close(route.sessionId, route.projectId) + frame(5) + expect(frames).toHaveLength(4) + expect(broker.accepts(route.sessionId, route.projectId, current)).toBe(false) + expect(protocol.listenerCount("Page.screencastFrame")).toBe(0) + off() + }) + + test("closes a context before waiting on a blocked renderer and permits reopening", async () => { + const entered = Promise.withResolvers() + const selected = Promise.withResolvers<{ focused: boolean; text: string }>() + const released = Promise.withResolvers() + const closed = Promise.withResolvers() + let contexts = 0 + const protocol = Object.assign(new EventEmitter(), { + send: async () => ({}), + detach: async () => undefined, + }) + const page = Object.assign(new EventEmitter(), { + url: () => "http://localhost:3000/", + title: async () => "Application", + screenshot: async () => Buffer.from("jpeg"), + context: () => ({ newCDPSession: async () => protocol }), + mainFrame: () => page, + goto: async () => undefined, + setViewportSize: async () => undefined, + evaluate: async () => { + entered.resolve() + return selected.promise + }, + }) + const broker = new BrowserBroker({ + log: () => {}, + network: async () => ({ active: true, authorize: () => undefined, close: () => released.promise }), + launch: async () => ({ + newContext: async () => { + contexts++ + return { + newPage: async () => page, + close: async () => { + selected.reject(new Error("Context closed")) + released.resolve() + closed.resolve() + }, + } }, - newPage: async () => page, + close: async () => undefined, }), - close: async () => undefined, + }) + brokers.push(broker) + const route = { projectId: "project", sessionId: "session", directory: "/tmp/project" } + const state = await broker.open(route, "http://localhost:3000/", false) + const identity = { browserId: state.browserId, navigation: state.navigation, revision: 1 } + await broker.viewport(route.sessionId, route.projectId, state.browserId, state.navigation, { + width: 320, + height: 200, + active: true, + revision: 1, + }) + const copying = broker.interact(route.sessionId, route.projectId, identity, { kind: "clipboard", action: "copy" }) + await entered.promise + const closing = broker.close(route.sessionId, route.projectId) + try { + await Promise.race([ + closed.promise, + Bun.sleep(1000).then(() => { + throw new Error("Context closure is blocked by renderer work") + }), + ]) + await closing + expect(await copying).toBeUndefined() + expect(broker.get(route.sessionId, route.projectId)).toBeUndefined() + expect(protocol.listenerCount("Page.screencastFrame")).toBe(0) + const reopened = await broker.open(route, "http://localhost:3000/", false) + expect(contexts).toBe(2) + expect(reopened.status).toBe("ready") + expect(reopened.browserId).not.toBe(state.browserId) + } finally { + selected.resolve({ focused: true, text: "stale" }) + released.resolve() + await Promise.allSettled([copying, closing]) } - const broker = new BrowserBroker({ log: () => {}, launch: async () => browser }) + }) + + test.each([ + ["http://localhost:3000/", "https://www.google.com/"], + ["https://www.google.com/", "http://localhost:3000/"], + ["http://localhost:3000/", "http://localhost:4000/"], + ])("replaces the browser context from %s to %s", async (first, second) => { + const closed: string[] = [] + const attached: string[] = [] + let contexts = 0 + const broker = new BrowserBroker({ + log: () => {}, + network: async (_page, opts) => { + const id = attached.push(opts.url.href) + return { active: true, authorize: () => undefined, close: async () => void closed.push(`network:${id}`) } + }, + launch: async () => ({ + newContext: async () => { + const id = ++contexts + let target = "about:blank" + return { + close: async () => void closed.push(`context:${id}`), + newPage: async () => ({ + url: () => target, + title: async () => "Application", + screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, + on: () => undefined, + mainFrame: () => undefined, + goto: async (url: string) => void (target = url), + }), + } + }, + close: async () => undefined, + }), + }) brokers.push(broker) - await broker.open({ sessionId: "socket", directory: "/tmp/project" }, "http://localhost:3000/") - let connected = false - let closed = false - await handler!({ - url: () => "ws://localhost:3000/hmr", - connectToServer: () => void (connected = true), - close: async () => void (closed = true), - }) - expect(connected).toBe(true) - expect(closed).toBe(false) - expect(broker.get("socket")).toMatchObject({ errors: 0, logs: [], error: undefined }) - connected = false - await handler!({ - url: () => "ws://localhost:4000/private", - connectToServer: () => void (connected = true), - close: async () => void (closed = true), - }) - expect(connected).toBe(false) - expect(closed).toBe(true) - expect(broker.get("socket")).toMatchObject({ - errors: 1, - logs: ["Blocked browser request: ws://localhost:4000"], - error: undefined, + const route = { projectId: "project", sessionId: "session", directory: "/tmp/project" } + const before = await broker.open(route, first) + const after = await broker.open(route, second) + expect(contexts).toBe(2) + expect(attached).toEqual([first, second]) + expect(closed).toEqual(["network:1", "context:1"]) + expect(after.browserId).not.toBe(before.browserId) + expect(broker.get(route.sessionId, route.projectId)).toMatchObject({ + browserId: after.browserId, + status: "ready", + navigation: 1, + url: second, }) + await broker.close(route.sessionId, route.projectId) + expect(closed).toEqual(["network:1", "context:1", "network:2", "context:2"]) + }) + + test("refreshes without granting navigation and reuses public contexts for explicit opens", async () => { + const grants: string[] = [] + let target = "about:blank" + let reloads = 0 + const page = { + url: () => target, + title: async () => "Application", + screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, + on: () => undefined, + mainFrame: () => undefined, + goto: async (url: string) => void (target = url), + reload: async () => void reloads++, + } + const broker = fixture(page, async () => ({ + active: true, + authorize: (url) => void grants.push(url.href), + close: async () => undefined, + })) + const route = { projectId: "project", sessionId: "session", directory: "/tmp/project" } + const first = await broker.open(route, "https://www.google.com/") + await broker.refresh(route.sessionId, route.projectId) + expect(grants).toEqual([]) + const second = await broker.open(route, "https://example.com/") + expect(second.browserId).toBe(first.browserId) + expect(grants).toEqual(["https://example.com/"]) + await broker.refresh(route.sessionId, route.projectId) + expect(grants).toEqual(["https://example.com/"]) + expect(reloads).toBe(2) }) }) diff --git a/packages/kilo-vscode/tests/unit/browser-controller.test.ts b/packages/kilo-vscode/tests/unit/browser-controller.test.ts index ada7e3dd4547..4b4d3a06f1c0 100644 --- a/packages/kilo-vscode/tests/unit/browser-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/browser-controller.test.ts @@ -76,6 +76,54 @@ function inspection(requestId: string, scope: BrowserScope, error?: string): Bro } describe("browser controller", () => { + test.each([ + ["google.com", "https://google.com"], + [" www.spiegel.de/news?q=test#section ", "https://www.spiegel.de/news?q=test#section"], + ["example.com:8443/path", "https://example.com:8443/path"], + ["//google.com/path", "https://google.com/path"], + ["localhost:3000/path", "http://localhost:3000/path"], + ["127.0.0.1:8080", "http://127.0.0.1:8080"], + ["localhost.example.com", "https://localhost.example.com"], + ["https://localhost:3000", "https://localhost:3000"], + ["http://google.com", "http://google.com"], + ["https://google.com", "https://google.com"], + ["file:///tmp/index.html", "file:///tmp/index.html"], + ])("opens %s as %s without changing explicit schemes", (value, expected) => { + const view = setup() + view.controller.setUrl(value) + view.controller.open() + expect(view.sent.at(-1)).toEqual({ + type: "open", + scope: { sessionId: "session-a", projectId: "project-a" }, + url: expected, + }) + expect(view.controller.url()).toBe(expected) + view.dispose() + }) + + test("retries a missing browser with the entered URL and clears the warning on success", () => { + const view = setup() + const scope = { sessionId: "session-a", projectId: "project-a" } + view.controller.setUrl("localhost:3000") + view.controller.open() + view.emit({ + type: "state", + value: { scope, browserId: "", status: "error", errors: 0, missing: "chrome", error: "Browser missing" }, + }) + expect(view.controller.url()).toBe("http://localhost:3000") + expect(view.controller.state()?.missing).toBe("chrome") + expect(view.controller.loading()).toBe(false) + view.controller.open() + expect(view.sent.at(-1)).toEqual({ type: "open", scope, url: "http://localhost:3000" }) + view.emit({ + type: "state", + value: { scope, browserId: "installed", status: "ready", errors: 0, url: "http://localhost:3000" }, + }) + expect(view.controller.state()?.missing).toBeUndefined() + expect(view.controller.loading()).toBe(false) + view.dispose() + }) + test("coalesces pointer movement while an inspection is active", () => { const view = setup() view.controller.toggleSelecting() diff --git a/packages/kilo-vscode/tests/unit/browser-message.test.ts b/packages/kilo-vscode/tests/unit/browser-message.test.ts index 2e494ed4ae7f..0c25dc63059c 100644 --- a/packages/kilo-vscode/tests/unit/browser-message.test.ts +++ b/packages/kilo-vscode/tests/unit/browser-message.test.ts @@ -1,5 +1,7 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { EventEmitter } from "node:events" import { browserMessage, handleBrowserMessage } from "../../src/agent-manager/browser-message" +import { createBrowserLifecycle } from "../../src/agent-manager/browser-lifecycle" import type { Host } from "../../src/agent-manager/host" import type { ProjectContexts } from "../../src/agent-manager/project/contexts" import type { AgentManagerInMessage, AgentManagerOutMessage } from "../../src/agent-manager/types" @@ -14,26 +16,34 @@ afterEach(async () => { async function fixture() { let attempts = 0 const state = { trusted: true, enabled: true } - const page = { + const protocol = Object.assign(new EventEmitter(), { + send: async (method: string, params?: Record) => { + protocol.emit(method, params) + return {} + }, + detach: async () => undefined, + }) + const page = Object.assign(new EventEmitter(), { url: () => "http://localhost:3000/", title: async () => "", screenshot: async () => Buffer.from("jpeg"), - on: () => undefined, - mainFrame: () => undefined, + mainFrame: () => page, + context: () => ({ newCDPSession: async () => protocol }), goto: async () => undefined, + reload: async () => undefined, setViewportSize: async () => undefined, - evaluate: async () => { + evaluate: async (_fn: unknown, opts?: { action: string; text?: string }) => { + if (opts?.action) return { focused: true, text: opts.action === "paste" ? opts.text : "selection" } if (++attempts === 1) throw new Error("Execution context was destroyed") return { tag: "button", selector: "#save", text: "Save" } }, - } + }) const broker = new BrowserBroker({ log: () => {}, + network: async () => ({ active: true, authorize: () => undefined, close: async () => undefined }), launch: async () => ({ newContext: async () => ({ close: async () => undefined, - route: async () => undefined, - routeWebSocket: async () => undefined, newPage: async () => page, }), close: async () => undefined, @@ -47,16 +57,19 @@ async function fixture() { const context = { id: "project", root: "/fixture", peekState: () => undefined, sessions: () => [{ id: "session" }] } const contexts = { resolve: (id: string) => (id === "project" ? context : undefined), + byDirectory: (directory: string) => (directory === "/fixture" ? context : undefined), active: () => context, } as unknown as ProjectContexts const host = { isTrusted: () => state.trusted, browserAutomation: () => state.enabled } as unknown as Host - const send = (message: AgentManagerInMessage) => - new Promise((resolve) => { - expect(handleBrowserMessage(message, { host, contexts, browser: broker, post: resolve, log: () => {} })).toBe( - true, - ) - }) - return { send, state, broker, current } + const dispatch = (message: AgentManagerInMessage, post: (message: AgentManagerOutMessage) => void) => { + expect(handleBrowserMessage(message, { host, contexts, browser: broker, post, log: () => {} })).toBe(true) + } + const send = (message: AgentManagerInMessage) => { + const result = Promise.withResolvers() + dispatch(message, result.resolve) + return result.promise + } + return { send, dispatch, state, broker, current, host, contexts, protocol, page } } const request = (id: string): AgentManagerInMessage => ({ @@ -72,6 +85,84 @@ const request = (id: string): AgentManagerInMessage => ({ }) describe("browser inspection responses", () => { + test.each([true, false])("forwards a missing browser before an entry exists (system Chrome: %s)", async (system) => { + const view = await fixture() + const broker = new BrowserBroker({ + log: () => {}, + useSystemChrome: () => system, + launch: async () => { + throw new Error( + system + ? "Chromium distribution 'chrome' is not found at /missing/chrome" + : "Executable doesn't exist at /missing/chromium", + ) + }, + }) + brokers.push(broker) + const result = Promise.withResolvers() + handleBrowserMessage( + { type: "agentManager.browser.open", projectId: "project", sessionId: "session", url: "http://localhost:3000/" }, + { host: view.host, contexts: view.contexts, browser: broker, post: result.resolve, log: () => {} }, + ) + expect(await result.promise).toMatchObject({ + type: "agentManager.browserState", + browserId: "", + projectId: "project", + sessionId: "session", + status: "error", + missing: system ? "chrome" : "chromium", + error: expect.stringContaining("was not found"), + }) + expect(broker.get("session", "project")).toBeUndefined() + }) + + test("suspends streams only for the current panel's lifecycle", async () => { + const view = await fixture() + const suspend = spyOn(view.broker, "suspend") + const lifecycle = createBrowserLifecycle({ + browser: view.broker, + host: view.host, + contexts: () => view.contexts, + post: () => {}, + openPanel: () => {}, + log: () => {}, + }) + const panel = () => { + const events = new EventEmitter() + return { + visible: true, + events, + onDidChangeVisibility(callback: (visible: boolean) => void) { + events.on("visible", callback) + return { dispose: () => events.off("visible", callback) } + }, + onDidDispose(callback: () => void) { + events.on("close", callback) + return { dispose: () => events.off("close", callback) } + }, + } + } + const first = panel() + lifecycle.attach(first) + first.events.emit("visible", true) + expect(suspend).not.toHaveBeenCalled() + first.events.emit("visible", false) + expect(suspend).toHaveBeenCalledTimes(1) + const second = panel() + lifecycle.attach(second) + first.events.emit("visible", false) + first.events.emit("close") + expect(suspend).toHaveBeenCalledTimes(1) + second.events.emit("visible", false) + second.events.emit("close") + expect(suspend).toHaveBeenCalledTimes(3) + await lifecycle.dispose() + suspend.mockClear() + second.events.emit("visible", false) + expect(suspend).not.toHaveBeenCalled() + suspend.mockRestore() + }) + test("returns correlated failures and continues processing later hover requests", async () => { const test = await fixture() expect(await test.send(request("first"))).toEqual({ @@ -127,6 +218,195 @@ describe("browser inspection responses", () => { expect(test.broker.get("session", "project")?.error).toBeUndefined() }) + test("reports a rejected URL without discarding the existing preview", async () => { + const view = await fixture() + expect( + await view.send({ + type: "agentManager.browser.open", + projectId: "project", + sessionId: "session", + url: "http://example.com/", + }), + ).toEqual({ ...browserMessage(view.current), error: expect.stringContaining("HTTPS") }) + expect(view.broker.get("session", "project")?.error).toBeUndefined() + }) + + test.each(["copy", "cut"] as const)("finishes %s and the clipboard write before a queued paste", async (action) => { + const view = await fixture() + const identity = { browserId: view.current.browserId, navigation: view.current.navigation, revision: 1 } + await view.broker.viewport("session", "project", identity.browserId, identity.navigation, { + width: 400, + height: 300, + revision: 1, + active: true, + }) + const entered = Promise.withResolvers() + const selected = Promise.withResolvers<{ focused: boolean; text: string }>() + const writing = Promise.withResolvers() + const written = Promise.withResolvers() + const pasted = Promise.withResolvers() + const events: string[] = [] + let clipboard = "old" + spyOn(view.page, "evaluate").mockImplementation(async (_fn, opts) => { + if (opts?.action === "paste") return { focused: true, text: opts.text } + entered.resolve() + return selected.promise + }) + view.host.copyToClipboard = async (value) => { + writing.resolve() + await written.promise + events.push("write") + clipboard = value + } + view.host.readClipboard = async () => { + events.push("read") + return clipboard + } + view.protocol.on("Input.insertText", (event: { text: string }) => pasted.resolve(event.text)) + const message = { + type: "agentManager.browser.interact", + projectId: "project", + sessionId: "session", + identity, + } as const + try { + view.dispatch({ ...message, event: { kind: "clipboard", action } }, () => {}) + await entered.promise + view.dispatch({ ...message, event: { kind: "clipboard", action: "paste" } }, () => {}) + selected.resolve({ focused: true, text: "new" }) + await writing.promise + expect(events).toEqual([]) + written.resolve() + expect(await pasted.promise).toBe("new") + expect(events).toEqual(["write", "read"]) + } finally { + selected.resolve({ focused: true, text: "new" }) + written.resolve() + } + }) + + test("does not write clipboard data after browser ownership is revoked", async () => { + const view = await fixture() + const identity = { browserId: view.current.browserId, navigation: view.current.navigation, revision: 1 } + await view.broker.viewport("session", "project", identity.browserId, identity.navigation, { + width: 400, + height: 300, + revision: 1, + active: true, + }) + const entered = Promise.withResolvers() + const selected = Promise.withResolvers<{ focused: boolean; text: string }>() + spyOn(view.page, "evaluate").mockImplementation(async () => { + entered.resolve() + return selected.promise + }) + const values: string[] = [] + const copying = view.broker.interact( + "session", + "project", + identity, + { kind: "clipboard", action: "copy" }, + undefined, + (value) => { + values.push(value) + }, + ) + await entered.promise + view.broker.bind( + () => undefined, + async () => false, + ) + selected.resolve({ focused: true, text: "private selection" }) + expect(await copying).toBeUndefined() + expect(values).toEqual([]) + }) + + test("reserves paste ordering before the host clipboard read resolves", async () => { + const view = await fixture() + const identity = { browserId: view.current.browserId, navigation: view.current.navigation ?? 0, revision: 1 } + await view.broker.viewport("session", "project", identity.browserId, identity.navigation, { + width: 400, + height: 300, + revision: 1, + active: true, + }) + const entered = Promise.withResolvers() + const clipboard = Promise.withResolvers() + const done = Promise.withResolvers() + const values: string[] = [] + view.host.readClipboard = () => { + entered.resolve() + return clipboard.promise + } + view.protocol.on("Input.insertText", (event: { text: string }) => { + values.push(event.text) + if (event.text === "after") done.resolve() + }) + const message = { + type: "agentManager.browser.interact", + projectId: "project", + sessionId: "session", + identity, + } as const + view.dispatch({ ...message, event: { kind: "clipboard", action: "paste" } }, () => {}) + await entered.promise + view.dispatch({ ...message, event: { kind: "text", text: "after" } }, () => {}) + await Bun.sleep(0) + expect(values).toEqual([]) + clipboard.resolve("pasted") + await done.promise + expect(values).toEqual(["pasted", "after"]) + }) + + test("does not read the clipboard for stale stream identities", async () => { + const view = await fixture() + const viewport = { width: 400, height: 300, revision: 2, active: true } + const identity = { + browserId: view.current.browserId, + navigation: view.current.navigation, + revision: viewport.revision, + } + await view.broker.viewport("session", "project", identity.browserId, identity.navigation, viewport) + const messages: AgentManagerOutMessage[] = [] + let reads = 0 + view.host.readClipboard = async () => { + reads++ + return "clipboard text" + } + for (const stale of [ + { ...identity, browserId: "retired" }, + { ...identity, navigation: identity.navigation - 1 }, + { ...identity, revision: identity.revision - 1 }, + ]) { + view.dispatch( + { + type: "agentManager.browser.interact", + projectId: "project", + sessionId: "session", + identity: stale, + event: { kind: "clipboard", action: "paste" }, + }, + (message) => messages.push(message), + ) + } + expect(reads).toBe(0) + const pasted = Promise.withResolvers() + view.protocol.once("Input.insertText", pasted.resolve) + view.dispatch( + { + type: "agentManager.browser.interact", + projectId: "project", + sessionId: "session", + identity, + event: { kind: "clipboard", action: "paste" }, + }, + (message) => messages.push(message), + ) + expect(await pasted.promise).toEqual({ text: "clipboard text" }) + expect(reads).toBe(1) + expect(messages).toEqual([]) + }) + test("preserves an untitled preview when native picker input fails", async () => { const test = await fixture() test.broker.input = async () => { diff --git a/packages/kilo-vscode/tests/unit/browser-network.test.ts b/packages/kilo-vscode/tests/unit/browser-network.test.ts new file mode 100644 index 000000000000..213eeb16f69e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-network.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test" +import { existsSync } from "node:fs" +import { chromium } from "playwright-core" +import { node } from "../fixtures/node" + +const executable = [ + process.env.CHROME_PATH, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + chromium.executablePath(), +].find((path) => path && existsSync(path)) + +test.skipIf(!executable)( + "browser network Chromium checks run in the extension host's Node runtime", + async () => { + const result = await node("browser-network", { + timeout: 180_000, + env: { ...process.env, CHROME_PATH: executable }, + }) + const skipped = process.env.KILO_BROWSER_GOOGLE_SMOKE === "1" ? 0 : 3 + expect(result.stdout).toMatch(/^# tests 28$/m) + expect(result.stdout).toMatch(new RegExp(`^# pass ${28 - skipped}$`, "m")) + expect(result.stdout).toMatch(new RegExp(`^# skipped ${skipped}$`, "m")) + expect(result.stdout).toMatch(/^# fail 0$/m) + expect(result.stdout).toMatch(/^# cancelled 0$/m) + }, + 190_000, +) diff --git a/packages/kilo-vscode/tests/unit/browser-policy.test.ts b/packages/kilo-vscode/tests/unit/browser-policy.test.ts new file mode 100644 index 000000000000..7f755bad4352 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-policy.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { parse } from "../../src/services/browser-automation/browser-policy" + +describe("browser URL policy", () => { + test.each([ + ["http://localhost:5173/path?q=1#section", "http://localhost:5173/path?q=1#section"], + ["http://127.0.0.1:3000", "http://127.0.0.1:3000/"], + ["https://www.google.com/search?q=browser", "https://www.google.com/search?q=browser"], + ["https://STATIC.Example.COM.:8443/a", "https://static.example.com:8443/a"], + ["https://bücher.example/", "https://xn--bcher-kva.example/"], + ["https://8.8.8.8:8443/", "https://8.8.8.8:8443/"], + ["https://[2606:4700:4700::1111]/", "https://[2606:4700:4700::1111]/"], + ["https://unresolved.example/", "https://unresolved.example/"], + ])("accepts %s", (value, expected) => { + expect(parse(value).href).toBe(expected) + }) + + test.each([ + "http://example.com/", + "http://[::1]:3000/", + "http://localhost.:3000/", + "http://app.localhost:3000/", + "https://localhost/", + "https://LOCALHOST./", + "https://app.localhost/", + "https://127.0.0.1/", + "https://127.1/", + "https://0x7f000001/", + "https://2130706433/", + "https://0.0.0.0/", + "https://10.0.0.1/", + "https://172.16.0.1/", + "https://192.168.0.1/", + "https://169.254.169.254/", + "https://100.64.0.1/", + "https://192.0.2.1/", + "https://224.0.0.1/", + "https://[::1]/", + "https://[fe80::1]/", + "https://[fc00::1]/", + "https://[::ffff:8.8.8.8]/", + "https://[64:ff9b::808:808]/", + "https://user:secret@example.com/", + "http://user@localhost:3000/", + "https://@example.com/", + "https://example.com:65536/", + "https://8.8.8.8:0/", + "http://localhost:0/", + "https://*.example.com/", + "https://example..com/", + "https:example.com", + " https://example.com/", + "https://example.com/\n", + "https://exam\tple.com/", + "https://example.com\\@localhost/", + "ws://localhost:3000/", + "wss://example.com/", + "file:///tmp/example.html", + "data:text/html,test", + ])("rejects %s", (value) => { + expect(() => parse(value)).toThrow() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/browser-proxy.test.ts b/packages/kilo-vscode/tests/unit/browser-proxy.test.ts new file mode 100644 index 000000000000..dc122473f0e2 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-proxy.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from "bun:test" +import { node } from "../fixtures/node" + +test("browser proxy protocols run in the extension host's Node runtime", async () => { + const result = await node("browser-proxy", { timeout: 20_000 }) + expect(result.stdout).toMatch(/^# tests 29$/m) + expect(result.stdout).toMatch(/^# pass 29$/m) + expect(result.stdout).toMatch(/^# skipped 0$/m) + expect(result.stdout).toMatch(/^# fail 0$/m) + expect(result.stdout).toMatch(/^# cancelled 0$/m) +}, 30_000) diff --git a/packages/kilo-vscode/tests/unit/browser-stream-input.test.ts b/packages/kilo-vscode/tests/unit/browser-stream-input.test.ts new file mode 100644 index 000000000000..64457c2092ba --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-stream-input.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from "bun:test" +import { + clicks, + clipboard, + key, + modifiers, + point, + pointer, + printable, + transition, + typing, + wheel, +} from "../../webview-ui/browser/stream-input" + +const bounds = { left: 100, top: 50, width: 400, height: 200 } +const position = { clientX: 300, clientY: 100 } +const flags = { altKey: false, ctrlKey: false, metaKey: false, shiftKey: false } +const keyboard = { + ...flags, + key: "a", + code: "KeyA", + keyCode: 65, + repeat: false, + isComposing: false, + getModifierState: (_name: string) => false, +} +const insertion = { inputType: "insertText", isComposing: false } + +function press(value: Partial[0]> = {}) { + return { ...keyboard, ...value } +} + +describe("browser stream pointer input", () => { + test("normalizes CSS coordinates and clamps captured drags outside the canvas", () => { + expect(point(position, bounds)).toEqual({ x: 0.5, y: 0.25 }) + expect(point({ clientX: -10, clientY: 400 }, bounds)).toEqual({ x: 0, y: 1 }) + expect(point(position, { ...bounds, width: 0 })).toBeUndefined() + expect(point(position, { ...bounds, height: -1 })).toBeUndefined() + expect(point(position, { ...bounds, width: Infinity })).toBeUndefined() + expect(point({ ...position, clientY: NaN }, bounds)).toBeUndefined() + }) + + test("preserves button state, click count, and CDP modifier bits", () => { + const event = { ...position, ...flags, button: 2, buttons: 2, ctrlKey: true, shiftKey: true } + expect(pointer(event, bounds, "down", 2)).toEqual({ + kind: "pointer", + action: "down", + x: 0.5, + y: 0.25, + button: "right", + buttons: 2, + clicks: 2, + modifiers: 10, + }) + expect(pointer({ ...event, button: -1 }, bounds, "move", 0)?.button).toBe("right") + expect(pointer({ ...event, button: 1, buttons: 4 }, bounds, "down", 1)?.button).toBe("middle") + expect(pointer({ ...event, button: 0, buttons: 0 }, bounds, "up", 1)?.buttons).toBe(0) + expect(pointer({ ...event, button: 3, buttons: 8 }, bounds, "down", 1)).toBeUndefined() + expect(modifiers({ altKey: true, ctrlKey: true, metaKey: true, shiftKey: true })).toBe(15) + }) + + test("preserves chorded button transitions instead of coalescing them as movement", () => { + const events = [ + { button: 0, buttons: 1 }, + { button: -1, buttons: 1 }, + { button: 2, buttons: 3 }, + { button: 0, buttons: 2 }, + { button: 2, buttons: 0 }, + { button: -1, buttons: 0 }, + ] + expect(events.map((event) => transition(event) ?? "move")).toEqual(["down", "move", "down", "up", "up", "move"]) + expect(transition({ button: 1, buttons: 5 })).toBe("down") + expect(transition({ button: 1, buttons: 1 })).toBe("up") + expect(transition({ button: 3, buttons: 8 })).toBeUndefined() + }) + + test("scales pixel, line, and page wheel deltas without losing fractional pixels", () => { + const event = { ...position, ...flags, deltaX: -0.5, deltaY: 2, deltaMode: 0 } + expect(wheel(event, bounds)).toMatchObject({ x: 0.5, y: 0.25, deltaX: -0.5, deltaY: 2 }) + expect(wheel({ ...event, deltaMode: 1 }, bounds, 20)).toMatchObject({ deltaX: -10, deltaY: 40 }) + expect(wheel({ ...event, deltaMode: 2 }, bounds)).toMatchObject({ deltaX: -200, deltaY: 400 }) + expect(wheel({ ...event, deltaX: -20000, deltaY: 20000 }, bounds)).toMatchObject({ + deltaX: -10000, + deltaY: 10000, + }) + expect(wheel({ ...event, deltaY: Infinity }, bounds)).toBeUndefined() + expect(wheel(event, { ...bounds, height: 0 })).toBeUndefined() + }) + + test("counts double clicks when pointer events have no native click detail", () => { + const count = clicks() + const event = { ...position, button: 0, timeStamp: 100 } + expect(count.down(event)).toBe(1) + expect(count.up()).toBe(1) + expect(count.down({ ...event, clientX: 302, timeStamp: 200 })).toBe(2) + expect(count.up()).toBe(2) + expect(count.down({ ...event, timeStamp: 300 })).toBe(3) + count.up() + expect(count.down({ ...event, timeStamp: 900 })).toBe(1) + count.up() + expect(count.down({ ...event, button: 2, timeStamp: 1000 })).toBe(1) + }) + + test("does not turn a drag or a cancelled pointer into a double click", () => { + const count = clicks() + const event = { ...position, button: 0, timeStamp: 100 } + count.down(event) + count.move({ clientX: 350, clientY: 100 }) + count.move(position) + count.up() + expect(count.down({ ...event, timeStamp: 200 })).toBe(1) + count.up() + count.reset() + expect(count.down({ ...event, timeStamp: 300 })).toBe(1) + }) +}) + +describe("browser stream keyboard input", () => { + test("includes printable text with keydown but not shortcuts or key releases", () => { + expect(printable(press())).toBe(true) + expect(key(press(), "down")).toEqual({ + kind: "key", + action: "down", + key: "a", + code: "KeyA", + keyCode: 65, + repeat: false, + modifiers: 0, + text: "a", + }) + expect(key(press(), "up")?.text).toBeUndefined() + expect(key(press({ ctrlKey: true }), "down")?.text).toBeUndefined() + expect(key(press({ metaKey: true }), "down")?.text).toBeUndefined() + expect(typing().input({ ...insertion, data: "a" }, "a")).toEqual({ kind: "text", text: "a" }) + expect(printable(press({ key: "A", shiftKey: true }))).toBe(true) + expect(printable(press({ key: "é", altKey: true }))).toBe(true) + expect(printable(press({ key: "𠮷" }))).toBe(true) + expect(printable(press({ ctrlKey: true }))).toBe(false) + expect(printable(press({ metaKey: true }))).toBe(false) + expect(printable(press({ ctrlKey: true, altKey: true, getModifierState: (name) => name === "AltGraph" }))).toBe( + true, + ) + }) + + test("forwards navigation keys, repeat state, and key releases", () => { + for (const name of ["Tab", "Enter", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Backspace", "Delete"]) { + const event = press({ key: name, code: name, repeat: true, shiftKey: true }) + expect(printable(event)).toBe(false) + expect(key(event, "down")).toMatchObject({ kind: "key", key: name, action: "down", repeat: true, modifiers: 8 }) + expect(key(event, "up")).toMatchObject({ key: name, action: "up" }) + } + }) + + test("does not forward IME processing or dead keys as raw input", () => { + for (const name of ["Dead", "Process", "Unidentified", ""]) { + expect(key(press({ key: name }), "down")).toBeUndefined() + } + expect(key(press({ isComposing: true }), "down")).toBeUndefined() + expect(key(press({ keyCode: 229 }), "down")).toBeUndefined() + }) + + test("routes clipboard shortcuts to the host without confusing AltGraph text", () => { + expect(clipboard(press({ key: "c", code: "KeyC", ctrlKey: true }))).toBe("copy") + expect(clipboard(press({ key: "X", code: "KeyX", metaKey: true, shiftKey: true }))).toBe("cut") + expect(clipboard(press({ key: "v", code: "KeyV", metaKey: true }))).toBe("paste") + expect(clipboard(press({ key: "с", code: "KeyC", ctrlKey: true }))).toBe("copy") + expect(clipboard(press({ key: "c", code: "KeyC" }))).toBeUndefined() + expect(clipboard(press({ key: "v", code: "KeyV", ctrlKey: true, altKey: true }))).toBeUndefined() + expect(clipboard(press({ key: "c", code: "KeyC", ctrlKey: true, getModifierState: () => true }))).toBeUndefined() + }) +}) + +describe("browser stream text composition", () => { + test("uses committed text and ignores clipboard and raw-key input types", () => { + const text = typing() + expect(text.input({ ...insertion, data: null }, "word")).toEqual({ kind: "text", text: "word" }) + expect(text.input({ ...insertion, inputType: "insertReplacementText", data: "replacement" }, "old")).toEqual({ + kind: "text", + text: "replacement", + }) + for (const inputType of [ + "insertFromPaste", + "insertFromDrop", + "deleteByCut", + "deleteContentBackward", + "insertLineBreak", + ]) { + expect(text.input({ ...insertion, inputType, data: "text" }, "text")).toBeUndefined() + } + }) + + test("commits composition once when input arrives before compositionend", () => { + const text = typing() + expect(text.start()).toEqual({ kind: "composition", text: "", start: 0, end: 0 }) + expect(text.update("日本")).toEqual({ kind: "composition", text: "日本", start: 2, end: 2 }) + expect(text.update("日本")).toBeUndefined() + expect( + text.input({ ...insertion, inputType: "insertCompositionText", data: "日本", isComposing: true }, "日本"), + ).toBeUndefined() + expect(text.end("日本")).toEqual({ kind: "text", text: "日本" }) + expect(text.active()).toBe(false) + expect(text.end("日本")).toBeUndefined() + }) + + test("ignores the trailing commit input but permits the next identical character", () => { + const text = typing() + text.start() + text.update("あ") + expect(text.end("あ")).toEqual({ kind: "text", text: "あ" }) + text.prepare(press({ keyCode: 229 })) + expect(text.input({ ...insertion, data: "あ" }, "あ")).toBeUndefined() + text.prepare(press()) + expect(text.input({ ...insertion, data: "あ" }, "あ")).toEqual({ kind: "text", text: "あ" }) + text.start() + text.end("あ") + text.prepare(press()) + expect(text.input({ ...insertion, data: "あ" }, "あ")).toEqual({ kind: "text", text: "あ" }) + }) + + test("suppresses composition-specific final events and clears cancelled composition", () => { + const text = typing() + text.start() + text.update("𠮷") + expect(text.update("𠮷a")).toEqual({ kind: "composition", text: "𠮷a", start: 3, end: 3 }) + text.end("𠮷a") + expect(text.input({ ...insertion, inputType: "insertFromComposition", data: "𠮷a" }, "𠮷a")).toBeUndefined() + text.start() + text.update("draft") + expect(text.end("")).toEqual({ kind: "composition", text: "", start: 0, end: 0 }) + text.start() + text.update("discard") + expect(text.reset()).toEqual({ kind: "composition", text: "", start: 0, end: 0 }) + expect(text.active()).toBe(false) + expect(text.end("discard")).toBeUndefined() + expect(text.reset()).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/browser-stream.test.ts b/packages/kilo-vscode/tests/unit/browser-stream.test.ts new file mode 100644 index 000000000000..631b3cd50d99 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-stream.test.ts @@ -0,0 +1,993 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" +import { EventEmitter } from "node:events" +import { existsSync } from "node:fs" +import { chromium, type Browser, type Page } from "playwright-core" +import { BrowserStream } from "../../src/services/browser-automation/browser-stream" +import { source, type BrowserFrame, type BrowserInteraction } from "../../src/shared/browser-stream" + +const streams: BrowserStream[] = [] +const pages: Page[] = [] +const view = { width: 640, height: 480, revision: 1, active: true } +const executable = [ + process.env.CHROME_PATH, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + chromium.executablePath(), +].find((path) => path && existsSync(path)) + +function jpeg(width = view.width, height = view.height, length = 17) { + const data = Buffer.alloc(length) + data.set([255, 216, 255, 192, 0, 11, 8, 0, 0, 0, 0, 1, 1, 17, 0, 255, 217]) + data.writeUInt16BE(height, 7) + data.writeUInt16BE(width, 9) + return data +} + +function protocol() { + const calls: Array<{ method: string; params?: Record }> = [] + const hooks = new Map Promise>() + const frames: BrowserFrame[] = [] + const logs: unknown[][] = [] + const scope = { browserId: "browser", navigation: 1 } + const delivery = { fail: false } + const session = Object.assign(new EventEmitter(), { + send: async (method: string, params?: Record) => { + calls.push({ method, params }) + await hooks.get(method)?.() + return {} + }, + detach: async () => { + calls.push({ method: "detach" }) + await hooks.get("detach")?.() + }, + }) + const page = Object.assign(new EventEmitter(), { + context: () => ({ + newCDPSession: async () => { + calls.push({ method: "connect" }) + await hooks.get("connect")?.() + return session + }, + }), + setViewportSize: async (size: { width: number; height: number }) => { + calls.push({ method: "resize", params: size }) + await hooks.get("resize")?.() + }, + mainFrame: () => page, + evaluate: async (_fn: unknown, opts: { action: string; text?: string }) => ({ + focused: true, + text: opts.action === "paste" ? opts.text : "copied", + }), + }) + const stream = new BrowserStream( + page as unknown as Page, + () => scope, + (frame) => { + if (delivery.fail) throw new Error(frame.data) + frames.push(frame) + }, + (...args) => logs.push(args), + ) + streams.push(stream) + const send = (id: number, data?: string, size = view) => { + session.emit("Page.screencastFrame", { + sessionId: id, + data: data ?? jpeg(size.width, size.height).toString("base64"), + metadata: { deviceWidth: size.width, deviceHeight: size.height }, + }) + } + return { stream, session, page, calls, hooks, frames, logs, scope, send, delivery } +} + +afterEach(async () => { + await Promise.all(streams.splice(0).map((stream) => stream.close())) + await Promise.all(pages.splice(0).map((page) => page.close())) +}) + +describe("BrowserStream protocol lifecycle", () => { + test("keeps frame sources in data images without treating payloads as external URLs", () => { + const payload = jpeg().toString("base64") + expect(source(payload)).toBe(`data:image/jpeg;base64,${payload}`) + for (const type of ["jpeg", "png", "webp"]) { + const image = `data:image/${type};base64,${payload}` + expect(source(image)).toBe(image) + } + for (const value of ["https://public.example/image", "javascript:alert(1)"]) { + expect(source(value)).toBe(`data:image/jpeg;base64,${value}`) + } + expect(source(`data:image/svg+xml;base64,${payload}`)).toBeUndefined() + expect(source(`data:text/html;base64,${payload}`)).toBeUndefined() + }) + + test("acknowledges CDP immediately and keeps only the newest buffered frame while input is blocked", async () => { + const fixture = protocol() + await fixture.stream.configure(view) + const entered = Promise.withResolvers() + const resume = Promise.withResolvers() + fixture.hooks.set("Input.insertText", async () => { + entered.resolve() + await resume.promise + }) + const input = fixture.stream.interact({ kind: "text", text: "typing" }) + await entered.promise + try { + fixture.send(1) + fixture.send(2) + fixture.send(3) + expect(fixture.frames.map((frame) => frame.sequence)).toEqual([1]) + expect( + fixture.calls.filter((call) => call.method === "Page.screencastFrameAck").map((call) => call.params?.sessionId), + ).toEqual([1, 2, 3]) + fixture.stream.acknowledge(999) + fixture.stream.acknowledge(NaN) + expect(fixture.frames).toHaveLength(1) + fixture.stream.acknowledge(1) + expect(fixture.frames.map((frame) => frame.sequence)).toEqual([1, 3]) + fixture.send(4) + fixture.stream.acknowledge(1) + expect(fixture.frames).toHaveLength(2) + fixture.stream.acknowledge(3) + expect(fixture.frames.map((frame) => frame.sequence)).toEqual([1, 3, 4]) + } finally { + resume.resolve() + await input + } + }) + + test("keeps paste ahead of later input while reading the clipboard", async () => { + const fixture = protocol() + await fixture.stream.configure(view) + const entered = Promise.withResolvers() + const clipboard = Promise.withResolvers() + const paste = fixture.stream.interact({ kind: "clipboard", action: "paste" }, () => { + entered.resolve() + return clipboard.promise + }) + await entered.promise + const next = fixture.stream.interact({ kind: "text", text: "after" }) + clipboard.resolve("pasted") + await Promise.all([paste, next]) + expect(fixture.calls.filter((call) => call.method === "Input.insertText").map((call) => call.params?.text)).toEqual( + ["pasted", "after"], + ) + }) + + test.each(["resize", "close"])("cancels a pending clipboard read on %s", async (action) => { + const fixture = protocol() + await fixture.stream.configure(view) + const entered = Promise.withResolvers() + const clipboard = Promise.withResolvers() + const paste = fixture.stream.interact({ kind: "clipboard", action: "paste" }, () => { + entered.resolve() + return clipboard.promise + }) + await entered.promise + await (action === "close" ? fixture.stream.close() : fixture.stream.configure({ ...view, revision: 2 })) + await paste + clipboard.resolve("stale") + expect(fixture.calls.filter((call) => call.method === "Input.insertText")).toEqual([]) + }) + + test.each(["resize", "close"])("does not block %s on a pending clipboard write", async (action) => { + const fixture = protocol() + await fixture.stream.configure(view) + const entered = Promise.withResolvers() + const written = Promise.withResolvers() + const copying = fixture.stream.interact({ kind: "clipboard", action: "copy" }, undefined, () => { + entered.resolve() + return written.promise + }) + await entered.promise + const changed = action === "close" ? fixture.stream.close() : fixture.stream.configure({ ...view, revision: 2 }) + try { + await Promise.race([ + changed, + Bun.sleep(1000).then(() => { + throw new Error("Clipboard write prevented stream cleanup") + }), + ]) + expect(await copying).toBeUndefined() + } finally { + written.resolve() + await Promise.allSettled([copying, changed]) + } + }) + + test("validates clipboard results before calling the host writer", async () => { + const fixture = protocol() + await fixture.stream.configure(view) + const values: string[] = [] + for (const value of ["x".repeat(65537), 42]) { + fixture.page.evaluate = async () => ({ focused: true, text: value as unknown as string }) + await expect( + fixture.stream.interact({ kind: "clipboard", action: "copy" }, undefined, (text) => { + values.push(text) + }), + ).rejects.toThrow("text limit") + } + expect(values).toEqual([]) + }) + + test("coalesces queued wheel input without crossing keys or direction changes", async () => { + const fixture = protocol() + await fixture.stream.configure(view) + const entered = Promise.withResolvers() + const resume = Promise.withResolvers() + fixture.hooks.set("Input.insertText", async () => { + entered.resolve() + await resume.promise + }) + const first = fixture.stream.interact({ kind: "text", text: "first" }) + await entered.promise + const wheel = { kind: "wheel", x: 0.8, y: 0.8, modifiers: 0, deltaX: 0, deltaY: 2 } as const + const inputs = Array.from({ length: 120 }, () => fixture.stream.interact(wheel)) + inputs.push( + fixture.stream.interact({ + kind: "key", + action: "down", + key: "Tab", + code: "Tab", + keyCode: 9, + modifiers: 0, + repeat: false, + }), + fixture.stream.interact({ ...wheel, deltaY: -4 }), + fixture.stream.interact({ ...wheel, deltaY: 6 }), + fixture.stream.interact({ ...wheel, deltaY: 5, modifiers: 2 }), + fixture.stream.interact({ ...wheel, deltaY: 10000 }), + fixture.stream.interact({ ...wheel, deltaY: 1 }), + ) + resume.resolve() + await Promise.all([first, ...inputs]) + const calls = fixture.calls.filter((call) => call.method.startsWith("Input.")) + expect(calls.map((call) => call.params?.deltaY ?? call.params?.key ?? call.params?.text)).toEqual([ + "first", + 240, + "Tab", + -4, + 6, + 5, + 10000, + 1, + ]) + }) + + test("drops old-sized JPEGs even when resize metadata is current", async () => { + const fixture = protocol() + await fixture.stream.configure(view) + fixture.send(1, jpeg(view.width, 5).toString("base64")) + fixture.send(2, Buffer.from("invalid image").toString("base64")) + expect(fixture.frames).toEqual([]) + fixture.send(3) + expect(fixture.frames).toHaveLength(1) + expect(fixture.frames.at(0)).toMatchObject({ width: view.width, height: view.height }) + }) + + test("drops oversized frames including a one-byte overflow with the same base64 length", async () => { + const fixture = protocol() + await fixture.stream.configure(view) + fixture.send(1, jpeg(view.width, view.height, 2 * 1024 * 1024).toString("base64")) + fixture.send(2, jpeg(view.width, view.height, 2 * 1024 * 1024 + 1).toString("base64")) + fixture.send(3, jpeg(view.width, view.height, 2 * 1024 * 1024 + 8).toString("base64")) + expect(fixture.frames).toHaveLength(1) + expect(Buffer.byteLength(fixture.frames.at(0)!.data, "base64")).toBe(2 * 1024 * 1024) + fixture.stream.acknowledge(1) + expect(fixture.frames).toHaveLength(1) + expect(fixture.calls.filter((call) => call.method === "Page.screencastFrameAck")).toHaveLength(3) + expect(fixture.logs).toEqual([]) + }) + + test("discards queued frames and stale acknowledgements on identity and revision changes", async () => { + const fixture = protocol() + await fixture.stream.configure(view) + fixture.send(1) + fixture.send(2) + fixture.scope.navigation++ + fixture.stream.acknowledge(1) + expect(fixture.frames).toHaveLength(1) + fixture.send(3) + fixture.send(4) + fixture.scope.browserId = "replacement" + fixture.stream.acknowledge(3) + expect(fixture.frames).toHaveLength(2) + fixture.send(5) + fixture.send(6) + await fixture.stream.configure({ ...view, revision: 2 }) + fixture.stream.acknowledge(5) + expect(fixture.frames).toHaveLength(3) + fixture.send(7) + fixture.send(8, undefined, { ...view, width: 320 }) + expect( + fixture.frames.map(({ browserId, navigation, revision, sequence }) => ({ + browserId, + navigation, + revision, + sequence, + })), + ).toEqual([ + { browserId: "browser", navigation: 1, revision: 1, sequence: 1 }, + { browserId: "browser", navigation: 2, revision: 1, sequence: 3 }, + { browserId: "replacement", navigation: 2, revision: 1, sequence: 5 }, + { browserId: "replacement", navigation: 2, revision: 2, sequence: 7 }, + ]) + }) + + test("allows only suspension at the current revision, including during an asynchronous start", async () => { + const fixture = protocol() + const entered = Promise.withResolvers() + const resume = Promise.withResolvers() + fixture.hooks.set("Page.startScreencast", async () => { + entered.resolve() + await resume.promise + }) + const configuring = fixture.stream.configure(view) + await entered.promise + fixture.send(1) + fixture.send(2) + const paused = fixture.stream.configure({ ...view, width: 320, height: 240, active: false }) + fixture.send(3) + resume.resolve() + await Promise.all([configuring, paused]) + expect(fixture.calls.filter((call) => call.method === "Page.stopScreencast")).toHaveLength(1) + expect(fixture.calls.filter((call) => call.method === "resize").map((call) => call.params)).toEqual([ + { width: view.width, height: view.height }, + ]) + fixture.stream.acknowledge(1) + expect(fixture.frames.map((frame) => frame.sequence)).toEqual([1]) + const count = fixture.calls.length + for (const revision of [0, 1]) { + for (const active of [false, true]) { + await fixture.stream.configure({ ...view, width: 320, height: 240, revision, active }) + } + } + await fixture.stream.interact({ kind: "text", text: "ignored while suspended" }) + expect(fixture.calls).toHaveLength(count) + const next = { ...view, width: 800, height: 600, revision: 2 } + await fixture.stream.configure(next) + fixture.send(4, undefined, next) + const frame = fixture.frames.at(-1) + expect([frame?.revision, frame?.width, frame?.height]).toEqual([2, 800, 600]) + expect(fixture.calls.filter((call) => call.method === "Page.startScreencast")).toHaveLength(2) + await fixture.stream.configure({ ...view, active: false }) + expect(fixture.calls.filter((call) => call.method === "Page.stopScreencast")).toHaveLength(1) + }) + + test.each(["connect", "Page.startScreencast", "Page.stopScreencast"])("closes safely during %s", async (method) => { + const fixture = protocol() + if (method === "Page.stopScreencast") await fixture.stream.configure(view) + const entered = Promise.withResolvers() + const resume = Promise.withResolvers() + fixture.hooks.set(method, async () => { + entered.resolve() + await resume.promise + }) + const configuring = fixture.stream.configure({ ...view, revision: 2, active: method !== "Page.stopScreencast" }) + await entered.promise + const closing = fixture.stream.close() + fixture.send(1) + resume.resolve() + await Promise.all([configuring, closing, fixture.stream.close()]) + expect(fixture.frames).toHaveLength(0) + expect(fixture.calls.filter((call) => call.method === "detach")).toHaveLength(1) + expect(fixture.session.listenerCount("Page.screencastFrame")).toBe(0) + expect(fixture.page.listenerCount("close")).toBe(0) + expect(fixture.page.listenerCount("framenavigated")).toBe(0) + if (method === "Page.startScreencast") { + expect(fixture.calls.map((call) => call.method).slice(-2)).toEqual(["Page.stopScreencast", "detach"]) + } + const count = fixture.calls.length + await fixture.stream.configure({ ...view, revision: 99 }) + await fixture.stream.interact({ kind: "text", text: "ignored" }) + fixture.send(2) + expect(fixture.calls).toHaveLength(count) + }) + + test("handles a rejected start after close and still detaches", async () => { + const fixture = protocol() + const entered = Promise.withResolvers() + const resume = Promise.withResolvers() + fixture.hooks.set("Page.startScreencast", async () => { + entered.resolve() + await resume.promise + throw new Error("Target closed during start") + }) + const configuring = fixture.stream.configure(view) + await entered.promise + const closing = fixture.stream.close() + resume.resolve() + await Promise.all([configuring, closing]) + expect(fixture.calls.map((call) => call.method).slice(-2)).toEqual(["Page.stopScreencast", "detach"]) + expect(fixture.session.listenerCount("Page.screencastFrame")).toBe(0) + }) + + test("drops superseded input and bounds failure logs without frame or text contents", async () => { + const fixture = protocol() + await fixture.stream.configure(view) + const entered = Promise.withResolvers() + const resume = Promise.withResolvers() + fixture.hooks.set("Input.insertText", async () => { + entered.resolve() + await resume.promise + }) + const first = fixture.stream.interact({ kind: "text", text: "first" }) + await entered.promise + const stale = fixture.stream.interact({ kind: "text", text: "stale" }) + fixture.scope.navigation++ + fixture.page.emit("framenavigated", fixture.page) + resume.resolve() + await Promise.all([first, stale]) + expect(fixture.calls.filter((call) => call.method === "Input.insertText").map((call) => call.params?.text)).toEqual( + ["first"], + ) + const secret = Buffer.concat([jpeg(), Buffer.from("private frame pixels")]).toString("base64") + fixture.delivery.fail = true + fixture.send(1, secret) + fixture.delivery.fail = false + fixture.send(2) + expect(fixture.frames).toHaveLength(1) + fixture.hooks.set("Page.screencastFrameAck", async () => { + throw new Error(secret) + }) + const text = "private input text".repeat(10000) + fixture.hooks.set("detach", async () => { + throw new Error(text) + }) + fixture.send(3, secret) + await fixture.stream.close() + expect(JSON.stringify(fixture.logs)).not.toContain(secret) + expect(JSON.stringify(fixture.logs)).not.toContain("private input text") + expect(fixture.logs.flat().every((value) => typeof value === "string" && value.length <= 80)).toBe(true) + }) +}) + +describe.skipIf(!executable)("BrowserStream Chromium", () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch({ + executablePath: executable, + headless: true, + chromiumSandbox: true, + args: ["--force-device-scale-factor=2"], + }) + }, 20000) + + afterAll(async () => { + await browser?.close() + }) + + async function fixture() { + const page = await browser.newPage({ viewport: { width: 640, height: 480 }, deviceScaleFactor: 2 }) + pages.push(page) + await page.setContent(` + + + + + +
editable text
+
first protected last
+

plain text

+ `) + await page.evaluate(() => { + const events: Record[] = [] + for (const type of [ + "mousedown", + "mouseup", + "mousemove", + "wheel", + "keydown", + "keyup", + "input", + "compositionstart", + "compositionend", + ]) { + document.addEventListener(type, (event) => { + const key = event as KeyboardEvent + const mouse = event as MouseEvent + events.push({ + type, + key: key.key, + code: key.code, + keyCode: key.keyCode, + ctrl: key.ctrlKey, + shift: key.shiftKey, + buttons: mouse.buttons, + x: mouse.clientX, + y: mouse.clientY, + }) + document.body.dataset.events = JSON.stringify(events) + }) + } + }) + const frames: BrowserFrame[] = [] + const events = new EventEmitter() + const logs: unknown[][] = [] + const scope = { browserId: "existing-page", navigation: 1 } + const stream = new BrowserStream( + page, + () => scope, + (frame) => { + frames.push(frame) + events.emit("frame", frame) + }, + (...args) => logs.push(args), + ) + streams.push(stream) + const next = (predicate: (frame: BrowserFrame) => boolean = () => true) => { + const found = frames.find(predicate) + if (found) return Promise.resolve(found) + const result = Promise.withResolvers() + const listener = (frame: BrowserFrame) => { + if (!predicate(frame)) return + events.off("frame", listener) + result.resolve(frame) + } + events.on("frame", listener) + return result.promise + } + const recorded = () => + page.evaluate(() => JSON.parse(document.body.dataset.events ?? "[]") as Record[]) + return { page, stream, frames, next, recorded, logs, scope } + } + + async function select(page: Page, selector: string, start = 0, end?: number) { + await page.locator(selector).evaluate( + (element, range) => { + const node = element as HTMLInputElement + node.focus() + node.setSelectionRange(range.start, range.end ?? node.value.length) + }, + { start, end }, + ) + } + + async function dom(page: Page, selector: string) { + await page.locator(selector).evaluate((element) => { + ;(element as HTMLElement).focus() + document.getSelection()?.selectAllChildren(element) + }) + } + + test("streams bounded JPEGs from the existing page and honors monotonic viewport revisions", async () => { + const { stream, page, next, frames } = await fixture() + await stream.configure({ ...view, width: 1, height: 9999, revision: 0, active: false }) + expect(page.viewportSize()).toEqual({ width: 32, height: 2160 }) + await stream.configure({ ...view, width: 9999, height: 1, active: false }) + expect(page.viewportSize()).toEqual({ width: 4096, height: 32 }) + await stream.configure({ ...view, revision: 0 }) + await stream.configure(view) + expect(page.viewportSize()).toEqual({ width: 4096, height: 32 }) + expect(frames).toHaveLength(0) + await stream.configure({ ...view, width: 640.4, height: 480.2, revision: 2 }) + const frame = await next() + expect([frame.browserId, frame.navigation, frame.revision, frame.width, frame.height]).toEqual([ + "existing-page", + 1, + 2, + 640, + 480, + ]) + const bytes = Buffer.from(frame.data, "base64") + expect(bytes.byteLength).toBeLessThanOrEqual(2 * 1024 * 1024) + expect([...bytes.subarray(0, 2)]).toEqual([255, 216]) + expect( + await page.evaluate(async (data) => { + const image = new Image() + image.src = `data:image/jpeg;base64,${data}` + await image.decode() + return [image.naturalWidth, image.naturalHeight] + }, frame.data), + ).toEqual([640, 480]) + expect(page.context().pages()).toHaveLength(1) + await stream.configure({ ...view, revision: 3, active: false }) + const count = frames.length + stream.acknowledge(frame.sequence) + await stream.interact({ kind: "text", text: "inactive" }) + await page.screenshot() + expect(frames).toHaveLength(count) + await stream.configure({ ...view, revision: 4 }) + const resumed = await next((frame) => frame.revision === 4) + expect(resumed.sequence).toBeGreaterThan(frame.sequence) + await stream.close() + expect(page.isClosed()).toBe(false) + expect(await page.locator("#input").inputValue()).toBe("") + }, 10000) + + test("streams Retina pixels while keeping input and scrolling in CSS coordinates", async () => { + const { stream, page, next } = await fixture() + await stream.configure({ ...view, width: 320, height: 240, scale: 2 }) + const frame = await next() + expect([frame.width, frame.height]).toEqual([640, 480]) + expect( + await page.evaluate(async (data) => { + const image = new Image() + image.src = `data:image/jpeg;base64,${data}` + await image.decode() + return [image.naturalWidth, image.naturalHeight, innerWidth, innerHeight, devicePixelRatio] + }, frame.data), + ).toEqual([640, 480, 320, 240, 2]) + const point = { kind: "pointer", x: 60 / 320, y: 35 / 240, button: "left", clicks: 1, modifiers: 0 } as const + await stream.interact({ ...point, action: "down", buttons: 1 }) + await stream.interact({ ...point, action: "up", buttons: 0 }) + await stream.interact({ kind: "text", text: "Retina input" }) + expect(await page.locator("#input").inputValue()).toBe("Retina input") + await stream.interact({ kind: "wheel", x: 0.9, y: 0.9, deltaX: 0, deltaY: 200, modifiers: 0 }) + await page.waitForFunction(() => scrollY === 200) + await stream.configure({ ...view, width: 384, height: 256, scale: 1.5, revision: 2 }) + const resized = await next((frame) => frame.revision === 2) + expect([resized.width, resized.height]).toEqual([576, 384]) + expect(await page.evaluate(() => [innerWidth, innerHeight, scrollY])).toEqual([384, 256, 200]) + }, 10000) + + test("continues streaming on the same page after navigation without an old UI acknowledgement", async () => { + const { stream, page, next, scope } = await fixture() + await stream.configure(view) + const first = await next() + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) scope.navigation++ + }) + await page.goto( + `data:text/html,${encodeURIComponent('Stream navigationNew page')}`, + ) + const current = await next((frame) => frame.navigation === 2) + expect(current.sequence).toBeGreaterThan(first.sequence) + expect(current.browserId).toBe(first.browserId) + expect(current.revision).toBe(first.revision) + expect(await page.title()).toBe("Stream navigation") + stream.acknowledge(first.sequence) + expect(page.context().pages()).toHaveLength(1) + }, 10000) + + test("dispatches pointer, raw keys, text and wheel in order and releases held input", async () => { + const { stream, page, recorded } = await fixture() + await stream.configure(view) + const pointer = { + kind: "pointer", + x: 60 / 640, + y: 35 / 480, + button: "left", + buttons: 1, + clicks: 1, + modifiers: 0, + } as const + await stream.interact({ ...pointer, action: "down" }) + await stream.interact({ ...pointer, action: "up", buttons: 0 }) + expect(await page.evaluate(() => document.activeElement?.id)).toBe("input") + const key = { kind: "key", key: "a", code: "KeyA", keyCode: 65, modifiers: 0, repeat: false } as const + await Promise.all([ + stream.interact({ ...key, action: "down" }), + stream.interact({ kind: "text", text: "a" }), + stream.interact({ ...key, action: "up" }), + ]) + expect(await page.locator("#input").inputValue()).toBe("a") + expect( + (await recorded()) + .filter((event) => ["keydown", "input", "keyup"].includes(String(event.type))) + .map((event) => event.type), + ).toEqual(["keydown", "input", "keyup"]) + expect((await recorded()).find((event) => event.type === "keydown")).toMatchObject({ + key: "a", + code: "KeyA", + keyCode: 65, + }) + await stream.interact({ ...key, action: "down", key: "Backspace", code: "Backspace", keyCode: 8 }) + await stream.interact({ ...key, action: "up", key: "Backspace", code: "Backspace", keyCode: 8 }) + expect(await page.locator("#input").inputValue()).toBe("") + await stream.interact({ ...key, action: "down", key: "Control", code: "ControlLeft", keyCode: 17, modifiers: 2 }) + await stream.interact({ ...pointer, action: "down", modifiers: 10 }) + await stream.interact({ ...pointer, action: "move", buttons: 0, modifiers: 10 }) + await stream.interact({ kind: "release" }) + const events = await recorded() + expect(events.filter((event) => event.type === "mouseup").at(-1)?.buttons).toBe(0) + expect(events.filter((event) => event.type === "keyup").map((event) => event.key)).toContain("Control") + expect(events.filter((event) => event.type === "keyup").at(-1)).toMatchObject({ + key: "Shift", + ctrl: false, + shift: false, + }) + await stream.interact({ kind: "wheel", x: 0.8, y: 0.8, deltaX: 0, deltaY: 300, modifiers: 0 }) + await page.waitForFunction(() => window.scrollY > 0) + }, 10000) + + test.each(["keydown", "keypress"])("respects cancelled printable %s events", async (type) => { + const { stream, page } = await fixture() + await stream.configure(view) + await page.locator("#input").focus() + const key = { kind: "key", key: "a", code: "KeyA", keyCode: 65, modifiers: 0, repeat: false } as const + const press = () => + Promise.all([stream.interact({ ...key, action: "down", text: "a" }), stream.interact({ ...key, action: "up" })]) + await press() + expect(await page.locator("#input").inputValue()).toBe("a") + await page.evaluate((type) => { + document.addEventListener(type, (event) => event.preventDefault()) + }, type) + await press() + expect(await page.locator("#input").inputValue()).toBe("a") + }) + + test("types shifted and Unicode characters through native key events", async () => { + const { stream, page } = await fixture() + await stream.configure(view) + await page.locator("#input").focus() + for (const key of [ + { key: "A", code: "KeyA", keyCode: 65, modifiers: 8 }, + { key: "é", code: "KeyE", keyCode: 69, modifiers: 1 }, + { key: "€", code: "KeyE", keyCode: 69, modifiers: 0 }, + { key: "𠮷", code: "", keyCode: 0, modifiers: 0 }, + ]) { + await stream.interact({ kind: "key", action: "down", ...key, text: key.key, repeat: false }) + await stream.interact({ kind: "key", action: "up", ...key, repeat: false }) + } + expect(await page.locator("#input").inputValue()).toBe("Aé€𠮷") + }) + + test("handles Enter and select-all without bypassing page keyboard handlers", async () => { + const { stream, page } = await fixture() + await stream.configure(view) + await page.locator("#area").fill("first") + await select(page, "#area", 5, 5) + const enter = { kind: "key", key: "Enter", code: "Enter", keyCode: 13, modifiers: 0, repeat: false } as const + await stream.interact({ ...enter, action: "down" }) + await stream.interact({ ...enter, action: "up" }) + expect(await page.locator("#area").inputValue()).toBe("first\n") + const all = { ...enter, key: "a", code: "KeyA", keyCode: 65, modifiers: process.platform === "darwin" ? 4 : 2 } + await stream.interact({ ...all, action: "down" }) + await stream.interact({ ...all, action: "up" }) + expect(await stream.interact({ kind: "clipboard", action: "copy" })).toBe("first\n") + await page.evaluate(() => { + document.addEventListener("keydown", (event) => { + if (event.key === "Enter") event.preventDefault() + }) + }) + await stream.interact({ ...enter, action: "down" }) + await stream.interact({ ...enter, action: "up" }) + expect(await page.locator("#area").inputValue()).toBe("first\n") + }) + + test.each(["inactive", "suspended", "closed"])("releases held keys and buttons when %s", async (state) => { + const { stream, page, recorded } = await fixture() + await stream.configure(view) + await stream.interact({ + kind: "key", + action: "down", + key: "Shift", + code: "ShiftRight", + keyCode: 16, + modifiers: 8, + repeat: false, + }) + await stream.interact({ + kind: "pointer", + action: "down", + x: 0.1, + y: 0.1, + button: "left", + buttons: 1, + clicks: 1, + modifiers: 8, + }) + if (state === "inactive") await stream.configure({ ...view, active: false, revision: 2 }) + if (state === "suspended") { + await stream.configure({ ...view, width: 800, height: 600, active: false }) + expect(page.viewportSize()).toEqual({ width: view.width, height: view.height }) + } + if (state === "closed") await stream.close() + const events = await recorded() + expect(events.filter((event) => event.type === "mouseup").at(-1)?.buttons).toBe(0) + expect(events.filter((event) => event.type === "keyup").at(-1)).toMatchObject({ + key: "Shift", + code: "ShiftRight", + shift: false, + }) + expect(page.isClosed()).toBe(false) + }) + + test("updates, commits and cancels IME composition without duplicate text", async () => { + const { stream, page, recorded } = await fixture() + await stream.configure(view) + await page.locator("#input").focus() + await stream.interact({ kind: "composition", text: "に", start: 1, end: 1 }) + expect(await page.locator("#input").inputValue()).toBe("に") + await stream.interact({ kind: "composition", text: "日本", start: 2, end: 2 }) + await stream.interact({ kind: "text", text: "日本" }) + expect(await page.locator("#input").inputValue()).toBe("日本") + await stream.interact({ kind: "composition", text: "語", start: 1, end: 1 }) + await stream.interact({ kind: "release" }) + expect(await page.locator("#input").inputValue()).toBe("日本") + expect((await recorded()).filter((event) => event.type === "compositionend")).toHaveLength(2) + }) + + test("honors cancelled and custom paste handlers before default text insertion", async () => { + const { stream, page } = await fixture() + await stream.configure(view) + await page.locator("#input").evaluate((element) => { + const node = element as HTMLInputElement + node.addEventListener("paste", (event) => { + const value = event.clipboardData?.getData("text/plain") + node.dataset.pasted = value + if (node.dataset.mode === "default") return + event.preventDefault() + if (node.dataset.mode === "custom") node.value = `custom:${value}` + }) + }) + for (const mode of ["cancel", "custom", "default"]) { + await page.locator("#input").fill("") + await page.locator("#input").evaluate((node, value) => ((node as HTMLElement).dataset.mode = value), mode) + await stream.interact({ kind: "clipboard", action: "paste" }, async () => "pasted") + expect(await page.locator("#input").getAttribute("data-pasted")).toBe("pasted") + expect(await page.locator("#input").inputValue()).toBe( + mode === "cancel" ? "" : mode === "custom" ? "custom:pasted" : "pasted", + ) + } + }) + + test.each(["copy", "cut"] as const)("honors cancelled and custom %s handlers", async (action) => { + const { stream, page } = await fixture() + await stream.configure(view) + await page.locator("#input").fill("original") + await select(page, "#input") + await page.locator("#input").evaluate((node, type) => { + node.addEventListener(type, (event) => { + event.preventDefault() + if ((node as HTMLElement).dataset.custom) + (event as ClipboardEvent).clipboardData?.setData("text/plain", "custom") + }) + }, action) + expect(await stream.interact({ kind: "clipboard", action })).toBeUndefined() + await page.locator("#input").evaluate((node) => ((node as HTMLElement).dataset.custom = "true")) + expect(await stream.interact({ kind: "clipboard", action })).toBe("custom") + expect(await page.locator("#input").inputValue()).toBe("original") + }) + + test("keeps password exclusion after a clipboard handler changes the field type", async () => { + const { stream, page } = await fixture() + await stream.configure(view) + await page.locator("#input").fill("private value") + await page.locator("#input").evaluate((element) => { + const node = element as HTMLInputElement + for (const type of ["copy", "cut"]) node.addEventListener(type, () => (node.type = "password")) + }) + for (const action of ["copy", "cut"] as const) { + await page.locator("#input").evaluate((node) => ((node as HTMLInputElement).type = "text")) + await select(page, "#input") + expect(await stream.interact({ kind: "clipboard", action })).toBeUndefined() + expect(await page.locator("#input").inputValue()).toBe("private value") + } + }) + + test("copies and cuts only eligible selections without reading the OS clipboard or passwords", async () => { + const { stream, page } = await fixture() + await stream.configure(view) + await page.locator("#input").fill("hello world") + await select(page, "#input", 1, 4) + expect(await stream.interact({ kind: "clipboard", action: "copy" })).toBe("ell") + expect(await page.locator("#input").inputValue()).toBe("hello world") + expect(await stream.interact({ kind: "clipboard", action: "cut" })).toBe("ell") + expect(await page.locator("#input").inputValue()).toBe("ho world") + await select(page, "#area", 0, 3) + expect(await stream.interact({ kind: "clipboard", action: "cut" })).toBe("one") + expect(await page.locator("#area").inputValue()).toBe(" two") + await select(page, "#readonly", 0, 4) + expect(await stream.interact({ kind: "clipboard", action: "cut" })).toBe("read") + expect(await page.locator("#readonly").inputValue()).toBe("read only") + await select(page, "#password") + expect(await stream.interact({ kind: "clipboard", action: "copy" })).toBeUndefined() + expect(await stream.interact({ kind: "clipboard", action: "cut" })).toBeUndefined() + expect(await page.locator("#password").inputValue()).toBe("test-password") + await dom(page, "#plain") + expect(await stream.interact({ kind: "clipboard", action: "cut" })).toBe("plain text") + expect(await page.locator("#plain").textContent()).toBe("plain text") + await dom(page, "#protected") + expect(await stream.interact({ kind: "clipboard", action: "cut" })).toBe("first protected last") + expect(await page.locator("#protected").textContent()).toBe("first protected last") + await dom(page, "#editable") + expect(await stream.interact({ kind: "clipboard", action: "cut" })).toBe("editable text") + expect(await page.locator("#editable").textContent()).toBe("") + await page.evaluate(() => { + Object.defineProperty(navigator, "clipboard", { + get: () => { + throw new Error("Clipboard must not be read") + }, + }) + }) + await expect(stream.interact({ kind: "clipboard", action: "paste" })).rejects.toThrow("Clipboard access") + await page.locator("#input").fill("x".repeat(65537)) + await select(page, "#input") + await expect(stream.interact({ kind: "clipboard", action: "cut" })).rejects.toThrow("text limit") + expect((await page.locator("#input").inputValue()).length).toBe(65537) + }, 10000) + + test("uses the focused iframe and shadow input for clipboard events", async () => { + const { stream, page } = await fixture() + await page.setContent( + '', + ) + await stream.configure(view) + const child = page.frameLocator('iframe[name="focused"]') + await child.locator("#host").evaluate((node) => { + const root = node.attachShadow({ mode: "open" }) + root.innerHTML = '' + const input = root.querySelector("input")! + input.focus() + input.setSelectionRange(0, 5) + }) + expect(await stream.interact({ kind: "clipboard", action: "copy" })).toBe("frame") + expect(await stream.interact({ kind: "clipboard", action: "cut" })).toBe("frame") + expect(await child.locator("input").inputValue()).toBe(" selection") + await child.locator("input").evaluate((node) => { + node.addEventListener("paste", (event) => { + event.preventDefault() + node.setAttribute("data-pasted", (event as ClipboardEvent).clipboardData?.getData("text/plain") ?? "") + }) + }) + await stream.interact({ kind: "clipboard", action: "paste" }, async () => "into frame") + expect(await child.locator("input").getAttribute("data-pasted")).toBe("into frame") + expect(await child.locator("input").inputValue()).toBe(" selection") + }) + + test("rejects invalid viewports, coordinates, ranges and text lengths before dispatch", async () => { + const { stream, page, recorded } = await fixture() + await stream.configure(view) + for (const change of [ + { width: NaN }, + { height: Infinity }, + { width: 0 }, + { height: -1 }, + { scale: NaN }, + { scale: Infinity }, + { scale: 0 }, + { revision: -1 }, + { revision: 1.5 }, + { active: "yes" }, + ]) { + await expect(stream.configure({ ...view, revision: 2, ...change } as typeof view)).rejects.toThrow( + "Invalid browser viewport", + ) + } + const pointer = { kind: "pointer", action: "move", x: 0, y: 0, button: "left", buttons: 0, clicks: 0, modifiers: 0 } + const invalid = [ + null, + { kind: "unknown" }, + ...[ + { x: NaN }, + { y: Infinity }, + { x: -0.1 }, + { y: 1.1 }, + { buttons: 8 }, + { clicks: 4 }, + { modifiers: 16 }, + { button: "unknown" }, + ].map((change) => ({ ...pointer, ...change })), + { kind: "wheel", x: 0, y: 0, deltaX: 0, deltaY: 10001, modifiers: 0 }, + { kind: "key", action: "down", key: "a", code: "KeyA", keyCode: 256, modifiers: 0, repeat: false }, + { kind: "key", action: "down", key: "a", code: "KeyA", keyCode: 65, modifiers: 0, repeat: "yes" }, + { kind: "key", action: "down", key: "a", code: "KeyA", keyCode: 65, modifiers: 0, repeat: false, text: 1 }, + { + kind: "key", + action: "down", + key: "a", + code: "KeyA", + keyCode: 65, + modifiers: 0, + repeat: false, + text: "a".repeat(65), + }, + { kind: "text", text: "x".repeat(65537) }, + { kind: "composition", text: "ab", start: 2, end: 1 }, + { kind: "composition", text: "ab", start: 0, end: 3 }, + { kind: "clipboard", action: "read" }, + ] + for (const input of invalid) { + await expect(stream.interact(input as BrowserInteraction)).rejects.toThrow("Invalid browser interaction") + } + expect(await recorded()).toEqual([]) + expect(page.viewportSize()).toEqual({ width: 640, height: 480 }) + await stream.interact({ ...pointer, x: 1, y: 1 } as BrowserInteraction) + expect((await recorded()).at(-1)).toMatchObject({ type: "mousemove", x: 639, y: 479 }) + await stream.close() + await stream.interact({ kind: "text", text: "closed" }) + expect(await page.locator("#input").inputValue()).toBe("") + }, 10000) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx index d1ca0495ce36..4facf9403cd4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx @@ -72,6 +72,8 @@ function command(command: BrowserCommand): WebviewMessage { return { type: "agentManager.browser.open", ...command.scope, url: command.url } } if (command.type === "refresh") return { type: "agentManager.browser.refresh", ...command.scope } + if (command.type === "back") return { type: "agentManager.browser.back", ...command.scope } + if (command.type === "forward") return { type: "agentManager.browser.forward", ...command.scope } if (command.type === "close") return { type: "agentManager.browser.close", ...command.scope } if (command.type === "state") return { type: "agentManager.browser.state", ...command.scope } if (command.type === "devtools") { @@ -80,6 +82,26 @@ function command(command: BrowserCommand): WebviewMessage { if (command.type === "input") { return { type: "agentManager.browser.input", ...command.scope, ...command.position, click: command.click } } + if (command.type === "viewport") { + return { + type: "agentManager.browser.viewport", + ...command.scope, + browserId: command.browserId, + navigation: command.navigation, + viewport: command.viewport, + } + } + if (command.type === "interact") { + return { type: "agentManager.browser.interact", ...command.scope, identity: command.identity, event: command.event } + } + if (command.type === "acknowledge") { + return { + type: "agentManager.browser.acknowledge", + ...command.scope, + identity: command.identity, + sequence: command.sequence, + } + } return { type: "agentManager.browser.inspect", ...command.scope, @@ -90,6 +112,9 @@ function command(command: BrowserCommand): WebviewMessage { } function event(message: ExtensionMessage): BrowserEvent | undefined { + if (message.type === "agentManager.browserFrame") { + return { type: "frame", value: { ...message, scope: scope(message.sessionId, message.projectId) } } + } if (message.type === "agentManager.browserState") { const value: BrowserState = { scope: scope(message.sessionId, message.projectId), @@ -102,7 +127,10 @@ function event(message: ExtensionMessage): BrowserEvent | undefined { errors: message.errors, logs: message.logs, error: message.error, + missing: message.missing, frameError: message.frameError, + back: message.back, + forward: message.forward, } return { type: "state", value } } @@ -151,12 +179,21 @@ function BrowserAdapter(props: { urlPlaceholder: language.t("agentManager.browser.urlPlaceholder"), open: language.t("agentManager.browser.open"), refresh: language.t("agentManager.browser.refresh"), + back: language.t("agentManager.browser.back"), + forward: language.t("agentManager.browser.forward"), close: language.t("agentManager.browser.close"), inspect: language.t("agentManager.browser.inspect"), devtoolsTitle: language.t("agentManager.browser.devtoolsTitle"), diagnostics: language.t("agentManager.browser.diagnostics"), diagnosticsHint: language.t("agentManager.browser.diagnosticsHint"), empty: language.t("agentManager.browser.empty"), + requirement: language.t("agentManager.browser.requirement"), + missingTitle: language.t("agentManager.browser.missingTitle"), + missingChrome: language.t("agentManager.browser.missingChrome"), + missingChromium: language.t("agentManager.browser.missingChromium"), + download: language.t("agentManager.browser.downloadChrome"), + retry: language.t("common.retry"), + settings: language.t("agentManager.browser.settings"), noSession: language.t("agentManager.browser.noSession"), screenshotAlt: language.t("agentManager.browser.screenshotAlt"), errors: (count: number) => language.t("agentManager.browser.errors", { count }), @@ -177,6 +214,8 @@ function BrowserAdapter(props: { transport={transport} labels={labels()} theme={theme} + download={() => vscode.postMessage({ type: "openExternal", url: "https://www.google.com/chrome/" })} + settings={() => vscode.postMessage({ type: "openSettingsPanel", tab: "browser", projectId: props.projectId() })} onReference={reference} onClose={props.onClose} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 9b609fc3d662..3d9f4399ba43 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -441,15 +441,26 @@ export const dict = { "agentManager.caffeination.active": "يتم إبقاء الكمبيوتر مستيقظًا أثناء عمل وكلاء Kilo", "agentManager.caffeination.unavailable": "وضع إبقاء الكمبيوتر مستيقظًا غير متاح على هذا النظام الأساسي", "agentManager.browser.title": "المتصفح المدمج", - "agentManager.browser.url": "URL التطبيق المحلي", + "agentManager.browser.url": "العنوان", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "فتح", "agentManager.browser.refresh": "تحديث المتصفح", + "agentManager.browser.back": "رجوع", + "agentManager.browser.forward": "تقدّم", "agentManager.browser.close": "إغلاق المتصفح", "agentManager.browser.inspect": "تحديد عنصر", "agentManager.browser.devtoolsTitle": "أدوات المطوّرين", - "agentManager.browser.empty": "افتح تطبيقًا محليًا لمعاينته هنا.", - "agentManager.browser.noSession": "ابدأ جلسة أو اختر جلسة في Agent Manager لاستعراض تطبيق محلي.", + "agentManager.browser.empty": "افتح تطبيقًا محليًا أو صفحة HTTPS عامة لعرض معاينة هنا.", + "agentManager.browser.requirement": + "يتطلب Google Chrome أو متصفح Playwright Chromium متوافقًا. لا تُثبَّت المتصفحات تلقائيًا.", + "agentManager.browser.missingTitle": "لم يتم العثور على المتصفح", + "agentManager.browser.missingChrome": + "لم يتم العثور على Google Chrome. ثبّت Chrome لاستخدام المتصفح المدمج، ثم أعد المحاولة.", + "agentManager.browser.missingChromium": + "لم يتم العثور على Playwright Chromium. ثبّت متصفحًا متوافقًا أو فعّل «استخدام Chrome النظام» في «إعدادات المتصفح».", + "agentManager.browser.downloadChrome": "تنزيل Chrome", + "agentManager.browser.settings": "إعدادات المتصفح", + "agentManager.browser.noSession": "ابدأ جلسة أو اختر جلسة في Agent Manager.", "agentManager.browser.screenshotAlt": "صفحة المتصفح الحالية", "agentManager.browser.errors": "مشكلات المتصفح: {{count}}", "agentManager.browser.diagnostics": "تشخيص المتصفح", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 9c768dbbfefd..fa5d42c8350a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -450,16 +450,26 @@ export const dict = { "agentManager.caffeination.unavailable": "O modo de manter o computador acordado não está disponível nesta plataforma", "agentManager.browser.title": "Navegador Integrado", - "agentManager.browser.url": "URL da aplicação local", + "agentManager.browser.url": "Endereço", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Abrir", "agentManager.browser.inspect": "Selecionar elemento", "agentManager.browser.devtoolsTitle": "Ferramentas de desenvolvedor", "agentManager.browser.refresh": "Atualizar navegador", + "agentManager.browser.back": "Voltar", + "agentManager.browser.forward": "Avançar", "agentManager.browser.close": "Fechar navegador", - "agentManager.browser.empty": "Abra uma aplicação local para visualizá-la aqui.", - "agentManager.browser.noSession": - "Inicie ou selecione uma sessão no Agent Manager para navegar em um aplicativo local.", + "agentManager.browser.empty": "Abra uma aplicação local ou uma página HTTPS pública para visualizá-la aqui.", + "agentManager.browser.requirement": + "Requer Google Chrome ou um navegador Playwright Chromium compatível. Os navegadores não são instalados automaticamente.", + "agentManager.browser.missingTitle": "Navegador não encontrado", + "agentManager.browser.missingChrome": + "Google Chrome não foi encontrado. Instale o Chrome para usar o Navegador Integrado e tente novamente.", + "agentManager.browser.missingChromium": + "Playwright Chromium não foi encontrado. Instale um navegador compatível ou ative “Usar Chrome do Sistema” em “Configurações do navegador”.", + "agentManager.browser.downloadChrome": "Baixar Chrome", + "agentManager.browser.settings": "Configurações do navegador", + "agentManager.browser.noSession": "Inicie ou selecione uma sessão no Agent Manager.", "agentManager.browser.screenshotAlt": "Página atual do navegador", "agentManager.browser.errors": "Problemas do navegador: {{count}}", "agentManager.browser.diagnostics": "Diagnóstico do navegador", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 37e2f56a1334..9b4af789dea0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -446,16 +446,26 @@ export const dict = { "agentManager.caffeination.active": "Računar se održava budnim dok Kilo agenti rade", "agentManager.caffeination.unavailable": "Režim održavanja računara budnim nije dostupan na ovoj platformi", "agentManager.browser.title": "Integrirani preglednik", - "agentManager.browser.url": "URL lokalne aplikacije", + "agentManager.browser.url": "Adresa", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Otvori", "agentManager.browser.inspect": "Odaberite element", "agentManager.browser.devtoolsTitle": "Razvojni alati", "agentManager.browser.refresh": "Osvježi preglednik", + "agentManager.browser.back": "Nazad", + "agentManager.browser.forward": "Naprijed", "agentManager.browser.close": "Zatvori preglednik", - "agentManager.browser.empty": "Otvorite lokalnu aplikaciju da biste je ovdje pregledali.", - "agentManager.browser.noSession": - "Pokrenite ili odaberite sesiju u Agent Manageru da biste pregledali lokalnu aplikaciju.", + "agentManager.browser.empty": "Otvorite lokalnu aplikaciju ili javnu HTTPS stranicu da biste je ovdje pregledali.", + "agentManager.browser.requirement": + "Potreban je Google Chrome ili kompatibilan preglednik Playwright Chromium. Preglednici se ne instaliraju automatski.", + "agentManager.browser.missingTitle": "Preglednik nije pronađen", + "agentManager.browser.missingChrome": + "Google Chrome nije pronađen. Instalirajte Chrome da biste koristili integrirani preglednik, pa pokušajte ponovo.", + "agentManager.browser.missingChromium": + "Playwright Chromium nije pronađen. Instalirajte kompatibilan preglednik ili u odjeljku „Postavke preglednika“ omogućite „Koristi sistemski Chrome“.", + "agentManager.browser.downloadChrome": "Preuzmi Chrome", + "agentManager.browser.settings": "Postavke preglednika", + "agentManager.browser.noSession": "Pokrenite ili odaberite sesiju u Agent Manageru.", "agentManager.browser.screenshotAlt": "Trenutna stranica preglednika", "agentManager.browser.errors": "Problemi preglednika: {{count}}", "agentManager.browser.diagnostics": "Dijagnostika preglednika", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index 8d0da12ec8c6..4f598b80576f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -448,15 +448,27 @@ export const dict = { "agentManager.caffeination.unavailable": "Tilstanden til at holde computeren vågen er ikke tilgængelig på denne platform", "agentManager.browser.title": "Integreret browser", - "agentManager.browser.url": "URL til lokal applikation", + "agentManager.browser.url": "Adresse", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Åbn", "agentManager.browser.inspect": "Vælg element", "agentManager.browser.devtoolsTitle": "Udviklerværktøjer", "agentManager.browser.refresh": "Opdater browser", + "agentManager.browser.back": "Tilbage", + "agentManager.browser.forward": "Frem", "agentManager.browser.close": "Luk browser", - "agentManager.browser.empty": "Åbn en lokal applikation for at få vist en forhåndsvisning her.", - "agentManager.browser.noSession": "Start eller vælg en session i Agent Manager for at gennemse en lokal applikation.", + "agentManager.browser.empty": + "Åbn en lokal applikation eller en offentlig HTTPS-side for at få vist en forhåndsvisning her.", + "agentManager.browser.requirement": + "Kræver Google Chrome eller en kompatibel Playwright Chromium-browser. Browsere installeres ikke automatisk.", + "agentManager.browser.missingTitle": "Browser ikke fundet", + "agentManager.browser.missingChrome": + "Google Chrome blev ikke fundet. Installer Chrome for at bruge den integrerede browser, og prøv igen.", + "agentManager.browser.missingChromium": + "Playwright Chromium blev ikke fundet. Installer en kompatibel browser, eller aktivér “Brug system-Chrome” under “Browserindstillinger”.", + "agentManager.browser.downloadChrome": "Download Chrome", + "agentManager.browser.settings": "Browserindstillinger", + "agentManager.browser.noSession": "Start eller vælg en session i Agent Manager.", "agentManager.browser.screenshotAlt": "Aktuel browserside", "agentManager.browser.errors": "Browserproblemer: {{count}}", "agentManager.browser.diagnostics": "Browserdiagnostik", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index 05dc57a16a72..200de6fa07ca 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -454,16 +454,27 @@ export const dict = { "agentManager.caffeination.active": "Computer wird wach gehalten, während Kilo-Agenten arbeiten", "agentManager.caffeination.unavailable": "Der Wachhaltemodus ist auf dieser Plattform nicht verfügbar", "agentManager.browser.title": "Integrierter Browser", - "agentManager.browser.url": "URL der lokalen Anwendung", + "agentManager.browser.url": "Adresse", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Öffnen", "agentManager.browser.inspect": "Element auswählen", "agentManager.browser.devtoolsTitle": "Entwicklertools", "agentManager.browser.refresh": "Browser aktualisieren", + "agentManager.browser.back": "Zurück", + "agentManager.browser.forward": "Vorwärts", "agentManager.browser.close": "Browser schließen", - "agentManager.browser.empty": "Öffnen Sie eine lokale Anwendung, um sie hier in der Vorschau anzuzeigen.", - "agentManager.browser.noSession": - "Starten oder wählen Sie eine Sitzung im Agent Manager, um eine lokale Anwendung zu öffnen.", + "agentManager.browser.empty": + "Öffnen Sie eine lokale Anwendung oder eine öffentliche HTTPS-Seite, um sie hier in der Vorschau anzuzeigen.", + "agentManager.browser.requirement": + "Erfordert Google Chrome oder einen kompatiblen Playwright Chromium-Browser. Browser werden nicht automatisch installiert.", + "agentManager.browser.missingTitle": "Browser nicht gefunden", + "agentManager.browser.missingChrome": + "Google Chrome wurde nicht gefunden. Installieren Sie Chrome für den integrierten Browser und versuchen Sie es erneut.", + "agentManager.browser.missingChromium": + "Playwright Chromium wurde nicht gefunden. Installieren Sie einen kompatiblen Browser oder aktivieren Sie „System-Chrome verwenden“ unter „Browsereinstellungen“.", + "agentManager.browser.downloadChrome": "Chrome herunterladen", + "agentManager.browser.settings": "Browsereinstellungen", + "agentManager.browser.noSession": "Starten oder wählen Sie eine Sitzung im Agent Manager.", "agentManager.browser.screenshotAlt": "Aktuelle Browserseite", "agentManager.browser.errors": "Browserprobleme: {{count}}", "agentManager.browser.diagnostics": "Browserdiagnose", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index 6c832b9f55e4..2ad12ca80aeb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -474,15 +474,26 @@ export const dict = { "agentManager.documents.source": "Show source", "agentManager.documents.comments": "{{count}} comments", "agentManager.browser.title": "Integrated Browser", - "agentManager.browser.url": "Local application URL", + "agentManager.browser.url": "Address", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Open", "agentManager.browser.inspect": "Select element", "agentManager.browser.devtoolsTitle": "Developer tools", "agentManager.browser.refresh": "Refresh browser", + "agentManager.browser.back": "Go back", + "agentManager.browser.forward": "Go forward", "agentManager.browser.close": "Close browser", - "agentManager.browser.empty": "Open a local application to preview it here.", - "agentManager.browser.noSession": "Start or select a session in Agent Manager to browse a local application.", + "agentManager.browser.empty": "Open a local application or a public HTTPS page to preview it here.", + "agentManager.browser.requirement": + "Requires Google Chrome or a compatible Playwright Chromium browser. Browsers are not installed automatically.", + "agentManager.browser.missingTitle": "Browser not found", + "agentManager.browser.missingChrome": + "Google Chrome was not found. Install Chrome to use the Integrated Browser, then retry.", + "agentManager.browser.missingChromium": + "Playwright Chromium was not found. Install a compatible browser or enable Use System Chrome in Browser Settings.", + "agentManager.browser.downloadChrome": "Download Chrome", + "agentManager.browser.settings": "Browser Settings", + "agentManager.browser.noSession": "Start or select a session in Agent Manager.", "agentManager.browser.screenshotAlt": "Current browser page", "agentManager.browser.errors": "Browser issues: {{count}}", "agentManager.browser.diagnostics": "Browser diagnostics", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 7dff0884eb94..bdf59925bf1c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -453,16 +453,26 @@ export const dict = { "agentManager.caffeination.unavailable": "El modo para mantener el equipo activo no está disponible en esta plataforma", "agentManager.browser.title": "Navegador integrado", - "agentManager.browser.url": "URL de la aplicación local", + "agentManager.browser.url": "Dirección", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Abrir", "agentManager.browser.inspect": "Seleccionar elemento", "agentManager.browser.devtoolsTitle": "Herramientas de desarrollo", "agentManager.browser.refresh": "Actualizar navegador", + "agentManager.browser.back": "Atrás", + "agentManager.browser.forward": "Adelante", "agentManager.browser.close": "Cerrar navegador", - "agentManager.browser.empty": "Abre una aplicación local para previsualizarla aquí.", - "agentManager.browser.noSession": - "Inicia o selecciona una sesión en Agent Manager para navegar por una aplicación local.", + "agentManager.browser.empty": "Abre una aplicación local o una página HTTPS pública para previsualizarla aquí.", + "agentManager.browser.requirement": + "Requiere Google Chrome o un navegador Playwright Chromium compatible. Los navegadores no se instalan automáticamente.", + "agentManager.browser.missingTitle": "Navegador no encontrado", + "agentManager.browser.missingChrome": + "No se encontró Google Chrome. Instala Chrome para usar el navegador integrado y vuelve a intentarlo.", + "agentManager.browser.missingChromium": + "No se encontró Playwright Chromium. Instala un navegador compatible o activa «Usar Chrome del sistema» en «Configuración del navegador».", + "agentManager.browser.downloadChrome": "Descargar Chrome", + "agentManager.browser.settings": "Configuración del navegador", + "agentManager.browser.noSession": "Inicia o selecciona una sesión en Agent Manager.", "agentManager.browser.screenshotAlt": "Página actual del navegador", "agentManager.browser.errors": "Problemas del navegador: {{count}}", "agentManager.browser.diagnostics": "Diagnóstico del navegador", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index b1861638db8c..bc71e8218b40 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -449,15 +449,26 @@ export const dict = { "agentManager.caffeination.active": "رایانه هنگام کار عامل‌های Kilo بیدار نگه داشته می‌شود", "agentManager.caffeination.unavailable": "حالت بیدار نگه داشتن رایانه در این پلتفرم در دسترس نیست", "agentManager.browser.title": "مرورگر یکپارچه", - "agentManager.browser.url": "URL برنامه محلی", + "agentManager.browser.url": "نشانی", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "باز کردن", "agentManager.browser.refresh": "بازخوانی مرورگر", + "agentManager.browser.back": "بازگشت", + "agentManager.browser.forward": "جلو", "agentManager.browser.close": "بستن مرورگر", "agentManager.browser.inspect": "انتخاب عنصر", "agentManager.browser.devtoolsTitle": "ابزارهای توسعه", - "agentManager.browser.empty": "برای پیش‌نمایش، یک برنامه محلی را باز کنید.", - "agentManager.browser.noSession": "برای مرور یک برنامه محلی، یک جلسه در Agent Manager شروع یا انتخاب کنید.", + "agentManager.browser.empty": "برای پیش‌نمایش در اینجا، یک برنامه محلی یا یک صفحه عمومی HTTPS را باز کنید.", + "agentManager.browser.requirement": + "به Google Chrome یا مرورگر سازگار Playwright Chromium نیاز است. مرورگرها به‌صورت خودکار نصب نمی‌شوند.", + "agentManager.browser.missingTitle": "مرورگر یافت نشد", + "agentManager.browser.missingChrome": + "Google Chrome یافت نشد. برای استفاده از مرورگر یکپارچه، Chrome را نصب کنید و دوباره تلاش کنید.", + "agentManager.browser.missingChromium": + "Playwright Chromium یافت نشد. یک مرورگر سازگار نصب کنید یا «استفاده از Chrome سیستم» را در «تنظیمات مرورگر» فعال کنید.", + "agentManager.browser.downloadChrome": "دانلود Chrome", + "agentManager.browser.settings": "تنظیمات مرورگر", + "agentManager.browser.noSession": "یک جلسه در Agent Manager شروع یا انتخاب کنید.", "agentManager.browser.screenshotAlt": "صفحه فعلی مرورگر", "agentManager.browser.errors": "مشکلات مرورگر: {{count}}", "agentManager.browser.diagnostics": "عیب‌یابی مرورگر", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 0b2fc0fc79fd..94b99f08b5d0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -453,16 +453,27 @@ export const dict = { "agentManager.caffeination.active": "Ordinateur maintenu éveillé pendant que les agents Kilo travaillent", "agentManager.caffeination.unavailable": "Le mode veille active n'est pas disponible sur cette plateforme", "agentManager.browser.title": "Navigateur intégré", - "agentManager.browser.url": "URL de l'application locale", + "agentManager.browser.url": "Adresse", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Ouvrir", "agentManager.browser.inspect": "Sélectionner l'élément", "agentManager.browser.devtoolsTitle": "Outils de développement", "agentManager.browser.refresh": "Actualiser le navigateur", + "agentManager.browser.back": "Page précédente", + "agentManager.browser.forward": "Page suivante", "agentManager.browser.close": "Fermer le navigateur", - "agentManager.browser.empty": "Ouvrez une application locale pour l'afficher ici en aperçu.", - "agentManager.browser.noSession": - "Démarrez ou sélectionnez une session dans Agent Manager pour parcourir une application locale.", + "agentManager.browser.empty": + "Ouvrez une application locale ou une page HTTPS publique pour en afficher un aperçu ici.", + "agentManager.browser.requirement": + "Nécessite Google Chrome ou un navigateur Playwright Chromium compatible. Les navigateurs ne sont pas installés automatiquement.", + "agentManager.browser.missingTitle": "Navigateur introuvable", + "agentManager.browser.missingChrome": + "Google Chrome est introuvable. Installez Chrome pour utiliser le navigateur intégré, puis réessayez.", + "agentManager.browser.missingChromium": + "Playwright Chromium est introuvable. Installez un navigateur compatible ou activez « Utiliser le Chrome système » dans « Paramètres du navigateur ».", + "agentManager.browser.downloadChrome": "Télécharger Chrome", + "agentManager.browser.settings": "Paramètres du navigateur", + "agentManager.browser.noSession": "Démarrez ou sélectionnez une session dans Agent Manager.", "agentManager.browser.screenshotAlt": "Page actuelle du navigateur", "agentManager.browser.errors": "Problèmes du navigateur : {{count}}", "agentManager.browser.diagnostics": "Diagnostics du navigateur", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 74ad174017e7..353fd990f4cc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -459,16 +459,27 @@ export const dict = { "agentManager.caffeination.unavailable": "La modalità per mantenere il computer attivo non è disponibile su questa piattaforma", "agentManager.browser.title": "Browser integrato", - "agentManager.browser.url": "URL dell'applicazione locale", + "agentManager.browser.url": "Indirizzo", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Apri", "agentManager.browser.inspect": "Seleziona elemento", "agentManager.browser.devtoolsTitle": "Strumenti per sviluppatori", "agentManager.browser.refresh": "Aggiorna browser", + "agentManager.browser.back": "Indietro", + "agentManager.browser.forward": "Avanti", "agentManager.browser.close": "Chiudi browser", - "agentManager.browser.empty": "Apri un'applicazione locale per visualizzarla qui.", - "agentManager.browser.noSession": - "Avvia o seleziona una sessione in Agent Manager per navigare in un'applicazione locale.", + "agentManager.browser.empty": + "Apri un'applicazione locale o una pagina HTTPS pubblica per visualizzarne qui l'anteprima.", + "agentManager.browser.requirement": + "Richiede Google Chrome o un browser Playwright Chromium compatibile. I browser non vengono installati automaticamente.", + "agentManager.browser.missingTitle": "Browser non trovato", + "agentManager.browser.missingChrome": + "Google Chrome non è stato trovato. Installa Chrome per usare il browser integrato, poi riprova.", + "agentManager.browser.missingChromium": + "Playwright Chromium non è stato trovato. Installa un browser compatibile o abilita «Usa Chrome di sistema» in «Impostazioni del browser».", + "agentManager.browser.downloadChrome": "Scarica Chrome", + "agentManager.browser.settings": "Impostazioni del browser", + "agentManager.browser.noSession": "Avvia o seleziona una sessione in Agent Manager.", "agentManager.browser.screenshotAlt": "Pagina corrente del browser", "agentManager.browser.errors": "Problemi del browser: {{count}}", "agentManager.browser.diagnostics": "Diagnostica del browser", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 4c9769e2c545..ef1548084dcb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -446,16 +446,26 @@ export const dict = { "agentManager.caffeination.active": "Kilo エージェントの作業中もコンピューターをスリープさせません", "agentManager.caffeination.unavailable": "このプラットフォームではスリープ防止モードを利用できません", "agentManager.browser.title": "統合ブラウザー", - "agentManager.browser.url": "ローカルアプリケーション URL", + "agentManager.browser.url": "アドレス", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "開く", "agentManager.browser.refresh": "ブラウザーを更新", + "agentManager.browser.back": "戻る", + "agentManager.browser.forward": "進む", "agentManager.browser.close": "ブラウザーを閉じる", "agentManager.browser.inspect": "要素を選択", "agentManager.browser.devtoolsTitle": "開発者ツール", - "agentManager.browser.empty": "ローカルアプリケーションを開くと、ここでプレビューできます。", - "agentManager.browser.noSession": - "ローカルアプリケーションを表示するには、Agent Manager でセッションを開始または選択してください。", + "agentManager.browser.empty": "ローカルアプリケーションまたは公開 HTTPS ページを開くと、ここでプレビューできます。", + "agentManager.browser.requirement": + "Google Chrome または互換性のある Playwright Chromium ブラウザーが必要です。ブラウザーは自動でインストールされません。", + "agentManager.browser.missingTitle": "ブラウザーが見つかりません", + "agentManager.browser.missingChrome": + "Google Chrome が見つかりません。統合ブラウザーを使用するには、Chrome をインストールしてから再試行してください。", + "agentManager.browser.missingChromium": + "Playwright Chromium が見つかりません。互換性のあるブラウザーをインストールするか、「ブラウザー設定」で「システムChromeを使用」を有効にしてください。", + "agentManager.browser.downloadChrome": "Chrome をダウンロード", + "agentManager.browser.settings": "ブラウザー設定", + "agentManager.browser.noSession": "Agent Manager でセッションを開始または選択してください。", "agentManager.browser.screenshotAlt": "現在のブラウザーページ", "agentManager.browser.errors": "ブラウザーの問題: {{count}} 件", "agentManager.browser.diagnostics": "ブラウザー診断", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index baa7c7eac662..124c7099a827 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -444,15 +444,26 @@ export const dict = { "agentManager.caffeination.active": "Kilo 에이전트가 작업하는 동안 컴퓨터를 절전 모드로 전환하지 않습니다", "agentManager.caffeination.unavailable": "이 플랫폼에서는 절전 방지 모드를 사용할 수 없습니다", "agentManager.browser.title": "통합 브라우저", - "agentManager.browser.url": "로컬 애플리케이션 URL", + "agentManager.browser.url": "주소", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "열기", "agentManager.browser.refresh": "브라우저 새로 고침", + "agentManager.browser.back": "뒤로", + "agentManager.browser.forward": "앞으로", "agentManager.browser.close": "브라우저 닫기", "agentManager.browser.inspect": "요소 선택", "agentManager.browser.devtoolsTitle": "개발자 도구", - "agentManager.browser.empty": "로컬 애플리케이션을 열어 여기에서 미리 보세요.", - "agentManager.browser.noSession": "로컬 애플리케이션을 탐색하려면 Agent Manager에서 세션을 시작하거나 선택하세요.", + "agentManager.browser.empty": "로컬 애플리케이션이나 공개 HTTPS 페이지를 열어 여기에서 미리 보세요.", + "agentManager.browser.requirement": + "Google Chrome 또는 호환되는 Playwright Chromium 브라우저가 필요합니다. 브라우저는 자동으로 설치되지 않습니다.", + "agentManager.browser.missingTitle": "브라우저를 찾을 수 없음", + "agentManager.browser.missingChrome": + "Google Chrome을 찾을 수 없습니다. 통합 브라우저를 사용하려면 Chrome을 설치한 후 다시 시도하세요.", + "agentManager.browser.missingChromium": + "Playwright Chromium을 찾을 수 없습니다. 호환되는 브라우저를 설치하거나 ‘브라우저 설정’에서 ‘시스템 Chrome 사용’을 활성화하세요.", + "agentManager.browser.downloadChrome": "Chrome 다운로드", + "agentManager.browser.settings": "브라우저 설정", + "agentManager.browser.noSession": "Agent Manager에서 세션을 시작하거나 선택하세요.", "agentManager.browser.screenshotAlt": "현재 브라우저 페이지", "agentManager.browser.errors": "브라우저 문제: {{count}}개", "agentManager.browser.diagnostics": "브라우저 진단", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index b6338892407b..df0b09357323 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -455,16 +455,26 @@ export const dict = { "agentManager.caffeination.unavailable": "De modus om de computer wakker te houden is niet beschikbaar op dit platform", "agentManager.browser.title": "Geïntegreerde browser", - "agentManager.browser.url": "URL van lokale applicatie", + "agentManager.browser.url": "Adres", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Openen", "agentManager.browser.inspect": "Element selecteren", "agentManager.browser.devtoolsTitle": "Ontwikkeltools", "agentManager.browser.refresh": "Browser vernieuwen", + "agentManager.browser.back": "Terug", + "agentManager.browser.forward": "Vooruit", "agentManager.browser.close": "Browser sluiten", - "agentManager.browser.empty": "Open een lokale applicatie om deze hier te bekijken.", - "agentManager.browser.noSession": - "Start of selecteer een sessie in Agent Manager om een lokale applicatie te bekijken.", + "agentManager.browser.empty": "Open een lokale applicatie of een openbare HTTPS-pagina om deze hier te bekijken.", + "agentManager.browser.requirement": + "Vereist Google Chrome of een compatibele Playwright Chromium-browser. Browsers worden niet automatisch geïnstalleerd.", + "agentManager.browser.missingTitle": "Browser niet gevonden", + "agentManager.browser.missingChrome": + "Google Chrome is niet gevonden. Installeer Chrome om de geïntegreerde browser te gebruiken en probeer het opnieuw.", + "agentManager.browser.missingChromium": + "Playwright Chromium is niet gevonden. Installeer een compatibele browser of schakel ‘Gebruik Systeem Chrome’ in via ‘Browserinstellingen’.", + "agentManager.browser.downloadChrome": "Chrome downloaden", + "agentManager.browser.settings": "Browserinstellingen", + "agentManager.browser.noSession": "Start of selecteer een sessie in Agent Manager.", "agentManager.browser.screenshotAlt": "Huidige browserpagina", "agentManager.browser.errors": "Browserproblemen: {{count}}", "agentManager.browser.diagnostics": "Browserdiagnostiek", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 22b7771a4fc0..43d5e286a6ef 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -446,15 +446,26 @@ export const dict = { "agentManager.caffeination.unavailable": "Modus for å holde datamaskinen våken er ikke tilgjengelig på denne plattformen", "agentManager.browser.title": "Integrert nettleser", - "agentManager.browser.url": "URL for lokal applikasjon", + "agentManager.browser.url": "Adresse", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Åpne", "agentManager.browser.inspect": "Velg element", "agentManager.browser.devtoolsTitle": "Utviklerverktøy", "agentManager.browser.refresh": "Oppdater nettleser", + "agentManager.browser.back": "Tilbake", + "agentManager.browser.forward": "Frem", "agentManager.browser.close": "Lukk nettleser", - "agentManager.browser.empty": "Åpne en lokal applikasjon for å forhåndsvise den her.", - "agentManager.browser.noSession": "Start eller velg en økt i Agent Manager for å bla i en lokal applikasjon.", + "agentManager.browser.empty": "Åpne en lokal applikasjon eller en offentlig HTTPS-side for å forhåndsvise den her.", + "agentManager.browser.requirement": + "Krever Google Chrome eller en kompatibel Playwright Chromium-nettleser. Nettlesere installeres ikke automatisk.", + "agentManager.browser.missingTitle": "Nettleser ikke funnet", + "agentManager.browser.missingChrome": + "Google Chrome ble ikke funnet. Installer Chrome for å bruke den integrerte nettleseren, og prøv igjen.", + "agentManager.browser.missingChromium": + "Playwright Chromium ble ikke funnet. Installer en kompatibel nettleser eller aktiver «Bruk system-Chrome» under «Nettleserinnstillinger».", + "agentManager.browser.downloadChrome": "Last ned Chrome", + "agentManager.browser.settings": "Nettleserinnstillinger", + "agentManager.browser.noSession": "Start eller velg en økt i Agent Manager.", "agentManager.browser.screenshotAlt": "Gjeldende nettleserside", "agentManager.browser.errors": "Nettleserproblemer: {{count}}", "agentManager.browser.diagnostics": "Nettleserdiagnostikk", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 5c92d240e9cd..7bb87261bd1d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -447,15 +447,27 @@ export const dict = { "agentManager.caffeination.active": "Komputer pozostaje aktywny, gdy agenci Kilo pracują", "agentManager.caffeination.unavailable": "Tryb utrzymywania komputera aktywnego jest niedostępny na tej platformie", "agentManager.browser.title": "Zintegrowana przeglądarka", - "agentManager.browser.url": "URL lokalnej aplikacji", + "agentManager.browser.url": "Adres", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Otwórz", "agentManager.browser.inspect": "Wybierz element", "agentManager.browser.devtoolsTitle": "Narzędzia deweloperskie", "agentManager.browser.refresh": "Odśwież przeglądarkę", + "agentManager.browser.back": "Wstecz", + "agentManager.browser.forward": "Dalej", "agentManager.browser.close": "Zamknij przeglądarkę", - "agentManager.browser.empty": "Otwórz lokalną aplikację, aby wyświetlić ją tutaj.", - "agentManager.browser.noSession": "Uruchom lub wybierz sesję w Agent Manager, aby przeglądać lokalną aplikację.", + "agentManager.browser.empty": + "Otwórz lokalną aplikację lub publiczną stronę HTTPS, aby wyświetlić jej podgląd tutaj.", + "agentManager.browser.requirement": + "Wymaga Google Chrome lub zgodnej przeglądarki Playwright Chromium. Przeglądarki nie są instalowane automatycznie.", + "agentManager.browser.missingTitle": "Nie znaleziono przeglądarki", + "agentManager.browser.missingChrome": + "Nie znaleziono Google Chrome. Zainstaluj Chrome, aby korzystać ze zintegrowanej przeglądarki, a następnie spróbuj ponownie.", + "agentManager.browser.missingChromium": + "Nie znaleziono Playwright Chromium. Zainstaluj zgodną przeglądarkę lub włącz opcję „Użyj systemowego Chrome” w sekcji „Ustawienia przeglądarki”.", + "agentManager.browser.downloadChrome": "Pobierz Chrome", + "agentManager.browser.settings": "Ustawienia przeglądarki", + "agentManager.browser.noSession": "Uruchom lub wybierz sesję w Agent Manager.", "agentManager.browser.screenshotAlt": "Bieżąca strona przeglądarki", "agentManager.browser.errors": "Problemy przeglądarki: {{count}}", "agentManager.browser.diagnostics": "Diagnostyka przeglądarki", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 31ccc914b151..2b6c427507ef 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -449,15 +449,27 @@ export const dict = { "agentManager.caffeination.active": "Компьютер не переходит в спящий режим, пока работают агенты Kilo", "agentManager.caffeination.unavailable": "Режим предотвращения сна недоступен на этой платформе", "agentManager.browser.title": "Встроенный браузер", - "agentManager.browser.url": "URL локального приложения", + "agentManager.browser.url": "Адрес", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Открыть", "agentManager.browser.refresh": "Обновить браузер", + "agentManager.browser.back": "Назад", + "agentManager.browser.forward": "Вперёд", "agentManager.browser.close": "Закрыть браузер", "agentManager.browser.inspect": "Выбрать элемент", "agentManager.browser.devtoolsTitle": "Инструменты разработчика", - "agentManager.browser.empty": "Откройте локальное приложение, чтобы просмотреть его здесь.", - "agentManager.browser.noSession": "Запустите или выберите сеанс в Agent Manager, чтобы открыть локальное приложение.", + "agentManager.browser.empty": + "Откройте локальное приложение или общедоступную HTTPS-страницу для предварительного просмотра здесь.", + "agentManager.browser.requirement": + "Требуется Google Chrome или совместимый браузер Playwright Chromium. Браузеры не устанавливаются автоматически.", + "agentManager.browser.missingTitle": "Браузер не найден", + "agentManager.browser.missingChrome": + "Google Chrome не найден. Установите Chrome для использования встроенного браузера, затем повторите попытку.", + "agentManager.browser.missingChromium": + "Playwright Chromium не найден. Установите совместимый браузер или включите «Использовать системный Chrome» в разделе «Настройки браузера».", + "agentManager.browser.downloadChrome": "Скачать Chrome", + "agentManager.browser.settings": "Настройки браузера", + "agentManager.browser.noSession": "Запустите или выберите сеанс в Agent Manager.", "agentManager.browser.screenshotAlt": "Текущая страница браузера", "agentManager.browser.errors": "Проблемы браузера: {{count}}", "agentManager.browser.diagnostics": "Диагностика браузера", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index b58c10c3dcc3..6be723eda0a3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -438,15 +438,26 @@ export const dict = { "agentManager.caffeination.active": "กำลังป้องกันคอมพิวเตอร์เข้าสู่โหมดพักขณะเอเจนต์ Kilo ทำงาน", "agentManager.caffeination.unavailable": "โหมดป้องกันการพักเครื่องไม่พร้อมใช้งานบนแพลตฟอร์มนี้", "agentManager.browser.title": "เบราว์เซอร์ในตัว", - "agentManager.browser.url": "URL ของแอปพลิเคชันในเครื่อง", + "agentManager.browser.url": "ที่อยู่", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "เปิด", "agentManager.browser.refresh": "รีเฟรชเบราว์เซอร์", + "agentManager.browser.back": "ย้อนกลับ", + "agentManager.browser.forward": "ไปข้างหน้า", "agentManager.browser.close": "ปิดเบราว์เซอร์", "agentManager.browser.inspect": "เลือกองค์ประกอบ", "agentManager.browser.devtoolsTitle": "เครื่องมือสำหรับนักพัฒนา", - "agentManager.browser.empty": "เปิดแอปพลิเคชันในเครื่องเพื่อดูตัวอย่างที่นี่", - "agentManager.browser.noSession": "เริ่มหรือเลือกเซสชันใน Agent Manager เพื่อเรียกดูแอปพลิเคชันในเครื่อง", + "agentManager.browser.empty": "เปิดแอปพลิเคชันในเครื่องหรือหน้าเว็บ HTTPS สาธารณะเพื่อดูตัวอย่างที่นี่", + "agentManager.browser.requirement": + "ต้องมี Google Chrome หรือเบราว์เซอร์ Playwright Chromium ที่เข้ากันได้ เบราว์เซอร์จะไม่ถูกติดตั้งโดยอัตโนมัติ", + "agentManager.browser.missingTitle": "ไม่พบเบราว์เซอร์", + "agentManager.browser.missingChrome": + "ไม่พบ Google Chrome โปรดติดตั้ง Chrome เพื่อใช้เบราว์เซอร์ในตัว แล้วลองอีกครั้ง", + "agentManager.browser.missingChromium": + "ไม่พบ Playwright Chromium โปรดติดตั้งเบราว์เซอร์ที่เข้ากันได้ หรือเปิด “ใช้ Chrome ของระบบ” ใน “การตั้งค่าเบราว์เซอร์”", + "agentManager.browser.downloadChrome": "ดาวน์โหลด Chrome", + "agentManager.browser.settings": "การตั้งค่าเบราว์เซอร์", + "agentManager.browser.noSession": "เริ่มหรือเลือกเซสชันใน Agent Manager", "agentManager.browser.screenshotAlt": "หน้าปัจจุบันของเบราว์เซอร์", "agentManager.browser.errors": "ปัญหาเบราว์เซอร์: {{count}} รายการ", "agentManager.browser.diagnostics": "การวินิจฉัยเบราว์เซอร์", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 2b92df004d0a..55ef29de2bfe 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -455,16 +455,26 @@ export const dict = { "agentManager.caffeination.active": "Kilo ajanları çalışırken bilgisayar uyanık tutuluyor", "agentManager.caffeination.unavailable": "Bilgisayarı uyanık tutma modu bu platformda kullanılamıyor", "agentManager.browser.title": "Entegre Tarayıcı", - "agentManager.browser.url": "Yerel uygulama URL'si", + "agentManager.browser.url": "Adres", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Aç", "agentManager.browser.refresh": "Tarayıcıyı yenile", + "agentManager.browser.back": "Geri", + "agentManager.browser.forward": "İleri", "agentManager.browser.close": "Tarayıcıyı kapat", "agentManager.browser.inspect": "Öğeyi seç", "agentManager.browser.devtoolsTitle": "Geliştirici araçları", - "agentManager.browser.empty": "Burada önizlemek için yerel bir uygulama açın.", - "agentManager.browser.noSession": - "Yerel bir uygulamada gezinmek için Agent Manager'da bir oturum başlatın veya seçin.", + "agentManager.browser.empty": "Burada önizlemek için yerel bir uygulama veya herkese açık bir HTTPS sayfası açın.", + "agentManager.browser.requirement": + "Google Chrome veya uyumlu bir Playwright Chromium tarayıcısı gerekir. Tarayıcılar otomatik olarak yüklenmez.", + "agentManager.browser.missingTitle": "Tarayıcı bulunamadı", + "agentManager.browser.missingChrome": + "Google Chrome bulunamadı. Entegre Tarayıcı'yı kullanmak için Chrome'u yükleyin, ardından yeniden deneyin.", + "agentManager.browser.missingChromium": + "Playwright Chromium bulunamadı. Uyumlu bir tarayıcı yükleyin veya “Tarayıcı Ayarları” bölümünde “Sistem Chrome'unu Kullan” seçeneğini etkinleştirin.", + "agentManager.browser.downloadChrome": "Chrome'u indir", + "agentManager.browser.settings": "Tarayıcı Ayarları", + "agentManager.browser.noSession": "Agent Manager'da bir oturum başlatın veya seçin.", "agentManager.browser.screenshotAlt": "Geçerli tarayıcı sayfası", "agentManager.browser.errors": "Tarayıcı sorunları: {{count}}", "agentManager.browser.diagnostics": "Tarayıcı tanılaması", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 6a61983878dc..acd3a9ba60b4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -457,15 +457,27 @@ export const dict = { "agentManager.caffeination.active": "Комп'ютер не переходить у режим сну, доки працюють агенти Kilo", "agentManager.caffeination.unavailable": "Режим запобігання сну недоступний на цій платформі", "agentManager.browser.title": "Вбудований браузер", - "agentManager.browser.url": "URL локальної програми", + "agentManager.browser.url": "Адреса", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Відкрити", "agentManager.browser.refresh": "Оновити браузер", + "agentManager.browser.back": "Назад", + "agentManager.browser.forward": "Вперед", "agentManager.browser.close": "Закрити браузер", "agentManager.browser.inspect": "Вибрати елемент", "agentManager.browser.devtoolsTitle": "Інструменти розробника", - "agentManager.browser.empty": "Відкрийте локальну програму, щоб переглянути її тут.", - "agentManager.browser.noSession": "Запустіть або виберіть сесію в Agent Manager, щоб відкрити локальний застосунок.", + "agentManager.browser.empty": + "Відкрийте локальну програму або загальнодоступну HTTPS-сторінку, щоб переглянути її тут.", + "agentManager.browser.requirement": + "Потрібен Google Chrome або сумісний браузер Playwright Chromium. Браузери не встановлюються автоматично.", + "agentManager.browser.missingTitle": "Браузер не знайдено", + "agentManager.browser.missingChrome": + "Google Chrome не знайдено. Встановіть Chrome для використання вбудованого браузера, а потім повторіть спробу.", + "agentManager.browser.missingChromium": + "Playwright Chromium не знайдено. Встановіть сумісний браузер або увімкніть «Використовувати системний Chrome» у розділі «Налаштування браузера».", + "agentManager.browser.downloadChrome": "Завантажити Chrome", + "agentManager.browser.settings": "Налаштування браузера", + "agentManager.browser.noSession": "Запустіть або виберіть сесію в Agent Manager.", "agentManager.browser.screenshotAlt": "Поточна сторінка браузера", "agentManager.browser.errors": "Проблеми браузера: {{count}}", "agentManager.browser.diagnostics": "Діагностика браузера", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 772987f90870..a8ac3da38bb8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -434,15 +434,24 @@ export const dict = { "agentManager.caffeination.unavailable": "此平台不支持保持唤醒模式", "agentManager.browser.title": "集成浏览器", - "agentManager.browser.url": "本地应用 URL", + "agentManager.browser.url": "地址", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "打开", "agentManager.browser.refresh": "刷新浏览器", + "agentManager.browser.back": "后退", + "agentManager.browser.forward": "前进", "agentManager.browser.close": "关闭浏览器", "agentManager.browser.inspect": "选择元素", "agentManager.browser.devtoolsTitle": "开发者工具", - "agentManager.browser.empty": "打开本地应用以在此处预览。", - "agentManager.browser.noSession": "请在 Agent Manager 中启动或选择会话以浏览本地应用。", + "agentManager.browser.empty": "打开本地应用或公开的 HTTPS 页面,即可在此处预览。", + "agentManager.browser.requirement": "需要 Google Chrome 或兼容的 Playwright Chromium 浏览器。浏览器不会自动安装。", + "agentManager.browser.missingTitle": "未找到浏览器", + "agentManager.browser.missingChrome": "未找到 Google Chrome。请安装 Chrome 以使用集成浏览器,然后重试。", + "agentManager.browser.missingChromium": + "未找到 Playwright Chromium。请安装兼容的浏览器,或在“浏览器设置”中启用“使用系统 Chrome”。", + "agentManager.browser.downloadChrome": "下载 Chrome", + "agentManager.browser.settings": "浏览器设置", + "agentManager.browser.noSession": "请在 Agent Manager 中启动或选择会话。", "agentManager.browser.screenshotAlt": "当前浏览器页面", "agentManager.browser.errors": "浏览器问题:{{count}}", "agentManager.browser.diagnostics": "浏览器诊断", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 5c1a1bd756c6..b27b17c5a355 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -433,15 +433,24 @@ export const dict = { "agentManager.caffeination.active": "Kilo 代理工作期間保持電腦喚醒", "agentManager.caffeination.unavailable": "此平台無法使用保持喚醒模式", "agentManager.browser.title": "整合瀏覽器", - "agentManager.browser.url": "本機應用程式 URL", + "agentManager.browser.url": "位址", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "開啟", "agentManager.browser.refresh": "重新整理瀏覽器", + "agentManager.browser.back": "上一頁", + "agentManager.browser.forward": "下一頁", "agentManager.browser.close": "關閉瀏覽器", "agentManager.browser.inspect": "選取元素", "agentManager.browser.devtoolsTitle": "開發人員工具", - "agentManager.browser.empty": "開啟本機應用程式,即可在此處預覽。", - "agentManager.browser.noSession": "請在 Agent Manager 中啟動或選取工作階段以瀏覽本機應用程式。", + "agentManager.browser.empty": "開啟本機應用程式或公開的 HTTPS 頁面,即可在此處預覽。", + "agentManager.browser.requirement": "需要 Google Chrome 或相容的 Playwright Chromium 瀏覽器。瀏覽器不會自動安裝。", + "agentManager.browser.missingTitle": "找不到瀏覽器", + "agentManager.browser.missingChrome": "找不到 Google Chrome。請安裝 Chrome 以使用整合瀏覽器,然後再試一次。", + "agentManager.browser.missingChromium": + "找不到 Playwright Chromium。請安裝相容的瀏覽器,或在「瀏覽器設定」中啟用「使用系統 Chrome」。", + "agentManager.browser.downloadChrome": "下載 Chrome", + "agentManager.browser.settings": "瀏覽器設定", + "agentManager.browser.noSession": "請在 Agent Manager 中啟動或選取工作階段。", "agentManager.browser.screenshotAlt": "目前的瀏覽器頁面", "agentManager.browser.errors": "瀏覽器問題:{{count}}", "agentManager.browser.diagnostics": "瀏覽器診斷", diff --git a/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx b/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx index eb6bbfc648c9..e21ac23aae35 100644 --- a/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx +++ b/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx @@ -1,5 +1,6 @@ import { For, Show, createMemo, type Accessor, type Component } from "solid-js" -import { Card } from "@kilocode/kilo-ui/card" +import { Button } from "@kilocode/kilo-ui/button" +import { Card, CardActions, CardDescription, CardTitle } from "@kilocode/kilo-ui/card" import { Collapsible } from "@kilocode/kilo-ui/collapsible" import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" @@ -7,6 +8,7 @@ import { Spinner } from "@kilocode/kilo-ui/spinner" import { TextField } from "@kilocode/kilo-ui/text-field" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { createBrowserController } from "./controller" +import { StreamViewport } from "./StreamViewport" import type { BrowserController } from "./controller" import type { BrowserLabels, BrowserPosition, BrowserScope, BrowserState, BrowserTransport } from "./types" import type { BrowserReference } from "../../src/shared/browser-feedback" @@ -31,6 +33,26 @@ const Toolbar: Component<{ const ready = () => !!props.controller.state()?.url && props.controller.state()?.status !== "closed" return (
+ + + + + + + transport: BrowserTransport controller: BrowserController labels: BrowserLabels + download: () => void + settings: () => void }> = (props) => { const issue = () => props.state?.frameError || props.state?.error const page = () => @@ -161,10 +186,7 @@ const Viewport: Component<{ props.state.status !== "closed" && (props.state.status !== "error" || !!props.state.title) && props.state.url - const identity = () => { - const url = page() - return url ? `${props.state?.browserId}:${props.state?.navigation ?? 0}:${url}` : undefined - } + const identity = () => (page() ? props.state?.browserId : undefined) return (
-
{props.session ? props.labels.empty : props.labels.noSession}
+
{props.scope()?.sessionId ? props.labels.empty : props.labels.noSession}
+ +
{props.labels.requirement}
+
} > {(_key) => ( -