mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
@@ -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
@@ -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", "?")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Wave 3 metadata requires a real allowlisted Linux producer artifact."""
|
||||
import pytest
|
||||
from src import browser_identity as browser
|
||||
from src.agent_runtime.resources import ResourceIdentityError
|
||||
|
||||
|
||||
@pytest.mark.parametrize('system,machine', [('Darwin', 'x86_64'), ('Darwin', 'arm64'),
|
||||
('Windows', 'AMD64'), ('Windows', 'ARM64'), ('Linux', 'riscv64')])
|
||||
async def test_unsupported_platform_fails_before_producer_execution(monkeypatch, system, machine):
|
||||
monkeypatch.setattr(browser.platform, 'system', lambda: system)
|
||||
monkeypatch.setattr(browser.platform, 'machine', lambda: machine)
|
||||
async def forbidden(*args, **kwargs): pytest.fail('Unsupported producer was executed')
|
||||
monkeypatch.setattr(browser, 'run_client', forbidden)
|
||||
with pytest.raises(ResourceIdentityError, match='Unsupported browser producer platform'):
|
||||
await browser.trusted_producer()
|
||||
|
||||
|
||||
def test_observed_release_hash_contract_is_explicit():
|
||||
assert set(browser.PRODUCER_HASHES) == {'linux-x64', 'linux-arm64'}
|
||||
assert browser.PRODUCER_VERSION == '0.35.0'
|
||||
assert browser.SESSION_ACTIONS == {'session_info'}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Unexpected programming defects must not look like successful policy denial."""
|
||||
import pytest
|
||||
from src import tool_execution
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
|
||||
from src.agent_runtime.resources import ResourceIdentityError
|
||||
from src.tool_capabilities import ToolRunSecurityContext
|
||||
from src.tool_types import ToolBlock
|
||||
|
||||
|
||||
@pytest.mark.parametrize('seam,tool,content', [
|
||||
('bind_backend_for_operation', 'bash', 'printf probe'),
|
||||
('resolve_process_operation', 'bash', 'printf probe'),
|
||||
('admit_owned_operation', 'edit_document', '{"document_id":"doc","content":"changed"}'),
|
||||
])
|
||||
@pytest.mark.parametrize('error_type', [AttributeError, ResourceIdentityError, ValueError, TypeError])
|
||||
async def test_binding_errors_keep_diagnostic_identity(tmp_path, monkeypatch, seam, tool, content, error_type):
|
||||
authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant(tool),))
|
||||
monkeypatch.setattr(tool_execution, '_owner_is_admin', lambda owner: True)
|
||||
def broken(*args, **kwargs):
|
||||
raise error_type('injected defect')
|
||||
monkeypatch.setattr(tool_execution, seam, broken)
|
||||
args = dict(owner='alice', session_id='thread', workspace=str(tmp_path), request_authority=authority,
|
||||
security_context=ToolRunSecurityContext())
|
||||
if error_type is AttributeError:
|
||||
with pytest.raises(AttributeError, match='injected defect'):
|
||||
await tool_execution.execute_tool_block(ToolBlock(tool, content), **args)
|
||||
else:
|
||||
_, result = await tool_execution.execute_tool_block(ToolBlock(tool, content), **args)
|
||||
assert result['failure_kind'] == 'resource_identity_denied' and result['blocked']
|
||||
|
||||
|
||||
async def test_argument_normalization_defect_propagates(tmp_path, monkeypatch):
|
||||
authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant('bash'),))
|
||||
def broken(*args, **kwargs): raise AttributeError('internal-only diagnostic')
|
||||
monkeypatch.setattr(ExactOperation, 'normalize', broken)
|
||||
with pytest.raises(AttributeError):
|
||||
await tool_execution.execute_tool_block(ToolBlock('bash', 'printf probe'), owner='alice',
|
||||
session_id='thread', workspace=str(tmp_path), request_authority=authority,
|
||||
security_context=ToolRunSecurityContext())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('content', ['{invalid', None])
|
||||
async def test_expected_bad_input_still_has_authority_denial(tmp_path, content):
|
||||
authority = RequestAuthority('request', 'alice', 'thread', str(tmp_path), (OperationGrant('api_call'),))
|
||||
_, result = await tool_execution.execute_tool_block(ToolBlock('api_call', content), owner='alice',
|
||||
session_id='thread', workspace=str(tmp_path), request_authority=authority,
|
||||
security_context=ToolRunSecurityContext())
|
||||
assert result['failure_kind'] == 'request_authority_denied'
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Structural dispatch cost and exact publication lifetime regressions."""
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from core.atomic_io import atomic_write_json
|
||||
from src import bg_jobs, containment, process_ownership
|
||||
from src.agent_runtime import resources as identities
|
||||
from src.agent_runtime.authority import ExactOperation, bind_request_authority
|
||||
from src.agent_runtime.resources import NativeBackendResource, ResourceIdentityError
|
||||
from src.agent_tools.subprocess_tools import BashTool
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
from tests.test_runtime_resource_integration import workspace, authority, dispatch
|
||||
from tests.test_background_resource_identity import seed
|
||||
from src.agent_runtime import process_resources as resources
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tool,content', [('bash', 'printf guarded'), ('python', 'print("guarded")')])
|
||||
async def test_real_dispatch_scans_workspace_once_per_binding(workspace, monkeypatch, tool, content):
|
||||
(workspace / 'child').mkdir()
|
||||
(workspace / 'child' / 'link').symlink_to(workspace / 'child')
|
||||
calls = []
|
||||
walk = os.walk
|
||||
def counted(*args, **kwargs):
|
||||
calls.append(args[0])
|
||||
return walk(*args, **kwargs)
|
||||
monkeypatch.setattr(os, 'walk', counted)
|
||||
admitted = authority(workspace, tool)
|
||||
for _ in range(2):
|
||||
calls.clear()
|
||||
_, result = await dispatch(admitted, tool, content)
|
||||
assert result['exit_code'] == 0, result
|
||||
assert calls == [workspace]
|
||||
assert not list(resources._LAUNCH_DIR.glob('*.json'))
|
||||
|
||||
|
||||
async def test_alias_created_after_resolution_is_denied_at_binding(workspace):
|
||||
admitted = authority(workspace)
|
||||
op = ExactOperation.normalize('bash', 'printf safe')
|
||||
bound = resources.resolve_process_operation(admitted, op, NativeBackendResource('bash'))
|
||||
resources._LAUNCH_DIR.mkdir(parents=True)
|
||||
state = resources._LAUNCH_DIR / ('a' * 32 + '.json')
|
||||
state.write_text('{}')
|
||||
(workspace / 'alias').symlink_to(state)
|
||||
with bind_request_authority(admitted), pytest.raises(ResourceIdentityError):
|
||||
with resources.bind_process_operation(bound):
|
||||
pytest.fail('New control-plane alias admitted')
|
||||
|
||||
|
||||
async def test_publication_retained_during_launch_and_retired_after_teardown(workspace, monkeypatch):
|
||||
entered, resume = asyncio.Event(), asyncio.Event()
|
||||
run = containment.run
|
||||
paths = []
|
||||
async def held(grant, command, **kwargs):
|
||||
launch = resources.active_process_operation().launch
|
||||
path = resources.launch_path(launch.generation)
|
||||
assert path.is_file()
|
||||
paths.append(path)
|
||||
entered.set()
|
||||
await resume.wait()
|
||||
return await run(grant, command, **kwargs)
|
||||
monkeypatch.setattr(containment, 'run', held)
|
||||
task = asyncio.create_task(dispatch(authority(workspace), 'bash', 'printf foreground'))
|
||||
await asyncio.wait_for(entered.wait(), 5)
|
||||
assert paths[0].is_file()
|
||||
resume.set()
|
||||
_, result = await task
|
||||
assert result['exit_code'] == 0 and result['teardown']['dead']
|
||||
assert not paths[0].exists()
|
||||
|
||||
|
||||
async def test_retired_publication_cannot_replay_bound_reservation(workspace):
|
||||
admitted = authority(workspace)
|
||||
op = ExactOperation.normalize('bash', 'printf once')
|
||||
bound = resources.resolve_process_operation(admitted, op, NativeBackendResource('bash'))
|
||||
from src import tool_execution
|
||||
token = tool_execution._active_workspace.set(str(workspace))
|
||||
try:
|
||||
with bind_request_authority(admitted), resources.bind_process_operation(bound):
|
||||
ctx = {'owner': 'alice', 'session_id': 'thread'}
|
||||
first = await BashTool().execute(op.input, ctx)
|
||||
assert first['exit_code'] == 0
|
||||
assert not resources.launch_path(bound.launch.generation).exists()
|
||||
second = await BashTool().execute(op.input, ctx)
|
||||
assert second['failure_kind'] == 'resource_identity_denied'
|
||||
copy = replace(bound, exact_approval=None)
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
with resources.bind_process_operation(copy):
|
||||
pytest.fail('Approval copy renewed a consumed launch')
|
||||
finally:
|
||||
tool_execution._active_workspace.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('publication', [[], None, 'malformed'])
|
||||
def test_nonobject_publication_cannot_be_retired(workspace, publication):
|
||||
resource, rec = seed(workspace, status='done')
|
||||
path = resources.launch_path(resource.generation)
|
||||
path.write_text(json.dumps(publication))
|
||||
launch = identities.ProcessLaunchResource.from_dict(rec['launch_resource'])
|
||||
assert not resources.retire_launch(launch, resource.containment_id, job=resource)
|
||||
assert json.loads(path.read_text()) == publication
|
||||
|
||||
|
||||
async def test_corrupt_publication_retirement_preserves_command_result(workspace, monkeypatch):
|
||||
attach = resources.attach_containment_processes
|
||||
paths = []
|
||||
def corrupt_after_attachment(launch, containment_id):
|
||||
observed = attach(launch, containment_id)
|
||||
path = resources.launch_path(launch.generation)
|
||||
path.write_text('[]')
|
||||
paths.append(path)
|
||||
return observed
|
||||
monkeypatch.setattr(resources, 'attach_containment_processes', corrupt_after_attachment)
|
||||
_, result = await dispatch(authority(workspace), 'bash', 'printf completed')
|
||||
assert result['exit_code'] == 0 and result['output'] == 'completed', result
|
||||
assert paths[0].read_text() == '[]'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status,followed_up,old,removed', [
|
||||
('running', True, True, False), ('done', False, True, False),
|
||||
('done', True, False, False), ('done', True, True, True), ('failed', True, True, True),
|
||||
])
|
||||
def test_background_publication_tracks_supported_history_lifetime(workspace, status, followed_up, old, removed):
|
||||
resource, rec = seed(workspace, status=status)
|
||||
rec.update(followed_up=followed_up, ended_at=time.time() - (bg_jobs._RETENTION_S + 10 if old else 0))
|
||||
jobs = {'job': rec}
|
||||
bg_jobs._save(jobs)
|
||||
assert resources.launch_path(resource.generation).exists()
|
||||
bg_jobs._prune(jobs, time.time())
|
||||
assert resources.launch_path(resource.generation).exists() is not removed
|
||||
assert ('job' not in jobs) is removed
|
||||
if removed:
|
||||
bg_jobs._save(jobs)
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
resources.validate_job(resource)
|
||||
|
||||
|
||||
def test_old_generation_retirement_cannot_delete_replacement(workspace):
|
||||
old, rec = seed(workspace, status='done')
|
||||
new, _ = seed(workspace, status='done')
|
||||
old_launch = identities.ProcessLaunchResource.from_dict(rec['launch_resource'])
|
||||
assert resources.retire_launch(old_launch, old.containment_id, job=old)
|
||||
assert resources.launch_path(new.generation).is_file()
|
||||
# Even a replaced file at the old generation's slot is not deletable by old linkage.
|
||||
replacement = json.loads(resources.launch_path(new.generation).read_text())
|
||||
atomic_write_json(resources.launch_path(old.generation), replacement)
|
||||
assert not resources.retire_launch(old_launch, old.containment_id, job=old)
|
||||
assert resources.launch_path(old.generation).is_file()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('manager,release,retired', [
|
||||
(process_ownership.OWNED, False, False), (process_ownership.UNVERIFIABLE, False, False),
|
||||
(process_ownership.OWNED, True, False), (process_ownership.UNVERIFIABLE, True, False),
|
||||
(process_ownership.GONE, False, True), (process_ownership.FOREIGN, False, True),
|
||||
(process_ownership.GONE, True, True),
|
||||
])
|
||||
def test_startup_retirement_does_not_invent_process_death(workspace, monkeypatch, manager, release, retired):
|
||||
admitted = authority(workspace)
|
||||
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf recovery'), NativeBackendResource('bash')).launch
|
||||
cid = 'receipt'
|
||||
resources.publish_launch(launch, admitted, cid)
|
||||
atomic_write_json(containment._store_path(), {cid: {'id': cid, 'launch_generation': launch.generation,
|
||||
'manager_pid': 123, 'manager_token': 'old-manager', 'release': {'dead': release}}})
|
||||
monkeypatch.setattr(process_ownership, 'verify', lambda *args: manager)
|
||||
assert resources.prune_foreground_publications() == int(retired)
|
||||
assert resources.launch_path(launch.generation).exists() is not retired
|
||||
assert containment._load_records()[cid]['release']['dead'] is release
|
||||
|
||||
|
||||
def test_restart_never_prunes_background_linkage(workspace, monkeypatch):
|
||||
resource, _ = seed(workspace, status='done')
|
||||
monkeypatch.setattr(process_ownership, 'verify', lambda *args: process_ownership.GONE)
|
||||
assert resources.prune_foreground_publications() == 0
|
||||
assert resources.launch_path(resource.generation).is_file()
|
||||
|
||||
|
||||
def test_snapshot_is_rebuilt_for_each_guard(workspace):
|
||||
resources._LAUNCH_DIR.mkdir(parents=True)
|
||||
target = workspace / 'data'; target.write_text('ordinary')
|
||||
(workspace / 'link').symlink_to(target)
|
||||
resources.guard_launch_workspace(identities.FilesystemRoot.seal(workspace))
|
||||
os.link(target, resources._LAUNCH_DIR / ('b' * 32 + '.json'))
|
||||
with pytest.raises(ResourceIdentityError):
|
||||
resources.guard_launch_workspace(identities.FilesystemRoot.seal(workspace))
|
||||
|
||||
|
||||
def test_missing_receipt_publication_cannot_recover_authority(workspace):
|
||||
admitted = authority(workspace)
|
||||
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf recovery'), NativeBackendResource('bash')).launch
|
||||
resources.publish_launch(launch, admitted, 'missing-receipt')
|
||||
assert resources.prune_foreground_publications() == 1
|
||||
assert not resources.launch_path(launch.generation).exists()
|
||||
assert not containment._load_records()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('receipt_data', ['{corrupt', '[]', '{"receipt":null}'])
|
||||
def test_unreadable_receipts_cannot_retire_live_consumers(workspace, receipt_data):
|
||||
admitted = authority(workspace)
|
||||
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf pending'), NativeBackendResource('bash')).launch
|
||||
resources.publish_launch(launch, admitted, 'receipt')
|
||||
containment._store_path().write_text(receipt_data)
|
||||
assert resources.prune_foreground_publications() == 0
|
||||
assert resources.launch_path(launch.generation).is_file()
|
||||
|
||||
|
||||
def test_missing_manager_identity_cannot_retire_attachment(workspace, monkeypatch):
|
||||
admitted = authority(workspace)
|
||||
launch = resources.resolve_process_operation(admitted, ExactOperation.normalize('bash', 'printf pending'), NativeBackendResource('bash')).launch
|
||||
resources.publish_launch(launch, admitted, 'receipt')
|
||||
atomic_write_json(containment._store_path(), {'receipt': {'id': 'receipt',
|
||||
'launch_generation': launch.generation, 'release': {'dead': True}}})
|
||||
monkeypatch.setattr(process_ownership, 'verify', lambda *args: pytest.fail('Missing manager treated as observed'))
|
||||
assert resources.prune_foreground_publications() == 0
|
||||
assert resources.launch_path(launch.generation).is_file()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Local administration and exact Cookbook caller-to-route regressions."""
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
|
||||
from routes import cookbook_routes, shell_routes
|
||||
from src import builtin_actions, tool_execution
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority, bind_request_authority, seal_task_authority, restore_task_authority
|
||||
from src.agent_runtime.remote_resources import bind_backend_for_operation, bind_backend_operation
|
||||
from src.agent_runtime.local_model_control import CAPABILITY_HEADER, model_control_headers
|
||||
from src.agent_runtime.resources import ResourceIdentityError
|
||||
from src.tools import cookbook
|
||||
from src.tool_capabilities import ToolRunSecurityContext
|
||||
from src.tool_types import ToolBlock
|
||||
|
||||
|
||||
def request(host='127.0.0.1', headers=None, user=None):
|
||||
req = Request({'type': 'http', 'method': 'POST', 'scheme': 'http', 'path': '/api/shell/exec',
|
||||
'server': ('127.0.0.1', 7000), 'client': (host, 1234),
|
||||
'headers': [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()],
|
||||
'app': SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(is_admin=lambda u: u == 'alice')))})
|
||||
req.state.current_user = user
|
||||
return req
|
||||
|
||||
|
||||
@pytest.mark.parametrize('host,headers,allowed', [
|
||||
('127.0.0.1', {}, True), ('::1', {}, True), ('192.0.2.1', {}, False),
|
||||
('127.0.0.1', {'x-forwarded-for': '192.0.2.1'}, False),
|
||||
('127.0.0.1', {'forwarded': 'for=192.0.2.1'}, False),
|
||||
('127.0.0.1', {'cf-ray': 'proxy'}, False),
|
||||
('127.0.0.1', {'x-forwarded-proto': 'https'}, False),
|
||||
('127.0.0.1', {'sec-fetch-site': 'cross-site'}, False),
|
||||
('127.0.0.1', {'origin': 'https://evil.example'}, False),
|
||||
('127.0.0.1', {INTERNAL_TOOL_HEADER: 'forged'}, False),
|
||||
('127.0.0.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}, False),
|
||||
])
|
||||
def test_auth_disabled_operator_transport(monkeypatch, host, headers, allowed):
|
||||
monkeypatch.setenv('AUTH_ENABLED', 'false')
|
||||
req = request(host, headers)
|
||||
if allowed:
|
||||
shell_routes._require_admin(req)
|
||||
else:
|
||||
with pytest.raises(HTTPException) as error:
|
||||
shell_routes._require_admin(req)
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user,allowed', [('alice', True), ('bob', False), (None, False), ('api', False), (INTERNAL_TOOL_USER, False)])
|
||||
def test_auth_enabled_administration(monkeypatch, user, allowed):
|
||||
monkeypatch.setenv('AUTH_ENABLED', 'true')
|
||||
if allowed:
|
||||
shell_routes._require_admin(request('192.0.2.1', user=user))
|
||||
else:
|
||||
with pytest.raises(HTTPException):
|
||||
shell_routes._require_admin(request(user=user))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def control_app(tmp_path, monkeypatch):
|
||||
# Auth tests can reload middleware after collection. Authenticate the live
|
||||
# transport token used by real producers, rather than a collection snapshot.
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
|
||||
monkeypatch.setenv('AUTH_ENABLED', 'true')
|
||||
manager = SimpleNamespace(is_configured=True, users={'alice': {}}, is_admin=lambda u: u == 'alice')
|
||||
import core.auth
|
||||
monkeypatch.setattr(core.auth, 'AuthManager', lambda: manager)
|
||||
monkeypatch.setattr(tool_execution, '_owner_is_admin', lambda u: u == 'alice')
|
||||
monkeypatch.setattr(cookbook_routes, 'TMUX_LOG_DIR', tmp_path / 'tmux')
|
||||
state = tmp_path / 'cookbook.json'
|
||||
state.write_text(json.dumps({'presets': [{'name': 'preset', 'model': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}]}))
|
||||
monkeypatch.setattr(cookbook_routes, 'COOKBOOK_STATE_FILE', str(state))
|
||||
monkeypatch.setattr(builtin_actions, 'COOKBOOK_STATE_FILE', str(state))
|
||||
spawned = []
|
||||
async def spawn(command, **kwargs):
|
||||
spawned.append(command)
|
||||
async def wait(): return 0
|
||||
async def read(): return b''
|
||||
return SimpleNamespace(returncode=0, wait=wait, stderr=SimpleNamespace(read=read))
|
||||
monkeypatch.setattr(asyncio, 'create_subprocess_shell', spawn)
|
||||
async def remote_probe(*args, **kwargs):
|
||||
async def communicate(): return b'tmux', b''
|
||||
return SimpleNamespace(returncode=0, communicate=communicate)
|
||||
monkeypatch.setattr(asyncio, 'create_subprocess_exec', remote_probe)
|
||||
import src.assistant_log
|
||||
monkeypatch.setattr(src.assistant_log, 'log_to_assistant', lambda *a, **k: None)
|
||||
async def endpoint(**kwargs): return {'added': True, 'endpoint_id': 'endpoint'}
|
||||
monkeypatch.setattr(cookbook, '_ensure_served_endpoint', endpoint)
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = manager
|
||||
@app.middleware('http')
|
||||
async def attribution(req, next):
|
||||
if req.headers.get(INTERNAL_TOOL_HEADER) == INTERNAL_TOOL_TOKEN:
|
||||
req.state.current_user = req.headers.get('X-Odysseus-Owner') or INTERNAL_TOOL_USER
|
||||
return await next(req)
|
||||
app.include_router(cookbook_routes.setup_cookbook_routes())
|
||||
app.include_router(shell_routes.setup_shell_routes())
|
||||
real_client = httpx.AsyncClient
|
||||
def client_factory(*args, **kwargs):
|
||||
kwargs.setdefault('transport', httpx.ASGITransport(app=app))
|
||||
return real_client(*args, **kwargs)
|
||||
monkeypatch.setattr(httpx, 'AsyncClient', client_factory)
|
||||
return app, spawned, tmp_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tool,args', [
|
||||
('download_model', {'repo_id': 'org/model', 'local': True}),
|
||||
('serve_model', {'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg', 'local': True}),
|
||||
('serve_preset', {'name': 'preset'}),
|
||||
])
|
||||
async def test_real_local_tool_dispatch_reaches_real_model_route(control_app, tool, args):
|
||||
app, spawned, work = control_app
|
||||
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant(tool),))
|
||||
_, result = await tool_execution.execute_tool_block(ToolBlock(tool, json.dumps(args)), owner='alice',
|
||||
session_id='thread', workspace=str(work), request_authority=authority, security_context=ToolRunSecurityContext())
|
||||
assert result['exit_code'] == 0, result
|
||||
assert result['session_id'].startswith('cookbook-' if tool == 'download_model' else 'serve-')
|
||||
assert len(spawned) == 1 and 'tmux new-session' in spawned[0]
|
||||
|
||||
|
||||
async def test_real_scheduled_local_action_uses_restored_exact_authority(control_app):
|
||||
app, spawned, work = control_app
|
||||
command = json.dumps({'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg', 'set_default': False})
|
||||
snapshot = seal_task_authority(command, 'action', 'cookbook_serve', owner='alice')
|
||||
authority = restore_task_authority(snapshot, command, 'action', 'cookbook_serve', owner='alice')
|
||||
with bind_request_authority(authority):
|
||||
message, ok = await builtin_actions.action_cookbook_serve('alice', command=command)
|
||||
assert ok, message
|
||||
assert len(spawned) == 1
|
||||
with bind_request_authority(authority):
|
||||
_, ok = await builtin_actions.action_cookbook_serve('alice', command=command.replace('samplepkg', 'changedpkg'))
|
||||
assert not ok and len(spawned) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('host,headers', [
|
||||
('127.0.0.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}),
|
||||
('192.0.2.1', {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}),
|
||||
('127.0.0.1', {INTERNAL_TOOL_HEADER: 'forged'}),
|
||||
('127.0.0.1', {CAPABILITY_HEADER: 'forged', INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}),
|
||||
])
|
||||
async def test_header_only_cannot_launch(control_app, host, headers):
|
||||
app, spawned, _ = control_app
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client:
|
||||
for path in ('/api/model/download', '/api/model/serve', '/api/shell/exec'):
|
||||
r = await client.post(path, json={'repo_id': 'org/model', 'cmd': 'printf nope', 'command': 'printf nope'}, headers=headers)
|
||||
assert r.status_code == 403
|
||||
assert not spawned
|
||||
|
||||
|
||||
@pytest.mark.parametrize('substitute', ['body', 'route', 'owner', 'remote', 'proxy', 'replay'])
|
||||
async def test_capability_exact_transport_binding(control_app, substitute):
|
||||
app, spawned, work = control_app
|
||||
content = json.dumps({'repo_id': 'org/model', 'local': True})
|
||||
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('download_model'),))
|
||||
operation = ExactOperation.normalize('download_model', content)
|
||||
backend = bind_backend_for_operation(authority, operation)
|
||||
body = {'repo_id': 'org/model'}
|
||||
with bind_request_authority(authority), bind_backend_operation(backend), model_control_headers('download_model', content, 'alice', body) as headers:
|
||||
changed = dict(headers); payload = dict(body); path = '/api/model/download'; host = '127.0.0.1'
|
||||
if substitute == 'body': payload['repo_id'] = 'org/changed'
|
||||
if substitute == 'route': path = '/api/model/serve'
|
||||
if substitute == 'owner': changed['X-Odysseus-Owner'] = 'bob'
|
||||
if substitute == 'remote': host = '192.0.2.1'
|
||||
if substitute == 'proxy': changed['x-forwarded-for'] = '192.0.2.1'
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client:
|
||||
if substitute == 'replay':
|
||||
assert (await client.post(path, json=payload, headers=changed)).status_code == 200
|
||||
r = await client.post(path, json=payload, headers=changed)
|
||||
assert r.status_code == 403
|
||||
assert len(spawned) == (1 if substitute == 'replay' else 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field', ['owner', 'request_id', 'session_id'])
|
||||
def test_producer_wrong_application_binding(control_app, field):
|
||||
_, _, work = control_app
|
||||
content = '{"repo_id":"org/model","local":true}'
|
||||
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('download_model'),))
|
||||
backend = bind_backend_for_operation(authority, ExactOperation.normalize('download_model', content))
|
||||
changed = replace(authority, **{field: 'replacement'}, resource_roots=None, backend_resources=None, owned_scopes=None)
|
||||
with bind_request_authority(changed), bind_backend_operation(backend), pytest.raises(ResourceIdentityError):
|
||||
with model_control_headers('download_model', content, 'alice', {'repo_id': 'org/model'}):
|
||||
pytest.fail('Substituted producer obtained a capability')
|
||||
|
||||
|
||||
async def test_remote_route_semantics_remain_unchanged(control_app):
|
||||
app, spawned, _ = control_app
|
||||
async with httpx.AsyncClient(base_url='http://127.0.0.1') as client:
|
||||
r = await client.post('/api/model/download', json={'repo_id': 'org/model', 'remote_host': 'gpu.example'},
|
||||
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN})
|
||||
assert r.status_code == 200 and r.json()['ok'], r.text
|
||||
assert len(spawned) == 1 and 'ssh ' in spawned[0]
|
||||
|
||||
|
||||
async def test_auth_disabled_local_route_usage(control_app, monkeypatch):
|
||||
app, spawned, _ = control_app
|
||||
monkeypatch.setenv('AUTH_ENABLED', 'false')
|
||||
async with httpx.AsyncClient(base_url='http://127.0.0.1') as client:
|
||||
shell = await client.post('/api/shell/exec', json={'command': ''})
|
||||
state = await client.post('/api/cookbook/state', json={'tasks': []})
|
||||
launch = await client.post('/api/model/download', json={'repo_id': 'org/model'})
|
||||
assert shell.status_code == state.status_code == launch.status_code == 200
|
||||
assert launch.json()['ok'] and len(spawned) == 1
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=('192.0.2.1', 1)), base_url='http://127.0.0.1') as client:
|
||||
for path, body in [('/api/shell/exec', {'command': ''}), ('/api/cookbook/state', {'tasks': []}), ('/api/model/download', {'repo_id': 'org/model'})]:
|
||||
assert (await client.post(path, json=body)).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize('host,internal', [('127.0.0.1', True), ('192.0.2.1', False)])
|
||||
async def test_scoped_wrapper_cannot_bypass_native_control(control_app, monkeypatch, host, internal):
|
||||
from routes.codex_routes import setup_codex_routes
|
||||
app, spawned, _ = control_app
|
||||
app.include_router(setup_codex_routes())
|
||||
monkeypatch.setenv('AUTH_ENABLED', 'false')
|
||||
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if internal else {}
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, client=(host, 123)), base_url='http://127.0.0.1') as client:
|
||||
r = await client.post('/api/codex/cookbook/serve', json={'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}, headers=headers)
|
||||
assert r.status_code == 403 and not spawned
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path', ['/api/codex/cookbook/serve', '/api/codex/cookbook/stop/job', '/api/codex/%63ookbook/serve'])
|
||||
async def test_generic_app_api_cannot_substitute_scoped_wrapper(control_app, path):
|
||||
_, spawned, work = control_app
|
||||
authority = RequestAuthority('request', 'alice', 'thread', str(work), (OperationGrant('app_api'),))
|
||||
content = json.dumps({'action': 'call', 'method': 'POST', 'path': path, 'body': {'repo_id': 'samplepkg', 'cmd': 'python -m pip install samplepkg'}})
|
||||
_, result = await tool_execution.execute_tool_block(ToolBlock('app_api', content), owner='alice',
|
||||
session_id='thread', workspace=str(work), request_authority=authority, security_context=ToolRunSecurityContext())
|
||||
assert result['failure_kind'] == 'resource_identity_denied' and not spawned
|
||||
|
||||
|
||||
async def test_direct_endpoint_call_keeps_producer_gate(control_app, monkeypatch):
|
||||
from routes.cookbook_helpers import ModelDownloadRequest
|
||||
app, spawned, _ = control_app
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
endpoint = next(route.endpoint for route in router.routes if getattr(route, 'path', '') == '/api/model/download')
|
||||
monkeypatch.setenv('AUTH_ENABLED', 'false')
|
||||
req = request('192.0.2.1')
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await endpoint(req, ModelDownloadRequest(repo_id='org/model'))
|
||||
assert exc.value.status_code == 403 and not spawned
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Closed inheritance is the complete subprocess environment boundary."""
|
||||
from src import tool_execution
|
||||
|
||||
def test_closed_subprocess_environment_drops_all_unlisted_credentials(monkeypatch):
|
||||
from unittest.mock import patch
|
||||
import os
|
||||
ambient = {'PATH': '/usr/bin', 'LANG': 'C.UTF-8', 'OPENAI_API_KEY': 'secret', 'HF_TOKEN': 'secret',
|
||||
'AUTH_ENABLED': 'false', 'DATABASE_URL': 'secret', 'PATH_TOKEN': 'secret',
|
||||
'AWS_SECRET_ACCESS_KEY': 'secret', 'ARBITRARY': 'secret', 'HOME': '/server/secret'}
|
||||
with patch.dict(os.environ, ambient, clear=True):
|
||||
child = tool_execution._agent_subprocess_env()
|
||||
assert child['PATH'] == '/usr/bin'
|
||||
assert child['HOME'] == tool_execution._AGENT_WORKDIR
|
||||
assert set(child) <= tool_execution._SAFE_SUBPROCESS_VARS | {'HOME', 'TERM', 'COLUMNS', 'LINES'}
|
||||
assert all(child.get(name) != value for name, value in ambient.items() if name not in {'PATH', 'LANG'})
|
||||
|
||||
|
||||
async def test_real_python_child_does_not_inherit_ambient_credentials(workspace, monkeypatch):
|
||||
from tests.test_runtime_resource_integration import authority, dispatch
|
||||
for name in ('OPENAI_API_KEY', 'HF_TOKEN', 'PATH_TOKEN', 'DATABASE_URL', 'ODYSSEUS_INTERNAL_TOKEN'):
|
||||
monkeypatch.setenv(name, 'never-inherit-this-value')
|
||||
_, result = await dispatch(authority(workspace, 'python'), 'python',
|
||||
'import os\nprint(any(v == "never-inherit-this-value" for v in os.environ.values()))')
|
||||
assert result['exit_code'] == 0 and result['output'] == 'False'
|
||||
|
||||
|
||||
from tests.test_runtime_resource_integration import workspace
|
||||
@@ -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. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user