Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/agent-manager-streamed-browser.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/kilo-docs/source-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@
<!-- packages/opencode/src/kilocode/anaconda-desktop/domain.ts -->
- <https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz>
<!-- packages/opencode/src/lsp/server.ts -->
- <https://www.google.com/chrome/>
<!-- packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx -->
- <https://www.googleapis.com/auth/cloud-platform>
<!-- packages/opencode/src/provider/provider.ts -->
- <https://www.rfc-editor.org/rfc/rfc8628.html#section-3.5>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1211,6 +1211,8 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x")
rpc.listed += item
rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(item.path, 7, GhState.OPEN, "https://example.test/pr/7")))
val gate = CompletableDeferred<Unit>()
rpc.beforePrStatus = { gate.await() }
val timers = TestUiTimers()
ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, service, testRootDisposable)
project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable)
Expand All @@ -1220,6 +1222,13 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
timers.advanceBy(300)
flush()

assertFalse(edt { panel.canOpenPr(item) })
assertTrue(edt { panel.canRename(item) })

gate.complete(Unit)
// The status binding uses Dispatchers.Default, which flush() does not drain.
waitUntil { panel.canOpenPr(item) }

assertTrue(edt { panel.canOpenPr(item) })
assertFalse(edt { panel.canOpenPr(null) })
assertFalse(edt { panel.canRename(item) })
Expand Down
3 changes: 2 additions & 1 deletion packages/kilo-sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions packages/kilo-sandbox/src/destination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export function isPublicAddress(input: string) {
return address.range() === "unicast"
}

export function isIpAddress(input: string) {
return isIP(input) !== 0
}

