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")