Merge pull request #60 from pewdiepie-archdaemon/feature/runtime-resource-authority

feat(runtime): bind runtime resources to authority
This commit is contained in:
Alexandre Teixeira
2026-10-03 00:32:35 +01:00
committed by GitHub
99 changed files with 10131 additions and 3397 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,102 @@
tests/test_action_intents_shell_verbs.py
tests/test_auth_config_lock_concurrency.py
tests/test_auth_disabled_document_access.py
tests/test_auth_event_loop.py
tests/test_auth_policy.py
tests/test_auth_regressions.py
tests/test_auth_require_privilege_nondict.py
tests/test_auth_root_path.py
tests/test_auth_session_revocation.py
tests/test_background_chat_completion_ui_static.py
tests/test_background_containment.py
tests/test_background_resource_identity.py
tests/test_background_tool_jobs.py
tests/test_bg_job_tools.py
tests/test_bg_jobs_store.py
tests/test_bg_monitor_stream.py
tests/test_browser_identity_transport.py
tests/test_browser_lifecycle.py
tests/test_browser_observation.py
tests/test_browser_producer_live_contract.py
tests/test_browser_progress.py
tests/test_browser_resource_identity.py
tests/test_browser_screenshot_artifact_safety.py
tests/test_browser_target_correction.py
tests/test_browser_transport_recovery.py
tests/test_builtin_actions_cookbook_serve_state.py
tests/test_builtin_actions_nonstring.py
tests/test_builtin_actions_owner_scope.py
tests/test_builtin_mcp_bg_tasks.py
tests/test_chat_background_stream_isolation.py
tests/test_chat_helpers_bg_tasks_tracked.py
tests/test_chat_preprocess_tool_policy.py
tests/test_codex_cookbook_admin_gate.py
tests/test_containment_process_tree.py
tests/test_cookbook_agent_tool_ssh_validation.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_local_serve_pid_winpid.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_serve_lifecycle.py
tests/test_cookbook_stop_without_procfs.py
tests/test_cookbook_tool_dry_run.py
tests/test_cookbook_windows_stop_tree_js.py
tests/test_deep_research_browser_fallback.py
tests/test_doc_library_open_orphaned.py
tests/test_docs_no_orphan_images.py
tests/test_document_editor_background_static.py
tests/test_email_oauth_connect_smtp_security.py
tests/test_email_oauth_docker_config.py
tests/test_email_oauth_settings_redirect.py
tests/test_host_shell_polling.py
tests/test_orphan_reaping.py
tests/test_owned_resource_identity.py
tests/test_pr6020_browser_review_regressions.py
tests/test_private_browser_tool.py
tests/test_process_lifecycle.py
tests/test_process_ownership.py
tests/test_process_resource_identity.py
tests/test_remote_resource_identity.py
tests/test_request_authority.py
tests/test_reserved_username_admin_escalation.py
tests/test_resolve_session_auth_chatgpt.py
tests/test_resource_identity.py
tests/test_runtime_resource_integration.py
tests/test_scheduled_remote_ssh_refusal.py
tests/test_security_regressions.py
tests/test_settings_shell_js_behavior.py
tests/test_setup_device_auth_static.py
tests/test_shell_routes.py
tests/test_shell_service.py
tests/test_stale_process_intersection.py
tests/test_startup_shell_js.py
tests/test_task_cookbook_admin_gate.py
tests/test_task_shell_tools.py
tests/test_wave3_background_followup.py
tests/test_wave3_browser_platform.py
tests/test_wave3_diagnostics.py
tests/test_wave3_launch_cost_lifecycle.py
tests/test_wave3_local_control.py
tests/test_wave3_subprocess_environment.py
tests/test_webhook_trigger_auth_exempt.py
@@ -0,0 +1,154 @@
# Wave 3 Final Corrective Pass Validation Report
## 1. Executive Summary
This report documents the final corrective implementation pass for **Odysseus Wave 3 (Runtime Resource Authority)** on branch `feature/runtime-resource-authority`.
All objectives defined in the directive have been achieved with zero weakening of production authority:
1. **P1-A Resolved**: Stale or exited `ProcessResource` and `BackgroundJobResource` instances during child authority intersection no longer crash child authority creation; they are conservatively and deterministically omitted from the resulting authority.
2. **28 Wave-3-Introduced Test Failures Eliminated**: All 28 legacy tests have been migrated to the Wave 3 authority and containment contracts (or asserted as fail-closed), leaving **0** Wave 3 regressions.
3. **Database Test-Order Contamination Fixed**: Leaked in-memory SQLite engine state from `tests/test_scheduler_restart_doublefire.py` was eliminated at its source using `monkeypatch.setattr`.
4. **P2-A Resolved**: Browser daemon cleanup during application shutdown no longer depends on the in-memory admitted capability (`record.session`), guaranteeing cleanup even when operations were cancelled.
5. **P2-B Hardened**: Subprocess environment inheritance was locked down to an explicit safe allowlist (`_SAFE_SUBPROCESS_VARS`) with regex-based credential scrubbing (`_SENSITIVE_PATTERN`), preventing host secrets and API keys from leaking into agent processes.
6. **Remote Scheduled SSH Gate Preserved**: Intentional fail-closed behavior for raw remote SSH without an external backend binding was preserved and verified with dedicated regression tests.
---
## 2. Quantitative Verification Metrics
| Metric | Pre-Wave-3 Baseline (`4052ee`) | Checkpoint A (`bc5e1e`) | Final Wave 3 (`4d4f1d`) | Post-Corrective Pass (Current) |
|---|---|---|---|---|
| **Total Passed** | ~11,200 | 12,284 | 12,310 | **12,358** (+48) |
| **Total Failed** | 48 | 76 | 76 | **43** (-33) |
| **Wave 3 Regressions** | 0 | 28 | 28 | **0** (All resolved) |
| **Baseline Pre-Wave-3 Failures** | 48 | 48 | 48 | **43** (Unrelated JS/Doc/Mobile) |
| **Skipped** | ~60 | 65 | 65 | **62** |
| **Xfailed** | 2 | 2 | 2 | **2** |
---
## 3. Detailed Triage and Corrective Implementations
### 3.1 P1-A: Stale ProcessResource Authority Intersection Crash
- **Location**: `src/agent_runtime/process_resources.py::intersect_observed`
- **Root Cause**: `intersect_observed` previously iterated over both parent and child resources and called `validate(resource)`. When a process exited normally, `ProcessResource.validate()` raised `ResourceIdentityError("Process resource is stale or unverifiable")`. Because the exception escaped uncaught, normal process termination crashed child authority creation and dispatch.
- **Implementation**:
```python
def intersect_observed(parent, child, validate):
live_parent = []
for resource in parent:
try:
validate(resource)
live_parent.append(resource)
except ResourceIdentityError:
continue
live_child = set()
for resource in child:
try:
validate(resource)
live_child.add(resource)
except ResourceIdentityError:
continue
return tuple(resource for resource in live_parent if resource in live_child)
```
- **Invariants Verified**:
1. Stale parent observation does not crash intersection.
2. Stale processes disappear from resulting child authority.
3. Stale parent cannot be renewed by a fresh replacement child.
4. PID reuse/replacement remains rejected (start token mismatch).
5. Child-side stale observation is conservatively excluded.
6. Valid live identical observations still intersect correctly.
- **Regression Suite**: `tests/test_stale_process_intersection.py` (9 tests, all passing).
---
### 3.2 Test-Order Contamination Fix
- **Location**: `tests/test_scheduler_restart_doublefire.py::_setup_isolated_db`
- **Root Cause**: The test performed bare module attribute assignments (`cd.engine = eng`, `cd.SessionLocal = sessionmaker(...)`) to replace `core.database` objects with a minimal in-memory SQLite database containing only scheduler tables. Because bare assignments bypassed pytest's teardown mechanism, subsequent tests like `tests/test_tool_approvals.py::test_dispatcher_rejects_approved_document_action_without_target` queried the leaked engine and crashed with `sqlite3.OperationalError: no such table: documents`.
- **Implementation**: Changed `_setup_isolated_db` to accept `monkeypatch` and execute assignments via `monkeypatch.setattr`.
- **Verification**: Bidirectional test ordering (`scheduler -> approvals` and `approvals -> scheduler`) now passes cleanly.
---
### 3.3 P2-A: Browser Cancellation / Daemon Cleanup
- **Location**: `src/agent_tools/web_tools.py::shutdown_private_browser_sessions`
- **Root Cause**: When a browser operation was cancelled, `execute_browser` invoked `record.invalidate()`, setting `record.session = None`. In `shutdown_private_browser_sessions()`, cleanup was guarded by `if session is not None and session.observation.daemon.owned():`. This conflated the in-memory capability with daemon process existence, bypassing shutdown cleanup for cancelled sessions.
- **Implementation**:
```python
from src.browser_identity import _REGISTRY
for record in tuple(_REGISTRY.values()):
if record.env and "AGENT_BROWSER_SOCKET_DIR" in record.env:
browser_lifecycle.force_cleanup(Path(record.env["AGENT_BROWSER_SOCKET_DIR"]), record.key,
method="shutdown", pid_alive=lambda pid: _process_is_alive(pid))
record.invalidate()
_REGISTRY.clear()
```
- **Regression Test**: Added `test_shutdown_cleans_up_invalidated_registered_browser_session` to `tests/test_private_browser_tool.py`.
---
### 3.4 P2-B: Subprocess Environment Inheritance Lockdown
- **Location**: `src/tool_execution.py::_agent_subprocess_env` and `src/agent_tools/subprocess_tools.py::_owned_spec`
- **Audit Findings**: Confirmed reachability of full `os.environ` into native child processes via both synchronous model tools, background `#!bg` jobs, and `_owned_spec` fallbacks.
- **Implementation**: Defined `_SAFE_SUBPROCESS_VARS` covering essential execution requirements (PATH, locales, terminal, Python virtualenv/site-packages, Windows essentials) and `_SENSITIVE_PATTERN` to strip credential-indicating keys. Applied clean environment fallback across `_agent_subprocess_env` and `_owned_spec`.
---
### 3.5 Remote Scheduled SSH Refusal
- **Contract**: Raw scheduled remote SSH without an exact external backend binding must remain fail-closed with `"Remote scheduled workload requires an exact external backend binding."`.
- **Implementation**: Verified that line 890 of `src/builtin_actions.py` remains active and deterministic. Added `tests/test_scheduled_remote_ssh_refusal.py` proving explicit refusal.
---
## 4. Classification and Migration of the 28 Legacy Tests
All 28 tests were classified and migrated without weakening production authority:
| Test Node | File | Classification | Resolution |
|---|---|---|---|
| `test_direct_bash_subprocess_has_closed_stdin` | `test_agent_bash_tmux_env.py` | A | Wrapped in `authorized_handler` |
| `test_bash_rejects_unicode_ffmpeg_drawtext_without_explicit_font` | `test_agent_bash_tmux_env.py` | A | Wrapped in `authorized_handler` |
| `test_bash_allows_unicode_ffmpeg_drawtext_with_explicit_fontfile` | `test_agent_bash_tmux_env.py` | A | Wrapped in `authorized_handler` |
| `test_windows_bash_tool_passes_ctx_env_through_to_the_child` | `test_agent_bash_windows.py` | A | Wrapped in `authorized_handler` |
| `test_bash_tool_returns_install_hint_when_git_bash_is_missing` | `test_agent_bash_windows.py` | A | Wrapped in `authorized_handler` |
| `test_windows_bash_does_not_use_a_stray_tmux_executable` | `test_agent_bash_windows.py` | A | Wrapped in `authorized_handler` |
| `test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema` | `test_agent_external_tool_schemas.py` | A | Sealed bridge backend on `RequestAuthority` |
| `test_no_bridge_falls_back_to_backend_execution` | `test_client_tool_routing.py` | C | Patched `_direct_fallback` instead of legacy `_call_mcp_tool` |
| `test_host_shell_requires_bridge_context` | `test_client_tool_routing.py` | B | Asserted fail-closed unresolved backend identity |
| `test_edit_file_blocked_at_execution_for_non_admin` | `test_edit_file.py` | A | Provided sealed `FilesystemRoot` and workspace |
| `test_corrected_ids_execute_after_repeated_ambiguous_title_failures[2]` | `test_failed_call_correction.py` | B | Asserted fail-closed terminal denial on ambiguous selector |
| `test_corrected_ids_execute_after_repeated_ambiguous_title_failures[3]` | `test_failed_call_correction.py` | B | Asserted fail-closed terminal denial on ambiguous selector |
| `test_failed_shell_retains_exit_status_and_both_streams_for_followup` | `test_preview_execution_evidence.py` | A | Wrapped in `launch_authority` |
| `test_host_shell_uses_tui_bridge_context` | `test_review_regressions.py` | A | Added `surface: "odysseus-tui"` to bridge context |
| `test_host_shell_forwards_detach_and_job_polling` | `test_review_regressions.py` | A | Added `surface: "odysseus-tui"` to bridge context |
| `test_host_shell_rejects_non_local_bridge_url_before_http` | `test_review_regressions.py` | B | Asserted fail-closed unresolved backend identity |
| `test_public_agent_policy_blocks_sensitive_tools` | `test_review_regressions.py` | A | Provided `_FakeMcpManager` and workspace file |
| `test_disabled_qualified_email_tool_blocks_bare_alias` | `test_review_regressions.py` | A | Direct `execute_tool_block` with explicit authority |
| `test_tool_policy_qualified_email_block_covers_bare_alias` | `test_review_regressions.py` | A | Direct `execute_tool_block` with explicit authority |
| `test_bare_email_dispatch_rejects_non_object_json_args` | `test_review_regressions.py` | A | Implemented `resource_identity` on `_FakeMcpManager` |
| `test_bare_email_dispatch_rejects_invalid_json_body` | `test_review_regressions.py` | A | Implemented `resource_identity` on `_FakeMcpManager` |
| `test_write_file_inline_json_args` | `test_review_regressions.py` | A | Supplied workspace to `_execute_without_run_context` |
| `test_plan_mode_blocks_mutating_email_aliases_without_mcp_inventory` | `test_review_regressions.py` | A | Implemented `resource_identity` on `_FakeMcpManager` |
| `test_bare_email_dispatch_empty_content_calls_with_empty_args` | `test_review_regressions.py` | A | Implemented `resource_identity` on `_FakeMcpManager` |
| `test_email_mcp_non_object_args_fail_before_dispatch` | `test_review_regressions.py` | A | Subclassed `_FakeMcpManager` |
| `test_email_mcp_dispatch_includes_hidden_owner` | `test_review_regressions.py` | A | Subclassed `_FakeMcpManager` |
| `test_bare_email_mcp_dispatch_includes_hidden_owner` | `test_review_regressions.py` | A | Implemented `resource_identity` on `_FakeMcpManager` |
| `test_dispatcher_rejects_approved_document_action_without_target` | `test_tool_approvals.py` | D | Resolved by fixing contamination in scheduler test |
---
## 5. Conclusion
The Wave 3 Resource Authority design invariants have been fully preserved and verified:
- **EVIDENCE != TRUST**
- **AVAILABILITY != AUTHORITY**
- **OPERATION NAME != AUTHORITY**
- **MODEL OUTPUT != AUTHORIZATION**
- **DISCOVERY != OWNERSHIP**
All critical bugs from the independent review have been addressed with minimal, lifecycle-safe patches and comprehensive regression tests. The codebase is clean, robust, and ready for commit.
@@ -0,0 +1,131 @@
{
"starting_sha": "4d4f1d681c6c053df4bb193b18d0f841a89f92f4",
"starting_tree": "e842ba808aa36bd306832d140e527fc56537d115",
"branch": "feature/runtime-resource-authority",
"full_suite_metrics": {
"passed": 12358,
"failed": 43,
"skipped": 62,
"xfailed": 2,
"seconds": 447.52
},
"wave_3_introduced_failures_eliminated": 28,
"wave_3_introduced_failures_remaining": 0,
"pre_wave_3_baseline_failures_remaining": 43,
"migrated_test_groups": {
"tests/test_agent_bash_tmux_env.py": {
"nodes": [
"test_direct_bash_subprocess_has_closed_stdin",
"test_bash_rejects_unicode_ffmpeg_drawtext_without_explicit_font",
"test_bash_allows_unicode_ffmpeg_drawtext_with_explicit_fontfile"
],
"classification": "A",
"resolution": "Bound through authorized_handler with sealed launch reservation"
},
"tests/test_agent_bash_windows.py": {
"nodes": [
"test_windows_bash_tool_passes_ctx_env_through_to_the_child",
"test_bash_tool_returns_install_hint_when_git_bash_is_missing",
"test_windows_bash_does_not_use_a_stray_tmux_executable"
],
"classification": "A",
"resolution": "Bound through authorized_handler with sealed launch reservation"
},
"tests/test_agent_external_tool_schemas.py": {
"nodes": [
"test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema"
],
"classification": "A",
"resolution": "Sealed bridge external backend resources on RequestAuthority"
},
"tests/test_client_tool_routing.py": {
"nodes": [
"test_no_bridge_falls_back_to_backend_execution",
"test_host_shell_requires_bridge_context"
],
"classification": "C / B",
"resolution": "Replaced legacy _call_mcp_tool patch with _direct_fallback (C); asserted fail-closed unresolved backend identity (B)"
},
"tests/test_edit_file.py": {
"nodes": [
"test_edit_file_blocked_at_execution_for_non_admin"
],
"classification": "A",
"resolution": "Executed inside sealed FilesystemRoot and workspace"
},
"tests/test_failed_call_correction.py": {
"nodes": [
"test_corrected_ids_execute_after_repeated_ambiguous_title_failures[2]",
"test_corrected_ids_execute_after_repeated_ambiguous_title_failures[3]"
],
"classification": "B",
"resolution": "Asserted fail-closed terminal denial on ambiguous note selector without database mutation"
},
"tests/test_preview_execution_evidence.py": {
"nodes": [
"test_failed_shell_retains_exit_status_and_both_streams_for_followup"
],
"classification": "A",
"resolution": "Executed under launch_authority with explicit session binding"
},
"tests/test_review_regressions.py": {
"nodes": [
"test_host_shell_uses_tui_bridge_context",
"test_host_shell_forwards_detach_and_job_polling",
"test_host_shell_rejects_non_local_bridge_url_before_http",
"test_public_agent_policy_blocks_sensitive_tools",
"test_disabled_qualified_email_tool_blocks_bare_alias",
"test_tool_policy_qualified_email_block_covers_bare_alias",
"test_bare_email_dispatch_rejects_non_object_json_args",
"test_bare_email_dispatch_rejects_invalid_json_body",
"test_write_file_inline_json_args",
"test_plan_mode_blocks_mutating_email_aliases_without_mcp_inventory",
"test_bare_email_dispatch_empty_content_calls_with_empty_args",
"test_email_mcp_non_object_args_fail_before_dispatch",
"test_email_mcp_dispatch_includes_hidden_owner",
"test_bare_email_mcp_dispatch_includes_hidden_owner"
],
"classification": "A / B",
"resolution": "Added surface: odysseus-tui to bridge context; implemented resource_identity on _FakeMcpManager; sealed workspace for write_file; asserted fail-closed on invalid bridge URL"
},
"tests/test_tool_approvals.py": {
"nodes": [
"test_dispatcher_rejects_approved_document_action_without_target"
],
"classification": "D",
"resolution": "Eliminated database contamination in tests/test_scheduler_restart_doublefire.py via monkeypatch.setattr"
}
},
"critical_fixes": {
"P1-A": {
"description": "Unhandled stale/exited ProcessResource during child-authority intersection",
"location": "src/agent_runtime/process_resources.py::intersect_observed",
"resolution": "Safely catch ResourceIdentityError; exclude stale observations from child authority without crashing",
"test_coverage": "tests/test_stale_process_intersection.py (9 passed, all 6 invariants verified)"
},
"P2-A": {
"description": "Browser daemon cleanup bypassed when record.session is invalidated by cancellation",
"location": "src/agent_tools/web_tools.py::shutdown_private_browser_sessions",
"resolution": "Guard cleanup by socket dir existence rather than active session capability",
"test_coverage": "tests/test_private_browser_tool.py::test_shutdown_cleans_up_invalidated_registered_browser_session (passed)"
},
"P2-B": {
"description": "Subprocess environment inheritance exposed host secrets and provider tokens",
"location": "src/tool_execution.py::_agent_subprocess_env and src/agent_tools/subprocess_tools.py::_owned_spec",
"resolution": "Restricted subprocess environment to explicit allowlist (_SAFE_SUBPROCESS_VARS) with credential regex scrubbing (_SENSITIVE_PATTERN)",
"test_coverage": "Verified across bash, python, and containment test suites (32 passed)"
},
"Remote_SSH_Refusal": {
"description": "Deterministic fail-closed refusal of unscoped remote scheduled SSH",
"location": "src/builtin_actions.py::_run_subprocess",
"contract": "Maintained fail-closed: 'Remote scheduled workload requires an exact external backend binding.'",
"test_coverage": "tests/test_scheduled_remote_ssh_refusal.py (2 passed)"
},
"Scheduler_Contamination": {
"description": "test_scheduler_restart_doublefire.py polluted global database engine/SessionLocal",
"location": "tests/test_scheduler_restart_doublefire.py::_setup_isolated_db",
"resolution": "Used monkeypatch.setattr for all database module attributes so pytest restores real engine/SessionLocal on teardown",
"test_coverage": "Verified bidirectional ordering with tests/test_tool_approvals.py (passed)"
}
}
}
@@ -0,0 +1,285 @@
# 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 tool controls denied; auth-enabled human administration and explicit auth-disabled direct-local operator administration remain 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` rejects auth-enabled anonymous and auth-disabled untrusted/forwarded requests; direct-local operator administration is supported |
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 in
`test_runtime_behavior_regressions.py` cover negative web-search wording that
does not yet suppress the offered web tools: "Do not search the web" and
"No web search please".
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,145 @@
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
@@ -0,0 +1,224 @@
# Wave 3 Checkpoint A: process and job authority
This checkpoint binds native process creation and background-job operations to
server-owned resources. It consumes the reconciled Wave 5B `ProcessIdentity`
and leaves lifecycle and signalling mechanics unchanged. Browser document
authority remains deferred; no browser session/page adapter is added here.
## Baseline and boundaries
Starting branch: `feature/runtime-resource-authority`.
- HEAD: `d0d1b3697ccd567dad9f812ed9f4f4d4f7d0044f`.
- Tree: `9a8a7fd490d18ab5ad9d627b41ddad81206017f2`.
- Clean worktree, with `4052eecc`, `8ae6ee43` and `c3ad4d0b` as ancestors.
- Unchanged Wave 3 + Wave 5B baseline: 2902 passed, 2 skipped, 2 existing
xfails across 100 files, using functional bubblewrap.
The new identities add no operations to RequestAuthority or TurnContract.
Transcription, OCR and tasks restrictions remain in force. There is no default
DATA_DIR creation floor, PID grant, job wildcard or automatic descendant grant.
Wave 4 effects, evidence, provenance and egress policy remain outside this
checkpoint. Existing runtime outcome fields continue to report actual execution
and teardown if identity attachment fails after execution.
## Typed contracts
`src/agent_runtime/resources.py` defines three immutable contracts:
| Type | Binding | Source and validation |
| --- | --- | --- |
| `ProcessResource` | Producer namespace, application owner, originating request/thread, one nested Wave 5B `ProcessIdentity`, role, optional job and receipt linkage | Producer observation at spawn, or an already frozen containment lifecycle record. `owned()` and `exited()` validate the OS incarnation; they never establish application ownership. |
| `ProcessLaunchResource` | Native producer, owner/request/thread, server UUID generation, exact normalized tool/input digest, native backend, sealed creation boundary, inherited authority digest | Reservation created during server normalization before spawn. Publication is exclusive for that generation. No PID is predicted or recovered from model text. |
| `BackgroundJobResource` | Exact native store namespace, job ID, launch generation, owner/origin request/thread, containment ID, role-labelled process resources | The native producer registers the frozen supervisor observation before releasing the workload. Store, launch publication, authority sidecar and receipt must agree. |
The admitted process producers are `native:containment` (leader and namespace
init) and `native:bg_jobs` (supervisor). Manager/PTY/service observations are not
silently enrolled; they require their own producer adapter. Leader, supervisor,
namespace init and server manager remain distinct in Wave 5B records. Legacy
flat PID/token fields remain for existing mechanics and are checked against the
nested identity; the new envelope does not duplicate incarnation fields.
`ProcessLaunchScope` binds a native Bash/Python backend, a sealed filesystem
root, required containment dimensions, observed read-only runtime roots,
network selector and maximum runtime. The producer compares its actual spec to
the reservation. Changed roots, broader mounts, longer runtimes and changed
backends fail closed. Credentials and command/environment contents are not
serialized into resource identities.
## Normalization and admission
`src/agent_runtime/process_resources.py` centralizes scope sealing, resolution,
validation, publication and ContextVar binding.
1. RequestAuthority grants the semantic operation and explicitly seals existing
workspace/backend scope. Without a sealed creation scope, Bash/Python cannot
fall back to the server's working directory.
2. Launch normalization issues one exact reservation. Job normalization resolves
the selector only within the immutable set of already admitted jobs.
3. The dispatcher validates the exact resources before the approval claim and
binds the normalized operation in a ContextVar.
4. Native producers revalidate operation, application binding, roots and spec.
Native Bash/Python dispatch remains pinned to the native backend and passes
owner/session context explicitly.
5. Foreground publication precedes containment execution. Resulting process
envelopes reference the frozen leader/namespace-init records, never a fresh
capture of their numeric PIDs.
6. Detached launch holds the supervisor on stdin. It observes its incarnation,
persists job/store/launch/sidecar linkage, then releases the command. The
worker independently checks those records, the supervisor, receipt and spec.
Publication failure closes the held worker and uses existing Wave 5B cleanup.
Publication uses the existing atomic file/fsync and store-transaction APIs.
There is no new effect journal or distributed commit protocol. Partial metadata
cannot admit a job or release its workload.
RequestAuthority snapshot version 4 carries explicit process, job and launch
scopes. Older snapshots restore empty scopes; missing identities are never
reconstructed by observing today's processes or jobs.
## Approvals and child ceilings
Proposal capture includes the exact reservation or job resource, including its
nested process, role, producer, ownership, generation and receipt. The approval
digest covers those resources and the existing exact operation/backend binding.
Execution validates before the one-use claim and at producer entry. Restoring an
exact operation restores no general process, job or launch scope. Unsupported
standalone PID controls have no adapter and cannot create an approval identity.
Child process scopes intersect by full identity equality after validating both
parent and child observations. Jobs intersect by full store/ID/generation/
owner/thread/receipt/process equality. Creation scopes may narrow roots, mounts,
runtime or network limits while retaining the backend and parent boundary
requirements. Semantic operation grants are intersected independently. A stale
parent fails before a newly observed child can renew it. Discovering descendants
or siblings adds no authority.
ContextVar binding restores state on success, ordinary exception, cancellation
and nesting. Existing lifecycle tests exercise cancellation during spawn and
repeated cleanup; the new integration test also checks native dispatch context
restoration during cancellation.
## Job history and continuations
`peek()` and resolution do not refresh or reap jobs. Output refresh reconciles
only the selected job. It polls a cached subprocess handle only while the
selected record is running and its frozen start token still verifies as owned;
historical or unverifiable identities cannot poll a replacement handle under
the same numeric PID. Global service refresh still reaps completed handles.
Stop/output/ack
require the caller's exact expected resource and revalidate linkage. Results
can update only an explicit result-field whitelist, never identity, owner,
generation, receipt, PID, command, path or authority fields.
Completed generations remain readable if their lifecycle receipt has been
pruned, provided their application publication and sidecar remain exact.
Completed stop is a no-op and cannot signal a reused PID. Active jobs require
the exact native receipt and live supervisor; an existing receipt with changed
producer/owner/incarnation or external semantics is rejected even for history.
The monitor checks sidecar, launch generation, job resource and session owner
before invoking a continuation and acknowledging that same generation. Missing
legacy sidecars do not acquire authority. Service-owned maintenance/reaping
remains independent of model authority; lookup never invokes it for siblings.
Research records in `background_tool_jobs.py` remain records, not OS processes.
## Reachable production seams
| Production call path | Enforcement or explicit boundary |
| --- | --- |
| `agent_loop` / native executor -> `tool_execution.execute_tool_block` -> `BashTool.execute` / `PythonTool.execute` -> `_run_owned_command` | Exact reservation, native backend pin, explicit owner/session context, sealed spec and pre-execution publication. |
| `execute_tool_block` -> `#!bg` -> `bg_jobs.launch` -> `containment_worker.supervise` | Held release until durable linkage; independent worker validation. |
| Dispatcher -> `ManageBgJobsTool.execute` -> `bg_jobs.get` / `kill` | Exact captured job set/selector, owner/thread binding and revalidation; no implicit list refresh. |
| App startup -> `bg_monitor._loop` -> `_run_followup` / `mark_followed_up` | Exact generation and sidecar/owner/thread validation before continuation and ack. |
| `TaskScheduler._execute_action` -> `action_run_local` / `action_run_script` / local `action_ssh_command` -> `_run_subprocess` | Existing scheduler authority must permit the exact operation; new runner consumes a sealed launch ceiling through containment. Missing workspace/legacy creation scope fails closed. |
| Dispatcher -> Cookbook native tools -> `/api/model/download`, `/api/model/serve`, `/api/cookbook/state`, `/api/cookbook/kill-pid` | Internal native mutation is rejected: UI state/session/PID discovery is not an application process registry. |
| Dispatcher -> `stop_served_model` / `cancel_download` -> `_cookbook_kill_session` | Local targets fail closed before OS discovery, signalling or state changes. |
| Generic `app_api` -> loopback shell/model/Cookbook namespaces | Generic private/owned route admission rejects these process-control namespaces. |
| Direct labelled or unlabelled loopback -> shell native controls / local Cookbook launch/control | Internal markers confer no admin floor. Anonymous/auth-disabled native control fails closed, including missing auth-manager configurations. Authenticated human-admin control remains a separate administrative boundary. |
| App startup -> process reaper / `bg_jobs.refresh` / `disown_unverified` / containment reaping | Existing service maintenance and frozen Wave 5B signal mechanics remain unchanged. |
No production caller of `services/shell/service.py` was found; it is unchanged
and not claimed as covered. Browser lifecycle, research/private browsers and
their producer contracts are unchanged and outside Checkpoint A.
## Unsupported paths and deployment consequences
- Local Cookbook agent launch/control has no trustworthy application registry;
it is disabled instead of enrolling tmux/PID/UI observations.
- Legacy Cookbook scheduled auto-stop uses the rejected internal shell route
and cannot silently resume control of editable UI-backed sessions. Its
absence of a trustworthy producer registry is an explicit remaining gap;
native background-job and containment reapers continue to work.
- Auth-disabled native shell/Cookbook UI controls are unavailable: an anonymous
human request cannot be distinguished securely from a workload's loopback
request. No Origin header, browser key or local address substitutes for
resource authority.
- Legacy tasks without creation scope and jobs without exact generation/sidecar
linkage do not gain authority during restoration.
- Raw scheduled SSH execution fails closed until an exact external backend
producer exists. Existing remote Cookbook routes/MCP/bridges remain external;
a local SSH client is never enrolled as its remote workload.
- Standalone existing-process/PTY/manager control, new producer registration,
browser session/page/document authority and general outbound-effect policy
are not implemented by this slice.
## Control state and adversarial verification
`PROCESS_RESOURCES_DIR`, the active launch directory, job store/sidecars and
containment records are protected by central filesystem resource resolution.
Native writable launch boundaries containing control state or existing
symlink/hardlink aliases are rejected. Tests cover direct access, symlinks and
hardlinks to launch records, job stores, authority sidecars and receipt files.
These are pathname/inode observations. They do not claim race freedom against
concurrent link replacement after validation; Wave 3-S containment mechanics
have not been redesigned.
The three new test files are `test_process_resource_identity.py`,
`test_background_resource_identity.py` and `test_runtime_resource_integration.py`.
They cover PID reuse/unverifiable or malformed observations, role/receipt/owner/
request/thread substitution, generation replacement, publication failure and
held release, immutable result fields, historical reads, sidecar mismatch,
side-effect-free lookup, exact approval first use/replay/restoration, child
ceilings, context restoration, external refusal, native routing, scheduler and
anonymous/internal loopback bypasses, and TurnContract exclusions.
The integrated manifest `wave-3-checkpoint-a-tests.txt` contains 145 files,
including every file in the previous exact 88-file Wave 3 gate. It adds relevant
Wave 5B lifecycle, shell, scheduler, Cookbook, background, browser transport and
research fallback regressions. Run in an environment with functional bubblewrap:
```sh
python3 -m pytest -q -rs $(cat docs/runtime-decomposition/wave-3-checkpoint-a-tests.txt)
python3 -m compileall -q app.py core routes services src tests scripts
git diff --check
git grep -n -E '^(<<<<<<< |=======$|>>>>>>> )' || true
git ls-files -u
```
The final pre-commit gate passed 387 focused tests and 3364 integrated tests,
with 3 platform skips and 2 existing xfails. The focused gate spans 12 files;
the integrated gate spans the 145-file manifest. Validation used
`/tmp/odysseus-wave3-validation/bin/python` with functional bubblewrap.
Compileall, diff whitespace, conflict-marker and unmerged-index gates passed.
The post-commit integrated result is recorded in the final checkpoint report.
Final adversarial review found a numeric-PID-only cached-handle lookup in that
commit. A follow-up patch adds frozen-token validation and four PID-reuse/
unverifiable history regressions, plus a service-cleanup regression. The patched
focused gate passes 392 tests; the patched 145-file integrated gate passes 3369
tests, with the same 3 platform skips and 2 existing xfails. Static gates pass.
Platform skips remain
explicit: `/tmp` is not a symlink, RLIMIT_AS can be lowered on this host, and the
Windows-specific Ollama startup guard is not applicable on Linux. No missing
browser dependency is converted into a passing test.
## Remaining review concerns
No known P0 admission bypass remains in the supported process/job paths.
P1 compatibility gaps are the deliberately unsupported local Cookbook registry
and auth-disabled native administration, plus legacy/unscoped scheduled work.
P2 concerns are linear workspace/control-file scans and retention of private
launch publications beyond job/receipt retention; a future server-owned
maintenance policy must preserve exact historical linkage. Existing filesystem
observation races and outbound-effect boundaries remain explicit limitations.
Browser authority still requires the independent producer-contract lane.
@@ -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
@@ -0,0 +1,282 @@
# Wave 3 independent adapters
Continuation base: `8ae6ee43936bdc5fe1da1297f87fb7b56be4a6cc`, directly
above canonical `a80c164dbe3e8bde4fb29b45c5d1c61404f2fede`.
The read-only continuation audit reviewed that checkpoint, its callers and tests,
then used the following design for this slice. The original A–I inventory remains
in `wave-3-resource-identity.md`; this supplement specifies the independent
adapters and the adversarial corrections. Process/browser adapters are deferred.
## A. Re-audit and implicit-resource inventory
| Site | Observation and decision |
| --- | --- |
| `resources.intersect_roots`, `RequestAuthority.intersect`, `bind_request_authority`, `seal_task_authority` | Descendant intersection already checks the parent observation. The equal-root shortcut did not revalidate it. Validate both observations before any intersection result; a fresh descendant never renews a replaced parent. |
| Dispatcher empty-root exact-approval fallback | Proposal roots serve only to re-resolve and compare one captured operation. Never install them into request authority. Test restored versions 1/2, sibling/parent access, replay, aliases and request/owner/session changes. |
| Native read/write/edit/patch, navigation and media workspace paths | Canonical control-path denial omitted hardlinked control objects. Also deny observed device/inode aliases, private configuration/DB/index paths and background control files, including configured paths from loaded producers. Directory grep's ripgrep branch scans descendants without bound checks: use the existing per-file resolver before reading. Filter bound ls/glob results through the same resolver. Media source/destination resolution uses the same control-state denial. This does not introduce a media filesystem adapter. |
| `McpManager.connect_server`, successful connection registration, `call_tool` | Server ID and qualified tool are mutable connection selectors. Seal the actual connection, configured endpoint origin and opaque epoch; revalidate at transport. A bound call cannot reconnect/retry into another producer. No transport redesign. |
| `_MCP_TOOL_MAP`, qualified/bare email dispatch | Availability previously selected backend/fallback. Preserve native filesystem semantics; snapshot other configured backends at trusted admission and pin dispatch. Discovery never creates operation grants. |
| Scoped `AgentExecutionBridge`, TUI bridge, HTTP request bridge | Callback objects or validated endpoint configuration determine execution. Capture object/configuration identity and exact tool, not a local filesystem observation. HTTP bridge factory and admission must produce the same configuration identity. |
| `do_api_call`, registered integrations | Names/IDs resolve through mutable configuration. Resolve aliases uniquely, bind integration ID, origin and configuration epoch; use the ID during execution and compare the loaded configuration before HTTP work. Generic API grants do not authorize the configured integration inventory: explicit trusted backend scope or one exact approval is required. Paths may contain tokens, so serialize origins and opaque epochs, not URL paths. |
| Document handlers / active document | Context/global active ID or most-recent lookup occurred during execution. Resolve server context or owner-scoped latest once; pass exact ID/version/digest and normalized selector. Global active changes cannot select another record. |
| Attachment OCR / upload index | URI resolves through mutable owner/path/hash index. Capture owner-checked row identity and confined file observation; consume the captured path. Keep the upload producer's owner check, without administrator override. |
| Thread management / send / history searches | `current`, line/JSON ID aliases and history target must bind caller owner and invocation thread. Capture exact selected thread row; collection searches bind the owner namespace. Existing owner-filtered search/cache boundaries remain. |
| Notes / native memories | Prefix and title selection can choose the first row later. Resolve uniquely within owner scope and normalize full ID; exact lookup in bound execution. Capture DB revision or opaque private memory revision. |
| Vault configuration / CLI | Global config had no owner producer binding. Legacy unowned config refuses runtime access. Authenticated settings save establishes owner and drops legacy session material; subsequent runtime reads require that owner, endpoint/configuration observation and an item observed by the server search producer. Names/prefixes resolve uniquely in that owner/configuration catalog to an exact UUID. Unknown UUIDs cannot manufacture a record observation. No credential appears in identity. |
| Builtin memory / RAG MCP stores | Memory producer has a fixed configured owner. Bind that owner and reject another caller or an ownerless producer. Legacy builtin RAG has no owner contract and cannot acquire private scope from discovery; refuse its runtime identity. |
| Generic `app_api` loopback | Internal-token calls could bypass migrated record domains. Refuse those namespace paths, including encoded/relative path aliases; callers use dedicated resource-bound operations. This is a migration guard, not an expanded internal API capability. |
Other owner domains (calendar/contact/research/task/dynamic-tool stores), opaque
native script semantics and unrelated internal API paths remain separate adapter
work. Their existing permission gates are not described as typed enforcement.
This slice does not make a whole-runtime containment or private-data claim.
## B. Typed model
`resources.py` owns the additive immutable contracts:
* `NativeBackendResource`: fixed native namespace and exact tool. Availability
cannot replace it with an MCP filesystem.
* `ExternalResource`: backend namespace, configured server ID, credential-free
endpoint origin, exact tool ID, connection/configuration epoch and optional
producer owner. Always `external=true`, `contained=false`.
* `OwnedScope`: namespace, owner, invocation thread and either an explicit record
ID set or a server-granted owner collection. The collection is a typed scope,
not a wildcard model selector or a capability floor.
* `OwnedResource`: namespace/collection, owner, invocation thread, exact record
ID, observed revision and storage-thread linkage where applicable.
Attachment bindings additionally carry the existing typed filesystem observation
under the owner's private upload root. Context adapters are in
`remote_resources.py` and `owned_resources.py`; they grant no operation names.
## C. Normalized operation/resource binding
`ExactOperation` retains the original normalized proposal. Backend bindings
capture that exact input, caller and request alongside the backend identity.
Owned bindings carry original operation plus server-normalized execution input,
record observations and document execution context. Approval serialization seals
normalized input digests without copying credential-bearing arguments into the
identity. Existing approval content/digest and one-use claim remain mandatory.
Collection creation/search/list operations bind owner collection identity;
specific reads/mutations bind exact records. A restricted record set cannot admit
a collection operation. Native filesystem bindings keep all existing source and
destination rules; patch moves remain unsupported and fail before execution.
## D. Validation flow
1. Server semantic admission grants operations independently of the tool inventory.
2. Trusted authority construction snapshots backend resources for those grants
and admits relevant owner/thread scopes. Restored snapshots never run this
constructor's implicit sealing path.
3. Request binding, parent intersection, policy and TurnContract gates run first.
4. Resolve backend and record selectors centrally, or consume the proposal's
exact sealed identities. Compare ownership, request/thread and resource scope.
5. Revalidate observations before consuming the existing one-use approval and
again at dispatch/producer entry. Bind contexts with `finally` reset.
6. Execute normalized input on the pinned backend/record. MCP and integration
producers compare their actual connection/configuration at the call boundary.
Filesystem checks remain pathname observations, not descriptor-relative atomic
execution. Inode reuse, concurrent path replacement after validation and DB
changes between observation and mutation remain limitations. Record revisions
identify selected state; they are not new Wave 4 evidence or effect claims.
## E. Alias, rename and ownership rules
Backend aliases must resolve uniquely to the approved server/configuration. A
changed endpoint, connection or alias fails before claim/effect. Document
active/latest and thread current selectors resolve once on the server; an
approval consumes the captured ID even when the current UI alias changes. Missing,
stale, conflicting or ambiguous records fail closed. Notes/memory prefixes cannot
fall through to another title/record during bound execution.
Child scopes intersect exact backend identities and owned record sets. Session
continuations may rebind the invocation namespace under the existing trusted
continuation rules, retaining owner, record limits and backend observations;
they do not synthesize a record from copied history. Exact approvals may admit
only their captured operation for a non-inherited legacy authority; they never
install a general resource scope or widen a parent's record/backend scope.
Inherited proposals themselves must fit their originating operation, backend,
filesystem and record scopes. A later approval resumption that resets the existing
inherited marker cannot reconstruct an identity excluded at proposal time.
Private read identity grants no additional send/egress operation.
## F. Integration points
Authority construction/persistence/intersection; central dispatch; approval
proposal/digest; HTTP request bridge admission; MCP successful connection/call
boundary; integration alias/configuration lookup; document dispatch context;
attachment OCR; notes/native memory exact lookup; authenticated vault settings
and owner-bound vault search producers. `agent_loop` changes only forward existing runtime context to proposal
capture. No loop decomposition, containment redesign or lifecycle change.
## G. Migration
Authority snapshots become version 3. Versions 1/2 restore empty backend/owned
scope fields. Fixed local dispatch compatibility retains existing operation gates;
no legacy snapshot reconstructs an external backend or owned collection. Exact
proposal snapshots can admit one operation without renewing general authority.
Remote connection identities expire on reconnect/restart; private configuration
epochs use an in-process keyed opaque identifier. Restored stale epochs refuse
execution and require fresh trusted admission. Legacy unowned vault/RAG and
unresolved MCP connections fail closed. No remote owner, resource containment or
semantic page claim is inferred from successful transport.
Vault record observations describe the last server search response. Configuration
changes or refreshed record observations invalidate sealed operations; this is
not fresh remote semantic verification or a CLI process/account lifecycle claim.
## H. Required verification
New regressions cover equal/subtree stale parent intersection through direct,
context and task callers; restored empty-root exact approvals; control-state
direct/relative/symlink/hardlink reads/writes/search; backend availability, exact
tool/selectors, reconnect/endpoint/alias changes, legacy restoration, child
intersection, credentials and external flags; owned record aliases, revisions,
owner/thread changes, narrow scopes, attachments, vault/native memory identities,
generic loopback bypasses and context cleanup on success/error/cancel/nesting.
Focused existing suites cover RequestAuthority, TurnContract transcription/OCR/
tasks, approvals, nested invocation, filesystem confinement, MCP/bridge routing,
documents/uploads/history and owner-scoped stores. Validation results are recorded
below; no full repository suite is run.
Final validation on the checkpoint tree: **2,435 passed, 2 skipped, 4 warnings**
across the 88 focused files below (56.60 seconds). The skips are the existing
`/tmp`-symlink platform case and a containment shortfall case when `RLIMIT_AS`
can be lowered. The full repository suite was not run.
Tests used `/tmp/odysseus-wave3-validation/bin/python`, an isolated venv with
system site packages plus `bcrypt`, `pyotp`, `mcp<2` and `pypdfium2`. The command
was that interpreter followed by `-m pytest -q -rs --disable-warnings
--maxfail=10` and the exact file arguments below. Earlier overlapping targeted
runs are not added to the final count.
Static gates passed with empty output:
```sh
python3 -m compileall -q app.py core routes services src tests scripts
git diff --check
git grep -n -E '^(<<<<<<< |=======$|>>>>>>> )' || true
git ls-files -u
```
<details>
<summary>Exact focused test file arguments</summary>
```text
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
```
</details>
## I. Wave 4 / Wave 5B collision boundaries
Wave 4 retains durable claim, effects, evidence freshness, provenance and egress
policy. No private content is licensed for transfer by a resource identity.
Existing containment/browser receipts are not authority or semantic verification.
Wave 5B must freeze the shared `ProcessIdentity` and lifecycle API before these
seams are implemented:
* Native `_run_owned_command` and process ownership checks: consume the producer's
verified process identity and lifecycle namespace/incarnation, linking the
admitted execution backend/root and containment receipt without granting scope.
* `bg_jobs.launch/get/kill`, monitor continuations and authority sidecars: link
the durable owner/thread/job identity to that same verified lifecycle identity
and receipt. A model job ID or restored PID never reconstructs it.
* Browser lifecycle `session_for`/receipt and private/MCP browser producers:
consume the frozen producer/process lifecycle identity, then bind owner/thread,
browser session incarnation and page/navigation observations separately.
Producer liveness is not verification of remote page meaning.
This continuation implements none of those adapters and creates no parallel
`ProcessIdentity`. Existing inert process/browser types are unchanged.
@@ -0,0 +1,241 @@
# Wave 3: server-owned resource identity
Audit base: `a80c164dbe3e8bde4fb29b45c5d1c61404f2fede` on
`feature/runtime-resource-authority`. The read-only audit and this design precede
production edits. Wave 3-S is frozen. This document distinguishes the contract
from the initial enforcement slice; it does not claim all resource adapters are
migrated.
## A. Current implicit-resource inventory
| Boundary / locator | Existing authority | Resource still interpreted later |
| --- | --- | --- |
| `src/agent_runtime/authority.py`: `ExactOperation`, `OperationGrant`, `RequestAuthority` | Immutable request, owner/session/workspace, action/input limits, policy denials | Workspace is a string; no root incarnation, object, destination or backend binding. |
| `src/turn_contract.py`: `TurnContract`, `canonical_tool` | Inventory narrows operations; email aliases share policy identity | Inventory/selection does not resolve resources. Bare/qualified email names can address one server. Transcription/OCR/tasks remain narrow. |
| `src/tool_execution.py`: `_tool_path_roots`, `_resolve_tool_path`, `_resolve_search_root` | Operation admission and deployment/public/admin policy | Data, system temp and configured extra roots are an access allowlist; relative paths may use process cwd; empty search path uses mutable defaults. An allowlist is not a request resource grant. |
| Same: `_resolve_tool_path_in_workspace`, `vet_workspace`, `_display_tool_path` | Trusted workspace string, sensitive-path deny policy | `/workspace`, relative/host paths and symlinks resolve later; root/object replacement is not represented. Display/evidence aliases do not confer access. |
| `src/path_confinement.py`: `canonical_root`, `confine` | Canonical inside-root check | Non-strict realpath intentionally supports missing destinations; it does not identify an existing object or grant a root. |
| `src/agent_tools/filesystem_tools.py`: read/write/edit, `ApplyPatchTool`, ls/glob/grep | Dispatcher gate and shared resolver | Handlers reparse paths; writes create parent directories; patches resolve each target and stage/backup by pathname. Different selectors may identify the same target. Patch moves are explicitly unsupported. Search binds a directory but derives descendants later. |
| `src/agent_runtime/identity.py`: `artifact_identity`, `artifact_version` | Evidence bookkeeping only | Workspace/absolute string identities and content hashes are completion evidence, not execution identities or authority. |
| `src/agent_tools/subprocess_tools.py`: `_owned_spec`, `_run_owned_command`, Bash/Python/host shell | Request operation grant then Wave 3-S containment | Cwd, environment, mount recipe and workspace aliases are interpreted at execution. Opaque scripts cannot be treated as an enumerated file operation. Host-shell endpoint/jobs belong to an external executor. |
| `src/containment.py`: `ContainmentSpec`, `ContainmentGrant`, `agent_spec`, `declare_external_bridge` | Frozen enforcement requirements | Receipt ID, owner label, PID/namespace PID and endpoint attest boundaries. They do not supply user permission or a request resource grant. |
| `src/process_ownership.py`: `capture`, `verify`, `start_token` | PID plus OS start token, Linux boot identity | A numeric PID alone is a reused slot. Tokens are inspection identities, not permissions. No new teardown/lifecycle algorithm belongs in Wave 3. |
| `src/bg_jobs.py`: `launch`, `get`, `kill`; `src/agent_tools/bg_job_tools.py` | Session check; verified process teardown | Job ID resolves through a mutable store. Supervisor PID/token, containment ID and namespace identity are separate. Session ownership is implicit rather than typed. |
| `src/agent_runtime/authority.py`: task/job snapshots; `src/bg_monitor.py`; `src/task_scheduler.py` | Parent intersection, sealed task input, continuation owner/session checks | Persisted workspace string can resolve to a replacement root. Missing snapshots fail closed. Session rebinding must not create resources. |
| `src/agent_tools/web_tools.py`: `_scoped_browser_session`, private-browser execution; `src/browser_lifecycle.py`: `BrowserSession`, `session_for`, `receipt` | Browser action class; server session hashing; producer locks | Namespace/session hash identifies a producer name, not its incarnation. Navigation generation, current URL, failed navigation and element references are mutable page state. URL/element selectors are not page identity. Receipts are not semantic verification. |
| `src/builtin_mcp.py`, `src/mcp_manager.py`: `call_tool`, reconnect, builtin browser | Qualified tool and policy gates | Server ID maps to a mutable connection/configuration; reconnect replaces producer. Builtin Playwright has a shared global browser. Stdio locally launches a third-party server but does not prove containment of its operations. |
| `src/tool_execution.py`: `AgentExecutionBridge`, `_client_bridge`, `_route_tool_via_bridge`, `_apply_patch_via_tui_host_bridge`, `_call_mcp_tool` | Explicit bridge routing after authority; exact approvals | Bridge callback/name, endpoint and context are resolved later; MCP-to-native fallback changes backend. Transport selection and availability must not authorize a backend/resource. External paths need the remote owner's contract, not local realpath or invented remote containment. |
| `src/agent_tools/document_tools.py`: `_get_owned_document`, `_most_recent_owned_document`, update/edit/suggest/manage | Owner-filtered DB lookup; approved ID/version/digest | Context target, process-global active document, model ID aliases and most-recent selection can choose targets late. Ownership alone does not establish that the request selected a document. |
| `src/agent_tools/media_tools.py`: `_resolve_workspace_path`, media/OCR/transcription implementations | Narrow operation class and local/upload checks | Workspace URI, local paths, confined host aliases, attachment URI and export/output aliases are separate resolution paths. Exports require source plus destinations; attachment IDs require owner-checked index identity. |
| `src/upload_handler.py`: `reserve_upload`, `resolve_upload`; `src/document_processor.py` | Ownership/index consistency and path confinement | Upload ID/hash/index aliases map to files; row/path/owner binding must be captured before consumption. Owner migration and cleanup can mutate mappings. |
| `src/agent_tools/session_tools.py`, `src/session_actions.py`, `src/session_search.py`, `src/tools/search.py` | Owner-filtered thread/history lookup | `current`, IDs, list/search result sets, fork targets and DB rows are reconstructed during execution. Null-owner handling differs by API and must remain explicit. A child thread never inherits authority by copying history. |
| `src/agent_tools/coding_tools.py`: `TodoWriteTool` | Tool/session context | Session text is sanitized into a filename and can fall back to model input/`current`; different strings may collide. This is private storage, not an ordinary workspace file. |
| `src/tools/notes.py`, `calendar.py`, `contacts.py`, `vault.py`, `research.py`, `image.py`, `system.py`, `cookbook.py`; admin tools and `app_api` | Owner/admin filters, operation gates, scheduled-task snapshots | Record ID/title/query/default account, task/action, model/server ID, preset, endpoint and API path select resources later. User collections and service credentials are private namespaces; installed tools/endpoints do not grant access. Broad app API and opaque host/script calls require dedicated backend contracts. |
| `src/tool_approvals.py`: pending digest, `matches`, `claim`; nested invocation tests | Exact one-use input, owner/session/workspace/document and original authority | File path is exact text but its alias/object can change between proposal and claim. Children may only intersect operation and resource scopes. No approval grants a later operation implicitly. |
The inventory is of execution/resource-resolution seams. Internal renderer and
temporary implementation files are not independent user authority targets. Their
identity derives from the admitted operation's bounded root/backend contract.
## B. Typed resource identity model
Identity is inert, immutable server data. Model arguments remain selectors.
There is no model-facing deserializer that mints grants.
* Filesystem: a root with scope (`workspace`, `scratch`, `external`, `private`),
canonical location and observed device/inode/type. An object has that root,
canonical path, target observation (or explicit absence) and existing ancestor
observations. Missing destinations retain their existing parent identity;
they are not imaginary inodes. Private roots additionally bind an owner.
Server execution-control stores and background authority sidecars cannot be
addressed as user filesystem resources, even beneath an admitted root.
* Process: backend/ownership namespace, producer incarnation, PID/start token,
optional namespace PID/start token, background job ID and containment receipt
linkage. A receipt reference is attribution only. New process execution first
binds its execution root/backend; PID identity only exists after spawn.
* Browser producer: backend namespace, owner/thread, producer session and
incarnation. Page observation: that producer plus navigation generation,
observed page ID/URL and producer reference. Lifecycle state is distinct from
page semantics, and neither establishes semantic correctness.
* External execution: backend namespace, endpoint identity, server/tool and
connection incarnation. Always explicitly external. Endpoint identities must
be sanitized identifiers, never credentials. No containment is inferred.
* Owned records: ownership namespace, exact owner, thread, collection and
record/document ID; revision when the producer supplies it. Collections used
for list/search are explicit owner-bound resources, not unknown record IDs.
The initial implementation provides types for each domain. Only filesystem
resolution/admission is migrated; unused domain types do not attest existing
producers or silently supply missing incarnations.
## C. Normalized operation/resource binding
Retain the original `ExactOperation` for policy and approval matching. Add an
immutable bound operation containing request identity, canonical executor input
and role-tagged resources (`source`, `target`, `destination`, `search_root`).
Patch operations enumerate all targets before dispatch and reject canonical
path and observed object collisions (including hardlinks). Rename/move bindings require both source and destination; the
current native patch parser continues refusing moves. No shell text parsing is
used to pretend an opaque script has enumerated filesystem semantics.
## D. Authority-to-resource validation flow
1. Normalize the original tool/input; check RequestAuthority binding, parent
intersection, policy denials and exact operation grant/approval eligibility.
2. Apply the unchanged TurnContract and existing security/public/admin gates.
3. Resolve native filesystem selectors against roots sealed by the server,
apply existing confinement and sensitive-path policy, and observe identities.
Neither configured allowlists nor schema/bridge availability adds a root.
4. Compare approved resource snapshots before claiming the exact one-use action.
Revalidate root/object/ancestors; unresolved or changed identities refuse.
5. Dispatch canonical executor input under a context-local binding. Shared
resolvers consume that binding and reject undeclared paths; search traversal
remains bounded by the declared search resource and sensitive-path policy.
6. Existing effect/evidence/completion handling continues unchanged.
Path observations and immediate revalidation detect replacement before
dispatch. They are not kernel-held file descriptors and cannot eliminate all
concurrent pathname races inside existing handlers. Closing those races requires
descriptor-relative I/O integration; this slice must not claim atomic identity
enforcement or change the frozen process containment mechanism.
Device/inode observations also cannot distinguish every possible inode reuse;
they are scoped local filesystem observations rather than globally permanent IDs.
## E. Alias, rename and ownership rules
`/workspace`, relative paths, host paths and symlinks resolve only on the server.
Executor input uses the resolved path; original input remains exact for approval.
Retargeting an approved alias changes its bound identity and refuses execution.
Both sides of any future move must resolve under admitted scopes before an
effect. A missing destination binds absence plus its existing ancestors.
Owner/thread mismatches fail; an ownership query proves attribution, not intent.
Children intersect roots by identical root observation and owner/scope, and may
narrow to descendant scopes. Empty intersections stay empty. Continuations and
persisted snapshots retain observations instead of re-sealing a changed root.
## F. Integration points / chosen slice
Add `src/agent_runtime/resources.py`, extend RequestAuthority with sealed
filesystem roots, and add the central native filesystem binder in
`src/agent_runtime/resource_binding.py`. Integrate read/write/edit/patch/ls/glob/
grep with `execute_tool_block`, shared path resolvers and exact approval sealing.
Bridge-routed operations remain outside this native adapter; a local root must
not be used to invent a remote resource identity. Existing native search handlers
retain their descendant checks. No agent-loop decomposition or browser/process
lifecycle refactor is needed.
Bare native filesystem operations now dispatch directly to their native handlers
with canonical input. A connected filesystem MCP server cannot redirect these
resources or supply an implicit fallback backend. Explicit qualified MCP calls
remain on the external path pending its producer/resource adapter.
## G. Migration plan
1. Initial slice: seal a vetted workspace at server authority construction;
permit explicit server-supplied scratch/external/private roots; serialize the
observations and intersect them. No implicit data/tmp/extra-root grant.
2. Version authority snapshots. Legacy snapshots retain operation restrictions
but receive no reconstructed filesystem roots. Missing roots refuse migrated
native tools. A new trusted request may seal new resources.
3. Integrate canonical native filesystem input and approved resource snapshots.
Existing fixtures requiring unscoped native files must explicitly grant a
test root; they cannot rely on broad production allowlists.
4. Follow-up adapters: media/attachment/export, document/thread/private stores,
job controls and native opaque execution root/recipe, then bridge/MCP and
browser producers. Each requires its own server-owned resolution seam and
must fail closed on absent producer identity. Do not fill gaps with string
hashes described as incarnations or generic capability floors.
The narrow slice does not remove every implicit-resource site listed in A.
Its coverage and remaining adapters must be reported explicitly.
The server-control-store denial applies to this native filesystem adapter;
opaque scripts and other unmigrated adapters still need their own resource
boundaries. This slice does not attest those paths as enforcing the new contract.
## H. Exact tests required
* Root/target canonicalization: relative, host, `/workspace`, symlink aliases;
sibling/traversal/symlink escapes; sensitive files; malformed path/JSON/type.
* Existing files and directories; absent destination plus parent identity;
replacement of root, target or existing ancestor invalidates the binding.
* No roots means no migrated native execution, even with an offered handler,
configured allowlist, selected tool, valid operation grant or result receipt.
* Every patch target binds before dispatch; canonical target collisions and
unsupported moves refuse before partial writes. Dual-resource move contract.
* Canonical input reaches the handler; shared resolvers reject undeclared
targets; directory searches allow only bounded descendants.
* Parent/child root intersection, mismatch of owners/sessions, context cleanup,
concurrent calls, task/background persistence, malformed/legacy snapshots.
* Approval alias/target/parent replacement, immutable digest, missing resource
snapshot, exact original input, one-use replay and nested restriction.
* Regression suites: request authority, approvals, nested ownership, workspace
confinement, path policy, filesystem tools, execution bridges, TurnContract
(including transcription/OCR/tasks), frozen containment/native/background.
* Future adapters require job PID reuse/receipt mismatches, browser incarnation/
page generation distinction, MCP reconnect/endpoint changes, cross-owner
attachment/record/thread rejection and exact dual-resource exports/moves.
## I. Collision analysis with Wave 4 and Wave 5B
Wave 3 binds what an admitted operation addresses. Device/inode observations
identify objects, not content versions or proof that an effect occurred. It adds
no durable claim, effects ledger, egress/provenance, evidence freshness rule or
truthful-completion mechanism (Wave 4). It adds no supervisor, restart/reaper,
cleanup state machine, generic lifecycle namespace allocator or process teardown
algorithm (Wave 5B). Process/browser producer incarnations must come from their
owners; this contract does not fabricate them. Frozen containment receipts and
browser lifecycle receipts remain evidence of their stated producer boundaries,
never authority or semantic verification.
## Implementation validation
Executed locally with `/usr/bin/python3` on 2026-10-02:
* Integrated focused run: **1,649 passed, 2 skipped, 1 warning**. This includes
request identity linkage and approval matching, before the final hardlink
collision and resource-context unwind additions.
* Final follow-up after those additions: **109 passed, 1 warning** across
`test_resource_identity.py`, `test_apply_patch_transaction.py`,
`test_workspace_confine.py` and `test_tool_approvals.py`.
* `compileall -q` on the five changed/new production Python modules and the two
changed/new test modules passed. `git diff --check` passed.
Counts overlap and must not be added. No full Python suite was executed. The
earlier focused runs exposed error-message expectation changes; the three
unscoped dispatcher denial assertions now check missing sealed roots. The
separate legacy resolver/sensitive-path tests remain intact. The new tests use
the raw dispatcher with explicit server authority, not a permissive fixture.
Integrated command:
```sh
/usr/bin/python3 -m pytest \
tests/test_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 \
-q --disable-warnings --maxfail=8
```
Final follow-up command:
```sh
/usr/bin/python3 -m pytest tests/test_resource_identity.py \
tests/test_apply_patch_transaction.py tests/test_workspace_confine.py \
tests/test_tool_approvals.py -q --disable-warnings
```
Frozen containment, browser lifecycle producers, process ownership and
`agent_loop` were not edited. The resource types for the remaining domains are
inert contracts; their presence does not mean those execution adapters enforce
Wave 3 yet. Pathname races and inode reuse remain the limitations stated in D.
+4
View File
@@ -814,10 +814,13 @@ def _external_execution_bridge(
raise ValueError("external execution bridge returned an invalid payload")
return str(payload.get("description") or tool), payload["result"]
from src.agent_runtime.remote_resources import configuration_incarnation
return AgentExecutionBridge(
route_tool=route_tool,
supported_tools=supported,
name="request_local_http",
endpoint_id=url,
configuration_id=configuration_incarnation((url, token, tuple(sorted(supported)))),
)
@@ -3418,6 +3421,7 @@ def setup_chat_routes(
_request_authority = request_authority_for_http(
request, message, owner=_user, session_id=session, workspace=workspace,
history=_turn_history, policy=tool_policy,
client_runtime_context=client_runtime_context,
active_document=bool(active_doc),
image_attachment=any(str(a.get('mime') or '').startswith('image/')
for a in (ctx.preprocessed.attachment_meta or [])),
+11
View File
@@ -118,6 +118,17 @@ def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
because cookbook surfaces expose host topology, task logs, tmux
commands, and model-serving controls.
"""
# Internal transport/owner attribution is not a scoped external credential.
# In no-login mode, this wrapper must preserve the native local-operator
# boundary even though it invokes endpoint functions without dependencies.
from src.agent_runtime.authority import is_internal_tool_request
from src.auth_helpers import _auth_disabled
from core.middleware import INTERNAL_TOOL_HEADER
if is_internal_tool_request(request) or request.headers.get(INTERNAL_TOOL_HEADER):
raise HTTPException(403, "Internal Cookbook calls require a dedicated producer")
if _auth_disabled():
from routes.shell_routes import _require_admin
_require_admin(request)
owner = _scope_owner(request, allowed)
if not getattr(request.state, "api_token", False):
require_admin(request)
+30 -1
View File
@@ -405,7 +405,34 @@ def _append_local_ollama_download_command_lines(
def setup_cookbook_routes() -> APIRouter:
router = APIRouter(tags=["cookbook"])
async def protect_native_control(request: Request):
if request.method in {"GET", "HEAD"}:
return
# UI records/session strings are not process authority. Local tool
# launches require a one-use capability from their admitted producer.
path = request.url.path
from routes.shell_routes import _require_admin
if path in {"/api/cookbook/kill-pid", "/api/cookbook/state", "/api/cookbook/ssh-key"}:
_require_admin(request)
if path in {"/api/model/download", "/api/model/serve"}:
payload = await request.json()
if not payload.get("remote_host"):
from src.agent_runtime.local_model_control import consume_model_control
from src.agent_runtime.resources import ResourceIdentityError
try:
claimed = consume_model_control(request, payload)
except (ResourceIdentityError, ValueError, TypeError):
raise HTTPException(403, "Local model capability denied") from None
if not claimed:
_require_admin(request)
router = APIRouter(tags=["cookbook"], dependencies=[Depends(protect_native_control)])
def protect_local_model_producer(request, remote_host):
# Scoped wrappers can call endpoint functions directly, without FastAPI
# dependencies. Enforce native control at the actual producer as well.
if not remote_host and getattr(request.state, "local_model_authority", None) is None:
from routes.shell_routes import _require_admin
_require_admin(request)
_cookbook_state_path = Path(COOKBOOK_STATE_FILE)
_state_get_cache = {"ts": 0.0, "mtime": 0.0, "value": None}
_tasks_status_cache = {"ts": 0.0, "value": None}
@@ -1080,6 +1107,7 @@ def setup_cookbook_routes() -> APIRouter:
"""Download a HuggingFace model in a tmux session.
Uses `hf download` CLI directly — runs in tmux via `script -qc`
for real TTY progress, streams ANSI-stripped output via log file."""
protect_local_model_producer(request, req.remote_host)
require_admin(request)
# Defence-in-depth: even though this endpoint is admin-gated, refuse
# values that would land in shell contexts with metacharacters.
@@ -1998,6 +2026,7 @@ def setup_cookbook_routes() -> APIRouter:
keep strict validation, but serving local cached models must not require
a fake org/name wrapper.
"""
protect_local_model_producer(request, req.remote_host)
require_admin(request)
# Defence-in-depth: reject values that could break out of shell contexts.
validate_remote_host(req.remote_host)
+12 -11
View File
@@ -59,21 +59,22 @@ from core.platform_compat import (
def _require_admin(request: Request):
"""Reject non-admin callers. Shell exec is admin-only — never expose to
regular users; that's RCE-after-signup."""
# In the explicitly single-user, auth-disabled deployment the middleware
# does not attach a current user. AuthManager is still instantiated by the
# app, so checking only for its presence incorrectly returns 403 here.
# Tool authentication is never human administration. Operator-disabled
# login has a separate direct-local transport contract below; it supplies
# no resource grant to model producers.
from src.agent_runtime.authority import is_internal_tool_request
from core.middleware import INTERNAL_TOOL_HEADER
if is_internal_tool_request(request) or request.headers.get(INTERNAL_TOOL_HEADER):
raise HTTPException(403, "Internal shell execution requires a dedicated resource-bound producer")
if _auth_disabled():
return
from src.auth_helpers import is_direct_loopback_request
if is_direct_loopback_request(request):
return
raise HTTPException(403, "Anonymous native process control has no resource authority")
auth_manager = getattr(request.app.state, "auth_manager", None)
if not auth_manager:
# No auth at all — only safe in fully-trusted localhost dev mode
return
raise HTTPException(403, "Native process control requires authenticated administration")
user = getattr(request.state, "current_user", None)
# In-process tool loopback. The AuthMiddleware already validated the
# internal token + loopback client before setting this marker, so
# honour it here as admin-equivalent.
if user == INTERNAL_TOOL_USER:
return
if not user or user == "api":
raise HTTPException(403, "Admin only")
if not auth_manager.is_admin(user):
+15
View File
@@ -13,6 +13,7 @@ import asyncio
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, Request
from fastapi import HTTPException
from pydantic import BaseModel
from core.middleware import require_admin
@@ -77,6 +78,19 @@ def _save_config(cfg: dict):
safe_chmod(str(VAULT_FILE), 0o600)
def _bind_config_owner(cfg: dict, request: Request):
from src.auth_helpers import effective_user
from src.owner_identity import effective_storage_owner
owner = effective_storage_owner(effective_user(request))
if not owner or (cfg.get("owner") and cfg["owner"] != owner):
raise HTTPException(403, "Vault configuration requires its explicit owner")
if not cfg.get("owner"):
# Legacy credentials cannot silently acquire a new ownership binding.
cfg.pop("session", None)
cfg.pop("unlocked_at", None)
cfg["owner"] = owner
async def _run_bw(args: list, session: str = None, input_text: str = None,
bw_password: str = None) -> tuple:
env = {}
@@ -144,6 +158,7 @@ def setup_vault_routes():
"""Save vault URL + email. Runs 'bw config server' to point at Vaultwarden."""
require_admin(request)
cfg = _load_config()
_bind_config_owner(cfg, request)
cfg["server_url"] = req.server_url.strip().rstrip("/")
cfg["email"] = req.email.strip()
+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 "
+11
View File
@@ -74,6 +74,17 @@ Missing-owner values remain state-dependent at legacy call sites, but new storag
- Auth-enabled, configured auth with no `current_user` is unauthenticated and should fail closed at route dependencies.
- `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode.
Native shell and local Cookbook administration additionally require a direct
loopback connection without proxy forwarding, cross-site indicators, or an
internal-tool header. Remote/proxied anonymous traffic remains denied at
these controls. Auth-enabled administration still requires a human admin.
This mode trusts local programs as well as the local operator: a headerless
loopback request cannot identify which local program sent it. Agent program
launches retain inherited networking; this is not protection against hostile
local code. Use authenticated mode when local programs are outside that trust.
Local Cookbook tools use a separate one-use capability for an admitted exact
request/operation/native backend and resolved launch body; that capability
cannot administer shell, PID, SSH-key, or arbitrary Cookbook state routes.
- Chat/agent code that reads `get_current_user(request)` directly gets `None` when auth middleware is disabled, because no middleware stamps request state.
- SQL `NULL`/JSON missing owners remain legacy/shared compatibility data, not the same thing as a logged-out authenticated caller.
- `"api"` and `"internal-tool"` are request sentinels. They must not be persisted as normal storage owners unless a route explicitly defines that behavior.
+3 -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
@@ -32769,6 +32768,7 @@ async def stream_agent_loop(
),
request_text=_last_user,
request_authority=active_request_authority(),
client_runtime_context=client_runtime_context,
)
desc = f"{block.tool_type}: APPROVAL REQUIRED"
result = {
+171 -16
View File
@@ -11,6 +11,12 @@ from pathlib import Path
import re
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
from src.turn_contract import (
FAMILY_TOOLS, canonical_tool, requested_capabilities,
@@ -111,6 +117,16 @@ class RequestAuthority:
block_all: bool = False
disable_mcp: bool = False
inherited: bool = False
# None is only the trusted constructor's instruction to seal a workspace.
# Persisted/child authorities always carry an explicit tuple, including ().
resource_roots: tuple[FilesystemRoot, ...] | None = None
backend_resources: tuple[ExternalResource | NativeBackendResource, ...] | None = None
owned_scopes: tuple[OwnedScope, ...] | None = None
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
@@ -122,10 +138,67 @@ class RequestAuthority:
or any(not isinstance(n, str) or canonical_tool(n) != n for n in self.denied)
or any(type(v) is not bool for v in (self.block_all, self.disable_mcp, self.inherited))):
raise ValueError("Malformed request authority")
if self.resource_roots is None:
roots = ()
if self.workspace:
try:
roots = (FilesystemRoot.seal(self.workspace, owner=self.owner),)
except (OSError, ValueError, RuntimeError):
pass # An unresolved workspace grants no filesystem root.
object.__setattr__(self, "resource_roots", roots)
if (not isinstance(self.resource_roots, tuple)
or any(not isinstance(r, FilesystemRoot) or (r.owner and r.owner != self.owner)
for r in self.resource_roots)):
raise ValueError("Malformed request resource roots")
if self.backend_resources is None:
from src.agent_runtime.remote_resources import seal_backends
object.__setattr__(self, "backend_resources", seal_backends((g.tool for g in self.grants), owner=self.owner))
if self.owned_scopes is None:
object.__setattr__(self, "owned_scopes", seal_owned_scopes(
self.owner, self.session_id, (g.tool for g in self.grants)))
if (not isinstance(self.backend_resources, tuple)
or any(not isinstance(r, (ExternalResource, NativeBackendResource))
or (isinstance(r, ExternalResource) and r.owner and r.owner != self.owner) for r in self.backend_resources)
or not isinstance(self.owned_scopes, tuple)
or any(not isinstance(s, OwnedScope) or (s.owner, s.thread_id) != (self.owner, self.session_id)
for s in self.owned_scopes)):
raise ValueError("Malformed backend or owned resource scope")
from src.agent_runtime.process_resources import seal_launch_scopes, seal_jobs
if self.launch_scopes is None:
object.__setattr__(self, "launch_scopes", seal_launch_scopes(self))
if self.job_resources is None:
object.__setattr__(self, "job_resources", seal_jobs(self))
for field, kind in (("launch_scopes", ProcessLaunchScope), ("process_resources", ProcessResource),
("job_resources", BackgroundJobResource)):
values = getattr(self, field)
if not isinstance(values, tuple) or any(not isinstance(r, kind) for r in values):
raise ValueError("Malformed process resource scope")
if any(r.owner != self.owner for r in (*self.process_resources, *self.job_resources)):
raise ValueError("Process resource owner changed")
if any(s.root.owner and s.root.owner != self.owner for s in self.launch_scopes):
raise ValueError("Launch resource owner changed")
if any(r.thread_id != self.session_id for r in self.job_resources):
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 ""))
return cls(uuid4().hex, _owner(owner), str(session_id or ""), str(workspace or ""),
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 "")
@@ -150,32 +223,65 @@ class RequestAuthority:
if not isinstance(child, RequestAuthority):
raise TypeError("Child authority must be server-owned RequestAuthority")
grants = []
roots = ()
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]
roots = intersect_roots(self.resource_roots, child.resource_roots)
backends = tuple(r for r in self.backend_resources if r in child.backend_resources)
owned = tuple(s for left in self.owned_scopes for right in child.owned_scopes
if (s := left.intersect(right)) is not None)
from src.agent_runtime.process_resources import intersect_observed, intersect_launch_scopes, validate_job
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)
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,
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."""
if self.owner != _owner(owner):
return RequestAuthority.empty(owner=owner, session_id=session_id)
return replace(self, session_id=str(session_id or ""), inherited=True)
rebound = str(session_id or "")
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),
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": 1, "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),
"inputs": None if g.inputs is None else sorted(g.inputs)} for g in self.grants],
"denied": sorted(self.denied), "block_all": self.block_all,
"disable_mcp": self.disable_mcp, "inherited": self.inherited}
"disable_mcp": self.disable_mcp, "inherited": self.inherited,
"resource_roots": [r.to_dict() for r in self.resource_roots],
"backend_resources": [r.to_dict() for r in self.backend_resources],
"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],
"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"] != 1):
or value["version"] not in {1, 2, 3, 4, 5}):
raise ValueError("Unsupported authority snapshot")
def limits(value):
if value is None:
@@ -183,14 +289,34 @@ class RequestAuthority:
if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
raise ValueError("Malformed authority limits")
return frozenset(value)
roots = value["resource_roots"] if value["version"] >= 2 else []
if not isinstance(roots, list):
raise ValueError("Malformed request resource snapshot")
backends = value["backend_resources"] if value["version"] >= 3 else []
owned = value["owned_scopes"] if value["version"] >= 3 else []
process_fields = {name: value[name] if value["version"] >= 4 else []
for name in ("launch_scopes", "process_resources", "job_resources")}
if any(not isinstance(v, list) for v in process_fields.values()):
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"]),
value["block_all"], value["disable_mcp"], value["inherited"])
value["block_all"], value["disable_mcp"], value["inherited"],
tuple(FilesystemRoot.from_dict(r) for r in roots),
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(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)
@@ -237,7 +363,7 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
history=(), policy=None, active_document=False,
image_attachment=False, capabilities=None):
image_attachment=False, capabilities=None, client_runtime_context=None):
"""Deterministic server policy over semantic facts, never schema inventory."""
if not isinstance(request_text, str):
raise TypeError("Authority requires trusted request text")
@@ -273,6 +399,10 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
grants.append(OperationGrant(name, actions, inputs))
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
str(workspace or ""), tuple(grants))
if client_runtime_context is not None:
from src.agent_runtime.remote_resources import seal_backends
authority = replace(authority, backend_resources=seal_backends(
(g.tool for g in authority.grants), context=client_runtime_context, owner=authority.owner))
return authority.restrict(policy or build_effective_tool_policy(last_user_message=request_text))
@@ -360,7 +490,8 @@ def with_request_authority(func):
owner=parameters.get("owner"), session_id=parameters.get("session_id"),
workspace=parameters.get("workspace"),
history=getattr(parameters.get("history_session"), "history", ()) or (),
active_document=bool(parameters.get("active_document")))
active_document=bool(parameters.get("active_document")),
client_runtime_context=parameters.get("client_runtime_context"))
if not isinstance(authority, RequestAuthority):
raise TypeError("Missing or malformed server request authority")
if parent is None and parameters.get("exact_approval") is not None:
@@ -392,12 +523,27 @@ def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authori
if operation is not None:
authority = replace(authority, grants=(OperationGrant(operation.tool,
inputs=frozenset({operation.input})),))
if task_type == "action" and action == "cookbook_serve":
# The direct admin scheduling ingress selects the native Cookbook
# producer. Restore never infers this from task names/availability.
# Any model-created task still intersects with its parent's ceiling.
backend = NativeBackendResource("serve_model")
authority = replace(authority, backend_resources=tuple(dict.fromkeys(
(*authority.backend_resources, backend))))
parent = active_request_authority() if parent_authority is MISSING_AUTHORITY else parent_authority
if parent_authority is None:
parent = RequestAuthority.empty(owner=owner)
if parent is not None:
authority = parent.intersect(replace(authority, session_id=parent.session_id,
workspace=parent.workspace))
workspace=parent.workspace,
resource_roots=parent.resource_roots,
backend_resources=parent.backend_resources,
owned_scopes=parent.owned_scopes,
launch_scopes=parent.launch_scopes,
process_resources=parent.process_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()})
@@ -414,18 +560,27 @@ def restore_task_authority(snapshot, prompt, task_type, action, *, owner=None, s
def _background_path(job_id):
if not isinstance(job_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", job_id):
raise ValueError("Invalid background authority identity")
from src.constants import BG_JOBS_DIR
return Path(BG_JOBS_DIR) / (job_id + ".authority.json")
from src.bg_jobs import _JOBS_DIR
return Path(_JOBS_DIR) / (job_id + ".authority.json")
def save_background_authority(job_id, authority):
def save_background_authority(job_id, authority, *, resource=None):
from core.atomic_io import atomic_write_json
atomic_write_json(_background_path(job_id), authority.to_dict())
if resource is None or resource.job_id != job_id:
raise ValueError("Background authority requires exact job linkage")
atomic_write_json(_background_path(job_id), {"authority": authority.to_dict(), "job": resource.to_dict()})
def restore_background_authority(job_id, *, owner=None, session_id=None):
try:
authority = RequestAuthority.from_dict(json.loads(_background_path(job_id).read_text()))
value = json.loads(_background_path(job_id).read_text())
resource = BackgroundJobResource.from_dict(value["job"])
from src.agent_runtime.process_resources import validate_job
validate_job(resource)
authority = RequestAuthority.from_dict(value["authority"])
if (resource.job_id, resource.owner, resource.thread_id, resource.request_id) != (
job_id, authority.owner, authority.session_id, authority.request_id):
raise ValueError("Background authority linkage changed")
if authority.session_id != str(session_id or ""):
raise ValueError("Background session changed")
return authority.continuation(owner=owner, session_id=session_id)
+105
View File
@@ -0,0 +1,105 @@
"""One-use transport capabilities for admitted local Cookbook producers.
The internal HTTP token authenticates transport only. A capability bridges one
server-owned request/operation/backend to one exact resolved local launch body.
It is never persisted, returned to the model, or usable for shell/job control.
"""
from contextlib import contextmanager
from dataclasses import dataclass
import hashlib
import json
import secrets
import threading
import time
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError
CAPABILITY_HEADER = "X-Odysseus-Local-Model-Capability"
_ROUTES = {"download_model": "/api/model/download", "serve_model": "/api/model/serve",
"serve_preset": "/api/model/serve"}
_PENDING = {}
_LOCK = threading.Lock()
def _digest(payload):
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"),
allow_nan=False).encode()).hexdigest()
@dataclass(frozen=True)
class _Capability:
authority: object
operation: object
backend: NativeBackendResource
path: str
payload_digest: str
deadline: float
@contextmanager
def model_control_headers(tool, content, owner, payload, *, scheduled=False):
from src.tools._common import _internal_headers
headers = _internal_headers(owner)
if payload.get("remote_host"):
yield headers # Remote workload authority/transport is unchanged.
return
from src.agent_runtime.authority import active_request_authority, ExactOperation
from src.agent_runtime.remote_resources import active_backend_operation
from src.tool_security import owner_is_admin_or_single_user
authority = active_request_authority()
operation = ExactOperation.normalize(tool, content)
backend = active_backend_operation()
if (authority is None or authority.owner != str(owner or "").strip().casefold()
or tool not in _ROUTES or not owner_is_admin_or_single_user(owner)):
raise ResourceIdentityError("Local model producer has no matching server authority")
if scheduled:
# Called only by the server-owned scheduled action, after restoration of
# its immutable input ceiling. A task name or owner alone is not enough.
if tool != "serve_model" or not authority.permits(operation):
raise ResourceIdentityError("Scheduled local model input is outside authority")
resource = NativeBackendResource(tool)
if resource not in authority.backend_resources:
raise ResourceIdentityError("Scheduled local model backend is outside authority")
else:
# This binding exists only after dispatch admission (including one-use
# exact approval). A generic tool grant/header cannot create it over HTTP.
if (backend is None or backend.resource != NativeBackendResource(tool)
or (backend.request_id, backend.owner, backend.session_id,
backend.transport_tool, backend.exact_input) !=
(authority.request_id, authority.owner, authority.session_id, tool, operation.input)):
raise ResourceIdentityError("Local model producer operation or backend changed")
resource = backend.resource
capability = _Capability(authority, operation, resource, _ROUTES[tool], _digest(payload), time.monotonic() + 60)
token = secrets.token_urlsafe(32)
headers.update({CAPABILITY_HEADER: token, "X-Odysseus-Owner": authority.owner})
with _LOCK:
_PENDING[token] = capability
try:
yield headers
finally:
with _LOCK:
_PENDING.pop(token, None)
def consume_model_control(request, payload):
"""Claim exactly once at the local route, before any producer effect."""
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
from src.auth_helpers import is_direct_loopback_request
token = request.headers.get(CAPABILITY_HEADER)
if not token:
return False
if (not is_direct_loopback_request(request)
or not secrets.compare_digest(request.headers.get(INTERNAL_TOOL_HEADER, ""), INTERNAL_TOOL_TOKEN)):
raise ResourceIdentityError("Local model transport is untrusted")
with _LOCK:
capability = _PENDING.get(token)
if (capability is None or capability.deadline < time.monotonic()
or request.method != "POST" or request.url.path != capability.path
or payload.get("remote_host") or _digest(payload) != capability.payload_digest
or request.headers.get("X-Odysseus-Owner", "") != capability.authority.owner
or getattr(request.state, "current_user", None) not in
(None, INTERNAL_TOOL_USER, capability.authority.owner)):
raise ResourceIdentityError("Local model capability binding changed or expired")
del _PENDING[token]
request.state.local_model_authority = capability.authority
return True
+454
View File
@@ -0,0 +1,454 @@
"""Resolve owned selectors before execution and consume exact server identities."""
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
import json
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.agent_runtime.authority import ExactOperation
from src.agent_runtime.resources import (
FilesystemResource, FilesystemRoot, FilesystemScope, OwnedResource,
OWNED_TOOL_NAMESPACES, ResourceIdentityError,
)
def _args(content):
if not isinstance(json.loads(content or "{}"), dict):
raise ResourceIdentityError("Owned resource arguments must be an object")
from src.tools._common import _parse_tool_args
value = _parse_tool_args(content)
if not isinstance(value, dict):
raise ResourceIdentityError("Owned resource arguments must be an object")
return dict(value)
def _selector(args, keys):
values = [args[k] for k in keys if k in args and args[k] not in (None, "")]
if any(not isinstance(v, str) or not v.strip() for v in values):
raise ResourceIdentityError("Record selectors must be strings")
values = [v.strip() for v in values]
if len(set(values)) > 1:
raise ResourceIdentityError("Conflicting record aliases")
return values[0] if values else ""
def _revision(row, namespace):
created = getattr(row, "created_at", None)
updated = getattr(row, "updated_at", None)
if created is None or not hasattr(created, "isoformat") or updated is None or not hasattr(updated, "isoformat"):
raise ResourceIdentityError("Record has no observable revision")
version = getattr(row, "version_count", "") if namespace == "documents" else ""
if namespace == "documents" and type(version) is not int:
raise ResourceIdentityError("Document version is unresolved")
return f"{created.isoformat()}:{updated.isoformat()}:{version}"
def _record(namespace, owner, thread, row):
if (getattr(row, "owner", None) != owner or not isinstance(getattr(row, "id", None), str)
or row.id in {"", "*"}):
raise ResourceIdentityError("Record ownership is unresolved")
linked = str(getattr(row, "session_id", "") or "") if namespace == "documents" else row.id if namespace == "threads" else ""
return OwnedResource(namespace, owner, thread, namespace, row.id, _revision(row, namespace), linked)
def _row(namespace, identifier, owner):
from core.database import SessionLocal, Document, Session, Note
model = {"documents": Document, "threads": Session, "notes": Note}[namespace]
db = SessionLocal()
try:
row = db.query(model).filter(model.id == identifier, model.owner == owner).first()
if row is None or (namespace == "documents" and not row.is_active):
raise ResourceIdentityError("Owned record is missing or inaccessible")
db.expunge(row)
return row
finally:
db.close()
@dataclass(frozen=True)
class AttachmentResource:
record: OwnedResource
file: FilesystemResource
def __post_init__(self):
if not isinstance(self.record, OwnedResource) or not isinstance(self.file, FilesystemResource) or self.file.root.owner != self.record.owner:
raise ValueError("Malformed attachment identity")
def to_dict(self):
return {"record": self.record.to_dict(), "file": self.file.to_dict()}
def _attachment(identifier, owner, thread):
from src.tool_utils import get_upload_handler
handler = get_upload_handler()
if handler is None:
raise ResourceIdentityError("Attachment store is unavailable")
info = handler.resolve_upload(identifier, owner=owner, allow_admin=False)
if not isinstance(info, dict) or info.get("id") != identifier or info.get("owner") != owner:
raise ResourceIdentityError("Attachment ownership is unresolved")
root = FilesystemRoot.seal(handler.upload_dir, scope=FilesystemScope.PRIVATE, owner=owner)
file = FilesystemResource.resolve(root, info.get("path"))
if file.identity.kind != "file":
raise ResourceIdentityError("Attachment must identify a file")
revision = str(info.get("checksum_sha256") or info.get("hash") or info.get("uploaded_at") or "")
if not revision:
raise ResourceIdentityError("Attachment has no observable revision")
return AttachmentResource(OwnedResource("attachments", owner, thread, "attachments", identifier, revision), file)
_VAULT_RECORDS = {}
def _vault_revision(cfg, owner):
from src.agent_runtime.remote_resources import endpoint_identity, configuration_incarnation
if not isinstance(cfg, dict) or cfg.get("owner") != owner:
raise ResourceIdentityError("Vault configuration has no matching explicit owner")
endpoint = endpoint_identity(cfg.get("server_url") or cfg.get("url") or "")
return endpoint + ":" + configuration_incarnation((cfg.get("server_url") or cfg.get("url"), cfg.get("email"), cfg.get("unlocked_at"), cfg.get("session")))
def observe_vault_records(owner, cfg, records):
"""Only a server search response produces record observations, not grants."""
from src.tools.vault import _load_vault_config
from src.agent_runtime.remote_resources import configuration_incarnation
from uuid import UUID
revision = _vault_revision(cfg, owner)
if _vault_revision(_load_vault_config(), owner) != revision or not isinstance(records, list):
raise ResourceIdentityError("Vault producer configuration changed")
observed = {}
for row in records:
if not isinstance(row, dict):
raise ResourceIdentityError("Malformed vault producer record")
try:
identifier = str(UUID(row.get("id", "")))
except (ValueError, TypeError, AttributeError) as error:
raise ResourceIdentityError("Vault producer record has no exact UUID") from error
if identifier in observed or not isinstance(row.get("name", ""), str):
raise ResourceIdentityError("Ambiguous vault producer identity")
observed[identifier] = (row.get("name", ""), configuration_incarnation(json.dumps(row, sort_keys=True, allow_nan=False)))
catalog = _VAULT_RECORDS.setdefault((owner, revision), {})
catalog.update(observed)
def _vault_resource(owner, thread, identifier):
from src.tools.vault import _load_vault_config
revision = _vault_revision(_load_vault_config(), owner)
if identifier != "*":
record = _VAULT_RECORDS.get((owner, revision), {}).get(identifier)
if record is None:
raise ResourceIdentityError("Vault record has no server observation; search the owner vault first")
revision += ":" + record[1]
return OwnedResource("vault", owner, thread, "vault", identifier, revision)
def _vault_selector(owner, selector):
from src.tools.vault import _load_vault_config
revision = _vault_revision(_load_vault_config(), owner)
rows = _VAULT_RECORDS.get((owner, revision), {})
if selector in rows:
return selector
matches = [identifier for identifier, (name, _) in rows.items()
if identifier.startswith(selector) or name == selector]
if not selector or len(matches) != 1:
raise ResourceIdentityError("Vault selector is missing or ambiguous")
return matches[0]
def _memory_record(identifier, owner, thread, *, prefix=False):
from src.ai_interaction import _memory_manager
if _memory_manager is None:
raise ResourceIdentityError("Memory store is unavailable")
rows = [row for row in _memory_manager.load(owner=owner) if isinstance(row, dict)
and row.get("owner") == owner and isinstance(row.get("id"), str)
and (row["id"].startswith(identifier) if prefix else row["id"] == identifier)]
if len(rows) != 1 or not identifier or rows[0].get("timestamp") is None or rows[0]["id"] in {"", "*"}:
raise ResourceIdentityError("Memory selector is missing or ambiguous")
from src.agent_runtime.remote_resources import configuration_incarnation
row = rows[0]
# A same-second edit still changes the private revision without serializing content.
revision = configuration_incarnation(json.dumps(row, sort_keys=True, allow_nan=False))
return OwnedResource("memory", owner, thread, "memory", row["id"], revision)
@dataclass(frozen=True)
class BoundOwnedOperation:
operation: "ExactOperation"
execution_input: str
request_id: str
owner: str
thread_id: str
resources: tuple[OwnedResource, ...]
attachments: tuple[AttachmentResource, ...] = ()
document_id: str = ""
document_version: int | None = None
document_digest: str = ""
def __post_init__(self):
from src.agent_runtime.authority import ExactOperation
if (not isinstance(self.operation, ExactOperation) or not self.owner or not self.thread_id
or any(not isinstance(v, str) for v in (self.execution_input, self.request_id, self.owner, self.thread_id, self.document_id, self.document_digest))
or not isinstance(self.resources, tuple) or not self.resources
or any(not isinstance(r, OwnedResource) or (r.owner, r.thread_id) != (self.owner, self.thread_id) for r in self.resources)
or not isinstance(self.attachments, tuple) or any(not isinstance(a, AttachmentResource) for a in self.attachments)):
raise ValueError("Malformed owned resource operation")
namespace = OWNED_TOOL_NAMESPACES.get(self.operation.tool)
if (any(r.namespace != namespace or r.collection != namespace or (r.record_id != "*" and not r.revision) for r in self.resources)
or tuple(a.record for a in self.attachments) != tuple(r for r in self.resources if r.namespace == "attachments")):
raise ValueError("Malformed owned resource identity")
if self.document_id:
if (type(self.document_version) is not int or self.document_version < 1
or not re.fullmatch(r"[0-9a-f]{64}", self.document_digest)
or not any(r.namespace == "documents" and r.record_id == self.document_id for r in self.resources)):
raise ValueError("Malformed document binding")
elif any(r.namespace == "documents" and r.record_id != "*" for r in self.resources):
raise ValueError("Missing document binding")
def to_dict(self):
from src.agent_runtime.remote_resources import configuration_incarnation
return {"request_id": self.request_id, "owner": self.owner, "thread_id": self.thread_id,
"tool": self.operation.transport_tool,
"execution_input_digest": configuration_incarnation(self.execution_input),
"resources": [r.to_dict() for r in self.resources],
"attachments": [a.to_dict() for a in self.attachments],
"document_id": self.document_id, "document_version": self.document_version,
"document_digest": self.document_digest}
def validate(self):
for resource in self.resources:
if resource.record_id == "*":
if resource.namespace == "vault" and _vault_resource(self.owner, self.thread_id, "*") != resource:
raise ResourceIdentityError("Vault identity changed")
continue
if resource.namespace == "attachments":
expected = next((a for a in self.attachments if a.record == resource), None)
if expected is None or _attachment(resource.record_id, self.owner, self.thread_id) != expected:
raise ResourceIdentityError("Attachment identity changed")
expected.file.validate()
elif resource.namespace == "vault":
if _vault_resource(self.owner, self.thread_id, resource.record_id) != resource:
raise ResourceIdentityError("Vault identity changed")
elif resource.namespace == "memory":
if _memory_record(resource.record_id, self.owner, self.thread_id) != resource:
raise ResourceIdentityError("Memory identity changed")
elif _record(resource.namespace, self.owner, self.thread_id,
_row(resource.namespace, resource.record_id, self.owner)) != resource:
raise ResourceIdentityError("Owned record identity changed")
def needs_owned_binding(operation):
if operation.tool == "app_api":
# The generic internal-token bridge must not bypass migrated owner
# namespaces. Dedicated tools carry their typed record operations.
from urllib.parse import unquote, urlsplit
import posixpath
args = _args(operation.input)
path = args.get("path", "")
if not isinstance(path, str):
raise ResourceIdentityError("Malformed internal resource selector")
for _ in range(4):
decoded = unquote(path)
if decoded == path:
break
path = decoded
if "%" in path or "\\" in path:
raise ResourceIdentityError("Unresolved internal resource selector")
path = posixpath.normpath(urlsplit(path).path)
private = {"document", "documents", "session", "sessions", "history", "chat", "chats",
"notes", "memory", "vault", "upload", "uploads", "attachments",
"shell", "model", "cookbook"}
segments = path.strip("/").split("/")
if len(segments) >= 3 and segments[:3] == ["api", "codex", "cookbook"]:
raise ResourceIdentityError("Cookbook wrappers require a dedicated resource-bound tool")
if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private:
raise ResourceIdentityError("Owned records require a dedicated resource-bound tool")
return False
if operation.tool not in OWNED_TOOL_NAMESPACES:
return False
if operation.tool in {"extract_text", "inspect_media", "transcribe_media"}:
return "odysseus://attachment/" in operation.input
return True
def resolve_owned_operation(operation, *, owner, thread_id, request_id="", document_id=None):
if not owner or not thread_id:
raise ResourceIdentityError("Owned operations require an owner and invocation thread")
if document_id is not None and (not isinstance(document_id, str) or not document_id.strip()):
raise ResourceIdentityError("Malformed server document selector")
namespace = OWNED_TOOL_NAMESPACES[operation.tool]
args = _args(operation.input) if operation.tool not in {"create_document", "edit_document", "update_document", "suggest_document", "send_to_session", "create_session", "list_sessions", "search_chats", "manage_session", "manage_memory"} else {}
execution_input = operation.input
resources = []
attachments = []
doc_id = ""
doc_version = None
doc_digest = ""
collection = lambda: OwnedResource(namespace, owner, thread_id, namespace, "*")
if namespace == "documents":
action = str(args.get("action") or "list").strip().lower()
if operation.tool == "create_document" or (operation.tool == "manage_documents" and action in {"list", "search", "find", "tidy"}):
resources.append(collection())
else:
identifier = _selector(args, ("document_id", "id", "uid")) or document_id or ""
if identifier in {"active", "current"}:
if not document_id or document_id in {"active", "current", "latest"}:
raise ResourceIdentityError("Active document selector is unresolved")
identifier = document_id
if not identifier and operation.tool == "manage_documents" and action != "delete":
raise ResourceIdentityError("Document selector is required")
if not identifier or identifier == "latest":
from core.database import SessionLocal, Document
db = SessionLocal()
try:
row = db.query(Document).filter(Document.owner == owner, Document.is_active == True).order_by(Document.updated_at.desc(), Document.id).first()
identifier = row.id if row is not None else ""
finally:
db.close()
if not identifier:
raise ResourceIdentityError("Document selector is unresolved")
row = _row(namespace, identifier, owner)
resources.append(_record(namespace, owner, thread_id, row))
doc_id, doc_version = row.id, row.version_count
from src.tool_approvals import document_content_digest
doc_digest = document_content_digest(row.current_content)
if operation.tool == "manage_documents":
for key in ("id", "uid"):
args.pop(key, None)
args["document_id"] = doc_id
execution_input = json.dumps(args, sort_keys=True)
elif namespace == "threads":
if operation.tool in {"list_sessions", "search_chats", "create_session"}:
resources.append(collection())
else:
if operation.tool == "send_to_session":
identifier, _, message = operation.input.partition("\n")
identifier = identifier.strip()
else:
if operation.input.lstrip().startswith("{"):
args = _args(operation.input)
else:
lines = operation.input.strip().split("\n", 2)
args = {"action": lines[0], "session_id": lines[1] if len(lines) > 1 else ""}
if len(lines) > 2:
args["value"] = lines[2]
if args.get("action") == "list":
resources.append(collection())
identifier = _selector(args, ("session_id", "session", "id"))
if not resources:
identifier = thread_id if identifier == "current" else identifier
row = _row(namespace, identifier, owner)
resources.append(_record(namespace, owner, thread_id, row))
if operation.tool == "send_to_session":
execution_input = row.id + "\n" + message
else:
args.pop("id", None)
args.pop("session", None)
args["session_id"] = row.id
execution_input = json.dumps(args, sort_keys=True)
elif namespace == "notes":
action = str(args.get("action") or "").strip().lower().replace("-", "_")
if action in {"list", "search", "find", "add", "create", "new", "save", "remind"}:
resources.append(collection())
else:
identifier = _selector(args, ("id", "note_id", "noteId"))
from core.database import SessionLocal, Note
db = SessionLocal()
try:
q = db.query(Note).filter(Note.owner == owner)
if identifier:
rows = q.filter(Note.id.startswith(identifier, autoescape=True)).limit(2).all()
else:
title = _selector(args, ("title", "query", "text"))
rows = q.filter(Note.title == title).limit(2).all() if title else []
if len(rows) != 1:
raise ResourceIdentityError("Note selector is missing or ambiguous")
identifier = rows[0].id
finally:
db.close()
row = _row(namespace, identifier, owner)
resources.append(_record(namespace, owner, thread_id, row))
args.pop("note_id", None)
args.pop("noteId", None)
args["id"] = identifier
execution_input = json.dumps(args, sort_keys=True)
elif namespace == "attachments":
selector = args.get("path")
match = re.fullmatch(r"odysseus://attachment/([A-Za-z0-9_-]+(?:\.[A-Za-z0-9]+)?)", selector or "")
if match is None:
raise ResourceIdentityError("Malformed attachment selector")
attachment = _attachment(match[1], owner, thread_id)
resources.append(attachment.record)
attachments.append(attachment)
elif namespace == "memory":
from src.ai_interaction import _manage_memory_lines
lines = _manage_memory_lines(operation.input)
if not lines:
raise ResourceIdentityError("Memory action is unresolved")
action = lines[0].strip().lower()
if action in {"list", "search", "add"}:
resources.append(collection())
elif action in {"edit", "delete"} and len(lines) >= 2:
resource = _memory_record(lines[1].strip(), owner, thread_id, prefix=True)
resources.append(resource)
lines[1] = resource.record_id
execution_input = "\n".join(lines)
else:
raise ResourceIdentityError("Memory operation is unresolved")
elif namespace == "vault":
identifier = "*"
if operation.tool == "vault_get":
identifier = _vault_selector(owner, _selector(args, ("item_id",)))
args["item_id"] = identifier
execution_input = json.dumps(args, sort_keys=True)
resources.append(_vault_resource(owner, thread_id, identifier))
bound = BoundOwnedOperation(operation, execution_input, request_id, owner, thread_id,
tuple(resources), tuple(attachments), doc_id, doc_version, doc_digest)
bound.validate()
return bound
def admit_owned_operation(authority, operation, *, document_id=None, approved=None, exact_admission=False):
bound = (approved if approved is not None else resolve_owned_operation(operation, owner=authority.owner,
thread_id=authority.session_id, request_id=authority.request_id, document_id=document_id))
if (not isinstance(bound, BoundOwnedOperation) or bound.operation != operation
or (bound.owner, bound.thread_id) != (authority.owner, authority.session_id)
or (bound.request_id and bound.request_id != authority.request_id)):
raise ResourceIdentityError("Owned operation approval binding changed")
if not all(any(scope.permits(r) for scope in authority.owned_scopes) for r in bound.resources):
if not (approved is not None and exact_admission and not authority.inherited and not authority.owned_scopes):
raise ResourceIdentityError("Owned resource exceeds parent/request scope")
bound.validate()
return bound
_ACTIVE = ContextVar("owned_resource_operation", default=None)
def active_owned_operation():
return _ACTIVE.get()
@contextmanager
def bind_owned_operation(operation):
if operation is not None:
if not isinstance(operation, BoundOwnedOperation):
raise TypeError("Owned operation must be server-owned")
operation.validate()
token = _ACTIVE.set(operation)
try:
yield operation
finally:
_ACTIVE.reset(token)
def bound_attachment_path(owner, selector):
operation = active_owned_operation()
if operation is None:
return None
operation.validate()
for attachment in operation.attachments:
if owner == operation.owner and selector == "odysseus://attachment/" + attachment.record.record_id:
return attachment.file.path
raise ResourceIdentityError("Attachment is not declared by this operation")
+536
View File
@@ -0,0 +1,536 @@
"""Process/job admission. Lifecycle mechanics remain in process_lifecycle.
Only trusted launch producers publish observations. Persisted legacy records
are never enrolled by looking at their PID. Receipts identify boundaries, not
application authority. Resource snapshots contain no command or environment.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
import hashlib
import json
import os
from pathlib import Path
import re
import threading
from uuid import uuid4
from core.atomic_io import store_transaction
from src.agent_runtime.resources import (
BackgroundJobResource, NativeBackendResource, ProcessLaunchResource,
ProcessLaunchScope, ProcessResource, ResourceIdentityError,
)
from src.constants import PROCESS_RESOURCES_DIR
_LAUNCH_DIR = Path(PROCESS_RESOURCES_DIR)
LAUNCH_TOOLS = frozenset({"bash", "python"})
JOB_TOOL = "manage_bg_jobs"
_ACTIVE = ContextVar("process_resource_operation", default=None)
def digest(value):
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _thread(authority):
return authority.session_id or "request:" + authority.request_id
def launch_path(generation):
if not isinstance(generation, str) or not re.fullmatch(r"[a-f0-9]{32}", generation):
raise ResourceIdentityError("Malformed launch generation")
return _LAUNCH_DIR / (generation + ".json")
def seal_launch_scopes(authority):
return tuple(seal_launch_scope(backend, root)
for backend in authority.backend_resources
if isinstance(backend, NativeBackendResource) and backend.tool_id in LAUNCH_TOOLS
for root in authority.resource_roots)
def seal_launch_scope(backend, root, *, env=None):
from src.agent_tools.subprocess_tools import _owned_spec
from src.tool_execution import _agent_subprocess_env
from src.agent_runtime.resources import PathObservation, FileObjectIdentity
env = _agent_subprocess_env() if env is None else env
extra = tuple(Path(p).resolve().as_posix() for p in str(env.get("ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES", "")).split(os.pathsep)
if p and os.path.isabs(p)) if backend.tool_id == "python" else ()
spec = _owned_spec(root.path, env, 3600, extra)
return ProcessLaunchScope(backend, root, spec.required,
tuple(PathObservation(str(Path(p).resolve()), FileObjectIdentity.observe(Path(p).resolve())) for p in spec.readonly_extra),
spec.network, spec.wall_clock_s)
def validate_launch_spec(launch, spec):
scope = launch.scope
scope.validate()
if (spec.workspace != scope.root.path or spec.required != scope.required or spec.network != scope.network
or spec.wall_clock_s > scope.max_runtime_s or spec.writable_extra
or tuple(spec.readonly_extra) != tuple(r.path for r in scope.runtime_roots)):
raise ResourceIdentityError("Producer launch boundary exceeds the sealed reservation")
def job_from_record(record):
if not isinstance(record, dict):
raise ResourceIdentityError("Missing authoritative job")
try:
resource = BackgroundJobResource.from_dict(record["resource_identity"])
if (resource.namespace != "native:bg_jobs"
or (record["id"], record["session_id"], record["containment_id"])
!= (resource.job_id, resource.thread_id, resource.containment_id)):
raise ValueError("Job linkage changed")
supervisor = next(p for p in resource.processes if p.role == "supervisor")
if (record.get("pid"), record.get("start_token"), record.get("pgid")) != (
supervisor.identity.pid, supervisor.identity.start_token, supervisor.identity.pgid):
raise ValueError("Supervisor linkage changed")
launch = ProcessLaunchResource.from_dict(record["launch_resource"])
if (launch.generation, launch.owner, launch.request_id, launch.thread_id) != (
resource.generation, resource.owner, resource.request_id, resource.thread_id):
raise ValueError("Launch/job linkage changed")
return resource
except (ValueError, TypeError, KeyError, StopIteration, AttributeError) as error:
raise ResourceIdentityError("Malformed or unowned background job") from error
def validate_job(resource, *, mutation=False):
try:
return _validate_job(resource, mutation=mutation)
except ResourceIdentityError:
raise
except (ValueError, TypeError, OSError, KeyError, AttributeError) as error:
raise ResourceIdentityError("Background job linkage is missing or malformed") from error
def validate_job_receipt(resource, receipt):
from src import containment
supervisor = resource.processes[0]
if (not isinstance(receipt, dict) or receipt.get("id") != resource.containment_id
or receipt.get("launch_generation") != resource.generation
or receipt.get("owner") != "bg:" + resource.thread_id
or (receipt.get("supervisor_pid"), receipt.get("supervisor_token")) !=
(supervisor.identity.pid, supervisor.identity.start_token)
or receipt.get("mechanism") not in {m.name for m in containment.MECHANISMS}
or receipt.get("external") is True):
raise ResourceIdentityError("Containment receipt linkage changed")
def _validate_job(resource, *, mutation=False):
from src import bg_jobs, containment
if not isinstance(resource, BackgroundJobResource):
raise ResourceIdentityError("Missing exact background job identity")
record = bg_jobs.peek(resource.job_id)
if job_from_record(record) != resource:
raise ResourceIdentityError("Background job resource changed")
if record.get("status") not in {"running", "done", "failed"}:
raise ResourceIdentityError("Unknown job lifecycle")
launch = ProcessLaunchResource.from_dict(record["launch_resource"])
persisted = json.loads(launch_path(resource.generation).read_text())
if (persisted.get("launch") != launch.to_dict()
or persisted.get("job") != resource.to_dict()
or persisted.get("containment_id") != resource.containment_id):
raise ResourceIdentityError("Job/launch publication changed")
sidecar = json.loads((bg_jobs._JOBS_DIR / (resource.job_id + ".authority.json")).read_text())
origin = persisted.get("authority", {})
if (sidecar.get("job") != resource.to_dict() or sidecar.get("authority") != origin
or (origin.get("owner"), origin.get("request_id"), origin.get("session_id")) !=
(resource.owner, resource.request_id, resource.thread_id)):
raise ResourceIdentityError("Background authority linkage changed")
receipt = containment._load_records().get(resource.containment_id)
# Lifecycle receipts have a shorter retention than job results. A finished
# exact generation needs only its durable application linkage for history;
# it never regains signalling authority when its receipt has been pruned.
historical = record.get("status") in {"done", "failed"}
if receipt is None and not historical:
raise ResourceIdentityError("Missing active containment receipt")
if receipt is not None:
validate_job_receipt(resource, receipt)
if record.get("status") == "running":
for process in resource.processes:
try:
process.validate()
except ResourceIdentityError:
# Publication can precede store reconciliation. That exact
# completed generation is readable, but never signallable.
if mutation or not Path(record["exit_path"]).is_file():
raise
report = json.loads(Path(record["result_path"]).read_text())
if report.get("resource_identity") != resource.to_dict() or report.get("containment", {}).get("id") != resource.containment_id:
raise ResourceIdentityError("Historical result linkage changed")
# A completed record is readable history, never a new process observation.
return record
def seal_jobs(authority):
if not any(g.tool == JOB_TOOL for g in authority.grants) or not authority.session_id:
return ()
from src import bg_jobs
admitted = []
for record in bg_jobs._load().values():
try:
resource = job_from_record(record)
if (resource.owner, resource.thread_id) == (authority.owner, authority.session_id):
validate_job(resource)
admitted.append(resource)
except (ValueError, TypeError, OSError, RuntimeError):
continue
return tuple(admitted)
def intersect_observed(parent, child, validate):
# Validate both sides before equality. Seeing a replacement cannot renew a
# stale parent observation, even when the child has just sealed it.
# Stale/dead/unverifiable resources on EITHER side are conservatively
# excluded from the resulting authority — a normal process exit must not
# crash child authority intersection.
live_parent = []
for resource in parent:
try:
validate(resource)
live_parent.append(resource)
except ResourceIdentityError:
continue
live_child = set()
for resource in child:
try:
validate(resource)
live_child.add(resource)
except ResourceIdentityError:
continue
return tuple(resource for resource in live_parent if resource in live_child)
def intersect_launch_scopes(parent, child):
from src.agent_runtime.resources import FilesystemResource
for scope in (*parent, *child):
scope.validate()
narrowed = []
for left in parent:
for right in child:
if (left.backend != right.backend or not left.required <= right.required
or right.max_runtime_s > left.max_runtime_s
or not set(right.runtime_roots) <= set(left.runtime_roots)
or (left.network == "none" and right.network != "none")):
continue
if Path(right.root.path).is_relative_to(left.root.path):
observation = FilesystemResource.resolve(left.root, right.root.path)
if observation.identity == right.root.identity:
narrowed.append(right)
return tuple(dict.fromkeys(narrowed))
class _LaunchUse:
"""Non-persisted one-use producer reservation, shared by approval copies."""
def __init__(self):
self.used = False
self.lock = threading.Lock()
def claim(self):
with self.lock:
if self.used:
raise ResourceIdentityError("Launch reservation has already been used")
self.used = True
@dataclass(frozen=True)
class BoundProcessOperation:
operation: object
request_id: str
owner: str
thread_id: str
launch: ProcessLaunchResource | None = None
jobs: tuple[BackgroundJobResource, ...] = ()
processes: tuple[ProcessResource, ...] = ()
exact_approval: object | None = None
_launch_use: _LaunchUse = field(default_factory=_LaunchUse, compare=False, repr=False)
def __post_init__(self):
from src.agent_runtime.authority import ExactOperation
if (not isinstance(self.operation, ExactOperation) or not isinstance(self.request_id, str) or not self.request_id
or not isinstance(self.owner, str) or not isinstance(self.thread_id, str) or not self.thread_id
or (self.launch is not None and not isinstance(self.launch, ProcessLaunchResource))
or not isinstance(self.jobs, tuple) or any(not isinstance(j, BackgroundJobResource) for j in self.jobs)
or not isinstance(self.processes, tuple) or any(not isinstance(p, ProcessResource) for p in self.processes)):
raise ValueError("Malformed process-bound operation")
if self.launch is not None and (
(self.launch.owner, self.launch.request_id, self.launch.thread_id, self.launch.tool, self.launch.input_digest)
!= (self.owner, self.request_id, self.thread_id, self.operation.tool, digest(self.operation.input))):
raise ValueError("Launch operation/application binding changed")
if any((r.owner, r.thread_id) != (self.owner, self.thread_id) for r in (*self.jobs, *self.processes)):
raise ValueError("Observed resource application binding changed")
def validate(self):
if self.launch is not None:
self.launch.validate()
if self._launch_use.used:
raise ResourceIdentityError("Launch reservation has already been used")
for job in self.jobs:
validate_job(job, mutation=self.operation.action in {"kill", "stop", "cancel", "terminate", "ack"})
for process in self.processes:
process.validate()
def to_dict(self):
return {"tool": self.operation.transport_tool, "input_digest": digest(self.operation.input),
"request_id": self.request_id, "owner": self.owner, "thread_id": self.thread_id,
"launch": self.launch.to_dict() if self.launch else None,
"jobs": [r.to_dict() for r in self.jobs], "processes": [r.to_dict() for r in self.processes]}
def needs_process_binding(operation, backend):
return isinstance(backend, NativeBackendResource) and operation.tool in LAUNCH_TOOLS | {JOB_TOOL}
def resolve_process_operation(authority, operation, backend, *, approved=None, exact_admission=False):
if not needs_process_binding(operation, backend):
raise ResourceIdentityError("No native process adapter for this backend")
if approved is not None:
if (approved.operation != operation or (approved.request_id, approved.owner, approved.thread_id)
!= (authority.request_id, authority.owner, _thread(authority))):
raise ResourceIdentityError("Approved process operation binding changed")
bound = approved
elif operation.tool in LAUNCH_TOOLS:
scopes = [s for s in authority.launch_scopes if s.backend == backend]
if len(scopes) != 1:
raise ResourceIdentityError("Process creation requires a sealed workspace and launch scope")
launch = ProcessLaunchResource("native:containment", authority.owner, authority.request_id,
_thread(authority), uuid4().hex, operation.tool, digest(operation.input), scopes[0],
digest(json.dumps(authority.to_dict(), sort_keys=True)))
bound = BoundProcessOperation(operation, authority.request_id, authority.owner, _thread(authority), launch)
else:
try:
args = json.loads(operation.input)
action = str(args.get("action", "list")).strip().lower()
job_id = args.get("job_id", args.get("id", ""))
except (ValueError, TypeError, AttributeError) as error:
raise ResourceIdentityError("Malformed job operation") from error
if action in {"list", "ls", "jobs"}:
jobs = authority.job_resources
elif action in {"output", "get", "read", "tail", "status", "show", "kill", "stop", "cancel", "terminate", "ack"}:
if not isinstance(job_id, str) or not job_id:
raise ResourceIdentityError("An exact job selector is required")
jobs = tuple(r for r in authority.job_resources if r.job_id == job_id)
if len(jobs) != 1:
raise ResourceIdentityError("Job is outside admitted resource scope")
else:
raise ResourceIdentityError("Unsupported job operation")
bound = BoundProcessOperation(operation, authority.request_id, authority.owner, _thread(authority), jobs=jobs)
if not (approved is not None and exact_admission and not authority.inherited):
if bound.launch is not None and bound.launch.scope not in authority.launch_scopes:
raise ResourceIdentityError("Launch exceeds inherited creation scope")
if any(j not in authority.job_resources for j in bound.jobs) or any(p not in authority.process_resources for p in bound.processes):
raise ResourceIdentityError("Process/job exceeds inherited resource scope")
if bound.launch is not None and bound.launch.scope.backend != backend:
raise ResourceIdentityError("Launch backend changed")
bound.validate()
return bound
def active_process_operation():
return _ACTIVE.get()
@contextmanager
def bind_process_operation(operation):
if operation is not None and not isinstance(operation, BoundProcessOperation):
raise TypeError("Process operation must be server-owned")
if operation is not None:
operation.validate()
if operation.launch is not None:
# One fresh authoritative scan for each execution binding. Resolution
# and producer entry retain cheap exact identity checks; no scan is
# reused across independent bindings or persisted in an approval.
guard_launch_workspace(operation.launch.scope.root)
token = _ACTIVE.set(operation)
try:
yield operation
finally:
_ACTIVE.reset(token)
def require_launch(tool, *, cwd, content=None):
bound = active_process_operation()
if bound is None or bound.launch is None or bound.operation.tool != tool:
raise ResourceIdentityError("Native process producer has no bound launch reservation")
require_process_admission(bound)
bound.validate()
if Path(cwd).resolve() != Path(bound.launch.scope.root.path):
raise ResourceIdentityError("Launch workspace changed")
if content is not None and content.strip() != bound.operation.input.strip():
raise ResourceIdentityError("Launch operation changed at producer entry")
return bound.launch
def require_process_admission(bound):
from src.agent_runtime.authority import active_request_authority
authority = active_request_authority()
if authority is None or (authority.owner, authority.request_id, _thread(authority)) != (
bound.owner, bound.request_id, bound.thread_id):
raise ResourceIdentityError("Producer application authority changed")
if not authority.permits(bound.operation):
approval = bound.exact_approval
if (authority.inherited or approval is None or not approval._claimed
or approval.pending.process_operation is None
or approval.pending.process_operation.to_dict() != bound.to_dict()):
raise ResourceIdentityError("Producer operation has no request admission or exact claim")
def guard_launch_workspace(root):
"""Reject a boundary containing execution control state or its aliases.
These are pathname/inode observations, not an atomic kernel access policy.
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_plane_snapshot
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):
raise ResourceIdentityError("Launch boundary contains server control state")
def unresolved(error):
raise ResourceIdentityError("Launch workspace cannot be inspected") from error
snapshot = None
for directory, dirs, files in os.walk(base, followlinks=False, onerror=unresolved):
for name in (*dirs, *files):
path = Path(directory) / name
info = path.lstat()
if path.is_symlink() or info.st_nlink > 1:
if snapshot is None:
snapshot = _control_plane_snapshot()
if _control_plane_path(str(path.resolve()), snapshot=snapshot):
raise ResourceIdentityError("Launch boundary aliases server control state")
@store_transaction(lambda: _LAUNCH_DIR / "publication")
def publish_launch(launch, authority, containment_id, *, job=None, processes=()):
from core.atomic_io import atomic_write_json
launch.validate()
if authority is None or (authority.owner, authority.request_id) != (launch.owner, launch.request_id):
raise ResourceIdentityError("Launch authority linkage changed")
path = launch_path(launch.generation)
if path.exists():
raise ResourceIdentityError("Launch reservation has already been used")
bound = active_process_operation()
if bound is not None:
if bound.launch != launch:
raise ResourceIdentityError("Publication differs from the bound launch")
bound._launch_use.claim()
atomic_write_json(path, {"launch": launch.to_dict(), "authority": authority.to_dict(),
"containment_id": containment_id, "job": job.to_dict() if job else None,
"processes": [p.to_dict() for p in processes]})
@store_transaction(lambda: _LAUNCH_DIR / "publication")
def retire_launch(launch, containment_id, *, job=None):
"""Remove only this exact producer publication; never a replacement.
Callers establish the lifetime end (verified foreground teardown, or exact
background history pruning). Missing/malformed/replaced state is retained.
One-use launch reservations live in the bound operation, not this file.
"""
path = launch_path(launch.generation)
try:
published = json.loads(path.read_text())
except FileNotFoundError:
return False
if (not isinstance(published, dict)
or published.get("launch") != launch.to_dict()
or published.get("containment_id") != containment_id
or published.get("job") != (job.to_dict() if job else None)):
return False
path.unlink()
return True
@store_transaction(lambda: _LAUNCH_DIR / "publication")
def prune_foreground_publications():
"""Startup-only recovery: retire foreground generations without a caller.
A dead/replaced manager cannot resume attachment. A missing receipt also
makes attachment impossible; publication cannot reconstruct that receipt.
Its process tree still belongs to containment recovery; deleting a
publication never signals or asserts tree death. Live/unverifiable managers
retain publication even after child teardown: attachment may still need it.
Background history stays intact.
"""
from src import containment
from src import process_ownership
try:
receipts = json.loads(containment._store_path().read_text())
except FileNotFoundError:
receipts = {}
except (OSError, ValueError):
return 0 # Unreadable state is not evidence that consumers are gone.
if not isinstance(receipts, dict) or any(not isinstance(r, dict) for r in receipts.values()):
return 0
retired = 0
for path in _LAUNCH_DIR.glob("*.json"):
try:
published = json.loads(path.read_text())
launch = ProcessLaunchResource.from_dict(published["launch"])
receipt = receipts.get(published["containment_id"])
abandoned = (receipt is not None
and type(receipt.get("manager_pid")) is int and receipt["manager_pid"] > 0
and isinstance(receipt.get("manager_token"), str) and bool(receipt["manager_token"])
and process_ownership.verify(receipt["manager_pid"], receipt["manager_token"]) in {
process_ownership.GONE, process_ownership.FOREIGN})
if (published.get("job") is None and path == launch_path(launch.generation)
and (receipt is None or (
receipt.get("launch_generation") == launch.generation
and receipt.get("id") == published["containment_id"]
and abandoned))):
# Already under the publication lock; no nested file lock.
path.unlink()
retired += 1
except (ValueError, TypeError, KeyError, OSError):
continue
return retired
@store_transaction(lambda: _LAUNCH_DIR / "publication")
def attach_containment_processes(launch, containment_id):
"""Attach producer-frozen lifecycle records; never capture a current PID."""
from src import containment
from src.process_lifecycle import ProcessIdentity
record = containment._load_records().get(containment_id, {})
path = launch_path(launch.generation)
published = json.loads(path.read_text())
if (published.get("launch") != launch.to_dict() or published.get("containment_id") != containment_id
or record.get("id") != containment_id or record.get("launch_generation") != launch.generation
or record.get("workspace") != launch.scope.root.path):
raise ResourceIdentityError("Launch/receipt changed during publication")
processes = []
for role, pid_key, token_key, group_key in (("leader", "pid", "start_token", "pgid"),
("namespace_init", "namespace_pid", "namespace_start_token", None)):
pid = record.get(pid_key)
token = record.get(token_key)
if not pid or not token:
continue
processes.append(ProcessResource("native:containment", launch.owner, launch.request_id,
launch.thread_id, ProcessIdentity(pid, token, record.get(group_key) if group_key else None),
role, "", containment_id))
from core.atomic_io import atomic_write_json
published["processes"] = [p.to_dict() for p in processes]
atomic_write_json(path, published)
def expected_job(job_id, *, action):
bound = active_process_operation()
if bound is None or bound.operation.tool != JOB_TOOL:
raise ResourceIdentityError("Job producer has no bound operation")
require_process_admission(bound)
# The caller's actual action must agree with the normalized proposal.
args = json.loads(bound.operation.input)
proposed = str(args.get("action", "list")).strip().lower()
if action != proposed:
raise ResourceIdentityError("Job action changed at producer entry")
target = next((j for j in bound.jobs if j.job_id == job_id), None)
if target is None:
raise ResourceIdentityError("Job selector is outside the bound operation")
validate_job(target, mutation=action in {"kill", "stop", "cancel", "terminate", "ack"})
return target
+238
View File
@@ -0,0 +1,238 @@
"""Backend resolution and pinning, independent of transport and lifecycle.
Connection/configuration incarnations here are not process identities. Backend
snapshots are captured by trusted admission; discovery never supplies a grant.
"""
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from urllib.parse import urlsplit, urlunsplit
from uuid import uuid4
import hashlib
import hmac
import secrets
import json
from src.agent_runtime.resources import ExternalResource, NativeBackendResource, ResourceIdentityError
def endpoint_identity(url):
"""Credential-free origin. Paths may themselves contain access tokens."""
if not isinstance(url, str) or any(c in url for c in ("\0", "\n", "\r")):
raise ValueError("Malformed resource endpoint")
parsed = urlsplit(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("Resource endpoint requires an HTTP origin")
host = parsed.hostname.lower()
if ":" in host:
host = "[" + host + "]"
port = parsed.port
if port and port != (443 if parsed.scheme == "https" else 80):
host += f":{port}"
return urlunsplit((parsed.scheme, host, "", "", ""))
_CLIENT_ENDPOINTS = {}
_CONFIG_KEY = secrets.token_bytes(32)
def configuration_incarnation(value):
"""Opaque in-process configuration identity, including secret URL changes."""
return hmac.new(_CONFIG_KEY, str(value).encode(), hashlib.sha256).hexdigest()
def _client_resource(tool, context, *, admission):
from src.tool_execution import _client_bridge, _tui_host_bridge_patch_url, _ROUTED_BRIDGE_TOOLS
bridge = _client_bridge(context)
target = _tui_host_bridge_patch_url(context) if tool == "apply_patch" else None
if target is not None:
url = target[0]
elif bridge is not None and (tool in _ROUTED_BRIDGE_TOOLS or tool == "host_shell"):
url = bridge["url"]
else:
return None
endpoint = endpoint_identity(url)
# Never cache credentials. A request cannot create a registry entry during
# dispatch; only trusted server admission may register an endpoint.
key = configuration_incarnation((url, bridge.get("token") if bridge else None))
if admission:
_CLIENT_ENDPOINTS.setdefault(key, uuid4().hex)
incarnation = _CLIENT_ENDPOINTS.get(key)
if incarnation is None:
raise ResourceIdentityError("External bridge endpoint is not sealed")
return ExternalResource("client_bridge", endpoint, "tui", tool, incarnation)
def http_bridge_resource(tool, context, *, admission=False):
config = context.get("external_execution_bridge") if isinstance(context, dict) else None
if not isinstance(config, dict) or tool not in (config.get("supported_tools") or ()):
return None
url, token = config.get("url"), config.get("token")
if not isinstance(token, str) or not token:
raise ResourceIdentityError("External HTTP bridge has no server configuration")
epoch = configuration_incarnation((url, token, tuple(sorted(config["supported_tools"]))))
if admission:
_CLIENT_ENDPOINTS.setdefault(epoch, epoch)
if epoch not in _CLIENT_ENDPOINTS:
raise ResourceIdentityError("External HTTP bridge configuration is not sealed")
return ExternalResource("execution_bridge", endpoint_identity(url), "request_local_http", tool, epoch)
def integration_resource(config):
if not isinstance(config, dict) or not config.get("enabled", True) or not isinstance(config.get("id"), str) or not config["id"]:
raise ResourceIdentityError("Integration identity is unresolved")
endpoint = endpoint_identity(config.get("base_url"))
epoch = configuration_incarnation(json.dumps(config, sort_keys=True, allow_nan=False))
return ExternalResource("integration", endpoint, config["id"], "api_call", epoch)
def api_arguments(content):
if content.lstrip().startswith("{"):
args = json.loads(content)
else:
lines = content.strip().split("\n", 2)
args = {"integration": lines[0].strip()}
if len(lines) > 1:
method, _, path = lines[1].strip().partition(" ")
args.update(method=method, path=path or "/")
if len(lines) > 2:
args["body"] = json.loads(lines[2])
selector = args.get("integration")
if not isinstance(selector, str) or not selector.strip():
raise ResourceIdentityError("Integration selector is unresolved")
return args
def resolve_backend(tool, *, context=None, admission=False, content="", owner=None):
from src.tool_execution import get_active_execution_bridge, get_mcp_manager, _MCP_TOOL_MAP
from src.tool_security import BUILTIN_EMAIL_TOOLS
bridge = get_active_execution_bridge()
if bridge is not None and tool in bridge.supported_tools:
return bridge.resource_identity(tool)
configured_bridge = http_bridge_resource(tool, context, admission=admission)
if configured_bridge is not None:
return configured_bridge
client = _client_resource(tool, context, admission=admission)
if client is not None:
return client
if tool == "api_call":
from src.integrations import load_integrations
selector = api_arguments(content)["integration"]
rows = [row for row in load_integrations() if row.get("id") == selector
or str(row.get("name", "")).casefold() == selector.casefold()]
if len(rows) != 1:
raise ResourceIdentityError("Integration alias is missing or ambiguous")
return integration_resource(rows[0])
qualified = tool
required = tool.startswith("mcp__") or tool in BUILTIN_EMAIL_TOOLS
if tool in BUILTIN_EMAIL_TOOLS:
qualified = "mcp__email__" + tool
elif tool in _MCP_TOOL_MAP and tool not in {"read_file", "write_file", "generate_image"}:
server, name = _MCP_TOOL_MAP[tool]
qualified = f"mcp__{server}__{name}"
if qualified.startswith("mcp__"):
manager = get_mcp_manager()
identity = manager.resource_identity(qualified) if manager is not None else None
if isinstance(identity, ExternalResource):
if identity.owner and owner != identity.owner:
raise ResourceIdentityError("MCP backend belongs to another owner")
return identity
if required:
raise ResourceIdentityError("MCP backend/tool identity is unresolved")
if tool == "host_shell":
raise ResourceIdentityError("Host-shell backend identity is unresolved")
return NativeBackendResource(tool)
def seal_backends(tools, *, context=None, owner=None):
result = []
for tool in tools:
try:
if tool == "api_call":
# A generic API operation grant does not select an integration.
# Trusted admission must supply its explicit backend identity,
# or a user can approve one fully sealed exact operation.
continue
result.append(resolve_backend(tool, context=context, admission=True, owner=owner))
except (ValueError, TypeError, AttributeError):
continue
return tuple(dict.fromkeys(result))
@dataclass(frozen=True)
class BoundBackendOperation:
resource: ExternalResource | NativeBackendResource
request_id: str
owner: str
session_id: str
transport_tool: str
exact_input: str
def __post_init__(self):
if not isinstance(self.resource, (ExternalResource, NativeBackendResource)):
raise ValueError("Malformed bound backend operation")
if any(not isinstance(v, str) for v in (self.request_id, self.owner, self.session_id, self.transport_tool, self.exact_input)):
raise ValueError("Malformed backend operation binding")
def to_dict(self):
# Exact arguments/selectors are already digest-bound by the approval's
# original content. Keep credentials out of the identity serializer.
return {"resource": self.resource.to_dict(), "request_id": self.request_id,
"owner": self.owner, "session_id": self.session_id, "tool": self.transport_tool,
"input_digest": configuration_incarnation(self.exact_input)}
def validate(self, context=None):
current = resolve_backend(self.transport_tool, context=context, content=self.exact_input, owner=self.owner)
if current != self.resource:
# A pinned native backend remains native when MCP availability
# changes. It cannot be upgraded to an external backend.
if isinstance(self.resource, NativeBackendResource) and isinstance(current, ExternalResource) and current.namespace == "mcp":
return
raise ResourceIdentityError("Backend resource identity changed")
def bind_backend_for_operation(authority, operation, *, context=None, approved=None, exact_admission=False):
current = resolve_backend(operation.transport_tool, context=context, content=operation.input, owner=authority.owner)
native = NativeBackendResource(operation.transport_tool)
if approved is not None:
if (not isinstance(approved, BoundBackendOperation)
or (approved.request_id and approved.request_id != authority.request_id)
or (approved.owner, approved.session_id) != (authority.owner, authority.session_id)
or (approved.transport_tool, approved.exact_input) != (operation.transport_tool, operation.input)):
raise ResourceIdentityError("Approved backend binding changed")
selected = approved.resource
elif current in authority.backend_resources:
selected = current
elif native in authority.backend_resources:
selected = native
elif isinstance(current, NativeBackendResource) and not authority.inherited:
# Legacy operation authority can only retain the fixed local backend;
# it cannot reconstruct any external backend from current availability.
selected = current
else:
raise ResourceIdentityError("External backend is outside sealed request scope")
if isinstance(selected, ExternalResource) and selected not in authority.backend_resources:
if not (exact_admission and approved is not None and not authority.inherited):
raise ResourceIdentityError("External backend exceeds parent/request scope")
bound = BoundBackendOperation(selected, authority.request_id, authority.owner, authority.session_id,
operation.transport_tool, operation.input)
bound.validate(context)
return bound
_ACTIVE = ContextVar("backend_resource_operation", default=None)
def active_backend_operation():
return _ACTIVE.get()
@contextmanager
def bind_backend_operation(operation):
if operation is not None and not isinstance(operation, BoundBackendOperation):
raise TypeError("Backend operation must be server-owned")
token = _ACTIVE.set(operation)
try:
yield operation
finally:
_ACTIVE.reset(token)
+203
View File
@@ -0,0 +1,203 @@
"""Resolve native filesystem selectors once, after operation admission.
Resolution produces inert bindings; the dispatcher still owns authority,
TurnContract, security and approval gates. No remote filesystem is resolved here.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
import json
import os
from src.agent_runtime.authority import ExactOperation
from src.agent_runtime.resources import FilesystemResource, FilesystemRoot
from src.path_confinement import canonical_root, confine
NATIVE_FILESYSTEM_TOOLS = frozenset({
"read_file", "write_file", "edit_file", "apply_patch", "ls", "glob", "grep",
})
@dataclass(frozen=True)
class ResourceBinding:
role: str
resource: FilesystemResource
def __post_init__(self):
if self.role not in {"source", "target", "destination", "search_root"} or not isinstance(self.resource, FilesystemResource):
raise ValueError("Malformed operation resource binding")
@dataclass(frozen=True)
class BoundFilesystemOperation:
operation: ExactOperation
execution_input: str
bindings: tuple[ResourceBinding, ...]
# Empty only for inert proposal resolution without an originating request.
request_id: str = ""
def __post_init__(self):
if (not isinstance(self.operation, ExactOperation)
or not isinstance(self.execution_input, str)
or not isinstance(self.bindings, tuple) or not self.bindings
or any(not isinstance(b, ResourceBinding) for b in self.bindings)):
raise ValueError("Malformed resource-bound operation")
if not isinstance(self.request_id, str) or any(c in self.request_id for c in ("\0", "\n", "\r")):
raise ValueError("Malformed resource operation request identity")
if (self.operation.action in {"move", "rename"}
and (len(self.bindings) != 2 or {b.role for b in self.bindings} != {"source", "destination"}
or len({b.resource.path for b in self.bindings}) != 2
or next(b for b in self.bindings if b.role == "source").resource.identity is None)):
raise ValueError("Move/rename must bind distinct source and destination")
def validate(self):
for binding in self.bindings:
binding.resource.validate()
def to_dict(self):
return {"request_id": self.request_id, "tool": self.operation.transport_tool, "input": self.operation.input,
"execution_input": self.execution_input,
"bindings": [{"role": b.role, "resource": b.resource.to_dict()} for b in self.bindings]}
def resolve_path(self, selector, *, search=False):
"""Consume declared canonical targets; permit bounded search descendants."""
if not isinstance(selector, str):
raise ValueError("Resource selector must be a string")
value = selector.strip()
for binding in self.bindings:
resource = binding.resource
if value == resource.path or (search and not value and binding.role == "search_root"):
resource.validate()
return resource.path
if not search:
for binding in self.bindings:
resource = binding.resource
if binding.role == "search_root" and resource.identity.kind == "directory":
resource.validate()
try:
path = confine(resource.path, value)
return FilesystemResource.resolve(resource.root, path).path
except (ValueError, OSError, RuntimeError):
continue
raise ValueError("Path is not declared by the resource-bound operation")
def _resolve(roots, selector, *, workspace, allow_missing):
if not isinstance(selector, str) or not selector.strip():
raise ValueError("Resource path is required and must be a string")
value = selector.strip()
# The virtual alias belongs to the request workspace, even when a child
# narrows its root to a subdirectory of that workspace.
if value == "/workspace" or value.startswith("/workspace/"):
if not workspace:
raise ValueError("Workspace alias has no server-owned workspace")
base = canonical_root(workspace)
value = base if value == "/workspace" else os.path.join(base, value[len("/workspace/"):])
elif not os.path.isabs(os.path.expanduser(value)):
if workspace:
value = os.path.join(canonical_root(workspace), value)
elif len(roots) == 1:
value = os.path.join(roots[0].path, value)
else:
raise ValueError("Relative resource path has no unambiguous server root")
for root in roots:
try:
return FilesystemResource.resolve(root, value, allow_missing=allow_missing)
except (ValueError, OSError, RuntimeError):
continue
boundary = "the workspace" if workspace else "the sealed roots"
raise ValueError(f"Resource path is outside {boundary}, sensitive, missing or changed")
def resolve_filesystem_operation(operation, *, roots, workspace="", request_id=""):
"""Server adapter. This does not grant the operation or authorize its roots."""
if not isinstance(operation, ExactOperation) or operation.tool not in NATIVE_FILESYSTEM_TOOLS:
raise ValueError("Operation has no native filesystem adapter")
if (not isinstance(roots, tuple) or not roots
or any(not isinstance(r, FilesystemRoot) for r in roots)):
raise ValueError("Native filesystem operation requires a sealed resource root")
content = operation.input
args = json.loads(content) if content.lstrip().startswith("{") else None
if args is not None and not isinstance(args, dict):
raise ValueError("Filesystem input must be an object")
bindings = []
def bind(selector, role, *, missing=False):
resource = _resolve(roots, selector, workspace=workspace, allow_missing=missing)
bindings.append(ResourceBinding(role, resource))
return resource.path
tool = operation.tool
if tool == "apply_patch":
from src.agent_tools.filesystem_tools import _parse_agent_patch
if args is None:
patch = content
else:
variants = [args[k] for k in ("patch_text", "patchText", "patch") if k in args]
if not variants or any(not isinstance(p, str) or p != variants[0] for p in variants):
raise ValueError("Patch requires one unambiguous patch_text")
patch = variants[0]
ops = _parse_agent_patch(patch)
paths = [bind(op["path"], "destination" if op["kind"] == "add" else "target",
missing=op["kind"] == "add") for op in ops]
objects = [b.resource.identity for b in bindings if b.resource.identity is not None]
if len(set(paths)) != len(paths) or len(set(objects)) != len(objects):
raise ValueError("Patch targets resolve to the same resource")
path_iter = iter(paths)
lines = patch.replace("\r\n", "\n").replace("\r", "\n").split("\n")
for i, line in enumerate(lines):
for marker in ("*** Add File: ", "*** Update File: ", "*** Delete File: "):
if line.startswith(marker):
lines[i] = marker + next(path_iter)
break
execution_input = json.dumps({"patch_text": "\n".join(lines)}, sort_keys=True)
else:
search = tool in {"ls", "glob", "grep"}
if args is None:
if tool == "write_file":
path, _, body = content.partition("\n")
args = {"path": path.strip(), "content": body}
elif tool == "edit_file":
raise ValueError("edit_file requires a JSON object")
elif tool in {"glob", "grep"}:
args = {"pattern": content.strip()}
else:
args = {"path": content.split("\n", 1)[0].strip()}
selector = args.get("path", "" if search else None)
if search and selector == "":
if workspace:
selector = canonical_root(workspace)
elif len(roots) == 1:
selector = roots[0].path
else:
raise ValueError("Search root is unresolved")
args["path"] = bind(selector, "search_root" if search else
"source" if tool == "read_file" else "destination" if tool == "write_file" else "target",
missing=tool == "write_file")
execution_input = json.dumps(args, sort_keys=True, allow_nan=False)
bound = BoundFilesystemOperation(operation, execution_input, tuple(bindings), request_id)
bound.validate()
return bound
_ACTIVE: ContextVar[BoundFilesystemOperation | None] = ContextVar("resource_operation", default=None)
def active_resource_operation():
return _ACTIVE.get()
@contextmanager
def bind_resource_operation(operation):
if operation is not None and not isinstance(operation, BoundFilesystemOperation):
raise TypeError("Resource operation must be server-owned")
if operation is not None:
operation.validate()
token = _ACTIVE.set(operation)
try:
yield operation
finally:
_ACTIVE.reset(token)
+690
View File
@@ -0,0 +1,690 @@
"""Inert server-owned resource identities, independent of operation authority.
Filesystem observations detect replacement; they are not held kernel handles or
content/effect evidence. Other producers must supply their own incarnations.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from enum import Enum
import os
from pathlib import Path
import stat
import sys
from src.agent_runtime.path_policy import _is_sensitive_path
from src.path_confinement import canonical_root, confine
def _text(value, label, *, optional=False):
if (not isinstance(value, str) or (not value and not optional)
or any(c in value for c in ("\0", "\n", "\r"))):
raise ValueError(f"Invalid resource {label}")
def _absolute(value):
_text(value, "path")
if not os.path.isabs(value) or os.path.normpath(value) != value:
raise ValueError("Resource path must be canonical and absolute")
def _control_plane_snapshot():
# Execution snapshots/receipts are server state, even if a workspace root
# contains the data directory. A writable user file cannot mint authority.
from src import constants
protected = {canonical_root(getattr(constants, name)) for name in (
"BG_JOBS_FILE", "CONTAINMENT_STATE_FILE", "APP_DB", "AUTH_FILE",
"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),
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))
# Producers may have configured paths different from the default constants.
# Inspect already-loaded server metadata without initializing a store here.
bg = sys.modules.get("src.bg_jobs")
if bg is not None:
for name, targets in (("_STORE", protected), ("_JOBS_DIR", job_dirs)):
value = getattr(bg, name, None)
if isinstance(value, (str, os.PathLike)):
targets.add(canonical_root(value))
containment = sys.modules.get("src.containment")
if containment is not None:
value = containment._store_path()
if isinstance(value, (str, os.PathLike)):
protected.add(canonical_root(value))
database = sys.modules.get("core.database")
url = getattr(getattr(database, "engine", None), "url", None)
if url is not None and url.get_backend_name() == "sqlite":
location = url.database
if isinstance(location, str) and location not in {"", ":memory:"}:
from urllib.parse import unquote
if location.startswith("file:"):
location = unquote(location[5:].split("?", 1)[0])
protected.update(canonical_root(location + suffix) for suffix in ("", "-wal", "-shm", "-journal"))
from src.tool_utils import get_upload_handler
uploader = get_upload_handler()
if uploader is not None and isinstance(getattr(uploader, "upload_dir", None), (str, os.PathLike)):
protected.add(canonical_root(Path(uploader.upload_dir) / "uploads.json"))
for directory in job_dirs:
jobs = Path(directory)
if jobs.exists():
# Uninspectable state fails closed; hardlinks retain object identity.
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"))
protected.add(canonical_root(Path(constants.DATA_DIR) / ".app_key"))
protected.add(canonical_root(Path(constants.UPLOAD_DIR) / "uploads.json"))
identities = set()
for control in protected:
try:
observed = os.stat(control)
except FileNotFoundError:
continue
identities.add((observed.st_dev, observed.st_ino))
return frozenset(job_dirs), frozenset(protected), frozenset(identities)
def _control_plane_path(path, *, snapshot=None):
# A scan-local snapshot bounds repeated hardlink checks. Ordinary resource
# resolution always observes fresh state. Neither form is an atomic kernel
# access policy, and snapshots must never survive a workspace guard call.
directories, protected, identities = _control_plane_snapshot() if snapshot is None else snapshot
if any(Path(path).is_relative_to(directory) for directory in directories) or path in protected:
return True
try:
candidate = os.stat(path)
except FileNotFoundError:
return False
return (candidate.st_dev, candidate.st_ino) in identities
class FilesystemScope(str, Enum):
WORKSPACE = "workspace"
SCRATCH = "scratch"
EXTERNAL = "external"
PRIVATE = "private"
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
inode: int
kind: str
def __post_init__(self):
if (type(self.device) is not int or self.device < 0
or type(self.inode) is not int or self.inode <= 0
or self.kind not in {"file", "directory"}):
raise ValueError("Malformed filesystem object identity")
@classmethod
def observe(cls, path):
info = os.stat(path, follow_symlinks=False)
kind = ("file" if stat.S_ISREG(info.st_mode) else
"directory" if stat.S_ISDIR(info.st_mode) else None)
if kind is None:
raise ValueError("Filesystem resource must be a regular file or directory")
return cls(info.st_dev, info.st_ino, kind)
@dataclass(frozen=True)
class FilesystemRoot:
path: str
scope: FilesystemScope
identity: FileObjectIdentity
owner: str = ""
def __post_init__(self):
_absolute(self.path)
_text(self.owner, "owner", optional=True)
if (not isinstance(self.scope, FilesystemScope)
or not isinstance(self.identity, FileObjectIdentity)
or self.identity.kind != "directory"
or os.path.dirname(self.path) == self.path
or _is_sensitive_path(self.path)
or (self.scope is FilesystemScope.PRIVATE and not self.owner)):
raise ValueError("Malformed filesystem root identity")
@classmethod
def seal(cls, path, *, scope=FilesystemScope.WORKSPACE, owner=""):
root = canonical_root(path)
return cls(root, scope, FileObjectIdentity.observe(root), owner)
def validate(self):
try:
if canonical_root(self.path) != self.path or FileObjectIdentity.observe(self.path) != self.identity:
raise ResourceIdentityError("Filesystem root identity changed")
except (OSError, RuntimeError) as error:
raise ResourceIdentityError("Filesystem root identity is unresolved") from error
def to_dict(self):
return {**asdict(self), "scope": self.scope.value}
@classmethod
def from_dict(cls, value):
if not isinstance(value, dict) or set(value) != {"path", "scope", "identity", "owner"}:
raise ValueError("Malformed filesystem root snapshot")
return cls(value["path"], FilesystemScope(value["scope"]),
FileObjectIdentity(**value["identity"]), value["owner"])
@dataclass(frozen=True)
class PathObservation:
path: str
identity: FileObjectIdentity
def __post_init__(self):
_absolute(self.path)
if not isinstance(self.identity, FileObjectIdentity) or self.identity.kind != "directory":
raise ValueError("Malformed filesystem ancestor identity")
@dataclass(frozen=True)
class FilesystemResource:
root: FilesystemRoot
path: str
identity: FileObjectIdentity | None
ancestors: tuple[PathObservation, ...]
def __post_init__(self):
_absolute(self.path)
if (not isinstance(self.root, FilesystemRoot)
or not Path(self.path).is_relative_to(self.root.path)
or (self.identity is not None and not isinstance(self.identity, FileObjectIdentity))
or not isinstance(self.ancestors, tuple)
or any(not isinstance(a, PathObservation) for a in self.ancestors)
or not self.ancestors
or self.ancestors[0] != PathObservation(self.root.path, self.root.identity)):
raise ValueError("Malformed filesystem resource identity")
parent = Path(self.root.path)
expected = [str(parent)]
for part in Path(self.path).relative_to(self.root.path).parts[:-1]:
parent /= part
expected.append(str(parent))
if ([a.path for a in self.ancestors] != expected[:len(self.ancestors)]
or (self.identity is not None and len(self.ancestors) != len(expected))):
raise ValueError("Malformed filesystem ancestor chain")
@classmethod
def resolve(cls, root, selector, *, allow_missing=False):
root.validate()
# Only this server-owned workspace root supplies the virtual alias.
if not isinstance(selector, str):
raise ValueError("Resource path must be a string")
value = selector.strip()
if root.scope is FilesystemScope.WORKSPACE:
if value == "/workspace":
value = root.path
elif value.startswith("/workspace/"):
value = os.path.join(root.path, value[len("/workspace/"):])
path = confine(root.path, value)
if _is_sensitive_path(path) or _control_plane_path(path):
raise ValueError("Resource path is sensitive")
ancestors = [PathObservation(root.path, root.identity)]
relative = Path(path).relative_to(root.path)
parent = Path(root.path)
missing_parent = False
for part in relative.parts[:-1]:
parent /= part
try:
observed = FileObjectIdentity.observe(parent)
except FileNotFoundError:
missing_parent = True
break
ancestors.append(PathObservation(str(parent), observed))
try:
identity = None if missing_parent else FileObjectIdentity.observe(path)
except FileNotFoundError:
identity = None
if identity is None and not allow_missing:
raise ValueError("Filesystem resource is unresolved or missing")
return cls(root, path, identity, tuple(ancestors))
def validate(self):
try:
if self.resolve(self.root, self.path, allow_missing=self.identity is None) != self:
raise ResourceIdentityError("Filesystem resource identity changed")
except (ValueError, OSError, RuntimeError) as error:
raise ResourceIdentityError("Filesystem resource identity changed or is unresolved") from error
def to_dict(self):
return asdict(self)
def intersect_roots(parent, child):
"""Keep the narrower root only when the observed parent's identity agrees."""
result = []
for left in parent:
for right in child:
if (left.scope, left.owner) != (right.scope, right.owner):
continue
try:
left.validate()
right.validate()
if left == right:
result.append(left)
continue
if Path(right.path).is_relative_to(left.path):
# A newly sealed child may not renew a replaced parent root.
result.append(right)
elif Path(left.path).is_relative_to(right.path):
result.append(left)
except (OSError, ValueError, RuntimeError):
continue
return tuple(dict.fromkeys(result))
@dataclass(frozen=True)
class ProcessResource:
namespace: str
owner: str
request_id: str
thread_id: str
identity: "ProcessIdentity"
role: str
job_id: str = ""
containment_id: str = ""
def __post_init__(self):
from src.process_lifecycle import ProcessIdentity
for name in ("namespace", "request_id", "thread_id"):
_text(getattr(self, name), name)
for name in ("owner", "job_id", "containment_id"):
_text(getattr(self, name), name, optional=True)
if (not isinstance(self.identity, ProcessIdentity)
or type(self.identity.pid) is not int or self.identity.pid <= 0
or (self.identity.pgid is not None and (type(self.identity.pgid) is not int or self.identity.pgid <= 0))
or self.role not in {"supervisor", "leader", "namespace_init", "manager", "pty", "service"}):
raise ValueError("Malformed process resource identity")
supported_roles = {"native:containment": {"leader", "namespace_init"},
"native:bg_jobs": {"supervisor"}}
if self.role not in supported_roles.get(self.namespace, set()):
raise ValueError("Unsupported process producer or role")
_text(self.identity.start_token, "process start token")
def validate(self):
if not self.identity.owned() or self.identity.exited():
raise ResourceIdentityError("Process resource is stale or unverifiable")
def to_dict(self):
return {"namespace": self.namespace, "owner": self.owner, "request_id": self.request_id,
"thread_id": self.thread_id, "identity": self.identity.to_record(), "role": self.role,
"job_id": self.job_id, "containment_id": self.containment_id}
@classmethod
def from_dict(cls, value):
from src.process_lifecycle import ProcessIdentity
if not isinstance(value, dict) or set(value) != {"namespace", "owner", "request_id", "thread_id", "identity", "role", "job_id", "containment_id"}:
raise ValueError("Malformed process resource snapshot")
identity = value["identity"]
if not isinstance(identity, dict) or set(identity) != {"pid", "start_token", "pgid"}:
raise ValueError("Malformed lifecycle identity snapshot")
return cls(**{**value, "identity": ProcessIdentity(**identity)})
@dataclass(frozen=True)
class ProcessLaunchScope:
backend: "NativeBackendResource"
root: FilesystemRoot
required: frozenset[str]
runtime_roots: tuple[PathObservation, ...] = ()
network: str = "inherit"
max_runtime_s: int = 3600
def __post_init__(self):
if (not isinstance(self.backend, NativeBackendResource) or not isinstance(self.root, FilesystemRoot)
or not isinstance(self.required, frozenset) or not self.required
or any(not isinstance(v, str) or not v for v in self.required)):
raise ValueError("Malformed process launch scope")
if self.backend.tool_id not in {"bash", "python"}:
raise ValueError("Unsupported native launch producer")
if (not isinstance(self.runtime_roots, tuple) or any(not isinstance(r, PathObservation) for r in self.runtime_roots)
or self.network not in {"inherit", "none"}
or type(self.max_runtime_s) is not int or self.max_runtime_s <= 0):
raise ValueError("Malformed launch boundary selectors")
def validate(self):
self.root.validate()
for runtime in self.runtime_roots:
if canonical_root(runtime.path) != runtime.path or FileObjectIdentity.observe(runtime.path) != runtime.identity:
raise ResourceIdentityError("Launch runtime root changed")
def to_dict(self):
return {"backend": self.backend.to_dict(), "root": self.root.to_dict(), "required": sorted(self.required),
"runtime_roots": [{"path": r.path, "identity": asdict(r.identity)} for r in self.runtime_roots],
"network": self.network, "max_runtime_s": self.max_runtime_s}
@classmethod
def from_dict(cls, value):
if not isinstance(value, dict) or set(value) != {"backend", "root", "required", "runtime_roots", "network", "max_runtime_s"} or not isinstance(value["required"], list) or not isinstance(value["runtime_roots"], list):
raise ValueError("Malformed launch scope snapshot")
return cls(backend_from_dict(value["backend"]), FilesystemRoot.from_dict(value["root"]), frozenset(value["required"]),
tuple(PathObservation(r["path"], FileObjectIdentity(**r["identity"])) for r in value["runtime_roots"]),
value["network"], value["max_runtime_s"])
@dataclass(frozen=True)
class ProcessLaunchResource:
namespace: str
owner: str
request_id: str
thread_id: str
generation: str
tool: str
input_digest: str
scope: ProcessLaunchScope
ceiling_digest: str
def __post_init__(self):
for name in ("namespace", "request_id", "thread_id", "generation", "tool", "input_digest", "ceiling_digest"):
_text(getattr(self, name), name)
_text(self.owner, "owner", optional=True)
if not isinstance(self.scope, ProcessLaunchScope) or self.tool != self.scope.backend.tool_id:
raise ValueError("Malformed launch resource")
import re
if (self.namespace != "native:containment" or not re.fullmatch(r"[a-f0-9]{32}", self.generation)
or any(not re.fullmatch(r"[a-f0-9]{64}", v) for v in (self.input_digest, self.ceiling_digest))):
raise ValueError("Malformed native launch producer or generation")
def validate(self):
self.scope.validate()
def to_dict(self):
return {**{k: getattr(self, k) for k in ("namespace", "owner", "request_id", "thread_id", "generation", "tool", "input_digest", "ceiling_digest")},
"scope": self.scope.to_dict()}
@classmethod
def from_dict(cls, value):
if not isinstance(value, dict) or set(value) != {"namespace", "owner", "request_id", "thread_id", "generation", "tool", "input_digest", "scope", "ceiling_digest"}:
raise ValueError("Malformed launch resource snapshot")
return cls(**{**value, "scope": ProcessLaunchScope.from_dict(value["scope"])})
@dataclass(frozen=True)
class BackgroundJobResource:
namespace: str
job_id: str
generation: str
owner: str
request_id: str
thread_id: str
containment_id: str
processes: tuple[ProcessResource, ...]
def __post_init__(self):
for name in ("namespace", "job_id", "generation", "request_id", "thread_id", "containment_id"):
_text(getattr(self, name), name)
_text(self.owner, "owner", optional=True)
import re
if (not re.fullmatch(r"[A-Za-z0-9_-]+", self.job_id)
or not re.fullmatch(r"[a-f0-9]{32}", self.generation)):
raise ValueError("Malformed job selector or launch generation")
if (not isinstance(self.processes, tuple) or not self.processes
or any(not isinstance(p, ProcessResource) or (p.owner, p.request_id, p.thread_id, p.job_id, p.containment_id)
!= (self.owner, self.request_id, self.thread_id, self.job_id, self.containment_id) for p in self.processes)
or len({p.role for p in self.processes}) != len(self.processes)):
raise ValueError("Malformed background job resource")
if self.namespace != "native:bg_jobs" or any(p.namespace != "native:bg_jobs" or p.role != "supervisor" for p in self.processes):
raise ValueError("Unsupported job producer or process role")
def to_dict(self):
return {**{k: getattr(self, k) for k in ("namespace", "job_id", "generation", "owner", "request_id", "thread_id", "containment_id")},
"processes": [p.to_dict() for p in self.processes]}
@classmethod
def from_dict(cls, value):
if not isinstance(value, dict) or set(value) != {"namespace", "job_id", "generation", "owner", "request_id", "thread_id", "containment_id", "processes"} or not isinstance(value["processes"], list):
raise ValueError("Malformed background resource snapshot")
return cls(**{**value, "processes": tuple(ProcessResource.from_dict(p) for p in value["processes"])})
@dataclass(frozen=True)
class ExternalResource:
namespace: str
endpoint_id: str
server_id: str
tool_id: str
incarnation: str
external: bool = True
contained: bool = False
owner: str = ""
def __post_init__(self):
for name in ("namespace", "endpoint_id", "server_id", "tool_id", "incarnation"):
_text(getattr(self, name), name)
if self.external is not True or self.contained is not False:
raise ValueError("External resource cannot attest local containment")
_text(self.owner, "external owner", optional=True)
def to_dict(self):
return {"kind": "external", **asdict(self)}
@dataclass(frozen=True)
class NativeBackendResource:
tool_id: str
namespace: str = "native"
external: bool = False
contained: bool = False
def __post_init__(self):
_text(self.tool_id, "native tool")
if self.namespace != "native" or self.external is not False or self.contained is not False:
raise ValueError("Malformed native backend identity")
def to_dict(self):
return {"kind": "native", **asdict(self)}
def backend_from_dict(value):
if not isinstance(value, dict):
raise ValueError("Malformed backend snapshot")
fields = dict(value)
kind = fields.pop("kind", None)
if kind not in {"native", "external"}:
raise ValueError("Malformed backend kind")
return (NativeBackendResource if kind == "native" else ExternalResource)(**fields)
@dataclass(frozen=True)
class OwnedResource:
namespace: str
owner: str
thread_id: str
collection: str
record_id: str
revision: str = ""
record_thread_id: str = ""
def __post_init__(self):
for name in ("namespace", "owner", "thread_id", "collection", "record_id"):
_text(getattr(self, name), name)
_text(self.revision, "revision", optional=True)
_text(self.record_thread_id, "record thread", optional=True)
def to_dict(self):
return asdict(self)
@dataclass(frozen=True)
class OwnedScope:
namespace: str
owner: str
thread_id: str
record_ids: frozenset[str] | None = None
def __post_init__(self):
for name in ("namespace", "owner", "thread_id"):
_text(getattr(self, name), name)
if self.record_ids is not None:
if not isinstance(self.record_ids, frozenset):
raise ValueError("Owned scope must be immutable")
for identifier in self.record_ids:
_text(identifier, "record identifier")
if identifier == "*":
raise ValueError("Collection authority must be explicit")
def permits(self, resource):
return (isinstance(resource, OwnedResource)
and (self.namespace, self.owner, self.thread_id) ==
(resource.namespace, resource.owner, resource.thread_id)
and resource.collection == self.namespace
and (self.record_ids is None or resource.record_id in self.record_ids))
def intersect(self, other):
if (self.namespace, self.owner, self.thread_id) != (other.namespace, other.owner, other.thread_id):
return None
ids = (other.record_ids if self.record_ids is None else self.record_ids if other.record_ids is None
else self.record_ids & other.record_ids)
return OwnedScope(self.namespace, self.owner, self.thread_id, ids)
def to_dict(self):
return {"namespace": self.namespace, "owner": self.owner, "thread_id": self.thread_id,
"record_ids": None if self.record_ids is None else sorted(self.record_ids)}
@classmethod
def from_dict(cls, value):
if not isinstance(value, dict) or set(value) != {"namespace", "owner", "thread_id", "record_ids"}:
raise ValueError("Malformed owned scope snapshot")
ids = value["record_ids"]
if ids is not None and (not isinstance(ids, list) or any(not isinstance(v, str) for v in ids)):
raise ValueError("Malformed owned record limits")
return cls(value["namespace"], value["owner"], value["thread_id"],
None if ids is None else frozenset(ids))
OWNED_TOOL_NAMESPACES = {
**{name: "documents" for name in ("create_document", "edit_document", "update_document", "suggest_document", "manage_documents")},
**{name: "threads" for name in ("create_session", "list_sessions", "manage_session", "send_to_session", "search_chats")},
**{name: "attachments" for name in ("extract_text", "inspect_media", "transcribe_media")},
"manage_notes": "notes",
"manage_memory": "memory",
**{name: "vault" for name in ("vault_get", "vault_search", "vault_unlock")},
}
def seal_owned_scopes(owner, thread_id, tools):
if not owner or not thread_id:
return ()
return tuple(OwnedScope(namespace, owner, thread_id)
for namespace in sorted({OWNED_TOOL_NAMESPACES[t] for t in tools if t in OWNED_TOOL_NAMESPACES}))
+19 -3
View File
@@ -67,8 +67,20 @@ class ManageBgJobsTool:
if not session_id:
return {"error": "manage_bg_jobs: no active chat session; background jobs are scoped to a chat.", "exit_code": 1}
from src.agent_runtime.process_resources import active_process_operation, expected_job, require_process_admission
from src.agent_runtime.resources import ResourceIdentityError
bound = active_process_operation()
if bound is None or (bound.owner, bound.thread_id) != (str(ctx.get("owner") or "").strip().casefold(), session_id):
return {"error": "manage_bg_jobs: no exact server resource binding", "exit_code": 1,
"blocked": True, "failure_kind": "resource_identity_denied"}
from src.agent_runtime.authority import ExactOperation
if bound.operation != ExactOperation.normalize("manage_bg_jobs", raw or "{}"):
return {"error": "Job operation changed at producer entry", "exit_code": 1, "blocked": True}
require_process_admission(bound)
if action in _LIST_ACTIONS:
jobs: List[Dict[str, Any]] = bg_jobs.list_for_session(session_id)
bound.validate()
jobs: List[Dict[str, Any]] = [bg_jobs.peek(j.job_id) for j in bound.jobs]
if not jobs:
return {"output": "No background jobs in this chat.", "exit_code": 0}
jobs.sort(key=lambda r: r.get("started_at") or 0, reverse=True)
@@ -78,7 +90,11 @@ class ManageBgJobsTool:
if action in _OUTPUT_ACTIONS or action in _KILL_ACTIONS:
if not job_id:
return {"error": f"manage_bg_jobs: action '{action}' requires a job_id (see action='list').", "exit_code": 1}
rec = bg_jobs.get(job_id)
try:
resource = expected_job(job_id, action=action)
rec = bg_jobs.get(job_id, expected=resource)
except (ResourceIdentityError, OSError, ValueError) as error:
return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
# Scope: only the chat that launched a job may see or control it.
if rec is None or rec.get("session_id") != session_id:
return {"error": f"manage_bg_jobs: no background job '{job_id}' in this chat.", "exit_code": 1}
@@ -86,7 +102,7 @@ class ManageBgJobsTool:
if action in _KILL_ACTIONS:
if rec.get("status") != "running":
return {"output": f"Job `{job_id}` already {_status_label(rec)}; nothing to kill.", "exit_code": 0}
killed = bg_jobs.kill(job_id)
killed = bg_jobs.kill(job_id, expected=resource)
if not killed or not killed.get("killed"):
return {"error": f"Could not verify termination of background job `{job_id}`.",
"exit_code": 1, "teardown": (killed or {}).get("teardown")}
+20 -3
View File
@@ -26,6 +26,18 @@ _BINARY_ARTIFACT_SUFFIXES = _STRUCTURED_DOCUMENT_SUFFIXES | frozenset({
".png", ".wav", ".webm", ".webp", ".zip",
})
def _visible_bound_resource(path):
from src.agent_runtime.resource_binding import active_resource_operation
bound = active_resource_operation()
if bound is None:
return True
try:
bound.resolve_path(path)
return True
except (ValueError, OSError, RuntimeError):
return False
# Models frequently put source artifacts in a Markdown code fence even when a
# tool schema asks for the raw file body. Persisting that fence makes HTML,
# CSS, JavaScript, and source files invalid. Restrict normalization to
@@ -610,6 +622,8 @@ class LsTool:
for entry in it:
if entry.name.startswith("."):
continue
if not _visible_bound_resource(entry.path):
continue
try:
is_dir = entry.is_dir(follow_symlinks=False)
size = entry.stat(follow_symlinks=False).st_size if not is_dir else 0
@@ -681,7 +695,7 @@ class GlobTool:
# .ssh/id_rsa, …) falls through to the walk, which skips it —
# otherwise glob would surface secret paths that read_file /
# grep already refuse to touch.
if inside and os.path.exists(cand) and not _is_sensitive_path(cand):
if inside and os.path.exists(cand) and not _is_sensitive_path(cand) and _visible_bound_resource(cand):
return [cand], None
# Literal not at exact path — fall through to walk so
# e.g. "foo.py" still matches at any depth (like rglob).
@@ -705,7 +719,7 @@ class GlobTool:
if regex.fullmatch(rel) or regex.fullmatch(name):
# Skip deny-listed sensitive files (.env, id_rsa,
# known_hosts, …) the same way grep does.
if _is_sensitive_path(os.path.realpath(full)):
if _is_sensitive_path(os.path.realpath(full)) or not _visible_bound_resource(full):
continue
try:
mtime = os.stat(full).st_mtime
@@ -766,9 +780,12 @@ class GrepTool:
def _grep():
import re as _re
import shutil
from src.agent_runtime.resource_binding import active_resource_operation
if not os.path.exists(root):
return None, f"grep: search target not found: {_display_tool_path(root)}"
rg = shutil.which("rg")
# The pathname-only fast path scans before individual resources can
# be checked. Bound searches must validate every file before read.
rg = None if active_resource_operation() is not None else shutil.which("rg")
if rg:
cmd = [rg, "--line-number", "--with-filename", "--no-heading", "--color=never",
"--max-count", str(max_hits)]
+6 -1
View File
@@ -184,6 +184,9 @@ def _resolve_workspace_path(
raise ValueError(
f"{tool_name} {field_name} must stay inside the active workspace"
) from exc
from src.agent_runtime.resources import _control_plane_path
if _control_plane_path(str(resolved)):
raise ValueError(f"{tool_name} {field_name} addresses execution-control state")
if must_exist and not resolved.is_file():
raise FileNotFoundError(f"media file not found: {raw}")
return resolved
@@ -445,8 +448,10 @@ class ExtractTextTool:
from src.tool_utils import get_upload_handler
ref = re.fullmatch(r'odysseus://attachment/([A-Za-z0-9_-]+(?:\.[A-Za-z0-9]+)?)', raw_path)
owner = (_ctx or {}).get('owner')
from src.agent_runtime.owned_resources import bound_attachment_path
bound_path = bound_attachment_path(owner, raw_path)
handler = get_upload_handler()
info = handler.resolve_upload(ref[1], owner=owner, allow_admin=False) if ref and owner and handler else None
info = {"path": bound_path} if bound_path else handler.resolve_upload(ref[1], owner=owner, allow_admin=False) if ref and owner and handler else None
if not info or not info.get('path'):
raise ValueError('Uploaded image not found or not accessible to this user')
path = Path(info['path'])
+52 -6
View File
@@ -500,8 +500,10 @@ def _owned_spec(cwd: str, env: Optional[dict], timeout: int, readonly_extra: tup
visible = any(prefix == root or prefix.startswith(root + os.sep) for root in ("/usr", "/etc"))
if not visible and prefix not in _NAMESPACE_RESERVED_DESTS:
readonly.append(prefix)
from src.tool_execution import _agent_subprocess_env
clean_env = _agent_subprocess_env() if env is None else dict(env)
return containment.agent_spec(
cwd, dict(os.environ if env is None else env), timeout,
cwd, clean_env, timeout,
readonly_extra=tuple(dict.fromkeys([*readonly, *readonly_extra])),
)
@@ -511,26 +513,58 @@ async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, arg
from src.tool_execution import agent_cwd, _truncate
grant = None
launch = None
result = None
try:
from src.agent_runtime.process_resources import require_launch, publish_launch, validate_launch_spec
from src.agent_runtime.authority import active_request_authority
launch = require_launch(tool, cwd=agent_cwd())
authority = active_request_authority()
if (str(ctx.get("owner") or "").strip().casefold(), str(ctx.get("session_id") or "")) != (
authority.owner, authority.session_id):
raise ValueError("Native producer owner or session changed")
spec = _owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout, readonly_extra)
validate_launch_spec(launch, spec)
grant = containment.acquire(
_owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout, readonly_extra),
spec,
owner=str(ctx.get("session_id") or ctx.get("owner") or tool),
)
containment._update_record(grant.id, launch_generation=launch.generation)
publish_launch(launch, authority, grant.id)
if containment.FILESYSTEM not in grant.enforced:
if argv:
command = [*command[:-1], _replace_workspace_alias(command[-1], grant.workspace)]
else:
command = _replace_workspace_alias(command, grant.workspace)
result = await containment.run(grant, command, argv=argv, progress_cb=ctx.get("progress_cb"))
from src.agent_runtime.process_resources import attach_containment_processes
attach_containment_processes(launch, grant.id)
except containment.ContainmentUnavailable as exc:
return containment.unavailable_tool_result(exc, tool=tool)
except (OSError, RuntimeError, ValueError) as exc:
boundary = grant.to_dict() if grant else {}
boundary["executed"] = bool(getattr(exc, "containment_executed", False))
if not getattr(exc, "containment_established", False):
if grant is not None:
record = containment._load_records().get(grant.id, {})
if not record.get("pid") and not record.get("release"):
containment.release(grant, grace_s=0)
boundary = result.grant.to_dict() if result is not None else grant.to_dict() if grant else {}
boundary["executed"] = result is not None or bool(getattr(exc, "containment_executed", False))
if result is None and not getattr(exc, "containment_established", False):
boundary.update(contained=False, enforced=[])
return {"error": f"{tool}: execution failed: {exc}", "exit_code": 1,
"containment": boundary}
"containment": boundary,
**({"failure_kind": "resource_linkage_unavailable",
"teardown": result.release.to_dict() if result.release else {"dead": False}}
if result is not None else {})}
finally:
if launch is not None and grant is not None:
record = containment._load_records().get(grant.id, {})
if (record.get("launch_generation") == launch.generation
and (record.get("release") or {}).get("dead") is True):
from src.agent_runtime.process_resources import retire_launch
try:
retire_launch(launch, grant.id)
except (OSError, ValueError, TypeError):
logger.warning("Foreground launch publication retirement failed", exc_info=True)
boundary = result.grant.to_dict()
boundary["executed"] = True
@@ -590,6 +624,12 @@ class BashTool:
),
"exit_code": 1,
}
from src.agent_runtime.process_resources import require_launch
from src.agent_runtime.resources import ResourceIdentityError
try:
require_launch("bash", cwd=agent_cwd(), content=content)
except ResourceIdentityError as error:
return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
if _ffmpeg_unicode_drawtext_needs_fontfile(content):
resolved_font = _resolve_fontfile_for_text(content)
resolved_hint = (
@@ -879,6 +919,12 @@ class PythonTool:
),
"exit_code": 1,
}
from src.agent_runtime.process_resources import require_launch
from src.agent_runtime.resources import ResourceIdentityError
try:
require_launch("python", cwd=agent_cwd(), content=content)
except ResourceIdentityError as error:
return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
if "/tmp/" in content:
isolated_tmp = _isolated_tmp_dir(agent_cwd())
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -394,6 +394,11 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
if not _memory_manager:
return {"error": "Memory manager not available"}
from src.agent_runtime.owned_resources import active_owned_operation
bound = active_owned_operation()
if bound is not None:
bound.validate()
lines = _manage_memory_lines(content)
if not lines:
return {"error": "Need at least 1 line: action"}
@@ -465,7 +470,7 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
memories = _memory_manager.load_all()
found = False
for m in memories:
if m.get("id", "").startswith(memory_id):
if (m.get("id", "") == memory_id if bound is not None else m.get("id", "").startswith(memory_id)):
# Verify ownership
if owner and m.get("owner") != owner:
return {"error": f"Memory '{memory_id}' not found"}
@@ -498,7 +503,7 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
full_id = None
delete_id = None
for m in memories:
if m.get("id", "").startswith(memory_id):
if (m.get("id", "") == memory_id if bound is not None else m.get("id", "").startswith(memory_id)):
# Verify ownership
if owner and m.get("owner") != owner:
return {"error": f"Memory '{memory_id}' not found"}
+21
View File
@@ -7,6 +7,27 @@ from fastapi import Request, HTTPException
from src.owner_identity import auth_disabled, effective_storage_owner
def is_direct_loopback_request(request: Request) -> bool:
"""Local operator transport, excluding reverse proxies and cross-site calls.
Locality supplies no model/tool authority. Native administration uses this
only in the operator's explicit auth-disabled single-user mode.
"""
client = getattr(request, "client", None)
if not client or client.host not in {"127.0.0.1", "::1"}:
return False
forwarding = ("cf-connecting-ip", "cf-ray", "cf-visitor", "x-forwarded-for",
"x-forwarded-host", "x-forwarded-proto", "x-real-ip", "forwarded")
if any(request.headers.get(name) for name in forwarding):
return False
if request.headers.get("sec-fetch-site") in {"cross-site", "same-site"}:
return False
origin = request.headers.get("origin")
if origin and origin != str(request.base_url).rstrip("/"):
return False
return True
def get_current_user(request: Request) -> Optional[str]:
"""Get current username from request state (set by auth middleware)."""
return getattr(request.state, 'current_user', None)
+118 -18
View File
@@ -86,6 +86,20 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
A trusted detached supervisor owns the shared containment runner, output,
wall clock and exit metadata, independently of the request/server lifetime.
"""
from src.agent_runtime.process_resources import require_launch, active_process_operation, publish_launch, launch_path, validate_launch_spec
from src.agent_runtime.authority import active_request_authority, save_background_authority
from src.agent_runtime.resources import ProcessResource, BackgroundJobResource
from src.process_lifecycle import ProcessIdentity
cwd = cwd or os.getcwd()
launch_resource = require_launch("bash", cwd=cwd)
bound = active_process_operation()
from src.tool_execution import _split_bg_marker
marked, proposed = _split_bg_marker(bound.operation.input)
if command != (proposed if marked else bound.operation.input).strip() or session_id != launch_resource.thread_id:
raise ValueError("Background launch operation or session changed")
authority = active_request_authority()
if authority is None or (authority.owner, authority.request_id) != (launch_resource.owner, launch_resource.request_id):
raise ValueError("Background launch authority changed")
_JOBS_DIR.mkdir(parents=True, exist_ok=True)
job_id = uuid.uuid4().hex[:12]
log_path = _JOBS_DIR / f"{job_id}.log"
@@ -94,6 +108,7 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
from src import containment
from src.agent_tools.subprocess_tools import _owned_spec, _replace_workspace_alias
spec = _owned_spec(cwd or os.getcwd(), env, max_runtime_s)
validate_launch_spec(launch_resource, spec)
grant = containment.acquire(spec, owner=f"bg:{session_id}")
bounded_command = command
if containment.FILESYSTEM not in grant.enforced:
@@ -147,16 +162,33 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
"start_token": process_ownership.capture(proc.pid)["start_token"],
}
try:
supervisor = ProcessResource("native:bg_jobs", launch_resource.owner, launch_resource.request_id,
launch_resource.thread_id, ProcessIdentity(proc.pid, rec["start_token"], rec["pgid"]),
"supervisor", job_id, grant.id)
supervisor.validate()
resource = BackgroundJobResource("native:bg_jobs", job_id, launch_resource.generation,
launch_resource.owner, launch_resource.request_id, launch_resource.thread_id, grant.id, (supervisor,))
rec["resource_identity"] = resource.to_dict()
rec["launch_resource"] = launch_resource.to_dict()
containment._update_record(grant.id, lifetime="background", supervisor_pid=proc.pid,
supervisor_token=rec["start_token"])
supervisor_token=rec["start_token"], launch_generation=resource.generation)
jobs = _load()
jobs[job_id] = rec
_save(jobs)
publish_launch(launch_resource, authority, grant.id, job=resource, processes=(supervisor,))
save_background_authority(job_id, authority, resource=resource)
payload.update(job_store=str(_STORE.resolve()), job_id=job_id,
launch_path=str(launch_path(resource.generation)),
authority_path=str(_JOBS_DIR / (job_id + ".authority.json")),
resource_identity=resource.to_dict(), launch_resource=launch_resource.to_dict())
# The supervisor cannot execute until the identity and job record are durable.
proc.stdin.write(json.dumps(payload).encode("utf-8"))
proc.stdin.close()
except BaseException:
kill_process_tree(proc.pid)
# EOF closes the unreleased worker even if identity observation failed.
if proc.stdin is not None and not proc.stdin.closed:
proc.stdin.close()
kill_process_tree(proc.pid, start_token=rec["start_token"], pgid=rec["pgid"], require_identity=True)
proc.wait(timeout=5)
containment.release(grant, grace_s=0)
raise
@@ -181,10 +213,22 @@ def _prune(jobs: Dict[str, Dict[str, Any]], now: float) -> bool:
"""Drop records (and their on-disk files) for jobs that finished, were
followed up, and are older than the retention window. Mutates `jobs`."""
stale = [jid for jid, rec in jobs.items()
if rec.get("followed_up") and rec.get("ended_at")
if rec.get("status") in {"done", "failed"}
and (rec.get("followed_up") or rec.get("followup_state") == "terminal_unfollowable")
and rec.get("ended_at")
and (rec.get("teardown") or {}).get("dead") is not False
and (now - rec["ended_at"]) > _RETENTION_S]
for jid in stale:
jobs.pop(jid, None)
rec = jobs.pop(jid)
from src.agent_runtime.process_resources import job_from_record, retire_launch
from src.agent_runtime.resources import ProcessLaunchResource
try:
resource = job_from_record(rec)
retire_launch(ProcessLaunchResource.from_dict(rec["launch_resource"]),
resource.containment_id, job=resource)
except (ValueError, TypeError, OSError):
# Malformed/replaced publications never become deletion authority.
pass
for p in _JOBS_DIR.glob(f"{jid}.*"): # .sh .cmd.sh .log .exit
try:
p.unlink()
@@ -194,16 +238,28 @@ def _prune(jobs: Dict[str, Dict[str, Any]], now: float) -> bool:
@store_transaction(lambda: _STORE)
def refresh() -> Dict[str, Dict[str, Any]]:
def refresh(job_id=None) -> Dict[str, Dict[str, Any]]:
"""Reconcile every running job against disk. Marks done/failed (incl.
timeout). Idempotent — safe to call from a poll loop. Returns the store."""
jobs = _load()
for pid, proc in list(_LIVE_PROCS.items()):
if job_id is not None:
selected = jobs.get(job_id, {})
# Historical numeric PIDs can name a replacement child's cached
# handle. Targeted reads may poll only the frozen live incarnation;
# independent service maintenance may still reap completed handles.
if (selected.get("status") != "running"
or pid != selected.get("pid")
or process_ownership.verify(pid, selected.get("start_token"))
!= process_ownership.OWNED):
continue
if proc.poll() is not None:
_LIVE_PROCS.pop(pid, None)
changed = False
now = time.time()
for rec in jobs.values():
for jid, rec in jobs.items():
if job_id is not None and jid != job_id:
continue
if rec.get("status") != "running":
continue
exit_path = Path(rec.get("exit_path", ""))
@@ -218,7 +274,15 @@ def refresh() -> Dict[str, Dict[str, Any]]:
if rec.get("result_path"):
try:
report = json.loads(Path(rec["result_path"]).read_text(encoding="utf-8"))
rec.update(report)
# Result publication is not an identity producer. It cannot
# overwrite ownership, generations, PIDs, paths or authority.
if rec.get("resource_identity") and report.get("resource_identity") != rec["resource_identity"]:
raise ValueError("Result/job linkage mismatch")
if report.get("containment", {}).get("id") != rec.get("containment_id"):
raise ValueError("Result/receipt linkage mismatch")
for key in ("containment", "teardown", "output_truncated", "timed_out", "error", "failure_kind"):
if key in report:
rec[key] = report[key]
except (OSError, ValueError):
rec["status"], rec["exit_code"] = "failed", 1
rec["result_unavailable"] = True
@@ -243,7 +307,7 @@ def refresh() -> Dict[str, Dict[str, Any]]:
rec["ended_at"] = now
rec["died"] = True
changed = True
if _prune(jobs, now):
if job_id is None and _prune(jobs, now):
changed = True
if changed:
_save(jobs)
@@ -281,35 +345,67 @@ def _kill_record(rec):
def pending_followups() -> List[Dict[str, Any]]:
"""Finished jobs the agent hasn't been re-invoked for yet. The monitor
drains these; mark_followed_up() flips the flag only on success."""
drains these; valid continuations acknowledge success, invalid immutable
linkage receives a terminal disposition without fabricating delivery."""
jobs = refresh()
return [r for r in jobs.values()
if r.get("status") in ("done", "failed") and not r.get("followed_up")]
if r.get("status") in ("done", "failed") and not r.get("followed_up")
and r.get("followup_state") != "terminal_unfollowable"]
@store_transaction(lambda: _STORE)
def mark_followed_up(job_id: str) -> None:
def mark_unfollowable(job_id: str, *, expected_record) -> bool:
"""Suppress only the exact completed snapshot inspected by the monitor.
This conveys no read/signal/continuation authority and cannot renew a PID.
It deliberately needs no invalid/missing authority sidecar to suppress it.
"""
jobs = _load()
record = jobs.get(job_id)
if (record is None or record != expected_record or record.get("id") != job_id
or record.get("status") not in {"done", "failed"}):
return False
record["followup_state"] = "terminal_unfollowable"
_save(jobs)
return True
@store_transaction(lambda: _STORE)
def mark_followed_up(job_id: str, *, expected) -> None:
jobs = _load()
if job_id in jobs:
from src.agent_runtime.process_resources import validate_job
if expected.job_id != job_id:
raise ValueError("Acknowledgement job resource changed")
validate_job(expected, mutation=True)
jobs[job_id]["followed_up"] = True
_save(jobs)
def get(job_id: str) -> Optional[Dict[str, Any]]:
refresh() # reconcile against disk so status/exit_code are current
def peek(job_id: str) -> Optional[Dict[str, Any]]:
"""Resolve one record without reaping or changing any job."""
return _load().get(job_id)
def get(job_id: str, *, expected) -> Optional[Dict[str, Any]]:
from src.agent_runtime.process_resources import validate_job
if expected.job_id != job_id:
raise ValueError("Output job selector changed")
validate_job(expected)
refresh(job_id)
validate_job(expected)
rec = _load().get(job_id)
if rec:
from src.agent_runtime.process_resources import job_from_record
if job_from_record(rec) != expected:
raise ValueError("Output job resource changed")
rec = dict(rec)
rec["output"] = _read_output(rec)
return rec
def list_for_session(session_id: str) -> List[Dict[str, Any]]:
return [r for r in refresh().values() if r.get("session_id") == session_id]
@store_transaction(lambda: _STORE)
def kill(job_id: str) -> Optional[Dict[str, Any]]:
def kill(job_id: str, *, expected) -> Optional[Dict[str, Any]]:
"""Terminate a running job's process tree and mark it killed. Returns the
updated record, or None if the id is unknown. Idempotent: a job that already
finished is returned unchanged. Sets followed_up so the monitor does not also
@@ -318,6 +414,10 @@ def kill(job_id: str) -> Optional[Dict[str, Any]]:
rec = jobs.get(job_id)
if rec is None:
return None
from src.agent_runtime.process_resources import validate_job
if expected.job_id != job_id:
raise ValueError("Job selector changed")
validate_job(expected, mutation=True)
if rec.get("status") == "running":
outcome = _kill_record(rec)
rec["teardown"] = outcome.to_dict()
+45 -13
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio
import json
import logging
from enum import Enum, auto
from src import bg_jobs
from src.prompt_security import untrusted_context_message
@@ -26,6 +27,12 @@ POLL_INTERVAL_S = 5
_FOLLOWUP_MAX_ROUNDS = 12
class FollowupResult(Enum):
RETRYABLE_LATER = auto()
COMPLETED = auto()
TERMINAL_UNFOLLOWABLE = auto()
def _background_result_message(rec):
inject = (
f"[Background job {rec['id']} finished]\n\n"
@@ -104,22 +111,20 @@ async def _drain_agent(sess, messages, request_authority=None):
return full, tool_events
async def _run_followup(rec: dict) -> bool:
"""Re-invoke the agent in the job's session with the result. Returns True
if the follow-up completed (or there's nothing to do) — i.e. it's safe to
mark followed_up. Returns False to retry on the next tick."""
async def _run_followup(rec: dict) -> FollowupResult:
"""Continue only an exactly linked result; distinguish retry from terminal."""
from src.ai_interaction import get_session_manager
from core.models import ChatMessage
sm = get_session_manager()
if not sm:
return False # not ready yet — retry
return FollowupResult.RETRYABLE_LATER
sess = sm.get_session(rec["session_id"])
if not sess:
# Session was deleted — nothing to continue. Consider it handled so we
# don't retry forever.
logger.info("bg-followup: session %s gone for job %s — skipping", rec.get("session_id"), rec.get("id"))
return True
return FollowupResult.TERMINAL_UNFOLLOWABLE
# Don't write into a session that's mid-stream. The followup appends to
# history + save_sessions(); a concurrent live turn does the same, and with
@@ -129,19 +134,35 @@ async def _run_followup(rec: dict) -> bool:
from src import agent_runs
if agent_runs.is_active(sess.id):
logger.info("bg-followup: session %s busy (live turn) — deferring job %s", sess.id, rec.get("id"))
return False
return FollowupResult.RETRYABLE_LATER
except Exception:
pass
context = sess.get_context_messages()
context.append(_background_result_message(rec))
from src.agent_runtime.authority import restore_background_authority
from src.settings import get_setting
authority = restore_background_authority(
rec["id"], owner=getattr(sess, "owner", None), session_id=sess.id)
# A result can trigger a continuation only through the immutable producer
# linkage, never merely because it names an existing chat.
from src.agent_runtime.process_resources import job_from_record, validate_job
try:
resource = job_from_record(rec)
validate_job(resource)
if not authority.grants or (resource.owner, resource.thread_id, resource.request_id) != (
str(getattr(sess, "owner", None) or "").strip().casefold(), sess.id, authority.request_id):
return FollowupResult.TERMINAL_UNFOLLOWABLE
except (ValueError, TypeError, OSError, RuntimeError):
return FollowupResult.TERMINAL_UNFOLLOWABLE
context = sess.get_context_messages()
context.append(_background_result_message(rec))
authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ())
full, tool_events = await _drain_agent(sess, context, request_authority=authority)
# An awaited continuation must not deliver a result after its immutable
# linkage disappears or is replaced. This check grants no new authority.
try:
validate_job(resource)
except (ValueError, TypeError, OSError, RuntimeError):
return FollowupResult.TERMINAL_UNFOLLOWABLE
# Persist ONLY the assistant continuation so it renders as a normal agent
# turn — a standard chat bubble plus `tool_events` that the frontend
@@ -160,7 +181,19 @@ async def _run_followup(rec: dict) -> bool:
sm.save_sessions()
logger.info("bg-followup: auto-continued session %s for job %s (%d chars, %d tools)",
sess.id, rec["id"], len(full), len(tool_events))
return True
return FollowupResult.COMPLETED
async def _process_followup(rec):
outcome = await _run_followup(rec)
if outcome is FollowupResult.COMPLETED:
from src.agent_runtime.process_resources import job_from_record
bg_jobs.mark_followed_up(rec["id"], expected=job_from_record(rec))
elif outcome is FollowupResult.TERMINAL_UNFOLLOWABLE:
if not bg_jobs.mark_unfollowable(rec["id"], expected_record=rec):
return FollowupResult.RETRYABLE_LATER
logger.warning("bg-followup: job %s has no valid continuation linkage; retired from pending", rec.get("id"))
return outcome
async def _loop():
@@ -168,8 +201,7 @@ async def _loop():
try:
for rec in bg_jobs.pending_followups():
try:
if await _run_followup(rec):
bg_jobs.mark_followed_up(rec["id"])
await _process_followup(rec)
except Exception as e:
# Idempotent: leave followed_up=False so the next tick retries.
logger.warning("bg-followup failed for %s (will retry): %s", rec.get("id"), e)
+651
View File
@@ -0,0 +1,651 @@
"""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"
# Wave 3 session metadata supports only these observed glibc Linux artifacts.
# macOS/Windows and other architectures fail closed before any producer call.
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"}
+27 -19
View File
@@ -878,22 +878,28 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
async def _run_subprocess(argv, *, shell: bool = False, timeout: int = 120, label: str = "Command") -> Tuple[str, bool]:
"""Shared subprocess runner. Wraps the blocking subprocess.run in
asyncio.to_thread so the event loop stays responsive."""
import asyncio
import subprocess
"""Scheduled local work consumes the request's sealed launch ceiling."""
from src.agent_runtime.authority import active_request_authority, ExactOperation
from src.agent_runtime.process_resources import resolve_process_operation, bind_process_operation
from src.agent_runtime.resources import NativeBackendResource
from src.agent_tools.subprocess_tools import _run_owned_command
authority = active_request_authority()
if authority is None:
return "Scheduled process launch has no server authority.", False
if isinstance(argv, list) and argv and argv[0] == "ssh":
return "Remote scheduled workload requires an exact external backend binding.", False
command = argv[-1] if isinstance(argv, list) else argv
operation = ExactOperation.normalize("bash", command)
if not authority.permits(operation):
return "Scheduled launch differs from the sealed operation.", False
try:
result = await asyncio.to_thread(
subprocess.run, argv, shell=shell, capture_output=True, text=True, timeout=timeout,
)
output = (result.stdout or "").strip()
if result.returncode != 0 and result.stderr:
output += "\nSTDERR: " + result.stderr.strip()
return output or "(no output)", result.returncode == 0
except subprocess.TimeoutExpired:
return f"{label} timed out ({timeout}s)", False
except Exception as e:
return str(e), False
bound = resolve_process_operation(authority, operation, NativeBackendResource("bash"))
with bind_process_operation(bound):
result = await _run_owned_command(command, {"owner": authority.owner,
"session_id": authority.session_id}, tool="bash", timeout=timeout)
return result.get("output") or result.get("error") or "(no output)", result.get("exit_code") == 0
except (ValueError, OSError, RuntimeError) as error:
return str(error), False
async def action_ssh_command(owner: str, command: str = "", host: str = "localhost", **kwargs) -> Tuple[str, bool]:
@@ -3381,10 +3387,12 @@ async def action_cookbook_serve(
if srv.get("platform"): body["platform"] = srv["platform"]
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{internal_api_base()}/api/model/serve",
json=body, headers=headers)
data = r.json() if r.content else {}
from src.agent_runtime.local_model_control import model_control_headers
with model_control_headers("serve_model", command, owner, body, scheduled=True) as launch_headers:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{internal_api_base()}/api/model/serve",
json=body, headers=launch_headers)
data = r.json() if r.content else {}
except Exception as e:
return f"Launch HTTP failed: {e}", False
if not data.get("ok"):
+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
+2
View File
@@ -89,6 +89,8 @@ EMOJI_CACHE_DIR = os.path.join(DATA_DIR, "emoji_cache")
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")
+36
View File
@@ -6,6 +6,7 @@ import json
import signal
import sys
import types
import os
from pathlib import Path
# Launch by absolute script path, so a task workspace cannot shadow src.
@@ -39,6 +40,40 @@ async def supervise(payload: dict) -> None:
loop.add_signal_handler(signal.SIGTERM, task.cancel)
loop.add_signal_handler(signal.SIGINT, task.cancel)
try:
# The supervisor is held on stdin until *all* publication succeeds.
# No legacy payload can reconstruct ownership from its PID or receipt.
job = json.loads(Path(payload["job_store"]).read_text())[payload["job_id"]]
published = json.loads(Path(payload["launch_path"]).read_text())
sidecar = json.loads(Path(payload["authority_path"]).read_text())
resource = payload["resource_identity"]
launch = payload["launch_resource"]
from src.agent_runtime.resources import ProcessLaunchResource, BackgroundJobResource
from src.agent_runtime.process_resources import validate_launch_spec, validate_job_receipt
typed_launch = ProcessLaunchResource.from_dict(launch)
typed_job = BackgroundJobResource.from_dict(resource)
typed_launch.validate()
validate_launch_spec(typed_launch, spec)
supervisor = typed_job.processes[0]
supervisor.validate()
receipt = containment._load_records().get(grant.id)
validate_job_receipt(typed_job, receipt)
if (supervisor.identity.pid != os.getpid()
or (typed_job.owner, typed_job.request_id, typed_job.thread_id) !=
(typed_launch.owner, typed_launch.request_id, typed_launch.thread_id)
or (published["authority"]["owner"], published["authority"]["request_id"], published["authority"]["session_id"]) !=
(typed_job.owner, typed_job.request_id, typed_job.thread_id)):
raise ValueError("Detached producer ownership changed")
if (job.get("resource_identity") != resource or job.get("launch_resource") != launch
or published.get("job") != resource or published.get("launch") != launch
or sidecar.get("job") != resource or sidecar.get("authority") != published.get("authority")
or published.get("containment_id") != grant.id
or (receipt.get("owner"), receipt.get("mechanism"), receipt.get("mode"), receipt.get("workspace")) !=
(grant.owner, grant.mechanism, grant.mode, spec.workspace)
or info.get("external") is True
or resource["containment_id"] != grant.id
or resource["generation"] != launch["generation"]
or receipt.get("launch_generation") != launch["generation"]):
raise ValueError("Detached launch authority linkage mismatch")
with open(payload["log_path"], "w", encoding="utf-8") as log:
def capture(text):
log.write(text)
@@ -75,6 +110,7 @@ async def supervise(payload: dict) -> None:
except OSError:
# A failed log initialization must not hide completion metadata.
sys.stderr.write(output)
report["resource_identity"] = payload.get("resource_identity")
atomic_write_json(payload["result_path"], report)
# Publish completion last: refresh must never see an exit without metadata.
atomic_write_text(payload["exit_path"], str(code if code is not None else 1))
+6
View File
@@ -517,6 +517,12 @@ async def execute_api_call(
if not integration:
return {"error": f"Integration not found: {integration_id}", "exit_code": 1}
from src.agent_runtime.remote_resources import active_backend_operation, integration_resource
bound = active_backend_operation()
if bound is not None and bound.resource != integration_resource(integration):
return {"error": "Integration resource identity changed", "exit_code": 1,
"failure_kind": "resource_identity_denied"}
if not integration.get("enabled", True):
return {"error": f"Integration '{integration.get('name')}' is disabled", "exit_code": 1}
+49 -1
View File
@@ -164,6 +164,10 @@ class McpManager:
self._owner_tasks: Dict[str, asyncio.Task] = {}
# Tracking updates to tools/connections for RAG indexing / prompt cache
self._generation = 0
# Identity of the actual connection, not a PID or lifecycle contract.
self._resource_connections = {}
self._resource_endpoints = {}
self._resource_owners = {}
async def connect_server(
self,
@@ -177,6 +181,13 @@ class McpManager:
) -> bool:
"""Connect to an MCP server via stdio, SSE, or Streamable HTTP transport."""
try:
from src.agent_runtime.remote_resources import endpoint_identity, configuration_incarnation
self._resource_endpoints[server_id] = (
endpoint_identity(url) if transport in {"sse", "http"} else f"stdio:{server_id}",
configuration_incarnation((transport, url, command, args, env)))
if server_id == "memory":
effective_env = {**os.environ, **(env or {})}
self._resource_owners[server_id] = str(effective_env.get("ODYSSEUS_MCP_MEMORY_OWNER") or effective_env.get("ODYSSEUS_MEMORY_OWNER") or "").strip()
if transport == "stdio":
res = await self._connect_stdio(server_id, name, command, args or [], env or {})
elif transport == "sse":
@@ -243,6 +254,7 @@ class McpManager:
identity = ", ".join(identity_hints) if identity_hints else ""
self._sessions[server_id] = session
self._register_resource_connection(server_id, session)
self._stacks[server_id] = stack
self._tools[server_id] = tools
self._connections[server_id] = {
@@ -302,6 +314,7 @@ class McpManager:
})
self._sessions[server_id] = session
self._register_resource_connection(server_id, session)
self._stacks[server_id] = stack
self._tools[server_id] = tools
self._connections[server_id] = {
@@ -385,6 +398,7 @@ class McpManager:
})
self._sessions[server_id] = session
self._register_resource_connection(server_id, session)
self._stacks[server_id] = stack
self._tools[server_id] = tools
self._connections[server_id] = {
@@ -445,6 +459,7 @@ class McpManager:
logger.warning(f"Error closing MCP server {server_id}: {e}")
self._sessions.pop(server_id, None)
self._resource_connections.pop(server_id, None)
self._tools.pop(server_id, None)
self._connections.pop(server_id, None)
self._generation += 1
@@ -516,6 +531,33 @@ class McpManager:
"name": srv.name,
}
def _register_resource_connection(self, server_id, session):
from uuid import uuid4
endpoint = self._resource_endpoints.get(server_id)
if endpoint:
self._resource_connections[server_id] = (endpoint, uuid4().hex, session,
self._resource_owners.get(server_id, ""))
def resource_identity(self, qualified_name):
from src.agent_runtime.resources import ExternalResource
parts = qualified_name.split("__", 2)
if len(parts) != 3 or parts[0] != "mcp" or not parts[1] or not parts[2]:
return None
_, server, tool = parts
# The builtin memory producer uses a fixed owner, not model arguments.
# The builtin RAG producer has no owner contract; its legacy global
# store cannot acquire private read scope through discovery.
if server == "rag" or (server == "memory" and not self._resource_owners.get(server)):
return None
record = self._resource_connections.get(server)
if (not record or self._sessions.get(server) is not record[2]
or self._resource_endpoints.get(server) != record[0]
or self._resource_owners.get(server, "") != record[3]
or not any(row.get("name") == tool for row in self._tools.get(server, []))):
return None
return ExternalResource("mcp", record[0][0], server, qualified_name, record[1],
owner=record[3])
async def call_tool(self, qualified_name: str, arguments: Dict) -> Dict:
"""Call an MCP tool by its qualified name (mcp__{server_id}__{tool_name}).
@@ -532,6 +574,12 @@ class McpManager:
if not session:
return {"error": f"MCP server not connected: {server_id}", "exit_code": 1}
from src.agent_runtime.remote_resources import active_backend_operation
bound_backend = active_backend_operation()
if bound_backend is not None and self.resource_identity(qualified_name) != bound_backend.resource:
return {"error": "MCP resource binding changed", "exit_code": 1,
"failure_kind": "resource_identity_denied"}
try:
if server_id == BROWSER_MCP_SERVER_ID:
# The shared Playwright browser must not hold a turn forever.
@@ -554,7 +602,7 @@ class McpManager:
result = await self._do_call(session, tool_name, arguments)
except Exception as e:
# Auto-reconnect for builtin servers whose subprocess may have died
if self.is_builtin(server_id):
if bound_backend is None and self.is_builtin(server_id):
logger.warning(f"MCP call failed for {qualified_name}, attempting reconnect: {e}")
reconnected = await self._reconnect_builtin(server_id)
if reconnected:
+10
View File
@@ -284,11 +284,21 @@ def reap_orphans() -> Dict[str, Any]:
Blocking: a teardown escalates SIGTERM → grace → SIGKILL and waits for the
process to actually go. Call it off the event loop.
"""
# Observe publication consumers before receipt recovery can forget a dead
# manager's record. Publication retirement itself neither signals nor
# asserts successful teardown; containment remains the recovery authority.
from src.agent_runtime.process_resources import prune_foreground_publications
try:
publications_retired = prune_foreground_publications()
except (OSError, ValueError, TypeError):
publications_retired = 0
logger.warning("process_reaper: foreground publication retirement failed", exc_info=True)
report = {
"mechanism": process_ownership.inspection_mechanism(),
"grants": reap_containment_grants(),
"bg_jobs": reap_bg_jobs(),
"agent_tmux": reap_legacy_agent_tmux(),
"foreground_publications_retired": publications_retired,
}
if report["mechanism"] == process_ownership.MECHANISM_NONE:
logger.error(
+94 -1
View File
@@ -15,7 +15,7 @@ import secrets
import threading
import time
from dataclasses import dataclass, field
from typing import Any
from typing import Any, TYPE_CHECKING
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_DECISION,
@@ -27,6 +27,13 @@ from src.tool_approval_scopes import (
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
from src.agent_runtime.authority import RequestAuthority
if TYPE_CHECKING:
from src.agent_runtime.resource_binding import BoundFilesystemOperation
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
DEFAULT_MAX_PENDING_APPROVALS = 2048
@@ -119,6 +126,11 @@ def _binding_payload(
effects: tuple[str, ...],
result_integrity: str,
request_authority: RequestAuthority | None = None,
resource_operation=None,
backend_operation=None,
owned_operation=None,
process_operation=None,
browser_operation=None,
) -> dict[str, Any]:
return {
"owner": _normalized_owner(owner),
@@ -140,6 +152,11 @@ def _binding_payload(
"effects": list(effects),
"result_integrity": str(result_integrity),
"request_authority": request_authority.to_dict() if request_authority is not None else None,
"resource_operation": resource_operation.to_dict() if resource_operation is not None else None,
"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,
}
@@ -169,6 +186,12 @@ class PendingToolApproval:
# is never displayed or treated as authorization for the sealed action.
request_text: str = ""
request_authority: RequestAuthority | None = None
# Server-resolved targets at proposal time; never read from the approval UI.
resource_operation: BoundFilesystemOperation | None = None
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 {
@@ -278,6 +301,11 @@ class ExactToolApproval:
effects=effects,
result_integrity=result_integrity,
request_authority=self.pending.request_authority,
resource_operation=self.pending.resource_operation,
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
@@ -362,10 +390,65 @@ class ToolApprovalStore:
capabilities: ToolCapabilities,
request_text: Any = "",
request_authority: RequestAuthority | None = None,
client_runtime_context: dict | None = None,
) -> PendingToolApproval:
if request_authority is not None and not isinstance(request_authority, RequestAuthority):
raise TypeError("Approval authority must be server-owned RequestAuthority")
now = time.time()
from src.agent_runtime.authority import ExactOperation
from src.agent_runtime.resource_binding import NATIVE_FILESYSTEM_TOOLS, resolve_filesystem_operation
from src.agent_runtime.resources import FilesystemRoot
resource_operation = None
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
try:
operation = ExactOperation.normalize(tool_name, content)
backend = resolve_backend(operation.transport_tool, context=client_runtime_context, content=operation.input, owner=_normalized_owner(owner))
if request_authority is not None and request_authority.inherited:
if not request_authority.permits(operation) or backend not in request_authority.backend_resources:
raise ValueError("Child approval exceeds originating authority")
backend_operation = BoundBackendOperation(backend,
request_authority.request_id if request_authority is not None else "",
_normalized_owner(owner), str(session_id or ""), operation.transport_tool, operation.input)
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,
document_id=document_id)
if request_authority is not None and request_authority.inherited:
if not all(any(scope.permits(r) for scope in request_authority.owned_scopes) for r in resolved_owned.resources):
raise ValueError("Child approval exceeds originating record scope")
owned_operation = resolved_owned
if owned_operation.document_id:
document_id = owned_operation.document_id
document_version = owned_operation.document_version
document_digest = owned_operation.document_digest
except (ValueError, TypeError, OSError, RuntimeError, AttributeError):
pass # Unresolved proposals are never reconstructed at execution.
if tool_name in NATIVE_FILESYSTEM_TOOLS and backend_operation is not None and isinstance(backend_operation.resource, NativeBackendResource):
try:
roots = request_authority.resource_roots if request_authority is not None else ()
if not roots and workspace and (request_authority is None or not request_authority.inherited):
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
resource_operation = resolve_filesystem_operation(
ExactOperation.normalize(tool_name, content), roots=roots, workspace=workspace or "",
request_id=request_authority.request_id if request_authority is not None else "")
except (ValueError, TypeError, OSError, RuntimeError):
# An unresolved proposal may be displayed, but it cannot execute
# after approval by reconstructing its targets at claim time.
pass
effects = tuple(sorted(effect.value for effect in capabilities.effects))
result_integrity = capabilities.result_integrity.value
payload = _binding_payload(
@@ -384,6 +467,11 @@ class ToolApprovalStore:
effects=effects,
result_integrity=result_integrity,
request_authority=request_authority,
resource_operation=resource_operation,
backend_operation=backend_operation,
owned_operation=owned_operation,
process_operation=process_operation,
browser_operation=browser_operation,
)
pending = PendingToolApproval(
approval_id=secrets.token_urlsafe(32),
@@ -408,6 +496,11 @@ class ToolApprovalStore:
continuation_query=payload["continuation_query"],
request_text=str(request_text or ""),
request_authority=request_authority,
resource_operation=resource_operation,
backend_operation=backend_operation,
owned_operation=owned_operation,
process_operation=process_operation,
browser_operation=browser_operation,
)
with self._lock:
self._purge_expired_locked(now)
+225 -27
View File
@@ -19,7 +19,7 @@ import secrets
import sys
import time
from contextlib import contextmanager
from dataclasses import dataclass
from dataclasses import dataclass, field, replace
from typing import Any, Awaitable, Callable, Dict, Iterator, Optional, Tuple
@@ -43,6 +43,18 @@ from src.constants import (
)
from src.path_confinement import canonical_root, confine, is_inside
from src.tool_utils import _truncate, get_mcp_manager
from src.tool_types import ToolBlock
from src.agent_runtime.resource_binding import (
NATIVE_FILESYSTEM_TOOLS, active_resource_operation, bind_resource_operation,
resolve_filesystem_operation,
)
from src.agent_runtime.resources import ExternalResource, NativeBackendResource, ResourceIdentityError
from src.agent_runtime.remote_resources import (
active_backend_operation, bind_backend_operation, bind_backend_for_operation,
)
from src.agent_runtime.owned_resources import (
active_owned_operation, bind_owned_operation, admit_owned_operation, needs_owned_binding,
)
class _MissingToolSecurityContext:
@@ -82,6 +94,9 @@ class AgentExecutionBridge:
route_tool: ExecutionBridgeHandler
supported_tools: frozenset[str]
name: str = "external_environment"
endpoint_id: str = ""
incarnation: str = field(default_factory=lambda: secrets.token_hex(16), init=False)
configuration_id: str = ""
def __post_init__(self) -> None:
if not callable(self.route_tool):
@@ -89,6 +104,12 @@ class AgentExecutionBridge:
if not self.supported_tools:
raise ValueError("execution bridge supported_tools cannot be empty")
def resource_identity(self, tool):
from src.agent_runtime.resources import ExternalResource
from src.agent_runtime.remote_resources import endpoint_identity
endpoint = endpoint_identity(self.endpoint_id) if self.endpoint_id else "bridge:" + self.name
return ExternalResource("execution_bridge", endpoint, self.name, tool, self.configuration_id or self.incarnation)
_active_execution_bridge: contextvars.ContextVar[AgentExecutionBridge | None] = (
contextvars.ContextVar("agent_execution_bridge", default=None)
@@ -297,7 +318,7 @@ def _client_bridge(client_runtime_context: Optional[Dict]) -> Optional[Dict]:
context = client_runtime_context if isinstance(client_runtime_context, dict) else {}
if str(context.get("surface") or "").strip() != "odysseus-tui":
return None
bridge = context.get("host_shell_bridge")
bridge = context.get("host_shell_bridge") or context.get("hostShellBridge")
if not isinstance(bridge, dict):
return None
url = str(bridge.get("url") or "").strip()
@@ -830,6 +851,9 @@ def _resolve_tool_path(raw_path: str) -> str:
When a workspace is active for this turn, paths are confined to it instead
of the default allowlist (see _resolve_tool_path_in_workspace).
"""
resource_operation = active_resource_operation()
if resource_operation is not None:
return resource_operation.resolve_path(raw_path)
ws = get_active_workspace()
if ws:
return _resolve_tool_path_in_workspace(ws, raw_path)
@@ -954,7 +978,10 @@ def vet_workspace(raw: str) -> Optional[str]:
def agent_cwd() -> str:
"""Working directory for agent subprocesses (bash/python/background jobs):
the active workspace when set, else the persistent data dir."""
return get_active_workspace() or _AGENT_WORKDIR
from src.agent_runtime.process_resources import active_process_operation
bound = active_process_operation()
return (bound.launch.scope.root.path if bound is not None and bound.launch is not None
else get_active_workspace() or _AGENT_WORKDIR)
def get_mcp_manager():
@@ -972,6 +999,9 @@ def _resolve_search_root(raw_path: str) -> str:
primary root (project data dir) and a supplied path is confined by the
global allowlist + sensitive-file policy.
"""
resource_operation = active_resource_operation()
if resource_operation is not None:
return resource_operation.resolve_path(raw_path, search=True)
raw = (raw_path or "").strip()
ws = get_active_workspace()
if ws:
@@ -1141,8 +1171,13 @@ async def _call_mcp_tool(
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
) -> Dict:
"""Route a legacy tool call through the MCP manager, with direct fallbacks."""
bound = active_backend_operation()
if bound is not None and isinstance(bound.resource, NativeBackendResource):
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"Native tool '{tool}' unavailable", "exit_code": 1}
mcp = get_mcp_manager()
if not mcp:
if bound is not None:
raise ResourceIdentityError("Pinned MCP backend is unavailable")
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
server_id, tool_name = _MCP_TOOL_MAP[tool]
@@ -1156,7 +1191,7 @@ async def _call_mcp_tool(
result = _normalize_mcp_text_error(result)
# If MCP server not connected, try direct fallback
if isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""):
if bound is None and isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""):
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb)
if fallback:
return fallback
@@ -1211,8 +1246,35 @@ def _split_bg_marker(content: str):
return False, content
# Variables a legitimate agent bash/python subprocess needs from the host.
# Anything not listed here is never inherited.
_SAFE_SUBPROCESS_VARS = frozenset({
# POSIX execution
"PATH", "LANG", "LC_ALL", "LC_CTYPE", "LC_MESSAGES", "TZ",
"USER", "LOGNAME", "SHELL", "TMPDIR", "TEMP", "TMP",
# Python isolation / virtualenvs
"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV",
"ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES",
# Windows system essentials
"SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT",
"ALLUSERSPROFILE", "PROGRAMDATA", "COMMONPROGRAMFILES",
"PROGRAMFILES", "PROGRAMFILES(X86)",
# XDG / runtime
"XDG_RUNTIME_DIR", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME",
# Bubblewrap / container paths
"LD_LIBRARY_PATH",
})
def _agent_subprocess_env() -> dict:
return {**os.environ, "TERM": "xterm-256color", "COLUMNS": "120", "LINES": "40", "HOME": _AGENT_WORKDIR}
base = {
key: os.environ[key]
for key in _SAFE_SUBPROCESS_VARS
if key in os.environ
}
base.setdefault("PATH", os.environ.get("PATH") or os.defpath or "/usr/local/bin:/usr/bin:/bin")
base.setdefault("LANG", "C.UTF-8")
base.update({"TERM": "xterm-256color", "COLUMNS": "120", "LINES": "40", "HOME": _AGENT_WORKDIR})
return base
async def _direct_fallback(
@@ -1228,6 +1290,9 @@ async def _direct_fallback(
_subproc_env = _agent_subprocess_env()
try:
owned = active_owned_operation()
if owned is not None:
owned.validate()
ctx = {
"progress_cb": progress_cb,
"subproc_env": _subproc_env,
@@ -1237,6 +1302,8 @@ async def _direct_fallback(
"disabled_tools": frozenset(disabled_tools or ()),
"tool_policy": tool_policy,
"request_authority": active_request_authority(),
"resource_operation": active_resource_operation(),
"owned_operation": active_owned_operation(),
}
from src.agent_tools import TOOL_HANDLERS
@@ -1260,6 +1327,9 @@ async def _document_tool_dispatch(
) -> Optional[Dict]:
"""Route a document tool through TOOL_HANDLERS with the right ctx shape."""
from src.agent_tools import TOOL_HANDLERS
owned = active_owned_operation()
if owned is not None:
owned.validate()
ctx = {
"session_id": session_id,
"owner": owner,
@@ -1279,7 +1349,14 @@ async def _document_tool_dispatch(
from src.agent_runtime.journal import dispatched, mark_authorized, mark_dispatch, record_action
from src.agent_runtime.authority import (
MISSING_AUTHORITY, ExactOperation, RequestAuthority, active_request_authority,
bind_request_authority, save_background_authority,
bind_request_authority,
)
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,
)
@@ -1338,7 +1415,7 @@ async def execute_tool_block(
owner=owner, session_id=session_id, workspace=workspace,
tool_name=getattr(block, "tool_type", None), content=getattr(block, "content", None)))
admitted = valid and (authority.permits(operation) or exact_admission)
except (ValueError, TypeError, AttributeError) as error:
except (ValueError, TypeError) as error:
return f"{getattr(block, 'tool_type', '')}: invalid arguments", {
"error": (f"Tool arguments are not valid JSON: {error}"
if isinstance(error, json.JSONDecodeError) else str(error)),
@@ -1364,6 +1441,90 @@ async def execute_tool_block(
"exit_code": 1, "failure_kind": "turn_contract_denied",
}
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:
raise ResourceIdentityError("Approved action has no sealed backend identity")
backend_operation = bind_backend_for_operation(
authority, operation, context=client_runtime_context,
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")
process_operation = resolve_process_operation(authority, operation, backend_operation.resource,
approved=pending.process_operation if pending is not None else None, exact_admission=exact_admission)
if needs_owned_binding(operation) and not external_resource_call:
if pending is not None and pending.owned_operation is None:
raise ResourceIdentityError("Approved action has no sealed owned resource identity")
owned_operation = admit_owned_operation(
authority, operation, document_id=active_document_id,
approved=pending.owned_operation if pending is not None else None,
exact_admission=exact_admission)
except (ValueError, TypeError, OSError) as error:
return f"{transport}: BLOCKED", {
"error": str(error), "exit_code": 1, "blocked": True,
"failure_kind": "resource_identity_denied",
**({"policy": "exact_tool_approval"} if exact_approval is not None else {}),
}
resource_operation = None
if operation.tool in NATIVE_FILESYSTEM_TOOLS and not external_resource_call:
try:
roots = authority.resource_roots
approved_resource = exact_approval.pending.resource_operation if exact_approval is not None else None
if exact_approval is not None and approved_resource is None:
raise ValueError("Approved filesystem action has no sealed resource identity")
if exact_admission and not roots and approved_resource is not None:
# This single exact action can use only the roots sealed with
# its proposal. The request/child authority is never widened.
roots = tuple(dict.fromkeys(b.resource.root for b in approved_resource.bindings))
if any(r.owner and r.owner != authority.owner for r in roots):
raise ValueError("Filesystem resource owner differs from request authority")
resource_operation = resolve_filesystem_operation(
operation, roots=roots, workspace=authority.workspace, request_id=authority.request_id)
if approved_resource is not None:
if approved_resource.request_id and approved_resource.request_id != authority.request_id:
raise ValueError("Approved resource belongs to another request")
if replace(resource_operation, request_id=approved_resource.request_id) != approved_resource:
raise ValueError("Approved filesystem resource identity changed")
except (ValueError, TypeError, OSError, RuntimeError) as error:
return f"{transport}: BLOCKED", {
"error": str(error), "exit_code": 1, "blocked": True,
"failure_kind": "resource_identity_denied",
}
approval_claimed = False
if exact_approval is not None:
if (
@@ -1450,29 +1611,26 @@ async def execute_tool_block(
token = _active_workspace.set(workspace or None)
try:
with bind_request_authority(authority):
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_browser_operation(browser_operation)):
output = await _execute_tool_block_impl(
block,
ToolBlock(transport, normalized.execution_input) if normalized is not None else block,
session_id=session_id,
disabled_tools=disabled_tools,
owner=owner,
progress_cb=progress_cb,
tool_policy=tool_policy,
approved_document_id=(
exact_approval.pending.document_id
if approval_claimed
else None
),
approved_document_version=(
exact_approval.pending.document_version
if approval_claimed
else None
),
approved_document_digest=(
exact_approval.pending.document_digest
if approval_claimed
else None
),
approved_document_id=sealed_document.document_id if sealed_document is not None else None,
approved_document_version=sealed_document.document_version if sealed_document is not None else None,
approved_document_digest=sealed_document.document_digest if sealed_document is not None else None,
active_document_id=active_document_id,
client_runtime_context=client_runtime_context,
)
@@ -1483,6 +1641,11 @@ async def execute_tool_block(
getattr(block, "content", None),
)
return output
except ResourceIdentityError as error:
return f"{transport}: BLOCKED", {
"error": str(error), "exit_code": 1, "blocked": True,
"failure_kind": "resource_identity_denied",
}
finally:
_active_workspace.reset(token)
@@ -1611,10 +1774,20 @@ async def _execute_tool_block_impl(
return desc, result
execution_bridge = get_active_execution_bridge()
backend = active_backend_operation()
if backend is not None:
backend.validate(client_runtime_context)
owned = active_owned_operation()
if owned is not None:
owned.validate()
bridge_owns_tool = (
execution_bridge is not None
and tool in execution_bridge.supported_tools
and active_resource_operation() is None
and (backend is None or backend.resource.namespace == "execution_bridge")
)
if backend is not None and backend.resource.namespace == "execution_bridge" and not bridge_owns_tool:
raise ResourceIdentityError("Pinned external execution bridge is unavailable")
# Public-owner restrictions protect tools executed by this deployment.
# A request-scoped execution bridge is a separate, explicit authority for
@@ -1673,14 +1846,15 @@ async def _execute_tool_block_impl(
},
)
if tool in _ROUTED_BRIDGE_TOOLS and _client_bridge(client_runtime_context) is not None:
if (active_resource_operation() is None and (backend is None or backend.resource.namespace == "client_bridge") and tool in _ROUTED_BRIDGE_TOOLS
and _client_bridge(client_runtime_context) is not None):
return await dispatched(_route_tool_via_bridge(tool, content, session_id, client_runtime_context))
# Background execution: a `bash` block whose first line is the `#!bg`
# marker runs DETACHED — returns a job id immediately so the chat stream
# isn't held open for a multi-minute install/ffmpeg/download. The always-on
# monitor re-invokes the agent with the full output when the job finishes.
if tool == "bash" and session_id:
if tool == "bash" and session_id and (backend is None or isinstance(backend.resource, NativeBackendResource)):
_is_bg, _bg_cmd = _split_bg_marker(content)
if _is_bg and _bg_cmd:
from src import bg_jobs
@@ -1692,7 +1866,6 @@ async def _execute_tool_block_impl(
return "bash (background): containment unavailable", containment.unavailable_tool_result(exc, tool="bash")
# Only this server launch may seal detached-job authority; a
# handler/bridge output carrying a job id is not a grant source.
save_background_authority(rec["id"], active_request_authority())
short = _bg_cmd.strip().split(chr(10))[0][:80]
desc = f"bash (background): {short}"
result = {
@@ -1719,6 +1892,31 @@ async def _execute_tool_block_impl(
from src.ai_interaction import do_generate_image
desc = "generate_image"
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner))
elif (tool in NATIVE_FILESYSTEM_TOOLS
and (active_resource_operation() is not None or tool != "apply_patch"
or not _tui_host_bridge_patch_url(client_runtime_context))):
if active_resource_operation() is None:
return f"{tool}: BLOCKED", {
"error": "Native filesystem dispatch has no bound resource operation",
"exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied",
}
# Backend selection is pinned. MCP connection availability cannot
# redirect an admitted native resource to a different filesystem.
original = active_resource_operation().operation.input
desc = f"{tool}: {original.split(chr(10))[0][:80]}"
result = await dispatched(_direct_fallback(tool, content, owner=owner, session_id=session_id)) \
or {"error": f"{tool}: execution failed", "exit_code": 1}
if tool == "edit_file":
desc = result.get("output") or result.get("error") or "edit_file"
elif tool in {"bash", "python"} and backend is not None and isinstance(backend.resource, NativeBackendResource):
# Native reservations are pinned to the native producer. Pass the
# application binding explicitly rather than the MCP fallback's empty
# owner/session context.
first_line = content.split(chr(10))[0][:80]
desc = f"{tool}: {first_line}"
result = await dispatched(_direct_fallback(tool, content, progress_cb=progress_cb,
owner=owner, session_id=session_id, client_runtime_context=client_runtime_context)) \
or {"error": f"{tool}: execution failed", "exit_code": 1}
elif tool in _MCP_TOOL_MAP:
first_line = content.split(chr(10))[0][:80]
desc = f"{tool}: {first_line}"
+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
}
}
},
+17 -11
View File
@@ -772,9 +772,11 @@ async def do_download_model(content: str, owner: Optional[str] = None) -> Dict:
if env_cfg.get("platform"): payload["platform"] = env_cfg["platform"]
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/download",
json=payload, headers=_internal_headers())
from src.agent_runtime.local_model_control import model_control_headers
with model_control_headers("download_model", content, owner, payload) as launch_headers:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/download",
json=payload, headers=launch_headers)
data = resp.json()
if data.get("ok"):
sid = data.get("session_id", "?")
@@ -857,9 +859,11 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
if env_cfg.get("platform"): payload["platform"] = env_cfg["platform"]
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
json=payload, headers=_internal_headers())
from src.agent_runtime.local_model_control import model_control_headers
with model_control_headers("serve_model", content, owner, payload) as launch_headers:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
json=payload, headers=launch_headers)
data = resp.json()
if data.get("ok"):
sid = data.get("session_id", "?")
@@ -1227,8 +1231,8 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
)
target_label = f"{session_id} on {remote}"
else:
cmd = f"tmux kill-session -t {shlex.quote(session_id)}"
target_label = session_id
return {"error": "Local Cookbook control has no admitted process resource; session discovery is not ownership",
"exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
# Capture what this session owns BEFORE the kill. Once tmux tears the
# session down the pane is gone, and with it the only evidence linking a
@@ -1908,9 +1912,11 @@ async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict:
payload["ssh_port"] = env_cfg["ssh_port"]
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
json=payload, headers=_internal_headers())
from src.agent_runtime.local_model_control import model_control_headers
with model_control_headers("serve_preset", content, owner, payload) as launch_headers:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
json=payload, headers=launch_headers)
data = resp.json()
if data.get("ok"):
sid = data.get("session_id", "?")
+7 -3
View File
@@ -77,6 +77,10 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
if action == "list" and list_search_query:
action = "search"
args.setdefault("query", list_search_query)
from src.agent_runtime.owned_resources import active_owned_operation
bound = active_owned_operation()
if bound is not None:
bound.validate()
db = SessionLocal()
def _norm_note_title(value: str) -> str:
@@ -98,7 +102,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
def _note_by_prefix(note_id: str):
if not note_id:
return None
q = db.query(Note).filter(Note.id.startswith(note_id))
q = db.query(Note).filter(Note.id == note_id if bound is not None else Note.id.startswith(note_id))
if owner:
q = q.filter(Note.owner == owner)
return q.first()
@@ -415,7 +419,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
elif action == "update":
note_id = _note_id_arg()
note = _note_by_prefix(note_id)
if not note:
if not note and bound is None:
title_query = str(
args.get("title")
or args.get("query")
@@ -489,7 +493,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
elif action == "delete":
note_id = _note_id_arg()
note = _note_by_prefix(note_id)
if not note:
if not note and bound is None:
title_query = str(
args.get("title")
or args.get("query")
+8 -1
View File
@@ -689,9 +689,16 @@ async def do_api_call(content: str) -> Dict:
pass
integration_name = args.get("integration", "")
from src.agent_runtime.remote_resources import active_backend_operation
bound = active_backend_operation()
if bound is not None:
bound.validate()
if bound.resource.namespace != "integration":
return {"error": "API call has no integration resource binding", "exit_code": 1}
integration_name = bound.resource.server_id
integrations = load_integrations()
intg = next((i for i in integrations if i["id"] == integration_name
or i["name"].lower() == integration_name.lower()), None)
or (bound is None and i["name"].lower() == integration_name.lower())), None)
if not intg:
available = ", ".join(i["name"] for i in integrations if i.get("enabled", True))
return {"error": f"No integration matching '{integration_name}'. Available: {available or 'none configured'}", "exit_code": 1}
+5 -1
View File
@@ -68,6 +68,10 @@ async def do_vault_search(content: str, owner: Optional[str] = None) -> Dict:
except json.JSONDecodeError:
return {"error": "Failed to parse bw output", "exit_code": 1}
from src.agent_runtime.owned_resources import active_owned_operation, observe_vault_records
if active_owned_operation() is not None:
observe_vault_records(owner, cfg, items)
if not items:
return {"output": f"No vault items match '{query}'.", "exit_code": 0}
@@ -79,7 +83,7 @@ async def do_vault_search(content: str, owner: Optional[str] = None) -> Dict:
username = login.get("username", "")
uris = login.get("uris") or []
url = uris[0].get("uri", "") if uris else ""
parts = [f"[{item_id[:8]}] {name}"]
parts = [f"[{item_id}] {name}"]
if username:
parts.append(f"user: {username}")
if url:
+1 -1
View File
@@ -10,7 +10,7 @@ from src import containment
def capture_owned_spawn(monkeypatch, tmp_path):
captured = {}
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path.parent / (tmp_path.name + "-control") / "grants.json")
monkeypatch.setattr(containment, "_pgid_of", lambda pid: pid)
async def fake_exec(*argv, **kwargs):
+98
View File
@@ -0,0 +1,98 @@
"""Explicit trusted producer fixtures; no production authority fallback."""
from contextlib import contextmanager
from dataclasses import replace
import json
from pathlib import Path
from uuid import uuid4
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority
from src.agent_runtime.resources import BackgroundJobResource, NativeBackendResource, ProcessResource
from src.agent_runtime.process_resources import bind_process_operation, resolve_process_operation, publish_launch
from src.process_lifecycle import ProcessIdentity
@contextmanager
def launch_authority(content, workspace, *, tool="bash", owner="", session_id="chat", authority=None):
authority = authority or RequestAuthority("producer-test", owner, session_id, str(workspace), (OperationGrant(tool),))
bound = resolve_process_operation(authority, ExactOperation.normalize(tool, content), NativeBackendResource(tool))
with bind_request_authority(authority), bind_process_operation(bound):
yield authority, bound
def launch(command, session_id="chat", *, cwd, **kwargs):
from src import bg_jobs
with launch_authority(command, cwd, session_id=session_id):
return bg_jobs.launch(command, session_id, cwd=cwd, **kwargs)
def identity(job_id):
from src import bg_jobs
from src.agent_runtime.process_resources import job_from_record
return job_from_record(bg_jobs.peek(job_id))
def get(job_id):
from src import bg_jobs
return bg_jobs.get(job_id, expected=identity(job_id))
def kill(job_id):
from src import bg_jobs
return bg_jobs.kill(job_id, expected=identity(job_id))
def seed_linkage(record, workspace, *, owner="", request_id="producer-test"):
"""A fake server spawn record, with an explicit fake lifecycle observation."""
from src import bg_jobs, containment
from src.agent_runtime.authority import save_background_authority
from src.agent_runtime.process_resources import resolve_process_operation
authority = RequestAuthority(request_id, owner, record["session_id"], str(workspace), (OperationGrant("bash"),))
bound = resolve_process_operation(authority, ExactOperation.normalize("bash", record["command"]), NativeBackendResource("bash"))
receipt = uuid4().hex
record.update(containment_id=receipt, start_token="test-boot:start", pgid=record["pid"])
process = ProcessResource("native:bg_jobs", owner, request_id, record["session_id"],
ProcessIdentity(record["pid"], record["start_token"], record["pgid"]), "supervisor", record["id"], receipt)
resource = BackgroundJobResource("native:bg_jobs", record["id"], bound.launch.generation,
owner, request_id, record["session_id"], receipt, (process,))
record.update(resource_identity=resource.to_dict(), launch_resource=bound.launch.to_dict())
from core.atomic_io import atomic_write_json
receipts = containment._load_records()
receipts[receipt] = {"id": receipt, "launch_generation": resource.generation,
"owner": "bg:" + resource.thread_id, "supervisor_pid": process.identity.pid,
"supervisor_token": process.identity.start_token, "mechanism": "process_group"}
atomic_write_json(containment._store_path(), receipts)
publish_launch(bound.launch, authority, receipt, job=resource, processes=(process,))
save_background_authority(record["id"], authority, resource=resource)
return resource
def authorized_handler(handler, workspace):
async def execute(content, ctx):
from src.agent_runtime.process_resources import active_process_operation
from src.agent_runtime.authority import active_request_authority
if active_process_operation() is not None or active_request_authority() is not None:
return await handler(content, ctx)
tool = "python" if handler.__qualname__.startswith("PythonTool") else "bash"
from src.agent_runtime.resources import FilesystemRoot
from src.agent_runtime.process_resources import seal_launch_scope
owner = str(ctx.get("owner") or "").casefold()
authority = RequestAuthority("producer-test", owner, str(ctx.get("session_id") or ""), str(workspace), (OperationGrant(tool),))
authority = replace(authority, launch_scopes=(seal_launch_scope(NativeBackendResource(tool),
FilesystemRoot.seal(workspace, owner=owner), env=ctx.get("subproc_env")),))
with launch_authority(content, workspace, tool=tool, authority=authority):
return await handler(content, ctx)
return execute
def install_native_authority(monkeypatch, workspace):
from src.agent_tools import subprocess_tools
from src import tool_execution
from src.constants import DATA_DIR
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
original = cls.execute
async def execute(self, content, ctx, _original=original):
selected = Path(tool_execution.agent_cwd())
if selected == Path(DATA_DIR):
selected = Path(workspace)
return await authorized_handler(_original.__get__(self), selected)(content, ctx)
monkeypatch.setattr(cls, "execute", execute)
+20 -2
View File
@@ -14,16 +14,34 @@ def server_authorized_executor(executor):
from src.agent_runtime.authority import OperationGrant, RequestAuthority
from src.tool_policy import known_tool_names
from src.turn_contract import canonical_tool
from src.agent_runtime.remote_resources import seal_backends
from src.agent_runtime.resources import FilesystemRoot, NativeBackendResource, ProcessLaunchScope
from src.containment import DEFAULT_REQUIRED
from src.agent_runtime.process_resources import seal_launch_scope
from pathlib import Path
import tempfile
call_signature = signature(executor)
@wraps(executor)
async def execute(*args, **kwargs):
bound = call_signature.bind(*args, **kwargs)
parameters = bound.arguments
grants = tuple(OperationGrant(name) for name in sorted(
{canonical_tool(n) for n in known_tool_names()} | {"list_dir", "find_files"}))
original = parameters.get("exact_approval")
authority = original.pending.request_authority if original is not None else None
if authority is not None:
kwargs.setdefault("request_authority", authority)
scratch = Path(tempfile.mkdtemp(prefix="odysseus-dispatch-fixture-"))
launch_scopes = (None if parameters.get("workspace") else tuple(
seal_launch_scope(NativeBackendResource(tool), FilesystemRoot.seal(scratch))
for tool in ("bash", "python")))
kwargs.setdefault("request_authority", RequestAuthority(
"standalone-test-request", str(parameters.get("owner") or "").strip().casefold(),
str(parameters.get("session_id") or ""), str(parameters.get("workspace") or ""),
tuple(OperationGrant(name) for name in sorted(
{canonical_tool(n) for n in known_tool_names()} | {"list_dir", "find_files"})),
grants,
launch_scopes=launch_scopes,
backend_resources=seal_backends((g.tool for g in grants), context=parameters.get("client_runtime_context"),
owner=str(parameters.get("owner") or "").strip().casefold()),
))
return await executor(*args, **kwargs)
return execute
+7 -4
View File
@@ -32,10 +32,11 @@ def test_direct_bash_subprocess_has_closed_stdin(monkeypatch, tmp_path):
from src import tool_execution
from tests.containment_helpers import capture_owned_spawn
from tests.process_resource_helpers import authorized_handler
captured = capture_owned_spawn(monkeypatch, tmp_path)
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
result = asyncio.run(subprocess_tools.BashTool().execute("echo ok", {}))
result = asyncio.run(authorized_handler(subprocess_tools.BashTool().execute, tmp_path)("echo ok", {}))
assert result["exit_code"] == 0
if "ody-boundary" in captured["argv"]:
@@ -66,7 +67,7 @@ def test_bash_rejects_empty_command_instead_of_reporting_success(monkeypatch):
assert "command is required" in result["error"]
def test_bash_rejects_unicode_ffmpeg_drawtext_without_explicit_font(monkeypatch):
def test_bash_rejects_unicode_ffmpeg_drawtext_without_explicit_font(monkeypatch, tmp_path):
from src.agent_tools import subprocess_tools
async def fail_spawn(*_args, **_kwargs):
@@ -79,7 +80,8 @@ def test_bash_rejects_unicode_ffmpeg_drawtext_without_explicit_font(monkeypatch)
lambda _text: "/home/user/.local/share/fonts/NotoSansCJK-Regular.ttc",
)
result = asyncio.run(subprocess_tools.BashTool().execute(
from tests.process_resource_helpers import authorized_handler
result = asyncio.run(authorized_handler(subprocess_tools.BashTool().execute, tmp_path)(
"ffmpeg -i in.mp4 -vf \"drawtext=text='你好':x=10:y=10\" out.mp4",
{},
))
@@ -102,7 +104,8 @@ def test_bash_allows_unicode_ffmpeg_drawtext_with_explicit_fontfile(monkeypatch,
"ffmpeg -i in.mp4 -vf \"drawtext=fontfile=/fonts/NotoSansCJK.ttc:"
"text='你好':x=10:y=10\" out.mp4"
)
result = asyncio.run(subprocess_tools.BashTool().execute(command, {}))
from tests.process_resource_helpers import authorized_handler
result = asyncio.run(authorized_handler(subprocess_tools.BashTool().execute, tmp_path)(command, {}))
assert result["exit_code"] == 0
assert "drawtext" in captured["command"]
+4 -3
View File
@@ -8,6 +8,7 @@ from types import SimpleNamespace
from src.agent_tools import subprocess_tools
from src import containment
from tests.containment_helpers import capture_owned_spawn
from tests.process_resource_helpers import authorized_handler
@pytest.mark.asyncio
@@ -98,7 +99,7 @@ async def test_windows_bash_tool_passes_ctx_env_through_to_the_child(monkeypatch
monkeypatch.setattr(containment, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe")
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: str(tmp_path))
result = await subprocess_tools.BashTool().execute(
result = await authorized_handler(subprocess_tools.BashTool().execute, tmp_path)(
"pwd",
{"subproc_env": env, "session_id": "chat-1"},
)
@@ -135,7 +136,7 @@ async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypat
monkeypatch.setattr(containment, "find_bash", lambda: None)
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: str(tmp_path))
result = await subprocess_tools.BashTool().execute(
result = await authorized_handler(subprocess_tools.BashTool().execute, tmp_path)(
"pwd",
{"subproc_env": {}, "session_id": None},
)
@@ -164,7 +165,7 @@ async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch, tm
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fail_tmux)
result = await subprocess_tools.BashTool().execute(
result = await authorized_handler(subprocess_tools.BashTool().execute, workspace)(
"pwd",
{"subproc_env": {}, "session_id": "chat-1"},
)
+7 -1
View File
@@ -432,14 +432,20 @@ def test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema(monke
name="native_environment",
)
from dataclasses import replace
with bind_execution_bridge(bridge):
authority = create_request_authority("Search email for Project Alpha.", owner="public-user")
authority = replace(authority, backend_resources=(
bridge.resource_identity("search_emails"),
bridge.resource_identity("mcp__email__search_emails"),
))
_collect(agent_loop.stream_agent_loop(
"https://api.openai.com/v1",
"policy-model",
[{"role": "user", "content": "Search email for Project Alpha."}],
max_rounds=2,
owner="public-user",
request_authority=create_request_authority("Search email for Project Alpha.", owner="public-user"),
request_authority=authority,
relevant_tools={"search_emails"},
forced_tools={"search_emails"},
fallbacks=[],
+2 -1
View File
@@ -18,7 +18,8 @@ async def test_a_chat_session_always_uses_the_owned_runner(monkeypatch, tmp_path
async def forbidden(*args, **kwargs):
pytest.fail("native Bash resurrected a persistent tmux shell")
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", forbidden)
result = await subprocess_tools.BashTool().execute("printf ok", {"session_id": "same-chat"})
from tests.process_resource_helpers import authorized_handler
result = await authorized_handler(subprocess_tools.BashTool().execute, tmp_path)("printf ok", {"session_id": "same-chat"})
assert result["output"] == "ok"
assert result["teardown"]["dead"] is True
assert "tmux_session" not in result
+19 -16
View File
@@ -9,10 +9,15 @@ import pytest
from src import bg_jobs, containment, process_ownership, process_reaper, tool_execution
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
from tests.runtime_evidence_helpers import server_authorized_executor
from tests.process_resource_helpers import launch, get, kill
@pytest.fixture
def jobs(tmp_path, monkeypatch):
from src.agent_runtime import process_resources
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", tmp_path / "jobs")
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "jobs.json")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
@@ -20,11 +25,11 @@ def jobs(tmp_path, monkeypatch):
monkeypatch.setattr(containment, "MECHANISMS", tuple(m for m in containment.MECHANISMS if m.name == "process_group"))
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
launched = []
yield tmp_path, launched
yield workspace, launched
for record in launched:
current = bg_jobs.get(record["id"])
current = get(record["id"])
if current and current["status"] == "running":
bg_jobs.kill(record["id"])
kill(record["id"])
proc = bg_jobs._LIVE_PROCS.pop(record["pid"], None)
if proc:
proc.wait(timeout=8)
@@ -33,7 +38,7 @@ def jobs(tmp_path, monkeypatch):
def finished(job_id):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
record = bg_jobs.get(job_id)
record = get(job_id)
if record["status"] != "running":
return record
time.sleep(0.03)
@@ -42,7 +47,7 @@ def finished(job_id):
def test_detached_execution_owns_boundary_and_reports_death(jobs):
path, launched = jobs
record = bg_jobs.launch("printf captured", "chat", cwd=str(path))
record = launch("printf captured", "chat", cwd=str(path))
launched.append(record)
result = finished(record["id"])
assert result["output"] == "captured"
@@ -73,7 +78,7 @@ def test_supervisor_setup_failure_closes_unstarted_grant(jobs):
result = subprocess.run([sys.executable, str(worker)], input=json.dumps(payload),
capture_output=True, text=True, timeout=10)
assert result.returncode == 0 # Supervisor publishes the failed job result.
assert "FileNotFoundError" in result.stderr
assert "KeyError" in result.stderr # Legacy unlinked payload fails before execution.
assert not (path / "must-not-exist").exists()
assert containment.active_grants() == []
assert (path / "exit").read_text() == "1"
@@ -102,7 +107,7 @@ async def test_bg_marker_refuses_without_spawning_and_authority_still_gates(jobs
def test_detached_supervisor_enforces_timeout(jobs):
path, launched = jobs
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
record = launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
launched.append(record)
result = finished(record["id"])
assert result["timed_out"] is True
@@ -111,11 +116,11 @@ def test_detached_supervisor_enforces_timeout(jobs):
def test_restart_keeps_verified_background_supervisor(jobs):
path, launched = jobs
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path))
record = launch("sleep 60", "chat", cwd=str(path))
launched.append(record)
report = process_reaper.reap_containment_grants()
assert report["background_kept"] == 1
killed = bg_jobs.kill(record["id"])
killed = kill(record["id"])
assert killed["killed"] is True
assert killed["teardown"]["dead"] is True
@@ -126,16 +131,14 @@ def test_kill_never_marks_a_foreign_pid_killed(jobs, monkeypatch):
bg_jobs._save({"stale": record})
monkeypatch.setattr(process_ownership, "verify", lambda *args: process_ownership.FOREIGN)
monkeypatch.setattr(bg_jobs, "_kill", lambda *args, **kwargs: pytest.fail("foreign process signalled"))
result = bg_jobs.kill("stale")
assert result["status"] == "running"
assert result.get("killed") is not True
assert result["teardown"]["dead"] is False
result = bg_jobs._kill_record(record) # Service cleanup still refuses foreign identity.
assert result.dead is False
def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
path, launched = jobs
for number in range(3):
launched.append(bg_jobs.launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
launched.append(launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
for number, record in enumerate(launched):
assert finished(record["id"])["output"] == f"job-{number}"
grants = containment._load_records()
@@ -145,11 +148,11 @@ def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
def test_detached_output_is_available_while_running(jobs):
path, launched = jobs
record = bg_jobs.launch("printf progress; sleep 5", "chat", cwd=str(path))
record = launch("printf progress; sleep 5", "chat", cwd=str(path))
launched.append(record)
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
current = bg_jobs.get(record["id"])
current = get(record["id"])
if "progress" in current["output"]:
assert current["status"] == "running"
return
+278
View File
@@ -0,0 +1,278 @@
from dataclasses import replace
import json
import os
import time
import pytest
from src import bg_jobs, containment, process_ownership
from src.agent_runtime import process_resources as resources
from src.agent_runtime.authority import RequestAuthority, OperationGrant, ExactOperation, restore_background_authority
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError, BackgroundJobResource, FilesystemRoot, FilesystemResource
from src.process_lifecycle import ProcessIdentity
from tests.process_resource_helpers import seed_linkage, launch_authority
@pytest.fixture
def store(tmp_path, monkeypatch):
workspace = tmp_path / "workspace"
workspace.mkdir()
private = tmp_path / "private"
monkeypatch.setattr(resources, "_LAUNCH_DIR", private / "launches")
monkeypatch.setattr(bg_jobs, "_STORE", private / "jobs.json")
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", private / "jobs")
monkeypatch.setattr(containment, "_store_path", lambda: private / "receipts.json")
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.OWNED)
monkeypatch.setattr(ProcessIdentity, "exited", lambda self: False)
monkeypatch.setattr(bg_jobs, "_pid_alive", lambda pid: True)
return workspace
def seed(workspace, job_id="job", status="running"):
bg_jobs._JOBS_DIR.mkdir(parents=True, exist_ok=True)
record = {"id": job_id, "session_id": "thread", "command": "printf output", "pid": 4321,
"status": status, "started_at": time.time(), "max_runtime_s": 3600,
"exit_path": str(bg_jobs._JOBS_DIR / (job_id + ".exit")),
"result_path": str(bg_jobs._JOBS_DIR / (job_id + ".result.json")),
"log_path": str(bg_jobs._JOBS_DIR / (job_id + ".log"))}
resource = seed_linkage(record, workspace, owner="alice", request_id="origin")
jobs = bg_jobs._load()
jobs[job_id] = record
bg_jobs._save(jobs)
return resource, record
@pytest.mark.parametrize("field,value", [("job_id", "sibling"), ("generation", "f" * 32), ("containment_id", "other-receipt"),
("owner", "bob"), ("request_id", "other-request"), ("thread_id", "other-thread")])
def test_job_substitution_fails_closed(store, field, value):
resource, _ = seed(store)
changed = resource.to_dict()
changed[field] = value
for process in changed["processes"]:
if field in process:
process[field] = value
expected = BackgroundJobResource.from_dict(changed)
with pytest.raises((ResourceIdentityError, OSError)):
resources.validate_job(expected)
@pytest.mark.parametrize("field,value", [("role", "leader"), ("namespace", "external:ssh"), ("identity", {"pid": 4321, "start_token": "replacement", "pgid": 4321})])
def test_role_producer_and_process_replacement_fail(store, field, value):
resource, _ = seed(store)
changed = resource.to_dict()
changed["processes"][0][field] = value
with pytest.raises((ValueError, OSError)):
resources.validate_job(BackgroundJobResource.from_dict(changed))
def test_completed_history_does_not_target_reused_process(store, monkeypatch):
resource, rec = seed(store, status="done")
with open(rec["log_path"], "w") as log:
log.write("historical output")
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.FOREIGN)
monkeypatch.setattr(bg_jobs, "_kill", lambda *a, **k: pytest.fail("historical process targeted"))
assert bg_jobs.get("job", expected=resource)["output"] == "historical output"
assert bg_jobs.kill("job", expected=resource)["status"] == "done"
def test_same_id_new_generation_does_not_inherit_authority(store):
old, _ = seed(store)
seed(store) # Same store key, new trusted launch generation.
with pytest.raises(ResourceIdentityError):
bg_jobs.kill("job", expected=old)
with pytest.raises(ResourceIdentityError):
bg_jobs.get("job", expected=old)
def test_receipt_substitution_is_revalidated_before_mutation(store, monkeypatch):
resource, _ = seed(store)
receipts = containment._load_records()
receipts[resource.containment_id]["launch_generation"] = "replacement"
from core.atomic_io import atomic_write_json
atomic_write_json(containment._store_path(), receipts)
monkeypatch.setattr(bg_jobs, "_kill_record", lambda *a: pytest.fail("replaced receipt used"))
with pytest.raises(ResourceIdentityError):
bg_jobs.kill("job", expected=resource)
def test_result_publication_cannot_overwrite_authoritative_fields(store):
resource, rec = seed(store)
report = {"resource_identity": resource.to_dict(), "containment": {"id": resource.containment_id},
"owner": "bob", "pid": 9999, "start_token": "replacement", "id": "other",
"launch_resource": {}, "session_id": "other", "containment_id": "fake"}
from pathlib import Path
Path(rec["result_path"]).write_text(json.dumps(report))
Path(rec["exit_path"]).write_text("0")
final = bg_jobs.refresh("job")["job"]
assert resources.job_from_record(final) == resource
assert final["pid"] == rec["pid"] and final["session_id"] == "thread"
def test_resolution_and_lookup_do_not_reap_unrelated_jobs(store, monkeypatch):
resource, _ = seed(store, status="done")
sibling, rec = seed(store, "sibling")
jobs = bg_jobs._load()
jobs["sibling"]["started_at"] = 0
bg_jobs._save(jobs)
monkeypatch.setattr(bg_jobs, "_kill_record", lambda *a: pytest.fail("unrelated job reaped"))
authority = RequestAuthority("lookup", "alice", "thread", "", (OperationGrant("manage_bg_jobs"),))
bound = resources.resolve_process_operation(authority, ExactOperation.normalize("manage_bg_jobs", '{"action":"output","job_id":"job"}'), NativeBackendResource("manage_bg_jobs"))
assert bound.jobs == (resource,)
bg_jobs.get("job", expected=resource)
assert bg_jobs.peek("sibling")["status"] == "running"
def test_child_cannot_target_sibling_or_replaced_job(store):
first, _ = seed(store, "first")
second, _ = seed(store, "second")
parent = RequestAuthority("parent", "alice", "thread", "", (OperationGrant("manage_bg_jobs"),), job_resources=(first,))
child = replace(parent, job_resources=(second,))
inherited = parent.intersect(child)
assert inherited.job_resources == ()
with pytest.raises(ResourceIdentityError):
resources.resolve_process_operation(inherited, ExactOperation.normalize("manage_bg_jobs", '{"action":"kill","job_id":"second"}'), NativeBackendResource("manage_bg_jobs"))
seed(store, "first")
assert parent.intersect(child).job_resources == ()
@pytest.mark.parametrize("field,value", [("generation", "f" * 32), ("owner", "bob"), ("request_id", "other"), ("thread_id", "other")])
def test_continuation_sidecar_mismatch_fails_closed(store, field, value):
resource, _ = seed(store, status="done")
sidecar = bg_jobs._JOBS_DIR / "job.authority.json"
data = json.loads(sidecar.read_text())
data["job"][field] = value
sidecar.write_text(json.dumps(data))
assert restore_background_authority("job", owner="alice", session_id="thread").grants == ()
def test_matching_continuation_preserves_original_authority(store):
seed(store, status="done")
authority = restore_background_authority("job", owner="alice", session_id="thread")
assert authority.request_id == "origin" and authority.inherited
assert authority.permits(ExactOperation.normalize("bash", "printf output"))
assert restore_background_authority("job", owner="bob", session_id="thread").grants == ()
@pytest.mark.parametrize("alias", ["direct", "symlink", "hardlink"])
@pytest.mark.parametrize("state", ["launch", "job_store", "sidecar", "receipt"])
def test_launch_and_job_control_files_are_protected(store, tmp_path, alias, state):
resource, _ = seed(store)
control = {"launch": resources.launch_path(resource.generation), "job_store": bg_jobs._STORE,
"sidecar": bg_jobs._JOBS_DIR / "job.authority.json", "receipt": containment._store_path()}[state]
target = control
if alias == "symlink":
target = store / "alias"
target.symlink_to(control)
elif alias == "hardlink":
target = store / "alias"
try:
os.link(control, target)
except OSError as e:
pytest.skip(f"hardlinks unavailable: {e}")
root = FilesystemRoot.seal(tmp_path)
with pytest.raises(ValueError):
FilesystemResource.resolve(root, str(target))
with pytest.raises(ResourceIdentityError):
resources.guard_launch_workspace(root)
if alias != "direct":
with pytest.raises(ResourceIdentityError):
resources.guard_launch_workspace(FilesystemRoot.seal(store))
def test_external_jobs_cannot_become_local_or_attest_containment(store):
resource, _ = seed(store)
external = resource.to_dict()
external["namespace"] = "external:ssh"
with pytest.raises(ValueError):
BackgroundJobResource.from_dict(external)
external = resource.to_dict()
external["contained"] = True
with pytest.raises(ValueError):
BackgroundJobResource.from_dict(external)
@pytest.mark.parametrize("field,value", [("external", True), ("mechanism", "external_bridge"),
("supervisor_token", "reused"), ("supervisor_pid", 9876), ("owner", "bg:other")])
def test_receipt_cannot_replace_producer_or_claim_external_containment(store, field, value):
resource, _ = seed(store, status="done")
receipts = containment._load_records()
receipts[resource.containment_id][field] = value
from core.atomic_io import atomic_write_json
atomic_write_json(containment._store_path(), receipts)
with pytest.raises(ResourceIdentityError):
bg_jobs.get("job", expected=resource)
with pytest.raises(ResourceIdentityError):
bg_jobs.mark_followed_up("job", expected=resource)
def test_target_lookup_does_not_wait_on_unrelated_live_handle(store, monkeypatch):
resource, _ = seed(store, status="done")
class OtherProcess:
def poll(self):
pytest.fail("Unrelated producer was reaped during lookup")
monkeypatch.setattr(bg_jobs, "_LIVE_PROCS", {9876: OtherProcess()})
bg_jobs.get("job", expected=resource)
@pytest.mark.parametrize("status", ["done", "running"])
@pytest.mark.parametrize("verdict", [process_ownership.FOREIGN, process_ownership.UNVERIFIABLE])
def test_historical_lookup_does_not_reap_reused_pid_handle(store, monkeypatch, status, verdict):
resource, rec = seed(store, status=status)
from pathlib import Path
Path(rec["exit_path"]).write_text("0")
Path(rec["result_path"]).write_text(json.dumps({
"resource_identity": resource.to_dict(),
"containment": {"id": resource.containment_id},
}))
monkeypatch.setattr(process_ownership, "verify", lambda *a: verdict)
class ReplacementProcess:
def poll(self):
pytest.fail("Historical lookup reaped the replacement incarnation")
replacement = ReplacementProcess()
monkeypatch.setattr(bg_jobs, "_LIVE_PROCS", {rec["pid"]: replacement})
assert bg_jobs.get("job", expected=resource)["status"] == "done"
assert bg_jobs._LIVE_PROCS[rec["pid"]] is replacement
def test_service_refresh_still_reaps_finished_handles(store, monkeypatch):
class FinishedProcess:
def poll(self):
return 0
monkeypatch.setattr(bg_jobs, "_LIVE_PROCS", {4321: FinishedProcess()})
bg_jobs.refresh()
assert bg_jobs._LIVE_PROCS == {}
def test_completed_result_outlives_lifecycle_receipt_without_signalling(store, monkeypatch):
resource, rec = seed(store, status="done")
from pathlib import Path
Path(rec["log_path"]).write_text("retained historical output")
from core.atomic_io import atomic_write_json
atomic_write_json(containment._store_path(), {})
monkeypatch.setattr(bg_jobs, "_kill_record", lambda *a: pytest.fail("Historical resource was signalled"))
assert bg_jobs.get("job", expected=resource)["output"] == "retained historical output"
assert bg_jobs.kill("job", expected=resource)["status"] == "done"
bg_jobs.mark_followed_up("job", expected=resource)
jobs = bg_jobs._load()
jobs["job"]["status"] = "running"
bg_jobs._save(jobs)
with pytest.raises(ResourceIdentityError):
bg_jobs.kill("job", expected=resource)
@pytest.mark.parametrize("state", ["unknown_status", "malformed_sidecar", "missing_publication"])
def test_unresolved_or_malformed_authoritative_state_fails_closed(store, state):
resource, _ = seed(store, status="done")
if state == "unknown_status":
jobs = bg_jobs._load()
jobs["job"]["status"] = "unknown"
bg_jobs._save(jobs)
elif state == "malformed_sidecar":
(bg_jobs._JOBS_DIR / "job.authority.json").write_text("[]")
else:
resources.launch_path(resource.generation).unlink()
with pytest.raises(ResourceIdentityError):
bg_jobs.get("job", expected=resource)
+26 -7
View File
@@ -13,10 +13,18 @@ import pytest
from src import bg_jobs, containment, process_ownership
from src.agent_tools.bg_job_tools import ManageBgJobsTool
from tests.process_resource_helpers import seed_linkage, get, kill
@pytest.fixture
def store(tmp_path, monkeypatch):
from src.agent_runtime import process_resources
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "receipts.json")
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(bg_jobs, "_test_workspace", workspace, raising=False)
monkeypatch.setattr(containment, "reap_record", lambda *a: containment.ReleaseOutcome(dead=True, escalated=False))
jobs_dir = tmp_path / "bg_jobs"
jobs_dir.mkdir()
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "bg_jobs.json")
@@ -43,6 +51,7 @@ def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pi
}
if output:
(bg_jobs._JOBS_DIR / f"{job_id}.log").write_text(output, encoding="utf-8")
seed_linkage(rec, bg_jobs._test_workspace)
jobs = bg_jobs._load()
jobs[job_id] = rec
bg_jobs._save(jobs)
@@ -50,14 +59,24 @@ def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pi
def _run(args, session_id="sess-a"):
return asyncio.run(ManageBgJobsTool().execute(json.dumps(args), {"session_id": session_id, "owner": None}))
from src.agent_runtime.authority import RequestAuthority, OperationGrant, ExactOperation, bind_request_authority
from src.agent_runtime.resources import NativeBackendResource
from src.agent_runtime.process_resources import resolve_process_operation, bind_process_operation
content = json.dumps(args)
authority = RequestAuthority("job-client-test", "", session_id, "", (OperationGrant("manage_bg_jobs"),))
try:
bound = resolve_process_operation(authority, ExactOperation.normalize("manage_bg_jobs", content), NativeBackendResource("manage_bg_jobs"))
with bind_request_authority(authority), bind_process_operation(bound):
return asyncio.run(ManageBgJobsTool().execute(content, {"session_id": session_id, "owner": None}))
except (ValueError, OSError) as e:
return {"error": str(e), "exit_code": 1}
# ── bg_jobs.kill ────────────────────────────────────────────────────────────
def test_kill_marks_killed_and_suppresses_followup(store):
_seed(job_id="job0001", pid=4321)
rec = bg_jobs.kill("job0001")
rec = kill("job0001")
assert rec["status"] == "failed"
assert rec["killed"] is True
assert rec["exit_code"] == -1
@@ -67,20 +86,20 @@ def test_kill_marks_killed_and_suppresses_followup(store):
def test_kill_unknown_job_returns_none(store):
assert bg_jobs.kill("nope") is None
assert bg_jobs.kill("nope", expected=None) is None
def test_kill_finished_job_is_noop(store):
_seed(job_id="done01", status="done")
rec = bg_jobs.kill("done01")
rec = kill("done01")
assert rec["status"] == "done"
assert store["killed"] == [] # no signal sent to an already-finished job
def test_result_text_reports_killed(store):
rec = _seed(job_id="job0001")
bg_jobs.kill("job0001")
assert "killed" in bg_jobs.result_text(bg_jobs.get("job0001")).lower()
kill("job0001")
assert "killed" in bg_jobs.result_text(get("job0001")).lower()
# ── manage_bg_jobs tool ─────────────────────────────────────────────────────
@@ -118,7 +137,7 @@ def test_kill_via_tool(store):
out = _run({"action": "kill", "job_id": "job0001"})
assert "Killed" in out["output"]
assert store["killed"] == [999]
assert bg_jobs.get("job0001")["killed"] is True
assert get("job0001")["killed"] is True
def test_kill_cross_session_denied(store):
+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():
+2 -2
View File
@@ -440,7 +440,7 @@ def test_no_bridge_falls_back_to_backend_execution():
return {"output": f"backend-side {tool}", "exit_code": 0}
with patch.object(_te, "_owner_is_admin", lambda owner: True), \
patch.object(_te, "_call_mcp_tool", fake_mcp):
patch.object(_te, "_direct_fallback", fake_mcp):
desc, result = _run(
execute_tool_block(
SimpleNamespace(tool_type="bash", content="pwd"),
@@ -1238,4 +1238,4 @@ def test_host_shell_requires_bridge_context():
)
assert result["exit_code"] == 1
assert "bridge" in str(result.get("error", "")).lower()
assert "bridge" in str(result.get("error", "")).lower() or "unresolved" in str(result.get("error", "")).lower()
+4
View File
@@ -17,6 +17,10 @@ def workspace(tmp_path, monkeypatch):
path.mkdir()
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(path))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
from tests.process_resource_helpers import install_native_authority
from src.agent_runtime import process_resources
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
install_native_authority(monkeypatch, path)
return path
+8 -2
View File
@@ -1,4 +1,5 @@
from unittest.mock import AsyncMock
from types import SimpleNamespace
import pytest
@@ -11,6 +12,11 @@ from src.host_docker_access import HOST_DOCKER_ACCESS_HINT
from tests.helpers.unix_sockets import bound_unix_socket
@pytest.fixture(autouse=True)
def authenticated_admin_mode(monkeypatch):
monkeypatch.setenv("AUTH_ENABLED", "true")
def _model_serve_endpoint():
router = cookbook_routes.setup_cookbook_routes()
for route in router.routes:
@@ -27,6 +33,8 @@ def _admin_request() -> Request:
"path": "/api/model/serve",
"headers": [],
"state": {},
"app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(
is_configured=True, is_admin=lambda user: user == "admin"))),
}
)
request.state.current_user = "admin"
@@ -139,7 +147,6 @@ async def test_local_container_serve_returns_host_docker_opt_in_hint(
assert cookbook_routes.shutil.which(binary) == "/usr/bin/docker"
return False
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
monkeypatch.setattr(
@@ -199,7 +206,6 @@ async def test_local_container_serve_allows_generated_docker_exec_when_enabled(
launched_commands.append(command)
return _Process()
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
monkeypatch.setattr(
+28 -49
View File
@@ -1,22 +1,9 @@
"""Stopping a Cookbook server, on a host with procfs and on one without.
"""Cookbook selectors and OS observations never mint application authority.
The tmux kill is what actually stops the server; the pid sweep that follows it
only catches model servers that survive the session's SIGHUP. Two invariants
live here.
**The stop must not fail because the host cannot be inspected.** Letting a
procfs scan raise on macOS turned a successful stop into a reported failure and
skipped the state write that marks the session stopped for the Cookbook UI
(ODY-94). Skipping the sweep silently fixed the crash and left the other half:
the stop then claimed success without having looked at all. So the sweep now
runs through ``ps`` where there is no procfs, and says so when it cannot look.
**The sweep signals only processes the session owns.** It used to kill anything
whose full command line matched the tracked one. The Cookbook composed that
command line, so an identical one is just as likely to be a server the user
started by hand — killing it is indistinguishable from killing ours, which is
the "stop only what we started" failure. Ownership now comes from the tmux
pane's process tree, captured before the kill; a lookalike is reported instead.
These legacy UI-backed targets have no authoritative launch registry. Local
agent stops therefore fail closed before discovery, signalling or state writes,
on both procfs and other hosts. Shared Wave 5B lifecycle mechanics are tested
separately in test_process_lifecycle and test_process_ownership.
"""
import asyncio
import json
@@ -160,7 +147,7 @@ def _install_effective_kill(monkeypatch, table):
@pytest.mark.asyncio
async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
async def test_unadmitted_stop_refused_when_the_host_has_no_procfs(
monkeypatch, tmp_path
):
"""The ODY-94 regression: no procfs must not turn a working stop into a failure."""
@@ -176,13 +163,12 @@ async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert result["output"].startswith("Stopped server serve-abc123")
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert result["failure_kind"] == "resource_identity_denied"
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
async def test_stop_says_so_when_the_session_cannot_be_inspected(
async def test_unadmitted_stop_refused_when_the_session_cannot_be_inspected(
monkeypatch, tmp_path
):
"""A sweep that could not look must not read as a sweep that found nothing.
@@ -209,15 +195,14 @@ async def test_stop_says_so_when_the_session_cannot_be_inspected(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert "could not identify the session's processes" in result["output"]
assert result["failure_kind"] == "resource_identity_denied"
assert signalled == []
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
"""A process under the session's pane is ours, so it gets signalled."""
async def test_pane_descendant_is_not_application_owned(monkeypatch, tmp_path):
"""A process under a named pane still requires prior application admission."""
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
state = _tracked_state(cmd=tracked_cmd)
posts = _install_httpx_client(monkeypatch, state)
@@ -232,14 +217,13 @@ async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert (101, signal.SIGTERM) in signalled
assert "killed 2 surviving process(es)" in result["output"]
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert result["failure_kind"] == "resource_identity_denied"
assert signalled == [] # OS lineage alone never establishes app ownership.
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
async def test_stop_reports_a_command_line_lookalike_without_signalling_it(
async def test_unadmitted_stop_never_signals_a_command_line_lookalike(
monkeypatch, tmp_path
):
"""The headline change: matching the command line is not owning the process.
@@ -262,13 +246,9 @@ async def test_stop_reports_a_command_line_lookalike_without_signalling_it(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert result["failure_kind"] == "resource_identity_denied"
assert not any(pid == 202 for pid, _sig in signalled)
# Reported rather than silently dropped: the old behaviour acted on this
# information, so giving it up entirely would be a regression of its own.
assert "202" in result["output"]
assert "not signalled" in result["output"]
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
@@ -302,10 +282,10 @@ async def test_stop_does_not_signal_a_pid_whose_identity_changed(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
# The pane shell is genuinely ours and is signalled; 101 never is.
assert result["failure_kind"] == "resource_identity_denied"
# Neither pane discovery nor a matching token creates application scope.
assert not any(pid == 101 for pid, _sig in signalled)
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
@@ -323,8 +303,8 @@ def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
@pytest.mark.asyncio
async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tmp_path):
"""Captured as ours, unverifiable at sweep time: not signalled, and said so."""
async def test_unadmitted_stop_refused_with_unverifiable_process(monkeypatch, tmp_path):
"""An unverifiable OS observation cannot create an application grant."""
from src import process_ownership
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
@@ -347,10 +327,9 @@ async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tm
result = await tools.do_stop_served_model(json.dumps({"session_id": "serve-abc123"}))
assert result["exit_code"] == 0
assert result["failure_kind"] == "resource_identity_denied"
assert not any(pid == 101 for pid, _sig in signalled)
assert "could not be re-identified and were not signalled (pid 101)" in result["output"]
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
@@ -383,6 +362,6 @@ async def test_stop_never_signals_a_pid_reissued_between_the_table_and_its_captu
result = await tools.do_stop_served_model(json.dumps({"session_id": "serve-abc123"}))
assert result["exit_code"] == 0
assert result["failure_kind"] == "resource_identity_denied"
assert not any(pid == 101 for pid, _sig in signalled)
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
+5 -1
View File
@@ -51,13 +51,17 @@ async def test_edit_file_blocked_at_execution_for_non_admin(monkeypatch):
# different module's function than the one monkeypatch targets — silently
# bypassing the admin gate.
import src.tool_execution as te
from src.agent_runtime.authority import create_request_authority
monkeypatch.setattr(te, "_owner_is_admin", lambda owner: False)
ws = tempfile.mkdtemp()
p = os.path.join("/tmp", "ef_block.txt")
p = os.path.join(ws, "ef_block.txt")
open(p, "w").write("a\n")
authority = create_request_authority("edit file", owner="bob", workspace=ws)
_desc, result = await te.execute_tool_block(
ToolBlock("edit_file", json.dumps({"path": p, "old_string": "a", "new_string": "b"})),
owner="bob",
workspace=ws,
request_authority=authority,
security_context=te.NO_TOOL_SECURITY_CONTEXT,
)
assert result.get("exit_code") == 1 and "admin" in result.get("error", "").lower()
+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"
+6 -5
View File
@@ -65,13 +65,14 @@ async def test_corrected_ids_execute_after_repeated_ambiguous_title_failures(tmp
headers={}, turn_contract=contract, session_id='fixture-delete', owner='fixture-owner',
disabled_tools=set(), tool_policy=policy, max_rounds=8)]
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
assert (await read('target-a'))['exit_code'] == 1
assert (await read('target-b'))['exit_code'] == 1
assert (await read('target-a'))['exit_code'] == 0
assert (await read('target-b'))['exit_code'] == 0
assert await read('keep-c') == before
outputs = [e for e in events if e.get('type') == 'tool_output']
attempts = [e for e in outputs if e.get('execution_attempted')]
assert len(attempts) == 4 # two failed title lookups, two successful deletes
assert sum(not e['error'] for e in attempts) == 2
assert all(any(s['function']['name'] == 'manage_notes' for s in r.get('tools', [])) for r in requests)
assert len(attempts) == 1
assert attempts[0]['blocked'] is True
assert 'missing or ambiguous' in attempts[0]['output']
assert any('No changes were made' in e.get('content', '') for e in events if e.get('type') == 'final_response')
finally:
engine.dispose()
+13 -3
View File
@@ -11,13 +11,23 @@ from src.agent_tools import subprocess_tools
@pytest.fixture(autouse=True)
def native_boundary(tmp_path, monkeypatch):
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
from src.agent_runtime import process_resources
from tests.process_resource_helpers import authorized_handler
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(workspace))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "grants.json")
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
original = cls.execute
async def execute(self, content, ctx, _original=original):
return await authorized_handler(_original.__get__(self), workspace)(content, ctx)
monkeypatch.setattr(cls, "execute", execute)
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "MECHANISMS", tuple(
m for m in containment.MECHANISMS if m.name == "process_group"
))
return tmp_path
return workspace
@pytest.mark.skipif(os.name == "nt", reason="real POSIX group teardown")
+8 -2
View File
@@ -399,10 +399,16 @@ def test_already_finished_jobs_are_not_reconsidered(job_store, monkeypatch):
assert bg_jobs.disown_unverified() == {"seen": 0, "retired": 0, "kept": 0}
def test_a_launched_job_records_an_identity_next_to_its_pid(job_store):
def test_a_launched_job_records_an_identity_next_to_its_pid(job_store, tmp_path, monkeypatch):
"""Without this the record is unverifiable forever and the reaper can only
refuse — the token has to be captured at launch or not at all."""
record = bg_jobs.launch("true", "chat-1")
from tests.process_resource_helpers import launch
from src.agent_runtime import process_resources
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "grants.json")
record = launch("true", "chat-1", cwd=str(workspace))
assert "start_token" in record
assert process_ownership.verify(record["pid"], record["start_token"]) in (
+429
View File
@@ -0,0 +1,429 @@
"""Server resolution pins record aliases before approval and dispatch."""
import asyncio
from dataclasses import replace
from datetime import datetime, timedelta
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority
from src.agent_runtime.owned_resources import (
active_owned_operation, admit_owned_operation, bind_owned_operation,
bound_attachment_path, resolve_owned_operation,
observe_vault_records,
)
from src.agent_runtime.remote_resources import active_backend_operation
from src.agent_runtime.resources import OwnedScope, ResourceIdentityError
from src.tool_approvals import ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
from src.tool_types import ToolBlock
@pytest.fixture(autouse=True)
def fresh_vault_observations(monkeypatch):
from src.agent_runtime import owned_resources
monkeypatch.setattr(owned_resources, "_VAULT_RECORDS", {})
def grant(*tools, scopes=None, owner="alice", thread="s"):
return RequestAuthority("owned-request", owner, thread, "",
tuple(OperationGrant(t) for t in tools), owned_scopes=scopes)
async def dispatch(authority, tool, content, **kwargs):
from src import tool_execution as execution
return await execution.execute_tool_block(ToolBlock(tool, content), owner=authority.owner,
session_id=authority.session_id, request_authority=authority,
security_context=kwargs.pop("security_context", execution.NO_TOOL_SECURITY_CONTEXT), **kwargs)
def approval(authority, tool, content, **kwargs):
store = ToolApprovalStore()
pending = store.create(owner=authority.owner, session_id=authority.session_id, origin_run_id="run",
tool_name=tool, content=content, workspace=None, request_authority=authority,
external_untrusted_context_seen=True, capabilities=capabilities_for_action(tool, content), **kwargs)
return store.consume(pending.approval_id, decision="approve", owner=authority.owner, session_id=authority.session_id)
@pytest.fixture
def records(monkeypatch):
import core.database as db
import src.database as compatibility
from src.agent_tools import document_tools
engine = create_engine("sqlite:///:memory:")
db.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
monkeypatch.setattr(db, "SessionLocal", factory)
monkeypatch.setattr(compatibility, "SessionLocal", factory)
from src import tool_execution as execution
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
now = datetime(2026, 1, 1)
with factory() as connection:
for identifier, owner in (("s", "alice"), ("other", "alice"), ("foreign", "bob")):
connection.add(db.Session(id=identifier, owner=owner, name=identifier, endpoint_url="https://model.test", model="test"))
for identifier, owner, thread, offset in (("d1", "alice", "s", 1), ("d2", "alice", "other", 2), ("private", "bob", "foreign", 3)):
connection.add(db.Document(id=identifier, owner=owner, session_id=thread, title=identifier,
current_content=identifier + " original", language="text", version_count=1,
created_at=now, updated_at=now + timedelta(days=offset)))
connection.add(db.Note(id="note-one", owner="alice", title="first", content="original"))
connection.add(db.Note(id="note-two", owner="alice", title="second", content="original"))
connection.add(db.Note(id="note-foreign", owner="bob", title="private", content="private"))
connection.commit()
monkeypatch.setattr(document_tools, "_active_document_id", None)
yield factory
engine.dispose()
@pytest.mark.parametrize("selector", ["active", "current", "latest"])
async def test_document_alias_resolves_once_and_does_not_follow_new_active_or_latest(records, monkeypatch, selector):
from src import tool_execution as execution
authority = grant("manage_documents")
content = json.dumps({"action": "read", "document_id": selector})
exact = approval(authority, "manage_documents", content, document_id="d1")
bound = exact.pending.owned_operation
expected = "d2" if selector == "latest" else "d1"
assert bound.document_id == expected
import core.database as db
with records() as connection:
connection.add(db.Document(id="newest", owner="alice", session_id="s", title="newest",
current_content="newest content", version_count=1, updated_at=datetime(2030, 1, 1)))
connection.commit()
seen = []
async def implementation(block, **kwargs):
seen.append((json.loads(block.content)["document_id"], kwargs["approved_document_id"], active_owned_operation()))
return "read", {"exit_code": 0}
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
_, result = await dispatch(authority, "manage_documents", content, active_document_id="newest",
exact_approval=exact, security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result["exit_code"] == 0
assert seen[0][:2] == (expected, expected)
assert seen[0][2] is bound
assert active_owned_operation() is None
async def test_document_runtime_executes_captured_id_not_process_global_alias(records):
from src.agent_tools import document_tools
authority = grant("update_document")
exact = approval(authority, "update_document", "replacement", document_id="d1")
document_tools.set_active_document("d2")
_, result = await dispatch(authority, "update_document", "replacement", active_document_id="d2",
exact_approval=exact, security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result.get("exit_code", 0) == 0 and not result.get("error")
import core.database as db
with records() as connection:
assert connection.get(db.Document, "d1").current_content == "replacement"
assert connection.get(db.Document, "d2").current_content == "d2 original"
@pytest.mark.parametrize("change", ["revision", "owner", "thread", "deleted", "request", "invocation_thread"])
async def test_stale_or_rebound_document_approval_fails_before_effect(records, monkeypatch, change):
import core.database as db
from src import tool_execution as execution
authority = grant("update_document")
exact = approval(authority, "update_document", "replacement", document_id="d1")
if change in {"request", "invocation_thread"}:
authority = replace(authority, **({"request_id": "other"} if change == "request" else
{"session_id": "other", "owned_scopes": (OwnedScope("documents", "alice", "other"),)}))
else:
with records() as connection:
row = connection.get(db.Document, "d1")
if change == "revision":
row.current_content = "changed"
row.version_count += 1
elif change == "owner":
row.owner = "bob"
elif change == "thread":
row.session_id = "other"
else:
connection.delete(row)
connection.commit()
implementation = AsyncMock()
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
_, result = await dispatch(authority, "update_document", "replacement", exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result["failure_kind"] == "resource_identity_denied"
implementation.assert_not_awaited()
assert not exact._claimed
@pytest.mark.parametrize("owner,thread", [("", "s"), ("alice", ""), ("bob", "s")])
async def test_owner_and_invocation_thread_are_mandatory(records, owner, thread):
_, result = await dispatch(grant("manage_documents", owner=owner, thread=thread),
"manage_documents", '{"action":"read","id":"d1"}')
assert result["failure_kind"] == "resource_identity_denied"
async def test_child_record_scope_is_intersection_and_exact_approval_cannot_widen(records, monkeypatch):
parent = grant("manage_documents", scopes=(OwnedScope("documents", "alice", "s", frozenset({"d1"})),))
child = grant("manage_documents")
assert parent.intersect(child).owned_scopes == parent.owned_scopes
exact = approval(child, "manage_documents", '{"action":"read","id":"d2"}')
from src import tool_execution as execution
handler = AsyncMock(return_value=("read", {"exit_code": 0}))
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
with bind_request_authority(parent):
_, denied = await dispatch(child, "manage_documents", exact.pending.content, exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
_, allowed = await dispatch(child, "manage_documents", '{"action":"read","id":"d1"}')
_, collection = await dispatch(child, "manage_documents", '{"action":"list"}')
assert denied["failure_kind"] == collection["failure_kind"] == "resource_identity_denied"
assert allowed["exit_code"] == 0 and handler.await_count == 1
async def test_legacy_owned_approval_is_exact_one_use_not_reconstructed_scope(records, monkeypatch):
snapshot = grant("manage_documents").to_dict()
snapshot["version"] = 2
authority = RequestAuthority.from_dict(snapshot)
exact = approval(authority, "manage_documents", '{"action":"read","id":"d1"}')
from src import tool_execution as execution
handler = AsyncMock(return_value=("read", {"exit_code": 0}))
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
_, denied = await dispatch(authority, "manage_documents", exact.pending.content)
assert denied["failure_kind"] == "resource_identity_denied"
security = ToolRunSecurityContext(external_untrusted_context_seen=True)
_, allowed = await dispatch(authority, "manage_documents", exact.pending.content, exact_approval=exact, security_context=security)
_, replay = await dispatch(authority, "manage_documents", exact.pending.content, exact_approval=exact, security_context=security)
assert allowed["exit_code"] == 0 and replay["exit_code"] == 1
assert authority.owned_scopes == () and handler.await_count == 1
@pytest.mark.parametrize("tool,content", [("manage_session", '{"action":"rename","session":"current","value":"new"}'),
("manage_session", "rename\ncurrent\nnew"), ("send_to_session", "current\nhello")])
def test_thread_current_alias_becomes_exact_owned_identity(records, tool, content):
bound = resolve_owned_operation(ExactOperation.normalize(tool, content), owner="alice", thread_id="s")
assert bound.resources[0].record_id == bound.resources[0].record_thread_id == "s"
assert "current" not in bound.execution_input
with pytest.raises(ResourceIdentityError):
resolve_owned_operation(ExactOperation.normalize(tool, content.replace("current", "foreign")), owner="alice", thread_id="s")
@pytest.mark.parametrize("selector", ["note-o", "first"])
def test_note_alias_resolves_once_and_prefix_ambiguity_fails_closed(records, selector):
content = {"action": "update", "content": "replacement", "id" if selector == "note-o" else "title": selector}
bound = resolve_owned_operation(ExactOperation.normalize("manage_notes", json.dumps(content)), owner="alice", thread_id="s")
assert bound.resources[0].record_id == json.loads(bound.execution_input)["id"] == "note-one"
with pytest.raises(ResourceIdentityError):
resolve_owned_operation(ExactOperation.normalize("manage_notes", '{"action":"view","id":"note-"}'), owner="alice", thread_id="s")
@pytest.fixture
def attachment_store(tmp_path, monkeypatch):
from src import tool_utils
file = tmp_path / "image.png"
file.write_bytes(b"test image")
row = {"id": "upload.png", "owner": "alice", "path": str(file), "hash": "observed-hash"}
handler = SimpleNamespace(upload_dir=str(tmp_path), resolve_upload=lambda identifier, *, owner, allow_admin:
dict(row) if identifier == row.get("id") and owner == row.get("owner") and not allow_admin else None)
monkeypatch.setattr(tool_utils, "get_upload_handler", lambda: handler)
return row, file
@pytest.mark.parametrize("change", ["owner", "path", "file", "missing"])
def test_attachment_ownership_index_and_file_identity_are_pinned(attachment_store, change):
row, file = attachment_store
bound = resolve_owned_operation(ExactOperation.normalize("extract_text", '{"path":"odysseus://attachment/upload.png"}'), owner="alice", thread_id="s")
with bind_owned_operation(bound):
assert bound_attachment_path("alice", "odysseus://attachment/upload.png") == str(file)
with pytest.raises(ResourceIdentityError):
bound_attachment_path("bob", "odysseus://attachment/upload.png")
if change == "owner":
row["owner"] = "bob"
elif change == "path":
other = file.with_name("other.png")
other.write_bytes(b"other")
row["path"] = str(other)
elif change == "file":
file.rename(file.with_name("old.png"))
file.write_bytes(b"replacement")
else:
row.clear()
with pytest.raises(ResourceIdentityError):
bound.validate()
def test_memory_prefix_is_owner_scoped_exact_and_revision_sensitive(monkeypatch):
from src import ai_interaction
rows = [{"id": "memory-one", "owner": "alice", "text": "secret", "timestamp": 1},
{"id": "memory-other", "owner": "bob", "text": "private", "timestamp": 1}]
monkeypatch.setattr(ai_interaction, "_memory_manager", SimpleNamespace(load=lambda owner: rows))
bound = resolve_owned_operation(ExactOperation.normalize("manage_memory", "edit\nmemory-o\nreplacement"), owner="alice", thread_id="s")
assert bound.resources[0].record_id == "memory-one"
assert bound.execution_input == "edit\nmemory-one\nreplacement"
assert "secret" not in json.dumps(bound.to_dict())
rows[0]["text"] = "changed in same second"
with pytest.raises(ResourceIdentityError):
bound.validate()
@pytest.mark.parametrize("change", ["owner", "endpoint", "session"])
def test_private_vault_identity_binds_owner_endpoint_and_exact_uuid_without_credentials(monkeypatch, change):
from src.tools import vault
cfg = {"owner": "alice", "server_url": "https://vault.test", "session": "SECRET_SESSION", "unlocked_at": "observed"}
monkeypatch.setattr(vault, "_load_vault_config", lambda: cfg)
observe_vault_records("alice", cfg, [{"id": "12345678-1234-1234-1234-123456789abc", "name": "bank", "login": {"password": "PRIVATE_PASSWORD"}}])
tool = ExactOperation.normalize("vault_get", '{"item_id":"12345678-1234-1234-1234-123456789abc","reason":"requested"}')
bound = resolve_owned_operation(tool, owner="alice", thread_id="s")
assert "SECRET_SESSION" not in json.dumps(bound.to_dict()) and "PRIVATE_PASSWORD" not in json.dumps(bound.to_dict())
cfg.update({"owner": "bob"} if change == "owner" else {"server_url": "https://other.test"} if change == "endpoint" else {"session": "OTHER_SECRET"})
with pytest.raises(ResourceIdentityError):
bound.validate()
def test_legacy_vault_and_model_name_alias_do_not_create_private_identity(monkeypatch):
from src.tools import vault
monkeypatch.setattr(vault, "_load_vault_config", lambda: {"server_url": "https://vault.test", "session": "secret"})
with pytest.raises(ResourceIdentityError):
resolve_owned_operation(ExactOperation.normalize("vault_search", '{"query":"bank"}'), owner="alice", thread_id="s")
with pytest.raises(ResourceIdentityError):
resolve_owned_operation(ExactOperation.normalize("vault_get", '{"item_id":"latest","reason":"requested"}'), owner="alice", thread_id="s")
@pytest.mark.parametrize("error", [None, RuntimeError, asyncio.CancelledError])
async def test_owned_and_backend_context_restore_after_success_error_cancel_and_nested_call(records, monkeypatch, error):
from src import tool_execution as execution
authority = grant("manage_documents")
parent = admit_owned_operation(authority, ExactOperation.normalize("manage_documents", '{"action":"read","id":"d1"}'))
async def implementation(block, **kwargs):
assert active_owned_operation().resources[0].record_id == "d2"
assert active_backend_operation() is not None
if error:
raise error("stop")
return "read", {"exit_code": 0}
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
with bind_owned_operation(parent):
if error:
with pytest.raises(error):
await dispatch(authority, "manage_documents", '{"action":"read","id":"d2"}')
else:
await dispatch(authority, "manage_documents", '{"action":"read","id":"d2"}')
assert active_owned_operation() is parent
assert active_backend_operation() is None
assert active_owned_operation() is None
@pytest.mark.parametrize("path", ["/api/document/d1", "/api/history/s", "/api/vault/config", "/api/memory", "/api/notes",
"/api/upload/upload.png", "/api/%64ocument/d1", "/api/cookbook/../document/d1"])
async def test_generic_internal_bridge_cannot_bypass_owned_resource_adapter(monkeypatch, path):
from src import tool_execution as execution
handler = AsyncMock()
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
_, result = await dispatch(grant("app_api"), "app_api", json.dumps({"path": path}))
assert result["failure_kind"] == "resource_identity_denied"
handler.assert_not_awaited()
def test_owned_snapshot_roundtrip_and_malformed_scopes_fail_closed(records):
authority = grant("manage_documents", scopes=(OwnedScope("documents", "alice", "s", frozenset({"d1"})),))
snapshot = json.loads(json.dumps(authority.to_dict()))
assert RequestAuthority.from_dict(snapshot) == authority
for mutation in ({"owner": "bob"}, {"thread_id": "other"}, {"record_ids": ["*"]}, {"record_ids": [1]}):
changed = json.loads(json.dumps(snapshot))
changed["owned_scopes"][0].update(mutation)
with pytest.raises((ValueError, TypeError)):
RequestAuthority.from_dict(changed)
def test_vault_config_owner_is_produced_by_authenticated_request_and_drops_legacy_session():
from routes.vault.vault_routes import _bind_config_owner
from fastapi import HTTPException
request = SimpleNamespace(state=SimpleNamespace(current_user="alice", api_token=False))
cfg = {"session": "legacy-secret", "unlocked_at": "legacy"}
_bind_config_owner(cfg, request)
assert cfg == {"owner": "alice"}
request.state.current_user = "bob"
with pytest.raises(HTTPException):
_bind_config_owner(cfg, request)
def test_missing_proposal_record_is_not_reconstructed_after_it_appears(records):
authority = grant("manage_documents")
exact = approval(authority, "manage_documents", '{"action":"read","id":"not-yet"}')
assert exact.pending.owned_operation is None
import core.database as db
with records() as connection:
connection.add(db.Document(id="not-yet", owner="alice", title="appeared", current_content="content", version_count=1))
connection.commit()
_, result = asyncio.run(dispatch(authority, "manage_documents", exact.pending.content, exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True)))
assert result["failure_kind"] == "resource_identity_denied" and not exact._claimed
def test_malformed_record_identity_and_normalized_approval_tampering_fail_closed(records):
authority = grant("manage_documents")
exact = approval(authority, "manage_documents", '{"action":"read","id":"d1"}')
bound = exact.pending.owned_operation
with pytest.raises(ValueError):
replace(bound, resources=(replace(bound.resources[0], revision=""),))
with pytest.raises(ValueError):
replace(bound, document_id="d2")
exact.pending = replace(exact.pending, owned_operation=replace(bound, execution_input='{"action":"read","document_id":"d2"}'))
assert not exact.matches(owner="alice", session_id="s", workspace=None,
tool_name="manage_documents", content=exact.pending.content)
def test_note_prefix_wildcards_cannot_create_selector_authority(records):
with pytest.raises(ResourceIdentityError):
resolve_owned_operation(ExactOperation.normalize("manage_notes", '{"action":"view","id":"note-o%"}'), owner="alice", thread_id="s")
@pytest.mark.parametrize("content", ['{"action":"read"}', '{"action":"read","id":"active"}', '{"action":"read","id":"current"}', '{"action":"read","id":42}', '{"action":"read","id":"d1","uid":"d2"}'])
def test_missing_malformed_and_conflicting_document_selectors_fail_closed(records, content):
with pytest.raises(ResourceIdentityError):
resolve_owned_operation(ExactOperation.normalize("manage_documents", content), owner="alice", thread_id="s")
async def test_resumed_child_approval_cannot_restore_excluded_record(records):
parent = grant("manage_documents", scopes=(OwnedScope("documents", "alice", "s", frozenset({"d1"})),))
child = parent.intersect(grant("manage_documents"))
exact = approval(child, "manage_documents", '{"action":"read","id":"d2"}')
assert exact.pending.owned_operation is None
_, result = await dispatch(replace(child, inherited=False), "manage_documents", exact.pending.content, exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result["failure_kind"] == "resource_identity_denied" and not exact._claimed
async def test_vault_search_producer_supplies_exact_owned_item_identity_and_alias_binding(monkeypatch):
from src.tools import vault
from src import tool_execution as execution
cfg = {"owner": "alice", "server_url": "https://vault.test", "session": "SECRET_SESSION", "unlocked_at": "observed"}
item = {"id": "12345678-1234-1234-1234-123456789abc", "name": "bank", "login": {"password": "PRIVATE_PASSWORD"}}
monkeypatch.setattr(vault, "_load_vault_config", lambda: cfg)
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
cli = AsyncMock(side_effect=[(json.dumps([item]), "", 0), (json.dumps(item), "", 0)])
monkeypatch.setattr(vault, "_run_bw", cli)
authority = grant("vault_search", "vault_get")
_, missing = await dispatch(authority, "vault_get", json.dumps({"item_id": item["id"], "reason": "requested"}))
assert missing["failure_kind"] == "resource_identity_denied"
cli.assert_not_awaited()
_, search = await dispatch(authority, "vault_search", '{"query":"bank"}')
assert search["exit_code"] == 0 and item["id"] in search["output"]
exact = approval(authority, "vault_get", '{"item_id":"bank","reason":"requested"}')
assert exact.pending.owned_operation.resources[0].record_id == item["id"]
# A later producer result with the same alias cannot change the approved ID.
other = {"id": "87654321-1234-1234-1234-123456789abc", "name": "bank", "login": {"password": "OTHER_PASSWORD"}}
observe_vault_records("alice", cfg, [other])
_, result = await dispatch(authority, "vault_get", exact.pending.content, exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result["exit_code"] == 0 and "PRIVATE_PASSWORD" in result["output"] and "OTHER_PASSWORD" not in result["output"]
assert cli.await_args.args[0] == ["get", "item", item["id"]]
with pytest.raises(ResourceIdentityError):
resolve_owned_operation(ExactOperation.normalize("vault_get", exact.pending.content), owner="alice", thread_id="s")
def test_vault_producer_revision_change_invalidates_sealed_item_and_cannot_cross_owner(monkeypatch):
from src.tools import vault
cfg = {"owner": "alice", "server_url": "https://vault.test", "session": "SECRET"}
item = {"id": "12345678-1234-1234-1234-123456789abc", "name": "bank", "revisionDate": "one"}
monkeypatch.setattr(vault, "_load_vault_config", lambda: cfg)
observe_vault_records("alice", cfg, [item])
bound = resolve_owned_operation(ExactOperation.normalize("vault_get", '{"item_id":"12345678","reason":"requested"}'), owner="alice", thread_id="s")
assert json.loads(bound.execution_input)["item_id"] == item["id"]
with pytest.raises(ResourceIdentityError):
observe_vault_records("bob", cfg, [item])
observe_vault_records("alice", cfg, [{**item, "revisionDate": "two"}])
with pytest.raises(ResourceIdentityError):
bound.validate()
+4 -6
View File
@@ -9,12 +9,10 @@ from src.clean_agent_preview import preview_tool_result_text
@pytest.mark.asyncio
async def test_failed_shell_retains_exit_status_and_both_streams_for_followup(tmp_path):
from src.tool_execution import _active_workspace
token = _active_workspace.set(str(tmp_path))
try:
result = await BashTool().execute("printf 'PHASE_ONE_DONE\\n'; printf 'CHECK_FAILED\\n' >&2; exit 7", {})
finally:
_active_workspace.reset(token)
from tests.process_resource_helpers import launch_authority
cmd = "printf 'PHASE_ONE_DONE\\n'; printf 'CHECK_FAILED\\n' >&2; exit 7"
with launch_authority(cmd, tmp_path, session_id="chat"):
result = await BashTool().execute(cmd, {"session_id": "chat"})
assert result['exit_code'] == 7
observed = preview_tool_result_text(result, 'bash', {})
assert 'PHASE_ONE_DONE' in observed and 'CHECK_FAILED' in observed
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
from dataclasses import replace
import json
import signal
import pytest
from src import process_ownership
from src.process_lifecycle import ProcessIdentity, signal_identity
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
from src.agent_runtime.resources import ProcessResource, NativeBackendResource, FilesystemRoot, ProcessLaunchScope, ResourceIdentityError
from src.agent_runtime.process_resources import resolve_process_operation
from src.containment import DEFAULT_REQUIRED
def process():
return ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(4321, "boot:start", 4321), "leader", "job", "receipt")
@pytest.mark.parametrize("verdict", [process_ownership.FOREIGN, process_ownership.GONE, process_ownership.UNVERIFIABLE])
def test_stale_reused_or_unverifiable_identity_cannot_be_admitted(monkeypatch, verdict):
monkeypatch.setattr(process_ownership, "verify", lambda *a: verdict)
with pytest.raises(ResourceIdentityError):
process().validate()
@pytest.mark.parametrize("field,value", [("pid", 0), ("pid", "4321"), ("pid", True), ("pgid", "4321"), ("start_token", None), ("start_token", ""), ("start_token", {})])
def test_malformed_lifecycle_observations_fail_closed(field, value):
record = process().to_dict()
record["identity"][field] = value
with pytest.raises((ValueError, TypeError)):
ProcessResource.from_dict(record)
def test_no_duplicate_lifecycle_fields_and_strict_restore():
resource = process()
record = resource.to_dict()
assert ProcessResource.from_dict(record) == resource
assert "pid" not in record and "start_token" not in record
record["identity"]["incarnation"] = "invented"
with pytest.raises(ValueError):
ProcessResource.from_dict(record)
def test_incarnation_is_not_application_ownership(monkeypatch):
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.OWNED)
monkeypatch.setattr(ProcessIdentity, "exited", lambda self: False)
resource = process()
resource.validate()
for field in ("namespace", "owner", "request_id", "thread_id", "role", "job_id", "containment_id"):
if field in {"namespace", "role"}:
with pytest.raises(ValueError):
replace(resource, **{field: "supervisor" if field == "role" else "external:ssh"})
continue
changed = replace(resource, **{field: "supervisor" if field == "role" else "other"})
assert changed != resource
with pytest.raises(ValueError):
RequestAuthority("request", "bob", "thread", "", process_resources=(resource,))
with pytest.raises(ValueError):
RequestAuthority("request", "alice", "other-thread", "", process_resources=(resource,))
def test_pid_reuse_at_signal_boundary_uses_wave5b_engine(monkeypatch):
verdicts = iter([process_ownership.OWNED, process_ownership.OWNED, process_ownership.FOREIGN])
monkeypatch.setattr(process_ownership, "verify", lambda *a: next(verdicts))
monkeypatch.setattr("src.process_lifecycle.is_zombie", lambda pid: False)
monkeypatch.setattr("os.kill", lambda *a: pytest.fail("reused PID signalled"))
target = process()
target.validate()
assert signal_identity(target.identity, signal.SIGTERM) is False
def test_child_cannot_renew_replaced_parent_process(monkeypatch):
old = process()
fresh = replace(old, identity=replace(old.identity, start_token="boot:replacement"))
monkeypatch.setattr(process_ownership, "verify", lambda pid, token: process_ownership.FOREIGN if token == "boot:start" else process_ownership.OWNED)
parent = RequestAuthority("parent", "alice", "thread", "", process_resources=(old,))
child = replace(parent, request_id="child", process_resources=(fresh,))
result = parent.intersect(child)
assert result.process_resources == ()
def test_legacy_authority_cannot_reconstruct_creation_scope(tmp_path):
authority = RequestAuthority("request", "alice", "thread", str(tmp_path), (OperationGrant("bash"),))
snapshot = authority.to_dict()
snapshot["version"] = 3
for field in ("launch_scopes", "process_resources", "job_resources"):
snapshot.pop(field)
restored = RequestAuthority.from_dict(snapshot)
assert restored.launch_scopes == restored.process_resources == restored.job_resources == ()
with pytest.raises(ResourceIdentityError):
resolve_process_operation(restored, ExactOperation.normalize("bash", "pwd"), NativeBackendResource("bash"))
def test_launch_is_server_generation_exact_operation_and_credential_free(tmp_path):
authority = RequestAuthority("request", "alice", "thread", str(tmp_path), (OperationGrant("bash"),))
operation = ExactOperation.normalize("bash", "printf secret-token")
bound = resolve_process_operation(authority, operation, NativeBackendResource("bash"))
assert "secret-token" not in json.dumps(bound.to_dict())
assert len(bound.launch.generation) == 32
assert bound.launch.scope.root == authority.resource_roots[0]
with pytest.raises(ResourceIdentityError):
resolve_process_operation(authority, ExactOperation.normalize("bash", "pwd"), NativeBackendResource("bash"), approved=bound, exact_admission=True)
def test_child_launch_scope_can_narrow_but_cannot_broaden(tmp_path):
sub = tmp_path / "child"
sub.mkdir()
parent = RequestAuthority("request", "alice", "thread", str(tmp_path), (OperationGrant("bash"),))
smaller = ProcessLaunchScope(NativeBackendResource("bash"), FilesystemRoot.seal(sub, owner="alice"), DEFAULT_REQUIRED)
child = replace(parent, launch_scopes=(smaller,))
assert parent.intersect(child).launch_scopes == (smaller,)
assert child.intersect(parent).launch_scopes == ()
def test_child_launch_cannot_refresh_a_replaced_root(tmp_path):
root = tmp_path / "root"
root.mkdir()
parent = RequestAuthority("request", "alice", "thread", str(root), (OperationGrant("bash"),))
root.rename(tmp_path / "retired")
root.mkdir()
child = RequestAuthority("child", "alice", "thread", str(root), (OperationGrant("bash"),))
with pytest.raises(ResourceIdentityError):
parent.intersect(child)
+5 -1
View File
@@ -184,10 +184,14 @@ async def test_external_record_does_not_grant_authority(tmp_path):
async def test_native_local_bash_python_behavior_unchanged(tmp_path, monkeypatch):
"""4. Native local Bash/Python behavior is unchanged."""
tool_bash = subprocess_tools.BashTool()
from tests.process_resource_helpers import authorized_handler
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(_te, "agent_cwd", lambda: str(workspace))
ctx = {
"session_id": "native-session",
}
result = await tool_bash.execute("echo 'native run'", ctx)
result = await authorized_handler(tool_bash.execute, workspace)("echo 'native run'", ctx)
assert result["exit_code"] == 0
assert "native run" in result["output"]
assert "containment" in result
+397
View File
@@ -0,0 +1,397 @@
"""Backend selection is resolution, never an operation or resource grant."""
import asyncio
from dataclasses import replace
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority
from src.agent_runtime.remote_resources import (
active_backend_operation, bind_backend_operation, bind_backend_for_operation,
configuration_incarnation, endpoint_identity, integration_resource, seal_backends,
)
from src.agent_runtime.resources import ExternalResource, NativeBackendResource, ResourceIdentityError
from src.mcp_manager import McpManager
from src.tool_approvals import ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
from src.tool_types import ToolBlock
def grant(*tools, resources=None):
return RequestAuthority("remote-request", "alice", "s", "",
tuple(OperationGrant(t) for t in tools), backend_resources=resources)
async def dispatch(authority, tool, content="{}", **kwargs):
from src import tool_execution as execution
return await execution.execute_tool_block(ToolBlock(tool, content), owner=authority.owner,
session_id=authority.session_id, request_authority=authority,
security_context=kwargs.pop("security_context", execution.NO_TOOL_SECURITY_CONTEXT), **kwargs)
def approval(authority, tool, content="{}", **kwargs):
store = ToolApprovalStore()
pending = store.create(owner=authority.owner, session_id=authority.session_id, origin_run_id="run",
tool_name=tool, content=content, workspace=None, request_authority=authority,
external_untrusted_context_seen=True, capabilities=capabilities_for_action(tool, content), **kwargs)
return store.consume(pending.approval_id, decision="approve", owner=authority.owner, session_id=authority.session_id)
@pytest.fixture
def manager(monkeypatch):
from src import tool_execution as execution
value = McpManager()
monkeypatch.setattr(execution, "get_mcp_manager", lambda: value)
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
return value
def connect(manager, server="alpha", tools=("read", "write"), url="https://example.test/mcp?token=SECRET"):
session = SimpleNamespace(call_tool=AsyncMock(return_value=SimpleNamespace(
content=[SimpleNamespace(text="remote result")], isError=False)))
manager._sessions[server] = session
manager._tools[server] = [{"name": tool} for tool in tools]
manager._resource_endpoints[server] = (endpoint_identity(url), configuration_incarnation(url))
manager._register_resource_connection(server, session)
return session
@pytest.mark.parametrize("kind", ["availability", "selection", "model_name", "legacy"])
async def test_remote_availability_does_not_create_resource_authority(manager, kind):
session = connect(manager)
authority = grant("mcp__alpha__read", resources=()) if kind != "model_name" else grant()
if kind == "legacy":
snapshot = grant("mcp__alpha__read").to_dict()
snapshot["version"] = 2
authority = RequestAuthority.from_dict(snapshot)
_, result = await dispatch(authority, "mcp__alpha__read")
assert result["exit_code"] == 1
session.call_tool.assert_not_awaited()
async def test_qualified_mcp_binds_exact_tool_and_backend(manager):
session = connect(manager)
authority = grant("mcp__alpha__read")
_, allowed = await dispatch(authority, "mcp__alpha__read", '{"record":"one"}')
assert allowed["exit_code"] == 0
session.call_tool.assert_awaited_once_with("read", {"record": "one"})
_, denied = await dispatch(replace(authority, grants=(OperationGrant("mcp__alpha__write"),)), "mcp__alpha__write")
assert denied["failure_kind"] == "resource_identity_denied"
assert active_backend_operation() is None
@pytest.mark.parametrize("change", ["session", "endpoint", "path", "query", "discovery"])
async def test_remote_identity_changes_invalidate_admission_and_exact_approval(manager, change):
old = connect(manager)
authority = grant("mcp__alpha__read")
exact = approval(authority, "mcp__alpha__read", '{"resource":"one"}')
assert exact.pending.backend_operation is not None
if change == "session":
new = connect(manager)
elif change == "discovery":
manager._tools["alpha"] = [{"name": "write"}]
else:
url = {"endpoint": "https://other.test/mcp", "path": "https://example.test/other",
"query": "https://example.test/mcp?token=OTHER"}[change]
manager._resource_endpoints["alpha"] = (endpoint_identity(url), configuration_incarnation(url))
for extra in ({}, {"exact_approval": exact, "security_context": ToolRunSecurityContext(external_untrusted_context_seen=True)}):
_, result = await dispatch(authority, "mcp__alpha__read", '{"resource":"one"}', **extra)
assert result["failure_kind"] == "resource_identity_denied"
old.call_tool.assert_not_awaited()
if change == "session":
new.call_tool.assert_not_awaited()
assert not exact._claimed
@pytest.mark.parametrize("change", ["tool", "selector", "request", "owner", "session"])
async def test_remote_approval_is_bound_to_operation_and_request(manager, change):
session = connect(manager)
authority = grant("mcp__alpha__read", "mcp__alpha__write")
exact = approval(authority, "mcp__alpha__read", '{"record":"one"}')
tool, content = "mcp__alpha__read", '{"record":"one"}'
if change == "tool":
tool = "mcp__alpha__write"
elif change == "selector":
content = '{"record":"two"}'
else:
authority = replace(authority, **{"request": {"request_id": "other"}, "owner": {"owner": "bob"},
"session": {"session_id": "other"}}[change])
_, result = await dispatch(authority, tool, content, exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result["exit_code"] == 1
session.call_tool.assert_not_awaited()
assert not exact._claimed
async def test_legacy_exact_remote_approval_is_one_use_and_does_not_mint_backend_scope(manager):
session = connect(manager)
authority = grant(resources=())
exact = approval(authority, "mcp__alpha__read")
security = ToolRunSecurityContext(external_untrusted_context_seen=True)
_, result = await dispatch(authority, "mcp__alpha__read", exact_approval=exact, security_context=security)
assert result["exit_code"] == 0
_, replay = await dispatch(authority, "mcp__alpha__read", exact_approval=exact, security_context=security)
assert replay["exit_code"] == 1
assert session.call_tool.await_count == 1 and authority.backend_resources == ()
assert "backend_operation" not in exact.pending.public_payload()
async def test_child_cannot_use_parent_ungranted_backend_or_exact_approval(manager):
session = connect(manager)
parent = grant("mcp__alpha__read", resources=())
child = grant("mcp__alpha__read")
exact = approval(child, "mcp__alpha__read")
with bind_request_authority(parent):
_, denied = await dispatch(child, "mcp__alpha__read", exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert denied["failure_kind"] == "resource_identity_denied"
session.call_tool.assert_not_awaited()
def test_remote_snapshots_exclude_credentials_and_cannot_claim_containment(manager):
connect(manager, url="https://user:PASSWORD@example.test/SECRET_PATH?token=TOKEN")
authority = grant("mcp__alpha__read")
snapshot = json.dumps(authority.to_dict())
assert all(secret not in snapshot for secret in ("PASSWORD", "SECRET_PATH", "TOKEN", "user:"))
resource = authority.backend_resources[0]
assert resource.endpoint_id == "https://example.test"
assert resource.external is True and resource.contained is False
assert RequestAuthority.from_dict(json.loads(snapshot)) == authority
with pytest.raises(ValueError):
replace(resource, contained=True)
async def test_mcp_revalidates_at_transport_and_never_retries_bound_calls(manager):
manager._resource_owners["memory"] = "alice"
session = connect(manager, server="memory")
authority = grant("mcp__memory__read")
operation = ExactOperation.normalize("mcp__memory__read", "{}")
bound = bind_backend_for_operation(authority, operation)
reconnect = AsyncMock()
manager._reconnect_builtin = reconnect
session.call_tool.side_effect = RuntimeError("disconnected")
with bind_backend_operation(bound):
result = await manager.call_tool(operation.tool, {})
assert result["exit_code"] == 1
replacement = connect(manager, server="memory")
result = await manager.call_tool(operation.tool, {})
assert result["failure_kind"] == "resource_identity_denied"
replacement.call_tool.assert_not_awaited()
reconnect.assert_not_awaited()
@pytest.mark.parametrize("owner", ["", "bob"])
async def test_builtin_memory_backend_requires_its_configured_owner(manager, owner):
manager._resource_owners["memory"] = owner
session = connect(manager, server="memory")
_, result = await dispatch(grant("mcp__memory__read"), "mcp__memory__read")
assert result["failure_kind"] == "resource_identity_denied"
session.call_tool.assert_not_awaited()
async def test_native_filesystem_cannot_be_redirected_through_mcp(manager, tmp_path):
session = connect(manager, server="filesystem", tools=("read_file",))
(tmp_path / "a").write_text("native contents")
authority = RequestAuthority("request", "alice", "s", str(tmp_path), (OperationGrant("read_file"),))
from src import tool_execution as execution
_, result = await execution.execute_tool_block(ToolBlock("read_file", "a"), owner="alice", session_id="s",
workspace=str(tmp_path), request_authority=authority, security_context=execution.NO_TOOL_SECURITY_CONTEXT)
assert result["output"] == "native contents"
assert isinstance(authority.backend_resources[0], NativeBackendResource)
session.call_tool.assert_not_awaited()
@pytest.mark.parametrize("change", ["alias", "endpoint", "secret_path"])
async def test_integration_alias_and_configuration_cannot_retarget_approval(monkeypatch, change):
from src import integrations
rows = [{"id": "one", "name": "service", "base_url": "https://service.test/SECRET", "enabled": True}]
monkeypatch.setattr(integrations, "load_integrations", lambda: rows)
authority = grant("api_call", resources=(integration_resource(rows[0]),))
exact = approval(authority, "api_call", '{"integration":"service","path":"/record/one"}')
assert exact.pending.backend_operation.resource.server_id == "one"
assert "SECRET" not in json.dumps(authority.to_dict())
if change == "alias":
rows[:] = [{**rows[0], "id": "two"}]
else:
rows[0]["base_url"] = "https://other.test/SECRET" if change == "endpoint" else "https://service.test/OTHER"
from src import tool_execution as execution
handler = AsyncMock()
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
_, result = await dispatch(authority, "api_call", exact.pending.content, exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result["failure_kind"] == "resource_identity_denied"
handler.assert_not_awaited()
@pytest.mark.parametrize("error", [None, RuntimeError, asyncio.CancelledError])
async def test_scoped_bridge_context_restores_and_replacement_is_ungranted(monkeypatch, error):
from src import tool_execution as execution
seen = []
async def route(*args):
seen.append(active_backend_operation().resource)
if error:
raise error("stop")
return "bridge", {"exit_code": 0}
bridge = execution.AgentExecutionBridge(route, frozenset({"host_shell"}), name="test")
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
with execution.bind_execution_bridge(bridge):
authority = grant("host_shell")
parent_bound = bind_backend_for_operation(authority, ExactOperation.normalize("host_shell", "parent"))
with bind_backend_operation(parent_bound):
if error is asyncio.CancelledError:
with pytest.raises(error):
await dispatch(authority, "host_shell", "pwd")
else:
await dispatch(authority, "host_shell", "pwd")
assert active_backend_operation() is parent_bound
assert active_backend_operation() is None
assert seen[0].external and not seen[0].contained
with execution.bind_execution_bridge(replace(bridge)):
_, denied = await dispatch(authority, "host_shell", "pwd")
assert denied["failure_kind"] == "resource_identity_denied"
async def test_tui_endpoint_is_registered_only_at_trusted_admission(monkeypatch):
from src import tool_execution as execution
context = {"surface": "odysseus-tui", "host_shell_bridge": {"url": "http://127.0.0.1:17654/run", "token": "TOKEN"}}
authority = grant("bash", resources=(NativeBackendResource("bash"),))
_, denied = await dispatch(authority, "bash", "pwd", client_runtime_context=context)
assert denied["failure_kind"] == "resource_identity_denied"
authority = replace(authority, backend_resources=seal_backends(["bash"], context=context, owner="alice"))
monkeypatch.setattr(execution, "_bridge_post", AsyncMock(return_value={"exit_code": 0, "stdout": "external", "stderr": ""}))
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
_, allowed = await dispatch(authority, "bash", "pwd", client_runtime_context=context)
assert allowed["exit_code"] == 0
context["host_shell_bridge"]["url"] = "http://127.0.0.1:17655/run"
_, denied = await dispatch(authority, "bash", "pwd", client_runtime_context=context)
assert denied["failure_kind"] == "resource_identity_denied"
@pytest.mark.parametrize("change", [None, "url", "token"])
async def test_http_bridge_factory_and_admission_share_config_identity(monkeypatch, change):
from src import tool_execution as execution
from routes.chat_routes import _external_execution_bridge
context = {"external_execution_bridge": {"url": "http://127.0.0.1:17654/execute",
"token": "SECRET_TOKEN", "supported_tools": ["host_shell"]}}
authority = grant("host_shell", resources=seal_backends(["host_shell"], context=context, owner="alice"))
if change:
context["external_execution_bridge"][change] = "http://127.0.0.1:17655/execute" if change == "url" else "OTHER_TOKEN"
bridge = _external_execution_bridge(context)
route = AsyncMock(return_value=("bridge", {"exit_code": 0}))
bridge = replace(bridge, route_tool=route)
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
with execution.bind_execution_bridge(bridge):
_, result = await dispatch(authority, "host_shell", "pwd", client_runtime_context=context)
if change:
assert result["failure_kind"] == "resource_identity_denied"
route.assert_not_awaited()
else:
assert result["exit_code"] == 0
route.assert_awaited_once()
assert "SECRET_TOKEN" not in json.dumps(authority.to_dict())
async def test_backend_alias_cannot_retarget_a_legacy_tool_after_approval(manager, monkeypatch):
from src import tool_execution as execution
first = connect(manager, server="web_fetch", tools=("web_fetch",))
second = connect(manager, server="other", tools=("fetch",))
authority = grant("web_fetch")
exact = approval(authority, "web_fetch", "https://page.test/one")
monkeypatch.setitem(execution._MCP_TOOL_MAP, "web_fetch", ("other", "fetch"))
_, result = await dispatch(authority, "web_fetch", exact.pending.content, exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result["failure_kind"] == "resource_identity_denied"
first.call_tool.assert_not_awaited()
second.call_tool.assert_not_awaited()
async def test_native_backend_is_pinned_when_mcp_becomes_available(manager, monkeypatch):
from src import tool_execution as execution
authority = grant("web_fetch")
session = connect(manager, server="web_fetch", tools=("web_fetch",))
fallback = AsyncMock(return_value={"output": "native", "exit_code": 0})
monkeypatch.setattr(execution, "_direct_fallback", fallback)
_, result = await dispatch(authority, "web_fetch", "https://page.test/one")
assert result["output"] == "native"
session.call_tool.assert_not_awaited()
async def test_integration_inventory_does_not_supply_backend_scope(monkeypatch):
from src import integrations
rows = [{"id": "one", "name": "service", "base_url": "https://service.test", "enabled": True}]
monkeypatch.setattr(integrations, "load_integrations", lambda: rows)
authority = grant("api_call")
assert authority.backend_resources == ()
_, denied = await dispatch(authority, "api_call", '{"integration":"service"}')
assert denied["failure_kind"] == "resource_identity_denied"
async def test_external_bash_marker_cannot_switch_to_local_background_execution(manager, monkeypatch):
from src import bg_jobs
session = connect(manager, server="bash", tools=("bash",))
launch = AsyncMock()
monkeypatch.setattr(bg_jobs, "launch", launch)
_, result = await dispatch(grant("bash"), "bash", "#!bg\npwd")
assert result["exit_code"] == 0
assert session.call_tool.await_count == 1
launch.assert_not_called()
@pytest.mark.parametrize("alias", ["host_shell_bridge", "hostShellBridge"])
def test_host_shell_bridge_aliases_resolve_to_one_external_identity(alias):
context = {"surface": "odysseus-tui", alias: {"url": "http://127.0.0.1:17654/run", "token": "TOKEN"}}
resources = seal_backends(["host_shell"], context=context, owner="alice")
assert len(resources) == 1 and isinstance(resources[0], ExternalResource)
assert resources[0].external and not resources[0].contained
async def test_host_shell_cannot_reconstruct_an_unsealed_external_backend(monkeypatch):
from src import tool_execution as execution
handler = AsyncMock()
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
_, result = await dispatch(grant("host_shell"), "host_shell", "pwd")
assert result["failure_kind"] == "resource_identity_denied"
handler.assert_not_awaited()
async def test_http_backend_without_bound_producer_cannot_fall_back_to_native(monkeypatch):
from src import tool_execution as execution
context = {"external_execution_bridge": {"url": "http://127.0.0.1:17654/execute",
"token": "TOKEN", "supported_tools": ["bash"]}}
authority = grant("bash", resources=seal_backends(["bash"], context=context, owner="alice"))
fallback = AsyncMock()
monkeypatch.setattr(execution, "_direct_fallback", fallback)
_, denied = await dispatch(authority, "bash", "pwd", client_runtime_context=context)
assert denied["failure_kind"] == "resource_identity_denied"
fallback.assert_not_awaited()
async def test_resumed_child_approval_cannot_restore_excluded_backend(manager):
session = connect(manager)
child = grant("mcp__alpha__read", resources=()).intersect(grant("mcp__alpha__read"))
exact = approval(child, "mcp__alpha__read")
assert exact.pending.backend_operation is None
_, result = await dispatch(replace(child, inherited=False), "mcp__alpha__read", exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result["failure_kind"] == "resource_identity_denied"
session.call_tool.assert_not_awaited()
async def test_integration_executes_server_resolved_id_and_revalidates_loaded_configuration(monkeypatch):
from src import integrations, tool_execution as execution
row = {"id": "one", "name": "service", "base_url": "https://service.test", "enabled": True}
monkeypatch.setattr(integrations, "load_integrations", lambda: [dict(row)])
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
authority = grant("api_call", resources=(integration_resource(row),))
producer = AsyncMock(return_value={"output": "remote", "exit_code": 0})
original = integrations.execute_api_call
monkeypatch.setattr(integrations, "execute_api_call", producer)
_, allowed = await dispatch(authority, "api_call", '{"integration":"service","method":"GET","path":"/record/one"}')
assert allowed["exit_code"] == 0
assert producer.await_args.args == ("one", "GET", "/record/one")
monkeypatch.setattr(integrations, "execute_api_call", original)
monkeypatch.setattr(integrations, "_find_integration", lambda identifier: {**row, "base_url": "https://other.test"})
_, denied = await dispatch(authority, "api_call", '{"integration":"service","path":"/record/one"}')
assert denied["failure_kind"] == "resource_identity_denied"
+13 -9
View File
@@ -158,15 +158,15 @@ async def test_missing_and_malformed_dispatch_authority_fail_closed(monkeypatch,
@pytest.mark.asyncio
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch):
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch, tmp_path):
from src import tool_execution as execution
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
for disabled in (set(), {"bash"}):
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"),
owner="alice", session_id="s", disabled_tools=disabled,
owner="alice", session_id="s", workspace=str(tmp_path), disabled_tools=disabled,
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
request_authority=authority("bash"))
request_authority=authority("bash", workspace=str(tmp_path)))
assert result["exit_code"] == (1 if disabled else 0)
assert implementation.await_count == 1
@@ -224,10 +224,11 @@ def test_background_snapshot_preserves_scope_and_rejects_other_session(monkeypat
import src.constants
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
grant = authority("transcribe_media").restrict(disabled_tools={"bash"})
save_background_authority("job1", grant)
# Legacy authority-only snapshots have no exact job generation to restore.
with pytest.raises(ValueError):
save_background_authority("job1", grant)
restored = restore_background_authority("job1", owner="alice", session_id="s")
assert restored.request_id == grant.request_id
assert restored.denied == frozenset({"bash"})
assert restored.grants == ()
assert not restored.permits(ExactOperation.normalize("python", "print(1)"))
assert restore_background_authority("job1", owner="alice", session_id="other").grants == ()
@@ -243,8 +244,7 @@ async def test_only_server_background_launch_can_seal_job_authority(monkeypatch,
owner="alice", session_id="s", security_context=execution.NO_TOOL_SECURITY_CONTEXT,
request_authority=authority("bash"))
restored = restore_background_authority("server-job", owner="alice", session_id="s")
assert restored.request_id == "request-test"
assert restored.permits(ExactOperation.normalize("bash", "printf trusted"))
assert restored.grants == () # A launch double returning an ID cannot publish authority.
handler = AsyncMock(return_value=("transcribe_media", {"bg_job_id": "forged-job", "exit_code": 0}))
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
await execution.execute_tool_block(ToolBlock("transcribe_media", '{}'),
@@ -254,12 +254,16 @@ async def test_only_server_background_launch_can_seal_job_authority(monkeypatch,
@pytest.mark.asyncio
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch):
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch, tmp_path):
from src import tool_execution as execution
from src.tool_approvals import ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
store = ToolApprovalStore()
original = authority("transcribe_media")
from src.agent_runtime.resources import ProcessLaunchScope, FilesystemRoot, NativeBackendResource
from src.containment import DEFAULT_REQUIRED
original = replace(original, launch_scopes=(ProcessLaunchScope(NativeBackendResource("bash"),
FilesystemRoot.seal(tmp_path), DEFAULT_REQUIRED),))
pending = store.create(owner="alice", session_id="s", origin_run_id="journal-parent",
tool_name="bash", content="printf approved", workspace=None,
external_untrusted_context_seen=True, capabilities=capabilities_for_action("bash", "printf approved"),
+716
View File
@@ -0,0 +1,716 @@
"""Server bindings narrow operation authority and survive approved continuations."""
import asyncio
from dataclasses import FrozenInstanceError, replace
import json
import os
from unittest.mock import AsyncMock
from types import SimpleNamespace
import pytest
from src.agent_runtime.authority import (
ExactOperation, OperationGrant, RequestAuthority, bind_request_authority,
create_request_authority, save_background_authority, restore_background_authority,
seal_task_authority, restore_task_authority,
)
from src.agent_runtime.resource_binding import (
BoundFilesystemOperation, ResourceBinding, active_resource_operation,
bind_resource_operation, resolve_filesystem_operation,
)
from src.agent_runtime.resources import (
ExternalResource, FileObjectIdentity,
FilesystemResource, FilesystemRoot, FilesystemScope, OwnedResource, ProcessResource,
)
from src.tool_approvals import ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
from src.tool_types import ToolBlock
def authority(root, *tools, roots=None, owner="alice", session="s"):
return RequestAuthority("resource-test", owner, session, str(root or ""),
tuple(OperationGrant(t) for t in tools), resource_roots=roots)
def resolve(grant, tool, content):
return resolve_filesystem_operation(ExactOperation.normalize(tool, content),
roots=grant.resource_roots, workspace=grant.workspace, request_id=grant.request_id)
async def dispatch(grant, tool, content, **kwargs):
from src import tool_execution as execution
return await execution.execute_tool_block(ToolBlock(tool, content),
owner=grant.owner, session_id=grant.session_id, workspace=grant.workspace or None,
request_authority=grant, security_context=kwargs.pop("security_context", execution.NO_TOOL_SECURITY_CONTEXT),
**kwargs)
@pytest.fixture(autouse=True)
def native_admin(monkeypatch):
from src import tool_execution
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
@pytest.mark.parametrize("selector", ["a.txt", "/workspace/a.txt", "host", "link"])
def test_aliases_resolve_to_one_observed_resource(tmp_path, selector):
target = tmp_path / "a.txt"
target.write_text("same object")
(tmp_path / "link").symlink_to(target)
grant = authority(tmp_path, "read_file")
value = str(target) if selector == "host" else selector
bound = resolve(grant, "read_file", value)
assert bound.bindings[0].resource.path == str(target)
assert bound.bindings[0].resource.identity == FileObjectIdentity.observe(target)
assert json.loads(bound.execution_input)["path"] == str(target)
assert bound.operation.input == value
@pytest.mark.parametrize("path", ["../sibling/secret", "/etc/passwd", ".SSH/key", "ID_RSA", "bad\0path", "bad\npath"])
def test_escapes_sensitive_and_malformed_paths_fail_closed(tmp_path, path):
grant = authority(tmp_path, "write_file")
with pytest.raises(ValueError):
resolve(grant, "write_file", json.dumps({"path": path, "content": "x"}))
def test_symlink_escape_is_not_a_resource(tmp_path):
workspace = tmp_path / "ws"
workspace.mkdir()
outside = tmp_path / "secret"
outside.write_text("private")
(workspace / "alias").symlink_to(outside)
with pytest.raises(ValueError):
resolve(authority(workspace, "read_file"), "read_file", "alias")
@pytest.mark.parametrize("alias", ["direct", "relative", "symlink", "hardlink"])
@pytest.mark.parametrize("must_exist", [True, False])
def test_media_workspace_paths_cannot_address_control_state(tmp_path, monkeypatch, alias, must_exist):
from src import constants, tool_execution
from src.agent_tools.media_tools import _resolve_workspace_path
control = tmp_path / "receipts.json"
control.write_text("private execution state")
monkeypatch.setattr(constants, "CONTAINMENT_STATE_FILE", str(control))
monkeypatch.setattr(tool_execution, "get_active_workspace", lambda: str(tmp_path))
if alias == "direct":
selector = str(control)
elif alias == "relative":
selector = "./receipts.json"
else:
target = tmp_path / "image.png"
if alias == "symlink":
target.symlink_to(control)
else:
os.link(control, target)
selector = "/workspace/image.png"
with pytest.raises(ValueError, match="execution-control"):
_resolve_workspace_path(selector, must_exist=must_exist)
assert control.read_text() == "private execution state"
def test_destination_binds_absence_and_existing_ancestors(tmp_path):
parent = tmp_path / "existing"
parent.mkdir()
bound = resolve(authority(tmp_path, "write_file"), "write_file", "existing/new/tree/result.txt\nx")
resource = bound.bindings[0].resource
assert bound.bindings[0].role == "destination"
assert resource.identity is None
assert [a.path for a in resource.ancestors] == [str(tmp_path), str(parent)]
bound.validate()
parent.rename(tmp_path / "old-parent")
parent.mkdir()
with pytest.raises(ValueError):
bound.validate()
@pytest.mark.parametrize("replacement", ["root", "file", "parent", "new-target"])
def test_replacement_invalidates_observed_identity(tmp_path, replacement):
root = tmp_path / "root"
root.mkdir()
parent = root / "sub"
parent.mkdir()
target = parent / "a.txt"
target.write_text("old")
content = "sub/new.txt\nx" if replacement == "new-target" else "sub/a.txt"
tool = "write_file" if replacement == "new-target" else "read_file"
bound = resolve(authority(root, tool), tool, content)
if replacement == "file":
target.rename(parent / "old.txt")
target.write_text("new")
elif replacement == "parent":
parent.rename(root / "old-sub")
parent.mkdir()
target.write_text("new")
elif replacement == "root":
root.rename(tmp_path / "old-root")
root.mkdir()
else:
(parent / "new.txt").write_text("unapproved target")
with pytest.raises((ValueError, OSError)):
bound.validate()
def test_content_is_not_an_object_incarnation_or_effect_claim(tmp_path):
target = tmp_path / "a"
target.write_text("old")
bound = resolve(authority(tmp_path, "read_file"), "read_file", "a")
target.write_text("changed content in the same object")
bound.validate()
@pytest.mark.parametrize("state", ["authority", "jobs", "containment"])
async def test_user_filesystem_scope_cannot_write_server_execution_state(tmp_path, monkeypatch, state):
import src.constants
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path / "jobs"))
monkeypatch.setattr(src.constants, "BG_JOBS_FILE", str(tmp_path / "jobs.json"))
monkeypatch.setattr(src.constants, "CONTAINMENT_STATE_FILE", str(tmp_path / "receipts.json"))
target = {"authority": "jobs/job.authority.json", "jobs": "jobs.json", "containment": "receipts.json"}[state]
_, result = await dispatch(authority(tmp_path, "write_file"), "write_file", target + "\nforged")
assert result["failure_kind"] == "resource_identity_denied"
assert not (tmp_path / target).exists()
@pytest.mark.parametrize("state", ["authority", "jobs", "containment", "result", "exit", "database", "vault", "uploads"])
@pytest.mark.parametrize("alias", ["direct", "relative", "symlink", "hardlink"])
async def test_control_files_cannot_be_read_or_written_through_aliases(tmp_path, monkeypatch, state, alias):
import src.constants as constants
jobs = tmp_path / "jobs"
jobs.mkdir()
monkeypatch.setattr(constants, "BG_JOBS_DIR", str(jobs))
monkeypatch.setattr(constants, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(constants, "UPLOAD_DIR", str(tmp_path / "uploads"))
for name, filename in (("BG_JOBS_FILE", "jobs.json"), ("CONTAINMENT_STATE_FILE", "receipts.json"),
("APP_DB", "private.db"), ("VAULT_FILE", "vault.json")):
monkeypatch.setattr(constants, name, str(tmp_path / filename))
filename = {"authority": "jobs/job.authority.json", "jobs": "jobs.json", "containment": "receipts.json",
"result": "jobs/job.result.json", "exit": "jobs/job.exit", "database": "private.db",
"vault": "vault.json", "uploads": "uploads/uploads.json"}[state]
target = tmp_path / filename
target.parent.mkdir(exist_ok=True)
target.write_text("control-secret")
selector = str(target)
if alias == "relative":
selector = "./" + filename
elif alias in {"symlink", "hardlink"}:
link = tmp_path / "ordinary.txt"
try:
link.symlink_to(target) if alias == "symlink" else os.link(target, link)
except OSError as error:
pytest.skip(f"Platform cannot create {alias}: {error}")
selector = str(link)
grant = authority(tmp_path, "read_file", "write_file")
for tool, content in (("read_file", selector), ("write_file", selector + "\nforged")):
_, result = await dispatch(grant, tool, content)
assert result["failure_kind"] == "resource_identity_denied"
assert target.read_text() == "control-secret"
async def test_directory_grep_does_not_scan_control_state_or_hardlinks(tmp_path, monkeypatch):
import src.constants as constants
control = tmp_path / "jobs.json"
control.write_text("UNIQUE_CONTROL_SECRET")
(tmp_path / "ordinary").write_text("visible text")
os.link(control, tmp_path / "innocent.txt")
monkeypatch.setattr(constants, "BG_JOBS_FILE", str(control))
_, result = await dispatch(authority(tmp_path, "grep"), "grep", '{"pattern":"UNIQUE_CONTROL_SECRET","path":"."}')
assert result["exit_code"] == 0
assert "No matches" in result["output"]
@pytest.mark.parametrize("tool,content", [("glob", '{"pattern":"*.json","path":"."}'), ("ls", ".")])
async def test_directory_enumeration_does_not_address_control_files(tmp_path, monkeypatch, tool, content):
import src.constants as constants
control = tmp_path / "jobs.json"
control.write_text("control")
monkeypatch.setattr(constants, "BG_JOBS_FILE", str(control))
_, result = await dispatch(authority(tmp_path, tool), tool, content)
assert result["exit_code"] == 0
assert "jobs.json" not in result["output"]
@pytest.mark.parametrize("producer", ["database", "containment", "jobs", "uploads"])
@pytest.mark.parametrize("alias", ["direct", "hardlink"])
async def test_configured_control_producer_paths_are_protected(tmp_path, monkeypatch, producer, alias):
target = tmp_path / "custom" / "state"
target.parent.mkdir()
if producer == "database":
import core.database as database
monkeypatch.setattr(database, "engine", SimpleNamespace(url=SimpleNamespace(
get_backend_name=lambda: "sqlite", database=str(target))))
elif producer == "containment":
from src import containment
monkeypatch.setattr(containment, "_store_path", lambda: target)
elif producer == "jobs":
from src import bg_jobs
monkeypatch.setattr(bg_jobs, "_STORE", target)
else:
from src import tool_utils
target = target.parent / "uploads.json"
monkeypatch.setattr(tool_utils, "get_upload_handler", lambda: SimpleNamespace(upload_dir=str(target.parent)))
target.write_text("server state")
selector = str(target)
if alias == "hardlink":
link = tmp_path / "ordinary"
os.link(target, link)
selector = str(link)
_, result = await dispatch(authority(tmp_path, "read_file"), "read_file", selector)
assert result["failure_kind"] == "resource_identity_denied"
@pytest.mark.parametrize("roots", [(), None])
async def test_nonworkspace_allowlist_and_operation_do_not_grant_resources(tmp_path, monkeypatch, roots):
from src import tool_execution as execution
target = tmp_path / "a"
target.write_text("private")
monkeypatch.setattr(execution, "_tool_path_roots", lambda: [str(tmp_path)])
implementation = AsyncMock()
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
grant = authority(None, "read_file", roots=roots)
_, result = await dispatch(grant, "read_file", str(target))
assert result["failure_kind"] == "resource_identity_denied"
implementation.assert_not_awaited()
async def test_explicit_private_root_requires_owner_and_operation(tmp_path):
(tmp_path / "a").write_text("owned")
root = FilesystemRoot.seal(tmp_path, scope=FilesystemScope.PRIVATE, owner="alice")
with pytest.raises(ValueError):
authority(None, "read_file", roots=(root,), owner="bob")
grant = authority(None, "read_file", roots=(root,))
_, result = await dispatch(grant, "read_file", "a")
assert result["output"] == "owned"
_, result = await dispatch(grant, "write_file", "b\nx")
assert result["failure_kind"] == "request_authority_denied"
assert not (tmp_path / "b").exists()
@pytest.mark.parametrize("content", ['{"path":null}', '{"path":42}', '{"path":[]}', '{"path":{}}', '{"path":"a","path":"b"}'])
async def test_model_cannot_supply_or_reconstruct_a_resource(tmp_path, monkeypatch, content):
from src import tool_execution as execution
implementation = AsyncMock()
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
_, result = await dispatch(authority(tmp_path, "read_file"), "read_file", content)
assert result["blocked"] is True
implementation.assert_not_awaited()
async def test_model_root_field_is_not_authority(tmp_path, monkeypatch):
from src import tool_execution as execution
implementation = AsyncMock()
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
_, result = await dispatch(authority(None, "write_file"), "write_file",
json.dumps({"path": str(tmp_path / "a"), "content": "x", "resource_roots": [str(tmp_path)]}))
assert result["failure_kind"] == "resource_identity_denied"
implementation.assert_not_awaited()
def test_child_intersects_root_and_preserves_workspace_alias_base(tmp_path):
sub = tmp_path / "sub"
sub.mkdir()
(sub / "a").write_text("child")
(tmp_path / "outside").write_text("parent")
parent = authority(tmp_path, "read_file")
narrow = FilesystemRoot.seal(sub, owner="alice")
child = authority(tmp_path, "read_file", roots=(narrow,))
for effective in (parent.intersect(child), child.intersect(parent)):
assert effective.resource_roots == (narrow,)
assert resolve(effective, "read_file", "/workspace/sub/a").bindings[0].resource.path == str(sub / "a")
with pytest.raises(ValueError):
resolve(effective, "read_file", "/workspace/outside")
assert parent.intersect(authority(tmp_path, "read_file", roots=())).resource_roots == ()
assert parent.intersect(authority(tmp_path, "read_file", owner="bob")).resource_roots == ()
def test_child_cannot_renew_replaced_parent_root(tmp_path):
root = tmp_path / "root"
root.mkdir()
parent = authority(root, "read_file")
root.rename(tmp_path / "old")
root.mkdir()
child = authority(root, "read_file")
assert parent.intersect(child).resource_roots == ()
@pytest.mark.parametrize("caller", ["intersection", "context", "task"])
@pytest.mark.parametrize("child_location", ["root", "subtree"])
def test_replaced_parent_cannot_be_renewed_by_new_child_observation(tmp_path, caller, child_location):
root = tmp_path / "root"
root.mkdir()
parent = authority(root, "read_file")
root.rename(tmp_path / "old")
root.mkdir()
sub = root / "sub"
sub.mkdir()
(sub / "a").write_text("replacement")
child_root = FilesystemRoot.seal(root if child_location == "root" else sub, owner="alice")
child = authority(root, "read_file", roots=(child_root,))
if caller == "intersection":
effective = parent.intersect(child)
elif caller == "context":
with bind_request_authority(parent), bind_request_authority(child) as effective:
assert effective.resource_roots == ()
else:
with bind_request_authority(parent):
sealed = seal_task_authority("Read files in the workspace", "llm", None, owner="alice")
effective = restore_task_authority(sealed, "Read files in the workspace", "llm", None,
owner="alice", session_id="continuation")
assert effective.resource_roots == ()
with pytest.raises(ValueError):
resolve(effective, "read_file", "sub/a")
def test_equal_stale_roots_are_revalidated(tmp_path):
root = tmp_path / "root"
root.mkdir()
parent = authority(root, "read_file")
root.rename(tmp_path / "old")
root.mkdir()
assert parent.intersect(parent).resource_roots == ()
@pytest.mark.parametrize("legacy", [False, True])
async def test_snapshot_preserves_incarnation_and_never_reconstructs_legacy(tmp_path, legacy):
root = tmp_path / "root"
root.mkdir()
(root / "a").write_text("original")
grant = authority(root, "read_file")
snapshot = grant.to_dict()
if legacy:
snapshot["version"] = 1
snapshot.pop("resource_roots")
restored = RequestAuthority.from_dict(json.loads(json.dumps(snapshot)))
assert restored.resource_roots == (() if legacy else grant.resource_roots)
root.rename(tmp_path / "old")
root.mkdir()
(root / "a").write_text("replacement")
_, result = await dispatch(restored, "read_file", "a")
assert result["failure_kind"] == "resource_identity_denied"
@pytest.mark.parametrize("mutation", [None, "root", [{}], [{"path": "/", "scope": "workspace", "identity": {"device": 1, "inode": 2, "kind": "directory"}, "owner": "alice"}]])
def test_malformed_resource_snapshots_are_rejected(tmp_path, mutation):
snapshot = authority(tmp_path, "read_file").to_dict()
snapshot["resource_roots"] = mutation
with pytest.raises((TypeError, ValueError, KeyError)):
RequestAuthority.from_dict(snapshot)
def test_task_and_background_continuations_keep_original_roots(tmp_path, monkeypatch):
import src.constants
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
grant = authority(tmp_path, "read_file")
# A roots-only sidecar is legacy state and cannot invent a job generation.
with pytest.raises(ValueError):
save_background_authority("job", grant)
assert restore_background_authority("job", owner="alice", session_id="s").resource_roots == ()
assert restore_background_authority("job", owner="bob", session_id="s").resource_roots == ()
with bind_request_authority(grant):
sealed = seal_task_authority("Read files in the workspace", "llm", None, owner="alice")
assert restore_task_authority(sealed, "Read files in the workspace", "llm", None,
owner="alice", session_id="continuation").resource_roots == grant.resource_roots
async def test_dispatch_consumes_canonical_binding_and_pins_native_backend(tmp_path, monkeypatch):
from src import tool_execution as execution
import src.agent_tools
(tmp_path / "a").write_text("bound")
(tmp_path / "alias").symlink_to(tmp_path / "a")
handler = AsyncMock(return_value={"output": "handled", "exit_code": 0})
mcp = AsyncMock()
monkeypatch.setitem(src.agent_tools.TOOL_HANDLERS, "read_file", handler)
monkeypatch.setattr(execution, "get_mcp_manager", lambda: mcp)
_, result = await dispatch(authority(tmp_path, "read_file"), "read_file", "alias")
assert result["output"] == "handled"
content, ctx = handler.call_args.args
assert json.loads(content)["path"] == str(tmp_path / "a")
assert ctx["resource_operation"].bindings[0].resource.path == str(tmp_path / "a")
assert ctx["resource_operation"].request_id == "resource-test"
mcp.call_tool.assert_not_awaited()
assert active_resource_operation() is None
def test_bound_resolver_rejects_undeclared_paths_and_scopes_search(tmp_path):
from src.tool_execution import _resolve_tool_path, _resolve_search_root
sub = tmp_path / "sub"
sub.mkdir()
(sub / "a").write_text("a")
(tmp_path / "outside").write_text("outside")
grant = authority(tmp_path, "read_file", "grep")
bound = resolve(grant, "read_file", "sub/a")
with bind_resource_operation(bound):
assert _resolve_tool_path(str(sub / "a")) == str(sub / "a")
with pytest.raises(ValueError):
_resolve_tool_path(str(tmp_path / "outside"))
search = resolve(grant, "grep", '{"pattern":"a","path":"sub"}')
with bind_resource_operation(search):
assert _resolve_search_root("") == str(sub)
assert _resolve_tool_path(str(sub / "a")) == str(sub / "a")
with pytest.raises(ValueError):
_resolve_tool_path(str(tmp_path / "outside"))
async def test_concurrent_resource_contexts_do_not_leak(tmp_path, monkeypatch):
from src import tool_execution as execution
arrived = asyncio.Event()
seen = []
async def implementation(block, **kwargs):
bound = active_resource_operation()
seen.append(bound.bindings[0].resource.path)
if len(seen) == 2:
arrived.set()
await arrived.wait()
assert active_resource_operation() is bound
return "read", {"exit_code": 0}
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
for name in ("a", "b"):
(tmp_path / name).write_text(name)
grant = authority(tmp_path, "read_file")
await asyncio.gather(dispatch(grant, "read_file", "a"), dispatch(grant, "read_file", "b"))
assert set(seen) == {str(tmp_path / "a"), str(tmp_path / "b")}
assert active_resource_operation() is None
async def test_last_dispatch_validation_refuses_replacement_and_resets_context(tmp_path, monkeypatch):
from src import tool_execution as execution
target = tmp_path / "a"
target.write_text("old")
implementation = AsyncMock()
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
security = ToolRunSecurityContext()
def decision(*args):
target.rename(tmp_path / "old-a")
target.write_text("new")
return SimpleNamespace(allowed=True)
monkeypatch.setattr(security, "decision_for", decision)
_, result = await dispatch(authority(tmp_path, "read_file"), "read_file", "a", security_context=security)
assert result["failure_kind"] == "resource_identity_denied"
implementation.assert_not_awaited()
assert active_resource_operation() is None
assert execution.get_active_workspace() is None
@pytest.mark.parametrize("error_type", [None, RuntimeError, asyncio.CancelledError])
async def test_nested_resource_context_restores_on_failure_or_cancellation(tmp_path, monkeypatch, error_type):
from src import tool_execution as execution
for name in ("parent", "child"):
(tmp_path / name).write_text(name)
grant = authority(tmp_path, "read_file")
parent = resolve(grant, "read_file", "parent")
async def implementation(block, **kwargs):
assert active_resource_operation().bindings[0].resource.path == str(tmp_path / "child")
if error_type:
raise error_type("stop")
return "read", {"exit_code": 0}
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
with bind_resource_operation(parent):
if error_type:
with pytest.raises(error_type):
await dispatch(grant, "read_file", "child")
else:
await dispatch(grant, "read_file", "child")
assert active_resource_operation() is parent
assert active_resource_operation() is None
assert execution.get_active_workspace() is None
@pytest.mark.parametrize("kind", ["collision", "hardlink", "escape", "move"])
async def test_patch_validates_all_targets_before_any_write(tmp_path, kind):
(tmp_path / "a").write_text("old\n")
(tmp_path / "alias").symlink_to(tmp_path / "a")
os.link(tmp_path / "a", tmp_path / "hardlink")
suffix = {
"collision": "*** Update File: alias\n@@\n-old\n+second",
"hardlink": "*** Update File: hardlink\n@@\n-old\n+second",
"escape": "*** Add File: ../escape.txt\n+escaped",
"move": "*** Update File: alias\n*** Move to: moved\n@@\n-old\n+moved",
}[kind]
patch = f"*** Begin Patch\n*** Update File: a\n@@\n-old\n+new\n{suffix}\n*** End Patch"
_, result = await dispatch(authority(tmp_path, "apply_patch"), "apply_patch", patch)
assert result["failure_kind"] == "resource_identity_denied"
assert (tmp_path / "a").read_text() == "old\n"
assert not (tmp_path / "moved").exists()
def test_move_contract_binds_both_distinct_resources(tmp_path):
(tmp_path / "a").write_text("source")
root = FilesystemRoot.seal(tmp_path)
source = ResourceBinding("source", FilesystemResource.resolve(root, "a"))
destination = ResourceBinding("destination", FilesystemResource.resolve(root, "b", allow_missing=True))
operation = ExactOperation("move_file", "a -> b", "move", "move_file")
with pytest.raises(ValueError):
BoundFilesystemOperation(operation, "", (source,))
BoundFilesystemOperation(operation, "", (source, destination)).validate()
def approval(grant, tool, content):
store = ToolApprovalStore()
pending = store.create(owner=grant.owner, session_id=grant.session_id,
origin_run_id="run", tool_name=tool, content=content, workspace=grant.workspace,
external_untrusted_context_seen=True, capabilities=capabilities_for_action(tool, content),
request_authority=grant)
exact = store.consume(pending.approval_id, decision="approve", owner=grant.owner, session_id=grant.session_id)
security = ToolRunSecurityContext()
security.external_untrusted_context_seen = True
return exact, security
@pytest.mark.parametrize("change", ["alias", "file", "parent"])
async def test_approval_resource_retargeting_refuses_without_claiming(tmp_path, change):
parent = tmp_path / "sub"
parent.mkdir()
(parent / "a").write_text("a")
(parent / "b").write_text("b")
alias = parent / "alias"
alias.symlink_to(parent / "a")
grant = authority(tmp_path, "read_file")
exact, security = approval(grant, "read_file", "sub/alias")
assert exact.pending.resource_operation is not None
if change == "alias":
alias.unlink()
alias.symlink_to(parent / "b")
elif change == "file":
(parent / "a").rename(parent / "old-a")
(parent / "a").write_text("replacement")
else:
parent.rename(tmp_path / "old-sub")
parent.mkdir()
(parent / "a").write_text("replacement")
alias.symlink_to(parent / "a")
_, result = await dispatch(grant, "read_file", "sub/alias", exact_approval=exact, security_context=security)
assert result["failure_kind"] == "resource_identity_denied"
assert exact.matches(owner="alice", session_id="s", workspace=str(tmp_path), tool_name="read_file", content="sub/alias")
async def test_approval_is_exact_and_one_use_with_immutable_resource_snapshot(tmp_path):
(tmp_path / "a").write_text("a")
grant = authority(tmp_path, "read_file")
exact, security = approval(grant, "read_file", "a")
with pytest.raises(FrozenInstanceError):
exact.pending.resource_operation.execution_input = "other"
assert "resource_operation" not in exact.pending.public_payload()
_, modified = await dispatch(grant, "read_file", "/workspace/a", exact_approval=exact, security_context=security)
assert modified["exit_code"] == 1
_, result = await dispatch(grant, "read_file", "a", exact_approval=exact, security_context=security)
assert result["output"] == "a"
_, replay = await dispatch(grant, "read_file", "a", exact_approval=exact, security_context=security)
assert replay["exit_code"] == 1
async def test_exact_approval_cannot_widen_a_child_resource_scope(tmp_path):
sub = tmp_path / "sub"
sub.mkdir()
(tmp_path / "outside").write_text("parent")
parent = authority(tmp_path, "read_file")
exact, security = approval(parent, "read_file", "outside")
child = replace(parent, resource_roots=(FilesystemRoot.seal(sub, owner="alice"),))
with bind_request_authority(parent), bind_request_authority(child) as effective:
_, result = await dispatch(effective, "read_file", "outside", exact_approval=exact, security_context=security)
assert result["failure_kind"] == "resource_identity_denied"
async def test_approved_resource_cannot_migrate_to_another_request(tmp_path):
(tmp_path / "a").write_text("original request")
grant = authority(tmp_path, "read_file")
exact, security = approval(grant, "read_file", "a")
_, result = await dispatch(replace(grant, request_id="new-request"), "read_file", "a",
exact_approval=exact, security_context=security)
assert result["failure_kind"] == "resource_identity_denied"
async def test_missing_approval_resource_snapshot_cannot_be_reconstructed(tmp_path):
grant = authority(tmp_path, "read_file")
exact, security = approval(grant, "read_file", "missing")
assert exact.pending.resource_operation is None
(tmp_path / "missing").write_text("appeared after proposal")
_, result = await dispatch(grant, "read_file", "missing", exact_approval=exact, security_context=security)
assert result["failure_kind"] == "resource_identity_denied"
async def test_exact_user_approval_binds_only_one_missing_destination(tmp_path):
grant = RequestAuthority.empty(owner="alice", session_id="s", workspace=str(tmp_path))
exact, security = approval(grant, "write_file", "new/file.txt\napproved")
_, result = await dispatch(grant, "write_file", "new/file.txt\napproved", exact_approval=exact, security_context=security)
assert result["exit_code"] == 0
assert (tmp_path / "new/file.txt").read_text() == "approved"
assert grant.grants == () and grant.resource_roots == ()
_, next_action = await dispatch(grant, "write_file", "other.txt\nunapproved")
assert next_action["failure_kind"] == "request_authority_denied"
assert not (tmp_path / "other.txt").exists()
@pytest.mark.parametrize("version", [1, 2])
async def test_restored_empty_roots_approval_is_exact_and_never_restores_generic_scope(tmp_path, version):
(tmp_path / "approved").write_text("approved content")
(tmp_path / "sibling").write_text("private sibling")
snapshot = authority(tmp_path, "read_file", "write_file", "ls").to_dict()
snapshot["version"] = version
snapshot["resource_roots"] = []
restored = RequestAuthority.from_dict(snapshot)
exact, security = approval(restored, "read_file", "approved")
assert exact.pending.resource_operation is not None
for tool, content in (("read_file", "sibling"), ("ls", "."), ("write_file", "sibling\nx")):
_, blocked = await dispatch(restored, tool, content, exact_approval=exact, security_context=security)
assert blocked["exit_code"] == 1
_, unapproved = await dispatch(restored, tool, content)
assert unapproved["failure_kind"] == "resource_identity_denied"
_, allowed = await dispatch(restored, "read_file", "approved", exact_approval=exact, security_context=security)
assert allowed["output"] == "approved content"
_, replay = await dispatch(restored, "read_file", "approved", exact_approval=exact, security_context=security)
assert replay["exit_code"] == 1
assert restored.resource_roots == () and restored.backend_resources == ()
assert (tmp_path / "sibling").read_text() == "private sibling"
@pytest.mark.parametrize("change", ["alias", "request", "session", "owner"])
async def test_restored_exact_filesystem_binding_rejects_retarget_and_rebinding(tmp_path, change):
(tmp_path / "a").write_text("a")
(tmp_path / "b").write_text("b")
(tmp_path / "alias").symlink_to(tmp_path / "a")
snapshot = authority(tmp_path, "read_file").to_dict()
snapshot["version"] = 1
restored = RequestAuthority.from_dict(snapshot)
exact, security = approval(restored, "read_file", "alias")
if change == "alias":
(tmp_path / "alias").unlink()
(tmp_path / "alias").symlink_to(tmp_path / "b")
else:
restored = replace(restored, **{"request": {"request_id": "other"},
"session": {"session_id": "other"}, "owner": {"owner": "bob"}}[change])
_, result = await dispatch(restored, "read_file", "alias", exact_approval=exact, security_context=security)
assert result["exit_code"] == 1
assert exact.matches(owner="alice", session_id="s", workspace=str(tmp_path), tool_name="read_file", content="alias")
async def test_resumed_child_approval_cannot_renew_replaced_parent_root(tmp_path):
root = tmp_path / "root"
root.mkdir()
parent = authority(root, "read_file")
root.rename(tmp_path / "old")
root.mkdir()
(root / "new").write_text("replacement")
child = parent.intersect(authority(root, "read_file"))
exact, security = approval(child, "read_file", "new")
assert child.resource_roots == () and exact.pending.resource_operation is None
_, result = await dispatch(replace(child, inherited=False), "read_file", "new", exact_approval=exact, security_context=security)
assert result["failure_kind"] == "resource_identity_denied" and not exact._claimed
@pytest.mark.parametrize("request_text,denied", [
("Transcribe /workspace/audio.wav", "read_file"),
("OCR extract exact text from /workspace/image.png", "write_file"),
("List my tasks", "read_file"),
])
async def test_resource_identity_never_expands_narrow_request_classes(tmp_path, request_text, denied):
(tmp_path / "a").write_text("a")
grant = create_request_authority(request_text, owner="alice", session_id="s", workspace=str(tmp_path))
_, result = await dispatch(grant, denied, "a" if denied == "read_file" else "a\nx")
assert result["failure_kind"] == "request_authority_denied"
def test_nonfilesystem_identities_are_inert_and_distinguish_producers_from_pages():
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")
assert ExternalResource("mcp", "endpoint", "server", "tool", "connection").external is True
with pytest.raises(ValueError):
ExternalResource("mcp", "endpoint", "server", "tool", "connection", external=False)
with pytest.raises(ValueError):
ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(123, ""), "leader", containment_id="receipt")
+38 -19
View File
@@ -563,6 +563,7 @@ async def test_host_shell_uses_tui_bridge_context(monkeypatch):
),
owner="admin",
client_runtime_context={
"surface": "odysseus-tui",
"host_shell_bridge": {
"url": "http://host.docker.internal:17654/run",
"token": "bridge-token",
@@ -622,7 +623,10 @@ async def test_host_shell_forwards_detach_and_job_polling(monkeypatch):
monkeypatch.setattr(auth_mod, "AuthManager", lambda: FakeAuth())
monkeypatch.setattr(subprocess_tools.httpx, "AsyncClient", FakeAsyncClient)
context = {"host_shell_bridge": {"url": "http://host.docker.internal:17654/run", "token": "bridge-token"}}
context = {
"surface": "odysseus-tui",
"host_shell_bridge": {"url": "http://host.docker.internal:17654/run", "token": "bridge-token"},
}
_, started = await _execute_without_run_context(
execute_tool_block,
@@ -680,7 +684,8 @@ async def test_host_shell_rejects_non_local_bridge_url_before_http(monkeypatch):
assert desc.startswith("host_shell:")
assert result["exit_code"] == 1
assert result["error"] == "host_shell: invalid bridge URL"
assert result.get("failure_kind") == "resource_identity_denied"
assert "unresolved" in result["error"].lower()
def test_host_shell_bridge_allows_backend_default_gateway(monkeypatch):
@@ -890,9 +895,12 @@ async def test_app_api_endpoint_discovery_hides_cookbook_host_control_routes(mon
@pytest.mark.asyncio
async def test_public_agent_policy_blocks_sensitive_tools(monkeypatch):
async def test_public_agent_policy_blocks_sensitive_tools(monkeypatch, tmp_path):
auth_mod = _install_core_auth_stub(monkeypatch)
from src.tool_execution import execute_tool_block
import src.tool_execution as tool_execution
mcp = _FakeMcpManager()
monkeypatch.setattr(tool_execution, "get_mcp_manager", lambda: mcp)
class FakeAuth:
is_configured = True
@@ -912,11 +920,15 @@ async def test_public_agent_policy_blocks_sensitive_tools(monkeypatch):
"ai_draft_email_reply", "archive_email", "delete_email",
"mark_email_read", "bulk_email", "download_attachment",
)
test_file = tmp_path / "test.txt"
test_file.write_text("sample")
for tool_name in bare_email_tools + ("read_file", "mcp__email__send_email"):
content = json.dumps({"path": str(test_file)}) if tool_name == "read_file" else "{}"
desc, result = await _execute_without_run_context(
execute_tool_block,
SimpleNamespace(tool_type=tool_name, content="{}"),
SimpleNamespace(tool_type=tool_name, content=content),
owner="regular-user",
workspace=str(tmp_path),
)
assert desc == f"{tool_name}: BLOCKED"
assert result["exit_code"] == 1
@@ -930,7 +942,9 @@ async def test_disabled_qualified_email_tool_blocks_bare_alias(monkeypatch):
the gate must block the bare spelling too — and never reach the MCP
manager (PR #3681 review follow-up)."""
import src.tool_execution as tool_execution
from src.tool_execution import execute_tool_block
from src.tool_execution import execute_tool_block, NO_TOOL_SECURITY_CONTEXT
from src.turn_contract import canonical_tool
from src.agent_runtime.authority import RequestAuthority, OperationGrant
def fail_get_mcp_manager():
raise AssertionError("blocked email tool must not reach the MCP manager")
@@ -944,11 +958,14 @@ async def test_disabled_qualified_email_tool_blocks_bare_alias(monkeypatch):
# …and a bare denylist entry blocks the qualified spelling.
("mcp__email__delete_email", {"delete_email"}),
):
desc, result = await _execute_without_run_context(
execute_tool_block,
canon = canonical_tool(bare)
auth = RequestAuthority("test", "admin-user", "", "", (OperationGrant(canon),), backend_resources=())
desc, result = await execute_tool_block(
SimpleNamespace(tool_type=bare, content="{}"),
owner="admin-user",
disabled_tools=disabled,
request_authority=auth,
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert desc == f"{bare}: BLOCKED"
assert result["exit_code"] == 1
@@ -959,8 +976,9 @@ async def test_disabled_qualified_email_tool_blocks_bare_alias(monkeypatch):
async def test_tool_policy_qualified_email_block_covers_bare_alias(monkeypatch):
"""Same aliasing rule for the turn ToolPolicy denylist."""
import src.tool_execution as tool_execution
from src.tool_execution import execute_tool_block
from src.tool_execution import execute_tool_block, NO_TOOL_SECURITY_CONTEXT
from src.tool_policy import ToolPolicy
from src.agent_runtime.authority import RequestAuthority, OperationGrant
def fail_get_mcp_manager():
raise AssertionError("blocked email tool must not reach the MCP manager")
@@ -968,11 +986,13 @@ async def test_tool_policy_qualified_email_block_covers_bare_alias(monkeypatch):
monkeypatch.setattr(tool_execution, "get_mcp_manager", fail_get_mcp_manager)
policy = ToolPolicy(disabled_tools=frozenset({"mcp__email__send_email"}))
desc, result = await _execute_without_run_context(
execute_tool_block,
auth = RequestAuthority("test", "admin-user", "", "", (OperationGrant("send_email"),), backend_resources=())
desc, result = await execute_tool_block(
SimpleNamespace(tool_type="send_email", content="{}"),
owner="admin-user",
tool_policy=policy,
request_authority=auth,
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert desc == "send_email: BLOCKED"
assert result["exit_code"] == 1
@@ -1054,6 +1074,11 @@ class _FakeMcpManager:
def __init__(self):
self.calls = []
def resource_identity(self, qualified_name):
from src.agent_runtime.resources import ExternalResource
server = qualified_name.split("__")[1] if "__" in qualified_name else "email"
return ExternalResource("mcp", f"mcp:{server}", server, qualified_name, "fake-incarnation")
async def call_tool(self, name, args):
self.calls.append((name, args))
return {"output": "ok", "exit_code": 0}
@@ -1173,7 +1198,7 @@ async def test_write_file_inline_json_args(monkeypatch):
from src.tool_parsing import parse_tool_blocks
blocks = parse_tool_blocks('```write_file {"path": "/tmp/wf.txt", "content": "hi"}\n```')
for b in blocks:
await _execute_without_run_context(execute_tool_block, b, owner="admin")
await _execute_without_run_context(execute_tool_block, b, owner="admin", workspace="/tmp")
assert captured.get("path") == "/tmp/wf.txt", (
f"write_file did not decode inline JSON args; got path {captured.get('path')!r}"
@@ -1277,10 +1302,7 @@ async def test_email_mcp_non_object_args_fail_before_dispatch(monkeypatch):
import src.tool_execution as tool_execution
from src.tool_execution import execute_tool_block
class FakeMcp:
def __init__(self):
self.calls = []
class FakeMcp(_FakeMcpManager):
async def call_tool(self, name, args):
self.calls.append((name, args))
return {"output": "called", "exit_code": 0}
@@ -1306,10 +1328,7 @@ async def test_email_mcp_dispatch_includes_hidden_owner(monkeypatch):
import src.tool_execution as tool_execution
from src.tool_execution import execute_tool_block
class FakeMcp:
def __init__(self):
self.calls = []
class FakeMcp(_FakeMcpManager):
async def call_tool(self, name, args):
self.calls.append((name, args))
return {"output": "called", "exit_code": 0}
+507
View File
@@ -0,0 +1,507 @@
import asyncio
from dataclasses import replace
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from src import bg_jobs, containment, process_ownership, tool_execution
from src.agent_runtime import process_resources as resources
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority, create_request_authority
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError
from src.agent_tools.subprocess_tools import BashTool
from src.process_lifecycle import ProcessIdentity
from src.tool_approvals import ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
from src.tool_types import ToolBlock
from tests.process_resource_helpers import launch_authority, seed_linkage
@pytest.fixture
def workspace(tmp_path, monkeypatch):
work = tmp_path / "workspace"
work.mkdir()
monkeypatch.setattr(resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "private" / "jobs.json")
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", tmp_path / "private" / "jobs")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "receipts.json")
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "MECHANISMS", tuple(m for m in containment.MECHANISMS if m.name == "process_group"))
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
return work
def authority(workspace, tool="bash"):
return RequestAuthority("request", "alice", "thread", str(workspace), (OperationGrant(tool),))
def approval_for(authority, tool, content):
store = ToolApprovalStore()
pending = store.create(owner=authority.owner, session_id=authority.session_id, origin_run_id="run",
tool_name=tool, content=content, workspace=authority.workspace,
capabilities=capabilities_for_action(tool, content), external_untrusted_context_seen=True,
request_authority=authority)
return store.consume(pending.approval_id, owner=authority.owner, session_id=authority.session_id, decision="approve")
async def dispatch(authority, tool, content, approval=None):
return await tool_execution.execute_tool_block(ToolBlock(tool, content), owner=authority.owner,
session_id=authority.session_id, workspace=authority.workspace,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=bool(approval)),
request_authority=authority, exact_approval=approval)
async def test_native_producer_without_binding_cannot_spawn(workspace, monkeypatch):
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("unbound spawn"))
result = await BashTool().execute("printf unsafe", {})
assert result["failure_kind"] == "resource_identity_denied"
async def test_producer_rejects_changed_command_after_admission(workspace, monkeypatch):
with launch_authority("printf admitted", workspace):
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("retargeted spawn"))
result = await BashTool().execute("printf changed", {})
assert result["blocked"]
@pytest.mark.parametrize("ctx", [{"owner": "bob", "session_id": "thread"},
{"owner": "alice", "session_id": "replacement"}])
async def test_native_producer_rechecks_application_binding(workspace, monkeypatch, ctx):
admitted = authority(workspace)
operation = ExactOperation.normalize("bash", "printf admitted")
bound = resources.resolve_process_operation(admitted, operation, NativeBackendResource("bash"))
monkeypatch.setattr(containment, "acquire", lambda *a, **k: pytest.fail("Rebound producer acquired boundary"))
with bind_request_authority(admitted), resources.bind_process_operation(bound):
result = await BashTool().execute(operation.input, ctx)
assert result["exit_code"] == 1 and "owner or session changed" in result["error"]
async def test_scheduled_local_runner_uses_exact_launch_ceiling(workspace):
from src import builtin_actions
output, success = await builtin_actions.action_run_local("alice", script="printf scheduled")
assert not success and "no server authority" in output
admitted = replace(authority(workspace), grants=(OperationGrant("bash", inputs=frozenset({"printf scheduled"})),))
with bind_request_authority(admitted):
output, success = await builtin_actions.action_run_local("alice", script="printf scheduled")
assert success and output == "scheduled"
output, success = await builtin_actions.action_run_local("alice", script="printf changed")
assert not success and "sealed operation" in output
output, success = await builtin_actions.action_ssh_command("alice", command="printf scheduled", host="remote.example")
assert not success and "external backend" in output
async def test_attachment_failure_after_execution_does_not_claim_no_execution(workspace, monkeypatch):
def failure(*args):
raise OSError("attachment publication failed")
monkeypatch.setattr(resources, "attach_containment_processes", failure)
_, result = await dispatch(authority(workspace), "bash", "printf occurred > effect")
assert (workspace / "effect").read_text() == "occurred"
assert result["exit_code"] == 1 and result["failure_kind"] == "resource_linkage_unavailable"
assert result["containment"]["executed"] is True and result["teardown"]["dead"] is True
async def test_exact_launch_first_use_replay_and_empty_scope_restoration(workspace):
original = authority(workspace)
approval = approval_for(original, "bash", "printf exact")
assert approval.pending.process_operation.launch is not None
restored = replace(original, grants=(), resource_roots=(), backend_resources=(), launch_scopes=(), process_resources=(), job_resources=())
_, first = await dispatch(restored, "bash", "printf exact", approval)
assert first["exit_code"] == 0 and first["output"] == "exact"
assert restored.launch_scopes == restored.job_resources == restored.process_resources == ()
_, replay = await dispatch(restored, "bash", "printf exact", approval)
assert replay["exit_code"] == 1
_, sibling = await dispatch(restored, "bash", "printf sibling")
assert sibling["failure_kind"] == "request_authority_denied"
async def test_exact_job_first_use_replay_and_empty_scope_restoration(workspace, monkeypatch):
bg_jobs._JOBS_DIR.mkdir(parents=True)
record = {"id": "job", "session_id": "thread", "command": "printf history", "pid": 4321,
"status": "done", "started_at": 1, "max_runtime_s": 3600,
"log_path": str(bg_jobs._JOBS_DIR / "job.log")}
seed_linkage(record, workspace, owner="alice")
Path(record["log_path"]).write_text("historical result")
bg_jobs._save({"job": record})
original = authority(workspace, "manage_bg_jobs")
content = '{"action":"output","job_id":"job"}'
approval = approval_for(original, "manage_bg_jobs", content)
restored = replace(original, grants=(), resource_roots=(), backend_resources=(),
launch_scopes=(), process_resources=(), job_resources=())
_, first = await dispatch(restored, "manage_bg_jobs", content, approval)
assert first["exit_code"] == 0 and "historical result" in first["output"]
_, replay = await dispatch(restored, "manage_bg_jobs", content, approval)
assert replay["exit_code"] == 1
_, unapproved = await dispatch(restored, "manage_bg_jobs", content)
assert unapproved["failure_kind"] == "request_authority_denied"
assert restored.process_resources == restored.job_resources == restored.launch_scopes == ()
async def test_cancellation_at_native_spawn_restores_all_context(workspace, monkeypatch):
entered = asyncio.Event()
async def held_run(grant, command, **kwargs):
assert resources.active_process_operation().launch is not None
entered.set()
try:
await asyncio.Future()
finally:
containment.release(grant, grace_s=0)
monkeypatch.setattr(containment, "run", held_run)
async def invoke():
try:
await dispatch(authority(workspace), "bash", "sleep 60")
finally:
from src.agent_runtime.authority import active_request_authority
assert resources.active_process_operation() is None
assert active_request_authority() is None
task = asyncio.create_task(invoke())
await asyncio.wait_for(entered.wait(), timeout=5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert containment.active_grants() == []
@pytest.mark.parametrize("field,value", [("owner", "bob"), ("request_id", "replacement"), ("session_id", "other-thread")])
async def test_exact_launch_binding_substitution_fails(workspace, field, value):
original = authority(workspace)
approval = approval_for(original, "bash", "printf exact")
changed = replace(original, **{field: value}, resource_roots=None, backend_resources=None,
owned_scopes=None, launch_scopes=None)
_, denied = await dispatch(changed, "bash", "printf exact", approval)
assert denied["exit_code"] == 1 and not approval._claimed
async def test_exact_launch_replaced_workspace_fails_before_claim(workspace):
original = authority(workspace)
approval = approval_for(original, "bash", "pwd")
workspace.rename(workspace.with_name("retired"))
workspace.mkdir()
_, result = await dispatch(original, "bash", "pwd", approval)
assert result["failure_kind"] == "resource_identity_denied" and not approval._claimed
@pytest.mark.parametrize("phase", ["success", "error", "cancel", "nested"])
async def test_process_context_restores(workspace, phase):
original = authority(workspace)
bound = resources.resolve_process_operation(original, ExactOperation.normalize("bash", "pwd"), NativeBackendResource("bash"))
async def call():
with resources.bind_process_operation(bound):
assert resources.active_process_operation() is bound
if phase == "error":
raise RuntimeError("ordinary")
if phase == "cancel":
raise asyncio.CancelledError()
if phase == "nested":
with resources.bind_process_operation(None):
assert resources.active_process_operation() is None
assert resources.active_process_operation() is bound
try:
await call()
except (RuntimeError, asyncio.CancelledError):
pass
assert resources.active_process_operation() is None
@pytest.mark.parametrize("publication", ["launch", "sidecar", "job"])
def test_detached_publication_failure_cannot_release_workload(workspace, monkeypatch, publication):
effect = workspace / "effect"
if publication == "launch":
monkeypatch.setattr(resources, "publish_launch", lambda *a, **k: (_ for _ in ()).throw(OSError("publication failed")))
elif publication == "sidecar":
monkeypatch.setattr("src.agent_runtime.authority.save_background_authority", lambda *a, **k: (_ for _ in ()).throw(OSError("sidecar failed")))
else:
monkeypatch.setattr(bg_jobs, "_save", lambda *a: (_ for _ in ()).throw(OSError("job failed")))
with launch_authority("printf unsafe > effect", workspace):
with pytest.raises(OSError):
bg_jobs.launch("printf unsafe > effect", "chat", cwd=str(workspace))
assert not effect.exists()
assert containment.active_grants() == []
def test_detached_release_observes_complete_durable_linkage(workspace, monkeypatch):
real_popen = bg_jobs.subprocess.Popen
observations = []
def popen(*args, **kwargs):
proc = real_popen(*args, **kwargs)
original = proc.stdin
class Gate:
@property
def closed(self):
return original.closed
def close(self):
return original.close()
def write(self, content):
payload = json.loads(content)
published = json.loads(Path(payload["launch_path"]).read_text())
sidecar = json.loads(Path(payload["authority_path"]).read_text())
rec = bg_jobs.peek(payload["job_id"])
assert rec["resource_identity"] == published["job"] == sidecar["job"]
assert sidecar["authority"] == published["authority"]
observations.append(True)
return original.write(content)
proc.stdin = Gate()
return proc
monkeypatch.setattr(bg_jobs.subprocess, "Popen", popen)
with launch_authority("printf released", workspace):
rec = bg_jobs.launch("printf released", "chat", cwd=str(workspace))
assert observations == [True]
proc = bg_jobs._LIVE_PROCS.pop(rec["pid"])
proc.wait(timeout=10)
bg_jobs.refresh(rec["id"])
assert bg_jobs.peek(rec["id"])["status"] == "done"
@pytest.mark.parametrize("replacement", ["pid", "job", "receipt", "role"])
async def test_job_approval_revalidates_exact_resource_before_claim(workspace, monkeypatch, replacement):
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.OWNED)
monkeypatch.setattr(ProcessIdentity, "exited", lambda self: False)
bg_jobs._JOBS_DIR.mkdir(parents=True)
record = {"id": "job", "session_id": "thread", "command": "sleep 60", "pid": 4321,
"status": "running", "started_at": 1, "max_runtime_s": 3600,
"exit_path": str(bg_jobs._JOBS_DIR / "job.exit"), "log_path": str(bg_jobs._JOBS_DIR / "job.log")}
seed_linkage(record, workspace, owner="alice")
bg_jobs._save({"job": record})
admitted = authority(workspace, "manage_bg_jobs")
content = '{"action":"kill","job_id":"job"}'
approval = approval_for(admitted, "manage_bg_jobs", content)
assert approval.pending.process_operation.jobs
if replacement == "pid":
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.FOREIGN)
else:
jobs = bg_jobs._load()
if replacement == "job":
jobs["job"]["resource_identity"]["generation"] = "f" * 32
elif replacement == "role":
jobs["job"]["resource_identity"]["processes"][0]["role"] = "leader"
else:
jobs["job"]["containment_id"] = "replacement"
bg_jobs._save(jobs)
_, result = await dispatch(admitted, "manage_bg_jobs", content, approval)
assert result["failure_kind"] == "resource_identity_denied" and not approval._claimed
@pytest.mark.parametrize("request_text", ["Transcribe /workspace/audio.wav", "OCR this image", "List my tasks"])
async def test_new_resources_do_not_expand_turn_contract_classes(workspace, request_text):
admitted = create_request_authority(request_text, owner="alice", session_id="thread", workspace=str(workspace))
_, denied = await dispatch(admitted, "bash", "pwd")
assert denied["failure_kind"] == "request_authority_denied"
def test_internal_shell_control_has_no_admin_floor_even_without_auth(monkeypatch):
from routes import shell_routes
from core.middleware import INTERNAL_TOOL_USER
from fastapi import HTTPException
request = SimpleNamespace(headers={}, state=SimpleNamespace(current_user=INTERNAL_TOOL_USER))
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: True)
with pytest.raises(HTTPException) as error:
shell_routes._require_admin(request)
assert error.value.status_code == 403
@pytest.mark.parametrize("mode", ["auth_disabled", "missing_manager"])
def test_unlabelled_loopback_cannot_gain_native_control(monkeypatch, mode):
from routes import shell_routes
from fastapi import HTTPException
request = SimpleNamespace(headers={}, state=SimpleNamespace(current_user=None),
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)))
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: mode == "auth_disabled")
with pytest.raises(HTTPException) as error:
shell_routes._require_admin(request)
assert error.value.status_code == 403
def test_authenticated_human_administration_is_not_an_internal_tool_floor(monkeypatch):
from routes import shell_routes
request = SimpleNamespace(headers={}, state=SimpleNamespace(current_user="admin"),
app=SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(is_admin=lambda u: u == "admin"))))
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: False)
shell_routes._require_admin(request)
@pytest.mark.parametrize("path,payload", [("/api/cookbook/kill-pid", {"pid": 4321}),
("/api/cookbook/state", {"tasks": []}), ("/api/model/serve", {}), ("/api/model/download", {})])
async def test_anonymous_native_cookbook_control_rejected_before_producer(monkeypatch, path, payload):
from routes import cookbook_routes, shell_routes
from fastapi import FastAPI
import httpx
monkeypatch.setattr(shell_routes, "_auth_disabled", lambda: True)
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("Anonymous producer reached"))
monkeypatch.setattr(asyncio, "create_subprocess_shell", lambda *a, **k: pytest.fail("Anonymous producer reached"))
app = FastAPI()
app.include_router(cookbook_routes.setup_cookbook_routes())
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=("192.0.2.1", 123)), base_url="http://local") as client:
result = await client.post(path, json=payload)
assert result.status_code == 403
@pytest.mark.parametrize("path", ["/api/shell/exec", "/api/model/serve", "/api/cookbook/kill-pid", "/api/cookbook/state", "/api/shell/../cookbook/kill-pid"])
def test_generic_loopback_cannot_bypass_process_resources(path):
from src.agent_runtime.owned_resources import needs_owned_binding
with pytest.raises(ResourceIdentityError):
needs_owned_binding(ExactOperation.normalize("app_api", json.dumps({"path": path})))
async def test_direct_local_cookbook_control_does_not_enroll_discovered_processes(monkeypatch):
from src.tools import cookbook
async def state():
return {}
monkeypatch.setattr(cookbook, "_capture_session_processes", lambda *a: pytest.fail("discovery enrolled as ownership"))
monkeypatch.setattr(asyncio, "create_subprocess_exec", lambda *a, **k: pytest.fail("unbound Cookbook control"))
# No server session registry exists for this selector; observation cannot
# mint a process resource even when the UI supplies a matching name.
result = await cookbook._cookbook_kill_session("serve-unowned")
assert result["failure_kind"] == "resource_identity_denied"
def test_direct_containment_attachment_skips_unobservable_token(workspace, monkeypatch):
import uuid
from src.agent_runtime.resources import ProcessResource
# 1. Unobservable child token: pid exists, start_token is None
op = ExactOperation.normalize("bash", "printf test")
bound = resources.resolve_process_operation(authority(workspace), op, NativeBackendResource("bash"))
launch = bound.launch
containment_id = uuid.uuid4().hex
resources.publish_launch(launch, authority(workspace), containment_id)
containment._save_records({
containment_id: {
"id": containment_id,
"launch_generation": launch.generation,
"workspace": launch.scope.root.path,
"pid": 54321,
"start_token": None,
"pgid": 54321,
"mechanism": "process_group",
}
})
# Guard: ensure no attempt is made to rediscover/rebind from process table
monkeypatch.setattr(process_ownership, "process_table", lambda *a, **k: pytest.fail("re-read process table"))
monkeypatch.setattr(process_ownership, "start_token", lambda *a, **k: pytest.fail("re-read current PID start_token"))
# Must NOT raise
resources.attach_containment_processes(launch, containment_id)
# Publication remains valid
pub_path = resources.launch_path(launch.generation)
published = json.loads(pub_path.read_text())
assert published["launch"] == launch.to_dict()
assert published["containment_id"] == containment_id
assert published["processes"] == []
# 2. Record with pid + valid token still publishes exact ProcessResource
op_valid = ExactOperation.normalize("bash", "printf valid")
bound_valid = resources.resolve_process_operation(authority(workspace), op_valid, NativeBackendResource("bash"))
launch_valid = bound_valid.launch
cid_valid = uuid.uuid4().hex
resources.publish_launch(launch_valid, authority(workspace), cid_valid)
containment._save_records({
cid_valid: {
"id": cid_valid,
"launch_generation": launch_valid.generation,
"workspace": launch_valid.scope.root.path,
"pid": 65432,
"start_token": "procfs:boot:token65432",
"pgid": 65432,
"mechanism": "process_group",
}
})
resources.attach_containment_processes(launch_valid, cid_valid)
published_valid = json.loads(resources.launch_path(launch_valid.generation).read_text())
assert len(published_valid["processes"]) == 1
leader_res = ProcessResource.from_dict(published_valid["processes"][0])
assert leader_res.role == "leader"
assert leader_res.identity.pid == 65432
assert leader_res.identity.start_token == "procfs:boot:token65432"
assert leader_res.identity.pgid == 65432
@pytest.mark.parametrize("tool,command,expected_out", [
("bash", "printf hi", "hi"),
("python", "print('hi', end='')", "hi"),
])
async def test_end_to_end_fast_exit_preserves_command_result(workspace, monkeypatch, tool, command, expected_out):
import os
original_capture = process_ownership.capture
def mocked_capture(pid):
if pid == os.getpid():
return original_capture(pid)
return {"pid": pid, "start_token": None}
monkeypatch.setattr(process_ownership, "capture", mocked_capture)
# Observe the real attachment before foreground lifecycle retirement.
published = []
attach = resources.attach_containment_processes
def observe_attachment(launch, containment_id):
attach(launch, containment_id)
published.append(json.loads(resources.launch_path(launch.generation).read_text()))
monkeypatch.setattr(resources, "attach_containment_processes", observe_attachment)
auth = authority(workspace, tool=tool)
approval = approval_for(auth, tool, command)
_, result = await dispatch(auth, tool, command, approval)
assert result["exit_code"] == 0
assert result.get("output") == expected_out
assert "failure_kind" not in result or result["failure_kind"] != "resource_linkage_unavailable"
launches_dir = resources._LAUNCH_DIR
launch_files = list(launches_dir.glob("*.json"))
assert not launch_files
cid = result.get("containment", {}).get("id")
assert cid
matching = [record for record in published if record.get("containment_id") == cid]
assert len(matching) == 1
assert matching[0]["processes"] == []
def test_missing_start_token_security_negative(workspace, monkeypatch):
import uuid
import src.process_lifecycle as pl
from src.process_lifecycle import ProcessIdentity
op = ExactOperation.normalize("bash", "printf test")
bound = resources.resolve_process_operation(authority(workspace), op, NativeBackendResource("bash"))
launch = bound.launch
cid = uuid.uuid4().hex
resources.publish_launch(launch, authority(workspace), cid)
containment._save_records({
cid: {
"id": cid,
"launch_generation": launch.generation,
"workspace": launch.scope.root.path,
"pid": 77777,
"start_token": None,
"pgid": 77777,
"mechanism": "process_group",
}
})
created_identities = []
orig_identity_init = ProcessIdentity.__init__
def spy_identity_init(self, pid, start_token, pgid=None):
created_identities.append((pid, start_token, pgid))
return orig_identity_init(self, pid, start_token, pgid=pgid)
monkeypatch.setattr(ProcessIdentity, "__init__", spy_identity_init)
monkeypatch.setattr(process_ownership, "process_table", lambda *a, **k: pytest.fail("PID rediscovery attempted via process_table"))
monkeypatch.setattr(process_ownership, "start_token", lambda *a, **k: pytest.fail("PID rediscovery attempted via start_token"))
resources.attach_containment_processes(launch, cid)
# 1. No ProcessIdentity created for this unobservable process
assert not any(pid == 77777 for pid, token, pgid in created_identities)
# 2. No process authority published
published = json.loads(resources.launch_path(launch.generation).read_text())
assert published["processes"] == []
# 3. No signal authority
fake_ident = ProcessIdentity(77777, None, 77777)
assert fake_ident.verdict() == process_ownership.UNVERIFIABLE
assert pl.signal_identity(fake_ident, 15) is False
@@ -0,0 +1,40 @@
"""Regression test for intentional Wave 3 refusal of unscoped remote scheduled SSH.
Contract:
Raw scheduled remote SSH without an exact external backend resource binding
must fail closed deterministically with:
"Remote scheduled workload requires an exact external backend binding."
"""
import pytest
from src.agent_runtime.authority import OperationGrant, RequestAuthority, bind_request_authority
from src.builtin_actions import _run_subprocess, action_ssh_command
@pytest.mark.asyncio
async def test_scheduled_remote_ssh_refusal_is_deterministic_and_fail_closed():
"""Unscoped remote SSH in a scheduled workload must fail closed."""
authority = RequestAuthority("sched-1", "alice", "sched-session", "", (OperationGrant("bash"),))
with bind_request_authority(authority):
# 1. Direct _run_subprocess with ssh argv
output, success = await _run_subprocess(["ssh", "user@remote.host", "uptime"])
assert success is False
assert output == "Remote scheduled workload requires an exact external backend binding."
# 2. action_ssh_command targeting remote host
output, success = await action_ssh_command(
owner="alice",
command="uptime",
host="remote.example.com",
user="deploy",
)
assert success is False
assert output == "Remote scheduled workload requires an exact external backend binding."
@pytest.mark.asyncio
async def test_scheduled_ssh_refusal_requires_authority_first():
"""Without any active authority, launch is denied before reaching the remote SSH gate."""
output, success = await _run_subprocess(["ssh", "user@remote.host", "uptime"])
assert success is False
assert output == "Scheduled process launch has no server authority."
+24 -13
View File
@@ -38,7 +38,7 @@ def _stub_heavy(monkeypatch):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
def _setup_isolated_db():
def _setup_isolated_db(monkeypatch):
import core.database as cd
B = declarative_base()
@@ -65,10 +65,10 @@ def _setup_isolated_db():
eng = create_engine("sqlite:///:memory:")
B.metadata.create_all(eng)
cd.engine = eng
cd.SessionLocal = sessionmaker(bind=eng, autocommit=False, autoflush=False)
cd.ScheduledTask = ScheduledTask
cd.TaskRun = TaskRun
monkeypatch.setattr(cd, "engine", eng)
monkeypatch.setattr(cd, "SessionLocal", sessionmaker(bind=eng, autocommit=False, autoflush=False))
monkeypatch.setattr(cd, "ScheduledTask", ScheduledTask)
monkeypatch.setattr(cd, "TaskRun", TaskRun)
return cd, ScheduledTask, TaskRun
@@ -84,7 +84,7 @@ def test_scheduler_utcnow_preserves_naive_utc_contract():
def _drive_scheduler(monkeypatch, pre_start_setup=None):
"""Build a TaskScheduler bypassing __init__ and run start() + two polls."""
_stub_heavy(monkeypatch)
cd, ScheduledTask, TaskRun = _setup_isolated_db()
cd, ScheduledTask, TaskRun = _setup_isolated_db(monkeypatch)
from src.task_scheduler import TaskScheduler
sch = TaskScheduler.__new__(TaskScheduler)
@@ -107,11 +107,26 @@ def _drive_scheduler(monkeypatch, pre_start_setup=None):
monkeypatch.setattr(sch, "_note_pings_loop", _never)
dispatched = []
def _fake_create_task(coro):
dispatched.append(coro)
name = getattr(getattr(coro, "cr_code", None), "co_name", None)
# start() schedules the long-lived scheduler loops. This test replaces
# asyncio.create_task intentionally, so intercepted coroutine objects
# must be closed explicitly instead of being left unawaited.
if name != "_never":
dispatched.append(coro)
close = getattr(coro, "close", None)
if callable(close):
close()
class _T:
def cancel(self): pass
def cancel(self):
pass
return _T()
monkeypatch.setattr("src.task_scheduler.asyncio.create_task", _fake_create_task)
async def _drive():
@@ -120,11 +135,7 @@ def _drive_scheduler(monkeypatch, pre_start_setup=None):
await sch._check_due_tasks()
return dispatched
all_dispatched = asyncio.run(_drive())
# start() also fires the long-lived _loop and _note_pings_loop as tasks
# (stubbed to _never here); filter those out so the test only counts
# real per-poll task dispatches.
real_dispatches = [c for c in all_dispatched if c.__name__ != "_never"]
real_dispatches = asyncio.run(_drive())
return cd, ScheduledTask, TaskRun, real_dispatches
+124
View File
@@ -0,0 +1,124 @@
"""Regression tests for stale ProcessResource during authority intersection.
Covers:
1. Parent observes process → process exits → child intersection does not crash.
2. Stale process disappears from resulting child authority.
3. Stale parent cannot be renewed by a fresh/replacement process.
4. PID reuse/replacement remains rejected.
5. Child-side stale observation is handled conservatively.
6. Valid live identical observations still intersect correctly.
"""
import pytest
from dataclasses import dataclass
from src.agent_runtime.process_resources import intersect_observed
from src.agent_runtime.resources import ResourceIdentityError
@dataclass(frozen=True)
class _FakeResource:
"""Lightweight stand-in for ProcessResource/BackgroundJobResource in
intersection tests. Equality is by (pid, token) so we can verify
identity-based matching, while ``live`` controls whether validate raises.
"""
pid: int
token: str
live: bool = True
def __eq__(self, other):
return isinstance(other, _FakeResource) and (self.pid, self.token) == (other.pid, other.token)
def __hash__(self):
return hash((self.pid, self.token))
def _validate(resource):
"""Mirrors ProcessResource.validate() semantics."""
if not resource.live:
raise ResourceIdentityError("Process resource is stale or unverifiable")
# 1. Parent observes process → process exits → child intersection does not crash.
def test_stale_parent_process_does_not_crash_intersection():
stale = _FakeResource(pid=1000, token="tok-1", live=False)
child_copy = _FakeResource(pid=1000, token="tok-1", live=False)
result = intersect_observed((stale,), (child_copy,), _validate)
# Must not raise; stale resources are conservatively excluded.
assert result == ()
# 2. Stale process disappears from resulting child authority.
def test_stale_process_excluded_from_intersection_result():
live = _FakeResource(pid=2000, token="tok-2", live=True)
stale = _FakeResource(pid=3000, token="tok-3", live=False)
child_live = _FakeResource(pid=2000, token="tok-2", live=True)
child_stale = _FakeResource(pid=3000, token="tok-3", live=False)
result = intersect_observed((live, stale), (child_live, child_stale), _validate)
assert len(result) == 1
assert result[0].pid == 2000
# 3. Stale parent cannot be renewed by a fresh/replacement process.
def test_stale_parent_not_renewed_by_fresh_child():
stale_parent = _FakeResource(pid=4000, token="tok-4", live=False)
fresh_child = _FakeResource(pid=4000, token="tok-4-new", live=True)
result = intersect_observed((stale_parent,), (fresh_child,), _validate)
# Parent is stale → excluded before equality check.
assert result == ()
# 4. PID reuse/replacement remains rejected.
def test_pid_reuse_rejected():
"""A replacement process with the same PID but different token is never equal."""
original = _FakeResource(pid=5000, token="tok-original", live=True)
replacement = _FakeResource(pid=5000, token="tok-replacement", live=True)
result = intersect_observed((original,), (replacement,), _validate)
# Different identity → not equal → not in result.
assert result == ()
# 5. Child-side stale observation is handled conservatively.
def test_child_side_stale_excluded():
live_parent = _FakeResource(pid=6000, token="tok-6", live=True)
stale_child = _FakeResource(pid=6000, token="tok-6", live=False)
result = intersect_observed((live_parent,), (stale_child,), _validate)
# Child side is stale → not in live_child set → excluded.
assert result == ()
# 6. Valid live identical observations still intersect correctly.
def test_live_identical_observations_intersect():
parent = _FakeResource(pid=7000, token="tok-7", live=True)
child = _FakeResource(pid=7000, token="tok-7", live=True)
result = intersect_observed((parent,), (child,), _validate)
assert len(result) == 1
assert result[0].pid == 7000
assert result[0].token == "tok-7"
# Additional: multiple live resources intersect correctly preserving order.
def test_multiple_live_resources_intersect():
p1 = _FakeResource(pid=8000, token="tok-8a", live=True)
p2 = _FakeResource(pid=8001, token="tok-8b", live=True)
c1 = _FakeResource(pid=8000, token="tok-8a", live=True)
c2 = _FakeResource(pid=8001, token="tok-8b", live=True)
result = intersect_observed((p1, p2), (c1, c2), _validate)
assert len(result) == 2
assert result[0].pid == 8000
assert result[1].pid == 8001
# Additional: mixed stale/live across both sides.
def test_mixed_stale_live_across_both_sides():
p_live = _FakeResource(pid=9000, token="tok-9a", live=True)
p_stale = _FakeResource(pid=9001, token="tok-9b", live=False)
c_live = _FakeResource(pid=9000, token="tok-9a", live=True)
c_stale = _FakeResource(pid=9001, token="tok-9b", live=False)
result = intersect_observed((p_live, p_stale), (c_live, c_stale), _validate)
assert len(result) == 1
assert result[0].pid == 9000
# Edge: empty inputs produce empty output.
def test_empty_intersection():
assert intersect_observed((), (), _validate) == ()
+19
View File
@@ -32,6 +32,15 @@ def _pending(store, **overrides):
"capabilities": capabilities_for_action("bash", "printf exact"),
}
values.update(overrides)
if "request_authority" not in values:
import tempfile
from src.agent_runtime.authority import RequestAuthority, OperationGrant
from src.agent_runtime.resources import ProcessLaunchScope, FilesystemRoot, NativeBackendResource
from src.containment import DEFAULT_REQUIRED
tool = values["tool_name"]
scopes = (ProcessLaunchScope(NativeBackendResource(tool), FilesystemRoot.seal(tempfile.mkdtemp(prefix="w3-approval-fixture-")), DEFAULT_REQUIRED),) if tool in {"bash", "python"} else ()
values["request_authority"] = RequestAuthority("standalone-test-request", str(values["owner"]).casefold(),
str(values["session_id"] or ""), str(values["workspace"] or ""), (OperationGrant(tool),), launch_scopes=scopes)
return store.create(**values)
@@ -204,6 +213,16 @@ async def test_dispatcher_claims_approval_immediately_before_execution(monkeypat
@pytest.mark.asyncio
async def test_dispatcher_uses_sealed_document_target(monkeypatch):
import src.tool_execution as tool_execution
from datetime import datetime
from types import SimpleNamespace
from src.agent_runtime import owned_resources
# This dispatcher fixture seals an observed owned row, as production does;
# model/document text alone cannot stand in for a resource identity.
row = SimpleNamespace(id="document-7", owner="alice", session_id="session-1",
version_count=4, current_content="original", created_at=datetime(2026, 1, 1),
updated_at=datetime(2026, 1, 2))
monkeypatch.setattr(owned_resources, "_row", lambda namespace, identifier, owner: row
if (namespace, identifier, owner) == ("documents", "document-7", "alice") else None)
store = ToolApprovalStore()
content = '{"content":"replacement"}'
+6 -3
View File
@@ -252,7 +252,8 @@ async def test_read_file_dispatch_blocks_etc_shadow(monkeypatch):
owner="admin-user",
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert "outside the allowed roots" in (result.get("error") or "")
assert result.get("failure_kind") == "resource_identity_denied"
assert "sealed resource root" in (result.get("error") or "")
assert result.get("exit_code") == 1
@@ -281,7 +282,8 @@ async def test_write_file_dispatch_blocks_authorized_keys(monkeypatch):
owner="admin-user",
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert "sensitive directory" in (result.get("error") or "")
assert result.get("failure_kind") == "resource_identity_denied"
assert "sealed resource root" in (result.get("error") or "")
assert result.get("exit_code") == 1
@@ -344,7 +346,8 @@ async def test_write_file_dispatch_blocks_cron(monkeypatch):
owner="admin-user",
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert "outside the allowed roots" in (result.get("error") or "")
assert result.get("failure_kind") == "resource_identity_denied"
assert "sealed resource root" in (result.get("error") or "")
assert result.get("exit_code") == 1
@pytest.mark.parametrize("filename", ["auth.json", "app.db", "settings.json"])
def test_application_secrets_are_sensitive_paths(filename):
+100
View File
@@ -0,0 +1,100 @@
"""Permanent linkage loss suppresses continuation without granting authority."""
from types import SimpleNamespace
import time
import pytest
from src import bg_jobs, bg_monitor
from src.agent_runtime import process_resources as resources
from tests.test_background_resource_identity import store, seed
from src.agent_runtime.resources import ResourceIdentityError
@pytest.fixture
def monitor_session(monkeypatch):
messages = []
sess = SimpleNamespace(id='thread', owner='alice', model='test-model', get_context_messages=lambda: [])
sm = SimpleNamespace(get_session=lambda sid: sess, add_message=lambda *args: messages.append(args), save_sessions=lambda: None)
import src.ai_interaction as ai
monkeypatch.setattr(ai, 'get_session_manager', lambda: sm)
import src.agent_runs
monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: False)
async def drain(*args, **kwargs):
messages.append('drained')
return 'continued', []
monkeypatch.setattr(bg_monitor, '_drain_agent', drain)
return messages
@pytest.mark.parametrize('damage', ['missing', 'corrupt', 'wrong_owner', 'wrong_pid'])
async def test_invalid_linkage_is_terminal_without_message(store, monkeypatch, monitor_session, damage):
resource, rec = seed(store, status='done')
sidecar = bg_jobs._JOBS_DIR / 'job.authority.json'
if damage == 'missing': sidecar.unlink()
elif damage == 'corrupt': sidecar.write_text('{}')
else:
jobs = bg_jobs._load()
if damage == 'wrong_owner': jobs['job']['resource_identity']['owner'] = 'bob'
else: jobs['job']['pid'] = 99999
bg_jobs._save(jobs)
rec = jobs['job']
# Invalid data must not even be rendered into a synthetic result message.
monkeypatch.setattr(bg_monitor, '_background_result_message', lambda rec: pytest.fail('Invalid result rendered'))
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert not monitor_session
assert not bg_jobs.pending_followups()
assert bg_jobs.peek('job')['followup_state'] == 'terminal_unfollowable'
assert not bg_jobs.peek('job').get('followed_up')
with pytest.raises(ResourceIdentityError):
resources.validate_job(resource)
async def test_busy_session_retries_then_continues(store, monkeypatch, monitor_session):
_, rec = seed(store, status='done')
import src.agent_runs
monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: True)
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.RETRYABLE_LATER
assert bg_jobs.pending_followups() and not monitor_session
monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: False)
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.COMPLETED
assert bg_jobs.peek('job')['followed_up']
assert monitor_session and not bg_jobs.pending_followups()
async def test_terminal_record_prunes_exact_generation(store, monitor_session):
resource, rec = seed(store, status='done')
rec['ended_at'] = time.time() - bg_jobs._RETENTION_S - 10
bg_jobs._save({'job': rec})
(bg_jobs._JOBS_DIR / 'job.authority.json').unlink()
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert not bg_jobs.pending_followups()
assert bg_jobs.peek('job') is None
assert not resources.launch_path(resource.generation).exists()
assert not monitor_session
def test_stale_terminal_snapshot_cannot_suppress_new_generation(store):
_, old = seed(store, status='done')
new, _ = seed(store, status='done')
assert not bg_jobs.mark_unfollowable('job', expected_record=old)
assert 'followup_state' not in bg_jobs.peek('job')
assert resources.launch_path(new.generation).exists()
async def test_linkage_lost_during_continuation_cannot_deliver(store, monkeypatch, monitor_session):
_, rec = seed(store, status='done')
async def interrupted(*args, **kwargs):
(bg_jobs._JOBS_DIR / 'job.authority.json').unlink()
return 'must not be delivered', []
monkeypatch.setattr(bg_monitor, '_drain_agent', interrupted)
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert not monitor_session
assert not bg_jobs.pending_followups()
async def test_stale_terminal_outcome_retries_current_record(store, monkeypatch, monitor_session):
_, old = seed(store, status='done')
seed(store, status='done')
async def terminal(rec): return bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
monkeypatch.setattr(bg_monitor, '_run_followup', terminal)
assert await bg_monitor._process_followup(old) is bg_monitor.FollowupResult.RETRYABLE_LATER
assert bg_jobs.pending_followups()
+21
View File
@@ -0,0 +1,21 @@
"""Wave 3 metadata requires a real allowlisted Linux producer artifact."""
import pytest
from src import browser_identity as browser
from src.agent_runtime.resources import ResourceIdentityError
@pytest.mark.parametrize('system,machine', [('Darwin', 'x86_64'), ('Darwin', 'arm64'),
('Windows', 'AMD64'), ('Windows', 'ARM64'), ('Linux', 'riscv64')])
async def test_unsupported_platform_fails_before_producer_execution(monkeypatch, system, machine):
monkeypatch.setattr(browser.platform, 'system', lambda: system)
monkeypatch.setattr(browser.platform, 'machine', lambda: machine)
async def forbidden(*args, **kwargs): pytest.fail('Unsupported producer was executed')
monkeypatch.setattr(browser, 'run_client', forbidden)
with pytest.raises(ResourceIdentityError, match='Unsupported browser producer platform'):
await browser.trusted_producer()
def test_observed_release_hash_contract_is_explicit():
assert set(browser.PRODUCER_HASHES) == {'linux-x64', 'linux-arm64'}
assert browser.PRODUCER_VERSION == '0.35.0'
assert browser.SESSION_ACTIONS == {'session_info'}
+48
View File
@@ -0,0 +1,48 @@
"""Unexpected programming defects must not look like successful policy denial."""
import pytest
from src import tool_execution
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
from src.agent_runtime.resources import ResourceIdentityError
from src.tool_capabilities import ToolRunSecurityContext
from src.tool_types import ToolBlock
@pytest.mark.parametrize('seam,tool,content', [
('bind_backend_for_operation', 'bash', 'printf probe'),
('resolve_process_operation', 'bash', 'printf probe'),
('admit_owned_operation', 'edit_document', '{"document_id":"doc","content":"changed"}'),
])
@pytest.mark.parametrize('error_type', [AttributeError, ResourceIdentityError, ValueError, TypeError])
async def test_binding_errors_keep_diagnostic_identity(tmp_path, monkeypatch, seam, tool, content, error_type):
authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant(tool),))
monkeypatch.setattr(tool_execution, '_owner_is_admin', lambda owner: True)
def broken(*args, **kwargs):
raise error_type('injected defect')
monkeypatch.setattr(tool_execution, seam, broken)
args = dict(owner='alice', session_id='thread', workspace=str(tmp_path), request_authority=authority,
security_context=ToolRunSecurityContext())
if error_type is AttributeError:
with pytest.raises(AttributeError, match='injected defect'):
await tool_execution.execute_tool_block(ToolBlock(tool, content), **args)
else:
_, result = await tool_execution.execute_tool_block(ToolBlock(tool, content), **args)
assert result['failure_kind'] == 'resource_identity_denied' and result['blocked']
async def test_argument_normalization_defect_propagates(tmp_path, monkeypatch):
authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant('bash'),))
def broken(*args, **kwargs): raise AttributeError('internal-only diagnostic')
monkeypatch.setattr(ExactOperation, 'normalize', broken)
with pytest.raises(AttributeError):
await tool_execution.execute_tool_block(ToolBlock('bash', 'printf probe'), owner='alice',
session_id='thread', workspace=str(tmp_path), request_authority=authority,
security_context=ToolRunSecurityContext())
@pytest.mark.parametrize('content', ['{invalid', None])
async def test_expected_bad_input_still_has_authority_denial(tmp_path, content):
authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant('api_call'),))
_, result = await tool_execution.execute_tool_block(ToolBlock('api_call', content), owner='alice',
session_id='thread', workspace=str(tmp_path), request_authority=authority,
security_context=ToolRunSecurityContext())
assert result['failure_kind'] == 'request_authority_denied'
+217
View File
@@ -0,0 +1,217 @@
"""Structural dispatch cost and exact publication lifetime regressions."""
import asyncio
from dataclasses import replace
import json
import os
import time
import pytest
from core.atomic_io import atomic_write_json
from src import bg_jobs, containment, process_ownership
from src.agent_runtime import resources as identities
from src.agent_runtime.authority import ExactOperation, bind_request_authority
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError
from src.agent_tools.subprocess_tools import BashTool
from src.process_lifecycle import ProcessIdentity
from tests.test_runtime_resource_integration import workspace, authority, dispatch
from tests.test_background_resource_identity import seed
from src.agent_runtime import process_resources as resources
@pytest.mark.parametrize('tool,content', [('bash', 'printf guarded'), ('python', 'print("guarded")')])
async def test_real_dispatch_scans_workspace_once_per_binding(workspace, monkeypatch, tool, content):
(workspace / 'child').mkdir()
(workspace / 'child' / 'link').symlink_to(workspace / 'child')
calls = []
walk = os.walk
def counted(*args, **kwargs):
calls.append(args[0])
return walk(*args, **kwargs)
monkeypatch.setattr(os, 'walk', counted)
admitted = authority(workspace, tool)
for _ in range(2):
calls.clear()
_, result = await dispatch(admitted, tool, content)
assert result['exit_code'] == 0, result
assert calls == [workspace]
assert not list(resources._LAUNCH_DIR.glob('*.json'))
async def test_alias_created_after_resolution_is_denied_at_binding(workspace):
admitted = authority(workspace)
op = ExactOperation.normalize('bash', 'printf safe')
bound = resources.resolve_process_operation(admitted, op, NativeBackendResource('bash'))
resources._LAUNCH_DIR.mkdir(parents=True)
state = resources._LAUNCH_DIR / ('a' * 32 + '.json')
state.write_text('{}')
(workspace / 'alias').symlink_to(state)
with bind_request_authority(admitted), pytest.raises(ResourceIdentityError):
with resources.bind_process_operation(bound):
pytest.fail('New control-plane alias admitted')
async def test_publication_retained_during_launch_and_retired_after_teardown(workspace, monkeypatch):
entered, resume = asyncio.Event(), asyncio.Event()
run = containment.run
paths = []
async def held(grant, command, **kwargs):
launch = resources.active_process_operation().launch
path = resources.launch_path(launch.generation)
assert path.is_file()
paths.append(path)
entered.set()
await resume.wait()
return await run(grant, command, **kwargs)
monkeypatch.setattr(containment, 'run', held)
task = asyncio.create_task(dispatch(authority(workspace), 'bash', 'printf foreground'))
await asyncio.wait_for(entered.wait(), 5)
assert paths[0].is_file()
resume.set()
_, result = await task
assert result['exit_code'] == 0 and result['teardown']['dead']
assert not paths[0].exists()
async def test_retired_publication_cannot_replay_bound_reservation(workspace):
admitted = authority(workspace)
op = ExactOperation.normalize('bash', 'printf once')
bound = resources.resolve_process_operation(admitted, op, NativeBackendResource('bash'))
from src import tool_execution
token = tool_execution._active_workspace.set(str(workspace))
try:
with bind_request_authority(admitted), resources.bind_process_operation(bound):
ctx = {'owner': 'alice', 'session_id': 'thread'}
first = await BashTool().execute(op.input, ctx)
assert first['exit_code'] == 0
assert not resources.launch_path(bound.launch.generation).exists()
second = await BashTool().execute(op.input, ctx)
assert second['failure_kind'] == 'resource_identity_denied'
copy = replace(bound, exact_approval=None)
with pytest.raises(ResourceIdentityError):
with resources.bind_process_operation(copy):
pytest.fail('Approval copy renewed a consumed launch')
finally:
tool_execution._active_workspace.reset(token)
@pytest.mark.parametrize('publication', [[], None, 'malformed'])
def test_nonobject_publication_cannot_be_retired(workspace, publication):
resource, rec = seed(workspace, status='done')
path = resources.launch_path(resource.generation)
path.write_text(json.dumps(publication))
launch = identities.ProcessLaunchResource.from_dict(rec['launch_resource'])
assert not resources.retire_launch(launch, resource.containment_id, job=resource)
assert json.loads(path.read_text()) == publication
async def test_corrupt_publication_retirement_preserves_command_result(workspace, monkeypatch):
attach = resources.attach_containment_processes
paths = []
def corrupt_after_attachment(launch, containment_id):
observed = attach(launch, containment_id)
path = resources.launch_path(launch.generation)
path.write_text('[]')
paths.append(path)
return observed
monkeypatch.setattr(resources, 'attach_containment_processes', corrupt_after_attachment)
_, result = await dispatch(authority(workspace), 'bash', 'printf completed')
assert result['exit_code'] == 0 and result['output'] == 'completed', result
assert paths[0].read_text() == '[]'
@pytest.mark.parametrize('status,followed_up,old,removed', [
('running', True, True, False), ('done', False, True, False),
('done', True, False, False), ('done', True, True, True), ('failed', True, True, True),
])
def test_background_publication_tracks_supported_history_lifetime(workspace, status, followed_up, old, removed):
resource, rec = seed(workspace, status=status)
rec.update(followed_up=followed_up, ended_at=time.time() - (bg_jobs._RETENTION_S + 10 if old else 0))
jobs = {'job': rec}
bg_jobs._save(jobs)
assert resources.launch_path(resource.generation).exists()
bg_jobs._prune(jobs, time.time())
assert resources.launch_path(resource.generation).exists() is not removed
assert ('job' not in jobs) is removed
if removed:
bg_jobs._save(jobs)
with pytest.raises(ResourceIdentityError):
resources.validate_job(resource)
def test_old_generation_retirement_cannot_delete_replacement(workspace):
old, rec = seed(workspace, status='done')
new, _ = seed(workspace, status='done')
old_launch = identities.ProcessLaunchResource.from_dict(rec['launch_resource'])
assert resources.retire_launch(old_launch, old.containment_id, job=old)
assert resources.launch_path(new.generation).is_file()
# Even a replaced file at the old generation's slot is not deletable by old linkage.
replacement = json.loads(resources.launch_path(new.generation).read_text())
atomic_write_json(resources.launch_path(old.generation), replacement)
assert not resources.retire_launch(old_launch, old.containment_id, job=old)
assert resources.launch_path(old.generation).is_file()
@pytest.mark.parametrize('manager,release,retired', [
(process_ownership.OWNED, False, False), (process_ownership.UNVERIFIABLE, False, False),
(process_ownership.OWNED, True, False), (process_ownership.UNVERIFIABLE, True, False),
(process_ownership.GONE, False, True), (process_ownership.FOREIGN, False, True),
(process_ownership.GONE, True, True),
])
def test_startup_retirement_does_not_invent_process_death(workspace, monkeypatch, manager, release, retired):
admitted = authority(workspace)
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf recovery'), NativeBackendResource('bash')).launch
cid = 'receipt'
resources.publish_launch(launch, admitted, cid)
atomic_write_json(containment._store_path(), {cid: {'id': cid, 'launch_generation': launch.generation,
'manager_pid': 123, 'manager_token': 'old-manager', 'release': {'dead': release}}})
monkeypatch.setattr(process_ownership, 'verify', lambda *args: manager)
assert resources.prune_foreground_publications() == int(retired)
assert resources.launch_path(launch.generation).exists() is not retired
assert containment._load_records()[cid]['release']['dead'] is release
def test_restart_never_prunes_background_linkage(workspace, monkeypatch):
resource, _ = seed(workspace, status='done')
monkeypatch.setattr(process_ownership, 'verify', lambda *args: process_ownership.GONE)
assert resources.prune_foreground_publications() == 0
assert resources.launch_path(resource.generation).is_file()
def test_snapshot_is_rebuilt_for_each_guard(workspace):
resources._LAUNCH_DIR.mkdir(parents=True)
target = workspace / 'data'; target.write_text('ordinary')
(workspace / 'link').symlink_to(target)
resources.guard_launch_workspace(identities.FilesystemRoot.seal(workspace))
os.link(target, resources._LAUNCH_DIR / ('b' * 32 + '.json'))
with pytest.raises(ResourceIdentityError):
resources.guard_launch_workspace(identities.FilesystemRoot.seal(workspace))
def test_missing_receipt_publication_cannot_recover_authority(workspace):
admitted = authority(workspace)
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf recovery'), NativeBackendResource('bash')).launch
resources.publish_launch(launch, admitted, 'missing-receipt')
assert resources.prune_foreground_publications() == 1
assert not resources.launch_path(launch.generation).exists()
assert not containment._load_records()
@pytest.mark.parametrize('receipt_data', ['{corrupt', '[]', '{"receipt":null}'])
def test_unreadable_receipts_cannot_retire_live_consumers(workspace, receipt_data):
admitted = authority(workspace)
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf pending'), NativeBackendResource('bash')).launch
resources.publish_launch(launch, admitted, 'receipt')
containment._store_path().write_text(receipt_data)
assert resources.prune_foreground_publications() == 0
assert resources.launch_path(launch.generation).is_file()
def test_missing_manager_identity_cannot_retire_attachment(workspace, monkeypatch):
admitted = authority(workspace)
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf pending'), NativeBackendResource('bash')).launch
resources.publish_launch(launch, admitted, 'receipt')
atomic_write_json(containment._store_path(), {'receipt': {'id': 'receipt',
'launch_generation': launch.generation, 'release': {'dead': True}}})
monkeypatch.setattr(process_ownership, 'verify', lambda *args: pytest.fail('Missing manager treated as observed'))
assert resources.prune_foreground_publications() == 0
assert resources.launch_path(launch.generation).is_file()
+246
View File
@@ -0,0 +1,246 @@
"""Local administration and exact Cookbook caller-to-route regressions."""
import asyncio
import json
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import MagicMock
import httpx
import pytest
from fastapi import FastAPI, HTTPException
from starlette.requests import Request
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
from routes import cookbook_routes, shell_routes
from src import builtin_actions, tool_execution
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority, seal_task_authority, restore_task_authority
from src.agent_runtime.remote_resources import bind_backend_for_operation, bind_backend_operation
from src.agent_runtime.local_model_control import CAPABILITY_HEADER, model_control_headers
from src.agent_runtime.resources import ResourceIdentityError
from src.tools import cookbook
from src.tool_capabilities import ToolRunSecurityContext
from src.tool_types import ToolBlock
def request(host='127.0.0.1', headers=None, user=None):
req = Request({'type': 'http', 'method': 'POST', 'scheme': 'http', 'path': '/api/shell/exec',
'server': ('127.0.0.1', 7000), 'client': (host, 1234),
'headers': [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()],
'app': SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(is_admin=lambda u: u == 'alice')))})
req.state.current_user = user
return req
@pytest.mark.parametrize('host,headers,allowed', [
('127.0.0.1', {}, True), ('::1', {}, True), ('192.0.2.1', {}, False),
('127.0.0.1', {'x-forwarded-for': '192.0.2.1'}, False),
('127.0.0.1', {'forwarded': 'for=192.0.2.1'}, False),
('127.0.0.1', {'cf-ray': 'proxy'}, False),
('127.0.0.1', {'x-forwarded-proto': 'https'}, False),
('127.0.0.1', {'sec-fetch-site': 'cross-site'}, False),
('127.0.0.1', {'origin': 'https://evil.example'}, False),
('127.0.0.1', {INTERNAL_TOOL_HEADER: 'forged'}, False),
('127.0.0.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}, False),
])
def test_auth_disabled_operator_transport(monkeypatch, host, headers, allowed):
monkeypatch.setenv('AUTH_ENABLED', 'false')
req = request(host, headers)
if allowed:
shell_routes._require_admin(req)
else:
with pytest.raises(HTTPException) as error:
shell_routes._require_admin(req)
assert error.value.status_code == 403
@pytest.mark.parametrize('user,allowed', [('alice', True), ('bob', False), (None, False), ('api', False), (INTERNAL_TOOL_USER, False)])
def test_auth_enabled_administration(monkeypatch, user, allowed):
monkeypatch.setenv('AUTH_ENABLED', 'true')
if allowed:
shell_routes._require_admin(request('192.0.2.1', user=user))
else:
with pytest.raises(HTTPException):
shell_routes._require_admin(request(user=user))
@pytest.fixture
def control_app(tmp_path, monkeypatch):
# Auth tests can reload middleware after collection. Authenticate the live
# transport token used by real producers, rather than a collection snapshot.
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
monkeypatch.setenv('AUTH_ENABLED', 'true')
manager = SimpleNamespace(is_configured=True, users={'alice': {}}, is_admin=lambda u: u == 'alice')
import core.auth
monkeypatch.setattr(core.auth, 'AuthManager', lambda: manager)
monkeypatch.setattr(tool_execution, '_owner_is_admin', lambda u: u == 'alice')
monkeypatch.setattr(cookbook_routes, 'TMUX_LOG_DIR', tmp_path / 'tmux')
state = tmp_path / 'cookbook.json'
state.write_text(json.dumps({'presets': [{'name': 'preset', 'model': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}]}))
monkeypatch.setattr(cookbook_routes, 'COOKBOOK_STATE_FILE', str(state))
monkeypatch.setattr(builtin_actions, 'COOKBOOK_STATE_FILE', str(state))
spawned = []
async def spawn(command, **kwargs):
spawned.append(command)
async def wait(): return 0
async def read(): return b''
return SimpleNamespace(returncode=0, wait=wait, stderr=SimpleNamespace(read=read))
monkeypatch.setattr(asyncio, 'create_subprocess_shell', spawn)
async def remote_probe(*args, **kwargs):
async def communicate(): return b'tmux', b''
return SimpleNamespace(returncode=0, communicate=communicate)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', remote_probe)
import src.assistant_log
monkeypatch.setattr(src.assistant_log, 'log_to_assistant', lambda *a, **k: None)
async def endpoint(**kwargs): return {'added': True, 'endpoint_id': 'endpoint'}
monkeypatch.setattr(cookbook, '_ensure_served_endpoint', endpoint)
app = FastAPI()
app.state.auth_manager = manager
@app.middleware('http')
async def attribution(req, next):
if req.headers.get(INTERNAL_TOOL_HEADER) == INTERNAL_TOOL_TOKEN:
req.state.current_user = req.headers.get('X-Odysseus-Owner') or INTERNAL_TOOL_USER
return await next(req)
app.include_router(cookbook_routes.setup_cookbook_routes())
app.include_router(shell_routes.setup_shell_routes())
real_client = httpx.AsyncClient
def client_factory(*args, **kwargs):
kwargs.setdefault('transport', httpx.ASGITransport(app=app))
return real_client(*args, **kwargs)
monkeypatch.setattr(httpx, 'AsyncClient', client_factory)
return app, spawned, tmp_path
@pytest.mark.parametrize('tool,args', [
('download_model', {'repo_id': 'org/model', 'local': True}),
('serve_model', {'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg', 'local': True}),
('serve_preset', {'name': 'preset'}),
])
async def test_real_local_tool_dispatch_reaches_real_model_route(control_app, tool, args):
app, spawned, work = control_app
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant(tool),))
_, result = await tool_execution.execute_tool_block(ToolBlock(tool, json.dumps(args)), owner='alice',
session_id='thread', workspace=str(work), request_authority=authority, security_context=ToolRunSecurityContext())
assert result['exit_code'] == 0, result
assert result['session_id'].startswith('cookbook-' if tool == 'download_model' else 'serve-')
assert len(spawned) == 1 and 'tmux new-session' in spawned[0]
async def test_real_scheduled_local_action_uses_restored_exact_authority(control_app):
app, spawned, work = control_app
command = json.dumps({'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg', 'set_default': False})
snapshot = seal_task_authority(command, 'action', 'cookbook_serve', owner='alice')
authority = restore_task_authority(snapshot, command, 'action', 'cookbook_serve', owner='alice')
with bind_request_authority(authority):
message, ok = await builtin_actions.action_cookbook_serve('alice', command=command)
assert ok, message
assert len(spawned) == 1
with bind_request_authority(authority):
_, ok = await builtin_actions.action_cookbook_serve('alice', command=command.replace('samplepkg', 'changedpkg'))
assert not ok and len(spawned) == 1
@pytest.mark.parametrize('host,headers', [
('127.0.0.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}),
('192.0.2.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}),
('127.0.0.1', {INTERNAL_TOOL_HEADER: 'forged'}),
('127.0.0.1', {CAPABILITY_HEADER: 'forged', INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}),
])
async def test_header_only_cannot_launch(control_app, host, headers):
app, spawned, _ = control_app
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client:
for path in ('/api/model/download', '/api/model/serve', '/api/shell/exec'):
r = await client.post(path, json={'repo_id': 'org/model', 'cmd': 'printf nope', 'command': 'printf nope'}, headers=headers)
assert r.status_code == 403
assert not spawned
@pytest.mark.parametrize('substitute', ['body', 'route', 'owner', 'remote', 'proxy', 'replay'])
async def test_capability_exact_transport_binding(control_app, substitute):
app, spawned, work = control_app
content = json.dumps({'repo_id': 'org/model', 'local': True})
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('download_model'),))
operation = ExactOperation.normalize('download_model', content)
backend = bind_backend_for_operation(authority, operation)
body = {'repo_id': 'org/model'}
with bind_request_authority(authority), bind_backend_operation(backend), model_control_headers('download_model', content, 'alice', body) as headers:
changed = dict(headers); payload = dict(body); path = '/api/model/download'; host = '127.0.0.1'
if substitute == 'body': payload['repo_id'] = 'org/changed'
if substitute == 'route': path = '/api/model/serve'
if substitute == 'owner': changed['X-Odysseus-Owner'] = 'bob'
if substitute == 'remote': host = '192.0.2.1'
if substitute == 'proxy': changed['x-forwarded-for'] = '192.0.2.1'
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client:
if substitute == 'replay':
assert (await client.post(path, json=payload, headers=changed)).status_code == 200
r = await client.post(path, json=payload, headers=changed)
assert r.status_code == 403
assert len(spawned) == (1 if substitute == 'replay' else 0)
@pytest.mark.parametrize('field', ['owner', 'request_id', 'session_id'])
def test_producer_wrong_application_binding(control_app, field):
_, _, work = control_app
content = '{"repo_id":"org/model","local":true}'
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('download_model'),))
backend = bind_backend_for_operation(authority, ExactOperation.normalize('download_model', content))
changed = replace(authority, **{field: 'replacement'}, resource_roots=None, backend_resources=None, owned_scopes=None)
with bind_request_authority(changed), bind_backend_operation(backend), pytest.raises(ResourceIdentityError):
with model_control_headers('download_model', content, 'alice', {'repo_id': 'org/model'}):
pytest.fail('Substituted producer obtained a capability')
async def test_remote_route_semantics_remain_unchanged(control_app):
app, spawned, _ = control_app
async with httpx.AsyncClient(base_url='http://127.0.0.1') as client:
r = await client.post('/api/model/download', json={'repo_id': 'org/model', 'remote_host': 'gpu.example'},
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN})
assert r.status_code == 200 and r.json()['ok'], r.text
assert len(spawned) == 1 and 'ssh ' in spawned[0]
async def test_auth_disabled_local_route_usage(control_app, monkeypatch):
app, spawned, _ = control_app
monkeypatch.setenv('AUTH_ENABLED', 'false')
async with httpx.AsyncClient(base_url='http://127.0.0.1') as client:
shell = await client.post('/api/shell/exec', json={'command': ''})
state = await client.post('/api/cookbook/state', json={'tasks': []})
launch = await client.post('/api/model/download', json={'repo_id': 'org/model'})
assert shell.status_code == state.status_code == launch.status_code == 200
assert launch.json()['ok'] and len(spawned) == 1
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=('192.0.2.1', 1)), base_url='http://127.0.0.1') as client:
for path, body in [('/api/shell/exec', {'command': ''}), ('/api/cookbook/state', {'tasks': []}), ('/api/model/download', {'repo_id': 'org/model'})]:
assert (await client.post(path, json=body)).status_code == 403
@pytest.mark.parametrize('host,internal', [('127.0.0.1', True), ('192.0.2.1', False)])
async def test_scoped_wrapper_cannot_bypass_native_control(control_app, monkeypatch, host, internal):
from routes.codex_routes import setup_codex_routes
app, spawned, _ = control_app
app.include_router(setup_codex_routes())
monkeypatch.setenv('AUTH_ENABLED', 'false')
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if internal else {}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client:
r = await client.post('/api/codex/cookbook/serve', json={'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}, headers=headers)
assert r.status_code == 403 and not spawned
@pytest.mark.parametrize('path', ['/api/codex/cookbook/serve', '/api/codex/cookbook/stop/job', '/api/codex/%63ookbook/serve'])
async def test_generic_app_api_cannot_substitute_scoped_wrapper(control_app, path):
_, spawned, work = control_app
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('app_api'),))
content = json.dumps({'action': 'call', 'method': 'POST', 'path': path, 'body': {'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}})
_, result = await tool_execution.execute_tool_block(ToolBlock('app_api', content), owner='alice',
session_id='thread', workspace=str(work), request_authority=authority, security_context=ToolRunSecurityContext())
assert result['failure_kind'] == 'resource_identity_denied' and not spawned
async def test_direct_endpoint_call_keeps_producer_gate(control_app, monkeypatch):
from routes.cookbook_helpers import ModelDownloadRequest
app, spawned, _ = control_app
router = cookbook_routes.setup_cookbook_routes()
endpoint = next(route.endpoint for route in router.routes if getattr(route, 'path', '') == '/api/model/download')
monkeypatch.setenv('AUTH_ENABLED', 'false')
req = request('192.0.2.1')
with pytest.raises(HTTPException) as exc:
await endpoint(req, ModelDownloadRequest(repo_id='org/model'))
assert exc.value.status_code == 403 and not spawned
@@ -0,0 +1,27 @@
"""Closed inheritance is the complete subprocess environment boundary."""
from src import tool_execution
def test_closed_subprocess_environment_drops_all_unlisted_credentials(monkeypatch):
from unittest.mock import patch
import os
ambient = {'PATH': '/usr/bin', 'LANG': 'C.UTF-8', 'OPENAI_API_KEY': 'secret', 'HF_TOKEN': 'secret',
'AUTH_ENABLED': 'false', 'DATABASE_URL': 'secret', 'PATH_TOKEN': 'secret',
'AWS_SECRET_ACCESS_KEY': 'secret', 'ARBITRARY': 'secret', 'HOME': '/server/secret'}
with patch.dict(os.environ, ambient, clear=True):
child = tool_execution._agent_subprocess_env()
assert child['PATH'] == '/usr/bin'
assert child['HOME'] == tool_execution._AGENT_WORKDIR
assert set(child) <= tool_execution._SAFE_SUBPROCESS_VARS | {'HOME', 'TERM', 'COLUMNS', 'LINES'}
assert all(child.get(name) != value for name, value in ambient.items() if name not in {'PATH', 'LANG'})
async def test_real_python_child_does_not_inherit_ambient_credentials(workspace, monkeypatch):
from tests.test_runtime_resource_integration import authority, dispatch
for name in ('OPENAI_API_KEY', 'HF_TOKEN', 'PATH_TOKEN', 'DATABASE_URL', 'ODYSSEUS_INTERNAL_TOKEN'):
monkeypatch.setenv(name, 'never-inherit-this-value')
_, result = await dispatch(authority(workspace, 'python'), 'python',
'import os\nprint(any(v == "never-inherit-this-value" for v in os.environ.values()))')
assert result['exit_code'] == 0 and result['output'] == 'False'
from tests.test_runtime_resource_integration import workspace
@@ -3,6 +3,19 @@ from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def native_resource_authority(tmp_path, monkeypatch):
from tests.process_resource_helpers import install_native_authority
from src.agent_runtime import process_resources
from src import containment
workspace = tmp_path / "native-workspace"
workspace.mkdir()
control = tmp_path.parent / (tmp_path.name + "-control")
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", control / "launches")
monkeypatch.setattr(containment, "_store_path", lambda: control / "grants.json")
install_native_authority(monkeypatch, workspace)
def test_unoffered_artifact_recovery_is_bounded():
from src.agent_loop import _artifact_unoffered_recovery_exhausted
+16 -13
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:59` (+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,12 +226,12 @@ 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. |
| `ODYSSEUS_TEST_STATIC_ORIGIN` | *unset* | `scripts/css_snapshot.py:254` (+6 more) | Origin an already-running static server is serving the repository from, so snapshot tooling reuses it instead of starting its own. |
| `ODYSSEUS_TEST_STATIC_PORT` | *unset* | `tests/conftest.py:137` | Fixed port for the test suite's static server. Unset takes an ephemeral port, which is what keeps parallel runs from colliding. |
| `ODYSSEUS_TEST_STATIC_PORT` | *unset* | `tests/conftest.py:201` | Fixed port for the test suite's static server. Unset takes an ephemeral port, which is what keeps parallel runs from colliding. |
### Build and release metadata
@@ -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