mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52: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:
|
||||
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)
|
||||
_state_get_cache = {"ts": 0.0, "mtime": 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):
|
||||
"""Reject non-admin callers. Shell exec is admin-only — never expose to
|
||||
regular users; that's RCE-after-signup."""
|
||||
# In the explicitly single-user, auth-disabled deployment the middleware
|
||||
# does not attach a current user. AuthManager is still instantiated by the
|
||||
# app, so checking only for its presence incorrectly returns 403 here.
|
||||
# Anonymous loopback is also reachable from an admitted native workload.
|
||||
# It cannot be treated as a human admin or as process creation authority.
|
||||
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():
|
||||
return
|
||||
raise HTTPException(403, "Anonymous native process control has no resource authority")
|
||||
auth_manager = getattr(request.app.state, "auth_manager", None)
|
||||
if not auth_manager:
|
||||
# No auth at all — only safe in fully-trusted localhost dev mode
|
||||
return
|
||||
raise HTTPException(403, "Native process control requires authenticated administration")
|
||||
user = getattr(request.state, "current_user", None)
|
||||
# In-process tool loopback. The AuthMiddleware already validated the
|
||||
# internal token + loopback client before setting this marker, so
|
||||
# honour it here as admin-equivalent.
|
||||
if user == INTERNAL_TOOL_USER:
|
||||
return
|
||||
if not user or user == "api":
|
||||
raise HTTPException(403, "Admin only")
|
||||
if not auth_manager.is_admin(user):
|
||||
|
||||
@@ -13,6 +13,7 @@ from uuid import uuid4
|
||||
|
||||
from src.agent_runtime.resources import (
|
||||
FilesystemRoot, ExternalResource, NativeBackendResource, OwnedScope,
|
||||
ProcessLaunchScope, ProcessResource, BackgroundJobResource,
|
||||
backend_from_dict, intersect_roots, seal_owned_scopes,
|
||||
)
|
||||
from src.tool_policy import ToolPolicy, build_effective_tool_policy
|
||||
@@ -120,6 +121,9 @@ class RequestAuthority:
|
||||
resource_roots: tuple[FilesystemRoot, ...] | None = None
|
||||
backend_resources: tuple[ExternalResource | NativeBackendResource, ...] | None = None
|
||||
owned_scopes: tuple[OwnedScope, ...] | None = None
|
||||
launch_scopes: tuple[ProcessLaunchScope, ...] | None = None
|
||||
process_resources: tuple[ProcessResource, ...] = ()
|
||||
job_resources: tuple[BackgroundJobResource, ...] | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
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)
|
||||
for s in self.owned_scopes)):
|
||||
raise ValueError("Malformed backend or owned resource scope")
|
||||
from src.agent_runtime.process_resources import seal_launch_scopes, seal_jobs
|
||||
if self.launch_scopes is None:
|
||||
object.__setattr__(self, "launch_scopes", seal_launch_scopes(self))
|
||||
if self.job_resources is None:
|
||||
object.__setattr__(self, "job_resources", seal_jobs(self))
|
||||
for field, kind in (("launch_scopes", ProcessLaunchScope), ("process_resources", ProcessResource),
|
||||
("job_resources", BackgroundJobResource)):
|
||||
values = getattr(self, field)
|
||||
if not isinstance(values, tuple) or any(not isinstance(r, kind) for r in values):
|
||||
raise ValueError("Malformed process resource scope")
|
||||
if any(r.owner != self.owner for r in (*self.process_resources, *self.job_resources)):
|
||||
raise ValueError("Process resource owner changed")
|
||||
if any(s.root.owner and s.root.owner != self.owner for s in self.launch_scopes):
|
||||
raise ValueError("Launch resource owner changed")
|
||||
if any(r.thread_id != self.session_id for r in self.job_resources):
|
||||
raise ValueError("Job resource thread changed")
|
||||
if any(r.thread_id != (self.session_id or "request:" + self.request_id) for r in self.process_resources):
|
||||
raise ValueError("Process resource thread changed")
|
||||
|
||||
@classmethod
|
||||
def empty(cls, *, owner=None, session_id=None, workspace=None):
|
||||
return cls(uuid4().hex, _owner(owner), str(session_id or ""), str(workspace or ""),
|
||||
resource_roots=(), backend_resources=(), owned_scopes=())
|
||||
resource_roots=(), backend_resources=(), owned_scopes=(), launch_scopes=(), job_resources=())
|
||||
|
||||
def bound_to(self, *, owner=None, session_id=None, workspace=None):
|
||||
return (self.owner == _owner(owner) and self.session_id == str(session_id or "")
|
||||
@@ -188,6 +210,7 @@ class RequestAuthority:
|
||||
roots = ()
|
||||
backends = ()
|
||||
owned = ()
|
||||
launches = processes = jobs = ()
|
||||
if (self.owner, self.session_id, self.workspace) == (child.owner, child.session_id, child.workspace):
|
||||
theirs = {g.tool: g for g in child.grants}
|
||||
grants = [g.intersect(theirs[g.tool]) for g in self.grants if g.tool in theirs]
|
||||
@@ -195,10 +218,15 @@ class RequestAuthority:
|
||||
backends = tuple(r for r in self.backend_resources if r in child.backend_resources)
|
||||
owned = tuple(s for left in self.owned_scopes for right in child.owned_scopes
|
||||
if (s := left.intersect(right)) is not None)
|
||||
from src.agent_runtime.process_resources import intersect_observed, intersect_launch_scopes, validate_job
|
||||
launches = intersect_launch_scopes(self.launch_scopes, child.launch_scopes)
|
||||
processes = intersect_observed(self.process_resources, child.process_resources, lambda r: r.validate())
|
||||
jobs = intersect_observed(self.job_resources, child.job_resources, validate_job)
|
||||
return replace(self, grants=tuple(grants), denied=self.denied | child.denied,
|
||||
block_all=self.block_all or child.block_all,
|
||||
disable_mcp=self.disable_mcp or child.disable_mcp, inherited=True,
|
||||
resource_roots=roots, backend_resources=backends, owned_scopes=owned)
|
||||
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):
|
||||
"""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)
|
||||
rebound = str(session_id or "")
|
||||
return replace(self, session_id=rebound, inherited=True,
|
||||
owned_scopes=tuple(replace(s, thread_id=rebound) for s in self.owned_scopes) if rebound else ())
|
||||
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):
|
||||
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,
|
||||
"grants": [{"tool": g.tool,
|
||||
"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,
|
||||
"resource_roots": [r.to_dict() for r in self.resource_roots],
|
||||
"backend_resources": [r.to_dict() for r in self.backend_resources],
|
||||
"owned_scopes": [s.to_dict() for s in self.owned_scopes]}
|
||||
"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
|
||||
def from_dict(cls, value):
|
||||
if (not isinstance(value, dict) or type(value.get("version")) is not int
|
||||
or value["version"] not in {1, 2, 3}):
|
||||
or value["version"] not in {1, 2, 3, 4}):
|
||||
raise ValueError("Unsupported authority snapshot")
|
||||
def limits(value):
|
||||
if value is None:
|
||||
@@ -234,8 +267,12 @@ class RequestAuthority:
|
||||
roots = value["resource_roots"] if value["version"] >= 2 else []
|
||||
if not isinstance(roots, list):
|
||||
raise ValueError("Malformed request resource snapshot")
|
||||
backends = value["backend_resources"] if value["version"] == 3 else []
|
||||
owned = value["owned_scopes"] if value["version"] == 3 else []
|
||||
backends = value["backend_resources"] if value["version"] >= 3 else []
|
||||
owned = value["owned_scopes"] if value["version"] >= 3 else []
|
||||
process_fields = {name: value[name] if value["version"] >= 4 else []
|
||||
for name in ("launch_scopes", "process_resources", "job_resources")}
|
||||
if any(not isinstance(v, list) for v in process_fields.values()):
|
||||
raise ValueError("Malformed process resource snapshot")
|
||||
if not isinstance(backends, list) or not isinstance(owned, list):
|
||||
raise ValueError("Malformed request resource scope snapshot")
|
||||
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"]),
|
||||
value["block_all"], value["disable_mcp"], value["inherited"],
|
||||
tuple(FilesystemRoot.from_dict(r) for r in roots),
|
||||
tuple(backend_from_dict(r) for r in backends), tuple(OwnedScope.from_dict(s) for s in owned))
|
||||
tuple(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",
|
||||
@@ -462,7 +502,10 @@ def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authori
|
||||
workspace=parent.workspace,
|
||||
resource_roots=parent.resource_roots,
|
||||
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()})
|
||||
|
||||
|
||||
@@ -479,18 +522,27 @@ def restore_task_authority(snapshot, prompt, task_type, action, *, owner=None, s
|
||||
def _background_path(job_id):
|
||||
if not isinstance(job_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", job_id):
|
||||
raise ValueError("Invalid background authority identity")
|
||||
from src.constants import BG_JOBS_DIR
|
||||
return Path(BG_JOBS_DIR) / (job_id + ".authority.json")
|
||||
from src.bg_jobs import _JOBS_DIR
|
||||
return Path(_JOBS_DIR) / (job_id + ".authority.json")
|
||||
|
||||
|
||||
def save_background_authority(job_id, authority):
|
||||
def save_background_authority(job_id, authority, *, resource=None):
|
||||
from core.atomic_io import atomic_write_json
|
||||
atomic_write_json(_background_path(job_id), authority.to_dict())
|
||||
if resource is None or resource.job_id != job_id:
|
||||
raise ValueError("Background authority requires exact job linkage")
|
||||
atomic_write_json(_background_path(job_id), {"authority": authority.to_dict(), "job": resource.to_dict()})
|
||||
|
||||
|
||||
def restore_background_authority(job_id, *, owner=None, session_id=None):
|
||||
try:
|
||||
authority = RequestAuthority.from_dict(json.loads(_background_path(job_id).read_text()))
|
||||
value = json.loads(_background_path(job_id).read_text())
|
||||
resource = BackgroundJobResource.from_dict(value["job"])
|
||||
from src.agent_runtime.process_resources import validate_job
|
||||
validate_job(resource)
|
||||
authority = RequestAuthority.from_dict(value["authority"])
|
||||
if (resource.job_id, resource.owner, resource.thread_id, resource.request_id) != (
|
||||
job_id, authority.owner, authority.session_id, authority.request_id):
|
||||
raise ValueError("Background authority linkage changed")
|
||||
if authority.session_id != str(session_id or ""):
|
||||
raise ValueError("Background session changed")
|
||||
return authority.continuation(owner=owner, session_id=session_id)
|
||||
|
||||
@@ -257,7 +257,8 @@ def needs_owned_binding(operation):
|
||||
raise ResourceIdentityError("Unresolved internal resource selector")
|
||||
path = posixpath.normpath(urlsplit(path).path)
|
||||
private = {"document", "documents", "session", "sessions", "history", "chat", "chats",
|
||||
"notes", "memory", "vault", "upload", "uploads", "attachments"}
|
||||
"notes", "memory", "vault", "upload", "uploads", "attachments",
|
||||
"shell", "model", "cookbook"}
|
||||
segments = path.strip("/").split("/")
|
||||
if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private:
|
||||
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",
|
||||
"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.
|
||||
# Inspect already-loaded server metadata without initializing a store here.
|
||||
bg = sys.modules.get("src.bg_jobs")
|
||||
@@ -275,25 +278,165 @@ def intersect_roots(parent, child):
|
||||
@dataclass(frozen=True)
|
||||
class ProcessResource:
|
||||
namespace: str
|
||||
incarnation: str
|
||||
owner: str
|
||||
pid: int
|
||||
start_token: str
|
||||
request_id: str
|
||||
thread_id: str
|
||||
identity: "ProcessIdentity"
|
||||
role: str
|
||||
job_id: str = ""
|
||||
containment_id: str = ""
|
||||
namespace_pid: int | None = None
|
||||
namespace_start_token: str = ""
|
||||
|
||||
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)
|
||||
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)
|
||||
if (type(self.pid) is not int or self.pid <= 0
|
||||
or (self.namespace_pid is not None and
|
||||
(type(self.namespace_pid) is not int or self.namespace_pid <= 0))
|
||||
or bool(self.namespace_pid) != bool(self.namespace_start_token)):
|
||||
if (not isinstance(self.identity, ProcessIdentity)
|
||||
or type(self.identity.pid) is not int or self.identity.pid <= 0
|
||||
or (self.identity.pgid is not None and (type(self.identity.pgid) is not int or self.identity.pgid <= 0))
|
||||
or self.role not in {"supervisor", "leader", "namespace_init", "manager", "pty", "service"}):
|
||||
raise ValueError("Malformed process resource identity")
|
||||
supported_roles = {"native:containment": {"leader", "namespace_init"},
|
||||
"native:bg_jobs": {"supervisor"}}
|
||||
if self.role not in supported_roles.get(self.namespace, set()):
|
||||
raise ValueError("Unsupported process producer or role")
|
||||
_text(self.identity.start_token, "process start token")
|
||||
|
||||
def validate(self):
|
||||
if not self.identity.owned() or self.identity.exited():
|
||||
raise ResourceIdentityError("Process resource is stale or unverifiable")
|
||||
|
||||
def to_dict(self):
|
||||
return {"namespace": self.namespace, "owner": self.owner, "request_id": self.request_id,
|
||||
"thread_id": self.thread_id, "identity": self.identity.to_record(), "role": self.role,
|
||||
"job_id": self.job_id, "containment_id": self.containment_id}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
if not isinstance(value, dict) or set(value) != {"namespace", "owner", "request_id", "thread_id", "identity", "role", "job_id", "containment_id"}:
|
||||
raise ValueError("Malformed process resource snapshot")
|
||||
identity = value["identity"]
|
||||
if not isinstance(identity, dict) or set(identity) != {"pid", "start_token", "pgid"}:
|
||||
raise ValueError("Malformed lifecycle identity snapshot")
|
||||
return cls(**{**value, "identity": ProcessIdentity(**identity)})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessLaunchScope:
|
||||
backend: "NativeBackendResource"
|
||||
root: FilesystemRoot
|
||||
required: frozenset[str]
|
||||
runtime_roots: tuple[PathObservation, ...] = ()
|
||||
network: str = "inherit"
|
||||
max_runtime_s: int = 3600
|
||||
|
||||
def __post_init__(self):
|
||||
if (not isinstance(self.backend, NativeBackendResource) or not isinstance(self.root, FilesystemRoot)
|
||||
or not isinstance(self.required, frozenset) or not self.required
|
||||
or any(not isinstance(v, str) or not v for v in self.required)):
|
||||
raise ValueError("Malformed process launch scope")
|
||||
if self.backend.tool_id not in {"bash", "python"}:
|
||||
raise ValueError("Unsupported native launch producer")
|
||||
if (not isinstance(self.runtime_roots, tuple) or any(not isinstance(r, PathObservation) for r in self.runtime_roots)
|
||||
or self.network not in {"inherit", "none"}
|
||||
or type(self.max_runtime_s) is not int or self.max_runtime_s <= 0):
|
||||
raise ValueError("Malformed launch boundary selectors")
|
||||
|
||||
def validate(self):
|
||||
self.root.validate()
|
||||
for runtime in self.runtime_roots:
|
||||
if canonical_root(runtime.path) != runtime.path or FileObjectIdentity.observe(runtime.path) != runtime.identity:
|
||||
raise ResourceIdentityError("Launch runtime root changed")
|
||||
|
||||
def to_dict(self):
|
||||
return {"backend": self.backend.to_dict(), "root": self.root.to_dict(), "required": sorted(self.required),
|
||||
"runtime_roots": [{"path": r.path, "identity": asdict(r.identity)} for r in self.runtime_roots],
|
||||
"network": self.network, "max_runtime_s": self.max_runtime_s}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != {"backend", "root", "required", "runtime_roots", "network", "max_runtime_s"} or not isinstance(value["required"], list) or not isinstance(value["runtime_roots"], list):
|
||||
raise ValueError("Malformed launch scope snapshot")
|
||||
return cls(backend_from_dict(value["backend"]), FilesystemRoot.from_dict(value["root"]), frozenset(value["required"]),
|
||||
tuple(PathObservation(r["path"], FileObjectIdentity(**r["identity"])) for r in value["runtime_roots"]),
|
||||
value["network"], value["max_runtime_s"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessLaunchResource:
|
||||
namespace: str
|
||||
owner: str
|
||||
request_id: str
|
||||
thread_id: str
|
||||
generation: str
|
||||
tool: str
|
||||
input_digest: str
|
||||
scope: ProcessLaunchScope
|
||||
ceiling_digest: str
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "request_id", "thread_id", "generation", "tool", "input_digest", "ceiling_digest"):
|
||||
_text(getattr(self, name), name)
|
||||
_text(self.owner, "owner", optional=True)
|
||||
if not isinstance(self.scope, ProcessLaunchScope) or self.tool != self.scope.backend.tool_id:
|
||||
raise ValueError("Malformed launch resource")
|
||||
import re
|
||||
if (self.namespace != "native:containment" or not re.fullmatch(r"[a-f0-9]{32}", self.generation)
|
||||
or any(not re.fullmatch(r"[a-f0-9]{64}", v) for v in (self.input_digest, self.ceiling_digest))):
|
||||
raise ValueError("Malformed native launch producer or generation")
|
||||
|
||||
def validate(self):
|
||||
self.scope.validate()
|
||||
|
||||
def to_dict(self):
|
||||
return {**{k: getattr(self, k) for k in ("namespace", "owner", "request_id", "thread_id", "generation", "tool", "input_digest", "ceiling_digest")},
|
||||
"scope": self.scope.to_dict()}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != {"namespace", "owner", "request_id", "thread_id", "generation", "tool", "input_digest", "scope", "ceiling_digest"}:
|
||||
raise ValueError("Malformed launch resource snapshot")
|
||||
return cls(**{**value, "scope": ProcessLaunchScope.from_dict(value["scope"])})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackgroundJobResource:
|
||||
namespace: str
|
||||
job_id: str
|
||||
generation: str
|
||||
owner: str
|
||||
request_id: str
|
||||
thread_id: str
|
||||
containment_id: str
|
||||
processes: tuple[ProcessResource, ...]
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "job_id", "generation", "request_id", "thread_id", "containment_id"):
|
||||
_text(getattr(self, name), name)
|
||||
_text(self.owner, "owner", optional=True)
|
||||
import re
|
||||
if (not re.fullmatch(r"[A-Za-z0-9_-]+", self.job_id)
|
||||
or not re.fullmatch(r"[a-f0-9]{32}", self.generation)):
|
||||
raise ValueError("Malformed job selector or launch generation")
|
||||
if (not isinstance(self.processes, tuple) or not self.processes
|
||||
or any(not isinstance(p, ProcessResource) or (p.owner, p.request_id, p.thread_id, p.job_id, p.containment_id)
|
||||
!= (self.owner, self.request_id, self.thread_id, self.job_id, self.containment_id) for p in self.processes)
|
||||
or len({p.role for p in self.processes}) != len(self.processes)):
|
||||
raise ValueError("Malformed background job resource")
|
||||
if self.namespace != "native:bg_jobs" or any(p.namespace != "native:bg_jobs" or p.role != "supervisor" for p in self.processes):
|
||||
raise ValueError("Unsupported job producer or process role")
|
||||
|
||||
def to_dict(self):
|
||||
return {**{k: getattr(self, k) for k in ("namespace", "job_id", "generation", "owner", "request_id", "thread_id", "containment_id")},
|
||||
"processes": [p.to_dict() for p in self.processes]}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != {"namespace", "job_id", "generation", "owner", "request_id", "thread_id", "containment_id", "processes"} or not isinstance(value["processes"], list):
|
||||
raise ValueError("Malformed background resource snapshot")
|
||||
return cls(**{**value, "processes": tuple(ProcessResource.from_dict(p) for p in value["processes"])})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -67,8 +67,20 @@ class ManageBgJobsTool:
|
||||
if not session_id:
|
||||
return {"error": "manage_bg_jobs: no active chat session; background jobs are scoped to a chat.", "exit_code": 1}
|
||||
|
||||
from src.agent_runtime.process_resources import active_process_operation, expected_job, require_process_admission
|
||||
from src.agent_runtime.resources import ResourceIdentityError
|
||||
bound = active_process_operation()
|
||||
if bound is None or (bound.owner, bound.thread_id) != (str(ctx.get("owner") or "").strip().casefold(), session_id):
|
||||
return {"error": "manage_bg_jobs: no exact server resource binding", "exit_code": 1,
|
||||
"blocked": True, "failure_kind": "resource_identity_denied"}
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
if bound.operation != ExactOperation.normalize("manage_bg_jobs", raw or "{}"):
|
||||
return {"error": "Job operation changed at producer entry", "exit_code": 1, "blocked": True}
|
||||
require_process_admission(bound)
|
||||
|
||||
if action in _LIST_ACTIONS:
|
||||
jobs: List[Dict[str, Any]] = bg_jobs.list_for_session(session_id)
|
||||
bound.validate()
|
||||
jobs: List[Dict[str, Any]] = [bg_jobs.peek(j.job_id) for j in bound.jobs]
|
||||
if not jobs:
|
||||
return {"output": "No background jobs in this chat.", "exit_code": 0}
|
||||
jobs.sort(key=lambda r: r.get("started_at") or 0, reverse=True)
|
||||
@@ -78,7 +90,11 @@ class ManageBgJobsTool:
|
||||
if action in _OUTPUT_ACTIONS or action in _KILL_ACTIONS:
|
||||
if not job_id:
|
||||
return {"error": f"manage_bg_jobs: action '{action}' requires a job_id (see action='list').", "exit_code": 1}
|
||||
rec = bg_jobs.get(job_id)
|
||||
try:
|
||||
resource = expected_job(job_id, action=action)
|
||||
rec = bg_jobs.get(job_id, expected=resource)
|
||||
except (ResourceIdentityError, OSError, ValueError) as error:
|
||||
return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
|
||||
# Scope: only the chat that launched a job may see or control it.
|
||||
if rec is None or rec.get("session_id") != session_id:
|
||||
return {"error": f"manage_bg_jobs: no background job '{job_id}' in this chat.", "exit_code": 1}
|
||||
@@ -86,7 +102,7 @@ class ManageBgJobsTool:
|
||||
if action in _KILL_ACTIONS:
|
||||
if rec.get("status") != "running":
|
||||
return {"output": f"Job `{job_id}` already {_status_label(rec)}; nothing to kill.", "exit_code": 0}
|
||||
killed = bg_jobs.kill(job_id)
|
||||
killed = bg_jobs.kill(job_id, expected=resource)
|
||||
if not killed or not killed.get("killed"):
|
||||
return {"error": f"Could not verify termination of background job `{job_id}`.",
|
||||
"exit_code": 1, "teardown": (killed or {}).get("teardown")}
|
||||
|
||||
@@ -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
|
||||
|
||||
grant = None
|
||||
result = None
|
||||
try:
|
||||
from src.agent_runtime.process_resources import require_launch, publish_launch, validate_launch_spec
|
||||
from src.agent_runtime.authority import active_request_authority
|
||||
launch = require_launch(tool, cwd=agent_cwd())
|
||||
authority = active_request_authority()
|
||||
if (str(ctx.get("owner") or "").strip().casefold(), str(ctx.get("session_id") or "")) != (
|
||||
authority.owner, authority.session_id):
|
||||
raise ValueError("Native producer owner or session changed")
|
||||
spec = _owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout, readonly_extra)
|
||||
validate_launch_spec(launch, spec)
|
||||
grant = containment.acquire(
|
||||
_owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout, readonly_extra),
|
||||
spec,
|
||||
owner=str(ctx.get("session_id") or ctx.get("owner") or tool),
|
||||
)
|
||||
containment._update_record(grant.id, launch_generation=launch.generation)
|
||||
publish_launch(launch, authority, grant.id)
|
||||
if containment.FILESYSTEM not in grant.enforced:
|
||||
if argv:
|
||||
command = [*command[:-1], _replace_workspace_alias(command[-1], grant.workspace)]
|
||||
else:
|
||||
command = _replace_workspace_alias(command, grant.workspace)
|
||||
result = await containment.run(grant, command, argv=argv, progress_cb=ctx.get("progress_cb"))
|
||||
from src.agent_runtime.process_resources import attach_containment_processes
|
||||
attach_containment_processes(launch, grant.id)
|
||||
except containment.ContainmentUnavailable as exc:
|
||||
return containment.unavailable_tool_result(exc, tool=tool)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
boundary = grant.to_dict() if grant else {}
|
||||
boundary["executed"] = bool(getattr(exc, "containment_executed", False))
|
||||
if not getattr(exc, "containment_established", False):
|
||||
if grant is not None:
|
||||
record = containment._load_records().get(grant.id, {})
|
||||
if not record.get("pid") and not record.get("release"):
|
||||
containment.release(grant, grace_s=0)
|
||||
boundary = result.grant.to_dict() if result is not None else grant.to_dict() if grant else {}
|
||||
boundary["executed"] = result is not None or bool(getattr(exc, "containment_executed", False))
|
||||
if result is None and not getattr(exc, "containment_established", False):
|
||||
boundary.update(contained=False, enforced=[])
|
||||
return {"error": f"{tool}: execution failed: {exc}", "exit_code": 1,
|
||||
"containment": boundary}
|
||||
"containment": boundary,
|
||||
**({"failure_kind": "resource_linkage_unavailable",
|
||||
"teardown": result.release.to_dict() if result.release else {"dead": False}}
|
||||
if result is not None else {})}
|
||||
|
||||
boundary = result.grant.to_dict()
|
||||
boundary["executed"] = True
|
||||
@@ -590,6 +611,12 @@ class BashTool:
|
||||
),
|
||||
"exit_code": 1,
|
||||
}
|
||||
from src.agent_runtime.process_resources import require_launch
|
||||
from src.agent_runtime.resources import ResourceIdentityError
|
||||
try:
|
||||
require_launch("bash", cwd=agent_cwd(), content=content)
|
||||
except ResourceIdentityError as error:
|
||||
return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
|
||||
if _ffmpeg_unicode_drawtext_needs_fontfile(content):
|
||||
resolved_font = _resolve_fontfile_for_text(content)
|
||||
resolved_hint = (
|
||||
@@ -879,6 +906,12 @@ class PythonTool:
|
||||
),
|
||||
"exit_code": 1,
|
||||
}
|
||||
from src.agent_runtime.process_resources import require_launch
|
||||
from src.agent_runtime.resources import ResourceIdentityError
|
||||
try:
|
||||
require_launch("python", cwd=agent_cwd(), content=content)
|
||||
except ResourceIdentityError as error:
|
||||
return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
|
||||
if "/tmp/" in content:
|
||||
isolated_tmp = _isolated_tmp_dir(agent_cwd())
|
||||
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
|
||||
|
||||
+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,
|
||||
wall clock and exit metadata, independently of the request/server lifetime.
|
||||
"""
|
||||
from src.agent_runtime.process_resources import require_launch, active_process_operation, publish_launch, launch_path, validate_launch_spec
|
||||
from src.agent_runtime.authority import active_request_authority, save_background_authority
|
||||
from src.agent_runtime.resources import ProcessResource, BackgroundJobResource
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
cwd = cwd or os.getcwd()
|
||||
launch_resource = require_launch("bash", cwd=cwd)
|
||||
bound = active_process_operation()
|
||||
from src.tool_execution import _split_bg_marker
|
||||
marked, proposed = _split_bg_marker(bound.operation.input)
|
||||
if command != (proposed if marked else bound.operation.input).strip() or session_id != launch_resource.thread_id:
|
||||
raise ValueError("Background launch operation or session changed")
|
||||
authority = active_request_authority()
|
||||
if authority is None or (authority.owner, authority.request_id) != (launch_resource.owner, launch_resource.request_id):
|
||||
raise ValueError("Background launch authority changed")
|
||||
_JOBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
log_path = _JOBS_DIR / f"{job_id}.log"
|
||||
@@ -94,6 +108,7 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
|
||||
from src import containment
|
||||
from src.agent_tools.subprocess_tools import _owned_spec, _replace_workspace_alias
|
||||
spec = _owned_spec(cwd or os.getcwd(), env, max_runtime_s)
|
||||
validate_launch_spec(launch_resource, spec)
|
||||
grant = containment.acquire(spec, owner=f"bg:{session_id}")
|
||||
bounded_command = command
|
||||
if containment.FILESYSTEM not in grant.enforced:
|
||||
@@ -147,16 +162,33 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
|
||||
"start_token": process_ownership.capture(proc.pid)["start_token"],
|
||||
}
|
||||
try:
|
||||
supervisor = ProcessResource("native:bg_jobs", launch_resource.owner, launch_resource.request_id,
|
||||
launch_resource.thread_id, ProcessIdentity(proc.pid, rec["start_token"], rec["pgid"]),
|
||||
"supervisor", job_id, grant.id)
|
||||
supervisor.validate()
|
||||
resource = BackgroundJobResource("native:bg_jobs", job_id, launch_resource.generation,
|
||||
launch_resource.owner, launch_resource.request_id, launch_resource.thread_id, grant.id, (supervisor,))
|
||||
rec["resource_identity"] = resource.to_dict()
|
||||
rec["launch_resource"] = launch_resource.to_dict()
|
||||
containment._update_record(grant.id, lifetime="background", supervisor_pid=proc.pid,
|
||||
supervisor_token=rec["start_token"])
|
||||
supervisor_token=rec["start_token"], launch_generation=resource.generation)
|
||||
jobs = _load()
|
||||
jobs[job_id] = rec
|
||||
_save(jobs)
|
||||
publish_launch(launch_resource, authority, grant.id, job=resource, processes=(supervisor,))
|
||||
save_background_authority(job_id, authority, resource=resource)
|
||||
payload.update(job_store=str(_STORE.resolve()), job_id=job_id,
|
||||
launch_path=str(launch_path(resource.generation)),
|
||||
authority_path=str(_JOBS_DIR / (job_id + ".authority.json")),
|
||||
resource_identity=resource.to_dict(), launch_resource=launch_resource.to_dict())
|
||||
# The supervisor cannot execute until the identity and job record are durable.
|
||||
proc.stdin.write(json.dumps(payload).encode("utf-8"))
|
||||
proc.stdin.close()
|
||||
except BaseException:
|
||||
kill_process_tree(proc.pid)
|
||||
# EOF closes the unreleased worker even if identity observation failed.
|
||||
if proc.stdin is not None and not proc.stdin.closed:
|
||||
proc.stdin.close()
|
||||
kill_process_tree(proc.pid, start_token=rec["start_token"], pgid=rec["pgid"], require_identity=True)
|
||||
proc.wait(timeout=5)
|
||||
containment.release(grant, grace_s=0)
|
||||
raise
|
||||
@@ -194,16 +226,20 @@ def _prune(jobs: Dict[str, Dict[str, Any]], now: float) -> bool:
|
||||
|
||||
|
||||
@store_transaction(lambda: _STORE)
|
||||
def refresh() -> Dict[str, Dict[str, Any]]:
|
||||
def refresh(job_id=None) -> Dict[str, Dict[str, Any]]:
|
||||
"""Reconcile every running job against disk. Marks done/failed (incl.
|
||||
timeout). Idempotent — safe to call from a poll loop. Returns the store."""
|
||||
jobs = _load()
|
||||
for pid, proc in list(_LIVE_PROCS.items()):
|
||||
if job_id is not None and pid != jobs.get(job_id, {}).get("pid"):
|
||||
continue
|
||||
if proc.poll() is not None:
|
||||
_LIVE_PROCS.pop(pid, None)
|
||||
changed = False
|
||||
now = time.time()
|
||||
for rec in jobs.values():
|
||||
for jid, rec in jobs.items():
|
||||
if job_id is not None and jid != job_id:
|
||||
continue
|
||||
if rec.get("status") != "running":
|
||||
continue
|
||||
exit_path = Path(rec.get("exit_path", ""))
|
||||
@@ -218,7 +254,15 @@ def refresh() -> Dict[str, Dict[str, Any]]:
|
||||
if rec.get("result_path"):
|
||||
try:
|
||||
report = json.loads(Path(rec["result_path"]).read_text(encoding="utf-8"))
|
||||
rec.update(report)
|
||||
# Result publication is not an identity producer. It cannot
|
||||
# overwrite ownership, generations, PIDs, paths or authority.
|
||||
if rec.get("resource_identity") and report.get("resource_identity") != rec["resource_identity"]:
|
||||
raise ValueError("Result/job linkage mismatch")
|
||||
if report.get("containment", {}).get("id") != rec.get("containment_id"):
|
||||
raise ValueError("Result/receipt linkage mismatch")
|
||||
for key in ("containment", "teardown", "output_truncated", "timed_out", "error", "failure_kind"):
|
||||
if key in report:
|
||||
rec[key] = report[key]
|
||||
except (OSError, ValueError):
|
||||
rec["status"], rec["exit_code"] = "failed", 1
|
||||
rec["result_unavailable"] = True
|
||||
@@ -243,7 +287,7 @@ def refresh() -> Dict[str, Dict[str, Any]]:
|
||||
rec["ended_at"] = now
|
||||
rec["died"] = True
|
||||
changed = True
|
||||
if _prune(jobs, now):
|
||||
if job_id is None and _prune(jobs, now):
|
||||
changed = True
|
||||
if changed:
|
||||
_save(jobs)
|
||||
@@ -288,28 +332,45 @@ def pending_followups() -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
@store_transaction(lambda: _STORE)
|
||||
def mark_followed_up(job_id: str) -> None:
|
||||
def mark_followed_up(job_id: str, *, expected) -> None:
|
||||
jobs = _load()
|
||||
if job_id in jobs:
|
||||
from src.agent_runtime.process_resources import validate_job
|
||||
if expected.job_id != job_id:
|
||||
raise ValueError("Acknowledgement job resource changed")
|
||||
validate_job(expected, mutation=True)
|
||||
jobs[job_id]["followed_up"] = True
|
||||
_save(jobs)
|
||||
|
||||
|
||||
def get(job_id: str) -> Optional[Dict[str, Any]]:
|
||||
refresh() # reconcile against disk so status/exit_code are current
|
||||
def peek(job_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve one record without reaping or changing any job."""
|
||||
return _load().get(job_id)
|
||||
|
||||
|
||||
def get(job_id: str, *, expected) -> Optional[Dict[str, Any]]:
|
||||
from src.agent_runtime.process_resources import validate_job
|
||||
if expected.job_id != job_id:
|
||||
raise ValueError("Output job selector changed")
|
||||
validate_job(expected)
|
||||
refresh(job_id)
|
||||
validate_job(expected)
|
||||
rec = _load().get(job_id)
|
||||
if rec:
|
||||
from src.agent_runtime.process_resources import job_from_record
|
||||
if job_from_record(rec) != expected:
|
||||
raise ValueError("Output job resource changed")
|
||||
rec = dict(rec)
|
||||
rec["output"] = _read_output(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def list_for_session(session_id: str) -> List[Dict[str, Any]]:
|
||||
return [r for r in refresh().values() if r.get("session_id") == session_id]
|
||||
return [r for r in _load().values() if r.get("session_id") == session_id]
|
||||
|
||||
|
||||
@store_transaction(lambda: _STORE)
|
||||
def kill(job_id: str) -> Optional[Dict[str, Any]]:
|
||||
def kill(job_id: str, *, expected) -> Optional[Dict[str, Any]]:
|
||||
"""Terminate a running job's process tree and mark it killed. Returns the
|
||||
updated record, or None if the id is unknown. Idempotent: a job that already
|
||||
finished is returned unchanged. Sets followed_up so the monitor does not also
|
||||
@@ -318,6 +379,10 @@ def kill(job_id: str) -> Optional[Dict[str, Any]]:
|
||||
rec = jobs.get(job_id)
|
||||
if rec is None:
|
||||
return None
|
||||
from src.agent_runtime.process_resources import validate_job
|
||||
if expected.job_id != job_id:
|
||||
raise ValueError("Job selector changed")
|
||||
validate_job(expected, mutation=True)
|
||||
if rec.get("status") == "running":
|
||||
outcome = _kill_record(rec)
|
||||
rec["teardown"] = outcome.to_dict()
|
||||
|
||||
+13
-1
@@ -140,6 +140,17 @@ async def _run_followup(rec: dict) -> bool:
|
||||
from src.settings import get_setting
|
||||
authority = restore_background_authority(
|
||||
rec["id"], owner=getattr(sess, "owner", None), session_id=sess.id)
|
||||
# A result can trigger a continuation only through the immutable producer
|
||||
# linkage, never merely because it names an existing chat.
|
||||
from src.agent_runtime.process_resources import job_from_record, validate_job
|
||||
try:
|
||||
resource = job_from_record(rec)
|
||||
validate_job(resource)
|
||||
if not authority.grants or (resource.owner, resource.thread_id, resource.request_id) != (
|
||||
str(getattr(sess, "owner", None) or "").strip().casefold(), sess.id, authority.request_id):
|
||||
return False
|
||||
except (ValueError, TypeError, OSError, RuntimeError):
|
||||
return False
|
||||
authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ())
|
||||
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():
|
||||
try:
|
||||
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:
|
||||
# Idempotent: leave followed_up=False so the next tick retries.
|
||||
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]:
|
||||
"""Shared subprocess runner. Wraps the blocking subprocess.run in
|
||||
asyncio.to_thread so the event loop stays responsive."""
|
||||
import asyncio
|
||||
import subprocess
|
||||
"""Scheduled local work consumes the request's sealed launch ceiling."""
|
||||
from src.agent_runtime.authority import active_request_authority, ExactOperation
|
||||
from src.agent_runtime.process_resources import resolve_process_operation, bind_process_operation
|
||||
from src.agent_runtime.resources import NativeBackendResource
|
||||
from src.agent_tools.subprocess_tools import _run_owned_command
|
||||
authority = active_request_authority()
|
||||
if authority is None:
|
||||
return "Scheduled process launch has no server authority.", False
|
||||
if isinstance(argv, list) and argv and argv[0] == "ssh":
|
||||
return "Remote scheduled workload requires an exact external backend binding.", False
|
||||
command = argv[-1] if isinstance(argv, list) else argv
|
||||
operation = ExactOperation.normalize("bash", command)
|
||||
if not authority.permits(operation):
|
||||
return "Scheduled launch differs from the sealed operation.", False
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run, argv, shell=shell, capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
output = (result.stdout or "").strip()
|
||||
if result.returncode != 0 and result.stderr:
|
||||
output += "\nSTDERR: " + result.stderr.strip()
|
||||
return output or "(no output)", result.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"{label} timed out ({timeout}s)", False
|
||||
except Exception as e:
|
||||
return str(e), False
|
||||
bound = resolve_process_operation(authority, operation, NativeBackendResource("bash"))
|
||||
with bind_process_operation(bound):
|
||||
result = await _run_owned_command(command, {"owner": authority.owner,
|
||||
"session_id": authority.session_id}, tool="bash", timeout=timeout)
|
||||
return result.get("output") or result.get("error") or "(no output)", result.get("exit_code") == 0
|
||||
except (ValueError, OSError, RuntimeError) as error:
|
||||
return str(error), False
|
||||
|
||||
|
||||
async def action_ssh_command(owner: str, command: str = "", host: str = "localhost", **kwargs) -> Tuple[str, bool]:
|
||||
|
||||
@@ -89,6 +89,7 @@ EMOJI_CACHE_DIR = os.path.join(DATA_DIR, "emoji_cache")
|
||||
RAG_DIR = os.path.join(DATA_DIR, "rag")
|
||||
CHROMA_DIR = os.path.join(DATA_DIR, "chroma")
|
||||
BG_JOBS_DIR = os.path.join(DATA_DIR, "bg_jobs")
|
||||
PROCESS_RESOURCES_DIR = os.path.join(DATA_DIR, "process_resources")
|
||||
DEEP_RESEARCH_DIR = os.path.join(DATA_DIR, "deep_research")
|
||||
MCP_OAUTH_DIR = os.path.join(DATA_DIR, "mcp_oauth")
|
||||
GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images")
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import signal
|
||||
import sys
|
||||
import types
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Launch by absolute script path, so a task workspace cannot shadow src.
|
||||
@@ -39,6 +40,40 @@ async def supervise(payload: dict) -> None:
|
||||
loop.add_signal_handler(signal.SIGTERM, task.cancel)
|
||||
loop.add_signal_handler(signal.SIGINT, task.cancel)
|
||||
try:
|
||||
# The supervisor is held on stdin until *all* publication succeeds.
|
||||
# No legacy payload can reconstruct ownership from its PID or receipt.
|
||||
job = json.loads(Path(payload["job_store"]).read_text())[payload["job_id"]]
|
||||
published = json.loads(Path(payload["launch_path"]).read_text())
|
||||
sidecar = json.loads(Path(payload["authority_path"]).read_text())
|
||||
resource = payload["resource_identity"]
|
||||
launch = payload["launch_resource"]
|
||||
from src.agent_runtime.resources import ProcessLaunchResource, BackgroundJobResource
|
||||
from src.agent_runtime.process_resources import validate_launch_spec, validate_job_receipt
|
||||
typed_launch = ProcessLaunchResource.from_dict(launch)
|
||||
typed_job = BackgroundJobResource.from_dict(resource)
|
||||
typed_launch.validate()
|
||||
validate_launch_spec(typed_launch, spec)
|
||||
supervisor = typed_job.processes[0]
|
||||
supervisor.validate()
|
||||
receipt = containment._load_records().get(grant.id)
|
||||
validate_job_receipt(typed_job, receipt)
|
||||
if (supervisor.identity.pid != os.getpid()
|
||||
or (typed_job.owner, typed_job.request_id, typed_job.thread_id) !=
|
||||
(typed_launch.owner, typed_launch.request_id, typed_launch.thread_id)
|
||||
or (published["authority"]["owner"], published["authority"]["request_id"], published["authority"]["session_id"]) !=
|
||||
(typed_job.owner, typed_job.request_id, typed_job.thread_id)):
|
||||
raise ValueError("Detached producer ownership changed")
|
||||
if (job.get("resource_identity") != resource or job.get("launch_resource") != launch
|
||||
or published.get("job") != resource or published.get("launch") != launch
|
||||
or sidecar.get("job") != resource or sidecar.get("authority") != published.get("authority")
|
||||
or published.get("containment_id") != grant.id
|
||||
or (receipt.get("owner"), receipt.get("mechanism"), receipt.get("mode"), receipt.get("workspace")) !=
|
||||
(grant.owner, grant.mechanism, grant.mode, spec.workspace)
|
||||
or info.get("external") is True
|
||||
or resource["containment_id"] != grant.id
|
||||
or resource["generation"] != launch["generation"]
|
||||
or receipt.get("launch_generation") != launch["generation"]):
|
||||
raise ValueError("Detached launch authority linkage mismatch")
|
||||
with open(payload["log_path"], "w", encoding="utf-8") as log:
|
||||
def capture(text):
|
||||
log.write(text)
|
||||
@@ -75,6 +110,7 @@ async def supervise(payload: dict) -> None:
|
||||
except OSError:
|
||||
# A failed log initialization must not hide completion metadata.
|
||||
sys.stderr.write(output)
|
||||
report["resource_identity"] = payload.get("resource_identity")
|
||||
atomic_write_json(payload["result_path"], report)
|
||||
# Publish completion last: refresh must never see an exit without metadata.
|
||||
atomic_write_text(payload["exit_path"], str(code if code is not None else 1))
|
||||
|
||||
@@ -31,6 +31,7 @@ if TYPE_CHECKING:
|
||||
from src.agent_runtime.resource_binding import BoundFilesystemOperation
|
||||
from src.agent_runtime.remote_resources import BoundBackendOperation
|
||||
from src.agent_runtime.owned_resources import BoundOwnedOperation
|
||||
from src.agent_runtime.process_resources import BoundProcessOperation
|
||||
|
||||
|
||||
DEFAULT_APPROVAL_TTL_SECONDS = 10 * 60
|
||||
@@ -127,6 +128,7 @@ def _binding_payload(
|
||||
resource_operation=None,
|
||||
backend_operation=None,
|
||||
owned_operation=None,
|
||||
process_operation=None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"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,
|
||||
"backend_operation": backend_operation.to_dict() if backend_operation is not None else None,
|
||||
"owned_operation": owned_operation.to_dict() if owned_operation is not None else None,
|
||||
"process_operation": process_operation.to_dict() if process_operation is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +187,7 @@ class PendingToolApproval:
|
||||
resource_operation: BoundFilesystemOperation | None = None
|
||||
backend_operation: BoundBackendOperation | None = None
|
||||
owned_operation: BoundOwnedOperation | None = None
|
||||
process_operation: BoundProcessOperation | None = None
|
||||
|
||||
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -296,6 +300,7 @@ class ExactToolApproval:
|
||||
resource_operation=self.pending.resource_operation,
|
||||
backend_operation=self.pending.backend_operation,
|
||||
owned_operation=self.pending.owned_operation,
|
||||
process_operation=self.pending.process_operation,
|
||||
)
|
||||
return _canonical_digest(expected) == self.pending.digest
|
||||
|
||||
@@ -391,6 +396,7 @@ class ToolApprovalStore:
|
||||
resource_operation = None
|
||||
backend_operation = None
|
||||
owned_operation = None
|
||||
process_operation = None
|
||||
from src.agent_runtime.remote_resources import BoundBackendOperation, resolve_backend
|
||||
from src.agent_runtime.owned_resources import needs_owned_binding, resolve_owned_operation
|
||||
from src.agent_runtime.resources import NativeBackendResource
|
||||
@@ -403,6 +409,9 @@ class ToolApprovalStore:
|
||||
backend_operation = BoundBackendOperation(backend,
|
||||
request_authority.request_id if request_authority is not None else "",
|
||||
_normalized_owner(owner), str(session_id or ""), operation.transport_tool, operation.input)
|
||||
from src.agent_runtime.process_resources import needs_process_binding, resolve_process_operation
|
||||
if request_authority is not None and needs_process_binding(operation, backend):
|
||||
process_operation = resolve_process_operation(request_authority, operation, backend)
|
||||
if isinstance(backend, NativeBackendResource) and needs_owned_binding(operation):
|
||||
resolved_owned = resolve_owned_operation(operation, owner=_normalized_owner(owner),
|
||||
thread_id=str(session_id or ""), request_id=backend_operation.request_id,
|
||||
@@ -450,6 +459,7 @@ class ToolApprovalStore:
|
||||
resource_operation=resource_operation,
|
||||
backend_operation=backend_operation,
|
||||
owned_operation=owned_operation,
|
||||
process_operation=process_operation,
|
||||
)
|
||||
pending = PendingToolApproval(
|
||||
approval_id=secrets.token_urlsafe(32),
|
||||
@@ -477,6 +487,7 @@ class ToolApprovalStore:
|
||||
resource_operation=resource_operation,
|
||||
backend_operation=backend_operation,
|
||||
owned_operation=owned_operation,
|
||||
process_operation=process_operation,
|
||||
)
|
||||
with self._lock:
|
||||
self._purge_expired_locked(now)
|
||||
|
||||
+27
-4
@@ -978,7 +978,10 @@ def vet_workspace(raw: str) -> Optional[str]:
|
||||
def agent_cwd() -> str:
|
||||
"""Working directory for agent subprocesses (bash/python/background jobs):
|
||||
the active workspace when set, else the persistent data dir."""
|
||||
return get_active_workspace() or _AGENT_WORKDIR
|
||||
from src.agent_runtime.process_resources import active_process_operation
|
||||
bound = active_process_operation()
|
||||
return (bound.launch.scope.root.path if bound is not None and bound.launch is not None
|
||||
else get_active_workspace() or _AGENT_WORKDIR)
|
||||
|
||||
|
||||
def get_mcp_manager():
|
||||
@@ -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.authority import (
|
||||
MISSING_AUTHORITY, ExactOperation, RequestAuthority, active_request_authority,
|
||||
bind_request_authority, save_background_authority,
|
||||
bind_request_authority,
|
||||
)
|
||||
from src.agent_runtime.process_resources import (
|
||||
active_process_operation, bind_process_operation, needs_process_binding, resolve_process_operation,
|
||||
)
|
||||
|
||||
|
||||
@@ -1415,6 +1421,12 @@ async def execute_tool_block(
|
||||
exact_admission=exact_admission)
|
||||
external_resource_call = isinstance(backend_operation.resource, ExternalResource)
|
||||
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 pending is not None and pending.owned_operation is None:
|
||||
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)
|
||||
try:
|
||||
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
|
||||
sealed_document = owned_operation or (exact_approval.pending if approval_claimed else None)
|
||||
with (bind_request_authority(authority), bind_resource_operation(resource_operation),
|
||||
bind_backend_operation(backend_operation), bind_owned_operation(owned_operation)):
|
||||
bind_backend_operation(backend_operation), bind_owned_operation(owned_operation),
|
||||
bind_process_operation(process_operation)):
|
||||
output = await _execute_tool_block_impl(
|
||||
ToolBlock(transport, normalized.execution_input) if normalized is not None else block,
|
||||
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")
|
||||
# Only this server launch may seal detached-job authority; a
|
||||
# handler/bridge output carrying a job id is not a grant source.
|
||||
save_background_authority(rec["id"], active_request_authority())
|
||||
short = _bg_cmd.strip().split(chr(10))[0][:80]
|
||||
desc = f"bash (background): {short}"
|
||||
result = {
|
||||
@@ -1833,6 +1847,15 @@ async def _execute_tool_block_impl(
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
if tool == "edit_file":
|
||||
desc = result.get("output") or result.get("error") or "edit_file"
|
||||
elif tool in {"bash", "python"} and backend is not None and isinstance(backend.resource, NativeBackendResource):
|
||||
# Native reservations are pinned to the native producer. Pass the
|
||||
# application binding explicitly rather than the MCP fallback's empty
|
||||
# owner/session context.
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}"
|
||||
result = await dispatched(_direct_fallback(tool, content, progress_cb=progress_cb,
|
||||
owner=owner, session_id=session_id, client_runtime_context=client_runtime_context)) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
elif tool in _MCP_TOOL_MAP:
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}"
|
||||
|
||||
@@ -1227,8 +1227,8 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
|
||||
)
|
||||
target_label = f"{session_id} on {remote}"
|
||||
else:
|
||||
cmd = f"tmux kill-session -t {shlex.quote(session_id)}"
|
||||
target_label = session_id
|
||||
return {"error": "Local Cookbook control has no admitted process resource; session discovery is not ownership",
|
||||
"exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
|
||||
|
||||
# Capture what this session owns BEFORE the kill. Once tmux tears the
|
||||
# session down the pane is gone, and with it the only evidence linking a
|
||||
|
||||
@@ -10,7 +10,7 @@ from src import containment
|
||||
def capture_owned_spawn(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path.parent / (tmp_path.name + "-control") / "grants.json")
|
||||
monkeypatch.setattr(containment, "_pgid_of", lambda pid: pid)
|
||||
|
||||
async def fake_exec(*argv, **kwargs):
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Explicit trusted producer fixtures; no production authority fallback."""
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority
|
||||
from src.agent_runtime.resources import BackgroundJobResource, NativeBackendResource, ProcessResource
|
||||
from src.agent_runtime.process_resources import bind_process_operation, resolve_process_operation, publish_launch
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
|
||||
|
||||
@contextmanager
|
||||
def launch_authority(content, workspace, *, tool="bash", owner="", session_id="chat", authority=None):
|
||||
authority = authority or RequestAuthority("producer-test", owner, session_id, str(workspace), (OperationGrant(tool),))
|
||||
bound = resolve_process_operation(authority, ExactOperation.normalize(tool, content), NativeBackendResource(tool))
|
||||
with bind_request_authority(authority), bind_process_operation(bound):
|
||||
yield authority, bound
|
||||
|
||||
|
||||
def launch(command, session_id="chat", *, cwd, **kwargs):
|
||||
from src import bg_jobs
|
||||
with launch_authority(command, cwd, session_id=session_id):
|
||||
return bg_jobs.launch(command, session_id, cwd=cwd, **kwargs)
|
||||
|
||||
|
||||
def identity(job_id):
|
||||
from src import bg_jobs
|
||||
from src.agent_runtime.process_resources import job_from_record
|
||||
return job_from_record(bg_jobs.peek(job_id))
|
||||
|
||||
|
||||
def get(job_id):
|
||||
from src import bg_jobs
|
||||
return bg_jobs.get(job_id, expected=identity(job_id))
|
||||
|
||||
|
||||
def kill(job_id):
|
||||
from src import bg_jobs
|
||||
return bg_jobs.kill(job_id, expected=identity(job_id))
|
||||
|
||||
|
||||
def seed_linkage(record, workspace, *, owner="", request_id="producer-test"):
|
||||
"""A fake server spawn record, with an explicit fake lifecycle observation."""
|
||||
from src import bg_jobs, containment
|
||||
from src.agent_runtime.authority import save_background_authority
|
||||
from src.agent_runtime.process_resources import resolve_process_operation
|
||||
authority = RequestAuthority(request_id, owner, record["session_id"], str(workspace), (OperationGrant("bash"),))
|
||||
bound = resolve_process_operation(authority, ExactOperation.normalize("bash", record["command"]), NativeBackendResource("bash"))
|
||||
receipt = uuid4().hex
|
||||
record.update(containment_id=receipt, start_token="test-boot:start", pgid=record["pid"])
|
||||
process = ProcessResource("native:bg_jobs", owner, request_id, record["session_id"],
|
||||
ProcessIdentity(record["pid"], record["start_token"], record["pgid"]), "supervisor", record["id"], receipt)
|
||||
resource = BackgroundJobResource("native:bg_jobs", record["id"], bound.launch.generation,
|
||||
owner, request_id, record["session_id"], receipt, (process,))
|
||||
record.update(resource_identity=resource.to_dict(), launch_resource=bound.launch.to_dict())
|
||||
from core.atomic_io import atomic_write_json
|
||||
receipts = containment._load_records()
|
||||
receipts[receipt] = {"id": receipt, "launch_generation": resource.generation,
|
||||
"owner": "bg:" + resource.thread_id, "supervisor_pid": process.identity.pid,
|
||||
"supervisor_token": process.identity.start_token, "mechanism": "process_group"}
|
||||
atomic_write_json(containment._store_path(), receipts)
|
||||
publish_launch(bound.launch, authority, receipt, job=resource, processes=(process,))
|
||||
save_background_authority(record["id"], authority, resource=resource)
|
||||
return resource
|
||||
|
||||
|
||||
def authorized_handler(handler, workspace):
|
||||
async def execute(content, ctx):
|
||||
from src.agent_runtime.process_resources import active_process_operation
|
||||
from src.agent_runtime.authority import active_request_authority
|
||||
if active_process_operation() is not None or active_request_authority() is not None:
|
||||
return await handler(content, ctx)
|
||||
tool = "python" if handler.__qualname__.startswith("PythonTool") else "bash"
|
||||
from src.agent_runtime.resources import FilesystemRoot
|
||||
from src.agent_runtime.process_resources import seal_launch_scope
|
||||
owner = str(ctx.get("owner") or "").casefold()
|
||||
authority = RequestAuthority("producer-test", owner, str(ctx.get("session_id") or ""), str(workspace), (OperationGrant(tool),))
|
||||
authority = replace(authority, launch_scopes=(seal_launch_scope(NativeBackendResource(tool),
|
||||
FilesystemRoot.seal(workspace, owner=owner), env=ctx.get("subproc_env")),))
|
||||
with launch_authority(content, workspace, tool=tool, authority=authority):
|
||||
return await handler(content, ctx)
|
||||
return execute
|
||||
|
||||
|
||||
def install_native_authority(monkeypatch, workspace):
|
||||
from src.agent_tools import subprocess_tools
|
||||
from src import tool_execution
|
||||
from src.constants import DATA_DIR
|
||||
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
|
||||
original = cls.execute
|
||||
async def execute(self, content, ctx, _original=original):
|
||||
selected = Path(tool_execution.agent_cwd())
|
||||
if selected == Path(DATA_DIR):
|
||||
selected = Path(workspace)
|
||||
return await authorized_handler(_original.__get__(self), selected)(content, ctx)
|
||||
monkeypatch.setattr(cls, "execute", execute)
|
||||
@@ -15,6 +15,11 @@ def server_authorized_executor(executor):
|
||||
from src.tool_policy import known_tool_names
|
||||
from src.turn_contract import canonical_tool
|
||||
from src.agent_runtime.remote_resources import seal_backends
|
||||
from src.agent_runtime.resources import FilesystemRoot, NativeBackendResource, ProcessLaunchScope
|
||||
from src.containment import DEFAULT_REQUIRED
|
||||
from src.agent_runtime.process_resources import seal_launch_scope
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
call_signature = signature(executor)
|
||||
@wraps(executor)
|
||||
async def execute(*args, **kwargs):
|
||||
@@ -22,10 +27,19 @@ def server_authorized_executor(executor):
|
||||
parameters = bound.arguments
|
||||
grants = tuple(OperationGrant(name) for name in sorted(
|
||||
{canonical_tool(n) for n in known_tool_names()} | {"list_dir", "find_files"}))
|
||||
original = parameters.get("exact_approval")
|
||||
authority = original.pending.request_authority if original is not None else None
|
||||
if authority is not None:
|
||||
kwargs.setdefault("request_authority", authority)
|
||||
scratch = Path(tempfile.mkdtemp(prefix="odysseus-dispatch-fixture-"))
|
||||
launch_scopes = (None if parameters.get("workspace") else tuple(
|
||||
seal_launch_scope(NativeBackendResource(tool), FilesystemRoot.seal(scratch))
|
||||
for tool in ("bash", "python")))
|
||||
kwargs.setdefault("request_authority", RequestAuthority(
|
||||
"standalone-test-request", str(parameters.get("owner") or "").strip().casefold(),
|
||||
str(parameters.get("session_id") or ""), str(parameters.get("workspace") or ""),
|
||||
grants,
|
||||
launch_scopes=launch_scopes,
|
||||
backend_resources=seal_backends((g.tool for g in grants), context=parameters.get("client_runtime_context"),
|
||||
owner=str(parameters.get("owner") or "").strip().casefold()),
|
||||
))
|
||||
|
||||
@@ -18,7 +18,8 @@ async def test_a_chat_session_always_uses_the_owned_runner(monkeypatch, tmp_path
|
||||
async def forbidden(*args, **kwargs):
|
||||
pytest.fail("native Bash resurrected a persistent tmux shell")
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", forbidden)
|
||||
result = await subprocess_tools.BashTool().execute("printf ok", {"session_id": "same-chat"})
|
||||
from tests.process_resource_helpers import authorized_handler
|
||||
result = await authorized_handler(subprocess_tools.BashTool().execute, tmp_path)("printf ok", {"session_id": "same-chat"})
|
||||
assert result["output"] == "ok"
|
||||
assert result["teardown"]["dead"] is True
|
||||
assert "tmux_session" not in result
|
||||
|
||||
@@ -9,10 +9,15 @@ import pytest
|
||||
from src import bg_jobs, containment, process_ownership, process_reaper, tool_execution
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
from tests.process_resource_helpers import launch, get, kill
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jobs(tmp_path, monkeypatch):
|
||||
from src.agent_runtime import process_resources
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", tmp_path / "jobs")
|
||||
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "jobs.json")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
||||
@@ -20,11 +25,11 @@ def jobs(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(containment, "MECHANISMS", tuple(m for m in containment.MECHANISMS if m.name == "process_group"))
|
||||
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
|
||||
launched = []
|
||||
yield tmp_path, launched
|
||||
yield workspace, launched
|
||||
for record in launched:
|
||||
current = bg_jobs.get(record["id"])
|
||||
current = get(record["id"])
|
||||
if current and current["status"] == "running":
|
||||
bg_jobs.kill(record["id"])
|
||||
kill(record["id"])
|
||||
proc = bg_jobs._LIVE_PROCS.pop(record["pid"], None)
|
||||
if proc:
|
||||
proc.wait(timeout=8)
|
||||
@@ -33,7 +38,7 @@ def jobs(tmp_path, monkeypatch):
|
||||
def finished(job_id):
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
record = bg_jobs.get(job_id)
|
||||
record = get(job_id)
|
||||
if record["status"] != "running":
|
||||
return record
|
||||
time.sleep(0.03)
|
||||
@@ -42,7 +47,7 @@ def finished(job_id):
|
||||
|
||||
def test_detached_execution_owns_boundary_and_reports_death(jobs):
|
||||
path, launched = jobs
|
||||
record = bg_jobs.launch("printf captured", "chat", cwd=str(path))
|
||||
record = launch("printf captured", "chat", cwd=str(path))
|
||||
launched.append(record)
|
||||
result = finished(record["id"])
|
||||
assert result["output"] == "captured"
|
||||
@@ -73,7 +78,7 @@ def test_supervisor_setup_failure_closes_unstarted_grant(jobs):
|
||||
result = subprocess.run([sys.executable, str(worker)], input=json.dumps(payload),
|
||||
capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode == 0 # Supervisor publishes the failed job result.
|
||||
assert "FileNotFoundError" in result.stderr
|
||||
assert "KeyError" in result.stderr # Legacy unlinked payload fails before execution.
|
||||
assert not (path / "must-not-exist").exists()
|
||||
assert containment.active_grants() == []
|
||||
assert (path / "exit").read_text() == "1"
|
||||
@@ -102,7 +107,7 @@ async def test_bg_marker_refuses_without_spawning_and_authority_still_gates(jobs
|
||||
|
||||
def test_detached_supervisor_enforces_timeout(jobs):
|
||||
path, launched = jobs
|
||||
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
|
||||
record = launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
|
||||
launched.append(record)
|
||||
result = finished(record["id"])
|
||||
assert result["timed_out"] is True
|
||||
@@ -111,11 +116,11 @@ def test_detached_supervisor_enforces_timeout(jobs):
|
||||
|
||||
def test_restart_keeps_verified_background_supervisor(jobs):
|
||||
path, launched = jobs
|
||||
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path))
|
||||
record = launch("sleep 60", "chat", cwd=str(path))
|
||||
launched.append(record)
|
||||
report = process_reaper.reap_containment_grants()
|
||||
assert report["background_kept"] == 1
|
||||
killed = bg_jobs.kill(record["id"])
|
||||
killed = kill(record["id"])
|
||||
assert killed["killed"] is True
|
||||
assert killed["teardown"]["dead"] is True
|
||||
|
||||
@@ -126,16 +131,14 @@ def test_kill_never_marks_a_foreign_pid_killed(jobs, monkeypatch):
|
||||
bg_jobs._save({"stale": record})
|
||||
monkeypatch.setattr(process_ownership, "verify", lambda *args: process_ownership.FOREIGN)
|
||||
monkeypatch.setattr(bg_jobs, "_kill", lambda *args, **kwargs: pytest.fail("foreign process signalled"))
|
||||
result = bg_jobs.kill("stale")
|
||||
assert result["status"] == "running"
|
||||
assert result.get("killed") is not True
|
||||
assert result["teardown"]["dead"] is False
|
||||
result = bg_jobs._kill_record(record) # Service cleanup still refuses foreign identity.
|
||||
assert result.dead is False
|
||||
|
||||
|
||||
def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
|
||||
path, launched = jobs
|
||||
for number in range(3):
|
||||
launched.append(bg_jobs.launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
|
||||
launched.append(launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
|
||||
for number, record in enumerate(launched):
|
||||
assert finished(record["id"])["output"] == f"job-{number}"
|
||||
grants = containment._load_records()
|
||||
@@ -145,11 +148,11 @@ def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
|
||||
|
||||
def test_detached_output_is_available_while_running(jobs):
|
||||
path, launched = jobs
|
||||
record = bg_jobs.launch("printf progress; sleep 5", "chat", cwd=str(path))
|
||||
record = launch("printf progress; sleep 5", "chat", cwd=str(path))
|
||||
launched.append(record)
|
||||
deadline = time.monotonic() + 3
|
||||
while time.monotonic() < deadline:
|
||||
current = bg_jobs.get(record["id"])
|
||||
current = get(record["id"])
|
||||
if "progress" in current["output"]:
|
||||
assert current["status"] == "running"
|
||||
return
|
||||
|
||||
@@ -0,0 +1,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.agent_tools.bg_job_tools import ManageBgJobsTool
|
||||
from tests.process_resource_helpers import seed_linkage, get, kill
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
from src.agent_runtime import process_resources
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "receipts.json")
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(bg_jobs, "_test_workspace", workspace, raising=False)
|
||||
monkeypatch.setattr(containment, "reap_record", lambda *a: containment.ReleaseOutcome(dead=True, escalated=False))
|
||||
jobs_dir = tmp_path / "bg_jobs"
|
||||
jobs_dir.mkdir()
|
||||
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "bg_jobs.json")
|
||||
@@ -43,6 +51,7 @@ def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pi
|
||||
}
|
||||
if output:
|
||||
(bg_jobs._JOBS_DIR / f"{job_id}.log").write_text(output, encoding="utf-8")
|
||||
seed_linkage(rec, bg_jobs._test_workspace)
|
||||
jobs = bg_jobs._load()
|
||||
jobs[job_id] = rec
|
||||
bg_jobs._save(jobs)
|
||||
@@ -50,14 +59,24 @@ def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pi
|
||||
|
||||
|
||||
def _run(args, session_id="sess-a"):
|
||||
return asyncio.run(ManageBgJobsTool().execute(json.dumps(args), {"session_id": session_id, "owner": None}))
|
||||
from src.agent_runtime.authority import RequestAuthority, OperationGrant, ExactOperation, bind_request_authority
|
||||
from src.agent_runtime.resources import NativeBackendResource
|
||||
from src.agent_runtime.process_resources import resolve_process_operation, bind_process_operation
|
||||
content = json.dumps(args)
|
||||
authority = RequestAuthority("job-client-test", "", session_id, "", (OperationGrant("manage_bg_jobs"),))
|
||||
try:
|
||||
bound = resolve_process_operation(authority, ExactOperation.normalize("manage_bg_jobs", content), NativeBackendResource("manage_bg_jobs"))
|
||||
with bind_request_authority(authority), bind_process_operation(bound):
|
||||
return asyncio.run(ManageBgJobsTool().execute(content, {"session_id": session_id, "owner": None}))
|
||||
except (ValueError, OSError) as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
|
||||
# ── bg_jobs.kill ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_kill_marks_killed_and_suppresses_followup(store):
|
||||
_seed(job_id="job0001", pid=4321)
|
||||
rec = bg_jobs.kill("job0001")
|
||||
rec = kill("job0001")
|
||||
assert rec["status"] == "failed"
|
||||
assert rec["killed"] is True
|
||||
assert rec["exit_code"] == -1
|
||||
@@ -67,20 +86,20 @@ def test_kill_marks_killed_and_suppresses_followup(store):
|
||||
|
||||
|
||||
def test_kill_unknown_job_returns_none(store):
|
||||
assert bg_jobs.kill("nope") is None
|
||||
assert bg_jobs.kill("nope", expected=None) is None
|
||||
|
||||
|
||||
def test_kill_finished_job_is_noop(store):
|
||||
_seed(job_id="done01", status="done")
|
||||
rec = bg_jobs.kill("done01")
|
||||
rec = kill("done01")
|
||||
assert rec["status"] == "done"
|
||||
assert store["killed"] == [] # no signal sent to an already-finished job
|
||||
|
||||
|
||||
def test_result_text_reports_killed(store):
|
||||
rec = _seed(job_id="job0001")
|
||||
bg_jobs.kill("job0001")
|
||||
assert "killed" in bg_jobs.result_text(bg_jobs.get("job0001")).lower()
|
||||
kill("job0001")
|
||||
assert "killed" in bg_jobs.result_text(get("job0001")).lower()
|
||||
|
||||
|
||||
# ── manage_bg_jobs tool ─────────────────────────────────────────────────────
|
||||
@@ -118,7 +137,7 @@ def test_kill_via_tool(store):
|
||||
out = _run({"action": "kill", "job_id": "job0001"})
|
||||
assert "Killed" in out["output"]
|
||||
assert store["killed"] == [999]
|
||||
assert bg_jobs.get("job0001")["killed"] is True
|
||||
assert get("job0001")["killed"] is True
|
||||
|
||||
|
||||
def test_kill_cross_session_denied(store):
|
||||
|
||||
@@ -17,6 +17,10 @@ def workspace(tmp_path, monkeypatch):
|
||||
path.mkdir()
|
||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(path))
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
||||
from tests.process_resource_helpers import install_native_authority
|
||||
from src.agent_runtime import process_resources
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
install_native_authority(monkeypatch, path)
|
||||
return path
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
"""Stopping a Cookbook server, on a host with procfs and on one without.
|
||||
"""Cookbook selectors and OS observations never mint application authority.
|
||||
|
||||
The tmux kill is what actually stops the server; the pid sweep that follows it
|
||||
only catches model servers that survive the session's SIGHUP. Two invariants
|
||||
live here.
|
||||
|
||||
**The stop must not fail because the host cannot be inspected.** Letting a
|
||||
procfs scan raise on macOS turned a successful stop into a reported failure and
|
||||
skipped the state write that marks the session stopped for the Cookbook UI
|
||||
(ODY-94). Skipping the sweep silently fixed the crash and left the other half:
|
||||
the stop then claimed success without having looked at all. So the sweep now
|
||||
runs through ``ps`` where there is no procfs, and says so when it cannot look.
|
||||
|
||||
**The sweep signals only processes the session owns.** It used to kill anything
|
||||
whose full command line matched the tracked one. The Cookbook composed that
|
||||
command line, so an identical one is just as likely to be a server the user
|
||||
started by hand — killing it is indistinguishable from killing ours, which is
|
||||
the "stop only what we started" failure. Ownership now comes from the tmux
|
||||
pane's process tree, captured before the kill; a lookalike is reported instead.
|
||||
These legacy UI-backed targets have no authoritative launch registry. Local
|
||||
agent stops therefore fail closed before discovery, signalling or state writes,
|
||||
on both procfs and other hosts. Shared Wave 5B lifecycle mechanics are tested
|
||||
separately in test_process_lifecycle and test_process_ownership.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
@@ -160,7 +147,7 @@ def _install_effective_kill(monkeypatch, table):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
|
||||
async def test_unadmitted_stop_refused_when_the_host_has_no_procfs(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""The ODY-94 regression: no procfs must not turn a working stop into a failure."""
|
||||
@@ -176,13 +163,12 @@ async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["output"].startswith("Stopped server serve-abc123")
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_says_so_when_the_session_cannot_be_inspected(
|
||||
async def test_unadmitted_stop_refused_when_the_session_cannot_be_inspected(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""A sweep that could not look must not read as a sweep that found nothing.
|
||||
@@ -209,15 +195,14 @@ async def test_stop_says_so_when_the_session_cannot_be_inspected(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "could not identify the session's processes" in result["output"]
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert signalled == []
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
|
||||
"""A process under the session's pane is ours, so it gets signalled."""
|
||||
async def test_pane_descendant_is_not_application_owned(monkeypatch, tmp_path):
|
||||
"""A process under a named pane still requires prior application admission."""
|
||||
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
|
||||
state = _tracked_state(cmd=tracked_cmd)
|
||||
posts = _install_httpx_client(monkeypatch, state)
|
||||
@@ -232,14 +217,13 @@ async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert (101, signal.SIGTERM) in signalled
|
||||
assert "killed 2 surviving process(es)" in result["output"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert signalled == [] # OS lineage alone never establishes app ownership.
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_reports_a_command_line_lookalike_without_signalling_it(
|
||||
async def test_unadmitted_stop_never_signals_a_command_line_lookalike(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""The headline change: matching the command line is not owning the process.
|
||||
@@ -262,13 +246,9 @@ async def test_stop_reports_a_command_line_lookalike_without_signalling_it(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert not any(pid == 202 for pid, _sig in signalled)
|
||||
# Reported rather than silently dropped: the old behaviour acted on this
|
||||
# information, so giving it up entirely would be a regression of its own.
|
||||
assert "202" in result["output"]
|
||||
assert "not signalled" in result["output"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -302,10 +282,10 @@ async def test_stop_does_not_signal_a_pid_whose_identity_changed(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
# The pane shell is genuinely ours and is signalled; 101 never is.
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
# Neither pane discovery nor a matching token creates application scope.
|
||||
assert not any(pid == 101 for pid, _sig in signalled)
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
|
||||
@@ -323,8 +303,8 @@ def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tmp_path):
|
||||
"""Captured as ours, unverifiable at sweep time: not signalled, and said so."""
|
||||
async def test_unadmitted_stop_refused_with_unverifiable_process(monkeypatch, tmp_path):
|
||||
"""An unverifiable OS observation cannot create an application grant."""
|
||||
from src import process_ownership
|
||||
|
||||
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
|
||||
@@ -347,10 +327,9 @@ async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tm
|
||||
|
||||
result = await tools.do_stop_served_model(json.dumps({"session_id": "serve-abc123"}))
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert not any(pid == 101 for pid, _sig in signalled)
|
||||
assert "could not be re-identified and were not signalled (pid 101)" in result["output"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -383,6 +362,6 @@ async def test_stop_never_signals_a_pid_reissued_between_the_table_and_its_captu
|
||||
|
||||
result = await tools.do_stop_served_model(json.dumps({"session_id": "serve-abc123"}))
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["failure_kind"] == "resource_identity_denied"
|
||||
assert not any(pid == 101 for pid, _sig in signalled)
|
||||
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
|
||||
assert _stopped_statuses(posts, "serve-abc123") == []
|
||||
|
||||
@@ -11,13 +11,23 @@ from src.agent_tools import subprocess_tools
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def native_boundary(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
||||
from src.agent_runtime import process_resources
|
||||
from tests.process_resource_helpers import authorized_handler
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(workspace))
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "grants.json")
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
|
||||
original = cls.execute
|
||||
async def execute(self, content, ctx, _original=original):
|
||||
return await authorized_handler(_original.__get__(self), workspace)(content, ctx)
|
||||
monkeypatch.setattr(cls, "execute", execute)
|
||||
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
|
||||
monkeypatch.setattr(containment, "MECHANISMS", tuple(
|
||||
m for m in containment.MECHANISMS if m.name == "process_group"
|
||||
))
|
||||
return tmp_path
|
||||
return workspace
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="real POSIX group teardown")
|
||||
|
||||
@@ -399,10 +399,16 @@ def test_already_finished_jobs_are_not_reconsidered(job_store, monkeypatch):
|
||||
assert bg_jobs.disown_unverified() == {"seen": 0, "retired": 0, "kept": 0}
|
||||
|
||||
|
||||
def test_a_launched_job_records_an_identity_next_to_its_pid(job_store):
|
||||
def test_a_launched_job_records_an_identity_next_to_its_pid(job_store, tmp_path, monkeypatch):
|
||||
"""Without this the record is unverifiable forever and the reaper can only
|
||||
refuse — the token has to be captured at launch or not at all."""
|
||||
record = bg_jobs.launch("true", "chat-1")
|
||||
from tests.process_resource_helpers import launch
|
||||
from src.agent_runtime import process_resources
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "grants.json")
|
||||
record = launch("true", "chat-1", cwd=str(workspace))
|
||||
|
||||
assert "start_token" in record
|
||||
assert process_ownership.verify(record["pid"], record["start_token"]) in (
|
||||
|
||||
@@ -0,0 +1,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):
|
||||
"""4. Native local Bash/Python behavior is unchanged."""
|
||||
tool_bash = subprocess_tools.BashTool()
|
||||
from tests.process_resource_helpers import authorized_handler
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(_te, "agent_cwd", lambda: str(workspace))
|
||||
ctx = {
|
||||
"session_id": "native-session",
|
||||
}
|
||||
result = await tool_bash.execute("echo 'native run'", ctx)
|
||||
result = await authorized_handler(tool_bash.execute, workspace)("echo 'native run'", ctx)
|
||||
assert result["exit_code"] == 0
|
||||
assert "native run" in result["output"]
|
||||
assert "containment" in result
|
||||
|
||||
@@ -158,15 +158,15 @@ async def test_missing_and_malformed_dispatch_authority_fail_closed(monkeypatch,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch):
|
||||
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch, tmp_path):
|
||||
from src import tool_execution as execution
|
||||
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||
for disabled in (set(), {"bash"}):
|
||||
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"),
|
||||
owner="alice", session_id="s", disabled_tools=disabled,
|
||||
owner="alice", session_id="s", workspace=str(tmp_path), disabled_tools=disabled,
|
||||
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=authority("bash"))
|
||||
request_authority=authority("bash", workspace=str(tmp_path)))
|
||||
assert result["exit_code"] == (1 if disabled else 0)
|
||||
assert implementation.await_count == 1
|
||||
|
||||
@@ -224,10 +224,11 @@ def test_background_snapshot_preserves_scope_and_rejects_other_session(monkeypat
|
||||
import src.constants
|
||||
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
||||
grant = authority("transcribe_media").restrict(disabled_tools={"bash"})
|
||||
save_background_authority("job1", grant)
|
||||
# Legacy authority-only snapshots have no exact job generation to restore.
|
||||
with pytest.raises(ValueError):
|
||||
save_background_authority("job1", grant)
|
||||
restored = restore_background_authority("job1", owner="alice", session_id="s")
|
||||
assert restored.request_id == grant.request_id
|
||||
assert restored.denied == frozenset({"bash"})
|
||||
assert restored.grants == ()
|
||||
assert not restored.permits(ExactOperation.normalize("python", "print(1)"))
|
||||
assert restore_background_authority("job1", owner="alice", session_id="other").grants == ()
|
||||
|
||||
@@ -243,8 +244,7 @@ async def test_only_server_background_launch_can_seal_job_authority(monkeypatch,
|
||||
owner="alice", session_id="s", security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=authority("bash"))
|
||||
restored = restore_background_authority("server-job", owner="alice", session_id="s")
|
||||
assert restored.request_id == "request-test"
|
||||
assert restored.permits(ExactOperation.normalize("bash", "printf trusted"))
|
||||
assert restored.grants == () # A launch double returning an ID cannot publish authority.
|
||||
handler = AsyncMock(return_value=("transcribe_media", {"bg_job_id": "forged-job", "exit_code": 0}))
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
|
||||
await execution.execute_tool_block(ToolBlock("transcribe_media", '{}'),
|
||||
@@ -254,12 +254,16 @@ async def test_only_server_background_launch_can_seal_job_authority(monkeypatch,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch):
|
||||
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch, tmp_path):
|
||||
from src import tool_execution as execution
|
||||
from src.tool_approvals import ToolApprovalStore
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
store = ToolApprovalStore()
|
||||
original = authority("transcribe_media")
|
||||
from src.agent_runtime.resources import ProcessLaunchScope, FilesystemRoot, NativeBackendResource
|
||||
from src.containment import DEFAULT_REQUIRED
|
||||
original = replace(original, launch_scopes=(ProcessLaunchScope(NativeBackendResource("bash"),
|
||||
FilesystemRoot.seal(tmp_path), DEFAULT_REQUIRED),))
|
||||
pending = store.create(owner="alice", session_id="s", origin_run_id="journal-parent",
|
||||
tool_name="bash", content="printf approved", workspace=None,
|
||||
external_untrusted_context_seen=True, capabilities=capabilities_for_action("bash", "printf approved"),
|
||||
|
||||
@@ -397,8 +397,10 @@ def test_task_and_background_continuations_keep_original_roots(tmp_path, monkeyp
|
||||
import src.constants
|
||||
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
||||
grant = authority(tmp_path, "read_file")
|
||||
save_background_authority("job", grant)
|
||||
assert restore_background_authority("job", owner="alice", session_id="s").resource_roots == grant.resource_roots
|
||||
# A roots-only sidecar is legacy state and cannot invent a job generation.
|
||||
with pytest.raises(ValueError):
|
||||
save_background_authority("job", grant)
|
||||
assert restore_background_authority("job", owner="alice", session_id="s").resource_roots == ()
|
||||
assert restore_background_authority("job", owner="bob", session_id="s").resource_roots == ()
|
||||
with bind_request_authority(grant):
|
||||
sealed = seal_task_authority("Read files in the workspace", "llm", None, owner="alice")
|
||||
@@ -708,10 +710,11 @@ def test_nonfilesystem_identities_are_inert_and_distinguish_producers_from_pages
|
||||
page = BrowserPageResource(producer, "page-1", 2, "https://example.test")
|
||||
assert replace(producer, incarnation="incarnation-2") != producer
|
||||
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")
|
||||
assert ExternalResource("mcp", "endpoint", "server", "tool", "connection").external is True
|
||||
with pytest.raises(ValueError):
|
||||
ExternalResource("mcp", "endpoint", "server", "tool", "connection", external=False)
|
||||
with pytest.raises(ValueError):
|
||||
ProcessResource("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"),
|
||||
}
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,19 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def native_resource_authority(tmp_path, monkeypatch):
|
||||
from tests.process_resource_helpers import install_native_authority
|
||||
from src.agent_runtime import process_resources
|
||||
from src import containment
|
||||
workspace = tmp_path / "native-workspace"
|
||||
workspace.mkdir()
|
||||
control = tmp_path.parent / (tmp_path.name + "-control")
|
||||
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", control / "launches")
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: control / "grants.json")
|
||||
install_native_authority(monkeypatch, workspace)
|
||||
|
||||
|
||||
def test_unoffered_artifact_recovery_is_bounded():
|
||||
from src.agent_loop import _artifact_unoffered_recovery_exhausted
|
||||
|
||||
|
||||
Reference in New Issue
Block a user