mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 15:02:20 +02:00
feat(runtime): bind browser resources to authority
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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-<platform>`.
|
||||
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.
|
||||
@@ -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
|
||||
@@ -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 "
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
@@ -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()})
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
+115
-30
@@ -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
|
||||
|
||||
+58
-1205
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
+35
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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.",
|
||||
|
||||
+18
-25
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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("<title>x</title>")
|
||||
|
||||
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(
|
||||
"<html><head><title>Lifecycle</title></head><body><h1>Fresh heading</h1></body></html>"
|
||||
)
|
||||
|
||||
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("<title>A</title><h1>Alpha</h1>")
|
||||
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("<title>S</title><h1>Slow</h1>")
|
||||
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("<title>Doc</title><h1>Served heading</h1><p>Body text</p>")
|
||||
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"] == ""
|
||||
|
||||
@@ -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,<title>fixture</title><p>content</p>", "--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,<title>replacement</title>", "--pin-tab")
|
||||
navigated = await browser.observe_registered(live, "t1")
|
||||
assert navigated.loader_id != reloaded.loader_id
|
||||
await live.command("tab", "new", "data:text/html,<title>replacement</title>", "--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
|
||||
@@ -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 == []
|
||||
@@ -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"
|
||||
|
||||
@@ -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.'},
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user