From e175bea75206c10c3032080e12dd7c5f92f1d22f Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:06:40 +0100 Subject: [PATCH] feat(runtime): bind browser resources to authority --- .../wave-3-browser-final-results.json | 136 ++ .../wave-3-browser-authority.md | 282 ++++ .../wave-3-final-tests.txt | 149 ++ scripts/generate_env_reference.py | 15 + src/agent_loop.py | 5 +- src/agent_runtime/authority.py | 49 +- src/agent_runtime/process_resources.py | 3 + src/agent_runtime/resources.py | 145 +- src/agent_tools/web_tools.py | 1263 +------------- src/browser_identity.py | 649 ++++++++ src/clean_agent_preview.py | 50 +- src/constants.py | 1 + src/tool_approvals.py | 13 + src/tool_execution.py | 36 +- src/tool_index.py | 4 +- src/tool_schemas.py | 43 +- tests/test_browser_identity_transport.py | 158 ++ tests/test_browser_lifecycle.py | 353 ---- tests/test_browser_producer_live_contract.py | 109 ++ tests/test_browser_resource_identity.py | 291 ++++ ...test_browser_screenshot_artifact_safety.py | 3 +- tests/test_browser_transport_recovery.py | 2 +- tests/test_clean_agent_preview.py | 24 +- tests/test_execution_bridge.py | 6 +- tests/test_private_browser_tool.py | 1451 +---------------- tests/test_resource_identity.py | 6 +- website/configuration-reference.md | 27 +- 27 files changed, 2120 insertions(+), 3153 deletions(-) create mode 100644 docs/runtime-decomposition/validation/wave-3-browser-final-results.json create mode 100644 docs/runtime-decomposition/wave-3-browser-authority.md create mode 100644 docs/runtime-decomposition/wave-3-final-tests.txt create mode 100644 src/browser_identity.py create mode 100644 tests/test_browser_identity_transport.py create mode 100644 tests/test_browser_producer_live_contract.py create mode 100644 tests/test_browser_resource_identity.py diff --git a/docs/runtime-decomposition/validation/wave-3-browser-final-results.json b/docs/runtime-decomposition/validation/wave-3-browser-final-results.json new file mode 100644 index 000000000..f6095e4c5 --- /dev/null +++ b/docs/runtime-decomposition/validation/wave-3-browser-final-results.json @@ -0,0 +1,136 @@ +{ + "starting_sha": "bc5e1ee6922000a290371f8c2aa18802a03ffcad", + "starting_tree": "8e09cc2560f50a3472e06ec614d6ada028b7eb18", + "resource_focused": { + "passed": 1425 + }, + "integrated": { + "files": 149, + "passed": 3776, + "skipped": 7, + "xfailed": 2 + }, + "index_schema_config_focused": { + "passed": 40 + }, + "release_docker_live": { + "passed": 4, + "version": "0.35.0", + "architecture": "linux-x64", + "page_execution_enabled": false, + "pin_contract_proven": false + }, + "full": { + "passed": 12310, + "failed": 76, + "skipped": 65, + "xfailed": 2, + "subtests_passed": 6, + "seconds": 403.66 + }, + "failure_classification": { + "initial_failing_cases": 82, + "frozen_a_replay_failed": 79, + "frozen_a_replay_passed": 3, + "corrected_browser_regressions": [ + "tests/test_execution_bridge.py::test_registry_dispatch_preserves_session_id_for_native_handlers", + "tests/test_tool_index_schema_parity.py::test_every_schema_tool_has_an_index_description" + ], + "remaining_order_failure_reproduced_on_frozen_a": { + "command": "python -m pytest -q tests/test_scheduler_restart_doublefire.py tests/test_tool_approvals.py::test_dispatcher_rejects_approved_document_action_without_target", + "passed": 4, + "failed": 1 + }, + "all_final_failed_nodes_reproduced_on_frozen_a": true, + "final_failed_nodes": [ + "tests/test_agent_bash_tmux_env.py::test_direct_bash_subprocess_has_closed_stdin", + "tests/test_agent_bash_tmux_env.py::test_bash_rejects_unicode_ffmpeg_drawtext_without_explicit_font", + "tests/test_agent_bash_tmux_env.py::test_bash_allows_unicode_ffmpeg_drawtext_with_explicit_fontfile", + "tests/test_agent_bash_windows.py::test_windows_bash_tool_passes_ctx_env_through_to_the_child", + "tests/test_agent_bash_windows.py::test_bash_tool_returns_install_hint_when_git_bash_is_missing", + "tests/test_agent_bash_windows.py::test_windows_bash_does_not_use_a_stray_tmux_executable", + "tests/test_agent_external_tool_schemas.py::test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema", + "tests/test_client_tool_routing.py::test_no_bridge_falls_back_to_backend_execution", + "tests/test_client_tool_routing.py::test_host_shell_requires_bridge_context", + "tests/test_doc_library_open_orphaned.py::test_mobile_explicit_load_restores_full_editor_from_bottom_dock", + "tests/test_document_history_controls.py::test_mobile_rich_text_history_state_and_document_switch", + "tests/test_document_library_mobile_footer.py::test_mobile_open_in_new_chat_copies_to_materialized_session", + "tests/test_document_module_api.py::test_default_export_surface_is_complete_and_callable", + "tests/test_document_module_api.py::test_named_exports_survive_and_stay_callable", + "tests/test_document_module_api.py::test_window_bridge_is_the_default_export", + "tests/test_document_outline.py::test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile", + "tests/test_document_rich_checklist_enter.py::test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly", + "tests/test_document_rich_color_reset_and_contrast.py::test_rich_colors_follow_theme_and_undo_as_one_edit", + "tests/test_document_rich_docx_export.py::test_browser_word_export_contains_native_rich_docx_ooxml", + "tests/test_document_rich_docx_export.py::test_browser_markdown_word_export_keeps_heading_and_inline_formatting", + "tests/test_document_rich_find_boundaries.py::test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replacement", + "tests/test_document_rich_font_color_controls.py::test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile", + "tests/test_document_rich_heading_enter.py::test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable", + "tests/test_document_rich_heading_enter.py::test_heading_enter_preserves_shift_middle_and_empty_heading_semantics", + "tests/test_document_rich_image_caption.py::test_mobile_image_caption_survives_resize_history_and_empty_removal", + "tests/test_document_rich_input_rules.py::test_typing_markers_converts_blocks_and_preserves_following_text", + "tests/test_document_rich_keyboard_shortcuts.py::test_rich_document_shortcuts_work_at_desktop_and_mobile_widths", + "tests/test_document_rich_selection_toolbar.py::test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports", + "tests/test_document_rich_slash_menu.py::test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile", + "tests/test_document_rich_smart_link_paste.py::test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks", + "tests/test_document_rich_structure_tools.py::test_mobile_headings_page_break_history_and_persistence", + "tests/test_document_rich_table_cell_alignment.py::test_mobile_table_cell_alignment_tracks_state_and_native_history", + "tests/test_document_rich_table_header_preservation.py::test_mobile_structural_edits_preserve_header_modes_and_history", + "tests/test_document_rich_table_headers.py::test_mobile_header_row_and_column_toggle_independently_with_undo", + "tests/test_document_rich_table_merge_split.py::test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history", + "tests/test_document_rich_table_tab_history.py::test_mobile_table_tab_navigation_row_creation_and_history", + "tests/test_document_rich_toolbar_menus.py::test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens", + "tests/test_document_rich_toolbar_menus.py::test_mobile_toolbar_menu_preserves_selection_and_restores_focus", + "tests/test_document_rich_toolbar_menus.py::test_rich_toolbar_menus_track_live_formatting_values", + "tests/test_document_save_shortcut.py::test_ctrl_s_saves_rich_text_immediately_once_and_updates_status", + "tests/test_document_save_status.py::test_save_status_is_dirty_race_safe_and_reports_failures", + "tests/test_document_toolbar_order.py::test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile", + "tests/test_edit_file.py::test_edit_file_blocked_at_execution_for_non_admin", + "tests/test_email_library_module_graph_js.py::test_every_package_module_evaluates_on_its_own_in_a_browser", + "tests/test_email_library_module_graph_js.py::test_wrapper_and_entry_module_hand_out_the_same_functions", + "tests/test_escape_inner_layers.py::test_rich_escape_closes_toolbar_then_selection_badge", + "tests/test_escape_inner_layers.py::test_email_escape_closes_inner_states_without_closing_library", + "tests/test_failed_call_correction.py::test_corrected_ids_execute_after_repeated_ambiguous_title_failures[2]", + "tests/test_failed_call_correction.py::test_corrected_ids_execute_after_repeated_ambiguous_title_failures[3]", + "tests/test_history_resume_rendering_js.py::test_history_resume_rendering_browser_suite", + "tests/test_live_fallback_round_attribution.py::test_detached_resume_reconciles_canonical_terminal_failures", + "tests/test_live_fallback_round_attribution.py::test_detached_resume_surfaces_fallback_then_provider_alias_without_reload", + "tests/test_live_fallback_round_attribution.py::test_detached_resume_renders_preoutput_error_without_empty_reload", + "tests/test_manage_tasks_cron.py::test_cron_create_edit_resume_and_invalid_edit_rollback", + "tests/test_manage_tasks_cron.py::test_named_weekdays_create_and_edit_preserve_actual_clock", + "tests/test_manage_tasks_cron.py::test_time_only_edit_changes_cron_clock_not_calendar_fields[15 9 * * 1,3,5]", + "tests/test_manage_tasks_cron.py::test_time_only_edit_changes_cron_clock_not_calendar_fields[15 9 15 * *]", + "tests/test_manage_tasks_cron.py::test_time_only_edit_changes_cron_clock_not_calendar_fields[0,30 8-10 * * 2,4]", + "tests/test_manage_tasks_cron.py::test_invalid_cron_retime_rolls_back_all_edits", + "tests/test_preview_execution_evidence.py::test_failed_shell_retains_exit_status_and_both_streams_for_followup", + "tests/test_review_regressions.py::test_host_shell_uses_tui_bridge_context", + "tests/test_review_regressions.py::test_host_shell_forwards_detach_and_job_polling", + "tests/test_review_regressions.py::test_host_shell_rejects_non_local_bridge_url_before_http", + "tests/test_review_regressions.py::test_public_agent_policy_blocks_sensitive_tools", + "tests/test_review_regressions.py::test_disabled_qualified_email_tool_blocks_bare_alias", + "tests/test_review_regressions.py::test_tool_policy_qualified_email_block_covers_bare_alias", + "tests/test_review_regressions.py::test_bare_email_dispatch_rejects_non_object_json_args", + "tests/test_review_regressions.py::test_bare_email_dispatch_rejects_invalid_json_body", + "tests/test_review_regressions.py::test_write_file_inline_json_args", + "tests/test_review_regressions.py::test_plan_mode_blocks_mutating_email_aliases_without_mcp_inventory", + "tests/test_review_regressions.py::test_bare_email_dispatch_empty_content_calls_with_empty_args", + "tests/test_review_regressions.py::test_email_mcp_non_object_args_fail_before_dispatch", + "tests/test_review_regressions.py::test_email_mcp_dispatch_includes_hidden_owner", + "tests/test_review_regressions.py::test_bare_email_mcp_dispatch_includes_hidden_owner", + "tests/test_tool_approvals.py::test_dispatcher_rejects_approved_document_action_without_target", + "tests/test_turn_rendering_js.py::test_turn_rendering_browser_suite" + ] + }, + "static": { + "compileall": "passed", + "diff_check": "passed", + "conflict_markers": "none", + "unmerged_index": "none" + }, + "limitations": [ + "page/document reads and effects unconditionally unavailable", + "arm64 producer execution not live tested", + "18-case positive producer enabling gate remains blocked on atomic expected-identity operation support", + "full repository suite is not green; failures reproduced on frozen A" + ] +} diff --git a/docs/runtime-decomposition/wave-3-browser-authority.md b/docs/runtime-decomposition/wave-3-browser-authority.md new file mode 100644 index 000000000..abed7a760 --- /dev/null +++ b/docs/runtime-decomposition/wave-3-browser-authority.md @@ -0,0 +1,282 @@ +# Wave 3 browser authority: observations with page execution disabled + +Starting Checkpoint A: `bc5e1ee6922000a290371f8c2aa18802a03ffcad`, tree +`8e09cc2560f50a3472e06ec614d6ada028b7eb18`. Branch, cleanliness, both A +commits and canonical Wave 5B ancestry were verified before edits. Existing +145-file Checkpoint A baseline passed 3369 tests, with 3 platform skips +and 2 existing xfails. + +## Producer decision and live evidence + +The actual release Docker image was available locally: +`sha256:cc2d47e2327d573af01c6b027f23d2ab0f2ee9b85d658e9eb8065bd02b9c3515` +(Linux amd64). Its native binary reports exactly `agent-browser 0.35.0`. + +The isolated local-launch probe performed: + +1. Fresh local browser launch with the first `--pin-tab` request. +2. Create a sibling tab; capture and select an exact producer targetId. +3. `session info --no-pin-tab`, then `session info --pin-tab`. +4. Destroy the captured target using an external **test fixture**. +5. `snapshot --pin-tab`. + +Both re-arm calls succeeded. The snapshot also succeeded, a replacement target +became active, and there was no `tab_gone`. Lifecycle metadata reported +`relaunchedBrowser=false`, `restartedBackground=false`, `launched=false`. +The CLI's special `session info` path does not attach the pin fields to its +daemon request. Successful flags therefore cannot establish `pin_armed_for`. +The producer audit's proposed re-arm sequence is not valid in this mode. + +`tests/test_browser_producer_live_contract.py` reproduces this defect against +the actual binary, rather than treating the defect as a passing pin contract. +The four live tests also validate target/loader stability, reload/navigation, +same-document history change, distinct same-URL pages, and exact target switch +responses. Four passed in the actual release image. Raw GUIDs/CDP capability URLs +are neither printed nor saved by the tests or production adapter. + +Page/document reads and effects are **unconditionally disabled before producer +dispatch**. Observations, matching preconditions, matching postconditions, +successful pin flags, exact approval and child scope never override this gate. + +## Identity architecture + +`src/browser_identity.py` owns producer validation, private configuration, +registration, observations, metadata execution, resource binding and CDP +observation. `src/agent_runtime/resources.py` supplies immutable types: + +- `BrowserSessionObservation`: trusted namespace, version, platform, binary + digest, configuration digest, selector-only session key, one nested Wave 5B + `ProcessIdentity`, domain-separated browser GUID digest, and deterministic + session-incarnation digest. No duplicated start-token abstraction. +- `BrowserSessionResource`: the observation plus mandatory owner/thread binding. +- `BrowserPageResource`: exact parent session, producer targetId, opaque loaderId, + explicit page/document scope, and alias/URL audit metadata. Page authority is + session + target; document authority additionally includes loader. Metadata + does not participate in the authority key. + +Registration is server-only, checks the installed producer and creates private +owned configuration. It does not spawn or adopt a daemon/browser. Model-facing +lookup never creates a session. Legacy lifecycle records are not authority. +There is currently no model-facing launch/enrolment operation; default/legacy +sessions without a registered observation fail closed. + +An explicit trusted observation checks active producer state, captures the +daemon incarnation around exact executable observation, obtains the local CDP +capability, rejects lifecycle launch/replacement, validates tab schema and the +absence of labels, cross-checks CDP target type, captures main-frame loaderId, +detaches and rechecks daemon/browser identity. A changed session invalidates +every earlier page/document observation. A changed loader invalidates document +scope; a same-URL or same-alias replacement never inherits target scope. + +The proposed pin re-arm is **not implemented as an authority-establishing +action**. `pin_armed_for` stays unset; even modifying this field cannot enable +page execution. No alternate pin workaround or producer fork is introduced. + +## Trusted producer and observation transport + +Only explicit glibc Linux release binaries are allowlisted: + +| Platform | Version | Native binary SHA-256 | +| --- | --- | --- | +| linux-x64 | 0.35.0 | b7a28c3a43a7008dd02585e2e60c391c08983f7a099149caed63c9f13f57b752 | +| linux-arm64 | 0.35.0 | 92cd7d0897837ac648b9a6ab1965c69c5920e0f54df57e4295cdb1143b0541c8 | + +These digests were observed from the release image's installed package. x64 was +executed live; arm64 execution remains a separate architecture gate. Selection +uses `/usr/local/lib/node_modules/agent-browser/bin/agent-browser-`. +Version, hash, ownership, permissions and schema are checked. No PATH search, +npx execution/download, cache glob, mtime selection or replacement download. +0.27.0, unknown versions, platforms and hashes fail closed. + +The CDP sidecar accepts only loopback browser websocket capability URLs and +only `Target.getTargets`, `Target.getTargetInfo`, `Target.attachToTarget`, +`Page.getFrameTree`, `Target.detachFromTarget`. It does not enable domains, +evaluate, navigate, close targets or expose arbitrary CDP to tools. Frame identity +must equal the captured target and loaderId must be nonempty. Requests have +3-second bounds and bounded frame/message sizes. This is producer identity +observation, not semantic evidence or trust elevation. + +The capability URL stays in a non-serializable, non-repr memory field. Metadata +revalidation connects to that captured browser endpoint, rather than calling +`get cdp-url` again: that getter can auto-launch a replacement. Failed or changed +daemon/CDP observations invalidate the registered session; no rediscovery/retry. + +Configuration is exactly `{}` in an owned private cwd, with observed inode and +permissions checked. Client environment is constructed from an explicit fixed +allowlist: owned HOME/TMPDIR/socket directory, system PATH, Chromium path and +idle timeout. Ambient AGENT_BROWSER/CDP/provider/profile/state/config/proxy/XDG +settings and model subprocess environment are not inherited. Configuration is +part of the incarnation digest; credentials are not serialized. + +## Operation and approval boundaries + +| Operation | Binding | Current execution | +| --- | --- | --- | +| `session_info` | Exact registered session + caller/request | Supported metadata only; no URL/title/content, target selection or launch | +| New page, initial open, tab list, whole-session close | Session/creation producer guarantee | Disabled; no trustworthy atomic creation/control contract admitted | +| Select/close page, navigate/reload/back/forward, time wait, viewport scroll, page network/console | Exact session + target | Disabled before dispatch | +| Click/fill/press/evaluate, selector/ref interactions and waits | Exact session + target + loader | Disabled before dispatch | +| Snapshot/read/find/screenshot | Exact page, loader sandwich for any future read | Disabled before dispatch; no replacement-page read | + +Failure is structured: `failure_kind=browser_page_authority_unavailable`, +`executed=false`, `retryable=false`, `producer_capability_unavailable=true`. +Missing session authority produces a separate session-unavailable failure. +No timeout or post-check can authorize execution against a replacement. + +RequestAuthority version 5 carries explicit session/page ceilings. Old snapshots +restore empty browser scopes. Exact proposal capture binds normalized operation, +request/owner/thread and the exact session/page/document observation. Metadata +execution revalidates before one-use claim and at producer entry. Restoration +adds no general scope. Unsupported page approvals are never claimed/executed. + +Child scopes validate parent observations before intersection. Session ceilings +require exact incarnation; page ceilings require exact parent + target; document +ceilings also require loader. A page child cannot acquire session control, and a +document child cannot renew a replaced document. Discovery adds no authority. + +Model batches, raw tab/window/frame/connect commands, labels, raw targetIds, +configuration/session/CDP/provider/profile/state flags and flag-like positional +values are rejected. `page: tN` is strictly validated. The preview's automatic +open/snapshot batch rewrite and native read/post-click batches/recovery engine +are removed. Raw global Playwright browser control calls fail closed as well; +remote backend/stdio identity is not page authority. Other remote/MCP transport +mechanics remain unchanged and external. + +Client invocations are bounded at 20 seconds, below the source-verified 30-second +read/resend floor, with held-handle kill/wait on timeout/cancellation and no +Odysseus retries. Immediate producer EOF/reset retries cannot be eliminated by +this wrapper. **No exactly-once claim is made; all effects remain disabled.** + +## Control state and prior unsupported paths + +Private browser runtime/configuration is protected by central control-plane +resolution and native launch workspace guards, including actual configured +directories. Direct, symlink and hardlink tests cover it. These are pathname/ +inode observations, not race-freedom claims or a new containment policy. +Service-owned Wave 5B cleanup remains independent of model authority; shutdown +does not discover/download/run an untrusted producer binary. + +Re-audit of Checkpoint A seams found: + +| Path | Remaining enforcement | +| --- | --- | +| PTY/native manager routes | `routes/shell_routes.py:setup_shell_routes.shell_exec/shell_stream` call `_require_admin` before `_exec_shell/_generate_pty/_generate_tmux`; internal/anonymous controls denied, authenticated human administration separate | +| Additional process producers | `resources.ProcessResource.__post_init__` admits only frozen native producer/role combinations; `process_resources.resolve_process_operation` requires sealed observations | +| Raw scheduled SSH | `TaskScheduler._execute_action` → `builtin_actions.action_ssh_command` → `_run_subprocess` refuses SSH without an external workload adapter | +| Local Cookbook scheduled auto-stop | `routes/cookbook_routes.py:setup_cookbook_routes.protect_native_control` applies shell admin boundary to local mutation; `tools/cookbook._cookbook_kill_session` refuses registry-less local control; legacy internal shell route cannot gain administration | +| Legacy/unscoped tasks | `authority.restore_task_authority` → `process_resources.resolve_process_operation` admits no missing creation scope | +| Anonymous administration / generic app_api | `owned_resources.needs_owned_binding` rejects shell/model/Cookbook namespaces; `_require_admin` also rejects unlabelled loopback when anonymous or unauthenticated | + +No model-reachable page producer entry remains in the native/research wrapper. +Trusted observation/setup methods are not tools or routes. Native arbitrary +program/network effects and remote workload effects retain their existing +explicit launch/backend boundaries; this checkpoint adds no general network +egress/provenance policy (Wave 4). + +## Validation and remaining release gates + +`wave-3-final-tests.txt` contains 149 files, retaining all 145 Checkpoint A files +and the exact prior 88-file selection. Legacy positive page/batch/recovery tests +are replaced by explicit unsupported-before-dispatch tests; formatting, +filesystem, YouTube, Wave 5B ownership/cleanup and research fallback tests remain. + +Final resource/authority/approval focused run: **1,425 passed**. Final 149-file +integrated gate: **3,776 passed, 7 skipped, 2 xfailed**. The exact old 88-file +selection and all 145 Checkpoint A files were verified as subsets of this gate. +The 7 skips are `/tmp` not being a symlink, applicable RLIMIT_AS already +available, the Windows Ollama startup guard, and four explicit Docker-only +producer probes. Those four probes ran separately: **4 passed** on the actual +release x64 image. Index/schema/configuration checks separately passed 40 tests. + +Full-suite failure classification was performed against an isolated archive of +the frozen Checkpoint A (no checkout/rewrite): replay of the initial 82 failing +cases reproduced 79. Two browser/schema regressions were corrected. The third +case, `test_dispatcher_rejects_approved_document_action_without_target`, passed +alone but failed identically on the frozen archive when preceded by +`test_scheduler_restart_doublefire.py`. That fixture permanently replaces +`core.database.SessionLocal/engine` with a task-only database. This is an +existing suite-order issue, not a browser authority regression. Missing Node +Playwright dependencies and legacy fixtures that expect unscoped execution +also remain explicit full-suite limitations; they are not skipped or counted +as passes. New browser test environment documentation also records the existing +memory backend owner settings required to regenerate the configuration page. + +Final full repository run: **12,310 passed, 76 failed, 65 skipped, 2 xfailed, +6 subtests passed** (403.66 seconds). Every final failed node was reproduced on +frozen Checkpoint A, using the scheduler-order reproduction for the document +case. This is **not a green full-suite gate**. Exact failed node IDs and totals +are in `validation/wave-3-browser-final-results.json`. + +Full-suite skips include smoke/live endpoints without an instance or opt-in, +the four separately executed release producer probes, the three platform cases, +missing caldav/chromadb/fitz/openpyxl/markitdown/libmagic/Node Playwright, +ffmpeg format limitations and missing rsvg-convert. Nothing was silently +converted into a pass. The two existing strict xfails remain the inferred single-file deletion and inferred CSV overwrite path cases in `test_runtime_behavior_regressions.py`. + +Compileall, whitespace, conflict-marker and unmerged-index checks pass. +The coherent fail-closed implementation is available for independent review; +full-suite cleanup remains outstanding and page enabling is not merge-ready. + +## Exact production changes since Checkpoint A + +```text +src/browser_identity.py +src/agent_runtime/resources.py +src/agent_runtime/authority.py +src/agent_runtime/process_resources.py +src/agent_tools/web_tools.py +src/tool_execution.py +src/tool_approvals.py +src/tool_schemas.py +src/tool_index.py +src/clean_agent_preview.py +src/agent_loop.py +src/constants.py +scripts/generate_env_reference.py +``` + +`website/configuration-reference.md` is regenerated documentation. Runtime +instructions/schema/index no longer advertise executable page interactions. +The agent loop change is only the browser prompt snippet; it is not decomposed. +Wave 5B lifecycle mechanics and MCP transport are not modified. + +```sh +python3 -m pytest -q -rs $(cat docs/runtime-decomposition/wave-3-final-tests.txt) +python3 -m pytest -q -rs +python3 -m compileall -q app.py core routes services src tests scripts +git diff --check +git grep -n -E '^(<<<<<<< |=======$|>>>>>>> )' || true +git ls-files -u +``` + +Live release probe (source checkout mounted read-only, isolated container state): + +```sh +docker run --rm --network none \ + -e ODYSSEUS_BROWSER_LIVE_CONTRACT=1 -e ODYSSEUS_DATA_DIR=/tmp/w3-data \ + -e DATABASE_URL=sqlite:///:memory: -v "$PWD:/app:ro" \ + --entrypoint python odysseus-maintainer-preview-odysseus:latest \ + -m pytest -q -rs -o cache_dir=/tmp/w3-pytest-cache \ + tests/test_browser_producer_live_contract.py +``` + +The x64 probes pass by proving observation contracts **and the known defect**. +They are not a positive merge gate for enabling page effects. Re-enabling needs +a separately audited/allowlisted producer that executes only while expected +browser incarnation, targetId and optional loaderId still match, rejects stale +state atomically before reading/effect, and does not resend an indeterminate +effect. No producer changes are implemented here. + +The original positive 18-case Docker gate remains mandatory before re-enabling: +stable/repeated targets; reload; cross-/same-document navigation; identical URLs; +close/recreate; browser and daemon replacement; popup races; destroyed targets; +local-launch pin/atomic binding; exact target switch; A-F label collision; +lifecycle metadata; timeout/duplicate effects; bfcache; prerender/frame invariant; +strict schema. It must run per supported release architecture. Pin success and +pre/post checking alone can never substitute for atomic binding. + +P1: producer page/document capability unavailable; unregistered sessions and +Checkpoint A compatibility paths intentionally denied. P2: private-runtime scan +cost/retention, filesystem observation races and architecture-specific live +coverage. Wave 4 remains responsible for effects/provenance/egress and truthful +completion evidence; no Wave 4 journal or lifecycle redesign is introduced. diff --git a/docs/runtime-decomposition/wave-3-final-tests.txt b/docs/runtime-decomposition/wave-3-final-tests.txt new file mode 100644 index 000000000..c1d740475 --- /dev/null +++ b/docs/runtime-decomposition/wave-3-final-tests.txt @@ -0,0 +1,149 @@ +tests/test_resource_identity.py +tests/test_owned_resource_identity.py +tests/test_remote_resource_identity.py +tests/test_request_authority.py +tests/test_tool_approvals.py +tests/test_tool_approval_single_action_scope.py +tests/test_tool_approval_task_scope.py +tests/test_workspace_confine.py +tests/test_tool_path_confinement.py +tests/test_path_confinement_boundary.py +tests/test_filesystem_tool_argument_validation.py +tests/test_code_nav_tools.py +tests/test_apply_patch_transaction.py +tests/test_execution_bridge.py +tests/test_production_external_bridge.py +tests/test_turn_contract.py +tests/test_turn_contract_read_operations.py +tests/test_turn_contract_integration.py +tests/test_agent_turn_contract_boundaries.py +tests/test_explicit_personal_turn_contract.py +tests/test_nested_invocation_ownership.py +tests/test_containment_contract.py +tests/test_containment_enforcement.py +tests/test_containment_process_tree.py +tests/test_native_execution_containment.py +tests/test_background_containment.py +tests/test_process_ownership.py +tests/test_bg_jobs_store.py +tests/test_bg_job_tools.py +tests/test_execution_filesystem_boundary.py +tests/test_mcp_manager.py +tests/test_mcp_reconnect_args.py +tests/test_mcp_text_error_normalization.py +tests/test_mcp_param_hint_hardening.py +tests/test_mcp_tool_params_in_prompt.py +tests/test_mcp_memory_owner_scope.py +tests/test_mcp_cache_invalidation.py +tests/test_multiple_mcp_servers_timeout.py +tests/test_mcp_dependency_compatibility.py +tests/test_builtin_mcp_bg_tasks.py +tests/test_builtin_mcp_pythonpath.py +tests/test_builtin_mcp_npx_cache.py +tests/test_mcp_add_server_args_validation.py +tests/test_manage_mcp_command_allowlist.py +tests/test_document_tool_owner_scope.py +tests/test_owned_document_query.py +tests/test_document_session_owner_scope.py +tests/test_active_document_mutation_guard.py +tests/test_native_document_stream.py +tests/test_document_followup_integrity.py +tests/test_document_active_restore.py +tests/test_attachment_refs.py +tests/test_upload_handler_atomicity.py +tests/test_upload_handler_cleanup.py +tests/test_upload_handler_rename_owner.py +tests/test_upload_routes_owner_scope.py +tests/test_resolve_upload_path_nondict.py +tests/test_personal_upload_isolation.py +tests/test_personal_upload_privilege.py +tests/test_extract_text_tool.py +tests/test_media_ingress.py +tests/test_session_tools_registry.py +tests/test_session_owner_attribution.py +tests/test_session_list_owner_scope.py +tests/test_session_endpoint_owner_scope.py +tests/test_session_search.py +tests/test_session_search_batch_fetch.py +tests/test_history_topics_owner_scope.py +tests/test_history_order_by_timestamp_regression.py +tests/test_history_db_fallback_hidden.py +tests/test_memory_owner_isolation.py +tests/test_memory_routes_session_owner.py +tests/test_manage_memory_json_contract.py +tests/test_manage_memory_list.py +tests/test_memory_store_unreadable_no_wipe.py +tests/test_manage_notes_search_contract.py +tests/test_notes_fail_closed_auth.py +tests/test_notes_checklist_state.py +tests/test_vault_password_not_in_argv.py +tests/test_vault_routes_shim.py +tests/test_external_context_tool_gate.py +tests/test_chat_route_tool_policy.py +tests/test_product_turn_contract_route.py +tests/test_native_tool_result_threading.py +tests/test_host_shell_polling.py +tests/test_integrations_url_join.py +tests/test_integration_api_call_ssrf.py +tests/test_integrations_api_call_truncation.py +tests/test_process_resource_identity.py +tests/test_background_resource_identity.py +tests/test_runtime_resource_integration.py +tests/test_process_lifecycle.py +tests/test_browser_lifecycle.py +tests/test_private_browser_tool.py +tests/test_browser_transport_recovery.py +tests/test_shell_routes.py +tests/test_agent_tmux_retirement.py +tests/test_cookbook_stop_without_procfs.py +tests/test_cookbook_serve_lifecycle.py +tests/test_task_scheduler_cancel.py +tests/test_task_shell_tools.py +tests/test_runtime_behavior_regressions.py +tests/test_workspace_artifact_tool_floor.py +tests/test_bg_monitor_stream.py +tests/test_orphan_reaping.py +tests/test_cookbook_agent_tool_ssh_validation.py +tests/test_codex_cookbook_admin_gate.py +tests/test_task_cookbook_admin_gate.py +tests/test_builtin_actions_cookbook_serve_state.py +tests/test_cookbook_local_serve_pid_winpid.py +tests/test_scheduler_restart_doublefire.py +tests/test_task_scheduler_session_delivery.py +tests/test_cookbook_cache_scan_isolation.py +tests/test_cookbook_cached_scan_refresh.py +tests/test_cookbook_chat_deeplinks_static.py +tests/test_cookbook_cpu_only_serve.py +tests/test_cookbook_dead_download_status.py +tests/test_cookbook_dependency_completion_regression.py +tests/test_cookbook_deps_recipes.py +tests/test_cookbook_diagnosis.py +tests/test_cookbook_diagnosis_js.py +tests/test_cookbook_docker_access.py +tests/test_cookbook_download_toast_duration.py +tests/test_cookbook_endpoint_registration.py +tests/test_cookbook_error_feedback.py +tests/test_cookbook_error_tail_lines.py +tests/test_cookbook_finished_download_label.py +tests/test_cookbook_gemma4_thinking_template.py +tests/test_cookbook_helpers.py +tests/test_cookbook_hf_token.py +tests/test_cookbook_official_trending_filter.py +tests/test_cookbook_package_detection.py +tests/test_cookbook_port_parsing_js.py +tests/test_cookbook_progress_signal_js.py +tests/test_cookbook_remote_windows_diffusers.py +tests/test_cookbook_same_host_server_profiles_js.py +tests/test_cookbook_tool_dry_run.py +tests/test_cookbook_windows_stop_tree_js.py +tests/test_scheduler_prompt_cache_time.py +tests/test_scheduler_scheduled_time_validation.py +tests/test_task_scheduler_cache.py +tests/test_task_scheduler_fixture_isolation.py +tests/test_tool_task_cancelled_on_disconnect.py +tests/test_background_tool_jobs.py +tests/test_deep_research_browser_fallback.py +tests/test_browser_resource_identity.py +tests/test_browser_identity_transport.py +tests/test_browser_producer_live_contract.py +tests/test_clean_agent_preview.py diff --git a/scripts/generate_env_reference.py b/scripts/generate_env_reference.py index 35f017e03..5cc1cf23b 100644 --- a/scripts/generate_env_reference.py +++ b/scripts/generate_env_reference.py @@ -496,6 +496,21 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = { "Security-relevant. Comma-separated allowlist of MCP launcher basenames the " "agent may start. Empty by default, and the deny list still wins.", ), + "ODYSSEUS_MCP_MEMORY_OWNER": ( + "Memory and skills", USER, + "Application owner binding for the configured memory MCP backend. Takes " + "precedence over ODYSSEUS_MEMORY_OWNER; missing ownership fails closed.", + ), + "ODYSSEUS_MEMORY_OWNER": ( + "Memory and skills", USER, + "Fallback application owner binding for the memory MCP backend. This " + "configuration identifies ownership; it does not grant read or egress authority.", + ), + "ODYSSEUS_BROWSER_LIVE_CONTRACT": ( + "Testing, capture and development tooling", INTERNAL, + "Set 1 only in the allowlisted release Docker environment to run the " + "browser producer contract tests. Does not enable browser page operations.", + ), "ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES": ( "Agent loop and tool execution", USER, "Security-relevant. Absolute package roots, separated by the platform path " diff --git a/src/agent_loop.py b/src/agent_loop.py index e87f789c0..86c3418c2 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -7507,10 +7507,9 @@ Get current conditions and a three-day forecast using Open-Meteo. Use this for w "private_browser": """\ ```private_browser -{"action": "open", "url": "https://example.com"} +{"action": "session_info"} ``` -Private browser automation through Odysseus' agent-browser wrapper. Actions include open/read/snapshot/find/evaluate/click/fill/press/wait/screenshot/close/batch. For find, pass visible text in `find`. For evaluate, pass JavaScript in `script`. Use ONLY for specific pages that need JavaScript, login/session state, clicking, forms, waiting, screenshots, or rendered DOM inspection. For open-ended search use `web_search`. For ordinary URL reading use `web_fetch`. -After opening a page, call `snapshot` before interacting, then use the returned element refs such as `@e12` as `target`; target is a selector/ref, never guessed visible text. Prefer one `batch` for known consecutive steps, e.g. `[["open","https://example.com"],["snapshot"]]`. Batch commands must be non-empty.""", +Registered browser session metadata only: session_info. Page/document reads and effects are unavailable because the configured local producer cannot guarantee captured-target binding. Do not send batches, raw commands, flags, URLs or guessed page handles. Use web_search/web_fetch for supported web access.""", "youtube_tool": """\ ```youtube_tool diff --git a/src/agent_runtime/authority.py b/src/agent_runtime/authority.py index 57822c490..d1059a8e1 100644 --- a/src/agent_runtime/authority.py +++ b/src/agent_runtime/authority.py @@ -14,6 +14,7 @@ from uuid import uuid4 from src.agent_runtime.resources import ( FilesystemRoot, ExternalResource, NativeBackendResource, OwnedScope, ProcessLaunchScope, ProcessResource, BackgroundJobResource, + BrowserSessionResource, BrowserPageResource, backend_from_dict, intersect_roots, seal_owned_scopes, ) from src.tool_policy import ToolPolicy, build_effective_tool_policy @@ -124,6 +125,8 @@ class RequestAuthority: launch_scopes: tuple[ProcessLaunchScope, ...] | None = None process_resources: tuple[ProcessResource, ...] = () job_resources: tuple[BackgroundJobResource, ...] | None = None + browser_sessions: tuple[BrowserSessionResource, ...] | None = None + browser_pages: tuple[BrowserPageResource, ...] | None = None def __post_init__(self): if (not isinstance(self.request_id, str) or not self.request_id @@ -178,11 +181,24 @@ class RequestAuthority: raise ValueError("Job resource thread changed") if any(r.thread_id != (self.session_id or "request:" + self.request_id) for r in self.process_resources): raise ValueError("Process resource thread changed") + from src.browser_identity import seal_browser_resources + sessions, pages = seal_browser_resources(self) if self.browser_sessions is None or self.browser_pages is None else ((), ()) + if self.browser_sessions is None: + object.__setattr__(self, "browser_sessions", sessions) + if self.browser_pages is None: + object.__setattr__(self, "browser_pages", pages) + for values, kind in ((self.browser_sessions, BrowserSessionResource), (self.browser_pages, BrowserPageResource)): + if not isinstance(values, tuple) or any(not isinstance(r, kind) for r in values): + raise ValueError("Malformed browser resource scope") + for r in values: + session = r.session if isinstance(r, BrowserPageResource) else r + if (session.owner, session.thread_id) != (self.owner, self.session_id): + raise ValueError("Browser owner/thread binding changed") @classmethod def empty(cls, *, owner=None, session_id=None, workspace=None): return cls(uuid4().hex, _owner(owner), str(session_id or ""), str(workspace or ""), - resource_roots=(), backend_resources=(), owned_scopes=(), launch_scopes=(), job_resources=()) + resource_roots=(), backend_resources=(), owned_scopes=(), launch_scopes=(), job_resources=(), browser_sessions=(), browser_pages=()) def bound_to(self, *, owner=None, session_id=None, workspace=None): return (self.owner == _owner(owner) and self.session_id == str(session_id or "") @@ -211,6 +227,7 @@ class RequestAuthority: backends = () owned = () launches = processes = jobs = () + browser_sessions = browser_pages = () if (self.owner, self.session_id, self.workspace) == (child.owner, child.session_id, child.workspace): theirs = {g.tool: g for g in child.grants} grants = [g.intersect(theirs[g.tool]) for g in self.grants if g.tool in theirs] @@ -222,11 +239,15 @@ class RequestAuthority: launches = intersect_launch_scopes(self.launch_scopes, child.launch_scopes) processes = intersect_observed(self.process_resources, child.process_resources, lambda r: r.validate()) jobs = intersect_observed(self.job_resources, child.job_resources, validate_job) + from src.browser_identity import intersect_browser + browser_sessions, browser_pages = intersect_browser(self.browser_sessions, self.browser_pages, + child.browser_sessions, child.browser_pages) return replace(self, grants=tuple(grants), denied=self.denied | child.denied, block_all=self.block_all or child.block_all, disable_mcp=self.disable_mcp or child.disable_mcp, inherited=True, resource_roots=roots, backend_resources=backends, owned_scopes=owned, - launch_scopes=launches, process_resources=processes, job_resources=jobs) + launch_scopes=launches, process_resources=processes, job_resources=jobs, + browser_sessions=browser_sessions, browser_pages=browser_pages) def continuation(self, *, owner=None, session_id=None): """A server continuation may rebind a session, never change owner/grants.""" @@ -236,10 +257,12 @@ class RequestAuthority: return replace(self, session_id=rebound, inherited=True, owned_scopes=tuple(replace(s, thread_id=rebound) for s in self.owned_scopes) if rebound else (), process_resources=tuple(r for r in self.process_resources if r.thread_id == rebound), - job_resources=tuple(r for r in self.job_resources if r.thread_id == rebound)) + job_resources=tuple(r for r in self.job_resources if r.thread_id == rebound), + browser_sessions=tuple(r for r in self.browser_sessions if r.thread_id == rebound), + browser_pages=tuple(r for r in self.browser_pages if r.session.thread_id == rebound)) def to_dict(self): - return {"version": 4, "request_id": self.request_id, "owner": self.owner, + return {"version": 5, "request_id": self.request_id, "owner": self.owner, "session_id": self.session_id, "workspace": self.workspace, "grants": [{"tool": g.tool, "actions": None if g.actions is None else sorted(g.actions), @@ -251,12 +274,14 @@ class RequestAuthority: "owned_scopes": [s.to_dict() for s in self.owned_scopes], "launch_scopes": [s.to_dict() for s in self.launch_scopes], "process_resources": [r.to_dict() for r in self.process_resources], - "job_resources": [r.to_dict() for r in self.job_resources]} + "job_resources": [r.to_dict() for r in self.job_resources], + "browser_sessions": [r.to_dict() for r in self.browser_sessions], + "browser_pages": [r.to_dict() for r in self.browser_pages]} @classmethod def from_dict(cls, value): if (not isinstance(value, dict) or type(value.get("version")) is not int - or value["version"] not in {1, 2, 3, 4}): + or value["version"] not in {1, 2, 3, 4, 5}): raise ValueError("Unsupported authority snapshot") def limits(value): if value is None: @@ -275,6 +300,8 @@ class RequestAuthority: raise ValueError("Malformed process resource snapshot") if not isinstance(backends, list) or not isinstance(owned, list): raise ValueError("Malformed request resource scope snapshot") + if value["version"] >= 5 and any(not isinstance(value.get(name), list) for name in ("browser_sessions", "browser_pages")): + raise ValueError("Malformed browser resource scope snapshot") return cls(value["request_id"], value["owner"], value["session_id"], value["workspace"], tuple(OperationGrant(g["tool"], limits(g["actions"]), limits(g["inputs"])) for g in value["grants"]), limits(value["denied"]), @@ -283,11 +310,13 @@ class RequestAuthority: tuple(backend_from_dict(r) for r in backends), tuple(OwnedScope.from_dict(s) for s in owned), tuple(ProcessLaunchScope.from_dict(s) for s in process_fields["launch_scopes"]), tuple(ProcessResource.from_dict(r) for r in process_fields["process_resources"]), - tuple(BackgroundJobResource.from_dict(r) for r in process_fields["job_resources"])) + tuple(BackgroundJobResource.from_dict(r) for r in process_fields["job_resources"]), + tuple(BrowserSessionResource.from_dict(r) for r in value["browser_sessions"]) if value["version"] >= 5 else (), + tuple(BrowserPageResource.from_dict(r) for r in value["browser_pages"]) if value["version"] >= 5 else ()) _BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find", - "screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs"}) + "screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info"}) @dataclass(frozen=True) @@ -505,7 +534,9 @@ def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authori owned_scopes=parent.owned_scopes, launch_scopes=parent.launch_scopes, process_resources=parent.process_resources, - job_resources=parent.job_resources)) + job_resources=parent.job_resources, + browser_sessions=parent.browser_sessions, + browser_pages=parent.browser_pages)) return _json({"task_input": [prompt, task_type, action], "authority": authority.to_dict()}) diff --git a/src/agent_runtime/process_resources.py b/src/agent_runtime/process_resources.py index 53e90054b..c5f5a17f6 100644 --- a/src/agent_runtime/process_resources.py +++ b/src/agent_runtime/process_resources.py @@ -347,8 +347,11 @@ def guard_launch_workspace(root): They do not claim freedom from concurrent link replacement after checking. """ from src import bg_jobs, containment, constants + from src import browser_identity from src.agent_runtime.resources import _control_plane_path control = (Path(bg_jobs._STORE), Path(bg_jobs._JOBS_DIR), containment._store_path(), _LAUNCH_DIR, + Path(constants.BROWSER_RESOURCES_DIR), + browser_identity.STATE_ROOT, Path(constants.APP_DB), Path(constants.AUTH_FILE), Path(constants.SETTINGS_FILE)) base = Path(root.path) if any(Path(p).resolve().is_relative_to(base) for p in control): diff --git a/src/agent_runtime/resources.py b/src/agent_runtime/resources.py index 7440950b1..8513d087f 100644 --- a/src/agent_runtime/resources.py +++ b/src/agent_runtime/resources.py @@ -37,7 +37,11 @@ def _control_plane_path(path): "SETTINGS_FILE", "SESSIONS_FILE", "USER_PREFS_FILE", "VAULT_FILE", "SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB", "MEMORY_FILE", "INTEGRATIONS_FILE", )} - job_dirs = {canonical_root(constants.BG_JOBS_DIR), canonical_root(constants.PROCESS_RESOURCES_DIR)} + job_dirs = {canonical_root(constants.BG_JOBS_DIR), canonical_root(constants.PROCESS_RESOURCES_DIR), + canonical_root(constants.BROWSER_RESOURCES_DIR)} + browser = sys.modules.get("src.browser_identity") + if browser is not None: + job_dirs.add(canonical_root(browser.STATE_ROOT)) processes = sys.modules.get("src.agent_runtime.process_resources") if processes is not None: job_dirs.add(canonical_root(processes._LAUNCH_DIR)) @@ -73,7 +77,7 @@ def _control_plane_path(path): return True if jobs.exists(): # Uninspectable state fails closed; hardlinks retain object identity. - protected.update(canonical_root(p) for p in jobs.iterdir()) + protected.update(canonical_root(p) for p in jobs.rglob("*") if p.is_file()) protected.update(canonical_root(getattr(constants, name) + suffix) for name in ("APP_DB", "SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB") for suffix in ("-wal", "-shm", "-journal")) @@ -106,6 +110,115 @@ class ResourceIdentityError(ValueError): """An observed execution resource has changed or cannot be resolved.""" +@dataclass(frozen=True) +class BrowserSessionObservation: + producer_namespace: str + producer_version: str + platform: str + binary_sha256: str + configuration_digest: str + session_key: str + daemon: "ProcessIdentity" + browser_instance_digest: str + session_incarnation: str + + def __post_init__(self): + from src.process_lifecycle import ProcessIdentity + from src.browser_identity import PRODUCER_HASHES, incarnation + if (self.producer_namespace != "native:agent-browser" + or self.producer_version != "0.35.0" + or PRODUCER_HASHES.get(self.platform) != self.binary_sha256 + or not isinstance(self.daemon, ProcessIdentity) + or type(self.daemon.pid) is not int or self.daemon.pid <= 0 + or (self.daemon.pgid is not None and (type(self.daemon.pgid) is not int or self.daemon.pgid <= 0))): + raise ValueError("Unsupported browser producer observation") + import re + _text(self.daemon.start_token, "daemon incarnation") + if not re.fullmatch(r"ody-[a-f0-9]{24}", self.session_key): + raise ValueError("Malformed browser session selector") + for value in (self.configuration_digest, self.browser_instance_digest, self.session_incarnation): + if not re.fullmatch(r"[a-f0-9]{64}", value): + raise ValueError("Malformed browser digest") + if incarnation(self) != self.session_incarnation: + raise ValueError("Browser incarnation digest changed") + + def to_dict(self): + return {**asdict(self), "daemon": self.daemon.to_record()} + + @classmethod + def from_dict(cls, value): + from src.process_lifecycle import ProcessIdentity + if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__): + raise ValueError("Malformed browser observation snapshot") + daemon = value["daemon"] + if not isinstance(daemon, dict) or set(daemon) != {"pid", "start_token", "pgid"}: + raise ValueError("Malformed browser daemon observation") + return cls(**{**value, "daemon": ProcessIdentity(**daemon)}) + + +@dataclass(frozen=True) +class BrowserSessionResource: + owner: str + thread_id: str + observation: BrowserSessionObservation + + def __post_init__(self): + _text(self.owner, "browser owner") + _text(self.thread_id, "browser thread") + if not isinstance(self.observation, BrowserSessionObservation): + raise ValueError("Missing browser session observation") + + def validate(self): + from src.browser_identity import validate_session + validate_session(self) + + def to_dict(self): + return {"owner": self.owner, "thread_id": self.thread_id, "observation": self.observation.to_dict()} + + @classmethod + def from_dict(cls, value): + if not isinstance(value, dict) or set(value) != {"owner", "thread_id", "observation"}: + raise ValueError("Malformed browser resource snapshot") + return cls(value["owner"], value["thread_id"], BrowserSessionObservation.from_dict(value["observation"])) + + +@dataclass(frozen=True) +class BrowserPageResource: + session: BrowserSessionResource + target_id: str + loader_id: str + resolved_alias: str = "" + observed_url: str = "" + scope: str = "document" + + def __post_init__(self): + import re + if not isinstance(self.session, BrowserSessionResource) or not re.fullmatch(r"[A-F0-9]{32}", self.target_id): + raise ValueError("Malformed browser page identity") + if self.scope not in {"page", "document"}: + raise ValueError("Malformed browser page scope") + _text(self.loader_id, "document loader", optional=self.scope == "page") + _text(self.observed_url, "observed URL", optional=True) + if self.resolved_alias and not re.fullmatch(r"t[1-9][0-9]*", self.resolved_alias): + raise ValueError("Malformed browser alias metadata") + + def authority_key(self): + return (self.session, self.target_id, self.loader_id if self.scope == "document" else None) + + def validate(self): + from src.browser_identity import validate_page + validate_page(self) + + def to_dict(self): + return {**asdict(self), "session": self.session.to_dict()} + + @classmethod + def from_dict(cls, value): + if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__): + raise ValueError("Malformed browser page snapshot") + return cls(**{**value, "session": BrowserSessionResource.from_dict(value["session"])}) + + @dataclass(frozen=True) class FileObjectIdentity: device: int @@ -439,34 +552,6 @@ class BackgroundJobResource: return cls(**{**value, "processes": tuple(ProcessResource.from_dict(p) for p in value["processes"])}) -@dataclass(frozen=True) -class BrowserProducer: - namespace: str - owner: str - thread_id: str - session_id: str - incarnation: str - - def __post_init__(self): - for name in ("namespace", "owner", "thread_id", "session_id", "incarnation"): - _text(getattr(self, name), name) - - -@dataclass(frozen=True) -class BrowserPageResource: - producer: BrowserProducer - page_id: str - navigation_generation: int - observed_url: str - - def __post_init__(self): - if (not isinstance(self.producer, BrowserProducer) - or type(self.navigation_generation) is not int or self.navigation_generation < 0): - raise ValueError("Malformed browser page identity") - _text(self.page_id, "page") - _text(self.observed_url, "observed URL") - - @dataclass(frozen=True) class ExternalResource: namespace: str diff --git a/src/agent_tools/web_tools.py b/src/agent_tools/web_tools.py index 700ab4a85..fe33e2da0 100644 --- a/src/agent_tools/web_tools.py +++ b/src/agent_tools/web_tools.py @@ -2339,37 +2339,50 @@ class YouTubeTool: class PrivateBrowserTool: - """Small deterministic wrapper around Vercel's agent-browser CLI. + """Resource-bound session metadata; page/document execution is unavailable.""" + _ACTIONS = {"session_info"} + _AUTO_SCREENSHOT_ACTIONS = set() - This is intentionally narrower than handing the model the raw browser MCP - schema. Use web_search/web_fetch first; this exists for JS-rendered pages, - forms, clicks, screenshots, and logged-in browser state. - """ + async def execute(self, content: str, ctx: dict) -> dict: + from src.browser_identity import execute_browser + return await execute_browser(content, dict(ctx or {})) - _ACTIONS = { - "open", - "read", - "snapshot", - "find", - "evaluate", - "click", - "fill", - "press", - "scroll", - "wait", - "screenshot", - "close", - "batch", - } - _AUTO_SCREENSHOT_ACTIONS = { - "open", - "snapshot", - "batch", - "click", - "fill", - "press", - "scroll", - } + async def _execute_unlocked(self, content, ctx, **kwargs): + # Legacy internal callers must pass through the same capability gate. + return await self.execute(content, ctx) + + async def _capture_post_click_state(self, *args, **kwargs): + from src.agent_runtime.resources import ResourceIdentityError + raise ResourceIdentityError("browser_page_authority_unavailable") + + @staticmethod + def _local_agent_browser_binary(): + # Retired cache discovery seam. Trusted selection is browser_identity. + return None + + def _parse_args(self, content): + from src.browser_identity import parse_operation + try: + _, args = parse_operation(content) + return args, None + except (ValueError, TypeError) as error: + return {}, str(error) + + def _command_for_action(self, prefix, action, args): + from src.browser_identity import parse_operation, SESSION_ACTIONS, PAGE_FAILURE + try: + parse_operation(json.dumps({**args, "action": action})) + except (ValueError, TypeError) as error: + return [], None, str(error) + if action not in SESSION_ACTIONS: + return [], None, PAGE_FAILURE + return [*prefix, *( ["session", "info"] if action == "session_info" else ["tab", "list"] )], None, None + + def _normalize_batch_screenshots(self, *args): + raise ValueError("Model-authored browser batch is forbidden") + + def _timeout_seconds(self, args, *, action=""): + return 20 @staticmethod def _shopping_landing_hint(output: str) -> str: @@ -2390,24 +2403,6 @@ class PrivateBrowserTool: f"Local shopping link: @{match.group('ref')} ({label})." ) - @staticmethod - def _retryable_local_open_failure(output: str) -> bool: - """Return whether a local-page open failed during browser bootstrap. - - ``agent-browser`` keeps a daemon behind the short-lived CLI. During - parallel runtime startup the daemon can disappear between the client - connection and Chromium setup, producing a transient ENOENT/connection - error. Retry only this narrow class of failure; page JavaScript errors - and arbitrary browser failures must still be surfaced to the model. - """ - - text = str(output or "").lower() - return ( - "could not configure browser" in text - and "failed to connect" in text - and ("no such file" in text or "enoent" in text) - ) - @staticmethod def _terminate_subprocess(proc) -> None: """Terminate a browser CLI and descendants spawned for its session. @@ -2567,30 +2562,6 @@ class PrivateBrowserTool: return True return False - @staticmethod - def _local_agent_browser_binary() -> str | None: - """Find the native installed binary before falling back to npx. - - The package's ``.bin/agent-browser`` entrypoint is a Node wrapper. It - launches the persistent native daemon with inherited stdio, which can - leave the harness's subprocess pipes open after the CLI request has - completed. Calling the native binary directly avoids that pipe leak. - """ - - candidates = sorted( - ( - path - for path in _accessible_glob( - [npm_root / "_npx" for npm_root in _host_npm_roots()], - "*/node_modules/agent-browser/bin/agent-browser-linux-x64", - ) - if path.is_file() and os.access(path, os.X_OK) - ), - key=lambda path: path.stat().st_mtime, - reverse=True, - ) - return str(candidates[0]) if candidates else None - @staticmethod def _resolve_workspace_path(raw_path: str) -> Path: """Resolve a logical agent path inside the active task workspace.""" @@ -2621,693 +2592,6 @@ class PrivateBrowserTool: resolved = cls._resolve_workspace_path(raw_path) return resolved.as_uri() - # Time allowed beyond the action timeout for one bounded recovery attempt. - _RECOVERY_BUDGET_S = 75 - _CLOSE_TIMEOUT_S = 10 - - async def execute(self, content: str, ctx: dict) -> dict: - """Run one browser action inside its session's lifecycle. - - Actions on one session are serialized. A call without an owning - Odysseus session gets a browser of its own that is closed before the - call returns; it never falls back to agent-browser's shared default - session. Cancellation stops every CLI client the call started and - cleans the session's browser tree, because its state is unknown. - """ - - ctx = dict(ctx) if isinstance(ctx, dict) else {} - session_id = str(ctx.get("session_id") or "").strip() - ephemeral = not session_id - if ephemeral: - session_id = f"ephemeral-{uuid.uuid4().hex}" - ctx["session_id"] = session_id - runtime_env = ctx.get("subproc_env") if isinstance(ctx.get("subproc_env"), dict) else {} - key = _scoped_browser_session(_browser_namespace(runtime_env), session_id) - browser = browser_lifecycle.session_for(key, ephemeral) - clock = browser_lifecycle.StageClock() - procs: list = [] - token = _BROWSER_CALL_PROCS.set(procs) - lock = browser.lock() - acquired = False - try: - await lock.acquire() - acquired = True - result = await self._execute_unlocked(content, ctx, browser=browser, clock=clock) - if ephemeral: - await self._release_session(browser, session_id, clock) - if isinstance(result, dict) and browser.env is not None: - result["browser_lifecycle"] = browser.receipt(clock) - return result - except asyncio.CancelledError: - if acquired: - for proc in procs: - if getattr(proc, "returncode", None) is None: - self._terminate_subprocess(proc) - if browser.env is not None: - self._terminate_owned_daemon(browser.env, session_id) - browser.discarded("cancelled") - raise - except Exception: - if acquired and ephemeral and browser.env is not None: - self._terminate_owned_daemon(browser.env, session_id) - raise - finally: - if acquired: - lock.release() - _BROWSER_CALL_PROCS.reset(token) - if ephemeral: - browser_lifecycle.forget(key) - _ACTIVE_BROWSER_SESSIONS.discard(key) - - async def _release_session( - self, - browser: browser_lifecycle.BrowserSession, - session_id: str, - clock: browser_lifecycle.StageClock, - ) -> None: - """Close a session gracefully, then verify nothing it owned survives.""" - - if browser.env is None: - return - started = time.monotonic() - graceful = False - if self._owned_daemon_exists(browser.env, session_id): - proc = None - try: - proc = await _spawn_browser_cli( - *browser.command_prefix, - "close", - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - env=browser.env, - start_new_session=True, - ) - await asyncio.wait_for(proc.wait(), timeout=self._CLOSE_TIMEOUT_S) - graceful = (proc.returncode or 0) == 0 - except Exception: - if proc is not None: - self._terminate_subprocess(proc) - receipt = self._terminate_owned_daemon(browser.env, session_id) - verified = bool(receipt.get("verified")) if isinstance(receipt, dict) else False - clock.record("close", started, verified or graceful) - clock.extra["cleanup"] = {"graceful_close": graceful, **(receipt or {})} - if browser.page_url: - clock.extra["closed_page_url"] = browser.page_url - browser.discarded("closed") - - def _discard_session( - self, - browser: browser_lifecycle.BrowserSession, - env: dict[str, str], - session_id: str, - clock: browser_lifecycle.StageClock, - state: str, - ) -> None: - """Force-clean a session whose browser state can no longer be trusted.""" - - started = time.monotonic() - receipt = self._terminate_owned_daemon(env, session_id or None) - verified = bool(receipt.get("verified")) if isinstance(receipt, dict) else False - clock.record("forced_cleanup", started, verified, reason=state) - clock.extra["cleanup"] = receipt - browser.discarded(state) - - @staticmethod - def _navigation_target(action: str, args: dict) -> str: - """URL this action navigates the session to, or ``""``.""" - - if action == "read" and any( - str(args.get(key) or "").strip() for key in ("selector", "target", "ref") - ): - return "" - if action in {"open", "read"}: - return str(args.get("url") or "").strip() - if action == "batch" and isinstance(args.get("commands"), list): - target = "" - for command in args["commands"]: - if ( - isinstance(command, list) - and len(command) > 1 - and str(command[0]).lower() in {"open", "goto", "navigate"} - ): - target = str(command[1]).strip() - return target - return "" - - @staticmethod - def _read_page_from_rows(output: str) -> dict[str, Any]: - """Page text from an open + ``get text`` batch, only if both succeeded.""" - - try: - rows = json.loads(output) - except (ValueError, TypeError): - rows = None - if not isinstance(rows, list) or len(rows) != 2 or not all(isinstance(r, dict) for r in rows): - return {"ok": False, "error": "private_browser read returned no structured page result"} - opened, extracted = rows - for row in rows: - if row.get("success") is not True: - return {"ok": False, "error": f"private_browser read failed: {row.get('error') or 'unknown error'}"} - opened_result = opened.get("result") if isinstance(opened.get("result"), dict) else {} - extracted_result = extracted.get("result") if isinstance(extracted.get("result"), dict) else {} - text = extracted_result.get("text") - if not isinstance(text, str): - return {"ok": False, "error": "private_browser read observed no page text"} - url = str(opened_result.get("url") or extracted_result.get("origin") or "") - title = str(opened_result.get("title") or "") - header = "\n".join(part for part in (title, url) if part) - return {"ok": True, "url": url, "text": f"{header}\n\n{text}".strip()} - - @staticmethod - def _batch_navigation_outcome(output: str, command_ok: bool) -> tuple[str, str]: - """Outcome of a batch's last navigation: ``ok``, ``failed`` or ``unknown``. - - A later command failing does not undo a navigation that succeeded, - so the per-command rows decide, not the batch exit status. - """ - - try: - rows = json.loads(output) - except (ValueError, TypeError): - rows = None - if isinstance(rows, list): - for row in reversed(rows): - command = row.get("command") if isinstance(row, dict) else None - if not ( - isinstance(command, list) - and command - and str(command[0]).lower() in {"open", "goto", "navigate"} - ): - continue - if row.get("success") is True: - result = row.get("result") if isinstance(row.get("result"), dict) else {} - return "ok", str(result.get("url") or "") - return "failed", "" - return ("ok", "") if command_ok else ("unknown", "") - - @staticmethod - def _navigated_url(output: str) -> str: - """Final URL reported by ``open`` (after redirects), when present.""" - - match = re.search(r"^\s+([a-z][a-z0-9+.-]*:\S+)\s*$", str(output or ""), re.MULTILINE) - return match.group(1) if match else "" - - async def _execute_unlocked( - self, - content: str, - ctx: dict, - *, - browser: browser_lifecycle.BrowserSession, - clock: browser_lifecycle.StageClock, - retry: bool = False, - deadline: float | None = None, - ) -> dict: - args, err = self._parse_args(content) - if err: - return {"error": err, "exit_code": 1} - args.pop("_odysseus_browser_retry", None) - - action = str(args.get("action") or "").strip().lower() - if action not in self._ACTIONS: - return { - "error": "private_browser: action must be one of " - + ", ".join(sorted(self._ACTIONS)), - "exit_code": 1, - } - - # ``snapshot`` captures the already-open browser page. It has no - # target-path argument, but a model can plausibly confuse it with the - # image-inspection tool. Previously that typo was silently ignored, - # allowing a stale page from the browser session to be presented as - # evidence about an unrelated local image. Reject it before starting - # a browser process and point the agent to the native visual tool. - if action == "snapshot" and str(args.get("path") or "").strip(): - return { - "error": ( - "private_browser snapshot does not accept path. " - "Use inspect_media with {\"path\": \"/workspace/...\"} " - "to inspect a local image, PDF, SVG, or video; use " - "private_browser open with a file:///workspace/*.html URL " - "to inspect a local HTML page." - ), - "exit_code": 1, - } - - binary = shutil.which("agent-browser") - if not binary: - binary = self._local_agent_browser_binary() - cmd_prefix = [binary] if binary else ["npx", "-y", "agent-browser"] - if not binary and not shutil.which("npx"): - return { - "error": ( - "private_browser requires agent-browser or npx. " - "Install with `npm install -g agent-browser && agent-browser install`." - ), - "exit_code": 1, - } - - cmd_prefix = self._with_session_args(cmd_prefix, ctx) - timeout_s = self._timeout_seconds(args, action=action) - screenshot_path: Path | None = None - batch_screenshot_paths: list[Path] = [] - command_args = dict(args) - try: - candidate_url = str(command_args.get("url") or "").strip() - if action in {"open", "read"} and ( - candidate_url.lower().startswith("file://") - or candidate_url == "/workspace" - or candidate_url.startswith("/workspace/") - ): - command_args["url"] = self._resolve_local_file_url( - candidate_url - ) - # agent-browser's `read URL` path accepts only HTTP(S), while - # `open` supports local file URLs and returns page state. Treat - # a model's local read request as the supported visual open. - if action == "read": - action = "open" - elif action == "screenshot" and str(command_args.get("path") or "").strip(): - resolved_screenshot = self._resolve_workspace_path( - str(command_args["path"]) - ) - if resolved_screenshot.suffix.lower() not in {".png", ".jpg", ".jpeg"}: - raise ValueError( - "screenshot path is an image OUTPUT destination, not a page to inspect; " - "use a .png, .jpg or .jpeg destination, or omit path. " - "Use open with url to view an HTML page first." - ) - command_args["path"] = str(resolved_screenshot) - screenshot_path = resolved_screenshot - except (OSError, ValueError) as exc: - return {"error": f"private_browser path rejected: {exc}", "exit_code": 1} - if action == "screenshot" and not str(command_args.get("path") or "").strip(): - screenshot_path = self._new_screenshot_path() - command_args["path"] = str(screenshot_path) - elif action == "batch": - command_args["commands"], batch_screenshot_paths = self._normalize_batch_screenshots( - command_args.get("commands") - ) - - command, stdin_data, err = self._command_for_action(cmd_prefix, action, command_args) - if err: - return {"error": err, "exit_code": 1} - - progress_cb = ctx.get("progress_cb") if isinstance(ctx, dict) else None - if progress_cb: - await progress_cb({"elapsed_s": 0, "tail": f"private_browser: {action}"}) - - # Capture the service account's npm cache before the tool sandbox - # replaces HOME with the task data directory. Without this, every - # isolated task asks npx to download agent-browser into a fresh cache - # and commonly hits the 45 second browser timeout. - host_npm_cache = ( - os.environ.get("npm_config_cache") - or os.environ.get("NPM_CONFIG_CACHE") - or str(_service_home() / ".npm") - ) - env = dict(os.environ) - if isinstance(ctx, dict) and isinstance(ctx.get("subproc_env"), dict): - env.update(ctx["subproc_env"]) - # The task runner gives ordinary subprocesses an isolated HOME. The - # browser daemon is different: Chromium's crashpad/profile bootstrap - # requires a real account home, while workspace access remains - # confined by the resolved file URL and the per-session namespace. - env["HOME"] = str(_service_home()) - env.setdefault("npm_config_loglevel", "error") - env.setdefault("NPM_CONFIG_LOGLEVEL", "error") - # agent-browser daemons otherwise default to a one-hour idle lifetime. - # A task can retain state across model rounds, but completed/aborted - # benchmark tasks must not leave Chrome sessions resident for hours. - env.setdefault("AGENT_BROWSER_IDLE_TIMEOUT_MS", "300000") - if not binary and not ( - env.get("npm_config_cache") or env.get("NPM_CONFIG_CACHE") - ): - env["npm_config_cache"] = host_npm_cache - env["NPM_CONFIG_CACHE"] = host_npm_cache - # agent-browser does not search the normal Playwright cache when it is - # launched through npx. Reuse the browser already installed for this - # Odysseus host instead of making every browser action depend on a - # second, separately managed Chrome download. - if not env.get("AGENT_BROWSER_EXECUTABLE_PATH"): - candidates = _browser_executable_candidates() - if candidates: - env["AGENT_BROWSER_EXECUTABLE_PATH"] = str(candidates[0]) - - opened_url = str(command_args.get("url") or "").strip().lower() - verifies_local_html = ( - action == "open" - and opened_url.startswith("file:") - and urllib.parse.urlsplit(opened_url).path.endswith((".html", ".htm")) - ) - # Browser sessions persist across actions, including their JavaScript - # error buffers. The current agent-browser release reports success for - # `errors --clear` without reliably clearing that buffer. Reset the - # session before opening a local artifact so verification considers - # only errors emitted by this page. Opening a URL replaces prior page - # state anyway; cookies are irrelevant for confined file:// artifacts. - session_id = str((ctx or {}).get("session_id") or "").strip() - browser.bind(env, cmd_prefix) - loop = asyncio.get_running_loop() - if deadline is None: - deadline = loop.time() + timeout_s + self._RECOVERY_BUDGET_S - warm = self._owned_daemon_exists(env, session_id) - if verifies_local_html and warm: - reset_started = time.monotonic() - await self._reset_browser_session(cmd_prefix, env, timeout_s) - clock.record("reset", reset_started, True) - browser.discarded("reset") - navigation_url = self._navigation_target(action, command_args) - stale_note = ( - browser.stale_observation_note() - if action in browser_lifecycle.OBSERVATION_ACTIONS and not navigation_url - else "" - ) - command_started = time.monotonic() - - # agent-browser starts a persistent daemon which can inherit the - # client's stdout/stderr descriptors. Pipes therefore never reach - # EOF when the short-lived CLI client exits, and communicate() waits - # until the browser idle timeout even though the command succeeded. - # Temporary files preserve the CLI output while making completion - # depend on the client process, not its detached daemon. - stdout_file = tempfile.TemporaryFile() - stderr_file = tempfile.TemporaryFile() - attempt_timeout = max(1.0, min(float(timeout_s), deadline - loop.time())) - proc = None - try: - proc = await _spawn_browser_cli( - *command, - stdin=asyncio.subprocess.PIPE if stdin_data is not None else None, - stdout=stdout_file, - stderr=stderr_file, - env=env, - start_new_session=True, - ) - await asyncio.wait_for( - proc.communicate(stdin_data.encode("utf-8") if stdin_data is not None else None), - timeout=attempt_timeout, - ) - stdout_file.seek(0) - stderr_file.seek(0) - stdout = stdout_file.read() - stderr = stderr_file.read() - except asyncio.TimeoutError: - if proc is not None: - with contextlib.suppress(Exception): - self._terminate_subprocess(proc) - clock.record(action, command_started, False, cold_start=not warm, failure="timeout") - self._discard_session(browser, env, session_id, clock, "timed_out") - # A failed local-page verification can leave agent-browser's - # persistent session between a page-error response and the next - # repair attempt. The session was cleaned above, so reopen exactly - # once within the call's deadline; never retry mutating browser - # actions or arbitrary URLs. - remaining = deadline - loop.time() - if verifies_local_html and action == "open" and not retry and remaining >= 10: - retry_args = dict(args) - retry_args["timeout_ms"] = int( - min(max(60.0, float(timeout_s)), remaining - 5) * 1000 - ) - clock.extra["recovery_attempts"] = 1 - return await self._execute_unlocked( - json.dumps(retry_args), ctx, - browser=browser, clock=clock, retry=True, deadline=deadline, - ) - return { - "error": f"private_browser timed out after {int(attempt_timeout)}s", - "exit_code": 1, - } - except Exception as e: - if proc is not None: - with contextlib.suppress(Exception): - self._terminate_subprocess(proc) - clock.record(action, command_started, False, cold_start=not warm, failure=type(e).__name__) - if proc is not None: - # The client reached the daemon, so the session's state is - # unknown. A client that never started left it untouched. - self._discard_session(browser, env, session_id, clock, "failed") - return {"error": f"private_browser failed: {type(e).__name__}: {e}", "exit_code": 1} - finally: - stdout_file.close() - stderr_file.close() - - out = stdout.decode("utf-8", errors="replace").strip() - err_text = stderr.decode("utf-8", errors="replace").strip() - combined = out - if err_text: - combined = f"{combined}\n\n[stderr]\n{err_text}".strip() - command_ok = (proc.returncode or 0) == 0 - read_page = None - if action == "read" and navigation_url: - read_page = self._read_page_from_rows(out) - command_ok = command_ok and read_page.get("ok", False) - clock.record(action, command_started, command_ok, cold_start=not warm) - if not command_ok and browser_lifecycle.LAUNCH_FAILURE_RE.search(combined): - # The browser never became ready. The daemon outlives this failure - # and a later close cannot reach a browser, so clean it here. - self._discard_session(browser, env, session_id, clock, "launch_failed") - return { - "output": combined[:4000], - "error": ( - "private_browser could not launch the browser; no page was " - "opened or observed. The browser session was cleaned up." - ), - "exit_code": 1, - "untrusted_content": True, - } - if navigation_url: - outcome, final_url = "ok" if command_ok else "failed", "" - if action == "batch": - outcome, final_url = self._batch_navigation_outcome(out, command_ok) - if outcome == "ok": - browser.navigated( - final_url - or (read_page or {}).get("url") - or self._navigated_url(out) - or navigation_url - ) - elif outcome == "failed": - browser.navigation_failed(navigation_url) - else: - browser.navigation_unknown(navigation_url) - if read_page is not None: - if not command_ok: - return { - "output": combined[:4000], - "error": read_page.get("error") or "private_browser read failed; no page text was observed", - "exit_code": 1, - "untrusted_content": True, - } - out = read_page["text"] - combined = out if not err_text else f"{out}\n\n[stderr]\n{err_text}" - elif command_ok and browser.state in {"idle", "closed", "reset"}: - browser.state = "ready" - if stale_note and command_ok: - combined = f"[{stale_note}]\n\n{combined}".strip() - clock.extra["stale_observation"] = True - from src.turn_contract import active_turn_contract - contract = active_turn_contract() - model_choice = getattr(contract, 'routing_experiment', '') == 'recent_model_choice' - if action == 'snapshot' and model_choice and (proc.returncode or 0) == 0: - combined = self._dialog_first_snapshot(out) - if err_text: - combined = f"[stderr]\n{err_text}\n\n{combined}".strip() - fill_error = "" - empty_observation = (proc.returncode or 0) == 0 and self._empty_dom_observation(out) - observe_state_change = action in {"open", "fill", "press"} and model_choice - failed_interaction = action in {"click", "fill"} and (proc.returncode or 0) != 0 - if empty_observation or failed_interaction or ((action == "click" or observe_state_change) and (proc.returncode or 0) == 0): - # A click can navigate, replace the DOM, or open a modal. Return - # the settled post-click DOM in the same tool result so callers do - # not race navigation with a separate immediate read and so the - # next conversational turn receives current element refs. A failed - # interaction also needs refs for a covering dialog - # or changed DOM. A successful fill may run input handlers that - # open a modal or replace the field: CLI success is not proof that - # the intended value survived. Observe only; never retry an action. - post_click_state, fill_error = await self._capture_post_click_state( - cmd_prefix, env, timeout_s, - verify_fill=(command[-2], command[-1]) - if action == "fill" and not failed_interaction else None, - ) - if post_click_state: - label = f'page state after failed {action}' if failed_interaction else f'post-{action} page state' - combined = f"{combined}\n\n[{label}]\n{post_click_state}".strip() - if fill_error: - # The CLI's optimistic "Done" contradicts verified failure. - # Report the outcome, retaining current DOM but not that claim. - combined = fill_error - if post_click_state: - combined += f"\n\n[post-fill page state]\n{post_click_state}" - # Parallel benchmark runtimes can race a detached agent-browser - # daemon during Chromium bootstrap. Recover once for a confined - # local HTML verification, after cleaning only this runtime's browser - # state. Do not retry arbitrary URLs or mutating browser actions. - if ( - verifies_local_html - and action == "open" - and (proc.returncode or 0) != 0 - and self._retryable_local_open_failure(combined) - and not retry - and deadline - loop.time() >= 10 - ): - self._discard_session(browser, env, session_id, clock, "bootstrap_failed") - clock.extra["recovery_attempts"] = 1 - return await self._execute_unlocked( - json.dumps(args), ctx, - browser=browser, clock=clock, retry=True, deadline=deadline, - ) - page_errors = "" - if ( - verifies_local_html - and (proc.returncode or 0) == 0 - ): - page_errors = await self._capture_page_errors( - cmd_prefix, - env, - timeout_s, - ) - if page_errors: - combined = f"{combined}\n\n[page errors]\n{page_errors}".strip() - if len(combined) > MAX_OUTPUT_CHARS: - from src.browser_observation import compact_browser_observation - combined = compact_browser_observation(combined, budget=MAX_OUTPUT_CHARS) - shopping_hint = self._shopping_landing_hint(combined) - if shopping_hint: - combined = f"{combined}\n\n[{shopping_hint}]" - result = { - "output": combined, - "exit_code": 1 if page_errors or fill_error else (proc.returncode or 0), - "untrusted_content": True, - } - if page_errors: - result["error"] = ( - "The local HTML page opened, but JavaScript page errors were " - "detected. Fix the artifact and reopen it to verify." - ) - elif fill_error: - result["error"] = fill_error - result["browser_command_exit_code"] = proc.returncode or 0 - if ( - action == "screenshot" - and screenshot_path - and (proc.returncode or 0) == 0 - and screenshot_path.exists() - and screenshot_path.stat().st_size > 0 - ): - image = self._image_payload_from_path(screenshot_path) - if image: - result["images"] = [image] - elif action in self._AUTO_SCREENSHOT_ACTIONS and (proc.returncode or 0) == 0: - image = await self._capture_screenshot(cmd_prefix, env, timeout_s) - if image: - result["images"] = [image] - if batch_screenshot_paths and (proc.returncode or 0) == 0: - images = [self._image_payload_from_path(path) for path in batch_screenshot_paths] - images = [image for image in images if image] - if images: - result["images"] = images - return result - - async def _capture_post_click_state( - self, - cmd_prefix: list[str], - env: dict[str, str], - timeout_s: int, - *, - verify_fill: tuple[str, str] | None = None, - ) -> tuple[str, str]: - """Return a bounded settled observation without repeating an action.""" - commands = [["wait", "1000"], ["snapshot"]] - unverified = "Browser fill could not be verified; the input outcome is unknown." if verify_fill else "" - if verify_fill: - # Read using the old handle BEFORE snapshot replaces the ref map. - # Never repeat the fill or disclose input values in diagnostics. - commands.insert(1, ["get", "value", verify_fill[0]]) - from src.turn_contract import active_turn_contract - model_choice = getattr(active_turn_contract(), 'routing_experiment', '') == 'recent_model_choice' - # A navigation can acknowledge the click before the destination renders. - # Retry only an explicitly empty observation, once, within ONE deadline. - # Never repeat the action or read old fill refs after a snapshot refresh. - loop = asyncio.get_running_loop() - deadline = loop.time() + min(timeout_s, 20) - text, observation_note = "", "" - first_rows, rows = [], [] - for attempt in range(2): - proc = None - try: - async with asyncio.timeout(max(0, deadline - loop.time())): - proc = await _spawn_browser_cli( - *cmd_prefix, "batch", "--json", - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, start_new_session=True, - ) - stdout, stderr = await proc.communicate(json.dumps(commands).encode()) - if (proc.returncode or 0) != 0: - raise RuntimeError('observation failed') - except Exception: - if proc is not None: - with contextlib.suppress(Exception): - self._terminate_subprocess(proc) - if not attempt: - return "", unverified - observation_note = "A fresh page snapshot could not be obtained; the last observation was empty." - break - observed = stdout.decode("utf-8", errors="replace").strip() - if not observed: - observed = stderr.decode("utf-8", errors="replace").strip() - try: - observed_rows = json.loads(observed) - if not isinstance(observed_rows, list): - observed_rows = [] - except (ValueError, TypeError): - observed_rows = [] - snapshots = [row['result']['snapshot'] for row in observed_rows - if isinstance(row, dict) and row.get('success') is True - and isinstance(row.get('result'), dict) - and isinstance(row['result'].get('snapshot'), str)] - if attempt and not snapshots: - observation_note = "A fresh page snapshot could not be obtained; the last observation was empty." - break - text, rows = observed, observed_rows - if not attempt: - first_rows = rows - if not snapshots or not self._empty_dom_observation(observed): - break - commands = [["wait", "1000"], ["snapshot"]] - if not observation_note and self._empty_dom_observation(text): - observation_note = ( - "Browser observation incomplete: the page still has no readable content after waiting. " - "Navigation success is not evidence that results loaded. Do not infer page results." - ) - fill_error = "" - if verify_fill: - fill_error = unverified - for row in first_rows: - if not isinstance(row, dict) or row.get("command") != ["get", "value", verify_fill[0]]: - continue - value = row.get("result") - if row.get("success") is True and isinstance(value, dict) and isinstance(value.get("value"), str): - fill_error = "" if value["value"] == verify_fill[1] else ( - "Browser input did not retain the requested text; fill is incomplete." - ) - break - # Keep only snapshot rows. A missing/malformed snapshot must never - # fall back to dumping the raw value-verification response. - text = json.dumps([row for row in rows if isinstance(row, dict) - and isinstance(row.get("result"), dict) - and isinstance(row["result"].get("snapshot"), str)]) - if model_choice: - text = self._snapshot_observation(text) - if observation_note: - text += '\n' + observation_note - if len(text) > MAX_OUTPUT_CHARS: - from src.browser_observation import compact_browser_observation - text = compact_browser_observation(text, budget=MAX_OUTPUT_CHARS) - return text, fill_error - @staticmethod def _empty_dom_observation(text: str) -> bool: """Recognize empty accessibility scaffolding, not an actual no-results message.""" @@ -3378,103 +2662,6 @@ class PrivateBrowserTool: snapshots.append((str(result.get('origin') or '') + '\n' + snapshot).strip()) return '\n\n'.join(snapshots + errors) if snapshots else text - - async def _reset_browser_session( - self, - cmd_prefix: list[str], - env: dict[str, str], - timeout_s: int, - ) -> None: - """Best-effort reset of state retained by a persistent browser session.""" - proc = None - try: - proc = await _spawn_browser_cli( - *cmd_prefix, - "close", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - start_new_session=True, - ) - await asyncio.wait_for( - proc.communicate(), - timeout=min(timeout_s, 20), - ) - except Exception: - if proc is not None: - with contextlib.suppress(Exception): - self._terminate_subprocess(proc) - - async def _capture_page_errors( - self, - cmd_prefix: list[str], - env: dict[str, str], - timeout_s: int, - ) -> str: - """Return bounded JavaScript errors from the current browser page.""" - proc = None - try: - proc = await _spawn_browser_cli( - *cmd_prefix, - "errors", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - start_new_session=True, - ) - stdout, _stderr = await asyncio.wait_for( - proc.communicate(), - timeout=min(timeout_s, 20), - ) - except Exception: - if proc is not None: - with contextlib.suppress(Exception): - self._terminate_subprocess(proc) - return "" - if (proc.returncode or 0) != 0: - return "" - text = stdout.decode("utf-8", errors="replace").strip() - if not text or re.fullmatch( - r"(?:no (?:page )?errors?(?: found)?|0 errors?|\[\])\.?", - text, - re.IGNORECASE, - ): - return "" - return text[:4000] - - async def _capture_screenshot( - self, - cmd_prefix: list[str], - env: dict[str, str], - timeout_s: int, - ) -> dict[str, str] | None: - screenshot_path = self._new_screenshot_path() - command, stdin_data, err = self._command_for_action( - cmd_prefix, - "screenshot", - {"path": str(screenshot_path)}, - ) - if err or stdin_data is not None: - return None - proc = None - try: - proc = await _spawn_browser_cli( - *command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - start_new_session=True, - ) - await asyncio.wait_for(proc.communicate(), timeout=min(timeout_s, 20)) - except Exception: - if proc is not None: - with contextlib.suppress(Exception): - proc.kill() - return None - if (proc.returncode or 0) != 0: - return None - return self._image_payload_from_path(screenshot_path) - def _new_screenshot_path(self) -> Path: configured = os.getenv("ODYSSEUS_BROWSER_SCREENSHOT_DIR") candidates = [ @@ -3497,120 +2684,6 @@ class PrivateBrowserTool: ) as tmp: return Path(tmp.name) - def _normalize_batch_screenshots(self, commands: Any) -> tuple[Any, list[Path]]: - if not isinstance(commands, list): - return commands, [] - paths: list[Path] = [] - normalized: list[Any] = [] - for command in commands: - if isinstance(command, list) and command: - action = str(command[0]).strip().lower() - if action == "wait": - # Compact/OpenAI schemas sometimes preserve an omitted - # selector as null and put the timeout in the next slot: - # ["wait", null, 2500]. agent-browser accepts only arrays - # of strings, so recover the intended timeout instead of - # rejecting the whole browser batch. - wait_args = [value for value in command[1:] if value is not None] - normalized.append(["wait", *[str(value) for value in wait_args]]) - continue - if action in {"open", "read"} and len(command) >= 2: - candidate_url = str(command[1] or "").strip() - if ( - candidate_url.lower().startswith("file://") - or candidate_url == "/workspace" - or candidate_url.startswith("/workspace/") - ): - normalized.append([ - "open", - self._resolve_local_file_url(candidate_url), - *command[2:], - ]) - continue - if action == "read" and not re.match( - r"^(?:https?|file)://", candidate_url, re.IGNORECASE - ): - # The top-level read action treats target/selector as - # DOM text extraction. Keep batch semantics identical; - # agent-browser's bare `read h1` instead interprets h1 - # as a URL/path and fails before the model can answer. - normalized.append(["get", "text", candidate_url]) - continue - if action == "evaluate": - normalized.append(["eval", *command[1:]]) - continue - if action == "find" and len(command) == 2: - normalized.append(["find", "text", str(command[1]), "text"]) - continue - if isinstance(command, dict): - action = str(command.get("action") or "").strip().lower() - candidate_url = str(command.get("url") or "").strip() - if action in {"open", "read"} and ( - candidate_url.lower().startswith("file://") - or candidate_url == "/workspace" - or candidate_url.startswith("/workspace/") - ): - updated = dict(command) - updated["action"] = "open" - updated["url"] = self._resolve_local_file_url(candidate_url) - command = updated - if isinstance(command, list) and command and str(command[0]).strip().lower() == "screenshot": - path = self._new_screenshot_path() - paths.append(path) - normalized.append(["screenshot", str(path)]) - continue - elif isinstance(command, dict) and str(command.get("action") or "").strip().lower() == "screenshot": - path = self._new_screenshot_path() - paths.append(path) - normalized.append(["screenshot", str(path)]) - continue - if isinstance(command, dict): - action = str(command.get("action") or "").strip().lower() - converted, stdin_data, error = self._command_for_action([], action, command) - if not error and stdin_data is None and converted: - normalized.append(converted) - continue - normalized.append(command) - # Opening a page invalidates every prior element ref. A small router - # sometimes guesses human labels ("search input") and places fill or - # click immediately after open in the same batch. That cannot use the - # new DOM and predictably fails. End that batch at a snapshot so the - # next model round receives real refs; preserve explicit refs/CSS for - # callers that intentionally supplied a stable selector. - open_index = next(( - index for index, command in enumerate(normalized) - if ( - isinstance(command, list) and command - and str(command[0]).strip().lower() == "open" - ) or ( - isinstance(command, dict) - and str(command.get("action") or "").strip().lower() == "open" - ) - ), None) - if open_index is not None: - for index in range(open_index + 1, len(normalized)): - command = normalized[index] - if isinstance(command, list) and command: - action = str(command[0]).strip().lower() - target = str(command[1] if len(command) > 1 else "").strip() - elif isinstance(command, dict): - action = str(command.get("action") or "").strip().lower() - target = str(command.get("selector") or command.get("target") or "").strip() - else: - continue - if action == "snapshot": - break - if action not in {"click", "fill", "wait"}: - continue - explicit_selector = bool( - target.startswith(("@", "#", ".", "[", "//", "xpath=", "css=")) - or any(char in target for char in (">", ":", "[", "]")) - ) - if target and not explicit_selector: - normalized = [*normalized[:index], ["snapshot"]] - break - return normalized, paths - def _image_payload_from_path(self, path: Path) -> dict[str, str] | None: try: if not path.exists() or path.stat().st_size <= 0: @@ -3622,242 +2695,22 @@ class PrivateBrowserTool: except Exception: return None - def _parse_args(self, content: str) -> tuple[dict, str | None]: - raw = (content or "").strip() - if not raw: - return {}, "private_browser: provide a JSON object with an action" - try: - parsed = json.loads(raw) - except json.JSONDecodeError: - return {"action": "read", "url": raw}, None - if not isinstance(parsed, dict): - return {}, "private_browser: arguments must be a JSON object" - return parsed, None - - def _timeout_seconds(self, args: dict, *, action: str = "") -> int: - value = args.get("timeout_ms") - if isinstance(value, int) and value > 0: - if action == "wait" and not any( - str(args.get(key) or "").strip() - for key in ("selector", "target", "key") - ): - # For a bare wait, timeout_ms is the requested sleep duration, - # not the subprocess deadline. Leave startup/IPC headroom so - # `wait 2000` cannot race an asyncio timeout at exactly 2s. - return min(125, max(45, (value + 999) // 1000 + 5)) - return max(1, min(120, value // 1000 or 1)) - return 45 - - def _command_for_action( - self, - prefix: list[str], - action: str, - args: dict, - ) -> tuple[list[str], str | None, str | None]: - if action in {"read", "wait", "click", "fill"} and "ref" in args: - # Snapshots label elements as ref=eN. Accept that explicit handle - # as a transport alias, not as permission to infer a CSS selector. - ref = str(args.get("ref") or "").strip() - if not re.fullmatch(r"@?e[0-9]+", ref): - return [], None, "private_browser: ref must be an element handle such as e2 or @e2" - target = "@" + ref.lstrip("@") - supplied = [str(args[key]).strip() for key in ("selector", "target") if args.get(key)] - if any(value not in {target, target[1:]} for value in supplied): - return [], None, "private_browser: ref conflicts with selector/target; specify one element" - args = {**args, "selector": target} - if action == "open": - url = str(args.get("url") or "").strip() - if not url: - return [], None, "private_browser open: url is required" - return [*prefix, "open", url], None, None - if action == "read": - target = str(args.get("selector") or args.get("target") or "").strip() - if target: - return [*prefix, "get", "text", target], None, None - url = str(args.get("url") or "").strip() - # agent-browser has no `read` command. Navigate and extract in one - # client call so the text is observed after this navigation. - if url: - return [*prefix, "batch", "--json"], json.dumps( - [["open", url], ["get", "text", "body"]] - ), None - return [*prefix, "get", "text", "body"], None, None - if action == "snapshot": - return [*prefix, "snapshot"], None, None - if action == "find": - value = str(args.get("find") or args.get("text") or args.get("value") or "").strip() - if not value: - return [], None, "private_browser find: find/text is required" - return [*prefix, "find", "text", value, "text"], None, None - if action == "evaluate": - script = str(args.get("script") or args.get("text") or args.get("value") or "").strip() - if not script: - return [], None, "private_browser evaluate: script is required" - return [*prefix, "eval", script], None, None - if action == "close": - return [*prefix, "close"], None, None - if action == "scroll": - direction = str( - args.get("direction") or args.get("target") or "down" - ).strip().lower() - if direction in {"bottom", "end"}: - return [*prefix, "press", "End"], None, None - if direction in {"top", "home"}: - return [*prefix, "press", "Home"], None, None - if direction not in {"up", "down", "left", "right"}: - return [], None, ( - "private_browser scroll: direction must be up, down, left, " - "right, top, or bottom" - ) - raw_amount = args.get("amount", 300) - try: - amount = max(1, min(100_000, int(raw_amount))) - except (TypeError, ValueError): - return [], None, "private_browser scroll: amount must be an integer" - # Compact routers often express scrolling as 1-10 wheel steps even - # though this wrapper accepts pixels. Five pixels is effectively a - # no-op and caused repeated snapshot loops. Interpret these tiny - # values as conventional 300px wheel steps. - if amount <= 10: - amount *= 300 - return [*prefix, "scroll", direction, str(amount)], None, None - if action == "wait": - target = str(args.get("selector") or args.get("target") or "").strip() - if target: - return [*prefix, "wait", target], None, None - duration_ms = args.get("timeout_ms") - if isinstance(duration_ms, int) and duration_ms > 0: - return [*prefix, "wait", str(min(120_000, duration_ms))], None, None - return [], None, "private_browser wait: selector/target or timeout_ms is required" - if action in {"click", "press"}: - target = str(args.get("selector") or args.get("target") or args.get("key") or "").strip() - if not target: - return [], None, f"private_browser {action}: selector/target/key is required" - if action == "click": - role_name = str(args.get("text") or args.get("value") or "").strip() - role = target.casefold() - if role_name and role in { - "link", "button", "menuitem", "tab", "checkbox", "radio", - }: - return [ - *prefix, "find", "role", role, "click", "--name", role_name, - ], None, None - quoted_role = re.fullmatch( - r"(?Plink|button|menuitem|tab|checkbox|radio)\s+" - r"(?P['\"])(?P.*?)(?P=quote)", - target, - re.IGNORECASE, - ) - if quoted_role: - return [ - *prefix, "find", "role", quoted_role.group("role").lower(), - "click", "--name", quoted_role.group("name").strip(), - ], None, None - visible_text = re.fullmatch( - r"(?P[a-z][a-z0-9_-]*)?:has-text\(\s*" - r"(?P['\"])(?P.*?)(?P=quote)\s*\)", - target, - re.IGNORECASE, - ) - if visible_text: - text = visible_text.group("text").strip() - role = { - "a": "link", - "button": "button", - }.get((visible_text.group("tag") or "").lower()) - if role: - return [ - *prefix, "find", "role", role, "click", "--name", text, - ], None, None - return [*prefix, "find", "text", text, "click"], None, None - return [*prefix, action, target], None, None - if action == "fill": - selector = str(args.get("selector") or args.get("target") or "").strip() - text = str(args.get("text") or args.get("value") or "") - if not selector: - return [], None, "private_browser fill: selector is required" - return [*prefix, "fill", selector, text], None, None - if action == "screenshot": - path = str(args.get("path") or "").strip() - command = [*prefix, "screenshot"] - if path: - command.append(path) - return command, None, None - commands = args.get("commands") - if not isinstance(commands, list): - return [], None, "private_browser batch: commands must be a list" - if not commands: - # Some compact routers emit an empty batch as "continue inspecting - # the current page". Treat it as a harmless snapshot so the agent - # gets state back instead of burning failed rounds and being forced - # to stop before it can scroll/click/read. - return [*prefix, "snapshot"], None, None - return [*prefix, "batch", "--json"], json.dumps(commands), None - - def _with_session_args(self, prefix: list[str], ctx: dict) -> list[str]: - session_id = str((ctx or {}).get("session_id") or "").strip() - if not session_id: - return prefix - runtime_env = (ctx or {}).get("subproc_env") if isinstance(ctx, dict) else None - namespace = str( - (runtime_env or {}).get("ODYSSEUS_BROWSER_NAMESPACE") - or os.getenv("ODYSSEUS_BROWSER_NAMESPACE", "odysseus-ui") - ).strip() or "odysseus-ui" - # Upstream agent-browser exposes --session, not --namespace. Fold the - # runtime namespace into the session key so independent Odysseus - # runtimes remain isolated without relying on a fork-only CLI flag. - scoped_session = _scoped_browser_session(namespace, session_id) - _ACTIVE_BROWSER_SESSIONS.add(scoped_session) - return [*prefix, "--session", scoped_session] async def shutdown_private_browser_sessions() -> None: - """Close and verify every browser session this runtime started. - - Each session is closed with the environment it was launched with, so its - runtime directory resolves to the daemon's own. ``close`` is sent only to - a verified live daemon, because against a missing one it bootstraps a new - browser. Forced cleanup of the session's own browser tree always follows. - """ - - binary = shutil.which("agent-browser") or PrivateBrowserTool._local_agent_browser_binary() - command_prefix = [binary] if binary else ( - ["npx", "-y", "agent-browser"] if shutil.which("npx") else [] - ) - sessions = sorted(_ACTIVE_BROWSER_SESSIONS) - if not command_prefix or not sessions: - return - base_env = dict(os.environ) - base_env["HOME"] = str(_service_home()) - base_env.setdefault("AGENT_BROWSER_IDLE_TIMEOUT_MS", "300000") - try: - for session in sessions: - record = browser_lifecycle.registered(session) - env = record.env if record is not None and record.env is not None else base_env - root = browser_lifecycle.runtime_root(env) - if browser_lifecycle.has_live_daemon( - root, session, pid_alive=lambda pid: _process_is_alive(pid) - ): - proc = None - try: - proc = await _spawn_browser_cli( - *command_prefix, "--session", session, "close", - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - env=env, - start_new_session=True, - ) - await asyncio.wait_for(proc.communicate(), timeout=20) - except Exception: - if proc is not None: - with contextlib.suppress(Exception): - PrivateBrowserTool._terminate_subprocess(proc) - browser_lifecycle.force_cleanup( - root, session, method="shutdown", - pid_alive=lambda pid: _process_is_alive(pid), - ) - browser_lifecycle.forget(session) - finally: - _ACTIVE_BROWSER_SESSIONS.difference_update(sessions) - PrivateBrowserTool._terminate_owned_chrome(base_env) - PrivateBrowserTool._terminate_owned_daemon(base_env) + """Service-owned cleanup only; never discover/download a producer binary.""" + for session in tuple(_ACTIVE_BROWSER_SESSIONS): + record = browser_lifecycle.registered(session) + if record is not None and record.env is not None: + browser_lifecycle.force_cleanup(browser_lifecycle.runtime_root(record.env), session, + method="shutdown", pid_alive=lambda pid: _process_is_alive(pid)) + browser_lifecycle.forget(session) + _ACTIVE_BROWSER_SESSIONS.discard(session) + from src.browser_identity import _REGISTRY + for record in tuple(_REGISTRY.values()): + session = record.session + if session is not None and session.observation.daemon.owned(): + browser_lifecycle.force_cleanup(Path(record.env["AGENT_BROWSER_SOCKET_DIR"]), record.key, + method="shutdown", pid_alive=lambda pid: _process_is_alive(pid)) + record.invalidate() + _REGISTRY.clear() diff --git a/src/browser_identity.py b/src/browser_identity.py new file mode 100644 index 000000000..cfac39f2c --- /dev/null +++ b/src/browser_identity.py @@ -0,0 +1,649 @@ +"""Trusted browser observations. No page execution capability is available. + +0.35.0 local-launch CLI drops pin flags on `session info`; live Docker probes +proved destroyed-target retargeting. Observations are not permission to run a +page command. The future producer must atomically enforce expected identities. +""" +from __future__ import annotations + +import asyncio +import base64 +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, replace, field +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import struct +import tempfile +from typing import Any +from urllib.parse import urlsplit + +from src.agent_runtime.resources import ( + BrowserPageResource, BrowserSessionObservation, BrowserSessionResource, + NativeBackendResource, ResourceIdentityError, +) +from src.process_lifecycle import ProcessIdentity, observe +from src.constants import BROWSER_RESOURCES_DIR + +PRODUCER_VERSION = "0.35.0" +PRODUCER_HASHES = { + "linux-x64": "b7a28c3a43a7008dd02585e2e60c391c08983f7a099149caed63c9f13f57b752", + "linux-arm64": "92cd7d0897837ac648b9a6ab1965c69c5920e0f54df57e4295cdb1143b0541c8", +} +# Explicit release installation paths; PATH and npm caches are never searched. +PRODUCER_ROOT = Path("/usr/local/lib/node_modules/agent-browser/bin") +STATE_ROOT = Path(BROWSER_RESOURCES_DIR) +CLIENT_DEADLINE_S = 20 # Below 0.35.0's source-verified 30s read/resend floor. +CDP_DEADLINE_S = 3 +CDP_METHODS = frozenset({"Target.getTargets", "Target.getTargetInfo", "Target.attachToTarget", + "Page.getFrameTree", "Target.detachFromTarget"}) +PAGE_ACTIONS = frozenset({"open", "read", "snapshot", "find", "evaluate", "click", "fill", + "press", "scroll", "wait", "screenshot", "navigate", "reload", "back", "forward", + "select_page", "close_page", "network", "console", "new_page", "tabs"}) +SESSION_ACTIONS = frozenset({"session_info"}) +PAGE_FAILURE = "browser_page_authority_unavailable" +_ACTIVE = ContextVar("browser_resource_operation", default=None) +_REGISTRY: dict[tuple[str, str], "RegisteredBrowser"] = {} + + +def digest(domain, value): + return hashlib.sha256((domain + "\0" + json.dumps(value, sort_keys=True, separators=(",", ":"))).encode()).hexdigest() + + +def incarnation(observation): + values = observation.to_dict() if hasattr(observation, "to_dict") else dict(observation) + values.pop("session_incarnation", None) + return digest("odysseus.browser.session.v1", values) + + +def browser_digest(url): + # Never include the capability URL, raw GUID or exceptions containing them + # in results/logs/persisted records. + if not isinstance(url, str) or not re.fullmatch( + r"ws://127\.0\.0\.1:[1-9][0-9]{0,4}/devtools/browser/[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}", url): + raise ResourceIdentityError("Unverifiable browser endpoint") + parsed = urlsplit(url) + if parsed.port is None or parsed.port > 65535: + raise ResourceIdentityError("Invalid browser endpoint port") + return digest("odysseus.browser.guid.v1", parsed.path.rsplit("/", 1)[-1]) + + +def page_unavailable(): + return {"error": "The configured producer cannot guarantee stable binding to the captured page in local-launch mode.", + "exit_code": 1, "failure_kind": PAGE_FAILURE, "executed": False, + "retryable": False, "producer_capability_unavailable": True} + + +def parse_operation(content): + from src.agent_runtime.authority import ExactOperation + operation = ExactOperation.normalize("private_browser", content) + try: + args = json.loads(operation.input) + except (ValueError, TypeError): + raise ResourceIdentityError("Browser arguments require a JSON object") from None + if not isinstance(args, dict): + raise ResourceIdentityError("Browser arguments require a JSON object") + action = args.get("action") + if not isinstance(action, str) or action not in PAGE_ACTIONS | SESSION_ACTIONS | {"close"}: + raise ResourceIdentityError("Unsupported browser action; raw commands and batch are forbidden") + allowed = {"action", "page", "url", "selector", "target", "ref", "key", "direction", "amount", + "timeout_ms", "timeout_s", "text", "value", "script", "path", "find"} + if set(args) - allowed: + raise ResourceIdentityError("Browser flags, labels, configuration and raw targetIds are forbidden") + if "page" in args and (not isinstance(args["page"], str) or not re.fullmatch(r"t[1-9][0-9]*", args["page"])): + raise ResourceIdentityError("Browser page selector must be tN") + if action in SESSION_ACTIONS and set(args) != {"action"}: + raise ResourceIdentityError("Session metadata takes no page or CLI arguments") + for key, value in args.items(): + if isinstance(value, str) and ("\0" in value or value.lstrip().startswith("-")): + raise ResourceIdentityError("Model values cannot become browser flags") + return operation, args + + +def native_browser(operation, backend): + return operation.tool == "private_browser" and isinstance(backend, NativeBackendResource) + + +@dataclass(frozen=True) +class TrustedProducer: + path: Path + platform: str + binary_sha256: str + + def validate(self): + if (self.path != PRODUCER_ROOT / ("agent-browser-" + self.platform) + or self.path.is_symlink() or not self.path.is_file() + or self.path.stat().st_mode & 0o022 + or self.path.stat().st_uid != os.getuid() and self.path.stat().st_uid != 0 + or hashlib.sha256(self.path.read_bytes()).hexdigest() != PRODUCER_HASHES.get(self.platform)): + raise ResourceIdentityError("Browser producer is not an allowlisted release binary") + + +async def trusted_producer(): + machine = {"x86_64": "x64", "aarch64": "arm64"}.get(platform.machine()) + key = platform.system().lower() + "-" + str(machine) + if key not in PRODUCER_HASHES: + raise ResourceIdentityError("Unsupported browser producer platform") + producer = TrustedProducer(PRODUCER_ROOT / ("agent-browser-" + key), key, PRODUCER_HASHES[key]) + producer.validate() + stdout, _ = await run_client([str(producer.path), "--version"], env={"PATH": "/usr/bin:/bin"}, cwd="/") + if stdout.strip() != "agent-browser " + PRODUCER_VERSION: + raise ResourceIdentityError("Unsupported browser producer version") + return producer + + +async def run_client(argv, *, env, cwd): + """One bounded invocation, never retry. Timeout/cancellation kills the client. + + Internal immediate EOF/reset retries cannot be eliminated by an outer + deadline. Consequently no effect is authorized by this client wrapper. + """ + process = None + # Files avoid detached daemon pipe inheritance keeping communicate alive. + with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err: + spawn = None + try: + spawn = asyncio.create_task(asyncio.create_subprocess_exec(*argv, stdout=out, stderr=err, + stdin=asyncio.subprocess.DEVNULL, env=env, cwd=cwd, start_new_session=True)) + process = await asyncio.shield(spawn) + await asyncio.wait_for(process.wait(), CLIENT_DEADLINE_S) + if process.returncode != 0: + raise ResourceIdentityError("Browser producer command failed") + out.seek(0); err.seek(0) + raw = out.read(1024 * 1024 + 1) + if len(raw) > 1024 * 1024: + raise ResourceIdentityError("Oversized producer response") + return raw.decode("utf-8", errors="strict"), "" + except (asyncio.TimeoutError, asyncio.CancelledError): + if process is None and spawn is not None: + process = await asyncio.shield(spawn) + if process is not None and process.returncode is None: + process.kill() + await asyncio.shield(process.wait()) + raise + + +def response(raw): + from src.agent_runtime.authority import _pairs, _invalid_constant + try: + value = json.loads(raw, object_pairs_hook=_pairs, parse_constant=_invalid_constant) + except (ValueError, TypeError): + raise ResourceIdentityError("Malformed browser producer response") from None + if (not isinstance(value, dict) or set(value) - {"success", "data", "error"} or value.get("success") is not True + or value.get("error") is not None or not isinstance(value.get("data"), dict)): + raise ResourceIdentityError("Unsuccessful browser producer response") + return value["data"] + + +@dataclass +class RegisteredBrowser: + owner: str + thread_id: str + producer: TrustedProducer + key: str + cwd: Path + env: dict[str, str] + config: Path + config_identity: tuple[int, int] + lock: asyncio.Lock + session: BrowserSessionResource | None = None + pages: tuple[BrowserPageResource, ...] = () + # A successful pin flag is NOT evidence this producer has armed its manager. + pin_armed_for: str | None = None + _endpoint: str = field(default="", repr=False) # In memory only, never a snapshot. + + def validate_config(self): + self.producer.validate() + expected = owned_environment(self.cwd, self.key) + if self.env != expected or self.config != self.cwd / "config.json": + raise ResourceIdentityError("Browser producer configuration changed") + info = self.config.lstat() + if (self.cwd.is_symlink() or self.cwd.stat().st_mode & 0o077 + or self.config.is_symlink() or info.st_mode & 0o077 + or (info.st_dev, info.st_ino) != self.config_identity or self.config.read_text() != "{}"): + raise ResourceIdentityError("Browser owned configuration changed") + + async def command(self, *args): + self.validate_config() + raw, _ = await run_client([str(self.producer.path), "--config", str(self.config), + "--session", self.key, "--json", *args], env=self.env, cwd=self.cwd) + return response(raw) + + def invalidate(self): + self.session = None + self.pages = () + self.pin_armed_for = None + self._endpoint = "" + + +def owned_environment(cwd, key): + # No ambient AGENT_BROWSER_*, XDG, proxy, provider, CDP, profile or state. + return {"PATH": "/usr/bin:/bin", "HOME": str(cwd), "TMPDIR": str(cwd / "tmp"), + "AGENT_BROWSER_SOCKET_DIR": str(cwd / "runtime"), + "AGENT_BROWSER_EXECUTABLE_PATH": "/usr/bin/chromium", + "AGENT_BROWSER_IDLE_TIMEOUT_MS": "300000"} + + +async def register_producer(owner, thread_id): + """Server-only registration, not model discovery, restoration or lookup. + + Does not launch a daemon/browser. A future trusted launch producer must + populate this exact owned runtime; legacy lifecycle entries are not adopted. + """ + if not isinstance(owner, str) or not owner or not isinstance(thread_id, str) or not thread_id: + raise ResourceIdentityError("Browser application ownership is required") + if (owner, thread_id) in _REGISTRY: + raise ResourceIdentityError("Browser producer is already registered") + producer = await trusted_producer() + key = "ody-" + digest("odysseus.browser.selector.v1", [owner, thread_id])[:24] + STATE_ROOT.mkdir(parents=True, exist_ok=True, mode=0o700) + cwd = STATE_ROOT / key + cwd.mkdir(mode=0o700) # Existing unregistered state is not authoritative. + for directory in ("tmp", "runtime"): + (cwd / directory).mkdir(mode=0o700) + config = cwd / "config.json" + with config.open("x") as f: + os.chmod(config, 0o600) + f.write("{}") + f.flush(); os.fsync(f.fileno()) + info = config.stat() + record = RegisteredBrowser(owner, thread_id, producer, key, cwd, owned_environment(cwd, key), + config, (info.st_dev, info.st_ino), asyncio.Lock()) + record.validate_config() + _REGISTRY[(owner, thread_id)] = record + return record + + +def registered(owner, thread_id): + return _REGISTRY.get((owner, thread_id)) # Lookup never creates a session. + + +def daemon_observation(record, info): + required = {"session", "active", "version", "pid", "runtimeError", "socketDir", "namespace", "runtime"} + if (not isinstance(info, dict) or not required <= info.keys() + or info.get("session") != record.key or info.get("active") is not True + or info.get("version") != PRODUCER_VERSION or info.get("runtimeError") is not None + or info.get("socketDir") != record.env["AGENT_BROWSER_SOCKET_DIR"] + or info.get("namespace") is not None): + raise ResourceIdentityError("Unregistered browser daemon") + runtime = info.get("runtime") + pid = info.get("pid") + required_runtime = {"backgroundPid", "session", "engine", "browserLaunched", + "compatibilityStatus", "socketDir", "restoreKey"} + if (type(pid) is not int or pid <= 0 or not isinstance(runtime, dict) + or not required_runtime <= runtime.keys() + or runtime.get("backgroundPid") != pid or runtime.get("session") != record.key + or runtime.get("engine") != "chrome" or runtime.get("browserLaunched") is not True + or runtime.get("compatibilityStatus") != "current" + or runtime.get("socketDir") != info["socketDir"] or runtime.get("restoreKey") is not None): + raise ResourceIdentityError("Malformed browser lifecycle observation") + def executable(candidate): + return Path(f"/proc/{candidate}/exe").resolve(strict=True) + seen = observe(pid, executable) + if seen is None or seen.facts != record.producer.path or not seen.identity.owned(): + raise ResourceIdentityError("Daemon does not match the trusted binary incarnation") + return seen.identity + + +class CDPSidecar: + """Minimal loopback websocket client for the five identity-only methods.""" + def __init__(self, url): + browser_digest(url) + self._url = url # Ephemeral capability; never repr/serialize/log. + self._counter = 0 + + async def __aenter__(self): + url = urlsplit(self._url) + self.reader, self.writer = await asyncio.wait_for(asyncio.open_connection(url.hostname, url.port), CDP_DEADLINE_S) + key = base64.b64encode(os.urandom(16)).decode() + request = f"GET {url.path} HTTP/1.1\r\nHost: 127.0.0.1:{url.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n" + try: + self.writer.write(request.encode()) + await asyncio.wait_for(self.writer.drain(), CDP_DEADLINE_S) + header = await asyncio.wait_for(self.reader.readuntil(b"\r\n\r\n"), CDP_DEADLINE_S) + accept = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()) + headers = dict(line.split(b":", 1) for line in header.split(b"\r\n")[1:] if b":" in line) + if not header.startswith(b"HTTP/1.1 101 ") or not any(k.lower() == b"sec-websocket-accept" and v.strip() == accept for k, v in headers.items()): + raise ResourceIdentityError("Invalid CDP websocket handshake") + return self + except BaseException: + self.writer.close() + raise + + async def __aexit__(self, *args): + self.writer.close() + try: + await asyncio.wait_for(self.writer.wait_closed(), CDP_DEADLINE_S) + finally: + self._url = "" + + async def _send(self, payload, opcode=1): + mask = os.urandom(4) + size = len(payload) + if size > 65535 or opcode in {9, 10} and size > 125: + raise ResourceIdentityError("Oversized CDP observation request") + length = bytes([0x80 | size]) if size < 126 else b"\xfe" + struct.pack("!H", size) + self.writer.write(bytes([0x80 | opcode]) + length + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload))) + await self.writer.drain() + + async def _message(self): + chunks = bytearray() + for _ in range(64): + first, second = await self.reader.readexactly(2) + if second & 0x80 or first & 0x70: + raise ResourceIdentityError("Invalid CDP websocket frame") + size = second & 127 + if size in {126, 127}: + size = struct.unpack("!H" if size == 126 else "!Q", await self.reader.readexactly(2 if size == 126 else 8))[0] + if size + len(chunks) > 1024 * 1024: + raise ResourceIdentityError("Oversized CDP response") + payload = await self.reader.readexactly(size) + opcode = first & 15 + if opcode == 9: + await self._send(payload, 10) + continue + if opcode not in {0, 1}: + raise ResourceIdentityError("Unexpected CDP websocket opcode") + chunks.extend(payload) + if first & 0x80: + from src.agent_runtime.authority import _pairs, _invalid_constant + return json.loads(chunks, object_pairs_hook=_pairs, parse_constant=_invalid_constant) + raise ResourceIdentityError("Unbounded CDP websocket response") + + async def call(self, method, params=None, session_id=None): + if method not in CDP_METHODS: + raise ResourceIdentityError("CDP method is outside the identity allowlist") + self._counter += 1 + message = {"id": self._counter, "method": method, "params": params or {}} + if session_id is not None: + message["sessionId"] = session_id + async def exchange(): + await self._send(json.dumps(message).encode()) + for _ in range(32): + result = await self._message() + if not isinstance(result, dict): + raise ResourceIdentityError("Malformed CDP identity envelope") + if "id" in result and type(result["id"]) is not int: + raise ResourceIdentityError("Malformed CDP response identity") + if result.get("id") == self._counter: + if "error" in result or not isinstance(result.get("result"), dict): + raise ResourceIdentityError("Unverifiable CDP identity response") + return result["result"] + raise ResourceIdentityError("Unbounded CDP event stream") + try: + return await asyncio.wait_for(exchange(), CDP_DEADLINE_S) + except (OSError, ValueError, asyncio.TimeoutError, asyncio.IncompleteReadError): + raise ResourceIdentityError("CDP identity observation unavailable") from None + + +def tabs_schema(data): + tabs = data.get("tabs") + if not isinstance(tabs, list): + raise ResourceIdentityError("Missing producer tab inventory") + aliases, targets = set(), set() + for row in tabs: + if (not isinstance(row, dict) or set(row) != {"tabId", "targetId", "label", "title", "url", "type", "active"} + or not isinstance(row.get("tabId"), str) + or not re.fullmatch(r"t[1-9][0-9]*", row["tabId"]) + or not isinstance(row.get("targetId"), str) or not re.fullmatch(r"[A-F0-9]{32}", row["targetId"]) + or row.get("label") is not None or row.get("type") != "page" + or type(row.get("active")) is not bool or not isinstance(row.get("url"), str) + or not isinstance(row.get("title"), str) + or row["tabId"] in aliases or row["targetId"] in targets): + raise ResourceIdentityError("Malformed, labelled or ambiguous producer page") + aliases.add(row["tabId"]); targets.add(row["targetId"]) + return tabs + + +async def observe_registered(record, alias=None): + """Observe only an existing registered producer; never auto-launch/rearm. + + get cdp-url can launch when cold, so it is preceded by strict active runtime + validation and followed by launch metadata rejection. No result reaches the + model if the trusted observation cannot be established. + """ + try: + async with record.lock: + return await _observe_registered_locked(record, alias) + except BaseException: + record.invalidate() + raise + + +async def _observe_registered_locked(record, alias): + try: + first = daemon_observation(record, await record.command("session", "info")) + endpoint = await record.command("get", "cdp-url") + lifecycle = endpoint.get("lifecycle") + if (not isinstance(lifecycle, dict) or any(lifecycle.get(k) is not False for k in + ("launched", "relaunchedBrowser", "restartedBackground"))): + raise ResourceIdentityError("Unexpected browser lifecycle launch") + url = endpoint.get("cdpUrl") + browser = browser_digest(url) + values = dict(producer_namespace="native:agent-browser", producer_version=PRODUCER_VERSION, + platform=record.producer.platform, binary_sha256=record.producer.binary_sha256, + configuration_digest=digest("odysseus.browser.config.v1", [record.env, str(record.cwd), "{}"]), + session_key=record.key, daemon=first.to_record(), browser_instance_digest=browser) + observation = BrowserSessionObservation(**{**values, "daemon": first, "session_incarnation": incarnation(values)}) + session = BrowserSessionResource(record.owner, record.thread_id, observation) + rows = tabs_schema(await record.command("tab", "list")) + pages = [] + async with CDPSidecar(url) as cdp: + targets = (await cdp.call("Target.getTargets")).get("targetInfos") + if not isinstance(targets, list): + raise ResourceIdentityError("Missing CDP target inventory") + for row in rows: + # Never select a page by targetId: even read dispatch is disabled. + target = row["targetId"] + if not any(t.get("targetId") == target and t.get("type") == "page" for t in targets if isinstance(t, dict)): + raise ResourceIdentityError("Producer/CDP target disagreement") + attached = await cdp.call("Target.attachToTarget", {"targetId": target, "flatten": True}) + sid = attached.get("sessionId") + if not isinstance(sid, str) or not sid: + raise ResourceIdentityError("Missing CDP observation session") + try: + tree = await cdp.call("Page.getFrameTree", session_id=sid) + frame = tree.get("frameTree", {}).get("frame", {}) + if frame.get("id") != target or not isinstance(frame.get("loaderId"), str) or not frame["loaderId"]: + raise ResourceIdentityError("Unsupported main-frame/document invariant") + pages.append(BrowserPageResource(session, target, frame["loaderId"], row["tabId"], row["url"])) + info = (await cdp.call("Target.getTargetInfo", {"targetId": target})).get("targetInfo", {}) + if info.get("targetId") != target or info.get("type") != "page": + raise ResourceIdentityError("Page disappeared during observation") + finally: + await cdp.call("Target.detachFromTarget", {"sessionId": sid}) + last = daemon_observation(record, await record.command("session", "info")) + final = await record.command("get", "cdp-url") + if first != last or not first.owned() or browser_digest(final.get("cdpUrl")) != browser: + raise ResourceIdentityError("Browser incarnation changed during observation") + final_lifecycle = final.get("lifecycle", {}) + if any(final_lifecycle.get(k) is not False for k in ("launched", "relaunchedBrowser", "restartedBackground")): + raise ResourceIdentityError("Unexpected browser replacement") + if record.session != session: + record.invalidate() + record.session, record.pages = session, tuple(pages) + record._endpoint = url + if alias is not None: + match = [p for p in pages if p.resolved_alias == alias] + if len(match) != 1: + raise ResourceIdentityError("Unresolved browser alias") + return match[0] + return session + except BaseException: + record.invalidate() + raise + + +def validate_session(resource): + record = registered(resource.owner, resource.thread_id) + if record is None or record.session != resource or not resource.observation.daemon.owned(): + raise ResourceIdentityError("Browser observation is stale, replaced or unregistered") + record.validate_config() + + +def validate_page(resource): + resource.session.validate() + record = registered(resource.session.owner, resource.session.thread_id) + if not any(p.target_id == resource.target_id and (resource.scope == "page" or p.loader_id == resource.loader_id) for p in record.pages): + raise ResourceIdentityError("Browser page/document observation changed") + + +def seal_browser_resources(authority): + record = registered(authority.owner, authority.session_id) + if record is None or record.session is None or not any(g.tool == "private_browser" for g in authority.grants): + return (), () + try: + record.session.validate() + except ResourceIdentityError: + return (), () + return (record.session,), record.pages + + +def intersect_browser(parent_sessions, parent_pages, child_sessions, child_pages): + # Validate old observations before considering anything newly observed. + for item in (*parent_sessions, *parent_pages, *child_sessions, *child_pages): + item.validate() + sessions = tuple(s for s in parent_sessions if s in child_sessions) + pages = [] + for p in parent_pages: + for c in child_pages: + if p.session == c.session and p.target_id == c.target_id and (p.scope == "page" or p.loader_id == c.loader_id): + pages.append(c if p.scope == "page" else replace(c, loader_id=p.loader_id, scope="document")) + return sessions, tuple(pages) + + +@dataclass(frozen=True) +class BoundBrowserOperation: + operation: Any + request_id: str + owner: str + thread_id: str + session: BrowserSessionResource + page: BrowserPageResource | None = None + exact_approval: Any = None + + def validate(self): + if (self.session.owner, self.session.thread_id) != (self.owner, self.thread_id) or not self.request_id: + raise ResourceIdentityError("Browser application binding changed") + operation, args = parse_operation(self.operation.input) + if operation != self.operation or self.operation.tool != "private_browser": + raise ResourceIdentityError("Browser normalized operation changed") + self.session.validate() + if self.page is not None: + if self.page.session != self.session: + raise ResourceIdentityError("Browser page/session binding changed") + self.page.validate() + if args["action"] not in SESSION_ACTIONS and self.page is None: + raise ResourceIdentityError("Missing proposal-bound page observation") + + def to_dict(self): + return {"operation": {"tool": self.operation.tool, "input": self.operation.input, + "action": self.operation.action, "transport_tool": self.operation.transport_tool}, + "request_id": self.request_id, "owner": self.owner, "thread_id": self.thread_id, + "session": self.session.to_dict(), "page": self.page.to_dict() if self.page else None} + + +def resolve_browser_operation(authority, operation, *, approved=None, exact_admission=False): + _, args = parse_operation(operation.input) + if approved is not None: + bound = approved + if (bound.operation != operation or (bound.request_id, bound.owner, bound.thread_id) != + (authority.request_id, authority.owner, authority.session_id)): + raise ResourceIdentityError("Approved browser operation binding changed") + else: + record = registered(authority.owner, authority.session_id) + if record is None or record.session is None: + raise ResourceIdentityError("No admitted browser session observation") + page = None + if args["action"] not in SESSION_ACTIONS: + alias = args.get("page") + matches = [p for p in record.pages if alias and p.resolved_alias == alias] + if len(matches) != 1: + raise ResourceIdentityError("An observed tN selector is required") + page = matches[0] # Alias is audit metadata after this single resolution. + bound = BoundBrowserOperation(operation, authority.request_id, authority.owner, + authority.session_id, record.session, page) + bound.validate() + if not (approved is not None and exact_admission and not authority.inherited): + if bound.page is None and bound.session not in authority.browser_sessions: + raise ResourceIdentityError("Browser session is outside admitted scope") + if bound.page is not None and not any(p.session == bound.page.session and p.target_id == bound.page.target_id + and (p.scope == "page" or p.loader_id == bound.page.loader_id) for p in authority.browser_pages): + raise ResourceIdentityError("Browser page/document is outside admitted scope") + return bound + + +async def revalidate_browser_operation(bound): + bound.validate() + record = registered(bound.owner, bound.thread_id) + async with record.lock: + try: + # The existing capability connects to the captured browser only. + # Never issue get cdp-url here: its CLI can auto-launch a replacement. + if daemon_observation(record, await record.command("session", "info")) != bound.session.observation.daemon: + raise ResourceIdentityError("Browser proposal daemon replaced") + if browser_digest(record._endpoint) != bound.session.observation.browser_instance_digest: + raise ResourceIdentityError("Browser proposal incarnation replaced") + async with CDPSidecar(record._endpoint) as cdp: + await cdp.call("Target.getTargets") + bound.validate() + except BaseException: + record.invalidate() + raise + + +@contextmanager +def bind_browser_operation(bound): + if bound is not None: + bound.validate() + token = _ACTIVE.set(bound) + try: + yield bound + finally: + _ACTIVE.reset(token) + + +async def execute_browser(content, ctx): + try: + operation, args = parse_operation(content) + # Unconditional capability denial, before producer selection, alias + # lookup, spawning, approval claims or any page-specific data read. + if args["action"] not in SESSION_ACTIONS: + return page_unavailable() + from src.agent_runtime.authority import active_request_authority + authority, bound = active_request_authority(), _ACTIVE.get() + if authority is None or bound is None or bound.operation != operation: + raise ResourceIdentityError("Browser producer requires a normalized resource-bound operation") + if (authority.owner, authority.request_id, authority.session_id) != (bound.owner, bound.request_id, bound.thread_id): + raise ResourceIdentityError("Browser caller authority changed") + if (str(ctx.get("owner") or "").casefold(), str(ctx.get("session_id") or "")) != (bound.owner, bound.thread_id): + raise ResourceIdentityError("Browser producer caller changed") + if not authority.permits(operation): + approval = bound.exact_approval + if (authority.inherited or approval is None or not approval._claimed + or approval.pending.browser_operation is None or approval.pending.browser_operation.to_dict() != bound.to_dict()): + raise ResourceIdentityError("Browser operation lacks exact admission") + bound.validate() + record = registered(bound.owner, bound.thread_id) + await revalidate_browser_operation(bound) + async with record.lock: + bound.validate() + # Metadata only. Never return URL/title/content, raw CDP capability, + # or producer lifecycle data as semantic verification. + output = {"session_incarnation": bound.session.observation.session_incarnation, + "producer_version": PRODUCER_VERSION} + return {"output": json.dumps(output), "exit_code": 0, "executed": True, + "browser_page_operations_supported": False} + except asyncio.CancelledError: + record = registered(str(ctx.get("owner") or "").casefold(), str(ctx.get("session_id") or "")) + if record is not None: + record.invalidate() + raise + except Exception: + # No raw producer/CDP exception text: it can contain capability URLs. + return {"error": "Trusted browser session metadata is unavailable.", "exit_code": 1, + "executed": False, "retryable": False, "failure_kind": "browser_session_authority_unavailable"} diff --git a/src/clean_agent_preview.py b/src/clean_agent_preview.py index 8b1372f9a..34f0b4106 100644 --- a/src/clean_agent_preview.py +++ b/src/clean_agent_preview.py @@ -158,7 +158,7 @@ SAFE_ACTIONS = { 'manage_contact': frozenset({'list', 'search', 'find'}), 'private_browser': frozenset({ 'open', 'read', 'snapshot', 'find', 'evaluate', 'click', 'fill', 'press', - 'scroll', 'wait', 'screenshot', 'close', 'batch', + 'scroll', 'wait', 'screenshot', 'close', 'session_info', }), # These UI effects are reversible. A model switch is additionally bound # below to explicit user wording; keep toggle mutation, mode changes, and @@ -2472,31 +2472,16 @@ def compact_schemas(schemas, *, model=None): properties['code']['description'] = 'Valid Python source code to execute once.' elif function.get('name') == 'private_browser': function['description'] = ( - 'Browse and interact with websites. First open then snapshot the page. ' - 'Use returned element refs (such as @e1) for fill/click; never guess selectors. ' - 'press uses a keyboard key such as Enter on the focused element. ' - 'To search a site, fill its search field and submit, then snapshot results. ' - 'find only locates one existing page element/text; it does not search the site. ' - 'To list links, headings, or controls, use snapshot and read its returned DOM.' + 'Registered session_info metadata only. Page/document reads and effects are ' + 'unavailable because the producer cannot atomically bind a captured page. ' + 'No batch, raw commands, flags, labels or current-tab selectors.' ) for name in ('target', 'selector'): if isinstance(properties.get(name), dict): properties[name]['description'] = ( - 'For click/fill/read/wait: snapshot ref such as @e2 or CSS selector, not visible text.' + 'Disabled page operation: ref such as @e2 or CSS selector, not visible text.' ) - if isinstance(properties.get('key'), dict): - properties['key']['description'] = 'For press: keyboard key such as Enter on the currently focused element.' - commands = properties.get('commands') - if isinstance(commands, dict): - commands['description'] = ( - 'For action=batch, an array of command arrays such as ' - '[["open","https://example.com"],["snapshot"]].' - ) - commands['items'] = { - 'type': 'array', - 'items': {'type': 'string'}, - 'minItems': 1, - } + properties.pop('commands', None) elif function.get('name') == 'ui_control': function['description'] = ( 'Control the UI. Themes: get_theme reads current saved colors and available names; ' @@ -2672,28 +2657,6 @@ def normalize_preview_function_args(name, args, *, user_text=''): # is a lossless completion of an explicit field, not inferred content. args['content'] += '\n' tool_type, normalized = normalize_native_function_args(name, args) - if ( - tool_type == 'private_browser' - and str(normalized.get('action') or '').casefold() == 'open' - and str(normalized.get('url') or '').startswith(('http://', 'https://')) - ): - # Opening a page invalidates old element references. The compact - # model commonly emits only ``open`` and then answers from the title, - # leaving a later conversational turn with no refs it can safely - # click. Make the transport honor the browser schema's documented - # open-then-snapshot contract in one atomic call. This is generic DOM - # grounding, not a rule for any particular site or link label. - normalized = { - 'action': 'batch', - 'commands': [ - ['open', normalized['url']], - ['snapshot'], - ], - **( - {'timeout_ms': normalized['timeout_ms']} - if normalized.get('timeout_ms') is not None else {} - ), - } if ( tool_type == 'inspect_media' and str(normalized.get('sampling') or '').casefold() == 'overview' @@ -2703,6 +2666,7 @@ def normalize_preview_function_args(name, args, *, user_text=''): # eight observations per native sheet. Avoid the tool's broader # default, which would require lossy second-stage sheet packing. normalized['frames'] = 24 + return tool_type, normalized diff --git a/src/constants.py b/src/constants.py index ac114f9c8..7fbcadaea 100644 --- a/src/constants.py +++ b/src/constants.py @@ -90,6 +90,7 @@ RAG_DIR = os.path.join(DATA_DIR, "rag") CHROMA_DIR = os.path.join(DATA_DIR, "chroma") BG_JOBS_DIR = os.path.join(DATA_DIR, "bg_jobs") PROCESS_RESOURCES_DIR = os.path.join(DATA_DIR, "process_resources") +BROWSER_RESOURCES_DIR = os.path.join(DATA_DIR, "browser_resources") DEEP_RESEARCH_DIR = os.path.join(DATA_DIR, "deep_research") MCP_OAUTH_DIR = os.path.join(DATA_DIR, "mcp_oauth") GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images") diff --git a/src/tool_approvals.py b/src/tool_approvals.py index dfc5d1cca..bf8b7a66a 100644 --- a/src/tool_approvals.py +++ b/src/tool_approvals.py @@ -32,6 +32,7 @@ if TYPE_CHECKING: from src.agent_runtime.remote_resources import BoundBackendOperation from src.agent_runtime.owned_resources import BoundOwnedOperation from src.agent_runtime.process_resources import BoundProcessOperation + from src.browser_identity import BoundBrowserOperation DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60 @@ -129,6 +130,7 @@ def _binding_payload( backend_operation=None, owned_operation=None, process_operation=None, + browser_operation=None, ) -> dict[str, Any]: return { "owner": _normalized_owner(owner), @@ -154,6 +156,7 @@ def _binding_payload( "backend_operation": backend_operation.to_dict() if backend_operation is not None else None, "owned_operation": owned_operation.to_dict() if owned_operation is not None else None, "process_operation": process_operation.to_dict() if process_operation is not None else None, + "browser_operation": browser_operation.to_dict() if browser_operation is not None else None, } @@ -188,6 +191,7 @@ class PendingToolApproval: backend_operation: BoundBackendOperation | None = None owned_operation: BoundOwnedOperation | None = None process_operation: BoundProcessOperation | None = None + browser_operation: BoundBrowserOperation | None = None def public_payload(self, *, reason: str | None = None) -> dict[str, Any]: return { @@ -301,6 +305,7 @@ class ExactToolApproval: backend_operation=self.pending.backend_operation, owned_operation=self.pending.owned_operation, process_operation=self.pending.process_operation, + browser_operation=self.pending.browser_operation, ) return _canonical_digest(expected) == self.pending.digest @@ -397,6 +402,7 @@ class ToolApprovalStore: backend_operation = None owned_operation = None process_operation = None + browser_operation = None from src.agent_runtime.remote_resources import BoundBackendOperation, resolve_backend from src.agent_runtime.owned_resources import needs_owned_binding, resolve_owned_operation from src.agent_runtime.resources import NativeBackendResource @@ -412,6 +418,11 @@ class ToolApprovalStore: from src.agent_runtime.process_resources import needs_process_binding, resolve_process_operation if request_authority is not None and needs_process_binding(operation, backend): process_operation = resolve_process_operation(request_authority, operation, backend) + from src.browser_identity import native_browser, resolve_browser_operation + if native_browser(operation, backend): + if request_authority is None: + raise ValueError("Browser approval requires originating resource authority") + browser_operation = resolve_browser_operation(request_authority, operation) if isinstance(backend, NativeBackendResource) and needs_owned_binding(operation): resolved_owned = resolve_owned_operation(operation, owner=_normalized_owner(owner), thread_id=str(session_id or ""), request_id=backend_operation.request_id, @@ -460,6 +471,7 @@ class ToolApprovalStore: backend_operation=backend_operation, owned_operation=owned_operation, process_operation=process_operation, + browser_operation=browser_operation, ) pending = PendingToolApproval( approval_id=secrets.token_urlsafe(32), @@ -488,6 +500,7 @@ class ToolApprovalStore: backend_operation=backend_operation, owned_operation=owned_operation, process_operation=process_operation, + browser_operation=browser_operation, ) with self._lock: self._purge_expired_locked(now) diff --git a/src/tool_execution.py b/src/tool_execution.py index f4f2cf5b0..224543223 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -1327,6 +1327,10 @@ from src.agent_runtime.authority import ( from src.agent_runtime.process_resources import ( active_process_operation, bind_process_operation, needs_process_binding, resolve_process_operation, ) +from src.browser_identity import ( + native_browser, parse_operation as parse_browser_operation, SESSION_ACTIONS, + page_unavailable, resolve_browser_operation, bind_browser_operation, revalidate_browser_operation, +) @record_action @@ -1411,6 +1415,22 @@ async def execute_tool_block( } transport = operation.transport_tool + if operation.tool == "private_browser": + try: + _, browser_args = parse_browser_operation(operation.input) + except (ValueError, TypeError): + return f"{transport}: UNSUPPORTED", {**page_unavailable(), "error": "Browser raw commands, flags and batches are unsupported."} + if browser_args["action"] not in SESSION_ACTIONS: + return f"{transport}: UNSUPPORTED", page_unavailable() + # Raw global Playwright MCP has no authoritative session/page observation. + # Its transport process and remote backend identity cannot substitute for it. + if transport.startswith("mcp__") and transport.rsplit("__", 1)[-1] in { + "browser_click", "browser_fill_form", "browser_type", "browser_press_key", "browser_evaluate", + "browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_take_screenshot", + "browser_wait_for", "browser_tabs", "browser_close", "browser_run_code", "browser_network_requests", + "browser_console_messages", "browser_drag", "browser_hover", "browser_select_option", + "browser_file_upload", "browser_handle_dialog", "browser_resize", "browser_install"}: + return f"{transport}: UNSUPPORTED", page_unavailable() try: pending = exact_approval.pending if exact_approval is not None else None if pending is not None and pending.backend_operation is None: @@ -1420,8 +1440,20 @@ async def execute_tool_block( approved=pending.backend_operation if pending is not None else None, exact_admission=exact_admission) external_resource_call = isinstance(backend_operation.resource, ExternalResource) + if operation.tool == "private_browser" and external_resource_call: + raise ResourceIdentityError("External backend cannot supply native browser session authority") owned_operation = None process_operation = None + browser_operation = None + if native_browser(operation, backend_operation.resource): + _, browser_args = parse_browser_operation(operation.input) + if browser_args["action"] not in SESSION_ACTIONS: + return f"{transport}: UNSUPPORTED", page_unavailable() + if pending is not None and pending.browser_operation is None: + raise ResourceIdentityError("Approved action has no sealed browser identity") + browser_operation = resolve_browser_operation(authority, operation, + approved=pending.browser_operation if pending is not None else None, exact_admission=exact_admission) + await revalidate_browser_operation(browser_operation) if needs_process_binding(operation, backend_operation.resource): if pending is not None and pending.process_operation is None: raise ResourceIdentityError("Approved action has no sealed process/job identity") @@ -1555,11 +1587,13 @@ async def execute_tool_block( backend_operation.validate(client_runtime_context) if process_operation is not None and approval_claimed: process_operation = replace(process_operation, exact_approval=exact_approval) + if browser_operation is not None and approval_claimed: + browser_operation = replace(browser_operation, exact_approval=exact_approval) normalized = resource_operation or owned_operation sealed_document = owned_operation or (exact_approval.pending if approval_claimed else None) with (bind_request_authority(authority), bind_resource_operation(resource_operation), bind_backend_operation(backend_operation), bind_owned_operation(owned_operation), - bind_process_operation(process_operation)): + bind_process_operation(process_operation), bind_browser_operation(browser_operation)): output = await _execute_tool_block_impl( ToolBlock(transport, normalized.execution_input) if normalized is not None else block, session_id=session_id, diff --git a/src/tool_index.py b/src/tool_index.py index 25d82b1db..33337211c 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -111,8 +111,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = { "get_weather": "Get current weather and a three-day forecast for a city or place from Open-Meteo without an API key. Use for weather lookups before web_search.", "web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.", "pdf_extract": "Extract focused, source-attributed passages and exact table values from an online PDF or task-local /workspace/*.pdf. Use for arXiv papers, reports, manuals, PDF tables, evaluation metrics, and multi-document PDF extraction. Prefer this over Python requests, curl, downloading, pdftotext, or guessing. Include target model names, metrics, and table headings in query.", - "youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. Use for YouTube comments/transcript/channel latest-video tasks; use private_browser only for visual site interaction.", - "private_browser": "Private browser automation through Odysseus' agent-browser wrapper. Use only for specific pages that need JavaScript, login/session state, clicking, filling forms, waiting, screenshots, or rendered DOM inspection. For open-ended search use web_search; for ordinary URL reading use web_fetch.", + "youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. Use for YouTube comments/transcript/channel latest-video tasks.", + "private_browser": "Trusted metadata for an existing server-registered browser session only. Page/document reads and interactions are unavailable because the configured producer cannot guarantee exact target binding. No model batch or raw browser commands. Use web_search or web_fetch for supported web access.", "inspect_media": "Inspect local workspace images, SVGs, videos, and PDF pages with the current multimodal model. Samples bounded timestamped video frames uniformly, at scene cuts, or from temporally diverse motion peaks; renders SVG to PNG; exports stills or clips; concatenates ranges; changes clip speed while preserving audio pitch; and renders query-relevant PDF pages. Prefer these native operations over raw ffmpeg. Increase max_dimension only for small visual details; saved exports keep source quality.", "extract_text": "Extract exact visible text, confidence, and pixel centers from a local workspace image with Odysseus local OCR. Use for screenshots, scans, labels, numbers, receipts, and text-location tasks; use inspect_media for general visual understanding.", "transcribe_media": "Transcribe dialogue, narration, names, and spoken timing from a local audio or video file with Odysseus local Whisper. Returns [START --> END] TEXT segments and always persists them to a workspace text file. For a named chapter, question, scene, or topic, locate its boundaries and restrict filtering to that interval. This handles audio speech; combine with inspect_media for audiovisual tasks or visually burned-in subtitles.", diff --git a/src/tool_schemas.py b/src/tool_schemas.py index 5c414f81e..02c1c5ebf 100644 --- a/src/tool_schemas.py +++ b/src/tool_schemas.py @@ -393,35 +393,28 @@ FUNCTION_TOOL_SCHEMAS = [ "type": "function", "function": { "name": "private_browser", - "description": "Private browser automation through Odysseus' agent-browser wrapper. After open, snapshot the page and interact with returned element refs such as @e12; click/fill target is a selector or element ref, never guessed visible text. Prefer one batch for known consecutive steps, such as open plus snapshot. Use only when a specific page needs JavaScript, login/session state, interaction, or rendered DOM. For open-ended search use web_search; for reading a normal URL use web_fetch.", + "description": "Trusted browser session metadata only. Page/document operations are unavailable because the local producer cannot atomically bind a captured target. No batch or raw CLI flags. Use web_search/web_fetch for supported web access.", "parameters": { "type": "object", "properties": { - "action": {"type": "string", "enum": ["open", "read", "snapshot", "find", "evaluate", "click", "fill", "press", "scroll", "wait", "screenshot", "close", "batch"]}, - "url": {"type": "string", "description": "Required URL for open; optional URL for read (omit to read the current page)"}, - "selector": {"type": "string", "description": "Element ref or selector for read/click/fill/wait"}, - "target": {"type": "string", "description": "Element ref returned by snapshot (preferred, e.g. @e12) or CSS selector for read/click/fill/wait; never a guessed visible label; top or bottom for scroll"}, - "key": {"type": "string", "description": "Key name for press action, e.g. Enter"}, - "direction": {"type": "string", "enum": ["up", "down", "left", "right"], "description": "Direction for scroll action"}, - "amount": {"type": "integer", "minimum": 1, "description": "Optional scroll distance in pixels; default 300"}, - "text": {"type": "string", "description": "Text for fill action"}, - "value": {"type": "string", "description": "Alternative text/value for fill action"}, - "find": {"type": "string", "description": "Visible text to locate for find action"}, - "script": {"type": "string", "description": "JavaScript expression for evaluate action"}, - "path": {"type": "string", "description": "Optional output path for screenshot"}, - "commands": { - "type": "array", - "description": "Non-empty batch commands as arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]. Do not send an empty batch; use action=snapshot for current page state.", - "items": { - "oneOf": [ - {"type": "array", "items": {"type": "string"}}, - {"type": "object"}, - ] - }, - }, - "timeout_ms": {"type": "integer", "description": "Optional operation timeout, max 120000; for action=wait without a selector, this is the wait duration"} + "action": {"type": "string", "enum": ["session_info", "tabs", "open", "read", "snapshot", "find", "evaluate", "click", "fill", "press", "scroll", "wait", "screenshot", "close", "navigate", "reload", "back", "forward", "select_page", "close_page", "network", "console", "new_page"]}, + "page": {"type": "string", "pattern": "^t[1-9][0-9]*$", "description": "Observed alias only; page commands remain disabled for the current producer."}, + "url": {"type": "string"}, + "selector": {"type": "string"}, + "target": {"type": "string"}, + "ref": {"type": "string"}, + "key": {"type": "string"}, + "direction": {"type": "string"}, + "text": {"type": "string"}, + "value": {"type": "string"}, + "script": {"type": "string"}, + "path": {"type": "string"}, + "find": {"type": "string"}, + "amount": {"type": "integer"}, + "timeout_ms": {"type": "integer", "minimum": 0, "maximum": 20000} }, - "required": ["action"] + "required": ["action"], + "additionalProperties": False } } }, diff --git a/tests/test_browser_identity_transport.py b/tests/test_browser_identity_transport.py new file mode 100644 index 000000000..6769d7aff --- /dev/null +++ b/tests/test_browser_identity_transport.py @@ -0,0 +1,158 @@ +import asyncio +import json +from types import SimpleNamespace + +import pytest + +from src import browser_identity as browser +from src.agent_runtime.resources import ResourceIdentityError +from tests.test_browser_resource_identity import producer, observed, authority +from tests.test_runtime_resource_integration import approval_for, dispatch + + +@pytest.mark.parametrize("phase", ["timeout", "cancel", "spawn_cancel"]) +async def test_client_is_killed_before_resend_deadline_without_retry(monkeypatch, phase): + calls = [] + class Child: + returncode = None + killed = False + async def wait(self): + if self.killed: + self.returncode = -9 + return -9 + await asyncio.Future() + def kill(self): self.killed = True + child = Child() + started, release = asyncio.Event(), asyncio.Event() + async def spawn(*args, **kwargs): + calls.append(args); started.set() + if phase == "spawn_cancel": await release.wait() + return child + monkeypatch.setattr(browser.asyncio, "create_subprocess_exec", spawn) + original = asyncio.wait_for + async def bounded(awaitable, timeout): + assert timeout == browser.CLIENT_DEADLINE_S and timeout < 30 + return await original(awaitable, .01 if phase == "timeout" else timeout) + monkeypatch.setattr(browser.asyncio, "wait_for", bounded) + task = asyncio.create_task(browser.run_client(["trusted-producer", "session", "info"], env={}, cwd="/")) + await started.wait() + if phase != "timeout": task.cancel() + release.set() + with pytest.raises((asyncio.TimeoutError, asyncio.CancelledError)): await task + assert child.killed and len(calls) == 1 + + +async def test_sidecar_allowlist_has_no_enable_mutation_or_arbitrary_cdp(): + client = browser.CDPSidecar("ws://127.0.0.1:1234/devtools/browser/12345678-1234-1234-1234-123456789abc") + for method in ("Page.enable", "Runtime.evaluate", "Page.navigate", "Target.closeTarget", "Browser.close"): + with pytest.raises(ValueError): await client.call(method) + + +@pytest.mark.parametrize("envelope", [[], None, {"id": True, "result": {}}, {"id": "1", "result": {}}, {"id": 1, "error": {}, "result": {}}]) +async def test_sidecar_rejects_malformed_identity_envelopes(monkeypatch, envelope): + client = browser.CDPSidecar("ws://127.0.0.1:1234/devtools/browser/12345678-1234-1234-1234-123456789abc") + async def send(*args): pass + async def receive(): return envelope + monkeypatch.setattr(client, "_send", send) + monkeypatch.setattr(client, "_message", receive) + with pytest.raises(ResourceIdentityError): + await client.call("Target.getTargets") + + +@pytest.mark.parametrize("field", ["namespace", "runtimeError", "restoreKey"]) +async def test_missing_nullable_lifecycle_fields_are_not_valid_observations(producer, field): + record = await observed(producer) + info = await record.command("session", "info") + del (info["runtime"] if field == "restoreKey" else info)[field] + with pytest.raises(ResourceIdentityError): browser.daemon_observation(record, info) + + +async def test_observation_cancellation_while_waiting_for_lock_invalidates_session(producer): + record = await observed(producer) + await record.lock.acquire() + task = asyncio.create_task(browser.observe_registered(record)) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): await task + record.lock.release() + assert record.session is None and record.pages == () + + +@pytest.mark.parametrize("args", [{"action": "click", "page": "t1"}, {"action": "batch", "commands": [["click", "e1"]]}]) +async def test_central_dispatch_cannot_bypass_page_denial(producer, args): + await observed(producer) + current = authority() + producer.calls.clear(); producer.cdp_calls.clear() + _, result = await dispatch(current, "private_browser", json.dumps(args)) + assert result["failure_kind"] == browser.PAGE_FAILURE and result["executed"] is False + assert not producer.calls and not producer.cdp_calls + + +@pytest.mark.parametrize("replacement", ["browser", "daemon"]) +async def test_exact_approval_revalidates_before_claim(producer, replacement): + record = await observed(producer) + current = authority() + content = '{"action":"session_info"}' + approval = approval_for(current, "private_browser", content) + if replacement == "daemon": + producer.pid += 1 + else: + record._endpoint = "ws://127.0.0.1:1234/devtools/browser/87654321-1234-1234-1234-123456789abc" + _, result = await dispatch(current, "private_browser", content, approval) + assert result["exit_code"] == 1 and not approval._claimed + assert record.session is None and record.pages == () + + +async def test_metadata_revalidation_never_auto_launches_or_calls_get_cdp_url(producer): + await observed(producer) + current = authority() + producer.calls.clear() + _, result = await dispatch(current, "private_browser", '{"action":"session_info"}') + assert result["exit_code"] == 0 + assert producer.calls and all(command == ("session", "info") for command in producer.calls) + + +@pytest.mark.parametrize("status", ["EOF", "connection reset", "EAGAIN", "read timeout"]) +async def test_page_failures_never_enter_producer_internal_retry_path(producer, status, monkeypatch): + async def forbidden(*args, **kwargs): + pytest.fail("Producer retry hazard reached: " + status) + monkeypatch.setattr(browser, "run_client", forbidden) + producer.calls.clear() + from src.agent_tools.web_tools import PrivateBrowserTool + result = await PrivateBrowserTool().execute('{"action":"wait","page":"t1","timeout_ms":120000}', {}) + assert result["executed"] is False and result["retryable"] is False + assert producer.calls == [] + + +async def test_page_scoped_child_still_cannot_execute_even_matching_observation(producer): + await observed(producer) + from dataclasses import replace + parent = replace(authority(), browser_sessions=()) + child = parent.intersect(authority()) + _, result = await dispatch(child, "private_browser", '{"action":"click","page":"t1","ref":"e1"}') + assert result["failure_kind"] == browser.PAGE_FAILURE and result["executed"] is False + + +async def test_observed_url_or_alias_change_is_not_resource_authority(producer): + record = await observed(producer) + from dataclasses import replace + original = record.pages[0] + metadata = replace(original, resolved_alias="t99", observed_url="https://different.example") + assert original.authority_key() == metadata.authority_key() + metadata.validate() + + +def test_raw_global_playwright_and_native_backend_are_not_substitutable(): + from src.agent_runtime.resources import ExternalResource + from src.agent_runtime.authority import ExactOperation + assert not browser.native_browser(ExactOperation.normalize("private_browser", '{"action":"session_info"}'), + ExternalResource("mcp", "endpoint", "server", "tool", "epoch")) + + +@pytest.mark.parametrize("tool", ["browser_click", "browser_snapshot", "browser_evaluate", "browser_navigate", "browser_run_code"]) +async def test_raw_mcp_browser_execution_cannot_evade_disabled_page_contract(tool): + from src.agent_runtime.authority import RequestAuthority, OperationGrant + name = "mcp__builtin_browser__" + tool + current = RequestAuthority("request", "alice", "thread", "", (OperationGrant(name),)) + _, result = await dispatch(current, name, '{}') + assert result["failure_kind"] == browser.PAGE_FAILURE and result["executed"] is False diff --git a/tests/test_browser_lifecycle.py b/tests/test_browser_lifecycle.py index 529832e79..b508df8bf 100644 --- a/tests/test_browser_lifecycle.py +++ b/tests/test_browser_lifecycle.py @@ -244,177 +244,6 @@ def _run(payload, ctx): return asyncio.run(PrivateBrowserTool().execute(json.dumps(payload), ctx)) -def test_timeout_cleans_only_this_sessions_browser(browser_env) -> None: - state, calls, cleaned, swept = browser_env - - async def _hang(command): - raise asyncio.TimeoutError() - - state["behaviour"] = _hang - result = _run({"action": "open", "url": "https://example.com"}, {"session_id": "s-timeout"}) - - assert result["exit_code"] == 1 and "timed out" in result["error"] - assert cleaned == ["s-timeout"] - assert swept == [], "a per-session timeout must not sweep other sessions' Chrome" - lifecycle = result["browser_lifecycle"] - assert lifecycle["state"] == "timed_out" - assert lifecycle["cleanup"]["verified"] is True - assert [stage["stage"] for stage in lifecycle["stages"]] == ["open", "forced_cleanup"] - assert sum(1 for call in calls if "open" in call) == 1, "remote opens are never retried" - - -def test_launch_failure_is_reported_and_cleaned(browser_env) -> None: - state, _, cleaned, _ = browser_env - - async def _no_sandbox(command): - return 1, ("Chrome exited early (exit code: unknown) without writing DevToolsActivePort\n" - "FATAL: No usable sandbox!") - - state["behaviour"] = _no_sandbox - result = _run({"action": "open", "url": "https://example.com"}, {"session_id": "s-launch"}) - - assert result["exit_code"] == 1 - assert "could not launch the browser" in result["error"] - assert cleaned == ["s-launch"] - assert result["browser_lifecycle"]["state"] == "launch_failed" - assert result["browser_lifecycle"]["navigation_generation"] == 0 - - -def test_observation_after_failed_navigation_is_marked_stale(browser_env) -> None: - state, _, _, _ = browser_env - - async def _behaviour(command): - if command[-2:] == ["open", "https://good.example/"]: - return 0, "✓ Good\n https://good.example/\n" - if "open" in command: - return 1, "net::ERR_NAME_NOT_RESOLVED" - return 0, '- heading "Good page" [ref=e1]' - - state["behaviour"] = _behaviour - ctx = {"session_id": "s-stale"} - opened = _run({"action": "open", "url": "https://good.example/"}, ctx) - assert opened["browser_lifecycle"]["navigation_generation"] == 1 - assert opened["browser_lifecycle"]["page_url"] == "https://good.example/" - - failed = _run({"action": "open", "url": "https://bad.example/"}, ctx) - assert failed["exit_code"] == 1 - assert failed["browser_lifecycle"]["state"] == "navigation_failed" - - observed = _run({"action": "snapshot"}, ctx) - assert observed["output"].startswith("[Browser lifecycle: the most recent navigation to https://bad.example/ failed") - assert "shows https://good.example/ (navigation #1)" in observed["output"] - assert observed["browser_lifecycle"]["stale_observation"] is True - - _run({"action": "open", "url": "https://good.example/"}, ctx) - fresh = _run({"action": "snapshot"}, ctx) - assert not fresh["output"].startswith("[Browser lifecycle") - assert "stale_observation" not in fresh["browser_lifecycle"] - - -def test_sessionless_call_gets_its_own_browser_and_closes_it(browser_env, monkeypatch) -> None: - state, calls, cleaned, _ = browser_env - monkeypatch.setattr(PrivateBrowserTool, "_owned_daemon_exists", staticmethod(lambda env, session: True)) - - async def _ok(command): - return 0, "✓ T\n https://example.com/\n" - - state["behaviour"] = _ok - first = _run({"action": "open", "url": "https://example.com/"}, {}) - second = _run({"action": "open", "url": "https://example.com/"}, {}) - - sessions = [call[call.index("--session") + 1] for call in calls if "--session" in call] - assert all(session.startswith("ody-") for session in sessions) - assert len({sessions[0], sessions[-1]}) == 2, "sessionless calls must not share a browser" - assert any(call[-1] == "close" for call in calls) - assert first["browser_lifecycle"]["ownership"] == "ephemeral" - assert first["browser_lifecycle"]["cleanup"]["graceful_close"] is True - assert first["browser_lifecycle"]["state"] == "closed" - assert len(cleaned) == 2 - assert not web_tools._ACTIVE_BROWSER_SESSIONS.intersection(sessions) - assert not any(browser_lifecycle.registered(s) for s in sessions) - assert second["exit_code"] == 0 - - -def test_actions_on_one_session_are_serialized(browser_env) -> None: - state, _, _, _ = browser_env - active = {"now": 0, "peak": 0} - - async def _slow(command): - active["now"] += 1 - active["peak"] = max(active["peak"], active["now"]) - await asyncio.sleep(0.02) - active["now"] -= 1 - return 0, '- heading "x"' - - state["behaviour"] = _slow - - async def _both(): - tool = PrivateBrowserTool() - await asyncio.gather( - tool.execute(json.dumps({"action": "snapshot"}), {"session_id": "s-lock"}), - tool.execute(json.dumps({"action": "snapshot"}), {"session_id": "s-lock"}), - ) - - asyncio.run(_both()) - assert active["peak"] == 1 - - -def test_cancellation_stops_clients_and_cleans_the_session(browser_env, monkeypatch) -> None: - state, calls, cleaned, _ = browser_env - terminated = [] - - async def _forever(command): - await asyncio.sleep(3600) - - state["behaviour"] = _forever - monkeypatch.setattr( - PrivateBrowserTool, "_terminate_subprocess", - staticmethod(lambda proc: terminated.append(proc.command)), - ) - - async def _cancel(): - task = asyncio.create_task(PrivateBrowserTool().execute( - json.dumps({"action": "open", "url": "https://example.com"}), - {"session_id": "s-cancel"}, - )) - while not calls: - await asyncio.sleep(0.01) - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - asyncio.run(_cancel()) - - assert terminated and terminated[0][-1] == "https://example.com" - assert cleaned == ["s-cancel"] - key = web_tools._scoped_browser_session("odysseus-ui", "s-cancel") - assert browser_lifecycle.registered(key).state == "cancelled" - - -def test_local_open_recovery_is_single_and_inside_the_deadline(browser_env, monkeypatch, tmp_path) -> None: - state, calls, cleaned, _ = browser_env - page = tmp_path / "page.html" - page.write_text("x") - - async def _hang(command): - raise asyncio.TimeoutError() - - state["behaviour"] = _hang - payload = {"action": "open", "url": "/workspace/page.html", "_odysseus_browser_retry": True} - result = _run(payload, {"session_id": "s-retry"}) - - opens = [call for call in calls if call[-1] == page.as_uri()] - assert len(opens) == 2, "a model-supplied retry flag must not change recovery" - assert result["browser_lifecycle"]["recovery_attempts"] == 1 - assert cleaned == ["s-retry", "s-retry"] - - calls.clear() - monkeypatch.setattr(PrivateBrowserTool, "_RECOVERY_BUDGET_S", 0) - exhausted = _run({"action": "open", "url": "/workspace/page.html", "timeout_ms": 1000}, {"session_id": "s-budget"}) - assert len([call for call in calls if call[-1] == page.as_uri()]) == 1 - assert "recovery_attempts" not in exhausted["browser_lifecycle"] - - def test_research_reader_passes_its_timeout_to_the_browser(monkeypatch) -> None: from src.research_navigator import ResearchNavigator @@ -486,76 +315,6 @@ def _owned_processes(runtime: Path) -> list[int]: return owned -@real_browser -def test_real_local_page_open_extract_and_ephemeral_cleanup(real_runtime) -> None: - workspace, runtime, env = real_runtime - (workspace / "page.html").write_text( - "Lifecycle