export async function resolveDestination(dest: Destination) {
const addresses = await lookup(dest.host, { all: true, verbatim: true })
if (addresses.length === 0 || addresses.some((entry) => !isPublicAddress(entry.address))) {
Expand Down
8 changes: 7 additions & 1 deletion packages/kilo-sandbox/test/destination.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { isPublicAddress, normalizeDestinations, parseDestination } from "../src/destination"
import { isIpAddress, isPublicAddress, normalizeDestinations, parseDestination } from "../src/destination"

describe("sandbox network destinations", () => {
test("normalizes exact DNS hosts and ports", () => {
Expand Down Expand Up @@ -72,4 +72,10 @@ describe("sandbox network destinations", () => {
expect(isPublicAddress(input), `${note}: ${input}`).toBe(expected)
}
})

test("classifies IP literals without treating hostnames as addresses", () => {
expect(isIpAddress("8.8.8.8")).toBe(true)
expect(isIpAddress("2001:4860:4860::8888")).toBe(true)
expect(isIpAddress("example.com")).toBe(false)
})
})
1 change: 1 addition & 0 deletions packages/kilo-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,7 @@
"@kilocode/kilo-memory": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
"@kilocode/plugin": "workspace:*",
"@kilocode/sandbox": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@lottiefiles/dotlottie-web": "0.63.0",
"@opencode-ai/core": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ export class AgentManagerProvider implements Disposable {
panel.dispose()
}
this.panel = ctx
this.browserLifecycle.replay()
this.browserLifecycle.attach(ctx)

for (const poller of [this.statsPoller, this.projectPollers]) poller.setVisible(ctx.visible)
this.diffs.setVisible(ctx.visible).catch((err) => this.log("Failed to update diff visibility:", err))
Expand Down
57 changes: 40 additions & 17 deletions packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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<PanelContext, "visible" | "onDidChangeVisibility" | "onDidDispose">

export function createBrowserLifecycle(input: {
browser?: BrowserBroker
host: Host
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -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<void> {
return Promise.all([...browser.sessions()].map((sessionId) => browser.close(sessionId))).then(() => undefined)
},
dispose(): Promise<void> {
current = undefined
off()
frames()
return browser.disposeAsync()
},
}
Expand Down
104 changes: 75 additions & 29 deletions packages/kilo-vscode/src/agent-manager/browser-message.ts
Original file line number Diff line number Diff line change
@@ -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<AgentManagerInMessage, { type: `agentManager.browser.${string}` }>
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 (
Expand All @@ -22,6 +29,7 @@ function fail(
m: BrowserMessage,
error: string,
state?: BrowserState,
missing?: BrowserState["missing"],
): void {
if (m.type === "agentManager.browser.inspect") {
deps.post({
Expand All @@ -47,6 +55,7 @@ function fail(
status: "error",
errors: 0,
error,
missing,
})
}

Expand All @@ -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.")
Expand All @@ -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")
Expand All @@ -102,20 +143,30 @@ function action(
if (m.type === "agentManager.browser.open") {
if (!m.url) return true
void deps.browser
.open({ projectId: scope.project, sessionId: m.sessionId, directory: scope.directory }, m.url)
.open({ projectId: scope.project, sessionId: m.sessionId, directory: scope.directory }, m.url, false)
.catch((error: unknown) => {
deps.log("Browser open failed:", error)
const current = deps.browser.get(m.sessionId, scope.project)
if (current) {
deps.post(browserMessage(current))
return
}
fail(deps, m, diagnostic(error, m.url))
fail(
deps,
m,
diagnostic(error, m.url),
deps.browser.get(m.sessionId, scope.project),
error instanceof BrowserLaunchError ? error.missing : undefined,
)
})
return true
}
if (m.type === "agentManager.browser.back" || m.type === "agentManager.browser.forward") {
const direction = m.type === "agentManager.browser.back" ? "back" : "forward"
void deps.browser.history(m.sessionId, scope.project, direction).catch((error: unknown) => {
deps.log("Browser history navigation failed:", error)
const current = deps.browser.get(m.sessionId, scope.project)
if (current) deps.post(browserMessage(current))
})
return true
}
if (m.type === "agentManager.browser.refresh") {
void deps.browser.refresh(m.sessionId, scope.project).catch((error: unknown) => {
void deps.browser.refresh(m.sessionId, scope.project, false).catch((error: unknown) => {
deps.log("Browser refresh failed:", error)
const current = deps.browser.get(m.sessionId, scope.project)
if (current) deps.post(browserMessage(current))
Expand Down Expand Up @@ -153,22 +204,14 @@ function action(
})
return true
}
if (m.type !== "agentManager.browser.close") return false
void deps.browser
.close(m.sessionId, scope.project)
.catch((error: unknown) => deps.log("Browser close failed:", error))
return true
}

export function handleBrowserMessage(
message: AgentManagerInMessage,
deps: {
host: Host
contexts: ProjectContexts
browser: BrowserBroker
post: (message: AgentManagerOutMessage) => void
log: (...args: unknown[]) => void
},
): boolean {
export function handleBrowserMessage(message: AgentManagerInMessage, deps: Dependencies): boolean {
if (!message.type.startsWith("agentManager.browser.")) return false
const m = message as BrowserMessage
if (!deps.host.isTrusted()) {
Expand Down Expand Up @@ -196,6 +239,9 @@ export function browserMessage(state: BrowserState): AgentManagerOutMessage {
errors: state.errors,
logs: state.logs,
error: state.error,
missing: state.missing,
frameError: state.frameError,
back: state.back,
forward: state.forward,
}
}
4 changes: 3 additions & 1 deletion packages/kilo-vscode/src/agent-manager/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export interface Host {
/** Whether the experimental multi-project Agent Manager mode is enabled. */
multiProject(): boolean
browserAutomation(): boolean
approveBrowserNavigation?(origin: string): Promise<boolean>

/** Whether background worktree pre-warming is enabled. */
worktreePool(): boolean
Expand Down Expand Up @@ -210,7 +211,8 @@ export interface Host {
extensionKeybindings(): Array<{ command: string; key?: string; mac?: string; when?: string }>

/** Copy text to the system clipboard. */
copyToClipboard(text: string): void
copyToClipboard(text: string): void | Promise<void>
readClipboard?(): Promise<string>

/** Capture a telemetry event. */
capture(event: string, properties?: Record<string, unknown>): void
Expand Down
2 changes: 2 additions & 0 deletions packages/kilo-vscode/src/agent-manager/project/state-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const STATE_GATED = new Set<string>([
"agentManager.moveSection",
"agentManager.browser.open",
"agentManager.browser.refresh",
"agentManager.browser.back",
"agentManager.browser.forward",
"agentManager.browser.close",
"agentManager.browser.state",
"agentManager.browser.inspect",
Expand Down
Loading
Loading