mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
Merge pull request #60 from pewdiepie-archdaemon/feature/runtime-resource-authority
feat(runtime): bind runtime resources to authority
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"starting_sha": "bc5e1ee6922000a290371f8c2aa18802a03ffcad",
|
||||
"starting_tree": "8e09cc2560f50a3472e06ec614d6ada028b7eb18",
|
||||
"resource_focused": {
|
||||
"passed": 1425
|
||||
},
|
||||
"integrated": {
|
||||
"files": 149,
|
||||
"passed": 3776,
|
||||
"skipped": 7,
|
||||
"xfailed": 2
|
||||
},
|
||||
"index_schema_config_focused": {
|
||||
"passed": 40
|
||||
},
|
||||
"release_docker_live": {
|
||||
"passed": 4,
|
||||
"version": "0.35.0",
|
||||
"architecture": "linux-x64",
|
||||
"page_execution_enabled": false,
|
||||
"pin_contract_proven": false
|
||||
},
|
||||
"full": {
|
||||
"passed": 12310,
|
||||
"failed": 76,
|
||||
"skipped": 65,
|
||||
"xfailed": 2,
|
||||
"subtests_passed": 6,
|
||||
"seconds": 403.66
|
||||
},
|
||||
"failure_classification": {
|
||||
"initial_failing_cases": 82,
|
||||
"frozen_a_replay_failed": 79,
|
||||
"frozen_a_replay_passed": 3,
|
||||
"corrected_browser_regressions": [
|
||||
"tests/test_execution_bridge.py::test_registry_dispatch_preserves_session_id_for_native_handlers",
|
||||
"tests/test_tool_index_schema_parity.py::test_every_schema_tool_has_an_index_description"
|
||||
],
|
||||
"remaining_order_failure_reproduced_on_frozen_a": {
|
||||
"command": "python -m pytest -q tests/test_scheduler_restart_doublefire.py tests/test_tool_approvals.py::test_dispatcher_rejects_approved_document_action_without_target",
|
||||
"passed": 4,
|
||||
"failed": 1
|
||||
},
|
||||
"all_final_failed_nodes_reproduced_on_frozen_a": true,
|
||||
"final_failed_nodes": [
|
||||
"tests/test_agent_bash_tmux_env.py::test_direct_bash_subprocess_has_closed_stdin",
|
||||
"tests/test_agent_bash_tmux_env.py::test_bash_rejects_unicode_ffmpeg_drawtext_without_explicit_font",
|
||||
"tests/test_agent_bash_tmux_env.py::test_bash_allows_unicode_ffmpeg_drawtext_with_explicit_fontfile",
|
||||
"tests/test_agent_bash_windows.py::test_windows_bash_tool_passes_ctx_env_through_to_the_child",
|
||||
"tests/test_agent_bash_windows.py::test_bash_tool_returns_install_hint_when_git_bash_is_missing",
|
||||
"tests/test_agent_bash_windows.py::test_windows_bash_does_not_use_a_stray_tmux_executable",
|
||||
"tests/test_agent_external_tool_schemas.py::test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema",
|
||||
"tests/test_client_tool_routing.py::test_no_bridge_falls_back_to_backend_execution",
|
||||
"tests/test_client_tool_routing.py::test_host_shell_requires_bridge_context",
|
||||
"tests/test_doc_library_open_orphaned.py::test_mobile_explicit_load_restores_full_editor_from_bottom_dock",
|
||||
"tests/test_document_history_controls.py::test_mobile_rich_text_history_state_and_document_switch",
|
||||
"tests/test_document_library_mobile_footer.py::test_mobile_open_in_new_chat_copies_to_materialized_session",
|
||||
"tests/test_document_module_api.py::test_default_export_surface_is_complete_and_callable",
|
||||
"tests/test_document_module_api.py::test_named_exports_survive_and_stay_callable",
|
||||
"tests/test_document_module_api.py::test_window_bridge_is_the_default_export",
|
||||
"tests/test_document_outline.py::test_outline_jumps_in_markdown_and_rich_text_and_fits_mobile",
|
||||
"tests/test_document_rich_checklist_enter.py::test_enter_creates_unchecked_task_and_empty_enter_exits_cleanly",
|
||||
"tests/test_document_rich_color_reset_and_contrast.py::test_rich_colors_follow_theme_and_undo_as_one_edit",
|
||||
"tests/test_document_rich_docx_export.py::test_browser_word_export_contains_native_rich_docx_ooxml",
|
||||
"tests/test_document_rich_docx_export.py::test_browser_markdown_word_export_keeps_heading_and_inline_formatting",
|
||||
"tests/test_document_rich_find_boundaries.py::test_find_rejects_cross_block_matches_but_supports_inline_matches_and_replacement",
|
||||
"tests/test_document_rich_font_color_controls.py::test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile",
|
||||
"tests/test_document_rich_heading_enter.py::test_mobile_heading_enter_exits_cleanly_and_is_one_step_undoable",
|
||||
"tests/test_document_rich_heading_enter.py::test_heading_enter_preserves_shift_middle_and_empty_heading_semantics",
|
||||
"tests/test_document_rich_image_caption.py::test_mobile_image_caption_survives_resize_history_and_empty_removal",
|
||||
"tests/test_document_rich_input_rules.py::test_typing_markers_converts_blocks_and_preserves_following_text",
|
||||
"tests/test_document_rich_keyboard_shortcuts.py::test_rich_document_shortcuts_work_at_desktop_and_mobile_widths",
|
||||
"tests/test_document_rich_selection_toolbar.py::test_selection_toolbar_formats_and_stays_inside_desktop_and_mobile_viewports",
|
||||
"tests/test_document_rich_slash_menu.py::test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile",
|
||||
"tests/test_document_rich_smart_link_paste.py::test_rich_url_paste_links_selections_and_plain_urls_without_unsafe_autolinks",
|
||||
"tests/test_document_rich_structure_tools.py::test_mobile_headings_page_break_history_and_persistence",
|
||||
"tests/test_document_rich_table_cell_alignment.py::test_mobile_table_cell_alignment_tracks_state_and_native_history",
|
||||
"tests/test_document_rich_table_header_preservation.py::test_mobile_structural_edits_preserve_header_modes_and_history",
|
||||
"tests/test_document_rich_table_headers.py::test_mobile_header_row_and_column_toggle_independently_with_undo",
|
||||
"tests/test_document_rich_table_merge_split.py::test_mobile_merge_split_round_trip_preserves_headers_formatting_and_history",
|
||||
"tests/test_document_rich_table_tab_history.py::test_mobile_table_tab_navigation_row_creation_and_history",
|
||||
"tests/test_document_rich_toolbar_menus.py::test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens",
|
||||
"tests/test_document_rich_toolbar_menus.py::test_mobile_toolbar_menu_preserves_selection_and_restores_focus",
|
||||
"tests/test_document_rich_toolbar_menus.py::test_rich_toolbar_menus_track_live_formatting_values",
|
||||
"tests/test_document_save_shortcut.py::test_ctrl_s_saves_rich_text_immediately_once_and_updates_status",
|
||||
"tests/test_document_save_status.py::test_save_status_is_dirty_race_safe_and_reports_failures",
|
||||
"tests/test_document_toolbar_order.py::test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile",
|
||||
"tests/test_edit_file.py::test_edit_file_blocked_at_execution_for_non_admin",
|
||||
"tests/test_email_library_module_graph_js.py::test_every_package_module_evaluates_on_its_own_in_a_browser",
|
||||
"tests/test_email_library_module_graph_js.py::test_wrapper_and_entry_module_hand_out_the_same_functions",
|
||||
"tests/test_escape_inner_layers.py::test_rich_escape_closes_toolbar_then_selection_badge",
|
||||
"tests/test_escape_inner_layers.py::test_email_escape_closes_inner_states_without_closing_library",
|
||||
"tests/test_failed_call_correction.py::test_corrected_ids_execute_after_repeated_ambiguous_title_failures[2]",
|
||||
"tests/test_failed_call_correction.py::test_corrected_ids_execute_after_repeated_ambiguous_title_failures[3]",
|
||||
"tests/test_history_resume_rendering_js.py::test_history_resume_rendering_browser_suite",
|
||||
"tests/test_live_fallback_round_attribution.py::test_detached_resume_reconciles_canonical_terminal_failures",
|
||||
"tests/test_live_fallback_round_attribution.py::test_detached_resume_surfaces_fallback_then_provider_alias_without_reload",
|
||||
"tests/test_live_fallback_round_attribution.py::test_detached_resume_renders_preoutput_error_without_empty_reload",
|
||||
"tests/test_manage_tasks_cron.py::test_cron_create_edit_resume_and_invalid_edit_rollback",
|
||||
"tests/test_manage_tasks_cron.py::test_named_weekdays_create_and_edit_preserve_actual_clock",
|
||||
"tests/test_manage_tasks_cron.py::test_time_only_edit_changes_cron_clock_not_calendar_fields[15 9 * * 1,3,5]",
|
||||
"tests/test_manage_tasks_cron.py::test_time_only_edit_changes_cron_clock_not_calendar_fields[15 9 15 * *]",
|
||||
"tests/test_manage_tasks_cron.py::test_time_only_edit_changes_cron_clock_not_calendar_fields[0,30 8-10 * * 2,4]",
|
||||
"tests/test_manage_tasks_cron.py::test_invalid_cron_retime_rolls_back_all_edits",
|
||||
"tests/test_preview_execution_evidence.py::test_failed_shell_retains_exit_status_and_both_streams_for_followup",
|
||||
"tests/test_review_regressions.py::test_host_shell_uses_tui_bridge_context",
|
||||
"tests/test_review_regressions.py::test_host_shell_forwards_detach_and_job_polling",
|
||||
"tests/test_review_regressions.py::test_host_shell_rejects_non_local_bridge_url_before_http",
|
||||
"tests/test_review_regressions.py::test_public_agent_policy_blocks_sensitive_tools",
|
||||
"tests/test_review_regressions.py::test_disabled_qualified_email_tool_blocks_bare_alias",
|
||||
"tests/test_review_regressions.py::test_tool_policy_qualified_email_block_covers_bare_alias",
|
||||
"tests/test_review_regressions.py::test_bare_email_dispatch_rejects_non_object_json_args",
|
||||
"tests/test_review_regressions.py::test_bare_email_dispatch_rejects_invalid_json_body",
|
||||
"tests/test_review_regressions.py::test_write_file_inline_json_args",
|
||||
"tests/test_review_regressions.py::test_plan_mode_blocks_mutating_email_aliases_without_mcp_inventory",
|
||||
"tests/test_review_regressions.py::test_bare_email_dispatch_empty_content_calls_with_empty_args",
|
||||
"tests/test_review_regressions.py::test_email_mcp_non_object_args_fail_before_dispatch",
|
||||
"tests/test_review_regressions.py::test_email_mcp_dispatch_includes_hidden_owner",
|
||||
"tests/test_review_regressions.py::test_bare_email_mcp_dispatch_includes_hidden_owner",
|
||||
"tests/test_tool_approvals.py::test_dispatcher_rejects_approved_document_action_without_target",
|
||||
"tests/test_turn_rendering_js.py::test_turn_rendering_browser_suite"
|
||||
]
|
||||
},
|
||||
"static": {
|
||||
"compileall": "passed",
|
||||
"diff_check": "passed",
|
||||
"conflict_markers": "none",
|
||||
"unmerged_index": "none"
|
||||
},
|
||||
"limitations": [
|
||||
"page/document reads and effects unconditionally unavailable",
|
||||
"arm64 producer execution not live tested",
|
||||
"18-case positive producer enabling gate remains blocked on atomic expected-identity operation support",
|
||||
"full repository suite is not green; failures reproduced on frozen A"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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 [])),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}))
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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("/") + "/")
|
||||
|
||||
+57
-1205
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -111,8 +111,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"get_weather": "Get current weather and a three-day forecast for a city or place from Open-Meteo without an API key. Use for weather lookups before web_search.",
|
||||
"web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.",
|
||||
"pdf_extract": "Extract focused, source-attributed passages and exact table values from an online PDF or task-local /workspace/*.pdf. Use for arXiv papers, reports, manuals, PDF tables, evaluation metrics, and multi-document PDF extraction. Prefer this over Python requests, curl, downloading, pdftotext, or guessing. Include target model names, metrics, and table headings in query.",
|
||||
"youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. Use for YouTube comments/transcript/channel latest-video tasks; use private_browser only for visual site interaction.",
|
||||
"private_browser": "Private browser automation through Odysseus' agent-browser wrapper. Use only for specific pages that need JavaScript, login/session state, clicking, filling forms, waiting, screenshots, or rendered DOM inspection. For open-ended search use web_search; for ordinary URL reading use web_fetch.",
|
||||
"youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. Use for YouTube comments/transcript/channel latest-video tasks.",
|
||||
"private_browser": "Trusted metadata for an existing server-registered browser session only. Page/document reads and interactions are unavailable because the configured producer cannot guarantee exact target binding. No model batch or raw browser commands. Use web_search or web_fetch for supported web access.",
|
||||
"inspect_media": "Inspect local workspace images, SVGs, videos, and PDF pages with the current multimodal model. Samples bounded timestamped video frames uniformly, at scene cuts, or from temporally diverse motion peaks; renders SVG to PNG; exports stills or clips; concatenates ranges; changes clip speed while preserving audio pitch; and renders query-relevant PDF pages. Prefer these native operations over raw ffmpeg. Increase max_dimension only for small visual details; saved exports keep source quality.",
|
||||
"extract_text": "Extract exact visible text, confidence, and pixel centers from a local workspace image with Odysseus local OCR. Use for screenshots, scans, labels, numbers, receipts, and text-location tasks; use inspect_media for general visual understanding.",
|
||||
"transcribe_media": "Transcribe dialogue, narration, names, and spoken timing from a local audio or video file with Odysseus local Whisper. Returns [START --> END] TEXT segments and always persists them to a workspace text file. For a named chapter, question, scene, or topic, locate its boundaries and restrict filtering to that interval. This handles audio speech; combine with inspect_media for audiovisual tasks or visually burned-in subtitles.",
|
||||
|
||||
+18
-25
@@ -393,35 +393,28 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "private_browser",
|
||||
"description": "Private browser automation through Odysseus' agent-browser wrapper. After open, snapshot the page and interact with returned element refs such as @e12; click/fill target is a selector or element ref, never guessed visible text. Prefer one batch for known consecutive steps, such as open plus snapshot. Use only when a specific page needs JavaScript, login/session state, interaction, or rendered DOM. For open-ended search use web_search; for reading a normal URL use web_fetch.",
|
||||
"description": "Trusted browser session metadata only. Page/document operations are unavailable because the local producer cannot atomically bind a captured target. No batch or raw CLI flags. Use web_search/web_fetch for supported web access.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["open", "read", "snapshot", "find", "evaluate", "click", "fill", "press", "scroll", "wait", "screenshot", "close", "batch"]},
|
||||
"url": {"type": "string", "description": "Required URL for open; optional URL for read (omit to read the current page)"},
|
||||
"selector": {"type": "string", "description": "Element ref or selector for read/click/fill/wait"},
|
||||
"target": {"type": "string", "description": "Element ref returned by snapshot (preferred, e.g. @e12) or CSS selector for read/click/fill/wait; never a guessed visible label; top or bottom for scroll"},
|
||||
"key": {"type": "string", "description": "Key name for press action, e.g. Enter"},
|
||||
"direction": {"type": "string", "enum": ["up", "down", "left", "right"], "description": "Direction for scroll action"},
|
||||
"amount": {"type": "integer", "minimum": 1, "description": "Optional scroll distance in pixels; default 300"},
|
||||
"text": {"type": "string", "description": "Text for fill action"},
|
||||
"value": {"type": "string", "description": "Alternative text/value for fill action"},
|
||||
"find": {"type": "string", "description": "Visible text to locate for find action"},
|
||||
"script": {"type": "string", "description": "JavaScript expression for evaluate action"},
|
||||
"path": {"type": "string", "description": "Optional output path for screenshot"},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"description": "Non-empty batch commands as arrays, e.g. [[\"open\", \"https://example.com\"], [\"snapshot\"]]. Do not send an empty batch; use action=snapshot for current page state.",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
{"type": "object"},
|
||||
]
|
||||
},
|
||||
},
|
||||
"timeout_ms": {"type": "integer", "description": "Optional operation timeout, max 120000; for action=wait without a selector, this is the wait duration"}
|
||||
"action": {"type": "string", "enum": ["session_info", "tabs", "open", "read", "snapshot", "find", "evaluate", "click", "fill", "press", "scroll", "wait", "screenshot", "close", "navigate", "reload", "back", "forward", "select_page", "close_page", "network", "console", "new_page"]},
|
||||
"page": {"type": "string", "pattern": "^t[1-9][0-9]*$", "description": "Observed alias only; page commands remain disabled for the current producer."},
|
||||
"url": {"type": "string"},
|
||||
"selector": {"type": "string"},
|
||||
"target": {"type": "string"},
|
||||
"ref": {"type": "string"},
|
||||
"key": {"type": "string"},
|
||||
"direction": {"type": "string"},
|
||||
"text": {"type": "string"},
|
||||
"value": {"type": "string"},
|
||||
"script": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"find": {"type": "string"},
|
||||
"amount": {"type": "integer"},
|
||||
"timeout_ms": {"type": "integer", "minimum": 0, "maximum": 20000}
|
||||
},
|
||||
"required": ["action"]
|
||||
"required": ["action"],
|
||||
"additionalProperties": False
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+17
-11
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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=[],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src import browser_identity as browser
|
||||
from src.agent_runtime.resources import ResourceIdentityError
|
||||
from tests.test_browser_resource_identity import producer, observed, authority
|
||||
from tests.test_runtime_resource_integration import approval_for, dispatch
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", ["timeout", "cancel", "spawn_cancel"])
|
||||
async def test_client_is_killed_before_resend_deadline_without_retry(monkeypatch, phase):
|
||||
calls = []
|
||||
class Child:
|
||||
returncode = None
|
||||
killed = False
|
||||
async def wait(self):
|
||||
if self.killed:
|
||||
self.returncode = -9
|
||||
return -9
|
||||
await asyncio.Future()
|
||||
def kill(self): self.killed = True
|
||||
child = Child()
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
async def spawn(*args, **kwargs):
|
||||
calls.append(args); started.set()
|
||||
if phase == "spawn_cancel": await release.wait()
|
||||
return child
|
||||
monkeypatch.setattr(browser.asyncio, "create_subprocess_exec", spawn)
|
||||
original = asyncio.wait_for
|
||||
async def bounded(awaitable, timeout):
|
||||
assert timeout == browser.CLIENT_DEADLINE_S and timeout < 30
|
||||
return await original(awaitable, .01 if phase == "timeout" else timeout)
|
||||
monkeypatch.setattr(browser.asyncio, "wait_for", bounded)
|
||||
task = asyncio.create_task(browser.run_client(["trusted-producer", "session", "info"], env={}, cwd="/"))
|
||||
await started.wait()
|
||||
if phase != "timeout": task.cancel()
|
||||
release.set()
|
||||
with pytest.raises((asyncio.TimeoutError, asyncio.CancelledError)): await task
|
||||
assert child.killed and len(calls) == 1
|
||||
|
||||
|
||||
async def test_sidecar_allowlist_has_no_enable_mutation_or_arbitrary_cdp():
|
||||
client = browser.CDPSidecar("ws://127.0.0.1:1234/devtools/browser/12345678-1234-1234-1234-123456789abc")
|
||||
for method in ("Page.enable", "Runtime.evaluate", "Page.navigate", "Target.closeTarget", "Browser.close"):
|
||||
with pytest.raises(ValueError): await client.call(method)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("envelope", [[], None, {"id": True, "result": {}}, {"id": "1", "result": {}}, {"id": 1, "error": {}, "result": {}}])
|
||||
async def test_sidecar_rejects_malformed_identity_envelopes(monkeypatch, envelope):
|
||||
client = browser.CDPSidecar("ws://127.0.0.1:1234/devtools/browser/12345678-1234-1234-1234-123456789abc")
|
||||
async def send(*args): pass
|
||||
async def receive(): return envelope
|
||||
monkeypatch.setattr(client, "_send", send)
|
||||
monkeypatch.setattr(client, "_message", receive)
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
await client.call("Target.getTargets")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["namespace", "runtimeError", "restoreKey"])
|
||||
async def test_missing_nullable_lifecycle_fields_are_not_valid_observations(producer, field):
|
||||
record = await observed(producer)
|
||||
info = await record.command("session", "info")
|
||||
del (info["runtime"] if field == "restoreKey" else info)[field]
|
||||
with pytest.raises(ResourceIdentityError): browser.daemon_observation(record, info)
|
||||
|
||||
|
||||
async def test_observation_cancellation_while_waiting_for_lock_invalidates_session(producer):
|
||||
record = await observed(producer)
|
||||
await record.lock.acquire()
|
||||
task = asyncio.create_task(browser.observe_registered(record))
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError): await task
|
||||
record.lock.release()
|
||||
assert record.session is None and record.pages == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [{"action": "click", "page": "t1"}, {"action": "batch", "commands": [["click", "e1"]]}])
|
||||
async def test_central_dispatch_cannot_bypass_page_denial(producer, args):
|
||||
await observed(producer)
|
||||
current = authority()
|
||||
producer.calls.clear(); producer.cdp_calls.clear()
|
||||
_, result = await dispatch(current, "private_browser", json.dumps(args))
|
||||
assert result["failure_kind"] == browser.PAGE_FAILURE and result["executed"] is False
|
||||
assert not producer.calls and not producer.cdp_calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("replacement", ["browser", "daemon"])
|
||||
async def test_exact_approval_revalidates_before_claim(producer, replacement):
|
||||
record = await observed(producer)
|
||||
current = authority()
|
||||
content = '{"action":"session_info"}'
|
||||
approval = approval_for(current, "private_browser", content)
|
||||
if replacement == "daemon":
|
||||
producer.pid += 1
|
||||
else:
|
||||
record._endpoint = "ws://127.0.0.1:1234/devtools/browser/87654321-1234-1234-1234-123456789abc"
|
||||
_, result = await dispatch(current, "private_browser", content, approval)
|
||||
assert result["exit_code"] == 1 and not approval._claimed
|
||||
assert record.session is None and record.pages == ()
|
||||
|
||||
|
||||
async def test_metadata_revalidation_never_auto_launches_or_calls_get_cdp_url(producer):
|
||||
await observed(producer)
|
||||
current = authority()
|
||||
producer.calls.clear()
|
||||
_, result = await dispatch(current, "private_browser", '{"action":"session_info"}')
|
||||
assert result["exit_code"] == 0
|
||||
assert producer.calls and all(command == ("session", "info") for command in producer.calls)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["EOF", "connection reset", "EAGAIN", "read timeout"])
|
||||
async def test_page_failures_never_enter_producer_internal_retry_path(producer, status, monkeypatch):
|
||||
async def forbidden(*args, **kwargs):
|
||||
pytest.fail("Producer retry hazard reached: " + status)
|
||||
monkeypatch.setattr(browser, "run_client", forbidden)
|
||||
producer.calls.clear()
|
||||
from src.agent_tools.web_tools import PrivateBrowserTool
|
||||
result = await PrivateBrowserTool().execute('{"action":"wait","page":"t1","timeout_ms":120000}', {})
|
||||
assert result["executed"] is False and result["retryable"] is False
|
||||
assert producer.calls == []
|
||||
|
||||
|
||||
async def test_page_scoped_child_still_cannot_execute_even_matching_observation(producer):
|
||||
await observed(producer)
|
||||
from dataclasses import replace
|
||||
parent = replace(authority(), browser_sessions=())
|
||||
child = parent.intersect(authority())
|
||||
_, result = await dispatch(child, "private_browser", '{"action":"click","page":"t1","ref":"e1"}')
|
||||
assert result["failure_kind"] == browser.PAGE_FAILURE and result["executed"] is False
|
||||
|
||||
|
||||
async def test_observed_url_or_alias_change_is_not_resource_authority(producer):
|
||||
record = await observed(producer)
|
||||
from dataclasses import replace
|
||||
original = record.pages[0]
|
||||
metadata = replace(original, resolved_alias="t99", observed_url="https://different.example")
|
||||
assert original.authority_key() == metadata.authority_key()
|
||||
metadata.validate()
|
||||
|
||||
|
||||
def test_raw_global_playwright_and_native_backend_are_not_substitutable():
|
||||
from src.agent_runtime.resources import ExternalResource
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
assert not browser.native_browser(ExactOperation.normalize("private_browser", '{"action":"session_info"}'),
|
||||
ExternalResource("mcp", "endpoint", "server", "tool", "epoch"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", ["browser_click", "browser_snapshot", "browser_evaluate", "browser_navigate", "browser_run_code"])
|
||||
async def test_raw_mcp_browser_execution_cannot_evade_disabled_page_contract(tool):
|
||||
from src.agent_runtime.authority import RequestAuthority, OperationGrant
|
||||
name = "mcp__builtin_browser__" + tool
|
||||
current = RequestAuthority("request", "alice", "thread", "", (OperationGrant(name),))
|
||||
_, result = await dispatch(current, name, '{}')
|
||||
assert result["failure_kind"] == browser.PAGE_FAILURE and result["executed"] is False
|
||||
@@ -244,177 +244,6 @@ def _run(payload, ctx):
|
||||
return asyncio.run(PrivateBrowserTool().execute(json.dumps(payload), ctx))
|
||||
|
||||
|
||||
def test_timeout_cleans_only_this_sessions_browser(browser_env) -> None:
|
||||
state, calls, cleaned, swept = browser_env
|
||||
|
||||
async def _hang(command):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
state["behaviour"] = _hang
|
||||
result = _run({"action": "open", "url": "https://example.com"}, {"session_id": "s-timeout"})
|
||||
|
||||
assert result["exit_code"] == 1 and "timed out" in result["error"]
|
||||
assert cleaned == ["s-timeout"]
|
||||
assert swept == [], "a per-session timeout must not sweep other sessions' Chrome"
|
||||
lifecycle = result["browser_lifecycle"]
|
||||
assert lifecycle["state"] == "timed_out"
|
||||
assert lifecycle["cleanup"]["verified"] is True
|
||||
assert [stage["stage"] for stage in lifecycle["stages"]] == ["open", "forced_cleanup"]
|
||||
assert sum(1 for call in calls if "open" in call) == 1, "remote opens are never retried"
|
||||
|
||||
|
||||
def test_launch_failure_is_reported_and_cleaned(browser_env) -> None:
|
||||
state, _, cleaned, _ = browser_env
|
||||
|
||||
async def _no_sandbox(command):
|
||||
return 1, ("Chrome exited early (exit code: unknown) without writing DevToolsActivePort\n"
|
||||
"FATAL: No usable sandbox!")
|
||||
|
||||
state["behaviour"] = _no_sandbox
|
||||
result = _run({"action": "open", "url": "https://example.com"}, {"session_id": "s-launch"})
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "could not launch the browser" in result["error"]
|
||||
assert cleaned == ["s-launch"]
|
||||
assert result["browser_lifecycle"]["state"] == "launch_failed"
|
||||
assert result["browser_lifecycle"]["navigation_generation"] == 0
|
||||
|
||||
|
||||
def test_observation_after_failed_navigation_is_marked_stale(browser_env) -> None:
|
||||
state, _, _, _ = browser_env
|
||||
|
||||
async def _behaviour(command):
|
||||
if command[-2:] == ["open", "https://good.example/"]:
|
||||
return 0, "✓ Good\n https://good.example/\n"
|
||||
if "open" in command:
|
||||
return 1, "net::ERR_NAME_NOT_RESOLVED"
|
||||
return 0, '- heading "Good page" [ref=e1]'
|
||||
|
||||
state["behaviour"] = _behaviour
|
||||
ctx = {"session_id": "s-stale"}
|
||||
opened = _run({"action": "open", "url": "https://good.example/"}, ctx)
|
||||
assert opened["browser_lifecycle"]["navigation_generation"] == 1
|
||||
assert opened["browser_lifecycle"]["page_url"] == "https://good.example/"
|
||||
|
||||
failed = _run({"action": "open", "url": "https://bad.example/"}, ctx)
|
||||
assert failed["exit_code"] == 1
|
||||
assert failed["browser_lifecycle"]["state"] == "navigation_failed"
|
||||
|
||||
observed = _run({"action": "snapshot"}, ctx)
|
||||
assert observed["output"].startswith("[Browser lifecycle: the most recent navigation to https://bad.example/ failed")
|
||||
assert "shows https://good.example/ (navigation #1)" in observed["output"]
|
||||
assert observed["browser_lifecycle"]["stale_observation"] is True
|
||||
|
||||
_run({"action": "open", "url": "https://good.example/"}, ctx)
|
||||
fresh = _run({"action": "snapshot"}, ctx)
|
||||
assert not fresh["output"].startswith("[Browser lifecycle")
|
||||
assert "stale_observation" not in fresh["browser_lifecycle"]
|
||||
|
||||
|
||||
def test_sessionless_call_gets_its_own_browser_and_closes_it(browser_env, monkeypatch) -> None:
|
||||
state, calls, cleaned, _ = browser_env
|
||||
monkeypatch.setattr(PrivateBrowserTool, "_owned_daemon_exists", staticmethod(lambda env, session: True))
|
||||
|
||||
async def _ok(command):
|
||||
return 0, "✓ T\n https://example.com/\n"
|
||||
|
||||
state["behaviour"] = _ok
|
||||
first = _run({"action": "open", "url": "https://example.com/"}, {})
|
||||
second = _run({"action": "open", "url": "https://example.com/"}, {})
|
||||
|
||||
sessions = [call[call.index("--session") + 1] for call in calls if "--session" in call]
|
||||
assert all(session.startswith("ody-") for session in sessions)
|
||||
assert len({sessions[0], sessions[-1]}) == 2, "sessionless calls must not share a browser"
|
||||
assert any(call[-1] == "close" for call in calls)
|
||||
assert first["browser_lifecycle"]["ownership"] == "ephemeral"
|
||||
assert first["browser_lifecycle"]["cleanup"]["graceful_close"] is True
|
||||
assert first["browser_lifecycle"]["state"] == "closed"
|
||||
assert len(cleaned) == 2
|
||||
assert not web_tools._ACTIVE_BROWSER_SESSIONS.intersection(sessions)
|
||||
assert not any(browser_lifecycle.registered(s) for s in sessions)
|
||||
assert second["exit_code"] == 0
|
||||
|
||||
|
||||
def test_actions_on_one_session_are_serialized(browser_env) -> None:
|
||||
state, _, _, _ = browser_env
|
||||
active = {"now": 0, "peak": 0}
|
||||
|
||||
async def _slow(command):
|
||||
active["now"] += 1
|
||||
active["peak"] = max(active["peak"], active["now"])
|
||||
await asyncio.sleep(0.02)
|
||||
active["now"] -= 1
|
||||
return 0, '- heading "x"'
|
||||
|
||||
state["behaviour"] = _slow
|
||||
|
||||
async def _both():
|
||||
tool = PrivateBrowserTool()
|
||||
await asyncio.gather(
|
||||
tool.execute(json.dumps({"action": "snapshot"}), {"session_id": "s-lock"}),
|
||||
tool.execute(json.dumps({"action": "snapshot"}), {"session_id": "s-lock"}),
|
||||
)
|
||||
|
||||
asyncio.run(_both())
|
||||
assert active["peak"] == 1
|
||||
|
||||
|
||||
def test_cancellation_stops_clients_and_cleans_the_session(browser_env, monkeypatch) -> None:
|
||||
state, calls, cleaned, _ = browser_env
|
||||
terminated = []
|
||||
|
||||
async def _forever(command):
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
state["behaviour"] = _forever
|
||||
monkeypatch.setattr(
|
||||
PrivateBrowserTool, "_terminate_subprocess",
|
||||
staticmethod(lambda proc: terminated.append(proc.command)),
|
||||
)
|
||||
|
||||
async def _cancel():
|
||||
task = asyncio.create_task(PrivateBrowserTool().execute(
|
||||
json.dumps({"action": "open", "url": "https://example.com"}),
|
||||
{"session_id": "s-cancel"},
|
||||
))
|
||||
while not calls:
|
||||
await asyncio.sleep(0.01)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
asyncio.run(_cancel())
|
||||
|
||||
assert terminated and terminated[0][-1] == "https://example.com"
|
||||
assert cleaned == ["s-cancel"]
|
||||
key = web_tools._scoped_browser_session("odysseus-ui", "s-cancel")
|
||||
assert browser_lifecycle.registered(key).state == "cancelled"
|
||||
|
||||
|
||||
def test_local_open_recovery_is_single_and_inside_the_deadline(browser_env, monkeypatch, tmp_path) -> None:
|
||||
state, calls, cleaned, _ = browser_env
|
||||
page = tmp_path / "page.html"
|
||||
page.write_text("<title>x</title>")
|
||||
|
||||
async def _hang(command):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
state["behaviour"] = _hang
|
||||
payload = {"action": "open", "url": "/workspace/page.html", "_odysseus_browser_retry": True}
|
||||
result = _run(payload, {"session_id": "s-retry"})
|
||||
|
||||
opens = [call for call in calls if call[-1] == page.as_uri()]
|
||||
assert len(opens) == 2, "a model-supplied retry flag must not change recovery"
|
||||
assert result["browser_lifecycle"]["recovery_attempts"] == 1
|
||||
assert cleaned == ["s-retry", "s-retry"]
|
||||
|
||||
calls.clear()
|
||||
monkeypatch.setattr(PrivateBrowserTool, "_RECOVERY_BUDGET_S", 0)
|
||||
exhausted = _run({"action": "open", "url": "/workspace/page.html", "timeout_ms": 1000}, {"session_id": "s-budget"})
|
||||
assert len([call for call in calls if call[-1] == page.as_uri()]) == 1
|
||||
assert "recovery_attempts" not in exhausted["browser_lifecycle"]
|
||||
|
||||
|
||||
def test_research_reader_passes_its_timeout_to_the_browser(monkeypatch) -> None:
|
||||
from src.research_navigator import ResearchNavigator
|
||||
|
||||
@@ -486,76 +315,6 @@ def _owned_processes(runtime: Path) -> list[int]:
|
||||
return owned
|
||||
|
||||
|
||||
@real_browser
|
||||
def test_real_local_page_open_extract_and_ephemeral_cleanup(real_runtime) -> None:
|
||||
workspace, runtime, env = real_runtime
|
||||
(workspace / "page.html").write_text(
|
||||
"<html><head><title>Lifecycle</title></head><body><h1>Fresh heading</h1></body></html>"
|
||||
)
|
||||
|
||||
result = _run(
|
||||
{"action": "batch", "commands": [["open", "/workspace/page.html"], ["snapshot"]]},
|
||||
{"subproc_env": env},
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0, result
|
||||
assert "Fresh heading" in result["output"]
|
||||
lifecycle = result["browser_lifecycle"]
|
||||
assert lifecycle["ownership"] == "ephemeral"
|
||||
assert lifecycle["navigation_generation"] == 1
|
||||
assert lifecycle["state"] == "closed" and lifecycle["page_url"] == ""
|
||||
assert lifecycle["closed_page_url"].endswith("/page.html")
|
||||
assert lifecycle["cleanup"]["verified"] is True
|
||||
assert [stage["stage"] for stage in lifecycle["stages"]] == ["batch", "close"]
|
||||
time.sleep(0.5)
|
||||
assert _owned_processes(runtime) == []
|
||||
assert list((runtime / "agent-browser").glob("ody-*")) == []
|
||||
assert list((runtime / "tmp").glob("agent-browser-chrome-*")) == []
|
||||
|
||||
|
||||
@real_browser
|
||||
def test_real_retained_session_survives_then_forced_cleanup_leaves_nothing(real_runtime) -> None:
|
||||
workspace, runtime, env = real_runtime
|
||||
(workspace / "a.html").write_text("<title>A</title><h1>Alpha</h1>")
|
||||
ctx = {"session_id": "retained", "subproc_env": env}
|
||||
|
||||
opened = _run({"action": "open", "url": "/workspace/a.html"}, ctx)
|
||||
assert opened["exit_code"] == 0, opened
|
||||
observed = _run({"action": "snapshot"}, ctx)
|
||||
assert "Alpha" in observed["output"]
|
||||
assert observed["browser_lifecycle"]["ownership"] == "retained"
|
||||
assert _owned_processes(runtime), "a retained session keeps its browser"
|
||||
|
||||
receipt = PrivateBrowserTool._terminate_owned_daemon(dict(os.environ, **env), "retained")
|
||||
|
||||
assert receipt["verified"] is True and receipt["killed"] >= 2
|
||||
assert receipt["removed_profiles"] == 1
|
||||
assert _owned_processes(runtime) == []
|
||||
assert list((runtime / "agent-browser").glob("ody-*")) == []
|
||||
|
||||
|
||||
@real_browser
|
||||
def test_real_cancellation_leaves_no_browser(real_runtime) -> None:
|
||||
workspace, runtime, env = real_runtime
|
||||
(workspace / "slow.html").write_text("<title>S</title><h1>Slow</h1>")
|
||||
ctx = {"session_id": "cancelled", "subproc_env": env}
|
||||
assert _run({"action": "open", "url": "/workspace/slow.html"}, ctx)["exit_code"] == 0
|
||||
|
||||
async def _cancel_wait():
|
||||
task = asyncio.create_task(PrivateBrowserTool().execute(
|
||||
json.dumps({"action": "wait", "timeout_ms": 30000}), ctx,
|
||||
))
|
||||
await asyncio.sleep(1.5)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
asyncio.run(_cancel_wait())
|
||||
time.sleep(0.5)
|
||||
assert _owned_processes(runtime) == []
|
||||
assert list((runtime / "agent-browser").glob("ody-*")) == []
|
||||
|
||||
|
||||
def test_browser_mcp_call_is_bounded_and_never_replayed(monkeypatch) -> None:
|
||||
from src.mcp_manager import McpManager
|
||||
|
||||
@@ -577,115 +336,3 @@ def test_browser_mcp_call_is_bounded_and_never_replayed(monkeypatch) -> None:
|
||||
assert result["exit_code"] == 1
|
||||
assert "timed out after 0.05s and was not retried" in result["error"]
|
||||
assert calls == ["browser_navigate"]
|
||||
|
||||
|
||||
def test_read_url_navigates_and_extracts_in_one_observation(browser_env) -> None:
|
||||
state, calls, _, _ = browser_env
|
||||
|
||||
async def _batch(command):
|
||||
return 0, json.dumps([
|
||||
{"command": ["open", "https://example.com/"], "success": True,
|
||||
"result": {"title": "Example", "url": "https://example.com/final"}},
|
||||
{"command": ["get", "text", "body"], "success": True,
|
||||
"result": {"text": "Example body"}},
|
||||
])
|
||||
|
||||
state["behaviour"] = _batch
|
||||
result = _run({"action": "read", "url": "https://example.com/"}, {"session_id": "s-read"})
|
||||
|
||||
assert calls[-1][-2:] == ["batch", "--json"]
|
||||
assert result["exit_code"] == 0
|
||||
assert result["output"] == "Example\nhttps://example.com/final\n\nExample body"
|
||||
assert result["browser_lifecycle"]["page_url"] == "https://example.com/final"
|
||||
|
||||
|
||||
def test_read_url_without_extracted_text_is_a_failure(browser_env) -> None:
|
||||
state, _, _, _ = browser_env
|
||||
|
||||
async def _no_text(command):
|
||||
return 0, json.dumps([
|
||||
{"success": True, "result": {"url": "https://example.com/"}},
|
||||
{"success": False, "error": "Timeout waiting for body", "result": None},
|
||||
])
|
||||
|
||||
state["behaviour"] = _no_text
|
||||
result = _run({"action": "read", "url": "https://example.com/"}, {"session_id": "s-read-fail"})
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Timeout waiting for body" in result["error"]
|
||||
assert result["browser_lifecycle"]["state"] == "navigation_failed"
|
||||
|
||||
|
||||
@real_browser
|
||||
def test_real_read_url_extracts_text_after_navigation(real_runtime) -> None:
|
||||
import functools
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
workspace, runtime, env = real_runtime
|
||||
(workspace / "doc.html").write_text("<title>Doc</title><h1>Served heading</h1><p>Body text</p>")
|
||||
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(workspace))
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
url = f"http://127.0.0.1:{server.server_address[1]}/doc.html"
|
||||
result = _run({"action": "read", "url": url}, {"subproc_env": env})
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
assert result["exit_code"] == 0, result
|
||||
assert result["output"].startswith(f"Doc\n{url}")
|
||||
assert "Served heading" in result["output"] and "Body text" in result["output"]
|
||||
assert result["browser_lifecycle"]["closed_page_url"] == url
|
||||
assert result["browser_lifecycle"]["cleanup"]["verified"] is True
|
||||
time.sleep(0.5)
|
||||
assert _owned_processes(runtime) == []
|
||||
|
||||
|
||||
def test_selector_read_is_an_observation_not_a_navigation() -> None:
|
||||
assert PrivateBrowserTool._navigation_target(
|
||||
"read", {"selector": "#main", "url": "https://elsewhere.example/"}
|
||||
) == ""
|
||||
assert PrivateBrowserTool._navigation_target(
|
||||
"batch", {"commands": [["open", "file:///a.html"], ["snapshot"], ["open", "file:///b.html"]]}
|
||||
) == "file:///b.html"
|
||||
|
||||
|
||||
def test_batch_navigation_outcome_comes_from_its_rows(browser_env) -> None:
|
||||
state, _, _, _ = browser_env
|
||||
responses = {}
|
||||
|
||||
async def _batch(command):
|
||||
if command[-2:] == ["batch", "--json"]:
|
||||
return responses["batch"]
|
||||
return 0, '- heading "x"'
|
||||
|
||||
state["behaviour"] = _batch
|
||||
ctx = {"session_id": "s-batch"}
|
||||
|
||||
# The open succeeded; a later click failing must not mark it failed.
|
||||
responses["batch"] = (1, json.dumps([
|
||||
{"command": ["open", "https://a.example/"], "success": True,
|
||||
"result": {"url": "https://a.example/landing"}},
|
||||
{"command": ["click", "@e9"], "success": False, "error": "no element"},
|
||||
]))
|
||||
result = _run({"action": "batch", "commands": [["open", "https://a.example/"], ["click", "@e9"]]}, ctx)
|
||||
assert result["browser_lifecycle"]["page_url"] == "https://a.example/landing"
|
||||
assert result["browser_lifecycle"]["state"] == "ready"
|
||||
assert "stale_observation" not in _run({"action": "snapshot"}, ctx)["browser_lifecycle"]
|
||||
|
||||
responses["batch"] = (1, json.dumps([
|
||||
{"command": ["open", "https://b.example/"], "success": False, "error": "net::ERR"},
|
||||
]))
|
||||
failed = _run({"action": "batch", "commands": [["open", "https://b.example/"]]}, ctx)
|
||||
assert failed["browser_lifecycle"]["state"] == "navigation_failed"
|
||||
note = _run({"action": "snapshot"}, ctx)["output"]
|
||||
assert "shows https://a.example/landing (navigation #1), not https://b.example/" in note
|
||||
|
||||
responses["batch"] = (1, "daemon connection lost")
|
||||
_run({"action": "batch", "commands": [["open", "https://c.example/"]]}, ctx)
|
||||
unknown = _run({"action": "snapshot"}, ctx)
|
||||
assert "outcome of the most recent navigation to https://c.example/ is unknown" in unknown["output"]
|
||||
assert unknown["browser_lifecycle"]["page_url"] == ""
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Release-only probes, isolated owned sessions; no model page authorization.
|
||||
|
||||
Run in the actual release image with ODYSSEUS_BROWSER_LIVE_CONTRACT=1. Without
|
||||
that explicit gate these are reported as skips, not producer-contract passes.
|
||||
The pin test asserts the known 0.35.0 defect, never enables page operations.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
from src import browser_identity as browser
|
||||
from src.agent_tools.web_tools import PrivateBrowserTool
|
||||
from src import browser_lifecycle
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.environ.get("ODYSSEUS_BROWSER_LIVE_CONTRACT") != "1",
|
||||
reason="requires explicit live contract gate in the allowlisted 0.35.0 release Docker image")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def live(tmp_path, monkeypatch):
|
||||
from pathlib import Path
|
||||
# Unix-domain sockets have a strict path-length limit. Match the release's
|
||||
# short owned runtime instead of pytest's long per-test directory name.
|
||||
directory = tempfile.TemporaryDirectory(prefix="w3-live-")
|
||||
monkeypatch.setattr(browser, "STATE_ROOT", Path(directory.name))
|
||||
monkeypatch.setattr(browser, "_REGISTRY", {})
|
||||
record = await browser.register_producer("live-contract", "thread")
|
||||
# Test setup only. Exercise the source-audited first-pin local launch case.
|
||||
await record.command("get", "cdp-url", "--pin-tab")
|
||||
try:
|
||||
yield record
|
||||
finally:
|
||||
try:
|
||||
await record.command("close")
|
||||
finally:
|
||||
browser_lifecycle.force_cleanup(record.cwd / "runtime", record.key)
|
||||
directory.cleanup()
|
||||
|
||||
|
||||
async def test_live_exact_schema_target_loader_and_observation_stability(live):
|
||||
first = await browser.observe_registered(live, "t1")
|
||||
second = await browser.observe_registered(live, "t1")
|
||||
assert first.authority_key() == second.authority_key()
|
||||
assert first.loader_id and first.target_id
|
||||
assert live.pin_armed_for is None
|
||||
assert "devtools/browser" not in json.dumps(first.to_dict())
|
||||
|
||||
|
||||
async def test_live_document_navigation_reload_hash_and_identical_tabs(live):
|
||||
await live.command("open", "data:text/html,<title>fixture</title><p>content</p>", "--pin-tab")
|
||||
first = await browser.observe_registered(live, "t1")
|
||||
await live.command("eval", "history.replaceState(null,'','#same')", "--pin-tab")
|
||||
same = await browser.observe_registered(live, "t1")
|
||||
assert same.loader_id == first.loader_id
|
||||
await live.command("reload", "--pin-tab")
|
||||
reloaded = await browser.observe_registered(live, "t1")
|
||||
assert reloaded.loader_id != first.loader_id
|
||||
await live.command("open", "data:text/html,<title>replacement</title>", "--pin-tab")
|
||||
navigated = await browser.observe_registered(live, "t1")
|
||||
assert navigated.loader_id != reloaded.loader_id
|
||||
await live.command("tab", "new", "data:text/html,<title>replacement</title>", "--pin-tab")
|
||||
await browser.observe_registered(live)
|
||||
assert len({p.target_id for p in live.pages}) == 2
|
||||
assert len({p.loader_id for p in live.pages}) == 2
|
||||
|
||||
|
||||
async def test_live_local_launch_rearm_drops_flags_and_retargets_destroyed_page(live):
|
||||
await live.command("tab", "new", "about:blank", "--pin-tab")
|
||||
await browser.observe_registered(live)
|
||||
# Digit-leading target avoids the distinct producer label-parser hazard.
|
||||
captured = next((p for p in live.pages if p.target_id[0].isdigit()), None)
|
||||
for _ in range(8):
|
||||
if captured is not None:
|
||||
break
|
||||
await live.command("tab", "new", "about:blank", "--pin-tab")
|
||||
await browser.observe_registered(live)
|
||||
captured = next((p for p in live.pages if p.target_id[0].isdigit()), None)
|
||||
assert captured is not None, "could not obtain a digit-leading target for the pin probe"
|
||||
switched = await live.command("tab", captured.target_id, "--pin-tab")
|
||||
assert switched["targetId"] == captured.target_id
|
||||
await live.command("session", "info", "--no-pin-tab")
|
||||
await live.command("session", "info", "--pin-tab")
|
||||
endpoint = urlsplit(live._endpoint)
|
||||
# External destruction is TEST FIXTURE ONLY, outside the identity sidecar.
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{endpoint.port}/json/close/{captured.target_id}", timeout=3) as response:
|
||||
assert response.status == 200
|
||||
result = await live.command("snapshot", "--pin-tab")
|
||||
active = [t for t in browser.tabs_schema(await live.command("tab", "list")) if t["active"]]
|
||||
assert active and active[0]["targetId"] != captured.target_id
|
||||
assert "tab_gone" not in json.dumps(result)
|
||||
assert result["lifecycle"]["relaunchedBrowser"] is False
|
||||
assert live.pin_armed_for is None
|
||||
# Actual Odysseus refuses before any page command, even with this observation.
|
||||
denied = await PrivateBrowserTool().execute('{"action":"snapshot","page":"t1"}',
|
||||
{"owner": "live-contract", "session_id": "thread"})
|
||||
assert denied["failure_kind"] == browser.PAGE_FAILURE and denied["executed"] is False
|
||||
|
||||
|
||||
async def test_live_af_target_switch_is_exact_but_never_grants_page_execution(live):
|
||||
await browser.observe_registered(live)
|
||||
captured = live.pages[0]
|
||||
switched = await live.command("tab", captured.target_id, "--pin-tab")
|
||||
assert switched["targetId"] == captured.target_id
|
||||
denied = await PrivateBrowserTool().execute('{"action":"click","page":"t1","ref":"e1"}', {})
|
||||
assert denied["executed"] is False
|
||||
@@ -0,0 +1,291 @@
|
||||
from dataclasses import replace
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src import browser_identity as browser
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority
|
||||
from src.agent_runtime.resources import BrowserSessionResource, BrowserPageResource, ResourceIdentityError, FilesystemRoot, FilesystemResource
|
||||
from src.agent_tools.web_tools import PrivateBrowserTool
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
from tests.test_runtime_resource_integration import approval_for, dispatch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def producer(tmp_path, monkeypatch):
|
||||
root = tmp_path / "release"
|
||||
root.mkdir()
|
||||
binary = root / "agent-browser-linux-x64"
|
||||
binary.write_bytes(b"explicit trusted fake producer")
|
||||
binary.chmod(0o755)
|
||||
checksum = hashlib.sha256(binary.read_bytes()).hexdigest()
|
||||
monkeypatch.setattr(browser, "PRODUCER_ROOT", root)
|
||||
monkeypatch.setattr(browser, "PRODUCER_HASHES", {"linux-x64": checksum})
|
||||
monkeypatch.setattr(browser, "STATE_ROOT", tmp_path / "private")
|
||||
monkeypatch.setattr(browser, "_REGISTRY", {})
|
||||
monkeypatch.setattr(ProcessIdentity, "owned", lambda self: True)
|
||||
state = SimpleNamespace(pid=4321, guid="12345678-1234-1234-1234-123456789abc", loader="loader-original",
|
||||
target="A" * 32, label=None, active=True, version="0.35.0", launches=False, calls=[], cdp_calls=[], raw_calls=[])
|
||||
async def run(argv, **kwargs):
|
||||
state.raw_calls.append(argv)
|
||||
return "agent-browser " + state.version, ""
|
||||
monkeypatch.setattr(browser, "run_client", run)
|
||||
monkeypatch.setattr(browser.platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr(browser.platform, "machine", lambda: "x86_64")
|
||||
monkeypatch.setattr(browser, "observe", lambda pid, facts: SimpleNamespace(
|
||||
identity=ProcessIdentity(state.pid, "frozen:" + str(state.pid), state.pid), facts=binary))
|
||||
class Sidecar:
|
||||
def __init__(self, url):
|
||||
browser.browser_digest(url)
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): pass
|
||||
async def call(self, method, params=None, session_id=None):
|
||||
assert method in browser.CDP_METHODS
|
||||
state.cdp_calls.append(method)
|
||||
if method == "Target.getTargets":
|
||||
return {"targetInfos": [{"targetId": state.target, "type": "page"}]}
|
||||
if method == "Target.getTargetInfo":
|
||||
return {"targetInfo": {"targetId": state.target, "type": "page"}}
|
||||
if method == "Target.attachToTarget": return {"sessionId": "observation-only"}
|
||||
if method == "Page.getFrameTree": return {"frameTree": {"frame": {"id": state.target, "loaderId": state.loader}}}
|
||||
return {}
|
||||
monkeypatch.setattr(browser, "CDPSidecar", Sidecar)
|
||||
async def command(record, *args):
|
||||
state.calls.append(args)
|
||||
lifecycle = {"launched": state.launches, "relaunchedBrowser": False, "restartedBackground": False}
|
||||
if args[:2] == ("session", "info"):
|
||||
return {"active": state.active, "version": state.version, "pid": state.pid, "session": record.key,
|
||||
"socketDir": record.env["AGENT_BROWSER_SOCKET_DIR"], "namespace": None, "runtimeError": None,
|
||||
"runtime": {"backgroundPid": state.pid, "session": record.key, "engine": "chrome", "browserLaunched": True,
|
||||
"compatibilityStatus": "current", "socketDir": record.env["AGENT_BROWSER_SOCKET_DIR"], "restoreKey": None}}
|
||||
if args == ("get", "cdp-url"):
|
||||
return {"cdpUrl": "ws://127.0.0.1:12345/devtools/browser/" + state.guid, "lifecycle": lifecycle}
|
||||
if args == ("tab", "list"):
|
||||
return {"tabs": [{"tabId": "t1", "targetId": state.target, "label": state.label, "title": "metadata",
|
||||
"url": "https://same.example", "type": "page", "active": True}]}
|
||||
pytest.fail("Page command reached the producer")
|
||||
monkeypatch.setattr(browser.RegisteredBrowser, "command", command)
|
||||
return state
|
||||
|
||||
|
||||
async def observed(producer):
|
||||
record = await browser.register_producer("alice", "thread")
|
||||
await browser.observe_registered(record)
|
||||
return record
|
||||
|
||||
|
||||
def authority():
|
||||
return RequestAuthority("request", "alice", "thread", "", (OperationGrant("private_browser"),))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", sorted(browser.PAGE_ACTIONS | {"close"}))
|
||||
async def test_disabled_page_operations_never_observe_select_or_execute(producer, action):
|
||||
record = await observed(producer)
|
||||
old = record.pages[0]
|
||||
producer.target, producer.loader = "B" * 32, "replacement-document"
|
||||
record.pin_armed_for = record.session.observation.session_incarnation # Still not a producer capability.
|
||||
producer.calls.clear(); producer.cdp_calls.clear()
|
||||
result = await PrivateBrowserTool().execute(json.dumps({"action": action, "page": "t1"}),
|
||||
{"owner": "alice", "session_id": "thread"})
|
||||
assert result["failure_kind"] == browser.PAGE_FAILURE
|
||||
assert result["executed"] is False and result["retryable"] is False
|
||||
assert producer.calls == producer.cdp_calls == []
|
||||
assert old.target_id != producer.target
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [{"action": "batch", "commands": [["click", "@e1"]]},
|
||||
{"action": "tab"}, {"action": "window"}, {"action": "frame"}, {"action": "connect"},
|
||||
{"action": "click", "target": "--new-tab"}, {"action": "evaluate", "--cdp": "endpoint"},
|
||||
{"action": "click", "targetId": "A" * 32}, {"action": "open", "label": "unsafe"},
|
||||
{"action": "open", "provider": "remote"}, {"action": "open", "profile": "private"},
|
||||
{"action": "open", "state": "private"}, {"action": "open", "session-name": "other"},
|
||||
{"action": "open", "config": "other"}])
|
||||
async def test_raw_model_escapes_never_spawn(producer, args):
|
||||
result = await PrivateBrowserTool().execute(json.dumps(args), {})
|
||||
assert result["executed"] is False
|
||||
assert producer.raw_calls == producer.calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page", ["t0", "t01", "t-1", "current", "title", "label", "A" * 32, 0, None])
|
||||
def test_alias_validation(page):
|
||||
with pytest.raises(ValueError): browser.parse_operation(json.dumps({"action": "click", "page": page}))
|
||||
|
||||
|
||||
async def test_observation_serializes_no_guid_or_control_url(producer):
|
||||
record = await observed(producer)
|
||||
page = record.pages[0]
|
||||
payload = json.dumps(page.to_dict())
|
||||
assert producer.guid not in payload and "devtools/browser" not in payload
|
||||
assert BrowserPageResource.from_dict(page.to_dict()) == page
|
||||
assert page.target_id == "A" * 32 and page.loader_id == producer.loader
|
||||
assert record.pin_armed_for is None
|
||||
assert not any("pin-tab" in str(c) for c in producer.calls)
|
||||
assert "Target.detachFromTarget" in producer.cdp_calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [("pid", 5678), ("guid", "87654321-1234-1234-1234-123456789abc")])
|
||||
async def test_session_replacement_invalidates_every_old_observation(producer, field, value):
|
||||
record = await observed(producer)
|
||||
old, page = record.session, record.pages[0]
|
||||
record.pin_armed_for = old.observation.session_incarnation
|
||||
setattr(producer, field, value)
|
||||
await browser.observe_registered(record)
|
||||
assert record.session != old and record.pin_armed_for is None
|
||||
with pytest.raises(ValueError): old.validate()
|
||||
with pytest.raises(ValueError): page.validate()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [("label", "A" * 32), ("loader", ""), ("active", False),
|
||||
("version", "0.27.0"), ("version", "0.36.0"), ("launches", True)])
|
||||
async def test_bad_producer_observation_fails_closed(producer, field, value):
|
||||
record = await observed(producer)
|
||||
setattr(producer, field, value)
|
||||
with pytest.raises(ValueError): await browser.observe_registered(record)
|
||||
assert record.session is None and record.pages == ()
|
||||
|
||||
|
||||
async def test_replacing_same_url_page_or_loader_invalidates_document(producer):
|
||||
record = await observed(producer)
|
||||
old = record.pages[0]
|
||||
producer.loader = "new-loader"
|
||||
await browser.observe_registered(record)
|
||||
with pytest.raises(ValueError): old.validate()
|
||||
document = record.pages[0]
|
||||
producer.target = "C" * 32
|
||||
await browser.observe_registered(record)
|
||||
with pytest.raises(ValueError): document.validate()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["0.27.0", "0.36.0", "", "0.35.0-extra"])
|
||||
async def test_exact_producer_version_gate(producer, version):
|
||||
producer.version = version
|
||||
with pytest.raises(ValueError): await browser.trusted_producer()
|
||||
|
||||
|
||||
async def test_binary_hash_gate_does_not_search_path_or_npx(producer):
|
||||
(browser.PRODUCER_ROOT / "agent-browser-linux-x64").write_bytes(b"replacement")
|
||||
with pytest.raises(ValueError): await browser.trusted_producer()
|
||||
assert producer.raw_calls == []
|
||||
assert PrivateBrowserTool._local_agent_browser_binary() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ['{}', '{"success":true}', '{"success":1,"data":{}}',
|
||||
'{"success":true,"data":{},"extra":1}', '{"success":true,"data":{},"success":false}',
|
||||
'{"success":true,"data":{},"error":"secret"}', 'not-json'])
|
||||
def test_strict_response_schema(raw):
|
||||
with pytest.raises(ValueError): browser.response(raw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["ws://127.0.0.1:123/devtools/browser", "ws://evil:123/devtools/browser/12345678-1234-1234-1234-123456789abc",
|
||||
"http://127.0.0.1:123/devtools/browser/12345678-1234-1234-1234-123456789abc", "ws://127.0.0.1:99999/devtools/browser/12345678-1234-1234-1234-123456789abc"])
|
||||
def test_endpoint_validation_does_not_leak_capability(url):
|
||||
with pytest.raises(ValueError) as failure: browser.browser_digest(url)
|
||||
assert url not in str(failure.value)
|
||||
|
||||
|
||||
async def test_environment_config_and_cwd_are_server_owned(producer, monkeypatch):
|
||||
monkeypatch.setenv("AGENT_BROWSER_CDP", "untrusted")
|
||||
monkeypatch.setenv("AGENT_BROWSER_CONFIG", "untrusted")
|
||||
record = await observed(producer)
|
||||
assert record.env == browser.owned_environment(record.cwd, record.key)
|
||||
assert record.cwd.is_relative_to(browser.STATE_ROOT)
|
||||
assert record.config.read_text() == "{}"
|
||||
record.config.write_text('{"cdp":"remote"}')
|
||||
with pytest.raises(ValueError): record.validate_config()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alias", ["direct", "symlink", "hardlink"])
|
||||
async def test_browser_control_state_is_not_user_filesystem(producer, tmp_path, alias):
|
||||
record = await observed(producer)
|
||||
target = record.config
|
||||
if alias != "direct":
|
||||
target = tmp_path / "alias"
|
||||
(os.link(record.config, target) if alias == "hardlink" else target.symlink_to(record.config))
|
||||
with pytest.raises(ValueError): FilesystemResource.resolve(FilesystemRoot.seal(tmp_path), str(target))
|
||||
from src.agent_runtime.process_resources import guard_launch_workspace
|
||||
with pytest.raises(ValueError): guard_launch_workspace(FilesystemRoot.seal(tmp_path))
|
||||
|
||||
|
||||
async def test_session_metadata_exact_approval_first_use_and_replay(producer):
|
||||
await observed(producer)
|
||||
original = authority()
|
||||
content = '{"action":"session_info"}'
|
||||
approval = approval_for(original, "private_browser", content)
|
||||
assert approval.pending.browser_operation.session == original.browser_sessions[0]
|
||||
restored = replace(original, grants=(), browser_sessions=(), browser_pages=(), backend_resources=())
|
||||
_, first = await dispatch(restored, "private_browser", content, approval)
|
||||
assert first["exit_code"] == 0
|
||||
assert "https://same.example" not in first["output"]
|
||||
_, replay = await dispatch(restored, "private_browser", content, approval)
|
||||
assert replay["exit_code"] == 1
|
||||
assert restored.browser_sessions == restored.browser_pages == ()
|
||||
|
||||
|
||||
async def test_page_approval_cannot_enable_unsupported_operations(producer):
|
||||
await observed(producer)
|
||||
original = authority()
|
||||
content = '{"action":"click","page":"t1","ref":"e1"}'
|
||||
approval = approval_for(original, "private_browser", content)
|
||||
assert approval.pending.browser_operation.page.loader_id == producer.loader
|
||||
producer.calls.clear(); producer.cdp_calls.clear()
|
||||
restored = replace(original, grants=(), browser_sessions=(), browser_pages=())
|
||||
_, denied = await dispatch(restored, "private_browser", content, approval)
|
||||
assert denied["failure_kind"] == browser.PAGE_FAILURE and denied["executed"] is False
|
||||
assert not approval._claimed and producer.calls == producer.cdp_calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [("owner", "bob"), ("request_id", "other"), ("session_id", "other")])
|
||||
async def test_browser_approval_application_binding_is_exact(producer, field, value):
|
||||
await observed(producer)
|
||||
original = authority()
|
||||
content = '{"action":"session_info"}'
|
||||
approval = approval_for(original, "private_browser", content)
|
||||
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=())
|
||||
_, result = await dispatch(changed, "private_browser", content, approval)
|
||||
assert result["exit_code"] == 1 and not approval._claimed
|
||||
|
||||
|
||||
async def test_page_child_cannot_acquire_session_scope_or_new_document(producer):
|
||||
record = await observed(producer)
|
||||
original = replace(authority(), browser_sessions=())
|
||||
child = original.intersect(authority())
|
||||
assert child.browser_sessions == () and child.browser_pages == original.browser_pages
|
||||
with pytest.raises(ValueError): browser.resolve_browser_operation(child, ExactOperation.normalize("private_browser", '{"action":"session_info"}'))
|
||||
producer.loader = "replacement"
|
||||
await browser.observe_registered(record)
|
||||
with pytest.raises(ValueError): original.intersect(authority())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", ["success", "exception", "cancel", "nested"])
|
||||
async def test_browser_context_restoration(producer, phase):
|
||||
await observed(producer)
|
||||
bound = browser.resolve_browser_operation(authority(), ExactOperation.normalize("private_browser", '{"action":"session_info"}'))
|
||||
try:
|
||||
with browser.bind_browser_operation(bound):
|
||||
if phase == "exception": raise RuntimeError()
|
||||
if phase == "cancel": raise asyncio.CancelledError()
|
||||
if phase == "nested":
|
||||
with browser.bind_browser_operation(None): assert browser._ACTIVE.get() is None
|
||||
assert browser._ACTIVE.get() is bound
|
||||
except (RuntimeError, asyncio.CancelledError): pass
|
||||
assert browser._ACTIVE.get() is None
|
||||
|
||||
|
||||
async def test_legacy_restoration_does_not_discover_browser_scopes(producer):
|
||||
await observed(producer)
|
||||
data = authority().to_dict()
|
||||
data["version"] = 4
|
||||
del data["browser_sessions"], data["browser_pages"]
|
||||
restored = RequestAuthority.from_dict(data)
|
||||
assert restored.browser_sessions == restored.browser_pages == ()
|
||||
|
||||
|
||||
def test_lookup_does_not_create_legacy_or_missing_session(producer):
|
||||
assert browser.registered("alice", "thread") is None
|
||||
assert authority().browser_sessions == ()
|
||||
assert browser._REGISTRY == {} and producer.raw_calls == []
|
||||
@@ -21,5 +21,6 @@ def test_screenshot_cannot_overwrite_nonimage_artifact(monkeypatch, tmp_path, na
|
||||
{"session_id": "artifact-safety"},
|
||||
))
|
||||
assert result["exit_code"] == 1
|
||||
assert "OUTPUT destination" in result["error"]
|
||||
assert result["failure_kind"] == "browser_page_authority_unavailable"
|
||||
assert result["executed"] is False
|
||||
assert source.read_bytes() == b"original artifact"
|
||||
|
||||
@@ -59,7 +59,7 @@ async def test_stream_recovers_navigation_then_fetch_without_email_classifier(mo
|
||||
return {'tool_calls': [{'index': 0, 'id': name, 'type': 'function',
|
||||
'function': {'name': name, 'arguments': json.dumps(args)}}]}
|
||||
responses = iter([
|
||||
call('private_browser', {'action': 'batch', 'commands': [['open', URL], ['find', 'wardrobe'], ['snapshot']]}),
|
||||
call('private_browser', {'action': 'open', 'url': URL}),
|
||||
call('web_fetch', {'url': URL}),
|
||||
call('web_search', {'query': 'wardrobe'}),
|
||||
{'content': 'The site could not be read and no usable product evidence was found.'},
|
||||
|
||||
@@ -3392,7 +3392,7 @@ def test_skill_update_alias_normalizes_to_edit_before_policy():
|
||||
assert args['action'] == 'edit'
|
||||
|
||||
|
||||
def test_private_browser_open_normalizes_to_atomic_snapshot_batch():
|
||||
def test_private_browser_open_never_creates_an_internal_batch():
|
||||
tool, args = normalize_preview_function_args(
|
||||
'private_browser',
|
||||
{'action': 'open', 'url': 'https://example.com', 'timeout_ms': 12000},
|
||||
@@ -3400,8 +3400,8 @@ def test_private_browser_open_normalizes_to_atomic_snapshot_batch():
|
||||
|
||||
assert tool == 'private_browser'
|
||||
assert args == {
|
||||
'action': 'batch',
|
||||
'commands': [['open', 'https://example.com'], ['snapshot']],
|
||||
'action': 'open',
|
||||
'url': 'https://example.com',
|
||||
'timeout_ms': 12000,
|
||||
}
|
||||
|
||||
@@ -3495,7 +3495,7 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
|
||||
'chat_with_model': ({'model': 'qwen', 'message': 'hello'}, 'ask model qwen to answer hello'),
|
||||
'pipeline': ({'steps': [{'model': 'qwen', 'instruction': 'draft'}]}, 'run a model pipeline to draft'),
|
||||
'pdf_extract': ({'url': 'https://example.com/x.pdf', 'query': 'metric'}, 'read this pdf'),
|
||||
'private_browser': ({'action': 'batch', 'commands': [['open', 'https://example.com'], ['snapshot']]}, 'use the private browser'),
|
||||
'private_browser': ({'action': 'session_info'}, 'use the private browser'),
|
||||
'read_email': ({'uid': '1'}, 'read my email'),
|
||||
'reply_to_email': ({'uid': '1', 'body': 'Thanks'}, 'reply to email UID 1 saying Thanks'),
|
||||
'search_chats': ({'query': 'project'}, 'search my chats'),
|
||||
@@ -4322,23 +4322,19 @@ def test_compact_browser_distinguishes_element_refs_from_keyboard_keys():
|
||||
original = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'private_browser')
|
||||
browser = compact_schemas([original])[0]['function']
|
||||
assert 'fill/click/press' not in browser['description']
|
||||
assert 'key' in browser['description'] and 'Enter' in browser['description']
|
||||
assert 'focused' in browser['parameters']['properties']['key']['description']
|
||||
assert 'unavailable' in browser['description']
|
||||
assert 'commands' not in browser['parameters']['properties']
|
||||
assert set(browser['parameters']['properties']) == set(original['function']['parameters']['properties'])
|
||||
|
||||
|
||||
def test_v3_browser_batch_schema_matches_executor_sequence_contract():
|
||||
def test_v3_browser_schema_does_not_offer_batch_or_current_tab_authority():
|
||||
browser = next(
|
||||
schema for schema in compact_schemas(FUNCTION_TOOL_SCHEMAS)
|
||||
if schema['function']['name'] == 'private_browser'
|
||||
)['function']
|
||||
commands = browser['parameters']['properties']['commands']
|
||||
|
||||
assert commands['items']['type'] == 'array'
|
||||
assert commands['items']['items'] == {'type': 'string'}
|
||||
assert '[["open"' in commands['description']
|
||||
assert 'snapshot' in browser['description']
|
||||
assert 'does not search the site' in browser['description']
|
||||
assert 'commands' not in browser['parameters']['properties']
|
||||
assert 'batch' not in browser['parameters']['properties']['action']['enum']
|
||||
assert 'unavailable' in browser['description']
|
||||
|
||||
|
||||
def test_v3_browser_target_fields_preserve_selector_semantics():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") == []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
+32
-1446
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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) == ()
|
||||
@@ -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"}'
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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'}
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user