From 9f739b045b69c41ea70a7e6f441092797da53d93 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 4 Sep 2026 19:23:38 +0200 Subject: [PATCH 1/5] feat: stream Agent Manager browser previews with public HTTPS --- .changeset/agent-manager-streamed-browser.md | 6 + bun.lock | 1 + packages/kilo-docs/source-links.md | 2 + packages/kilo-sandbox/package.json | 3 +- packages/kilo-vscode/package.json | 1 + .../src/agent-manager/AgentManagerProvider.ts | 2 +- .../src/agent-manager/browser-lifecycle.ts | 57 +- .../src/agent-manager/browser-message.ts | 93 +- .../kilo-vscode/src/agent-manager/host.ts | 4 +- .../kilo-vscode/src/agent-manager/types.ts | 18 + .../src/agent-manager/vscode-host.ts | 17 +- .../browser-automation/browser-broker.ts | 474 +++++--- .../browser-automation/browser-network.ts | 437 +++++++ .../browser-automation/browser-policy.ts | 30 + .../browser-automation/browser-proxy.ts | 553 +++++++++ .../browser-automation/browser-stream.ts | 645 ++++++++++ .../src/services/browser-automation/index.ts | 2 +- .../kilo-vscode/src/shared/browser-stream.ts | 52 + .../tests/fixtures/browser-network-tls.ts | 16 + .../tests/fixtures/browser-panel-render.tsx | 78 +- .../tests/unit/agent-manager-tab-bar.test.ts | 9 +- .../tests/unit/browser-broker.test.ts | 655 ++++++++-- .../tests/unit/browser-controller.test.ts | 48 + .../tests/unit/browser-message.test.ts | 310 ++++- .../tests/unit/browser-network.test.ts | 1077 +++++++++++++++++ .../tests/unit/browser-policy.test.ts | 63 + .../tests/unit/browser-proxy.test.ts | 772 ++++++++++++ .../tests/unit/browser-stream-input.test.ts | 237 ++++ .../tests/unit/browser-stream.test.ts | 993 +++++++++++++++ .../webview-ui/agent-manager/BrowserPanel.tsx | 33 + .../webview-ui/agent-manager/i18n/ar.ts | 13 +- .../webview-ui/agent-manager/i18n/br.ts | 13 +- .../webview-ui/agent-manager/i18n/bs.ts | 13 +- .../webview-ui/agent-manager/i18n/da.ts | 14 +- .../webview-ui/agent-manager/i18n/de.ts | 14 +- .../webview-ui/agent-manager/i18n/en.ts | 13 +- .../webview-ui/agent-manager/i18n/es.ts | 13 +- .../webview-ui/agent-manager/i18n/fa.ts | 13 +- .../webview-ui/agent-manager/i18n/fr.ts | 14 +- .../webview-ui/agent-manager/i18n/it.ts | 14 +- .../webview-ui/agent-manager/i18n/ja.ts | 13 +- .../webview-ui/agent-manager/i18n/ko.ts | 13 +- .../webview-ui/agent-manager/i18n/nl.ts | 13 +- .../webview-ui/agent-manager/i18n/no.ts | 13 +- .../webview-ui/agent-manager/i18n/pl.ts | 14 +- .../webview-ui/agent-manager/i18n/ru.ts | 14 +- .../webview-ui/agent-manager/i18n/th.ts | 13 +- .../webview-ui/agent-manager/i18n/tr.ts | 13 +- .../webview-ui/agent-manager/i18n/uk.ts | 14 +- .../webview-ui/agent-manager/i18n/zh.ts | 11 +- .../webview-ui/agent-manager/i18n/zht.ts | 11 +- .../webview-ui/browser/BrowserPanel.tsx | 88 +- .../webview-ui/browser/StreamViewport.tsx | 556 +++++++++ .../webview-ui/browser/browser.css | 16 +- .../webview-ui/browser/controller.ts | 13 +- .../webview-ui/browser/stream-input.ts | 200 +++ .../kilo-vscode/webview-ui/browser/stream.css | 42 + .../kilo-vscode/webview-ui/browser/types.ts | 19 + .../kilo-vscode/webview-ui/src/i18n/ar.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/br.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/bs.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/da.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/de.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/en.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/es.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/fa.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/fr.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/it.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/ja.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/ko.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/nl.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/no.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/pl.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/ru.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/th.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/tr.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/uk.ts | 3 +- .../kilo-vscode/webview-ui/src/i18n/zh.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/zht.ts | 3 +- .../src/types/messages/extension-messages.ts | 9 + .../src/types/messages/webview-messages.ts | 10 + .../src/kilocode/tool/browser-open.ts | 33 +- .../src/kilocode/tool/browser-open.txt | 22 +- .../test/kilocode/sandbox/http-tools.test.ts | 75 +- 84 files changed, 7650 insertions(+), 459 deletions(-) create mode 100644 .changeset/agent-manager-streamed-browser.md create mode 100644 packages/kilo-vscode/src/services/browser-automation/browser-network.ts create mode 100644 packages/kilo-vscode/src/services/browser-automation/browser-policy.ts create mode 100644 packages/kilo-vscode/src/services/browser-automation/browser-proxy.ts create mode 100644 packages/kilo-vscode/src/services/browser-automation/browser-stream.ts create mode 100644 packages/kilo-vscode/src/shared/browser-stream.ts create mode 100644 packages/kilo-vscode/tests/fixtures/browser-network-tls.ts create mode 100644 packages/kilo-vscode/tests/unit/browser-network.test.ts create mode 100644 packages/kilo-vscode/tests/unit/browser-policy.test.ts create mode 100644 packages/kilo-vscode/tests/unit/browser-proxy.test.ts create mode 100644 packages/kilo-vscode/tests/unit/browser-stream-input.test.ts create mode 100644 packages/kilo-vscode/tests/unit/browser-stream.test.ts create mode 100644 packages/kilo-vscode/webview-ui/browser/StreamViewport.tsx create mode 100644 packages/kilo-vscode/webview-ui/browser/stream-input.ts create mode 100644 packages/kilo-vscode/webview-ui/browser/stream.css 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 7911d1bdf00b..fb14460a45e9 100644 --- a/bun.lock +++ b/bun.lock @@ -451,6 +451,7 @@ "@kilocode/kilo-memory": "workspace:*", "@kilocode/kilo-ui": "workspace:*", "@kilocode/plugin": "workspace:*", + "@kilocode/sandbox": "workspace:*", "@kilocode/sdk": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 12958155663b..40995f095510 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -204,6 +204,8 @@ - +- + - - diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index f04329e21a18..c6d8a30fd505 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-vscode/package.json b/packages/kilo-vscode/package.json index 8d3b04f94704..fa7cdcfefa2b 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1315,6 +1315,7 @@ "@kilocode/kilo-memory": "workspace:*", "@kilocode/kilo-ui": "workspace:*", "@kilocode/plugin": "workspace:*", + "@kilocode/sandbox": "workspace:*", "@kilocode/sdk": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index f33a63c4b7c2..a85ed8903102 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -391,7 +391,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..9130ee603762 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,21 @@ 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.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 +195,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 +230,7 @@ export function browserMessage(state: BrowserState): AgentManagerOutMessage { errors: state.errors, logs: state.logs, error: state.error, + missing: state.missing, frameError: state.frameError, } } diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 134b7833a54e..5fb71e1da83e 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -132,6 +132,7 @@ export interface Host { /** Whether the experimental multi-project Agent Manager mode is enabled. */ multiProject(): boolean browserAutomation(): boolean + approveBrowserNavigation?(origin: string): Promise /** Read the persisted additional-project registry payload. */ readProjects(): unknown @@ -171,7 +172,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/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index ada84e90edc8..6b84c5ea671b 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -21,6 +21,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 } @@ -454,6 +455,7 @@ interface BrowserStateMessage { errors: number logs?: string[] error?: string + missing?: "chrome" | "chromium" frameError?: string } @@ -470,6 +472,12 @@ interface BrowserInspectionMessage { hover?: boolean } +interface BrowserFrameMessage extends BrowserFrame { + type: "agentManager.browserFrame" + projectId?: string + sessionId: string +} + interface BrowserDevtoolsMessage { type: "agentManager.browserDevtools" browserId: string @@ -520,6 +528,7 @@ export type AgentManagerOutMessage = | BrowserStateMessage | BrowserInspectionMessage | BrowserDevtoolsMessage + | BrowserFrameMessage | RunStatusMessage | TerminalCreatedMessage | TerminalRestartedMessage @@ -1124,9 +1133,18 @@ interface BrowserRequestIn { | "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 c651ee896db8..e7312df7e178 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -259,6 +259,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" + } + readProjects(): unknown { return this.context.globalState.get("agentManager.projects") } @@ -328,8 +337,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 7556fd28c656..805912437986 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,26 @@ 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 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 } 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,6 +46,7 @@ export interface BrowserState { errors: number logs?: string[] error?: string + missing?: "chrome" | "chromium" frameError?: string } @@ -64,11 +81,15 @@ 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 } @@ -77,9 +98,34 @@ 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 +} + +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 and try again." + : missing === "chromium" + ? "A compatible Playwright Chromium browser was not found. Install one or enable Use System Chrome in Kilo Settings > Web Tools." + : `The browser could not start. ${detail}`, + { cause }, + ) + this.name = "BrowserLaunchError" + } } class BrowserNavigationError extends Error { @@ -101,16 +147,20 @@ 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 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)) { + const launch = error instanceof BrowserLaunchError + if (!launch && /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." + 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 +189,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 +217,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 +278,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 +295,92 @@ 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 + if (!entry.stream) { + entry.stream = new BrowserStream( + entry.page, + () => ({ browserId: entry.browserId, navigation: entry.state.navigation }), + (frame) => { + if (!this.view(sessionId, projectId, frame) || entry.viewport?.active !== true) return + for (const viewer of this.viewers) viewer({ ...frame, projectId, sessionId }) + }, + this.opts.log, + ) + } + entry.viewport = { ...viewport } + await entry.stream.configure(viewport) + } + + 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.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 +390,73 @@ 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.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 +472,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 +572,62 @@ export class BrowserBroker { }) } - refresh(sessionId: string, projectId?: string): Promise { + 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) + 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) + 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,6 +644,7 @@ export class BrowserBroker { this.server = undefined this.port = undefined this.listeners.clear() + this.viewers.clear() } dispose(): void { @@ -461,9 +655,27 @@ export class BrowserBroker { if (this.closed) throw new Error("Browser broker is closed") if (this.browser) return this.browser if (this.browserStarting) return this.browserStarting + const system = this.opts.useSystemChrome?.() !== false this.browserStarting = (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 @@ -472,12 +684,19 @@ export class BrowserBroker { try { return await this.browserStarting } catch (error) { + const gateway = this.gateway + this.gateway = undefined + if (gateway) { + await gateway.close().catch((failure: unknown) => this.opts.log("Browser proxy close failed", failure)) + this.proxies.delete(gateway) + } 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 > Web Tools." - : "Install Google Chrome or select an existing Playwright Chromium browser in Kilo Settings > Web Tools." - throw new Error(`${action} ${detail}`.slice(0, 1000), { cause: 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 { this.browserStarting = undefined } @@ -514,46 +733,28 @@ 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)) + if (!entry.navigating) entry.state.navigation++ + void this.update(entry) + .then(() => this.emit(entry.state)) + .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") - }) - 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.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 +765,29 @@ 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 waiting = entry.waiting.size > 0 && TIMEOUT.test(error instanceof Error ? error.message : String(error)) + const failure = waiting + ? new BrowserNavigationError( + "Browser navigation stopped while waiting for approval in VS Code. Dismiss the approval prompt and retry.", + ) + : error + if (waiting) + 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 +809,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,8 +820,14 @@ 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) + if (entry.state.navigation !== navigation || entry.page.url() !== url) return + entry.state.url = url + entry.state.title = title } private async capture(entry: Entry): Promise { @@ -667,32 +884,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..af1a1eedf05e --- /dev/null +++ b/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts @@ -0,0 +1,30 @@ +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") + } + 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..38a7cdfadfc8 --- /dev/null +++ b/packages/kilo-vscode/src/services/browser-automation/browser-proxy.ts @@ -0,0 +1,553 @@ +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 + +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", () => socket.destroy()) + }) + } + + 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`, + () => client.destroy(), + ) + } + + 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 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 { + state.close() + if (!response.headersSent) response.writeHead(403, { 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..86e3fa2489bd --- /dev/null +++ b/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts @@ -0,0 +1,645 @@ +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 + } + + 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 2e2b283357d5..087ad3b1744c 100644 --- a/packages/kilo-vscode/src/services/browser-automation/index.ts +++ b/packages/kilo-vscode/src/services/browser-automation/index.ts @@ -1,4 +1,4 @@ -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-panel-render.tsx b/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx index c3ad9695fbc0..0a2de1060f85 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,8 +33,12 @@ 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", @@ -46,6 +51,13 @@ const [labels, update] = createSignal({ 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 +71,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 +84,70 @@ 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) ;(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 +204,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 +218,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/unit/agent-manager-tab-bar.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tab-bar.test.ts index bfe9af64a759..9bf1ca915452 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 @@ -18,17 +18,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 8fbd650eed64..12889c67f7ec 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,12 +43,15 @@ 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://[::1]:3000")).toThrow() expect(() => broker.validate("http://0.0.0.0:3000")).toThrow() expect(() => broker.validate("http://example.com")).toThrow() expect(() => broker.validate("http://username:password@localhost:3000")).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", ) @@ -85,6 +103,95 @@ 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"), + 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}`), + 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[] = [] @@ -257,8 +364,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 +373,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 +520,24 @@ 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) - }, + 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 +545,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,21 +585,57 @@ describe("BrowserBroker", () => { expect(untrusted.sessions()).toEqual([]) }) - test("explains how to recover when the selected browser runtime is missing", async () => { + 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: () => false, + useSystemChrome: () => system, launch: async () => { - throw new Error("Browser executable does not exist") + attempts++ + throw cause }, }) brokers.push(broker) - await expect( - broker.open({ sessionId: "missing-runtime", directory: "/tmp/project" }, "http://localhost:3000/"), - ).rejects.toThrow("enable Use System Chrome") + const open = () => + broker.open({ sessionId: "missing-runtime", directory: "/tmp/project" }, "http://localhost:3000/") + await expect(open()).rejects.toMatchObject({ name: "BrowserLaunchError", missing, cause }) + 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("rejects unregistered browser sessions before launching Chrome", async () => { const broker = new BrowserBroker({ log: () => {} }) broker.bind(() => undefined) @@ -577,7 +703,7 @@ 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 = { @@ -595,13 +721,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 () => { @@ -632,14 +757,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)) @@ -655,6 +778,126 @@ 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"), + 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"), + 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 @@ -796,54 +1039,250 @@ 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"), + 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"), + 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..0f51834cb8ae --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-network.test.ts @@ -0,0 +1,1077 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" +import { createHash, X509Certificate } from "node:crypto" +import { createSocket } from "node:dgram" +import { once } from "node:events" +import { existsSync } from "node:fs" +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 "../fixtures/browser-network-tls" + +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)) +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.skipIf(!executable)("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}`] : []), + ], + }) + } + + beforeAll(async () => { + gateway = await BrowserProxy.start("deny") + browser = await launch() + }, 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() + }) + + afterAll(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", 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, + ) + }, 20000) + + test("loads public HTTPS modules from a localhost document without granting public navigation", 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) + }, 20000) + + test("a public module redirect cannot contact the approved local origin", 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) + }, 20000) + + test.each(["frame", "data-worker", "blob-worker"])( + "an opaque %s cannot contact the approved local HTTP or WebSocket endpoint", + async (mode) => { + 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) + }, + 15000, + ) + + test.each(["http", "https"])( + "%s origin 401 never receives cached proxy credentials, even with a matching realm", + async (scheme) => { + 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) + }, + 20000, + ) + + test.each([ + ["allow", 301], + ["deny", 301], + ["allow", 302], + ["deny", 302], + ] as const)( + "%s checks approval at every cross-origin %i redirect hop", + async (mode, status) => { + 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]) + }, + 20000, + ) + + test.each(["deny", "allow"] as const)( + "%s preserves the server's native CORS preflight decision before a credentialed mutation", + async (mode) => { + 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([]) + }, + 20000, + ) + + test("public cross-origin scripts, frames, fetch and WSS do not grant navigation permission", 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) + }, 20000) + + test("tracks nested out-of-process frames and closes one context without changing another", 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() + }, 20000) + + test.each(["direct", "noopener", "blank", "new-page"])( + "blocks the first %s popup HTTPS request with cached proxy auth and a warm connection", + async (mode) => { + 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) + }, + 20000, + ) + + test("expires pending broker approval without blaming the server or allowing a late response", 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([]) + }, 45000) + + test("retires routing and pending approvals without leaving cached proxy access", 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([]) + }, 20000) + + test("certificate errors remain errors without the fixture-only certificate pin", 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) + expect(source.contacts()).toBeGreaterThan(0) + }, 20000) + + test.skipIf(process.env.KILO_BROWSER_GOOGLE_SMOKE !== "1").each(["google.com", "www.google.com"])( + "Google HTTPS manual probe: %s", + async (host) => { + 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) + }, + 30000, + ) + + test.skipIf(process.env.KILO_BROWSER_GOOGLE_SMOKE !== "1")( + "Google broker navigation probe", + 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).toStartWith("data:image/jpeg;base64,") + expect(state.error).toBeUndefined() + } + await Promise.all(streaming) + expect(approvals).toContain("https://www.google.com") + expect(logs).toEqual([]) + }, + 60000, + ) + + test("remote pages and workers make no forbidden loopback TCP or UDP contact", 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) + } + }, 20000) + + test("a failed proxy does not fall back to direct loopback HTTP or WebSocket connections", 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) + }, 20000) +}) 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..6074634d8565 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-proxy.test.ts @@ -0,0 +1,772 @@ +import { afterEach, describe, expect, test } from "bun: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 } 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()).toStartWith("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", () => { + 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).toStartWith("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()).toStartWith("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) + }) + + test.each([ + ["8.8.8.8:443", "8.8.8.8", 4], + ["[2606:4700:4700::1111]:443", "2606:4700:4700::1111", 6], + ] as const)("pins literal %s and preserves coalesced CONNECT data without SNI", async (host, address, family) => { + 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) + }) + + test.each(["disposal", "disconnect"])("%s cancels a stalled dial without trying later candidates", async (mode) => { + 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) + }) + + test.each(["disposal", "disconnect"])("%s prevents delayed DNS from opening a socket", async (mode) => { + 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) + }) + + test.each(["end", "disconnect", "disposal", "error"])( + "%s closes a local HTTP stream after it outlives the setup deadline", + async (mode) => { + 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()).toStartWith("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("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()) + 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/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..6b0c30189ec3 --- /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, AltGraph, 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: 3 }, + { 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 f5d58d27ef1a..3c786ec739af 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx @@ -69,6 +69,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, @@ -79,6 +99,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), @@ -91,6 +114,7 @@ function event(message: ExtensionMessage): BrowserEvent | undefined { errors: message.errors, logs: message.logs, error: message.error, + missing: message.missing, frameError: message.frameError, } return { type: "state", value } @@ -146,6 +170,13 @@ function BrowserAdapter(props: { 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 }), @@ -166,6 +197,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 fc489376fa74..3a6d70312a63 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -254,14 +254,23 @@ export const dict = { "agentManager.import.noBranchesHint": "الصق رابط PR أعلاه أو أنشئ Worktree جديدًا.", "agentManager.import.failed": "فشل الاستيراد", "agentManager.browser.title": "المتصفح", - "agentManager.browser.url": "URL التطبيق المحلي", + "agentManager.browser.url": "العنوان", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "فتح", "agentManager.browser.refresh": "تحديث المتصفح", "agentManager.browser.close": "إغلاق المتصفح", "agentManager.browser.inspect": "تحديد عنصر", "agentManager.browser.devtoolsTitle": "أدوات المطوّرين", - "agentManager.browser.empty": "افتح تطبيقًا محليًا لمعاينته هنا.", + "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}}", 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 e438dcea0f5b..873c8641ce0e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -259,14 +259,23 @@ export const dict = { "agentManager.import.noBranchesHint": "Cole uma URL de PR acima ou crie um novo Worktree.", "agentManager.import.failed": "Falha na importação", "agentManager.browser.title": "Navegador", - "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.close": "Fechar navegador", - "agentManager.browser.empty": "Abra uma aplicação local para visualizá-la aqui.", + "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 a Automação do navegador 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": "Selecione primeiro uma sessão do Agent Manager.", "agentManager.browser.screenshotAlt": "Página atual do navegador", "agentManager.browser.errors": "Problemas do navegador: {{count}}", 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 6aa988b1fc19..c54fe1208e7c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -257,14 +257,23 @@ export const dict = { "agentManager.import.noBranchesHint": "Zalijepite PR URL iznad ili kreirajte novi Worktree.", "agentManager.import.failed": "Uvoz neuspješan", "agentManager.browser.title": "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.close": "Zatvori preglednik", - "agentManager.browser.empty": "Otvorite lokalnu aplikaciju da biste je ovdje pregledali.", + "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 funkciju „Automatizacija preglednika“, 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": "Najprije odaberite sesiju aplikacije Agent Manager.", "agentManager.browser.screenshotAlt": "Trenutna stranica preglednika", "agentManager.browser.errors": "Problemi preglednika: {{count}}", 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 efc6d1b38e86..b648651c0611 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -259,14 +259,24 @@ export const dict = { "agentManager.import.noBranchesHint": "Indsæt en PR URL ovenfor eller opret et nyt Worktree.", "agentManager.import.failed": "Import mislykkedes", "agentManager.browser.title": "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.close": "Luk browser", - "agentManager.browser.empty": "Åbn en lokal applikation for at få vist en forhåndsvisning her.", + "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 Browserautomatisering, 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": "Vælg først en session i Agent Manager.", "agentManager.browser.screenshotAlt": "Aktuel browserside", "agentManager.browser.errors": "Browserproblemer: {{count}}", 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 793952d08142..14463ce57792 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -261,14 +261,24 @@ export const dict = { "agentManager.import.noBranchesHint": "Fügen Sie oben eine PR-URL ein oder erstellen Sie einen neuen Worktree.", "agentManager.import.failed": "Import fehlgeschlagen", "agentManager.browser.title": "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.close": "Browser schließen", - "agentManager.browser.empty": "Öffnen Sie eine lokale Anwendung, um sie hier in der Vorschau anzuzeigen.", + "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 die Browserautomatisierung 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": "Wählen Sie zuerst eine Sitzung im Agent Manager aus.", "agentManager.browser.screenshotAlt": "Aktuelle Browserseite", "agentManager.browser.errors": "Browserprobleme: {{count}}", 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 d5059aefd321..0139eece7e70 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -247,14 +247,23 @@ export const dict = { "agentManager.documents.source": "Show source", "agentManager.documents.comments": "{{count}} comments", "agentManager.browser.title": "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.close": "Close browser", - "agentManager.browser.empty": "Open a local application to preview it here.", + "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 Browser Automation, 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": "Select an Agent Manager session first.", "agentManager.browser.screenshotAlt": "Current browser page", "agentManager.browser.errors": "Browser issues: {{count}}", 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 0a632cc61084..0813cbb5057b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -260,14 +260,23 @@ export const dict = { "agentManager.import.noBranchesHint": "Pega una URL de PR arriba o crea un nuevo Worktree.", "agentManager.import.failed": "Error en la importación", "agentManager.browser.title": "Navegador", - "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.close": "Cerrar navegador", - "agentManager.browser.empty": "Abre una aplicación local para previsualizarla aquí.", + "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 «Automatización del navegador» 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": "Selecciona primero una sesión de Agent Manager.", "agentManager.browser.screenshotAlt": "Página actual del navegador", "agentManager.browser.errors": "Problemas del navegador: {{count}}", 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 22a9f0b595a9..ffd611032c5e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -261,14 +261,23 @@ export const dict = { "agentManager.import.noBranchesHint": "یک URL درخواست ادغام را در بالا جای‌گذاری کنید یا یک worktree جدید بسازید.", "agentManager.import.failed": "وارد کردن ناموفق بود", "agentManager.browser.title": "مرورگر", - "agentManager.browser.url": "URL برنامه محلی", + "agentManager.browser.url": "نشانی", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "باز کردن", "agentManager.browser.refresh": "بازخوانی مرورگر", "agentManager.browser.close": "بستن مرورگر", "agentManager.browser.inspect": "انتخاب عنصر", "agentManager.browser.devtoolsTitle": "ابزارهای توسعه", - "agentManager.browser.empty": "برای پیش‌نمایش، یک برنامه محلی را باز کنید.", + "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}}", 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 5514a95d5b5d..3b26c758b86c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -261,14 +261,24 @@ export const dict = { "agentManager.import.noBranchesHint": "Collez une URL de PR ci-dessus ou créez un nouveau Worktree.", "agentManager.import.failed": "Échec de l'importation", "agentManager.browser.title": "Navigateur", - "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.close": "Fermer le navigateur", - "agentManager.browser.empty": "Ouvrez une application locale pour l'afficher ici en aperçu.", + "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 « Automatisation du navigateur », 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": "Sélectionnez d'abord une session dans Agent Manager.", "agentManager.browser.screenshotAlt": "Page actuelle du navigateur", "agentManager.browser.errors": "Problèmes du navigateur : {{count}}", 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 706737f68f4d..a71205f20d30 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -266,14 +266,24 @@ export const dict = { "agentManager.import.noBranchesHint": "Incolla un URL PR sopra o crea un nuovo worktree.", "agentManager.import.failed": "Importazione non riuscita", "agentManager.browser.title": "Browser", - "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.close": "Chiudi browser", - "agentManager.browser.empty": "Apri un'applicazione locale per visualizzarla qui.", + "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 «Automazione del browser», 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": "Seleziona prima una sessione di Agent Manager.", "agentManager.browser.screenshotAlt": "Pagina corrente del browser", "agentManager.browser.errors": "Problemi del browser: {{count}}", 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 0bcb00715690..c021f946b3d6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -258,14 +258,23 @@ export const dict = { "agentManager.import.noBranchesHint": "上にPR URLを貼り付けるか、新しいWorktreeを作成してください。", "agentManager.import.failed": "インポートに失敗しました", "agentManager.browser.title": "ブラウザー", - "agentManager.browser.url": "ローカルアプリケーション URL", + "agentManager.browser.url": "アドレス", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "開く", "agentManager.browser.refresh": "ブラウザーを更新", "agentManager.browser.close": "ブラウザーを閉じる", "agentManager.browser.inspect": "要素を選択", "agentManager.browser.devtoolsTitle": "開発者ツール", - "agentManager.browser.empty": "ローカルアプリケーションを開くと、ここでプレビューできます。", + "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}} 件", 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 baeb0130795e..aa69c4db8402 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -256,14 +256,23 @@ export const dict = { "agentManager.import.noBranchesHint": "위에 PR URL을 붙여넣거나 새 Worktree를 만드세요.", "agentManager.import.failed": "가져오기 실패", "agentManager.browser.title": "브라우저", - "agentManager.browser.url": "로컬 애플리케이션 URL", + "agentManager.browser.url": "주소", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "열기", "agentManager.browser.refresh": "브라우저 새로 고침", "agentManager.browser.close": "브라우저 닫기", "agentManager.browser.inspect": "요소 선택", "agentManager.browser.devtoolsTitle": "개발자 도구", - "agentManager.browser.empty": "로컬 애플리케이션을 열어 여기에서 미리 보세요.", + "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}}개", 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 2eb7d0921b57..a15a80c6f724 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -263,14 +263,23 @@ export const dict = { "agentManager.import.noBranchesHint": "Plak hierboven een PR URL of maak een nieuwe worktree.", "agentManager.import.failed": "Importeren mislukt", "agentManager.browser.title": "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.close": "Browser sluiten", - "agentManager.browser.empty": "Open een lokale applicatie om deze hier 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 Browserautomatisering 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": "Selecteer eerst een sessie in Agent Manager.", "agentManager.browser.screenshotAlt": "Huidige browserpagina", "agentManager.browser.errors": "Browserproblemen: {{count}}", 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 7dd293b28c48..d9b08ac74d89 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -256,14 +256,23 @@ export const dict = { "agentManager.import.noBranchesHint": "Lim inn en PR URL ovenfor eller opprett et nytt Worktree.", "agentManager.import.failed": "Import mislyktes", "agentManager.browser.title": "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.close": "Lukk nettleser", - "agentManager.browser.empty": "Åpne en lokal applikasjon for å forhåndsvise den her.", + "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 «Nettleserautomatisering», 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": "Velg en økt i Agent Manager først.", "agentManager.browser.screenshotAlt": "Gjeldende nettleserside", "agentManager.browser.errors": "Nettleserproblemer: {{count}}", 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 a74670e319ed..add78e6e0cf1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -257,14 +257,24 @@ export const dict = { "agentManager.import.noBranchesHint": "Wklej URL PR powyżej lub utwórz nowy Worktree.", "agentManager.import.failed": "Import nie powiódł się", "agentManager.browser.title": "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.close": "Zamknij przeglądarkę", - "agentManager.browser.empty": "Otwórz lokalną aplikację, aby wyświetlić ją tutaj.", + "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ć z funkcji „Automatyzacja 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": "Najpierw wybierz sesję aplikacji Agent Manager.", "agentManager.browser.screenshotAlt": "Bieżąca strona przeglądarki", "agentManager.browser.errors": "Problemy przeglądarki: {{count}}", 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 3cab92d86fef..10c9d9ee0001 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -259,14 +259,24 @@ export const dict = { "agentManager.import.noBranchesHint": "Вставьте URL PR выше или создайте новый Worktree.", "agentManager.import.failed": "Ошибка импорта", "agentManager.browser.title": "Браузер", - "agentManager.browser.url": "URL локального приложения", + "agentManager.browser.url": "Адрес", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Открыть", "agentManager.browser.refresh": "Обновить браузер", "agentManager.browser.close": "Закрыть браузер", "agentManager.browser.inspect": "Выбрать элемент", "agentManager.browser.devtoolsTitle": "Инструменты разработчика", - "agentManager.browser.empty": "Откройте локальное приложение, чтобы просмотреть его здесь.", + "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}}", 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 cd14b48bac7c..9a838092bb06 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -252,14 +252,23 @@ export const dict = { "agentManager.import.noBranchesHint": "วาง URL ของ PR ด้านบนหรือสร้าง Worktree ใหม่", "agentManager.import.failed": "นำเข้าล้มเหลว", "agentManager.browser.title": "เบราว์เซอร์", - "agentManager.browser.url": "URL ของแอปพลิเคชันในเครื่อง", + "agentManager.browser.url": "ที่อยู่", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "เปิด", "agentManager.browser.refresh": "รีเฟรชเบราว์เซอร์", "agentManager.browser.close": "ปิดเบราว์เซอร์", "agentManager.browser.inspect": "เลือกองค์ประกอบ", "agentManager.browser.devtoolsTitle": "เครื่องมือสำหรับนักพัฒนา", - "agentManager.browser.empty": "เปิดแอปพลิเคชันในเครื่องเพื่อดูตัวอย่างที่นี่", + "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}} รายการ", 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 98a54388a938..e487b9bbf9d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -265,14 +265,23 @@ export const dict = { "agentManager.import.noBranchesHint": "Yukarıya bir PR URL'si yapıştırın veya yeni bir worktree oluşturun.", "agentManager.import.failed": "İçe aktarma başarısız oldu", "agentManager.browser.title": "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.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.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ı. Tarayıcı Otomasyonu özelliğini 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": "Önce bir Agent Manager oturumu seçin.", "agentManager.browser.screenshotAlt": "Geçerli tarayıcı sayfası", "agentManager.browser.errors": "Tarayıcı sorunları: {{count}}", 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 7ccf063ff04a..da06a4abb665 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -266,14 +266,24 @@ export const dict = { "agentManager.import.noBranchesHint": "Вставте URL PR вище або створіть нове робоче дерево.", "agentManager.import.failed": "Імпортування не вдалося", "agentManager.browser.title": "Браузер", - "agentManager.browser.url": "URL локальної програми", + "agentManager.browser.url": "Адреса", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "Відкрити", "agentManager.browser.refresh": "Оновити браузер", "agentManager.browser.close": "Закрити браузер", "agentManager.browser.inspect": "Вибрати елемент", "agentManager.browser.devtoolsTitle": "Інструменти розробника", - "agentManager.browser.empty": "Відкрийте локальну програму, щоб переглянути її тут.", + "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}}", 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 7c78d8711ce0..495155fb3d90 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -251,14 +251,21 @@ export const dict = { "agentManager.import.failed": "导入失败", "agentManager.browser.title": "浏览器", - "agentManager.browser.url": "本地应用 URL", + "agentManager.browser.url": "地址", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "打开", "agentManager.browser.refresh": "刷新浏览器", "agentManager.browser.close": "关闭浏览器", "agentManager.browser.inspect": "选择元素", "agentManager.browser.devtoolsTitle": "开发者工具", - "agentManager.browser.empty": "打开本地应用以在此处预览。", + "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}}", 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 84dbac1246d4..1b789ac6ae14 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -250,14 +250,21 @@ export const dict = { "agentManager.import.noBranchesHint": "在上方貼上 PR URL 或建立新的 Worktree。", "agentManager.import.failed": "匯入失敗", "agentManager.browser.title": "瀏覽器", - "agentManager.browser.url": "本機應用程式 URL", + "agentManager.browser.url": "位址", "agentManager.browser.urlPlaceholder": "http://localhost:3000", "agentManager.browser.open": "開啟", "agentManager.browser.refresh": "重新整理瀏覽器", "agentManager.browser.close": "關閉瀏覽器", "agentManager.browser.inspect": "選取元素", "agentManager.browser.devtoolsTitle": "開發人員工具", - "agentManager.browser.empty": "開啟本機應用程式,即可在此處預覽。", + "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}}", diff --git a/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx b/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx index eb6bbfc648c9..2ae4432eca94 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" @@ -151,9 +153,12 @@ const Picker: Component<{ const Viewport: Component<{ state?: BrowserState - session?: string + scope: Accessor + transport: BrowserTransport controller: BrowserController labels: BrowserLabels + download: () => void + settings: () => void }> = (props) => { const issue = () => props.state?.frameError || props.state?.error const page = () => @@ -161,10 +166,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) => ( - `, + ) + }) + 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-proxy.ts b/packages/kilo-vscode/tests/fixtures/browser-proxy.ts new file mode 100644 index 000000000000..db2682c7cc5d --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/browser-proxy.ts @@ -0,0 +1,783 @@ +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 } 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("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/browser-network.test.ts b/packages/kilo-vscode/tests/unit/browser-network.test.ts index 35f42c78b688..213eeb16f69e 100644 --- a/packages/kilo-vscode/tests/unit/browser-network.test.ts +++ b/packages/kilo-vscode/tests/unit/browser-network.test.ts @@ -1,18 +1,7 @@ -import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" -import { createHash, X509Certificate } from "node:crypto" -import { createSocket } from "node:dgram" -import { once } from "node:events" +import { expect, test } from "bun:test" import { existsSync } from "node:fs" -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 "../fixtures/browser-network-tls" +import { chromium } from "playwright-core" +import { node } from "../fixtures/node" const executable = [ process.env.CHROME_PATH, @@ -21,1056 +10,20 @@ const executable = [ "/usr/bin/chromium", chromium.executablePath(), ].find((path) => path && existsSync(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.skipIf(!executable)("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}`] : []), - ], - }) - } - - beforeAll(async () => { - gateway = await BrowserProxy.start("deny") - browser = await launch() - }, 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() - }) - - afterAll(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", 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, - ) - }, 20000) - - test("loads public HTTPS modules from a localhost document without granting public navigation", 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) - }, 20000) - - test("a public module redirect cannot contact the approved local origin", 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) - }, 20000) - - test.each(["frame", "data-worker", "blob-worker"])( - "an opaque %s cannot contact the approved local HTTP or WebSocket endpoint", - async (mode) => { - 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) - }, - 15000, - ) - - test.each(["http", "https"])( - "%s origin 401 never receives cached proxy credentials, even with a matching realm", - async (scheme) => { - 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) - }, - 20000, - ) - - test.each([ - ["allow", 301], - ["deny", 301], - ["allow", 302], - ["deny", 302], - ] as const)( - "%s checks approval at every cross-origin %i redirect hop", - async (mode, status) => { - 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]) - }, - 20000, - ) - - test.each(["deny", "allow"] as const)( - "%s preserves the server's native CORS preflight decision before a credentialed mutation", - async (mode) => { - 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([]) - }, - 20000, - ) - - test("public cross-origin scripts, frames, fetch and WSS do not grant navigation permission", 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) - }, 20000) - - test("tracks nested out-of-process frames and closes one context without changing another", 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() - }, 20000) - - test.each(["direct", "noopener", "blank", "new-page"])( - "blocks the first %s popup HTTPS request with cached proxy auth and a warm connection", - async (mode) => { - 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) - }, - 20000, - ) - - test("expires pending broker approval without blaming the server or allowing a late response", 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([]) - }, 45000) - - test("retires routing and pending approvals without leaving cached proxy access", 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([]) - }, 20000) - - test("certificate errors remain errors without the fixture-only certificate pin", 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) - }, 20000) - - test.skipIf(process.env.KILO_BROWSER_GOOGLE_SMOKE !== "1").each(["google.com", "www.google.com"])( - "Google HTTPS manual probe: %s", - async (host) => { - 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) - }, - 30000, - ) - - test.skipIf(process.env.KILO_BROWSER_GOOGLE_SMOKE !== "1")( - "Google broker navigation probe", - 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).toStartWith("data:image/jpeg;base64,") - expect(state.error).toBeUndefined() - } - await Promise.all(streaming) - expect(approvals).toContain("https://www.google.com") - expect(logs).toEqual([]) - }, - 60000, - ) - - test("remote pages and workers make no forbidden loopback TCP or UDP contact", 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) - } - }, 20000) - - test("a failed proxy does not fall back to direct loopback HTTP or WebSocket connections", 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) - }, 20000) -}) +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-proxy.test.ts b/packages/kilo-vscode/tests/unit/browser-proxy.test.ts index c2318e9b8cd8..5dfb4252cf1c 100644 --- a/packages/kilo-vscode/tests/unit/browser-proxy.test.ts +++ b/packages/kilo-vscode/tests/unit/browser-proxy.test.ts @@ -1,776 +1,11 @@ -import { afterEach, describe, expect, test } from "bun: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 } 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 destroyed(response: ServerResponse) { - return response.destroyed ?? response.socket?.destroyed ?? false -} - -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()).toStartWith("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", () => { - 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).toStartWith("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()).toStartWith("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) - }) - - test.each([ - ["8.8.8.8:443", "8.8.8.8", 4], - ["[2606:4700:4700::1111]:443", "2606:4700:4700::1111", 6], - ] as const)("pins literal %s and preserves coalesced CONNECT data without SNI", async (host, address, family) => { - 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) - }) - - test.each(["disposal", "disconnect"])("%s cancels a stalled dial without trying later candidates", async (mode) => { - 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(destroyed(upstream)).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) - }) - - test.each(["disposal", "disconnect"])("%s prevents delayed DNS from opening a socket", async (mode) => { - 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) - }) - - test.each(["end", "disconnect", "disposal", "error"])( - "%s closes a local HTTP stream after it outlives the setup deadline", - async (mode) => { - 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()).toStartWith("HTTP/1.1 200") - expect(destroyed(upstream)).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(destroyed(upstream)).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(destroyed(upstream)).toBe(true) - }) - - 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()) - await once(websocket, "open") - const response = once(websocket, "message") - websocket.send("local websocket") - expect((await response).at(0).toString()).toBe("local websocket") - }) -}) +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 28$/m) + expect(result.stdout).toMatch(/^# pass 28$/m) + expect(result.stdout).toMatch(/^# skipped 0$/m) + expect(result.stdout).toMatch(/^# fail 0$/m) + expect(result.stdout).toMatch(/^# cancelled 0$/m) +}, 30_000) From 4ef8aeb55b8853050e90d9e0d49318035c5eb18a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 25 Sep 2026 10:50:21 +0200 Subject: [PATCH 4/5] fix(agent-manager): recover the Integrated Browser and add history navigation Restart Chromium after it disconnects and replace crashed pages on refresh. Report unreachable local servers instead of a proxy HTTP 403. Serialize agent screenshots with stream resizes so agent-opened pages stream. Add back and forward buttons and open 0.0.0.0 dev server URLs on loopback. --- .../src/agent-manager/browser-message.ts | 11 + .../src/agent-manager/project/state-gate.ts | 2 + .../kilo-vscode/src/agent-manager/types.ts | 4 + .../browser-automation/browser-broker.ts | 202 +++++++++---- .../browser-automation/browser-policy.ts | 2 + .../browser-automation/browser-proxy.ts | 16 +- .../browser-automation/browser-stream.ts | 5 + .../tests/fixtures/browser-panel-render.tsx | 15 + .../tests/fixtures/browser-proxy.ts | 18 +- .../tests/unit/browser-broker.test.ts | 270 +++++++++++++++++- .../tests/unit/browser-proxy.test.ts | 4 +- .../webview-ui/agent-manager/BrowserPanel.tsx | 6 + .../webview-ui/agent-manager/i18n/ar.ts | 2 + .../webview-ui/agent-manager/i18n/br.ts | 2 + .../webview-ui/agent-manager/i18n/bs.ts | 2 + .../webview-ui/agent-manager/i18n/da.ts | 2 + .../webview-ui/agent-manager/i18n/de.ts | 2 + .../webview-ui/agent-manager/i18n/en.ts | 2 + .../webview-ui/agent-manager/i18n/es.ts | 2 + .../webview-ui/agent-manager/i18n/fa.ts | 2 + .../webview-ui/agent-manager/i18n/fr.ts | 2 + .../webview-ui/agent-manager/i18n/it.ts | 2 + .../webview-ui/agent-manager/i18n/ja.ts | 2 + .../webview-ui/agent-manager/i18n/ko.ts | 2 + .../webview-ui/agent-manager/i18n/nl.ts | 2 + .../webview-ui/agent-manager/i18n/no.ts | 2 + .../webview-ui/agent-manager/i18n/pl.ts | 2 + .../webview-ui/agent-manager/i18n/ru.ts | 2 + .../webview-ui/agent-manager/i18n/th.ts | 2 + .../webview-ui/agent-manager/i18n/tr.ts | 2 + .../webview-ui/agent-manager/i18n/uk.ts | 2 + .../webview-ui/agent-manager/i18n/zh.ts | 2 + .../webview-ui/agent-manager/i18n/zht.ts | 2 + .../webview-ui/browser/BrowserPanel.tsx | 20 ++ .../webview-ui/browser/controller.ts | 8 +- .../kilo-vscode/webview-ui/browser/types.ts | 6 + .../src/types/messages/extension-messages.ts | 2 + .../src/types/messages/webview-messages.ts | 2 + .../src/kilocode/tool/browser-open.ts | 6 +- .../src/kilocode/tool/browser-open.txt | 2 +- .../test/kilocode/sandbox/http-tools.test.ts | 35 ++- 41 files changed, 617 insertions(+), 61 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/browser-message.ts b/packages/kilo-vscode/src/agent-manager/browser-message.ts index 9130ee603762..ab3b339fe9d4 100644 --- a/packages/kilo-vscode/src/agent-manager/browser-message.ts +++ b/packages/kilo-vscode/src/agent-manager/browser-message.ts @@ -156,6 +156,15 @@ function action(m: BrowserMessage, deps: Dependencies): boolean { }) 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, false).catch((error: unknown) => { deps.log("Browser refresh failed:", error) @@ -232,5 +241,7 @@ export function browserMessage(state: BrowserState): AgentManagerOutMessage { error: state.error, missing: state.missing, frameError: state.frameError, + back: state.back, + forward: state.forward, } } 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 61e735311c60..d58dd06a048c 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -486,6 +486,8 @@ interface BrowserStateMessage { error?: string missing?: "chrome" | "chromium" frameError?: string + back?: boolean + forward?: boolean } interface BrowserInspectionMessage { @@ -1229,6 +1231,8 @@ interface BrowserRequestIn { type: | "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/services/browser-automation/browser-broker.ts b/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts index 41f0da2dec28..6d504ac91c48 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts @@ -7,6 +7,7 @@ import { chromium, type BrowserContext, type BrowserContextOptions, + type CDPSession, type LaunchOptions, type Page, } from "playwright-core" @@ -15,7 +16,7 @@ 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 } from "./browser-proxy" +import { BrowserProxy, UNREACHABLE } from "./browser-proxy" import { parse } from "./browser-policy" import type { BrowserFrame, @@ -48,6 +49,8 @@ export interface BrowserState { error?: string missing?: "chrome" | "chromium" frameError?: string + back?: boolean + forward?: boolean } export interface BrowserElement { @@ -91,6 +94,7 @@ export interface BrowserContextFactory { debugging?: number newContext(options: BrowserContextOptions): Promise close(): Promise + on?(event: "disconnected", listener: () => void): unknown } interface Entry { @@ -108,6 +112,9 @@ interface Entry { stream?: BrowserStream viewport?: BrowserViewport navigating?: boolean + dead?: boolean + unreachable?: boolean + history?: Promise } export class BrowserLaunchError extends Error { @@ -148,13 +155,25 @@ 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)) const launch = error instanceof BrowserLaunchError - if (!launch && /ERR_CONNECTION_REFUSED|ECONNREFUSED/i.test(text)) { - return `Cannot connect to ${url ?? "the local application"}. Make sure the local server is running.` - } + 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." @@ -311,19 +330,23 @@ export class BrowserBroker { 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 - if (!entry.stream) { - entry.stream = new BrowserStream( - entry.page, - () => ({ browserId: entry.browserId, navigation: entry.state.navigation }), - (frame) => { - if (!this.view(sessionId, projectId, frame) || entry.viewport?.active !== true) return - for (const viewer of this.viewers) viewer({ ...frame, projectId, sessionId }) - }, - this.opts.log, - ) - } entry.viewport = { ...viewport } - await entry.stream.configure(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 { @@ -376,7 +399,13 @@ export class BrowserBroker { ): 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.browserId !== identity.browserId || entry.state.navigation !== identity.navigation) return + 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 } @@ -410,6 +439,7 @@ export class BrowserBroker { throw new Error("Browser session project cannot change") } const replace = + existing.dead === true || existing.network?.active === false || existing.local !== (url.protocol === "http:") || (existing.local && existing.origin !== url.origin) @@ -572,11 +602,32 @@ export class BrowserBroker { }) } + 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) + 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) }) @@ -608,6 +659,9 @@ export class BrowserBroker { 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() @@ -651,12 +705,12 @@ export class BrowserBroker { 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 const system = this.opts.useSystemChrome?.() !== false - this.browserStarting = (async () => { + const starting = (async () => { const port = this.opts.launch ? undefined : await reserve() const base = options(system, port) const gateway = await BrowserProxy.start("deny") @@ -679,27 +733,62 @@ export class BrowserBroker { 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 gateway = this.gateway - this.gateway = undefined - if (gateway) { - await gateway.close().catch((failure: unknown) => this.opts.log("Browser proxy close failed", failure)) - this.proxies.delete(gateway) - } - 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 { - 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 { @@ -712,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) => { @@ -725,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") @@ -749,6 +840,7 @@ export class BrowserBroker { 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 @@ -775,13 +867,8 @@ export class BrowserBroker { entry.state.status = "ready" this.emit(entry.state) } catch (error) { - const waiting = entry.waiting.size > 0 && TIMEOUT.test(error instanceof Error ? error.message : String(error)) - const failure = waiting - ? new BrowserNavigationError( - "Browser navigation stopped while waiting for approval in VS Code. Dismiss the approval prompt and retry.", - ) - : error - if (waiting) + 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, failure) @@ -825,13 +912,32 @@ export class BrowserBroker { 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" diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts b/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts index af1a1eedf05e..f64d25da2b86 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts @@ -10,6 +10,8 @@ export function parse(value: string): URL { 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 loopback. + if (url.protocol === "http:" && url.hostname === "0.0.0.0") url.hostname = "127.0.0.1" if (url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)) { 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 index 1aba14446b98..e92c60fa7d83 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-proxy.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-proxy.ts @@ -24,6 +24,11 @@ interface Options { 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") @@ -403,7 +408,7 @@ export class BrowserProxy { attempt.close() } } - throw err + throw new Unreachable("Browser proxy could not connect", { cause: err }) } private tunnel(request: IncomingMessage, client: Duplex, head: Buffer) { @@ -491,9 +496,14 @@ export class BrowserProxy { response.end() }) request.pipe(upstream) - } catch { + } catch (error) { state.close() - if (!response.headersSent) response.writeHead(403, { connection: "close" }) + if (!response.headersSent) { + response.writeHead( + error instanceof Unreachable ? 502 : 403, + error instanceof Unreachable ? { connection: "close", [UNREACHABLE]: "1" } : { connection: "close" }, + ) + } response.end() } } diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts b/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts index 86e3fa2489bd..96ed4815622d 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-stream.ts @@ -390,6 +390,11 @@ export class BrowserStream { 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 diff --git a/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx b/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx index 0a2de1060f85..36f868ebaade 100644 --- a/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx +++ b/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx @@ -45,6 +45,8 @@ const [labels, update] = createSignal({ urlPlaceholder: "Local URL", open: "Go", refresh: "Reload", + back: "Back", + forward: "Forward", close: "Close", inspect: "Select element", devtoolsTitle: "Developer tools", @@ -132,6 +134,19 @@ receive?.({ type: "state", value: { ...state, logs: ["[info] Updated"] } }) 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 } }) diff --git a/packages/kilo-vscode/tests/fixtures/browser-proxy.ts b/packages/kilo-vscode/tests/fixtures/browser-proxy.ts index db2682c7cc5d..3af2dbb40af7 100644 --- a/packages/kilo-vscode/tests/fixtures/browser-proxy.ts +++ b/packages/kilo-vscode/tests/fixtures/browser-proxy.ts @@ -10,7 +10,7 @@ import { } from "node:http" import { connect, createServer, type Server, Socket } from "node:net" import WebSocket, { WebSocketServer } from "ws" -import { BrowserProxy } from "../../src/services/browser-automation/browser-proxy" +import { BrowserProxy, UNREACHABLE } from "../../src/services/browser-automation/browser-proxy" const cleanup: Array<() => Promise | void> = [] @@ -674,6 +674,22 @@ describe("browser proxy", { concurrency: false, timeout: 15_000 }, () => { 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"), { diff --git a/packages/kilo-vscode/tests/unit/browser-broker.test.ts b/packages/kilo-vscode/tests/unit/browser-broker.test.ts index 483dd990bab1..3d5c835f7e43 100644 --- a/packages/kilo-vscode/tests/unit/browser-broker.test.ts +++ b/packages/kilo-vscode/tests/unit/browser-broker.test.ts @@ -52,7 +52,7 @@ describe("BrowserBroker", () => { 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() - expect(() => broker.validate("http://0.0.0.0:3000")).toThrow() + expect(broker.validate("http://0.0.0.0:3000/path?q=1").href).toBe("http://127.0.0.1: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() @@ -81,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 () => { @@ -119,6 +120,7 @@ describe("BrowserBroker", () => { url: () => "about:blank", title: async () => "", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: () => undefined, mainFrame: () => undefined, goto: async () => { @@ -168,6 +170,7 @@ describe("BrowserBroker", () => { url: () => url, title: async () => "Local fixture", screenshot: async () => Buffer.from(`shot-${++shots}`), + off: () => undefined, on: () => undefined, mainFrame: () => undefined, goto: async (value: string) => { @@ -200,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) }, @@ -344,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, @@ -532,6 +537,7 @@ describe("BrowserBroker", () => { url: () => "http://localhost:3000/", title: async () => "Local app", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: () => undefined, mainFrame: () => undefined, goto: async () => undefined, @@ -643,6 +649,259 @@ describe("BrowserBroker", () => { 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 } + }, + 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: () => {} }) broker.bind(() => undefined) @@ -661,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) => { @@ -717,6 +977,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) => { @@ -751,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) => { @@ -813,6 +1075,7 @@ describe("BrowserBroker", () => { url: () => target, title: async () => "Application", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: () => undefined, mainFrame: () => undefined, goto: async (url: string) => void (target = url), @@ -879,6 +1142,7 @@ describe("BrowserBroker", () => { url: () => target, title: async () => "Application", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: () => undefined, mainFrame: () => undefined, goto: async (url: string) => void (target = url), @@ -948,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) }, @@ -1019,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) }, @@ -1236,6 +1502,7 @@ describe("BrowserBroker", () => { url: () => target, title: async () => "Application", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: () => undefined, mainFrame: () => undefined, goto: async (url: string) => void (target = url), @@ -1271,6 +1538,7 @@ describe("BrowserBroker", () => { url: () => target, title: async () => "Application", screenshot: async () => Buffer.from("jpeg"), + off: () => undefined, on: () => undefined, mainFrame: () => undefined, goto: async (url: string) => void (target = url), diff --git a/packages/kilo-vscode/tests/unit/browser-proxy.test.ts b/packages/kilo-vscode/tests/unit/browser-proxy.test.ts index 5dfb4252cf1c..dc122473f0e2 100644 --- a/packages/kilo-vscode/tests/unit/browser-proxy.test.ts +++ b/packages/kilo-vscode/tests/unit/browser-proxy.test.ts @@ -3,8 +3,8 @@ 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 28$/m) - expect(result.stdout).toMatch(/^# pass 28$/m) + 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) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx index 81db73844479..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") { @@ -127,6 +129,8 @@ function event(message: ExtensionMessage): BrowserEvent | undefined { error: message.error, missing: message.missing, frameError: message.frameError, + back: message.back, + forward: message.forward, } return { type: "state", value } } @@ -175,6 +179,8 @@ 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"), 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 01de3e365d57..3d9f4399ba43 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -445,6 +445,8 @@ export const dict = { "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": "أدوات المطوّرين", 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 352f79aa88ae..fa5d42c8350a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -456,6 +456,8 @@ export const dict = { "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 ou uma página HTTPS pública para visualizá-la aqui.", "agentManager.browser.requirement": 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 91f519724248..9b4af789dea0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -452,6 +452,8 @@ export const dict = { "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 ili javnu HTTPS stranicu da biste je ovdje pregledali.", "agentManager.browser.requirement": 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 02c994193efd..4f598b80576f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -454,6 +454,8 @@ export const dict = { "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 eller en offentlig HTTPS-side for at få vist en forhåndsvisning her.", 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 6498ba6d4214..200de6fa07ca 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -460,6 +460,8 @@ export const dict = { "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 oder eine öffentliche HTTPS-Seite, um sie hier in der Vorschau anzuzeigen.", 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 1c10e87a31fa..2ad12ca80aeb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -480,6 +480,8 @@ export const dict = { "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 or a public HTTPS page to preview it here.", "agentManager.browser.requirement": 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 3d045cc616be..bdf59925bf1c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -459,6 +459,8 @@ export const dict = { "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 o una página HTTPS pública para previsualizarla aquí.", "agentManager.browser.requirement": 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 c2ad940835b9..bc71e8218b40 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -453,6 +453,8 @@ export const dict = { "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": "ابزارهای توسعه", 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 9f8d62c7451a..94b99f08b5d0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -459,6 +459,8 @@ export const dict = { "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 ou une page HTTPS publique pour en afficher un aperçu ici.", 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 0f2d220c9043..353fd990f4cc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -465,6 +465,8 @@ export const dict = { "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 o una pagina HTTPS pubblica per visualizzarne qui l'anteprima.", 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 6ba71464fb07..ef1548084dcb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -450,6 +450,8 @@ export const dict = { "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": "開発者ツール", 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 17b7d844e002..124c7099a827 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -448,6 +448,8 @@ export const dict = { "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": "개발자 도구", 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 e76473d1d731..df0b09357323 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -461,6 +461,8 @@ export const dict = { "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 of een openbare HTTPS-pagina om deze hier te bekijken.", "agentManager.browser.requirement": 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 e9f05bd52735..43d5e286a6ef 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -452,6 +452,8 @@ export const dict = { "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 eller en offentlig HTTPS-side for å forhåndsvise den her.", "agentManager.browser.requirement": 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 8c30ca0b5ae5..7bb87261bd1d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -453,6 +453,8 @@ export const dict = { "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ę lub publiczną stronę HTTPS, aby wyświetlić jej podgląd tutaj.", 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 040eb0cb3103..2b6c427507ef 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -453,6 +453,8 @@ export const dict = { "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": "Инструменты разработчика", 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 f293bb7f9a16..6be723eda0a3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -442,6 +442,8 @@ export const dict = { "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": "เครื่องมือสำหรับนักพัฒนา", 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 be5ee7d2b0dc..55ef29de2bfe 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -459,6 +459,8 @@ export const dict = { "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ı", 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 a56cc273e6bf..acd3a9ba60b4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -461,6 +461,8 @@ export const dict = { "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": "Інструменти розробника", 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 cbdc3e043334..a8ac3da38bb8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -438,6 +438,8 @@ export const dict = { "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": "开发者工具", 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 3bcee8471701..b27b17c5a355 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -437,6 +437,8 @@ export const dict = { "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": "開發人員工具", diff --git a/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx b/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx index 2ae4432eca94..e21ac23aae35 100644 --- a/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx +++ b/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx @@ -33,6 +33,26 @@ const Toolbar: Component<{ const ready = () => !!props.controller.state()?.url && props.controller.state()?.status !== "closed" return (
+ + + + + + void open: () => void refresh: () => void + back: () => void + forward: () => void close: () => void toggleSelecting: () => void toggleTools: () => void @@ -42,7 +44,7 @@ export interface BrowserController { function address(value: string): string { if (/^[a-z][a-z\d+.-]*:\/\//i.test(value)) return value const host = value.replace(/^\/\//, "") - const scheme = /^(?:localhost|127\.0\.0\.1)(?=[:/?#]|$)/i.test(host) ? "http" : "https" + const scheme = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?=[:/?#]|$)/i.test(host) ? "http" : "https" return `${scheme}://${host}` } @@ -91,7 +93,7 @@ export function createBrowserController(props: BrowserControllerOptions): Browse props.transport.send(command) } - const request = (type: "refresh" | "close" | "state") => { + const request = (type: "refresh" | "back" | "forward" | "close" | "state") => { sync() if (!current) return send({ type, scope: current }) @@ -243,6 +245,8 @@ export function createBrowserController(props: BrowserControllerOptions): Browse send({ type: "open", scope: current, url: target }) }, refresh: () => request("refresh"), + back: () => request("back"), + forward: () => request("forward"), close: () => { if (!sync()) return stop() diff --git a/packages/kilo-vscode/webview-ui/browser/types.ts b/packages/kilo-vscode/webview-ui/browser/types.ts index 83bf8bfe6d33..ab73ec8d2c86 100644 --- a/packages/kilo-vscode/webview-ui/browser/types.ts +++ b/packages/kilo-vscode/webview-ui/browser/types.ts @@ -43,6 +43,8 @@ export interface BrowserState { error?: string missing?: "chrome" | "chromium" frameError?: string + back?: boolean + forward?: boolean } export interface BrowserInspection { @@ -65,6 +67,8 @@ export interface BrowserDevtools { export type BrowserCommand = | { type: "open"; scope: BrowserScope; url: string } | { type: "refresh"; scope: BrowserScope } + | { type: "back"; scope: BrowserScope } + | { type: "forward"; scope: BrowserScope } | { type: "close"; scope: BrowserScope } | { type: "state"; scope: BrowserScope } | { type: "inspect"; scope: BrowserScope; position: BrowserPosition; hover: boolean; requestId: string } @@ -91,6 +95,8 @@ export interface BrowserLabels { urlPlaceholder: string open: string refresh: string + back: string + forward: string close: string inspect: string devtoolsTitle: string diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index eb3d4e2c60e5..7eb4e422da23 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -1606,6 +1606,8 @@ export interface AgentManagerBrowserStateMessage { error?: string missing?: "chrome" | "chromium" frameError?: string + back?: boolean + forward?: boolean } export interface AgentManagerBrowserInspectionMessage { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index b5f8d9dea31c..98c0a9b80b78 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -1371,6 +1371,8 @@ export interface AgentManagerBrowserRequestMessage { type: | "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/opencode/src/kilocode/tool/browser-open.ts b/packages/opencode/src/kilocode/tool/browser-open.ts index 85f3dfd38609..5b447a9dcb6b 100644 --- a/packages/opencode/src/kilocode/tool/browser-open.ts +++ b/packages/opencode/src/kilocode/tool/browser-open.ts @@ -74,6 +74,8 @@ export const BrowserOpenTool = Tool.define< } const url = URL.parse(params.url) + // Dev servers print 0.0.0.0 when they listen on every interface. It is not a destination, so use loopback. + if (url?.protocol === "http:" && url.hostname === "0.0.0.0") url.hostname = "127.0.0.1" if (!url) { return { title: "Browser URL invalid", @@ -101,7 +103,7 @@ export const BrowserOpenTool = Tool.define< permission: "browser_open", patterns: [`navigate:${url.origin}`], always: [], - metadata: { operation: "open", url: params.url }, + metadata: { operation: "open", url: url.href }, }) return yield* Effect.gen(function* () { @@ -109,7 +111,7 @@ export const BrowserOpenTool = Tool.define< http, broker, token, - { sessionID: ctx.sessionID, directory: instance.directory, url: params.url }, + { sessionID: ctx.sessionID, directory: instance.directory, url: url.href }, ctx.abort, ) if (response.status < 200 || response.status >= 300) { diff --git a/packages/opencode/src/kilocode/tool/browser-open.txt b/packages/opencode/src/kilocode/tool/browser-open.txt index dff3a79ff64a..61b0ed214e6a 100644 --- a/packages/opencode/src/kilocode/tool/browser-open.txt +++ b/packages/opencode/src/kilocode/tool/browser-open.txt @@ -6,7 +6,7 @@ browser instead of launching another browser. Native select menus on macOS are not captured by the streamed view. HTTP URLs on localhost or 127.0.0.1 and public HTTPS URLs are supported. Use localhost -for IPv6 loopback servers. Local applications can load public HTTPS resources and +for IPv6 loopback servers. HTTP URLs on 0.0.0.0 open as 127.0.0.1. Local applications can load public HTTPS resources and secure WebSockets, while HTTP and local WebSockets stay on the approved loopback origin. Local preview documents must stay on that origin. Public pages can load public HTTPS resources and secure WebSockets across origins, but cannot access diff --git a/packages/opencode/test/kilocode/sandbox/http-tools.test.ts b/packages/opencode/test/kilocode/sandbox/http-tools.test.ts index b86d153ad690..8deb264436f1 100644 --- a/packages/opencode/test/kilocode/sandbox/http-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/http-tools.test.ts @@ -203,6 +203,39 @@ describe("model HTTP tool network policy", () => { }).pipe(Effect.scoped) }) + it.instance("opens a 0.0.0.0 dev server address on loopback", () => { + const patterns: string[] = [] + return Effect.gen(function* () { + const server = yield* serve(async (request) => { + expect(await request.json()).toMatchObject({ url: "http://127.0.0.1:3018/app?x=1" }) + return Response.json({ + browserId: "browser-test", + sessionId: ctx.sessionID, + status: "ready", + url: "http://127.0.0.1:3018/app?x=1", + errors: 0, + }) + }) + const env = yield* Env.Service + yield* env.set("KILO_BROWSER_BROKER_URL", server.url.origin) + yield* env.set("KILO_BROWSER_BROKER_TOKEN", "browser-secret") + const info = yield* BrowserOpenTool + const browser = yield* info.init() + const result = yield* run( + profile("deny"), + ToolNetwork.tool( + ToolNetwork.builtin({ id: "browser_open" }), + browser.execute( + { url: "http://0.0.0.0:3018/app?x=1" }, + { ...ctx, ask: (request) => Effect.sync(() => patterns.push(...request.patterns)) }, + ), + ), + ) + expect(patterns).toEqual(["navigate:http://127.0.0.1:3018"]) + expect(result.metadata.status).toBe("ready") + }).pipe(Effect.scoped) + }) + for (const url of ["https://www.google.com/", "https://www.google.com/search?q=browser"]) { it.instance(`forwards approved public HTTPS (${url}) through only the authenticated loopback broker`, () => { let requests = 0 @@ -320,7 +353,7 @@ describe("model HTTP tool network policy", () => { "http://example.com/", "http://user:secret@localhost:3018/", "http://[::1]:3018/", - "http://0.0.0.0:3018/", + "https://0.0.0.0:3018/", ]) { const result = yield* run( profile("deny"), From cbf5dfa3057166bddd955ecd2b00576f210c8454 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 25 Sep 2026 11:01:14 +0200 Subject: [PATCH 5/5] fix(agent-manager): open 0.0.0.0 dev server URLs on localhost Keep the origin that dev tools usually configure, so cookies, OAuth redirects, and CORS allowlists for localhost apply. --- .../src/services/browser-automation/browser-policy.ts | 5 +++-- packages/kilo-vscode/tests/unit/browser-broker.test.ts | 2 +- packages/opencode/src/kilocode/tool/browser-open.ts | 5 +++-- packages/opencode/src/kilocode/tool/browser-open.txt | 2 +- .../opencode/test/kilocode/sandbox/http-tools.test.ts | 8 ++++---- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts b/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts index f64d25da2b86..279f1d1f0476 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-policy.ts @@ -10,8 +10,9 @@ export function parse(value: string): URL { 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 loopback. - if (url.protocol === "http:" && url.hostname === "0.0.0.0") url.hostname = "127.0.0.1" + // 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 } diff --git a/packages/kilo-vscode/tests/unit/browser-broker.test.ts b/packages/kilo-vscode/tests/unit/browser-broker.test.ts index 3d5c835f7e43..b9555506c105 100644 --- a/packages/kilo-vscode/tests/unit/browser-broker.test.ts +++ b/packages/kilo-vscode/tests/unit/browser-broker.test.ts @@ -52,7 +52,7 @@ describe("BrowserBroker", () => { 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() - expect(broker.validate("http://0.0.0.0:3000/path?q=1").href).toBe("http://127.0.0.1:3000/path?q=1") + 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() diff --git a/packages/opencode/src/kilocode/tool/browser-open.ts b/packages/opencode/src/kilocode/tool/browser-open.ts index 5b447a9dcb6b..07e2d3c38e8a 100644 --- a/packages/opencode/src/kilocode/tool/browser-open.ts +++ b/packages/opencode/src/kilocode/tool/browser-open.ts @@ -74,8 +74,9 @@ export const BrowserOpenTool = Tool.define< } const url = URL.parse(params.url) - // Dev servers print 0.0.0.0 when they listen on every interface. It is not a destination, so use loopback. - if (url?.protocol === "http:" && url.hostname === "0.0.0.0") url.hostname = "127.0.0.1" + // 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) { return { title: "Browser URL invalid", diff --git a/packages/opencode/src/kilocode/tool/browser-open.txt b/packages/opencode/src/kilocode/tool/browser-open.txt index 61b0ed214e6a..6530d72f3962 100644 --- a/packages/opencode/src/kilocode/tool/browser-open.txt +++ b/packages/opencode/src/kilocode/tool/browser-open.txt @@ -6,7 +6,7 @@ browser instead of launching another browser. Native select menus on macOS are not captured by the streamed view. HTTP URLs on localhost or 127.0.0.1 and public HTTPS URLs are supported. Use localhost -for IPv6 loopback servers. HTTP URLs on 0.0.0.0 open as 127.0.0.1. Local applications can load public HTTPS resources and +for IPv6 loopback servers. HTTP URLs on 0.0.0.0 open as localhost. Local applications can load public HTTPS resources and secure WebSockets, while HTTP and local WebSockets stay on the approved loopback origin. Local preview documents must stay on that origin. Public pages can load public HTTPS resources and secure WebSockets across origins, but cannot access diff --git a/packages/opencode/test/kilocode/sandbox/http-tools.test.ts b/packages/opencode/test/kilocode/sandbox/http-tools.test.ts index 8deb264436f1..c28b852c65c5 100644 --- a/packages/opencode/test/kilocode/sandbox/http-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/http-tools.test.ts @@ -203,16 +203,16 @@ describe("model HTTP tool network policy", () => { }).pipe(Effect.scoped) }) - it.instance("opens a 0.0.0.0 dev server address on loopback", () => { + it.instance("opens a 0.0.0.0 dev server address on localhost", () => { const patterns: string[] = [] return Effect.gen(function* () { const server = yield* serve(async (request) => { - expect(await request.json()).toMatchObject({ url: "http://127.0.0.1:3018/app?x=1" }) + expect(await request.json()).toMatchObject({ url: "http://localhost:3018/app?x=1" }) return Response.json({ browserId: "browser-test", sessionId: ctx.sessionID, status: "ready", - url: "http://127.0.0.1:3018/app?x=1", + url: "http://localhost:3018/app?x=1", errors: 0, }) }) @@ -231,7 +231,7 @@ describe("model HTTP tool network policy", () => { ), ), ) - expect(patterns).toEqual(["navigate:http://127.0.0.1:3018"]) + expect(patterns).toEqual(["navigate:http://localhost:3018"]) expect(result.metadata.status).toBe("ready") }).pipe(Effect.scoped) })