Fresh heading

" - ) - - result = _run( - {"action": "batch", "commands": [["open", "/workspace/page.html"], ["snapshot"]]}, - {"subproc_env": env}, - ) - - assert result["exit_code"] == 0, result - assert "Fresh heading" in result["output"] - lifecycle = result["browser_lifecycle"] - assert lifecycle["ownership"] == "ephemeral" - assert lifecycle["navigation_generation"] == 1 - assert lifecycle["state"] == "closed" and lifecycle["page_url"] == "" - assert lifecycle["closed_page_url"].endswith("/page.html") - assert lifecycle["cleanup"]["verified"] is True - assert [stage["stage"] for stage in lifecycle["stages"]] == ["batch", "close"] - time.sleep(0.5) - assert _owned_processes(runtime) == [] - assert list((runtime / "agent-browser").glob("ody-*")) == [] - assert list((runtime / "tmp").glob("agent-browser-chrome-*")) == [] - - -@real_browser -def test_real_retained_session_survives_then_forced_cleanup_leaves_nothing(real_runtime) -> None: - workspace, runtime, env = real_runtime - (workspace / "a.html").write_text("A

Alpha

") - ctx = {"session_id": "retained", "subproc_env": env} - - opened = _run({"action": "open", "url": "/workspace/a.html"}, ctx) - assert opened["exit_code"] == 0, opened - observed = _run({"action": "snapshot"}, ctx) - assert "Alpha" in observed["output"] - assert observed["browser_lifecycle"]["ownership"] == "retained" - assert _owned_processes(runtime), "a retained session keeps its browser" - - receipt = PrivateBrowserTool._terminate_owned_daemon(dict(os.environ, **env), "retained") - - assert receipt["verified"] is True and receipt["killed"] >= 2 - assert receipt["removed_profiles"] == 1 - assert _owned_processes(runtime) == [] - assert list((runtime / "agent-browser").glob("ody-*")) == [] - - -@real_browser -def test_real_cancellation_leaves_no_browser(real_runtime) -> None: - workspace, runtime, env = real_runtime - (workspace / "slow.html").write_text("S

