mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-22 06:42: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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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.
|
No model-reachable page producer entry remains in the native/research wrapper.
|
||||||
Trusted observation/setup methods are not tools or routes. Native arbitrary
|
Trusted observation/setup methods are not tools or routes. Native arbitrary
|
||||||
|
|||||||
@@ -303,3 +303,25 @@ the rebase:
|
|||||||
refusals (no claim, no execution id).
|
refusals (no claim, no execution id).
|
||||||
8. Rerun the four Wave 4 suites plus `test_runtime_resource_integration.py` and the
|
8. Rerun the four Wave 4 suites plus `test_runtime_resource_integration.py` and the
|
||||||
`test_wave3_*` suites on the rebased tree.
|
`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
|
because cookbook surfaces expose host topology, task logs, tmux
|
||||||
commands, and model-serving controls.
|
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)
|
owner = _scope_owner(request, allowed)
|
||||||
if not getattr(request.state, "api_token", False):
|
if not getattr(request.state, "api_token", False):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
|||||||
@@ -408,8 +408,8 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
async def protect_native_control(request: Request):
|
async def protect_native_control(request: Request):
|
||||||
if request.method in {"GET", "HEAD"}:
|
if request.method in {"GET", "HEAD"}:
|
||||||
return
|
return
|
||||||
# Cookbook's UI records and session strings are not an application
|
# UI records/session strings are not process authority. Local tool
|
||||||
# process registry. No loopback caller can use them as local authority.
|
# launches require a one-use capability from their admitted producer.
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
from routes.shell_routes import _require_admin
|
from routes.shell_routes import _require_admin
|
||||||
if path in {"/api/cookbook/kill-pid", "/api/cookbook/state", "/api/cookbook/ssh-key"}:
|
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"}:
|
if path in {"/api/model/download", "/api/model/serve"}:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
if not payload.get("remote_host"):
|
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)])
|
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)
|
_cookbook_state_path = Path(COOKBOOK_STATE_FILE)
|
||||||
_state_get_cache = {"ts": 0.0, "mtime": 0.0, "value": None}
|
_state_get_cache = {"ts": 0.0, "mtime": 0.0, "value": None}
|
||||||
_tasks_status_cache = {"ts": 0.0, "value": None}
|
_tasks_status_cache = {"ts": 0.0, "value": None}
|
||||||
@@ -1093,6 +1107,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
"""Download a HuggingFace model in a tmux session.
|
"""Download a HuggingFace model in a tmux session.
|
||||||
Uses `hf download` CLI directly — runs in tmux via `script -qc`
|
Uses `hf download` CLI directly — runs in tmux via `script -qc`
|
||||||
for real TTY progress, streams ANSI-stripped output via log file."""
|
for real TTY progress, streams ANSI-stripped output via log file."""
|
||||||
|
protect_local_model_producer(request, req.remote_host)
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
# Defence-in-depth: even though this endpoint is admin-gated, refuse
|
# Defence-in-depth: even though this endpoint is admin-gated, refuse
|
||||||
# values that would land in shell contexts with metacharacters.
|
# 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
|
keep strict validation, but serving local cached models must not require
|
||||||
a fake org/name wrapper.
|
a fake org/name wrapper.
|
||||||
"""
|
"""
|
||||||
|
protect_local_model_producer(request, req.remote_host)
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
# Defence-in-depth: reject values that could break out of shell contexts.
|
# Defence-in-depth: reject values that could break out of shell contexts.
|
||||||
validate_remote_host(req.remote_host)
|
validate_remote_host(req.remote_host)
|
||||||
|
|||||||
@@ -59,12 +59,17 @@ from core.platform_compat import (
|
|||||||
def _require_admin(request: Request):
|
def _require_admin(request: Request):
|
||||||
"""Reject non-admin callers. Shell exec is admin-only — never expose to
|
"""Reject non-admin callers. Shell exec is admin-only — never expose to
|
||||||
regular users; that's RCE-after-signup."""
|
regular users; that's RCE-after-signup."""
|
||||||
# Anonymous loopback is also reachable from an admitted native workload.
|
# Tool authentication is never human administration. Operator-disabled
|
||||||
# It cannot be treated as a human admin or as process creation authority.
|
# 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
|
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")
|
raise HTTPException(403, "Internal shell execution requires a dedicated resource-bound producer")
|
||||||
if _auth_disabled():
|
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")
|
raise HTTPException(403, "Anonymous native process control has no resource authority")
|
||||||
auth_manager = getattr(request.app.state, "auth_manager", None)
|
auth_manager = getattr(request.app.state, "auth_manager", None)
|
||||||
if not auth_manager:
|
if not auth_manager:
|
||||||
|
|||||||
@@ -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, 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.
|
- `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.
|
- 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.
|
- 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.
|
- `"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:
|
if operation is not None:
|
||||||
authority = replace(authority, grants=(OperationGrant(operation.tool,
|
authority = replace(authority, grants=(OperationGrant(operation.tool,
|
||||||
inputs=frozenset({operation.input})),))
|
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
|
parent = active_request_authority() if parent_authority is MISSING_AUTHORITY else parent_authority
|
||||||
if parent_authority is None:
|
if parent_authority is None:
|
||||||
parent = RequestAuthority.empty(owner=owner)
|
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",
|
"notes", "memory", "vault", "upload", "uploads", "attachments",
|
||||||
"shell", "model", "cookbook"}
|
"shell", "model", "cookbook"}
|
||||||
segments = path.strip("/").split("/")
|
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:
|
if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private:
|
||||||
raise ResourceIdentityError("Owned records require a dedicated resource-bound tool")
|
raise ResourceIdentityError("Owned records require a dedicated resource-bound tool")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from core.atomic_io import store_transaction
|
from core.atomic_io import store_transaction
|
||||||
|
|
||||||
@@ -220,6 +221,19 @@ def intersect_launch_scopes(parent, child):
|
|||||||
return tuple(dict.fromkeys(narrowed))
|
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)
|
@dataclass(frozen=True)
|
||||||
class BoundProcessOperation:
|
class BoundProcessOperation:
|
||||||
operation: object
|
operation: object
|
||||||
@@ -230,6 +244,7 @@ class BoundProcessOperation:
|
|||||||
jobs: tuple[BackgroundJobResource, ...] = ()
|
jobs: tuple[BackgroundJobResource, ...] = ()
|
||||||
processes: tuple[ProcessResource, ...] = ()
|
processes: tuple[ProcessResource, ...] = ()
|
||||||
exact_approval: object | None = None
|
exact_approval: object | None = None
|
||||||
|
_launch_use: _LaunchUse = field(default_factory=_LaunchUse, compare=False, repr=False)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
from src.agent_runtime.authority import ExactOperation
|
from src.agent_runtime.authority import ExactOperation
|
||||||
@@ -249,7 +264,8 @@ class BoundProcessOperation:
|
|||||||
def validate(self):
|
def validate(self):
|
||||||
if self.launch is not None:
|
if self.launch is not None:
|
||||||
self.launch.validate()
|
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:
|
for job in self.jobs:
|
||||||
validate_job(job, mutation=self.operation.action in {"kill", "stop", "cancel", "terminate", "ack"})
|
validate_job(job, mutation=self.operation.action in {"kill", "stop", "cancel", "terminate", "ack"})
|
||||||
for process in self.processes:
|
for process in self.processes:
|
||||||
@@ -321,6 +337,11 @@ def bind_process_operation(operation):
|
|||||||
raise TypeError("Process operation must be server-owned")
|
raise TypeError("Process operation must be server-owned")
|
||||||
if operation is not None:
|
if operation is not None:
|
||||||
operation.validate()
|
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)
|
token = _ACTIVE.set(operation)
|
||||||
try:
|
try:
|
||||||
yield operation
|
yield operation
|
||||||
@@ -363,7 +384,7 @@ def guard_launch_workspace(root):
|
|||||||
"""
|
"""
|
||||||
from src import bg_jobs, containment, constants
|
from src import bg_jobs, containment, constants
|
||||||
from src import browser_identity
|
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,
|
control = (Path(bg_jobs._STORE), Path(bg_jobs._JOBS_DIR), containment._store_path(), _LAUNCH_DIR,
|
||||||
Path(constants.BROWSER_RESOURCES_DIR),
|
Path(constants.BROWSER_RESOURCES_DIR),
|
||||||
browser_identity.STATE_ROOT,
|
browser_identity.STATE_ROOT,
|
||||||
@@ -373,12 +394,16 @@ def guard_launch_workspace(root):
|
|||||||
raise ResourceIdentityError("Launch boundary contains server control state")
|
raise ResourceIdentityError("Launch boundary contains server control state")
|
||||||
def unresolved(error):
|
def unresolved(error):
|
||||||
raise ResourceIdentityError("Launch workspace cannot be inspected") from 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 directory, dirs, files in os.walk(base, followlinks=False, onerror=unresolved):
|
||||||
for name in (*dirs, *files):
|
for name in (*dirs, *files):
|
||||||
path = Path(directory) / name
|
path = Path(directory) / name
|
||||||
info = path.lstat()
|
info = path.lstat()
|
||||||
if (path.is_symlink() or info.st_nlink > 1) and _control_plane_path(str(path.resolve())):
|
if path.is_symlink() or info.st_nlink > 1:
|
||||||
raise ResourceIdentityError("Launch boundary aliases server control state")
|
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")
|
@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)
|
path = launch_path(launch.generation)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
raise ResourceIdentityError("Launch reservation has already been used")
|
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(),
|
atomic_write_json(path, {"launch": launch.to_dict(), "authority": authority.to_dict(),
|
||||||
"containment_id": containment_id, "job": job.to_dict() if job else None,
|
"containment_id": containment_id, "job": job.to_dict() if job else None,
|
||||||
"processes": [p.to_dict() for p in processes]})
|
"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")
|
@store_transaction(lambda: _LAUNCH_DIR / "publication")
|
||||||
def attach_containment_processes(launch, containment_id):
|
def attach_containment_processes(launch, containment_id):
|
||||||
"""Attach producer-frozen lifecycle records; never capture a current PID."""
|
"""Attach producer-frozen lifecycle records; never capture a current PID."""
|
||||||
@@ -410,10 +507,13 @@ def attach_containment_processes(launch, containment_id):
|
|||||||
processes = []
|
processes = []
|
||||||
for role, pid_key, token_key, group_key in (("leader", "pid", "start_token", "pgid"),
|
for role, pid_key, token_key, group_key in (("leader", "pid", "start_token", "pgid"),
|
||||||
("namespace_init", "namespace_pid", "namespace_start_token", None)):
|
("namespace_init", "namespace_pid", "namespace_start_token", None)):
|
||||||
if record.get(pid_key):
|
pid = record.get(pid_key)
|
||||||
processes.append(ProcessResource("native:containment", launch.owner, launch.request_id,
|
token = record.get(token_key)
|
||||||
launch.thread_id, ProcessIdentity(record[pid_key], record.get(token_key), record.get(group_key) if group_key else None),
|
if not pid or not token:
|
||||||
role, "", containment_id))
|
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
|
from core.atomic_io import atomic_write_json
|
||||||
published["processes"] = [p.to_dict() for p in processes]
|
published["processes"] = [p.to_dict() for p in processes]
|
||||||
atomic_write_json(path, published)
|
atomic_write_json(path, published)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def _aliases_effect_store(candidate, directories):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _control_plane_path(path):
|
def _control_plane_snapshot():
|
||||||
# Execution snapshots/receipts are server state, even if a workspace root
|
# Execution snapshots/receipts are server state, even if a workspace root
|
||||||
# contains the data directory. A writable user file cannot mint authority.
|
# contains the data directory. A writable user file cannot mint authority.
|
||||||
from src import constants
|
from src import constants
|
||||||
@@ -85,11 +85,9 @@ def _control_plane_path(path):
|
|||||||
if processes is not None:
|
if processes is not None:
|
||||||
job_dirs.add(canonical_root(processes._LAUNCH_DIR))
|
job_dirs.add(canonical_root(processes._LAUNCH_DIR))
|
||||||
# Durable effect claims/outcomes/observations are server evidence state.
|
# Durable effect claims/outcomes/observations are server evidence state.
|
||||||
# The store is not inventoried here: it grows with every run. Aliases are
|
# They are prefix-protected below, but never inventoried: the store grows
|
||||||
# caught below by ``_aliases_effect_store`` instead.
|
# with every run. Hardlink aliases are caught by ``_aliases_effect_store``.
|
||||||
effect_dirs = _effect_store_dirs()
|
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.
|
# Producers may have configured paths different from the default constants.
|
||||||
# Inspect already-loaded server metadata without initializing a store here.
|
# Inspect already-loaded server metadata without initializing a store here.
|
||||||
bg = sys.modules.get("src.bg_jobs")
|
bg = sys.modules.get("src.bg_jobs")
|
||||||
@@ -118,8 +116,6 @@ def _control_plane_path(path):
|
|||||||
protected.add(canonical_root(Path(uploader.upload_dir) / "uploads.json"))
|
protected.add(canonical_root(Path(uploader.upload_dir) / "uploads.json"))
|
||||||
for directory in job_dirs:
|
for directory in job_dirs:
|
||||||
jobs = Path(directory)
|
jobs = Path(directory)
|
||||||
if Path(path).is_relative_to(jobs):
|
|
||||||
return True
|
|
||||||
if jobs.exists():
|
if jobs.exists():
|
||||||
# Uninspectable state fails closed; hardlinks retain object identity.
|
# Uninspectable state fails closed; hardlinks retain object identity.
|
||||||
protected.update(canonical_root(p) for p in jobs.rglob("*") if p.is_file())
|
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"))
|
for suffix in ("-wal", "-shm", "-journal"))
|
||||||
protected.add(canonical_root(Path(constants.DATA_DIR) / ".app_key"))
|
protected.add(canonical_root(Path(constants.DATA_DIR) / ".app_key"))
|
||||||
protected.add(canonical_root(Path(constants.UPLOAD_DIR) / "uploads.json"))
|
protected.add(canonical_root(Path(constants.UPLOAD_DIR) / "uploads.json"))
|
||||||
if path in protected:
|
identities = set()
|
||||||
return True
|
|
||||||
try:
|
|
||||||
candidate = os.stat(path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return False
|
|
||||||
if _aliases_effect_store(candidate, effect_dirs):
|
|
||||||
return True
|
|
||||||
for control in protected:
|
for control in protected:
|
||||||
try:
|
try:
|
||||||
observed = os.stat(control)
|
observed = os.stat(control)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
continue
|
continue
|
||||||
if (candidate.st_dev, candidate.st_ino) == (observed.st_dev, observed.st_ino):
|
identities.add((observed.st_dev, observed.st_ino))
|
||||||
return True
|
# Effect directories join the prefix set only after the recursive inventory.
|
||||||
return False
|
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):
|
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
|
from src.tool_execution import agent_cwd, _truncate
|
||||||
|
|
||||||
grant = None
|
grant = None
|
||||||
|
launch = None
|
||||||
result = None
|
result = None
|
||||||
try:
|
try:
|
||||||
from src.agent_runtime.process_resources import require_launch, publish_launch, validate_launch_spec
|
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",
|
**({"failure_kind": "resource_linkage_unavailable",
|
||||||
"teardown": result.release.to_dict() if result.release else {"dead": False}}
|
"teardown": result.release.to_dict() if result.release else {"dead": False}}
|
||||||
if result is not None else {})}
|
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 = result.grant.to_dict()
|
||||||
boundary["executed"] = True
|
boundary["executed"] = True
|
||||||
|
|||||||
@@ -7,6 +7,27 @@ from fastapi import Request, HTTPException
|
|||||||
from src.owner_identity import auth_disabled, effective_storage_owner
|
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]:
|
def get_current_user(request: Request) -> Optional[str]:
|
||||||
"""Get current username from request state (set by auth middleware)."""
|
"""Get current username from request state (set by auth middleware)."""
|
||||||
return getattr(request.state, 'current_user', None)
|
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
|
"""Drop records (and their on-disk files) for jobs that finished, were
|
||||||
followed up, and are older than the retention window. Mutates `jobs`."""
|
followed up, and are older than the retention window. Mutates `jobs`."""
|
||||||
stale = [jid for jid, rec in jobs.items()
|
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]
|
and (now - rec["ended_at"]) > _RETENTION_S]
|
||||||
for jid in stale:
|
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
|
for p in _JOBS_DIR.glob(f"{jid}.*"): # .sh .cmd.sh .log .exit
|
||||||
try:
|
try:
|
||||||
p.unlink()
|
p.unlink()
|
||||||
@@ -333,10 +345,29 @@ def _kill_record(rec):
|
|||||||
|
|
||||||
def pending_followups() -> List[Dict[str, Any]]:
|
def pending_followups() -> List[Dict[str, Any]]:
|
||||||
"""Finished jobs the agent hasn't been re-invoked for yet. The monitor
|
"""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()
|
jobs = refresh()
|
||||||
return [r for r in jobs.values()
|
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)
|
@store_transaction(lambda: _STORE)
|
||||||
@@ -373,10 +404,6 @@ def get(job_id: str, *, expected) -> Optional[Dict[str, Any]]:
|
|||||||
return rec
|
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)
|
@store_transaction(lambda: _STORE)
|
||||||
def kill(job_id: str, *, expected) -> Optional[Dict[str, Any]]:
|
def kill(job_id: str, *, expected) -> Optional[Dict[str, Any]]:
|
||||||
"""Terminate a running job's process tree and mark it killed. Returns the
|
"""Terminate a running job's process tree and mark it killed. Returns the
|
||||||
|
|||||||
+46
-16
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
from src import bg_jobs
|
from src import bg_jobs
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
@@ -26,6 +27,12 @@ POLL_INTERVAL_S = 5
|
|||||||
_FOLLOWUP_MAX_ROUNDS = 12
|
_FOLLOWUP_MAX_ROUNDS = 12
|
||||||
|
|
||||||
|
|
||||||
|
class FollowupResult(Enum):
|
||||||
|
RETRYABLE_LATER = auto()
|
||||||
|
COMPLETED = auto()
|
||||||
|
TERMINAL_UNFOLLOWABLE = auto()
|
||||||
|
|
||||||
|
|
||||||
def _background_result_message(rec):
|
def _background_result_message(rec):
|
||||||
inject = (
|
inject = (
|
||||||
f"[Background job {rec['id']} finished]\n\n"
|
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
|
return full, tool_events
|
||||||
|
|
||||||
|
|
||||||
async def _run_followup(rec: dict) -> bool:
|
async def _run_followup(rec: dict) -> FollowupResult:
|
||||||
"""Re-invoke the agent in the job's session with the result. Returns True
|
"""Continue only an exactly linked result; distinguish retry from terminal."""
|
||||||
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."""
|
|
||||||
from src.ai_interaction import get_session_manager
|
from src.ai_interaction import get_session_manager
|
||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
|
|
||||||
sm = get_session_manager()
|
sm = get_session_manager()
|
||||||
if not sm:
|
if not sm:
|
||||||
return False # not ready yet — retry
|
return FollowupResult.RETRYABLE_LATER
|
||||||
sess = sm.get_session(rec["session_id"])
|
sess = sm.get_session(rec["session_id"])
|
||||||
if not sess:
|
if not sess:
|
||||||
# Session was deleted — nothing to continue. Consider it handled so we
|
# Session was deleted — nothing to continue. Consider it handled so we
|
||||||
# don't retry forever.
|
# don't retry forever.
|
||||||
logger.info("bg-followup: session %s gone for job %s — skipping", rec.get("session_id"), rec.get("id"))
|
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
|
# 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
|
# 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
|
from src import agent_runs
|
||||||
if agent_runs.is_active(sess.id):
|
if agent_runs.is_active(sess.id):
|
||||||
logger.info("bg-followup: session %s busy (live turn) — deferring job %s", sess.id, rec.get("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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
context = sess.get_context_messages()
|
|
||||||
context.append(_background_result_message(rec))
|
|
||||||
|
|
||||||
from src.agent_runtime.authority import restore_background_authority
|
from src.agent_runtime.authority import restore_background_authority
|
||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
authority = restore_background_authority(
|
authority = restore_background_authority(
|
||||||
@@ -165,11 +177,19 @@ async def _run_followup(rec: dict) -> bool:
|
|||||||
_settle_launch_effect(resource, rec)
|
_settle_launch_effect(resource, rec)
|
||||||
if not authority.grants or (resource.owner, resource.thread_id, resource.request_id) != (
|
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):
|
str(getattr(sess, "owner", None) or "").strip().casefold(), sess.id, authority.request_id):
|
||||||
return False
|
return FollowupResult.TERMINAL_UNFOLLOWABLE
|
||||||
except (ValueError, TypeError, OSError, RuntimeError):
|
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 ())
|
authority = authority.restrict(disabled_tools=get_setting("disabled_tools", []) or ())
|
||||||
full, tool_events = await _drain_agent(sess, context, request_authority=authority)
|
full, tool_events = await _drain_agent(sess, context, request_authority=authority)
|
||||||
|
# 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
|
# Persist ONLY the assistant continuation so it renders as a normal agent
|
||||||
# turn — a standard chat bubble plus `tool_events` that the frontend
|
# 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()
|
sm.save_sessions()
|
||||||
logger.info("bg-followup: auto-continued session %s for job %s (%d chars, %d tools)",
|
logger.info("bg-followup: auto-continued session %s for job %s (%d chars, %d tools)",
|
||||||
sess.id, rec["id"], len(full), len(tool_events))
|
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():
|
async def _loop():
|
||||||
@@ -196,9 +228,7 @@ async def _loop():
|
|||||||
try:
|
try:
|
||||||
for rec in bg_jobs.pending_followups():
|
for rec in bg_jobs.pending_followups():
|
||||||
try:
|
try:
|
||||||
if await _run_followup(rec):
|
await _process_followup(rec)
|
||||||
from src.agent_runtime.process_resources import job_from_record
|
|
||||||
bg_jobs.mark_followed_up(rec["id"], expected=job_from_record(rec))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Idempotent: leave followed_up=False so the next tick retries.
|
# Idempotent: leave followed_up=False so the next tick retries.
|
||||||
logger.warning("bg-followup failed for %s (will retry): %s", rec.get("id"), e)
|
logger.warning("bg-followup failed for %s (will retry): %s", rec.get("id"), e)
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ from src.process_lifecycle import ProcessIdentity, observe
|
|||||||
from src.constants import BROWSER_RESOURCES_DIR
|
from src.constants import BROWSER_RESOURCES_DIR
|
||||||
|
|
||||||
PRODUCER_VERSION = "0.35.0"
|
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 = {
|
PRODUCER_HASHES = {
|
||||||
"linux-x64": "b7a28c3a43a7008dd02585e2e60c391c08983f7a099149caed63c9f13f57b752",
|
"linux-x64": "b7a28c3a43a7008dd02585e2e60c391c08983f7a099149caed63c9f13f57b752",
|
||||||
"linux-arm64": "92cd7d0897837ac648b9a6ab1965c69c5920e0f54df57e4295cdb1143b0541c8",
|
"linux-arm64": "92cd7d0897837ac648b9a6ab1965c69c5920e0f54df57e4295cdb1143b0541c8",
|
||||||
|
|||||||
@@ -3387,10 +3387,12 @@ async def action_cookbook_serve(
|
|||||||
if srv.get("platform"): body["platform"] = srv["platform"]
|
if srv.get("platform"): body["platform"] = srv["platform"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
from src.agent_runtime.local_model_control import model_control_headers
|
||||||
r = await client.post(f"{internal_api_base()}/api/model/serve",
|
with model_control_headers("serve_model", command, owner, body, scheduled=True) as launch_headers:
|
||||||
json=body, headers=headers)
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
data = r.json() if r.content else {}
|
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:
|
except Exception as e:
|
||||||
return f"Launch HTTP failed: {e}", False
|
return f"Launch HTTP failed: {e}", False
|
||||||
if not data.get("ok"):
|
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
|
Blocking: a teardown escalates SIGTERM → grace → SIGKILL and waits for the
|
||||||
process to actually go. Call it off the event loop.
|
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 = {
|
report = {
|
||||||
"mechanism": process_ownership.inspection_mechanism(),
|
"mechanism": process_ownership.inspection_mechanism(),
|
||||||
"grants": reap_containment_grants(),
|
"grants": reap_containment_grants(),
|
||||||
"bg_jobs": reap_bg_jobs(),
|
"bg_jobs": reap_bg_jobs(),
|
||||||
"agent_tmux": reap_legacy_agent_tmux(),
|
"agent_tmux": reap_legacy_agent_tmux(),
|
||||||
|
"foreground_publications_retired": publications_retired,
|
||||||
}
|
}
|
||||||
if report["mechanism"] == process_ownership.MECHANISM_NONE:
|
if report["mechanism"] == process_ownership.MECHANISM_NONE:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
+3
-13
@@ -1246,8 +1246,6 @@ def _split_bg_marker(content: str):
|
|||||||
return False, content
|
return False, content
|
||||||
|
|
||||||
|
|
||||||
import re as _re
|
|
||||||
|
|
||||||
# Variables a legitimate agent bash/python subprocess needs from the host.
|
# Variables a legitimate agent bash/python subprocess needs from the host.
|
||||||
# Anything not listed here is never inherited.
|
# Anything not listed here is never inherited.
|
||||||
_SAFE_SUBPROCESS_VARS = frozenset({
|
_SAFE_SUBPROCESS_VARS = frozenset({
|
||||||
@@ -1267,19 +1265,11 @@ _SAFE_SUBPROCESS_VARS = frozenset({
|
|||||||
"LD_LIBRARY_PATH",
|
"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:
|
def _agent_subprocess_env() -> dict:
|
||||||
base = {
|
base = {
|
||||||
key: os.environ[key]
|
key: os.environ[key]
|
||||||
for key in _SAFE_SUBPROCESS_VARS
|
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("PATH", os.environ.get("PATH") or os.defpath or "/usr/local/bin:/usr/bin:/bin")
|
||||||
base.setdefault("LANG", "C.UTF-8")
|
base.setdefault("LANG", "C.UTF-8")
|
||||||
@@ -1425,7 +1415,7 @@ async def execute_tool_block(
|
|||||||
owner=owner, session_id=session_id, workspace=workspace,
|
owner=owner, session_id=session_id, workspace=workspace,
|
||||||
tool_name=getattr(block, "tool_type", None), content=getattr(block, "content", None)))
|
tool_name=getattr(block, "tool_type", None), content=getattr(block, "content", None)))
|
||||||
admitted = valid and (authority.permits(operation) or exact_admission)
|
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", {
|
return f"{getattr(block, 'tool_type', '')}: invalid arguments", {
|
||||||
"error": (f"Tool arguments are not valid JSON: {error}"
|
"error": (f"Tool arguments are not valid JSON: {error}"
|
||||||
if isinstance(error, json.JSONDecodeError) else str(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,
|
authority, operation, document_id=active_document_id,
|
||||||
approved=pending.owned_operation if pending is not None else None,
|
approved=pending.owned_operation if pending is not None else None,
|
||||||
exact_admission=exact_admission)
|
exact_admission=exact_admission)
|
||||||
except (ValueError, TypeError, OSError, RuntimeError, AttributeError) as error:
|
except (ValueError, TypeError, OSError) as error:
|
||||||
return f"{transport}: BLOCKED", {
|
return f"{transport}: BLOCKED", {
|
||||||
"error": str(error), "exit_code": 1, "blocked": True,
|
"error": str(error), "exit_code": 1, "blocked": True,
|
||||||
"failure_kind": "resource_identity_denied",
|
"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("platform"): payload["platform"] = env_cfg["platform"]
|
||||||
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
|
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
from src.agent_runtime.local_model_control import model_control_headers
|
||||||
resp = await client.post(f"{_INTERNAL_BASE}/api/model/download",
|
with model_control_headers("download_model", content, owner, payload) as launch_headers:
|
||||||
json=payload, headers=_internal_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()
|
data = resp.json()
|
||||||
if data.get("ok"):
|
if data.get("ok"):
|
||||||
sid = data.get("session_id", "?")
|
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("platform"): payload["platform"] = env_cfg["platform"]
|
||||||
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
|
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
from src.agent_runtime.local_model_control import model_control_headers
|
||||||
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
|
with model_control_headers("serve_model", content, owner, payload) as launch_headers:
|
||||||
json=payload, headers=_internal_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()
|
data = resp.json()
|
||||||
if data.get("ok"):
|
if data.get("ok"):
|
||||||
sid = data.get("session_id", "?")
|
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"]
|
payload["ssh_port"] = env_cfg["ssh_port"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
from src.agent_runtime.local_model_control import model_control_headers
|
||||||
resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
|
with model_control_headers("serve_preset", content, owner, payload) as launch_headers:
|
||||||
json=payload, headers=_internal_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()
|
data = resp.json()
|
||||||
if data.get("ok"):
|
if data.get("ok"):
|
||||||
sid = data.get("session_id", "?")
|
sid = data.get("session_id", "?")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
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
|
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():
|
def _model_serve_endpoint():
|
||||||
router = cookbook_routes.setup_cookbook_routes()
|
router = cookbook_routes.setup_cookbook_routes()
|
||||||
for route in router.routes:
|
for route in router.routes:
|
||||||
@@ -27,6 +33,8 @@ def _admin_request() -> Request:
|
|||||||
"path": "/api/model/serve",
|
"path": "/api/model/serve",
|
||||||
"headers": [],
|
"headers": [],
|
||||||
"state": {},
|
"state": {},
|
||||||
|
"app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(
|
||||||
|
is_configured=True, is_admin=lambda user: user == "admin"))),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
request.state.current_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"
|
assert cookbook_routes.shutil.which(binary) == "/usr/bin/docker"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
|
|
||||||
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
|
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
|
||||||
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
|
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -199,7 +206,6 @@ async def test_local_container_serve_allows_generated_docker_exec_when_enabled(
|
|||||||
launched_commands.append(command)
|
launched_commands.append(command)
|
||||||
return _Process()
|
return _Process()
|
||||||
|
|
||||||
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
|
|
||||||
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
|
monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available)
|
||||||
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
|
monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|||||||
@@ -329,6 +329,14 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
|
|||||||
alias = workspace / "sneaky.jsonl"
|
alias = workspace / "sneaky.jsonl"
|
||||||
os.link(store / f"{7:032x}.jsonl", alias)
|
os.link(store / f"{7:032x}.jsonl", alias)
|
||||||
assert resources._control_plane_path(str(alias)) is True
|
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"
|
assert str(store) not in globbed, "the effect store is listed one level, never recursively inventoried"
|
||||||
# Multiply linked files elsewhere stay ordinary.
|
# Multiply linked files elsewhere stay ordinary.
|
||||||
elsewhere = tmp_path / "other.txt"
|
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"))
|
monkeypatch.setattr(asyncio, "create_subprocess_shell", lambda *a, **k: pytest.fail("Anonymous producer reached"))
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(cookbook_routes.setup_cookbook_routes())
|
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)
|
result = await client.post(path, json=payload)
|
||||||
assert result.status_code == 403
|
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.
|
# mint a process resource even when the UI supplies a matching name.
|
||||||
result = await cookbook._cookbook_kill_session("serve-unowned")
|
result = await cookbook._cookbook_kill_session("serve-unowned")
|
||||||
assert result["failure_kind"] == "resource_identity_denied"
|
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_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_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_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_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. |
|
| `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