feat(runtime): bind process and job resources to authority

This commit is contained in:
Alexandre Teixeira
2026-10-02 18:54:09 +01:00
parent 7b8ac6f631
commit db41d7e822
36 changed files with 2251 additions and 174 deletions
@@ -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.
+14 -1
View File
@@ -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
View File
@@ -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):
+67 -15
View File
@@ -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)
+2 -1
View File
@@ -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")
+418
View File
@@ -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
View File
@@ -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)
+19 -3
View File
@@ -67,8 +67,20 @@ class ManageBgJobsTool:
if not session_id:
return {"error": "manage_bg_jobs: no active chat session; background jobs are scoped to a chat.", "exit_code": 1}
from src.agent_runtime.process_resources import active_process_operation, expected_job, require_process_admission
from src.agent_runtime.resources import ResourceIdentityError
bound = active_process_operation()
if bound is None or (bound.owner, bound.thread_id) != (str(ctx.get("owner") or "").strip().casefold(), session_id):
return {"error": "manage_bg_jobs: no exact server resource binding", "exit_code": 1,
"blocked": True, "failure_kind": "resource_identity_denied"}
from src.agent_runtime.authority import ExactOperation
if bound.operation != ExactOperation.normalize("manage_bg_jobs", raw or "{}"):
return {"error": "Job operation changed at producer entry", "exit_code": 1, "blocked": True}
require_process_admission(bound)
if action in _LIST_ACTIONS:
jobs: List[Dict[str, Any]] = bg_jobs.list_for_session(session_id)
bound.validate()
jobs: List[Dict[str, Any]] = [bg_jobs.peek(j.job_id) for j in bound.jobs]
if not jobs:
return {"output": "No background jobs in this chat.", "exit_code": 0}
jobs.sort(key=lambda r: r.get("started_at") or 0, reverse=True)
@@ -78,7 +90,11 @@ class ManageBgJobsTool:
if action in _OUTPUT_ACTIONS or action in _KILL_ACTIONS:
if not job_id:
return {"error": f"manage_bg_jobs: action '{action}' requires a job_id (see action='list').", "exit_code": 1}
rec = bg_jobs.get(job_id)
try:
resource = expected_job(job_id, action=action)
rec = bg_jobs.get(job_id, expected=resource)
except (ResourceIdentityError, OSError, ValueError) as error:
return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
# Scope: only the chat that launched a job may see or control it.
if rec is None or rec.get("session_id") != session_id:
return {"error": f"manage_bg_jobs: no background job '{job_id}' in this chat.", "exit_code": 1}
@@ -86,7 +102,7 @@ class ManageBgJobsTool:
if action in _KILL_ACTIONS:
if rec.get("status") != "running":
return {"output": f"Job `{job_id}` already {_status_label(rec)}; nothing to kill.", "exit_code": 0}
killed = bg_jobs.kill(job_id)
killed = bg_jobs.kill(job_id, expected=resource)
if not killed or not killed.get("killed"):
return {"error": f"Could not verify termination of background job `{job_id}`.",
"exit_code": 1, "teardown": (killed or {}).get("teardown")}
+38 -5
View File
@@ -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
View File
@@ -86,6 +86,20 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
A trusted detached supervisor owns the shared containment runner, output,
wall clock and exit metadata, independently of the request/server lifetime.
"""
from src.agent_runtime.process_resources import require_launch, active_process_operation, publish_launch, launch_path, validate_launch_spec
from src.agent_runtime.authority import active_request_authority, save_background_authority
from src.agent_runtime.resources import ProcessResource, BackgroundJobResource
from src.process_lifecycle import ProcessIdentity
cwd = cwd or os.getcwd()
launch_resource = require_launch("bash", cwd=cwd)
bound = active_process_operation()
from src.tool_execution import _split_bg_marker
marked, proposed = _split_bg_marker(bound.operation.input)
if command != (proposed if marked else bound.operation.input).strip() or session_id != launch_resource.thread_id:
raise ValueError("Background launch operation or session changed")
authority = active_request_authority()
if authority is None or (authority.owner, authority.request_id) != (launch_resource.owner, launch_resource.request_id):
raise ValueError("Background launch authority changed")
_JOBS_DIR.mkdir(parents=True, exist_ok=True)
job_id = uuid.uuid4().hex[:12]
log_path = _JOBS_DIR / f"{job_id}.log"
@@ -94,6 +108,7 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
from src import containment
from src.agent_tools.subprocess_tools import _owned_spec, _replace_workspace_alias
spec = _owned_spec(cwd or os.getcwd(), env, max_runtime_s)
validate_launch_spec(launch_resource, spec)
grant = containment.acquire(spec, owner=f"bg:{session_id}")
bounded_command = command
if containment.FILESYSTEM not in grant.enforced:
@@ -147,16 +162,33 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
"start_token": process_ownership.capture(proc.pid)["start_token"],
}
try:
supervisor = ProcessResource("native:bg_jobs", launch_resource.owner, launch_resource.request_id,
launch_resource.thread_id, ProcessIdentity(proc.pid, rec["start_token"], rec["pgid"]),
"supervisor", job_id, grant.id)
supervisor.validate()
resource = BackgroundJobResource("native:bg_jobs", job_id, launch_resource.generation,
launch_resource.owner, launch_resource.request_id, launch_resource.thread_id, grant.id, (supervisor,))
rec["resource_identity"] = resource.to_dict()
rec["launch_resource"] = launch_resource.to_dict()
containment._update_record(grant.id, lifetime="background", supervisor_pid=proc.pid,
supervisor_token=rec["start_token"])
supervisor_token=rec["start_token"], launch_generation=resource.generation)
jobs = _load()
jobs[job_id] = rec
_save(jobs)
publish_launch(launch_resource, authority, grant.id, job=resource, processes=(supervisor,))
save_background_authority(job_id, authority, resource=resource)
payload.update(job_store=str(_STORE.resolve()), job_id=job_id,
launch_path=str(launch_path(resource.generation)),
authority_path=str(_JOBS_DIR / (job_id + ".authority.json")),
resource_identity=resource.to_dict(), launch_resource=launch_resource.to_dict())
# The supervisor cannot execute until the identity and job record are durable.
proc.stdin.write(json.dumps(payload).encode("utf-8"))
proc.stdin.close()
except BaseException:
kill_process_tree(proc.pid)
# EOF closes the unreleased worker even if identity observation failed.
if proc.stdin is not None and not proc.stdin.closed:
proc.stdin.close()
kill_process_tree(proc.pid, start_token=rec["start_token"], pgid=rec["pgid"], require_identity=True)
proc.wait(timeout=5)
containment.release(grant, grace_s=0)
raise
@@ -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
View File
@@ -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
View File
@@ -878,22 +878,28 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
async def _run_subprocess(argv, *, shell: bool = False, timeout: int = 120, label: str = "Command") -> Tuple[str, bool]:
"""Shared subprocess runner. Wraps the blocking subprocess.run in
asyncio.to_thread so the event loop stays responsive."""
import asyncio
import subprocess
"""Scheduled local work consumes the request's sealed launch ceiling."""
from src.agent_runtime.authority import active_request_authority, ExactOperation
from src.agent_runtime.process_resources import resolve_process_operation, bind_process_operation
from src.agent_runtime.resources import NativeBackendResource
from src.agent_tools.subprocess_tools import _run_owned_command
authority = active_request_authority()
if authority is None:
return "Scheduled process launch has no server authority.", False
if isinstance(argv, list) and argv and argv[0] == "ssh":
return "Remote scheduled workload requires an exact external backend binding.", False
command = argv[-1] if isinstance(argv, list) else argv
operation = ExactOperation.normalize("bash", command)
if not authority.permits(operation):
return "Scheduled launch differs from the sealed operation.", False
try:
result = await asyncio.to_thread(
subprocess.run, argv, shell=shell, capture_output=True, text=True, timeout=timeout,
)
output = (result.stdout or "").strip()
if result.returncode != 0 and result.stderr:
output += "\nSTDERR: " + result.stderr.strip()
return output or "(no output)", result.returncode == 0
except subprocess.TimeoutExpired:
return f"{label} timed out ({timeout}s)", False
except Exception as e:
return str(e), False
bound = resolve_process_operation(authority, operation, NativeBackendResource("bash"))
with bind_process_operation(bound):
result = await _run_owned_command(command, {"owner": authority.owner,
"session_id": authority.session_id}, tool="bash", timeout=timeout)
return result.get("output") or result.get("error") or "(no output)", result.get("exit_code") == 0
except (ValueError, OSError, RuntimeError) as error:
return str(error), False
async def action_ssh_command(owner: str, command: str = "", host: str = "localhost", **kwargs) -> Tuple[str, bool]:
+1
View File
@@ -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")
+36
View File
@@ -6,6 +6,7 @@ import json
import signal
import sys
import types
import os
from pathlib import Path
# Launch by absolute script path, so a task workspace cannot shadow src.
@@ -39,6 +40,40 @@ async def supervise(payload: dict) -> None:
loop.add_signal_handler(signal.SIGTERM, task.cancel)
loop.add_signal_handler(signal.SIGINT, task.cancel)
try:
# The supervisor is held on stdin until *all* publication succeeds.
# No legacy payload can reconstruct ownership from its PID or receipt.
job = json.loads(Path(payload["job_store"]).read_text())[payload["job_id"]]
published = json.loads(Path(payload["launch_path"]).read_text())
sidecar = json.loads(Path(payload["authority_path"]).read_text())
resource = payload["resource_identity"]
launch = payload["launch_resource"]
from src.agent_runtime.resources import ProcessLaunchResource, BackgroundJobResource
from src.agent_runtime.process_resources import validate_launch_spec, validate_job_receipt
typed_launch = ProcessLaunchResource.from_dict(launch)
typed_job = BackgroundJobResource.from_dict(resource)
typed_launch.validate()
validate_launch_spec(typed_launch, spec)
supervisor = typed_job.processes[0]
supervisor.validate()
receipt = containment._load_records().get(grant.id)
validate_job_receipt(typed_job, receipt)
if (supervisor.identity.pid != os.getpid()
or (typed_job.owner, typed_job.request_id, typed_job.thread_id) !=
(typed_launch.owner, typed_launch.request_id, typed_launch.thread_id)
or (published["authority"]["owner"], published["authority"]["request_id"], published["authority"]["session_id"]) !=
(typed_job.owner, typed_job.request_id, typed_job.thread_id)):
raise ValueError("Detached producer ownership changed")
if (job.get("resource_identity") != resource or job.get("launch_resource") != launch
or published.get("job") != resource or published.get("launch") != launch
or sidecar.get("job") != resource or sidecar.get("authority") != published.get("authority")
or published.get("containment_id") != grant.id
or (receipt.get("owner"), receipt.get("mechanism"), receipt.get("mode"), receipt.get("workspace")) !=
(grant.owner, grant.mechanism, grant.mode, spec.workspace)
or info.get("external") is True
or resource["containment_id"] != grant.id
or resource["generation"] != launch["generation"]
or receipt.get("launch_generation") != launch["generation"]):
raise ValueError("Detached launch authority linkage mismatch")
with open(payload["log_path"], "w", encoding="utf-8") as log:
def capture(text):
log.write(text)
@@ -75,6 +110,7 @@ async def supervise(payload: dict) -> None:
except OSError:
# A failed log initialization must not hide completion metadata.
sys.stderr.write(output)
report["resource_identity"] = payload.get("resource_identity")
atomic_write_json(payload["result_path"], report)
# Publish completion last: refresh must never see an exit without metadata.
atomic_write_text(payload["exit_path"], str(code if code is not None else 1))
+11
View File
@@ -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
View File
@@ -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}"
+2 -2
View File
@@ -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
+1 -1
View File
@@ -10,7 +10,7 @@ from src import containment
def capture_owned_spawn(monkeypatch, tmp_path):
captured = {}
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path.parent / (tmp_path.name + "-control") / "grants.json")
monkeypatch.setattr(containment, "_pgid_of", lambda pid: pid)
async def fake_exec(*argv, **kwargs):
+98
View File
@@ -0,0 +1,98 @@
"""Explicit trusted producer fixtures; no production authority fallback."""
from contextlib import contextmanager
from dataclasses import replace
import json
from pathlib import Path
from uuid import uuid4
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority
from src.agent_runtime.resources import BackgroundJobResource, NativeBackendResource, ProcessResource
from src.agent_runtime.process_resources import bind_process_operation, resolve_process_operation, publish_launch
from src.process_lifecycle import ProcessIdentity
@contextmanager
def launch_authority(content, workspace, *, tool="bash", owner="", session_id="chat", authority=None):
authority = authority or RequestAuthority("producer-test", owner, session_id, str(workspace), (OperationGrant(tool),))
bound = resolve_process_operation(authority, ExactOperation.normalize(tool, content), NativeBackendResource(tool))
with bind_request_authority(authority), bind_process_operation(bound):
yield authority, bound
def launch(command, session_id="chat", *, cwd, **kwargs):
from src import bg_jobs
with launch_authority(command, cwd, session_id=session_id):
return bg_jobs.launch(command, session_id, cwd=cwd, **kwargs)
def identity(job_id):
from src import bg_jobs
from src.agent_runtime.process_resources import job_from_record
return job_from_record(bg_jobs.peek(job_id))
def get(job_id):
from src import bg_jobs
return bg_jobs.get(job_id, expected=identity(job_id))
def kill(job_id):
from src import bg_jobs
return bg_jobs.kill(job_id, expected=identity(job_id))
def seed_linkage(record, workspace, *, owner="", request_id="producer-test"):
"""A fake server spawn record, with an explicit fake lifecycle observation."""
from src import bg_jobs, containment
from src.agent_runtime.authority import save_background_authority
from src.agent_runtime.process_resources import resolve_process_operation
authority = RequestAuthority(request_id, owner, record["session_id"], str(workspace), (OperationGrant("bash"),))
bound = resolve_process_operation(authority, ExactOperation.normalize("bash", record["command"]), NativeBackendResource("bash"))
receipt = uuid4().hex
record.update(containment_id=receipt, start_token="test-boot:start", pgid=record["pid"])
process = ProcessResource("native:bg_jobs", owner, request_id, record["session_id"],
ProcessIdentity(record["pid"], record["start_token"], record["pgid"]), "supervisor", record["id"], receipt)
resource = BackgroundJobResource("native:bg_jobs", record["id"], bound.launch.generation,
owner, request_id, record["session_id"], receipt, (process,))
record.update(resource_identity=resource.to_dict(), launch_resource=bound.launch.to_dict())
from core.atomic_io import atomic_write_json
receipts = containment._load_records()
receipts[receipt] = {"id": receipt, "launch_generation": resource.generation,
"owner": "bg:" + resource.thread_id, "supervisor_pid": process.identity.pid,
"supervisor_token": process.identity.start_token, "mechanism": "process_group"}
atomic_write_json(containment._store_path(), receipts)
publish_launch(bound.launch, authority, receipt, job=resource, processes=(process,))
save_background_authority(record["id"], authority, resource=resource)
return resource
def authorized_handler(handler, workspace):
async def execute(content, ctx):
from src.agent_runtime.process_resources import active_process_operation
from src.agent_runtime.authority import active_request_authority
if active_process_operation() is not None or active_request_authority() is not None:
return await handler(content, ctx)
tool = "python" if handler.__qualname__.startswith("PythonTool") else "bash"
from src.agent_runtime.resources import FilesystemRoot
from src.agent_runtime.process_resources import seal_launch_scope
owner = str(ctx.get("owner") or "").casefold()
authority = RequestAuthority("producer-test", owner, str(ctx.get("session_id") or ""), str(workspace), (OperationGrant(tool),))
authority = replace(authority, launch_scopes=(seal_launch_scope(NativeBackendResource(tool),
FilesystemRoot.seal(workspace, owner=owner), env=ctx.get("subproc_env")),))
with launch_authority(content, workspace, tool=tool, authority=authority):
return await handler(content, ctx)
return execute
def install_native_authority(monkeypatch, workspace):
from src.agent_tools import subprocess_tools
from src import tool_execution
from src.constants import DATA_DIR
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
original = cls.execute
async def execute(self, content, ctx, _original=original):
selected = Path(tool_execution.agent_cwd())
if selected == Path(DATA_DIR):
selected = Path(workspace)
return await authorized_handler(_original.__get__(self), selected)(content, ctx)
monkeypatch.setattr(cls, "execute", execute)
+14
View File
@@ -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()),
))
+2 -1
View File
@@ -18,7 +18,8 @@ async def test_a_chat_session_always_uses_the_owned_runner(monkeypatch, tmp_path
async def forbidden(*args, **kwargs):
pytest.fail("native Bash resurrected a persistent tmux shell")
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", forbidden)
result = await subprocess_tools.BashTool().execute("printf ok", {"session_id": "same-chat"})
from tests.process_resource_helpers import authorized_handler
result = await authorized_handler(subprocess_tools.BashTool().execute, tmp_path)("printf ok", {"session_id": "same-chat"})
assert result["output"] == "ok"
assert result["teardown"]["dead"] is True
assert "tmux_session" not in result
+19 -16
View File
@@ -9,10 +9,15 @@ import pytest
from src import bg_jobs, containment, process_ownership, process_reaper, tool_execution
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
from tests.runtime_evidence_helpers import server_authorized_executor
from tests.process_resource_helpers import launch, get, kill
@pytest.fixture
def jobs(tmp_path, monkeypatch):
from src.agent_runtime import process_resources
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", tmp_path / "jobs")
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "jobs.json")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
@@ -20,11 +25,11 @@ def jobs(tmp_path, monkeypatch):
monkeypatch.setattr(containment, "MECHANISMS", tuple(m for m in containment.MECHANISMS if m.name == "process_group"))
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
launched = []
yield tmp_path, launched
yield workspace, launched
for record in launched:
current = bg_jobs.get(record["id"])
current = get(record["id"])
if current and current["status"] == "running":
bg_jobs.kill(record["id"])
kill(record["id"])
proc = bg_jobs._LIVE_PROCS.pop(record["pid"], None)
if proc:
proc.wait(timeout=8)
@@ -33,7 +38,7 @@ def jobs(tmp_path, monkeypatch):
def finished(job_id):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
record = bg_jobs.get(job_id)
record = get(job_id)
if record["status"] != "running":
return record
time.sleep(0.03)
@@ -42,7 +47,7 @@ def finished(job_id):
def test_detached_execution_owns_boundary_and_reports_death(jobs):
path, launched = jobs
record = bg_jobs.launch("printf captured", "chat", cwd=str(path))
record = launch("printf captured", "chat", cwd=str(path))
launched.append(record)
result = finished(record["id"])
assert result["output"] == "captured"
@@ -73,7 +78,7 @@ def test_supervisor_setup_failure_closes_unstarted_grant(jobs):
result = subprocess.run([sys.executable, str(worker)], input=json.dumps(payload),
capture_output=True, text=True, timeout=10)
assert result.returncode == 0 # Supervisor publishes the failed job result.
assert "FileNotFoundError" in result.stderr
assert "KeyError" in result.stderr # Legacy unlinked payload fails before execution.
assert not (path / "must-not-exist").exists()
assert containment.active_grants() == []
assert (path / "exit").read_text() == "1"
@@ -102,7 +107,7 @@ async def test_bg_marker_refuses_without_spawning_and_authority_still_gates(jobs
def test_detached_supervisor_enforces_timeout(jobs):
path, launched = jobs
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
record = launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
launched.append(record)
result = finished(record["id"])
assert result["timed_out"] is True
@@ -111,11 +116,11 @@ def test_detached_supervisor_enforces_timeout(jobs):
def test_restart_keeps_verified_background_supervisor(jobs):
path, launched = jobs
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path))
record = launch("sleep 60", "chat", cwd=str(path))
launched.append(record)
report = process_reaper.reap_containment_grants()
assert report["background_kept"] == 1
killed = bg_jobs.kill(record["id"])
killed = kill(record["id"])
assert killed["killed"] is True
assert killed["teardown"]["dead"] is True
@@ -126,16 +131,14 @@ def test_kill_never_marks_a_foreign_pid_killed(jobs, monkeypatch):
bg_jobs._save({"stale": record})
monkeypatch.setattr(process_ownership, "verify", lambda *args: process_ownership.FOREIGN)
monkeypatch.setattr(bg_jobs, "_kill", lambda *args, **kwargs: pytest.fail("foreign process signalled"))
result = bg_jobs.kill("stale")
assert result["status"] == "running"
assert result.get("killed") is not True
assert result["teardown"]["dead"] is False
result = bg_jobs._kill_record(record) # Service cleanup still refuses foreign identity.
assert result.dead is False
def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
path, launched = jobs
for number in range(3):
launched.append(bg_jobs.launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
launched.append(launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
for number, record in enumerate(launched):
assert finished(record["id"])["output"] == f"job-{number}"
grants = containment._load_records()
@@ -145,11 +148,11 @@ def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
def test_detached_output_is_available_while_running(jobs):
path, launched = jobs
record = bg_jobs.launch("printf progress; sleep 5", "chat", cwd=str(path))
record = launch("printf progress; sleep 5", "chat", cwd=str(path))
launched.append(record)
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
current = bg_jobs.get(record["id"])
current = get(record["id"])
if "progress" in current["output"]:
assert current["status"] == "running"
return
+247
View File
@@ -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)
+26 -7
View File
@@ -13,10 +13,18 @@ import pytest
from src import bg_jobs, containment, process_ownership
from src.agent_tools.bg_job_tools import ManageBgJobsTool
from tests.process_resource_helpers import seed_linkage, get, kill
@pytest.fixture
def store(tmp_path, monkeypatch):
from src.agent_runtime import process_resources
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "receipts.json")
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(bg_jobs, "_test_workspace", workspace, raising=False)
monkeypatch.setattr(containment, "reap_record", lambda *a: containment.ReleaseOutcome(dead=True, escalated=False))
jobs_dir = tmp_path / "bg_jobs"
jobs_dir.mkdir()
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "bg_jobs.json")
@@ -43,6 +51,7 @@ def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pi
}
if output:
(bg_jobs._JOBS_DIR / f"{job_id}.log").write_text(output, encoding="utf-8")
seed_linkage(rec, bg_jobs._test_workspace)
jobs = bg_jobs._load()
jobs[job_id] = rec
bg_jobs._save(jobs)
@@ -50,14 +59,24 @@ def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pi
def _run(args, session_id="sess-a"):
return asyncio.run(ManageBgJobsTool().execute(json.dumps(args), {"session_id": session_id, "owner": None}))
from src.agent_runtime.authority import RequestAuthority, OperationGrant, ExactOperation, bind_request_authority
from src.agent_runtime.resources import NativeBackendResource
from src.agent_runtime.process_resources import resolve_process_operation, bind_process_operation
content = json.dumps(args)
authority = RequestAuthority("job-client-test", "", session_id, "", (OperationGrant("manage_bg_jobs"),))
try:
bound = resolve_process_operation(authority, ExactOperation.normalize("manage_bg_jobs", content), NativeBackendResource("manage_bg_jobs"))
with bind_request_authority(authority), bind_process_operation(bound):
return asyncio.run(ManageBgJobsTool().execute(content, {"session_id": session_id, "owner": None}))
except (ValueError, OSError) as e:
return {"error": str(e), "exit_code": 1}
# ── bg_jobs.kill ────────────────────────────────────────────────────────────
def test_kill_marks_killed_and_suppresses_followup(store):
_seed(job_id="job0001", pid=4321)
rec = bg_jobs.kill("job0001")
rec = kill("job0001")
assert rec["status"] == "failed"
assert rec["killed"] is True
assert rec["exit_code"] == -1
@@ -67,20 +86,20 @@ def test_kill_marks_killed_and_suppresses_followup(store):
def test_kill_unknown_job_returns_none(store):
assert bg_jobs.kill("nope") is None
assert bg_jobs.kill("nope", expected=None) is None
def test_kill_finished_job_is_noop(store):
_seed(job_id="done01", status="done")
rec = bg_jobs.kill("done01")
rec = kill("done01")
assert rec["status"] == "done"
assert store["killed"] == [] # no signal sent to an already-finished job
def test_result_text_reports_killed(store):
rec = _seed(job_id="job0001")
bg_jobs.kill("job0001")
assert "killed" in bg_jobs.result_text(bg_jobs.get("job0001")).lower()
kill("job0001")
assert "killed" in bg_jobs.result_text(get("job0001")).lower()
# ── manage_bg_jobs tool ─────────────────────────────────────────────────────
@@ -118,7 +137,7 @@ def test_kill_via_tool(store):
out = _run({"action": "kill", "job_id": "job0001"})
assert "Killed" in out["output"]
assert store["killed"] == [999]
assert bg_jobs.get("job0001")["killed"] is True
assert get("job0001")["killed"] is True
def test_kill_cross_session_denied(store):
+4
View File
@@ -17,6 +17,10 @@ def workspace(tmp_path, monkeypatch):
path.mkdir()
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(path))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
from tests.process_resource_helpers import install_native_authority
from src.agent_runtime import process_resources
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
install_native_authority(monkeypatch, path)
return path
+28 -49
View File
@@ -1,22 +1,9 @@
"""Stopping a Cookbook server, on a host with procfs and on one without.
"""Cookbook selectors and OS observations never mint application authority.
The tmux kill is what actually stops the server; the pid sweep that follows it
only catches model servers that survive the session's SIGHUP. Two invariants
live here.
**The stop must not fail because the host cannot be inspected.** Letting a
procfs scan raise on macOS turned a successful stop into a reported failure and
skipped the state write that marks the session stopped for the Cookbook UI
(ODY-94). Skipping the sweep silently fixed the crash and left the other half:
the stop then claimed success without having looked at all. So the sweep now
runs through ``ps`` where there is no procfs, and says so when it cannot look.
**The sweep signals only processes the session owns.** It used to kill anything
whose full command line matched the tracked one. The Cookbook composed that
command line, so an identical one is just as likely to be a server the user
started by hand — killing it is indistinguishable from killing ours, which is
the "stop only what we started" failure. Ownership now comes from the tmux
pane's process tree, captured before the kill; a lookalike is reported instead.
These legacy UI-backed targets have no authoritative launch registry. Local
agent stops therefore fail closed before discovery, signalling or state writes,
on both procfs and other hosts. Shared Wave 5B lifecycle mechanics are tested
separately in test_process_lifecycle and test_process_ownership.
"""
import asyncio
import json
@@ -160,7 +147,7 @@ def _install_effective_kill(monkeypatch, table):
@pytest.mark.asyncio
async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
async def test_unadmitted_stop_refused_when_the_host_has_no_procfs(
monkeypatch, tmp_path
):
"""The ODY-94 regression: no procfs must not turn a working stop into a failure."""
@@ -176,13 +163,12 @@ async def test_stop_marks_session_stopped_when_the_host_has_no_procfs(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert result["output"].startswith("Stopped server serve-abc123")
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert result["failure_kind"] == "resource_identity_denied"
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
async def test_stop_says_so_when_the_session_cannot_be_inspected(
async def test_unadmitted_stop_refused_when_the_session_cannot_be_inspected(
monkeypatch, tmp_path
):
"""A sweep that could not look must not read as a sweep that found nothing.
@@ -209,15 +195,14 @@ async def test_stop_says_so_when_the_session_cannot_be_inspected(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert "could not identify the session's processes" in result["output"]
assert result["failure_kind"] == "resource_identity_denied"
assert signalled == []
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
"""A process under the session's pane is ours, so it gets signalled."""
async def test_pane_descendant_is_not_application_owned(monkeypatch, tmp_path):
"""A process under a named pane still requires prior application admission."""
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
state = _tracked_state(cmd=tracked_cmd)
posts = _install_httpx_client(monkeypatch, state)
@@ -232,14 +217,13 @@ async def test_stop_kills_the_sessions_own_survivor(monkeypatch, tmp_path):
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert (101, signal.SIGTERM) in signalled
assert "killed 2 surviving process(es)" in result["output"]
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert result["failure_kind"] == "resource_identity_denied"
assert signalled == [] # OS lineage alone never establishes app ownership.
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
async def test_stop_reports_a_command_line_lookalike_without_signalling_it(
async def test_unadmitted_stop_never_signals_a_command_line_lookalike(
monkeypatch, tmp_path
):
"""The headline change: matching the command line is not owning the process.
@@ -262,13 +246,9 @@ async def test_stop_reports_a_command_line_lookalike_without_signalling_it(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
assert result["failure_kind"] == "resource_identity_denied"
assert not any(pid == 202 for pid, _sig in signalled)
# Reported rather than silently dropped: the old behaviour acted on this
# information, so giving it up entirely would be a regression of its own.
assert "202" in result["output"]
assert "not signalled" in result["output"]
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
@@ -302,10 +282,10 @@ async def test_stop_does_not_signal_a_pid_whose_identity_changed(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 0
# The pane shell is genuinely ours and is signalled; 101 never is.
assert result["failure_kind"] == "resource_identity_denied"
# Neither pane discovery nor a matching token creates application scope.
assert not any(pid == 101 for pid, _sig in signalled)
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
@@ -323,8 +303,8 @@ def test_model_process_scan_returns_empty_without_procfs(monkeypatch, tmp_path):
@pytest.mark.asyncio
async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tmp_path):
"""Captured as ours, unverifiable at sweep time: not signalled, and said so."""
async def test_unadmitted_stop_refused_with_unverifiable_process(monkeypatch, tmp_path):
"""An unverifiable OS observation cannot create an application grant."""
from src import process_ownership
tracked_cmd = "python -m vllm.entrypoints.openai.api_server --model org/model"
@@ -347,10 +327,9 @@ async def test_stop_reports_a_survivor_it_can_no_longer_identify(monkeypatch, tm
result = await tools.do_stop_served_model(json.dumps({"session_id": "serve-abc123"}))
assert result["exit_code"] == 0
assert result["failure_kind"] == "resource_identity_denied"
assert not any(pid == 101 for pid, _sig in signalled)
assert "could not be re-identified and were not signalled (pid 101)" in result["output"]
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
@pytest.mark.asyncio
@@ -383,6 +362,6 @@ async def test_stop_never_signals_a_pid_reissued_between_the_table_and_its_captu
result = await tools.do_stop_served_model(json.dumps({"session_id": "serve-abc123"}))
assert result["exit_code"] == 0
assert result["failure_kind"] == "resource_identity_denied"
assert not any(pid == 101 for pid, _sig in signalled)
assert _stopped_statuses(posts, "serve-abc123") == ["stopped"]
assert _stopped_statuses(posts, "serve-abc123") == []
+13 -3
View File
@@ -11,13 +11,23 @@ from src.agent_tools import subprocess_tools
@pytest.fixture(autouse=True)
def native_boundary(tmp_path, monkeypatch):
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
from src.agent_runtime import process_resources
from tests.process_resource_helpers import authorized_handler
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(workspace))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "grants.json")
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
for cls in (subprocess_tools.BashTool, subprocess_tools.PythonTool):
original = cls.execute
async def execute(self, content, ctx, _original=original):
return await authorized_handler(_original.__get__(self), workspace)(content, ctx)
monkeypatch.setattr(cls, "execute", execute)
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "MECHANISMS", tuple(
m for m in containment.MECHANISMS if m.name == "process_group"
))
return tmp_path
return workspace
@pytest.mark.skipif(os.name == "nt", reason="real POSIX group teardown")
+8 -2
View File
@@ -399,10 +399,16 @@ def test_already_finished_jobs_are_not_reconsidered(job_store, monkeypatch):
assert bg_jobs.disown_unverified() == {"seen": 0, "retired": 0, "kept": 0}
def test_a_launched_job_records_an_identity_next_to_its_pid(job_store):
def test_a_launched_job_records_an_identity_next_to_its_pid(job_store, tmp_path, monkeypatch):
"""Without this the record is unverifiable forever and the reaper can only
refuse — the token has to be captured at launch or not at all."""
record = bg_jobs.launch("true", "chat-1")
from tests.process_resource_helpers import launch
from src.agent_runtime import process_resources
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(process_resources, "_LAUNCH_DIR", tmp_path / "private" / "launches")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "private" / "grants.json")
record = launch("true", "chat-1", cwd=str(workspace))
assert "start_token" in record
assert process_ownership.verify(record["pid"], record["start_token"]) in (
+123
View File
@@ -0,0 +1,123 @@
from dataclasses import replace
import json
import signal
import pytest
from src import process_ownership
from src.process_lifecycle import ProcessIdentity, signal_identity
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
from src.agent_runtime.resources import ProcessResource, NativeBackendResource, FilesystemRoot, ProcessLaunchScope, ResourceIdentityError
from src.agent_runtime.process_resources import resolve_process_operation
from src.containment import DEFAULT_REQUIRED
def process():
return ProcessResource("native:containment", "alice", "request", "thread", ProcessIdentity(4321, "boot:start", 4321), "leader", "job", "receipt")
@pytest.mark.parametrize("verdict", [process_ownership.FOREIGN, process_ownership.GONE, process_ownership.UNVERIFIABLE])
def test_stale_reused_or_unverifiable_identity_cannot_be_admitted(monkeypatch, verdict):
monkeypatch.setattr(process_ownership, "verify", lambda *a: verdict)
with pytest.raises(ResourceIdentityError):
process().validate()
@pytest.mark.parametrize("field,value", [("pid", 0), ("pid", "4321"), ("pid", True), ("pgid", "4321"), ("start_token", None), ("start_token", ""), ("start_token", {})])
def test_malformed_lifecycle_observations_fail_closed(field, value):
record = process().to_dict()
record["identity"][field] = value
with pytest.raises((ValueError, TypeError)):
ProcessResource.from_dict(record)
def test_no_duplicate_lifecycle_fields_and_strict_restore():
resource = process()
record = resource.to_dict()
assert ProcessResource.from_dict(record) == resource
assert "pid" not in record and "start_token" not in record
record["identity"]["incarnation"] = "invented"
with pytest.raises(ValueError):
ProcessResource.from_dict(record)
def test_incarnation_is_not_application_ownership(monkeypatch):
monkeypatch.setattr(process_ownership, "verify", lambda *a: process_ownership.OWNED)
monkeypatch.setattr(ProcessIdentity, "exited", lambda self: False)
resource = process()
resource.validate()
for field in ("namespace", "owner", "request_id", "thread_id", "role", "job_id", "containment_id"):
if field in {"namespace", "role"}:
with pytest.raises(ValueError):
replace(resource, **{field: "supervisor" if field == "role" else "external:ssh"})
continue
changed = replace(resource, **{field: "supervisor" if field == "role" else "other"})
assert changed != resource
with pytest.raises(ValueError):
RequestAuthority("request", "bob", "thread", "", process_resources=(resource,))
with pytest.raises(ValueError):
RequestAuthority("request", "alice", "other-thread", "", process_resources=(resource,))
def test_pid_reuse_at_signal_boundary_uses_wave5b_engine(monkeypatch):
verdicts = iter([process_ownership.OWNED, process_ownership.OWNED, process_ownership.FOREIGN])
monkeypatch.setattr(process_ownership, "verify", lambda *a: next(verdicts))
monkeypatch.setattr("src.process_lifecycle.is_zombie", lambda pid: False)
monkeypatch.setattr("os.kill", lambda *a: pytest.fail("reused PID signalled"))
target = process()
target.validate()
assert signal_identity(target.identity, signal.SIGTERM) is False
def test_child_cannot_renew_replaced_parent_process(monkeypatch):
old = process()
fresh = replace(old, identity=replace(old.identity, start_token="boot:replacement"))
monkeypatch.setattr(process_ownership, "verify", lambda pid, token: process_ownership.FOREIGN if token == "boot:start" else process_ownership.OWNED)
parent = RequestAuthority("parent", "alice", "thread", "", process_resources=(old,))
child = replace(parent, request_id="child", process_resources=(fresh,))
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)
+5 -1
View File
@@ -184,10 +184,14 @@ async def test_external_record_does_not_grant_authority(tmp_path):
async def test_native_local_bash_python_behavior_unchanged(tmp_path, monkeypatch):
"""4. Native local Bash/Python behavior is unchanged."""
tool_bash = subprocess_tools.BashTool()
from tests.process_resource_helpers import authorized_handler
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.setattr(_te, "agent_cwd", lambda: str(workspace))
ctx = {
"session_id": "native-session",
}
result = await tool_bash.execute("echo 'native run'", ctx)
result = await authorized_handler(tool_bash.execute, workspace)("echo 'native run'", ctx)
assert result["exit_code"] == 0
assert "native run" in result["output"]
assert "containment" in result
+13 -9
View File
@@ -158,15 +158,15 @@ async def test_missing_and_malformed_dispatch_authority_fail_closed(monkeypatch,
@pytest.mark.asyncio
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch):
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch, tmp_path):
from src import tool_execution as execution
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
for disabled in (set(), {"bash"}):
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"),
owner="alice", session_id="s", disabled_tools=disabled,
owner="alice", session_id="s", workspace=str(tmp_path), disabled_tools=disabled,
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
request_authority=authority("bash"))
request_authority=authority("bash", workspace=str(tmp_path)))
assert result["exit_code"] == (1 if disabled else 0)
assert implementation.await_count == 1
@@ -224,10 +224,11 @@ def test_background_snapshot_preserves_scope_and_rejects_other_session(monkeypat
import src.constants
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
grant = authority("transcribe_media").restrict(disabled_tools={"bash"})
save_background_authority("job1", grant)
# Legacy authority-only snapshots have no exact job generation to restore.
with pytest.raises(ValueError):
save_background_authority("job1", grant)
restored = restore_background_authority("job1", owner="alice", session_id="s")
assert restored.request_id == grant.request_id
assert restored.denied == frozenset({"bash"})
assert restored.grants == ()
assert not restored.permits(ExactOperation.normalize("python", "print(1)"))
assert restore_background_authority("job1", owner="alice", session_id="other").grants == ()
@@ -243,8 +244,7 @@ async def test_only_server_background_launch_can_seal_job_authority(monkeypatch,
owner="alice", session_id="s", security_context=execution.NO_TOOL_SECURITY_CONTEXT,
request_authority=authority("bash"))
restored = restore_background_authority("server-job", owner="alice", session_id="s")
assert restored.request_id == "request-test"
assert restored.permits(ExactOperation.normalize("bash", "printf trusted"))
assert restored.grants == () # A launch double returning an ID cannot publish authority.
handler = AsyncMock(return_value=("transcribe_media", {"bg_job_id": "forged-job", "exit_code": 0}))
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
await execution.execute_tool_block(ToolBlock("transcribe_media", '{}'),
@@ -254,12 +254,16 @@ async def test_only_server_background_launch_can_seal_job_authority(monkeypatch,
@pytest.mark.asyncio
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch):
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch, tmp_path):
from src import tool_execution as execution
from src.tool_approvals import ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
store = ToolApprovalStore()
original = authority("transcribe_media")
from src.agent_runtime.resources import ProcessLaunchScope, FilesystemRoot, NativeBackendResource
from src.containment import DEFAULT_REQUIRED
original = replace(original, launch_scopes=(ProcessLaunchScope(NativeBackendResource("bash"),
FilesystemRoot.seal(tmp_path), DEFAULT_REQUIRED),))
pending = store.create(owner="alice", session_id="s", origin_run_id="journal-parent",
tool_name="bash", content="printf approved", workspace=None,
external_untrusted_context_seen=True, capabilities=capabilities_for_action("bash", "printf approved"),
+7 -4
View File
@@ -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")
+354
View File
@@ -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"
+9
View File
@@ -32,6 +32,15 @@ def _pending(store, **overrides):
"capabilities": capabilities_for_action("bash", "printf exact"),
}
values.update(overrides)
if "request_authority" not in values:
import tempfile
from src.agent_runtime.authority import RequestAuthority, OperationGrant
from src.agent_runtime.resources import ProcessLaunchScope, FilesystemRoot, NativeBackendResource
from src.containment import DEFAULT_REQUIRED
tool = values["tool_name"]
scopes = (ProcessLaunchScope(NativeBackendResource(tool), FilesystemRoot.seal(tempfile.mkdtemp(prefix="w3-approval-fixture-")), DEFAULT_REQUIRED),) if tool in {"bash", "python"} else ()
values["request_authority"] = RequestAuthority("standalone-test-request", str(values["owner"]).casefold(),
str(values["session_id"] or ""), str(values["workspace"] or ""), (OperationGrant(tool),), launch_scopes=scopes)
return store.create(**values)
@@ -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