Slow

") - ctx = {"session_id": "cancelled", "subproc_env": env} - assert _run({"action": "open", "url": "/workspace/slow.html"}, ctx)["exit_code"] == 0 - - async def _cancel_wait(): - task = asyncio.create_task(PrivateBrowserTool().execute( - json.dumps({"action": "wait", "timeout_ms": 30000}), ctx, - )) - await asyncio.sleep(1.5) - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - - asyncio.run(_cancel_wait()) - time.sleep(0.5) - assert _owned_processes(runtime) == [] - assert list((runtime / "agent-browser").glob("ody-*")) == [] - - def test_browser_mcp_call_is_bounded_and_never_replayed(monkeypatch) -> None: from src.mcp_manager import McpManager @@ -577,115 +336,3 @@ def test_browser_mcp_call_is_bounded_and_never_replayed(monkeypatch) -> None: assert result["exit_code"] == 1 assert "timed out after 0.05s and was not retried" in result["error"] assert calls == ["browser_navigate"] - - -def test_read_url_navigates_and_extracts_in_one_observation(browser_env) -> None: - state, calls, _, _ = browser_env - - async def _batch(command): - return 0, json.dumps([ - {"command": ["open", "https://example.com/"], "success": True, - "result": {"title": "Example", "url": "https://example.com/final"}}, - {"command": ["get", "text", "body"], "success": True, - "result": {"text": "Example body"}}, - ]) - - state["behaviour"] = _batch - result = _run({"action": "read", "url": "https://example.com/"}, {"session_id": "s-read"}) - - assert calls[-1][-2:] == ["batch", "--json"] - assert result["exit_code"] == 0 - assert result["output"] == "Example\nhttps://example.com/final\n\nExample body" - assert result["browser_lifecycle"]["page_url"] == "https://example.com/final" - - -def test_read_url_without_extracted_text_is_a_failure(browser_env) -> None: - state, _, _, _ = browser_env - - async def _no_text(command): - return 0, json.dumps([ - {"success": True, "result": {"url": "https://example.com/"}}, - {"success": False, "error": "Timeout waiting for body", "result": None}, - ]) - - state["behaviour"] = _no_text - result = _run({"action": "read", "url": "https://example.com/"}, {"session_id": "s-read-fail"}) - - assert result["exit_code"] == 1 - assert "Timeout waiting for body" in result["error"] - assert result["browser_lifecycle"]["state"] == "navigation_failed" - - -@real_browser -def test_real_read_url_extracts_text_after_navigation(real_runtime) -> None: - import functools - import http.server - import threading - - workspace, runtime, env = real_runtime - (workspace / "doc.html").write_text("Doc

