diff --git a/docker/run/AGENTS.md b/docker/run/AGENTS.md index bc07f5f5a..a6c08997f 100644 --- a/docker/run/AGENTS.md +++ b/docker/run/AGENTS.md @@ -19,6 +19,7 @@ - `BRANCH` is required for branch-based Docker builds. - Preserve exposed ports for SSH, HTTP, and tunneled services unless docs and workflows are updated together. - Keep the two-runtime Python model aligned with the root contract. +- Keep runtime desktop packages on `kali-last-snapshot`; carry the rolling base's matching ATK introspection package into that transaction, then pin the verified Python 3.13-compatible LibreOffice and complete Xpra runtime versions in `fs/ins/install_additional.sh` for both published architectures. - Do not bake secrets, local `.env` values, or user data into the image. - Runtime startup must ensure `/a0/usr/uploads` exists before supervised services start. - Runtime startup raises the soft open-file limit toward `A0_NOFILE_LIMIT` (default `65535`) before supervisord starts, bounded by the container hard limit. diff --git a/docker/run/fs/ins/install_additional.sh b/docker/run/fs/ins/install_additional.sh index e9c844be7..efdb0d084 100644 --- a/docker/run/fs/ins/install_additional.sh +++ b/docker/run/fs/ins/install_additional.sh @@ -12,80 +12,58 @@ if ! command -v apt-get >/dev/null 2>&1; then exit 0 fi -XPRA_PACKAGES=(xpra xpra-x11 xpra-html5) +KALI_SUITE="kali-last-snapshot" +LIBREOFFICE_VERSION="4:26.2.4.2-1" +XPRA_VERSION="6.5.2-r0-1" +arch="$(dpkg --print-architecture)" -install_xpra_repo() { - local os_id="" - local codename="" - local uri="https://xpra.org" - local suite="trixie" - local arch +XPRA_HTML5_VERSION="19-r1-1" +if [ "$arch" = "arm64" ]; then + XPRA_HTML5_VERSION="21-r1-1" +fi - arch="$(dpkg --print-architecture 2>/dev/null || echo amd64)" +LIBREOFFICE_PACKAGES=( + "libreoffice-core=$LIBREOFFICE_VERSION" + "libreoffice-writer=$LIBREOFFICE_VERSION" + "libreoffice-calc=$LIBREOFFICE_VERSION" + "libreoffice-impress=$LIBREOFFICE_VERSION" + "libreoffice-gtk3=$LIBREOFFICE_VERSION" + "python3-uno=$LIBREOFFICE_VERSION" +) +XPRA_PACKAGES=( + "xpra-common=$XPRA_VERSION" + "xpra-server=$XPRA_VERSION" + "xpra-client=$XPRA_VERSION" + "xpra-client-gtk3=$XPRA_VERSION" + "xpra-x11=$XPRA_VERSION" + "xpra-html5=$XPRA_HTML5_VERSION" +) - if [ -r /etc/os-release ]; then - # shellcheck disable=SC1091 - . /etc/os-release - os_id="${ID:-}" - codename="${VERSION_CODENAME:-}" - fi +apt-get update +ATK_VERSION="$(dpkg-query -W -f='${Version}' libatk1.0-0t64)" +ATK_GIR_PACKAGE="/tmp/gir1.2-atk-1.0_${ATK_VERSION}_${arch}.deb" +(cd /tmp && apt-get download "gir1.2-atk-1.0=$ATK_VERSION") - if [ "$os_id" = "kali" ]; then - uri="https://xpra.org/beta" - suite="sid" - elif [ "$codename" = "sid" ] || [ "$codename" = "forky" ]; then - uri="https://xpra.org/beta" - suite="$codename" - elif [ -n "$codename" ]; then - suite="$codename" - fi +for source in /etc/apt/sources.list /etc/apt/sources.list.d/kali.sources; do + [ ! -f "$source" ] || sed -i "s/kali-rolling/$KALI_SUITE/g" "$source" +done - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates wget - configure_xpra_repo "$uri" "$suite" "$arch" - apt-get update - - if ! xpra_install_check; then - echo "xpra packages are not installable from ${uri} ${suite} for ${arch}; falling back to https://xpra.org trixie" - XPRA_PACKAGES=(xpra-server xpra-x11 xpra-html5) - configure_xpra_repo "https://xpra.org" "trixie" "$arch" - apt-get update - if ! xpra_install_check; then - cat /tmp/xpra-install-check.log - exit 1 - fi - fi -} - -xpra_install_check() { - DEBIAN_FRONTEND=noninteractive apt-get install -s --no-install-recommends "${XPRA_PACKAGES[@]}" >/tmp/xpra-install-check.log 2>&1 -} - -configure_xpra_repo() { - local uri="$1" - local suite="$2" - local arch="$3" - - wget -O /usr/share/keyrings/xpra.asc https://xpra.org/xpra.asc - cat >/etc/apt/sources.list.d/xpra.sources </etc/apt/sources.list.d/xpra.sources < Backup & Restore** and avoid mapping the entire `/a0` directory. See [How to update Agent Zero](../setup/installation.md#how-to-update-agent-zero). -**8. My browser tool fails or says Playwright is missing. What now?** +**8. My browser tool fails or says Chromium is missing. What now?** In normal Docker installs, the Browser already includes what it needs. @@ -35,7 +35,7 @@ browser the first time it is needed. To install it ahead of time, run this from the project root after installing Python requirements: ```bash -PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium +PLAYWRIGHT_BROWSERS_PATH=tmp/playwright patchright install chromium --no-shell ``` If **Bring Your Own Browser** mode fails: diff --git a/docs/setup/dev-setup.md b/docs/setup/dev-setup.md index 5fd35fa17..d1390d0de 100644 --- a/docs/setup/dev-setup.md +++ b/docs/setup/dev-setup.md @@ -68,12 +68,12 @@ Now when you select one of the python files in the project, you should see prope ```bash pip install -r requirements.txt -PLAYWRIGHT_BROWSERS_PATH=./tmp/playwright playwright install chromium +PLAYWRIGHT_BROWSERS_PATH=./tmp/playwright patchright install chromium --no-shell ``` The first command installs Python dependencies. -The second command installs full Playwright Chromium into `./tmp/playwright`, +The second command installs full Patchright Chromium into `./tmp/playwright`, relative to the project root. Docker images use the absolute path `/a0/tmp/playwright` and ship Chromium preinstalled. diff --git a/helpers/api.py b/helpers/api.py index 351ba4716..6047f3eb9 100644 --- a/helpers/api.py +++ b/helpers/api.py @@ -214,7 +214,7 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None: return await cached() # Resolve file path for the handler - # Try built-in api folder first, then plugin api folders + # Try built-in and plugin api folders before the user fallback handler_cls: type[ApiHandler] | None = None # Check built-in python/api/.py @@ -239,6 +239,15 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None: if classes: handler_cls = classes[0] + # Check user api/.py + if handler_cls is None: + user_api_dir = files.get_abs_path(files.USER_DIR, files.API_DIR) + user_file = files.get_abs_path(user_api_dir, f"{path}.py") + if files.is_in_dir(user_file, user_api_dir) and files.exists(user_file): + classes = load_classes_from_file(user_file, ApiHandler) + if classes: + handler_cls = classes[0] + if handler_cls is None: return Response(f"API endpoint not found: {path}", 404) diff --git a/helpers/api.py.dox.md b/helpers/api.py.dox.md index d9d6bb546..5de8d844c 100644 --- a/helpers/api.py.dox.md +++ b/helpers/api.py.dox.md @@ -45,6 +45,7 @@ ## Key Concepts - Important called helpers/classes observed in the source: `wraps`, `app.add_url_rule`, `watchdog.add_watchdog`, `cls.requires_auth`, `_use_context`, `login.get_credentials_hash`, `files.get_abs_path`, `handler_cls.requires_csrf`, `handler_cls.requires_api_key`, `handler_cls.requires_auth`, `handler_cls.requires_loopback`, `cache.add`, `PrintStyle.debug`, `cache.clear`, `get_settings`, `f`, `is_loopback_address`, `Response`, `redirect`, `files.is_in_dir`. +- HTTP handlers retain built-in `api/` and explicit plugin API precedence, then fall back to standalone `usr/api/`; built-in and user roots are containment-checked, and every loaded handler keeps its declared authentication, CSRF, API-key, loopback, and method gates. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/helpers/backup.py b/helpers/backup.py index 38fc70a69..f97ff67af 100644 --- a/helpers/backup.py +++ b/helpers/backup.py @@ -8,7 +8,7 @@ from typing import List, Dict, Any, Optional from pathspec import PathSpec -from helpers import files, runtime, git +from helpers import files, runtime, git, dotenv from helpers.localization import Localization from helpers.print_style import PrintStyle @@ -608,6 +608,9 @@ class BackupService: ) -> Dict[str, Any]: """Restore files from backup archive""" + allowed_origins = dotenv.get_dotenv_value("ALLOWED_ORIGINS", "") + dotenv_path = os.path.abspath(dotenv.get_dotenv_file_path()) + # Save uploaded file temporarily temp_dir = tempfile.mkdtemp() temp_file = os.path.join(temp_dir, "backup.zip") @@ -725,6 +728,11 @@ class BackupService: with zipf.open(archive_path) as source, open(target_path, 'wb') as target: shutil.copyfileobj(source, target) + if os.path.abspath(target_path) == dotenv_path: + dotenv.save_dotenv_value( + "ALLOWED_ORIGINS", allowed_origins, reload_env=False + ) + restored_files.append({ "archive_path": archive_path, "original_path": original_path, diff --git a/helpers/backup.py.dox.md b/helpers/backup.py.dox.md index 5612447ec..5cb9c24ec 100644 --- a/helpers/backup.py.dox.md +++ b/helpers/backup.py.dox.md @@ -27,6 +27,7 @@ - Imported dependency areas include: `datetime`, `helpers`, `helpers.localization`, `helpers.print_style`, `json`, `os`, `pathspec`, `platform`, `tempfile`, `typing`, `zipfile`. - `test_patterns(..., max_files=None)` is the unlimited scan mode. UI preview and dry-run callers may pass bounded limits, but real backup creation and restore clean-before-restore must use unlimited matching so archives and cleanup are not silently truncated. - Default backup metadata includes persistent `/usr` data but excludes Time Travel shadow history under `usr/.time_travel/**`. +- Restoring `usr/.env` preserves the destination instance's allowed origins while restoring authentication and other portable configuration from the archive. ## Key Concepts diff --git a/helpers/dotenv.py b/helpers/dotenv.py index 3ce4d938f..ebb027b7a 100644 --- a/helpers/dotenv.py +++ b/helpers/dotenv.py @@ -21,7 +21,7 @@ def get_dotenv_value(key: str, default: Any = None): # load_dotenv() return os.getenv(key, default) -def save_dotenv_value(key: str, value: str): +def save_dotenv_value(key: str, value: str, reload_env: bool = True): if value is None: value = "" dotenv_path = get_dotenv_file_path() @@ -40,4 +40,5 @@ def save_dotenv_value(key: str, value: str): f.seek(0) f.writelines(lines) f.truncate() - load_dotenv() + if reload_env: + load_dotenv() diff --git a/helpers/dotenv.py.dox.md b/helpers/dotenv.py.dox.md index 2e142d657..375f5d95d 100644 --- a/helpers/dotenv.py.dox.md +++ b/helpers/dotenv.py.dox.md @@ -14,7 +14,7 @@ - `load_dotenv()` - `get_dotenv_file_path()` - `get_dotenv_value(key: str, default: Any=...)` -- `save_dotenv_value(key: str, value: str)` +- `save_dotenv_value(key: str, value: str, reload_env: bool=...)` - Notable constants/configuration names: `KEY_AUTH_LOGIN`, `KEY_AUTH_PASSWORD`, `KEY_RFC_PASSWORD`, `KEY_ROOT_PASSWORD`. ## Runtime Contracts @@ -23,6 +23,7 @@ - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. - Observed side-effect areas: filesystem reads, filesystem writes, secret handling. - Imported dependency areas include: `dotenv`, `files`, `os`, `re`, `typing`. +- `save_dotenv_value(..., reload_env=False)` updates the persisted file without changing the running process environment. ## Key Concepts diff --git a/helpers/litellm_transport.py b/helpers/litellm_transport.py index f84a12285..6a1498b71 100644 --- a/helpers/litellm_transport.py +++ b/helpers/litellm_transport.py @@ -428,7 +428,13 @@ class LiteLLMTransport: ) -> LLMResult | None: if parser.completed_response is None: return None - return self._llm_result_from_response(parser.completed_response, request) + response = _object_to_dict(parser.completed_response) + output = _as_list(response.get("output")) + if parser.function_calls and not any( + _get_value(item, "type") == "function_call" for item in output + ): + response["output"] = [*output, *parser.function_calls.values()] + return self._llm_result_from_response(response, request) def _stream_result_from_chat_parser( self, parser: "ChatCompletionsStreamParser" diff --git a/helpers/litellm_transport.py.dox.md b/helpers/litellm_transport.py.dox.md index 710765642..1a8e20795 100644 --- a/helpers/litellm_transport.py.dox.md +++ b/helpers/litellm_transport.py.dox.md @@ -33,6 +33,7 @@ - Fall back to Chat Completions when a Responses endpoint fails before output with an endpoint-specific server error, proxy path-unavailable error, or LiteLLM proxy-extra import error. - Fall back to Chat Completions when LiteLLM's Responses mock streaming path tries to JSON-decode a real SSE stream before any output. - Preserve Chat Completions tool calls from both non-streaming responses and streaming deltas as canonical `LLMResult` function-call items. +- Preserve Responses function calls collected from stream events when a terminal completed event omits them. - Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported. - Keep prompt-cache markers only for providers that accept them. diff --git a/helpers/parallel_tools.py b/helpers/parallel_tools.py index eacc7367b..759d1db32 100644 --- a/helpers/parallel_tools.py +++ b/helpers/parallel_tools.py @@ -23,6 +23,7 @@ PARALLEL_WORKER_JOB_KEY = "_parallel_job_id" PARALLEL_WORKER_KIND_KEY = "_parallel_worker_kind" CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id" +CHILD_PARENT_AGENT_NUMBER_KEY = "parent_agent_number" CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind" CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label" CHILD_PARALLEL_JOB_ID_KEY = "parallel_job_id" @@ -53,6 +54,7 @@ class ParallelJob: tool_name: str tool_args: dict[str, Any] kind: JobKind + parent_agent: "Agent | None" = field(default=None, repr=False) state: JobState = "pending" created_at: float = field(default_factory=time.time) started_at: float | None = None @@ -208,6 +210,7 @@ async def start_parallel_jobs( tool_name=call.tool_name, tool_args=call.tool_args, kind=kind, + parent_agent=agent, ) job_store[job.id] = job jobs.append(job) @@ -218,6 +221,8 @@ async def start_parallel_jobs( job.started_at = time.time() task = DeferredTask(thread_name=THREAD_BACKGROUND) job.deferred_task = task + if _parallel_worker_kind(agent) == "subordinate" and context.task: + context.task.add_child_task(task) task.start_task(_run_parallel_job, context.id, job.id) except Exception as exc: _finish_job(job, "error", error=str(exc)) @@ -410,34 +415,39 @@ async def _run_parallel_job(parent_context_id: str, job_id: str) -> None: async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob) -> str: - from agent import AgentContext, AgentContextType, UserMessage - from helpers import message_queue, persist_chat + from agent import AgentContext from helpers.tool_policy import ensure_tool_allowed - from tools.call_subordinate import _validate_subordinate_profile + from tools.call_subordinate import get_or_create_subordinate, run_subordinate parent_context = AgentContext.get(parent_context_id) if not parent_context: raise ValueError("Parent context not found.") - ensure_tool_allowed(parent_context.agent0, "call_subordinate") + parent_agent = job.parent_agent or parent_context.agent0 + ensure_tool_allowed(parent_agent, "call_subordinate") args = job.tool_args message = str(args.get("message") or "").strip() if not message: raise ValueError("call_subordinate requires `tool_args.message`.") - profile = _validate_subordinate_profile( - parent_context.agent0, - str(args.get("profile") or args.get("agent_profile") or ""), + context_id = str(args.get("context_id") or args.get("agent_id") or "").strip() + reset = args.get("reset", False) + slot = ( + job.id + if coerce_bool(reset, False) and not context_id + else "default" ) attachments = args.get("attachments") if isinstance(args.get("attachments"), list) else [] - attachments = [str(item) for item in attachments] - - child_name = _subordinate_context_name(job) - worker_context = AgentContext( - config=_clone_config(parent_context.config, profile=profile), - name=child_name, - type=AgentContextType.USER, + subordinate = get_or_create_subordinate( + parent_agent, + profile=str(args.get("profile") or args.get("agent_profile") or ""), + reset=reset, + context_id=context_id, + name=str(args.get("name") or ""), + message=message, + slot=slot, ) + worker_context = subordinate.context job.worker_context_id = worker_context.id if job.deferred_task: worker_context.task = job.deferred_task @@ -445,30 +455,9 @@ async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob) worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context.id) worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id) worker_context.set_data(PARALLEL_WORKER_KIND_KEY, job.kind) - worker_context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent_context.id) - worker_context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "parallel") - worker_context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, child_name) worker_context.set_output_data(CHILD_PARALLEL_JOB_ID_KEY, job.id) worker_context.set_output_data(CHILD_PARALLEL_TOOL_NAME_KEY, job.tool_name) - _copy_project(parent_context, worker_context) - - system_prompt = _subordinate_worker_system_prompt(profile) - message_queue.log_user_message(worker_context, message, attachments, source=" (parallel)") - worker_context.agent0.hist_add_user_message( - UserMessage( - message=message, - attachments=attachments, - system_message=[system_prompt], - ) - ) - persist_chat.save_tmp_chat(worker_context) - - try: - result = await worker_context.agent0.monologue() - worker_context.agent0.history.new_topic() - return result - finally: - persist_chat.save_tmp_chat(worker_context) + return await run_subordinate(parent_agent, subordinate, message, attachments) async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str: @@ -711,16 +700,13 @@ def _job_snapshot(job: ParallelJob, *, include_result: bool) -> dict[str, Any]: return data -def _clone_config(config: "AgentConfig", *, profile: str = "") -> "AgentConfig": +def _clone_config(config: "AgentConfig") -> "AgentConfig": try: - cloned = replace( + return replace( config, knowledge_subdirs=list(config.knowledge_subdirs), additional=dict(config.additional), ) - if profile: - cloned.profile = profile - return cloned except Exception: return config @@ -734,27 +720,3 @@ def _copy_project(parent_context: "AgentContext", worker_context: "AgentContext" projects.activate_project(worker_context.id, project_name, mark_dirty=False) except Exception: pass - - -def _subordinate_worker_system_prompt(profile: str) -> str: - lines = [ - "You are running as an isolated parallel worker for a parent Agent Zero chat.", - "Return a concise final textual summary for the parent. Artifacts and files are supplementary, not a substitute for the textual result.", - ] - if profile: - lines.append(f"Act with the `{profile}` profile's expertise and priorities.") - return "\n".join(lines) - - -def _subordinate_context_name(job: ParallelJob) -> str: - name = str(job.tool_args.get("name") or "").strip() - if name: - return name - message = str(job.tool_args.get("message") or "").strip() - label = _short_label(message) - return label or f"Parallel subordinate {job.index + 1}" - - -def _short_label(text: str, limit: int = 80) -> str: - compact = " ".join(text.split()) - return compact[:limit].rstrip() diff --git a/helpers/parallel_tools.py.dox.md b/helpers/parallel_tools.py.dox.md index a729458ca..a015553da 100644 --- a/helpers/parallel_tools.py.dox.md +++ b/helpers/parallel_tools.py.dox.md @@ -26,11 +26,11 @@ - Normalization accepts full agent-reply-shaped objects when `tool_name` and `tool_args` are present; non-contract planning fields such as `thoughts` or `headline` are ignored. - `tool_calls` should be an array, but normalization also accepts a valid JSON string encoding of that array to recover provider/model stringification. - Normalization rejects `document_query` and `response` inside `parallel`: document parsing and Q&A must run sequentially, while `response` must remain top-level so it can end the message loop. -- `call_subordinate` jobs first enforce the parent profile's delegation policy - and validate the requested profile through the sequential delegation owner, - then run in isolated child chat contexts tagged with parent-chat metadata; - they must not be added to the scheduler task list and may use normal child-chat - tools, including `parallel`. +- `call_subordinate` jobs first enforce the actual calling agent's delegation policy, then call the same creation and execution functions as direct delegation in `tools/call_subordinate.py`; this helper does not construct or prompt a second kind of subordinate. +- Fresh parallel sibling calls create distinct `parent.number + 1` child agents. Their job snapshots expose stable `context_id` values that direct or parallel `reset=false` calls can continue after success or failure. +- Jobs retain their actual parent agent so parallel calls made by A1 create A2 rather than falling back to a context's A0. +- Subordinate child chats are tagged with job metadata, remain outside the scheduler task list, and may use normal child-chat tools including `parallel`. +- Nested parallel jobs started by a parallel subordinate are registered as child `DeferredTask` instances so stopping the ancestor also stops its descendants. - Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`. - Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk. - Parent-visible child log items are created for each wrapped call so the WebUI can inspect concurrent children separately while the wrapper result remains model-history-only. diff --git a/helpers/plugins.py b/helpers/plugins.py index 14d9a37be..4441cbea0 100644 --- a/helpers/plugins.py +++ b/helpers/plugins.py @@ -121,7 +121,7 @@ class PluginUpdateInfo(BaseModel): def register_watchdogs(): - def on_plugin_change(events: list[WatchItem]): + def on_plugin_change(events: list[WatchItem], frontend_reload: bool = True): plugin_names: list[str] = [] for path, _event in events: path = path.replace("\\", "/") @@ -132,7 +132,11 @@ def register_watchdogs(): plugin_names.append(plugin_name) print_style.PrintStyle.debug("Plugins watchdog triggered", plugin_names) python_change = any(path.endswith('.py') for path, _event in events) - after_plugin_change(plugin_names or None, python_change=python_change) + after_plugin_change( + plugin_names or None, + python_change=python_change, + frontend_reload=frontend_reload, + ) relevant_patterns = ["**/extensions/**/*", TOGGLE_FILE_PATTERN, HOOKS_SCRIPT] @@ -162,7 +166,7 @@ def register_watchdogs(): *expand_patterns(f"*/{projects.PROJECT_META_DIR}/plugins/"), *expand_patterns(f"*/{projects.PROJECT_META_DIR}/agents/*/plugins/"), ], - handler=on_plugin_change, + handler=lambda events: on_plugin_change(events, frontend_reload=False), ) # add watchdogs for plugin overrides in /agents/plugins and /usr/agents/plugins @@ -173,16 +177,21 @@ def register_watchdogs(): files.get_abs_path(subagents.USER_AGENTS_DIR), ], patterns=[*expand_patterns(f"*/plugins/*/")], - handler=on_plugin_change, + handler=lambda events: on_plugin_change(events, frontend_reload=False), ) @extension.extensible -def after_plugin_change(plugin_names: list[str] | None = None, python_change:bool=False): +def after_plugin_change( + plugin_names: list[str] | None = None, + python_change: bool = False, + frontend_reload: bool = True, +): clear_plugin_cache(plugin_names) if python_change: refresh_plugin_modules(plugin_names) - send_frontend_reload_notification(plugin_names) + if frontend_reload: + send_frontend_reload_notification(plugin_names) def refresh_plugin_modules(plugin_names: list[str] | None = None): @@ -582,7 +591,9 @@ def toggle_plugin( files.write_file(enabled_file, "") else: files.write_file(disabled_file, "") - after_plugin_change([plugin_name]) + after_plugin_change( + [plugin_name], frontend_reload=not (project_name or agent_profile) + ) @extension.extensible diff --git a/helpers/plugins.py.dox.md b/helpers/plugins.py.dox.md index e5c1e77e1..6eb0310f3 100644 --- a/helpers/plugins.py.dox.md +++ b/helpers/plugins.py.dox.md @@ -17,7 +17,7 @@ - `PluginUpdateInfo` (`BaseModel`) - Top-level functions: - `register_watchdogs()` -- `after_plugin_change(plugin_names: list[str] | None=..., python_change: bool=...)` +- `after_plugin_change(plugin_names: list[str] | None=..., python_change: bool=..., frontend_reload: bool=...)` - `refresh_plugin_modules(plugin_names: list[str] | None=...)` - `clear_plugin_cache(plugin_names: list[str] | None=...)` - `get_plugin_roots(plugin_name: str=...) -> List[str]`: Plugin root directories, ordered by priority (user first). @@ -53,6 +53,8 @@ stale global or scoped disable files, and disable attempts are rejected. - Config hooks receive `hook_context={"caller": caller}` with one of `ui`, `agent`, or `api`; this is behavioral context, not an authorization boundary. +- Project- and agent-scoped plugin changes invalidate runtime caches without a + frontend reload prompt because the loaded WebUI extension bundle is global. - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, plugin state, settings/state persistence, secret handling. - Imported dependency areas include: `__future__`, `asyncio`, `glob`, `helpers`, `helpers.defer`, `helpers.watchdog`, `json`, `pathlib`, `pydantic`, `re`, `regex`, `time`, `typing`. diff --git a/helpers/responses_tools.py.dox.md b/helpers/responses_tools.py.dox.md index f534eda5e..7c2631867 100644 --- a/helpers/responses_tools.py.dox.md +++ b/helpers/responses_tools.py.dox.md @@ -17,6 +17,7 @@ owns the Responses-specific prompt-name compatibility rules. - Local prompt-derived function names use existing bullet declarations that pair a backticked name with `arg` or `args` for multi-tool prompt files, otherwise prefer explicit `"tool_name"` examples, then the first prompt heading, and finally the prompt filename. - Apply registered tool-prompt render kwargs before deriving native metadata so descriptions never expose unresolved prompt templates. +- Keep emitted schemas provider-neutral; provider-specific strictness belongs at the provider request boundary. - Use an explicitly embedded JSON input schema when present. Infer only an unambiguous single backticked argument on an otherwise empty `args:` line; all other local tools receive an honest permissive object schema instead of prose-guessed types. - Native local-tool descriptions reuse the tool catalog's compact prompt description; Responses retains native-name mapping, schema derivation, and diff --git a/helpers/ui_server.py b/helpers/ui_server.py index 2b8d57601..aa3f3586b 100644 --- a/helpers/ui_server.py +++ b/helpers/ui_server.py @@ -205,6 +205,12 @@ class UiServerRuntime: handlers.serve_extension_asset, methods=["GET"], ) + self.webapp.add_url_rule( + "/usr/extensions/webui/", + "serve_user_extension_asset", + handlers.serve_user_extension_asset, + methods=["GET"], + ) self._routes_registered = True def register_transport_handlers(self) -> None: @@ -403,9 +409,19 @@ class UiRouteHandlers: @requires_auth async def serve_extension_asset(self, asset_path): - exts = files.get_abs_path("extensions/webui") - path = files.get_abs_path(exts, asset_path) - if not files.is_in_dir(path, exts): + return self._serve_extension_asset( + files.get_abs_path("extensions/webui"), asset_path + ) + + @requires_auth + async def serve_user_extension_asset(self, asset_path): + return self._serve_extension_asset( + files.get_abs_path(files.USER_DIR, "extensions/webui"), asset_path + ) + + def _serve_extension_asset(self, extension_dir, asset_path): + path = files.get_abs_path(extension_dir, asset_path) + if not files.is_in_dir(path, extension_dir): return Response("Access denied", 403) return send_file(path) diff --git a/helpers/ui_server.py.dox.md b/helpers/ui_server.py.dox.md index 80d42395e..0af0da6e8 100644 --- a/helpers/ui_server.py.dox.md +++ b/helpers/ui_server.py.dox.md @@ -28,6 +28,7 @@ - `async serve_builtin_plugin_asset(self, plugin_name, asset_path)` - `async serve_plugin_asset(self, plugin_name, asset_path)` - `async serve_extension_asset(self, asset_path)` + - `async serve_user_extension_asset(self, asset_path)` - Top-level functions: - `_positive_int_env(name: str, default: int) -> int` - `configure_process_environment() -> None` @@ -45,6 +46,7 @@ - Important called helpers/classes observed in the source: `logging.getLogger.setLevel`, `Localization.get.apply_process_timezone`, `_positive_int_env`, `field`, `Flask`, `threading.RLock`, `socketio.AsyncServer`, `WsManager`, `set_shared_ws_manager`, `cls`, `server_runtime.refresh_runtime_settings`, `settings_helper.get_settings`, `settings_helper.set_runtime_settings_snapshot`, `self.ws_manager.set_server_restart_broadcast`, `UiRouteHandlers`, `self.webapp.add_url_rule`, `register_api_route`, `register_ws_namespace`, `files.read_file`, `render_template_string`, `session.pop`. - `serve_index()` bootstraps the normalized UI control visibility map, timezone and time-format preferences, and the complete enabled WebUI extension manifest so startup extension discovery requires no per-surface API requests. +- Authenticated extension asset routes serve root-contained files from both `extensions/webui/` and `usr/extensions/webui/`, matching the URLs emitted by the WebUI extension manifest. - The authenticated `/` route uses `serve_splash()` to return the no-store, self-contained bootstrap document. The authenticated extensionless `/ui/index` route renders the existing index and runtime/user placeholders for the splash to install into the current document without navigation; `/index.html` remains a direct fallback for the same rendering path. The authenticated `/safe` route first returns a no-store, self-contained document that unregisters all origin service workers, then renders the existing index through `serve_index()` when its internal `__direct=1` marker is present; it never initializes the asset bundle or a worker. The authenticated `serve_ui_asset_bundle()` endpoint passes the application entry URL to the generic recursive bundler and supports gzip transfer and payload-specific ETag revalidation while component, extension, and Alpine lifecycles remain unchanged. - The Starlette HTTP branch applies negotiated gzip to responses of at least 1 KiB at compression level 6 while preserving already encoded responses; Socket.IO remains outside that middleware branch. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. diff --git a/helpers/virtual_desktop.py b/helpers/virtual_desktop.py index d417cf4ad..8de9ded15 100644 --- a/helpers/virtual_desktop.py +++ b/helpers/virtual_desktop.py @@ -115,29 +115,38 @@ def get_registry() -> VirtualDesktopRegistry: return _registry -def session_url(token: str, *, title: str = "Desktop") -> str: +def session_url( + token: str, + *, + title: str = "Desktop", + encoding: str = "jpeg", + quality: int = 85, + speed: int = 80, + file_transfer: bool = True, + printing: bool = True, +) -> str: quoted_token = quote(str(token), safe="") base_path = f"{SESSION_PATH}/{quoted_token}/" - query = urlencode( - { - "path": base_path, - "title": title, - "encoding": "jpeg", - "quality": "85", - "speed": "80", - "sharing": "true", - "clipboard": "true", - "clipboard_direction": "both", - "clipboard_poll": "true", - "clipboard_preferred_format": "text/plain", - "printing": "true", - "file_transfer": "true", - "sound": "false", - "offscreen": "true", - "floating_menu": "false", - "xpramenu": "false", - }, - ) + options = { + "path": base_path, + "title": title, + "quality": str(max(0, min(100, int(quality)))), + "speed": str(max(0, min(100, int(speed)))), + "sharing": "true", + "clipboard": "true", + "clipboard_direction": "both", + "clipboard_poll": "true", + "clipboard_preferred_format": "text/plain", + "printing": str(bool(printing)).lower(), + "file_transfer": str(bool(file_transfer)).lower(), + "sound": "false", + "offscreen": "true", + "floating_menu": "false", + "xpramenu": "false", + } + if encoding: + options["encoding"] = str(encoding) + query = urlencode(options) return f"{base_path}index.html?{query}" @@ -264,6 +273,7 @@ def resize_display( keys: tuple[str, ...] = (), xauthority: str = "", home: str = "", + settle_seconds: float = 0.15, ) -> dict[str, Any]: target_width, target_height = normalize_size(width, height, max_width=max_width, max_height=max_height) xrandr = shutil.which("xrandr") @@ -296,7 +306,8 @@ def resize_display( timeout=4, env=env, ) - time.sleep(0.15) + if settle_seconds > 0: + time.sleep(settle_seconds) current = current_display_size(display, xauthority=xauthority, home=home) ok = current == (target_width, target_height) if ok: diff --git a/helpers/virtual_desktop.py.dox.md b/helpers/virtual_desktop.py.dox.md index 34e7b2510..2681e2cfb 100644 --- a/helpers/virtual_desktop.py.dox.md +++ b/helpers/virtual_desktop.py.dox.md @@ -23,13 +23,13 @@ - `proxy_for_token(token: str) -> VirtualDesktopEndpoint | None` - `resize_session(token: str, width: int, height: int) -> dict[str, Any]` - `get_registry() -> VirtualDesktopRegistry` -- `session_url(token: str, title: str=...) -> str` +- `session_url(token: str, title: str=..., encoding: str=..., quality: int=..., speed: int=..., file_transfer: bool=..., printing: bool=...) -> str` - `collect_status() -> dict[str, Any]` - `find_xpra_html_root() -> Path | None` - `_package_installed(package: str) -> bool` - `normalize_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=...) -> tuple[int, int]` - `normalize_desktop_display_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=..., min_aspect_ratio: float=...) -> tuple[int, int]` -- `resize_display(display: int, width: int, height: int, max_width: int=..., max_height: int=..., window_class: str=..., keys: tuple[str, ...]=..., xauthority: str=..., home: str=...) -> dict[str, Any]` +- `resize_display(display: int, width: int, height: int, max_width: int=..., max_height: int=..., window_class: str=..., keys: tuple[str, ...]=..., xauthority: str=..., home: str=..., settle_seconds: float=...) -> dict[str, Any]` - `_ensure_xrandr_mode(env: dict[str, str], width: int, height: int) -> None` - `_select_xrandr_mode(env: dict[str, str], width: int, height: int) -> subprocess.CompletedProcess[str]` - `_xrandr_output_modes(env: dict[str, str]) -> tuple[str, set[str]]` @@ -54,6 +54,8 @@ - Important called helpers/classes observed in the source: `Path`, `files.get_abs_path`, `get_registry.register`, `get_registry.unregister`, `get_registry.proxy_for_token`, `get_registry.resize`, `quote`, `urlencode`, `find_xpra_html_root`, `subprocess.run`, `normalize_size`, `shutil.which`, `_display_env`, `current_display_size`, `_ensure_xrandr_mode`, `_select_xrandr_mode`, `time.sleep`, `strip`, `_xrandr_output_modes`, `result.stdout.splitlines`. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. +- Session URLs keep Desktop's JPEG, printing, and file-transfer defaults while allowing restricted viewers such as Browser to negotiate encoding and disable unrelated capabilities. +- Display resizing keeps the Desktop settle delay by default; latency-sensitive callers may skip it when they immediately verify the XRandR size. ## Work Guidance diff --git a/plugins/_a0_acp/README.md b/plugins/_a0_acp/README.md new file mode 100644 index 000000000..26a3ad9c4 --- /dev/null +++ b/plugins/_a0_acp/README.md @@ -0,0 +1,17 @@ +# Agent Client Protocol + +`_a0_acp` is the bundled Agent Client Protocol bridge. ACP-capable editors +start the local connector with: + +```bash +a0 acp --host http://localhost:32081 +``` + +The connector owns editor-hosted files and terminal access. The Agent Zero +runtime owns ACP session metadata, history, modes, and model settings. The +default transport is the connector; the hidden `transport: container` setting +is only a compatibility fallback for an already configured legacy `a0_acp` +plugin inside the selected container. + +On startup, Agent Zero removes retired `usr/plugins/a0_acp` installations and +their project or agent overrides. The bundled `_a0_acp` configuration is kept. diff --git a/plugins/_a0_acp/api/session.py b/plugins/_a0_acp/api/session.py new file mode 100644 index 000000000..87e98f56e --- /dev/null +++ b/plugins/_a0_acp/api/session.py @@ -0,0 +1,223 @@ +"""Authenticated ACP session metadata API for the host-side A0 CLI.""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from helpers.api import Request, Response +from plugins._a0_connector.api.v1.base import ProtectedConnectorApiHandler + + +PLUGIN_NAME = "_a0_acp" +CTX_IS_ACP = "acp_session" +CTX_CWD = "acp_cwd" +CTX_ADDITIONAL_DIRECTORIES = "acp_additional_directories" +CTX_MODE = "acp_mode" +CTX_MODEL_ID = "acp_model_id" +CTX_CONFIG_OPTIONS = "acp_config_options" +CTX_TRANSPORT = "acp_transport" +CTX_WORKDIR = "workdir_path" +_VALID_MODES = {"default", "plan", "act"} +_MAX_PATHS = 32 +_MAX_PATH_LENGTH = 4096 + + +def _config() -> dict[str, Any]: + from helpers.plugins import get_plugin_config + + return dict(get_plugin_config(PLUGIN_NAME) or {}) + + +def _paths(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [ + str(path).strip() + for path in value[:_MAX_PATHS] + if str(path).strip() and len(str(path).strip()) <= _MAX_PATH_LENGTH + ] + + +def _mode(value: object) -> str: + mode = str(value or "default").strip().lower() + return mode if mode in _VALID_MODES else "default" + + +def _timestamp(value: object) -> str: + if hasattr(value, "isoformat"): + return value.isoformat() + return str(value or "") + + +def _session_payload(context) -> dict[str, Any]: + return { + "session_id": context.id, + "title": context.name or "Agent Zero ACP", + "cwd": str(context.get_data(CTX_CWD) or ""), + "additional_directories": _paths(context.get_data(CTX_ADDITIONAL_DIRECTORIES)), + "updated_at": _timestamp(context.last_message or context.created_at), + "mode": _mode(context.get_data(CTX_MODE)), + "model_id": str(context.get_data(CTX_MODEL_ID) or ""), + } + + +def _mark_dirty(context_id: str, reason: str) -> None: + try: + from helpers.state_monitor_integration import mark_dirty_for_context + + mark_dirty_for_context(context_id, reason=reason) + except Exception: + return + + +class Session(ProtectedConnectorApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + del request + action = str(input.get("action") or "config").strip().lower() + if action == "config": + return {"ok": True, "config": _config()} + + if action == "list": + return self._list_sessions(input) + if action == "configure": + return self._configure(input) + if action == "fork": + return self._fork(input) + if action == "close": + return self._close(input) + if action == "set_mode": + return self._set_value(input, CTX_MODE, _mode(input.get("mode"))) + if action == "set_model": + return self._set_value(input, CTX_MODEL_ID, str(input.get("model_id") or "").strip()) + if action == "set_config_option": + return self._set_config_option(input) + return Response(status=400, response=f"Unknown ACP action: {action}") + + def _context(self, input: dict): + from agent import AgentContext + + context_id = str(input.get("context_id") or input.get("session_id") or "").strip() + if not context_id: + return None, Response(status=400, response="context_id is required") + context = AgentContext.get(context_id) + if context is None: + return None, Response(status=404, response="ACP session not found") + return context, None + + def _list_sessions(self, input: dict) -> dict: + from agent import AgentContext + from helpers import persist_chat + + persist_chat.load_tmp_chats() + cwd = str(input.get("cwd") or "").strip() + sessions = [ + _session_payload(context) + for context in AgentContext.all() + if context.get_data(CTX_IS_ACP) + and (not cwd or str(context.get_data(CTX_CWD) or "") == cwd) + ] + sessions.sort(key=lambda session: str(session["updated_at"]), reverse=True) + return {"ok": True, "sessions": sessions} + + def _configure(self, input: dict) -> dict | Response: + from helpers import persist_chat + + config = _config() + if not bool(config.get("enabled", True)): + return Response(status=403, response="ACP is disabled in Agent Zero settings") + context, error = self._context(input) + if error: + return error + + cwd = str(input.get("cwd") or "").strip() + if not cwd or len(cwd) > _MAX_PATH_LENGTH: + return Response(status=400, response="A valid ACP workspace path is required") + transport = str(config.get("transport") or "connector").strip().lower() + if transport not in {"connector", "container"}: + transport = "connector" + + context.set_data(CTX_IS_ACP, True) + context.set_data(CTX_CWD, cwd) + context.set_data(CTX_ADDITIONAL_DIRECTORIES, _paths(input.get("additional_directories"))) + context.set_data(CTX_MODE, _mode(input.get("mode"))) + context.set_data(CTX_TRANSPORT, transport) + if transport == "container": + container_workspace = str(config.get("container_workspace_root") or "").strip() + if container_workspace: + context.set_data(CTX_WORKDIR, container_workspace) + if not context.name: + context.name = Path(cwd).name or "Agent Zero ACP" + persist_chat.save_tmp_chat(context) + _mark_dirty(context.id, "a0_acp.configure") + return {"ok": True, "session": _session_payload(context), "config": config} + + def _fork(self, input: dict) -> dict | Response: + from agent import AgentContext + from helpers import persist_chat + + context, error = self._context(input) + if error: + return error + if not context.get_data(CTX_IS_ACP): + return Response(status=400, response="Only ACP sessions can be forked through ACP") + + new_ids = persist_chat.load_json_chats([persist_chat.export_json_chat(context)]) + if not new_ids: + return Response(status=500, response="Could not fork ACP session") + fork = AgentContext.get(new_ids[0]) + if fork is None: + return Response(status=500, response="Forked ACP session could not be loaded") + + fork.name = f"{context.name or 'Agent Zero ACP'} (fork)" + fork.set_data(CTX_IS_ACP, True) + fork.set_data(CTX_CWD, str(input.get("cwd") or context.get_data(CTX_CWD) or "")) + fork.set_data( + CTX_ADDITIONAL_DIRECTORIES, + _paths(input.get("additional_directories")) + or _paths(context.get_data(CTX_ADDITIONAL_DIRECTORIES)), + ) + fork.set_data(CTX_MODE, _mode(context.get_data(CTX_MODE))) + fork.set_data(CTX_TRANSPORT, context.get_data(CTX_TRANSPORT) or "connector") + persist_chat.save_tmp_chat(fork) + _mark_dirty(fork.id, "a0_acp.fork") + return {"ok": True, "session": _session_payload(fork)} + + def _close(self, input: dict) -> dict | Response: + from agent import AgentContext + from helpers import persist_chat + + context, error = self._context(input) + if error: + return error + context.kill_process() + AgentContext.remove(context.id) + persist_chat.remove_chat(context.id) + return {"ok": True} + + def _set_value(self, input: dict, key: str, value: object) -> dict | Response: + from helpers import persist_chat + + context, error = self._context(input) + if error: + return error + context.set_data(key, value) + persist_chat.save_tmp_chat(context) + _mark_dirty(context.id, f"a0_acp.{key}") + return {"ok": True, "session": _session_payload(context)} + + def _set_config_option(self, input: dict) -> dict | Response: + from helpers import persist_chat + + context, error = self._context(input) + if error: + return error + config_id = str(input.get("config_id") or "").strip() + if not config_id: + return Response(status=400, response="config_id is required") + options = context.get_data(CTX_CONFIG_OPTIONS) + options = dict(options) if isinstance(options, dict) else {} + options[config_id] = input.get("value") + context.set_data(CTX_CONFIG_OPTIONS, options) + persist_chat.save_tmp_chat(context) + _mark_dirty(context.id, "a0_acp.config_option") + return {"ok": True, "config_options": options} diff --git a/plugins/_a0_acp/default_config.yaml b/plugins/_a0_acp/default_config.yaml new file mode 100644 index 000000000..96f41b59c --- /dev/null +++ b/plugins/_a0_acp/default_config.yaml @@ -0,0 +1,15 @@ +# The normal ACP transport is the A0 CLI running on the editor host. +enabled: true +agent_profile: "" +host_file_access: read_write +host_code_execution: true +session_history: true + +# Advanced compatibility transport. These values are intentionally not exposed +# by the standard settings UI because they require a preconfigured legacy ACP +# plugin inside the selected container. +transport: connector +container_id: "" +container_workdir: /a0 +container_python: /opt/venv-a0/bin/python +container_workspace_root: "" diff --git a/plugins/_a0_acp/extensions/python/message_loop_prompts_after/_50_acp_mode.py b/plugins/_a0_acp/extensions/python/message_loop_prompts_after/_50_acp_mode.py new file mode 100644 index 000000000..ce665a3be --- /dev/null +++ b/plugins/_a0_acp/extensions/python/message_loop_prompts_after/_50_acp_mode.py @@ -0,0 +1,17 @@ +from agent import LoopData +from helpers.extension import Extension + + +_MODE_PROMPTS = { + "plan": "ACP session mode: plan first. Prefer analysis and tradeoffs. Do not modify files unless the user explicitly asks.", + "act": "ACP session mode: act. Complete actionable work end-to-end with focused implementation and validation.", +} + + +class AcpMode(Extension): + async def execute(self, loop_data: LoopData = LoopData(), **kwargs): + if not self.agent or not self.agent.context.get_data("acp_session"): + return + prompt = _MODE_PROMPTS.get(str(self.agent.context.get_data("acp_mode") or "")) + if prompt: + loop_data.extras_temporary["acp_mode"] = prompt diff --git a/plugins/_a0_acp/extensions/python/startup_migration/_10_migrate_legacy_acp.py b/plugins/_a0_acp/extensions/python/startup_migration/_10_migrate_legacy_acp.py new file mode 100644 index 000000000..cc4d727d1 --- /dev/null +++ b/plugins/_a0_acp/extensions/python/startup_migration/_10_migrate_legacy_acp.py @@ -0,0 +1,53 @@ +"""Retire the former community ACP plugin after Core ships its replacement.""" +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from helpers import cache, files +from helpers.extension import Extension +from helpers.print_style import PrintStyle + + +LEGACY_PLUGIN_NAME = "a0_acp" + + +class LegacyAcpMigration(Extension): + def execute(self, **kwargs: Any) -> None: + result = migrate_legacy_acp() + if result["removed_roots"]: + PrintStyle.info("Removed retired ACP plugin files:", result["removed_roots"]) + + +def migrate_legacy_acp(base_dir: str | Path | None = None) -> dict[str, list[str]]: + root = Path(base_dir or files.get_abs_path("")).resolve() + removed_roots: list[str] = [] + errors: list[str] = [] + + for plugin_root in _legacy_plugin_roots(root): + try: + if plugin_root.is_dir() and not plugin_root.is_symlink(): + shutil.rmtree(plugin_root) + else: + plugin_root.unlink() + removed_roots.append(str(plugin_root)) + except OSError as exc: + errors.append(f"Could not remove retired ACP plugin at {plugin_root}: {exc}") + + if removed_roots: + cache.clear("*(plugins)*") + cache.clear("*(extensions)*") + cache.clear("*(api)*") + + return {"removed_roots": removed_roots, "errors": errors} + + +def _legacy_plugin_roots(root: Path) -> list[Path]: + candidates = [ + root / "usr" / "plugins" / LEGACY_PLUGIN_NAME, + *root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"), + *root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"), + *root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"), + ] + return [candidate for candidate in candidates if candidate.exists() or candidate.is_symlink()] diff --git a/plugins/_a0_acp/plugin.yaml b/plugins/_a0_acp/plugin.yaml new file mode 100644 index 000000000..8e817c847 --- /dev/null +++ b/plugins/_a0_acp/plugin.yaml @@ -0,0 +1,9 @@ +name: _a0_acp +title: Agent Client Protocol +description: Connect ACP-capable editors through the local A0 CLI connector. +version: "2.0" +settings_sections: + - external +per_project_config: false +per_agent_config: false +always_enabled: true diff --git a/plugins/_a0_acp/tests/test_migration.py b/plugins/_a0_acp/tests/test_migration.py new file mode 100644 index 000000000..d8faad273 --- /dev/null +++ b/plugins/_a0_acp/tests/test_migration.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from plugins._a0_acp.extensions.python.startup_migration._10_migrate_legacy_acp import ( + migrate_legacy_acp, +) + + +def test_migrate_legacy_acp_removes_all_stale_plugin_roots(tmp_path: Path) -> None: + stale_roots = [ + tmp_path / "usr" / "plugins" / "a0_acp", + tmp_path / "usr" / "projects" / "demo" / ".a0proj" / "plugins" / "a0_acp", + tmp_path / "usr" / "agents" / "reviewer" / "plugins" / "a0_acp", + ] + bundled_config = tmp_path / "usr" / "plugins" / "_a0_acp" / "config.json" + + for root in stale_roots: + (root / ".git").mkdir(parents=True) + (root / "plugin.yaml").write_text("name: a0_acp\n", encoding="utf-8") + (root / ".git" / "config").write_text("[core]\n", encoding="utf-8") + bundled_config.parent.mkdir(parents=True) + bundled_config.write_text('{"enabled": true}\n', encoding="utf-8") + + result = migrate_legacy_acp(tmp_path) + + assert len(result["removed_roots"]) == len(stale_roots) + assert result["errors"] == [] + assert all(not root.exists() for root in stale_roots) + assert bundled_config.read_text(encoding="utf-8") == '{"enabled": true}\n' + assert migrate_legacy_acp(tmp_path) == {"removed_roots": [], "errors": []} diff --git a/plugins/_a0_acp/tests/test_session.py b/plugins/_a0_acp/tests/test_session.py new file mode 100644 index 000000000..0173b490f --- /dev/null +++ b/plugins/_a0_acp/tests/test_session.py @@ -0,0 +1,19 @@ +from datetime import datetime, timezone + +from plugins._a0_acp.api.session import _session_payload + + +class _Context: + id = "ctx-acp" + name = "ACP" + created_at = datetime(2026, 8, 16, tzinfo=timezone.utc) + last_message = datetime(2026, 8, 16, 12, 34, tzinfo=timezone.utc) + + def get_data(self, key: str): + return {"acp_cwd": "/workspace", "acp_mode": "default"}.get(key) + + +def test_session_payload_serializes_datetime_metadata() -> None: + payload = _session_payload(_Context()) + + assert payload["updated_at"] == "2026-08-16T12:34:00+00:00" diff --git a/plugins/_a0_acp/webui/config.html b/plugins/_a0_acp/webui/config.html new file mode 100644 index 000000000..4817755e9 --- /dev/null +++ b/plugins/_a0_acp/webui/config.html @@ -0,0 +1,81 @@ + + + Agent Client Protocol + + + +
+ +
+ + diff --git a/plugins/_browser/AGENTS.md b/plugins/_browser/AGENTS.md index b790991e4..688cec007 100644 --- a/plugins/_browser/AGENTS.md +++ b/plugins/_browser/AGENTS.md @@ -2,27 +2,40 @@ ## Purpose -- Own the built-in Playwright browser tool and WebUI browser viewer. +- Own the built-in Patchright browser tool and WebUI browser viewer. - Bridge browser automation, page inspection helpers, and browser panel UI. ## Ownership - `plugin.yaml` and `default_config.yaml` own metadata and browser settings defaults. - `tools/browser.py` owns the agent-facing browser tool. -- `helpers/` owns Playwright runtime, selectors, URL helpers, extension management, and connector runtime logic. +- `helpers/` owns the Patchright runtime, private interactive display, selectors, URL helpers, extension management, and connector runtime logic. - `api/` owns status, extension, and browser WebSocket handlers. - `assets/`, `prompts/`, `skills/`, `extensions/`, and `webui/` own browser scripts, prompts, skill guidance, hook contributions, and UI. ## Local Contracts - Keep browser actions safe around external pages, credentials, and user data. -- Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding. +- Preserve Patchright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding. - Keep the WebUI Browser inside its own modal/canvas affordance; do not replace it with page-level navigation. -- Default the visible WebUI Browser to live CDP screencast for responsiveness. Keep lightweight CDP/DOM state snapshots as the fallback transport. +- Default the visible WebUI Browser to the authenticated Xpra HTML5 viewer for its existing Patchright page. Keep live CDP screencast and lightweight snapshots as automatic fallbacks. +- Do not block an available interactive viewer on a redundant Chromium screenshot; capture initial snapshots only for fallback transports. +- Keep headful Chromium in a normal window with its own toolbar clipped above the private display; do not use browser fullscreen, which shows Chromium's exit warning. +- Persist open-tab ownership and URLs through the shared KVP store; automatically restore the current chat when its Browser surface opens in per-chat mode and every saved chat in shared mode, then hide Chromium's redundant crash-restore advisory. +- When no Browser tab manifest exists yet, use Chromium's last session once to migrate open tabs into the owned manifest. +- Throttle interactive resize updates throughout a drag and let the native-sized Chromium viewport follow the private display; do not defer all layout updates until resizing stops. +- Keep exactly one interactive viewer iframe connected during canvas/modal handoff so hidden surfaces cannot compete to resize the same display. +- Notify the active Xpra client of its new frame geometry before resizing the backing display; after an interactive canvas/modal handoff, reconcile once after Xpra's deferred resize so Chromium cannot retain the previous surface size. +- Present the Xpra shadow window as the raw browser canvas: remove its HTML decoration and shadow pointer while preserving exact viewport geometry. +- Keep one internal Chromium, Xvfb, and Xpra runtime per Agent Zero process with one unguessable gateway token. +- Bind Browser Xpra endpoints to loopback, route them through the authenticated virtual-desktop gateway, and keep file transfer, URL opening, printing, and audio disabled. - Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the ``/data URL path for snapshots and fallback rendering. - Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path. - Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values. -- Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other AgentContext runtimes only when the Browser settings tab scope is `shared`. +- Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other context handles only when the Browser settings tab scope is `shared`. +- Share one persistent internal-Browser sign-in profile across chats while enforcing tab ownership through context-bound runtime handles; resetting or removing a chat closes only its tabs and never deletes the shared profile. +- On first shared-profile use after an upgrade, adopt the first requesting chat's legacy Browser profile when one exists. +- Show an accessible in-panel startup state while the on-demand shared Browser runtime is cold-starting; keep that one runtime warm until Browser configuration changes or Agent Zero shuts down. - Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar. - For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic CLI selection, a browser family/id, an HTTP CDP discovery address, or a full DevTools WebSocket endpoint and must be forwarded to the connector runtime as `browser_selection`. - Browser Settings must refresh connected A0 CLI host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening. @@ -30,8 +43,14 @@ - Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces. - Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback. - Do not hardcode user-specific browser paths or secrets. -- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection. +- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection. After the first Browser tool call, use the selected preset for subsequent model turns in that monologue and clear it at monologue end. +- 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. +- 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. +- Browser startup and on-demand launch must converge on the Chromium revision declared by Patchright; let its installer select the host architecture rather than hardcoding x64 or ARM downloads. +- `hooks.prepare_playwright_cache()` owns reconciliation of the pinned Patchright package and Chromium binary so repository self-updates and fresh images use the same setup path. +- Browser startup must install the shared virtual-desktop route hook itself; do not make Browser depend on the Desktop plugin being enabled. ## Work Guidance @@ -44,7 +63,7 @@ ## Verification - Smoke-test browser launch, navigation, DOM capture, and WebUI viewer after runtime changes. -- For viewer render-path changes, verify the live Browser panel paints a screencast frame on canvas with `frameSrc` empty and snapshots still falling back to the image path. +- For viewer render-path changes, verify direct iframe interaction reaches the same page controlled by Patchright, separate contexts use separate displays, and an unavailable Xpra runtime falls back to CDP screencast/snapshot rendering. - Run browser prompt/skill regression tests after changing browser prompt or Browser plugin skills. ## Child DOX Index diff --git a/plugins/_browser/api/status.py b/plugins/_browser/api/status.py index 2669fe677..8b9cefd80 100644 --- a/plugins/_browser/api/status.py +++ b/plugins/_browser/api/status.py @@ -1,5 +1,6 @@ from helpers.api import ApiHandler, Request from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config +from plugins._browser.helpers.interactive_view import collect_status as collect_interactive_status from plugins._browser.helpers.playwright import ( get_playwright_binary, get_playwright_cache_dir, @@ -37,5 +38,6 @@ class Status(ApiHandler): "requires_full_browser": launch_config["requires_full_browser"], }, "host_browser": host_browser, + "interactive_view": collect_interactive_status(), "contexts": known_context_ids(), } diff --git a/plugins/_browser/api/ws_browser.py b/plugins/_browser/api/ws_browser.py index 6c810ebc9..41c5011e3 100644 --- a/plugins/_browser/api/ws_browser.py +++ b/plugins/_browser/api/ws_browser.py @@ -14,7 +14,11 @@ from plugins._browser.helpers.config import ( TAB_SCOPE_KEY, get_browser_config, ) -from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions +from plugins._browser.helpers.runtime import ( + get_runtime, + has_restorable_browser_tabs, + list_runtime_sessions, +) FRAME_READ_TIMEOUT_SECONDS = 0.5 @@ -25,7 +29,12 @@ SCREENCAST_STREAM_QUALITY = 80 SCREENSHOT_QUALITY = 92 VIEWER_TRANSPORT_SCREENCAST = "screencast" VIEWER_TRANSPORT_SNAPSHOT = "snapshot" -VIEWER_TRANSPORTS = {VIEWER_TRANSPORT_SCREENCAST, VIEWER_TRANSPORT_SNAPSHOT} +VIEWER_TRANSPORT_INTERACTIVE = "interactive" +VIEWER_TRANSPORTS = { + VIEWER_TRANSPORT_INTERACTIVE, + VIEWER_TRANSPORT_SCREENCAST, + VIEWER_TRANSPORT_SNAPSHOT, +} class WsBrowser(WsHandler): @@ -75,6 +84,8 @@ class WsBrowser(WsHandler): create_browser = self._bool(data.get("create_browser", data.get("createBrowser"))) runtime = await get_runtime(context_id, create=create_browser) + if not runtime and not create_browser and has_restorable_browser_tabs(context_id): + runtime = await get_runtime(context_id) listing = {"browsers": [], "last_interacted_browser_id": None} browsers: list[dict[str, Any]] = [] if runtime: @@ -87,8 +98,19 @@ class WsBrowser(WsHandler): if opened.get("id"): listing["last_interacted_browser_id"] = opened.get("id") active_id = self._active_browser_id(listing, data.get("browser_id")) + requested_transport = self._viewer_transport(data) + viewer_transport, interactive_view = await self._effective_viewer( + runtime, + active_id, + data, + ) initial_viewport = self._viewport_from_data(data) - if runtime and active_id and initial_viewport: + if ( + runtime + and active_id + and initial_viewport + and viewer_transport != VIEWER_TRANSPORT_INTERACTIVE + ): await runtime.call( "set_viewport", active_id, @@ -103,7 +125,6 @@ class WsBrowser(WsHandler): if existing: existing.cancel() viewer_id = str(data.get("viewer_id") or "") - viewer_transport = self._viewer_transport(data) binary_frames = self._bool(data.get("binary_frames", data.get("binaryFrames"))) slim_frames = self._bool(data.get("slim_frames", data.get("slimFrames", binary_frames))) capture_scale = self._capture_scale_from_data(data) @@ -120,9 +141,16 @@ class WsBrowser(WsHandler): capture_scale=capture_scale, ) else: - stream_task = self._stream_state(sid, context_id, active_id, viewer_id) + stream_task = self._stream_state( + sid, + context_id, + active_id, + viewer_id, + viewer_transport=viewer_transport, + ) self._streams[stream_key] = asyncio.create_task(stream_task) - snapshot = await self._snapshot_for_browser(runtime, active_id) + if viewer_transport != VIEWER_TRANSPORT_INTERACTIVE: + snapshot = await self._snapshot_for_browser(runtime, active_id) browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers) @@ -136,6 +164,14 @@ class WsBrowser(WsHandler): "tab_scope": tab_scope, "viewer_id": viewer_id, "viewer_transport": viewer_transport, + "interactive_view": interactive_view, + "viewer_fallback_reason": ( + str(interactive_view.get("error") or "") + if requested_transport == VIEWER_TRANSPORT_INTERACTIVE + and interactive_view + and not interactive_view.get("available") + else "" + ), "binary_frames": binary_frames, "slim_frames": slim_frames, } @@ -253,7 +289,20 @@ class WsBrowser(WsHandler): listing = await runtime.call("list") last_interacted_browser_id = listing.get("last_interacted_browser_id") - snapshot = await self._snapshot_for_result(runtime, result) + active_id = self._active_browser_id( + listing, + self._result_browser_id(result) or browser_id, + ) + viewer_transport, interactive_view = await self._effective_viewer( + runtime, + active_id, + data, + ) + snapshot = ( + None + if viewer_transport == VIEWER_TRANSPORT_INTERACTIVE + else await self._snapshot_for_result(runtime, result) + ) browsers, all_browsers, tab_scope = await self._tabs_for_scope( context_id, listing.get("browsers") or [], @@ -273,7 +322,8 @@ class WsBrowser(WsHandler): "all_browsers": all_browsers, "tab_scope": tab_scope, "last_interacted_browser_id": last_interacted_browser_id, - "viewer_transport": self._viewer_transport(data), + "viewer_transport": viewer_transport, + "interactive_view": interactive_view, }, correlation_id=data.get("correlationId"), ) @@ -288,7 +338,8 @@ class WsBrowser(WsHandler): "command": command, "browser_id": browser_id, "viewer_id": viewer_id, - "viewer_transport": self._viewer_transport(data), + "viewer_transport": viewer_transport, + "interactive_view": interactive_view, } async def _input(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult: @@ -326,12 +377,15 @@ class WsBrowser(WsHandler): text=str(data.get("text") or ""), ) elif input_type == "viewport": + viewer_transport = self._viewer_transport(data) result = await runtime.call( "set_viewport", browser_id, int(data.get("width") or 0), int(data.get("height") or 0), restart_screencast=bool(data.get("restart_stream")), + resize_interactive=viewer_transport == VIEWER_TRANSPORT_INTERACTIVE, + include_state=viewer_transport != VIEWER_TRANSPORT_INTERACTIVE, ) elif input_type == "wheel": result = await runtime.call( @@ -582,6 +636,8 @@ class WsBrowser(WsHandler): context_id: str, browser_id: int | str | None, viewer_id: str = "", + *, + viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT, ) -> None: last_signature = None while True: @@ -594,7 +650,7 @@ class WsBrowser(WsHandler): sid, context_id, viewer_id=viewer_id, - frame_source=VIEWER_TRANSPORT_SNAPSHOT, + frame_source=viewer_transport, ) last_signature = signature await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS) @@ -625,7 +681,7 @@ class WsBrowser(WsHandler): browsers=browsers, viewer_id=viewer_id, state=state, - viewer_transport=VIEWER_TRANSPORT_SNAPSHOT, + viewer_transport=viewer_transport, ) last_signature = signature await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS) @@ -653,6 +709,36 @@ class WsBrowser(WsHandler): active_id = browsers[0].get("id") return active_id + async def _effective_viewer( + self, + runtime: Any, + browser_id: int | str | None, + data: dict[str, Any], + ) -> tuple[str, dict[str, Any] | None]: + requested = self._viewer_transport(data) + if requested != VIEWER_TRANSPORT_INTERACTIVE or not runtime or not browser_id: + return requested, None + viewport = self._viewport_from_data(data) or {} + try: + viewer = await runtime.call( + "interactive_viewer", + browser_id, + width=int(viewport.get("width") or 0), + height=int(viewport.get("height") or 0), + ) + except Exception as exc: + viewer = {"available": False, "error": str(exc)} + if viewer.get("available"): + return VIEWER_TRANSPORT_INTERACTIVE, viewer + return VIEWER_TRANSPORT_SCREENCAST, viewer + + @staticmethod + def _result_browser_id(result: Any) -> int | str | None: + if not isinstance(result, dict): + return None + state = result.get("state") if isinstance(result.get("state"), dict) else result + return state.get("id") if isinstance(state, dict) else None + @staticmethod def _state_for_browser( browsers: list[dict[str, Any]], diff --git a/plugins/_browser/default_config.yaml b/plugins/_browser/default_config.yaml index 22d05d2e8..e15d3d880 100644 --- a/plugins/_browser/default_config.yaml +++ b/plugins/_browser/default_config.yaml @@ -8,7 +8,8 @@ default_homepage: "about:blank" # When the Browser surface is already open, keep it synced to agent Browser tool results. autofocus_active_page: true -# Browser tab visibility in the WebUI: +# 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. # - shared: show Browser tabs from all active chats. browser_tab_scope: "per_context" diff --git a/plugins/_browser/extensions/python/_functions/agent/Agent/get_chat_model/start/_20_browser_model.py b/plugins/_browser/extensions/python/_functions/agent/Agent/get_chat_model/start/_20_browser_model.py new file mode 100644 index 000000000..de17c4680 --- /dev/null +++ b/plugins/_browser/extensions/python/_functions/agent/Agent/get_chat_model/start/_20_browser_model.py @@ -0,0 +1,14 @@ +from helpers.extension import Extension +from plugins._browser.helpers.config import ( + browser_model_is_active, + resolve_browser_model, +) + + +class BrowserModelProvider(Extension): + def execute(self, data: dict = {}, **kwargs): + if self.agent and browser_model_is_active(self.agent): + data["result"] = resolve_browser_model( + self.agent, + fallback=data.get("result"), + ) diff --git a/plugins/_browser/extensions/python/monologue_end/_20_browser_model.py b/plugins/_browser/extensions/python/monologue_end/_20_browser_model.py new file mode 100644 index 000000000..3a0b42b18 --- /dev/null +++ b/plugins/_browser/extensions/python/monologue_end/_20_browser_model.py @@ -0,0 +1,8 @@ +from helpers.extension import Extension +from plugins._browser.helpers.config import clear_browser_model + + +class BrowserModelCleanup(Extension): + def execute(self, **kwargs): + if self.agent: + clear_browser_model(self.agent) diff --git a/plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py b/plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py index c30aa4b57..8839dc55c 100644 --- a/plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py +++ b/plugins/_browser/extensions/python/startup_migration/_20_browser_playwright_cache.py @@ -3,6 +3,7 @@ from __future__ import annotations import threading from typing import Any +from helpers import virtual_desktop_routes from helpers.extension import Extension from helpers.print_style import PrintStyle from plugins._browser import hooks @@ -13,6 +14,7 @@ _startup_migration_thread: threading.Thread | None = None class BrowserPlaywrightCacheMigration(Extension): def execute(self, **kwargs): + virtual_desktop_routes.install_route_hooks() _start_background_cache_migration() @@ -33,7 +35,7 @@ def _start_background_cache_migration() -> threading.Thread: def _migrate_cache_safely() -> None: try: - _log_cache_migration_result(hooks.cleanup_playwright_cache()) + _log_cache_migration_result(hooks.prepare_playwright_cache()) except Exception as exc: PrintStyle.warning("Browser Playwright cache migration failed:", exc) diff --git a/plugins/_browser/helpers/config.py b/plugins/_browser/helpers/config.py index e412b652c..f000a6f3c 100644 --- a/plugins/_browser/helpers/config.py +++ b/plugins/_browser/helpers/config.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: PLUGIN_NAME = "_browser" MODEL_PRESET_KEY = "model_preset" +BROWSER_MODEL_ACTIVE_KEY = "_browser_model_active" DEFAULT_HOMEPAGE_KEY = "default_homepage" AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page" TAB_SCOPE_KEY = "browser_tab_scope" @@ -30,11 +31,6 @@ DEFAULT_MAX_OPEN_TABS = 32 MIN_MAX_OPEN_TABS = 1 HARD_MAX_OPEN_TABS = 50 DEFAULT_HOST_BROWSER_PRIVACY_POLICY = "allow" -BASE_BROWSER_ARGS = [ - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-gpu", -] def _normalize_extension_paths(value: Any) -> list[str]: @@ -324,10 +320,34 @@ def resolve_browser_model_selection( } -def resolve_browser_model(agent: "Agent", settings: dict[str, Any] | None = None): +def activate_browser_model(agent: "Agent") -> dict[str, Any]: + selection = resolve_browser_model_selection(agent=agent) + agent.set_data( + BROWSER_MODEL_ACTIVE_KEY, + selection["selected_preset_name"] + if selection["source_kind"] == "preset" + else "", + ) + return selection + + +def clear_browser_model(agent: "Agent") -> None: + agent.set_data(BROWSER_MODEL_ACTIVE_KEY, "") + + +def browser_model_is_active(agent: "Agent") -> bool: + return bool(agent.get_data(BROWSER_MODEL_ACTIVE_KEY)) + + +def resolve_browser_model( + agent: "Agent", + settings: dict[str, Any] | None = None, + fallback: Any = None, +): selection = resolve_browser_model_selection(agent=agent, settings=settings) if selection["source_kind"] == "main": - return agent.get_chat_model() + clear_browser_model(agent) + return fallback if fallback is not None else agent.get_chat_model() import models from plugins._model_config.helpers import model_config @@ -388,7 +408,7 @@ def describe_browser_extensions(settings: dict[str, Any] | None) -> dict[str, An def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, Any]: config = normalize_browser_config(settings) extensions = describe_browser_extensions(config) - args = list(BASE_BROWSER_ARGS) + args = ["--hide-crash-restore-bubble"] channel: str | None = None browser_mode = "chromium" proxy = None diff --git a/plugins/_browser/helpers/interactive_view.py b/plugins/_browser/helpers/interactive_view.py new file mode 100644 index 000000000..a603633d0 --- /dev/null +++ b/plugins/_browser/helpers/interactive_view.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import os +import select +import shutil +import socket +import subprocess +import threading +import time +import uuid +from pathlib import Path +from typing import Any + +from helpers import files, virtual_desktop +from helpers.print_style import PrintStyle + + +DEFAULT_WIDTH = 1024 +DEFAULT_HEIGHT = 768 +START_TIMEOUT_SECONDS = 15.0 + + +def collect_status() -> dict[str, Any]: + binaries = { + name: shutil.which(name) or "" + for name in ("Xvfb", "xpra", "xrandr") + } + html_root = virtual_desktop.find_xpra_html_root() + missing = [name for name, path in binaries.items() if not path] + if not html_root: + missing.append("xpra-html5") + return { + "available": not missing, + "missing": missing, + "binaries": binaries, + "xpra_html_root": str(html_root or ""), + } + + +class BrowserInteractiveView: + """Own one private X display and its optional Xpra viewer.""" + + def __init__(self, context_id: str) -> None: + self.context_id = str(context_id) + self.token = f"browser-{uuid.uuid4().hex}" + self.state_dir = Path(files.get_abs_path("tmp", "browser", "displays", self.token)) + self.display: int | None = None + self.port = 0 + self.width = DEFAULT_WIDTH + self.height = DEFAULT_HEIGHT + self._xvfb: subprocess.Popen[Any] | None = None + self._xpra: subprocess.Popen[Any] | None = None + self._lock = threading.RLock() + + @property + def display_name(self) -> str: + return f":{self.display}" if self.display is not None else "" + + def ensure_display(self) -> str: + with self._lock: + if self._running(self._xvfb) and self.display is not None: + return self.display_name + + self._stop_locked() + xvfb = shutil.which("Xvfb") + if not xvfb: + return "" + + self.state_dir.mkdir(parents=True, exist_ok=True) + self.state_dir.chmod(0o700) + read_fd, write_fd = os.pipe() + try: + process = subprocess.Popen( + [ + xvfb, + "-displayfd", + str(write_fd), + "-screen", + "0", + f"{virtual_desktop.MAX_WIDTH}x{virtual_desktop.MAX_HEIGHT}x24", + "+extension", + "GLX", + "+extension", + "RANDR", + "+extension", + "RENDER", + "+extension", + "Composite", + "-nolisten", + "tcp", + "-noreset", + "-ac", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + pass_fds=(write_fd,), + ) + except OSError: + os.close(read_fd) + return "" + finally: + os.close(write_fd) + + try: + ready, _, _ = select.select([read_fd], [], [], 5) + display_number = os.read(read_fd, 32).decode().strip() if ready else "" + finally: + os.close(read_fd) + + if not display_number.isdigit() or process.poll() is not None: + self._terminate(process) + return "" + + self._xvfb = process + self.display = int(display_number) + self.resize(self.width, self.height) + return self.display_name + + def ensure_viewer(self, width: int = 0, height: int = 0) -> dict[str, Any]: + with self._lock: + display_name = self.ensure_display() + if not display_name: + return self._unavailable("Xvfb is unavailable.") + + status = collect_status() + if not status["available"]: + return self._unavailable( + f"Interactive Browser runtime needs: {', '.join(status['missing'])}." + ) + + self.resize(width or self.width, height or self.height) + if not self._running(self._xpra): + try: + self._start_xpra(str(status["binaries"]["xpra"])) + except Exception as exc: + PrintStyle.warning(f"Interactive Browser viewer failed to start: {exc}") + self._terminate(self._xpra) + self._xpra = None + self.port = 0 + virtual_desktop.unregister_session(self.token) + return self._unavailable(str(exc)) + + virtual_desktop.register_session( + token=self.token, + host="127.0.0.1", + port=self.port, + owner="browser", + title="Browser", + resize=self.resize, + ) + return { + "available": True, + "token": self.token, + "url": virtual_desktop.session_url( + self.token, + title="Browser", + encoding="", + quality=90, + speed=90, + file_transfer=False, + printing=False, + ), + "width": self.width, + "height": self.height, + } + + def resize(self, width: int, height: int) -> dict[str, Any]: + with self._lock: + target_width, target_height = virtual_desktop.normalize_size(width, height) + self.width = target_width + self.height = target_height + if self.display is None or not self._running(self._xvfb): + return { + "ok": False, + "error": "Browser display is unavailable.", + "width": target_width, + "height": target_height, + } + result = virtual_desktop.resize_display( + display=self.display, + width=target_width, + height=target_height, + settle_seconds=0, + ) + return result + + def close(self) -> None: + with self._lock: + self._stop_locked() + shutil.rmtree(self.state_dir, ignore_errors=True) + + def _start_xpra(self, xpra: str) -> None: + self.port = self._free_port() + runtime_dir = self.state_dir / "runtime" + socket_dir = self.state_dir / "sockets" + runtime_dir.mkdir(parents=True, exist_ok=True) + socket_dir.mkdir(parents=True, exist_ok=True) + runtime_dir.chmod(0o700) + env = { + **os.environ, + "DISPLAY": self.display_name, + "XDG_RUNTIME_DIR": str(runtime_dir), + } + self._xpra = subprocess.Popen( + [ + xpra, + "shadow", + self.display_name, + "--daemon=no", + "--mdns=no", + "--html=on", + "--tray=no", + "--system-tray=no", + "--notifications=no", + "--clipboard=yes", + "--clipboard-direction=both", + "--file-transfer=no", + "--open-files=no", + "--open-url=no", + "--printing=no", + "--audio=no", + "--speaker=off", + "--microphone=off", + "--sharing=yes", + "--resize-display=yes", + "--encoding=auto", + "--quality=90", + "--speed=90", + f"--bind-tcp=127.0.0.1:{self.port}", + f"--socket-dir={socket_dir}", + f"--log-dir={self.state_dir}", + "--log-file=xpra.log", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + ) + self._wait_for_port(self._xpra, self.port) + + def _stop_locked(self) -> None: + virtual_desktop.unregister_session(self.token) + self._terminate(self._xpra) + self._terminate(self._xvfb) + self._xpra = None + self._xvfb = None + self.port = 0 + self.display = None + + def _unavailable(self, error: str) -> dict[str, Any]: + return { + "available": False, + "error": str(error or "Interactive Browser viewer is unavailable."), + } + + @staticmethod + def _running(process: subprocess.Popen[Any] | None) -> bool: + return bool(process and process.poll() is None) + + @staticmethod + def _terminate(process: subprocess.Popen[Any] | None) -> None: + if not process or process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + + @staticmethod + def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + return int(probe.getsockname()[1]) + + @staticmethod + def _wait_for_port( + process: subprocess.Popen[Any], + port: int, + timeout: float = START_TIMEOUT_SECONDS, + ) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError("Xpra exited before its Browser endpoint was ready.") + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return + except OSError: + time.sleep(0.1) + raise TimeoutError("Timed out waiting for the interactive Browser endpoint.") diff --git a/plugins/_browser/helpers/playwright.py b/plugins/_browser/helpers/playwright.py index f1c91014f..4878adca0 100644 --- a/plugins/_browser/helpers/playwright.py +++ b/plugins/_browser/helpers/playwright.py @@ -1,12 +1,16 @@ +import json import os +import re import subprocess +import sys +from importlib import resources from pathlib import Path from helpers import files FULL_CHROMIUM_PATTERNS = ( - "chromium-*/chrome-linux/chrome", - "chromium-*/chrome-win/chrome.exe", + "chromium-*/chrome-linux*/chrome", + "chromium-*/chrome-win*/chrome.exe", ) PLAYWRIGHT_CACHE_ENV = "A0_BROWSER_PLAYWRIGHT_CACHE_DIR" PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright") @@ -52,16 +56,43 @@ def configure_playwright_env() -> str: return cache_dir -def find_playwright_binary(cache_dir: Path) -> Path | None: - for pattern in FULL_CHROMIUM_PATTERNS: - binary = next(cache_dir.glob(pattern), None) - if binary and binary.exists(): - return binary - return None +def find_playwright_binary(cache_dir: Path, revision: str = "") -> Path | None: + prefix = f"chromium-{revision}" if revision.isdigit() else "chromium-*" + binaries = [ + binary + for pattern in FULL_CHROMIUM_PATTERNS + for binary in cache_dir.glob(pattern.replace("chromium-*", prefix)) + if binary.exists() + ] + return max(binaries, key=_chromium_revision) if binaries else None + + +def _chromium_revision(binary: Path) -> int: + match = re.search(r"chromium-(\d+)", binary.as_posix()) + return int(match.group(1)) if match else -1 def get_playwright_binary() -> Path | None: - return find_playwright_binary(_primary_cache_dir()) + cache_dir = _primary_cache_dir() + binary = find_playwright_binary(_primary_cache_dir()) + revision = get_playwright_chromium_revision() + if revision and (not binary or _chromium_revision(binary) != int(revision)): + return find_playwright_binary(cache_dir, revision=revision) + return binary + + +def get_playwright_chromium_revision() -> str: + try: + manifest = resources.files("patchright").joinpath("driver/package/browsers.json") + browsers = json.loads(manifest.read_text(encoding="utf-8"))["browsers"] + revision = next( + str(browser.get("revision", "")) + for browser in browsers + if browser.get("name") == "chromium" + ) + except (ImportError, FileNotFoundError, KeyError, StopIteration, TypeError, ValueError): + return "" + return revision if revision.isdigit() else "" def ensure_playwright_binary() -> Path: @@ -72,13 +103,12 @@ def ensure_playwright_binary() -> Path: cache_dir = configure_playwright_env() env = os.environ.copy() env["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir - install_command = ["playwright", "install", "chromium"] subprocess.check_call( - install_command, + [sys.executable, "-m", "patchright", "install", "chromium", "--no-shell"], env=env, ) binary = get_playwright_binary() if not binary: - raise RuntimeError("Playwright Chromium binary not found after installation") + raise RuntimeError("Patchright Chromium binary not found after installation") return binary diff --git a/plugins/_browser/helpers/runtime.py b/plugins/_browser/helpers/runtime.py index f5ac52866..0911eb441 100644 --- a/plugins/_browser/helpers/runtime.py +++ b/plugins/_browser/helpers/runtime.py @@ -4,6 +4,7 @@ import atexit import asyncio import base64 import contextlib +import contextvars import os import re import shutil @@ -15,19 +16,21 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from helpers import chat_media, files +from helpers import chat_media, files, kvp from helpers.defer import DeferredTask from helpers.errors import RepairableException from helpers.print_style import PrintStyle from plugins._browser.helpers.config import ( + DEFAULT_BROWSER_TAB_SCOPE, DEFAULT_HOMEPAGE_KEY, DEFAULT_MAX_OPEN_TABS, MAX_OPEN_TABS_KEY, + TAB_SCOPE_KEY, build_browser_launch_config, get_browser_config, ) -from plugins._browser.helpers.playwright import configure_playwright_env, ensure_playwright_binary +from plugins._browser.helpers.interactive_view import BrowserInteractiveView from plugins._browser.helpers.url import normalize_url @@ -35,6 +38,9 @@ PLUGIN_DIR = Path(__file__).resolve().parents[1] DOM_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-dom-helper.js" CONTENT_HELPER_PATH = PLUGIN_DIR / "assets" / "browser-page-content.js" RUNTIME_DATA_KEY = "_browser_runtime" +SHARED_RUNTIME_ID = "shared" +BROWSER_TABS_KEY = "browser_open_tabs" +BROWSER_TABS_VERSION = 1 DEFAULT_VIEWPORT = {"width": 1024, "height": 768} CHROME_SINGLETON_FILES = ("SingletonLock", "SingletonCookie", "SingletonSocket") SCREENCAST_MAX_WIDTH = 4096 @@ -285,10 +291,90 @@ def _safe_context_id(context_id: str) -> str: return _SAFE_CONTEXT_RE.sub("_", str(context_id or "default")).strip("._") or "default" +def _load_browser_tabs() -> tuple[bool, list[dict[str, Any]]]: + try: + payload = kvp.get_persistent(BROWSER_TABS_KEY, None) + except Exception as exc: + PrintStyle.warning(f"Browser tab recovery state could not be read: {exc}") + return False, [] + if payload is None: + return False, [] + if not isinstance(payload, dict) or not isinstance(payload.get("tabs"), list): + PrintStyle.warning("Browser tab recovery state is invalid; starting without it.") + return False, [] + + tabs: list[dict[str, Any]] = [] + for entry in payload["tabs"]: + if not isinstance(entry, dict): + continue + context_id = str(entry.get("context_id") or "").strip() + url = str(entry.get("url") or "").strip() + if not context_id or not url: + continue + tabs.append( + { + "context_id": context_id, + "url": url, + "active": bool(entry.get("active")), + } + ) + return True, tabs + + +def _save_browser_tabs(tabs: list[dict[str, Any]]) -> None: + kvp.set_persistent( + BROWSER_TABS_KEY, + {"version": BROWSER_TABS_VERSION, "tabs": tabs}, + ) + + +def _forget_browser_context(context_id: str) -> None: + exists, tabs = _load_browser_tabs() + if not exists: + return + remaining = [entry for entry in tabs if entry["context_id"] != context_id] + if len(remaining) != len(tabs): + try: + _save_browser_tabs(remaining) + except Exception as exc: + PrintStyle.warning(f"Browser tab recovery state could not be updated: {exc}") + + +def has_restorable_browser_tabs(context_id: str) -> bool: + exists, tabs = _load_browser_tabs() + if not exists: + for runtime_id in (SHARED_RUNTIME_ID, _safe_context_id(context_id)): + session_dir = Path( + files.get_abs_path( + "tmp", + "browser", + "sessions", + runtime_id, + "Default", + "Sessions", + ) + ) + try: + if any(session_dir.glob("Session_*")) or any(session_dir.glob("Tabs_*")): + return True + except OSError: + continue + return False + if not tabs: + return False + if str( + get_browser_config().get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE) + or DEFAULT_BROWSER_TAB_SCOPE + ) == "shared": + return True + return any(entry["context_id"] == str(context_id) for entry in tabs) + + @dataclass class BrowserPage: id: int page: Any + context_id: str = "" class _BrowserScreencast: @@ -540,12 +626,25 @@ class BrowserRuntime: self._closed = False async def call(self, method: str, *args: Any, **kwargs: Any) -> Any: + return await self.call_for(self.context_id, method, *args, **kwargs) + + async def call_for( + self, + context_id: str, + method: str, + *args: Any, + **kwargs: Any, + ) -> Any: if self._closed and method != "close": raise RuntimeError("Browser runtime is closed.") async def runner(): - fn = getattr(self._core, method) - return await fn(*args, **kwargs) + token = self._core.request_context_id.set(str(context_id or self.context_id)) + try: + fn = getattr(self._core, method) + return await fn(*args, **kwargs) + finally: + self._core.request_context_id.reset(token) return await self._worker.execute_inside(runner) @@ -556,7 +655,19 @@ class BrowserRuntime: await self.call("close", delete_profile=delete_profile) finally: self._closed = True - self._worker.kill(terminate_thread=True) + try: + self._worker.kill(terminate_thread=True) + finally: + self._core.interactive_view.close() + + +class BrowserRuntimeSession: + def __init__(self, context_id: str, runtime: BrowserRuntime): + self.context_id = str(context_id) + self._runtime = runtime + + async def call(self, method: str, *args: Any, **kwargs: Any) -> Any: + return await self._runtime.call_for(self.context_id, method, *args, **kwargs) class _BrowserRuntimeCore: @@ -578,19 +689,183 @@ class _BrowserRuntimeCore: def __init__(self, context_id: str): self.context_id = context_id self.safe_context_id = _safe_context_id(context_id) + self.request_context_id: contextvars.ContextVar[str] = contextvars.ContextVar( + f"browser_context_{id(self)}", + default=context_id, + ) self.playwright = None self.context = None self.pages: dict[int, BrowserPage] = {} self.screencasts: dict[str, _BrowserScreencast] = {} self.next_browser_id = 1 - self.last_interacted_browser_id: int | None = None + self._last_interacted_browser_ids: dict[str, int] = {} self._dom_helper_source: str | None = None self._content_helper_source: str | None = None self._start_lock: asyncio.Lock | None = None self._registry_lock: asyncio.Lock | None = None self._closing = False self._pending_popups: list[asyncio.Future[int]] = [] + self._pending_popup_contexts: dict[asyncio.Future[int], str] = {} self._background_popup_pages: set[int] = set() + self._bootstrap_page: Any | None = None + self._restore_state_exists = False + self._restore_state_loaded = False + self._restore_entries: list[dict[str, Any]] = [] + self._restored_context_ids: set[str] = set() + self._restored_all = False + self._restoring_tabs = False + self._browser_chrome_height: int | None = None + self._browser_window_page: Any | None = None + self._browser_window_session: Any | None = None + self._browser_window_id: int | None = None + self.interactive_view = BrowserInteractiveView(context_id) + + @property + def current_context_id(self) -> str: + return str(self.request_context_id.get() or self.context_id) + + @property + def last_interacted_browser_id(self) -> int | None: + return self._last_interacted_browser_ids.get(self.current_context_id) + + @last_interacted_browser_id.setter + def last_interacted_browser_id(self, browser_id: int | None) -> None: + self._set_last_interacted(self.current_context_id, browser_id) + + def _set_last_interacted(self, context_id: str, browser_id: int | None) -> None: + context_id = str(context_id or self.context_id) + if browser_id is None: + self._last_interacted_browser_ids.pop(context_id, None) + else: + self._last_interacted_browser_ids[context_id] = int(browser_id) + + def _load_restore_state(self) -> None: + self._restore_state_exists, self._restore_entries = _load_browser_tabs() + self._restore_state_loaded = True + self._restored_context_ids.clear() + self._restored_all = False + self._restoring_tabs = False + + def _tab_scope(self) -> str: + return str( + get_browser_config().get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE) + or DEFAULT_BROWSER_TAB_SCOPE + ) + + def _persist_browser_tabs(self) -> None: + if not self._restore_state_loaded or self._restoring_tabs: + return + + replaced_contexts = set(self._restored_context_ids) + live_tabs: list[dict[str, Any]] = [] + for browser_id in sorted(self.pages): + browser_page = self.pages[browser_id] + context_id = self._page_context_id(browser_page) + replaced_contexts.add(context_id) + try: + url = str(browser_page.page.url or "about:blank").strip() + except Exception: + continue + if not url: + url = "about:blank" + live_tabs.append( + { + "context_id": context_id, + "url": url, + "active": ( + self._last_interacted_browser_ids.get(context_id) == browser_id + ), + } + ) + + preserved_tabs = ( + [] + if self._restored_all + else [ + entry + for entry in self._restore_entries + if entry["context_id"] not in replaced_contexts + ] + ) + tabs = preserved_tabs + live_tabs + try: + _save_browser_tabs(tabs) + except Exception as exc: + PrintStyle.warning(f"Browser tab recovery state could not be saved: {exc}") + return + self._restore_state_exists = True + self._restore_entries = tabs + + async def _restore_tabs_for_scope(self) -> None: + if not self._restore_state_loaded or self._restoring_tabs or not self.context: + return + + tab_scope = self._tab_scope() + if tab_scope == "shared": + if self._restored_all: + return + entries = [ + entry + for entry in self._restore_entries + if entry["context_id"] not in self._restored_context_ids + ] + else: + context_id = self.current_context_id + if self._restored_all or context_id in self._restored_context_ids: + return + entries = [ + entry for entry in self._restore_entries if entry["context_id"] == context_id + ] + + restored_ids: dict[str, list[int]] = {} + active_ids: dict[str, int] = {} + navigations: list[tuple[Any, str]] = [] + self._restoring_tabs = True + try: + for entry in entries: + context_id = entry["context_id"] + if len(self._context_browser_ids(context_id)) >= self._max_open_tabs(): + continue + page = self._bootstrap_page + self._bootstrap_page = None + if not page or page.is_closed(): + page = await self.context.new_page() + browser_page = await self._register_page(page, context_id) + navigations.append((page, normalize_url(entry["url"]))) + restored_ids.setdefault(context_id, []).append(browser_page.id) + if entry["active"]: + active_ids[context_id] = browser_page.id + await asyncio.gather( + *( + self._goto(page, url, wait_until="commit") + for page, url in navigations + ) + ) + finally: + self._restoring_tabs = False + + for context_id, browser_ids in restored_ids.items(): + self._set_last_interacted( + context_id, + active_ids.get(context_id, browser_ids[0]), + ) + if tab_scope == "shared": + self._restored_all = True + self._restored_context_ids.update(entry["context_id"] for entry in entries) + else: + self._restored_context_ids.add(self.current_context_id) + self._persist_browser_tabs() + + def _page_context_id(self, browser_page: BrowserPage) -> str: + return str(browser_page.context_id or self.context_id) + + def _context_browser_ids(self, context_id: str | None = None) -> list[int]: + target = str(context_id or self.current_context_id) + return sorted( + browser_id + for browser_id, browser_page in self.pages.items() + if self._page_context_id(browser_page) == target + ) def _ensure_registry_lock(self) -> asyncio.Lock: if self._registry_lock is None: @@ -610,11 +885,12 @@ class _BrowserRuntimeCore: previous_focus: int | None, fallback_id: int, ) -> int | None: - if previous_focus in self.pages: + browser_ids = self._context_browser_ids() + if previous_focus in browser_ids: return int(previous_focus) - if fallback_id in self.pages: + if fallback_id in browser_ids: return int(fallback_id) - return next(iter(sorted(self.pages)), None) + return next(iter(browser_ids), None) def _normalize_modifiers(self, modifiers: list[str] | str | None) -> list[str] | None: if modifiers is None: @@ -733,6 +1009,7 @@ class _BrowserRuntimeCore: async def ensure_started(self) -> None: if self._context_is_alive(): + await self._restore_tabs_for_scope() return if self.context: await self._discard_stale_context("Browser context is stale; restarting.") @@ -742,12 +1019,14 @@ class _BrowserRuntimeCore: async with self._start_lock: if self._context_is_alive(): + await self._restore_tabs_for_scope() return if self.context: await self._discard_stale_context("Browser context is stale; restarting.") elif self.playwright and not self._closing: await self._stop_playwright("Browser context closed; restarting Playwright.") await self._start() + await self._restore_tabs_for_scope() def _context_is_alive(self) -> bool: if not self.context: @@ -772,9 +1051,15 @@ class _BrowserRuntimeCore: if not waiter.done(): waiter.set_exception(RuntimeError("Browser context closed.")) self._pending_popups.clear() + self._pending_popup_contexts.clear() self._background_popup_pages.clear() + self._bootstrap_page = None + self._browser_chrome_height = None + self._browser_window_page = None + self._browser_window_session = None + self._browser_window_id = None self.pages.clear() - self.last_interacted_browser_id = None + self._last_interacted_browser_ids.clear() for screencast in self.screencasts.values(): screencast.stopped = True screencast._drop_queued_frames() @@ -797,27 +1082,52 @@ class _BrowserRuntimeCore: self.playwright = None async def _start(self) -> None: - from playwright.async_api import async_playwright + from plugins._browser import hooks + self._load_restore_state() + preparation = hooks.prepare_playwright_cache() + if preparation.get("errors") or not preparation.get("binary"): + problem = preparation.get("errors") or "missing binary" + raise RuntimeError(f"Browser setup failed: {problem}") + from patchright.async_api import async_playwright + + self.profile_dir.parent.mkdir(parents=True, exist_ok=True) + 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._release_orphaned_profile_singleton() browser_config = get_browser_config() launch_config = build_browser_launch_config(browser_config) - configure_playwright_env() - browser_binary = ensure_playwright_binary() + browser_binary = Path(preparation["binary"]) + browser_display = self.interactive_view.ensure_display() + launch_args = list(launch_config["args"]) + if not self._restore_state_exists: + launch_args.append("--restore-last-session") + if browser_display: + launch_args.extend( + [ + "--window-position=0,0", + f"--window-size={self.interactive_view.width},{self.interactive_view.height}", + ] + ) self.playwright = await async_playwright().start() launch_kwargs: dict[str, Any] = { "user_data_dir": str(self.profile_dir), - "headless": True, + "headless": not bool(browser_display), "accept_downloads": True, "downloads_path": str(self.downloads_dir), - "viewport": DEFAULT_VIEWPORT, - "screen": DEFAULT_VIEWPORT, - "no_viewport": False, - "args": launch_config["args"], + "args": launch_args, } + if browser_display: + launch_kwargs["env"] = {**os.environ, "DISPLAY": browser_display} + launch_kwargs["no_viewport"] = True + else: + launch_kwargs.update( + viewport=DEFAULT_VIEWPORT, + screen=DEFAULT_VIEWPORT, + no_viewport=False, + ) if launch_config["channel"]: launch_kwargs["channel"] = launch_config["channel"] else: @@ -840,11 +1150,24 @@ class _BrowserRuntimeCore: self.context.set_default_navigation_timeout(30000) self.context.on("close", self._on_context_closed) self.context.on("page", self._on_new_page_sync) - await self.context.add_init_script(path=str(DOM_HELPER_PATH)) - await self.context.add_init_script(path=str(CONTENT_HELPER_PATH)) - for page in list(self.context.pages): + existing_pages = list(self.context.pages) + if self._restore_state_exists: + for page in existing_pages: + if self._bootstrap_page is None: + self._bootstrap_page = page + await self._fit_browser_window(page) + continue + with contextlib.suppress(Exception): + await page.close() + return + + for page in existing_pages: if page.url == "about:blank": + if browser_display and self._bootstrap_page is None: + self._bootstrap_page = page + await self._fit_browser_window(page) + continue try: await page.close() except Exception: @@ -852,6 +1175,22 @@ class _BrowserRuntimeCore: continue await self._register_page(page) + def _adopt_legacy_profile(self, context_id: str) -> None: + if self.safe_context_id != SHARED_RUNTIME_ID or self.profile_dir.exists(): + return + legacy_profile = Path( + files.get_abs_path("tmp/browser/sessions", _safe_context_id(context_id)) + ) + if legacy_profile == self.profile_dir or not legacy_profile.is_dir(): + return + try: + legacy_profile.rename(self.profile_dir) + PrintStyle.info( + f"Browser adopted the existing profile for context {context_id}." + ) + except OSError as exc: + PrintStyle.warning(f"Browser profile migration failed: {exc}") + def _release_orphaned_profile_singleton(self) -> None: lock_path = self.profile_dir / "SingletonLock" owner_pid = self._profile_singleton_owner_pid(lock_path) @@ -915,14 +1254,19 @@ class _BrowserRuntimeCore: async def open(self, url: str = "") -> dict[str, Any]: await self.ensure_started() self._ensure_can_open_page() - page = await self.context.new_page() - browser_page = await self._register_page(page) + context_id = self.current_context_id + page = self._bootstrap_page + self._bootstrap_page = None + if not page or page.is_closed(): + page = await self.context.new_page() + browser_page = await self._register_page(page, context_id) self.last_interacted_browser_id = browser_page.id target_url = self._initial_url(url) if target_url and target_url != "about:blank": await self._goto(page, normalize_url(target_url)) else: await self._settle(page) + self._persist_browser_tabs() return {"id": browser_page.id, "state": await self._state(browser_page.id)} def _initial_url(self, url: str = "") -> str: @@ -938,20 +1282,21 @@ class _BrowserRuntimeCore: value = DEFAULT_MAX_OPEN_TABS return max(1, value) - def _tab_limit_error(self) -> RepairableException: + def _tab_limit_error(self, context_id: str | None = None) -> RepairableException: max_open_tabs = self._max_open_tabs() + open_tabs = len(self._context_browser_ids(context_id)) return RepairableException( - f"Browser tab limit reached ({len(self.pages)}/{max_open_tabs}). " + f"Browser tab limit reached ({open_tabs}/{max_open_tabs}). " "Navigate an existing browser_id or close tabs with close/close_all before opening more." ) def _ensure_can_open_page(self) -> None: - if len(self.pages) >= self._max_open_tabs(): + if len(self._context_browser_ids()) >= self._max_open_tabs(): raise self._tab_limit_error() async def list(self, include_content: bool = False) -> dict[str, Any]: await self.ensure_started() - ids = sorted(self.pages) + ids = self._context_browser_ids() if not ids: return { "browsers": [], @@ -980,6 +1325,16 @@ class _BrowserRuntimeCore: "last_interacted_browser_id": self.last_interacted_browser_id, } + async def list_all(self) -> dict[str, Any]: + await self.ensure_started() + browser_ids = sorted(self.pages) + return { + "browsers": await asyncio.gather( + *(self._state(browser_id) for browser_id in browser_ids) + ), + "last_interacted_browser_ids": dict(self._last_interacted_browser_ids), + } + async def multi(self, calls: list[dict[str, Any]]) -> list[dict[str, Any]]: if not isinstance(calls, list) or not calls: raise ValueError("multi requires a non-empty list of calls") @@ -1220,8 +1575,13 @@ class _BrowserRuntimeCore: async def set_active(self, browser_id: int | str | None) -> dict[str, Any]: await self.ensure_started() resolved_id = self._resolve_browser_id(browser_id) + page = self._page(resolved_id) # Explicit focus change — bypass _maybe_promote. self.last_interacted_browser_id = int(resolved_id) + with contextlib.suppress(Exception): + await page.bring_to_front() + await self._fit_browser_window(page) + self._persist_browser_tabs() return await self._state(resolved_id) async def state(self, browser_id: int | str | None = None) -> dict[str, Any]: @@ -1240,6 +1600,7 @@ class _BrowserRuntimeCore: page = self._page(resolved_id) await self._goto(page, normalize_url(url), wait_until=wait_until) self._maybe_promote(resolved_id) + self._persist_browser_tabs() return await self._state(resolved_id) async def back( @@ -1254,6 +1615,7 @@ class _BrowserRuntimeCore: await page.go_back(wait_until=wait_until, timeout=10000) await self._settle(page, short=wait_until == "commit") self._maybe_promote(resolved_id) + self._persist_browser_tabs() return await self._state(resolved_id) async def forward( @@ -1268,6 +1630,7 @@ class _BrowserRuntimeCore: await page.go_forward(wait_until=wait_until, timeout=10000) await self._settle(page, short=wait_until == "commit") self._maybe_promote(resolved_id) + self._persist_browser_tabs() return await self._state(resolved_id) async def reload( @@ -1282,6 +1645,7 @@ class _BrowserRuntimeCore: await page.reload(wait_until=wait_until, timeout=15000) await self._settle(page, short=wait_until == "commit") self._maybe_promote(resolved_id) + self._persist_browser_tabs() return await self._state(resolved_id) async def content( @@ -1296,6 +1660,7 @@ class _BrowserRuntimeCore: result = await page.evaluate( "(payload) => globalThis.__spaceBrowserPageContent__.capture(payload || null)", payload or None, + isolated_context=True, ) self._maybe_promote(resolved_id) return result or {} @@ -1308,6 +1673,7 @@ class _BrowserRuntimeCore: result = await page.evaluate( "(ref) => globalThis.__spaceBrowserPageContent__.detail(ref)", reference_id, + isolated_context=True, ) self._maybe_promote(resolved_id) return result or {} @@ -1324,6 +1690,7 @@ class _BrowserRuntimeCore: result = await page.evaluate( "(payload) => globalThis.__spaceBrowserPageContent__.annotate(payload || null)", payload or None, + isolated_context=True, ) self._maybe_promote(resolved_id) return result or {} @@ -1332,7 +1699,7 @@ class _BrowserRuntimeCore: await self.ensure_started() resolved_id = self._resolve_browser_id(browser_id) page = self._page(resolved_id) - result = await page.evaluate(str(script or "undefined")) + result = await page.evaluate(str(script or "undefined"), isolated_context=False) self._maybe_promote(resolved_id) return {"result": result, "state": await self._state(resolved_id)} @@ -1364,6 +1731,7 @@ class _BrowserRuntimeCore: box = await page.evaluate( "(ref) => globalThis.__spaceBrowserPageContent__.boundingBoxFor(ref)", reference_id, + isolated_context=True, ) background = focus_popup is False or ( @@ -1373,6 +1741,7 @@ class _BrowserRuntimeCore: loop = asyncio.get_running_loop() waiter: asyncio.Future[int] = loop.create_future() self._pending_popups.append(waiter) + self._pending_popup_contexts[waiter] = self.current_context_id warning: str | None = None opened_id: int | None = None @@ -1416,6 +1785,7 @@ class _BrowserRuntimeCore: finally: if waiter in self._pending_popups: self._pending_popups.remove(waiter) + self._pending_popup_contexts.pop(waiter, None) if not waiter.done(): waiter.cancel() @@ -1430,6 +1800,7 @@ class _BrowserRuntimeCore: finally: if waiter in self._pending_popups: self._pending_popups.remove(waiter) + self._pending_popup_contexts.pop(waiter, None) if background: # Background-mode click: preserve the pre-click focus even when @@ -1515,6 +1886,7 @@ class _BrowserRuntimeCore: "action": normalized_action, "text": str(text or ""), }, + isolated_context=False, ) or {} except Exception as exc: clipboard_result = { @@ -1556,20 +1928,26 @@ class _BrowserRuntimeCore: await page.close() self.pages.pop(resolved_id, None) if self.last_interacted_browser_id == resolved_id: - self.last_interacted_browser_id = next(iter(sorted(self.pages)), None) + self.last_interacted_browser_id = next(iter(self._context_browser_ids()), None) + self._persist_browser_tabs() return await self.list() async def close_all_browsers(self) -> dict[str, Any]: await self.ensure_started() - await self._stop_all_screencasts() - for browser_id in list(self.pages): + await self.close_context() + return {"browsers": [], "last_interacted_browser_id": None} + + async def close_context(self) -> None: + for browser_id in self._context_browser_ids(): + await self._stop_screencasts_for_browser(browser_id) try: await self.pages[browser_id].page.close() except Exception: pass - self.pages.clear() + self.pages.pop(browser_id, None) self.last_interacted_browser_id = None - return {"browsers": [], "last_interacted_browser_id": None} + self._restored_context_ids.add(self.current_context_id) + self._persist_browser_tabs() async def screenshot( self, @@ -1599,6 +1977,7 @@ class _BrowserRuntimeCore: await self.ensure_started() resolved_id = self._resolve_browser_id(browser_id) page = self._page(resolved_id) + page_context_id = self._page_context_id(self.pages[resolved_id]) raw_path = str(path or "").strip() if not raw_path: image = await page.screenshot( @@ -1607,7 +1986,7 @@ class _BrowserRuntimeCore: full_page=bool(full_page), ) saved = chat_media.save_image_bytes( - context_id=self.context_id, + context_id=page_context_id, payload=image, mime_type="image/jpeg", category="screenshots", @@ -1616,7 +1995,7 @@ class _BrowserRuntimeCore: ) return { "browser_id": resolved_id, - "context_id": self.context_id, + "context_id": page_context_id, "path": saved.path, "a0_path": saved.a0_path, "mime": "image/jpeg", @@ -1645,7 +2024,7 @@ class _BrowserRuntimeCore: local_path = str(output_path) return { "browser_id": resolved_id, - "context_id": self.context_id, + "context_id": page_context_id, "path": local_path, "a0_path": files.normalize_a0_path(local_path), "mime": mime, @@ -1682,7 +2061,7 @@ class _BrowserRuntimeCore: await screencast.start( quality=quality, every_nth_frame=every_nth_frame, - viewport=page.viewport_size or DEFAULT_VIEWPORT, + viewport=await self._page_viewport(page), capture_scale=capture_scale, ) except Exception: @@ -1696,6 +2075,61 @@ class _BrowserRuntimeCore: "state": await self._state(resolved_id), } + @staticmethod + async def _page_viewport(page: Any) -> dict[str, int]: + viewport = getattr(page, "viewport_size", None) + if viewport: + return { + "width": int(viewport.get("width") or DEFAULT_VIEWPORT["width"]), + "height": int(viewport.get("height") or DEFAULT_VIEWPORT["height"]), + } + try: + measured = await page.evaluate( + "() => ({ width: globalThis.innerWidth, height: globalThis.innerHeight })", + isolated_context=False, + ) + return { + "width": int(measured.get("width") or DEFAULT_VIEWPORT["width"]), + "height": int(measured.get("height") or DEFAULT_VIEWPORT["height"]), + } + except Exception: + return dict(DEFAULT_VIEWPORT) + + async def interactive_viewer( + self, + browser_id: int | str | None = None, + *, + width: int = 0, + height: int = 0, + ) -> dict[str, Any]: + await self.ensure_started() + resolved_id = self._resolve_browser_id(browser_id) + page = self._page(resolved_id) + current_viewport = await self._page_viewport(page) + viewer = self.interactive_view.ensure_viewer( + width or int(current_viewport.get("width") or DEFAULT_VIEWPORT["width"]), + height or int(current_viewport.get("height") or DEFAULT_VIEWPORT["height"]), + ) + if not viewer.get("available"): + return viewer + + await self._stop_screencasts_for_browser(resolved_id) + with contextlib.suppress(Exception): + await page.bring_to_front() + viewport_result = await self.set_viewport( + resolved_id, + int(viewer.get("width") or width or DEFAULT_VIEWPORT["width"]), + int(viewer.get("height") or height or DEFAULT_VIEWPORT["height"]), + resize_interactive=True, + ) + self.last_interacted_browser_id = int(resolved_id) + return { + **viewer, + "browser_id": resolved_id, + "state": viewport_result["state"], + "viewport": viewport_result["viewport"], + } + async def read_screencast_frame( self, stream_id: str, @@ -1735,15 +2169,30 @@ class _BrowserRuntimeCore: width: int, height: int, restart_screencast: bool = False, + resize_interactive: bool = False, + include_state: bool = True, ) -> dict[str, Any]: await self.ensure_started() resolved_id = self._resolve_browser_id(browser_id) page = self._page(resolved_id) + if resize_interactive: + resized = self.interactive_view.resize(width, height) + viewport = { + "width": int(resized.get("width") or self.interactive_view.width), + "height": int(resized.get("height") or self.interactive_view.height), + } + await self._fit_browser_window(page) + self._maybe_promote(resolved_id) + return { + "state": await self._state(resolved_id) if include_state else None, + "viewport": viewport, + } + viewport = { "width": max(320, min(4096, int(width or DEFAULT_VIEWPORT["width"]))), "height": max(200, min(4096, int(height or DEFAULT_VIEWPORT["height"]))), } - current_viewport = page.viewport_size or {} + current_viewport = await self._page_viewport(page) changed = ( abs(int(current_viewport.get("width") or 0) - viewport["width"]) > VIEWPORT_SIZE_TOLERANCE @@ -1779,6 +2228,7 @@ class _BrowserRuntimeCore: "useOffsets": bool(offset_x or offset_y), }, }, + isolated_context=True, ) if not point or not isinstance(point, dict): raise ValueError(f"Could not resolve Browser ref {reference_id!r} to a viewport point") @@ -1995,6 +2445,7 @@ class _BrowserRuntimeCore: "ref": ref, "values": values if values is not None else value, }, + isolated_context=True, ) await self._settle(page, short=True) self._maybe_promote(resolved_id) @@ -2016,6 +2467,7 @@ class _BrowserRuntimeCore: "ref": ref, "checked": bool(checked), }, + isolated_context=True, ) await self._settle(page, short=True) self._maybe_promote(resolved_id) @@ -2036,12 +2488,14 @@ class _BrowserRuntimeCore: metadata = await page.evaluate( "(ref) => globalThis.__spaceBrowserPageContent__.fileInputFor(ref)", ref, + isolated_context=True, ) handle = None try: handle = await page.evaluate_handle( "(ref) => globalThis.__spaceBrowserPageContent__.fileInputElementFor(ref)", ref, + isolated_context=True, ) element = handle.as_element() if handle else None if element: @@ -2149,13 +2603,22 @@ class _BrowserRuntimeCore: return True async def close(self, delete_profile: bool = False) -> None: + if delete_profile: + with contextlib.suppress(Exception): + kvp.remove_persistent(BROWSER_TABS_KEY) + self._restore_entries.clear() + self._restore_state_exists = False + else: + self._persist_browser_tabs() self._closing = True for waiter in self._pending_popups: if not waiter.done(): waiter.set_exception(RuntimeError("Browser runtime is closing.")) self._pending_popups.clear() + self._pending_popup_contexts.clear() self._background_popup_pages.clear() await self._stop_all_screencasts() + await self._reset_browser_window_session() for browser_id in list(self.pages): try: await self.pages[browser_id].page.close() @@ -2174,7 +2637,7 @@ class _BrowserRuntimeCore: except Exception as exc: PrintStyle.warning(f"Playwright stop failed: {exc}") self.playwright = None - self.last_interacted_browser_id = None + self._last_interacted_browser_ids.clear() if delete_profile: shutil.rmtree(self.profile_dir, ignore_errors=True) @@ -2198,11 +2661,13 @@ class _BrowserRuntimeCore: action = await page.evaluate( "(args) => globalThis.__spaceBrowserPageContent__[args.method](args.ref)", {"method": helper_method, "ref": reference_id}, + isolated_context=True, ) else: action = await page.evaluate( "(args) => globalThis.__spaceBrowserPageContent__[args.method](args.ref, args.text)", {"method": helper_method, "ref": reference_id, "text": text}, + isolated_context=True, ) await self._settle(page, short=False) self._maybe_promote(resolved_id) @@ -2215,8 +2680,8 @@ class _BrowserRuntimeCore: *, wait_until: str = "domcontentloaded", ) -> None: - from playwright.async_api import Error as PlaywrightError - from playwright.async_api import TimeoutError as PlaywrightTimeoutError + from patchright.async_api import Error as PlaywrightError + from patchright.async_api import TimeoutError as PlaywrightTimeoutError try: await page.goto(url, wait_until=wait_until, timeout=30000) @@ -2227,8 +2692,8 @@ class _BrowserRuntimeCore: await self._settle(page, short=wait_until == "commit") async def _settle(self, page: Any, short: bool = False) -> None: - from playwright.async_api import Error as PlaywrightError - from playwright.async_api import TimeoutError as PlaywrightTimeoutError + from patchright.async_api import Error as PlaywrightError + from patchright.async_api import TimeoutError as PlaywrightTimeoutError try: await page.wait_for_load_state( @@ -2249,12 +2714,15 @@ class _BrowserRuntimeCore: except Exception: title = "" try: - history_length = await page.evaluate("() => globalThis.history?.length || 0") + history_length = await page.evaluate( + "() => globalThis.history?.length || 0", + isolated_context=False, + ) except Exception: history_length = 0 return { "id": browser_page.id, - "context_id": self.context_id, + "context_id": self._page_context_id(browser_page), "currentUrl": page.url, "title": title, "canGoBack": bool(history_length and int(history_length) > 1), @@ -2262,13 +2730,31 @@ class _BrowserRuntimeCore: "loading": False, } - def _register_page_locked(self, page: Any) -> BrowserPage: + def _register_page_locked( + self, + page: Any, + context_id: str | None = None, + ) -> BrowserPage: + requested_context_id = str(context_id or self.current_context_id) existing = self._browser_id_for_page(page) if existing is not None: - return self.pages[existing] + browser_page = self.pages[existing] + if ( + context_id is not None + and self._page_context_id(browser_page) != requested_context_id + ): + previous_context_id = self._page_context_id(browser_page) + browser_page.context_id = requested_context_id + if self._last_interacted_browser_ids.get(previous_context_id) == existing: + self._set_last_interacted(previous_context_id, None) + return browser_page browser_id = self.next_browser_id self.next_browser_id += 1 - browser_page = BrowserPage(id=browser_id, page=page) + browser_page = BrowserPage( + id=browser_id, + page=page, + context_id=requested_context_id, + ) self.pages[browser_id] = browser_page def on_close() -> None: @@ -2279,24 +2765,111 @@ class _BrowserRuntimeCore: self.pages.pop(browser_id, None) page.on("close", on_close) + + def on_navigated(frame: Any) -> None: + main_frame = getattr(page, "main_frame", None) + if main_frame is not None and frame is not main_frame: + return + try: + asyncio.create_task(self._persist_page_change_async(browser_id)) + except RuntimeError: + return + + page.on("framenavigated", on_navigated) return browser_page - async def _register_page(self, page: Any) -> BrowserPage: + async def _register_page( + self, + page: Any, + context_id: str | None = None, + ) -> BrowserPage: lock = self._ensure_registry_lock() async with lock: - return self._register_page_locked(page) + browser_page = self._register_page_locked(page, context_id) + await self._fit_browser_window(page) + return browser_page + + async def _fit_browser_window(self, page: Any) -> None: + if getattr(self.interactive_view, "display", None) is None or not self.context: + return + try: + if self._browser_window_session is None or self._browser_window_page is not page: + await self._reset_browser_window_session() + self._browser_window_page = page + self._browser_window_session = await self.context.new_cdp_session(page) + target = await self._browser_window_session.send("Browser.getWindowForTarget") + self._browser_window_id = target.get("windowId") + if self._browser_window_id is None: + await self._reset_browser_window_session() + return + current = await self._browser_window_session.send( + "Browser.getWindowBounds", + {"windowId": self._browser_window_id}, + ) + if current.get("bounds", {}).get("windowState") != "normal": + await self._browser_window_session.send( + "Browser.setWindowBounds", + { + "windowId": self._browser_window_id, + "bounds": {"windowState": "normal"}, + }, + ) + if self._browser_chrome_height is None: + chrome_height = await page.evaluate( + "() => Math.max(0, globalThis.outerHeight - globalThis.innerHeight)", + isolated_context=False, + ) + self._browser_chrome_height = max(0, min(256, int(chrome_height or 0))) + chrome_height = self._browser_chrome_height + await self._browser_window_session.send( + "Browser.setWindowBounds", + { + "windowId": self._browser_window_id, + "bounds": { + "windowState": "normal", + "left": 0, + "top": -chrome_height, + "width": self.interactive_view.width, + "height": self.interactive_view.height + chrome_height, + }, + }, + ) + except Exception as exc: + await self._reset_browser_window_session() + PrintStyle.warning(f"Interactive Browser window fit failed: {exc}") + + async def _reset_browser_window_session(self) -> None: + session = self._browser_window_session + self._browser_window_page = None + self._browser_window_session = None + self._browser_window_id = None + if session: + with contextlib.suppress(Exception): + await session.detach() async def _unregister_page_async(self, browser_id: int) -> None: try: lock = self._ensure_registry_lock() async with lock: - self.pages.pop(browser_id, None) - if self.last_interacted_browser_id == browser_id: - self.last_interacted_browser_id = next(iter(sorted(self.pages)), None) + browser_page = self.pages.pop(browser_id, None) + if browser_page: + context_id = self._page_context_id(browser_page) + if self._last_interacted_browser_ids.get(context_id) == browser_id: + remaining = self._context_browser_ids(context_id) + self._set_last_interacted( + context_id, + next(iter(remaining), None), + ) self._background_popup_pages.discard(browser_id) except Exception as exc: PrintStyle.warning(f"Page unregister failed: {exc}") + async def _persist_page_change_async(self, browser_id: int) -> None: + await asyncio.sleep(0) + if self._closing or self._restoring_tabs or browser_id not in self.pages: + return + self._persist_browser_tabs() + def _on_new_page_sync(self, page: Any) -> None: if self._closing or self.context is None: return @@ -2313,37 +2886,64 @@ class _BrowserRuntimeCore: return lock = self._ensure_registry_lock() close_over_limit = False + context_id = await self._new_page_context_id(page) async with lock: if self._closing: return if self._browser_id_for_page(page) is not None: return - if len(self.pages) >= self._max_open_tabs(): - limit_error = self._tab_limit_error() - while self._pending_popups: - waiter = self._pending_popups.pop(0) - if not waiter.done(): - waiter.set_exception(limit_error) - break + if len(self._context_browser_ids(context_id)) >= self._max_open_tabs(): + limit_error = self._tab_limit_error(context_id) + waiter = self._pop_pending_popup(context_id) + if waiter: + waiter.set_exception(limit_error) close_over_limit = True else: - browser_page = self._register_page_locked(page) + browser_page = self._register_page_locked(page, context_id) new_id = browser_page.id - while self._pending_popups: - waiter = self._pending_popups.pop(0) - if not waiter.done(): - waiter.set_result(new_id) - break + waiter = self._pop_pending_popup(context_id) + if waiter: + waiter.set_result(new_id) if new_id not in self._background_popup_pages: - self.last_interacted_browser_id = new_id + self._set_last_interacted(context_id, new_id) else: self._background_popup_pages.discard(new_id) if close_over_limit: with contextlib.suppress(Exception): await page.close() + else: + await self._fit_browser_window(page) + self._persist_browser_tabs() except Exception as exc: PrintStyle.warning(f"Popup registration failed: {exc}") + async def _new_page_context_id(self, page: Any) -> str: + opener_fn = getattr(page, "opener", None) + if callable(opener_fn): + with contextlib.suppress(Exception): + opener = await opener_fn() + opener_id = self._browser_id_for_page(opener) + if opener_id is not None: + return self._page_context_id(self.pages[opener_id]) + for waiter in self._pending_popups: + context_id = self._pending_popup_contexts.get(waiter) + if context_id and not waiter.done(): + return context_id + return self.current_context_id + + def _pop_pending_popup(self, context_id: str) -> asyncio.Future[int] | None: + for waiter in list(self._pending_popups): + if waiter.done(): + self._pending_popups.remove(waiter) + self._pending_popup_contexts.pop(waiter, None) + continue + if self._pending_popup_contexts.get(waiter) != context_id: + continue + self._pending_popups.remove(waiter) + self._pending_popup_contexts.pop(waiter, None) + return waiter + return None + def _browser_id_for_page(self, page: Any) -> int | None: for browser_id, browser_page in self.pages.items(): if browser_page.page == page: @@ -2351,17 +2951,18 @@ class _BrowserRuntimeCore: return None def _resolve_browser_id(self, browser_id: int | str | None = None) -> int: + browser_ids = self._context_browser_ids() if browser_id is None or str(browser_id).strip() == "": - if self.last_interacted_browser_id in self.pages: + if self.last_interacted_browser_id in browser_ids: return int(self.last_interacted_browser_id) - if self.pages: - return sorted(self.pages)[0] + if browser_ids: + return browser_ids[0] raise KeyError("No browser is open. Use action=open first.") value = str(browser_id).strip() if value.startswith("browser-"): value = value.split("-", 1)[1] resolved = int(value) - if resolved not in self.pages: + if resolved not in browser_ids: raise KeyError(f"Browser {resolved} is not open.") return resolved @@ -2384,13 +2985,14 @@ class _BrowserRuntimeCore: async def _ensure_content_helper(self, page: Any) -> None: await self._ensure_dom_helper(page) has_helper = await page.evaluate( - "() => Boolean(globalThis.__spaceBrowserPageContent__?.ready?.())" + "() => Boolean(globalThis.__spaceBrowserPageContent__?.ready?.())", + isolated_context=True, ) if has_helper: return if self._content_helper_source is None: self._content_helper_source = CONTENT_HELPER_PATH.read_text(encoding="utf-8") - await page.evaluate(self._content_helper_source) + await page.evaluate(self._content_helper_source, isolated_context=True) async def _ensure_dom_helper(self, page: Any) -> None: if self._dom_helper_source is None: @@ -2408,26 +3010,38 @@ class _BrowserRuntimeCore: targets = frames for target in targets: try: - has_helper = await target.evaluate(ready_script) + has_helper = await target.evaluate(ready_script, isolated_context=True) except Exception: continue if has_helper: continue with contextlib.suppress(Exception): - await target.evaluate(source) + await target.evaluate(source, isolated_context=True) -_runtimes: dict[str, BrowserRuntime] = {} +_runtimes: dict[str, BrowserRuntimeSession] = {} +_shared_runtime: BrowserRuntime | None = None _runtime_lock = threading.RLock() -async def get_runtime(context_id: str, *, create: bool = True) -> BrowserRuntime | None: +async def get_runtime( + context_id: str, + *, + create: bool = True, +) -> BrowserRuntimeSession | None: + global _shared_runtime context_id = str(context_id or "").strip() if not context_id: raise ValueError("context_id is required") with _runtime_lock: runtime = _runtimes.get(context_id) - if runtime is None and create: - runtime = BrowserRuntime(context_id) + if runtime is None and _shared_runtime is not None: + runtime = BrowserRuntimeSession(context_id, _shared_runtime) + if create: + _runtimes[context_id] = runtime + elif runtime is None and create: + if _shared_runtime is None: + _shared_runtime = BrowserRuntime(SHARED_RUNTIME_ID) + runtime = BrowserRuntimeSession(context_id, _shared_runtime) _runtimes[context_id] = runtime return runtime @@ -2436,10 +3050,14 @@ async def close_runtime(context_id: str, *, delete_profile: bool = True) -> None context_id = str(context_id or "").strip() if not context_id: return + _forget_browser_context(context_id) with _runtime_lock: runtime = _runtimes.pop(context_id, None) + shared_runtime = _shared_runtime if runtime: - await runtime.close(delete_profile=delete_profile) + await runtime.call("close_context") + elif shared_runtime: + await shared_runtime.call_for(context_id, "close_context") def close_runtime_sync(context_id: str, *, delete_profile: bool = True) -> None: @@ -2452,10 +3070,15 @@ def close_runtime_sync(context_id: str, *, delete_profile: bool = True) -> None: async def close_all_runtimes(*, delete_profiles: bool = False) -> None: + global _shared_runtime with _runtime_lock: - runtimes = list(_runtimes.values()) _runtimes.clear() - for runtime in runtimes: + runtime = _shared_runtime + _shared_runtime = None + if delete_profiles: + with contextlib.suppress(Exception): + kvp.remove_persistent(BROWSER_TABS_KEY) + if runtime: try: await runtime.close(delete_profile=delete_profiles) except Exception as exc: @@ -2479,6 +3102,31 @@ def known_context_ids() -> list[str]: async def list_runtime_sessions() -> list[dict[str, Any]]: with _runtime_lock: runtimes = list(_runtimes.items()) + shared_runtime = _shared_runtime + + if shared_runtime and str( + get_browser_config().get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE) + or DEFAULT_BROWSER_TAB_SCOPE + ) == "shared": + request_context_id = runtimes[0][0] if runtimes else SHARED_RUNTIME_ID + try: + listing = await shared_runtime.call_for(request_context_id, "list_all") + except Exception as exc: + PrintStyle.warning(f"Shared Browser runtime list failed: {exc}") + return [] + grouped: dict[str, list[dict[str, Any]]] = {} + for browser in listing.get("browsers") or []: + context_id = str(browser.get("context_id") or SHARED_RUNTIME_ID) + grouped.setdefault(context_id, []).append(browser) + active_ids = listing.get("last_interacted_browser_ids") or {} + return [ + { + "context_id": context_id, + "browsers": browsers, + "last_interacted_browser_id": active_ids.get(context_id), + } + for context_id, browsers in grouped.items() + ] sessions: list[dict[str, Any]] = [] for context_id, runtime in runtimes: diff --git a/plugins/_browser/hooks.py b/plugins/_browser/hooks.py index 257917f2b..8ccd83981 100644 --- a/plugins/_browser/hooks.py +++ b/plugins/_browser/hooks.py @@ -1,6 +1,12 @@ from __future__ import annotations +import importlib +import importlib.metadata +import importlib.util import shutil +import subprocess +import sys +import threading from pathlib import Path from helpers import files, plugins, yaml as yaml_helper @@ -10,6 +16,7 @@ from plugins._browser.helpers.config import ( normalize_browser_config, ) from plugins._browser.helpers.playwright import ( + ensure_playwright_binary, find_playwright_binary, get_playwright_cache_dir, get_retired_playwright_cache_dirs, @@ -17,6 +24,11 @@ from plugins._browser.helpers.playwright import ( from plugins._browser.helpers.runtime import close_all_runtimes_sync +_SETUP_LOCK = threading.Lock() +_PLUGIN_DIR = Path(__file__).resolve().parent +_ROOT_REQUIREMENTS_FILE = _PLUGIN_DIR.parents[1] / "requirements.txt" + + def _load_saved_browser_config(project_name: str = "", agent_profile: str = "") -> dict: entries = plugins.find_plugin_assets( plugins.CONFIG_FILE_NAME, @@ -95,6 +107,59 @@ def cleanup_playwright_cache() -> dict: return result +def prepare_playwright_cache() -> dict: + with _SETUP_LOCK: + _ensure_patchright_dependency() + result = cleanup_playwright_cache() + if result["errors"]: + return result + result["binary"] = str(ensure_playwright_binary()) + return result + + +def install() -> dict: + return prepare_playwright_cache() + + +def _ensure_patchright_dependency() -> None: + requirement = _patchright_requirement() + if _patchright_is_current(requirement): + return + + uv = shutil.which("uv") + if not uv: + raise RuntimeError("Browser plugin requires 'uv' to install Patchright automatically") + + subprocess.check_call( + [uv, "pip", "install", "--python", sys.executable, requirement], + cwd=str(_PLUGIN_DIR), + ) + importlib.invalidate_caches() + if not _patchright_is_current(requirement): + raise RuntimeError( + f"Browser dependency {requirement!r} is unavailable after installation" + ) + + +def _patchright_requirement() -> str: + if _ROOT_REQUIREMENTS_FILE.is_file(): + for line in _ROOT_REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines(): + requirement = line.strip() + if requirement.startswith("patchright=="): + return requirement + raise RuntimeError(f"Browser Patchright requirement not found in {_ROOT_REQUIREMENTS_FILE}") + + +def _patchright_is_current(requirement: str) -> bool: + expected_version = requirement.partition("==")[2] + if not expected_version or importlib.util.find_spec("patchright") is None: + return False + try: + return importlib.metadata.version("patchright") == expected_version + except importlib.metadata.PackageNotFoundError: + return False + + def _best_playwright_cache(candidates: list[Path]) -> Path | None: valid = [path for path in candidates if path.is_dir() and find_playwright_binary(path)] if not valid: diff --git a/plugins/_browser/tools/browser.py b/plugins/_browser/tools/browser.py index 10220f028..94773a7a2 100644 --- a/plugins/_browser/tools/browser.py +++ b/plugins/_browser/tools/browser.py @@ -10,6 +10,7 @@ from typing import Any from helpers import files from helpers.print_style import PrintStyle from helpers.tool import Response, Tool +from plugins._browser.helpers.config import activate_browser_model from plugins._browser.helpers.selector import get_tool_runtime @@ -74,6 +75,10 @@ class Browser(Tool): action = "clipboard" else: action = str(action or self.method or "state").strip().lower().replace("-", "_") + try: + activate_browser_model(self.agent) + except Exception as exc: + PrintStyle.warning(f"Browser model preset could not be activated: {exc}") try: runtime = await get_runtime(self.agent.context.id, agent=self.agent) except Exception as exc: diff --git a/plugins/_browser/webui/browser-panel.html b/plugins/_browser/webui/browser-panel.html index 05d0f43b7..8270393ff 100644 --- a/plugins/_browser/webui/browser-panel.html +++ b/plugins/_browser/webui/browser-panel.html @@ -20,11 +20,15 @@
+
-
- -