Simplify Browser quick settings and expand browser configuration
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions

Keep quick settings focused on the model preset, Host/Container choice,
native page zoom, and installed Chrome extensions. Show a dimmed host
configuration link only in Host mode, and match the Browser and Zoom
labels to the preset label.

Move extension creation, installation, and review into Browser config.
Open Chromium management pages for browsing data, history, passwords,
autofill, extensions, and downloads through the existing Browser surface.
Honor native download folder and save-dialog preferences, and add
annotation screenshots as an opt-in setting that defaults off.

Update the Browser contracts and regression coverage. Validation: 201
browser/tool tests and 29 host-connector tests passed, with the host suite
isolated from existing module stubs. Live checks on localhost:32083
covered settings navigation, runtime selection, zoom, annotation attachments,
download preferences, matching typography, and narrow layouts.
This commit is contained in:
Alessandro 2026-10-08 01:14:08 +02:00
parent cb93ed055b
commit 5a1698ce62
12 changed files with 665 additions and 267 deletions

View file

@ -60,6 +60,11 @@
- Clipboard `clipboard_action` reads legacy aliases `operation` and `event_type`; `a0_path` and `context_id` are screenshot result fields, not tool args. Document only canonical args in model-facing prompts.
- Browser action values accept legacy spellings `setactive`/`activate`/`focus` for `set_active`, `typesubmit` for `type_submit`, and `keychord` for `key_chord`; prompts document only canonical action values.
- Annotation mode highlights the DOM element under the pointer, keeps saved overlays page-local, and may batch annotated pages only within the active chat context.
- Quick settings contains the model preset, a scoped Host / Container choice, native container-page zoom, installed Chrome Extensions, and More settings. Show the dimmed Configure host browser text button directly below the choice only in Host mode; both settings links reuse the existing plugin config flow. The choice controls agent tool routing; the embedded viewer remains the container browser.
- Browser config owns extension creation, Web Store installation, and agent review actions. Management buttons open Chromium's native history, clear-data, password, contact, extension, and download pages using existing Browser surfaces; keep modal infrastructure unchanged and preserve the settings form underneath.
- Annotation screenshots default off. Capture only explicit annotation selections when the saved scoped preference allows it, never pointer hover; attach images through the chat attachment store only for the current chat, for both typed and voice workflows.
- Native zoom sends only allowlisted shortcuts to Chromium's private display; report the actual device-pixel ratio as the zoom percentage. Chromium retains its native zoom limits and per-site settings.
- Container downloads honor Chromium's native folder and save-dialog preferences. Seed the existing Browser download directory only when no folder is configured, preserve other profile preferences, and restore the native download behavior after Patchright launches.
- Annotation voice input reuses Whisper STT's configured draft/send delivery mode and shared microphone state.
- Internal-browser proxy settings map directly to Playwright's persistent-context proxy option, never to Bring Your Own Browser, and changes must restart active internal runtimes.
- Run internal Chromium headful through Patchright on the private virtual display; do not add user-agent or header spoofing on top of the patched driver.

View file

