browser: replace browser-use agent with native browser

Introduce the new built-in Browser plugin for Agent Zero, replacing the legacy
browser-use-based browser agent with a direct Playwright-powered browser tool,
live WebUI viewer, browser session controls, status APIs, configuration, and
extension-management support.

Add browser-specific modal behavior so the browser can run as a floating,
resizable, no-backdrop window, including modal focus, toggle, and idempotent
open helpers for richer WebUI surfaces.

Remove the old `_browser_agent` core plugin and the `browser-use` dependency,
then clean up stale browser-model wiring and references across agent code,
model configuration docs, setup guides, troubleshooting docs, skills, and
Agent Zero knowledge.

Update regression and WebUI extension-surface coverage for the new browser
architecture and modal behavior.

The legacy browser-use implementation has been extracted from core so it can
continue separately as a community plugin published through the A0 Plugin Index for any user or professional that were relying on it for workflow.
This commit is contained in:
Alessandro 2026-04-24 15:43:52 +02:00
parent 603fc2064b
commit 983d431a5e
65 changed files with 6936 additions and 1926 deletions

View file

@ -0,0 +1,27 @@
from helpers.api import ApiHandler, Request
from plugins._browser.helpers.extension_manager import (
get_extensions_root,
install_chrome_web_store_extension,
list_browser_extensions,
)
class Extensions(ApiHandler):
async def process(self, input: dict, request: Request) -> dict:
action = input.get("action", "list")
if action == "list":
return {
"ok": True,
"root": str(get_extensions_root()),
"extensions": list_browser_extensions(),
}
if action == "install_web_store":
try:
result = install_chrome_web_store_extension(str(input.get("url", "")))
except ValueError as exc:
return {"ok": False, "error": str(exc)}
return result
return {"ok": False, "error": f"Unknown action: {action}"}

View file

@ -0,0 +1,32 @@
from helpers.api import ApiHandler, Request
from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config
from plugins._browser.helpers.playwright import get_playwright_binary, get_playwright_cache_dir
from plugins._browser.helpers.runtime import known_context_ids
class Status(ApiHandler):
async def process(self, input: dict, request: Request) -> dict:
browser_config = get_browser_config()
launch_config = build_browser_launch_config(browser_config)
runtime_binary = get_playwright_binary(
full_browser=launch_config["requires_full_browser"]
)
shell_binary = get_playwright_binary(full_browser=False)
chromium_binary = get_playwright_binary(full_browser=True)
return {
"plugin": "_browser",
"playwright": {
"cache_dir": get_playwright_cache_dir(),
"binary_found": bool(runtime_binary),
"binary_path": str(runtime_binary) if runtime_binary else "",
"headless_shell_binary_path": str(shell_binary) if shell_binary else "",
"chromium_binary_path": str(chromium_binary) if chromium_binary else "",
"launch_mode": launch_config["browser_mode"],
},
"extensions": {
**launch_config["extensions"],
"launch_mode": launch_config["browser_mode"],
"requires_full_browser": launch_config["requires_full_browser"],
},
"contexts": known_context_ids(),
}

View file

