mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-10 17:02:20 +02:00
feat(runtime): bind process and job resources to authority
This commit is contained in:
@@ -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,215 @@
|
|||||||
|
# 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, including its owned subprocess handle. 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.
|
||||||
|
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.
|
||||||
@@ -405,7 +405,20 @@ def _append_local_ollama_download_command_lines(
|
|||||||
|
|
||||||
|
|
||||||
def setup_cookbook_routes() -> APIRouter:
|
def setup_cookbook_routes() -> APIRouter:
|
||||||
router = APIRouter(tags=["cookbook"])
|
async def protect_native_control(request: Request):
|
||||||
|
if request.method in {"GET", "HEAD"}:
|
||||||
|
return
|
||||||
|
# Cookbook's UI records and session strings are not an application
|
||||||
|
# process registry. No loopback caller can use them as local authority.
|
||||||
|
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"):
|
||||||
|
_require_admin(request)
|
||||||
|
router = APIRouter(tags=["cookbook"], dependencies=[Depends(protect_native_control)])
|
||||||
_cookbook_state_path = Path(COOKBOOK_STATE_FILE)
|
_cookbook_state_path = Path(COOKBOOK_STATE_FILE)
|
||||||
_state_get_cache = {"ts": 0.0, "mtime": 0.0, "value": None}
|
_state_get_cache = {"ts": 0.0, "mtime": 0.0, "value": None}
|
||||||
_tasks_status_cache = {"ts": 0.0, "value": None}
|
_tasks_status_cache = {"ts": 0.0, "value": None}
|
||||||
|
|||||||
+7
-11
@@ -59,21 +59,17 @@ from core.platform_compat import (
|
|||||||
def _require_admin(request: Request):
|
def _require_admin(request: Request):
|
||||||
"""Reject non-admin callers. Shell exec is admin-only — never expose to
|
"""Reject non-admin callers. Shell exec is admin-only — never expose to
|
||||||
regular users; that's RCE-after-signup."""
|
regular users; that's RCE-after-signup."""
|
||||||
# In the explicitly single-user, auth-disabled deployment the middleware
|
# Anonymous loopback is also reachable from an admitted native workload.
|
||||||
# does not attach a current user. AuthManager is still instantiated by the
|
# It cannot be treated as a human admin or as process creation authority.
|
||||||
# app, so checking only for its presence incorrectly returns 403 here.
|
from src.agent_runtime.authority import is_internal_tool_request
|
||||||
|
if is_internal_tool_request(request):
|
||||||
|
raise HTTPException(403, "Internal shell execution requires a dedicated resource-bound producer")
|
||||||
if _auth_disabled():
|
if _auth_disabled():
|
||||||
return
|
raise HTTPException(403, "Anonymous native process control has no resource authority")
|
||||||
auth_manager = getattr(request.app.state, "auth_manager", None)
|
auth_manager = getattr(request.app.state, "auth_manager", None)
|
||||||
if not auth_manager:
|
if not auth_manager:
|
||||||
# No auth at all — only safe in fully-trusted localhost dev mode
|
raise HTTPException(403, "Native process control requires authenticated administration")
|
||||||
return
|
|
||||||
user = getattr(request.state, "current_user", None)
|
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":
|
if not user or user == "api":
|
||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
if not auth_manager.is_admin(user):
|
if not auth_manager.is_admin(user):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from src.agent_runtime.resources import (
|
from src.agent_runtime.resources import (
|
||||||
FilesystemRoot, ExternalResource, NativeBackendResource, OwnedScope,
|
FilesystemRoot, ExternalResource, NativeBackendResource, OwnedScope,
|
||||||
|
ProcessLaunchScope, ProcessResource, BackgroundJobResource,
|
||||||
backend_from_dict, intersect_roots, seal_owned_scopes,
|
backend_from_dict, intersect_roots, seal_owned_scopes,
|
||||||
)
|
)
|
||||||
from src.tool_policy import ToolPolicy, build_effective_tool_policy
|
from src.tool_policy import ToolPolicy, build_effective_tool_policy
|
||||||
@@ -120,6 +121,9 @@ class RequestAuthority:
|
|||||||
resource_roots: tuple[FilesystemRoot, ...] | None = None
|
resource_roots: tuple[FilesystemRoot, ...] | None = None
|
||||||
backend_resources: tuple[ExternalResource | NativeBackendResource, ...] | None = None
|
backend_resources: tuple[ExternalResource | NativeBackendResource, ...] | None = None
|
||||||
owned_scopes: tuple[OwnedScope, ...] | None = None
|
owned_scopes: tuple[OwnedScope, ...] | None = None
|
||||||
|
launch_scopes: tuple[ProcessLaunchScope, ...] | None = None
|
||||||
|
process_resources: tuple[ProcessResource, ...] = ()
|
||||||
|
job_resources: tuple[BackgroundJobResource, ...] | None = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if (not isinstance(self.request_id, str) or not self.request_id
|
if (not isinstance(self.request_id, str) or not self.request_id
|
||||||
@@ -156,11 +160,29 @@ class RequestAuthority:
|
|||||||
or any(not isinstance(s, OwnedScope) or (s.owner, s.thread_id) != (self.owner, self.session_id)
|
or any(not isinstance(s, OwnedScope) or (s.owner, s.thread_id) != (self.owner, self.session_id)
|
||||||
for s in self.owned_scopes)):
|
for s in self.owned_scopes)):
|
||||||
raise ValueError("Malformed backend or owned resource scope")
|
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")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def empty(cls, *, owner=None, session_id=None, workspace=None):
|
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=())
|
resource_roots=(), backend_resources=(), owned_scopes=(), launch_scopes=(), job_resources=())
|
||||||
|
|
||||||
def bound_to(self, *, owner=None, session_id=None, workspace=None):
|
def bound_to(self, *, owner=None, session_id=None, workspace=None):
|
||||||
return (self.owner == _owner(owner) and self.session_id == str(session_id or "")
|
return (self.owner == _owner(owner) and self.session_id == str(session_id or "")
|
||||||
@@ -188,6 +210,7 @@ class RequestAuthority:
|
|||||||
roots = ()
|
roots = ()
|
||||||
backends = ()
|
backends = ()
|
||||||
owned = ()
|
owned = ()
|
||||||
|
launches = processes = jobs = ()
|
||||||
if (self.owner, self.session_id, self.workspace) == (child.owner, child.session_id, child.workspace):
|
if (self.owner, self.session_id, self.workspace) == (child.owner, child.session_id, child.workspace):
|
||||||
theirs = {g.tool: g for g in child.grants}
|
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]
|
grants = [g.intersect(theirs[g.tool]) for g in self.grants if g.tool in theirs]
|
||||||
@@ -195,10 +218,15 @@ class RequestAuthority:
|
|||||||
backends = tuple(r for r in self.backend_resources if r in child.backend_resources)
|
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
|
owned = tuple(s for left in self.owned_scopes for right in child.owned_scopes
|
||||||
if (s := left.intersect(right)) is not None)
|
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)
|
||||||
return replace(self, grants=tuple(grants), denied=self.denied | child.denied,
|
return replace(self, grants=tuple(grants), denied=self.denied | child.denied,
|
||||||
block_all=self.block_all or child.block_all,
|
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)
|
resource_roots=roots, backend_resources=backends, owned_scopes=owned,
|
||||||
|
launch_scopes=launches, process_resources=processes, job_resources=jobs)
|
||||||
|
|
||||||
def continuation(self, *, owner=None, session_id=None):
|
def continuation(self, *, owner=None, session_id=None):
|
||||||
"""A server continuation may rebind a session, never change owner/grants."""
|
"""A server continuation may rebind a session, never change owner/grants."""
|
||||||
@@ -206,10 +234,12 @@ class RequestAuthority:
|
|||||||
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
||||||
rebound = str(session_id or "")
|
rebound = str(session_id or "")
|
||||||
return replace(self, session_id=rebound, inherited=True,
|
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 ())
|
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))
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {"version": 3, "request_id": self.request_id, "owner": self.owner,
|
return {"version": 4, "request_id": self.request_id, "owner": self.owner,
|
||||||
"session_id": self.session_id, "workspace": self.workspace,
|
"session_id": self.session_id, "workspace": self.workspace,
|
||||||
"grants": [{"tool": g.tool,
|
"grants": [{"tool": g.tool,
|
||||||
"actions": None if g.actions is None else sorted(g.actions),
|
"actions": None if g.actions is None else sorted(g.actions),
|
||||||
@@ -218,12 +248,15 @@ class RequestAuthority:
|
|||||||
"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],
|
"resource_roots": [r.to_dict() for r in self.resource_roots],
|
||||||
"backend_resources": [r.to_dict() for r in self.backend_resources],
|
"backend_resources": [r.to_dict() for r in self.backend_resources],
|
||||||
"owned_scopes": [s.to_dict() for s in self.owned_scopes]}
|
"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]}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, value):
|
def from_dict(cls, value):
|
||||||
if (not isinstance(value, dict) or type(value.get("version")) is not int
|
if (not isinstance(value, dict) or type(value.get("version")) is not int
|
||||||
or value["version"] not in {1, 2, 3}):
|
or value["version"] not in {1, 2, 3, 4}):
|
||||||
raise ValueError("Unsupported authority snapshot")
|
raise ValueError("Unsupported authority snapshot")
|
||||||
def limits(value):
|
def limits(value):
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -234,8 +267,12 @@ class RequestAuthority:
|
|||||||
roots = value["resource_roots"] if value["version"] >= 2 else []
|
roots = value["resource_roots"] if value["version"] >= 2 else []
|
||||||
if not isinstance(roots, list):
|
if not isinstance(roots, list):
|
||||||
raise ValueError("Malformed request resource snapshot")
|
raise ValueError("Malformed request resource snapshot")
|
||||||
backends = value["backend_resources"] if value["version"] == 3 else []
|
backends = value["backend_resources"] if value["version"] >= 3 else []
|
||||||
owned = value["owned_scopes"] 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):
|
if not isinstance(backends, list) or not isinstance(owned, list):
|
||||||
raise ValueError("Malformed request resource scope snapshot")
|
raise ValueError("Malformed request resource scope snapshot")
|
||||||
return cls(value["request_id"], value["owner"], value["session_id"], value["workspace"],
|
return cls(value["request_id"], value["owner"], value["session_id"], value["workspace"],
|
||||||
@@ -243,7 +280,10 @@ class RequestAuthority:
|
|||||||
for g in value["grants"]), limits(value["denied"]),
|
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(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(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"]))
|
||||||
|
|
||||||
|
|
||||||
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
||||||
@@ -462,7 +502,10 @@ def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authori
|
|||||||
workspace=parent.workspace,
|
workspace=parent.workspace,
|
||||||
resource_roots=parent.resource_roots,
|
resource_roots=parent.resource_roots,
|
||||||
backend_resources=parent.backend_resources,
|
backend_resources=parent.backend_resources,
|
||||||
owned_scopes=parent.owned_scopes))
|
owned_scopes=parent.owned_scopes,
|
||||||
|
launch_scopes=parent.launch_scopes,
|
||||||
|
process_resources=parent.process_resources,
|
||||||
|
job_resources=parent.job_resources))
|
||||||
return _json({"task_input": [prompt, task_type, action], "authority": authority.to_dict()})
|
return _json({"task_input": [prompt, task_type, action], "authority": authority.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
@@ -479,18 +522,27 @@ def restore_task_authority(snapshot, prompt, task_type, action, *, owner=None, s
|
|||||||
def _background_path(job_id):
|
def _background_path(job_id):
|
||||||
if not isinstance(job_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", 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")
|
raise ValueError("Invalid background authority identity")
|
||||||
from src.constants import BG_JOBS_DIR
|
from src.bg_jobs import _JOBS_DIR
|
||||||
return Path(BG_JOBS_DIR) / (job_id + ".authority.json")
|
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
|
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):
|
def restore_background_authority(job_id, *, owner=None, session_id=None):
|
||||||
try:
|
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 ""):
|
if authority.session_id != str(session_id or ""):
|
||||||
raise ValueError("Background session changed")
|
raise ValueError("Background session changed")
|
||||||
return authority.continuation(owner=owner, session_id=session_id)
|
return authority.continuation(owner=owner, session_id=session_id)
|
||||||
|
|||||||
@@ -257,7 +257,8 @@ def needs_owned_binding(operation):
|
|||||||
raise ResourceIdentityError("Unresolved internal resource selector")
|
raise ResourceIdentityError("Unresolved internal resource selector")
|
||||||
path = posixpath.normpath(urlsplit(path).path)
|
path = posixpath.normpath(urlsplit(path).path)
|
||||||
private = {"document", "documents", "session", "sessions", "history", "chat", "chats",
|
private = {"document", "documents", "session", "sessions", "history", "chat", "chats",
|
||||||
"notes", "memory", "vault", "upload", "uploads", "attachments"}
|
"notes", "memory", "vault", "upload", "uploads", "attachments",
|
||||||
|
"shell", "model", "cookbook"}
|
||||||
segments = path.strip("/").split("/")
|
segments = path.strip("/").split("/")
|
||||||
if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private:
|
if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private:
|
||||||
raise ResourceIdentityError("Owned records require a dedicated resource-bound tool")
|
raise ResourceIdentityError("Owned records require a dedicated resource-bound tool")
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""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
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
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.
|
||||||
|
for resource in (*parent, *child):
|
||||||
|
validate(resource)
|
||||||
|
return tuple(resource for resource in parent if resource in 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))
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
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()
|
||||||
|
guard_launch_workspace(self.launch.scope.root)
|
||||||
|
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()
|
||||||
|
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.agent_runtime.resources import _control_plane_path
|
||||||
|
control = (Path(bg_jobs._STORE), Path(bg_jobs._JOBS_DIR), containment._store_path(), _LAUNCH_DIR,
|
||||||
|
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
|
||||||
|
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) and _control_plane_path(str(path.resolve())):
|
||||||
|
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")
|
||||||
|
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 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)):
|
||||||
|
if record.get(pid_key):
|
||||||
|
processes.append(ProcessResource("native:containment", launch.owner, launch.request_id,
|
||||||
|
launch.thread_id, ProcessIdentity(record[pid_key], record.get(token_key), 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
|
||||||
+155
-12
@@ -37,7 +37,10 @@ def _control_plane_path(path):
|
|||||||
"SETTINGS_FILE", "SESSIONS_FILE", "USER_PREFS_FILE", "VAULT_FILE",
|
"SETTINGS_FILE", "SESSIONS_FILE", "USER_PREFS_FILE", "VAULT_FILE",
|
||||||
"SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB", "MEMORY_FILE", "INTEGRATIONS_FILE",
|
"SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB", "MEMORY_FILE", "INTEGRATIONS_FILE",
|
||||||
)}
|
)}
|
||||||
job_dirs = {canonical_root(constants.BG_JOBS_DIR)}
|
job_dirs = {canonical_root(constants.BG_JOBS_DIR), canonical_root(constants.PROCESS_RESOURCES_DIR)}
|
||||||
|
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.
|
# Producers may have configured paths different from the default constants.
|
||||||
# Inspect already-loaded server metadata without initializing a store here.
|
# Inspect already-loaded server metadata without initializing a store here.
|
||||||
bg = sys.modules.get("src.bg_jobs")
|
bg = sys.modules.get("src.bg_jobs")
|
||||||
@@ -275,25 +278,165 @@ def intersect_roots(parent, child):
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProcessResource:
|
class ProcessResource:
|
||||||
namespace: str
|
namespace: str
|
||||||
incarnation: str
|
|
||||||
owner: str
|
owner: str
|
||||||
pid: int
|
request_id: str
|
||||||
start_token: str
|
thread_id: str
|
||||||
|
identity: "ProcessIdentity"
|
||||||
|
role: str
|
||||||
job_id: str = ""
|
job_id: str = ""
|
||||||
containment_id: str = ""
|
containment_id: str = ""
|
||||||
namespace_pid: int | None = None
|
|
||||||
namespace_start_token: str = ""
|
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
for name in ("namespace", "incarnation", "owner", "start_token"):
|
from src.process_lifecycle import ProcessIdentity
|
||||||
|
for name in ("namespace", "request_id", "thread_id"):
|
||||||
_text(getattr(self, name), name)
|
_text(getattr(self, name), name)
|
||||||
for name in ("job_id", "containment_id", "namespace_start_token"):
|
for name in ("owner", "job_id", "containment_id"):
|
||||||
_text(getattr(self, name), name, optional=True)
|
_text(getattr(self, name), name, optional=True)
|
||||||
if (type(self.pid) is not int or self.pid <= 0
|
if (not isinstance(self.identity, ProcessIdentity)
|
||||||
or (self.namespace_pid is not None and
|
or type(self.identity.pid) is not int or self.identity.pid <= 0
|
||||||
(type(self.namespace_pid) is not int or self.namespace_pid <= 0))
|
or (self.identity.pgid is not None and (type(self.identity.pgid) is not int or self.identity.pgid <= 0))
|
||||||
or bool(self.namespace_pid) != bool(self.namespace_start_token)):
|
or self.role not in {"supervisor", "leader", "namespace_init", "manager", "pty", "service"}):
|
||||||
raise ValueError("Malformed process resource identity")
|
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)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -67,8 +67,20 @@ class ManageBgJobsTool:
|
|||||||
if not session_id:
|
if not session_id:
|
||||||
return {"error": "manage_bg_jobs: no active chat session; background jobs are scoped to a chat.", "exit_code": 1}
|
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:
|
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:
|
if not jobs:
|
||||||
return {"output": "No background jobs in this chat.", "exit_code": 0}
|
return {"output": "No background jobs in this chat.", "exit_code": 0}
|
||||||
jobs.sort(key=lambda r: r.get("started_at") or 0, reverse=True)
|
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 action in _OUTPUT_ACTIONS or action in _KILL_ACTIONS:
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return {"error": f"manage_bg_jobs: action '{action}' requires a job_id (see action='list').", "exit_code": 1}
|
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.
|
# Scope: only the chat that launched a job may see or control it.
|
||||||
if rec is None or rec.get("session_id") != session_id:
|
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}
|
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 action in _KILL_ACTIONS:
|
||||||
if rec.get("status") != "running":
|
if rec.get("status") != "running":
|
||||||
return {"output": f"Job `{job_id}` already {_status_label(rec)}; nothing to kill.", "exit_code": 0}
|
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"):
|
if not killed or not killed.get("killed"):
|
||||||
return {"error": f"Could not verify termination of background job `{job_id}`.",
|
return {"error": f"Could not verify termination of background job `{job_id}`.",
|
||||||
"exit_code": 1, "teardown": (killed or {}).get("teardown")}
|
"exit_code": 1, "teardown": (killed or {}).get("teardown")}
|
||||||
|
|||||||
@@ -511,26 +511,47 @@ async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, arg
|
|||||||
from src.tool_execution import agent_cwd, _truncate
|
from src.tool_execution import agent_cwd, _truncate
|
||||||
|
|
||||||
grant = None
|
grant = None
|
||||||
|
result = None
|
||||||
try:
|
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(
|
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),
|
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 containment.FILESYSTEM not in grant.enforced:
|
||||||
if argv:
|
if argv:
|
||||||
command = [*command[:-1], _replace_workspace_alias(command[-1], grant.workspace)]
|
command = [*command[:-1], _replace_workspace_alias(command[-1], grant.workspace)]
|
||||||
else:
|
else:
|
||||||
command = _replace_workspace_alias(command, grant.workspace)
|
command = _replace_workspace_alias(command, grant.workspace)
|
||||||
result = await containment.run(grant, command, argv=argv, progress_cb=ctx.get("progress_cb"))
|
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:
|
except containment.ContainmentUnavailable as exc:
|
||||||
return containment.unavailable_tool_result(exc, tool=tool)
|
return containment.unavailable_tool_result(exc, tool=tool)
|
||||||
except (OSError, RuntimeError, ValueError) as exc:
|
except (OSError, RuntimeError, ValueError) as exc:
|
||||||
boundary = grant.to_dict() if grant else {}
|
if grant is not None:
|
||||||
boundary["executed"] = bool(getattr(exc, "containment_executed", False))
|
record = containment._load_records().get(grant.id, {})
|
||||||
if not getattr(exc, "containment_established", False):
|
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=[])
|
boundary.update(contained=False, enforced=[])
|
||||||
return {"error": f"{tool}: execution failed: {exc}", "exit_code": 1,
|
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 {})}
|
||||||
|
|
||||||
boundary = result.grant.to_dict()
|
boundary = result.grant.to_dict()
|
||||||
boundary["executed"] = True
|
boundary["executed"] = True
|
||||||
@@ -590,6 +611,12 @@ class BashTool:
|
|||||||
),
|
),
|
||||||
"exit_code": 1,
|
"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):
|
if _ffmpeg_unicode_drawtext_needs_fontfile(content):
|
||||||
resolved_font = _resolve_fontfile_for_text(content)
|
resolved_font = _resolve_fontfile_for_text(content)
|
||||||
resolved_hint = (
|
resolved_hint = (
|
||||||
@@ -879,6 +906,12 @@ class PythonTool:
|
|||||||
),
|
),
|
||||||
"exit_code": 1,
|
"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:
|
if "/tmp/" in content:
|
||||||
isolated_tmp = _isolated_tmp_dir(agent_cwd())
|
isolated_tmp = _isolated_tmp_dir(agent_cwd())
|
||||||
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
|
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
|
||||||
|
|||||||
+76
-11
@@ -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,
|
A trusted detached supervisor owns the shared containment runner, output,
|
||||||
wall clock and exit metadata, independently of the request/server lifetime.
|
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)
|
_JOBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
job_id = uuid.uuid4().hex[:12]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
log_path = _JOBS_DIR / f"{job_id}.log"
|
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 import containment
|
||||||
from src.agent_tools.subprocess_tools import _owned_spec, _replace_workspace_alias
|
from src.agent_tools.subprocess_tools import _owned_spec, _replace_workspace_alias
|
||||||
spec = _owned_spec(cwd or os.getcwd(), env, max_runtime_s)
|
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}")
|
grant = containment.acquire(spec, owner=f"bg:{session_id}")
|
||||||
bounded_command = command
|
bounded_command = command
|
||||||
if containment.FILESYSTEM not in grant.enforced:
|
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"],
|
"start_token": process_ownership.capture(proc.pid)["start_token"],
|
||||||
}
|
}
|
||||||
try:
|
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,
|
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 = _load()
|
||||||
jobs[job_id] = rec
|
jobs[job_id] = rec
|
||||||
_save(jobs)
|
_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.
|
# The supervisor cannot execute until the identity and job record are durable.
|
||||||
proc.stdin.write(json.dumps(payload).encode("utf-8"))
|
proc.stdin.write(json.dumps(payload).encode("utf-8"))
|
||||||
proc.stdin.close()
|
proc.stdin.close()
|
||||||
except BaseException:
|
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)
|
proc.wait(timeout=5)
|
||||||
containment.release(grant, grace_s=0)
|
containment.release(grant, grace_s=0)
|
||||||
raise
|
raise
|
||||||
@@ -194,16 +226,20 @@ def _prune(jobs: Dict[str, Dict[str, Any]], now: float) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@store_transaction(lambda: _STORE)
|
@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.
|
"""Reconcile every running job against disk. Marks done/failed (incl.
|
||||||
timeout). Idempotent — safe to call from a poll loop. Returns the store."""
|
timeout). Idempotent — safe to call from a poll loop. Returns the store."""
|
||||||
jobs = _load()
|
jobs = _load()
|
||||||
for pid, proc in list(_LIVE_PROCS.items()):
|
for pid, proc in list(_LIVE_PROCS.items()):
|
||||||
|
if job_id is not None and pid != jobs.get(job_id, {}).get("pid"):
|
||||||
|
continue
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
_LIVE_PROCS.pop(pid, None)
|
_LIVE_PROCS.pop(pid, None)
|
||||||
changed = False
|
changed = False
|
||||||
now = time.time()
|
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":
|
if rec.get("status") != "running":
|
||||||
continue
|
continue
|
||||||
exit_path = Path(rec.get("exit_path", ""))
|
exit_path = Path(rec.get("exit_path", ""))
|
||||||
@@ -218,7 +254,15 @@ def refresh() -> Dict[str, Dict[str, Any]]:
|
|||||||
if rec.get("result_path"):
|
if rec.get("result_path"):
|
||||||
try:
|
try:
|
||||||
report = json.loads(Path(rec["result_path"]).read_text(encoding="utf-8"))
|
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):
|
except (OSError, ValueError):
|
||||||
rec["status"], rec["exit_code"] = "failed", 1
|
rec["status"], rec["exit_code"] = "failed", 1
|
||||||
rec["result_unavailable"] = True
|
rec["result_unavailable"] = True
|
||||||
@@ -243,7 +287,7 @@ def refresh() -> Dict[str, Dict[str, Any]]:
|
|||||||
rec["ended_at"] = now
|
rec["ended_at"] = now
|
||||||
rec["died"] = True
|
rec["died"] = True
|
||||||
changed = True
|
changed = True
|
||||||
if _prune(jobs, now):
|
if job_id is None and _prune(jobs, now):
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
_save(jobs)
|
_save(jobs)
|
||||||
@@ -288,28 +332,45 @@ def pending_followups() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
@store_transaction(lambda: _STORE)
|
@store_transaction(lambda: _STORE)
|
||||||
def mark_followed_up(job_id: str) -> None:
|
def mark_followed_up(job_id: str, *, expected) -> None:
|
||||||
jobs = _load()
|
jobs = _load()
|
||||||
if job_id in jobs:
|
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
|
jobs[job_id]["followed_up"] = True
|
||||||
_save(jobs)
|
_save(jobs)
|
||||||
|
|
||||||
|
|
||||||
def get(job_id: str) -> Optional[Dict[str, Any]]:
|
def peek(job_id: str) -> Optional[Dict[str, Any]]:
|
||||||
refresh() # reconcile against disk so status/exit_code are current
|
"""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)
|
rec = _load().get(job_id)
|
||||||
if rec:
|
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 = dict(rec)
|
||||||
rec["output"] = _read_output(rec)
|
rec["output"] = _read_output(rec)
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
def list_for_session(session_id: str) -> List[Dict[str, Any]]:
|
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]
|
return [r for r in _load().values() if r.get("session_id") == session_id]
|
||||||
|
|
||||||
|
|
||||||
@store_transaction(lambda: _STORE)
|
@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
|
"""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
|
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
|
finished is returned unchanged. Sets followed_up so the monitor does not also
|
||||||
@@ -318,6 +379,10 @@ def kill(job_id: str) -> Optional[Dict[str, Any]]:
|
|||||||
rec = jobs.get(job_id)
|
rec = jobs.get(job_id)
|
||||||
if rec is None:
|
if rec is None:
|
||||||
return 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":
|
if rec.get("status") == "running":
|
||||||
outcome = _kill_record(rec)
|
outcome = _kill_record(rec)
|
||||||
rec["teardown"] = outcome.to_dict()
|
rec["teardown"] = outcome.to_dict()
|
||||||
|
|||||||
+13
-1
@@ -140,6 +140,17 @@ async def _run_followup(rec: dict) -> bool:
|
|||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
authority = restore_background_authority(
|
authority = restore_background_authority(
|
||||||
rec["id"], owner=getattr(sess, "owner", None), session_id=sess.id)
|
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 False
|
||||||
|
except (ValueError, TypeError, OSError, RuntimeError):
|
||||||
|
return False
|
||||||
authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ())
|
authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ())
|
||||||
full, tool_events = await _drain_agent(sess, context, request_authority=authority)
|
full, tool_events = await _drain_agent(sess, context, request_authority=authority)
|
||||||
|
|
||||||
@@ -169,7 +180,8 @@ async def _loop():
|
|||||||
for rec in bg_jobs.pending_followups():
|
for rec in bg_jobs.pending_followups():
|
||||||
try:
|
try:
|
||||||
if await _run_followup(rec):
|
if await _run_followup(rec):
|
||||||
bg_jobs.mark_followed_up(rec["id"])
|
from src.agent_runtime.process_resources import job_from_record
|
||||||
|
bg_jobs.mark_followed_up(rec["id"], expected=job_from_record(rec))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Idempotent: leave followed_up=False so the next tick retries.
|
# Idempotent: leave followed_up=False so the next tick retries.
|
||||||
logger.warning("bg-followup failed for %s (will retry): %s", rec.get("id"), e)
|
logger.warning("bg-followup failed for %s (will retry): %s", rec.get("id"), e)
|
||||||
|
|||||||
+21
-15
@@ -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]:
|
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
|
"""Scheduled local work consumes the request's sealed launch ceiling."""
|
||||||
asyncio.to_thread so the event loop stays responsive."""
|
from src.agent_runtime.authority import active_request_authority, ExactOperation
|
||||||
import asyncio
|
from src.agent_runtime.process_resources import resolve_process_operation, bind_process_operation
|
||||||
import subprocess
|
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:
|
try:
|
||||||
result = await asyncio.to_thread(
|
bound = resolve_process_operation(authority, operation, NativeBackendResource("bash"))
|
||||||
subprocess.run, argv, shell=shell, capture_output=True, text=True, timeout=timeout,
|
with bind_process_operation(bound):
|
||||||
)
|
result = await _run_owned_command(command, {"owner": authority.owner,
|
||||||
output = (result.stdout or "").strip()
|
"session_id": authority.session_id}, tool="bash", timeout=timeout)
|
||||||
if result.returncode != 0 and result.stderr:
|
return result.get("output") or result.get("error") or "(no output)", result.get("exit_code") == 0
|
||||||
output += "\nSTDERR: " + result.stderr.strip()
|
except (ValueError, OSError, RuntimeError) as error:
|
||||||
return output or "(no output)", result.returncode == 0
|
return str(error), False
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return f"{label} timed out ({timeout}s)", False
|
|
||||||
except Exception as e:
|
|
||||||
return str(e), False
|
|
||||||
|
|
||||||
|
|
||||||
async def action_ssh_command(owner: str, command: str = "", host: str = "localhost", **kwargs) -> Tuple[str, bool]:
|
async def action_ssh_command(owner: str, command: str = "", host: str = "localhost", **kwargs) -> Tuple[str, bool]:
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ EMOJI_CACHE_DIR = os.path.join(DATA_DIR, "emoji_cache")
|
|||||||
RAG_DIR = os.path.join(DATA_DIR, "rag")
|
RAG_DIR = os.path.join(DATA_DIR, "rag")
|
||||||
CHROMA_DIR = os.path.join(DATA_DIR, "chroma")
|
CHROMA_DIR = os.path.join(DATA_DIR, "chroma")
|
||||||
BG_JOBS_DIR = os.path.join(DATA_DIR, "bg_jobs")
|
BG_JOBS_DIR = os.path.join(DATA_DIR, "bg_jobs")
|
||||||
|
PROCESS_RESOURCES_DIR = os.path.join(DATA_DIR, "process_resources")
|
||||||
DEEP_RESEARCH_DIR = os.path.join(DATA_DIR, "deep_research")
|
DEEP_RESEARCH_DIR = os.path.join(DATA_DIR, "deep_research")
|
||||||
MCP_OAUTH_DIR = os.path.join(DATA_DIR, "mcp_oauth")
|
MCP_OAUTH_DIR = os.path.join(DATA_DIR, "mcp_oauth")
|
||||||
GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images")
|
GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Launch by absolute script path, so a task workspace cannot shadow src.
|
# 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.SIGTERM, task.cancel)
|
||||||
loop.add_signal_handler(signal.SIGINT, task.cancel)
|
loop.add_signal_handler(signal.SIGINT, task.cancel)
|
||||||
try:
|
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:
|
with open(payload["log_path"], "w", encoding="utf-8") as log:
|
||||||
def capture(text):
|
def capture(text):
|
||||||
log.write(text)
|
log.write(text)
|
||||||
@@ -75,6 +110,7 @@ async def supervise(payload: dict) -> None:
|
|||||||
except OSError:
|
except OSError:
|
||||||
# A failed log initialization must not hide completion metadata.
|
# A failed log initialization must not hide completion metadata.
|
||||||
sys.stderr.write(output)
|
sys.stderr.write(output)
|
||||||
|
report["resource_identity"] = payload.get("resource_identity")
|
||||||
atomic_write_json(payload["result_path"], report)
|
atomic_write_json(payload["result_path"], report)
|
||||||
# Publish completion last: refresh must never see an exit without metadata.
|
# 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))
|
atomic_write_text(payload["exit_path"], str(code if code is not None else 1))
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ if TYPE_CHECKING:
|
|||||||
from src.agent_runtime.resource_binding import BoundFilesystemOperation
|
from src.agent_runtime.resource_binding import BoundFilesystemOperation
|
||||||
from src.agent_runtime.remote_resources import BoundBackendOperation
|
from src.agent_runtime.remote_resources import BoundBackendOperation
|
||||||
from src.agent_runtime.owned_resources import BoundOwnedOperation
|
from src.agent_runtime.owned_resources import BoundOwnedOperation
|
||||||
|
from src.agent_runtime.process_resources import BoundProcessOperation
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60
|
DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60
|
||||||
@@ -127,6 +128,7 @@ def _binding_payload(
|
|||||||
resource_operation=None,
|
resource_operation=None,
|
||||||
backend_operation=None,
|
backend_operation=None,
|
||||||
owned_operation=None,
|
owned_operation=None,
|
||||||
|
process_operation=None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"owner": _normalized_owner(owner),
|
"owner": _normalized_owner(owner),
|
||||||
@@ -151,6 +153,7 @@ def _binding_payload(
|
|||||||
"resource_operation": resource_operation.to_dict() if resource_operation 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,
|
"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,
|
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -184,6 +187,7 @@ class PendingToolApproval:
|
|||||||
resource_operation: BoundFilesystemOperation | None = None
|
resource_operation: BoundFilesystemOperation | None = None
|
||||||
backend_operation: BoundBackendOperation | None = None
|
backend_operation: BoundBackendOperation | None = None
|
||||||
owned_operation: BoundOwnedOperation | None = None
|
owned_operation: BoundOwnedOperation | None = None
|
||||||
|
process_operation: BoundProcessOperation | None = None
|
||||||
|
|
||||||
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -296,6 +300,7 @@ class ExactToolApproval:
|
|||||||
resource_operation=self.pending.resource_operation,
|
resource_operation=self.pending.resource_operation,
|
||||||
backend_operation=self.pending.backend_operation,
|
backend_operation=self.pending.backend_operation,
|
||||||
owned_operation=self.pending.owned_operation,
|
owned_operation=self.pending.owned_operation,
|
||||||
|
process_operation=self.pending.process_operation,
|
||||||
)
|
)
|
||||||
return _canonical_digest(expected) == self.pending.digest
|
return _canonical_digest(expected) == self.pending.digest
|
||||||
|
|
||||||
@@ -391,6 +396,7 @@ class ToolApprovalStore:
|
|||||||
resource_operation = None
|
resource_operation = None
|
||||||
backend_operation = None
|
backend_operation = None
|
||||||
owned_operation = None
|
owned_operation = None
|
||||||
|
process_operation = None
|
||||||
from src.agent_runtime.remote_resources import BoundBackendOperation, resolve_backend
|
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.owned_resources import needs_owned_binding, resolve_owned_operation
|
||||||
from src.agent_runtime.resources import NativeBackendResource
|
from src.agent_runtime.resources import NativeBackendResource
|
||||||
@@ -403,6 +409,9 @@ class ToolApprovalStore:
|
|||||||
backend_operation = BoundBackendOperation(backend,
|
backend_operation = BoundBackendOperation(backend,
|
||||||
request_authority.request_id if request_authority is not None else "",
|
request_authority.request_id if request_authority is not None else "",
|
||||||
_normalized_owner(owner), str(session_id or ""), operation.transport_tool, operation.input)
|
_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)
|
||||||
if isinstance(backend, NativeBackendResource) and needs_owned_binding(operation):
|
if isinstance(backend, NativeBackendResource) and needs_owned_binding(operation):
|
||||||
resolved_owned = resolve_owned_operation(operation, owner=_normalized_owner(owner),
|
resolved_owned = resolve_owned_operation(operation, owner=_normalized_owner(owner),
|
||||||
thread_id=str(session_id or ""), request_id=backend_operation.request_id,
|
thread_id=str(session_id or ""), request_id=backend_operation.request_id,
|
||||||
@@ -450,6 +459,7 @@ class ToolApprovalStore:
|
|||||||
resource_operation=resource_operation,
|
resource_operation=resource_operation,
|
||||||
backend_operation=backend_operation,
|
backend_operation=backend_operation,
|
||||||
owned_operation=owned_operation,
|
owned_operation=owned_operation,
|
||||||
|
process_operation=process_operation,
|
||||||
)
|
)
|
||||||
pending = PendingToolApproval(
|
pending = PendingToolApproval(
|
||||||
approval_id=secrets.token_urlsafe(32),
|
approval_id=secrets.token_urlsafe(32),
|
||||||
@@ -477,6 +487,7 @@ class ToolApprovalStore:
|
|||||||
resource_operation=resource_operation,
|
resource_operation=resource_operation,
|
||||||
backend_operation=backend_operation,
|
backend_operation=backend_operation,
|
||||||
owned_operation=owned_operation,
|
owned_operation=owned_operation,
|
||||||
|
process_operation=process_operation,
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._purge_expired_locked(now)
|
self._purge_expired_locked(now)
|
||||||
|
|||||||
+27
-4
@@ -978,7 +978,10 @@ def vet_workspace(raw: str) -> Optional[str]:
|
|||||||
def agent_cwd() -> str:
|
def agent_cwd() -> str:
|
||||||
"""Working directory for agent subprocesses (bash/python/background jobs):
|
"""Working directory for agent subprocesses (bash/python/background jobs):
|
||||||
the active workspace when set, else the persistent data dir."""
|
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():
|
def get_mcp_manager():
|
||||||
@@ -1319,7 +1322,10 @@ async def _document_tool_dispatch(
|
|||||||
from src.agent_runtime.journal import dispatched, mark_authorized, mark_dispatch, record_action
|
from src.agent_runtime.journal import dispatched, mark_authorized, mark_dispatch, record_action
|
||||||
from src.agent_runtime.authority import (
|
from src.agent_runtime.authority import (
|
||||||
MISSING_AUTHORITY, ExactOperation, RequestAuthority, active_request_authority,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1415,6 +1421,12 @@ async def execute_tool_block(
|
|||||||
exact_admission=exact_admission)
|
exact_admission=exact_admission)
|
||||||
external_resource_call = isinstance(backend_operation.resource, ExternalResource)
|
external_resource_call = isinstance(backend_operation.resource, ExternalResource)
|
||||||
owned_operation = None
|
owned_operation = None
|
||||||
|
process_operation = None
|
||||||
|
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 needs_owned_binding(operation) and not external_resource_call:
|
||||||
if pending is not None and pending.owned_operation is None:
|
if pending is not None and pending.owned_operation is None:
|
||||||
raise ResourceIdentityError("Approved action has no sealed owned resource identity")
|
raise ResourceIdentityError("Approved action has no sealed owned resource identity")
|
||||||
@@ -1541,10 +1553,13 @@ async def execute_tool_block(
|
|||||||
token = _active_workspace.set(workspace or None)
|
token = _active_workspace.set(workspace or None)
|
||||||
try:
|
try:
|
||||||
backend_operation.validate(client_runtime_context)
|
backend_operation.validate(client_runtime_context)
|
||||||
|
if process_operation is not None and approval_claimed:
|
||||||
|
process_operation = replace(process_operation, exact_approval=exact_approval)
|
||||||
normalized = resource_operation or owned_operation
|
normalized = resource_operation or owned_operation
|
||||||
sealed_document = owned_operation or (exact_approval.pending if approval_claimed else None)
|
sealed_document = owned_operation or (exact_approval.pending if approval_claimed else None)
|
||||||
with (bind_request_authority(authority), bind_resource_operation(resource_operation),
|
with (bind_request_authority(authority), bind_resource_operation(resource_operation),
|
||||||
bind_backend_operation(backend_operation), bind_owned_operation(owned_operation)):
|
bind_backend_operation(backend_operation), bind_owned_operation(owned_operation),
|
||||||
|
bind_process_operation(process_operation)):
|
||||||
output = await _execute_tool_block_impl(
|
output = await _execute_tool_block_impl(
|
||||||
ToolBlock(transport, normalized.execution_input) if normalized is not None else block,
|
ToolBlock(transport, normalized.execution_input) if normalized is not None else block,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -1790,7 +1805,6 @@ async def _execute_tool_block_impl(
|
|||||||
return "bash (background): containment unavailable", containment.unavailable_tool_result(exc, tool="bash")
|
return "bash (background): containment unavailable", containment.unavailable_tool_result(exc, tool="bash")
|
||||||
# Only this server launch may seal detached-job authority; a
|
# Only this server launch may seal detached-job authority; a
|
||||||
# handler/bridge output carrying a job id is not a grant source.
|
# 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]
|
short = _bg_cmd.strip().split(chr(10))[0][:80]
|
||||||
desc = f"bash (background): {short}"
|
desc = f"bash (background): {short}"
|
||||||
result = {
|
result = {
|
||||||
@@ -1833,6 +1847,15 @@ async def _execute_tool_block_impl(
|
|||||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||||
if tool == "edit_file":
|
if tool == "edit_file":
|
||||||
desc = result.get("output") or result.get("error") or "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:
|
elif tool in _MCP_TOOL_MAP:
|
||||||
first_line = content.split(chr(10))[0][:80]
|
first_line = content.split(chr(10))[0][:80]
|
||||||
desc = f"{tool}: {first_line}"
|
desc = f"{tool}: {first_line}"
|
||||||
|
|||||||
@@ -1227,8 +1227,8 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
|
|||||||
)
|
)
|
||||||
target_label = f"{session_id} on {remote}"
|
target_label = f"{session_id} on {remote}"
|
||||||
else:
|
else:
|
||||||
cmd = f"tmux kill-session -t {shlex.quote(session_id)}"
|
return {"error": "Local Cookbook control has no admitted process resource; session discovery is not ownership",
|
||||||
target_label = session_id
|
"exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
|
||||||
|
|
||||||
# Capture what this session owns BEFORE the kill. Once tmux tears the
|
# 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
|
# session down the pane is gone, and with it the only evidence linking a
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from src import containment
|
|||||||
def capture_owned_spawn(monkeypatch, tmp_path):
|
def capture_owned_spawn(monkeypatch, tmp_path):
|
||||||
captured = {}
|
captured = {}
|
||||||
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
|
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)
|
monkeypatch.setattr(containment, "_pgid_of", lambda pid: pid)
|
||||||
|
|
||||||
async def fake_exec(*argv, **kwargs):
|
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)
|
||||||
@@ -15,6 +15,11 @@ def server_authorized_executor(executor):
|
|||||||
from src.tool_policy import known_tool_names
|
from src.tool_policy import known_tool_names
|
||||||
from src.turn_contract import canonical_tool
|
from src.turn_contract import canonical_tool
|
||||||
from src.agent_runtime.remote_resources import seal_backends
|
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)
|
call_signature = signature(executor)
|
||||||
@wraps(executor)
|
@wraps(executor)
|
||||||
async def execute(*args, **kwargs):
|
async def execute(*args, **kwargs):
|
||||||
@@ -22,10 +27,19 @@ def server_authorized_executor(executor):
|
|||||||
parameters = bound.arguments
|
parameters = bound.arguments
|
||||||
grants = tuple(OperationGrant(name) for name in sorted(
|
grants = tuple(OperationGrant(name) for name in sorted(
|
||||||
{canonical_tool(n) for n in known_tool_names()} | {"list_dir", "find_files"}))
|
{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(
|
kwargs.setdefault("request_authority", RequestAuthority(
|
||||||
"standalone-test-request", str(parameters.get("owner") or "").strip().casefold(),
|
"standalone-test-request", str(parameters.get("owner") or "").strip().casefold(),
|
||||||
str(parameters.get("session_id") or ""), str(parameters.get("workspace") or ""),
|
str(parameters.get("session_id") or ""), str(parameters.get("workspace") or ""),
|
||||||
grants,
|
grants,
|
||||||
|
launch_scopes=launch_scopes,
|
||||||
backend_resources=seal_backends((g.tool for g in grants), context=parameters.get("client_runtime_context"),
|
backend_resources=seal_backends((g.tool for g in grants), context=parameters.get("client_runtime_context"),
|
||||||
owner=str(parameters.get("owner") or "").strip().casefold()),
|
owner=str(parameters.get("owner") or "").strip().casefold()),
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ async def test_a_chat_session_always_uses_the_owned_runner(monkeypatch, tmp_path
|
|||||||
async def forbidden(*args, **kwargs):
|
async def forbidden(*args, **kwargs):
|
||||||
pytest.fail("native Bash resurrected a persistent tmux shell")
|
pytest.fail("native Bash resurrected a persistent tmux shell")
|
||||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", forbidden)
|
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["output"] == "ok"
|
||||||
assert result["teardown"]["dead"] is True
|
assert result["teardown"]["dead"] is True
|
||||||
assert "tmux_session" not in result
|
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 import bg_jobs, containment, process_ownership, process_reaper, tool_execution
|
||||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
|
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
|
||||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||||
|
from tests.process_resource_helpers import launch, get, kill
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def jobs(tmp_path, monkeypatch):
|
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, "_JOBS_DIR", tmp_path / "jobs")
|
||||||
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "jobs.json")
|
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "jobs.json")
|
||||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.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(containment, "MECHANISMS", tuple(m for m in containment.MECHANISMS if m.name == "process_group"))
|
||||||
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
|
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
|
||||||
launched = []
|
launched = []
|
||||||
yield tmp_path, launched
|
yield workspace, launched
|
||||||
for record in launched:
|
for record in launched:
|
||||||
current = bg_jobs.get(record["id"])
|
current = get(record["id"])
|
||||||
if current and current["status"] == "running":
|
if current and current["status"] == "running":
|
||||||
bg_jobs.kill(record["id"])
|
kill(record["id"])
|
||||||
proc = bg_jobs._LIVE_PROCS.pop(record["pid"], None)
|
proc = bg_jobs._LIVE_PROCS.pop(record["pid"], None)
|
||||||
if proc:
|
if proc:
|
||||||
proc.wait(timeout=8)
|
proc.wait(timeout=8)
|
||||||
@@ -33,7 +38,7 @@ def jobs(tmp_path, monkeypatch):
|
|||||||
def finished(job_id):
|
def finished(job_id):
|
||||||
deadline = time.monotonic() + 10
|
deadline = time.monotonic() + 10
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
record = bg_jobs.get(job_id)
|
record = get(job_id)
|
||||||
if record["status"] != "running":
|
if record["status"] != "running":
|
||||||
return record
|
return record
|
||||||
time.sleep(0.03)
|
time.sleep(0.03)
|
||||||
@@ -42,7 +47,7 @@ def finished(job_id):
|
|||||||
|
|
||||||
def test_detached_execution_owns_boundary_and_reports_death(jobs):
|
def test_detached_execution_owns_boundary_and_reports_death(jobs):
|
||||||
path, launched = 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)
|
launched.append(record)
|
||||||
result = finished(record["id"])
|
result = finished(record["id"])
|
||||||
assert result["output"] == "captured"
|
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),
|
result = subprocess.run([sys.executable, str(worker)], input=json.dumps(payload),
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
assert result.returncode == 0 # Supervisor publishes the failed job result.
|
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 not (path / "must-not-exist").exists()
|
||||||
assert containment.active_grants() == []
|
assert containment.active_grants() == []
|
||||||
assert (path / "exit").read_text() == "1"
|
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):
|
def test_detached_supervisor_enforces_timeout(jobs):
|
||||||
path, launched = 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)
|
launched.append(record)
|
||||||
result = finished(record["id"])
|
result = finished(record["id"])
|
||||||
assert result["timed_out"] is True
|
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):
|
def test_restart_keeps_verified_background_supervisor(jobs):
|
||||||
path, launched = 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)
|
launched.append(record)
|
||||||
report = process_reaper.reap_containment_grants()
|
report = process_reaper.reap_containment_grants()
|
||||||
assert report["background_kept"] == 1
|
assert report["background_kept"] == 1
|
||||||
killed = bg_jobs.kill(record["id"])
|
killed = kill(record["id"])
|
||||||
assert killed["killed"] is True
|
assert killed["killed"] is True
|
||||||
assert killed["teardown"]["dead"] 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})
|
bg_jobs._save({"stale": record})
|
||||||
monkeypatch.setattr(process_ownership, "verify", lambda *args: process_ownership.FOREIGN)
|
monkeypatch.setattr(process_ownership, "verify", lambda *args: process_ownership.FOREIGN)
|
||||||
monkeypatch.setattr(bg_jobs, "_kill", lambda *args, **kwargs: pytest.fail("foreign process signalled"))
|
monkeypatch.setattr(bg_jobs, "_kill", lambda *args, **kwargs: pytest.fail("foreign process signalled"))
|
||||||
result = bg_jobs.kill("stale")
|
result = bg_jobs._kill_record(record) # Service cleanup still refuses foreign identity.
|
||||||
assert result["status"] == "running"
|
assert result.dead is False
|
||||||
assert result.get("killed") is not True
|
|
||||||
assert result["teardown"]["dead"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
|
def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
|
||||||
path, launched = jobs
|
path, launched = jobs
|
||||||
for number in range(3):
|
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):
|
for number, record in enumerate(launched):
|
||||||
assert finished(record["id"])["output"] == f"job-{number}"
|
assert finished(record["id"])["output"] == f"job-{number}"
|
||||||
grants = containment._load_records()
|
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):
|
def test_detached_output_is_available_while_running(jobs):
|
||||||
path, launched = 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)
|
launched.append(record)
|
||||||
deadline = time.monotonic() + 3
|
deadline = time.monotonic() + 3
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
current = bg_jobs.get(record["id"])
|
current = get(record["id"])
|
||||||
if "progress" in current["output"]:
|
if "progress" in current["output"]:
|
||||||
assert current["status"] == "running"
|
assert current["status"] == "running"
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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")
|
||||||
|
with pytest.raises(ResourceIdentityError):
|
||||||
|
parent.intersect(child)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
|
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 import bg_jobs, containment, process_ownership
|
||||||
from src.agent_tools.bg_job_tools import ManageBgJobsTool
|
from src.agent_tools.bg_job_tools import ManageBgJobsTool
|
||||||
|
from tests.process_resource_helpers import seed_linkage, get, kill
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def store(tmp_path, monkeypatch):
|
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 = tmp_path / "bg_jobs"
|
||||||
jobs_dir.mkdir()
|
jobs_dir.mkdir()
|
||||||
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "bg_jobs.json")
|
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:
|
if output:
|
||||||
(bg_jobs._JOBS_DIR / f"{job_id}.log").write_text(output, encoding="utf-8")
|
(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 = bg_jobs._load()
|
||||||
jobs[job_id] = rec
|
jobs[job_id] = rec
|
||||||
bg_jobs._save(jobs)
|
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"):
|
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 ────────────────────────────────────────────────────────────
|
# ── bg_jobs.kill ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_kill_marks_killed_and_suppresses_followup(store):
|
def test_kill_marks_killed_and_suppresses_followup(store):
|
||||||
_seed(job_id="job0001", pid=4321)
|
_seed(job_id="job0001", pid=4321)
|
||||||
rec = bg_jobs.kill("job0001")
|
rec = kill("job0001")
|
||||||
assert rec["status"] == "failed"
|
assert rec["status"] == "failed"
|
||||||
assert rec["killed"] is True
|
assert rec["killed"] is True
|
||||||
assert rec["exit_code"] == -1
|
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):
|
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):
|
def test_kill_finished_job_is_noop(store):
|
||||||
_seed(job_id="done01", status="done")
|
_seed(job_id="done01", status="done")
|
||||||
rec = bg_jobs.kill("done01")
|
rec = kill("done01")
|
||||||
assert rec["status"] == "done"
|
assert rec["status"] == "done"
|
||||||
assert store["killed"] == [] # no signal sent to an already-finished job
|
assert store["killed"] == [] # no signal sent to an already-finished job
|
||||||
|
|
||||||
|
|
||||||
def test_result_text_reports_killed(store):
|
def test_result_text_reports_killed(store):
|
||||||
rec = _seed(job_id="job0001")
|
rec = _seed(job_id="job0001")
|
||||||
bg_jobs.kill("job0001")
|
kill("job0001")
|
||||||
assert "killed" in bg_jobs.result_text(bg_jobs.get("job0001")).lower()
|
assert "killed" in bg_jobs.result_text(get("job0001")).lower()
|
||||||
|
|
||||||
|
|
||||||
# ── manage_bg_jobs tool ─────────────────────────────────────────────────────
|
# ── manage_bg_jobs tool ─────────────────────────────────────────────────────
|
||||||
@@ -118,7 +137,7 @@ def test_kill_via_tool(store):
|
|||||||
out = _run({"action": "kill", "job_id": "job0001"})
|
out = _run({"action": "kill", "job_id": "job0001"})
|
||||||
assert "Killed" in out["output"]
|
assert "Killed" in out["output"]
|
||||||
assert store["killed"] == [999]
|
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):
|
def test_kill_cross_session_denied(store):
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ def workspace(tmp_path, monkeypatch):
|
|||||||
path.mkdir()
|
path.mkdir()
|
||||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(path))
|
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(path))
|
||||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
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
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
These legacy UI-backed targets have no authoritative launch registry. Local
|
||||||
only catches model servers that survive the session's SIGHUP. Two invariants
|
agent stops therefore fail closed before discovery, signalling or state writes,
|
||||||
live here.
|
on both procfs and other hosts. Shared Wave 5B lifecycle mechanics are tested
|
||||||
|
separately in test_process_lifecycle and test_process_ownership.
|
||||||
**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.
|
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
@@ -160,7 +147,7 @@ def _install_effective_kill(monkeypatch, table):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
monkeypatch, tmp_path
|
||||||
):
|
):
|
||||||
"""The ODY-94 regression: no procfs must not turn a working stop into a failure."""
|
"""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"})
|
json.dumps({"session_id": "serve-abc123"})
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
assert result["failure_kind"] == "resource_identity_denied"
|
||||||
assert result["output"].startswith("Stopped server serve-abc123")
|
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
monkeypatch, tmp_path
|
||||||
):
|
):
|
||||||
"""A sweep that could not look must not read as a sweep that found nothing.
|
"""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"})
|
json.dumps({"session_id": "serve-abc123"})
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
assert result["failure_kind"] == "resource_identity_denied"
|
||||||
assert "could not identify the session's processes" in result["output"]
|
|
||||||
assert signalled == []
|
assert signalled == []
|
||||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
|
async def test_pane_descendant_is_not_application_owned(monkeypatch, tmp_path):
|
||||||
"""A process under the session's pane is ours, so it gets signalled."""
|
"""A process under a named pane still requires prior application admission."""
|
||||||
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
|
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
|
||||||
state = _tracked_state(cmd=tracked_cmd)
|
state = _tracked_state(cmd=tracked_cmd)
|
||||||
posts = _install_httpx_client(monkeypatch, state)
|
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"})
|
json.dumps({"session_id": "serve-abc123"})
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
assert result["failure_kind"] == "resource_identity_denied"
|
||||||
assert (101, signal.SIGTERM) in signalled
|
assert signalled == [] # OS lineage alone never establishes app ownership.
|
||||||
assert "killed 2 surviving process(es)" in result["output"]
|
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
monkeypatch, tmp_path
|
||||||
):
|
):
|
||||||
"""The headline change: matching the command line is not owning the process.
|
"""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"})
|
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)
|
assert not any(pid == 202 for pid, _sig in signalled)
|
||||||
# Reported rather than silently dropped: the old behaviour acted on this
|
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||||
# 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"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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"})
|
json.dumps({"session_id": "serve-abc123"})
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["exit_code"] == 0
|
assert result["failure_kind"] == "resource_identity_denied"
|
||||||
# The pane shell is genuinely ours and is signalled; 101 never is.
|
# Neither pane discovery nor a matching token creates application scope.
|
||||||
assert not any(pid == 101 for pid, _sig in signalled)
|
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):
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tmp_path):
|
async def test_unadmitted_stop_refused_with_unverifiable_process(monkeypatch, tmp_path):
|
||||||
"""Captured as ours, unverifiable at sweep time: not signalled, and said so."""
|
"""An unverifiable OS observation cannot create an application grant."""
|
||||||
from src import process_ownership
|
from src import process_ownership
|
||||||
|
|
||||||
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
|
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"}))
|
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 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") == []
|
||||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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"}))
|
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 not any(pid == 101 for pid, _sig in signalled)
|
||||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||||
|
|||||||
@@ -11,13 +11,23 @@ from src.agent_tools import subprocess_tools
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def native_boundary(tmp_path, monkeypatch):
|
def native_boundary(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
|
from src.agent_runtime import process_resources
|
||||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
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, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
|
||||||
monkeypatch.setattr(containment, "MECHANISMS", tuple(
|
monkeypatch.setattr(containment, "MECHANISMS", tuple(
|
||||||
m for m in containment.MECHANISMS if m.name == "process_group"
|
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")
|
@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}
|
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
|
"""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."""
|
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 "start_token" in record
|
||||||
assert process_ownership.verify(record["pid"], record["start_token"]) in (
|
assert process_ownership.verify(record["pid"], record["start_token"]) in (
|
||||||
|
|||||||
@@ -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,))
|
||||||
|
with pytest.raises(ResourceIdentityError):
|
||||||
|
parent.intersect(child)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
async def test_native_local_bash_python_behavior_unchanged(tmp_path, monkeypatch):
|
||||||
"""4. Native local Bash/Python behavior is unchanged."""
|
"""4. Native local Bash/Python behavior is unchanged."""
|
||||||
tool_bash = subprocess_tools.BashTool()
|
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 = {
|
ctx = {
|
||||||
"session_id": "native-session",
|
"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 result["exit_code"] == 0
|
||||||
assert "native run" in result["output"]
|
assert "native run" in result["output"]
|
||||||
assert "containment" in result
|
assert "containment" in result
|
||||||
|
|||||||
@@ -158,15 +158,15 @@ async def test_missing_and_malformed_dispatch_authority_fail_closed(monkeypatch,
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
from src import tool_execution as execution
|
||||||
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
|
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
|
||||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||||
for disabled in (set(), {"bash"}):
|
for disabled in (set(), {"bash"}):
|
||||||
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"),
|
_, 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,
|
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 result["exit_code"] == (1 if disabled else 0)
|
||||||
assert implementation.await_count == 1
|
assert implementation.await_count == 1
|
||||||
|
|
||||||
@@ -224,10 +224,11 @@ def test_background_snapshot_preserves_scope_and_rejects_other_session(monkeypat
|
|||||||
import src.constants
|
import src.constants
|
||||||
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
||||||
grant = authority("transcribe_media").restrict(disabled_tools={"bash"})
|
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")
|
restored = restore_background_authority("job1", owner="alice", session_id="s")
|
||||||
assert restored.request_id == grant.request_id
|
assert restored.grants == ()
|
||||||
assert restored.denied == frozenset({"bash"})
|
|
||||||
assert not restored.permits(ExactOperation.normalize("python", "print(1)"))
|
assert not restored.permits(ExactOperation.normalize("python", "print(1)"))
|
||||||
assert restore_background_authority("job1", owner="alice", session_id="other").grants == ()
|
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,
|
owner="alice", session_id="s", security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||||
request_authority=authority("bash"))
|
request_authority=authority("bash"))
|
||||||
restored = restore_background_authority("server-job", owner="alice", session_id="s")
|
restored = restore_background_authority("server-job", owner="alice", session_id="s")
|
||||||
assert restored.request_id == "request-test"
|
assert restored.grants == () # A launch double returning an ID cannot publish authority.
|
||||||
assert restored.permits(ExactOperation.normalize("bash", "printf trusted"))
|
|
||||||
handler = AsyncMock(return_value=("transcribe_media", {"bg_job_id": "forged-job", "exit_code": 0}))
|
handler = AsyncMock(return_value=("transcribe_media", {"bg_job_id": "forged-job", "exit_code": 0}))
|
||||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
|
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
|
||||||
await execution.execute_tool_block(ToolBlock("transcribe_media", '{}'),
|
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
|
@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 import tool_execution as execution
|
||||||
from src.tool_approvals import ToolApprovalStore
|
from src.tool_approvals import ToolApprovalStore
|
||||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||||
store = ToolApprovalStore()
|
store = ToolApprovalStore()
|
||||||
original = authority("transcribe_media")
|
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",
|
pending = store.create(owner="alice", session_id="s", origin_run_id="journal-parent",
|
||||||
tool_name="bash", content="printf approved", workspace=None,
|
tool_name="bash", content="printf approved", workspace=None,
|
||||||
external_untrusted_context_seen=True, capabilities=capabilities_for_action("bash", "printf approved"),
|
external_untrusted_context_seen=True, capabilities=capabilities_for_action("bash", "printf approved"),
|
||||||
|
|||||||
@@ -397,8 +397,10 @@ def test_task_and_background_continuations_keep_original_roots(tmp_path, monkeyp
|
|||||||
import src.constants
|
import src.constants
|
||||||
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
||||||
grant = authority(tmp_path, "read_file")
|
grant = authority(tmp_path, "read_file")
|
||||||
save_background_authority("job", grant)
|
# A roots-only sidecar is legacy state and cannot invent a job generation.
|
||||||
assert restore_background_authority("job", owner="alice", session_id="s").resource_roots == grant.resource_roots
|
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 == ()
|
assert restore_background_authority("job", owner="bob", session_id="s").resource_roots == ()
|
||||||
with bind_request_authority(grant):
|
with bind_request_authority(grant):
|
||||||
sealed = seal_task_authority("Read files in the workspace", "llm", None, owner="alice")
|
sealed = seal_task_authority("Read files in the workspace", "llm", None, owner="alice")
|
||||||
@@ -708,10 +710,11 @@ def test_nonfilesystem_identities_are_inert_and_distinguish_producers_from_pages
|
|||||||
page = BrowserPageResource(producer, "page-1", 2, "https://example.test")
|
page = BrowserPageResource(producer, "page-1", 2, "https://example.test")
|
||||||
assert replace(producer, incarnation="incarnation-2") != producer
|
assert replace(producer, incarnation="incarnation-2") != producer
|
||||||
assert replace(page, navigation_generation=3) != page
|
assert replace(page, navigation_generation=3) != page
|
||||||
ProcessResource("local", "boot/process", "alice", 123, "boot:start", "job", "receipt", 124, "boot:init")
|
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")
|
OwnedResource("documents", "alice", "thread", "documents", "document", "revision")
|
||||||
assert ExternalResource("mcp", "endpoint", "server", "tool", "connection").external is True
|
assert ExternalResource("mcp", "endpoint", "server", "tool", "connection").external is True
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
ExternalResource("mcp", "endpoint", "server", "tool", "connection", external=False)
|
ExternalResource("mcp", "endpoint", "server", "tool", "connection", external=False)
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
ProcessResource("local", "incarnation", "alice", 123, "", containment_id="receipt")
|
ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(123, ""), "leader", containment_id="receipt")
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
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), 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"
|
||||||
@@ -32,6 +32,15 @@ def _pending(store, **overrides):
|
|||||||
"capabilities": capabilities_for_action("bash", "printf exact"),
|
"capabilities": capabilities_for_action("bash", "printf exact"),
|
||||||
}
|
}
|
||||||
values.update(overrides)
|
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)
|
return store.create(**values)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,19 @@ from pathlib import Path
|
|||||||
import pytest
|
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():
|
def test_unoffered_artifact_recovery_is_bounded():
|
||||||
from src.agent_loop import _artifact_unoffered_recovery_exhausted
|
from src.agent_loop import _artifact_unoffered_recovery_exhausted
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user