Served heading

Body text

") - handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(workspace)) - server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - url = f"http://127.0.0.1:{server.server_address[1]}/doc.html" - result = _run({"action": "read", "url": url}, {"subproc_env": env}) - finally: - server.shutdown() - server.server_close() - - assert result["exit_code"] == 0, result - assert result["output"].startswith(f"Doc\n{url}") - assert "Served heading" in result["output"] and "Body text" in result["output"] - assert result["browser_lifecycle"]["closed_page_url"] == url - assert result["browser_lifecycle"]["cleanup"]["verified"] is True - time.sleep(0.5) - assert _owned_processes(runtime) == [] - - -def test_selector_read_is_an_observation_not_a_navigation() -> None: - assert PrivateBrowserTool._navigation_target( - "read", {"selector": "#main", "url": "https://elsewhere.example/"} - ) == "" - assert PrivateBrowserTool._navigation_target( - "batch", {"commands": [["open", "file:///a.html"], ["snapshot"], ["open", "file:///b.html"]]} - ) == "file:///b.html" - - -def test_batch_navigation_outcome_comes_from_its_rows(browser_env) -> None: - state, _, _, _ = browser_env - responses = {} - - async def _batch(command): - if command[-2:] == ["batch", "--json"]: - return responses["batch"] - return 0, '- heading "x"' - - state["behaviour"] = _batch - ctx = {"session_id": "s-batch"} - - # The open succeeded; a later click failing must not mark it failed. - responses["batch"] = (1, json.dumps([ - {"command": ["open", "https://a.example/"], "success": True, - "result": {"url": "https://a.example/landing"}}, - {"command": ["click", "@e9"], "success": False, "error": "no element"}, - ])) - result = _run({"action": "batch", "commands": [["open", "https://a.example/"], ["click", "@e9"]]}, ctx) - assert result["browser_lifecycle"]["page_url"] == "https://a.example/landing" - assert result["browser_lifecycle"]["state"] == "ready" - assert "stale_observation" not in _run({"action": "snapshot"}, ctx)["browser_lifecycle"] - - responses["batch"] = (1, json.dumps([ - {"command": ["open", "https://b.example/"], "success": False, "error": "net::ERR"}, - ])) - failed = _run({"action": "batch", "commands": [["open", "https://b.example/"]]}, ctx) - assert failed["browser_lifecycle"]["state"] == "navigation_failed" - note = _run({"action": "snapshot"}, ctx)["output"] - assert "shows https://a.example/landing (navigation #1), not https://b.example/" in note - - responses["batch"] = (1, "daemon connection lost") - _run({"action": "batch", "commands": [["open", "https://c.example/"]]}, ctx) - unknown = _run({"action": "snapshot"}, ctx) - assert "outcome of the most recent navigation to https://c.example/ is unknown" in unknown["output"] - assert unknown["browser_lifecycle"]["page_url"] == "" diff --git a/tests/test_browser_producer_live_contract.py b/tests/test_browser_producer_live_contract.py new file mode 100644 index 000000000..bc1152b26 --- /dev/null +++ b/tests/test_browser_producer_live_contract.py @@ -0,0 +1,109 @@ +"""Release-only probes, isolated owned sessions; no model page authorization. + +Run in the actual release image with ODYSSEUS_BROWSER_LIVE_CONTRACT=1. Without +that explicit gate these are reported as skips, not producer-contract passes. +The pin test asserts the known 0.35.0 defect, never enables page operations. +""" +import json +import os +import tempfile +import urllib.request +from urllib.parse import urlsplit + +import pytest + +from src import browser_identity as browser +from src.agent_tools.web_tools import PrivateBrowserTool +from src import browser_lifecycle + +pytestmark = pytest.mark.skipif(os.environ.get("ODYSSEUS_BROWSER_LIVE_CONTRACT") != "1", + reason="requires explicit live contract gate in the allowlisted 0.35.0 release Docker image") + + +@pytest.fixture +async def live(tmp_path, monkeypatch): + from pathlib import Path + # Unix-domain sockets have a strict path-length limit. Match the release's + # short owned runtime instead of pytest's long per-test directory name. + directory = tempfile.TemporaryDirectory(prefix="w3-live-") + monkeypatch.setattr(browser, "STATE_ROOT", Path(directory.name)) + monkeypatch.setattr(browser, "_REGISTRY", {}) + record = await browser.register_producer("live-contract", "thread") + # Test setup only. Exercise the source-audited first-pin local launch case. + await record.command("get", "cdp-url", "--pin-tab") + try: + yield record + finally: + try: + await record.command("close") + finally: + browser_lifecycle.force_cleanup(record.cwd / "runtime", record.key) + directory.cleanup() + + +async def test_live_exact_schema_target_loader_and_observation_stability(live): + first = await browser.observe_registered(live, "t1") + second = await browser.observe_registered(live, "t1") + assert first.authority_key() == second.authority_key() + assert first.loader_id and first.target_id + assert live.pin_armed_for is None + assert "devtools/browser" not in json.dumps(first.to_dict()) + + +async def test_live_document_navigation_reload_hash_and_identical_tabs(live): + await live.command("open", "data:text/html,fixture

