Merge frozen lab b1666951 (Wave 3) into Wave 4 effects provenance

Integrates the merged and frozen Wave 3 lab commit
b1666951faf8285054e1ca90f11533b0fb53fb57 with a normal merge, preserving
every Wave 4 commit unchanged.

Conflict: src/agent_runtime/resources.py. Wave 3's _control_plane_snapshot()
/ _control_plane_path(path, *, snapshot=None) split is kept. The snapshot adds
the effect-store directories to its prefix set after the recursive job-dir
inventory and no longer references path (the auto-merged prefix check would
have raised NameError there). _control_plane_path calls _aliases_effect_store
after its os.stat, only for multiply linked files, so single-link files never
list the store.

Semantic reconciliation (no textual conflict): bg_monitor keeps launch
settlement right after the first successful validate_job and before the
authority check, with Wave 3's post-drain revalidation intact. The
deleted-session branch, terminal before linkage validation, now settles a
validated launch too: that job is later pruned and its publication retired,
which would otherwise leave its effect RUNNING. Regression tests cover the
snapshot form of the effect-store check and both deleted-session linkage
outcomes.
This commit is contained in:
Alexandre Teixeira
2026-10-03 01:13:36 +01:00
31 changed files with 1416 additions and 89 deletions
@@ -0,0 +1,102 @@
tests/test_action_intents_shell_verbs.py
tests/test_auth_config_lock_concurrency.py
tests/test_auth_disabled_document_access.py
tests/test_auth_event_loop.py
tests/test_auth_policy.py
tests/test_auth_regressions.py
tests/test_auth_require_privilege_nondict.py
tests/test_auth_root_path.py
tests/test_auth_session_revocation.py
tests/test_background_chat_completion_ui_static.py
tests/test_background_containment.py
tests/test_background_resource_identity.py
tests/test_background_tool_jobs.py
tests/test_bg_job_tools.py
tests/test_bg_jobs_store.py
tests/test_bg_monitor_stream.py
tests/test_browser_identity_transport.py
tests/test_browser_lifecycle.py
tests/test_browser_observation.py
tests/test_browser_producer_live_contract.py
tests/test_browser_progress.py
tests/test_browser_resource_identity.py
tests/test_browser_screenshot_artifact_safety.py
tests/test_browser_target_correction.py
tests/test_browser_transport_recovery.py
tests/test_builtin_actions_cookbook_serve_state.py
tests/test_builtin_actions_nonstring.py
tests/test_builtin_actions_owner_scope.py
tests/test_builtin_mcp_bg_tasks.py
tests/test_chat_background_stream_isolation.py
tests/test_chat_helpers_bg_tasks_tracked.py
tests/test_chat_preprocess_tool_policy.py
tests/test_codex_cookbook_admin_gate.py
tests/test_containment_process_tree.py
tests/test_cookbook_agent_tool_ssh_validation.py
tests/test_cookbook_cache_scan_isolation.py
tests/test_cookbook_cached_scan_refresh.py
tests/test_cookbook_chat_deeplinks_static.py
tests/test_cookbook_cpu_only_serve.py
tests/test_cookbook_dead_download_status.py
tests/test_cookbook_dependency_completion_regression.py
tests/test_cookbook_deps_recipes.py
tests/test_cookbook_diagnosis.py
tests/test_cookbook_diagnosis_js.py
tests/test_cookbook_docker_access.py
tests/test_cookbook_download_toast_duration.py
tests/test_cookbook_endpoint_registration.py
tests/test_cookbook_error_feedback.py
tests/test_cookbook_error_tail_lines.py
tests/test_cookbook_finished_download_label.py
tests/test_cookbook_gemma4_thinking_template.py
tests/test_cookbook_helpers.py
tests/test_cookbook_hf_token.py
tests/test_cookbook_local_serve_pid_winpid.py
tests/test_cookbook_official_trending_filter.py
tests/test_cookbook_package_detection.py
tests/test_cookbook_port_parsing_js.py
tests/test_cookbook_progress_signal_js.py
tests/test_cookbook_remote_windows_diffusers.py
tests/test_cookbook_same_host_server_profiles_js.py
tests/test_cookbook_serve_lifecycle.py
tests/test_cookbook_stop_without_procfs.py
tests/test_cookbook_tool_dry_run.py
tests/test_cookbook_windows_stop_tree_js.py
tests/test_deep_research_browser_fallback.py
tests/test_doc_library_open_orphaned.py
tests/test_docs_no_orphan_images.py
tests/test_document_editor_background_static.py
tests/test_email_oauth_connect_smtp_security.py
tests/test_email_oauth_docker_config.py
tests/test_email_oauth_settings_redirect.py
tests/test_host_shell_polling.py
tests/test_orphan_reaping.py
tests/test_owned_resource_identity.py
tests/test_pr6020_browser_review_regressions.py
tests/test_private_browser_tool.py
tests/test_process_lifecycle.py
tests/test_process_ownership.py
tests/test_process_resource_identity.py
tests/test_remote_resource_identity.py
tests/test_request_authority.py
tests/test_reserved_username_admin_escalation.py
tests/test_resolve_session_auth_chatgpt.py
tests/test_resource_identity.py
tests/test_runtime_resource_integration.py
tests/test_scheduled_remote_ssh_refusal.py
tests/test_security_regressions.py
tests/test_settings_shell_js_behavior.py
tests/test_setup_device_auth_static.py
tests/test_shell_routes.py
tests/test_shell_service.py
tests/test_stale_process_intersection.py
tests/test_startup_shell_js.py
tests/test_task_cookbook_admin_gate.py
tests/test_task_shell_tools.py
tests/test_wave3_background_followup.py
tests/test_wave3_browser_platform.py
tests/test_wave3_diagnostics.py
tests/test_wave3_launch_cost_lifecycle.py
tests/test_wave3_local_control.py
tests/test_wave3_subprocess_environment.py
tests/test_webhook_trigger_auth_exempt.py
@@ -160,12 +160,12 @@ Re-audit of Checkpoint A seams found:
| Path | Remaining enforcement |
| --- | --- |
| PTY/native manager routes | `routes/shell_routes.py:setup_shell_routes.shell_exec/shell_stream` call `_require_admin` before `_exec_shell/_generate_pty/_generate_tmux`; internal/anonymous controls denied, authenticated human administration separate |
| PTY/native manager routes | `routes/shell_routes.py:setup_shell_routes.shell_exec/shell_stream` call `_require_admin` before `_exec_shell/_generate_pty/_generate_tmux`; internal tool controls denied; auth-enabled human administration and explicit auth-disabled direct-local operator administration remain separate |
| Additional process producers | `resources.ProcessResource.__post_init__` admits only frozen native producer/role combinations; `process_resources.resolve_process_operation` requires sealed observations |
| Raw scheduled SSH | `TaskScheduler._execute_action` → `builtin_actions.action_ssh_command` → `_run_subprocess` refuses SSH without an external workload adapter |
| Local Cookbook scheduled auto-stop | `routes/cookbook_routes.py:setup_cookbook_routes.protect_native_control` applies shell admin boundary to local mutation; `tools/cookbook._cookbook_kill_session` refuses registry-less local control; legacy internal shell route cannot gain administration |
| Legacy/unscoped tasks | `authority.restore_task_authority` → `process_resources.resolve_process_operation` admits no missing creation scope |
| Anonymous administration / generic app_api | `owned_resources.needs_owned_binding` rejects shell/model/Cookbook namespaces; `_require_admin` also rejects unlabelled loopback when anonymous or unauthenticated |
| Anonymous administration / generic app_api | `owned_resources.needs_owned_binding` rejects shell/model/Cookbook namespaces; `_require_admin` rejects auth-enabled anonymous and auth-disabled untrusted/forwarded requests; direct-local operator administration is supported |
No model-reachable page producer entry remains in the native/research wrapper.
Trusted observation/setup methods are not tools or routes. Native arbitrary
@@ -303,3 +303,25 @@ the rebase:
refusals (no claim, no execution id).
8. Rerun the four Wave 4 suites plus `test_runtime_resource_integration.py` and the
`test_wave3_*` suites on the rebased tree.
## Integration with frozen lab `b1666951` (Wave 3 merged)
Merged (not rebased) so the Wave 4 commit SHAs are preserved. Resolution:
- `resources.py`: Wave 3's `_control_plane_snapshot()` / `_control_plane_path(path, *, snapshot=None)`
architecture is kept. The snapshot computes `_effect_store_dirs()` and adds them to
the returned prefix directories only after the recursive `job_dirs` inventory, and
never references `path`. `_control_plane_path` checks inventoried identities after
its `os.stat`, then calls `_aliases_effect_store` only for `st_nlink > 1`.
- `bg_monitor.py`: settlement stays immediately after the first successful
`validate_job`, before the authority comparison; Wave 3's post-drain revalidation is
unchanged. The deleted-session branch (terminal before linkage validation) now also
settles a validated launch, because that job is later pruned and its publication
retired, which would otherwise leave its effect RUNNING.
- Background publication is retired only by `bg_jobs._prune`, after a job is followed
up or terminal-unfollowable, so every path that reaches retirement has already had
its settlement attempt. A job with invalid linkage is never settled (no authority).
- Scheduled builtin actions (e.g. `cookbook_serve`) run in the scheduler outside any
agent journal and never reached `mark_dispatch`; Wave 3 only added their backend
authority. Agent-dispatched local control (`download_model`, `serve_model`,
`serve_preset`) is claimed by `dispatched()` before its handler mints a capability.
+11
View File
@@ -118,6 +118,17 @@ def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
because cookbook surfaces expose host topology, task logs, tmux
commands, and model-serving controls.
"""
# Internal transport/owner attribution is not a scoped external credential.
# In no-login mode, this wrapper must preserve the native local-operator
# boundary even though it invokes endpoint functions without dependencies.
from src.agent_runtime.authority import is_internal_tool_request
from src.auth_helpers import _auth_disabled
from core.middleware import INTERNAL_TOOL_HEADER
if is_internal_tool_request(request) or request.headers.get(INTERNAL_TOOL_HEADER):
raise HTTPException(403, "Internal Cookbook calls require a dedicated producer")
if _auth_disabled():
from routes.shell_routes import _require_admin
_require_admin(request)
owner = _scope_owner(request, allowed)
if not getattr(request.state, "api_token", False):
require_admin(request)
+19 -3
View File
@@ -408,8 +408,8 @@ def setup_cookbook_routes() -> APIRouter:
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.
# UI records/session strings are not process authority. Local tool
# launches require a one-use capability from their admitted producer.
path = request.url.path
from routes.shell_routes import _require_admin
if path in {"/api/cookbook/kill-pid", "/api/cookbook/state", "/api/cookbook/ssh-key"}:
@@ -417,8 +417,22 @@ def setup_cookbook_routes() -> APIRouter:
if path in {"/api/model/download", "/api/model/serve"}:
payload = await request.json()
if not payload.get("remote_host"):
_require_admin(request)
from src.agent_runtime.local_model_control import consume_model_control
from src.agent_runtime.resources import ResourceIdentityError
try:
claimed = consume_model_control(request, payload)
except (ResourceIdentityError, ValueError, TypeError):
raise HTTPException(403, "Local model capability denied") from None
if not claimed:
_require_admin(request)
router = APIRouter(tags=["cookbook"], dependencies=[Depends(protect_native_control)])
def protect_local_model_producer(request, remote_host):
# Scoped wrappers can call endpoint functions directly, without FastAPI
# dependencies. Enforce native control at the actual producer as well.
if not remote_host and getattr(request.state, "local_model_authority", None) is None:
from routes.shell_routes import _require_admin
_require_admin(request)
_cookbook_state_path = Path(COOKBOOK_STATE_FILE)
_state_get_cache = {"ts": 0.0, "mtime": 0.0, "value": None}
_tasks_status_cache = {"ts": 0.0, "value": None}
@@ -1093,6 +1107,7 @@ def setup_cookbook_routes() -> APIRouter:
"""Download a HuggingFace model in a tmux session.
Uses `hf download` CLI directly — runs in tmux via `script -qc`
for real TTY progress, streams ANSI-stripped output via log file."""
protect_local_model_producer(request, req.remote_host)
require_admin(request)
# Defence-in-depth: even though this endpoint is admin-gated, refuse
# values that would land in shell contexts with metacharacters.
@@ -2011,6 +2026,7 @@ def setup_cookbook_routes() -> APIRouter:
keep strict validation, but serving local cached models must not require
a fake org/name wrapper.
"""
protect_local_model_producer(request, req.remote_host)
require_admin(request)
# Defence-in-depth: reject values that could break out of shell contexts.
validate_remote_host(req.remote_host)
+8 -3
View File
@@ -59,12 +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."""
# Anonymous loopback is also reachable from an admitted native workload.
# It cannot be treated as a human admin or as process creation authority.
# Tool authentication is never human administration. Operator-disabled
# login has a separate direct-local transport contract below; it supplies
# no resource grant to model producers.
from src.agent_runtime.authority import is_internal_tool_request
if is_internal_tool_request(request):
from core.middleware import INTERNAL_TOOL_HEADER
if is_internal_tool_request(request) or request.headers.get(INTERNAL_TOOL_HEADER):
raise HTTPException(403, "Internal shell execution requires a dedicated resource-bound producer")
if _auth_disabled():
from src.auth_helpers import is_direct_loopback_request
if is_direct_loopback_request(request):
return
raise HTTPException(403, "Anonymous native process control has no resource authority")
auth_manager = getattr(request.app.state, "auth_manager", None)
if not auth_manager:
+11
View File
@@ -74,6 +74,17 @@ Missing-owner values remain state-dependent at legacy call sites, but new storag
- Auth-enabled, configured auth with no `current_user` is unauthenticated and should fail closed at route dependencies.
- `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode.
Native shell and local Cookbook administration additionally require a direct
loopback connection without proxy forwarding, cross-site indicators, or an
internal-tool header. Remote/proxied anonymous traffic remains denied at
these controls. Auth-enabled administration still requires a human admin.
This mode trusts local programs as well as the local operator: a headerless
loopback request cannot identify which local program sent it. Agent program
launches retain inherited networking; this is not protection against hostile
local code. Use authenticated mode when local programs are outside that trust.
Local Cookbook tools use a separate one-use capability for an admitted exact
request/operation/native backend and resolved launch body; that capability
cannot administer shell, PID, SSH-key, or arbitrary Cookbook state routes.
- Chat/agent code that reads `get_current_user(request)` directly gets `None` when auth middleware is disabled, because no middleware stamps request state.
- SQL `NULL`/JSON missing owners remain legacy/shared compatibility data, not the same thing as a logged-out authenticated caller.
- `"api"` and `"internal-tool"` are request sentinels. They must not be persisted as normal storage owners unless a route explicitly defines that behavior.
+7
View File
@@ -523,6 +523,13 @@ def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authori
if operation is not None:
authority = replace(authority, grants=(OperationGrant(operation.tool,
inputs=frozenset({operation.input})),))
if task_type == "action" and action == "cookbook_serve":
# The direct admin scheduling ingress selects the native Cookbook
# producer. Restore never infers this from task names/availability.
# Any model-created task still intersects with its parent's ceiling.
backend = NativeBackendResource("serve_model")
authority = replace(authority, backend_resources=tuple(dict.fromkeys(
(*authority.backend_resources, backend))))
parent = active_request_authority() if parent_authority is MISSING_AUTHORITY else parent_authority
if parent_authority is None:
parent = RequestAuthority.empty(owner=owner)
+105
View File
@@ -0,0 +1,105 @@
"""One-use transport capabilities for admitted local Cookbook producers.
The internal HTTP token authenticates transport only. A capability bridges one
server-owned request/operation/backend to one exact resolved local launch body.
It is never persisted, returned to the model, or usable for shell/job control.
"""
from contextlib import contextmanager
from dataclasses import dataclass
import hashlib
import json
import secrets
import threading
import time
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError
CAPABILITY_HEADER = "X-Odysseus-Local-Model-Capability"
_ROUTES = {"download_model": "/api/model/download", "serve_model": "/api/model/serve",
"serve_preset": "/api/model/serve"}
_PENDING = {}
_LOCK = threading.Lock()
def _digest(payload):
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"),
allow_nan=False).encode()).hexdigest()
@dataclass(frozen=True)
class _Capability:
authority: object
operation: object
backend: NativeBackendResource
path: str
payload_digest: str
deadline: float
@contextmanager
def model_control_headers(tool, content, owner, payload, *, scheduled=False):
from src.tools._common import _internal_headers
headers = _internal_headers(owner)
if payload.get("remote_host"):
yield headers # Remote workload authority/transport is unchanged.
return
from src.agent_runtime.authority import active_request_authority, ExactOperation
from src.agent_runtime.remote_resources import active_backend_operation
from src.tool_security import owner_is_admin_or_single_user
authority = active_request_authority()
operation = ExactOperation.normalize(tool, content)
backend = active_backend_operation()
if (authority is None or authority.owner != str(owner or "").strip().casefold()
or tool not in _ROUTES or not owner_is_admin_or_single_user(owner)):
raise ResourceIdentityError("Local model producer has no matching server authority")
if scheduled:
# Called only by the server-owned scheduled action, after restoration of
# its immutable input ceiling. A task name or owner alone is not enough.
if tool != "serve_model" or not authority.permits(operation):
raise ResourceIdentityError("Scheduled local model input is outside authority")
resource = NativeBackendResource(tool)
if resource not in authority.backend_resources:
raise ResourceIdentityError("Scheduled local model backend is outside authority")
else:
# This binding exists only after dispatch admission (including one-use
# exact approval). A generic tool grant/header cannot create it over HTTP.
if (backend is None or backend.resource != NativeBackendResource(tool)
or (backend.request_id, backend.owner, backend.session_id,
backend.transport_tool, backend.exact_input) !=
(authority.request_id, authority.owner, authority.session_id, tool, operation.input)):
raise ResourceIdentityError("Local model producer operation or backend changed")
resource = backend.resource
capability = _Capability(authority, operation, resource, _ROUTES[tool], _digest(payload), time.monotonic() + 60)
token = secrets.token_urlsafe(32)
headers.update({CAPABILITY_HEADER: token, "X-Odysseus-Owner": authority.owner})
with _LOCK:
_PENDING[token] = capability
try:
yield headers
finally:
with _LOCK:
_PENDING.pop(token, None)
def consume_model_control(request, payload):
"""Claim exactly once at the local route, before any producer effect."""
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
from src.auth_helpers import is_direct_loopback_request
token = request.headers.get(CAPABILITY_HEADER)
if not token:
return False
if (not is_direct_loopback_request(request)
or not secrets.compare_digest(request.headers.get(INTERNAL_TOOL_HEADER, ""), INTERNAL_TOOL_TOKEN)):
raise ResourceIdentityError("Local model transport is untrusted")
with _LOCK:
capability = _PENDING.get(token)
if (capability is None or capability.deadline < time.monotonic()
or request.method != "POST" or request.url.path != capability.path
or payload.get("remote_host") or _digest(payload) != capability.payload_digest
or request.headers.get("X-Odysseus-Owner", "") != capability.authority.owner
or getattr(request.state, "current_user", None) not in
(None, INTERNAL_TOOL_USER, capability.authority.owner)):
raise ResourceIdentityError("Local model capability binding changed or expired")
del _PENDING[token]
request.state.local_model_authority = capability.authority
return True
+2
View File
@@ -260,6 +260,8 @@ def needs_owned_binding(operation):
"notes", "memory", "vault", "upload", "uploads", "attachments",
"shell", "model", "cookbook"}
segments = path.strip("/").split("/")
if len(segments) >= 3 and segments[:3] == ["api", "codex", "cookbook"]:
raise ResourceIdentityError("Cookbook wrappers require a dedicated resource-bound tool")
if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private:
raise ResourceIdentityError("Owned records require a dedicated resource-bound tool")
return False
+109 -9
View File
@@ -8,12 +8,13 @@ from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from dataclasses import dataclass, field
import hashlib
import json
import os
from pathlib import Path
import re
import threading
from uuid import uuid4
from core.atomic_io import store_transaction
@@ -220,6 +221,19 @@ def intersect_launch_scopes(parent, child):
return tuple(dict.fromkeys(narrowed))
class _LaunchUse:
"""Non-persisted one-use producer reservation, shared by approval copies."""
def __init__(self):
self.used = False
self.lock = threading.Lock()
def claim(self):
with self.lock:
if self.used:
raise ResourceIdentityError("Launch reservation has already been used")
self.used = True
@dataclass(frozen=True)
class BoundProcessOperation:
operation: object
@@ -230,6 +244,7 @@ class BoundProcessOperation:
jobs: tuple[BackgroundJobResource, ...] = ()
processes: tuple[ProcessResource, ...] = ()
exact_approval: object | None = None
_launch_use: _LaunchUse = field(default_factory=_LaunchUse, compare=False, repr=False)
def __post_init__(self):
from src.agent_runtime.authority import ExactOperation
@@ -249,7 +264,8 @@ class BoundProcessOperation:
def validate(self):
if self.launch is not None:
self.launch.validate()
guard_launch_workspace(self.launch.scope.root)
if self._launch_use.used:
raise ResourceIdentityError("Launch reservation has already been used")
for job in self.jobs:
validate_job(job, mutation=self.operation.action in {"kill", "stop", "cancel", "terminate", "ack"})
for process in self.processes:
@@ -321,6 +337,11 @@ def bind_process_operation(operation):
raise TypeError("Process operation must be server-owned")
if operation is not None:
operation.validate()
if operation.launch is not None:
# One fresh authoritative scan for each execution binding. Resolution
# and producer entry retain cheap exact identity checks; no scan is
# reused across independent bindings or persisted in an approval.
guard_launch_workspace(operation.launch.scope.root)
token = _ACTIVE.set(operation)
try:
yield operation
@@ -363,7 +384,7 @@ def guard_launch_workspace(root):
"""
from src import bg_jobs, containment, constants
from src import browser_identity
from src.agent_runtime.resources import _control_plane_path
from src.agent_runtime.resources import _control_plane_path, _control_plane_snapshot
control = (Path(bg_jobs._STORE), Path(bg_jobs._JOBS_DIR), containment._store_path(), _LAUNCH_DIR,
Path(constants.BROWSER_RESOURCES_DIR),
browser_identity.STATE_ROOT,
@@ -373,12 +394,16 @@ def guard_launch_workspace(root):
raise ResourceIdentityError("Launch boundary contains server control state")
def unresolved(error):
raise ResourceIdentityError("Launch workspace cannot be inspected") from error
snapshot = None
for directory, dirs, files in os.walk(base, followlinks=False, onerror=unresolved):
for name in (*dirs, *files):
path = Path(directory) / name
info = path.lstat()
if (path.is_symlink() or info.st_nlink > 1) and _control_plane_path(str(path.resolve())):
raise ResourceIdentityError("Launch boundary aliases server control state")
if path.is_symlink() or info.st_nlink > 1:
if snapshot is None:
snapshot = _control_plane_snapshot()
if _control_plane_path(str(path.resolve()), snapshot=snapshot):
raise ResourceIdentityError("Launch boundary aliases server control state")
@store_transaction(lambda: _LAUNCH_DIR / "publication")
@@ -390,11 +415,83 @@ def publish_launch(launch, authority, containment_id, *, job=None, processes=())
path = launch_path(launch.generation)
if path.exists():
raise ResourceIdentityError("Launch reservation has already been used")
bound = active_process_operation()
if bound is not None:
if bound.launch != launch:
raise ResourceIdentityError("Publication differs from the bound launch")
bound._launch_use.claim()
atomic_write_json(path, {"launch": launch.to_dict(), "authority": authority.to_dict(),
"containment_id": containment_id, "job": job.to_dict() if job else None,
"processes": [p.to_dict() for p in processes]})
@store_transaction(lambda: _LAUNCH_DIR / "publication")
def retire_launch(launch, containment_id, *, job=None):
"""Remove only this exact producer publication; never a replacement.
Callers establish the lifetime end (verified foreground teardown, or exact
background history pruning). Missing/malformed/replaced state is retained.
One-use launch reservations live in the bound operation, not this file.
"""
path = launch_path(launch.generation)
try:
published = json.loads(path.read_text())
except FileNotFoundError:
return False
if (not isinstance(published, dict)
or published.get("launch") != launch.to_dict()
or published.get("containment_id") != containment_id
or published.get("job") != (job.to_dict() if job else None)):
return False
path.unlink()
return True
@store_transaction(lambda: _LAUNCH_DIR / "publication")
def prune_foreground_publications():
"""Startup-only recovery: retire foreground generations without a caller.
A dead/replaced manager cannot resume attachment. A missing receipt also
makes attachment impossible; publication cannot reconstruct that receipt.
Its process tree still belongs to containment recovery; deleting a
publication never signals or asserts tree death. Live/unverifiable managers
retain publication even after child teardown: attachment may still need it.
Background history stays intact.
"""
from src import containment
from src import process_ownership
try:
receipts = json.loads(containment._store_path().read_text())
except FileNotFoundError:
receipts = {}
except (OSError, ValueError):
return 0 # Unreadable state is not evidence that consumers are gone.
if not isinstance(receipts, dict) or any(not isinstance(r, dict) for r in receipts.values()):
return 0
retired = 0
for path in _LAUNCH_DIR.glob("*.json"):
try:
published = json.loads(path.read_text())
launch = ProcessLaunchResource.from_dict(published["launch"])
receipt = receipts.get(published["containment_id"])
abandoned = (receipt is not None
and type(receipt.get("manager_pid")) is int and receipt["manager_pid"] > 0
and isinstance(receipt.get("manager_token"), str) and bool(receipt["manager_token"])
and process_ownership.verify(receipt["manager_pid"], receipt["manager_token"]) in {
process_ownership.GONE, process_ownership.FOREIGN})
if (published.get("job") is None and path == launch_path(launch.generation)
and (receipt is None or (
receipt.get("launch_generation") == launch.generation
and receipt.get("id") == published["containment_id"]
and abandoned))):
# Already under the publication lock; no nested file lock.
path.unlink()
retired += 1
except (ValueError, TypeError, KeyError, OSError):
continue
return retired
@store_transaction(lambda: _LAUNCH_DIR / "publication")
def attach_containment_processes(launch, containment_id):
"""Attach producer-frozen lifecycle records; never capture a current PID."""
@@ -410,10 +507,13 @@ def attach_containment_processes(launch, containment_id):
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))
pid = record.get(pid_key)
token = record.get(token_key)
if not pid or not token:
continue
processes.append(ProcessResource("native:containment", launch.owner, launch.request_id,
launch.thread_id, ProcessIdentity(pid, token, record.get(group_key) if group_key else None),
role, "", containment_id))
from core.atomic_io import atomic_write_json
published["processes"] = [p.to_dict() for p in processes]
atomic_write_json(path, published)
+24 -18
View File
@@ -67,7 +67,7 @@ def _aliases_effect_store(candidate, directories):
return False
def _control_plane_path(path):
def _control_plane_snapshot():
# Execution snapshots/receipts are server state, even if a workspace root
# contains the data directory. A writable user file cannot mint authority.
from src import constants
@@ -85,11 +85,9 @@ def _control_plane_path(path):
if processes is not None:
job_dirs.add(canonical_root(processes._LAUNCH_DIR))
# Durable effect claims/outcomes/observations are server evidence state.
# The store is not inventoried here: it grows with every run. Aliases are
# caught below by ``_aliases_effect_store`` instead.
# They are prefix-protected below, but never inventoried: the store grows
# with every run. Hardlink aliases are caught by ``_aliases_effect_store``.
effect_dirs = _effect_store_dirs()
if any(Path(path).is_relative_to(directory) for directory in effect_dirs):
return True
# 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")
@@ -118,8 +116,6 @@ def _control_plane_path(path):
protected.add(canonical_root(Path(uploader.upload_dir) / "uploads.json"))
for directory in job_dirs:
jobs = Path(directory)
if Path(path).is_relative_to(jobs):
return True
if jobs.exists():
# Uninspectable state fails closed; hardlinks retain object identity.
protected.update(canonical_root(p) for p in jobs.rglob("*") if p.is_file())
@@ -128,22 +124,32 @@ def _control_plane_path(path):
for suffix in ("-wal", "-shm", "-journal"))
protected.add(canonical_root(Path(constants.DATA_DIR) / ".app_key"))
protected.add(canonical_root(Path(constants.UPLOAD_DIR) / "uploads.json"))
if path in protected:
return True
try:
candidate = os.stat(path)
except FileNotFoundError:
return False
if _aliases_effect_store(candidate, effect_dirs):
return True
identities = set()
for control in protected:
try:
observed = os.stat(control)
except FileNotFoundError:
continue
if (candidate.st_dev, candidate.st_ino) == (observed.st_dev, observed.st_ino):
return True
return False
identities.add((observed.st_dev, observed.st_ino))
# Effect directories join the prefix set only after the recursive inventory.
return frozenset(job_dirs | effect_dirs), frozenset(protected), frozenset(identities)
def _control_plane_path(path, *, snapshot=None):
# A scan-local snapshot bounds repeated hardlink checks. Ordinary resource
# resolution always observes fresh state. Neither form is an atomic kernel
# access policy, and snapshots must never survive a workspace guard call.
directories, protected, identities = _control_plane_snapshot() if snapshot is None else snapshot
if any(Path(path).is_relative_to(directory) for directory in directories) or path in protected:
return True
try:
candidate = os.stat(path)
except FileNotFoundError:
return False
if (candidate.st_dev, candidate.st_ino) in identities:
return True
# Only a multiply linked file can alias the (uninventoried) effect store.
return candidate.st_nlink > 1 and _aliases_effect_store(candidate, directories & _effect_store_dirs())
class FilesystemScope(str, Enum):
+11
View File
@@ -513,6 +513,7 @@ async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, arg
from src.tool_execution import agent_cwd, _truncate
grant = None
launch = None
result = None
try:
from src.agent_runtime.process_resources import require_launch, publish_launch, validate_launch_spec
@@ -554,6 +555,16 @@ async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, arg
**({"failure_kind": "resource_linkage_unavailable",
"teardown": result.release.to_dict() if result.release else {"dead": False}}
if result is not None else {})}
finally:
if launch is not None and grant is not None:
record = containment._load_records().get(grant.id, {})
if (record.get("launch_generation") == launch.generation
and (record.get("release") or {}).get("dead") is True):
from src.agent_runtime.process_resources import retire_launch
try:
retire_launch(launch, grant.id)
except (OSError, ValueError, TypeError):
logger.warning("Foreground launch publication retirement failed", exc_info=True)
boundary = result.grant.to_dict()
boundary["executed"] = True
+21
View File
@@ -7,6 +7,27 @@ from fastapi import Request, HTTPException
from src.owner_identity import auth_disabled, effective_storage_owner
def is_direct_loopback_request(request: Request) -> bool:
"""Local operator transport, excluding reverse proxies and cross-site calls.
Locality supplies no model/tool authority. Native administration uses this
only in the operator's explicit auth-disabled single-user mode.
"""
client = getattr(request, "client", None)
if not client or client.host not in {"127.0.0.1", "::1"}:
return False
forwarding = ("cf-connecting-ip", "cf-ray", "cf-visitor", "x-forwarded-for",
"x-forwarded-host", "x-forwarded-proto", "x-real-ip", "forwarded")
if any(request.headers.get(name) for name in forwarding):
return False
if request.headers.get("sec-fetch-site") in {"cross-site", "same-site"}:
return False
origin = request.headers.get("origin")
if origin and origin != str(request.base_url).rstrip("/"):
return False
return True
def get_current_user(request: Request) -> Optional[str]:
"""Get current username from request state (set by auth middleware)."""
return getattr(request.state, 'current_user', None)
+35 -8
View File
@@ -213,10 +213,22 @@ def _prune(jobs: Dict[str, Dict[str, Any]], now: float) -> bool:
"""Drop records (and their on-disk files) for jobs that finished, were
followed up, and are older than the retention window. Mutates `jobs`."""
stale = [jid for jid, rec in jobs.items()
if rec.get("followed_up") and rec.get("ended_at")
if rec.get("status") in {"done", "failed"}
and (rec.get("followed_up") or rec.get("followup_state") == "terminal_unfollowable")
and rec.get("ended_at")
and (rec.get("teardown") or {}).get("dead") is not False
and (now - rec["ended_at"]) > _RETENTION_S]
for jid in stale:
jobs.pop(jid, None)
rec = jobs.pop(jid)
from src.agent_runtime.process_resources import job_from_record, retire_launch
from src.agent_runtime.resources import ProcessLaunchResource
try:
resource = job_from_record(rec)
retire_launch(ProcessLaunchResource.from_dict(rec["launch_resource"]),
resource.containment_id, job=resource)
except (ValueError, TypeError, OSError):
# Malformed/replaced publications never become deletion authority.
pass
for p in _JOBS_DIR.glob(f"{jid}.*"): # .sh .cmd.sh .log .exit
try:
p.unlink()
@@ -333,10 +345,29 @@ def _kill_record(rec):
def pending_followups() -> List[Dict[str, Any]]:
"""Finished jobs the agent hasn't been re-invoked for yet. The monitor
drains these; mark_followed_up() flips the flag only on success."""
drains these; valid continuations acknowledge success, invalid immutable
linkage receives a terminal disposition without fabricating delivery."""
jobs = refresh()
return [r for r in jobs.values()
if r.get("status") in ("done", "failed") and not r.get("followed_up")]
if r.get("status") in ("done", "failed") and not r.get("followed_up")
and r.get("followup_state") != "terminal_unfollowable"]
@store_transaction(lambda: _STORE)
def mark_unfollowable(job_id: str, *, expected_record) -> bool:
"""Suppress only the exact completed snapshot inspected by the monitor.
This conveys no read/signal/continuation authority and cannot renew a PID.
It deliberately needs no invalid/missing authority sidecar to suppress it.
"""
jobs = _load()
record = jobs.get(job_id)
if (record is None or record != expected_record or record.get("id") != job_id
or record.get("status") not in {"done", "failed"}):
return False
record["followup_state"] = "terminal_unfollowable"
_save(jobs)
return True
@store_transaction(lambda: _STORE)
@@ -373,10 +404,6 @@ def get(job_id: str, *, expected) -> Optional[Dict[str, Any]]:
return rec
def list_for_session(session_id: str) -> List[Dict[str, Any]]:
return [r for r in _load().values() if r.get("session_id") == session_id]
@store_transaction(lambda: _STORE)
def kill(job_id: str, *, expected) -> Optional[Dict[str, Any]]:
"""Terminate a running job's process tree and mark it killed. Returns the
+46 -16
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import asyncio
import json
import logging
from enum import Enum, auto
from src import bg_jobs
from src.prompt_security import untrusted_context_message
@@ -26,6 +27,12 @@ POLL_INTERVAL_S = 5
_FOLLOWUP_MAX_ROUNDS = 12
class FollowupResult(Enum):
RETRYABLE_LATER = auto()
COMPLETED = auto()
TERMINAL_UNFOLLOWABLE = auto()
def _background_result_message(rec):
inject = (
f"[Background job {rec['id']} finished]\n\n"
@@ -120,22 +127,30 @@ async def _drain_agent(sess, messages, request_authority=None):
return full, tool_events
async def _run_followup(rec: dict) -> bool:
"""Re-invoke the agent in the job's session with the result. Returns True
if the follow-up completed (or there's nothing to do) — i.e. it's safe to
mark followed_up. Returns False to retry on the next tick."""
async def _run_followup(rec: dict) -> FollowupResult:
"""Continue only an exactly linked result; distinguish retry from terminal."""
from src.ai_interaction import get_session_manager
from core.models import ChatMessage
sm = get_session_manager()
if not sm:
return False # not ready yet — retry
return FollowupResult.RETRYABLE_LATER
sess = sm.get_session(rec["session_id"])
if not sess:
# Session was deleted — nothing to continue. Consider it handled so we
# don't retry forever.
logger.info("bg-followup: session %s gone for job %s — skipping", rec.get("session_id"), rec.get("id"))
return True
# The job is retired without a continuation, then pruned with its
# publication. Settle its launch effect first so it is not left RUNNING.
from src.agent_runtime.process_resources import job_from_record, validate_job
try:
resource = job_from_record(rec)
validate_job(resource)
except (ValueError, TypeError, OSError, RuntimeError):
pass # no validated linkage: nothing may be settled
else:
_settle_launch_effect(resource, rec)
return FollowupResult.TERMINAL_UNFOLLOWABLE
# Don't write into a session that's mid-stream. The followup appends to
# history + save_sessions(); a concurrent live turn does the same, and with
@@ -145,13 +160,10 @@ async def _run_followup(rec: dict) -> bool:
from src import agent_runs
if agent_runs.is_active(sess.id):
logger.info("bg-followup: session %s busy (live turn) — deferring job %s", sess.id, rec.get("id"))
return False
return FollowupResult.RETRYABLE_LATER
except Exception:
pass
context = sess.get_context_messages()
context.append(_background_result_message(rec))
from src.agent_runtime.authority import restore_background_authority
from src.settings import get_setting
authority = restore_background_authority(
@@ -165,11 +177,19 @@ async def _run_followup(rec: dict) -> bool:
_settle_launch_effect(resource, rec)
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
return FollowupResult.TERMINAL_UNFOLLOWABLE
except (ValueError, TypeError, OSError, RuntimeError):
return False
return FollowupResult.TERMINAL_UNFOLLOWABLE
context = sess.get_context_messages()
context.append(_background_result_message(rec))
authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ())
full, tool_events = await _drain_agent(sess, context, request_authority=authority)
# An awaited continuation must not deliver a result after its immutable
# linkage disappears or is replaced. This check grants no new authority.
try:
validate_job(resource)
except (ValueError, TypeError, OSError, RuntimeError):
return FollowupResult.TERMINAL_UNFOLLOWABLE
# Persist ONLY the assistant continuation so it renders as a normal agent
# turn — a standard chat bubble plus `tool_events` that the frontend
@@ -188,7 +208,19 @@ async def _run_followup(rec: dict) -> bool:
sm.save_sessions()
logger.info("bg-followup: auto-continued session %s for job %s (%d chars, %d tools)",
sess.id, rec["id"], len(full), len(tool_events))
return True
return FollowupResult.COMPLETED
async def _process_followup(rec):
outcome = await _run_followup(rec)
if outcome is FollowupResult.COMPLETED:
from src.agent_runtime.process_resources import job_from_record
bg_jobs.mark_followed_up(rec["id"], expected=job_from_record(rec))
elif outcome is FollowupResult.TERMINAL_UNFOLLOWABLE:
if not bg_jobs.mark_unfollowable(rec["id"], expected_record=rec):
return FollowupResult.RETRYABLE_LATER
logger.warning("bg-followup: job %s has no valid continuation linkage; retired from pending", rec.get("id"))
return outcome
async def _loop():
@@ -196,9 +228,7 @@ async def _loop():
try:
for rec in bg_jobs.pending_followups():
try:
if await _run_followup(rec):
from src.agent_runtime.process_resources import job_from_record
bg_jobs.mark_followed_up(rec["id"], expected=job_from_record(rec))
await _process_followup(rec)
except Exception as e:
# Idempotent: leave followed_up=False so the next tick retries.
logger.warning("bg-followup failed for %s (will retry): %s", rec.get("id"), e)
+2
View File
@@ -30,6 +30,8 @@ from src.process_lifecycle import ProcessIdentity, observe
from src.constants import BROWSER_RESOURCES_DIR
PRODUCER_VERSION = "0.35.0"
# Wave 3 session metadata supports only these observed glibc Linux artifacts.
# macOS/Windows and other architectures fail closed before any producer call.
PRODUCER_HASHES = {
"linux-x64": "b7a28c3a43a7008dd02585e2e60c391c08983f7a099149caed63c9f13f57b752",
"linux-arm64": "92cd7d0897837ac648b9a6ab1965c69c5920e0f54df57e4295cdb1143b0541c8",
+6 -4
View File
@@ -3387,10 +3387,12 @@ async def action_cookbook_serve(
if srv.get("platform"): body["platform"] = srv["platform"]
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{internal_api_base()}/api/model/serve",
json=body, headers=headers)
data = r.json() if r.content else {}
from src.agent_runtime.local_model_control import model_control_headers
with model_control_headers("serve_model", command, owner, body, scheduled=True) as launch_headers:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{internal_api_base()}/api/model/serve",
json=body, headers=launch_headers)
data = r.json() if r.content else {}
except Exception as e:
return f"Launch HTTP failed: {e}", False
if not data.get("ok"):
+10
View File
@@ -284,11 +284,21 @@ def reap_orphans() -> Dict[str, Any]:
Blocking: a teardown escalates SIGTERM → grace → SIGKILL and waits for the
process to actually go. Call it off the event loop.
"""
# Observe publication consumers before receipt recovery can forget a dead
# manager's record. Publication retirement itself neither signals nor
# asserts successful teardown; containment remains the recovery authority.
from src.agent_runtime.process_resources import prune_foreground_publications
try:
publications_retired = prune_foreground_publications()
except (OSError, ValueError, TypeError):
publications_retired = 0
logger.warning("process_reaper: foreground publication retirement failed", exc_info=True)
report = {
"mechanism": process_ownership.inspection_mechanism(),
"grants": reap_containment_grants(),
"bg_jobs": reap_bg_jobs(),
"agent_tmux": reap_legacy_agent_tmux(),
"foreground_publications_retired": publications_retired,
}
if report["mechanism"] == process_ownership.MECHANISM_NONE:
logger.error(
+3 -13
View File
@@ -1246,8 +1246,6 @@ def _split_bg_marker(content: str):
return False, content
import re as _re
# Variables a legitimate agent bash/python subprocess needs from the host.
# Anything not listed here is never inherited.
_SAFE_SUBPROCESS_VARS = frozenset({
@@ -1267,19 +1265,11 @@ _SAFE_SUBPROCESS_VARS = frozenset({
"LD_LIBRARY_PATH",
})
# Defence-in-depth: reject any allowlisted variable whose *name* matches
# a credential-bearing pattern (e.g. a user who sets PATH_TOKEN=...).
_SENSITIVE_PATTERN = _re.compile(
r"(?:KEY|TOKEN|SECRET|PASSW|AUTH|CREDENTIAL|PRIVATE|DATABASE_URL)",
_re.IGNORECASE,
)
def _agent_subprocess_env() -> dict:
base = {
key: os.environ[key]
for key in _SAFE_SUBPROCESS_VARS
if key in os.environ and not _SENSITIVE_PATTERN.search(key)
if key in os.environ
}
base.setdefault("PATH", os.environ.get("PATH") or os.defpath or "/usr/local/bin:/usr/bin:/bin")
base.setdefault("LANG", "C.UTF-8")
@@ -1425,7 +1415,7 @@ async def execute_tool_block(
owner=owner, session_id=session_id, workspace=workspace,
tool_name=getattr(block, "tool_type", None), content=getattr(block, "content", None)))
admitted = valid and (authority.permits(operation) or exact_admission)
except (ValueError, TypeError, AttributeError) as error:
except (ValueError, TypeError) as error:
return f"{getattr(block, 'tool_type', '')}: invalid arguments", {
"error": (f"Tool arguments are not valid JSON: {error}"
if isinstance(error, json.JSONDecodeError) else str(error)),
@@ -1503,7 +1493,7 @@ async def execute_tool_block(
authority, operation, document_id=active_document_id,
approved=pending.owned_operation if pending is not None else None,
exact_admission=exact_admission)
except (ValueError, TypeError, OSError, RuntimeError, AttributeError) as error:
except (ValueError, TypeError, OSError) as error:
return f"{transport}: BLOCKED", {
"error": str(error), "exit_code": 1, "blocked": True,
"failure_kind": "resource_identity_denied",
+15 -9
View File
@@ -772,9 +772,11 @@ async def do_download_model(content: str, owner: Optional[str] = None) -> Dict:
if env_cfg.get("platform"): payload["platform"] = env_cfg["platform"]
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/download",
json=payload, headers=_internal_headers())
from src.agent_runtime.local_model_control import model_control_headers
with model_control_headers("download_model", content, owner, payload) as launch_headers:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/download",
json=payload, headers=launch_headers)
data = resp.json()
if data.get("ok"):
sid = data.get("session_id", "?")
@@ -857,9 +859,11 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
if env_cfg.get("platform"): payload["platform"] = env_cfg["platform"]
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
json=payload, headers=_internal_headers())
from src.agent_runtime.local_model_control import model_control_headers
with model_control_headers("serve_model", content, owner, payload) as launch_headers:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
json=payload, headers=launch_headers)
data = resp.json()
if data.get("ok"):
sid = data.get("session_id", "?")
@@ -1908,9 +1912,11 @@ async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict:
payload["ssh_port"] = env_cfg["ssh_port"]
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
json=payload, headers=_internal_headers())
from src.agent_runtime.local_model_control import model_control_headers
with model_control_headers("serve_preset", content, owner, payload) as launch_headers:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
json=payload, headers=launch_headers)
data = resp.json()
if data.get("ok"):
sid = data.get("session_id", "?")
+8 -2
View File
@@ -1,4 +1,5 @@
from unittest.mock import AsyncMock
from types import SimpleNamespace
import pytest
@@ -11,6 +12,11 @@ from src.host_docker_access import HOST_DOCKER_ACCESS_HINT
from tests.helpers.unix_sockets import bound_unix_socket
@pytest.fixture(autouse=True)
def authenticated_admin_mode(monkeypatch):
monkeypatch.setenv("AUTH_ENABLED", "true")
def _model_serve_endpoint():
router = cookbook_routes.setup_cookbook_routes()
for route in router.routes:
@@ -27,6 +33,8 @@ def _admin_request() -> Request:
"path": "/api/model/serve",
"headers": [],
"state": {},
"app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(
is_configured=True, is_admin=lambda user: user == "admin"))),
}
)
request.state.current_user = "admin"
@@ -139,7 +147,6 @@ async def test_local_container_serve_returns_host_docker_opt_in_hint(
assert cookbook_routes.shutil.which(binary) == "/usr/bin/docker"
return False
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
monkeypatch.setattr(
@@ -199,7 +206,6 @@ async def test_local_container_serve_allows_generated_docker_exec_when_enabled(
launched_commands.append(command)
return _Process()
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
monkeypatch.setattr(
+8
View File
@@ -329,6 +329,14 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
alias = workspace / "sneaky.jsonl"
os.link(store / f"{7:032x}.jsonl", alias)
assert resources._control_plane_path(str(alias)) is True
# Wave 3's scan-local snapshot form: the store is a prefix, not inventory.
snapshot = resources._control_plane_snapshot()
assert str(store) in snapshot[0]
assert resources._control_plane_path(str(store / "new.jsonl"), snapshot=snapshot) is True
listed.clear()
assert resources._control_plane_path(str(ordinary), snapshot=snapshot) is False
assert str(store) not in listed
assert resources._control_plane_path(str(alias), snapshot=snapshot) is True
assert str(store) not in globbed, "the effect store is listed one level, never recursively inventoried"
# Multiply linked files elsewhere stay ordinary.
elsewhere = tmp_path / "other.txt"
+154 -1
View File
@@ -330,7 +330,7 @@ async def test_anonymous_native_cookbook_control_rejected_before_producer(monkey
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:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=("192.0.2.1", 123)), base_url="http://local") as client:
result = await client.post(path, json=payload)
assert result.status_code == 403
@@ -352,3 +352,156 @@ async def test_direct_local_cookbook_control_does_not_enroll_discovered_processe
# mint a process resource even when the UI supplies a matching name.
result = await cookbook._cookbook_kill_session("serve-unowned")
assert result["failure_kind"] == "resource_identity_denied"
def test_direct_containment_attachment_skips_unobservable_token(workspace, monkeypatch):
import uuid
from src.agent_runtime.resources import ProcessResource
# 1. Unobservable child token: pid exists, start_token is None
op = ExactOperation.normalize("bash", "printf test")
bound = resources.resolve_process_operation(authority(workspace), op, NativeBackendResource("bash"))
launch = bound.launch
containment_id = uuid.uuid4().hex
resources.publish_launch(launch, authority(workspace), containment_id)
containment._save_records({
containment_id: {
"id": containment_id,
"launch_generation": launch.generation,
"workspace": launch.scope.root.path,
"pid": 54321,
"start_token": None,
"pgid": 54321,
"mechanism": "process_group",
}
})
# Guard: ensure no attempt is made to rediscover/rebind from process table
monkeypatch.setattr(process_ownership, "process_table", lambda *a, **k: pytest.fail("re-read process table"))
monkeypatch.setattr(process_ownership, "start_token", lambda *a, **k: pytest.fail("re-read current PID start_token"))
# Must NOT raise
resources.attach_containment_processes(launch, containment_id)
# Publication remains valid
pub_path = resources.launch_path(launch.generation)
published = json.loads(pub_path.read_text())
assert published["launch"] == launch.to_dict()
assert published["containment_id"] == containment_id
assert published["processes"] == []
# 2. Record with pid + valid token still publishes exact ProcessResource
op_valid = ExactOperation.normalize("bash", "printf valid")
bound_valid = resources.resolve_process_operation(authority(workspace), op_valid, NativeBackendResource("bash"))
launch_valid = bound_valid.launch
cid_valid = uuid.uuid4().hex
resources.publish_launch(launch_valid, authority(workspace), cid_valid)
containment._save_records({
cid_valid: {
"id": cid_valid,
"launch_generation": launch_valid.generation,
"workspace": launch_valid.scope.root.path,
"pid": 65432,
"start_token": "procfs:boot:token65432",
"pgid": 65432,
"mechanism": "process_group",
}
})
resources.attach_containment_processes(launch_valid, cid_valid)
published_valid = json.loads(resources.launch_path(launch_valid.generation).read_text())
assert len(published_valid["processes"]) == 1
leader_res = ProcessResource.from_dict(published_valid["processes"][0])
assert leader_res.role == "leader"
assert leader_res.identity.pid == 65432
assert leader_res.identity.start_token == "procfs:boot:token65432"
assert leader_res.identity.pgid == 65432
@pytest.mark.parametrize("tool,command,expected_out", [
("bash", "printf hi", "hi"),
("python", "print('hi', end='')", "hi"),
])
async def test_end_to_end_fast_exit_preserves_command_result(workspace, monkeypatch, tool, command, expected_out):
import os
original_capture = process_ownership.capture
def mocked_capture(pid):
if pid == os.getpid():
return original_capture(pid)
return {"pid": pid, "start_token": None}
monkeypatch.setattr(process_ownership, "capture", mocked_capture)
# Observe the real attachment before foreground lifecycle retirement.
published = []
attach = resources.attach_containment_processes
def observe_attachment(launch, containment_id):
attach(launch, containment_id)
published.append(json.loads(resources.launch_path(launch.generation).read_text()))
monkeypatch.setattr(resources, "attach_containment_processes", observe_attachment)
auth = authority(workspace, tool=tool)
approval = approval_for(auth, tool, command)
_, result = await dispatch(auth, tool, command, approval)
assert result["exit_code"] == 0
assert result.get("output") == expected_out
assert "failure_kind" not in result or result["failure_kind"] != "resource_linkage_unavailable"
launches_dir = resources._LAUNCH_DIR
launch_files = list(launches_dir.glob("*.json"))
assert not launch_files
cid = result.get("containment", {}).get("id")
assert cid
matching = [record for record in published if record.get("containment_id") == cid]
assert len(matching) == 1
assert matching[0]["processes"] == []
def test_missing_start_token_security_negative(workspace, monkeypatch):
import uuid
import src.process_lifecycle as pl
from src.process_lifecycle import ProcessIdentity
op = ExactOperation.normalize("bash", "printf test")
bound = resources.resolve_process_operation(authority(workspace), op, NativeBackendResource("bash"))
launch = bound.launch
cid = uuid.uuid4().hex
resources.publish_launch(launch, authority(workspace), cid)
containment._save_records({
cid: {
"id": cid,
"launch_generation": launch.generation,
"workspace": launch.scope.root.path,
"pid": 77777,
"start_token": None,
"pgid": 77777,
"mechanism": "process_group",
}
})
created_identities = []
orig_identity_init = ProcessIdentity.__init__
def spy_identity_init(self, pid, start_token, pgid=None):
created_identities.append((pid, start_token, pgid))
return orig_identity_init(self, pid, start_token, pgid=pgid)
monkeypatch.setattr(ProcessIdentity, "__init__", spy_identity_init)
monkeypatch.setattr(process_ownership, "process_table", lambda *a, **k: pytest.fail("PID rediscovery attempted via process_table"))
monkeypatch.setattr(process_ownership, "start_token", lambda *a, **k: pytest.fail("PID rediscovery attempted via start_token"))
resources.attach_containment_processes(launch, cid)
# 1. No ProcessIdentity created for this unobservable process
assert not any(pid == 77777 for pid, token, pgid in created_identities)
# 2. No process authority published
published = json.loads(resources.launch_path(launch.generation).read_text())
assert published["processes"] == []
# 3. No signal authority
fake_ident = ProcessIdentity(77777, None, 77777)
assert fake_ident.verdict() == process_ownership.UNVERIFIABLE
assert pl.signal_identity(fake_ident, 15) is False
+115
View File
@@ -0,0 +1,115 @@
"""Permanent linkage loss suppresses continuation without granting authority."""
from types import SimpleNamespace
import time
import pytest
from src import bg_jobs, bg_monitor
from src.agent_runtime import process_resources as resources
from tests.test_background_resource_identity import store, seed
from src.agent_runtime.resources import ResourceIdentityError
@pytest.fixture
def monitor_session(monkeypatch):
messages = []
sess = SimpleNamespace(id='thread', owner='alice', model='test-model', get_context_messages=lambda: [])
sm = SimpleNamespace(get_session=lambda sid: sess, add_message=lambda *args: messages.append(args), save_sessions=lambda: None)
import src.ai_interaction as ai
monkeypatch.setattr(ai, 'get_session_manager', lambda: sm)
import src.agent_runs
monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: False)
async def drain(*args, **kwargs):
messages.append('drained')
return 'continued', []
monkeypatch.setattr(bg_monitor, '_drain_agent', drain)
return messages
@pytest.mark.parametrize('damage', ['missing', 'corrupt', 'wrong_owner', 'wrong_pid'])
async def test_invalid_linkage_is_terminal_without_message(store, monkeypatch, monitor_session, damage):
resource, rec = seed(store, status='done')
sidecar = bg_jobs._JOBS_DIR / 'job.authority.json'
if damage == 'missing': sidecar.unlink()
elif damage == 'corrupt': sidecar.write_text('{}')
else:
jobs = bg_jobs._load()
if damage == 'wrong_owner': jobs['job']['resource_identity']['owner'] = 'bob'
else: jobs['job']['pid'] = 99999
bg_jobs._save(jobs)
rec = jobs['job']
# Invalid data must not even be rendered into a synthetic result message.
monkeypatch.setattr(bg_monitor, '_background_result_message', lambda rec: pytest.fail('Invalid result rendered'))
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert not monitor_session
assert not bg_jobs.pending_followups()
assert bg_jobs.peek('job')['followup_state'] == 'terminal_unfollowable'
assert not bg_jobs.peek('job').get('followed_up')
with pytest.raises(ResourceIdentityError):
resources.validate_job(resource)
async def test_busy_session_retries_then_continues(store, monkeypatch, monitor_session):
_, rec = seed(store, status='done')
import src.agent_runs
monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: True)
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.RETRYABLE_LATER
assert bg_jobs.pending_followups() and not monitor_session
monkeypatch.setattr(src.agent_runs, 'is_active', lambda sid: False)
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.COMPLETED
assert bg_jobs.peek('job')['followed_up']
assert monitor_session and not bg_jobs.pending_followups()
async def test_terminal_record_prunes_exact_generation(store, monitor_session):
resource, rec = seed(store, status='done')
rec['ended_at'] = time.time() - bg_jobs._RETENTION_S - 10
bg_jobs._save({'job': rec})
(bg_jobs._JOBS_DIR / 'job.authority.json').unlink()
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert not bg_jobs.pending_followups()
assert bg_jobs.peek('job') is None
assert not resources.launch_path(resource.generation).exists()
assert not monitor_session
def test_stale_terminal_snapshot_cannot_suppress_new_generation(store):
_, old = seed(store, status='done')
new, _ = seed(store, status='done')
assert not bg_jobs.mark_unfollowable('job', expected_record=old)
assert 'followup_state' not in bg_jobs.peek('job')
assert resources.launch_path(new.generation).exists()
async def test_linkage_lost_during_continuation_cannot_deliver(store, monkeypatch, monitor_session):
_, rec = seed(store, status='done')
async def interrupted(*args, **kwargs):
(bg_jobs._JOBS_DIR / 'job.authority.json').unlink()
return 'must not be delivered', []
monkeypatch.setattr(bg_monitor, '_drain_agent', interrupted)
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert not monitor_session
assert not bg_jobs.pending_followups()
async def test_stale_terminal_outcome_retries_current_record(store, monkeypatch, monitor_session):
_, old = seed(store, status='done')
seed(store, status='done')
async def terminal(rec): return bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
monkeypatch.setattr(bg_monitor, '_run_followup', terminal)
assert await bg_monitor._process_followup(old) is bg_monitor.FollowupResult.RETRYABLE_LATER
assert bg_jobs.pending_followups()
@pytest.mark.parametrize('linkage', ['valid', 'damaged'])
async def test_deleted_session_settles_only_a_validated_launch(store, monkeypatch, monitor_session, linkage):
"""Wave 4: a job retired for a deleted session must not leave its launch effect RUNNING."""
resource, rec = seed(store, status='done')
if linkage == 'damaged':
(bg_jobs._JOBS_DIR / 'job.authority.json').write_text('{}')
import src.ai_interaction as ai
monkeypatch.setattr(ai, 'get_session_manager', lambda: SimpleNamespace(get_session=lambda sid: None))
settled = []
monkeypatch.setattr(bg_monitor, '_settle_launch_effect', lambda job, record: settled.append(job))
assert await bg_monitor._process_followup(rec) is bg_monitor.FollowupResult.TERMINAL_UNFOLLOWABLE
assert settled == ([resource] if linkage == 'valid' else [])
assert not monitor_session
+21
View File
@@ -0,0 +1,21 @@
"""Wave 3 metadata requires a real allowlisted Linux producer artifact."""
import pytest
from src import browser_identity as browser
from src.agent_runtime.resources import ResourceIdentityError
@pytest.mark.parametrize('system,machine', [('Darwin', 'x86_64'), ('Darwin', 'arm64'),
('Windows', 'AMD64'), ('Windows', 'ARM64'), ('Linux', 'riscv64')])
async def test_unsupported_platform_fails_before_producer_execution(monkeypatch, system, machine):
monkeypatch.setattr(browser.platform, 'system', lambda: system)
monkeypatch.setattr(browser.platform, 'machine', lambda: machine)
async def forbidden(*args, **kwargs): pytest.fail('Unsupported producer was executed')
monkeypatch.setattr(browser, 'run_client', forbidden)
with pytest.raises(ResourceIdentityError, match='Unsupported browser producer platform'):
await browser.trusted_producer()
def test_observed_release_hash_contract_is_explicit():
assert set(browser.PRODUCER_HASHES) == {'linux-x64', 'linux-arm64'}
assert browser.PRODUCER_VERSION == '0.35.0'
assert browser.SESSION_ACTIONS == {'session_info'}
+48
View File
@@ -0,0 +1,48 @@
"""Unexpected programming defects must not look like successful policy denial."""
import pytest
from src import tool_execution
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
from src.agent_runtime.resources import ResourceIdentityError
from src.tool_capabilities import ToolRunSecurityContext
from src.tool_types import ToolBlock
@pytest.mark.parametrize('seam,tool,content', [
('bind_backend_for_operation', 'bash', 'printf probe'),
('resolve_process_operation', 'bash', 'printf probe'),
('admit_owned_operation', 'edit_document', '{"document_id":"doc","content":"changed"}'),
])
@pytest.mark.parametrize('error_type', [AttributeError, ResourceIdentityError, ValueError, TypeError])
async def test_binding_errors_keep_diagnostic_identity(tmp_path, monkeypatch, seam, tool, content, error_type):
authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant(tool),))
monkeypatch.setattr(tool_execution, '_owner_is_admin', lambda owner: True)
def broken(*args, **kwargs):
raise error_type('injected defect')
monkeypatch.setattr(tool_execution, seam, broken)
args = dict(owner='alice', session_id='thread', workspace=str(tmp_path), request_authority=authority,
security_context=ToolRunSecurityContext())
if error_type is AttributeError:
with pytest.raises(AttributeError, match='injected defect'):
await tool_execution.execute_tool_block(ToolBlock(tool, content), **args)
else:
_, result = await tool_execution.execute_tool_block(ToolBlock(tool, content), **args)
assert result['failure_kind'] == 'resource_identity_denied' and result['blocked']
async def test_argument_normalization_defect_propagates(tmp_path, monkeypatch):
authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant('bash'),))
def broken(*args, **kwargs): raise AttributeError('internal-only diagnostic')
monkeypatch.setattr(ExactOperation, 'normalize', broken)
with pytest.raises(AttributeError):
await tool_execution.execute_tool_block(ToolBlock('bash', 'printf probe'), owner='alice',
session_id='thread', workspace=str(tmp_path), request_authority=authority,
security_context=ToolRunSecurityContext())
@pytest.mark.parametrize('content', ['{invalid', None])
async def test_expected_bad_input_still_has_authority_denial(tmp_path, content):
authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant('api_call'),))
_, result = await tool_execution.execute_tool_block(ToolBlock('api_call', content), owner='alice',
session_id='thread', workspace=str(tmp_path), request_authority=authority,
security_context=ToolRunSecurityContext())
assert result['failure_kind'] == 'request_authority_denied'
+217
View File
@@ -0,0 +1,217 @@
"""Structural dispatch cost and exact publication lifetime regressions."""
import asyncio
from dataclasses import replace
import json
import os
import time
import pytest
from core.atomic_io import atomic_write_json
from src import bg_jobs, containment, process_ownership
from src.agent_runtime import resources as identities
from src.agent_runtime.authority import ExactOperation, bind_request_authority
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError
from src.agent_tools.subprocess_tools import BashTool
from src.process_lifecycle import ProcessIdentity
from tests.test_runtime_resource_integration import workspace, authority, dispatch
from tests.test_background_resource_identity import seed
from src.agent_runtime import process_resources as resources
@pytest.mark.parametrize('tool,content', [('bash', 'printf guarded'), ('python', 'print("guarded")')])
async def test_real_dispatch_scans_workspace_once_per_binding(workspace, monkeypatch, tool, content):
(workspace / 'child').mkdir()
(workspace / 'child' / 'link').symlink_to(workspace / 'child')
calls = []
walk = os.walk
def counted(*args, **kwargs):
calls.append(args[0])
return walk(*args, **kwargs)
monkeypatch.setattr(os, 'walk', counted)
admitted = authority(workspace, tool)
for _ in range(2):
calls.clear()
_, result = await dispatch(admitted, tool, content)
assert result['exit_code'] == 0, result
assert calls == [workspace]
assert not list(resources._LAUNCH_DIR.glob('*.json'))
async def test_alias_created_after_resolution_is_denied_at_binding(workspace):
admitted = authority(workspace)
op = ExactOperation.normalize('bash', 'printf safe')
bound = resources.resolve_process_operation(admitted, op, NativeBackendResource('bash'))
resources._LAUNCH_DIR.mkdir(parents=True)
state = resources._LAUNCH_DIR / ('a' * 32 + '.json')
state.write_text('{}')
(workspace / 'alias').symlink_to(state)
with bind_request_authority(admitted), pytest.raises(ResourceIdentityError):
with resources.bind_process_operation(bound):
pytest.fail('New control-plane alias admitted')
async def test_publication_retained_during_launch_and_retired_after_teardown(workspace, monkeypatch):
entered, resume = asyncio.Event(), asyncio.Event()
run = containment.run
paths = []
async def held(grant, command, **kwargs):
launch = resources.active_process_operation().launch
path = resources.launch_path(launch.generation)
assert path.is_file()
paths.append(path)
entered.set()
await resume.wait()
return await run(grant, command, **kwargs)
monkeypatch.setattr(containment, 'run', held)
task = asyncio.create_task(dispatch(authority(workspace), 'bash', 'printf foreground'))
await asyncio.wait_for(entered.wait(), 5)
assert paths[0].is_file()
resume.set()
_, result = await task
assert result['exit_code'] == 0 and result['teardown']['dead']
assert not paths[0].exists()
async def test_retired_publication_cannot_replay_bound_reservation(workspace):
admitted = authority(workspace)
op = ExactOperation.normalize('bash', 'printf once')
bound = resources.resolve_process_operation(admitted, op, NativeBackendResource('bash'))
from src import tool_execution
token = tool_execution._active_workspace.set(str(workspace))
try:
with bind_request_authority(admitted), resources.bind_process_operation(bound):
ctx = {'owner': 'alice', 'session_id': 'thread'}
first = await BashTool().execute(op.input, ctx)
assert first['exit_code'] == 0
assert not resources.launch_path(bound.launch.generation).exists()
second = await BashTool().execute(op.input, ctx)
assert second['failure_kind'] == 'resource_identity_denied'
copy = replace(bound, exact_approval=None)
with pytest.raises(ResourceIdentityError):
with resources.bind_process_operation(copy):
pytest.fail('Approval copy renewed a consumed launch')
finally:
tool_execution._active_workspace.reset(token)
@pytest.mark.parametrize('publication', [[], None, 'malformed'])
def test_nonobject_publication_cannot_be_retired(workspace, publication):
resource, rec = seed(workspace, status='done')
path = resources.launch_path(resource.generation)
path.write_text(json.dumps(publication))
launch = identities.ProcessLaunchResource.from_dict(rec['launch_resource'])
assert not resources.retire_launch(launch, resource.containment_id, job=resource)
assert json.loads(path.read_text()) == publication
async def test_corrupt_publication_retirement_preserves_command_result(workspace, monkeypatch):
attach = resources.attach_containment_processes
paths = []
def corrupt_after_attachment(launch, containment_id):
observed = attach(launch, containment_id)
path = resources.launch_path(launch.generation)
path.write_text('[]')
paths.append(path)
return observed
monkeypatch.setattr(resources, 'attach_containment_processes', corrupt_after_attachment)
_, result = await dispatch(authority(workspace), 'bash', 'printf completed')
assert result['exit_code'] == 0 and result['output'] == 'completed', result
assert paths[0].read_text() == '[]'
@pytest.mark.parametrize('status,followed_up,old,removed', [
('running', True, True, False), ('done', False, True, False),
('done', True, False, False), ('done', True, True, True), ('failed', True, True, True),
])
def test_background_publication_tracks_supported_history_lifetime(workspace, status, followed_up, old, removed):
resource, rec = seed(workspace, status=status)
rec.update(followed_up=followed_up, ended_at=time.time() - (bg_jobs._RETENTION_S + 10 if old else 0))
jobs = {'job': rec}
bg_jobs._save(jobs)
assert resources.launch_path(resource.generation).exists()
bg_jobs._prune(jobs, time.time())
assert resources.launch_path(resource.generation).exists() is not removed
assert ('job' not in jobs) is removed
if removed:
bg_jobs._save(jobs)
with pytest.raises(ResourceIdentityError):
resources.validate_job(resource)
def test_old_generation_retirement_cannot_delete_replacement(workspace):
old, rec = seed(workspace, status='done')
new, _ = seed(workspace, status='done')
old_launch = identities.ProcessLaunchResource.from_dict(rec['launch_resource'])
assert resources.retire_launch(old_launch, old.containment_id, job=old)
assert resources.launch_path(new.generation).is_file()
# Even a replaced file at the old generation's slot is not deletable by old linkage.
replacement = json.loads(resources.launch_path(new.generation).read_text())
atomic_write_json(resources.launch_path(old.generation), replacement)
assert not resources.retire_launch(old_launch, old.containment_id, job=old)
assert resources.launch_path(old.generation).is_file()
@pytest.mark.parametrize('manager,release,retired', [
(process_ownership.OWNED, False, False), (process_ownership.UNVERIFIABLE, False, False),
(process_ownership.OWNED, True, False), (process_ownership.UNVERIFIABLE, True, False),
(process_ownership.GONE, False, True), (process_ownership.FOREIGN, False, True),
(process_ownership.GONE, True, True),
])
def test_startup_retirement_does_not_invent_process_death(workspace, monkeypatch, manager, release, retired):
admitted = authority(workspace)
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf recovery'), NativeBackendResource('bash')).launch
cid = 'receipt'
resources.publish_launch(launch, admitted, cid)
atomic_write_json(containment._store_path(), {cid: {'id': cid, 'launch_generation': launch.generation,
'manager_pid': 123, 'manager_token': 'old-manager', 'release': {'dead': release}}})
monkeypatch.setattr(process_ownership, 'verify', lambda *args: manager)
assert resources.prune_foreground_publications() == int(retired)
assert resources.launch_path(launch.generation).exists() is not retired
assert containment._load_records()[cid]['release']['dead'] is release
def test_restart_never_prunes_background_linkage(workspace, monkeypatch):
resource, _ = seed(workspace, status='done')
monkeypatch.setattr(process_ownership, 'verify', lambda *args: process_ownership.GONE)
assert resources.prune_foreground_publications() == 0
assert resources.launch_path(resource.generation).is_file()
def test_snapshot_is_rebuilt_for_each_guard(workspace):
resources._LAUNCH_DIR.mkdir(parents=True)
target = workspace / 'data'; target.write_text('ordinary')
(workspace / 'link').symlink_to(target)
resources.guard_launch_workspace(identities.FilesystemRoot.seal(workspace))
os.link(target, resources._LAUNCH_DIR / ('b' * 32 + '.json'))
with pytest.raises(ResourceIdentityError):
resources.guard_launch_workspace(identities.FilesystemRoot.seal(workspace))
def test_missing_receipt_publication_cannot_recover_authority(workspace):
admitted = authority(workspace)
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf recovery'), NativeBackendResource('bash')).launch
resources.publish_launch(launch, admitted, 'missing-receipt')
assert resources.prune_foreground_publications() == 1
assert not resources.launch_path(launch.generation).exists()
assert not containment._load_records()
@pytest.mark.parametrize('receipt_data', ['{corrupt', '[]', '{"receipt":null}'])
def test_unreadable_receipts_cannot_retire_live_consumers(workspace, receipt_data):
admitted = authority(workspace)
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf pending'), NativeBackendResource('bash')).launch
resources.publish_launch(launch, admitted, 'receipt')
containment._store_path().write_text(receipt_data)
assert resources.prune_foreground_publications() == 0
assert resources.launch_path(launch.generation).is_file()
def test_missing_manager_identity_cannot_retire_attachment(workspace, monkeypatch):
admitted = authority(workspace)
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf pending'), NativeBackendResource('bash')).launch
resources.publish_launch(launch, admitted, 'receipt')
atomic_write_json(containment._store_path(), {'receipt': {'id': 'receipt',
'launch_generation': launch.generation, 'release': {'dead': True}}})
monkeypatch.setattr(process_ownership, 'verify', lambda *args: pytest.fail('Missing manager treated as observed'))
assert resources.prune_foreground_publications() == 0
assert resources.launch_path(launch.generation).is_file()
+246
View File
@@ -0,0 +1,246 @@
"""Local administration and exact Cookbook caller-to-route regressions."""
import asyncio
import json
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import MagicMock
import httpx
import pytest
from fastapi import FastAPI, HTTPException
from starlette.requests import Request
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
from routes import cookbook_routes, shell_routes
from src import builtin_actions, tool_execution
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority, seal_task_authority, restore_task_authority
from src.agent_runtime.remote_resources import bind_backend_for_operation, bind_backend_operation
from src.agent_runtime.local_model_control import CAPABILITY_HEADER, model_control_headers
from src.agent_runtime.resources import ResourceIdentityError
from src.tools import cookbook
from src.tool_capabilities import ToolRunSecurityContext
from src.tool_types import ToolBlock
def request(host='127.0.0.1', headers=None, user=None):
req = Request({'type': 'http', 'method': 'POST', 'scheme': 'http', 'path': '/api/shell/exec',
'server': ('127.0.0.1', 7000), 'client': (host, 1234),
'headers': [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()],
'app': SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(is_admin=lambda u: u == 'alice')))})
req.state.current_user = user
return req
@pytest.mark.parametrize('host,headers,allowed', [
('127.0.0.1', {}, True), ('::1', {}, True), ('192.0.2.1', {}, False),
('127.0.0.1', {'x-forwarded-for': '192.0.2.1'}, False),
('127.0.0.1', {'forwarded': 'for=192.0.2.1'}, False),
('127.0.0.1', {'cf-ray': 'proxy'}, False),
('127.0.0.1', {'x-forwarded-proto': 'https'}, False),
('127.0.0.1', {'sec-fetch-site': 'cross-site'}, False),
('127.0.0.1', {'origin': 'https://evil.example'}, False),
('127.0.0.1', {INTERNAL_TOOL_HEADER: 'forged'}, False),
('127.0.0.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}, False),
])
def test_auth_disabled_operator_transport(monkeypatch, host, headers, allowed):
monkeypatch.setenv('AUTH_ENABLED', 'false')
req = request(host, headers)
if allowed:
shell_routes._require_admin(req)
else:
with pytest.raises(HTTPException) as error:
shell_routes._require_admin(req)
assert error.value.status_code == 403
@pytest.mark.parametrize('user,allowed', [('alice', True), ('bob', False), (None, False), ('api', False), (INTERNAL_TOOL_USER, False)])
def test_auth_enabled_administration(monkeypatch, user, allowed):
monkeypatch.setenv('AUTH_ENABLED', 'true')
if allowed:
shell_routes._require_admin(request('192.0.2.1', user=user))
else:
with pytest.raises(HTTPException):
shell_routes._require_admin(request(user=user))
@pytest.fixture
def control_app(tmp_path, monkeypatch):
# Auth tests can reload middleware after collection. Authenticate the live
# transport token used by real producers, rather than a collection snapshot.
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
monkeypatch.setenv('AUTH_ENABLED', 'true')
manager = SimpleNamespace(is_configured=True, users={'alice': {}}, is_admin=lambda u: u == 'alice')
import core.auth
monkeypatch.setattr(core.auth, 'AuthManager', lambda: manager)
monkeypatch.setattr(tool_execution, '_owner_is_admin', lambda u: u == 'alice')
monkeypatch.setattr(cookbook_routes, 'TMUX_LOG_DIR', tmp_path / 'tmux')
state = tmp_path / 'cookbook.json'
state.write_text(json.dumps({'presets': [{'name': 'preset', 'model': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}]}))
monkeypatch.setattr(cookbook_routes, 'COOKBOOK_STATE_FILE', str(state))
monkeypatch.setattr(builtin_actions, 'COOKBOOK_STATE_FILE', str(state))
spawned = []
async def spawn(command, **kwargs):
spawned.append(command)
async def wait(): return 0
async def read(): return b''
return SimpleNamespace(returncode=0, wait=wait, stderr=SimpleNamespace(read=read))
monkeypatch.setattr(asyncio, 'create_subprocess_shell', spawn)
async def remote_probe(*args, **kwargs):
async def communicate(): return b'tmux', b''
return SimpleNamespace(returncode=0, communicate=communicate)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', remote_probe)
import src.assistant_log
monkeypatch.setattr(src.assistant_log, 'log_to_assistant', lambda *a, **k: None)
async def endpoint(**kwargs): return {'added': True, 'endpoint_id': 'endpoint'}
monkeypatch.setattr(cookbook, '_ensure_served_endpoint', endpoint)
app = FastAPI()
app.state.auth_manager = manager
@app.middleware('http')
async def attribution(req, next):
if req.headers.get(INTERNAL_TOOL_HEADER) == INTERNAL_TOOL_TOKEN:
req.state.current_user = req.headers.get('X-Odysseus-Owner') or INTERNAL_TOOL_USER
return await next(req)
app.include_router(cookbook_routes.setup_cookbook_routes())
app.include_router(shell_routes.setup_shell_routes())
real_client = httpx.AsyncClient
def client_factory(*args, **kwargs):
kwargs.setdefault('transport', httpx.ASGITransport(app=app))
return real_client(*args, **kwargs)
monkeypatch.setattr(httpx, 'AsyncClient', client_factory)
return app, spawned, tmp_path
@pytest.mark.parametrize('tool,args', [
('download_model', {'repo_id': 'org/model', 'local': True}),
('serve_model', {'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg', 'local': True}),
('serve_preset', {'name': 'preset'}),
])
async def test_real_local_tool_dispatch_reaches_real_model_route(control_app, tool, args):
app, spawned, work = control_app
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant(tool),))
_, result = await tool_execution.execute_tool_block(ToolBlock(tool, json.dumps(args)), owner='alice',
session_id='thread', workspace=str(work), request_authority=authority, security_context=ToolRunSecurityContext())
assert result['exit_code'] == 0, result
assert result['session_id'].startswith('cookbook-' if tool == 'download_model' else 'serve-')
assert len(spawned) == 1 and 'tmux new-session' in spawned[0]
async def test_real_scheduled_local_action_uses_restored_exact_authority(control_app):
app, spawned, work = control_app
command = json.dumps({'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg', 'set_default': False})
snapshot = seal_task_authority(command, 'action', 'cookbook_serve', owner='alice')
authority = restore_task_authority(snapshot, command, 'action', 'cookbook_serve', owner='alice')
with bind_request_authority(authority):
message, ok = await builtin_actions.action_cookbook_serve('alice', command=command)
assert ok, message
assert len(spawned) == 1
with bind_request_authority(authority):
_, ok = await builtin_actions.action_cookbook_serve('alice', command=command.replace('samplepkg', 'changedpkg'))
assert not ok and len(spawned) == 1
@pytest.mark.parametrize('host,headers', [
('127.0.0.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}),
('192.0.2.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}),
('127.0.0.1', {INTERNAL_TOOL_HEADER: 'forged'}),
('127.0.0.1', {CAPABILITY_HEADER: 'forged', INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}),
])
async def test_header_only_cannot_launch(control_app, host, headers):
app, spawned, _ = control_app
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client:
for path in ('/api/model/download', '/api/model/serve', '/api/shell/exec'):
r = await client.post(path, json={'repo_id': 'org/model', 'cmd': 'printf nope', 'command': 'printf nope'}, headers=headers)
assert r.status_code == 403
assert not spawned
@pytest.mark.parametrize('substitute', ['body', 'route', 'owner', 'remote', 'proxy', 'replay'])
async def test_capability_exact_transport_binding(control_app, substitute):
app, spawned, work = control_app
content = json.dumps({'repo_id': 'org/model', 'local': True})
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('download_model'),))
operation = ExactOperation.normalize('download_model', content)
backend = bind_backend_for_operation(authority, operation)
body = {'repo_id': 'org/model'}
with bind_request_authority(authority), bind_backend_operation(backend), model_control_headers('download_model', content, 'alice', body) as headers:
changed = dict(headers); payload = dict(body); path = '/api/model/download'; host = '127.0.0.1'
if substitute == 'body': payload['repo_id'] = 'org/changed'
if substitute == 'route': path = '/api/model/serve'
if substitute == 'owner': changed['X-Odysseus-Owner'] = 'bob'
if substitute == 'remote': host = '192.0.2.1'
if substitute == 'proxy': changed['x-forwarded-for'] = '192.0.2.1'
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client:
if substitute == 'replay':
assert (await client.post(path, json=payload, headers=changed)).status_code == 200
r = await client.post(path, json=payload, headers=changed)
assert r.status_code == 403
assert len(spawned) == (1 if substitute == 'replay' else 0)
@pytest.mark.parametrize('field', ['owner', 'request_id', 'session_id'])
def test_producer_wrong_application_binding(control_app, field):
_, _, work = control_app
content = '{"repo_id":"org/model","local":true}'
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('download_model'),))
backend = bind_backend_for_operation(authority, ExactOperation.normalize('download_model', content))
changed = replace(authority, **{field: 'replacement'}, resource_roots=None, backend_resources=None, owned_scopes=None)
with bind_request_authority(changed), bind_backend_operation(backend), pytest.raises(ResourceIdentityError):
with model_control_headers('download_model', content, 'alice', {'repo_id': 'org/model'}):
pytest.fail('Substituted producer obtained a capability')
async def test_remote_route_semantics_remain_unchanged(control_app):
app, spawned, _ = control_app
async with httpx.AsyncClient(base_url='http://127.0.0.1') as client:
r = await client.post('/api/model/download', json={'repo_id': 'org/model', 'remote_host': 'gpu.example'},
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN})
assert r.status_code == 200 and r.json()['ok'], r.text
assert len(spawned) == 1 and 'ssh ' in spawned[0]
async def test_auth_disabled_local_route_usage(control_app, monkeypatch):
app, spawned, _ = control_app
monkeypatch.setenv('AUTH_ENABLED', 'false')
async with httpx.AsyncClient(base_url='http://127.0.0.1') as client:
shell = await client.post('/api/shell/exec', json={'command': ''})
state = await client.post('/api/cookbook/state', json={'tasks': []})
launch = await client.post('/api/model/download', json={'repo_id': 'org/model'})
assert shell.status_code == state.status_code == launch.status_code == 200
assert launch.json()['ok'] and len(spawned) == 1
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=('192.0.2.1', 1)), base_url='http://127.0.0.1') as client:
for path, body in [('/api/shell/exec', {'command': ''}), ('/api/cookbook/state', {'tasks': []}), ('/api/model/download', {'repo_id': 'org/model'})]:
assert (await client.post(path, json=body)).status_code == 403
@pytest.mark.parametrize('host,internal', [('127.0.0.1', True), ('192.0.2.1', False)])
async def test_scoped_wrapper_cannot_bypass_native_control(control_app, monkeypatch, host, internal):
from routes.codex_routes import setup_codex_routes
app, spawned, _ = control_app
app.include_router(setup_codex_routes())
monkeypatch.setenv('AUTH_ENABLED', 'false')
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if internal else {}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client:
r = await client.post('/api/codex/cookbook/serve', json={'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}, headers=headers)
assert r.status_code == 403 and not spawned
@pytest.mark.parametrize('path', ['/api/codex/cookbook/serve', '/api/codex/cookbook/stop/job', '/api/codex/%63ookbook/serve'])
async def test_generic_app_api_cannot_substitute_scoped_wrapper(control_app, path):
_, spawned, work = control_app
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('app_api'),))
content = json.dumps({'action': 'call', 'method': 'POST', 'path': path, 'body': {'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}})
_, result = await tool_execution.execute_tool_block(ToolBlock('app_api', content), owner='alice',
session_id='thread', workspace=str(work), request_authority=authority, security_context=ToolRunSecurityContext())
assert result['failure_kind'] == 'resource_identity_denied' and not spawned
async def test_direct_endpoint_call_keeps_producer_gate(control_app, monkeypatch):
from routes.cookbook_helpers import ModelDownloadRequest
app, spawned, _ = control_app
router = cookbook_routes.setup_cookbook_routes()
endpoint = next(route.endpoint for route in router.routes if getattr(route, 'path', '') == '/api/model/download')
monkeypatch.setenv('AUTH_ENABLED', 'false')
req = request('192.0.2.1')
with pytest.raises(HTTPException) as exc:
await endpoint(req, ModelDownloadRequest(repo_id='org/model'))
assert exc.value.status_code == 403 and not spawned
@@ -0,0 +1,27 @@
"""Closed inheritance is the complete subprocess environment boundary."""
from src import tool_execution
def test_closed_subprocess_environment_drops_all_unlisted_credentials(monkeypatch):
from unittest.mock import patch
import os
ambient = {'PATH': '/usr/bin', 'LANG': 'C.UTF-8', 'OPENAI_API_KEY': 'secret', 'HF_TOKEN': 'secret',
'AUTH_ENABLED': 'false', 'DATABASE_URL': 'secret', 'PATH_TOKEN': 'secret',
'AWS_SECRET_ACCESS_KEY': 'secret', 'ARBITRARY': 'secret', 'HOME': '/server/secret'}
with patch.dict(os.environ, ambient, clear=True):
child = tool_execution._agent_subprocess_env()
assert child['PATH'] == '/usr/bin'
assert child['HOME'] == tool_execution._AGENT_WORKDIR
assert set(child) <= tool_execution._SAFE_SUBPROCESS_VARS | {'HOME', 'TERM', 'COLUMNS', 'LINES'}
assert all(child.get(name) != value for name, value in ambient.items() if name not in {'PATH', 'LANG'})
async def test_real_python_child_does_not_inherit_ambient_credentials(workspace, monkeypatch):
from tests.test_runtime_resource_integration import authority, dispatch
for name in ('OPENAI_API_KEY', 'HF_TOKEN', 'PATH_TOKEN', 'DATABASE_URL', 'ODYSSEUS_INTERNAL_TOKEN'):
monkeypatch.setenv(name, 'never-inherit-this-value')
_, result = await dispatch(authority(workspace, 'python'), 'python',
'import os\nprint(any(v == "never-inherit-this-value" for v in os.environ.values()))')
assert result['exit_code'] == 0 and result['output'] == 'False'
from tests.test_runtime_resource_integration import workspace
+1 -1
View File
@@ -75,7 +75,7 @@ The source tree reads **112** `ODYSSEUS_*` variables: 81 an operator may want to
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15361` | How many video frames one tool result may contribute. Clamped to 1-8. |
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15329` | How many images one tool result may contribute to the model turn. Clamped to 1-8. |
| `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. |
| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_runtime/process_resources.py:58` (+2 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. |
| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_runtime/process_resources.py:59` (+2 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. |
| `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:925` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. |
| `ODYSSEUS_TOOL_APPROVAL_GATE` | `'0'` | `src/tool_capabilities.py:645` | Security-relevant. Truthy makes tool calls pass through the approval gate. Off by default. |