feat(runtime): bind browser resources to authority

This commit is contained in:
Alexandre Teixeira
2026-10-02 18:54:09 +01:00
parent b648f9ddbe
commit e175bea752
27 changed files with 2120 additions and 3153 deletions
@@ -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
+15
View File
@@ -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
View File
@@ -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
+40 -9
View File
@@ -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()})
+3
View File
@@ -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
View File
@@ -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
File diff suppressed because it is too large Load Diff
+649
View File
@@ -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"}
+7 -43
View File
@@ -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
+1
View File
@@ -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")
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
},
+158
View File
@@ -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
-353
View File
@@ -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
+291
View File
@@ -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"
+1 -1
View File
@@ -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.'},
+10 -14
View File
@@ -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():
+3 -3
View File
@@ -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
+1 -5
View File
@@ -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")
+15 -12
View File
@@ -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