content

", "--pin-tab") + first = await browser.observe_registered(live, "t1") + await live.command("eval", "history.replaceState(null,'','#same')", "--pin-tab") + same = await browser.observe_registered(live, "t1") + assert same.loader_id == first.loader_id + await live.command("reload", "--pin-tab") + reloaded = await browser.observe_registered(live, "t1") + assert reloaded.loader_id != first.loader_id + await live.command("open", "data:text/html,replacement", "--pin-tab") + navigated = await browser.observe_registered(live, "t1") + assert navigated.loader_id != reloaded.loader_id + await live.command("tab", "new", "data:text/html,replacement", "--pin-tab") + await browser.observe_registered(live) + assert len({p.target_id for p in live.pages}) == 2 + assert len({p.loader_id for p in live.pages}) == 2 + + +async def test_live_local_launch_rearm_drops_flags_and_retargets_destroyed_page(live): + await live.command("tab", "new", "about:blank", "--pin-tab") + await browser.observe_registered(live) + # Digit-leading target avoids the distinct producer label-parser hazard. + captured = next((p for p in live.pages if p.target_id[0].isdigit()), None) + for _ in range(8): + if captured is not None: + break + await live.command("tab", "new", "about:blank", "--pin-tab") + await browser.observe_registered(live) + captured = next((p for p in live.pages if p.target_id[0].isdigit()), None) + assert captured is not None, "could not obtain a digit-leading target for the pin probe" + switched = await live.command("tab", captured.target_id, "--pin-tab") + assert switched["targetId"] == captured.target_id + await live.command("session", "info", "--no-pin-tab") + await live.command("session", "info", "--pin-tab") + endpoint = urlsplit(live._endpoint) + # External destruction is TEST FIXTURE ONLY, outside the identity sidecar. + with urllib.request.urlopen(f"http://127.0.0.1:{endpoint.port}/json/close/{captured.target_id}", timeout=3) as response: + assert response.status == 200 + result = await live.command("snapshot", "--pin-tab") + active = [t for t in browser.tabs_schema(await live.command("tab", "list")) if t["active"]] + assert active and active[0]["targetId"] != captured.target_id + assert "tab_gone" not in json.dumps(result) + assert result["lifecycle"]["relaunchedBrowser"] is False + assert live.pin_armed_for is None + # Actual Odysseus refuses before any page command, even with this observation. + denied = await PrivateBrowserTool().execute('{"action":"snapshot","page":"t1"}', + {"owner": "live-contract", "session_id": "thread"}) + assert denied["failure_kind"] == browser.PAGE_FAILURE and denied["executed"] is False + + +async def test_live_af_target_switch_is_exact_but_never_grants_page_execution(live): + await browser.observe_registered(live) + captured = live.pages[0] + switched = await live.command("tab", captured.target_id, "--pin-tab") + assert switched["targetId"] == captured.target_id + denied = await PrivateBrowserTool().execute('{"action":"click","page":"t1","ref":"e1"}', {}) + assert denied["executed"] is False diff --git a/tests/test_browser_resource_identity.py b/tests/test_browser_resource_identity.py new file mode 100644 index 000000000..7ec9de375 --- /dev/null +++ b/tests/test_browser_resource_identity.py @@ -0,0 +1,291 @@ +from dataclasses import replace +import asyncio +import hashlib +import json +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from src import browser_identity as browser +from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority +from src.agent_runtime.resources import BrowserSessionResource, BrowserPageResource, ResourceIdentityError, FilesystemRoot, FilesystemResource +from src.agent_tools.web_tools import PrivateBrowserTool +from src.process_lifecycle import ProcessIdentity +from tests.test_runtime_resource_integration import approval_for, dispatch + + +@pytest.fixture +def producer(tmp_path, monkeypatch): + root = tmp_path / "release" + root.mkdir() + binary = root / "agent-browser-linux-x64" + binary.write_bytes(b"explicit trusted fake producer") + binary.chmod(0o755) + checksum = hashlib.sha256(binary.read_bytes()).hexdigest() + monkeypatch.setattr(browser, "PRODUCER_ROOT", root) + monkeypatch.setattr(browser, "PRODUCER_HASHES", {"linux-x64": checksum}) + monkeypatch.setattr(browser, "STATE_ROOT", tmp_path / "private") + monkeypatch.setattr(browser, "_REGISTRY", {}) + monkeypatch.setattr(ProcessIdentity, "owned", lambda self: True) + state = SimpleNamespace(pid=4321, guid="12345678-1234-1234-1234-123456789abc", loader="loader-original", + target="A" * 32, label=None, active=True, version="0.35.0", launches=False, calls=[], cdp_calls=[], raw_calls=[]) + async def run(argv, **kwargs): + state.raw_calls.append(argv) + return "agent-browser " + state.version, "" + monkeypatch.setattr(browser, "run_client", run) + monkeypatch.setattr(browser.platform, "system", lambda: "Linux") + monkeypatch.setattr(browser.platform, "machine", lambda: "x86_64") + monkeypatch.setattr(browser, "observe", lambda pid, facts: SimpleNamespace( + identity=ProcessIdentity(state.pid, "frozen:" + str(state.pid), state.pid), facts=binary)) + class Sidecar: + def __init__(self, url): + browser.browser_digest(url) + async def __aenter__(self): return self + async def __aexit__(self, *a): pass + async def call(self, method, params=None, session_id=None): + assert method in browser.CDP_METHODS + state.cdp_calls.append(method) + if method == "Target.getTargets": + return {"targetInfos": [{"targetId": state.target, "type": "page"}]} + if method == "Target.getTargetInfo": + return {"targetInfo": {"targetId": state.target, "type": "page"}} + if method == "Target.attachToTarget": return {"sessionId": "observation-only"} + if method == "Page.getFrameTree": return {"frameTree": {"frame": {"id": state.target, "loaderId": state.loader}}} + return {} + monkeypatch.setattr(browser, "CDPSidecar", Sidecar) + async def command(record, *args): + state.calls.append(args) + lifecycle = {"launched": state.launches, "relaunchedBrowser": False, "restartedBackground": False} + if args[:2] == ("session", "info"): + return {"active": state.active, "version": state.version, "pid": state.pid, "session": record.key, + "socketDir": record.env["AGENT_BROWSER_SOCKET_DIR"], "namespace": None, "runtimeError": None, + "runtime": {"backgroundPid": state.pid, "session": record.key, "engine": "chrome", "browserLaunched": True, + "compatibilityStatus": "current", "socketDir": record.env["AGENT_BROWSER_SOCKET_DIR"], "restoreKey": None}} + if args == ("get", "cdp-url"): + return {"cdpUrl": "ws://127.0.0.1:12345/devtools/browser/" + state.guid, "lifecycle": lifecycle} + if args == ("tab", "list"): + return {"tabs": [{"tabId": "t1", "targetId": state.target, "label": state.label, "title": "metadata", + "url": "https://same.example", "type": "page", "active": True}]} + pytest.fail("Page command reached the producer") + monkeypatch.setattr(browser.RegisteredBrowser, "command", command) + return state + + +async def observed(producer): + record = await browser.register_producer("alice", "thread") + await browser.observe_registered(record) + return record + + +def authority(): + return RequestAuthority("request", "alice", "thread", "", (OperationGrant("private_browser"),)) + + +@pytest.mark.parametrize("action", sorted(browser.PAGE_ACTIONS | {"close"})) +async def test_disabled_page_operations_never_observe_select_or_execute(producer, action): + record = await observed(producer) + old = record.pages[0] + producer.target, producer.loader = "B" * 32, "replacement-document" + record.pin_armed_for = record.session.observation.session_incarnation # Still not a producer capability. + producer.calls.clear(); producer.cdp_calls.clear() + result = await PrivateBrowserTool().execute(json.dumps({"action": action, "page": "t1"}), + {"owner": "alice", "session_id": "thread"}) + assert result["failure_kind"] == browser.PAGE_FAILURE + assert result["executed"] is False and result["retryable"] is False + assert producer.calls == producer.cdp_calls == [] + assert old.target_id != producer.target + + +@pytest.mark.parametrize("args", [{"action": "batch", "commands": [["click", "@e1"]]}, + {"action": "tab"}, {"action": "window"}, {"action": "frame"}, {"action": "connect"}, + {"action": "click", "target": "--new-tab"}, {"action": "evaluate", "--cdp": "endpoint"}, + {"action": "click", "targetId": "A" * 32}, {"action": "open", "label": "unsafe"}, + {"action": "open", "provider": "remote"}, {"action": "open", "profile": "private"}, + {"action": "open", "state": "private"}, {"action": "open", "session-name": "other"}, + {"action": "open", "config": "other"}]) +async def test_raw_model_escapes_never_spawn(producer, args): + result = await PrivateBrowserTool().execute(json.dumps(args), {}) + assert result["executed"] is False + assert producer.raw_calls == producer.calls == [] + + +@pytest.mark.parametrize("page", ["t0", "t01", "t-1", "current", "title", "label", "A" * 32, 0, None]) +def test_alias_validation(page): + with pytest.raises(ValueError): browser.parse_operation(json.dumps({"action": "click", "page": page})) + + +async def test_observation_serializes_no_guid_or_control_url(producer): + record = await observed(producer) + page = record.pages[0] + payload = json.dumps(page.to_dict()) + assert producer.guid not in payload and "devtools/browser" not in payload + assert BrowserPageResource.from_dict(page.to_dict()) == page + assert page.target_id == "A" * 32 and page.loader_id == producer.loader + assert record.pin_armed_for is None + assert not any("pin-tab" in str(c) for c in producer.calls) + assert "Target.detachFromTarget" in producer.cdp_calls + + +@pytest.mark.parametrize("field,value", [("pid", 5678), ("guid", "87654321-1234-1234-1234-123456789abc")]) +async def test_session_replacement_invalidates_every_old_observation(producer, field, value): + record = await observed(producer) + old, page = record.session, record.pages[0] + record.pin_armed_for = old.observation.session_incarnation + setattr(producer, field, value) + await browser.observe_registered(record) + assert record.session != old and record.pin_armed_for is None + with pytest.raises(ValueError): old.validate() + with pytest.raises(ValueError): page.validate() + + +@pytest.mark.parametrize("field,value", [("label", "A" * 32), ("loader", ""), ("active", False), + ("version", "0.27.0"), ("version", "0.36.0"), ("launches", True)]) +async def test_bad_producer_observation_fails_closed(producer, field, value): + record = await observed(producer) + setattr(producer, field, value) + with pytest.raises(ValueError): await browser.observe_registered(record) + assert record.session is None and record.pages == () + + +async def test_replacing_same_url_page_or_loader_invalidates_document(producer): + record = await observed(producer) + old = record.pages[0] + producer.loader = "new-loader" + await browser.observe_registered(record) + with pytest.raises(ValueError): old.validate() + document = record.pages[0] + producer.target = "C" * 32 + await browser.observe_registered(record) + with pytest.raises(ValueError): document.validate() + + +@pytest.mark.parametrize("version", ["0.27.0", "0.36.0", "", "0.35.0-extra"]) +async def test_exact_producer_version_gate(producer, version): + producer.version = version + with pytest.raises(ValueError): await browser.trusted_producer() + + +async def test_binary_hash_gate_does_not_search_path_or_npx(producer): + (browser.PRODUCER_ROOT / "agent-browser-linux-x64").write_bytes(b"replacement") + with pytest.raises(ValueError): await browser.trusted_producer() + assert producer.raw_calls == [] + assert PrivateBrowserTool._local_agent_browser_binary() is None + + +@pytest.mark.parametrize("raw", ['{}', '{"success":true}', '{"success":1,"data":{}}', + '{"success":true,"data":{},"extra":1}', '{"success":true,"data":{},"success":false}', + '{"success":true,"data":{},"error":"secret"}', 'not-json']) +def test_strict_response_schema(raw): + with pytest.raises(ValueError): browser.response(raw) + + +@pytest.mark.parametrize("url", ["ws://127.0.0.1:123/devtools/browser", "ws://evil:123/devtools/browser/12345678-1234-1234-1234-123456789abc", + "http://127.0.0.1:123/devtools/browser/12345678-1234-1234-1234-123456789abc", "ws://127.0.0.1:99999/devtools/browser/12345678-1234-1234-1234-123456789abc"]) +def test_endpoint_validation_does_not_leak_capability(url): + with pytest.raises(ValueError) as failure: browser.browser_digest(url) + assert url not in str(failure.value) + + +async def test_environment_config_and_cwd_are_server_owned(producer, monkeypatch): + monkeypatch.setenv("AGENT_BROWSER_CDP", "untrusted") + monkeypatch.setenv("AGENT_BROWSER_CONFIG", "untrusted") + record = await observed(producer) + assert record.env == browser.owned_environment(record.cwd, record.key) + assert record.cwd.is_relative_to(browser.STATE_ROOT) + assert record.config.read_text() == "{}" + record.config.write_text('{"cdp":"remote"}') + with pytest.raises(ValueError): record.validate_config() + + +@pytest.mark.parametrize("alias", ["direct", "symlink", "hardlink"]) +async def test_browser_control_state_is_not_user_filesystem(producer, tmp_path, alias): + record = await observed(producer) + target = record.config + if alias != "direct": + target = tmp_path / "alias" + (os.link(record.config, target) if alias == "hardlink" else target.symlink_to(record.config)) + with pytest.raises(ValueError): FilesystemResource.resolve(FilesystemRoot.seal(tmp_path), str(target)) + from src.agent_runtime.process_resources import guard_launch_workspace + with pytest.raises(ValueError): guard_launch_workspace(FilesystemRoot.seal(tmp_path)) + + +async def test_session_metadata_exact_approval_first_use_and_replay(producer): + await observed(producer) + original = authority() + content = '{"action":"session_info"}' + approval = approval_for(original, "private_browser", content) + assert approval.pending.browser_operation.session == original.browser_sessions[0] + restored = replace(original, grants=(), browser_sessions=(), browser_pages=(), backend_resources=()) + _, first = await dispatch(restored, "private_browser", content, approval) + assert first["exit_code"] == 0 + assert "https://same.example" not in first["output"] + _, replay = await dispatch(restored, "private_browser", content, approval) + assert replay["exit_code"] == 1 + assert restored.browser_sessions == restored.browser_pages == () + + +async def test_page_approval_cannot_enable_unsupported_operations(producer): + await observed(producer) + original = authority() + content = '{"action":"click","page":"t1","ref":"e1"}' + approval = approval_for(original, "private_browser", content) + assert approval.pending.browser_operation.page.loader_id == producer.loader + producer.calls.clear(); producer.cdp_calls.clear() + restored = replace(original, grants=(), browser_sessions=(), browser_pages=()) + _, denied = await dispatch(restored, "private_browser", content, approval) + assert denied["failure_kind"] == browser.PAGE_FAILURE and denied["executed"] is False + assert not approval._claimed and producer.calls == producer.cdp_calls == [] + + +@pytest.mark.parametrize("field,value", [("owner", "bob"), ("request_id", "other"), ("session_id", "other")]) +async def test_browser_approval_application_binding_is_exact(producer, field, value): + await observed(producer) + original = authority() + content = '{"action":"session_info"}' + approval = approval_for(original, "private_browser", content) + changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=()) + _, result = await dispatch(changed, "private_browser", content, approval) + assert result["exit_code"] == 1 and not approval._claimed + + +async def test_page_child_cannot_acquire_session_scope_or_new_document(producer): + record = await observed(producer) + original = replace(authority(), browser_sessions=()) + child = original.intersect(authority()) + assert child.browser_sessions == () and child.browser_pages == original.browser_pages + with pytest.raises(ValueError): browser.resolve_browser_operation(child, ExactOperation.normalize("private_browser", '{"action":"session_info"}')) + producer.loader = "replacement" + await browser.observe_registered(record) + with pytest.raises(ValueError): original.intersect(authority()) + + +@pytest.mark.parametrize("phase", ["success", "exception", "cancel", "nested"]) +async def test_browser_context_restoration(producer, phase): + await observed(producer) + bound = browser.resolve_browser_operation(authority(), ExactOperation.normalize("private_browser", '{"action":"session_info"}')) + try: + with browser.bind_browser_operation(bound): + if phase == "exception": raise RuntimeError() + if phase == "cancel": raise asyncio.CancelledError() + if phase == "nested": + with browser.bind_browser_operation(None): assert browser._ACTIVE.get() is None + assert browser._ACTIVE.get() is bound + except (RuntimeError, asyncio.CancelledError): pass + assert browser._ACTIVE.get() is None + + +async def test_legacy_restoration_does_not_discover_browser_scopes(producer): + await observed(producer) + data = authority().to_dict() + data["version"] = 4 + del data["browser_sessions"], data["browser_pages"] + restored = RequestAuthority.from_dict(data) + assert restored.browser_sessions == restored.browser_pages == () + + +def test_lookup_does_not_create_legacy_or_missing_session(producer): + assert browser.registered("alice", "thread") is None + assert authority().browser_sessions == () + assert browser._REGISTRY == {} and producer.raw_calls == [] diff --git a/tests/test_browser_screenshot_artifact_safety.py b/tests/test_browser_screenshot_artifact_safety.py index c49bbf67b..ce1103715 100644 --- a/tests/test_browser_screenshot_artifact_safety.py +++ b/tests/test_browser_screenshot_artifact_safety.py @@ -21,5 +21,6 @@ def test_screenshot_cannot_overwrite_nonimage_artifact(monkeypatch, tmp_path, na {"session_id": "artifact-safety"}, )) assert result["exit_code"] == 1 - assert "OUTPUT destination" in result["error"] + assert result["failure_kind"] == "browser_page_authority_unavailable" + assert result["executed"] is False assert source.read_bytes() == b"original artifact" diff --git a/tests/test_browser_transport_recovery.py b/tests/test_browser_transport_recovery.py index 6cb0cdca3..39ae95592 100644 --- a/tests/test_browser_transport_recovery.py +++ b/tests/test_browser_transport_recovery.py @@ -59,7 +59,7 @@ async def test_stream_recovers_navigation_then_fetch_without_email_classifier(mo return {'tool_calls': [{'index': 0, 'id': name, 'type': 'function', 'function': {'name': name, 'arguments': json.dumps(args)}}]} responses = iter([ - call('private_browser', {'action': 'batch', 'commands': [['open', URL], ['find', 'wardrobe'], ['snapshot']]}), + call('private_browser', {'action': 'open', 'url': URL}), call('web_fetch', {'url': URL}), call('web_search', {'query': 'wardrobe'}), {'content': 'The site could not be read and no usable product evidence was found.'}, diff --git a/tests/test_clean_agent_preview.py b/tests/test_clean_agent_preview.py index 6be1af5f6..4cb1519eb 100644 --- a/tests/test_clean_agent_preview.py +++ b/tests/test_clean_agent_preview.py @@ -3392,7 +3392,7 @@ def test_skill_update_alias_normalizes_to_edit_before_policy(): assert args['action'] == 'edit' -def test_private_browser_open_normalizes_to_atomic_snapshot_batch(): +def test_private_browser_open_never_creates_an_internal_batch(): tool, args = normalize_preview_function_args( 'private_browser', {'action': 'open', 'url': 'https://example.com', 'timeout_ms': 12000}, @@ -3400,8 +3400,8 @@ def test_private_browser_open_normalizes_to_atomic_snapshot_batch(): assert tool == 'private_browser' assert args == { - 'action': 'batch', - 'commands': [['open', 'https://example.com'], ['snapshot']], + 'action': 'open', + 'url': 'https://example.com', 'timeout_ms': 12000, } @@ -3495,7 +3495,7 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call(): 'chat_with_model': ({'model': 'qwen', 'message': 'hello'}, 'ask model qwen to answer hello'), 'pipeline': ({'steps': [{'model': 'qwen', 'instruction': 'draft'}]}, 'run a model pipeline to draft'), 'pdf_extract': ({'url': 'https://example.com/x.pdf', 'query': 'metric'}, 'read this pdf'), - 'private_browser': ({'action': 'batch', 'commands': [['open', 'https://example.com'], ['snapshot']]}, 'use the private browser'), + 'private_browser': ({'action': 'session_info'}, 'use the private browser'), 'read_email': ({'uid': '1'}, 'read my email'), 'reply_to_email': ({'uid': '1', 'body': 'Thanks'}, 'reply to email UID 1 saying Thanks'), 'search_chats': ({'query': 'project'}, 'search my chats'), @@ -4322,23 +4322,19 @@ def test_compact_browser_distinguishes_element_refs_from_keyboard_keys(): original = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'private_browser') browser = compact_schemas([original])[0]['function'] assert 'fill/click/press' not in browser['description'] - assert 'key' in browser['description'] and 'Enter' in browser['description'] - assert 'focused' in browser['parameters']['properties']['key']['description'] + assert 'unavailable' in browser['description'] + assert 'commands' not in browser['parameters']['properties'] assert set(browser['parameters']['properties']) == set(original['function']['parameters']['properties']) -def test_v3_browser_batch_schema_matches_executor_sequence_contract(): +def test_v3_browser_schema_does_not_offer_batch_or_current_tab_authority(): browser = next( schema for schema in compact_schemas(FUNCTION_TOOL_SCHEMAS) if schema['function']['name'] == 'private_browser' )['function'] - commands = browser['parameters']['properties']['commands'] - - assert commands['items']['type'] == 'array' - assert commands['items']['items'] == {'type': 'string'} - assert '[["open"' in commands['description'] - assert 'snapshot' in browser['description'] - assert 'does not search the site' in browser['description'] + assert 'commands' not in browser['parameters']['properties'] + assert 'batch' not in browser['parameters']['properties']['action']['enum'] + assert 'unavailable' in browser['description'] def test_v3_browser_target_fields_preserve_selector_semantics(): diff --git a/tests/test_execution_bridge.py b/tests/test_execution_bridge.py index bcfc33b96..d0a808a50 100644 --- a/tests/test_execution_bridge.py +++ b/tests/test_execution_bridge.py @@ -32,8 +32,8 @@ def test_registry_dispatch_preserves_session_id_for_native_handlers(monkeypatch) monkeypatch.setattr(tool_execution, "_direct_fallback", fallback) async def invoke(): - block = Block('{"action":"snapshot"}') - block.tool_type = "private_browser" + block = Block('{"location":"Lisbon"}') + block.tool_type = "get_weather" return await execute_tool_block( block, session_id="runtime-session", @@ -41,7 +41,7 @@ def test_registry_dispatch_preserves_session_id_for_native_handlers(monkeypatch) ) description, result = asyncio.run(invoke()) - assert description.startswith("registry: private_browser") + assert description.startswith("registry: get_weather") assert result["exit_code"] == 0 assert seen["session_id"] == "runtime-session" diff --git a/tests/test_private_browser_tool.py b/tests/test_private_browser_tool.py index 5ac659126..2c5ecc315 100644 --- a/tests/test_private_browser_tool.py +++ b/tests/test_private_browser_tool.py @@ -1,3 +1,8 @@ +"""Pure browser formatting/path and Wave 5B cleanup regressions. + +Legacy successful page-command/batch/recovery tests have been superseded by +failed-before-dispatch resource tests in test_browser_resource_identity.py. +""" import asyncio import pytest import base64 @@ -27,62 +32,6 @@ def test_browser_distinguishes_loading_scaffolding_from_content(snapshot, empty) assert PrivateBrowserTool._empty_dom_observation(observation) is empty -def test_open_snapshot_batch_waits_for_loading_scaffolding(monkeypatch): - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set()) - batches = [] - class Proc: - returncode = 0 - def __init__(self, kwargs): - self.kwargs = kwargs - async def communicate(self, stdin=None): - batches.append(json.loads(stdin)) - snapshot = '- generic\n - generic' if len(batches) == 1 else '- heading "Loaded results"' - output = json.dumps([{'success': True, 'result': {'snapshot': snapshot}}]).encode() - if self.kwargs['stdout'] != asyncio.subprocess.PIPE: - self.kwargs['stdout'].write(output) - return b'', b'' - return output, b'' - async def spawn(*command, **kwargs): - return Proc(kwargs) - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - result = asyncio.run(PrivateBrowserTool().execute(json.dumps({ - 'action': 'batch', 'commands': [['open', 'https://example.com'], ['snapshot']], - }), {'session_id': 'loading-scaffolding'})) - assert 'Loaded results' in result['output'] - assert len(batches) == 2 - assert batches[1] == [['wait', '1000'], ['snapshot']] - - -def test_private_browser_plain_url_defaults_to_read() -> None: - args, err = PrivateBrowserTool()._parse_args("https://example.com") - - assert err is None - assert args == {"action": "read", "url": "https://example.com"} - - -def test_private_browser_rejects_snapshot_path_as_stale_page_risk(monkeypatch) -> None: - """A snapshot has no target path; local media needs inspect_media.""" - - called = False - - async def _unexpected_subprocess(*args, **kwargs): - nonlocal called - called = True - raise AssertionError("snapshot path must be rejected before browser launch") - - monkeypatch.setattr(asyncio, "create_subprocess_exec", _unexpected_subprocess) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "snapshot", "path": "/workspace/fixture.png"}), - {"session_id": "snapshot-path"}, - )) - - assert result["exit_code"] == 1 - assert "inspect_media" in result["error"] - assert not called - - def test_private_browser_maps_workspace_file_urls_and_screenshot_paths(monkeypatch, tmp_path) -> None: monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path)) @@ -100,29 +49,6 @@ def test_private_browser_maps_workspace_file_urls_and_screenshot_paths(monkeypat assert resolved_path == tmp_path / "output.png" -def test_private_browser_maps_bare_workspace_page_for_direct_and_batch_open( - monkeypatch, tmp_path -) -> None: - monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path)) - page = tmp_path / "output.html" - page.write_text("local") - - assert PrivateBrowserTool._resolve_local_file_url( - "/workspace/output.html" - ) == page.as_uri() - - commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([ - ["open", "/workspace/output.html"], - {"action": "read", "url": "file:///workspace/output.html"}, - ]) - - assert paths == [] - assert commands == [ - ["open", page.as_uri()], - ["open", page.as_uri()], - ] - - def test_generate_image_has_stable_native_schema() -> None: names = { schema.get("function", {}).get("name") @@ -132,23 +58,6 @@ def test_generate_image_has_stable_native_schema() -> None: assert "generate_image" in names -def test_private_browser_batch_schema_declares_array_items() -> None: - schema = next( - schema["function"] - for schema in FUNCTION_TOOL_SCHEMAS - if schema.get("function", {}).get("name") == "private_browser" - ) - commands = schema["parameters"]["properties"]["commands"] - - # Providers such as Gemini reject an array property without `items` before - # generation starts. Keep both supported batch command representations - # explicit in the native JSON schema. - assert commands["items"]["oneOf"] == [ - {"type": "array", "items": {"type": "string"}}, - {"type": "object"}, - ] - - def test_all_native_array_schemas_declare_items() -> None: """Provider APIs reject an array schema without an item schema.""" @@ -166,138 +75,6 @@ def test_all_native_array_schemas_declare_items() -> None: list(walk(FUNCTION_TOOL_SCHEMAS, "FUNCTION_TOOL_SCHEMAS")) -def test_private_browser_builds_fill_command_without_shell() -> None: - command, stdin_data, err = PrivateBrowserTool()._command_for_action( - ["agent-browser"], - "fill", - {"selector": "@e1", "text": "hello"}, - ) - - assert err is None - assert stdin_data is None - assert command == ["agent-browser", "fill", "@e1", "hello"] - - -def test_private_browser_builds_visible_text_find_command() -> None: - command, stdin_data, err = PrivateBrowserTool()._command_for_action( - ["agent-browser"], - "find", - {"find": "Learn more"}, - ) - - assert err is None - assert stdin_data is None - assert command == ["agent-browser", "find", "text", "Learn more", "text"] - - -def test_private_browser_click_accepts_role_and_accessible_name_fields() -> None: - command, stdin_data, err = PrivateBrowserTool()._command_for_action( - ["agent-browser"], - "click", - {"target": "link", "text": "Learn more"}, - ) - - assert err is None - assert stdin_data is None - assert command == [ - "agent-browser", "find", "role", "link", "click", "--name", "Learn more", - ] - - -def test_private_browser_click_accepts_quoted_role_target() -> None: - command, stdin_data, err = PrivateBrowserTool()._command_for_action( - ["agent-browser"], - "click", - {"target": 'link "Learn more"'}, - ) - - assert err is None - assert stdin_data is None - assert command == [ - "agent-browser", "find", "role", "link", "click", "--name", "Learn more", - ] - - -def test_private_browser_batch_normalizes_stable_inspection_action_names() -> None: - commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([ - ["open", "https://example.com"], - ["evaluate", "document.title"], - ["find", "Learn more"], - ]) - - assert paths == [] - assert commands == [ - ["open", "https://example.com"], - ["eval", "document.title"], - ["find", "text", "Learn more", "text"], - ] - - -def test_private_browser_batch_read_selector_matches_top_level_read_semantics() -> None: - commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([ - ["open", "https://example.com"], - ["read", "h1"], - ]) - - assert paths == [] - assert commands == [ - ["open", "https://example.com"], - ["get", "text", "h1"], - ] - - -def test_private_browser_batch_recovers_omitted_wait_selector_with_timeout() -> None: - commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([ - ["fill", "@e2", "orange"], - ["wait", None, 2500], - ["snapshot"], - ]) - - assert paths == [] - assert commands == [ - ["fill", "@e2", "orange"], - ["wait", "2500"], - ["snapshot"], - ] - - -def test_private_browser_batch_normalizes_object_commands_to_cli_arrays() -> None: - commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([ - {"action": "open", "url": "https://example.com"}, - {"action": "snapshot"}, - {"action": "find", "find": "Contact"}, - ]) - - assert paths == [] - assert commands == [ - ["open", "https://example.com"], - ["snapshot"], - ["find", "text", "Contact", "text"], - ] - - -def test_private_browser_batch_stops_guessed_interaction_after_open_at_snapshot() -> None: - commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([ - ["open", "https://example.com"], - ["fill", "search input", "chair"], - ["press", "Enter"], - ]) - - assert paths == [] - assert commands == [["open", "https://example.com"], ["snapshot"]] - - -def test_private_browser_batch_preserves_explicit_css_after_open() -> None: - commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([ - ["open", "https://example.com"], - ["fill", "#search", "chair"], - ["press", "Enter"], - ]) - - assert paths == [] - assert commands[1] == ["fill", "#search", "chair"] - - def test_private_browser_exposes_global_store_landing_link_ref() -> None: output = ''' - heading "Welcome to IKEA Global!" @@ -309,385 +86,6 @@ def test_private_browser_exposes_global_store_landing_link_ref() -> None: assert "@e172" in hint -def test_private_browser_builds_evaluate_command() -> None: - command, stdin_data, err = PrivateBrowserTool()._command_for_action( - ["agent-browser"], - "evaluate", - {"script": "document.location.hostname"}, - ) - - assert err is None - assert stdin_data is None - assert command == ["agent-browser", "eval", "document.location.hostname"] - - -def test_private_browser_executes_scroll_with_native_browser_command(monkeypatch) -> None: - monkeypatch.setattr( - web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser" - ) - monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set()) - calls = [] - - class _FakeProc: - returncode = 0 - - async def communicate(self, stdin=None): - return b"scrolled", b"" - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls.append(list(command)) - return _FakeProc() - - monkeypatch.setattr( - asyncio, "create_subprocess_exec", _fake_create_subprocess_exec - ) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "scroll", "direction": "down", "amount": 500}), - {"session_id": "scroll-session"}, - )) - - assert result["exit_code"] == 0 - assert calls[0][-3:] == ["scroll", "down", "500"] - - -def test_private_browser_expands_tiny_scroll_steps_to_pixels() -> None: - command, stdin_data, err = PrivateBrowserTool()._command_for_action( - ["agent-browser"], "scroll", {"direction": "down", "amount": 5}, - ) - assert err is None - assert stdin_data is None - assert command[-3:] == ["scroll", "down", "1500"] - - -def test_private_browser_reads_element_from_current_page_without_url(monkeypatch) -> None: - monkeypatch.setattr( - web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser" - ) - monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set()) - calls = [] - - class _FakeProc: - returncode = 0 - - async def communicate(self, stdin=None): - return b"Play Animation", b"" - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls.append(list(command)) - return _FakeProc() - - monkeypatch.setattr( - asyncio, "create_subprocess_exec", _fake_create_subprocess_exec - ) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "read", "selector": "#playBtn"}), - {"session_id": "read-session"}, - )) - - assert result["exit_code"] == 0 - assert calls[0][-3:] == ["get", "text", "#playBtn"] - - -@pytest.mark.parametrize('mode,observed', [('recent_model_choice', True), ('baseline', False)]) -def test_keyboard_submit_returns_new_page_state_without_repeating_key(monkeypatch, mode, observed): - from types import SimpleNamespace - import src.turn_contract as turn_contract - monkeypatch.setattr(turn_contract, 'active_turn_contract', - lambda: SimpleNamespace(routing_experiment=mode)) - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - commands = [] - class Proc: - returncode = 0 - def __init__(self, kwargs): self.kwargs = kwargs - async def communicate(self, stdin=None): - if stdin: - assert all(command[0] in {'wait', 'snapshot'} for command in json.loads(stdin)) - return json.dumps([{'success': True, 'result': { - 'origin': 'https://example.org/results', - 'snapshot': '- heading "Search results" [ref=e4]'}}]).encode(), b'' - self.kwargs['stdout'].write(b'Done') - return b'', b'' - async def spawn(*command, **kwargs): - commands.append(command) - return Proc(kwargs) - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': 'press', 'key': 'Enter'}), {'session_id': 'keyboard-submit'})) - assert result['exit_code'] == 0 - assert ('Search results' in result['output']) is observed - assert sum('press' in command for command in commands) == 1 - assert commands[0][-2:] == ('press', 'Enter') - - -def test_private_browser_accepts_visible_text_button_selector(monkeypatch) -> None: - monkeypatch.setattr( - web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser" - ) - monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set()) - calls = [] - - class _FakeProc: - returncode = 0 - - async def communicate(self, stdin=None): - return b"clicked", b"" - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls.append(list(command)) - return _FakeProc() - - monkeypatch.setattr( - asyncio, "create_subprocess_exec", _fake_create_subprocess_exec - ) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({ - "action": "click", - "selector": 'button:has-text("Play Animation")', - }), - {"session_id": "click-session"}, - )) - - assert result["exit_code"] == 0 - assert calls[0][-6:] == [ - "find", "role", "button", "click", "--name", "Play Animation", - ] - - -def test_private_browser_successful_click_returns_settled_snapshot(monkeypatch) -> None: - monkeypatch.setattr( - web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser" - ) - monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set()) - calls = [] - - class _FakeProc: - returncode = 0 - - async def communicate(self, stdin=None): - if stdin: - return b'[{"command":["snapshot"],"result":{"snapshot":"heading Example"},"success":true}]', b"" - return b"clicked", b"" - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls.append(list(command)) - return _FakeProc() - - monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "click", "target": "@e2"}), - {"session_id": "click-settled-session"}, - )) - - assert result["exit_code"] == 0 - assert "post-click page state" in result["output"] - assert "heading Example" in result["output"] - assert calls[1][-2:] == ["batch", "--json"] - - -@pytest.mark.parametrize('action', ['fill', 'click', 'read', 'wait']) -@pytest.mark.parametrize('ref', ['e2', '@e2']) -def test_browser_accepts_explicit_snapshot_ref_at_execution_boundary(monkeypatch, action, ref): - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set()) - commands = [] - class Proc: - returncode = 0 - async def communicate(self, stdin=None): - return b'[]', b'' - async def spawn(*command, **kwargs): - commands.append(command) - return Proc() - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': action, 'ref': ref, 'text': 'orange'}), - {'session_id': 'snapshot-ref-alias-test'})) - assert result['exit_code'] == 0, result - suffix = {'fill': ('fill', '@e2', 'orange'), 'click': ('click', '@e2'), - 'read': ('get', 'text', '@e2'), 'wait': ('wait', '@e2')}[action] - assert commands[0][-len(suffix):] == suffix - - -@pytest.mark.parametrize('ref', ['button', '[ref=e2]', '--help', 'e2;click e3']) -def test_browser_rejects_malformed_ref_without_launching(monkeypatch, ref): - async def unexpected(*args, **kwargs): - raise AssertionError('invalid reference reached browser') - monkeypatch.setattr(asyncio, 'create_subprocess_exec', unexpected) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': 'fill', 'ref': ref, 'text': 'orange'}), {})) - assert result['exit_code'] == 1 - assert 'ref' in result['error'] - - -@pytest.mark.parametrize('field', ['selector', 'target']) -def test_browser_rejects_conflicting_ref_targets_without_launching(monkeypatch, field): - async def unexpected(*args, **kwargs): - raise AssertionError('conflicting targets reached browser') - monkeypatch.setattr(asyncio, 'create_subprocess_exec', unexpected) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': 'click', 'ref': 'e2', field: '@e3'}), {})) - assert result['exit_code'] == 1 - assert 'conflicts' in result['error'] - - -def test_model_choice_open_returns_page_refs_without_rewriting_requested_url(monkeypatch): - from types import SimpleNamespace - import src.turn_contract as turn_contract - monkeypatch.setattr(turn_contract, 'active_turn_contract', - lambda: SimpleNamespace(routing_experiment='recent_model_choice')) - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set()) - calls = [] - class Proc: - returncode = 0 - async def communicate(self, stdin=None): - return (b'[{"result":{"snapshot":"textbox Search [ref=e1]"},"success":true}]', b'') if stdin else (b'opened', b'') - async def spawn(*command, **kwargs): - calls.append(command) - return Proc() - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': 'open', 'url': 'https://example.org/catalog'}), - {'session_id': 'open-observation-test'})) - assert result['exit_code'] == 0 - assert 'textbox Search [ref=e1]' in result['output'] - assert 'https://example.org/catalog' in calls[0] - assert len(calls) == 2 - - -@pytest.mark.parametrize('mode,observed', [('recent_model_choice', True), ('baseline', False)]) -def test_successful_fill_observes_script_driven_dialog_without_retry(monkeypatch, mode, observed): - from types import SimpleNamespace - import src.turn_contract as turn_contract - monkeypatch.setattr(turn_contract, 'active_turn_contract', - lambda: SimpleNamespace(routing_experiment=mode)) - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set()) - commands = [] - class Proc: - returncode = 0 - async def communicate(self, stdin=None): - if stdin: - return json.dumps([ - {'command': ['get', 'value', '@e2'], 'success': True, 'result': {'value': 'orange'}}, - {'result': {'snapshot': 'dialog Preferences\nbutton Close [ref=e2]'}, 'success': True}, - ]).encode(), b'' - return b'', b'' - async def spawn(*command, **kwargs): - commands.append(command) - return Proc() - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': 'fill', 'target': '@e2', 'text': 'orange'}), - {'session_id': 'post-fill-observation'})) - assert result['exit_code'] == 0 - assert ('dialog Preferences' in result['output']) is observed - assert len(commands) == (2 if observed else 1) - assert sum('fill' in command for command in commands) == 1 - - -@pytest.mark.parametrize('mode,outcome', [ - ('recent_model_choice', 'populated'), ('recent_model_choice', 'empty'), - ('recent_model_choice', 'timeout'), ('recent_model_choice', 'invalid'), - ('baseline', 'populated'), -]) -def test_click_observes_empty_destination_with_bounded_read_only_retry(monkeypatch, mode, outcome): - from types import SimpleNamespace - import src.turn_contract as turn_contract - monkeypatch.setattr(turn_contract, 'active_turn_contract', - lambda: SimpleNamespace(routing_experiment=mode)) - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set()) - commands, batches, deadlines, killed = [], [], [], [] - real_timeout = asyncio.timeout - def timed_observation(delay): - deadlines.append(delay) - return real_timeout(delay) - monkeypatch.setattr(asyncio, 'timeout', timed_observation) - class Proc: - returncode = 0 - def __init__(self, kwargs): - self.kwargs = kwargs - def kill(self): - killed.append(True) - async def communicate(self, stdin=None): - if stdin: - batches.append(json.loads(stdin)) - if len(batches) == 1: - await asyncio.sleep(0.01) - elif outcome == 'timeout': - raise asyncio.TimeoutError() - elif outcome == 'invalid': - return b'[{"success": false, "error": "snapshot unavailable"}]', b'' - snapshot = '(empty page)' if len(batches) == 1 or outcome == 'empty' else '- heading "Destination" [ref=e7]' - return json.dumps([{'command': ['snapshot'], 'success': True, - 'result': {'origin': 'https://example.org/destination', 'snapshot': snapshot}}]).encode(), b'' - self.kwargs['stdout'].write('✓ Done'.encode()) - return b'', b'' - async def spawn(*command, **kwargs): - commands.append(command) - return Proc(kwargs) - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': 'click', 'target': '@e2'}), {'session_id': 'empty-destination'})) - assert result['exit_code'] == 0, result - if outcome == 'populated': - assert 'Destination' in result['output'] and '[ref=e7]' in result['output'] - assert '(empty page)' not in result['output'] - else: - assert '(empty page)' in result['output'] - assert '[ref=' not in result['output'] - if outcome in {'timeout', 'invalid'}: - assert 'fresh page snapshot could not be obtained' in result['output'] - assert bool(killed) is (outcome == 'timeout') - assert len(batches) == 2 - assert 0 < deadlines[1] < deadlines[0] <= 20 - assert sum('click' in command for command in commands) == 1 - assert all(command[0] in {'wait', 'snapshot'} for batch in batches for command in batch) - - -@pytest.mark.parametrize('retained', [True, False]) -def test_empty_snapshot_retry_preserves_fill_verification_without_reusing_old_refs(monkeypatch, retained): - from types import SimpleNamespace - import src.turn_contract as turn_contract - monkeypatch.setattr(turn_contract, 'active_turn_contract', - lambda: SimpleNamespace(routing_experiment='recent_model_choice')) - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - commands, batches = [], [] - class Proc: - returncode = 0 - def __init__(self, kwargs): - self.kwargs = kwargs - async def communicate(self, stdin=None): - if stdin: - batches.append(json.loads(stdin)) - rows = [{'command': ['snapshot'], 'success': True, 'result': { - 'snapshot': '(empty page)' if len(batches) == 1 else '- textbox Search [ref=e9]'}}] - if len(batches) == 1: - rows.insert(0, {'command': ['get', 'value', '@e2'], 'success': True, - 'result': {'value': 'private-sentinel' if retained else ''}}) - return json.dumps(rows).encode(), b'' - self.kwargs['stdout'].write('✓ Done'.encode()) - return b'', b'' - async def spawn(*command, **kwargs): - commands.append(command) - return Proc(kwargs) - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': 'fill', 'target': '@e2', 'text': 'private-sentinel'}), - {'session_id': 'fill-empty-snapshot'})) - assert result['exit_code'] == (0 if retained else 1), result - assert 'textbox Search [ref=e9]' in result['output'] - assert 'private-sentinel' not in json.dumps(result) - assert len(batches) == 2 - assert batches[0].index(['get', 'value', '@e2']) < batches[0].index(['snapshot']) - assert all(command[0] in {'wait', 'snapshot'} for command in batches[1]) - assert sum('fill' in command for command in commands) == 1 - - def test_snapshot_observation_preserves_dom_refs_without_duplicate_metadata(): snapshot = '- searchbox "Search catalog" [ref=e2]\n- button "Search" [ref=e3]' raw = json.dumps([{'success': True, 'result': { @@ -700,336 +98,6 @@ def test_snapshot_observation_preserves_dom_refs_without_duplicate_metadata(): assert PrivateBrowserTool._snapshot_observation(errors) == errors -@pytest.mark.parametrize('action', ['open', 'snapshot']) -def test_large_page_dialog_controls_survive_both_browser_observation_budgets(monkeypatch, action): - from types import SimpleNamespace - import src.turn_contract as turn_contract - from src.clean_agent_preview import preview_tool_result_text - monkeypatch.setattr(turn_contract, 'active_turn_contract', - lambda: SimpleNamespace(routing_experiment='recent_model_choice')) - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - snapshot = '- main\n' + ' - paragraph "Catalog item description"\n' * 1000 + ( - '- region "Preferences"\n' - ' - dialog "Choose preferences"\n' - ' - paragraph "Some choices are optional."\n' - ' - button "Only necessary" [ref=e901]\n' - ' - button "All options" [ref=e902]\n' - '- contentinfo\n' - ) - commands = [] - class Proc: - returncode = 0 - def __init__(self, command, kwargs): - self.command, self.kwargs = command, kwargs - async def communicate(self, stdin=None): - if not stdin and self.command[-1] == 'snapshot': - self.kwargs['stdout'].write(snapshot.encode()) - return (json.dumps([{'success': True, 'result': { - 'origin': 'https://example.org/catalog', 'snapshot': snapshot, - }}]).encode(), b'') if stdin else (b'', b'') - async def spawn(*command, **kwargs): - commands.append(command) - return Proc(command, kwargs) - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - args = {'action': action} - if action == 'open': - args['url'] = 'https://example.org/catalog' - result = asyncio.run(PrivateBrowserTool().execute(json.dumps(args), {'session_id': 'large-dialog'})) - observation = preview_tool_result_text(result, 'private_browser', args) - assert result['exit_code'] == 0 - assert '- dialog "Choose preferences"' in observation - assert observation.count('button "Only necessary" [ref=e901]') == 1 - assert observation.count('button "All options" [ref=e902]') == 1 - if action == 'open': - assert 'https://example.org/catalog' in observation - assert len(observation) < 8100 - assert not any('click' in command or 'fill' in command for command in commands) - - -def test_fill_reports_incomplete_when_browser_success_did_not_retain_text(monkeypatch): - from types import SimpleNamespace - import src.turn_contract as turn_contract - monkeypatch.setattr(turn_contract, 'active_turn_contract', - lambda: SimpleNamespace(routing_experiment='recent_model_choice')) - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set()) - commands = [] - batches = [] - class Proc: - returncode = 0 - def __init__(self, kwargs): - self.kwargs = kwargs - async def communicate(self, stdin=None): - if stdin: - batches.append(json.loads(stdin)) - return json.dumps([ - {'command': ['get', 'value', '@e2'], 'success': True, 'result': {'value': ''}}, - {'command': ['snapshot'], 'success': True, - 'result': {'snapshot': 'dialog Preferences\nbutton Close [ref=e2]'}}, - ]).encode(), b'' - self.kwargs['stdout'].write('✓ Done'.encode()) - return b'', b'' - async def spawn(*command, **kwargs): - commands.append(command) - return Proc(kwargs) - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': 'fill', 'ref': 'e2', 'text': 'orange'}), - {'session_id': 'incomplete-fill'})) - assert result['exit_code'] == 1, result - assert 'did not retain' in result['error'] - assert 'dialog Preferences' in result['output'] - assert '✓ Done' not in result['output'] - assert sum('fill' in command for command in commands) == 1 - assert batches[0].index(['get', 'value', '@e2']) < batches[0].index(['snapshot']) - - -@pytest.mark.parametrize('raw,expected_exit', [ - ([{'command': ['get', 'value', '@e2'], 'success': True, - 'result': {'value': 'sentinel-private-input'}}], 0), - ([{'command': ['get', 'value', '@e2'], 'success': False, - 'result': {'value': 'sentinel-private-input'}}], 1), - ([], 1), - ('malformed sentinel-private-input', 1), -]) -def test_fill_verification_is_truthful_without_dumping_input_values(monkeypatch, raw, expected_exit): - from types import SimpleNamespace - import src.turn_contract as turn_contract - monkeypatch.setattr(turn_contract, 'active_turn_contract', - lambda: SimpleNamespace(routing_experiment='recent_model_choice')) - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set()) - class Proc: - returncode = 0 - async def communicate(self, stdin=None): - return (json.dumps(raw).encode(), b'') if stdin else (b'', b'') - async def spawn(*command, **kwargs): - return Proc() - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': 'fill', 'target': '@e2', 'text': 'sentinel-private-input'}), - {'session_id': 'private-fill-verification'})) - assert result['exit_code'] == expected_exit - assert 'sentinel-private-input' not in json.dumps(result) - if expected_exit: - assert 'could not be verified' in result['error'] - - -@pytest.mark.parametrize('mode,observed', [('recent_model_choice', True), ('baseline', True)]) -@pytest.mark.parametrize('action', ['click', 'fill']) -def test_failed_interaction_returns_current_refs_without_retrying_action(monkeypatch, mode, observed, action): - from types import SimpleNamespace - import src.turn_contract as turn_contract - monkeypatch.setattr(turn_contract, 'active_turn_contract', - lambda: SimpleNamespace(routing_experiment=mode)) - monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser') - monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set()) - commands = [] - class Proc: - def __init__(self, kwargs, failed): - self.kwargs, self.failed = kwargs, failed - self.returncode = 1 if failed else 0 - async def communicate(self, stdin=None): - if self.failed: - self.kwargs['stderr'].write(b'Element is covered by a dialog') - return b'', b'' - return b'[{"result":{"snapshot":"dialog Cookie choices\\nbutton Reject optional [ref=e9]"},"success":true}]', b'' - async def spawn(*command, **kwargs): - commands.append(command) - return Proc(kwargs, len(commands) == 1) - monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn) - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({'action': action, 'target': '@e2', 'text': 'orange'}), {'session_id': 'failed-interaction-observation'})) - assert result['exit_code'] == 1 - assert 'Element is covered' in result['output'] - assert ('Reject optional [ref=e9]' in result['output']) is observed - assert len(commands) == (2 if observed else 1) - assert sum(action in command for command in commands) == 1 - if observed: - assert commands[1][-2:] == ('batch', '--json') - - -def test_private_browser_bare_wait_uses_timeout_as_duration_with_process_headroom() -> None: - tool = PrivateBrowserTool() - - command, stdin_data, err = tool._command_for_action( - ["agent-browser"], - "wait", - {"timeout_ms": 2000}, - ) - - assert err is None - assert stdin_data is None - assert command == ["agent-browser", "wait", "2000"] - assert tool._timeout_seconds({"timeout_ms": 2000}, action="wait") >= 7 - - -def test_private_browser_prefix_is_scoped_to_odysseus_session() -> None: - prefix = PrivateBrowserTool()._with_session_args( - ["agent-browser"], - {"session_id": "f42b1fb9-3747-44f0-bf64-61e7b3b14faa"}, - ) - - assert prefix == [ - "agent-browser", - "--session", - "ody-" + __import__('hashlib').sha256( - b'odysseus-ui\0f42b1fb9-3747-44f0-bf64-61e7b3b14faa' - ).hexdigest()[:20], - ] - - -def test_private_browser_namespace_can_isolate_parallel_runtimes(monkeypatch) -> None: - monkeypatch.setenv("ODYSSEUS_BROWSER_NAMESPACE", "clawmm-run/abc") - - prefix = PrivateBrowserTool()._with_session_args( - ["agent-browser"], - {"session_id": "session-1"}, - ) - - assert prefix == [ - "agent-browser", - "--session", - "ody-" + __import__('hashlib').sha256( - b'clawmm-run/abc\0session-1' - ).hexdigest()[:20], - ] - - -def test_private_browser_namespace_uses_effective_task_environment(monkeypatch) -> None: - monkeypatch.delenv("ODYSSEUS_BROWSER_NAMESPACE", raising=False) - - prefix = PrivateBrowserTool()._with_session_args( - ["agent-browser"], - { - "session_id": "session-1", - "subproc_env": {"ODYSSEUS_BROWSER_NAMESPACE": "clawmm-task-abc"}, - }, - ) - - assert prefix == [ - "agent-browser", - "--session", - "ody-" + __import__('hashlib').sha256( - b'clawmm-task-abc\0session-1' - ).hexdigest()[:20], - ] - - -def test_private_browser_long_session_and_namespace_are_bounded(monkeypatch): - monkeypatch.setenv('ODYSSEUS_BROWSER_NAMESPACE', 'runtime-' + 'n'*100) - prefix = PrivateBrowserTool()._with_session_args(['agent-browser'], {'session_id':'x'*200}) - assert len(prefix[prefix.index('--session')+1]) <= 24 - - -def test_private_browser_hashes_preserve_session_isolation(): - tool=PrivateBrowserTool() - names=[tool._with_session_args(['agent-browser'], {'session_id':s})[-1] - for s in ['x'*100+'a', 'x'*100+'b', 'a/b', 'a?b']] - assert len(set(names)) == 4 - assert tool._with_session_args(['agent-browser'], {'session_id':'x'*100+'a'})[-1] == names[0] - - -def test_private_browser_reuses_host_npx_cache_when_task_home_is_isolated( - monkeypatch, tmp_path -) -> None: - host_home = tmp_path / "host-home" - task_home = tmp_path / "task-home" - host_home.mkdir() - task_home.mkdir() - monkeypatch.setenv("HOME", str(host_home)) - monkeypatch.setattr(web_tools, "_service_home", lambda: host_home) - monkeypatch.setattr(web_tools, "_host_npm_roots", lambda: [host_home / ".npm"]) - monkeypatch.delenv("npm_config_cache", raising=False) - monkeypatch.delenv("NPM_CONFIG_CACHE", raising=False) - - def _which(name: str): - return "/usr/bin/npx" if name == "npx" else None - - monkeypatch.setattr(web_tools.shutil, "which", _which) - calls = {} - - class _FakeProc: - returncode = 0 - - def __init__(self, stdout): - self.stdout = stdout - - async def communicate(self, stdin=None): - self.stdout.write(json.dumps([ - {"success": True, "result": {"title": "T", "url": "https://example.com/"}}, - {"success": True, "result": {"text": "page text"}}, - ]).encode()) - return None, None - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls["env"] = kwargs["env"] - return _FakeProc(kwargs["stdout"]) - - monkeypatch.setattr( - asyncio, "create_subprocess_exec", _fake_create_subprocess_exec - ) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "read", "url": "https://example.com"}), - {"subproc_env": {"HOME": str(task_home)}}, - )) - - assert result["exit_code"] == 0 - assert calls["env"]["HOME"] == str(host_home) - assert calls["env"]["npm_config_cache"] == str(host_home / ".npm") - assert calls["env"]["NPM_CONFIG_CACHE"] == str(host_home / ".npm") - assert calls["env"]["AGENT_BROWSER_IDLE_TIMEOUT_MS"] == "300000" - - -def test_private_browser_prefers_installed_npx_binary(monkeypatch, tmp_path) -> None: - package_bin = ( - tmp_path - / ".npm" - / "_npx" - / "abc" - / "node_modules" - / "agent-browser" - / "bin" - ) - package_bin.mkdir(parents=True) - binary = package_bin / "agent-browser-linux-x64" - binary.write_text("#!/bin/sh\n") - binary.chmod(0o755) - monkeypatch.setattr(web_tools, "_service_home", lambda: tmp_path) - monkeypatch.setattr(web_tools, "_host_npm_roots", lambda: [tmp_path / ".npm"]) - - assert PrivateBrowserTool._local_agent_browser_binary() == str(binary) - - -def test_private_browser_skips_unreadable_host_cache(monkeypatch, tmp_path) -> None: - blocked = tmp_path / "blocked" - usable = tmp_path / "usable" - binary = ( - usable - / "_npx" - / "abc" - / "node_modules" - / "agent-browser" - / "bin" - / "agent-browser-linux-x64" - ) - binary.parent.mkdir(parents=True) - binary.write_text("#!/bin/sh\n") - binary.chmod(0o755) - real_glob = Path.glob - - def _glob(path, pattern): - if blocked in path.parents or path == blocked: - raise PermissionError(path) - return real_glob(path, pattern) - - monkeypatch.setattr(web_tools, "_host_npm_roots", lambda: [blocked, usable]) - monkeypatch.setattr(Path, "glob", _glob) - - assert PrivateBrowserTool._local_agent_browser_binary() == str(binary) - - def test_browser_executable_discovery_supports_chromium_snapshot_cache( monkeypatch, tmp_path ) -> None: @@ -1057,63 +125,6 @@ def test_browser_executable_discovery_supports_chromium_snapshot_cache( assert web_tools._browser_executable_candidates() == [chrome] -def test_private_browser_shutdown_is_namespace_scoped(monkeypatch, tmp_path) -> None: - calls = {} - - class _FakeProc: - async def communicate(self): - return b"", b"" - - monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/bin/agent-browser") - monkeypatch.setenv("ODYSSEUS_BROWSER_NAMESPACE", "clawmm-test") - monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path)) - monkeypatch.delenv("AGENT_BROWSER_SOCKET_DIR", raising=False) - web_tools._ACTIVE_BROWSER_SESSIONS.clear() - session = web_tools._scoped_browser_session("clawmm-test", "session-1") - web_tools._ACTIVE_BROWSER_SESSIONS.add(session) - (tmp_path / "agent-browser").mkdir() - (tmp_path / "agent-browser" / f"{session}.pid").write_text("7001") - proc = tmp_path / "proc" - (proc / "7001").mkdir(parents=True) - (proc / "7001" / "cmdline").write_bytes(b"agent-browser-linux-x64\0") - monkeypatch.setattr(platform_compat, "PROC_ROOT", proc) - monkeypatch.setattr(web_tools.os, "kill", lambda pid, sig: None) - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls["command"] = command - calls["env"] = kwargs["env"] - return _FakeProc() - - monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec) - asyncio.run(shutdown_private_browser_sessions()) - - assert calls["command"] == ( - "/bin/agent-browser", "--session", session, "close" - ) - assert not web_tools._ACTIVE_BROWSER_SESSIONS - - -def test_private_browser_shutdown_never_bootstraps_a_missing_daemon( - monkeypatch, tmp_path -) -> None: - monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/bin/agent-browser") - monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path)) - monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "proc") - (tmp_path / "proc").mkdir() - web_tools._ACTIVE_BROWSER_SESSIONS.clear() - web_tools._ACTIVE_BROWSER_SESSIONS.add( - web_tools._scoped_browser_session("odysseus-ui", "never-started") - ) - - async def _no_spawn(*command, **kwargs): - pytest.fail(f"close would start a fresh daemon: {command}") - - monkeypatch.setattr(asyncio, "create_subprocess_exec", _no_spawn) - asyncio.run(shutdown_private_browser_sessions()) - - assert not web_tools._ACTIVE_BROWSER_SESSIONS - - def test_browser_pid_candidates_include_upstream_root_session() -> None: runtime = Path("/run/user/1000") namespace = "clawmm-test" @@ -1139,304 +150,6 @@ def test_browser_pid_candidates_do_not_sweep_shared_root_without_session( assert candidates == [legacy / "ody-old.pid"] -def test_private_browser_local_file_read_uses_supported_open_action( - monkeypatch, tmp_path -) -> None: - page = tmp_path / "output.html" - page.write_text("local") - monkeypatch.setattr( - "src.tool_execution.get_active_workspace", lambda: str(tmp_path) - ) - monkeypatch.setattr( - web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser" - ) - monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set()) - monkeypatch.setattr(PrivateBrowserTool, "_owned_daemon_exists", staticmethod(lambda env, session: True)) - calls = [] - - class _FakeProc: - returncode = 0 - - def __init__(self, command): - self.command = list(command) - - async def communicate(self, stdin=None): - if self.command[-1] == "errors": - return b"No page errors found", b"" - return b"opened local page", b"" - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls.append(list(command)) - return _FakeProc(command) - - monkeypatch.setattr( - asyncio, "create_subprocess_exec", _fake_create_subprocess_exec - ) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "read", "url": "/workspace/output.html"}), - {"session_id": "local-read"}, - )) - - assert result["exit_code"] == 0 - assert calls[0][-1] == "close" - assert calls[1][-2:] == ["open", page.as_uri()] - assert calls[2][-1] == "errors" - - -def test_private_browser_new_local_session_skips_reset_close( - monkeypatch, tmp_path -) -> None: - page = tmp_path / "new.html" - page.write_text("new") - monkeypatch.setattr( - "src.tool_execution.get_active_workspace", lambda: str(tmp_path) - ) - monkeypatch.setattr( - web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser" - ) - monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set()) - calls = [] - - class _FakeProc: - returncode = 0 - - def __init__(self, command): - self.command = list(command) - - async def communicate(self, stdin=None): - if self.command[-1] == "errors": - return b"No page errors found", b"" - return b"opened new local page", b"" - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls.append(list(command)) - return _FakeProc(command) - - monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "open", "url": "/workspace/new.html"}), - {"session_id": "never-started"}, - )) - - assert result["exit_code"] == 0 - session = web_tools._scoped_browser_session("odysseus-ui", "never-started") - assert calls == [ - ["/usr/bin/agent-browser", "--session", session, "open", page.as_uri()], - ["/usr/bin/agent-browser", "--session", session, "errors"], - ] - - -def test_private_browser_local_html_ignores_stale_page_errors( - monkeypatch, tmp_path -) -> None: - page = tmp_path / "clean.html" - page.write_text("clean") - monkeypatch.setattr( - "src.tool_execution.get_active_workspace", lambda: str(tmp_path) - ) - monkeypatch.setattr( - web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser" - ) - monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set()) - monkeypatch.setattr(PrivateBrowserTool, "_owned_daemon_exists", staticmethod(lambda env, session: True)) - calls = [] - - class _FakeProc: - returncode = 0 - - def __init__(self, command): - self.command = list(command) - - async def communicate(self, stdin=None): - if self.command[-1] == "close": - return b"closed stale browser session", b"" - if self.command[-1] == "errors": - return b"No page errors found", b"" - return b"opened clean local page", b"" - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls.append(list(command)) - return _FakeProc(command) - - monkeypatch.setattr( - asyncio, "create_subprocess_exec", _fake_create_subprocess_exec - ) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "open", "url": "/workspace/clean.html"}), - {"session_id": "reused-session"}, - )) - - assert result["exit_code"] == 0 - assert "stale error" not in result["output"] - assert calls[0][-1] == "close" - assert calls[2][-1] == "errors" - - -def test_private_browser_local_html_surfaces_page_errors(monkeypatch, tmp_path) -> None: - page = tmp_path / "broken.html" - page.write_text("") - monkeypatch.setattr( - "src.tool_execution.get_active_workspace", lambda: str(tmp_path) - ) - monkeypatch.setattr( - web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser" - ) - monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set()) - - class _FakeProc: - returncode = 0 - - def __init__(self, command): - self.command = list(command) - - async def communicate(self, stdin=None): - if self.command[-1] == "errors": - return b"ReferenceError: missingFunction is not defined", b"" - return b"opened local page", b"" - - async def _fake_create_subprocess_exec(*command, **kwargs): - return _FakeProc(command) - - monkeypatch.setattr( - asyncio, "create_subprocess_exec", _fake_create_subprocess_exec - ) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "open", "url": "/workspace/broken.html"}), - {"session_id": "broken-local-page"}, - )) - - assert result["exit_code"] == 1 - assert "[page errors]" in result["output"] - assert "ReferenceError" in result["output"] - assert "Fix the artifact and reopen" in result["error"] - - -def test_private_browser_batch_uses_json_stdin() -> None: - command, stdin_data, err = PrivateBrowserTool()._command_for_action( - [ - "agent-browser", - "--namespace", - "odysseus-ui", - "--session", - "ody-session-a", - ], - "batch", - {"commands": [["open", "https://example.com"], ["snapshot"]]}, - ) - - assert err is None - assert command == [ - "agent-browser", - "--namespace", - "odysseus-ui", - "--session", - "ody-session-a", - "batch", - "--json", - ] - assert stdin_data == '[["open", "https://example.com"], ["snapshot"]]' - - -def test_private_browser_batch_screenshot_gets_writable_path(monkeypatch, tmp_path) -> None: - monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path)) - - commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([ - ["open", "https://example.com"], - ["screenshot"], - ]) - - assert len(paths) == 1 - assert commands[0] == ["open", "https://example.com"] - assert commands[1][0] == "screenshot" - assert commands[1][1].endswith(".png") - assert Path(commands[1][1]).parent == tmp_path / "odysseus-private-browser" - - -def test_private_browser_batch_screenshot_ignores_model_chosen_path(monkeypatch, tmp_path) -> None: - monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path)) - - commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([ - ["open", "https://example.com"], - ["screenshot", "/tmp/example_com.png"], - ["screenshot", {"path": "/tmp/also_bad.png"}], - ]) - - assert len(paths) == 2 - assert commands[1] == ["screenshot", str(paths[0])] - assert commands[2] == ["screenshot", str(paths[1])] - assert all(path.parent == tmp_path / "odysseus-private-browser" for path in paths) - - -def test_private_browser_empty_batch_recovers_as_snapshot() -> None: - command, stdin_data, err = PrivateBrowserTool()._command_for_action( - [ - "agent-browser", - "--namespace", - "odysseus-ui", - "--session", - "ody-session-a", - ], - "batch", - {"commands": []}, - ) - - assert err is None - assert command == [ - "agent-browser", - "--namespace", - "odysseus-ui", - "--session", - "ody-session-a", - "snapshot", - ] - assert stdin_data is None - - -def test_private_browser_screenshot_without_path_returns_image_payload(monkeypatch, tmp_path) -> None: - png_bytes = b"\x89PNG\r\n\x1a\nbrowser" - - monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser") - monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path)) - - class _FakeProc: - returncode = 0 - - async def communicate(self, stdin=None): - screenshot_path = Path(calls["command"][-1]) - screenshot_path.write_bytes(png_bytes) - return b"saved screenshot", b"" - - calls = {} - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls["command"] = list(command) - return _FakeProc() - - monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "screenshot"}), - {"session_id": "abc"}, - )) - - assert result["exit_code"] == 0 - assert calls["command"][:4] == [ - "/usr/bin/agent-browser", - "--session", - web_tools._scoped_browser_session("odysseus-ui", "abc"), - "screenshot", - ] - assert calls["command"][-1].endswith(".png") - assert result["images"] == [{ - "data": base64.b64encode(png_bytes).decode("ascii"), - "mimeType": "image/png", - }] - - def _cli_proc(calls, identity=None): class _Proc: pid = 1234 @@ -1508,160 +221,6 @@ def test_cli_group_that_moved_is_not_signalled(monkeypatch) -> None: assert calls == ["fallback-kill"] -def test_private_browser_retries_one_timed_out_local_open(monkeypatch, tmp_path) -> None: - page = tmp_path / "output.html" - page.write_text("retry") - monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path)) - monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser") - monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set()) - - monkeypatch.setattr(PrivateBrowserTool, "_capture_page_errors", lambda *args: _no_page_errors()) - - calls = [] - - class _Proc: - returncode = 0 - - def __init__(self, command): - self.command = list(command) - - async def communicate(self, stdin=None): - if self.command[-1] == "open" or ( - len(self.command) > 1 and self.command[-2] == "open" - ): - if sum(1 for call in calls if call[-1] == page.as_uri()) == 1: - raise asyncio.TimeoutError() - return b"opened", b"" - - def kill(self): - return None - - async def _no_page_errors(): - return "" - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls.append(list(command)) - return _Proc(command) - - monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "open", "url": "file:///workspace/output.html"}), - {"session_id": "retry-local-open"}, - )) - - assert result["exit_code"] == 0 - assert sum(1 for call in calls if call[-1] == page.as_uri()) == 2 - - -def test_private_browser_retries_transient_local_browser_bootstrap_failure( - monkeypatch, tmp_path -) -> None: - page = tmp_path / "output.html" - page.write_text("bootstrap retry") - monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path)) - monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser") - monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set()) - - async def _no_page_errors(): - return "" - - monkeypatch.setattr(PrivateBrowserTool, "_capture_page_errors", lambda *args: _no_page_errors()) - monkeypatch.setattr(PrivateBrowserTool, "_terminate_owned_chrome", lambda *args: None) - monkeypatch.setattr(PrivateBrowserTool, "_terminate_owned_daemon", lambda *args: None) - - calls = [] - - class _Proc: - def __init__(self, command, attempt, kwargs): - self.command = list(command) - self.returncode = 1 if attempt == 1 else 0 - self._stdout = kwargs.get("stdout") - self._stderr = kwargs.get("stderr") - - async def communicate(self, stdin=None): - if self.returncode: - self._stderr.write( - b"Could not configure browser: Failed to connect: " - b"No such file or directory (os error 2)" - ) - else: - self._stdout.write(b"opened") - return b"", b"" - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls.append(list(command)) - return _Proc( - command, - sum(1 for call in calls if call[-1] == page.as_uri()), - kwargs, - ) - - monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "open", "url": "file:///workspace/output.html"}), - {"session_id": "bootstrap-retry"}, - )) - - assert result["exit_code"] == 0, result - assert sum(1 for call in calls if call[-1] == page.as_uri()) == 2 - - -def test_private_browser_open_captures_visual_preview(monkeypatch, tmp_path) -> None: - png_bytes = b"\x89PNG\r\n\x1a\nauto-browser" - - monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser") - monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path)) - - class _FakeProc: - returncode = 0 - - def __init__(self, command): - self.command = list(command) - - async def communicate(self, stdin=None): - if "screenshot" in self.command: - Path(self.command[-1]).write_bytes(png_bytes) - return b"saved screenshot", b"" - return b"opened", b"" - - calls = [] - - async def _fake_create_subprocess_exec(*command, **kwargs): - calls.append(list(command)) - return _FakeProc(command) - - monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec) - - result = asyncio.run(PrivateBrowserTool().execute( - json.dumps({"action": "open", "url": "https://example.com"}), - {"session_id": "abc"}, - )) - - assert result["exit_code"] == 0 - assert calls[0] == [ - "/usr/bin/agent-browser", - "--session", - web_tools._scoped_browser_session("odysseus-ui", "abc"), - "open", - "https://example.com", - ] - assert len(calls) == 2 - assert calls[1][-2] == "screenshot" - assert result["images"] == [{ - "data": base64.b64encode(png_bytes).decode("ascii"), - "mimeType": "image/png", - }] - - -def test_private_browser_visual_preview_covers_state_changing_actions() -> None: - assert {'open', 'batch', 'snapshot', 'click', 'fill', 'press', 'scroll'} <= ( - PrivateBrowserTool._AUTO_SCREENSHOT_ACTIONS - ) - assert {'read', 'find', 'evaluate', 'wait'} - PrivateBrowserTool._AUTO_SCREENSHOT_ACTIONS - - def test_youtube_tool_comments_falls_back_to_ytdlp(monkeypatch) -> None: from services.youtube import youtube_handler diff --git a/tests/test_resource_identity.py b/tests/test_resource_identity.py index 48e12c351..a608486a2 100644 --- a/tests/test_resource_identity.py +++ b/tests/test_resource_identity.py @@ -18,7 +18,7 @@ from src.agent_runtime.resource_binding import ( bind_resource_operation, resolve_filesystem_operation, ) from src.agent_runtime.resources import ( - BrowserPageResource, BrowserProducer, ExternalResource, FileObjectIdentity, + ExternalResource, FileObjectIdentity, FilesystemResource, FilesystemRoot, FilesystemScope, OwnedResource, ProcessResource, ) from src.tool_approvals import ToolApprovalStore @@ -706,10 +706,6 @@ async def test_resource_identity_never_expands_narrow_request_classes(tmp_path, def test_nonfilesystem_identities_are_inert_and_distinguish_producers_from_pages(): - producer = BrowserProducer("browser", "alice", "thread", "session", "incarnation-1") - page = BrowserPageResource(producer, "page-1", 2, "https://example.test") - assert replace(producer, incarnation="incarnation-2") != producer - assert replace(page, navigation_generation=3) != page from src.process_lifecycle import ProcessIdentity ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(123, "boot:start"), "leader", "job", "receipt") OwnedResource("documents", "alice", "thread", "documents", "document", "revision") diff --git a/website/configuration-reference.md b/website/configuration-reference.md index 110744fcf..0ec87e27b 100644 --- a/website/configuration-reference.md +++ b/website/configuration-reference.md @@ -21,7 +21,7 @@ described as a switch that turns something off, the read rejects `0`, `false`, `no` and `off` and treats everything else as on. The `Default` column is the value the code falls back to when the variable is unset, quoted from the source. -The source tree reads **109** `ODYSSEUS_*` variables: 79 an operator may want to set, and 30 that are internal - sentinels, fixture switches, capture hooks and development tooling. The internal ones are listed too, in their own section, so this page can be checked against the source mechanically. +The source tree reads **112** `ODYSSEUS_*` variables: 81 an operator may want to set, and 31 that are internal - sentinels, fixture switches, capture hooks and development tooling. The internal ones are listed too, in their own section, so this page can be checked against the source mechanically. > This page is generated. Edit `scripts/generate_env_reference.py` and > re-run it; `tests/test_env_reference.py` enforces that the committed page @@ -52,7 +52,7 @@ The source tree reads **109** `ODYSSEUS_*` variables: 79 an operator may want to | Variable | Default | Read in | What it does | |---|---|---|---| | `ODYSSEUS_DATA_DIR` | `get_default_data_dir()` | `src/constants.py:56` (+1 more) | Root directory for every persisted file. Prefer this over the per-path overrides; the rest of `src/constants.py` derives from it. | -| `ODYSSEUS_MAIL_ATTACHMENTS_DIR` | `os.path.join(DATA_DIR, 'mail-attachments')` | `src/constants.py:103` | Dedicated override for the mail attachment store, which otherwise lives under the data directory. | +| `ODYSSEUS_MAIL_ATTACHMENTS_DIR` | `os.path.join(DATA_DIR, 'mail-attachments')` | `src/constants.py:105` | Dedicated override for the mail attachment store, which otherwise lives under the data directory. | ### Model routing and providers @@ -72,11 +72,11 @@ The source tree reads **109** `ODYSSEUS_*` variables: 79 an operator may want to | Variable | Default | Read in | What it does | |---|---|---|---| | `ODYSSEUS_DISABLE_MCP` | `''` | `src/builtin_mcp.py:89` | Truthy disables MCP entirely, as an escape hatch for compatibility problems with a server. | -| `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15362` | How many video frames one tool result may contribute. Clamped to 1-8. | -| `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15330` | How many images one tool result may contribute to the model turn. Clamped to 1-8. | +| `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15361` | How many video frames one tool result may contribute. Clamped to 1-8. | +| `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15329` | How many images one tool result may contribute to the model turn. Clamped to 1-8. | | `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. | -| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_tools/subprocess_tools.py:853` (+1 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. | -| `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:919` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. | +| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_runtime/process_resources.py:58` (+2 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. | +| `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:925` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. | | `ODYSSEUS_TOOL_APPROVAL_GATE` | `'0'` | `src/tool_capabilities.py:645` | Security-relevant. Truthy makes tool calls pass through the approval gate. Off by default. | ### Browser automation @@ -88,9 +88,9 @@ The source tree reads **109** `ODYSSEUS_*` variables: 79 an operator may want to | `ODYSSEUS_BROWSER_MCP_CACHE` | `os.path.join(base_dir, 'data', 'local', 'playwright-mcp-cache')` | `src/builtin_mcp.py:229` | Cache directory handed to the browser MCP server, so its npm download survives a container rebuild. | | `ODYSSEUS_BROWSER_MCP_CALL_TIMEOUT_S` | `'90'` | `src/mcp_manager.py:27` | Upper bound in seconds for one browser MCP tool call. A call that exceeds it fails without being retried. | | `ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE` | `''` | `src/builtin_mcp.py:90` | Truthy refuses to start the browser MCP server unless its npm package is already in the npx cache, instead of installing it at startup. | -| `ODYSSEUS_BROWSER_NAMESPACE` | `'odysseus-ui'` | `src/agent_tools/web_tools.py:100` (+3 more) | Namespace for the detached agent-browser daemon's pid files, so two runtimes on one machine do not terminate each other's browsers. | +| `ODYSSEUS_BROWSER_NAMESPACE` | `'odysseus-ui'` | `src/agent_tools/web_tools.py:100` (+1 more) | Namespace for the detached agent-browser daemon's pid files, so two runtimes on one machine do not terminate each other's browsers. | | `ODYSSEUS_BROWSER_NO_SANDBOX` | `'1'` | `src/builtin_mcp.py:142` | Security-relevant. On by default, adding `--no-sandbox` because the Docker image cannot use the Chromium sandbox. Set 0, false or no to keep it. | -| `ODYSSEUS_BROWSER_SCREENSHOT_DIR` | *unset* | `src/agent_tools/web_tools.py:3479` | Where private-browser screenshots are written. Falls back to the container path, then the system temp directory. | +| `ODYSSEUS_BROWSER_SCREENSHOT_DIR` | *unset* | `src/agent_tools/web_tools.py:2666` | Where private-browser screenshots are written. Falls back to the container path, then the system temp directory. | ### Container and workspace mounts @@ -152,6 +152,8 @@ The source tree reads **109** `ODYSSEUS_*` variables: 79 an operator may want to | Variable | Default | Read in | What it does | |---|---|---|---| +| `ODYSSEUS_MCP_MEMORY_OWNER` | *unset* | `src/mcp_manager.py:190` | Application owner binding for the configured memory MCP backend. Takes precedence over ODYSSEUS_MEMORY_OWNER; missing ownership fails closed. | +| `ODYSSEUS_MEMORY_OWNER` | *unset* | `src/mcp_manager.py:190` | Fallback application owner binding for the memory MCP backend. This configuration identifies ownership; it does not grant read or egress authority. | | `ODYSSEUS_SKILL_SEMANTIC_RETRIEVAL` | `'1'` | `services/memory/skills.py:796` | On by default. Set 0, false, no or off to fall back to keyword-only skill retrieval when no vector store is reachable. | | `ODYSSEUS_SKILL_SEMANTIC_THRESHOLD` | `'0.4'` | `services/memory/skills.py:807` | Minimum semantic score a skill needs to be retrieved. A non-numeric value falls back to the default. | @@ -161,14 +163,14 @@ The source tree reads **109** `ODYSSEUS_*` variables: 79 an operator may want to |---|---|---|---| | `ODYSSEUS_GROUNDING_MODEL` | `'google/owlvit-base-patch32'` | `routes/gallery/gallery_routes.py:96` | Object-grounding model id the gallery loads for text-driven selection. | | `ODYSSEUS_SAM_MODEL` | `'facebook/sam-vit-base'` | `routes/gallery/gallery_routes.py:60` | Segmentation model id the gallery loads for subject selection. | -| `ODYSSEUS_STT_MODEL` | *unset* | `src/agent_tools/media_tools.py:2184` | Default speech-to-text model for media transcription when the tool call does not name one. | +| `ODYSSEUS_STT_MODEL` | *unset* | `src/agent_tools/media_tools.py:2189` | Default speech-to-text model for media transcription when the tool call does not name one. | | `ODYSSEUS_TTS_CACHE_MAX_BYTES` | `500 * 1024 * 1024` | `services/tts/tts_service.py:47` | Cap on the synthesized-speech cache. A non-numeric value falls back to the default. | ### Auth and internal API | Variable | Default | Read in | What it does | |---|---|---|---| -| `ODYSSEUS_INTERNAL_BASE` | *unset* | `src/constants.py:190` | Base URL the in-app tool layer uses for loopback HTTP calls. Set it when the app is not reachable at the port it thinks it is bound to. | +| `ODYSSEUS_INTERNAL_BASE` | *unset* | `src/constants.py:192` | Base URL the in-app tool layer uses for loopback HTTP calls. Set it when the app is not reachable at the port it thinks it is bound to. | | `ODYSSEUS_INTERNAL_TOKEN` | *unset* | `core/middleware.py:20` | Security-relevant. Token that lets the in-app tool layer reach admin-gated routes over loopback. Unset generates a fresh per-process token, which is what you want unless something outside the process needs the same value. | ### Integrations (Claude, Codex) @@ -210,6 +212,7 @@ Listed for completeness. Setting one of these on a real install is either a no-o | Variable | Default | Read in | What it does | |---|---|---|---| | `ODYSSEUS_AJAX_TEST_URL` | *unset* | `tests/test_ajax_email_live.py:17` (+4 more) | Chat-completions URL of a live Ajax endpoint. Unset skips the opt-in live Ajax email tests. | +| `ODYSSEUS_BROWSER_LIVE_CONTRACT` | *unset* | `tests/test_browser_producer_live_contract.py:19` | Set 1 only in the allowlisted release Docker environment to run the browser producer contract tests. Does not enable browser page operations. | | `ODYSSEUS_EDITOR_ACTIONS` | `','.join([*actions, 'edit', 'update'])` | `tests/tools/editor_writing_smoke.py:71` | Comma-separated writing actions the editor-writing smoke tool runs. Unset runs every action plus edit and update. | | `ODYSSEUS_EDITOR_MAX_TOKENS` | `'4096'` | `tests/tools/editor_writing_smoke.py:110` | Completion token limit for each editor-writing smoke request. | | `ODYSSEUS_EDITOR_RICH_FIXTURE` | *unset* | `tests/tools/editor_writing_smoke.py:80` | Set to 1 to run the editor-writing smoke tool against a rich-text document fixture instead of Markdown. | @@ -223,7 +226,7 @@ Listed for completeness. Setting one of these on a real install is either a no-o | `ODYSSEUS_QA_TEACHER_TIMEOUT` | `'120'` | `scripts/odysseus_conversation_qa.py:372` | Timeout in seconds for that call. Clamped to 15-120. | | `ODYSSEUS_RUNTIME_REVISION` | `''` | `routes/chat_helpers.py:198` (+1 more) | Revision string stamped into each captured SFT trace record, so a trace can be tied back to the build that produced it. | | `ODYSSEUS_SFT_DISABLE_WORKSPACE_TOOLS` | `'1'` | `src/agent_loop.py:7408` | On by default. Keeps synthetic personal-assistant fixtures out of workspace mode; set 0, false, no or off to let them through. | -| `ODYSSEUS_SFT_FORCE_UTC_TIMEZONE` | `'0'` | `routes/chat_routes.py:2094` | Truthy forces `sft_` accounts to UTC for deterministic batch generation. Interactive accounts still follow the browser timezone. | +| `ODYSSEUS_SFT_FORCE_UTC_TIMEZONE` | `'0'` | `routes/chat_routes.py:2097` | Truthy forces `sft_` accounts to UTC for deterministic batch generation. Interactive accounts still follow the browser timezone. | | `ODYSSEUS_SFT_TRACE_CAPTURE` | `'1'` | `routes/chat_helpers.py:161` (+1 more) | On by default, but only for owners whose name starts with `sft_`. Set 0, false, no or off to stop writing training traces. | | `ODYSSEUS_SFT_TRACE_DIR` | *unset* | `routes/chat_helpers.py:195` (+2 more) | Directory the SFT trace JSONL files are written to. Defaults to `sft_traces` under the data directory. | | `ODYSSEUS_SKIP_RUN_HINT` | *unset* | `setup.py:284` | Any non-empty value suppresses the `start the server with` hint at the end of setup. `start-macos.sh` sets it because it starts the server itself. | @@ -257,7 +260,7 @@ reads three ways, because no single pattern covers the codebase: lines, so one read lives inside a string literal. The three passes are not redundancy. A line-based grep for a direct -`os.environ.get("ODYSSEUS_...` call finds 81 of the 109 variables on this +`os.environ.get("ODYSSEUS_...` call finds 82 of the 112 variables on this page. What it misses is reads through an env-reader helper, reads whose call spans more than one line, reads whose variable name is held in a module constant, and reads through a mapping passed in as an argument - which is the