mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 15:02:20 +02:00
feat(runtime): bind process and job resources to authority
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
"""Process/job admission. Lifecycle mechanics remain in process_lifecycle.
|
||||
|
||||
Only trusted launch producers publish observations. Persisted legacy records
|
||||
are never enrolled by looking at their PID. Receipts identify boundaries, not
|
||||
application authority. Resource snapshots contain no command or environment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from uuid import uuid4
|
||||
from core.atomic_io import store_transaction
|
||||
|
||||
from src.agent_runtime.resources import (
|
||||
BackgroundJobResource, NativeBackendResource, ProcessLaunchResource,
|
||||
ProcessLaunchScope, ProcessResource, ResourceIdentityError,
|
||||
)
|
||||
from src.constants import PROCESS_RESOURCES_DIR
|
||||
|
||||
_LAUNCH_DIR = Path(PROCESS_RESOURCES_DIR)
|
||||
LAUNCH_TOOLS = frozenset({"bash", "python"})
|
||||
JOB_TOOL = "manage_bg_jobs"
|
||||
_ACTIVE = ContextVar("process_resource_operation", default=None)
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _thread(authority):
|
||||
return authority.session_id or "request:" + authority.request_id
|
||||
|
||||
|
||||
def launch_path(generation):
|
||||
if not isinstance(generation, str) or not re.fullmatch(r"[a-f0-9]{32}", generation):
|
||||
raise ResourceIdentityError("Malformed launch generation")
|
||||
return _LAUNCH_DIR / (generation + ".json")
|
||||
|
||||
|
||||
def seal_launch_scopes(authority):
|
||||
return tuple(seal_launch_scope(backend, root)
|
||||
for backend in authority.backend_resources
|
||||
if isinstance(backend, NativeBackendResource) and backend.tool_id in LAUNCH_TOOLS
|
||||
for root in authority.resource_roots)
|
||||
|
||||
|
||||
def seal_launch_scope(backend, root, *, env=None):
|
||||
from src.agent_tools.subprocess_tools import _owned_spec
|
||||
from src.tool_execution import _agent_subprocess_env
|
||||
from src.agent_runtime.resources import PathObservation, FileObjectIdentity
|
||||
env = _agent_subprocess_env() if env is None else env
|
||||
extra = tuple(Path(p).resolve().as_posix() for p in str(env.get("ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES", "")).split(os.pathsep)
|
||||
if p and os.path.isabs(p)) if backend.tool_id == "python" else ()
|
||||
spec = _owned_spec(root.path, env, 3600, extra)
|
||||
return ProcessLaunchScope(backend, root, spec.required,
|
||||
tuple(PathObservation(str(Path(p).resolve()), FileObjectIdentity.observe(Path(p).resolve())) for p in spec.readonly_extra),
|
||||
spec.network, spec.wall_clock_s)
|
||||
|
||||
|
||||
def validate_launch_spec(launch, spec):
|
||||
scope = launch.scope
|
||||
scope.validate()
|
||||
if (spec.workspace != scope.root.path or spec.required != scope.required or spec.network != scope.network
|
||||
or spec.wall_clock_s > scope.max_runtime_s or spec.writable_extra
|
||||
or tuple(spec.readonly_extra) != tuple(r.path for r in scope.runtime_roots)):
|
||||
raise ResourceIdentityError("Producer launch boundary exceeds the sealed reservation")
|
||||
|
||||
|
||||
def job_from_record(record):
|
||||
if not isinstance(record, dict):
|
||||
raise ResourceIdentityError("Missing authoritative job")
|
||||
try:
|
||||
resource = BackgroundJobResource.from_dict(record["resource_identity"])
|
||||
if (resource.namespace != "native:bg_jobs"
|
||||
or (record["id"], record["session_id"], record["containment_id"])
|
||||
!= (resource.job_id, resource.thread_id, resource.containment_id)):
|
||||
raise ValueError("Job linkage changed")
|
||||
supervisor = next(p for p in resource.processes if p.role == "supervisor")
|
||||
if (record.get("pid"), record.get("start_token"), record.get("pgid")) != (
|
||||
supervisor.identity.pid, supervisor.identity.start_token, supervisor.identity.pgid):
|
||||
raise ValueError("Supervisor linkage changed")
|
||||
launch = ProcessLaunchResource.from_dict(record["launch_resource"])
|
||||
if (launch.generation, launch.owner, launch.request_id, launch.thread_id) != (
|
||||
resource.generation, resource.owner, resource.request_id, resource.thread_id):
|
||||
raise ValueError("Launch/job linkage changed")
|
||||
return resource
|
||||
except (ValueError, TypeError, KeyError, StopIteration, AttributeError) as error:
|
||||
raise ResourceIdentityError("Malformed or unowned background job") from error
|
||||
|
||||
|
||||
def validate_job(resource, *, mutation=False):
|
||||
try:
|
||||
return _validate_job(resource, mutation=mutation)
|
||||
except ResourceIdentityError:
|
||||
raise
|
||||
except (ValueError, TypeError, OSError, KeyError, AttributeError) as error:
|
||||
raise ResourceIdentityError("Background job linkage is missing or malformed") from error
|
||||
|
||||
|
||||
def validate_job_receipt(resource, receipt):
|
||||
from src import containment
|
||||
supervisor = resource.processes[0]
|
||||
if (not isinstance(receipt, dict) or receipt.get("id") != resource.containment_id
|
||||
or receipt.get("launch_generation") != resource.generation
|
||||
or receipt.get("owner") != "bg:" + resource.thread_id
|
||||
or (receipt.get("supervisor_pid"), receipt.get("supervisor_token")) !=
|
||||
(supervisor.identity.pid, supervisor.identity.start_token)
|
||||
or receipt.get("mechanism") not in {m.name for m in containment.MECHANISMS}
|
||||
or receipt.get("external") is True):
|
||||
raise ResourceIdentityError("Containment receipt linkage changed")
|
||||
|
||||
|
||||
def _validate_job(resource, *, mutation=False):
|
||||
from src import bg_jobs, containment
|
||||
if not isinstance(resource, BackgroundJobResource):
|
||||
raise ResourceIdentityError("Missing exact background job identity")
|
||||
record = bg_jobs.peek(resource.job_id)
|
||||
if job_from_record(record) != resource:
|
||||
raise ResourceIdentityError("Background job resource changed")
|
||||
if record.get("status") not in {"running", "done", "failed"}:
|
||||
raise ResourceIdentityError("Unknown job lifecycle")
|
||||
launch = ProcessLaunchResource.from_dict(record["launch_resource"])
|
||||
persisted = json.loads(launch_path(resource.generation).read_text())
|
||||
if (persisted.get("launch") != launch.to_dict()
|
||||
or persisted.get("job") != resource.to_dict()
|
||||
or persisted.get("containment_id") != resource.containment_id):
|
||||
raise ResourceIdentityError("Job/launch publication changed")
|
||||
sidecar = json.loads((bg_jobs._JOBS_DIR / (resource.job_id + ".authority.json")).read_text())
|
||||
origin = persisted.get("authority", {})
|
||||
if (sidecar.get("job") != resource.to_dict() or sidecar.get("authority") != origin
|
||||
or (origin.get("owner"), origin.get("request_id"), origin.get("session_id")) !=
|
||||
(resource.owner, resource.request_id, resource.thread_id)):
|
||||
raise ResourceIdentityError("Background authority linkage changed")
|
||||
receipt = containment._load_records().get(resource.containment_id)
|
||||
# Lifecycle receipts have a shorter retention than job results. A finished
|
||||
# exact generation needs only its durable application linkage for history;
|
||||
# it never regains signalling authority when its receipt has been pruned.
|
||||
historical = record.get("status") in {"done", "failed"}
|
||||
if receipt is None and not historical:
|
||||
raise ResourceIdentityError("Missing active containment receipt")
|
||||
if receipt is not None:
|
||||
validate_job_receipt(resource, receipt)
|
||||
if record.get("status") == "running":
|
||||
for process in resource.processes:
|
||||
try:
|
||||
process.validate()
|
||||
except ResourceIdentityError:
|
||||
# Publication can precede store reconciliation. That exact
|
||||
# completed generation is readable, but never signallable.
|
||||
if mutation or not Path(record["exit_path"]).is_file():
|
||||
raise
|
||||
report = json.loads(Path(record["result_path"]).read_text())
|
||||
if report.get("resource_identity") != resource.to_dict() or report.get("containment", {}).get("id") != resource.containment_id:
|
||||
raise ResourceIdentityError("Historical result linkage changed")
|
||||
# A completed record is readable history, never a new process observation.
|
||||
return record
|
||||
|
||||
|
||||
def seal_jobs(authority):
|
||||
if not any(g.tool == JOB_TOOL for g in authority.grants) or not authority.session_id:
|
||||
return ()
|
||||
from src import bg_jobs
|
||||
admitted = []
|
||||
for record in bg_jobs._load().values():
|
||||
try:
|
||||
resource = job_from_record(record)
|
||||
if (resource.owner, resource.thread_id) == (authority.owner, authority.session_id):
|
||||
validate_job(resource)
|
||||
admitted.append(resource)
|
||||
except (ValueError, TypeError, OSError, RuntimeError):
|
||||
continue
|
||||
return tuple(admitted)
|
||||
|
||||
|
||||
def intersect_observed(parent, child, validate):
|
||||
# Validate both sides before equality. Seeing a replacement cannot renew a
|
||||
# stale parent observation, even when the child has just sealed it.
|
||||
for resource in (*parent, *child):
|
||||
validate(resource)
|
||||
return tuple(resource for resource in parent if resource in child)
|
||||
|
||||
|
||||
def intersect_launch_scopes(parent, child):
|
||||
from src.agent_runtime.resources import FilesystemResource
|
||||
for scope in (*parent, *child):
|
||||
scope.validate()
|
||||
narrowed = []
|
||||
for left in parent:
|
||||
for right in child:
|
||||
if (left.backend != right.backend or not left.required <= right.required
|
||||
or right.max_runtime_s > left.max_runtime_s
|
||||
or not set(right.runtime_roots) <= set(left.runtime_roots)
|
||||
or (left.network == "none" and right.network != "none")):
|
||||
continue
|
||||
if Path(right.root.path).is_relative_to(left.root.path):
|
||||
observation = FilesystemResource.resolve(left.root, right.root.path)
|
||||
if observation.identity == right.root.identity:
|
||||
narrowed.append(right)
|
||||
return tuple(dict.fromkeys(narrowed))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundProcessOperation:
|
||||
operation: object
|
||||
request_id: str
|
||||
owner: str
|
||||
thread_id: str
|
||||
launch: ProcessLaunchResource | None = None
|
||||
jobs: tuple[BackgroundJobResource, ...] = ()
|
||||
processes: tuple[ProcessResource, ...] = ()
|
||||
exact_approval: object | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
if (not isinstance(self.operation, ExactOperation) or not isinstance(self.request_id, str) or not self.request_id
|
||||
or not isinstance(self.owner, str) or not isinstance(self.thread_id, str) or not self.thread_id
|
||||
or (self.launch is not None and not isinstance(self.launch, ProcessLaunchResource))
|
||||
or not isinstance(self.jobs, tuple) or any(not isinstance(j, BackgroundJobResource) for j in self.jobs)
|
||||
or not isinstance(self.processes, tuple) or any(not isinstance(p, ProcessResource) for p in self.processes)):
|
||||
raise ValueError("Malformed process-bound operation")
|
||||
if self.launch is not None and (
|
||||
(self.launch.owner, self.launch.request_id, self.launch.thread_id, self.launch.tool, self.launch.input_digest)
|
||||
!= (self.owner, self.request_id, self.thread_id, self.operation.tool, digest(self.operation.input))):
|
||||
raise ValueError("Launch operation/application binding changed")
|
||||
if any((r.owner, r.thread_id) != (self.owner, self.thread_id) for r in (*self.jobs, *self.processes)):
|
||||
raise ValueError("Observed resource application binding changed")
|
||||
|
||||
def validate(self):
|
||||
if self.launch is not None:
|
||||
self.launch.validate()
|
||||
guard_launch_workspace(self.launch.scope.root)
|
||||
for job in self.jobs:
|
||||
validate_job(job, mutation=self.operation.action in {"kill", "stop", "cancel", "terminate", "ack"})
|
||||
for process in self.processes:
|
||||
process.validate()
|
||||
|
||||
def to_dict(self):
|
||||
return {"tool": self.operation.transport_tool, "input_digest": digest(self.operation.input),
|
||||
"request_id": self.request_id, "owner": self.owner, "thread_id": self.thread_id,
|
||||
"launch": self.launch.to_dict() if self.launch else None,
|
||||
"jobs": [r.to_dict() for r in self.jobs], "processes": [r.to_dict() for r in self.processes]}
|
||||
|
||||
|
||||
def needs_process_binding(operation, backend):
|
||||
return isinstance(backend, NativeBackendResource) and operation.tool in LAUNCH_TOOLS | {JOB_TOOL}
|
||||
|
||||
|
||||
def resolve_process_operation(authority, operation, backend, *, approved=None, exact_admission=False):
|
||||
if not needs_process_binding(operation, backend):
|
||||
raise ResourceIdentityError("No native process adapter for this backend")
|
||||
if approved is not None:
|
||||
if (approved.operation != operation or (approved.request_id, approved.owner, approved.thread_id)
|
||||
!= (authority.request_id, authority.owner, _thread(authority))):
|
||||
raise ResourceIdentityError("Approved process operation binding changed")
|
||||
bound = approved
|
||||
elif operation.tool in LAUNCH_TOOLS:
|
||||
scopes = [s for s in authority.launch_scopes if s.backend == backend]
|
||||
if len(scopes) != 1:
|
||||
raise ResourceIdentityError("Process creation requires a sealed workspace and launch scope")
|
||||
launch = ProcessLaunchResource("native:containment", authority.owner, authority.request_id,
|
||||
_thread(authority), uuid4().hex, operation.tool, digest(operation.input), scopes[0],
|
||||
digest(json.dumps(authority.to_dict(), sort_keys=True)))
|
||||
bound = BoundProcessOperation(operation, authority.request_id, authority.owner, _thread(authority), launch)
|
||||
else:
|
||||
try:
|
||||
args = json.loads(operation.input)
|
||||
action = str(args.get("action", "list")).strip().lower()
|
||||
job_id = args.get("job_id", args.get("id", ""))
|
||||
except (ValueError, TypeError, AttributeError) as error:
|
||||
raise ResourceIdentityError("Malformed job operation") from error
|
||||
if action in {"list", "ls", "jobs"}:
|
||||
jobs = authority.job_resources
|
||||
elif action in {"output", "get", "read", "tail", "status", "show", "kill", "stop", "cancel", "terminate", "ack"}:
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
raise ResourceIdentityError("An exact job selector is required")
|
||||
jobs = tuple(r for r in authority.job_resources if r.job_id == job_id)
|
||||
if len(jobs) != 1:
|
||||
raise ResourceIdentityError("Job is outside admitted resource scope")
|
||||
else:
|
||||
raise ResourceIdentityError("Unsupported job operation")
|
||||
bound = BoundProcessOperation(operation, authority.request_id, authority.owner, _thread(authority), jobs=jobs)
|
||||
if not (approved is not None and exact_admission and not authority.inherited):
|
||||
if bound.launch is not None and bound.launch.scope not in authority.launch_scopes:
|
||||
raise ResourceIdentityError("Launch exceeds inherited creation scope")
|
||||
if any(j not in authority.job_resources for j in bound.jobs) or any(p not in authority.process_resources for p in bound.processes):
|
||||
raise ResourceIdentityError("Process/job exceeds inherited resource scope")
|
||||
if bound.launch is not None and bound.launch.scope.backend != backend:
|
||||
raise ResourceIdentityError("Launch backend changed")
|
||||
bound.validate()
|
||||
return bound
|
||||
|
||||
|
||||
def active_process_operation():
|
||||
return _ACTIVE.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_process_operation(operation):
|
||||
if operation is not None and not isinstance(operation, BoundProcessOperation):
|
||||
raise TypeError("Process operation must be server-owned")
|
||||
if operation is not None:
|
||||
operation.validate()
|
||||
token = _ACTIVE.set(operation)
|
||||
try:
|
||||
yield operation
|
||||
finally:
|
||||
_ACTIVE.reset(token)
|
||||
|
||||
|
||||
def require_launch(tool, *, cwd, content=None):
|
||||
bound = active_process_operation()
|
||||
if bound is None or bound.launch is None or bound.operation.tool != tool:
|
||||
raise ResourceIdentityError("Native process producer has no bound launch reservation")
|
||||
require_process_admission(bound)
|
||||
bound.validate()
|
||||
if Path(cwd).resolve() != Path(bound.launch.scope.root.path):
|
||||
raise ResourceIdentityError("Launch workspace changed")
|
||||
if content is not None and content.strip() != bound.operation.input.strip():
|
||||
raise ResourceIdentityError("Launch operation changed at producer entry")
|
||||
return bound.launch
|
||||
|
||||
|
||||
def require_process_admission(bound):
|
||||
from src.agent_runtime.authority import active_request_authority
|
||||
authority = active_request_authority()
|
||||
if authority is None or (authority.owner, authority.request_id, _thread(authority)) != (
|
||||
bound.owner, bound.request_id, bound.thread_id):
|
||||
raise ResourceIdentityError("Producer application authority changed")
|
||||
if not authority.permits(bound.operation):
|
||||
approval = bound.exact_approval
|
||||
if (authority.inherited or approval is None or not approval._claimed
|
||||
or approval.pending.process_operation is None
|
||||
or approval.pending.process_operation.to_dict() != bound.to_dict()):
|
||||
raise ResourceIdentityError("Producer operation has no request admission or exact claim")
|
||||
|
||||
|
||||
def guard_launch_workspace(root):
|
||||
"""Reject a boundary containing execution control state or its aliases.
|
||||
|
||||
These are pathname/inode observations, not an atomic kernel access policy.
|
||||
They do not claim freedom from concurrent link replacement after checking.
|
||||
"""
|
||||
from src import bg_jobs, containment, constants
|
||||
from src.agent_runtime.resources import _control_plane_path
|
||||
control = (Path(bg_jobs._STORE), Path(bg_jobs._JOBS_DIR), containment._store_path(), _LAUNCH_DIR,
|
||||
Path(constants.APP_DB), Path(constants.AUTH_FILE), Path(constants.SETTINGS_FILE))
|
||||
base = Path(root.path)
|
||||
if any(Path(p).resolve().is_relative_to(base) for p in control):
|
||||
raise ResourceIdentityError("Launch boundary contains server control state")
|
||||
def unresolved(error):
|
||||
raise ResourceIdentityError("Launch workspace cannot be inspected") from error
|
||||
for directory, dirs, files in os.walk(base, followlinks=False, onerror=unresolved):
|
||||
for name in (*dirs, *files):
|
||||
path = Path(directory) / name
|
||||
info = path.lstat()
|
||||
if (path.is_symlink() or info.st_nlink > 1) and _control_plane_path(str(path.resolve())):
|
||||
raise ResourceIdentityError("Launch boundary aliases server control state")
|
||||
|
||||
|
||||
@store_transaction(lambda: _LAUNCH_DIR / "publication")
|
||||
def publish_launch(launch, authority, containment_id, *, job=None, processes=()):
|
||||
from core.atomic_io import atomic_write_json
|
||||
launch.validate()
|
||||
if authority is None or (authority.owner, authority.request_id) != (launch.owner, launch.request_id):
|
||||
raise ResourceIdentityError("Launch authority linkage changed")
|
||||
path = launch_path(launch.generation)
|
||||
if path.exists():
|
||||
raise ResourceIdentityError("Launch reservation has already been used")
|
||||
atomic_write_json(path, {"launch": launch.to_dict(), "authority": authority.to_dict(),
|
||||
"containment_id": containment_id, "job": job.to_dict() if job else None,
|
||||
"processes": [p.to_dict() for p in processes]})
|
||||
|
||||
|
||||
@store_transaction(lambda: _LAUNCH_DIR / "publication")
|
||||
def attach_containment_processes(launch, containment_id):
|
||||
"""Attach producer-frozen lifecycle records; never capture a current PID."""
|
||||
from src import containment
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
record = containment._load_records().get(containment_id, {})
|
||||
path = launch_path(launch.generation)
|
||||
published = json.loads(path.read_text())
|
||||
if (published.get("launch") != launch.to_dict() or published.get("containment_id") != containment_id
|
||||
or record.get("id") != containment_id or record.get("launch_generation") != launch.generation
|
||||
or record.get("workspace") != launch.scope.root.path):
|
||||
raise ResourceIdentityError("Launch/receipt changed during publication")
|
||||
processes = []
|
||||
for role, pid_key, token_key, group_key in (("leader", "pid", "start_token", "pgid"),
|
||||
("namespace_init", "namespace_pid", "namespace_start_token", None)):
|
||||
if record.get(pid_key):
|
||||
processes.append(ProcessResource("native:containment", launch.owner, launch.request_id,
|
||||
launch.thread_id, ProcessIdentity(record[pid_key], record.get(token_key), record.get(group_key) if group_key else None),
|
||||
role, "", containment_id))
|
||||
from core.atomic_io import atomic_write_json
|
||||
published["processes"] = [p.to_dict() for p in processes]
|
||||
atomic_write_json(path, published)
|
||||
|
||||
|
||||
def expected_job(job_id, *, action):
|
||||
bound = active_process_operation()
|
||||
if bound is None or bound.operation.tool != JOB_TOOL:
|
||||
raise ResourceIdentityError("Job producer has no bound operation")
|
||||
require_process_admission(bound)
|
||||
# The caller's actual action must agree with the normalized proposal.
|
||||
args = json.loads(bound.operation.input)
|
||||
proposed = str(args.get("action", "list")).strip().lower()
|
||||
if action != proposed:
|
||||
raise ResourceIdentityError("Job action changed at producer entry")
|
||||
target = next((j for j in bound.jobs if j.job_id == job_id), None)
|
||||
if target is None:
|
||||
raise ResourceIdentityError("Job selector is outside the bound operation")
|
||||
validate_job(target, mutation=action in {"kill", "stop", "cancel", "terminate", "ack"})
|
||||
return target
|
||||
Reference in New Issue
Block a user