From 5a1698ce629774f2186dc72441e4c32e95263481 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:14:08 +0200 Subject: [PATCH] Simplify Browser quick settings and expand browser configuration 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. --- plugins/_browser/AGENTS.md | 5 + plugins/_browser/api/extensions.py | 21 +- plugins/_browser/api/ws_browser.py | 14 ++ plugins/_browser/default_config.yaml | 3 + plugins/_browser/helpers/config.py | 4 + plugins/_browser/helpers/runtime.py | 46 +++- .../_browser/webui/browser-config-store.js | 118 ++++++++-- plugins/_browser/webui/browser-panel.html | 197 ++++++++--------- plugins/_browser/webui/browser-store.js | 164 ++++++-------- plugins/_browser/webui/config.html | 149 ++++++++++++- tests/test_browser_agent_regressions.py | 208 ++++++++++++++---- tests/test_browser_tool_output.py | 3 +- 12 files changed, 665 insertions(+), 267 deletions(-) diff --git a/plugins/_browser/AGENTS.md b/plugins/_browser/AGENTS.md index 24782a548..08b0becdb 100644 --- a/plugins/_browser/AGENTS.md +++ b/plugins/_browser/AGENTS.md @@ -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. diff --git a/plugins/_browser/api/extensions.py b/plugins/_browser/api/extensions.py index d2a5629f0..bf72c2475 100644 --- a/plugins/_browser/api/extensions.py +++ b/plugins/_browser/api/extensions.py @@ -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), diff --git a/plugins/_browser/api/ws_browser.py b/plugins/_browser/api/ws_browser.py index dfd3020cb..8833d7003 100644 --- a/plugins/_browser/api/ws_browser.py +++ b/plugins/_browser/api/ws_browser.py @@ -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, diff --git a/plugins/_browser/default_config.yaml b/plugins/_browser/default_config.yaml index 47aff89ab..691fb056f 100644 --- a/plugins/_browser/default_config.yaml +++ b/plugins/_browser/default_config.yaml @@ -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. diff --git a/plugins/_browser/helpers/config.py b/plugins/_browser/helpers/config.py index 640a5a586..3320410b4 100644 --- a/plugins/_browser/helpers/config.py +++ b/plugins/_browser/helpers/config.py @@ -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, diff --git a/plugins/_browser/helpers/runtime.py b/plugins/_browser/helpers/runtime.py index 1813b4082..c31ca2134 100644 --- a/plugins/_browser/helpers/runtime.py +++ b/plugins/_browser/helpers/runtime.py @@ -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( diff --git a/plugins/_browser/webui/browser-config-store.js b/plugins/_browser/webui/browser-config-store.js index 45b21df9e..b13ef85b5 100644 --- a/plugins/_browser/webui/browser-config-store.js +++ b/plugins/_browser/webui/browser-config-store.js @@ -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}/ 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 = ""; } diff --git a/plugins/_browser/webui/browser-panel.html b/plugins/_browser/webui/browser-panel.html index 30213aa96..f18ba4aa4 100644 --- a/plugins/_browser/webui/browser-panel.html +++ b/plugins/_browser/webui/browser-panel.html @@ -98,45 +98,44 @@
-
Chrome Extensions
- -
- - -
- - +
+
+ Browser +
+ + +
- +
+ Zoom +
+
+ + 100% + +
+
-
-
@@ -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; diff --git a/plugins/_browser/webui/browser-store.js b/plugins/_browser/webui/browser-store.js index 5cb635766..eba3871d8 100644 --- a/plugins/_browser/webui/browser-store.js +++ b/plugins/_browser/webui/browser-store.js @@ -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}/ 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; diff --git a/plugins/_browser/webui/config.html b/plugins/_browser/webui/config.html index cb1c66b30..97159586f 100644 --- a/plugins/_browser/webui/config.html +++ b/plugins/_browser/webui/config.html @@ -375,12 +375,114 @@ +
+
Browsing data
+
Manage the container browser's shared profile. These pages open in Browser and their changes take effect immediately.
+
+
+ Clear browsing data + Choose a time range and remove history, cookies, cached files, or other site data. +
+ +
+
+
+ Browsing history + Search and manage pages visited in the container browser. +
+ +
+ +
+ +
+
Autofill and passwords
+
+
+ Password manager + Manage saved passwords, imports, and sign-in preferences in the container browser. +
+ +
+
+
+ Contact info + Add, edit, and delete saved addresses, phone numbers, and email addresses. +
+ +
+
+ +
+
Downloads
+
+
+ Location and download prompts + Choose a folder inside the container and whether to ask where to save each file. +
+ +
+
+
+ Download history + View and manage downloads from the container browser. +
+ +
+
+
Extensions
Choose which installed Chrome extensions Browser loads.
+
+
+ Extension manager + Open Chromium's extension details, permissions, and developer tools. +
+ +
+
+ +
+ + + +
+
@@ -408,6 +510,7 @@ @@ -429,14 +532,6 @@
-
- - -
-
- - -
@@ -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; diff --git a/tests/test_browser_agent_regressions.py b/tests/test_browser_agent_regressions.py index 1da7cef32..f33882074 100644 --- a/tests/test_browser_agent_regressions.py +++ b/tests/test_browser_agent_regressions.py @@ -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 "Settings" in html - assert "extensionHasOpenUi(extension)" in html - assert "openExtensionUi(extension)" in html - assert "Open" 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) diff --git a/tests/test_browser_tool_output.py b/tests/test_browser_tool_output.py index 09baf39bf..68ad4a1c9 100644 --- a/tests/test_browser_tool_output.py +++ b/tests/test_browser_tool_output.py @@ -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")