@ -0,0 +1,241 @@
from __future__ import annotations
import asyncio
from typing import Any, ClassVar
from agent import AgentContext
from helpers.ws import WsHandler
from helpers.ws_manager import WsResult
from plugins._browser.helpers.runtime import get_runtime
class WsBrowser(WsHandler):
_streams: ClassVar[dict[tuple[str, str], asyncio.Task[None]]] = {}
async def on_disconnect(self, sid: str) -> None:
for key in [key for key in self._streams if key[0] == sid]:
task = self._streams.pop(key)
task.cancel()
async def process(
self,
event: str,
data: dict[str, Any],
sid: str,
) -> dict[str, Any] | WsResult | None:
if not event.startswith("browser_"):
return None
if event == "browser_viewer_subscribe":
return await self._subscribe(data, sid)
if event == "browser_viewer_unsubscribe":
return self._unsubscribe(data, sid)
if event == "browser_viewer_command":
return await self._command(data, sid)
if event == "browser_viewer_input":
return await self._input(data, sid)
return WsResult.error(
code="UNKNOWN_BROWSER_EVENT",
message=f"Unknown browser event: {event}",
correlation_id=data.get("correlationId"),
)
async def _subscribe(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
context_id = self._context_id(data)
if not context_id:
return self._error("MISSING_CONTEXT", "context_id is required", data)
if not AgentContext.get(context_id):
return self._error("CONTEXT_NOT_FOUND", f"Context '{context_id}' was not found", data)
runtime = await get_runtime(context_id)
listing = await runtime.call("list")
browsers = listing.get("browsers") or []
if not browsers:
opened = await runtime.call("open", "about:blank")
listing = await runtime.call("list")
browsers = listing.get("browsers") or []
if opened.get("id"):
listing["last_interacted_browser_id"] = opened.get("id")
active_id = data.get("browser_id") or listing.get("last_interacted_browser_id")
if not active_id and browsers:
active_id = browsers[0].get("id")
stream_key = (sid, context_id)
existing = self._streams.pop(stream_key, None)
if existing:
existing.cancel()
self._streams[stream_key] = asyncio.create_task(
self._stream_frames(sid, context_id, active_id)
)
return {
"context_id": context_id,
"active_browser_id": active_id,
"browsers": browsers,
}
def _unsubscribe(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
context_id = self._context_id(data)
if not context_id:
return self._error("MISSING_CONTEXT", "context_id is required", data)
task = self._streams.pop((sid, context_id), None)
if task:
task.cancel()
return {"context_id": context_id, "unsubscribed": True}
async def _command(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
context_id = self._context_id(data)
if not context_id:
return self._error("MISSING_CONTEXT", "context_id is required", data)
runtime = await get_runtime(context_id)
command = str(data.get("command") or "").strip().lower().replace("-", "_")
browser_id = data.get("browser_id")
try:
if command == "open":
result = await runtime.call("open", data.get("url") or "about:blank")
elif command == "navigate":
result = await runtime.call("navigate", browser_id, data.get("url") or "")
elif command == "back":
result = await runtime.call("back", browser_id)
elif command == "forward":
result = await runtime.call("forward", browser_id)
elif command == "reload":
result = await runtime.call("reload", browser_id)
elif command == "close":
result = await runtime.call("close_browser", browser_id)
elif command == "list":
result = await runtime.call("list")
else:
return self._error("UNKNOWN_COMMAND", f"Unknown browser command: {command}", data)
except Exception as exc:
return self._error("COMMAND_FAILED", str(exc), data)
listing = await runtime.call("list")
last_interacted_browser_id = listing.get("last_interacted_browser_id")
await self.emit_to(
sid,
"browser_viewer_state",
{
"context_id": context_id,
"result": result,
"browsers": listing.get("browsers") or [],
"last_interacted_browser_id": last_interacted_browser_id,
},
correlation_id=data.get("correlationId"),
)
return {
"result": result,
"browsers": listing.get("browsers") or [],
"last_interacted_browser_id": last_interacted_browser_id,
}
async def _input(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
context_id = self._context_id(data)
if not context_id:
return self._error("MISSING_CONTEXT", "context_id is required", data)
runtime = await get_runtime(context_id, create=False)
if not runtime:
return self._error("NO_BROWSER_RUNTIME", "No browser runtime exists for this context", data)
input_type = str(data.get("input_type") or "").strip().lower()
browser_id = data.get("browser_id")
try:
if input_type == "mouse":
result = await runtime.call(
"mouse",
browser_id,
data.get("event_type") or "click",
float(data.get("x") or 0),
float(data.get("y") or 0),
data.get("button") or "left",
)
elif input_type == "keyboard":
result = await runtime.call(
"keyboard",
browser_id,
key=str(data.get("key") or ""),
text=str(data.get("text") or ""),
)
elif input_type == "viewport":
result = await runtime.call(
"set_viewport",
browser_id,
int(data.get("width") or 0),
int(data.get("height") or 0),
)
elif input_type == "wheel":
result = await runtime.call(
"wheel",
browser_id,
float(data.get("x") or 0),
float(data.get("y") or 0),
float(data.get("delta_x") or 0),
float(data.get("delta_y") or 0),
)
else:
return self._error("UNKNOWN_INPUT", f"Unknown browser input: {input_type}", data)
except Exception as exc:
return self._error("INPUT_FAILED", str(exc), data)
return {"state": result}
async def _stream_frames(
self,
sid: str,
context_id: str,
browser_id: int | str | None,
) -> None:
while True:
try:
runtime = await get_runtime(context_id, create=False)
if runtime:
listing = await runtime.call("list")
browsers = listing.get("browsers") or []
browser_ids = {str(browser.get("id")) for browser in browsers}
requested_id = str(browser_id or "") if browser_id else ""
active_id = (
browser_id
if requested_id and requested_id in browser_ids
else listing.get("last_interacted_browser_id")
)
if active_id and str(active_id) not in browser_ids:
active_id = None
if not active_id and browsers:
active_id = browsers[0].get("id")
if active_id:
frame = await runtime.call("screenshot", active_id)
frame["context_id"] = context_id
frame["browsers"] = browsers
await self.emit_to(sid, "browser_viewer_frame", frame)
else:
await self.emit_to(
sid,
"browser_viewer_frame",
{
"context_id": context_id,
"browser_id": None,
"browsers": browsers,
"image": "",
"mime": "",
"state": None,
},
)
await asyncio.sleep(0.75)
except asyncio.CancelledError:
raise
except Exception:
await asyncio.sleep(1.5)
@staticmethod
def _context_id(data: dict[str, Any]) -> str:
return str(data.get("context_id") or data.get("context") or "").strip()
@staticmethod
def _error(code: str, message: str, data: dict[str, Any]) -> WsResult:
return WsResult.error(
code=code,
message=message,
correlation_id=data.get("correlationId"),
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
# Load unpacked Chromium extension directories into the Browser tool.
# Paths must be readable from the Agent Zero runtime itself.
extensions_enabled: false
# One unpacked extension directory per item.
extension_paths: []
# Optional _model_config preset used by Browser-owned model helpers.
# Empty uses the effective Main Model.
model_preset: ""

View file

@ -0,0 +1,10 @@
from helpers.extension import Extension
from plugins._browser.helpers.runtime import close_runtime_sync
class CleanupBrowserRuntimeOnRemove(Extension):
def execute(self, data: dict = {}, **kwargs):
args = data.get("args", ())
context_id = args[0] if isinstance(args, tuple) and args else ""
if context_id:
close_runtime_sync(str(context_id), delete_profile=True)

View file

@ -0,0 +1,11 @@
from helpers.extension import Extension
from plugins._browser.helpers.runtime import close_runtime_sync
class CleanupBrowserRuntimeOnReset(Extension):
def execute(self, data: dict = {}, **kwargs):
args = data.get("args", ())
context = args[0] if isinstance(args, tuple) and args else None
context_id = getattr(context, "id", "")
if context_id:
close_runtime_sync(context_id, delete_profile=True)

View file

@ -0,0 +1,59 @@
from __future__ import annotations
from typing import Any
from agent import LoopData
from helpers.extension import Extension
from plugins._browser.helpers.runtime import get_runtime
class BrowserContextPrompt(Extension):
async def execute(
self,
system_prompt: list[str] = [],
loop_data: LoopData = LoopData(),
**kwargs: Any,
):
if not self.agent:
return
runtime = await get_runtime(self.agent.context.id, create=False)
if not runtime:
return
try:
listing = await runtime.call("list")
except Exception:
return
browsers = listing.get("browsers") or []
if not browsers:
return
rows = ["browser id|url|title"]
for browser in browsers:
rows.append(
f"{browser.get('id')}|{browser.get('currentUrl', '')}|{browser.get('title', '')}"
)
section = ["currently open web browsers", "\n".join(rows)]
last_id = listing.get("last_interacted_browser_id")
if last_id:
try:
state = await runtime.call("state", last_id)
content = await runtime.call("content", last_id, None)
document = content.get("document") if isinstance(content, dict) else ""
if document:
section.extend(
[
"",
"last interacted web browser",
f"browser id|url|title\n{state.get('id')}|{state.get('currentUrl', '')}|{state.get('title', '')}",
"page content↓",
str(document),
]
)
except Exception:
pass
system_prompt.append("\n".join(section))

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Any
from helpers.extension import Extension
from plugins._browser.api.ws_browser import WsBrowser
class BrowserWebuiWsDisconnect(Extension):
async def execute(
self,
instance: Any = None,
sid: str = "",
**kwargs: Any,
) -> None:
if instance is None:
return
handler = WsBrowser(
instance.socketio,
instance.lock,
manager=instance.manager,
namespace=instance.namespace,
)
await handler.on_disconnect(sid)

View file

@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Any
from helpers.extension import Extension
from helpers.ws_manager import WsResult
from plugins._browser.api.ws_browser import WsBrowser
class BrowserWebuiWsEvents(Extension):
async def execute(
self,
instance: Any = None,
sid: str = "",
event_type: str = "",
data: dict[str, Any] | None = None,
response_data: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
if not event_type.startswith("browser_") or instance is None or response_data is None:
return
handler = WsBrowser(
instance.socketio,
instance.lock,
manager=instance.manager,
namespace=instance.namespace,
)
result = await handler.process(event_type, data or {}, sid)
if result is None:
return
if isinstance(result, WsResult):
payload = result.as_result(
handler_id=handler.identifier,
fallback_correlation_id=(data or {}).get("correlationId"),
)
if payload.get("ok"):
response_data.update(payload.get("data") or {})
else:
response_data["browser_error"] = payload.get("error") or {
"code": "BROWSER_ERROR",
"error": "Browser request failed",
}
return
response_data.update(result)

View file

@ -0,0 +1,17 @@
<button
type="button"
class="text-button browser-chat-action"
title="Show or hide Browser"
aria-label="Show or hide Browser"
data-bs-placement="top"
data-bs-trigger="hover"
@click="window.toggleModal ? window.toggleModal('/plugins/_browser/webui/main.html') : window.openModal('/plugins/_browser/webui/main.html')"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2"></rect>
<path d="M3 8h18"></path>
<path d="M7 6h.01"></path>
<path d="M10 6h.01"></path>
</svg>
<p>Browser</p>
</button>

View file

@ -0,0 +1,76 @@
import {
createActionButton,
copyToClipboard,
} from "/components/messages/action-buttons/simple-action-buttons.js";
import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
import { store as speechStore } from "/components/chat/speech/speech-store.js";
import {
buildDetailPayload,
cleanStepTitle,
drawProcessStep,
} from "/js/messages.js";
const BROWSER_MODAL = "/plugins/_browser/webui/main.html";
export default async function registerBrowserToolHandler(extData) {
if (extData?.tool_name === "browser") {
extData.handler = drawBrowserTool;
}
}
function drawBrowserTool({
id,
type,
heading,
content,
kvps,
timestamp,
agentno = 0,
...additional
}) {
const title = cleanStepTitle(heading);
const displayKvps = { ...kvps };
const headerLabels = [
kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
].filter(Boolean);
const contentText = String(content ?? "");
const browserButton = createActionButton(
"visibility",
"Browser",
() => {
if (window.ensureModalOpen) {
void window.ensureModalOpen(BROWSER_MODAL);
return;
}
void window.openModal?.(BROWSER_MODAL);
},
);
browserButton.setAttribute("title", "Open Browser");
browserButton.setAttribute("aria-label", "Open Browser");
browserButton.setAttribute("data-bs-placement", "top");
browserButton.setAttribute("data-bs-trigger", "hover");
const actionButtons = [browserButton];
if (contentText.trim()) {
actionButtons.push(
createActionButton("detail", "", () =>
stepDetailStore.showStepDetail(
buildDetailPayload(arguments[0], { headerLabels }),
),
),
createActionButton("speak", "", () => speechStore.speak(contentText)),
createActionButton("copy", "", () => copyToClipboard(contentText)),
);
}
return drawProcessStep({
id,
title,
code: "WWW",
classes: undefined,
kvps: displayKvps,
content,
actionButtons: actionButtons.filter(Boolean),
log: arguments[0],
});
}

View file

@ -0,0 +1 @@
# Built-in direct browser helpers.

View file

@ -0,0 +1,272 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from agent import Agent
PLUGIN_NAME = "_browser"
MODEL_PRESET_KEY = "model_preset"
BASE_BROWSER_ARGS = [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
]
def _normalize_extension_paths(value: Any) -> list[str]:
if isinstance(value, str):
candidates = value.replace("\r\n", "\n").replace("\r", "\n").split("\n")
elif isinstance(value, (list, tuple, set)):
candidates = list(value)
else:
candidates = []
normalized_paths: list[str] = []
seen: set[str] = set()
for entry in candidates:
raw_path = str(entry or "").strip()
if not raw_path:
continue
normalized = str(Path(raw_path).expanduser())
if normalized in seen:
continue
seen.add(normalized)
normalized_paths.append(normalized)
return normalized_paths
def _normalize_model_preset(value: Any) -> str:
return str(value or "").strip()
def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
raw = settings if isinstance(settings, dict) else {}
return {
"extensions_enabled": bool(raw.get("extensions_enabled", False)),
"extension_paths": _normalize_extension_paths(raw.get("extension_paths", [])),
MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
}
def browser_runtime_config(settings: dict[str, Any] | None) -> dict[str, Any]:
config = normalize_browser_config(settings)
return {
"extensions_enabled": config["extensions_enabled"],
"extension_paths": config["extension_paths"],
}
def get_browser_config(agent: "Agent | None" = None) -> dict[str, Any]:
from helpers import plugins
return normalize_browser_config(plugins.get_plugin_config(PLUGIN_NAME, agent=agent) or {})
def get_browser_model_preset_name(
agent: "Agent | None" = None,
settings: dict[str, Any] | None = None,
) -> str:
config = (
normalize_browser_config(settings)
if settings is not None
else get_browser_config(agent=agent)
)
return str(config.get(MODEL_PRESET_KEY, "") or "").strip()
def get_browser_model_preset_options(
agent: "Agent | None" = None,
settings: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
from plugins._model_config.helpers import model_config
selected_name = get_browser_model_preset_name(agent=agent, settings=settings)
options: list[dict[str, Any]] = []
found_selected = False
for preset in model_config.get_presets():
name = str(preset.get("name", "") or "").strip()
if not name:
continue
if name == selected_name:
found_selected = True
chat_cfg = preset.get("chat", {}) if isinstance(preset, dict) else {}
if not isinstance(chat_cfg, dict):
chat_cfg = {}
provider = str(chat_cfg.get("provider", "") or "").strip()
model_name = str(chat_cfg.get("name", "") or "").strip()
summary = " / ".join(part for part in (provider, model_name) if part)
options.append(
{
"name": name,
"label": name,
"missing": False,
"summary": summary,
}
)
if selected_name and not found_selected:
options.append(
{
"name": selected_name,
"label": f"{selected_name} (missing)",
"missing": True,
"summary": "",
}
)
return options
def resolve_browser_model_selection(
agent: "Agent | None" = None,
settings: dict[str, Any] | None = None,
) -> dict[str, Any]:
from plugins._model_config.helpers import model_config
preset_name = get_browser_model_preset_name(agent=agent, settings=settings)
if preset_name:
preset = model_config.get_preset_by_name(preset_name)
if isinstance(preset, dict):
chat_cfg = preset.get("chat", {})
if isinstance(chat_cfg, dict) and (
str(chat_cfg.get("provider", "") or "").strip()
or str(chat_cfg.get("name", "") or "").strip()
):
return {
"config": chat_cfg,
"source_kind": "preset",
"source_label": f"Preset '{preset_name}' via _model_config",
"selected_preset_name": preset_name,
"preset_status": "active",
"warning": "",
}
return {
"config": model_config.get_chat_model_config(agent),
"source_kind": "main",
"source_label": "Main Model via _model_config",
"selected_preset_name": preset_name,
"preset_status": "invalid",
"warning": (
f"Configured browser preset '{preset_name}' does not define a chat model. "
"Falling back to the Main Model."
),
}
return {
"config": model_config.get_chat_model_config(agent),
"source_kind": "main",
"source_label": "Main Model via _model_config",
"selected_preset_name": preset_name,
"preset_status": "missing",
"warning": (
f"Configured browser preset '{preset_name}' was not found. "
"Falling back to the Main Model."
),
}
return {
"config": model_config.get_chat_model_config(agent),
"source_kind": "main",
"source_label": "Main Model via _model_config",
"selected_preset_name": "",
"preset_status": "none",
"warning": "",
}
def resolve_browser_model(agent: "Agent", settings: dict[str, Any] | None = None):
selection = resolve_browser_model_selection(agent=agent, settings=settings)
if selection["source_kind"] == "main":
return agent.get_chat_model()
import models
from plugins._model_config.helpers import model_config
model_config_object = model_config.build_model_config(
selection["config"],
models.ModelType.CHAT,
)
return models.get_chat_model(
model_config_object.provider,
model_config_object.name,
model_config=model_config_object,
**model_config_object.build_kwargs(),
)
def describe_browser_extensions(settings: dict[str, Any] | None) -> dict[str, Any]:
config = normalize_browser_config(settings)
path_details: list[dict[str, Any]] = []
for extension_path in config["extension_paths"]:
path = Path(extension_path)
exists = path.exists()
is_dir = path.is_dir() if exists else False
path_details.append(
{
"path": extension_path,
"exists": exists,
"is_dir": is_dir,
"loadable": exists and is_dir,
}
)
active_paths = [item["path"] for item in path_details if item["loadable"]]
invalid_paths = [item["path"] for item in path_details if not item["loadable"]]
active = bool(config["extensions_enabled"] and active_paths)
warnings: list[str] = []
if config["extensions_enabled"] and not config["extension_paths"]:
warnings.append(
"Extensions are enabled, but no unpacked extension directories are configured."
)
elif config["extensions_enabled"] and not active_paths:
warnings.append(
"Extensions are enabled, but none of the configured extension directories are readable unpacked folders."
)
elif invalid_paths:
warnings.append(
"Some configured extension directories are missing or not directories, so they will be skipped."
)
return {
"enabled": bool(config["extensions_enabled"]),
"active": active,
"configured_paths": config["extension_paths"],
"active_paths": active_paths,
"invalid_paths": invalid_paths,
"path_details": path_details,
"active_path_count": len(active_paths),
"warnings": warnings,
}
def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, Any]:
extensions = describe_browser_extensions(settings)
args = list(BASE_BROWSER_ARGS)
channel: str | None = None
browser_mode = "headless_shell"
if extensions["active"]:
joined_paths = ",".join(extensions["active_paths"])
args.extend(
[
f"--disable-extensions-except={joined_paths}",
f"--load-extension={joined_paths}",
]
)
channel = "chromium"
browser_mode = "chromium_extensions"
else:
args.insert(0, "--headless=new")
return {
"args": args,
"browser_mode": browser_mode,
"channel": channel,
"extensions": extensions,
"requires_full_browser": bool(extensions["active"]),
}

View file

@ -0,0 +1,177 @@
from __future__ import annotations
import json
import re
import shutil
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from typing import Any
from helpers import files, plugins
from plugins._browser.helpers.config import PLUGIN_NAME, get_browser_config
EXTENSION_ID_RE = re.compile(r"^[a-p]{32}$")
WEB_STORE_ID_RE = re.compile(r"(?<![a-p])([a-p]{32})(?![a-p])")
WEB_STORE_DOWNLOAD_URL = (
"https://clients2.google.com/service/update2/crx"
"?response=redirect"
"&prodversion=120.0.0.0"
"&acceptformat=crx2,crx3"
"&x=id%3D{extension_id}%26installsource%3Dondemand%26uc"
)
def get_extensions_root() -> Path:
root = Path(files.get_abs_path("usr/browser-extensions"))
root.mkdir(parents=True, exist_ok=True)
return root
def parse_chrome_web_store_extension_id(value: str) -> str:
source = str(value or "").strip()
if EXTENSION_ID_RE.fullmatch(source):
return source
match = WEB_STORE_ID_RE.search(source)
if match:
return match.group(1)
raise ValueError("Enter a Chrome Web Store URL or a 32-character extension id.")
def list_browser_extensions() -> list[dict[str, Any]]:
root = get_extensions_root()
config = get_browser_config()
enabled_paths = {str(Path(path).expanduser()) for path in config["extension_paths"]}
entries: list[dict[str, Any]] = []
for manifest_path in sorted(root.glob("**/manifest.json")):
extension_dir = manifest_path.parent
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
manifest = {}
extension_path = str(extension_dir)
entries.append(
{
"name": manifest.get("name") or extension_dir.name,
"version": manifest.get("version") or "",
"path": extension_path,
"enabled": extension_path in enabled_paths,
}
)
return entries
def install_chrome_web_store_extension(source: str) -> dict[str, Any]:
extension_id = parse_chrome_web_store_extension_id(source)
target = get_extensions_root() / "chrome-web-store" / extension_id
with tempfile.TemporaryDirectory(prefix="a0-browser-ext-") as tmp:
archive_path = Path(tmp) / f"{extension_id}.crx"
_download_crx(extension_id, archive_path)
payload_path = Path(tmp) / f"{extension_id}.zip"
payload_path.write_bytes(_crx_zip_payload(archive_path.read_bytes()))
extracted_path = Path(tmp) / "extracted"
_safe_extract_zip(payload_path, extracted_path)
if not (extracted_path / "manifest.json").is_file():
raise ValueError("Downloaded extension did not contain a manifest.json file.")
if target.exists():
shutil.rmtree(target)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(extracted_path, target)
config = _enable_extension_path(target)
manifest = _read_manifest(target)
return {
"ok": True,
"id": extension_id,
"name": manifest.get("name") or extension_id,
"version": manifest.get("version") or "",
"path": str(target),
"extensions_enabled": config["extensions_enabled"],
"extension_paths": config["extension_paths"],
}
def _download_crx(extension_id: str, archive_path: Path) -> None:
url = WEB_STORE_DOWNLOAD_URL.format(extension_id=extension_id)
request = urllib.request.Request(
url,
headers={
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
},
)
with urllib.request.urlopen(request, timeout=30) as response:
data = response.read()
if not data:
raise ValueError("Chrome Web Store returned an empty extension package.")
archive_path.write_bytes(data)
def _crx_zip_payload(data: bytes) -> bytes:
if data.startswith(b"PK"):
return data
if data[:4] != b"Cr24":
raise ValueError("Downloaded package is not a CRX or ZIP archive.")
version = int.from_bytes(data[4:8], "little")
if version == 2:
public_key_len = int.from_bytes(data[8:12], "little")
signature_len = int.from_bytes(data[12:16], "little")
offset = 16 + public_key_len + signature_len
elif version == 3:
header_len = int.from_bytes(data[8:12], "little")
offset = 12 + header_len
else:
raise ValueError(f"Unsupported CRX version: {version}.")
payload = data[offset:]
if not payload.startswith(b"PK"):
raise ValueError("CRX payload did not contain a ZIP archive.")
return payload
def _safe_extract_zip(archive_path: Path, target_dir: Path) -> None:
target_dir.mkdir(parents=True, exist_ok=True)
root = target_dir.resolve()
with zipfile.ZipFile(archive_path) as archive:
for member in archive.infolist():
destination = (target_dir / member.filename).resolve()
if not destination.is_relative_to(root):
raise ValueError("Extension archive contains an unsafe path.")
if member.is_dir():
destination.mkdir(parents=True, exist_ok=True)
continue
destination.parent.mkdir(parents=True, exist_ok=True)
with archive.open(member) as source, destination.open("wb") as output:
shutil.copyfileobj(source, output)
def _enable_extension_path(extension_path: Path) -> dict[str, Any]:
config = get_browser_config()
path = str(extension_path)
paths = list(config["extension_paths"])
if path not in paths:
paths.append(path)
config["extensions_enabled"] = True
config["extension_paths"] = paths
plugins.save_plugin_config(PLUGIN_NAME, "", "", config)
return config
def _read_manifest(extension_path: Path) -> dict[str, Any]:
manifest_path = extension_path / "manifest.json"
try:
return json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
return {}

View file

@ -0,0 +1,57 @@
import os
import subprocess
from pathlib import Path
from helpers import files
HEADLESS_SHELL_PATTERNS = (
"chromium_headless_shell-*/chrome-*/headless_shell",
"chromium_headless_shell-*/chrome-*/headless_shell.exe",
)
FULL_CHROMIUM_PATTERNS = (
"chromium-*/chrome-linux/chrome",
"chromium-*/chrome-win/chrome.exe",
)
def get_playwright_cache_dir() -> str:
return files.get_abs_path("tmp/playwright")
def configure_playwright_env() -> str:
cache_dir = get_playwright_cache_dir()
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
return cache_dir
def get_playwright_binary(*, full_browser: bool = False) -> Path | None:
cache_dir = Path(get_playwright_cache_dir())
patterns = FULL_CHROMIUM_PATTERNS if full_browser else (HEADLESS_SHELL_PATTERNS + FULL_CHROMIUM_PATTERNS)
for pattern in patterns:
binary = next(cache_dir.glob(pattern), None)
if binary and binary.exists():
return binary
return None
def ensure_playwright_binary(*, full_browser: bool = False) -> Path:
binary = get_playwright_binary(full_browser=full_browser)
if binary:
return binary
cache_dir = configure_playwright_env()
env = os.environ.copy()
env["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
install_command = ["playwright", "install", "chromium"]
if not full_browser:
install_command.append("--only-shell")
subprocess.check_call(
install_command,
env=env,
)
binary = get_playwright_binary(full_browser=full_browser)
if not binary:
raise RuntimeError("Playwright Chromium binary not found after installation")
return binary

View file

@ -0,0 +1,623 @@
from __future__ import annotations
import atexit
import asyncio
import base64
import re
import shutil
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from helpers import files
from helpers.defer import DeferredTask
from helpers.print_style import PrintStyle
from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config
from plugins._browser.helpers.playwright import configure_playwright_env, ensure_playwright_binary
PLUGIN_DIR = Path(__file__).resolve().parents[1]
CONTENT_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-page-content.js"
RUNTIME_DATA_KEY = "_browser_runtime"
DEFAULT_VIEWPORT = {"width": 1024, "height": 768}
_SPECIAL_SCHEME_RE = re.compile(r"^(?:about|blob|data|file|mailto|tel):", re.I)
_URL_SCHEME_RE = re.compile(r"^[a-z][a-z\d+\-.]*://", re.I)
_LOCAL_HOST_RE = re.compile(
r"^(?:localhost|\[[0-9a-f:.]+\]|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?$",
re.I,
)
_TYPED_HOST_RE = re.compile(
r"^(?:localhost|\[[0-9a-f:.]+\]|(?:\d{1,3}\.){3}\d{1,3}|"
r"(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)+[a-z\d-]{2,63})(?::\d+)?$",
re.I,
)
_SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
def normalize_url(value: str) -> str:
raw = str(value or "").strip()
if not raw:
raise ValueError("Browser navigation requires a non-empty URL.")
def with_trailing_path(url: str) -> str:
parts = urlsplit(url)
if parts.scheme in {"http", "https"} and not parts.path:
return urlunsplit((parts.scheme, parts.netloc, "/", parts.query, parts.fragment))
return urlunsplit(parts)
try:
host = re.split(r"[/?#]", raw, 1)[0] or ""
if (
not _URL_SCHEME_RE.match(raw)
and not _SPECIAL_SCHEME_RE.match(raw)
and not raw.startswith(("/", "?", "#", "."))
and not re.search(r"\s", raw)
and _TYPED_HOST_RE.match(host)
):
protocol = "http://" if _LOCAL_HOST_RE.match(host) else "https://"
return with_trailing_path(protocol + raw)
parts = urlsplit(raw)
if parts.scheme:
return with_trailing_path(raw)
except Exception:
pass
return with_trailing_path("https://" + raw)
def _safe_context_id(context_id: str) -> str:
return _SAFE_CONTEXT_RE.sub("_", str(context_id or "default")).strip("._") or "default"
@dataclass
class BrowserPage:
id: int
page: Any
class BrowserRuntime:
def __init__(self, context_id: str):
self.context_id = str(context_id)
self._core = _BrowserRuntimeCore(self.context_id)
self._worker = DeferredTask(thread_name=f"BrowserRuntime-{self.context_id}")
self._closed = False
async def call(self, method: str, *args: Any, **kwargs: Any) -> Any:
if self._closed and method != "close":
raise RuntimeError("Browser runtime is closed.")
async def runner():
fn = getattr(self._core, method)
return await fn(*args, **kwargs)
return await self._worker.execute_inside(runner)
async def close(self, delete_profile: bool = False) -> None:
if self._closed:
return
try:
await self.call("close", delete_profile=delete_profile)
finally:
self._closed = True
self._worker.kill(terminate_thread=True)
class _BrowserRuntimeCore:
def __init__(self, context_id: str):
self.context_id = context_id
self.safe_context_id = _safe_context_id(context_id)
self.playwright = None
self.context = None
self.pages: dict[int, BrowserPage] = {}
self.next_browser_id = 1
self.last_interacted_browser_id: int | None = None
self._content_helper_source: str | None = None
@property
def profile_dir(self) -> Path:
return Path(files.get_abs_path("tmp/browser/sessions", self.safe_context_id))
@property
def downloads_dir(self) -> Path:
return Path(files.get_abs_path("usr/downloads/browser"))
async def ensure_started(self) -> None:
if self.context:
return
from playwright.async_api import async_playwright
self.profile_dir.mkdir(parents=True, exist_ok=True)
self.downloads_dir.mkdir(parents=True, exist_ok=True)
browser_config = get_browser_config()
launch_config = build_browser_launch_config(browser_config)
configure_playwright_env()
browser_binary = ensure_playwright_binary(
full_browser=launch_config["requires_full_browser"]
)
self.playwright = await async_playwright().start()
launch_kwargs: dict[str, Any] = {
"user_data_dir": str(self.profile_dir),
"headless": True,
"accept_downloads": True,
"downloads_path": str(self.downloads_dir),
"viewport": DEFAULT_VIEWPORT,
"screen": DEFAULT_VIEWPORT,
"no_viewport": False,
"args": launch_config["args"],
}
if launch_config["channel"]:
launch_kwargs["channel"] = launch_config["channel"]
else:
launch_kwargs["executable_path"] = str(browser_binary)
self.context = await self.playwright.chromium.launch_persistent_context(
**launch_kwargs
)
self.context.set_default_timeout(30000)
self.context.set_default_navigation_timeout(30000)
await self.context.add_init_script(self._shadow_dom_script())
await self.context.add_init_script(path=str(CONTENT_HELPER_PATH))
for page in list(self.context.pages):
if page.url == "about:blank":
try:
await page.close()
except Exception:
pass
continue
self._register_page(page)
async def open(self, url: str = "about:blank") -> dict[str, Any]:
await self.ensure_started()
page = await self.context.new_page()
browser_page = self._register_page(page)
self.last_interacted_browser_id = browser_page.id
if url and url != "about:blank":
await self._goto(page, normalize_url(url))
else:
await self._settle(page)
return {"id": browser_page.id, "state": await self._state(browser_page.id)}
async def list(self) -> dict[str, Any]:
await self.ensure_started()
return {
"browsers": [await self._state(browser_id) for browser_id in sorted(self.pages)],
"last_interacted_browser_id": self.last_interacted_browser_id,
}
async def state(self, browser_id: int | str | None = None) -> dict[str, Any]:
await self.ensure_started()
return await self._state(self._resolve_browser_id(browser_id))
async def navigate(self, browser_id: int | str | None, url: str) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await self._goto(page, normalize_url(url))
self.last_interacted_browser_id = resolved_id
return await self._state(resolved_id)
async def back(self, browser_id: int | str | None = None) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await page.go_back(wait_until="domcontentloaded", timeout=10000)
await self._settle(page)
self.last_interacted_browser_id = resolved_id
return await self._state(resolved_id)
async def forward(self, browser_id: int | str | None = None) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await page.go_forward(wait_until="domcontentloaded", timeout=10000)
await self._settle(page)
self.last_interacted_browser_id = resolved_id
return await self._state(resolved_id)
async def reload(self, browser_id: int | str | None = None) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await page.reload(wait_until="domcontentloaded", timeout=15000)
await self._settle(page)
self.last_interacted_browser_id = resolved_id
return await self._state(resolved_id)
async def content(
self,
browser_id: int | str | None = None,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await self._ensure_content_helper(page)
result = await page.evaluate(
"(payload) => globalThis.__spaceBrowserPageContent__.capture(payload || null)",
payload or None,
)
self.last_interacted_browser_id = resolved_id
return result or {}
async def detail(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await self._ensure_content_helper(page)
result = await page.evaluate(
"(ref) => globalThis.__spaceBrowserPageContent__.detail(ref)",
reference_id,
)
self.last_interacted_browser_id = resolved_id
return result or {}
async def evaluate(self, browser_id: int | str | None, script: str) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
result = await page.evaluate(str(script or "undefined"))
self.last_interacted_browser_id = resolved_id
return {"result": result, "state": await self._state(resolved_id)}
async def click(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
return await self._reference_action("click", browser_id, reference_id)
async def submit(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
return await self._reference_action("submit", browser_id, reference_id)
async def scroll(self, browser_id: int | str | None, reference_id: int | str) -> dict[str, Any]:
return await self._reference_action("scroll", browser_id, reference_id)
async def type(
self,
browser_id: int | str | None,
reference_id: int | str,
text: str,
) -> dict[str, Any]:
return await self._reference_action("type", browser_id, reference_id, text)
async def type_submit(
self,
browser_id: int | str | None,
reference_id: int | str,
text: str,
) -> dict[str, Any]:
return await self._reference_action("typeSubmit", browser_id, reference_id, text)
async def close_browser(self, browser_id: int | str | None = None) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await page.close()
self.pages.pop(resolved_id, None)
if self.last_interacted_browser_id == resolved_id:
self.last_interacted_browser_id = next(iter(sorted(self.pages)), None)
return await self.list()
async def close_all_browsers(self) -> dict[str, Any]:
await self.ensure_started()
for browser_id in list(self.pages):
try:
await self.pages[browser_id].page.close()
except Exception:
pass
self.pages.clear()
self.last_interacted_browser_id = None
return {"browsers": [], "last_interacted_browser_id": None}
async def screenshot(
self,
browser_id: int | str | None = None,
*,
quality: int = 70,
) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
image = await page.screenshot(type="jpeg", quality=max(20, min(95, int(quality))))
return {
"browser_id": resolved_id,
"mime": "image/jpeg",
"image": base64.b64encode(image).decode("ascii"),
"state": await self._state(resolved_id),
}
async def set_viewport(
self,
browser_id: int | str | None,
width: int,
height: int,
) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
viewport = {
"width": max(320, min(4096, int(width or DEFAULT_VIEWPORT["width"]))),
"height": max(200, min(4096, int(height or DEFAULT_VIEWPORT["height"]))),
}
await page.set_viewport_size(viewport)
self.last_interacted_browser_id = resolved_id
return {"state": await self._state(resolved_id), "viewport": viewport}
async def mouse(
self,
browser_id: int | str | None,
event_type: str,
x: float,
y: float,
button: str = "left",
) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
event_type = str(event_type or "click").lower()
if event_type == "move":
await page.mouse.move(float(x), float(y))
elif event_type == "down":
await page.mouse.down(button=button)
elif event_type == "up":
await page.mouse.up(button=button)
else:
await page.mouse.click(float(x), float(y), button=button)
await self._settle(page, short=True)
self.last_interacted_browser_id = resolved_id
return await self._state(resolved_id)
async def wheel(
self,
browser_id: int | str | None,
x: float,
y: float,
delta_x: float = 0,
delta_y: float = 0,
) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await page.mouse.move(float(x), float(y))
await page.mouse.wheel(float(delta_x), float(delta_y))
await self._settle(page, short=True)
self.last_interacted_browser_id = resolved_id
return await self._state(resolved_id)
async def keyboard(
self,
browser_id: int | str | None,
*,
key: str = "",
text: str = "",
) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
if text:
await page.keyboard.type(str(text))
elif key:
await page.keyboard.press(str(key))
await self._settle(page, short=True)
self.last_interacted_browser_id = resolved_id
return await self._state(resolved_id)
async def close(self, delete_profile: bool = False) -> None:
for browser_id in list(self.pages):
try:
await self.pages[browser_id].page.close()
except Exception:
pass
self.pages.clear()
if self.context:
try:
await self.context.close()
except Exception as exc:
PrintStyle.warning(f"Browser context close failed: {exc}")
self.context = None
if self.playwright:
try:
await self.playwright.stop()
except Exception as exc:
PrintStyle.warning(f"Playwright stop failed: {exc}")
self.playwright = None
self.last_interacted_browser_id = None
if delete_profile:
shutil.rmtree(self.profile_dir, ignore_errors=True)
async def _reference_action(
self,
helper_method: str,
browser_id: int | str | None,
reference_id: int | str,
text: str | None = None,
) -> dict[str, Any]:
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await self._ensure_content_helper(page)
if text is None:
action = await page.evaluate(
"(args) => globalThis.__spaceBrowserPageContent__[args.method](args.ref)",
{"method": helper_method, "ref": reference_id},
)
else:
action = await page.evaluate(
"(args) => globalThis.__spaceBrowserPageContent__[args.method](args.ref, args.text)",
{"method": helper_method, "ref": reference_id, "text": text},
)
await self._settle(page, short=False)
self.last_interacted_browser_id = resolved_id
return {"action": action or {}, "state": await self._state(resolved_id)}
async def _goto(self, page: Any, url: str) -> None:
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
try:
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
except PlaywrightTimeoutError:
PrintStyle.warning(f"Browser navigation timed out after DOM handoff: {url}")
await self._settle(page)
async def _settle(self, page: Any, short: bool = False) -> None:
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
try:
await page.wait_for_load_state(
"domcontentloaded",
timeout=1000 if short else 5000,
)
except PlaywrightTimeoutError:
pass
await asyncio.sleep(0.1 if short else 0.35)
async def _state(self, browser_id: int) -> dict[str, Any]:
browser_page = self.pages.get(int(browser_id))
if not browser_page:
raise KeyError(f"Browser {browser_id} is not open.")
page = browser_page.page
try:
title = await page.title()
except Exception:
title = ""
try:
history_length = await page.evaluate("() => globalThis.history?.length || 0")
except Exception:
history_length = 0
return {
"id": browser_page.id,
"currentUrl": page.url,
"title": title,
"canGoBack": bool(history_length and int(history_length) > 1),
"canGoForward": False,
"loading": False,
}
def _register_page(self, page: Any) -> BrowserPage:
existing = self._browser_id_for_page(page)
if existing is not None:
return self.pages[existing]
browser_id = self.next_browser_id
self.next_browser_id += 1
browser_page = BrowserPage(id=browser_id, page=page)
self.pages[browser_id] = browser_page
def on_close() -> None:
self.pages.pop(browser_id, None)
page.on("close", on_close)
return browser_page
def _browser_id_for_page(self, page: Any) -> int | None:
for browser_id, browser_page in self.pages.items():
if browser_page.page == page:
return browser_id
return None
def _resolve_browser_id(self, browser_id: int | str | None = None) -> int:
if browser_id is None or str(browser_id).strip() == "":
if self.last_interacted_browser_id in self.pages:
return int(self.last_interacted_browser_id)
if self.pages:
return sorted(self.pages)[0]
raise KeyError("No browser is open. Use action=open first.")
value = str(browser_id).strip()
if value.startswith("browser-"):
value = value.split("-", 1)[1]
resolved = int(value)
if resolved not in self.pages:
raise KeyError(f"Browser {resolved} is not open.")
return resolved
def _page(self, browser_id: int) -> Any:
return self.pages[int(browser_id)].page
async def _ensure_content_helper(self, page: Any) -> None:
has_helper = await page.evaluate(
"() => Boolean(globalThis.__spaceBrowserPageContent__?.capture)"
)
if has_helper:
return
if self._content_helper_source is None:
self._content_helper_source = CONTENT_HELPER_PATH.read_text(encoding="utf-8")
await page.evaluate(self._content_helper_source)
@staticmethod
def _shadow_dom_script() -> str:
return """
(() => {
const original = Element.prototype.attachShadow;
if (original && !original.__a0BrowserOpenShadowPatch) {
const patched = function attachShadow(options) {
return original.call(this, { ...(options || {}), mode: "open" });
};
patched.__a0BrowserOpenShadowPatch = true;
Element.prototype.attachShadow = patched;
}
})();
"""
_runtimes: dict[str, BrowserRuntime] = {}
_runtime_lock = threading.RLock()
async def get_runtime(context_id: str, *, create: bool = True) -> BrowserRuntime | None:
context_id = str(context_id or "").strip()
if not context_id:
raise ValueError("context_id is required")
with _runtime_lock:
runtime = _runtimes.get(context_id)
if runtime is None and create:
runtime = BrowserRuntime(context_id)
_runtimes[context_id] = runtime
return runtime
async def close_runtime(context_id: str, *, delete_profile: bool = True) -> None:
context_id = str(context_id or "").strip()
if not context_id:
return
with _runtime_lock:
runtime = _runtimes.pop(context_id, None)
if runtime:
await runtime.close(delete_profile=delete_profile)
def close_runtime_sync(context_id: str, *, delete_profile: bool = True) -> None:
task = DeferredTask(thread_name="BrowserCleanup")
task.start_task(close_runtime, context_id, delete_profile=delete_profile)
try:
task.result_sync(timeout=30)
finally:
task.kill(terminate_thread=True)
async def close_all_runtimes(*, delete_profiles: bool = False) -> None:
with _runtime_lock:
runtimes = list(_runtimes.values())
_runtimes.clear()
for runtime in runtimes:
try:
await runtime.close(delete_profile=delete_profiles)
except Exception as exc:
PrintStyle.warning(f"Browser runtime cleanup failed: {exc}")
def close_all_runtimes_sync() -> None:
task = DeferredTask(thread_name="BrowserCleanupAll")
task.start_task(close_all_runtimes, delete_profiles=False)
try:
task.result_sync(timeout=30)
finally:
task.kill(terminate_thread=True)
def known_context_ids() -> list[str]:
with _runtime_lock:
return sorted(_runtimes)
atexit.register(close_all_runtimes_sync)

47
plugins/_browser/hooks.py Normal file
View file

@ -0,0 +1,47 @@
from __future__ import annotations
from helpers import files, plugins, yaml as yaml_helper
from plugins._browser.helpers.config import (
PLUGIN_NAME,
browser_runtime_config,
normalize_browser_config,
)
from plugins._browser.helpers.runtime import close_all_runtimes_sync
def _load_saved_browser_config(project_name: str = "", agent_profile: str = "") -> dict:
entries = plugins.find_plugin_assets(
plugins.CONFIG_FILE_NAME,
plugin_name=PLUGIN_NAME,
project_name=project_name,
agent_profile=agent_profile,
only_first=True,
)
path = entries[0].get("path", "") if entries else ""
if path and files.exists(path):
return files.read_file_json(path) or {}
plugin_dir = plugins.find_plugin_dir(PLUGIN_NAME)
default_path = (
files.get_abs_path(plugin_dir, plugins.CONFIG_DEFAULT_FILE_NAME)
if plugin_dir
else ""
)
if default_path and files.exists(default_path):
return yaml_helper.loads(files.read_file(default_path)) or {}
return {}
def get_plugin_config(default=None, **kwargs):
return normalize_browser_config(default)
def save_plugin_config(settings=None, project_name="", agent_profile="", **kwargs):
normalized = normalize_browser_config(settings)
current = normalize_browser_config(
_load_saved_browser_config(project_name=project_name, agent_profile=agent_profile)
)
if browser_runtime_config(normalized) != browser_runtime_config(current):
close_all_runtimes_sync()
return normalized

View file

@ -0,0 +1,9 @@
name: _browser
title: Browser
description: Built-in direct Playwright browser tool and WebUI viewer.
version: 1.0.0
always_enabled: false
settings_sections:
- external
per_project_config: false
per_agent_config: false

View file

@ -0,0 +1,48 @@
### browser
direct Playwright browser control with visible WebUI viewer
use for web browsing, page inspection, forms, downloads, and browser-only tasks
state stays open per chat context
refs come from content as typed markers: [link 3], [button 6], [image 1], [input text 8]
actions: open list state navigate back forward reload content detail click type submit type_submit scroll evaluate close close_all
common args: action browser_id url ref text selector selectors script
workflow:
- open creates a new browser and returns id/state
- content returns readable page markdown with typed refs
- detail inspects one ref, including link/image/input/button metadata
- click/type/type_submit/submit/scroll use refs from latest content capture and return {action,state}
- navigate/back/forward/reload return fresh state
- list shows open browsers
examples:
~~~json
{
"tool_name": "browser",
"tool_args": {
"action": "open",
"url": "https://example.com"
}
}
~~~
~~~json
{
"tool_name": "browser",
"tool_args": {
"action": "content",
"browser_id": 1
}
}
~~~
~~~json
{
"tool_name": "browser",
"tool_args": {
"action": "click",
"browser_id": 1,
"ref": 3
}
}
~~~

View file

@ -0,0 +1,107 @@
from __future__ import annotations
import json
from typing import Any
from helpers.tool import Response, Tool
from plugins._browser.helpers.runtime import get_runtime
class Browser(Tool):
async def execute(
self,
action: str = "",
browser_id: int | str | None = None,
url: str = "",
ref: int | str | None = None,
text: str = "",
selector: str = "",
selectors: list[str] | None = None,
script: str = "",
**kwargs: Any,
) -> Response:
action = str(action or self.method or "state").strip().lower().replace("-", "_")
runtime = await get_runtime(self.agent.context.id)
try:
if action == "open":
result = await runtime.call("open", url or "about:blank")
elif action == "list":
result = await runtime.call("list")
elif action == "state":
result = await runtime.call("state", browser_id)
elif action == "navigate":
result = await runtime.call("navigate", browser_id, url)
elif action == "back":
result = await runtime.call("back", browser_id)
elif action == "forward":
result = await runtime.call("forward", browser_id)
elif action == "reload":
result = await runtime.call("reload", browser_id)
elif action == "content":
payload = self._selector_payload(selector, selectors)
result = await runtime.call("content", browser_id, payload)
elif action == "detail":
result = await runtime.call("detail", browser_id, self._require_ref(ref))
elif action == "click":
result = await runtime.call("click", browser_id, self._require_ref(ref))
elif action == "type":
result = await runtime.call("type", browser_id, self._require_ref(ref), text)
elif action == "submit":
result = await runtime.call("submit", browser_id, self._require_ref(ref))
elif action in {"type_submit", "typesubmit"}:
result = await runtime.call(
"type_submit",
browser_id,
self._require_ref(ref),
text,
)
elif action == "scroll":
result = await runtime.call("scroll", browser_id, self._require_ref(ref))
elif action == "evaluate":
result = await runtime.call("evaluate", browser_id, script)
elif action == "close":
result = await runtime.call("close_browser", browser_id)
elif action == "close_all":
result = await runtime.call("close_all_browsers")
else:
return Response(
message=f"Unknown browser action: {action}",
break_loop=False,
)
except Exception as exc:
return Response(message=f"Browser {action} failed: {exc}", break_loop=False)
return Response(message=self._format_result(action, result), break_loop=False)
def get_log_object(self):
return self.agent.context.log.log(
type="tool",
heading=f"icon://captive_portal {self.agent.agent_name}: Using browser",
content="",
kvps=self.args,
_tool_name=self.name,
)
@staticmethod
def _require_ref(ref: int | str | None) -> int | str:
if ref is None or str(ref).strip() == "":
raise ValueError("ref is required for this browser action")
return ref
@staticmethod
def _selector_payload(selector: str = "", selectors: list[str] | None = None) -> dict | None:
if selectors:
return {"selectors": selectors}
if selector:
return {"selector": selector}
return None
@staticmethod
def _format_result(action: str, result: Any) -> str:
if action == "content" and isinstance(result, dict):
if set(result.keys()) == {"document"}:
return str(result.get("document") or "")
return json.dumps(result, indent=2, ensure_ascii=False)
return json.dumps(result, indent=2, ensure_ascii=False, default=str)

View file

@ -0,0 +1,155 @@
import { createStore } from "/js/AlpineStore.js";
import { fetchApi } from "/js/api.js";
const MODEL_CONFIG_API = "/plugins/_model_config/model_presets";
function normalizePathList(value) {
const source = Array.isArray(value)
? value
: String(value || "").split(/\r?\n/);
const seen = new Set();
const paths = [];
for (const item of source) {
const path = String(item || "").trim();
if (!path || seen.has(path)) continue;
seen.add(path);
paths.push(path);
}
return paths;
}
function ensureConfig(config) {
if (!config || typeof config !== "object") return null;
if (typeof config.extensions_enabled !== "boolean") {
config.extensions_enabled = Boolean(config.extensions_enabled);
}
config.extension_paths = normalizePathList(config.extension_paths);
config.model_preset = String(config.model_preset || "").trim();
delete config.model;
return config;
}
export const store = createStore("browserConfig", {
config: null,
extensionPathsText: "",
presets: [],
presetsLoading: false,
presetsError: "",
_presetsLoaded: false,
async init(config) {
this.bindConfig(config);
await this.loadPresets();
},
cleanup() {
this.config = null;
this.extensionPathsText = "";
this.presetsError = "";
},
bindConfig(config) {
const safeConfig = ensureConfig(config);
if (!safeConfig) return;
if (this.config === safeConfig) return;
this.config = safeConfig;
this.extensionPathsText = safeConfig.extension_paths.join("\n");
},
setExtensionPathsText(value) {
this.extensionPathsText = String(value || "");
this.syncExtensionPaths();
},
syncExtensionPaths() {
const safeConfig = ensureConfig(this.config);
if (!safeConfig) return;
safeConfig.extension_paths = normalizePathList(this.extensionPathsText);
},
hasPaths() {
return this.pathCount() > 0;
},
pathCount() {
return normalizePathList(this.extensionPathsText).length;
},
pathCountLabel() {
const count = this.pathCount();
if (!count) return "No extension paths configured";
return `${count} path${count === 1 ? "" : "s"} configured`;
},
extensionModeReady() {
const safeConfig = ensureConfig(this.config);
return Boolean(safeConfig?.extensions_enabled && this.pathCount());
},
async loadPresets() {
if (this._presetsLoaded || this.presetsLoading) return;
this.presetsLoading = true;
this.presetsError = "";
try {
const response = await fetchApi(MODEL_CONFIG_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "get" }),
});
const data = await response.json().catch(() => ({}));
this.presets = Array.isArray(data?.presets)
? data.presets.filter((preset) => String(preset?.name || "").trim())
: [];
this._presetsLoaded = true;
} catch (error) {
this.presets = [];
this.presetsError = error instanceof Error ? error.message : String(error);
} finally {
this.presetsLoading = false;
}
},
selectedPreset() {
const selected = String(this.config?.model_preset || "").trim();
if (!selected) return null;
return this.presets.find((preset) => preset?.name === selected) || null;
},
presetOptions() {
const selected = String(this.config?.model_preset || "").trim();
const options = this.presets.map((preset) => ({
...preset,
label: preset.name,
missing: false,
}));
if (selected && this._presetsLoaded && !options.some((preset) => preset.name === selected)) {
options.push({
name: selected,
label: `${selected} (missing)`,
missing: true,
});
}
return options;
},
selectedPresetSummary() {
const selected = String(this.config?.model_preset || "").trim();
if (!selected) return "Using the effective Main Model.";
const preset = this.selectedPreset();
if (!preset) return `Preset "${selected}" is not available. Browser will fall back to the Main Model.`;
const chat = preset.chat || {};
const parts = [chat.provider, chat.name].filter((item) => String(item || "").trim());
return parts.length ? parts.join(" / ") : "This preset has no Main Model; Browser will fall back to the Main Model.";
},
selectedPresetMissing() {
const selected = String(this.config?.model_preset || "").trim();
return Boolean(selected && this._presetsLoaded && !this.selectedPreset());
},
openPresets() {
void globalThis.openModal?.("/plugins/_model_config/webui/main.html");
},
});

View file

@ -0,0 +1,596 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { getNamespacedClient } from "/js/websocket.js";
import { store as chatInputStore } from "/components/chat/input/input-store.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
const websocket = getNamespacedClient("/ws");
websocket.addHandlers(["ws_webui"]);
const EXTENSIONS_ROOT_FALLBACK = "/a0/usr/browser-extensions";
function firstOk(response) {
const result = response?.results?.find((item) => item?.ok);
if (result) {
const data = result.data || {};
if (data.browser_error) {
throw new Error(data.browser_error.error || data.browser_error.code || "Browser request failed");
}
return data;
}
const error = response?.results?.find((item) => !item?.ok)?.error;
if (error) throw new Error(error.error || error.code || "Browser request failed");
return {};
}
const model = {
loading: true,
error: "",
status: null,
contextId: "",
browsers: [],
activeBrowserId: null,
address: "",
frameSrc: "",
frameState: null,
connected: false,
addressFocused: false,
_frameOff: null,
_stateOff: null,
_lastFrameAt: 0,
_floatingCleanup: null,
_stageElement: null,
_stageResizeObserver: null,
_viewportSyncTimer: null,
_lastViewportKey: "",
extensionMenuOpen: false,
extensionInstallUrl: "",
extensionActionLoading: false,
extensionActionMessage: "",
extensionActionError: "",
extensionsRoot: "",
extensionsList: [],
async refreshStatus() {
this.status = await callJsonApi("/plugins/_browser/status", {});
},
async refreshExtensionsList() {
const response = await callJsonApi("/plugins/_browser/extensions", { action: "list" });
if (response?.ok) {
this.extensionsRoot = response.root || EXTENSIONS_ROOT_FALLBACK;
this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
}
},
toggleExtensionsMenu() {
this.extensionMenuOpen = !this.extensionMenuOpen;
if (this.extensionMenuOpen) {
this.extensionActionMessage = "";
this.extensionActionError = "";
void this.refreshExtensionsList();
}
},
closeExtensionsMenu() {
this.extensionMenuOpen = false;
},
resolveContextId() {
const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
const selectedChat = globalThis.Alpine?.store?.("chats")?.selected;
return globalThis.getContext?.() || urlContext || selectedChat || "";
},
async openExtensionsSettings() {
if (!pluginSettingsStore?.openConfig) {
this.error = "Browser settings are unavailable.";
return;
}
try {
this.closeExtensionsMenu();
await pluginSettingsStore.openConfig("_browser");
await this.refreshAfterSettingsClose();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
}
},
async refreshAfterSettingsClose() {
this.loading = true;
this.error = "";
try {
await this.refreshStatus();
await this.refreshExtensionsList();
this.connected = false;
this.browsers = [];
this.setActiveBrowserId(null);
this.address = "";
this.frameState = null;
this.frameSrc = "";
if (this.contextId) {
await this.connectViewer();
}
} finally {
this.loading = false;
}
},
async openExtensionsFolder() {
this.closeExtensionsMenu();
try {
if (!this.extensionsRoot) {
await this.refreshExtensionsList();
}
void fileBrowserStore.open(this.extensionsRoot || EXTENSIONS_ROOT_FALLBACK);
} catch (error) {
this.extensionActionError = error instanceof Error ? error.message : String(error);
}
},
createExtensionWithAgent() {
this._prefillAgentPrompt(
[
"Use the a0-browser-ext skill to create a new Chrome extension for Agent Zero's Browser.",
"Start by asking me for the extension name, purpose, target websites, and required permissions.",
`Create it under ${this.extensionsRoot || EXTENSIONS_ROOT_FALLBACK}/<extension-slug> and keep permissions minimal.`,
].join("\n")
);
},
askAgentInstallExtension() {
const url = String(this.extensionInstallUrl || "").trim();
this._prefillAgentPrompt(
[
"Use the a0-browser-ext skill to install and review a Chrome Web Store extension for Agent Zero's Browser.",
url ? `Chrome Web Store URL or id: ${url}` : "Ask me for the Chrome Web Store URL or extension id first.",
"Explain the permissions and any sandbox risk before enabling it.",
].join("\n")
);
},
async installExtensionFromUrl() {
const url = String(this.extensionInstallUrl || "").trim();
this.extensionActionMessage = "";
this.extensionActionError = "";
if (!url) {
this.extensionActionError = "Paste a Chrome Web Store URL or extension id first.";
return;
}
this.extensionActionLoading = true;
try {
const response = await callJsonApi("/plugins/_browser/extensions", {
action: "install_web_store",
url,
});
if (!response?.ok) {
throw new Error(response?.error || "Install failed.");
}
this.extensionInstallUrl = "";
this.extensionActionMessage = `Installed ${response.name || response.id}. Browser sessions restart when extension settings change.`;
await this.refreshStatus();
await this.refreshExtensionsList();
} catch (error) {
this.extensionActionError = error instanceof Error ? error.message : String(error);
} finally {
this.extensionActionLoading = false;
}
},
_prefillAgentPrompt(prompt) {
chatInputStore.message = prompt;
chatInputStore.adjustTextareaHeight?.();
chatInputStore.focus?.();
this.closeExtensionsMenu();
},
async onOpen(element = null) {
this.loading = true;
this.error = "";
this.setupFloatingModal(element);
this.contextId = this.resolveContextId();
try {
await this.refreshStatus();
await this.connectViewer();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.loading = false;
}
},
async connectViewer() {
if (!this.contextId) {
this.connected = false;
this.error = "No active chat context is selected.";
return;
}
this.error = "";
await this._bindSocketEvents();
const response = await websocket.request(
"browser_viewer_subscribe",
{
context_id: this.contextId,
browser_id: this.activeBrowserId,
},
{ timeoutMs: 10000 },
);
const data = firstOk(response);
this.browsers = data.browsers || [];
this.setActiveBrowserId(data.active_browser_id || this.activeBrowserId || null);
this.connected = true;
this.queueViewportSync(true);
},
async _bindSocketEvents() {
if (!this._frameOff) {
const frameHandler = ({ data }) => {
if (data?.context_id !== this.contextId) return;
this.browsers = data.browsers || this.browsers;
this.setActiveBrowserId(data.browser_id || data.state?.id || this.activeBrowserId);
this.frameState = data.state || null;
if (!this.addressFocused && data.state?.currentUrl) {
this.address = data.state.currentUrl;
}
this.frameSrc = data.image ? `data:${data.mime || "image/jpeg"};base64,${data.image}` : "";
if (!data.image && !data.state) {
this.setActiveBrowserId(null);
this.frameState = null;
this.frameSrc = "";
}
this._lastFrameAt = Date.now();
};
await websocket.on("browser_viewer_frame", frameHandler);
this._frameOff = () => websocket.off("browser_viewer_frame", frameHandler);
}
if (!this._stateOff) {
const stateHandler = ({ data }) => {
if (data?.context_id !== this.contextId) return;
this.browsers = data.browsers || [];
this.setActiveBrowserId(data.last_interacted_browser_id || this.firstBrowserId());
this.queueViewportSync(true);
};
await websocket.on("browser_viewer_state", stateHandler);
this._stateOff = () => websocket.off("browser_viewer_state", stateHandler);
}
},
async command(command, extra = {}) {
this.error = "";
const previousActiveBrowserId = this.activeBrowserId;
try {
const response = await websocket.request(
"browser_viewer_command",
{
context_id: this.contextId,
browser_id: this.activeBrowserId,
command,
...extra,
},
{ timeoutMs: 20000 },
);
const data = firstOk(response);
this.browsers = data.browsers || this.browsers;
const result = data.result || {};
this.setActiveBrowserId(
result.id
|| result.state?.id
|| result.last_interacted_browser_id
|| data.last_interacted_browser_id
|| this.firstBrowserId()
);
if (!this.activeBrowserId) {
this.frameState = null;
this.frameSrc = "";
}
if (result.state?.currentUrl || result.currentUrl) {
this.address = result.state?.currentUrl || result.currentUrl;
}
const activeChanged = this.activeBrowserId && this.activeBrowserId !== previousActiveBrowserId;
if ((command === "open" || command === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
await this.connectViewer();
}
this.queueViewportSync(true);
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
}
},
async go() {
const url = String(this.address || "").trim();
if (!url) return;
this.addressFocused = false;
globalThis.document?.activeElement?.blur?.();
if (this.activeBrowserId) {
await this.command("navigate", { url });
} else {
await this.command("open", { url });
}
},
onAddressFocus() {
this.addressFocused = true;
},
onAddressBlur() {
this.addressFocused = false;
if (this.frameState?.currentUrl && !String(this.address || "").trim()) {
this.address = this.frameState.currentUrl;
}
},
async selectBrowser(id) {
if (String(id || "").trim() === "") {
await this.command("open", { url: "about:blank" });
return;
}
this.setActiveBrowserId(id);
if (this.contextId) {
await this.connectViewer();
}
},
firstBrowserId() {
const first = Array.isArray(this.browsers) ? this.browsers[0] : null;
return first?.id || null;
},
setActiveBrowserId(id) {
const previous = this.activeBrowserId;
const numeric = Number(id) || null;
const exists = !numeric || !Array.isArray(this.browsers) || this.browsers.some((browser) => Number(browser.id) === numeric);
this.activeBrowserId = exists ? numeric : null;
if (this.activeBrowserId !== previous) {
this._lastViewportKey = "";
}
},
pointerCoordinatesFor(event, element = null) {
const target = element || event?.currentTarget;
if (!target) return null;
const rect = target.getBoundingClientRect();
const naturalWidth = target.naturalWidth || rect.width;
const naturalHeight = target.naturalHeight || rect.height;
return {
x: ((event.clientX - rect.left) / Math.max(1, rect.width)) * naturalWidth,
y: ((event.clientY - rect.top) / Math.max(1, rect.height)) * naturalHeight,
};
},
currentViewportSize() {
const stage = this._stageElement;
if (!stage) return null;
const width = Math.floor(stage.clientWidth || 0);
const height = Math.floor(stage.clientHeight || 0);
if (width < 80 || height < 80) return null;
return {
width: Math.max(320, width),
height: Math.max(200, height),
};
},
queueViewportSync(force = false) {
if (this._viewportSyncTimer) {
globalThis.clearTimeout(this._viewportSyncTimer);
}
this._viewportSyncTimer = globalThis.setTimeout(() => {
this._viewportSyncTimer = null;
void this.syncViewport(force);
}, force ? 0 : 80);
},
async syncViewport(force = false) {
if (!this.contextId || !this.activeBrowserId) return;
const viewport = this.currentViewportSize();
if (!viewport) return;
const key = `${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
if (!force && this._lastViewportKey === key) return;
try {
await websocket.emit("browser_viewer_input", {
context_id: this.contextId,
browser_id: this.activeBrowserId,
input_type: "viewport",
width: viewport.width,
height: viewport.height,
});
this._lastViewportKey = key;
} catch (error) {
this._lastViewportKey = "";
console.warn("Browser viewport sync failed", error);
}
},
async sendMouse(eventType, event) {
if (!this.activeBrowserId || !event?.currentTarget) return;
const pointer = this.pointerCoordinatesFor(event);
if (!pointer) return;
await websocket.emit("browser_viewer_input", {
context_id: this.contextId,
browser_id: this.activeBrowserId,
input_type: "mouse",
event_type: eventType,
x: pointer.x,
y: pointer.y,
button: "left",
});
},
async sendWheel(event) {
if (!this.activeBrowserId || !event) return;
const image = event.currentTarget?.querySelector?.(".browser-frame") || event.target?.closest?.(".browser-frame");
const pointer = this.pointerCoordinatesFor(event, image);
if (!pointer) return;
await websocket.emit("browser_viewer_input", {
context_id: this.contextId,
browser_id: this.activeBrowserId,
input_type: "wheel",
x: pointer.x,
y: pointer.y,
delta_x: Number(event.deltaX || 0),
delta_y: Number(event.deltaY || 0),
});
},
async sendKey(event) {
if (!this.activeBrowserId) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target?.tagName);
if (editable) return;
event.preventDefault();
const printable = event.key && event.key.length === 1;
await websocket.emit("browser_viewer_input", {
context_id: this.contextId,
browser_id: this.activeBrowserId,
input_type: "keyboard",
key: printable ? "" : event.key,
text: printable ? event.key : "",
});
},
async cleanup() {
if (this.contextId) {
try {
await websocket.emit("browser_viewer_unsubscribe", { context_id: this.contextId });
} catch {}
}
this._frameOff?.();
this._stateOff?.();
this._frameOff = null;
this._stateOff = null;
this._floatingCleanup?.();
this._floatingCleanup = null;
this._stageResizeObserver?.disconnect?.();
this._stageResizeObserver = null;
this._stageElement = null;
if (this._viewportSyncTimer) {
globalThis.clearTimeout(this._viewportSyncTimer);
this._viewportSyncTimer = null;
}
this._lastViewportKey = "";
this.extensionMenuOpen = false;
this.extensionActionLoading = false;
this.connected = false;
},
setupFloatingModal(element = null) {
this._floatingCleanup?.();
const root = element || globalThis.document?.querySelector(".browser-panel");
const modal = root?.closest?.(".modal");
const inner = modal?.querySelector?.(".modal-inner");
const body = modal?.querySelector?.(".modal-bd");
const header = modal?.querySelector?.(".modal-header");
const stage = root?.querySelector?.(".browser-stage");
if (!modal || !inner || !header) return;
modal.classList.add("modal-floating");
inner.classList.add("browser-modal");
body?.classList?.add("browser-modal-body");
this._stageElement = stage || null;
const rect = inner.getBoundingClientRect();
inner.style.left = `${Math.max(8, rect.left)}px`;
inner.style.top = `${Math.max(8, rect.top)}px`;
inner.style.transform = "none";
let drag = null;
let resizeObserver = null;
const viewportGap = 8;
const clampPosition = (left, top) => {
const bounds = inner.getBoundingClientRect();
const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
return {
left: Math.min(Math.max(viewportGap, left), maxLeft),
top: Math.min(Math.max(viewportGap, top), maxTop),
};
};
const clampGeometry = () => {
const bounds = inner.getBoundingClientRect();
const left = Math.max(viewportGap, bounds.left);
const top = Math.max(viewportGap, bounds.top);
const maxWidth = Math.max(320, globalThis.innerWidth - viewportGap * 2);
const maxHeight = Math.max(300, globalThis.innerHeight - viewportGap * 2);
if (bounds.width > maxWidth) {
inner.style.width = `${maxWidth}px`;
}
if (bounds.height > maxHeight) {
inner.style.height = `${maxHeight}px`;
}
const next = clampPosition(left, top);
inner.style.left = `${next.left}px`;
inner.style.top = `${next.top}px`;
inner.style.maxWidth = `${Math.max(320, globalThis.innerWidth - next.left - viewportGap)}px`;
inner.style.maxHeight = `${Math.max(300, globalThis.innerHeight - next.top - viewportGap)}px`;
this.queueViewportSync();
};
clampGeometry();
globalThis.addEventListener("resize", clampGeometry);
if (globalThis.ResizeObserver) {
resizeObserver = new ResizeObserver(clampGeometry);
resizeObserver.observe(inner);
if (stage) {
this._stageResizeObserver?.disconnect?.();
this._stageResizeObserver = new ResizeObserver(() => this.queueViewportSync());
this._stageResizeObserver.observe(stage);
}
}
globalThis.requestAnimationFrame(() => this.queueViewportSync(true));
const onPointerMove = (event) => {
if (!drag) return;
const next = clampPosition(
drag.left + event.clientX - drag.x,
drag.top + event.clientY - drag.y,
);
inner.style.left = `${next.left}px`;
inner.style.top = `${next.top}px`;
clampGeometry();
};
const onPointerUp = () => {
drag = null;
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
try {
header.releasePointerCapture?.(header.__browserPanelPointerId || 0);
} catch {}
};
const onPointerDown = (event) => {
if (event.button !== 0) return;
if (event.target?.closest?.("button, input, select, textarea, a")) return;
const current = inner.getBoundingClientRect();
drag = {
x: event.clientX,
y: event.clientY,
left: current.left,
top: current.top,
};
header.__browserPanelPointerId = event.pointerId;
header.setPointerCapture?.(event.pointerId);
globalThis.addEventListener("pointermove", onPointerMove);
globalThis.addEventListener("pointerup", onPointerUp);
event.preventDefault();
};
header.addEventListener("pointerdown", onPointerDown);
this._floatingCleanup = () => {
header.removeEventListener("pointerdown", onPointerDown);
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
globalThis.removeEventListener("resize", clampGeometry);
resizeObserver?.disconnect?.();
this._stageResizeObserver?.disconnect?.();
this._stageResizeObserver = null;
};
},
get activeTitle() {
return this.frameState?.title || "Browser";
},
get activeUrl() {
return this.frameState?.currentUrl || this.address || "about:blank";
},
};
export const store = createStore("browserPage", model);

View file

@ -0,0 +1,225 @@
<html>
<head>
<title>Browser Settings</title>
<script type="module">
import { store } from "/plugins/_browser/webui/browser-config-store.js";
</script>
</head>
<body>
<div x-data>
<template x-if="$store.browserConfig && config">
<div
class="browser-config-sections"
x-init="$store.browserConfig.init(config)"
x-effect="$store.browserConfig.bindConfig(config)"
x-destroy="$store.browserConfig.cleanup()"
>
<div class="browser-config-card">
<div class="section-title">Browser Model Preset</div>
<div class="section-description">
Choose an optional Model Configuration preset for Browser-owned model helpers. Leave it
on default to follow the effective Main Model.
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Preset</div>
<div class="field-description" x-text="$store.browserConfig.selectedPresetSummary()"></div>
</div>
<div class="field-control">
<select x-model="config.model_preset" :disabled="$store.browserConfig.presetsLoading">
<option value="">Default Main Model</option>
<template x-for="preset in $store.browserConfig.presetOptions()" :key="preset.name">
<option :value="preset.name" x-text="preset.label"></option>
</template>
</select>
</div>
</div>
<div class="browser-config-note" x-show="$store.browserConfig.presetsLoading">
<span class="material-symbols-outlined spinning">progress_activity</span>
<span>Loading model presets...</span>
</div>
<div class="browser-config-warning" x-show="$store.browserConfig.selectedPresetMissing()">
<span class="material-symbols-outlined">warning</span>
<span>The saved preset is missing. Browser will use the effective Main Model until you choose another preset.</span>
</div>
<div class="browser-config-note" x-show="$store.browserConfig.presetsError">
<span class="material-symbols-outlined">error</span>
<span x-text="$store.browserConfig.presetsError"></span>
</div>
<div class="browser-config-actions">
<button type="button" class="btn btn-field" @click="$store.browserConfig.openPresets()">
<span class="material-symbols-outlined">tune</span>
<span>Edit Presets</span>
</button>
</div>
</div>
<div class="browser-config-card">
<div class="section-title">Chrome Extensions</div>
<div class="section-description">
Load unpacked Chromium extensions into the Browser tool. When extensions are active,
Browser switches from Playwright's lightweight headless shell to bundled Chromium so
the extensions can actually load.
</div>
<div class="browser-config-warning">
<span class="material-symbols-outlined">warning</span>
<span>
Browser extensions run inside the Docker browser sandbox, but malicious or buggy
extensions can still damage that sandboxed environment. Install only extensions you
trust and keep permissions as small as possible.
</span>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Enable extensions</div>
<div class="field-description">
Turn this on only when you have unpacked extension folders ready. Saving changes
restarts active Browser sessions so the new launch mode applies immediately.
</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="config.extensions_enabled" />
<span class="toggler"></span>
</label>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Extension directories</div>
<div class="field-description">
One unpacked extension directory per line. Use paths that are visible inside the
runtime environment itself, especially when Agent Zero is running in Docker.
</div>
</div>
<div class="field-control">
<textarea
:value="$store.browserConfig.extensionPathsText"
@input="$store.browserConfig.setExtensionPathsText($event.target.value)"
rows="6"
placeholder="/a0/usr/browser-extensions/my-extension"
></textarea>
</div>
</div>
<div class="browser-config-note">
<span class="material-symbols-outlined">info</span>
<span>
This first version supports unpacked extension folders only. Chrome Web Store installs
and `.crx` files are out of scope for now.
</span>
</div>
<div class="browser-config-note">
<span class="material-symbols-outlined">deployed_code</span>
<span>
Playwright currently requires a persistent Chromium context for extension loading, so
Browser stays in its faster headless-shell mode until valid extension folders are both
configured and enabled.
</span>
</div>
<div class="browser-config-pill-row">
<span class="browser-config-pill" x-text="$store.browserConfig.pathCountLabel()"></span>
<span class="browser-config-pill tone-active" x-show="$store.browserConfig.extensionModeReady()">
Extension mode ready
</span>
</div>
</div>
</div>
</template>
</div>
<style>
.browser-config-sections {
display: flex;
flex-direction: column;
gap: 16px;
}
.browser-config-card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.browser-config-card textarea {
min-height: 132px;
resize: vertical;
font-family: var(--font-family-monospace, monospace);
}
.browser-config-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.browser-config-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
color: var(--color-text-secondary);
font-size: var(--font-size-small);
}
.browser-config-warning {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 11px 12px;
border: 1px solid color-mix(in srgb, #d97706 44%, var(--color-border));
border-radius: 8px;
background: color-mix(in srgb, #d97706 14%, var(--color-background));
color: color-mix(in srgb, var(--color-text) 86%, #92400e);
font-size: var(--font-size-small);
line-height: 1.4;
}
.browser-config-warning .material-symbols-outlined {
color: #b45309;
font-size: 20px;
}
.browser-config-pill-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.browser-config-pill {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
background: color-mix(in srgb, var(--color-panel) 88%, transparent);
font-size: 0.78rem;
color: var(--color-text-secondary);
}
.browser-config-pill.tone-active {
color: #1b5e20;
border-color: rgba(27, 94, 32, 0.18);
background: rgba(46, 125, 50, 0.12);
}
</style>
</body>
</html>

View file

@ -0,0 +1,556 @@
<html class="browser-modal">
<head>
<title>Browser</title>
<script type="module">
import { store } from "/plugins/_browser/webui/browser-store.js";
</script>
</head>
<body class="browser-modal-body">
<div x-data>
<template x-if="$store.browserPage">
<div
class="browser-panel"
x-create="$store.browserPage.onOpen($el)"
x-destroy="$store.browserPage.cleanup()"
@keydown.window="$store.browserPage.sendKey($event)"
>
<div class="browser-meta">
<div class="browser-meta-top">
<div class="browser-titleline">
<span
class="browser-live-dot"
:class="{ active: $store.browserPage.connected && $store.browserPage.frameSrc }"
></span>
<span class="browser-title">Browser</span>
<span class="browser-id" x-show="$store.browserPage.activeBrowserId" x-text="'#' + $store.browserPage.activeBrowserId"></span>
</div>
<div class="browser-session-controls">
<select class="browser-select" x-model="$store.browserPage.activeBrowserId" @change="$store.browserPage.selectBrowser($event.target.value)">
<option value="">New Browser</option>
<template x-for="browser in $store.browserPage.browsers" :key="browser.id">
<option :value="browser.id" x-text="'#' + browser.id + ' ' + (browser.title || browser.currentUrl || 'about:blank')"></option>
</template>
</select>
<div
class="browser-extension-menu"
@click.outside="$store.browserPage.closeExtensionsMenu()"
@keydown.escape.window="$store.browserPage.closeExtensionsMenu()"
>
<button
type="button"
class="btn btn-icon-action browser-extensions"
title="Browser extensions"
aria-label="Browser extensions"
@click.stop="$store.browserPage.toggleExtensionsMenu()"
:aria-expanded="$store.browserPage.extensionMenuOpen.toString()"
:class="{ 'is-active': $store.browserPage.status?.extensions?.active }"
>
<span class="material-symbols-outlined">extension</span>
</button>
<div
class="browser-extension-dropdown"
x-show="$store.browserPage.extensionMenuOpen"
x-transition
style="display: none;"
>
<div class="browser-extension-warning">
<span class="material-symbols-outlined">warning</span>
<span>
Extensions run inside the Docker browser sandbox, but malicious or buggy extensions can still damage that environment. Review what you install.
</span>
</div>
<button type="button" class="dropdown-item" @click="$store.browserPage.createExtensionWithAgent()">
<span class="material-symbols-outlined">add_circle</span>
<span>+ Create New with A0</span>
</button>
<div class="browser-extension-url">
<label for="browser-extension-url">Chrome Web Store URL</label>
<input
id="browser-extension-url"
type="url"
x-model="$store.browserPage.extensionInstallUrl"
@keydown.enter.prevent="$store.browserPage.installExtensionFromUrl()"
placeholder="https://chromewebstore.google.com/detail/..."
/>
<div class="browser-extension-url-actions">
<button
type="button"
class="btn btn-ok"
@click="$store.browserPage.installExtensionFromUrl()"
:disabled="$store.browserPage.extensionActionLoading"
>
<span class="material-symbols-outlined" x-text="$store.browserPage.extensionActionLoading ? 'progress_activity' : 'download'"></span>
<span>Install URL</span>
</button>
<button type="button" class="btn btn-field" @click="$store.browserPage.askAgentInstallExtension()">
<span class="material-symbols-outlined">psychology_alt</span>
<span>Ask A0</span>
</button>
</div>
</div>
<button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsFolder()">
<span class="material-symbols-outlined">folder_open</span>
<span>My Browser Extensions</span>
</button>
<button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsSettings()">
<span class="material-symbols-outlined">tune</span>
<span>Browser Extension Settings</span>
</button>
<div class="browser-extension-message" x-show="$store.browserPage.extensionActionMessage" x-text="$store.browserPage.extensionActionMessage"></div>
<div class="browser-extension-error" x-show="$store.browserPage.extensionActionError" x-text="$store.browserPage.extensionActionError"></div>
</div>
</div>
<button class="btn btn-icon-action browser-close" title="Close Browser" @click="$confirmClick($event, () => $store.browserPage.command('close'))" :disabled="!$store.browserPage.activeBrowserId">
<span class="material-symbols-outlined">close</span>
</button>
</div>
</div>
</div>
<div class="browser-toolbar">
<div class="browser-navigation">
<button class="btn btn-icon-action" title="Back" @click="$store.browserPage.command('back')" :disabled="!$store.browserPage.activeBrowserId">
<span class="material-symbols-outlined">arrow_back</span>
</button>
<button class="btn btn-icon-action" title="Forward" @click="$store.browserPage.command('forward')" :disabled="!$store.browserPage.activeBrowserId">
<span class="material-symbols-outlined">arrow_forward</span>
</button>
<button class="btn btn-icon-action" title="Reload" @click="$store.browserPage.command('reload')" :disabled="!$store.browserPage.activeBrowserId">
<span class="material-symbols-outlined">refresh</span>
</button>
</div>
<form class="browser-address-form" @submit.prevent="$store.browserPage.go()">
<span class="material-symbols-outlined browser-address-icon">language</span>
<input
class="browser-address"
x-model="$store.browserPage.address"
@focus="$store.browserPage.onAddressFocus()"
@blur="$store.browserPage.onAddressBlur()"
placeholder="https://example.com"
autocomplete="off"
/>
</form>
</div>
<div class="browser-status" x-show="$store.browserPage.loading">
<span class="material-symbols-outlined spinning">progress_activity</span>
<span>Connecting browser...</span>
</div>
<div class="browser-error" x-show="$store.browserPage.error" x-text="$store.browserPage.error"></div>
<div
class="browser-stage"
tabindex="0"
@click="$el.focus()"
@wheel.prevent="$store.browserPage.sendWheel($event)"
>
<template x-if="$store.browserPage.frameSrc">
<img
class="browser-frame"
:src="$store.browserPage.frameSrc"
@click="$store.browserPage.sendMouse('click', $event)"
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)"
draggable="false"
/>
</template>
<template x-if="!$store.browserPage.frameSrc && !$store.browserPage.loading">
<div class="browser-empty">
<span class="material-symbols-outlined">captive_portal</span>
<button class="btn btn-field" @click="$store.browserPage.command('open', { url: 'about:blank' })">Open Browser</button>
</div>
</template>
</div>
</div>
</template>
</div>
<style>
.modal-inner.browser-modal {
box-sizing: border-box;
container-type: inline-size;
width: min(78vw, 1120px);
height: min(88vh, 900px);
min-width: min(320px, calc(100vw - 16px));
min-height: min(480px, calc(100vh - 16px));
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
resize: both;
border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
border-radius: 7px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
}
.modal.modal-floating {
pointer-events: none;
}
.modal.modal-floating .modal-inner {
pointer-events: auto;
}
.modal-inner.browser-modal .modal-header {
min-height: 34px;
padding: 0.35rem 0.75rem 0.35rem 1rem;
cursor: move;
user-select: none;
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
}
.modal-inner.browser-modal .modal-title {
font-size: 0.95rem;
letter-spacing: 0;
}
.modal-inner.browser-modal .modal-close {
font-size: 1.35rem;
line-height: 1;
}
.modal-inner.browser-modal .modal-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 0;
}
.modal-inner.browser-modal .modal-bd.browser-modal-body {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
padding: 0;
min-height: 0;
}
.modal-inner.browser-modal .modal-bd.browser-modal-body > div[x-data] {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.browser-panel {
box-sizing: border-box;
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0;
height: 100%;
min-height: 0;
}
.browser-toolbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas: "nav address";
gap: 6px;
align-items: center;
padding: 7px 8px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
background: color-mix(in srgb, var(--color-panel) 90%, transparent);
}
.browser-navigation {
grid-area: nav;
display: flex;
gap: 4px;
}
.browser-address-form {
grid-area: address;
min-width: 0;
position: relative;
margin: 0;
}
.browser-address-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 18px;
opacity: 0.58;
pointer-events: none;
}
.browser-address,
.browser-select {
width: 100%;
min-height: 32px;
padding: 5px 9px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
background: var(--color-input);
color: var(--color-text);
font: inherit;
}
.browser-address {
padding-left: 34px;
}
.browser-select {
min-width: 0;
}
.browser-meta {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
padding: 6px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
}
.browser-meta-top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.browser-titleline {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.browser-session-controls {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.browser-session-controls .browser-select {
width: min(320px, 52cqw);
}
.browser-session-controls .browser-extensions.is-active {
color: #2e7d32;
}
.browser-extension-menu {
position: relative;
display: flex;
flex: 0 0 auto;
}
.browser-extension-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 40;
display: flex;
flex-direction: column;
gap: 7px;
width: min(360px, calc(100vw - 24px));
padding: 10px;
border: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent);
border-radius: 7px;
background: var(--color-background);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.28);
}
.browser-extension-dropdown .dropdown-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 34px;
padding: 7px 9px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--color-text);
font-weight: 600;
text-align: left;
cursor: pointer;
}
.browser-extension-dropdown .dropdown-item:hover {
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
}
.browser-extension-warning,
.browser-extension-message,
.browser-extension-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 10px;
border-radius: 7px;
font-size: 0.8rem;
line-height: 1.35;
}
.browser-extension-warning {
border: 1px solid color-mix(in srgb, #d97706 42%, var(--color-border));
background: color-mix(in srgb, #d97706 14%, var(--color-background));
color: color-mix(in srgb, var(--color-text) 86%, #92400e);
}
.browser-extension-warning .material-symbols-outlined {
color: #b45309;
font-size: 19px;
}
.browser-extension-url {
display: flex;
flex-direction: column;
gap: 7px;
padding: 8px;
border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
border-radius: 7px;
background: var(--color-panel);
}
.browser-extension-url label {
font-size: 0.76rem;
color: var(--color-text-secondary);
}
.browser-extension-url input {
min-width: 0;
min-height: 32px;
padding: 6px 8px;
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
border-radius: 6px;
background: var(--color-input);
color: var(--color-text);
}
.browser-extension-url-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.browser-extension-url-actions .btn {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
}
.browser-extension-message {
background: color-mix(in srgb, #15803d 12%, var(--color-background));
color: color-mix(in srgb, var(--color-text) 88%, #166534);
}
.browser-extension-error {
background: color-mix(in srgb, #be123c 12%, var(--color-background));
color: #9f1239;
}
.browser-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #777;
flex: 0 0 auto;
}
.browser-live-dot.active {
background: #2e7d32;
box-shadow: 0 0 0 4px rgba(46, 125, 50, 0.13);
}
.browser-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.browser-title {
font-size: 0.9rem;
font-weight: 650;
}
.browser-id {
font-size: 0.78rem;
opacity: 0.68;
}
.browser-stage {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
background: #fff;
outline: none;
}
.browser-frame {
flex: 0 0 auto;
display: block;
width: 100%;
height: auto;
user-select: none;
background: #fff;
}
.browser-status,
.browser-error,
.browser-empty {
display: flex;
align-items: center;
gap: 8px;
min-height: 42px;
font-size: 0.88rem;
}
.browser-status,
.browser-error {
padding: 0 12px;
}
.browser-error {
color: #9f1239;
}
.browser-empty {
display: grid;
flex: 1 1 auto;
width: 100%;
min-height: 0;
justify-items: center;
align-content: center;
text-align: center;
padding: 24px;
color: var(--color-text);
background: var(--color-background);
}
@container (max-width: 460px) {
.browser-meta-top {
grid-template-columns: minmax(0, 1fr);
}
.browser-session-controls {
width: 100%;
}
.browser-session-controls .browser-select {
flex: 1 1 auto;
width: auto;
}
.browser-extension-dropdown {
right: 0;
left: auto;
width: min(296px, calc(100vw - 72px));
}
.browser-address,
.browser-select {
min-height: 34px;
}
}
</style>
</body>
</html>