@ -1,13 +1,16 @@
import asyncio
from types import SimpleNamespace
from helpers import plugins
from helpers import plugins, projects
from helpers.api import ApiHandler, Request
from plugins._browser.helpers.config import (
AUTOFOCUS_ACTIVE_PAGE_KEY,
ANNOTATION_SCREENSHOTS_KEY,
DEFAULT_HOMEPAGE_KEY,
MODEL_PRESET_KEY,
PLUGIN_NAME,
RUNTIME_BACKEND_KEY,
RUNTIME_BACKENDS,
get_browser_config,
get_browser_main_model_summary,
get_browser_model_preset_options,
@ -29,6 +32,18 @@ class Extensions(ApiHandler):
if action == "list":
return self._browser_extension_payload(agent=agent)
if action == "set_runtime_backend":
backend = input.get(RUNTIME_BACKEND_KEY)
if not isinstance(backend, str) or backend not in RUNTIME_BACKENDS:
return {"ok": False, "error": "Choose Host or Container browser."}
if input.get("context_id") and agent is None:
return {"ok": False, "error": "The selected chat is no longer available."}
config = get_browser_config(agent=agent)
config[RUNTIME_BACKEND_KEY] = backend
project_name = projects.get_context_project_name(agent.context) if agent else ""
plugins.save_plugin_config(PLUGIN_NAME, project_name or "", "", config)
return self._browser_extension_payload(agent=agent)
if action == "install_web_store":
try:
result = await asyncio.to_thread(
@ -94,6 +109,7 @@ class Extensions(ApiHandler):
def _browser_extension_payload(self, agent=None) -> dict:
config = get_browser_config()
scoped_config = get_browser_config(agent=agent)
return {
"ok": True,
"root": str(get_extensions_root()),
@ -101,6 +117,9 @@ class Extensions(ApiHandler):
"extension_paths": config["extension_paths"],
DEFAULT_HOMEPAGE_KEY: config[DEFAULT_HOMEPAGE_KEY],
AUTOFOCUS_ACTIVE_PAGE_KEY: config[AUTOFOCUS_ACTIVE_PAGE_KEY],
ANNOTATION_SCREENSHOTS_KEY: scoped_config[ANNOTATION_SCREENSHOTS_KEY],
RUNTIME_BACKEND_KEY: scoped_config[RUNTIME_BACKEND_KEY],
"settings_project_name": projects.get_context_project_name(agent.context) if agent else "",
MODEL_PRESET_KEY: config[MODEL_PRESET_KEY],
"main_model_summary": get_browser_main_model_summary(agent=agent),
"model_preset_options": get_browser_model_preset_options(agent=agent, settings=config),

View file

@ -10,6 +10,7 @@ from agent import AgentContext
from helpers.ws import WsHandler
from helpers.ws_manager import WsResult
from plugins._browser.helpers.config import (
ANNOTATION_SCREENSHOTS_KEY,
DEFAULT_BROWSER_TAB_SCOPE,
TAB_SCOPE_KEY,
get_browser_config,
@ -278,6 +279,8 @@ class WsBrowser(WsHandler):
result = await runtime.call("forward", browser_id, wait_until="commit")
elif command == "reload":
result = await runtime.call("reload", browser_id, wait_until="commit")
elif command == "zoom":
result = await runtime.call("zoom", browser_id, str(data.get("direction") or ""))
elif command == "close":
result = await runtime.call("close_browser", browser_id)
elif command == "list":
@ -428,13 +431,24 @@ class WsBrowser(WsHandler):
browser_id = data.get("browser_id")
viewer_id = str(data.get("viewer_id") or "")
payload = data.get("payload") if isinstance(data.get("payload"), dict) else {}
capture_requested = self._bool(data.get("capture_screenshot"))
include_screenshot = False
try:
annotation = await runtime.call("annotation_target", browser_id, payload)
screenshot = None
if capture_requested:
context = AgentContext.get(context_id)
config = get_browser_config(agent=context.agent0 if context else None)
include_screenshot = config[ANNOTATION_SCREENSHOTS_KEY]
if include_screenshot:
screenshot = await runtime.call("screenshot", browser_id, quality=SCREENSHOT_QUALITY)
except Exception as exc:
return self._error("ANNOTATION_FAILED", str(exc), data)
return {
"annotation": annotation,
**({ANNOTATION_SCREENSHOTS_KEY: include_screenshot} if capture_requested else {}),
**({"screenshot": screenshot} if screenshot else {}),
"context_id": context_id,
"browser_id": browser_id,
"viewer_id": viewer_id,

View file

@ -8,6 +8,9 @@ default_homepage: "about:blank"
# When the Browser surface is already open, keep it synced to agent Browser tool results.
autofocus_active_page: true
# Include page screenshots with annotations sent to the chat. Off by default.
annotation_screenshots: false
# Browser tab visibility in the WebUI. Both modes use the same internal
# Chromium runtime and sign-in profile:
# - per_context: each chat shows only its own Browser tabs.

View file

@ -13,6 +13,7 @@ MODEL_PRESET_KEY = "model_preset"
BROWSER_MODEL_ACTIVE_KEY = "_browser_model_active"
DEFAULT_HOMEPAGE_KEY = "default_homepage"
AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
ANNOTATION_SCREENSHOTS_KEY = "annotation_screenshots"
TAB_SCOPE_KEY = "browser_tab_scope"
MAX_OPEN_TABS_KEY = "max_open_tabs"
EVALUATE_TIMEOUT_KEY = "evaluate_timeout_seconds"
@ -166,6 +167,9 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
raw.get(AUTOFOCUS_ACTIVE_PAGE_KEY, True),
default=True,
),
ANNOTATION_SCREENSHOTS_KEY: _normalize_bool(
raw.get(ANNOTATION_SCREENSHOTS_KEY), default=False,
),
TAB_SCOPE_KEY: _normalize_choice(
raw.get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE),
allowed=BROWSER_TAB_SCOPES,

View file

@ -5,10 +5,12 @@ import asyncio
import base64
import contextlib
import contextvars
import json
import os
import re
import shutil
import signal
import subprocess
import threading
import time
import uuid
@ -1146,6 +1148,7 @@ class _BrowserRuntimeCore:
self._adopt_legacy_profile(self.current_context_id)
self.profile_dir.mkdir(parents=True, exist_ok=True)
self.downloads_dir.mkdir(parents=True, exist_ok=True)
self._prepare_download_preferences()
self._release_orphaned_profile_singleton()
browser_config = get_browser_config()
launch_config = build_browser_launch_config(browser_config)
@ -1203,6 +1206,13 @@ class _BrowserRuntimeCore:
self.context.on("page", self._on_new_page_sync)
existing_pages = list(self.context.pages)
if existing_pages:
session = await self.context.new_cdp_session(existing_pages[0])
try:
# Honor the folder and save-dialog preference from Chromium's settings.
await session.send("Browser.setDownloadBehavior", {"behavior": "default"})
finally:
await session.detach()
if self._restore_state_exists:
for page in existing_pages:
if self._bootstrap_page is None:
@ -1226,6 +1236,18 @@ class _BrowserRuntimeCore:
continue
await self._register_page(page)
def _prepare_download_preferences(self) -> None:
path = self.profile_dir / "Default" / "Preferences"
preferences = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
downloads = preferences.setdefault("download", {})
if downloads.get("default_directory"):
return
downloads["default_directory"] = str(self.downloads_dir)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".a0.tmp")
temporary.write_text(json.dumps(preferences), encoding="utf-8")
temporary.replace(path)
def _adopt_legacy_profile(self, context_id: str) -> None:
if self.safe_context_id != SHARED_RUNTIME_ID or self.profile_dir.exists():
return
@ -1621,6 +1643,26 @@ class _BrowserRuntimeCore:
await self.ensure_started()
return await self._state(self._resolve_browser_id(browser_id))
async def zoom(self, browser_id: int | str | None, direction: str) -> dict[str, Any]:
shortcuts = {"in": "ctrl+plus", "out": "ctrl+minus", "reset": "ctrl+0"}
if direction not in shortcuts:
raise ValueError("Zoom direction must be in, out, or reset.")
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
display = self.interactive_view.display_name
xdotool = shutil.which("xdotool")
if not display or not xdotool:
raise RuntimeError("Page zoom requires the interactive container browser.")
await self._page(resolved_id).bring_to_front()
await asyncio.to_thread(
subprocess.run,
[xdotool, "key", "--clearmodifiers", shortcuts[direction]],
env={**os.environ, "DISPLAY": display},
check=True, timeout=3, capture_output=True,
)
self._maybe_promote(resolved_id)
return await self._state(resolved_id)
async def navigate(
self,
browser_id: int | str | None,
@ -2842,7 +2884,7 @@ class _BrowserRuntimeCore:
try:
state = await page.evaluate(
"() => ({title: document.title, canGoBack: history.length > 1, "
"loading: document.readyState !== 'complete'})",
"loading: document.readyState !== 'complete', zoom: Math.round(devicePixelRatio * 100)})",
isolated_context=False,
)
except Exception:
@ -2855,6 +2897,8 @@ class _BrowserRuntimeCore:
"canGoBack": bool(state.get("canGoBack")),
"canGoForward": False,
"loading": bool(state.get("loading")),
"zoom": state.get("zoom", 100),
"zoom_available": bool(self.interactive_view.display_name and shutil.which("xdotool")),
}
def _register_page_locked(

View file

@ -3,6 +3,16 @@ import { callJsonApi } from "/js/api.js";
import { showConfirmDialog } from "/js/confirmDialog.js";
import { store as notificationStore } from "/components/notifications/notification-store.js";
const BROWSER_PAGES = {
history: "chrome://history/",
clearData: "chrome://settings/clearBrowserData",
passwords: "chrome://password-manager/passwords",
addresses: "chrome://settings/addresses",
extensions: "chrome://extensions/",
downloadSettings: "chrome://settings/downloads",
downloads: "chrome://downloads/",
};
const EXTENSIONS_ROOT = "/a0/usr/_browser/extensions";
const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions";
const BROWSER_SETUP_API = "/plugins/_browser/host_browser_setup";
const BROWSER_STATUS_API = "/plugins/_browser/status";
@ -36,6 +46,7 @@ function ensureConfig(config) {
config.extension_paths = normalizePathList(config.extension_paths);
config.default_homepage = String(config.default_homepage || "about:blank").trim() || "about:blank";
config.autofocus_active_page = normalizeBoolean(config.autofocus_active_page, true);
config.annotation_screenshots = normalizeBoolean(config.annotation_screenshots, false);
config.browser_tab_scope = normalizeChoice(config.browser_tab_scope, BROWSER_TAB_SCOPES, "per_context");
config.max_open_tabs = normalizeInt(config.max_open_tabs, DEFAULT_MAX_OPEN_TABS, MIN_MAX_OPEN_TABS, HARD_MAX_OPEN_TABS);
config.evaluate_timeout_seconds ??= 30;
@ -180,8 +191,10 @@ export const store = createStore("browserConfig", {
config: null,
extensionsList: [],
extensionsLoading: false,
extensionsError: "",
extensionsMessage: "",
extensionInstallUrl: "",
extensionActionLoading: false,
extensionsRoot: "",
openingBrowserPage: "",
extensionDeleteLoadingPath: "",
hostBrowserStatus: null,
hostBrowserStatusLoading: false,
@ -200,8 +213,8 @@ export const store = createStore("browserConfig", {
this.stopHostBrowserStatusRefresh();
this.config = null;
this.extensionsList = [];
this.extensionsError = "";
this.extensionsMessage = "";
this.extensionInstallUrl = "";
this.openingBrowserPage = "";
this.extensionDeleteLoadingPath = "";
this.hostBrowserStatus = null;
this.hostBrowserStatusLoading = false;
@ -234,6 +247,23 @@ export const store = createStore("browserConfig", {
}
},
async openBrowserPage(section) {
const url = BROWSER_PAGES[section];
if (!url || this.openingBrowserPage) return;
this.openingBrowserPage = section;
try {
const { store: browser } = await import("/plugins/_browser/webui/browser-store.js");
const { store: canvas } = await import("/components/canvas/right-canvas-store.js");
await browser.command("open", { url });
if (browser.error) throw new Error(browser.error);
await canvas.openModalSurface("browser");
} catch (error) {
notificationStore.addFrontendToastOnly("error", error instanceof Error ? error.message : String(error));
} finally {
this.openingBrowserPage = "";
}
},
setAutofocusActivePage(enabled) {
const safeConfig = ensureConfig(this.config);
if (!safeConfig) return;
@ -495,7 +525,6 @@ export const store = createStore("browserConfig", {
async loadExtensionsList() {
if (this.extensionsLoading) return;
this.extensionsLoading = true;
this.extensionsError = "";
try {
const response = await callJsonApi(BROWSER_EXTENSIONS_API, { action: "list" });
if (!response?.ok) {
@ -504,17 +533,15 @@ export const store = createStore("browserConfig", {
this.applyExtensionPayload(response);
} catch (error) {
this.extensionsList = [];
this.extensionsError = error instanceof Error ? error.message : String(error);
notificationStore.addFrontendToastOnly("error", error instanceof Error ? error.message : String(error));
} finally {
this.extensionsLoading = false;
}
},
applyExtensionPayload(response = {}) {
this.extensionsRoot = response.root || EXTENSIONS_ROOT;
this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
if (Array.isArray(response.extension_paths) && this.config) {
this.config.extension_paths = normalizePathList(response.extension_paths);
}
},
extensionEnabled(extension) {
@ -537,6 +564,70 @@ export const store = createStore("browserConfig", {
safeConfig.extension_paths = paths;
},
createExtensionWithAgent(context) {
this.prefillExtensionPrompt(context,
[
"Use the browser-extension-control 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}/<extension-slug> and keep permissions minimal.`,
].join("\n")
);
},
askAgentInstallExtension(context) {
const url = String(this.extensionInstallUrl || "").trim();
const prompt = url
? [
"Use the browser-extension-control skill to review and optionally install this Chrome Web Store extension for Agent Zero's Browser.",
`Chrome Web Store URL or id: ${url}`,
"Explain the permissions and any sandbox risk before enabling it.",
].join("\n")
: [
"Use the browser-extension-control skill to help me install and review a Chrome Web Store extension for Agent Zero's Browser.",
"Ask me for the Chrome Web Store URL or extension id first.",
"Explain the permissions and any sandbox risk before enabling it.",
].join("\n");
this.prefillExtensionPrompt(context, prompt);
},
async installExtensionFromUrl() {
if (this.extensionActionLoading) return;
const url = String(this.extensionInstallUrl || "").trim();
if (!url) {
notificationStore.addFrontendToastOnly("info", "Paste a Chrome Web Store URL or extension id first.");
return;
}
this.extensionActionLoading = true;
notificationStore.addFrontendToastOnly("info", "Installing extension… Large packages may take a few minutes.");
try {
const response = await callJsonApi("/plugins/_browser/extensions", {
action: "install_web_store",
url,
});
if (!response?.ok) {
throw new Error(response?.error || "Install failed.");
}
this.applyExtensionPayload(response);
this.setExtensionEnabled(response.path, true);
this.extensionInstallUrl = "";
notificationStore.addFrontendToastOnly("success", `Installed ${response.name || response.id}.`);
} catch (error) {
notificationStore.addFrontendToastOnly("error", error instanceof Error ? error.message : String(error));
} finally {
this.extensionActionLoading = false;
}
},
async prefillExtensionPrompt(context, prompt) {
if (context?.confirmDiscardUnsavedChanges && !context.confirmDiscardUnsavedChanges()) return;
const { store: chat } = await import("/components/chat/input/input-store.js");
await globalThis.closeModal?.();
chat.message = prompt;
chat.adjustTextareaHeight?.();
chat.focus?.();
},
extensionCanDelete(extension) {
return Boolean(extension?.can_delete);
},
@ -550,10 +641,8 @@ export const store = createStore("browserConfig", {
async deleteExtension(extension) {
const path = String(extension?.path || "").trim();
if (!path) return;
this.extensionsError = "";
this.extensionsMessage = "";
if (!this.extensionCanDelete(extension)) {
this.extensionsError = "Only Browser-managed extensions can be deleted.";
notificationStore.addFrontendToastOnly("error", "Only Browser-managed extensions can be deleted.");
return;
}
const name = String(extension?.name || "this extension").trim();
@ -576,9 +665,10 @@ export const store = createStore("browserConfig", {
throw new Error(response?.error || "Could not delete extension.");
}
this.applyExtensionPayload(response);
this.extensionsMessage = `Deleted ${response.name || name}.`;
this.setExtensionEnabled(path, false);
notificationStore.addFrontendToastOnly("success", `Deleted ${response.name || name}.`);
} catch (error) {
this.extensionsError = error instanceof Error ? error.message : String(error);
notificationStore.addFrontendToastOnly("error", error instanceof Error ? error.message : String(error));
} finally {
this.extensionDeleteLoadingPath = "";
}

View file

@ -98,45 +98,44 @@
<div class="browser-extension-preset-summary" x-text="$store.browserPage.modelPresetSummary()">
</div>
</div>
<div class="browser-extension-section-title">Chrome Extensions</div>
<button type="button" class="dropdown-item" @click="$store.browserPage.createExtensionWithAgent()">
<x-icon name="add_circle"></x-icon>
<span>Create New Extension with A0</span>
</button>
<div class="browser-extension-url">
<label for="browser-extension-url">Input a 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"
:aria-busy="$store.browserPage.extensionActionLoading.toString()">
<x-icon
:class="{ spinning: $store.browserPage.extensionActionLoading }"
:name="$store.browserPage.extensionActionLoading ? 'progress_activity' : 'download'"></x-icon>
<span x-text="$store.browserPage.extensionActionLoading ? 'Installing…' : 'Install URL'">Install URL</span>
</button>
<button type="button" class="btn btn-field"
@click="$store.browserPage.askAgentInstallExtension()">
<x-icon name="psychology_alt"></x-icon>
<span x-text="$store.browserPage.extensionAssistantActionLabel()">Scan with A0</span>
</button>
<div class="browser-quick-location">
<div class="browser-quick-row">
<span>Browser</span>
<div class="browser-location-toggle" role="group" aria-label="Browser used by the agent">
<button type="button" :aria-pressed="($store.browserPage.runtimeBackend === 'host_required').toString()"
:disabled="$store.browserPage.runtimeBackendSaving"
@click="$store.browserPage.setRuntimeBackend('host_required')">Host</button>
<button type="button" :aria-pressed="($store.browserPage.runtimeBackend === 'container').toString()"
:disabled="$store.browserPage.runtimeBackendSaving"
@click="$store.browserPage.setRuntimeBackend('container')">Container</button>
</div>
</div>
<div class="browser-extension-warning" x-show="$store.browserPage.hasExtensionInstallUrl()"
x-transition style="display: none;">
<x-icon name="warning"></x-icon>
<span>
Extensions run inside the Docker browser sandbox, but malicious or buggy extensions can still
damage that environment. Review what you install.
</span>
<button type="button" class="browser-configure-host"
x-show="$store.browserPage.runtimeBackend === 'host_required'"
@click="$store.browserPage.openExtensionsSettings()">Configure host browser</button>
</div>
<div class="browser-quick-row browser-zoom-row" title="Zoom the page in the container browser">
<span>Zoom</span>
<div class="browser-zoom-actions">
<div class="browser-zoom-stepper" role="group" aria-label="Page zoom">
<button type="button" aria-label="Zoom out" title="Zoom out"
:disabled="!$store.browserPage.canZoom() || $store.browserPage.zoomPercent() <= 25"
@click="$store.browserPage.command('zoom', { direction: 'out' })"><x-icon name="remove"></x-icon></button>
<span class="browser-zoom-value" aria-live="polite" x-text="$store.browserPage.zoomPercent() + '%'">100%</span>
<button type="button" aria-label="Zoom in" title="Zoom in"
:disabled="!$store.browserPage.canZoom() || $store.browserPage.zoomPercent() >= 500"
@click="$store.browserPage.command('zoom', { direction: 'in' })"><x-icon name="add"></x-icon></button>
</div>
<button type="button" class="browser-zoom-reset" aria-label="Reset zoom" title="Reset zoom"
:disabled="!$store.browserPage.canZoom() || $store.browserPage.zoomPercent() === 100"
@click="$store.browserPage.command('zoom', { direction: 'reset' })"><x-icon name="restart_alt"></x-icon></button>
</div>
</div>
<div class="browser-extension-list"
x-show="$store.browserPage.extensionsListLoading || $store.browserPage.extensionsList.length"
style="display: none;">
<div class="browser-extension-list-header">
<span>Installed extensions</span>
<span>Chrome Extensions</span>
<x-icon class="spinning"
x-show="$store.browserPage.extensionsListLoading" name="progress_activity"></x-icon>
</div>
@ -158,7 +157,7 @@
<span>Open</span>
</button>
<label class="browser-extension-toggle">
<input type="checkbox" :checked="extension.enabled"
<input type="checkbox" :checked="extension.enabled" :aria-label="'Enable ' + (extension.name || 'extension')"
:disabled="$store.browserPage.extensionToggleLoadingPath === extension.path"
@change="$store.browserPage.setExtensionEnabled(extension, $event.target.checked, $event.target)" />
<span class="browser-extension-switch"></span>
@ -169,12 +168,8 @@
</div>
<button type="button" class="dropdown-item" @click="$store.browserPage.openExtensionsSettings()">
<x-icon name="tune"></x-icon>
<span>Settings</span>
<span>More 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>
</div>
@ -796,70 +791,74 @@
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
}
.browser-extension-warning,
.browser-extension-message,
.browser-extension-error {
.browser-quick-location {
padding: 9px 2px;
border-top: 1px solid var(--color-border);
}
.browser-quick-row,
.browser-zoom-actions {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 10px;
border-radius: 7px;
font-size: 0.8rem;
line-height: 1.35;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.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;
.browser-location-toggle,
.browser-zoom-stepper {
display: inline-flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 8px;
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: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.browser-extension-url-actions .btn {
.browser-location-toggle button,
.browser-zoom-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-width: 0;
min-height: 30px;
border: 0;
padding: 4px 10px;
background: transparent;
color: var(--color-text-secondary);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
}
.browser-location-toggle button[aria-pressed="true"] {
background: var(--color-background);
color: var(--color-text);
box-shadow: inset 0 0 0 1px var(--color-border);
border-radius: 6px;
}
.browser-configure-host {
display: block;
margin-top: 6px;
padding: 0;
border: 0;
background: transparent;
color: color-mix(in srgb, var(--color-text) 76%, transparent);
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.browser-configure-host:hover { color: var(--color-text); text-decoration: underline; }
.browser-zoom-row { padding: 9px 2px; border-block: 1px solid var(--color-border); }
.browser-zoom-value { min-width: 44px; text-align: center; font-size: 0.8rem; font-variant-numeric: tabular-nums; }
.browser-zoom-actions { gap: 4px; }
.browser-zoom-actions button { padding: 4px; }
.browser-zoom-actions x-icon { font-size: 18px; }
.browser-zoom-actions button:disabled,
.browser-location-toggle button:disabled { opacity: 0.4; cursor: default; }
.browser-quick-location button:focus-visible,
.browser-zoom-actions button:focus-visible { outline: 2px solid var(--color-primary); outline-offset: -2px; }
.browser-extension-preset,
.browser-extension-list {
display: flex;
@ -867,14 +866,8 @@
gap: 7px;
}
.browser-extension-section-title {
padding: 6px 2px 0;
color: var(--color-text-secondary);
font-size: 0.76rem;
font-weight: 700;
}
.browser-extension-preset label,
.browser-quick-row > span,
.browser-extension-list-header {
font-size: 0.76rem;
font-weight: 650;
@ -1031,16 +1024,6 @@
opacity: 0.58;
}
.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-stage {
flex: 1 1 auto;
min-height: 0;

View file

@ -1,3 +1,5 @@
import { store as notificationStore } from "/components/notifications/notification-store.js";
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { getNamespacedClient } from "/js/websocket.js";
@ -16,7 +18,6 @@ import {
const websocket = getNamespacedClient("/ws");
websocket.addHandlers(["ws_webui"]);
const EXTENSIONS_ROOT = "/a0/usr/_browser/extensions";
const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
const BROWSER_COMMAND_TIMEOUT_MS = 45000;
@ -245,11 +246,6 @@ const model = {
_sessionRefreshPromise: null,
_sessionRefreshContextId: "",
extensionMenuOpen: false,
extensionInstallUrl: "",
extensionActionLoading: false,
extensionActionMessage: "",
extensionActionError: "",
extensionsRoot: "",
extensionsList: [],
extensionsListLoading: false,
extensionToggleLoadingPath: "",
@ -257,6 +253,10 @@ const model = {
modelPresetOptions: [],
mainModelSummary: "",
modelPresetSaving: false,
runtimeBackend: "container",
runtimeBackendSaving: false,
settingsProjectName: "",
annotationScreenshots: false,
browserInstallExpected: false,
defaultHomepage: "about:blank",
autofocusActivePage: true,
@ -283,17 +283,19 @@ const model = {
}
this.applyExtensionPayload(response);
} catch (error) {
this.extensionActionError = error instanceof Error ? error.message : String(error);
notificationStore.addFrontendToastOnly("error", error instanceof Error ? error.message : String(error));
} finally {
this.extensionsListLoading = false;
}
},
applyExtensionPayload(response = {}) {
this.extensionsRoot = response.root || EXTENSIONS_ROOT;
this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
this.defaultHomepage = String(response.default_homepage || "about:blank").trim() || "about:blank";
this.autofocusActivePage = normalizeBool(response.autofocus_active_page, true);
this.runtimeBackend = response.runtime_backend || "container";
this.settingsProjectName = response.settings_project_name || "";
this.annotationScreenshots = normalizeBool(response.annotation_screenshots, false);
this.modelPreset = String(response.model_preset || "");
this.mainModelSummary = String(response.main_model_summary || "");
this.modelPresetOptions = Array.isArray(response.model_preset_options)
@ -428,8 +430,6 @@ const model = {
toggleExtensionsMenu() {
this.extensionMenuOpen = !this.extensionMenuOpen;
if (this.extensionMenuOpen) {
this.extensionActionMessage = "";
this.extensionActionError = "";
void this.refreshExtensionsList();
}
},
@ -508,7 +508,7 @@ const model = {
}
try {
this.closeExtensionsMenu();
await pluginSettingsStore.openConfig("_browser");
await pluginSettingsStore.openConfig("_browser", this.settingsProjectName);
await this.refreshAfterSettingsClose();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
@ -536,70 +536,10 @@ const model = {
}
},
createExtensionWithAgent() {
this._prefillAgentPrompt(
[
"Use the browser-extension-control 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}/<extension-slug> and keep permissions minimal.`,
].join("\n")
);
},
askAgentInstallExtension() {
const url = String(this.extensionInstallUrl || "").trim();
const prompt = url
? [
"Use the browser-extension-control skill to review and optionally install this Chrome Web Store extension for Agent Zero's Browser.",
`Chrome Web Store URL or id: ${url}`,
"Explain the permissions and any sandbox risk before enabling it.",
].join("\n")
: [
"Use the browser-extension-control skill to help me install and review a Chrome Web Store extension for Agent Zero's Browser.",
"Ask me for the Chrome Web Store URL or extension id first.",
"Explain the permissions and any sandbox risk before enabling it.",
].join("\n");
this._prefillAgentPrompt(prompt);
},
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;
this.extensionActionMessage = "Installing extension… Large packages may take a few minutes.";
try {
const response = await callJsonApi("/plugins/_browser/extensions", {
action: "install_web_store",
context_id: this.resolveContextId() || this.contextId,
url,
});
if (!response?.ok) {
throw new Error(response?.error || "Install failed.");
}
this.applyExtensionPayload(response);
this.extensionInstallUrl = "";
this.extensionActionMessage = `Installed ${response.name || response.id}.`;
await this.refreshAfterSettingsClose();
} catch (error) {
this.extensionActionMessage = "";
this.extensionActionError = error instanceof Error ? error.message : String(error);
} finally {
this.extensionActionLoading = false;
}
},
async setExtensionEnabled(extension, enabled, input = null) {
const path = String(extension?.path || "");
if (!path) return;
const previous = Boolean(extension?.enabled);
this.extensionActionMessage = "";
this.extensionActionError = "";
this.extensionToggleLoadingPath = path;
try {
const response = await callJsonApi("/plugins/_browser/extensions", {
@ -612,11 +552,11 @@ const model = {
throw new Error(response?.error || "Could not update extension.");
}
this.applyExtensionPayload(response);
this.extensionActionMessage = `${enabled ? "Enabled" : "Disabled"} ${extension.name || "extension"}.`;
notificationStore.addFrontendToastOnly("success", `${enabled ? "Enabled" : "Disabled"} ${extension.name || "extension"}.`);
await this.refreshAfterSettingsClose();
} catch (error) {
if (input) input.checked = previous;
this.extensionActionError = error instanceof Error ? error.message : String(error);
notificationStore.addFrontendToastOnly("error", error instanceof Error ? error.message : String(error));
} finally {
this.extensionToggleLoadingPath = "";
}
@ -625,8 +565,6 @@ const model = {
async setBrowserModelPreset(value) {
const presetName = String(value || "");
this.modelPreset = presetName;
this.extensionActionMessage = "";
this.extensionActionError = "";
this.modelPresetSaving = true;
try {
const response = await callJsonApi("/plugins/_browser/extensions", {
@ -638,15 +576,46 @@ const model = {
throw new Error(response?.error || "Could not update browser model preset.");
}
this.applyExtensionPayload(response);
this.extensionActionMessage = "Browser model preset updated.";
notificationStore.addFrontendToastOnly("success", "Browser model preset updated.");
} catch (error) {
this.extensionActionError = error instanceof Error ? error.message : String(error);
notificationStore.addFrontendToastOnly("error", error instanceof Error ? error.message : String(error));
await this.refreshExtensionsList();
} finally {
this.modelPresetSaving = false;
}
},
async setRuntimeBackend(backend) {
if (this.runtimeBackendSaving || backend === this.runtimeBackend) return;
this.runtimeBackendSaving = true;
const contextId = this.resolveContextId() || this.contextId;
try {
const response = await callJsonApi("/plugins/_browser/extensions", {
action: "set_runtime_backend",
context_id: contextId,
runtime_backend: backend,
});
if (!response?.ok) throw new Error(response?.error || "Could not change Browser location.");
if (contextId !== (this.resolveContextId() || this.contextId)) return;
this.applyExtensionPayload(response);
if (backend === "host_required") {
notificationStore.addFrontendToastOnly("info", "The agent will use the host browser. This panel displays the container browser.");
}
} catch (error) {
notificationStore.addFrontendToastOnly("error", error instanceof Error ? error.message : String(error));
} finally {
this.runtimeBackendSaving = false;
}
},
zoomPercent() {
return Number(this.frameState?.zoom) || 100;
},
canZoom() {
return Boolean(this.activeBrowserId && this.frameState?.zoom_available && !this.isBusy());
},
modelPresetSummary() {
if (!this.modelPreset) {
return this.mainModelSummary ? `Using ${this.mainModelSummary}` : "Using Main Model";
@ -655,14 +624,6 @@ const model = {
return option?.summary || option?.label || this.modelPreset;
},
hasExtensionInstallUrl() {
return Boolean(String(this.extensionInstallUrl || "").trim());
},
extensionAssistantActionLabel() {
return "Scan with A0";
},
extensionVersionLabel(extension) {
const version = String(extension?.version || "").trim();
return version ? `v${version}` : "Unpacked extension";
@ -688,23 +649,14 @@ const model = {
async openExtensionUi(extension) {
const url = this.extensionOpenUrl(extension);
if (!url) return;
this.extensionActionMessage = "";
this.extensionActionError = "";
if (!extension?.enabled) {
this.extensionActionError = `Enable ${extension?.name || "this extension"} before opening it.`;
notificationStore.addFrontendToastOnly("info", `Enable ${extension?.name || "this extension"} before opening it.`);
return;
}
this.closeExtensionsMenu();
await this.command("open", { url });
},
_prefillAgentPrompt(prompt) {
chatInputStore.message = prompt;
chatInputStore.adjustTextareaHeight?.();
chatInputStore.focus?.();
this.closeExtensionsMenu();
},
async onOpen(element = null, options = {}) {
const requestedBrowserId = this.normalizeBrowserId(
options.requestedBrowserId ?? options.browserId ?? options.browser_id,
@ -2584,11 +2536,13 @@ const model = {
browser_id: browserId,
viewer_id: this._viewerToken,
payload,
capture_screenshot: true,
},
{ timeoutMs: 10000 },
);
if (sequence !== this._annotationSequence) return;
const data = firstOk(response);
this.annotationScreenshots = normalizeBool(data.annotation_screenshots, false);
const metadata = data.annotation || {};
this.annotationDraft = {
id: makeViewerToken(),
@ -2599,6 +2553,7 @@ const model = {
kind: metadata.kind || payload.kind,
rect: this.annotationRectFromMetadata(metadata, fallbackRect),
metadata,
screenshot: data.screenshot || null,
createdAt: Date.now(),
};
this.annotationDraftText = "";
@ -2741,9 +2696,26 @@ const model = {
return lines.join("\n").trim();
},
attachAnnotationScreenshots() {
if (!this.annotationScreenshots) return;
this.pendingAnnotations().forEach((annotation, index) => {
const image = annotation.screenshot?.image;
if (!image) return;
const name = `annotation-${index + 1}-${annotation.id}.jpg`;
const bytes = Uint8Array.from(atob(image), (character) => character.charCodeAt(0));
const file = new File([bytes], name, { type: "image/jpeg" });
attachmentsStore.addAttachment({
file, name, type: "image", extension: "jpg",
url: `data:image/jpeg;base64,${image}`,
displayInfo: attachmentsStore.getAttachmentDisplayInfo(file),
});
});
},
draftAnnotationsToChat(instruction = "") {
const prompt = this.buildAnnotationsPrompt(instruction);
if (!prompt) return;
this.attachAnnotationScreenshots();
const existingMessage = String(chatInputStore.message || "").trim();
chatInputStore.message = existingMessage ? `${existingMessage}\n\n${prompt}` : prompt;
chatInputStore.adjustTextareaHeight?.();
@ -2755,6 +2727,7 @@ const model = {
async sendAnnotationsToChat(instruction = "") {
const prompt = this.buildAnnotationsPrompt(instruction);
if (!prompt) return;
this.attachAnnotationScreenshots();
chatInputStore.message = prompt;
chatInputStore.adjustTextareaHeight?.();
try {
@ -3101,7 +3074,6 @@ const model = {
}
this.resetViewportTracking();
this.extensionMenuOpen = false;
this.extensionActionLoading = false;
this.extensionsListLoading = false;
this.extensionToggleLoadingPath = "";
this.modelPresetSaving = false;

View file

@ -375,12 +375,114 @@
</label>
</div>
<div class="browser-config-card browser-config-management">
<div class="section-title">Browsing data</div>
<div class="section-description">Manage the container browser's shared profile. These pages open in Browser and their changes take effect immediately.</div>
<div class="browser-config-management-row">
<div class="browser-config-switch-copy">
<span class="browser-config-field-label">Clear browsing data</span>
<span class="browser-config-field-help">Choose a time range and remove history, cookies, cached files, or other site data.</span>
</div>
<button type="button" class="button" :disabled="!!$store.browserConfig.openingBrowserPage"
@click="$store.browserConfig.openBrowserPage('clearData')">Clear browsing data</button>
</div>
<div class="browser-config-management-row">
<div class="browser-config-switch-copy">
<span class="browser-config-field-label">Browsing history</span>
<span class="browser-config-field-help">Search and manage pages visited in the container browser.</span>
</div>
<button type="button" class="button" aria-label="Manage browsing history" :disabled="!!$store.browserConfig.openingBrowserPage"
@click="$store.browserConfig.openBrowserPage('history')">Manage</button>
</div>
<label class="browser-config-management-row">
<span class="browser-config-switch-copy">
<span class="browser-config-field-label">Annotation screenshots</span>
<span class="browser-config-field-help">Include a page screenshot with each annotation. Images help explain visual changes and use more model context.</span>
</span>
<select class="browser-config-choice" x-model.boolean="config.annotation_screenshots" aria-label="Annotation screenshots">
<option value="false">Do not include</option>
<option value="true">Always include</option>
</select>
</label>
</div>
<div class="browser-config-card browser-config-management">
<div class="section-title">Autofill and passwords</div>
<div class="browser-config-management-row">
<div class="browser-config-switch-copy">
<span class="browser-config-field-label">Password manager</span>
<span class="browser-config-field-help">Manage saved passwords, imports, and sign-in preferences in the container browser.</span>
</div>
<button type="button" class="button" aria-label="Manage passwords" :disabled="!!$store.browserConfig.openingBrowserPage"
@click="$store.browserConfig.openBrowserPage('passwords')">Manage</button>
</div>
<div class="browser-config-management-row">
<div class="browser-config-switch-copy">
<span class="browser-config-field-label">Contact info</span>
<span class="browser-config-field-help">Add, edit, and delete saved addresses, phone numbers, and email addresses.</span>
</div>
<button type="button" class="button" aria-label="Manage contact info" :disabled="!!$store.browserConfig.openingBrowserPage"
@click="$store.browserConfig.openBrowserPage('addresses')">Manage</button>
</div>
</div>
<div class="browser-config-card browser-config-management">
<div class="section-title">Downloads</div>
<div class="browser-config-management-row">
<div class="browser-config-switch-copy">
<span class="browser-config-field-label">Location and download prompts</span>
<span class="browser-config-field-help">Choose a folder inside the container and whether to ask where to save each file.</span>
</div>
<button type="button" class="button" aria-label="Change download settings" :disabled="!!$store.browserConfig.openingBrowserPage"
@click="$store.browserConfig.openBrowserPage('downloadSettings')">Change</button>
</div>
<div class="browser-config-management-row">
<div class="browser-config-switch-copy">
<span class="browser-config-field-label">Download history</span>
<span class="browser-config-field-help">View and manage downloads from the container browser.</span>
</div>
<button type="button" class="button" aria-label="Manage download history" :disabled="!!$store.browserConfig.openingBrowserPage"
@click="$store.browserConfig.openBrowserPage('downloads')">Manage</button>
</div>
</div>
<div class="browser-config-card">
<div class="section-title">Extensions</div>
<div class="section-description">
Choose which installed Chrome extensions Browser loads.
</div>
<div class="browser-config-management-row">
<div class="browser-config-switch-copy">
<span class="browser-config-field-label">Extension manager</span>
<span class="browser-config-field-help">Open Chromium's extension details, permissions, and developer tools.</span>
</div>
<button type="button" class="button" aria-label="Manage Chrome extensions" :disabled="!!$store.browserConfig.openingBrowserPage"
@click="$store.browserConfig.openBrowserPage('extensions')">Manage</button>
</div>
<div class="browser-config-extension-install">
<label class="browser-config-field">
<span class="browser-config-field-label">Chrome Web Store URL</span>
<input type="text" x-model="$store.browserConfig.extensionInstallUrl"
@keydown.enter.prevent="$store.browserConfig.installExtensionFromUrl()"
placeholder="https://chromewebstore.google.com/detail/..." autocomplete="off" />
</label>
<div class="browser-config-install-actions">
<button type="button" class="button" @click="$store.browserConfig.installExtensionFromUrl()"
:disabled="$store.browserConfig.extensionActionLoading"
:aria-busy="$store.browserConfig.extensionActionLoading.toString()">
<x-icon :class="{ spinning: $store.browserConfig.extensionActionLoading }"
:name="$store.browserConfig.extensionActionLoading ? 'progress_activity' : 'download'"></x-icon>
<span x-text="$store.browserConfig.extensionActionLoading ? 'Installing…' : 'Install URL'">Install URL</span>
</button>
<button type="button" class="button" @click="$store.browserConfig.askAgentInstallExtension(context)">
<x-icon name="psychology_alt"></x-icon><span>Scan with A0</span>
</button>
<button type="button" class="button" @click="$store.browserConfig.createExtensionWithAgent(context)">
<x-icon name="add_circle"></x-icon><span>Create New Extension with A0</span>
</button>
</div>
</div>
<div class="browser-config-warning">
<x-icon name="warning"></x-icon>
<span>
@ -408,6 +510,7 @@
<input
type="checkbox"
:checked="$store.browserConfig.extensionEnabled(extension)"
:aria-label="'Enable ' + (extension.name || 'extension')"
:disabled="$store.browserConfig.extensionDeleteLoadingPath === extension.path"
@change="$store.browserConfig.setExtensionEnabled(extension, $event.target.checked)"
/>
@ -429,14 +532,6 @@
</span>
</div>
</template>
<div class="browser-config-note browser-config-success" x-show="$store.browserConfig.extensionsMessage">
<x-icon name="check_circle"></x-icon>
<span x-text="$store.browserConfig.extensionsMessage"></span>
</div>
<div class="browser-config-note" x-show="$store.browserConfig.extensionsError">
<x-icon name="error"></x-icon>
<span x-text="$store.browserConfig.extensionsError"></span>
</div>
</div>
<div class="browser-config-pill-row">
@ -466,6 +561,44 @@
border-radius: 8px;
}
.browser-config-management { gap: 0; }
.browser-config-management > .section-title { margin-bottom: 8px; }
.browser-config-management > .section-description { margin-bottom: 6px; }
.browser-config-management-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 14px 0;
}
.browser-config-management-row + .browser-config-management-row {
border-top: 1px solid var(--color-border);
}
.browser-config-management-row:last-child { padding-bottom: 0; }
.browser-config-management-row > .button { flex-shrink: 0; }
.browser-config-management-row > .browser-config-switch-copy { flex: 1; min-width: 0; }
.browser-config-choice {
flex-shrink: 0;
width: 190px;
max-width: 100%;
min-height: 36px;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-input);
color: var(--color-text);
font: inherit;
font-size: 0.84rem;
}
.browser-config-install-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.browser-config-install-actions .button { display: inline-flex; align-items: center; gap: 6px; }
.browser-config-management-row button:focus-visible,
.browser-config-choice:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
@media (max-width: 540px) {
.browser-config-management-row { flex-wrap: wrap; gap: 10px; }
.browser-config-management-row > .browser-config-switch-copy { flex-basis: 100%; }
}
.browser-config-field {
display: flex;
flex-direction: column;

View file

@ -208,6 +208,7 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
"extension_paths": [str(extension_dir)],
"default_homepage": "about:blank",
"autofocus_active_page": True,
"annotation_screenshots": False,
"browser_tab_scope": "per_context",
"max_open_tabs": 32,
"evaluate_timeout_seconds": 30.0,
@ -398,6 +399,10 @@ def test_browser_config_store_lists_supported_host_targets_and_migrates_endpoint
+ "if (!store.hostBrowserSetupAvailable('edge')) throw new Error('installed Edge Dev setup is hidden');\n"
+ "const custom = 'ws://localhost:9333/devtools/browser/custom';\n"
+ "if (stableHostBrowserSelection(custom, browserStatus) !== custom) throw new Error('custom endpoint changed');\n"
+ "store.config.extension_paths = ['/scoped/extension'];\n"
+ "store.applyExtensionPayload({ extension_paths: ['/global/extension'], extensions: [] });\n"
+ "if (store.config.extension_paths[0] !== '/scoped/extension') throw new Error('refresh replaced scoped or unsaved extensions');\n"
)
subprocess.run(["node", "--input-type=module", "-e", script], check=True, text=True)
@ -1027,41 +1032,23 @@ def test_browser_extension_manager_uses_modern_chrome_prodversion(monkeypatch):
assert "prodversion=120.0.0.0" not in url
def test_browser_extension_menu_exposes_agent_and_url_paths():
html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
encoding="utf-8"
)
skill = (
PROJECT_ROOT
/ "plugins"
/ "_browser"
/ "skills"
/ "browser-extension-control"
/ "SKILL.md"
)
assert "Create New Extension with A0" in html
assert "+ Create New with A0" not in html
assert "Input a Chrome Web Store URL" in html
assert "My Browser Extensions" not in html
assert "Browser LLM Preset" in html
assert "Chrome Extensions" in html
assert "Installed extensions" in html
assert "deleteExtension(extension)" not in html
assert "No extensions installed yet." not in html
assert "Browser Extension Settings" not in html
assert "<span>Settings</span>" in html
assert "extensionHasOpenUi(extension)" in html
assert "openExtensionUi(extension)" in html
assert "<span>Open</span>" in html
assert "hasExtensionInstallUrl()" in html
assert "malicious or buggy extensions" in html
assert "'Installing…' : 'Install URL'" in html
assert ':aria-busy="$store.browserPage.extensionActionLoading.toString()"' in html
assert "Large packages may take a few minutes." in (
PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
).read_text(encoding="utf-8")
assert skill.exists()
def test_browser_quick_settings_and_config_management():
panel = (PROJECT_ROOT / "plugins/_browser/webui/browser-panel.html").read_text()
config = (PROJECT_ROOT / "plugins/_browser/webui/config.html").read_text()
for action in ("Create New Extension with A0", "Chrome Web Store URL", "Install URL", "Scan with A0"):
assert action not in panel
assert action in config
for label in ("Browser LLM Preset", "Chrome Extensions", "More settings", "Configure host browser", "Page zoom"):
assert label in panel
assert "Installed extensions" not in panel
assert "openExtensionUi(extension)" in panel
assert "setRuntimeBackend('host_required')" in panel
assert "setRuntimeBackend('container')" in panel
assert 'x-show="$store.browserPage.runtimeBackend === \'host_required\'"' in panel
for action in ("history", "clearData", "passwords", "addresses", "extensions", "downloadSettings", "downloads"):
assert f"openBrowserPage('{action}')" in config
assert "Do not include" in config
assert 'x-model.boolean="config.annotation_screenshots"' in config
def test_browser_viewer_allows_slow_extension_startup():
@ -1608,7 +1595,7 @@ def test_browser_ui_spinners_have_browser_local_animation():
encoding="utf-8"
)
assert ":class=\"{ spinning: $store.browserPage.extensionActionLoading }\"" in main_html
assert ":class=\"{ spinning: $store.browserConfig.extensionActionLoading }\"" in config_html
assert "@keyframes browser-spin" in main_html
assert "@keyframes browser-config-spin" in config_html
@ -1656,7 +1643,7 @@ def test_browser_extension_settings_stay_user_facing():
def test_browser_tab_switch_completes_without_reloading_shared_interactive_viewer():
source = (PROJECT_ROOT / "plugins/_browser/webui/browser-store.js").read_text(encoding="utf-8")
source = source[source.index("const EXTENSIONS_ROOT"):source.index("export const store")]
source = source[source.index("const BROWSER_SUBSCRIBE_TIMEOUT_MS"):source.index("export const store")]
script = """
import assert from 'node:assert/strict';
let responseData;
@ -1702,7 +1689,7 @@ for (const [transport, oldUrl, nextUrl, stale, busy] of [
def test_browser_viewer_clears_empty_context_and_ignores_late_responses():
source = (PROJECT_ROOT / "plugins/_browser/webui/browser-store.js").read_text(encoding="utf-8")
source = source[source.index("const EXTENSIONS_ROOT"):source.index("export const store")]
source = source[source.index("const BROWSER_SUBSCRIBE_TIMEOUT_MS"):source.index("export const store")]
script = """
import assert from 'node:assert/strict';
const ok = data => ({ results: [{ ok: true, data }] });
@ -1858,7 +1845,6 @@ def test_browser_viewer_uses_tabs_for_session_switching():
assert "browser-live-dot" not in main_html
assert "async openNewBrowser()" in browser_store
assert "browserTabTitle(browser)" in browser_store
assert "Scan with A0" in browser_store
assert "Review with A0" not in browser_store
assert "Using ${this.mainModelSummary}" in browser_store
@ -5166,3 +5152,147 @@ def test_context_cleanup_preserves_shared_event_loop(monkeypatch, timeout):
else:
browser_runtime_module.close_runtime_sync("first")
assert calls == [("thread", "BrowserCleanup"), ("close", "first"), ("cancel", None)]
def test_browser_download_preferences_preserve_user_settings(monkeypatch, tmp_path):
monkeypatch.setattr(browser_runtime_module.files, "get_abs_path", lambda *parts: str(tmp_path.joinpath(*parts)))
core = _BrowserRuntimeCore("downloads")
path = core.profile_dir / "Default" / "Preferences"
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"download": {"prompt_for_download": True}, "unrelated": {"keep": 1}}))
core._prepare_download_preferences()
saved = json.loads(path.read_text())
assert saved["download"] == {"prompt_for_download": True, "default_directory": str(core.downloads_dir)}
assert saved["unrelated"] == {"keep": 1}
saved["download"]["default_directory"] = "/custom/downloads"
path.write_text(json.dumps(saved))
core._prepare_download_preferences()
assert json.loads(path.read_text()) == saved
@pytest.mark.anyio
async def test_browser_zoom_targets_private_display_and_rejects_invalid_direction(monkeypatch):
core = _BrowserRuntimeCore("zoom")
calls = []
async def started():
pass
async def focused():
calls.append("focus")
async def state(browser_id):
return {"id": browser_id, "zoom": 110}
core.ensure_started = started
core._state = state
core.interactive_view.display = 77
core.pages[1] = BrowserPage(id=1, page=SimpleNamespace(bring_to_front=focused), context_id="zoom")
monkeypatch.setattr(browser_runtime_module.shutil, "which", lambda name: "/usr/bin/xdotool")
monkeypatch.setattr(browser_runtime_module.subprocess, "run", lambda command, **kwargs: calls.append((command, kwargs)))
assert await core.zoom(1, "in") == {"id": 1, "zoom": 110}
assert calls[0] == "focus"
command, options = calls[1]
assert command == ["/usr/bin/xdotool", "key", "--clearmodifiers", "ctrl+plus"]
assert options["env"]["DISPLAY"] == ":77"
assert options["check"] is True and options["timeout"] == 3
with pytest.raises(ValueError, match="Zoom direction"):
await core.zoom(1, "in; arbitrary-command")
assert len(calls) == 2
@pytest.mark.anyio
@pytest.mark.parametrize("enabled,capture,expected", [(False, True, False), (True, False, False), (True, True, True)])
async def test_annotation_screenshots_require_saved_opt_in_and_explicit_capture(monkeypatch, enabled, capture, expected):
calls = []
agent = object()
class Runtime:
async def call(self, method, *args, **kwargs):
calls.append(method)
return {"image": "anBlZw==", "mime": "image/jpeg"} if method == "screenshot" else {"kind": "element"}
async def runtime(*args, **kwargs):
return Runtime()
def config(agent=None):
assert agent is not None
return {"annotation_screenshots": enabled}
monkeypatch.setattr(ws_browser_module, "get_runtime", runtime)
monkeypatch.setattr(ws_browser_module.AgentContext, "get", lambda context_id: SimpleNamespace(agent0=agent))
monkeypatch.setattr(ws_browser_module, "get_browser_config", config)
handler = ws_browser_module.WsBrowser(SimpleNamespace(), threading.RLock(), manager=None)
result = await handler.process("browser_viewer_annotation", {
"context_id": "ctx", "browser_id": 1, "capture_screenshot": capture,
"payload": {"kind": "element"},
}, "sid")
assert ("screenshot" in result) is expected
if capture:
assert result["annotation_screenshots"] is enabled
assert calls == (["annotation_target", "screenshot"] if expected else ["annotation_target"])
assert normalize_browser_config({})["annotation_screenshots"] is False
assert normalize_browser_config({"annotation_screenshots": "false"})["annotation_screenshots"] is False
@pytest.mark.anyio
async def test_quick_browser_location_saves_current_project_and_validates_input(monkeypatch):
from plugins._browser.api import extensions as api
saved = []
context = SimpleNamespace()
agent = SimpleNamespace(context=context)
handler = api.Extensions(SimpleNamespace(), threading.RLock())
monkeypatch.setattr(handler, "_agent_from_input", lambda data: agent)
monkeypatch.setattr(handler, "_browser_extension_payload", lambda agent=None: {"ok": True})
monkeypatch.setattr(api, "get_browser_config", lambda agent=None: {"runtime_backend": "container", "proxy_server": "http://proxy.test"})
monkeypatch.setattr(api.projects, "get_context_project_name", lambda ctx: "my-project")
monkeypatch.setattr(api.plugins, "save_plugin_config", lambda *args: saved.append(args))
for value in ("host_required", "container"):
assert await handler.process({"action": "set_runtime_backend", "context_id": "ctx", "runtime_backend": value}, None) == {"ok": True}
assert saved[-1] == ("_browser", "my-project", "", {"runtime_backend": value, "proxy_server": "http://proxy.test"})
for value in ("wrong", [], None):
result = await handler.process({"action": "set_runtime_backend", "runtime_backend": value}, None)
assert result["ok"] is False
monkeypatch.setattr(handler, "_agent_from_input", lambda data: None)
result = await handler.process({"action": "set_runtime_backend", "context_id": "deleted", "runtime_backend": "container"}, None)
assert result["ok"] is False
assert len(saved) == 2
def test_browser_annotation_attachments_and_quick_setting_failure():
source = (PROJECT_ROOT / "plugins/_browser/webui/browser-store.js").read_text()
source = source[source.index("const BROWSER_SUBSCRIBE_TIMEOUT_MS"):source.index("export const store")]
script = """
import assert from 'node:assert/strict';
let response;
let request;
const notices = [];
const notificationStore = { addFrontendToastOnly: (...args) => notices.push(args) };
const callJsonApi = async (path, payload) => { request = payload; return response; };
const attachmentsStore = { files: [], getAttachmentDisplayInfo: () => ({}), addAttachment(item) { this.files.push(item); } };
""" + source + """
Object.assign(model, { resolveContextId: () => 'ctx', contextId: 'ctx', activeBrowserContextId: 'ctx' });
model.annotationComments = [
{id: 'ours', contextId: 'ctx', browserId: 1, screenshot: {image: btoa('jpeg')}},
{id: 'other', contextId: 'other', browserId: 2, screenshot: {image: btoa('private')}},
];
model.attachAnnotationScreenshots();
assert.equal(attachmentsStore.files.length, 0);
model.annotationScreenshots = true;
model.attachAnnotationScreenshots();
assert.equal(attachmentsStore.files.length, 1);
assert.equal(await attachmentsStore.files[0].file.text(), 'jpeg');
assert.equal(attachmentsStore.files[0].name, 'annotation-1-ours.jpg');
response = {ok: true, runtime_backend: 'host_required', annotation_screenshots: false};
await model.setRuntimeBackend('host_required');
assert.equal(request.context_id, 'ctx');
assert.equal(model.runtimeBackend, 'host_required');
assert.equal(model.runtimeBackendSaving, false);
response = {ok: false, error: 'save failed'};
await model.setRuntimeBackend('container');
assert.equal(model.runtimeBackend, 'host_required');
assert.equal(model.runtimeBackendSaving, false);
assert.deepEqual(notices.at(-1), ['error', 'save failed']);
"""
subprocess.run(["node", "--input-type=module", "-e", script], check=True, text=True)

View file

@ -59,13 +59,14 @@ async def test_navigation_uses_requested_readiness_without_extra_settling(monkey
async def test_browser_state_reads_metadata_once_and_reports_loading(monkeypatch):
core = _BrowserRuntimeCore("state-test")
page = SimpleNamespace(url="https://example.com", evaluate=AsyncMock(return_value={
"title": "Example", "canGoBack": True, "loading": True,
"title": "Example", "canGoBack": True, "loading": True, "zoom": 125,
}))
core.pages[1] = BrowserPage(1, page, "state-test")
state = await core._state(1)
assert state == {
"id": 1, "context_id": "state-test", "currentUrl": page.url,
"title": "Example", "canGoBack": True, "canGoForward": False, "loading": True,
"zoom": 125, "zoom_available": False,
}
assert page.evaluate.await_count == 1
page.evaluate.side_effect = RuntimeError("Execution context destroyed during navigation")