mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-07 15:32:21 +02:00
Merge commit 'refs/phase3/pre-ajax/publication-tip' into integration/pre-ajax-release
# Conflicts: # routes/chat_routes.py # routes/session_routes.py # src/agent_loop.py # src/agent_tools/filesystem_tools.py # src/teacher_escalation.py # src/tool_capabilities.py # src/tool_execution.py # tests/test_mcp_add_server_args_validation.py # tests/test_token_cache_atomic_swap.py
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Run-scoped contracts behind the public agent-loop compatibility facade."""
|
||||
@@ -0,0 +1,588 @@
|
||||
"""Server-owned request admission, independent of model tool availability."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import aclosing, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
from src.agent_runtime.resources import (
|
||||
FilesystemRoot, ExternalResource, NativeBackendResource, OwnedScope,
|
||||
ProcessLaunchScope, ProcessResource, BackgroundJobResource,
|
||||
BrowserSessionResource, BrowserPageResource,
|
||||
backend_from_dict, intersect_roots, seal_owned_scopes,
|
||||
)
|
||||
from src.tool_policy import ToolPolicy, build_effective_tool_policy
|
||||
from src.turn_contract import (
|
||||
FAMILY_TOOLS, canonical_tool, requested_capabilities,
|
||||
RequiredReadOperation, required_read_operation_for_request, selected_tools_for_request,
|
||||
)
|
||||
|
||||
|
||||
def _owner(value):
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def _pairs(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("Duplicate operation argument")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _invalid_constant(value):
|
||||
raise ValueError("Non-finite operation argument")
|
||||
|
||||
|
||||
def _json(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExactOperation:
|
||||
tool: str
|
||||
input: str
|
||||
action: str | None = None
|
||||
transport_tool: str = ""
|
||||
|
||||
@classmethod
|
||||
def normalize(cls, tool, content):
|
||||
if not isinstance(tool, str) or not tool.strip() or not isinstance(content, str):
|
||||
raise ValueError("Operation requires a tool name and string input")
|
||||
transport_tool = tool.strip()
|
||||
tool = canonical_tool(transport_tool)
|
||||
normalized = content
|
||||
payload = None
|
||||
raw_input = tool in {"bash", "python"} or tool.startswith("scheduled__")
|
||||
if not raw_input and content.lstrip().startswith("{"):
|
||||
payload = json.loads(content, object_pairs_hook=_pairs, parse_constant=_invalid_constant)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Structured tool input must be an object")
|
||||
normalized = _json(payload)
|
||||
# Reuse the runtime's existing multiplexed-action normalization; this
|
||||
# classifies input and never grants permission or changes the input.
|
||||
from src.tool_capabilities import _action_from_content
|
||||
action = _action_from_content(tool, content)
|
||||
if tool == "private_browser" and isinstance(payload, dict):
|
||||
action = payload.get("action")
|
||||
if action is not None and not isinstance(action, str):
|
||||
raise ValueError("Browser action must be a string")
|
||||
action = action.strip().casefold() if action else None
|
||||
return cls(tool, normalized, action, transport_tool)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationGrant:
|
||||
tool: str
|
||||
actions: frozenset[str] | None = None
|
||||
inputs: frozenset[str] | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.tool, str) or not self.tool or canonical_tool(self.tool) != self.tool:
|
||||
raise ValueError("Grant requires a canonical tool identity")
|
||||
for values in (self.actions, self.inputs):
|
||||
if values is not None and (not isinstance(values, frozenset)
|
||||
or any(not isinstance(v, str) for v in values)):
|
||||
raise TypeError("Grant limits must be immutable string sets")
|
||||
|
||||
def permits(self, operation):
|
||||
return (self.tool == operation.tool
|
||||
and (self.actions is None or operation.action in self.actions)
|
||||
and (self.inputs is None or operation.input in self.inputs))
|
||||
|
||||
def intersect(self, other):
|
||||
if self.tool != other.tool:
|
||||
raise ValueError("Cannot intersect different operation classes")
|
||||
def limits(left, right):
|
||||
return right if left is None else left if right is None else left & right
|
||||
return OperationGrant(self.tool, limits(self.actions, other.actions),
|
||||
limits(self.inputs, other.inputs))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestAuthority:
|
||||
request_id: str
|
||||
owner: str
|
||||
session_id: str
|
||||
workspace: str
|
||||
grants: tuple[OperationGrant, ...] = ()
|
||||
denied: frozenset[str] = frozenset()
|
||||
block_all: bool = False
|
||||
disable_mcp: bool = False
|
||||
inherited: bool = False
|
||||
# None is only the trusted constructor's instruction to seal a workspace.
|
||||
# Persisted/child authorities always carry an explicit tuple, including ().
|
||||
resource_roots: tuple[FilesystemRoot, ...] | None = None
|
||||
backend_resources: tuple[ExternalResource | NativeBackendResource, ...] | None = None
|
||||
owned_scopes: tuple[OwnedScope, ...] | None = None
|
||||
launch_scopes: tuple[ProcessLaunchScope, ...] | None = None
|
||||
process_resources: tuple[ProcessResource, ...] = ()
|
||||
job_resources: tuple[BackgroundJobResource, ...] | None = None
|
||||
browser_sessions: tuple[BrowserSessionResource, ...] | None = None
|
||||
browser_pages: tuple[BrowserPageResource, ...] | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if (not isinstance(self.request_id, str) or not self.request_id
|
||||
or any(not isinstance(v, str) for v in (self.owner, self.session_id, self.workspace))
|
||||
or not isinstance(self.grants, tuple)
|
||||
or any(not isinstance(g, OperationGrant) for g in self.grants)
|
||||
or len({g.tool for g in self.grants}) != len(self.grants)
|
||||
or not isinstance(self.denied, frozenset)
|
||||
or any(not isinstance(n, str) or canonical_tool(n) != n for n in self.denied)
|
||||
or any(type(v) is not bool for v in (self.block_all, self.disable_mcp, self.inherited))):
|
||||
raise ValueError("Malformed request authority")
|
||||
if self.resource_roots is None:
|
||||
roots = ()
|
||||
if self.workspace:
|
||||
try:
|
||||
roots = (FilesystemRoot.seal(self.workspace, owner=self.owner),)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
pass # An unresolved workspace grants no filesystem root.
|
||||
object.__setattr__(self, "resource_roots", roots)
|
||||
if (not isinstance(self.resource_roots, tuple)
|
||||
or any(not isinstance(r, FilesystemRoot) or (r.owner and r.owner != self.owner)
|
||||
for r in self.resource_roots)):
|
||||
raise ValueError("Malformed request resource roots")
|
||||
if self.backend_resources is None:
|
||||
from src.agent_runtime.remote_resources import seal_backends
|
||||
object.__setattr__(self, "backend_resources", seal_backends((g.tool for g in self.grants), owner=self.owner))
|
||||
if self.owned_scopes is None:
|
||||
object.__setattr__(self, "owned_scopes", seal_owned_scopes(
|
||||
self.owner, self.session_id, (g.tool for g in self.grants)))
|
||||
if (not isinstance(self.backend_resources, tuple)
|
||||
or any(not isinstance(r, (ExternalResource, NativeBackendResource))
|
||||
or (isinstance(r, ExternalResource) and r.owner and r.owner != self.owner) for r in self.backend_resources)
|
||||
or not isinstance(self.owned_scopes, tuple)
|
||||
or any(not isinstance(s, OwnedScope) or (s.owner, s.thread_id) != (self.owner, self.session_id)
|
||||
for s in self.owned_scopes)):
|
||||
raise ValueError("Malformed backend or owned resource scope")
|
||||
from src.agent_runtime.process_resources import seal_launch_scopes, seal_jobs
|
||||
if self.launch_scopes is None:
|
||||
object.__setattr__(self, "launch_scopes", seal_launch_scopes(self))
|
||||
if self.job_resources is None:
|
||||
object.__setattr__(self, "job_resources", seal_jobs(self))
|
||||
for field, kind in (("launch_scopes", ProcessLaunchScope), ("process_resources", ProcessResource),
|
||||
("job_resources", BackgroundJobResource)):
|
||||
values = getattr(self, field)
|
||||
if not isinstance(values, tuple) or any(not isinstance(r, kind) for r in values):
|
||||
raise ValueError("Malformed process resource scope")
|
||||
if any(r.owner != self.owner for r in (*self.process_resources, *self.job_resources)):
|
||||
raise ValueError("Process resource owner changed")
|
||||
if any(s.root.owner and s.root.owner != self.owner for s in self.launch_scopes):
|
||||
raise ValueError("Launch resource owner changed")
|
||||
if any(r.thread_id != self.session_id for r in self.job_resources):
|
||||
raise ValueError("Job resource thread changed")
|
||||
if any(r.thread_id != (self.session_id or "request:" + self.request_id) for r in self.process_resources):
|
||||
raise ValueError("Process resource thread changed")
|
||||
from src.browser_identity import seal_browser_resources
|
||||
sessions, pages = seal_browser_resources(self) if self.browser_sessions is None or self.browser_pages is None else ((), ())
|
||||
if self.browser_sessions is None:
|
||||
object.__setattr__(self, "browser_sessions", sessions)
|
||||
if self.browser_pages is None:
|
||||
object.__setattr__(self, "browser_pages", pages)
|
||||
for values, kind in ((self.browser_sessions, BrowserSessionResource), (self.browser_pages, BrowserPageResource)):
|
||||
if not isinstance(values, tuple) or any(not isinstance(r, kind) for r in values):
|
||||
raise ValueError("Malformed browser resource scope")
|
||||
for r in values:
|
||||
session = r.session if isinstance(r, BrowserPageResource) else r
|
||||
if (session.owner, session.thread_id) != (self.owner, self.session_id):
|
||||
raise ValueError("Browser owner/thread binding changed")
|
||||
|
||||
@classmethod
|
||||
def empty(cls, *, owner=None, session_id=None, workspace=None):
|
||||
return cls(uuid4().hex, _owner(owner), str(session_id or ""), str(workspace or ""),
|
||||
resource_roots=(), backend_resources=(), owned_scopes=(), launch_scopes=(), job_resources=(), browser_sessions=(), browser_pages=())
|
||||
|
||||
def bound_to(self, *, owner=None, session_id=None, workspace=None):
|
||||
return (self.owner == _owner(owner) and self.session_id == str(session_id or "")
|
||||
and self.workspace == str(workspace or ""))
|
||||
|
||||
def restricted(self, operation):
|
||||
return (self.block_all or operation.tool in self.denied
|
||||
or (self.disable_mcp and (operation.tool.startswith("mcp__")
|
||||
or operation.transport_tool.startswith("mcp__"))))
|
||||
|
||||
def permits(self, operation):
|
||||
return not self.restricted(operation) and any(g.permits(operation) for g in self.grants)
|
||||
|
||||
def restrict(self, policy=None, disabled_tools=()):
|
||||
policy = policy or ToolPolicy()
|
||||
return replace(self, denied=self.denied | frozenset(
|
||||
canonical_tool(n) for n in set(disabled_tools or ()) | policy.all_disabled_names()),
|
||||
block_all=self.block_all or policy.block_all_tool_calls,
|
||||
disable_mcp=self.disable_mcp or policy.disable_mcp)
|
||||
|
||||
def intersect(self, child):
|
||||
if not isinstance(child, RequestAuthority):
|
||||
raise TypeError("Child authority must be server-owned RequestAuthority")
|
||||
grants = []
|
||||
roots = ()
|
||||
backends = ()
|
||||
owned = ()
|
||||
launches = processes = jobs = ()
|
||||
browser_sessions = browser_pages = ()
|
||||
if (self.owner, self.session_id, self.workspace) == (child.owner, child.session_id, child.workspace):
|
||||
theirs = {g.tool: g for g in child.grants}
|
||||
grants = [g.intersect(theirs[g.tool]) for g in self.grants if g.tool in theirs]
|
||||
roots = intersect_roots(self.resource_roots, child.resource_roots)
|
||||
backends = tuple(r for r in self.backend_resources if r in child.backend_resources)
|
||||
owned = tuple(s for left in self.owned_scopes for right in child.owned_scopes
|
||||
if (s := left.intersect(right)) is not None)
|
||||
from src.agent_runtime.process_resources import intersect_observed, intersect_launch_scopes, validate_job
|
||||
launches = intersect_launch_scopes(self.launch_scopes, child.launch_scopes)
|
||||
processes = intersect_observed(self.process_resources, child.process_resources, lambda r: r.validate())
|
||||
jobs = intersect_observed(self.job_resources, child.job_resources, validate_job)
|
||||
from src.browser_identity import intersect_browser
|
||||
browser_sessions, browser_pages = intersect_browser(self.browser_sessions, self.browser_pages,
|
||||
child.browser_sessions, child.browser_pages)
|
||||
return replace(self, grants=tuple(grants), denied=self.denied | child.denied,
|
||||
block_all=self.block_all or child.block_all,
|
||||
disable_mcp=self.disable_mcp or child.disable_mcp, inherited=True,
|
||||
resource_roots=roots, backend_resources=backends, owned_scopes=owned,
|
||||
launch_scopes=launches, process_resources=processes, job_resources=jobs,
|
||||
browser_sessions=browser_sessions, browser_pages=browser_pages)
|
||||
|
||||
def continuation(self, *, owner=None, session_id=None):
|
||||
"""A server continuation may rebind a session, never change owner/grants."""
|
||||
if self.owner != _owner(owner):
|
||||
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
||||
rebound = str(session_id or "")
|
||||
return replace(self, session_id=rebound, inherited=True,
|
||||
owned_scopes=tuple(replace(s, thread_id=rebound) for s in self.owned_scopes) if rebound else (),
|
||||
process_resources=tuple(r for r in self.process_resources if r.thread_id == rebound),
|
||||
job_resources=tuple(r for r in self.job_resources if r.thread_id == rebound),
|
||||
browser_sessions=tuple(r for r in self.browser_sessions if r.thread_id == rebound),
|
||||
browser_pages=tuple(r for r in self.browser_pages if r.session.thread_id == rebound))
|
||||
|
||||
def to_dict(self):
|
||||
return {"version": 5, "request_id": self.request_id, "owner": self.owner,
|
||||
"session_id": self.session_id, "workspace": self.workspace,
|
||||
"grants": [{"tool": g.tool,
|
||||
"actions": None if g.actions is None else sorted(g.actions),
|
||||
"inputs": None if g.inputs is None else sorted(g.inputs)} for g in self.grants],
|
||||
"denied": sorted(self.denied), "block_all": self.block_all,
|
||||
"disable_mcp": self.disable_mcp, "inherited": self.inherited,
|
||||
"resource_roots": [r.to_dict() for r in self.resource_roots],
|
||||
"backend_resources": [r.to_dict() for r in self.backend_resources],
|
||||
"owned_scopes": [s.to_dict() for s in self.owned_scopes],
|
||||
"launch_scopes": [s.to_dict() for s in self.launch_scopes],
|
||||
"process_resources": [r.to_dict() for r in self.process_resources],
|
||||
"job_resources": [r.to_dict() for r in self.job_resources],
|
||||
"browser_sessions": [r.to_dict() for r in self.browser_sessions],
|
||||
"browser_pages": [r.to_dict() for r in self.browser_pages]}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if (not isinstance(value, dict) or type(value.get("version")) is not int
|
||||
or value["version"] not in {1, 2, 3, 4, 5}):
|
||||
raise ValueError("Unsupported authority snapshot")
|
||||
def limits(value):
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
|
||||
raise ValueError("Malformed authority limits")
|
||||
return frozenset(value)
|
||||
roots = value["resource_roots"] if value["version"] >= 2 else []
|
||||
if not isinstance(roots, list):
|
||||
raise ValueError("Malformed request resource snapshot")
|
||||
backends = value["backend_resources"] if value["version"] >= 3 else []
|
||||
owned = value["owned_scopes"] if value["version"] >= 3 else []
|
||||
process_fields = {name: value[name] if value["version"] >= 4 else []
|
||||
for name in ("launch_scopes", "process_resources", "job_resources")}
|
||||
if any(not isinstance(v, list) for v in process_fields.values()):
|
||||
raise ValueError("Malformed process resource snapshot")
|
||||
if not isinstance(backends, list) or not isinstance(owned, list):
|
||||
raise ValueError("Malformed request resource scope snapshot")
|
||||
if value["version"] >= 5 and any(not isinstance(value.get(name), list) for name in ("browser_sessions", "browser_pages")):
|
||||
raise ValueError("Malformed browser resource scope snapshot")
|
||||
return cls(value["request_id"], value["owner"], value["session_id"], value["workspace"],
|
||||
tuple(OperationGrant(g["tool"], limits(g["actions"]), limits(g["inputs"]))
|
||||
for g in value["grants"]), limits(value["denied"]),
|
||||
value["block_all"], value["disable_mcp"], value["inherited"],
|
||||
tuple(FilesystemRoot.from_dict(r) for r in roots),
|
||||
tuple(backend_from_dict(r) for r in backends), tuple(OwnedScope.from_dict(s) for s in owned),
|
||||
tuple(ProcessLaunchScope.from_dict(s) for s in process_fields["launch_scopes"]),
|
||||
tuple(ProcessResource.from_dict(r) for r in process_fields["process_resources"]),
|
||||
tuple(BackgroundJobResource.from_dict(r) for r in process_fields["job_resources"]),
|
||||
tuple(BrowserSessionResource.from_dict(r) for r in value["browser_sessions"]) if value["version"] >= 5 else (),
|
||||
tuple(BrowserPageResource.from_dict(r) for r in value["browser_pages"]) if value["version"] >= 5 else ())
|
||||
|
||||
|
||||
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
||||
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticIntent:
|
||||
"""Routing facts, with no execution permission or provider inventory."""
|
||||
capabilities: frozenset[str]
|
||||
selected_tools: frozenset[str] | None
|
||||
required_read: RequiredReadOperation | None
|
||||
|
||||
|
||||
def interpret_request(request_text, *, history=(), workspace=None, active_document=False,
|
||||
image_attachment=False):
|
||||
if not isinstance(request_text, str):
|
||||
raise TypeError("Intent requires request text")
|
||||
# Routing may use model/tool history. Admission may only inherit intent
|
||||
# from trusted user requests; a model's proposal or attempted tool call
|
||||
# cannot establish a new authorized operation class.
|
||||
history = tuple(history or ())
|
||||
trusted_history = []
|
||||
for row in history:
|
||||
get = row.get if isinstance(row, dict) else lambda key, default=None: getattr(row, key, default)
|
||||
metadata = get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
try:
|
||||
metadata = json.loads(metadata)
|
||||
except ValueError:
|
||||
metadata = {}
|
||||
if (get("role") == "user" and isinstance(metadata, dict)
|
||||
and metadata.get("trusted") is not False and not metadata.get("tool_gate_untrusted")):
|
||||
trusted_history.append({"role": "user", "content": get("content", "")})
|
||||
families = requested_capabilities(request_text, trusted_history,
|
||||
active_document=active_document, workspace=bool(workspace), image_attachment=image_attachment)
|
||||
selected = selected_tools_for_request(request_text)
|
||||
if (families <= {"unknown"} and selected is None
|
||||
and re.search(r"\b(?:lan|local\s+(?:network|ip)|tailscale|arp|ip\s+route|default\s+route|subnet|network\s+interface|neighbor\s+table|wifi|ethernet)\b", request_text, re.I)
|
||||
and re.search(r"\b(?:find|check|inspect|show|list|lookup|locate)\b", request_text, re.I)
|
||||
and not re.search(r"\b(?:web|internet|online)\b", request_text, re.I)):
|
||||
# An explicit local-network lookup is a host operation. The existing
|
||||
# router already chooses host_shell; neither its schema nor bridge
|
||||
# availability grants Bash/Python alongside this request.
|
||||
return SemanticIntent(frozenset({"shell_files"}), frozenset({"host_shell"}), None)
|
||||
return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history))
|
||||
|
||||
|
||||
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||
history=(), policy=None, active_document=False,
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None):
|
||||
"""Deterministic server policy over semantic facts, never schema inventory."""
|
||||
if not isinstance(request_text, str):
|
||||
raise TypeError("Authority requires trusted request text")
|
||||
intent = interpret_request(request_text, history=history, active_document=active_document,
|
||||
workspace=workspace, image_attachment=image_attachment)
|
||||
families = intent.capabilities
|
||||
if capabilities is not None:
|
||||
families |= frozenset(capabilities)
|
||||
tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in families))
|
||||
selected = intent.selected_tools
|
||||
if selected is not None:
|
||||
tools = tools & set(selected) if families else set(selected)
|
||||
if tools & {"web_search", "web_fetch"}:
|
||||
tools.add("private_browser")
|
||||
operation = intent.required_read
|
||||
if operation is not None and canonical_tool(operation.tool) in {canonical_tool(n) for n in tools}:
|
||||
tools = {canonical_tool(operation.tool)}
|
||||
else:
|
||||
operation = None
|
||||
grants = []
|
||||
for name in sorted(tools | {"ask_user", "update_plan"}):
|
||||
name = canonical_tool(name)
|
||||
actions = inputs = None
|
||||
if name == "private_browser":
|
||||
actions = _BROWSER_READ_ACTIONS
|
||||
# Explicit interaction intent admits its operation class. A
|
||||
# browser offered only as static-Web fallback gets no such grant.
|
||||
if re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||
actions |= frozenset(action for action in ("click", "fill", "type", "press", "evaluate", "select")
|
||||
if re.search(r"\b" + action + r"\b", request_text, re.I))
|
||||
if operation is not None and name == canonical_tool(operation.tool):
|
||||
inputs = frozenset({ExactOperation.normalize(name, _json(dict(operation.args))).input})
|
||||
grants.append(OperationGrant(name, actions, inputs))
|
||||
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
|
||||
str(workspace or ""), tuple(grants))
|
||||
if client_runtime_context is not None:
|
||||
from src.agent_runtime.remote_resources import seal_backends
|
||||
authority = replace(authority, backend_resources=seal_backends(
|
||||
(g.tool for g in authority.grants), context=client_runtime_context, owner=authority.owner))
|
||||
return authority.restrict(policy or build_effective_tool_policy(last_user_message=request_text))
|
||||
|
||||
|
||||
_ACTIVE: ContextVar[RequestAuthority | None] = ContextVar("request_authority", default=None)
|
||||
MISSING_AUTHORITY = object()
|
||||
|
||||
|
||||
def active_request_authority():
|
||||
return _ACTIVE.get()
|
||||
|
||||
|
||||
def is_internal_tool_request(request):
|
||||
"""HTTP authentication/owner attribution does not make a tool payload user intent."""
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
return (request.headers.get(INTERNAL_TOOL_HEADER) == INTERNAL_TOOL_TOKEN
|
||||
or getattr(request.state, "current_user", None) == "internal-tool")
|
||||
|
||||
|
||||
def require_user_approval_request(request):
|
||||
if is_internal_tool_request(request):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(403, "Tool requests cannot submit user approval decisions.")
|
||||
|
||||
|
||||
def request_authority_for_http(request, request_text, **context):
|
||||
"""Known tool loopback is a continuation, never a fresh user grant source."""
|
||||
if is_internal_tool_request(request):
|
||||
return RequestAuthority.empty(owner=context.get("owner"),
|
||||
session_id=context.get("session_id"), workspace=context.get("workspace")).restrict(context.get("policy"))
|
||||
return create_request_authority(request_text, **context)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_request_authority(authority):
|
||||
if not isinstance(authority, RequestAuthority):
|
||||
raise TypeError("Authority must be server-owned RequestAuthority")
|
||||
parent = _ACTIVE.get()
|
||||
authority = parent.intersect(authority) if parent is not None else authority
|
||||
token = _ACTIVE.set(authority)
|
||||
try:
|
||||
yield authority
|
||||
finally:
|
||||
_ACTIVE.reset(token)
|
||||
|
||||
|
||||
def _request_text(messages):
|
||||
for message in reversed(messages or ()):
|
||||
metadata = message.get("metadata") or {}
|
||||
if (message.get("role") != "user" or metadata.get("trusted") is False
|
||||
or metadata.get("tool_gate_untrusted")):
|
||||
continue
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "\n".join(p.get("text", "") for p in content
|
||||
if isinstance(p, dict) and p.get("type") == "text")
|
||||
return ""
|
||||
|
||||
|
||||
def with_request_authority(func):
|
||||
"""Bind once per invocation; model rounds/fallbacks never recreate grants."""
|
||||
call_signature = signature(func)
|
||||
@wraps(func)
|
||||
async def wrapped(*args, **kwargs):
|
||||
bound = call_signature.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
parameters = bound.arguments
|
||||
parent = active_request_authority()
|
||||
authority = parameters.get("request_authority", MISSING_AUTHORITY)
|
||||
if authority is MISSING_AUTHORITY:
|
||||
approval = parameters.get("exact_approval")
|
||||
if parent is not None:
|
||||
authority = parent
|
||||
elif approval is not None:
|
||||
authority = approval.pending.request_authority or RequestAuthority.empty(
|
||||
owner=parameters.get("owner"), session_id=parameters.get("session_id"),
|
||||
workspace=parameters.get("workspace"))
|
||||
elif (parameters.get("_parent_run_id") or parameters.get("_is_teacher_run")
|
||||
or parameters.get("workload") == "background"):
|
||||
authority = RequestAuthority.empty(owner=parameters.get("owner"),
|
||||
session_id=parameters.get("session_id"), workspace=parameters.get("workspace"))
|
||||
else:
|
||||
authority = create_request_authority(_request_text(parameters.get("messages")),
|
||||
owner=parameters.get("owner"), session_id=parameters.get("session_id"),
|
||||
workspace=parameters.get("workspace"),
|
||||
history=getattr(parameters.get("history_session"), "history", ()) or (),
|
||||
active_document=bool(parameters.get("active_document")),
|
||||
client_runtime_context=parameters.get("client_runtime_context"))
|
||||
if not isinstance(authority, RequestAuthority):
|
||||
raise TypeError("Missing or malformed server request authority")
|
||||
if parent is None and parameters.get("exact_approval") is not None:
|
||||
authority = replace(authority, inherited=False)
|
||||
authority = authority.restrict(parameters.get("tool_policy"), parameters.get("disabled_tools"))
|
||||
with bind_request_authority(authority) as effective:
|
||||
if "request_authority" in parameters:
|
||||
parameters["request_authority"] = effective
|
||||
async with aclosing(func(*bound.args, **bound.kwargs)) as stream:
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
return wrapped
|
||||
|
||||
|
||||
def task_operation(task_type, action, prompt):
|
||||
if task_type == "action":
|
||||
tool = ("bash" if action in {"run_local", "run_script", "ssh_command"}
|
||||
else "serve_model" if action == "cookbook_serve" else "scheduled__" + str(action))
|
||||
return ExactOperation.normalize(tool, str(prompt or ""))
|
||||
if task_type == "research":
|
||||
return ExactOperation.normalize("trigger_research", str(prompt or ""))
|
||||
return None
|
||||
|
||||
|
||||
def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authority=MISSING_AUTHORITY):
|
||||
"""Only direct ingress grants; a model-created task is capped by its parent."""
|
||||
operation = task_operation(task_type, action, prompt)
|
||||
authority = create_request_authority(str(prompt or ""), owner=owner)
|
||||
if operation is not None:
|
||||
authority = replace(authority, grants=(OperationGrant(operation.tool,
|
||||
inputs=frozenset({operation.input})),))
|
||||
if task_type == "action" and action == "cookbook_serve":
|
||||
# The direct admin scheduling ingress selects the native Cookbook
|
||||
# producer. Restore never infers this from task names/availability.
|
||||
# Any model-created task still intersects with its parent's ceiling.
|
||||
backend = NativeBackendResource("serve_model")
|
||||
authority = replace(authority, backend_resources=tuple(dict.fromkeys(
|
||||
(*authority.backend_resources, backend))))
|
||||
parent = active_request_authority() if parent_authority is MISSING_AUTHORITY else parent_authority
|
||||
if parent_authority is None:
|
||||
parent = RequestAuthority.empty(owner=owner)
|
||||
if parent is not None:
|
||||
authority = parent.intersect(replace(authority, session_id=parent.session_id,
|
||||
workspace=parent.workspace,
|
||||
resource_roots=parent.resource_roots,
|
||||
backend_resources=parent.backend_resources,
|
||||
owned_scopes=parent.owned_scopes,
|
||||
launch_scopes=parent.launch_scopes,
|
||||
process_resources=parent.process_resources,
|
||||
job_resources=parent.job_resources,
|
||||
browser_sessions=parent.browser_sessions,
|
||||
browser_pages=parent.browser_pages))
|
||||
return _json({"task_input": [prompt, task_type, action], "authority": authority.to_dict()})
|
||||
|
||||
|
||||
def restore_task_authority(snapshot, prompt, task_type, action, *, owner=None, session_id=None):
|
||||
try:
|
||||
value = json.loads(snapshot)
|
||||
if value["task_input"] != [prompt, task_type, action]:
|
||||
raise ValueError("Scheduled request changed")
|
||||
return RequestAuthority.from_dict(value["authority"]).continuation(owner=owner, session_id=session_id)
|
||||
except (ValueError, TypeError, KeyError, AttributeError):
|
||||
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
||||
|
||||
|
||||
def _background_path(job_id):
|
||||
if not isinstance(job_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", job_id):
|
||||
raise ValueError("Invalid background authority identity")
|
||||
from src.bg_jobs import _JOBS_DIR
|
||||
return Path(_JOBS_DIR) / (job_id + ".authority.json")
|
||||
|
||||
|
||||
def save_background_authority(job_id, authority, *, resource=None):
|
||||
from core.atomic_io import atomic_write_json
|
||||
if resource is None or resource.job_id != job_id:
|
||||
raise ValueError("Background authority requires exact job linkage")
|
||||
atomic_write_json(_background_path(job_id), {"authority": authority.to_dict(), "job": resource.to_dict()})
|
||||
|
||||
|
||||
def restore_background_authority(job_id, *, owner=None, session_id=None):
|
||||
try:
|
||||
value = json.loads(_background_path(job_id).read_text())
|
||||
resource = BackgroundJobResource.from_dict(value["job"])
|
||||
from src.agent_runtime.process_resources import validate_job
|
||||
validate_job(resource)
|
||||
authority = RequestAuthority.from_dict(value["authority"])
|
||||
if (resource.job_id, resource.owner, resource.thread_id, resource.request_id) != (
|
||||
job_id, authority.owner, authority.session_id, authority.request_id):
|
||||
raise ValueError("Background authority linkage changed")
|
||||
if authority.session_id != str(session_id or ""):
|
||||
raise ValueError("Background session changed")
|
||||
return authority.continuation(owner=owner, session_id=session_id)
|
||||
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
||||
return RequestAuthority.empty(owner=owner, session_id=session_id)
|
||||
@@ -0,0 +1,421 @@
|
||||
"""One presentation gate between agent execution and externally visible prose.
|
||||
|
||||
Tool, progress and interaction events stay live. Answer deltas are held until
|
||||
the generator unwinds so a later replacement cannot conceal an earlier false
|
||||
claim. This consumes no provider calls. Cancellation closes the inner generator
|
||||
under the same journal/turn authority; it never emits a successful terminal event.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import aclosing
|
||||
from dataclasses import replace
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
import json
|
||||
import re
|
||||
from time import perf_counter
|
||||
|
||||
from src.agent_evidence import (
|
||||
CompletionDecision, CompletionStatus, EvidenceKind, EvidenceLedger,
|
||||
requirements_from_runtime_context, _execution_obligation, _unquoted_statements,
|
||||
_ARTIFACT_PATH,
|
||||
)
|
||||
from .effect_log import EffectLog
|
||||
from .journal import ActionJournal, bind_journal, current_journal
|
||||
|
||||
|
||||
def _ledger(journal: ActionJournal, requirements) -> EvidenceLedger:
|
||||
"""The single evidence view used for the decision and the prose filter."""
|
||||
ledger = EvidenceLedger.from_tool_events(journal.evidence_events(), requirements)
|
||||
ledger.record_effects(journal.effect_entries(),
|
||||
{action.action_id: index for index, action in enumerate(journal.actions, 1)},
|
||||
journal.partial_reads())
|
||||
return ledger
|
||||
|
||||
|
||||
_TEST_CLAIM = re.compile(
|
||||
r'\b(?:(?:all\s+)?(?:tests?|checks?|verification|suite)\s+(?:have\s+|has\s+|now\s+|are\s+|is\s+)*(?:passed|passing|successful|green)|'
|
||||
r'(?:passed|passing)\s+(?:all\s+)?(?:the\s+)?tests?|\d+\s+passed)\b', re.I)
|
||||
_TEST_STATUS_CLAIM = re.compile(
|
||||
r'\b(?:tests?|pytest|unittest|test suite|checks?|verification)\s*[:—-]?\s*'
|
||||
r'(?:all\s+|have\s+|has\s+|now\s+|are\s+|is\s+|ran\s+)*'
|
||||
r'(?:pass(?:ed|ing)?|succeeded|successful(?:ly)?|green)\b|'
|
||||
r'\b(?:zero|no|0)\s+(?:test\s+)?failures\b', re.I)
|
||||
_EXECUTION_CLAIM = re.compile(
|
||||
r'\b(?:(?:I|we|I\'ve|we\'ve|and)\s+(?:have\s+)?(?:successfully\s+)?(?:ran|executed|tested|verified|created|updated|modified|wrote|saved|fixed|completed|sent|deleted|submitted|published|deployed|configured|uploaded)|'
|
||||
rf'(?:file|artifact|command|script|service|server|email|message|record|resource|{_ARTIFACT_PATH})\s+(?:was\s+|has\s+been\s+|is\s+)?(?:successfully\s+)?(?:created|updated|written|saved|executed|started|sent|deleted|submitted|published|deployed|configured)|'
|
||||
r'(?:successfully\s+)(?:ran|executed|created|updated|saved|completed|sent|deleted|submitted|published|deployed)|'
|
||||
r'(?:the\s+)?(?:remote\s+)?(?:operation|request|call|mutation|action)\s+(?:was\s+|has\s+)?(?:successfully\s+)?(?:completed|succeeded|finished))\b', re.I)
|
||||
_UNATTESTED_TEST_METRIC = re.compile(
|
||||
r'\b\d+\s+(?:(?:unit|integration)\s+)?tests?\s+pass(?:ed|ing)?\b|'
|
||||
r'\b\d+\s+passed\b|\b\d+(?:\.\d+)?%\s+(?:test\s+)?coverage\b', re.I)
|
||||
_UNBOUNDED_SUCCESS = re.compile(
|
||||
r'\b(?:everything|all\s+(?:bugs|issues))\s+(?:is\s+|are\s+|has\s+been\s+)?'
|
||||
r'(?:fixed|resolved|working)\b', re.I)
|
||||
_MUTATION_CLAIM = re.compile(
|
||||
r'\b(?:created|updated|modified|wrote|written|saved|fixed|sent|deleted|submitted|published|deployed|configured|uploaded)\b', re.I)
|
||||
_TEST_IDENTITY = re.compile(r'\b(?:pytest|unittest)\b', re.I)
|
||||
_TEST_SUBJECT = re.compile(r'\b(?:tests?|test suite|pytest|unittest|checks?|verification)\b', re.I)
|
||||
_CLAIM_PATH = re.compile(_ARTIFACT_PATH)
|
||||
_BARE_SUCCESS = re.compile(r'^\s*(?:done|completed|success|all done|all set|fixed)[.!]?\s*$', re.I)
|
||||
_NON_REPORT_SCOPE = re.compile(
|
||||
r'^\s*(?:if|unless|suppose|imagine|hypothetically|for\s+(?:example|instance))\b|'
|
||||
r'\b(?:if|when|whenever|unless|until)\b|'
|
||||
r'\b(?:can|could|may|might|should|would|will|must)\b|'
|
||||
r'\b(?:says?|said|states?|stated|example)\b', re.I)
|
||||
|
||||
|
||||
def _current_run_claims(statement: str, *, execution_required: bool) -> list[tuple[str, str]]:
|
||||
"""Classify asserted execution, separately from the turn's obligation.
|
||||
|
||||
Past actions and current result/status predicates are reports. Conditional,
|
||||
modal, attributed and example clauses are scoped prose. Bare terminal
|
||||
success only carries execution meaning under an execution contract.
|
||||
"""
|
||||
if _BARE_SUCCESS.fullmatch(statement):
|
||||
return [('terminal', statement)] if execution_required else []
|
||||
actions = list(_EXECUTION_CLAIM.finditer(statement))
|
||||
leading = re.match(r'^\s*(?:successfully\s+)?(?:created|updated|modified|wrote|saved)\b', statement, re.I)
|
||||
if leading:
|
||||
actions.insert(0, leading)
|
||||
candidates = [('action', match) for match in actions]
|
||||
for kind, pattern in [('metric', _UNATTESTED_TEST_METRIC), ('metric', _UNBOUNDED_SUCCESS),
|
||||
('test', _TEST_CLAIM), ('test', _TEST_STATUS_CLAIM)]:
|
||||
candidates.extend((kind, match) for match in pattern.finditer(statement))
|
||||
claims = []
|
||||
for kind, match in candidates:
|
||||
# Scope markers after an asserted action do not make that action
|
||||
# hypothetical ("I ran pytest to see if ..."). An immediate conditional
|
||||
# continuation does qualify a result ("Tests passed if ...").
|
||||
if _NON_REPORT_SCOPE.search(statement[:match.start()]) or re.match(
|
||||
r'\s+(?:if|when|whenever|unless|until)\b', statement[match.end():], re.I):
|
||||
continue
|
||||
end = next((action.start() for action in actions if action.start() > match.start()), len(statement))
|
||||
scope = statement[match.start():end]
|
||||
if kind == 'action':
|
||||
if _MUTATION_CLAIM.search(match.group()):
|
||||
kind = 'mutation'
|
||||
elif _TEST_SUBJECT.search(scope):
|
||||
kind = 'test'
|
||||
else:
|
||||
kind = 'execution'
|
||||
claims.append((kind, scope))
|
||||
return claims
|
||||
|
||||
|
||||
def _supported_prose(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
|
||||
"""Remove unsupported assertions at statement boundaries; add no notice."""
|
||||
incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else ''
|
||||
execution_required = _execution_obligation(ledger.requirements)
|
||||
# Bare "Done." cannot stand for an external effect nobody verified.
|
||||
terminal_claims = execution_required or bool(ledger.unverified_external_effects())
|
||||
kept = []
|
||||
removed = ''
|
||||
for statement, scoped in _unquoted_statements(text):
|
||||
why = ''
|
||||
for claim, scope in _current_run_claims(scoped, execution_required=terminal_claims):
|
||||
paths = tuple(match.group().rstrip('.') for match in _CLAIM_PATH.finditer(scope))
|
||||
if claim == 'metric':
|
||||
why = 'test counts, coverage or exhaustive correctness were not established by execution evidence'
|
||||
elif claim == 'test':
|
||||
identities = tuple(match.group().lower() for match in _TEST_IDENTITY.finditer(scope))
|
||||
if (decision.status not in {CompletionStatus.VERIFIED, CompletionStatus.UNVERIFIED}
|
||||
or not ledger._supports_verifier_claim(identities, paths)):
|
||||
why = 'no current passing executable verification supports the claim'
|
||||
elif claim == 'mutation':
|
||||
if not ledger._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths):
|
||||
why = 'no matching artifact mutation supports the execution claim'
|
||||
elif claim == 'execution':
|
||||
# A generic assertion cannot be tied confidently to a receipt.
|
||||
why = 'no matching operation supports the execution claim'
|
||||
elif claim == 'terminal' and decision.status not in {CompletionStatus.SATISFIED, CompletionStatus.VERIFIED}:
|
||||
why = incomplete or 'no successful execution supports completion'
|
||||
if why:
|
||||
break
|
||||
if why:
|
||||
removed = removed or why
|
||||
else:
|
||||
kept.append(statement)
|
||||
prose = ''.join(kept).strip() if removed else text
|
||||
return prose, removed
|
||||
|
||||
|
||||
def completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
|
||||
"""Keep explanatory prose; remove unsupported assertions and attach facts.
|
||||
|
||||
Exit status proves neither test counts nor coverage. A bad assertion is
|
||||
removed at statement boundaries instead of erasing an entire explanation.
|
||||
The execution outcome remains separate from a discarded model assertion.
|
||||
Unverified external effects are always stated by the server, so no
|
||||
surviving prose can present a reported remote success as a verified one.
|
||||
"""
|
||||
answer, reason = _completion_answer(text, ledger, decision)
|
||||
return _disclose(answer, ledger), reason
|
||||
|
||||
|
||||
def _disclose(answer: str, ledger: EvidenceLedger) -> str:
|
||||
"""Append the server's facts for unverified external effects."""
|
||||
disclosure = _disclosure(answer, ledger)
|
||||
return answer.rstrip() + disclosure if disclosure else answer
|
||||
|
||||
|
||||
def _disclosure(answer: str, ledger: EvidenceLedger) -> str:
|
||||
"""Build the complete server-owned disclosure independently of prose length."""
|
||||
summary = ' '.join(ledger.effect_disclosures())
|
||||
if not summary:
|
||||
return ''
|
||||
return ('\n\n' + summary) if answer.strip() else summary
|
||||
|
||||
|
||||
def _completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
|
||||
incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else ''
|
||||
execution_required = _execution_obligation(ledger.requirements)
|
||||
prose, removed = _supported_prose(text, ledger, decision)
|
||||
if incomplete or (removed and execution_required and decision.status in {CompletionStatus.UNVERIFIED, CompletionStatus.AWAITING_USER}):
|
||||
reason = incomplete or removed
|
||||
missing = (' Missing artifacts: ' + ', '.join(decision.missing_artifacts) + '.'
|
||||
if decision.missing_artifacts else '')
|
||||
notice = 'The task is incomplete: ' + reason.rstrip('.') + '.' + missing
|
||||
recorded = [path for path in ledger.requirements.required_artifacts
|
||||
if ledger._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, (path,))]
|
||||
if removed and recorded:
|
||||
notice += ' Recorded artifact mutation: ' + ', '.join(recorded) + '.'
|
||||
return notice + ('\n\n' + prose if prose.strip() else ''), reason
|
||||
if removed and not execution_required and decision.status != CompletionStatus.VERIFIED:
|
||||
notice = 'Unsupported execution claims were omitted: ' + removed.rstrip('.') + '.'
|
||||
return (prose.rstrip() + '\n\n' + notice) if prose.strip() else notice, removed
|
||||
if decision.can_complete and (ledger.requirements.required_artifacts or ledger.requirements.verifier_required or removed):
|
||||
facts = []
|
||||
if ledger.requirements.required_artifacts:
|
||||
facts.append('Output available: ' + ', '.join(ledger.requirements.required_artifacts) + '.')
|
||||
if decision.status == CompletionStatus.VERIFIED or ledger._supports_verifier_claim():
|
||||
facts.append('The latest executable verification passed.')
|
||||
elif any(e.kind == EvidenceKind.ARTIFACT_VALIDATION and e.authoritative and e.success for e in ledger.events):
|
||||
facts.append('Artifact readback verified. No passing executable test result was recorded.')
|
||||
else:
|
||||
facts.append('No passing executable test result was recorded.')
|
||||
summary = ' '.join(facts)
|
||||
return (prose.rstrip() + '\n\n' + summary) if prose.strip() else summary, removed
|
||||
return prose, removed
|
||||
|
||||
|
||||
def _event(data: dict) -> str:
|
||||
return 'data: ' + json.dumps(data) + '\n\n'
|
||||
|
||||
|
||||
def with_completion_gate(func):
|
||||
call_signature = signature(func)
|
||||
|
||||
@wraps(func)
|
||||
async def wrapped(*args, **kwargs):
|
||||
started = perf_counter()
|
||||
first_answer_at = None
|
||||
arguments = call_signature.bind(*args, **kwargs)
|
||||
arguments.apply_defaults()
|
||||
bound = arguments.arguments
|
||||
messages = bound.get('messages') or []
|
||||
instruction = next((m.get('content', '') for m in reversed(messages)
|
||||
if m.get('role') == 'user' and isinstance(m.get('content'), str)), '')
|
||||
context = bound.get('client_runtime_context') or {}
|
||||
requirements = requirements_from_runtime_context(context, instruction=instruction)
|
||||
from src.tool_execution import vet_workspace
|
||||
# A completion declaration is not a filesystem permission. Only the
|
||||
# explicit, vetted runtime workspace may be read for artifact versions.
|
||||
trusted_workspace = vet_workspace(bound.get('workspace')) if bound.get('workspace') else ''
|
||||
requirements = replace(requirements, workspace_root=trusted_workspace or '')
|
||||
parent = current_journal()
|
||||
journal = ActionJournal(
|
||||
workspace=requirements.workspace_root, observed_artifacts=requirements.required_artifacts,
|
||||
parent_run_id=bound.get('_parent_run_id') or (parent.run_id if parent is not None else None))
|
||||
# One durable effect log per run lineage gives child effects and parent
|
||||
# observations a single total order for invalidation.
|
||||
journal.effects = (parent.effects if parent is not None and parent.effects is not None
|
||||
else EffectLog(journal.run_id))
|
||||
answer_events: list[dict] = []
|
||||
metrics_events: list[dict] = []
|
||||
answer = ''
|
||||
has_final = False
|
||||
done = False
|
||||
awaiting = False
|
||||
exhausted = False
|
||||
provider_error: str | None = None
|
||||
with bind_journal(journal):
|
||||
async with aclosing(func(*args, **kwargs)) as stream:
|
||||
async for chunk in stream:
|
||||
if chunk.strip() == 'data: [DONE]':
|
||||
done = True
|
||||
continue
|
||||
try:
|
||||
data = json.loads(chunk[6:]) if chunk.startswith('data: ') else None
|
||||
except (ValueError, TypeError):
|
||||
data = None
|
||||
if not isinstance(data, dict):
|
||||
if chunk.startswith('event: error'):
|
||||
# The inner stream may still emit failed-terminal
|
||||
# diagnostics. Hold the original error until those
|
||||
# and the buffered answer have been released.
|
||||
provider_error = provider_error or chunk
|
||||
continue
|
||||
yield chunk
|
||||
continue
|
||||
kind = data.get('type')
|
||||
if kind == 'completion_decision':
|
||||
existing = data.get('data') or {}
|
||||
awaiting |= existing.get('status') == 'awaiting_user'
|
||||
exhausted |= existing.get('status') == 'exhausted'
|
||||
continue
|
||||
if kind in {'metrics', 'agent_terminal'}:
|
||||
metrics_events.append(data)
|
||||
declared = (data.get('data') or {}).get('completion_requirements')
|
||||
awaiting |= bool((data.get('data') or {}).get('missing_workspace'))
|
||||
if isinstance(declared, dict):
|
||||
requirements = requirements_from_runtime_context({'completion_requirements': declared})
|
||||
requirements = replace(requirements, workspace_root=trusted_workspace or '')
|
||||
# New obligations affect future receipts only. Never
|
||||
# backfill historical versions with present bytes.
|
||||
journal.observed_artifacts = tuple(dict.fromkeys(
|
||||
(*journal.observed_artifacts, *requirements.required_artifacts)))
|
||||
continue
|
||||
if kind == 'ask_user':
|
||||
awaiting = True
|
||||
payload = data.get('data') or {}
|
||||
if isinstance(payload.get('question'), str):
|
||||
current = _ledger(journal, requirements)
|
||||
question, why = completion_answer(payload['question'], current, current.evaluate(awaiting_user=True))
|
||||
if why:
|
||||
data = {**data, 'data': {**payload, 'question': question}}
|
||||
chunk = _event(data)
|
||||
if kind == 'final_response':
|
||||
if first_answer_at is None:
|
||||
first_answer_at = perf_counter()
|
||||
answer = str(data.get('content') or '')
|
||||
has_final = True
|
||||
answer_events.append(data)
|
||||
continue
|
||||
if 'delta' in data or isinstance(data.get('thinking'), str):
|
||||
if first_answer_at is None:
|
||||
first_answer_at = perf_counter()
|
||||
# Boolean thinking=True marks a reasoning-only delta;
|
||||
# a textual thinking companion must not hide an answer
|
||||
# delta. Both shapes remain buffered until the gate.
|
||||
if isinstance(data.get('thinking'), str):
|
||||
answer_events.append({'delta': data['thinking'], 'thinking': True})
|
||||
data = {key: value for key, value in data.items() if key != 'thinking'}
|
||||
if 'delta' not in data:
|
||||
continue
|
||||
if data.get('thinking') is not True and 'delta' in data:
|
||||
if has_final:
|
||||
answer = ''
|
||||
has_final = False
|
||||
answer += str(data.get('delta') or '')
|
||||
answer_events.append(data)
|
||||
continue
|
||||
yield chunk
|
||||
if provider_error and not answer_events and not metrics_events:
|
||||
yield provider_error
|
||||
return
|
||||
presentation_replaced = False
|
||||
if not provider_error and not has_final and requirements.required_artifacts:
|
||||
terminal_texts = next((event.get('data', {}).get('round_texts')
|
||||
for event in reversed(metrics_events)
|
||||
if isinstance(event.get('data', {}).get('round_texts'), list)
|
||||
and all(isinstance(text, str) for text in event['data']['round_texts'])), None)
|
||||
if terminal_texts is not None:
|
||||
terminal_answer = '\n\n'.join(text for text in terminal_texts if text.strip())
|
||||
if terminal_answer != answer:
|
||||
# The loop can retract a rejected round while retaining
|
||||
# its live deltas. Do not resurrect those buffered drafts
|
||||
# after recovery. Terminal prose still passes this gate.
|
||||
presentation_replaced = True
|
||||
answer = terminal_answer
|
||||
answer_events = [event for event in answer_events if event.get('thinking') is True]
|
||||
ledger = _ledger(journal, requirements)
|
||||
decision = ledger.evaluate(exhausted=exhausted, awaiting_user=awaiting)
|
||||
if provider_error:
|
||||
decision = replace(decision, status=CompletionStatus.FAILED,
|
||||
can_complete=False, reason='Model request failed')
|
||||
# Exhaustion limits execution; factual source synthesis can remain
|
||||
# useful and must not be replaced merely because the budget ended.
|
||||
presentation_decision = ledger.evaluate(awaiting_user=awaiting) if exhausted and not provider_error else decision
|
||||
filtered_answer, reason = _completion_answer(answer, ledger, presentation_decision)
|
||||
safe_answer = _disclose(filtered_answer, ledger)
|
||||
# Evaluate each earlier draft as well as the final replacement.
|
||||
# Never replay an unsupported intermediate success claim.
|
||||
draft = ''.join(str(e.get('delta') or e.get('content') or '')
|
||||
+ (e['thinking'] if isinstance(e.get('thinking'), str) else '')
|
||||
for e in answer_events)
|
||||
_, unsafe_draft = completion_answer(draft, ledger, presentation_decision)
|
||||
if not answer.strip() and unsafe_draft:
|
||||
reason = reason or unsafe_draft
|
||||
safe_answer, _ = completion_answer(draft, ledger, presentation_decision)
|
||||
if reason and _execution_obligation(requirements) and decision.can_complete and decision.status == CompletionStatus.UNVERIFIED:
|
||||
decision = CompletionDecision(CompletionStatus.UNVERIFIED, False, reason,
|
||||
decision.evidence_ids, decision.missing_artifacts)
|
||||
released_at = perf_counter()
|
||||
if not provider_error:
|
||||
yield _event({'type': 'completion_decision', 'data': decision.to_dict()})
|
||||
# When the only change is the server's effect disclosure, the
|
||||
# model's answer events are released unchanged and the disclosure
|
||||
# follows them, so no earlier-round text is dropped.
|
||||
disclosure = _disclosure(filtered_answer, ledger)
|
||||
disclosure_only = bool(disclosure) and not (presentation_replaced or reason or unsafe_draft
|
||||
or filtered_answer != answer)
|
||||
replaced_answer = not disclosure_only and bool(
|
||||
presentation_replaced or reason or unsafe_draft or safe_answer != answer)
|
||||
if replaced_answer:
|
||||
reasoning = [event for event in answer_events if event.get('thinking') is True]
|
||||
_, unsafe_reasoning = completion_answer(
|
||||
''.join(str(event.get('delta') or '') for event in reasoning), ledger,
|
||||
replace(presentation_decision, can_complete=True))
|
||||
if not unsafe_reasoning:
|
||||
for event in reasoning:
|
||||
yield _event(event)
|
||||
yield _event({'type': 'final_response', 'content': safe_answer})
|
||||
else:
|
||||
for event in answer_events:
|
||||
yield _event(event)
|
||||
if disclosure_only:
|
||||
yield _event({'delta': disclosure})
|
||||
if provider_error:
|
||||
yield _event({'type': 'completion_decision', 'data': decision.to_dict()})
|
||||
for event in metrics_events:
|
||||
metadata = event.setdefault('data', {})
|
||||
metadata.update(completion_decision=decision.to_dict(), evidence_events=ledger.to_list(),
|
||||
action_receipts=journal.to_list(), completion_requirements=requirements.to_dict(),
|
||||
run_id=journal.run_id, parent_run_id=journal.parent_run_id)
|
||||
if ledger.effects:
|
||||
metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects]
|
||||
metadata['completion_gate'] = {
|
||||
'buffer_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
|
||||
'first_visible_answer_seconds': released_at - started,
|
||||
'additional_provider_calls': 0,
|
||||
'answer_replaced': replaced_answer,
|
||||
}
|
||||
if replaced_answer:
|
||||
if not provider_error:
|
||||
metadata['round_texts'] = [safe_answer]
|
||||
metadata['completion_gate_reason'] = reason or unsafe_draft or 'receipt_summary'
|
||||
elif disclosure_only and metadata.get('round_texts') and isinstance(metadata['round_texts'], list) \
|
||||
and isinstance(metadata['round_texts'][-1], str):
|
||||
# Reload renders round_texts: keep the disclosure with them.
|
||||
metadata['round_texts'] = [*metadata['round_texts'][:-1], metadata['round_texts'][-1].rstrip() + disclosure]
|
||||
if provider_error and isinstance(metadata.get('round_texts'), list):
|
||||
# Failed rounds stay as per-round diagnostics, but they are
|
||||
# rendered again on reload. Apply the same statement filter
|
||||
# as the live answer so a rejected claim cannot reappear.
|
||||
metadata['round_texts'] = [
|
||||
_supported_prose(text, ledger, presentation_decision)[0] if isinstance(text, str) else text
|
||||
for text in metadata['round_texts']]
|
||||
if isinstance(metadata.get('thinking'), str):
|
||||
_, unsafe_thinking = completion_answer(metadata['thinking'], ledger,
|
||||
replace(presentation_decision, can_complete=True))
|
||||
if unsafe_thinking:
|
||||
metadata.pop('thinking')
|
||||
yield _event(event)
|
||||
if provider_error:
|
||||
yield provider_error
|
||||
return
|
||||
if done:
|
||||
yield 'data: [DONE]\n\n'
|
||||
|
||||
return wrapped
|
||||
@@ -0,0 +1,519 @@
|
||||
"""Effective model context window, resolved once per logical turn.
|
||||
|
||||
A turn resolves the window it budgets against at preparation time, before any
|
||||
model request, and keeps the value together with the evidence that chose it.
|
||||
Terminal metrics report that stored resolution; they never start discovery.
|
||||
|
||||
Evidence classes are kept apart instead of being folded into one "known" flag:
|
||||
|
||||
* ``runtime_confirmed``: the serving process reported its active window
|
||||
(llama.cpp ``/slots`` or ``/props``) or rejected a request of this turn with
|
||||
an explicit limit.
|
||||
* ``provider_advertised``: the provider's model catalog lists a window.
|
||||
* ``operator_declared``: the client or operator declared a transport window.
|
||||
It caps runtime or provider evidence and replaces weaker evidence.
|
||||
* ``known_table``: the static ``KNOWN_CONTEXT_WINDOWS`` fallback.
|
||||
* ``unknown``: nothing above is available. The value is 0, never a default.
|
||||
|
||||
Any disagreement between sources is recorded as a conflict. An operator value
|
||||
below a measured value is a cap, not a contradiction; an operator value above
|
||||
it is a contradiction.
|
||||
|
||||
Context sizing is not authority: nothing here grants or denies an operation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field, replace
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Mapping, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Upper bound for all provider metadata I/O of one turn preparation. A slow
|
||||
# or unreachable metadata endpoint costs at most this much before the turn
|
||||
# proceeds with whatever evidence it has.
|
||||
PROBE_DEADLINE_SECONDS = 3.0
|
||||
# Remote provider metadata changes rarely; failures are retried sooner so a
|
||||
# transient outage does not pin a turn to weaker evidence for long. Local
|
||||
# servers are always re-probed because they can restart with another window.
|
||||
PROBE_CACHE_TTL_SECONDS = 600.0
|
||||
PROBE_FAILURE_TTL_SECONDS = 60.0
|
||||
|
||||
# Headers that describe the chat request body rather than the caller.
|
||||
_REQUEST_ONLY_HEADERS = frozenset({"content-type", "content-length", "accept", "accept-encoding"})
|
||||
|
||||
|
||||
class ContextEvidence(str, Enum):
|
||||
RUNTIME_CONFIRMED = "runtime_confirmed"
|
||||
PROVIDER_ADVERTISED = "provider_advertised"
|
||||
OPERATOR_DECLARED = "operator_declared"
|
||||
KNOWN_TABLE = "known_table"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextObservation:
|
||||
evidence: ContextEvidence
|
||||
value: int
|
||||
source: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"evidence": self.evidence.value, "value": self.value, "source": self.source}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextConflict:
|
||||
first: ContextObservation
|
||||
second: ContextObservation
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"first": self.first.to_dict(), "second": self.second.to_dict()}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextResolution:
|
||||
"""The effective window of one turn and why it was chosen."""
|
||||
|
||||
effective: int
|
||||
evidence: ContextEvidence
|
||||
source: str
|
||||
observations: tuple[ContextObservation, ...] = ()
|
||||
conflicts: tuple[ContextConflict, ...] = ()
|
||||
provider_io: bool = False
|
||||
cached: bool = False
|
||||
probe_errors: tuple[str, ...] = ()
|
||||
# The route this resolution describes. Empty for resolutions built
|
||||
# directly from observations by internal callers. The URL can carry
|
||||
# credentials, so it stays out of repr() and to_dict().
|
||||
endpoint_url: str = field(default="", repr=False)
|
||||
model: str = ""
|
||||
|
||||
@property
|
||||
def mismatch(self) -> bool:
|
||||
return bool(self.conflicts)
|
||||
|
||||
@property
|
||||
def budget_limit(self) -> int:
|
||||
"""Window the runtime may budget against; 0 means budget reactively."""
|
||||
return self.effective if self.evidence is not ContextEvidence.UNKNOWN else 0
|
||||
|
||||
@property
|
||||
def shaping_window(self) -> int:
|
||||
"""Window for the legacy history compaction/trim helpers.
|
||||
|
||||
Those helpers predate typed evidence and always size against some
|
||||
window, using DEFAULT_CONTEXT when none is known. This only feeds them
|
||||
a number; it never creates provenance for that number.
|
||||
"""
|
||||
if self.budget_limit:
|
||||
return self.budget_limit
|
||||
from src.model_context import DEFAULT_CONTEXT
|
||||
return DEFAULT_CONTEXT
|
||||
|
||||
def applies_to(self, endpoint_url: str, model: str) -> bool:
|
||||
"""Whether this resolution may be reused for the given route."""
|
||||
if not self.endpoint_url and not self.model:
|
||||
return True
|
||||
return self.endpoint_url == endpoint_url and self.model == model
|
||||
|
||||
def observe_runtime_limit(self, limit: Any, source: str = "provider_rejection") -> "ContextResolution":
|
||||
"""Fold a limit the provider stated during this turn. Performs no I/O."""
|
||||
try:
|
||||
value = int(limit or 0)
|
||||
except (TypeError, ValueError):
|
||||
return self
|
||||
if value <= 0:
|
||||
return self
|
||||
observation = ContextObservation(ContextEvidence.RUNTIME_CONFIRMED, value, source)
|
||||
if observation in self.observations:
|
||||
return self
|
||||
combined = combine_observations((*self.observations, observation))
|
||||
return replace(
|
||||
combined,
|
||||
provider_io=self.provider_io,
|
||||
cached=self.cached,
|
||||
probe_errors=self.probe_errors,
|
||||
endpoint_url=self.endpoint_url,
|
||||
model=self.model,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"effective": self.effective,
|
||||
"evidence": self.evidence.value,
|
||||
"source": self.source,
|
||||
"mismatch": self.mismatch,
|
||||
"conflicts": [conflict.to_dict() for conflict in self.conflicts],
|
||||
"observations": [observation.to_dict() for observation in self.observations],
|
||||
"provider_io": self.provider_io,
|
||||
"cached": self.cached,
|
||||
"probe_errors": list(self.probe_errors),
|
||||
}
|
||||
|
||||
|
||||
UNRESOLVED_CONTEXT = ContextResolution(0, ContextEvidence.UNKNOWN, "none")
|
||||
|
||||
_MEASURED = (ContextEvidence.RUNTIME_CONFIRMED, ContextEvidence.PROVIDER_ADVERTISED)
|
||||
|
||||
|
||||
def _conflicts(observations: tuple[ContextObservation, ...]) -> tuple[ContextConflict, ...]:
|
||||
conflicts = []
|
||||
for index, first in enumerate(observations):
|
||||
for second in observations[index + 1:]:
|
||||
if first.value == second.value:
|
||||
continue
|
||||
classes = {first.evidence, second.evidence}
|
||||
if ContextEvidence.OPERATOR_DECLARED in classes:
|
||||
operator, other = (
|
||||
(first, second) if first.evidence is ContextEvidence.OPERATOR_DECLARED
|
||||
else (second, first)
|
||||
)
|
||||
# A declared window replaces the static table and may cap a
|
||||
# measured window. Only a declaration above what the runtime
|
||||
# or provider supports contradicts it.
|
||||
if other.evidence not in _MEASURED or operator.value < other.value:
|
||||
continue
|
||||
conflicts.append(ContextConflict(first, second))
|
||||
return tuple(conflicts)
|
||||
|
||||
|
||||
def _strongest(observations, evidence: ContextEvidence) -> Optional[ContextObservation]:
|
||||
matching = [observation for observation in observations if observation.evidence is evidence]
|
||||
return min(matching, key=lambda observation: observation.value) if matching else None
|
||||
|
||||
|
||||
def combine_observations(observations) -> ContextResolution:
|
||||
"""Choose the effective window deterministically from observations.
|
||||
|
||||
The smallest runtime-confirmed value wins, else the smallest provider
|
||||
value. An operator declaration caps either, and replaces the known table
|
||||
or an unknown window. The known table is used only when nothing stronger
|
||||
exists. No observation yields an unknown window of 0.
|
||||
"""
|
||||
observations = tuple(observations)
|
||||
measured = (
|
||||
_strongest(observations, ContextEvidence.RUNTIME_CONFIRMED)
|
||||
or _strongest(observations, ContextEvidence.PROVIDER_ADVERTISED)
|
||||
)
|
||||
operator = _strongest(observations, ContextEvidence.OPERATOR_DECLARED)
|
||||
if measured and operator:
|
||||
chosen = operator if operator.value < measured.value else measured
|
||||
else:
|
||||
chosen = measured or operator or _strongest(observations, ContextEvidence.KNOWN_TABLE)
|
||||
conflicts = _conflicts(observations)
|
||||
if chosen is None:
|
||||
return replace(UNRESOLVED_CONTEXT, observations=observations, conflicts=conflicts)
|
||||
return ContextResolution(
|
||||
chosen.value, chosen.evidence, chosen.source,
|
||||
observations=observations, conflicts=conflicts,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider metadata probe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ProbeResult:
|
||||
observations: tuple[ContextObservation, ...] = ()
|
||||
errors: tuple[str, ...] = ()
|
||||
io: bool = False
|
||||
|
||||
|
||||
_probe_cache: dict[tuple[str, str, str], tuple[float, _ProbeResult]] = {}
|
||||
|
||||
|
||||
def clear_probe_cache() -> None:
|
||||
_probe_cache.clear()
|
||||
|
||||
|
||||
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
||||
|
||||
|
||||
def _origin(url: str) -> tuple[str, str, Optional[int]]:
|
||||
parsed = urlparse(url or "")
|
||||
scheme = parsed.scheme.lower()
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return ("", "", None)
|
||||
return (scheme, (parsed.hostname or "").lower(), port or _DEFAULT_PORTS.get(scheme))
|
||||
|
||||
|
||||
def _http_client(timeout: float):
|
||||
# Credentials must never follow a redirect to another location.
|
||||
return httpx.AsyncClient(timeout=timeout, follow_redirects=False)
|
||||
|
||||
|
||||
def _provider_urls(endpoint_url: str) -> tuple[Optional[str], str]:
|
||||
"""Models catalog URL and the server-resolved form of the endpoint.
|
||||
|
||||
Both come from the existing endpoint resolver, which may rewrite an
|
||||
unresolvable host to its Tailscale address. Blocking (DNS, subprocess);
|
||||
call it off the event loop.
|
||||
"""
|
||||
from src.endpoint_resolver import build_models_url, resolve_url
|
||||
|
||||
return build_models_url(endpoint_url), resolve_url(endpoint_url)
|
||||
|
||||
|
||||
def _probe_headers(trusted_origins, target_url: str, headers: Optional[Mapping[str, Any]]) -> dict:
|
||||
"""Forward the turn's provider credentials only to the provider's origin."""
|
||||
origin = _origin(target_url)
|
||||
if not headers or not origin[1] or origin not in trusted_origins:
|
||||
return {}
|
||||
return {
|
||||
str(name): str(value) for name, value in headers.items()
|
||||
if value is not None and str(name).lower() not in _REQUEST_ONLY_HEADERS
|
||||
}
|
||||
|
||||
|
||||
def _auth_fingerprint(headers: Optional[Mapping[str, Any]]) -> str:
|
||||
if not headers:
|
||||
return ""
|
||||
material = json.dumps(
|
||||
sorted((str(k).lower(), str(v)) for k, v in headers.items()
|
||||
if v is not None and str(k).lower() not in _REQUEST_ONLY_HEADERS),
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _serving_base(endpoint_url: str) -> str:
|
||||
# Same derivation the regular runtime uses for llama.cpp server routes.
|
||||
return endpoint_url.split("/v1")[0] if "/v1" in endpoint_url else endpoint_url.rsplit("/", 1)[0]
|
||||
|
||||
|
||||
async def _get_json(client, url, headers, errors, label):
|
||||
try:
|
||||
response = await client.get(url, headers=headers)
|
||||
except httpx.TimeoutException:
|
||||
errors.append(f"{label}:timeout")
|
||||
return None
|
||||
except httpx.TransportError:
|
||||
errors.append(f"{label}:transport_error")
|
||||
return None
|
||||
status = getattr(response, "status_code", 0)
|
||||
if not (200 <= int(status or 0) < 300):
|
||||
errors.append(f"{label}:http_{status}")
|
||||
return None
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
errors.append(f"{label}:invalid_payload")
|
||||
return None
|
||||
|
||||
|
||||
def _positive_int(value) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
||||
return 0
|
||||
return int(value)
|
||||
|
||||
|
||||
async def _probe(endpoint_url, model, headers, is_local, observations, errors, timeout):
|
||||
from src.copilot import is_copilot_base
|
||||
from src.model_context import _model_ctx_from_entry
|
||||
|
||||
# Credentials go only to the configured provider's origin, or to the
|
||||
# form of that same endpoint the server-owned resolver produced.
|
||||
trusted = {_origin(endpoint_url)}
|
||||
async with _http_client(timeout) as client:
|
||||
if is_local:
|
||||
base = _serving_base(endpoint_url)
|
||||
slots = await _get_json(
|
||||
client, f"{base}/slots", _probe_headers(trusted, f"{base}/slots", headers),
|
||||
errors, "slots",
|
||||
)
|
||||
n_ctx = _positive_int(slots[0].get("n_ctx")) if (
|
||||
isinstance(slots, list) and slots and isinstance(slots[0], dict)
|
||||
) else 0
|
||||
if not n_ctx:
|
||||
props = await _get_json(
|
||||
client, f"{base}/props", _probe_headers(trusted, f"{base}/props", headers),
|
||||
errors, "props",
|
||||
)
|
||||
generation = props.get("default_generation_settings") if isinstance(props, dict) else None
|
||||
n_ctx = _positive_int(generation.get("n_ctx")) if isinstance(generation, dict) else 0
|
||||
source = "llamacpp_props"
|
||||
else:
|
||||
source = "llamacpp_slots"
|
||||
if n_ctx:
|
||||
observations.append(
|
||||
ContextObservation(ContextEvidence.RUNTIME_CONFIRMED, n_ctx, source)
|
||||
)
|
||||
|
||||
# Copilot's catalog needs headers this layer does not own; an
|
||||
# unauthenticated probe only fails. Its models are table-covered.
|
||||
if is_copilot_base(endpoint_url):
|
||||
errors.append("models:unsupported_endpoint")
|
||||
return
|
||||
# URL building may resolve the host (DNS, tailscale lookup); keep that
|
||||
# off the event loop and inside the probe deadline.
|
||||
models_url, resolved_endpoint = await asyncio.to_thread(_provider_urls, endpoint_url)
|
||||
if not models_url:
|
||||
errors.append("models:unsupported_endpoint")
|
||||
return
|
||||
trusted.add(_origin(resolved_endpoint))
|
||||
payload = await _get_json(
|
||||
client, models_url, _probe_headers(trusted, models_url, headers),
|
||||
errors, "models",
|
||||
)
|
||||
if payload is None:
|
||||
return
|
||||
entries = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(entries, list):
|
||||
errors.append("models:invalid_payload")
|
||||
return
|
||||
wanted = model.split("/")[-1]
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_id = str(entry.get("id") or "")
|
||||
if entry_id == model or entry_id.split("/")[-1] == wanted:
|
||||
value = _model_ctx_from_entry(entry)
|
||||
if value:
|
||||
observations.append(ContextObservation(
|
||||
ContextEvidence.PROVIDER_ADVERTISED, int(value), "models_catalog",
|
||||
))
|
||||
else:
|
||||
errors.append("models:no_window_listed")
|
||||
return
|
||||
errors.append("models:model_not_listed")
|
||||
|
||||
|
||||
async def probe_provider_context(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
*,
|
||||
headers: Optional[Mapping[str, Any]] = None,
|
||||
deadline_seconds: float = PROBE_DEADLINE_SECONDS,
|
||||
is_local: Optional[bool] = None,
|
||||
) -> _ProbeResult:
|
||||
"""Query provider metadata once, bounded by ``deadline_seconds``.
|
||||
|
||||
Never raises: every failure is reported as a short, secret-free error code
|
||||
so a turn can continue with other evidence.
|
||||
"""
|
||||
observations: list[ContextObservation] = []
|
||||
errors: list[str] = []
|
||||
if is_local is None:
|
||||
is_local = await _is_local(endpoint_url)
|
||||
timeout = max(0.1, float(deadline_seconds))
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
_probe(endpoint_url, model, headers, is_local, observations, errors, timeout),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
errors.append("deadline_exceeded")
|
||||
except Exception as exc:
|
||||
logger.debug("Context window probe failed: %s", type(exc).__name__)
|
||||
errors.append("probe_failed")
|
||||
return _ProbeResult(tuple(observations), tuple(errors), io=True)
|
||||
|
||||
|
||||
async def _is_local(endpoint_url: str) -> bool:
|
||||
from src.model_context import is_local_endpoint
|
||||
|
||||
try:
|
||||
# Reads configured endpoints from the local database on the calling
|
||||
# thread, as the regular runtime does. Moving it to worker threads
|
||||
# gives SQLite sessions per-thread connections the app never uses.
|
||||
return bool(is_local_endpoint(endpoint_url))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _cached_probe(endpoint_url, model, headers, deadline_seconds, clock):
|
||||
is_local = await _is_local(endpoint_url)
|
||||
key = (endpoint_url, model, _auth_fingerprint(headers))
|
||||
if not is_local:
|
||||
cached = _probe_cache.get(key)
|
||||
if cached and cached[0] > clock():
|
||||
return cached[1], True
|
||||
result = await probe_provider_context(
|
||||
endpoint_url, model, headers=headers, deadline_seconds=deadline_seconds,
|
||||
is_local=is_local,
|
||||
)
|
||||
if not is_local:
|
||||
ttl = PROBE_CACHE_TTL_SECONDS if result.observations else PROBE_FAILURE_TTL_SECONDS
|
||||
_probe_cache[key] = (clock() + ttl, result)
|
||||
return result, False
|
||||
|
||||
|
||||
def declared_context_window(client_runtime_context: Any) -> int:
|
||||
"""Operator/client declared transport window, or 0."""
|
||||
if not isinstance(client_runtime_context, Mapping):
|
||||
return 0
|
||||
try:
|
||||
value = int(client_runtime_context.get("model_context_window") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return value if value > 0 else 0
|
||||
|
||||
|
||||
async def resolve_effective_context(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
*,
|
||||
headers: Optional[Mapping[str, Any]] = None,
|
||||
client_runtime_context: Any = None,
|
||||
deadline_seconds: float = PROBE_DEADLINE_SECONDS,
|
||||
probe: bool = True,
|
||||
clock=time.monotonic,
|
||||
) -> ContextResolution:
|
||||
"""Resolve the effective context window for one turn preparation."""
|
||||
from src.model_context import _lookup_known
|
||||
|
||||
observations: list[ContextObservation] = []
|
||||
errors: tuple[str, ...] = ()
|
||||
provider_io = cached = False
|
||||
if probe and endpoint_url and model:
|
||||
result, cached = await _cached_probe(
|
||||
endpoint_url, model, headers, deadline_seconds, clock,
|
||||
)
|
||||
observations.extend(result.observations)
|
||||
errors = result.errors
|
||||
provider_io = result.io and not cached
|
||||
declared = declared_context_window(client_runtime_context)
|
||||
if declared:
|
||||
observations.append(ContextObservation(
|
||||
ContextEvidence.OPERATOR_DECLARED, declared, "client_runtime_context",
|
||||
))
|
||||
known = _lookup_known(model or "")
|
||||
if known:
|
||||
observations.append(ContextObservation(ContextEvidence.KNOWN_TABLE, int(known), "known_table"))
|
||||
resolution = combine_observations(observations)
|
||||
resolution = replace(
|
||||
resolution, provider_io=provider_io, cached=cached, probe_errors=errors,
|
||||
endpoint_url=endpoint_url or "", model=model or "",
|
||||
)
|
||||
if resolution.mismatch:
|
||||
logger.info(
|
||||
"Context window sources disagree for %s: %s",
|
||||
model, [conflict.to_dict() for conflict in resolution.conflicts],
|
||||
)
|
||||
return resolution
|
||||
|
||||
|
||||
def context_metrics(resolution: Optional[ContextResolution], request_tokens: int) -> dict:
|
||||
"""Metrics fields derived only from a stored resolution. Performs no I/O."""
|
||||
resolution = resolution or UNRESOLVED_CONTEXT
|
||||
length = resolution.budget_limit
|
||||
percent = (
|
||||
min(round((request_tokens / length) * 100, 1), 100.0)
|
||||
if length and request_tokens else 0
|
||||
)
|
||||
return {
|
||||
"context_length": length,
|
||||
"context_percent": percent,
|
||||
"context_resolution": resolution.to_dict(),
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
"""Server-boundary adapters from admitted Wave 3 bindings to effect records.
|
||||
|
||||
Runs only inside the dispatcher's existing admission scope: the bindings read
|
||||
here are the contextvars the dispatcher bound after authority, resource and
|
||||
approval checks. Nothing here admits, resolves, broadens or re-derives a
|
||||
resource. Observations are recorded only for operations that were themselves
|
||||
admitted reads of the exact bound resource; evidence bookkeeping never performs
|
||||
a read that the operation was not already admitted to perform.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
from src.agent_runtime.effects import (
|
||||
CleanupState, Coverage, EffectClaim, ExecutionOutcome, Impact, ObservationMechanism, OperationRef,
|
||||
Postcondition, Predicate, ProducerFacts, ResourceKind, ResourceRef, producer_facts, resource_ref,
|
||||
)
|
||||
|
||||
|
||||
_FILESYSTEM_READS = frozenset({"read_file", "ls", "glob", "grep"})
|
||||
_JOB_READS = frozenset({"list", "ls", "jobs", "output", "get", "read", "tail", "status", "show"})
|
||||
_OWNED_READS = frozenset({"vault_get", "vault_search", "list_sessions", "search_chats"})
|
||||
_JOB_SETTLED = {"done", "failed"}
|
||||
# Largest pre-state an edit/patch postcondition is derived from.
|
||||
_PRE_STATE_LIMIT = 10 * 1024 * 1024
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DispatchCapture:
|
||||
"""The admitted bindings that were live when the backend was invoked."""
|
||||
|
||||
filesystem: Any = None
|
||||
owned: Any = None
|
||||
process: Any = None
|
||||
backend: Any = None
|
||||
browser: Any = None
|
||||
claim: EffectClaim | None = None
|
||||
read_only: bool = False
|
||||
paths: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
def capture_dispatch() -> DispatchCapture:
|
||||
from src.agent_runtime.owned_resources import active_owned_operation
|
||||
from src.agent_runtime.process_resources import active_process_operation
|
||||
from src.agent_runtime.remote_resources import active_backend_operation
|
||||
from src.agent_runtime.resource_binding import active_resource_operation
|
||||
import sys
|
||||
browser_module = sys.modules.get("src.browser_identity")
|
||||
browser = browser_module._ACTIVE.get() if browser_module is not None else None
|
||||
return DispatchCapture(active_resource_operation(), active_owned_operation(), active_process_operation(),
|
||||
active_backend_operation(), browser)
|
||||
|
||||
|
||||
def _exact_operation(capture: DispatchCapture):
|
||||
for bound in (capture.filesystem, capture.owned, capture.process, capture.browser):
|
||||
if bound is not None:
|
||||
return bound.operation, getattr(bound, "execution_input", None), getattr(bound, "request_id", "")
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def _operation(capture: DispatchCapture, action: Any) -> OperationRef:
|
||||
operation, execution_input, request_id = _exact_operation(capture)
|
||||
if operation is not None:
|
||||
return OperationRef.from_exact(operation, execution_input, request_id)
|
||||
backend = capture.backend
|
||||
# Unbound tools still name their final normalized dispatcher input.
|
||||
digest = hashlib.sha256(str(action.arguments).encode("utf-8", errors="replace")).hexdigest()
|
||||
return OperationRef(str(action.tool) or "unknown", "", digest,
|
||||
getattr(backend, "request_id", "") if backend is not None else "")
|
||||
|
||||
|
||||
def _write_file_digest(execution_input: str, path: str) -> str:
|
||||
"""The exact bytes WriteFileTool commits for this admitted input, or ''."""
|
||||
from src.agent_tools.filesystem_tools import _unwrap_fenced_source_body
|
||||
try:
|
||||
args = json.loads(execution_input)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
body = args.get("content") if isinstance(args, dict) else None
|
||||
if not isinstance(body, str) or os.linesep != "\n":
|
||||
return ""
|
||||
return hashlib.sha256(_unwrap_fenced_source_body(body, path).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _pre_state_text(resource: Any, *, newline: str | None) -> str | None:
|
||||
"""The exact bound file decoded as its producer decodes it, or None.
|
||||
|
||||
Reads only the admitted target binding (identity-checked). An oversized,
|
||||
replaced or undecodable file yields None: a truncated read must never
|
||||
stand in for the whole pre-state.
|
||||
"""
|
||||
data = _read_whole(resource, _PRE_STATE_LIMIT).data
|
||||
if data is None or len(data) > _PRE_STATE_LIMIT:
|
||||
return None
|
||||
try:
|
||||
return io.TextIOWrapper(io.BytesIO(data), encoding="utf-8", newline=newline).read()
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _edit_file_digest(execution_input: str, resource: Any) -> str:
|
||||
"""SHA-256 of the exact bytes edit_file writes for this admitted input, or ''."""
|
||||
from src.agent_tools.filesystem_tools import _edit_file_text
|
||||
try:
|
||||
args = json.loads(execution_input)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if not isinstance(args, dict):
|
||||
return ""
|
||||
old, new, replace_all = args.get("old_string"), args.get("new_string"), args.get("replace_all", False)
|
||||
if not isinstance(old, str) or not old or not isinstance(new, str) or type(replace_all) is not bool or old == new:
|
||||
return ""
|
||||
# edit_file reads with newline="" and writes with newline="": no translation.
|
||||
original = _pre_state_text(resource, newline="")
|
||||
if original is None:
|
||||
return ""
|
||||
updated, _ = _edit_file_text(original, old, new, replace_all)
|
||||
return "" if updated is None else hashlib.sha256(updated.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _patch_update_digest(op: dict, resource: Any) -> str:
|
||||
"""SHA-256 of the exact bytes apply_patch writes for one update, or ''."""
|
||||
from src.agent_tools.filesystem_tools import _apply_patch_hunks
|
||||
# apply_patch reads updates with universal newlines and writes newline="".
|
||||
original = _pre_state_text(resource, newline=None)
|
||||
if original is None:
|
||||
return ""
|
||||
try:
|
||||
updated = _apply_patch_hunks(original, op["hunks"], op["path"])
|
||||
except ValueError:
|
||||
return ""
|
||||
return hashlib.sha256(updated.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _filesystem_scope(bound: Any) -> tuple[tuple[ResourceRef, ...], tuple[Postcondition, ...]]:
|
||||
"""Exact bindings and the requested post-state of each mutation target.
|
||||
|
||||
Each postcondition is the exact content (or absence) the producer's own
|
||||
transformation yields from the admitted pre-state, so an unrelated change
|
||||
can never satisfy it. When any target's requested state cannot be derived
|
||||
the claim carries no postcondition at all and stays UNVERIFIED: a partial
|
||||
set would let the derivable targets verify the whole operation.
|
||||
"""
|
||||
from src.agent_tools.filesystem_tools import _parse_agent_patch
|
||||
tool = bound.operation.tool
|
||||
refs = tuple(resource_ref(b.resource, b.role) for b in bound.bindings)
|
||||
obligations: list[Postcondition] = []
|
||||
if tool == "write_file":
|
||||
expected = _write_file_digest(bound.execution_input, bound.bindings[0].resource.path)
|
||||
intent = bound.write_intent
|
||||
if intent is not None:
|
||||
from src.agent_tools.filesystem_tools import _unwrap_fenced_source_body
|
||||
resource = bound.bindings[0].resource
|
||||
body = _unwrap_fenced_source_body(intent[1], resource.path)
|
||||
if not body.strip() and not intent[3] and resource.identity is not None:
|
||||
pre_state = _pre_state_text(resource, newline=None)
|
||||
expected = hashlib.sha256(b"").hexdigest() if pre_state == "" else ""
|
||||
|
||||
if not expected:
|
||||
return refs, ()
|
||||
obligations.append(Postcondition(refs[0], Predicate.CONTENT_SHA256, expected))
|
||||
elif tool == "edit_file":
|
||||
expected = _edit_file_digest(bound.execution_input, bound.bindings[0].resource)
|
||||
if not expected:
|
||||
return refs, ()
|
||||
obligations.append(Postcondition(refs[0], Predicate.CONTENT_SHA256, expected))
|
||||
elif tool == "apply_patch":
|
||||
ops = _parse_agent_patch(json.loads(bound.execution_input)["patch_text"])
|
||||
if len(ops) != len(bound.bindings):
|
||||
return refs, ()
|
||||
for op, binding, ref in zip(ops, bound.bindings, refs):
|
||||
if op["kind"] == "add":
|
||||
obligations.append(Postcondition(ref, Predicate.CONTENT_SHA256,
|
||||
hashlib.sha256(op["content"].encode("utf-8")).hexdigest()))
|
||||
elif op["kind"] == "delete":
|
||||
obligations.append(Postcondition(ref, Predicate.ABSENT))
|
||||
else:
|
||||
expected = _patch_update_digest(op, binding.resource)
|
||||
if not expected:
|
||||
return refs, ()
|
||||
obligations.append(Postcondition(ref, Predicate.CONTENT_SHA256, expected))
|
||||
return refs, tuple(obligations)
|
||||
|
||||
|
||||
def classify(capture: DispatchCapture) -> dict[str, Any] | None:
|
||||
"""Claim scope for the captured bindings, or None for an admitted read.
|
||||
|
||||
Unbound operations get an unknown-scope claim: they may change anything.
|
||||
"""
|
||||
impact: tuple[ResourceRef, ...] = ()
|
||||
dependencies: tuple[ResourceRef, ...] = ()
|
||||
obligations: tuple[Postcondition, ...] = ()
|
||||
external = False
|
||||
if capture.browser is not None:
|
||||
# Wave 3 admits only session metadata. A page binding is never
|
||||
# effect-bindable; leave its scope unknown rather than infer it.
|
||||
if capture.browser.page is None:
|
||||
return None
|
||||
elif capture.filesystem is not None:
|
||||
if capture.filesystem.operation.tool in _FILESYSTEM_READS:
|
||||
return None
|
||||
impact, obligations = _filesystem_scope(capture.filesystem)
|
||||
elif capture.process is not None:
|
||||
bound = capture.process
|
||||
if bound.launch is not None:
|
||||
# An arbitrary command has unknown impact scope; the exact launch
|
||||
# reservation is kept only as lineage for background settlement.
|
||||
dependencies = (resource_ref(bound.launch, "launch"),)
|
||||
else:
|
||||
action = str(json.loads(bound.operation.input or "{}").get("action", "list")).strip().lower()
|
||||
if action in _JOB_READS:
|
||||
return None
|
||||
impact = tuple(resource_ref(job, "job") for job in bound.jobs) + tuple(
|
||||
resource_ref(process, "process") for job in bound.jobs for process in job.processes) + tuple(
|
||||
resource_ref(process, "process") for process in bound.processes)
|
||||
elif capture.owned is not None:
|
||||
if capture.owned.operation.tool in _OWNED_READS:
|
||||
return None
|
||||
impact = tuple(resource_ref(r, "record") for r in capture.owned.resources)
|
||||
dependencies = tuple(resource_ref(a.file, "attachment") for a in capture.owned.attachments)
|
||||
if capture.backend is not None:
|
||||
from src.agent_runtime.resources import ExternalResource
|
||||
if isinstance(capture.backend.resource, ExternalResource):
|
||||
external = True
|
||||
impact = (*impact, resource_ref(capture.backend.resource, "backend"))
|
||||
return {"impact_scope": impact, "dependencies": dependencies, "obligations": obligations, "external": external}
|
||||
|
||||
|
||||
def begin_effect(journal: Any, action: Any) -> DispatchCapture:
|
||||
"""Capture bindings and durably claim a possible effect before invocation."""
|
||||
capture = capture_dispatch()
|
||||
log = journal.effects
|
||||
try:
|
||||
scope = classify(capture)
|
||||
except Exception: # noqa: BLE001 - classification never blocks dispatch
|
||||
# An unclassifiable admitted operation may change anything.
|
||||
logger.warning("Effect scope classification failed; claiming unknown scope", exc_info=True)
|
||||
scope = {"impact_scope": (), "dependencies": (), "obligations": (), "external": False}
|
||||
if scope is None:
|
||||
capture.read_only = True
|
||||
else:
|
||||
capture.claim = log.claim(effect_id=action.action_id + ":effect", run_id=journal.run_id,
|
||||
action_id=action.action_id, operation=_operation(capture, action),
|
||||
parent_run_id=journal.parent_run_id or "", **scope)
|
||||
capture.paths = tuple(ref.location[-1] for ref in capture.claim.impact_scope
|
||||
if ref.kind is ResourceKind.FILESYSTEM)
|
||||
for ref in capture.claim.dependencies:
|
||||
if ref.kind is ResourceKind.PROCESS_LAUNCH:
|
||||
try:
|
||||
log.index_launch(ref.incarnation, capture.claim.effect_id)
|
||||
except (OSError, ValueError):
|
||||
# Without the index a later turn cannot settle this
|
||||
# launch: it stays running/unknown, never successful.
|
||||
logger.warning("Background launch lineage was not indexed", exc_info=True)
|
||||
return capture
|
||||
|
||||
|
||||
def _server_producer(capture: DispatchCapture) -> bool:
|
||||
"""The backend was a server-owned producer bound by Wave 3 admission.
|
||||
|
||||
Only such producers build their result dictionaries from server state. An
|
||||
unbound dynamic/registry tool returns whatever it likes, so its keys carry
|
||||
no lifecycle meaning. The MCP bridge builds only stdout/stderr/exit_code.
|
||||
"""
|
||||
return any(bound is not None for bound in (capture.filesystem, capture.owned, capture.process, capture.browser))
|
||||
|
||||
|
||||
def _facts(result: Any, capture: DispatchCapture) -> ProducerFacts:
|
||||
"""Typed producer facts, scoped to what the captured producer can attest."""
|
||||
facts = producer_facts(result)
|
||||
if not _server_producer(capture):
|
||||
# Reported success or failure is all an untrusted result can say.
|
||||
facts = ProducerFacts(exit_code=facts.exit_code)
|
||||
if capture.backend is not None and capture.claim is not None and capture.claim.external:
|
||||
facts = ProducerFacts(**{**facts.to_dict(), "external": True, "remote_acknowledged": facts.exit_code == 0})
|
||||
return facts
|
||||
|
||||
|
||||
def _execution(result: Any, facts: ProducerFacts, capture: DispatchCapture) -> ExecutionOutcome:
|
||||
if not isinstance(result, dict):
|
||||
return ExecutionOutcome.INTERRUPTED
|
||||
if facts.timed_out:
|
||||
return ExecutionOutcome.TIMED_OUT
|
||||
# Only a server process producer can say this operation's own work
|
||||
# continues: the native detached launch of an exact Wave 3 launch
|
||||
# reservation, or the host bridge's server-set detachment. Lifecycle keys
|
||||
# from any other producer (or a listing reporting something else as
|
||||
# running) do not.
|
||||
process = capture.process
|
||||
if process is not None:
|
||||
if process.launch is not None and isinstance(result.get("bg_job_id"), str) and facts.exit_code == 0:
|
||||
return ExecutionOutcome.RUNNING
|
||||
if result.get("detached") is True:
|
||||
return ExecutionOutcome.RUNNING
|
||||
denied = bool(result.get("blocked") or result.get("approval_required")
|
||||
or facts.failure_kind.endswith("_denied"))
|
||||
if facts.exit_code == 0 and not result.get("error") and not denied:
|
||||
return ExecutionOutcome.REPORTED_SUCCESS
|
||||
return ExecutionOutcome.FAILED
|
||||
|
||||
|
||||
def _cleanup(result: Any, facts: ProducerFacts, capture: DispatchCapture) -> CleanupState:
|
||||
if not isinstance(result, dict):
|
||||
return CleanupState.UNKNOWN
|
||||
if facts.external:
|
||||
# External execution reports no locally observed teardown.
|
||||
return CleanupState.UNKNOWN
|
||||
if capture.process is None:
|
||||
return CleanupState.NOT_APPLICABLE
|
||||
# Teardown is attested only by the native process/containment producer.
|
||||
if facts.failure_kind == "process_teardown_failed":
|
||||
return CleanupState.FAILED
|
||||
teardown = result.get("teardown")
|
||||
if isinstance(teardown, dict) and type(teardown.get("dead")) is bool:
|
||||
return CleanupState.VERIFIED if teardown["dead"] else CleanupState.FAILED
|
||||
return CleanupState.NOT_APPLICABLE
|
||||
|
||||
|
||||
def settle_effect(journal: Any, action: Any, capture: DispatchCapture | None, *,
|
||||
result: Any = None, error: BaseException | None = None) -> None:
|
||||
"""Append the outcome and any admitted-read observations for one action."""
|
||||
if capture is None:
|
||||
return
|
||||
log = journal.effects
|
||||
if capture.claim is not None:
|
||||
if error is not None:
|
||||
execution = (ExecutionOutcome.CANCELLED if isinstance(error, asyncio.CancelledError)
|
||||
else ExecutionOutcome.INTERRUPTED)
|
||||
facts, cleanup = ProducerFacts(), CleanupState.UNKNOWN
|
||||
else:
|
||||
facts = _facts(result, capture)
|
||||
execution, cleanup = _execution(result, facts, capture), _cleanup(result, facts, capture)
|
||||
log.outcome(effect_id=capture.claim.effect_id, execution=execution, impact=Impact.POSSIBLE,
|
||||
facts=facts, cleanup=cleanup, execution_id=action.execution_id or "")
|
||||
if (execution is ExecutionOutcome.REPORTED_SUCCESS and capture.process is not None
|
||||
and capture.process.launch is None):
|
||||
_settle_background(log, capture, result) # e.g. an exact kill
|
||||
return
|
||||
if error is not None or not isinstance(result, dict):
|
||||
return
|
||||
successful = result.get("exit_code") == 0 and not result.get("error")
|
||||
# A missing-file read reports failure, but can independently establish
|
||||
# absence. No other failed read is eligible for an observation.
|
||||
absent_read = (capture.filesystem is not None and capture.filesystem.operation.tool == "read_file"
|
||||
and capture.filesystem.bindings[0].resource.identity is None)
|
||||
if not successful and not absent_read:
|
||||
return
|
||||
for fields in _observations(capture, action, result):
|
||||
if successful or fields.get("exists") is False:
|
||||
log.observe(**fields)
|
||||
if capture.process is not None and capture.process.launch is None:
|
||||
_settle_background(log, capture, result)
|
||||
|
||||
|
||||
# -- observations ------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _WholeFileRead:
|
||||
data: bytes | None = None
|
||||
known_absent: bool = False
|
||||
|
||||
|
||||
def _read_whole(resource: Any, limit: int) -> _WholeFileRead:
|
||||
"""Read a stable binding, distinguish validated ENOENT from uncertainty.
|
||||
|
||||
Only a binding admitted as absent can prove absence. Disappearance of an
|
||||
existing identity, replacement, or any validation/access failure is unknown.
|
||||
"""
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
try:
|
||||
resource.validate()
|
||||
if resource.identity is None:
|
||||
try:
|
||||
os.lstat(resource.path)
|
||||
except FileNotFoundError:
|
||||
resource.validate()
|
||||
return _WholeFileRead(known_absent=True)
|
||||
return _WholeFileRead()
|
||||
descriptor = os.open(resource.path, flags)
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
identity = resource.identity
|
||||
if (not stat.S_ISREG(info.st_mode) or identity is None
|
||||
or (info.st_dev, info.st_ino) != (identity.device, identity.inode)):
|
||||
return _WholeFileRead()
|
||||
data = stream.read(limit + 1)
|
||||
resource.validate()
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
return _WholeFileRead()
|
||||
return _WholeFileRead(data=data)
|
||||
|
||||
|
||||
def _file_observation(capture: DispatchCapture, action: Any) -> dict[str, Any] | None:
|
||||
from src.agent_tools import filesystem_tools as producer
|
||||
bound = capture.filesystem
|
||||
binding = bound.bindings[0]
|
||||
resource = binding.resource
|
||||
args = json.loads(bound.execution_input)
|
||||
partial = bool(args.get("offset") or args.get("limit")) or (
|
||||
os.path.splitext(resource.path)[1].lower() in producer._STRUCTURED_DOCUMENT_SUFFIXES)
|
||||
read = _read_whole(resource, producer.MAX_READ_CHARS * 4)
|
||||
data = read.data
|
||||
if data is None and not read.known_absent:
|
||||
return None
|
||||
if read.known_absent:
|
||||
partial = False # ENOENT establishes absence of the whole bound path.
|
||||
elif len(data) > producer.MAX_READ_CHARS * 4 or len(data.decode("utf-8", errors="replace")) > producer.MAX_READ_CHARS:
|
||||
partial = True # the producer truncated what it read
|
||||
complete = not partial
|
||||
return dict(observation_id=action.action_id + ":observation", resource=resource_ref(resource, binding.role),
|
||||
mechanism=ObservationMechanism.FILESYSTEM_READ,
|
||||
coverage=Coverage.COMPLETE if complete else Coverage.PARTIAL,
|
||||
source_action_id=action.action_id, source_execution_id=action.execution_id or "",
|
||||
exists=not read.known_absent,
|
||||
content_sha256=hashlib.sha256(data).hexdigest() if complete and data is not None else "")
|
||||
|
||||
|
||||
def _observations(capture: DispatchCapture, action: Any, result: dict) -> list[dict[str, Any]]:
|
||||
base = dict(source_action_id=action.action_id, source_execution_id=action.execution_id or "")
|
||||
if capture.browser is not None and capture.browser.page is None:
|
||||
# Session lifecycle metadata only; never page/document state.
|
||||
return [dict(observation_id=action.action_id + ":observation",
|
||||
resource=resource_ref(capture.browser.session, "session"),
|
||||
mechanism=ObservationMechanism.BROWSER_SESSION, coverage=Coverage.PARTIAL,
|
||||
exists=True, **base)]
|
||||
if capture.filesystem is not None:
|
||||
tool = capture.filesystem.operation.tool
|
||||
if tool == "read_file":
|
||||
observation = _file_observation(capture, action)
|
||||
return [observation] if observation else []
|
||||
if tool in _FILESYSTEM_READS:
|
||||
# Listings/searches are partial: they cannot decide content.
|
||||
return [dict(observation_id=f"{action.action_id}:observation:{i}", resource=resource_ref(b.resource, b.role),
|
||||
mechanism=ObservationMechanism.FILESYSTEM_READ, coverage=Coverage.PARTIAL, exists=True, **base)
|
||||
for i, b in enumerate(capture.filesystem.bindings)]
|
||||
if capture.owned is not None and capture.owned.operation.tool in _OWNED_READS:
|
||||
return [dict(observation_id=f"{action.action_id}:observation:{i}", resource=resource_ref(r, "record"),
|
||||
mechanism=ObservationMechanism.OWNED_RECORD_READ, coverage=Coverage.PARTIAL, exists=True, **base)
|
||||
for i, r in enumerate(capture.owned.resources) if r.record_id != "*"]
|
||||
if capture.process is not None and capture.process.launch is None:
|
||||
from src.agent_runtime.process_resources import JOB_TOOL
|
||||
job = result.get("job")
|
||||
if isinstance(job, dict) and len(capture.process.jobs) == 1 and capture.process.operation.tool == JOB_TOOL:
|
||||
return [dict(observation_id=action.action_id + ":observation",
|
||||
resource=resource_ref(capture.process.jobs[0], "job"),
|
||||
mechanism=ObservationMechanism.JOB_STATE, coverage=Coverage.PARTIAL, exists=True, **base)]
|
||||
return []
|
||||
|
||||
|
||||
def _settle_background(log: Any, capture: DispatchCapture, result: dict) -> None:
|
||||
"""Settle a RUNNING launch claim from an admitted read of its exact job.
|
||||
|
||||
Linkage is the Wave 3 launch generation plus owner/request/thread, already
|
||||
validated by ``job_from_record`` at admission. Job completion is execution
|
||||
evidence for that claim; it verifies no postcondition.
|
||||
"""
|
||||
from src.agent_runtime.process_resources import JOB_TOOL
|
||||
job_facts = result.get("job")
|
||||
if (not isinstance(job_facts, dict) or len(capture.process.jobs) != 1
|
||||
or capture.process.operation.tool != JOB_TOOL):
|
||||
return
|
||||
settle_background_job(capture.process.jobs[0], job_facts, log=log)
|
||||
|
||||
|
||||
def settle_background_job(job: Any, job_facts: Any, *, log: Any = None) -> None:
|
||||
"""Settle the RUNNING launch claim of one exact, Wave 3-validated job.
|
||||
|
||||
``job`` must be a ``BackgroundJobResource`` the caller obtained through
|
||||
Wave 3 validation (an admitted job read, or the monitor's
|
||||
``job_from_record``/``validate_job``). ``job_facts`` are typed lifecycle
|
||||
facts from that server-owned record; delivered output is never consulted.
|
||||
"""
|
||||
from src.agent_runtime.effect_log import EffectLog, EffectPersistenceError, effects_dir
|
||||
from src.agent_runtime.resources import BackgroundJobResource
|
||||
if not isinstance(job, BackgroundJobResource) or not isinstance(job_facts, dict):
|
||||
return
|
||||
status = job_facts.get("status")
|
||||
if status not in _JOB_SETTLED:
|
||||
return
|
||||
lineage = ("process_launch", "native:containment", job.owner, job.request_id, job.thread_id, job.generation)
|
||||
owner = log if log is not None and any(any(ref.kind is ResourceKind.PROCESS_LAUNCH and ref.location == lineage
|
||||
for ref in c.dependencies) for c in log.history().claims) else None
|
||||
if owner is None:
|
||||
# Background continuation: the launch was claimed by an earlier run.
|
||||
directory = log.path.parent if log is not None and log.path is not None else effects_dir()
|
||||
indexed = EffectLog.launch_owner(job.generation, directory=directory)
|
||||
if indexed is not None:
|
||||
try:
|
||||
owner = EffectLog.open(indexed[0], directory=directory)
|
||||
except (EffectPersistenceError, ValueError):
|
||||
owner = None
|
||||
if owner is None:
|
||||
return
|
||||
history = owner.history()
|
||||
for claim in history.claims:
|
||||
if not any(ref.kind is ResourceKind.PROCESS_LAUNCH and ref.location == lineage for ref in claim.dependencies):
|
||||
continue
|
||||
latest = history.latest_outcome(claim.effect_id)
|
||||
if latest is None or latest.execution is not ExecutionOutcome.RUNNING:
|
||||
continue
|
||||
code = job_facts.get("exit_code")
|
||||
code = code if type(code) is int else None
|
||||
if job_facts.get("timed_out") is True:
|
||||
execution = ExecutionOutcome.TIMED_OUT
|
||||
elif job_facts.get("killed") is True:
|
||||
execution = ExecutionOutcome.CANCELLED
|
||||
elif status == "done" and code == 0 and job_facts.get("died") is not True:
|
||||
execution = ExecutionOutcome.REPORTED_SUCCESS
|
||||
else:
|
||||
execution = ExecutionOutcome.FAILED
|
||||
facts = ProducerFacts(exit_code=code, timed_out=job_facts.get("timed_out") is True, job_state=status)
|
||||
owner.outcome(effect_id=claim.effect_id, execution=execution, impact=Impact.POSSIBLE, facts=facts,
|
||||
cleanup=CleanupState.UNKNOWN, execution_id=latest.execution_id)
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Durable append-only effect log for one root run lineage.
|
||||
|
||||
This is the Wave 4 semantic store: claims, outcomes and observations only. It
|
||||
is not a resource database, a process/containment store or an authority source.
|
||||
A claim is fsynced before the backend is invoked; if that fails, the caller must
|
||||
refuse the invocation. Later records are appended; nothing is rewritten.
|
||||
|
||||
On reload, a claim without a settled outcome becomes an appended INTERRUPTED
|
||||
outcome with possible impact. Reload never manufactures success and never
|
||||
upgrades an old report to fresh state.
|
||||
|
||||
Several writers may append to one log (another ``EffectLog`` object, thread or
|
||||
process settling a background launch). Each append takes an exclusive advisory
|
||||
lock on the file, merges every durable record other writers appended, allocates
|
||||
the next position from that merged tail, checks the record against the merged
|
||||
history, then appends and fsyncs before releasing the lock. Positions therefore
|
||||
stay unique and a settled outcome is never appended twice. Cross-process
|
||||
exclusion relies on POSIX ``flock``; directory fsync relies on POSIX directory
|
||||
semantics. Neither is claimed where the platform does not provide it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import threading
|
||||
import weakref
|
||||
from typing import Any, Callable
|
||||
|
||||
try: # POSIX only; elsewhere exclusion is per process.
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-POSIX hosts
|
||||
fcntl = None
|
||||
|
||||
from src.constants import DATA_DIR
|
||||
from src.agent_runtime.effects import (
|
||||
EffectAssessment, EffectClaim, EffectHistory, EffectOutcome, ExecutionOutcome, Observation, assess_all,
|
||||
replay_interrupted,
|
||||
)
|
||||
from src.agent_runtime.resources import ResourceIdentityError
|
||||
|
||||
|
||||
EFFECTS_DIR = os.path.join(DATA_DIR, "effects")
|
||||
_RUN_ID = re.compile(r"[a-f0-9]{32}")
|
||||
_TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation}
|
||||
_VERSION = 1
|
||||
|
||||
|
||||
def _fsync_directory(directory: str | os.PathLike) -> None:
|
||||
"""Make a directory's entries durable. POSIX only; a no-op elsewhere."""
|
||||
if os.name != "posix":
|
||||
return
|
||||
descriptor = os.open(os.fspath(directory), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _ensure_directory(directory: Path) -> None:
|
||||
"""Create ``directory`` and make every newly created entry durable."""
|
||||
missing = []
|
||||
current = directory
|
||||
while not current.exists():
|
||||
missing.append(current)
|
||||
if current.parent == current:
|
||||
break
|
||||
current = current.parent
|
||||
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
for created in reversed(missing):
|
||||
_fsync_directory(created.parent)
|
||||
|
||||
|
||||
class EffectPersistenceError(ResourceIdentityError):
|
||||
"""A pre-invocation claim could not be made durable; do not invoke."""
|
||||
|
||||
|
||||
def effects_dir() -> Path:
|
||||
return Path(EFFECTS_DIR)
|
||||
|
||||
|
||||
def _parse(line: bytes) -> tuple[str, Any]:
|
||||
entry = json.loads(line.decode("utf-8"))
|
||||
if (not isinstance(entry, dict) or set(entry) != {"v", "type", "record"}
|
||||
or entry["v"] != _VERSION or entry["type"] not in _TYPES):
|
||||
raise ValueError("unsupported effect record")
|
||||
return entry["type"], _TYPES[entry["type"]].from_dict(entry["record"])
|
||||
|
||||
|
||||
class _Index:
|
||||
"""Incremental consistency of an append-ordered record stream.
|
||||
|
||||
At least as strict as ``EffectHistory`` validation for records appended in
|
||||
position order, so a record it accepts never makes the history invalid.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.positions: set[int] = set()
|
||||
self.claims: dict[str, int] = {}
|
||||
self.settled: set[str] = set()
|
||||
self.last: dict[str, int] = {}
|
||||
|
||||
def accepts(self, kind: str, record: Any) -> bool:
|
||||
if record.sequence in self.positions:
|
||||
return False
|
||||
if kind == "claim":
|
||||
return record.effect_id not in self.claims
|
||||
if kind == "outcome":
|
||||
effect = record.effect_id
|
||||
return (effect in self.claims and record.sequence > self.claims[effect]
|
||||
and effect not in self.settled and record.sequence > self.last.get(effect, -1))
|
||||
return True
|
||||
|
||||
def add(self, kind: str, record: Any) -> None:
|
||||
self.positions.add(record.sequence)
|
||||
if kind == "claim":
|
||||
self.claims[record.effect_id] = record.sequence
|
||||
elif kind == "outcome":
|
||||
self.last[record.effect_id] = record.sequence
|
||||
if record.execution is not ExecutionOutcome.RUNNING:
|
||||
self.settled.add(record.effect_id)
|
||||
|
||||
|
||||
def _records() -> dict[str, list]:
|
||||
return {"claim": [], "outcome": [], "observation": []}
|
||||
|
||||
|
||||
class EffectLog:
|
||||
# Logs still owned by a live run in this process. A later turn appends to
|
||||
# the same object rather than a second copy of the same history.
|
||||
_LIVE: "weakref.WeakValueDictionary[tuple[str, str], EffectLog]" = weakref.WeakValueDictionary()
|
||||
# Serializes the _LIVE check-and-load in ``open``.
|
||||
_OPEN_LOCK = threading.Lock()
|
||||
|
||||
def __init__(self, run_id: str, *, durable: bool = True, directory: str | os.PathLike | None = None) -> None:
|
||||
if not isinstance(run_id, str) or not _RUN_ID.fullmatch(run_id):
|
||||
raise ValueError("Effect log requires a server-generated run identifier")
|
||||
self.run_id = run_id
|
||||
self.path = (Path(directory) if directory is not None else effects_dir()) / f"{run_id}.jsonl" if durable else None
|
||||
if self.path is not None:
|
||||
self._LIVE[(str(self.path.parent), run_id)] = self
|
||||
# Records known to be on disk, in file order, and the bytes they span.
|
||||
self._durable, self._durable_index = _records(), _Index()
|
||||
self._offset = 0
|
||||
# Records this process holds that are not on disk: a failed non-claim
|
||||
# write, or an observation of a run that has no durable file yet.
|
||||
self._volatile: list[tuple[str, Any]] = []
|
||||
# Durable records plus every volatile record still consistent with
|
||||
# them. A volatile record that collides with a durable one (another
|
||||
# writer took its position or settled the same effect) is hidden:
|
||||
# losing an unpersisted outcome or observation is conservative.
|
||||
self._view, self._view_index = _records(), _Index()
|
||||
self._max_sequence = 0
|
||||
self._descriptor: int | None = None
|
||||
self._directory_synced = False
|
||||
# A non-claim record failed to persist. In-memory history stays
|
||||
# truthful for this process; replay may lack the later record.
|
||||
self.degraded = False
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# -- merged view ---------------------------------------------------------
|
||||
|
||||
def _rebuild_view(self) -> None:
|
||||
self._view, self._view_index = _records(), _Index()
|
||||
durable = sorted(((kind, r) for kind, records in self._durable.items() for r in records),
|
||||
key=lambda pair: pair[1].sequence)
|
||||
for kind, record in durable:
|
||||
self._view[kind].append(record)
|
||||
self._view_index.add(kind, record)
|
||||
for kind, record in self._volatile:
|
||||
if self._view_index.accepts(kind, record):
|
||||
self._view_index.add(kind, record)
|
||||
self._view[kind].append(record)
|
||||
|
||||
def _add_durable(self, kind: str, record: Any) -> None:
|
||||
self._durable[kind].append(record)
|
||||
self._durable_index.add(kind, record)
|
||||
self._view[kind].append(record)
|
||||
self._view_index.add(kind, record)
|
||||
self._max_sequence = max(self._max_sequence, record.sequence)
|
||||
|
||||
def _add_volatile(self, kind: str, record: Any) -> None:
|
||||
self._volatile.append((kind, record))
|
||||
self._view[kind].append(record)
|
||||
self._view_index.add(kind, record)
|
||||
self._max_sequence = max(self._max_sequence, record.sequence)
|
||||
|
||||
def _merged_history(self) -> EffectHistory:
|
||||
return EffectHistory(tuple(self._view["claim"]), tuple(self._view["outcome"]),
|
||||
tuple(self._view["observation"]))
|
||||
|
||||
# -- persistence -------------------------------------------------------
|
||||
|
||||
def _read_tail(self, descriptor: int, *, repair: bool) -> None:
|
||||
"""Merge complete records other writers appended after our offset.
|
||||
|
||||
A trailing partial line is a write that never returned to its caller
|
||||
(a crash or a failed write), so no backend invocation followed it.
|
||||
With ``repair`` (exclusive lock held) it is truncated so the next
|
||||
append starts on a record boundary.
|
||||
"""
|
||||
info = os.fstat(descriptor)
|
||||
if info.st_nlink != 1 or not stat.S_ISREG(info.st_mode):
|
||||
raise OSError("Effect log is aliased")
|
||||
if info.st_size < self._offset:
|
||||
raise OSError("Effect log shrank under its writer")
|
||||
if info.st_size == self._offset:
|
||||
return
|
||||
os.lseek(descriptor, self._offset, os.SEEK_SET)
|
||||
remaining, chunks = info.st_size - self._offset, []
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, remaining)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
data = b"".join(chunks)
|
||||
complete = data[:data.rfind(b"\n") + 1]
|
||||
if repair and len(complete) != len(data):
|
||||
os.ftruncate(descriptor, self._offset + len(complete))
|
||||
os.fsync(descriptor)
|
||||
added: list[tuple[str, Any]] = []
|
||||
for line in complete.splitlines():
|
||||
try:
|
||||
kind, record = _parse(line)
|
||||
except (ValueError, TypeError, KeyError, UnicodeDecodeError) as error:
|
||||
raise OSError("Effect log tail is corrupt") from error
|
||||
if not self._durable_index.accepts(kind, record):
|
||||
raise OSError("Effect log tail is inconsistent")
|
||||
self._durable[kind].append(record)
|
||||
self._durable_index.add(kind, record)
|
||||
self._max_sequence = max(self._max_sequence, record.sequence)
|
||||
added.append((kind, record))
|
||||
self._offset += len(complete)
|
||||
if added:
|
||||
self._rebuild_view()
|
||||
|
||||
@contextmanager
|
||||
def _locked(self):
|
||||
"""Hold the file exclusively with every durable record merged."""
|
||||
assert self.path is not None
|
||||
_ensure_directory(self.path.parent)
|
||||
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
descriptor = os.open(self.path, flags, 0o600)
|
||||
try:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
self._read_tail(descriptor, repair=True)
|
||||
self._descriptor = descriptor
|
||||
yield
|
||||
finally:
|
||||
self._descriptor = None
|
||||
os.close(descriptor) # releases the lock
|
||||
|
||||
def _write(self, kind: str, record: Any) -> None:
|
||||
"""Append one record under the held lock and make it durable."""
|
||||
descriptor = self._descriptor
|
||||
assert descriptor is not None
|
||||
line = json.dumps({"v": _VERSION, "type": kind, "record": record.to_dict()},
|
||||
sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
|
||||
data = line.encode("utf-8")
|
||||
start = os.fstat(descriptor).st_size
|
||||
try:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
view = view[os.write(descriptor, view):]
|
||||
os.fsync(descriptor)
|
||||
if not self._directory_synced:
|
||||
# The file's own fsync does not make its directory entry
|
||||
# durable. Sync it before the first claim returns, still under
|
||||
# the lock, so no writer can invoke a backend against a log a
|
||||
# crash could lose.
|
||||
_fsync_directory(self.path.parent)
|
||||
self._directory_synced = True
|
||||
except OSError:
|
||||
# Unacknowledged: take the record back so the file ends on a
|
||||
# record boundary and no writer later merges it as durable.
|
||||
try:
|
||||
os.ftruncate(descriptor, start)
|
||||
os.fsync(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
self._offset = start + len(data)
|
||||
|
||||
def _append(self, kind: str, build: Callable[[int, "EffectLog"], Any], *, required: bool):
|
||||
"""Allocate, validate and persist one record; ``None`` if it no longer applies.
|
||||
|
||||
``build(sequence, view)`` may return ``None`` when its precondition no
|
||||
longer holds against the merged view (``self``).
|
||||
"""
|
||||
with self._lock:
|
||||
durable = self.path is not None and (kind != "observation" or self._view["claim"]
|
||||
or self.path.exists())
|
||||
if not durable:
|
||||
return self._append_volatile(kind, build, required=required)
|
||||
try:
|
||||
with self._locked():
|
||||
record = build(self._max_sequence + 1, self)
|
||||
if record is None:
|
||||
return None
|
||||
if not (self._view_index.accepts(kind, record) and self._durable_index.accepts(kind, record)):
|
||||
# Another writer already settled it, or it collides.
|
||||
if required:
|
||||
raise ValueError("Effect record conflicts with the durable history")
|
||||
return None
|
||||
try:
|
||||
self._write(kind, record)
|
||||
except OSError:
|
||||
if required:
|
||||
raise
|
||||
self.degraded = True
|
||||
self._add_volatile(kind, record)
|
||||
return record
|
||||
self._add_durable(kind, record)
|
||||
return record
|
||||
except (OSError, ValueError) as error:
|
||||
if required:
|
||||
raise EffectPersistenceError("Effect claim could not be persisted durably") from error
|
||||
self.degraded = True
|
||||
# The lock or tail could not be taken: keep this process
|
||||
# truthful without touching the file.
|
||||
return self._append_volatile(kind, build, required=False)
|
||||
|
||||
def _append_volatile(self, kind: str, build, *, required: bool):
|
||||
record = build(self._max_sequence + 1, self)
|
||||
if record is None:
|
||||
return None
|
||||
if not self._view_index.accepts(kind, record):
|
||||
if required:
|
||||
raise ValueError("Effect record conflicts with the history")
|
||||
return None
|
||||
self._add_volatile(kind, record)
|
||||
return record
|
||||
|
||||
# -- records -----------------------------------------------------------
|
||||
|
||||
def claim(self, **fields: Any) -> EffectClaim:
|
||||
"""Persist a claim before invocation; raises if it is not durable."""
|
||||
run_id = fields.pop("run_id", self.run_id)
|
||||
return self._append("claim", lambda seq, _h: EffectClaim(sequence=seq, run_id=run_id, **fields),
|
||||
required=True)
|
||||
|
||||
def outcome(self, **fields: Any) -> EffectOutcome | None:
|
||||
"""Append an outcome; ``None`` if the effect was already settled."""
|
||||
return self._append("outcome", lambda seq, _h: EffectOutcome(sequence=seq, **fields), required=False)
|
||||
|
||||
def observe(self, **fields: Any) -> Observation | None:
|
||||
return self._append("observation", lambda seq, _h: Observation(sequence=seq, **fields), required=False)
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Merge records other writers appended (shared lock, no repair)."""
|
||||
if self.path is None:
|
||||
return
|
||||
with self._lock:
|
||||
try:
|
||||
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_CLOEXEC", 0))
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
self.degraded = True
|
||||
return
|
||||
try:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_SH)
|
||||
self._read_tail(descriptor, repair=False)
|
||||
except OSError:
|
||||
self.degraded = True
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
def history(self) -> EffectHistory:
|
||||
self.refresh()
|
||||
with self._lock:
|
||||
return self._merged_history()
|
||||
|
||||
def assessments(self) -> tuple[EffectAssessment, ...]:
|
||||
return assess_all(self.history())
|
||||
|
||||
# -- replay ------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def load(cls, run_id: str, *, directory: str | os.PathLike | None = None) -> "EffectLog":
|
||||
"""Reload a persisted log. A malformed record fails closed.
|
||||
|
||||
A torn final line (no newline) is the only tolerated damage: it was a
|
||||
write interrupted by a crash, so its claim never returned to a caller
|
||||
and no backend invocation followed it.
|
||||
"""
|
||||
log = cls(run_id, directory=directory)
|
||||
assert log.path is not None
|
||||
try:
|
||||
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0))
|
||||
except FileNotFoundError:
|
||||
return log
|
||||
except OSError as error:
|
||||
raise EffectPersistenceError("Effect log is unreadable") from error
|
||||
try:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_SH)
|
||||
info = os.fstat(descriptor)
|
||||
if info.st_nlink != 1 or not stat.S_ISREG(info.st_mode):
|
||||
raise EffectPersistenceError("Effect log is aliased")
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
descriptor = None
|
||||
raw = stream.read()
|
||||
except OSError as error:
|
||||
raise EffectPersistenceError("Effect log is unreadable") from error
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
complete = raw[:raw.rfind(b"\n") + 1] # drop a torn final write
|
||||
for line in complete.splitlines():
|
||||
try:
|
||||
kind, record = _parse(line)
|
||||
except (ValueError, TypeError, KeyError, UnicodeDecodeError) as error:
|
||||
raise EffectPersistenceError("Effect log is corrupt") from error
|
||||
if not log._durable_index.accepts(kind, record):
|
||||
raise EffectPersistenceError("Effect log history is inconsistent")
|
||||
log._durable[kind].append(record)
|
||||
log._durable_index.add(kind, record)
|
||||
log._max_sequence = max(log._max_sequence, record.sequence)
|
||||
try:
|
||||
log._rebuild_view()
|
||||
log._merged_history()
|
||||
except ValueError as error:
|
||||
raise EffectPersistenceError("Effect log history is inconsistent") from error
|
||||
log._offset = len(complete)
|
||||
return log
|
||||
|
||||
@classmethod
|
||||
def open(cls, run_id: str, *, directory: str | os.PathLike | None = None) -> "EffectLog":
|
||||
"""The live log for a run, or its replayed durable history.
|
||||
|
||||
A log that is not live belongs to a finished or crashed run, so its
|
||||
unsettled claims are recovered as interrupted before any append.
|
||||
"""
|
||||
base = Path(directory) if directory is not None else effects_dir()
|
||||
with cls._OPEN_LOCK:
|
||||
live = cls._LIVE.get((str(base), run_id))
|
||||
if live is not None:
|
||||
return live
|
||||
log = cls.load(run_id, directory=base)
|
||||
log.recover_interrupted()
|
||||
return log
|
||||
|
||||
# -- background launch lineage ------------------------------------------
|
||||
|
||||
def index_launch(self, generation: str, effect_id: str) -> None:
|
||||
"""Durably map an exact Wave 3 launch generation to its claim."""
|
||||
if self.path is None:
|
||||
return
|
||||
if not _RUN_ID.fullmatch(generation or ""):
|
||||
raise ValueError("Malformed launch generation")
|
||||
target = self.path.parent / f"launch-{generation}.json"
|
||||
temporary = target.with_suffix(".tmp")
|
||||
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
|
||||
_ensure_directory(target.parent)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
descriptor = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
view = view[os.write(descriptor, view):]
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.replace(temporary, target)
|
||||
_fsync_directory(target.parent)
|
||||
|
||||
@staticmethod
|
||||
def launch_owner(generation: str, *, directory: str | os.PathLike | None = None) -> tuple[str, str] | None:
|
||||
if not _RUN_ID.fullmatch(generation or ""):
|
||||
return None
|
||||
base = Path(directory) if directory is not None else effects_dir()
|
||||
try:
|
||||
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
if os.fstat(stream.fileno()).st_nlink != 1:
|
||||
return None
|
||||
value = json.loads(stream.read(4096))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if (not isinstance(value, dict) or set(value) != {"run_id", "effect_id"}
|
||||
or not isinstance(value["run_id"], str) or not _RUN_ID.fullmatch(value["run_id"])
|
||||
or not isinstance(value["effect_id"], str)):
|
||||
return None
|
||||
return value["run_id"], value["effect_id"]
|
||||
|
||||
def recover_interrupted(self) -> tuple[EffectOutcome, ...]:
|
||||
"""Append INTERRUPTED outcomes for claims that never settled.
|
||||
|
||||
Each claim is rechecked against the merged history under the lock, so
|
||||
a claim another writer settled (or marked running) meanwhile is left
|
||||
alone.
|
||||
"""
|
||||
with self._lock:
|
||||
appended = []
|
||||
for pending in replay_interrupted(self.history(), self._max_sequence + 1):
|
||||
def build(seq: int, log: "EffectLog", o: EffectOutcome = pending) -> EffectOutcome | None:
|
||||
if o.effect_id in log._view_index.last or o.effect_id in log._durable_index.last:
|
||||
return None
|
||||
return EffectOutcome(o.effect_id, seq, o.execution, o.impact, replayed=True)
|
||||
record = self._append("outcome", build, required=False)
|
||||
if record is not None:
|
||||
appended.append(record)
|
||||
return tuple(appended)
|
||||
@@ -0,0 +1,823 @@
|
||||
"""Wave 4 effect claims, outcomes, observations and verification.
|
||||
|
||||
This module consumes exact Wave 3 resource identities. It never resolves a
|
||||
selector, discovers an alias, grants an operation or performs I/O. A
|
||||
``ResourceRef`` can only be built from an already-admitted typed Wave 3 resource
|
||||
object; names, paths, PIDs, URLs, labels and dictionaries are not accepted.
|
||||
|
||||
Facts are kept separate:
|
||||
|
||||
* a claim records intent and scope before backend invocation, not dispatch;
|
||||
* an outcome records what the executor reported, not the resulting state;
|
||||
* an observation records state seen through an admitted mechanism;
|
||||
* verification is derived from fresh, relevant, complete observations made
|
||||
after the effect settled, and never from receipts or acknowledgements.
|
||||
|
||||
History is append-only. Invalidation and freshness are computed from the
|
||||
ordered record history; earlier records are never rewritten. Refresh is a new
|
||||
observation. Unknown scope is conservative, never "no impact".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import PurePosixPath
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
|
||||
def _sha(value: Any) -> str:
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False, default=str).encode()).hexdigest()
|
||||
|
||||
|
||||
def _text(value: Any, label: str, *, optional: bool = False) -> None:
|
||||
if (not isinstance(value, str) or (not value and not optional)
|
||||
or any(c in value for c in ("\0", "\n", "\r"))):
|
||||
raise ValueError(f"Invalid effect {label}")
|
||||
|
||||
|
||||
def _position(value: Any) -> None:
|
||||
if type(value) is not int or value < 0:
|
||||
raise ValueError("Effect history position must be a nonnegative integer")
|
||||
|
||||
|
||||
_SHA256 = re.compile(r"[a-f0-9]{64}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exact resource references (Wave 3 consumption only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ResourceKind(str, Enum):
|
||||
FILESYSTEM = "filesystem"
|
||||
PROCESS = "process"
|
||||
PROCESS_LAUNCH = "process_launch"
|
||||
BACKGROUND_JOB = "background_job"
|
||||
OWNED = "owned"
|
||||
EXTERNAL = "external"
|
||||
BROWSER_SESSION = "browser_session"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceRef:
|
||||
"""Historical reference to one exact admitted Wave 3 resource.
|
||||
|
||||
``location`` identifies where the resource lives (including the identity of
|
||||
its sealed root/namespace); ``incarnation`` identifies the object observed
|
||||
there when the reference was taken. Replacement keeps the location and
|
||||
changes the incarnation, so evidence never transfers to a replacement.
|
||||
``snapshot_sha256`` digests the full Wave 3 snapshot for audit. A ref is not
|
||||
authority: it is not accepted by any dispatcher, resolver or grant.
|
||||
"""
|
||||
|
||||
kind: ResourceKind
|
||||
role: str
|
||||
location: tuple[str, ...]
|
||||
incarnation: str
|
||||
snapshot_sha256: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.kind, ResourceKind):
|
||||
raise ValueError("Unsupported effect resource kind")
|
||||
_text(self.role, "resource role")
|
||||
_text(self.incarnation, "resource incarnation", optional=True)
|
||||
if (not isinstance(self.location, tuple) or len(self.location) < 2
|
||||
or any(not isinstance(part, str) or any(c in part for c in ("\0", "\n", "\r"))
|
||||
for part in self.location)
|
||||
or self.location[0] != self.kind.value):
|
||||
raise ValueError("Malformed effect resource location")
|
||||
if not _SHA256.fullmatch(self.snapshot_sha256 or ""):
|
||||
raise ValueError("Malformed effect resource snapshot digest")
|
||||
|
||||
@property
|
||||
def location_key(self) -> str:
|
||||
return _sha(list(self.location))
|
||||
|
||||
def same_location(self, other: "ResourceRef") -> bool:
|
||||
return self.kind is other.kind and self.location == other.location
|
||||
|
||||
def overlaps(self, other: "ResourceRef") -> bool:
|
||||
"""Conservative relevance between two exact references.
|
||||
|
||||
Filesystem relevance is ancestor-or-self within one sealed root
|
||||
identity: a mutation of ``d/x`` invalidates a listing of ``d`` and a
|
||||
replacement of ``d`` invalidates observations of ``d/x``. Other kinds
|
||||
only overlap at the same exact location. No alias discovery is done.
|
||||
"""
|
||||
if self.kind is not other.kind:
|
||||
return False
|
||||
if self.kind is ResourceKind.OWNED:
|
||||
# A collection binding ("*") covers every record it can create,
|
||||
# list or change; specific records only overlap themselves.
|
||||
return self.location[:-1] == other.location[:-1] and (
|
||||
self.location[-1] == other.location[-1] or "*" in (self.location[-1], other.location[-1]))
|
||||
if self.kind is not ResourceKind.FILESYSTEM:
|
||||
return self.location == other.location
|
||||
if self.location[:-1] != other.location[:-1]:
|
||||
return False
|
||||
left, right = PurePosixPath(self.location[-1]), PurePosixPath(other.location[-1])
|
||||
return left == right or left.is_relative_to(right) or right.is_relative_to(left)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"kind": self.kind.value, "role": self.role, "location": list(self.location),
|
||||
"incarnation": self.incarnation, "snapshot_sha256": self.snapshot_sha256}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Any) -> "ResourceRef":
|
||||
"""Reload a persisted historical reference. This creates no authority."""
|
||||
if (not isinstance(value, dict)
|
||||
or set(value) != {"kind", "role", "location", "incarnation", "snapshot_sha256"}
|
||||
or not isinstance(value["location"], list)):
|
||||
raise ValueError("Malformed persisted effect resource reference")
|
||||
return cls(ResourceKind(value["kind"]), value["role"], tuple(value["location"]),
|
||||
value["incarnation"], value["snapshot_sha256"])
|
||||
|
||||
|
||||
def resource_ref(resource: Any, role: str) -> ResourceRef:
|
||||
"""Reference an exact typed Wave 3 resource; anything else is refused.
|
||||
|
||||
Browser page/document resources are refused: Wave 3 fails closed for page
|
||||
authority and Wave 4 must not promote page observations into identity.
|
||||
"""
|
||||
from src.agent_runtime import resources as wave3
|
||||
if isinstance(resource, wave3.BrowserPageResource):
|
||||
raise TypeError("Browser page resources are not effect-bindable")
|
||||
if isinstance(resource, wave3.FilesystemResource):
|
||||
root = resource.root
|
||||
location = ("filesystem", root.scope.value, root.owner, root.path,
|
||||
str(root.identity.device), str(root.identity.inode), resource.path)
|
||||
chain = [[a.path, a.identity.device, a.identity.inode] for a in resource.ancestors]
|
||||
identity = resource.identity
|
||||
incarnation = ("absent:" + _sha(chain) if identity is None else
|
||||
f"{identity.kind}:{identity.device}:{identity.inode}:" + _sha(chain))
|
||||
return ResourceRef(ResourceKind.FILESYSTEM, role, location, incarnation, _sha(resource.to_dict()))
|
||||
if isinstance(resource, wave3.ProcessResource):
|
||||
ident = resource.identity
|
||||
location = ("process", resource.namespace, resource.owner, resource.request_id, resource.thread_id,
|
||||
str(ident.pid), ident.start_token, resource.role)
|
||||
return ResourceRef(ResourceKind.PROCESS, role, location, ident.start_token, _sha(resource.to_dict()))
|
||||
if isinstance(resource, wave3.ProcessLaunchResource):
|
||||
# The reservation generation is the exact launch -> job linkage that
|
||||
# Wave 3 validates in ``job_from_record``.
|
||||
location = ("process_launch", resource.namespace, resource.owner, resource.request_id,
|
||||
resource.thread_id, resource.generation)
|
||||
return ResourceRef(ResourceKind.PROCESS_LAUNCH, role, location, resource.generation,
|
||||
_sha(resource.to_dict()))
|
||||
if isinstance(resource, wave3.BackgroundJobResource):
|
||||
location = ("background_job", resource.namespace, resource.owner, resource.request_id,
|
||||
resource.thread_id, resource.job_id, resource.generation)
|
||||
return ResourceRef(ResourceKind.BACKGROUND_JOB, role, location, resource.generation,
|
||||
_sha(resource.to_dict()))
|
||||
if isinstance(resource, wave3.OwnedResource):
|
||||
location = ("owned", resource.namespace, resource.owner, resource.thread_id,
|
||||
resource.collection, resource.record_id)
|
||||
return ResourceRef(ResourceKind.OWNED, role, location, resource.revision, _sha(resource.to_dict()))
|
||||
if isinstance(resource, wave3.ExternalResource):
|
||||
location = ("external", resource.namespace, resource.owner, resource.endpoint_id,
|
||||
resource.server_id, resource.tool_id)
|
||||
return ResourceRef(ResourceKind.EXTERNAL, role, location, resource.incarnation, _sha(resource.to_dict()))
|
||||
if isinstance(resource, wave3.BrowserSessionResource):
|
||||
observation = resource.observation
|
||||
location = ("browser_session", resource.owner, resource.thread_id, observation.session_key)
|
||||
return ResourceRef(ResourceKind.BROWSER_SESSION, role, location, observation.session_incarnation,
|
||||
_sha(resource.to_dict()))
|
||||
raise TypeError("Effect scope requires an exact Wave 3 resource identity")
|
||||
|
||||
|
||||
def bound_filesystem_refs(bound: Any) -> tuple[ResourceRef, ...]:
|
||||
"""References for an admitted ``BoundFilesystemOperation``'s exact bindings."""
|
||||
from src.agent_runtime.resource_binding import BoundFilesystemOperation
|
||||
if not isinstance(bound, BoundFilesystemOperation):
|
||||
raise TypeError("Filesystem effect scope requires a server-owned bound operation")
|
||||
return tuple(resource_ref(binding.resource, binding.role) for binding in bound.bindings)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claims
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationRef:
|
||||
"""Final normalized operation reference; not a second normalization API."""
|
||||
|
||||
tool: str
|
||||
action: str
|
||||
input_sha256: str
|
||||
request_id: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.tool, "operation tool")
|
||||
_text(self.action, "operation action", optional=True)
|
||||
_text(self.request_id, "operation request", optional=True)
|
||||
if not _SHA256.fullmatch(self.input_sha256 or ""):
|
||||
raise ValueError("Malformed operation input digest")
|
||||
|
||||
@classmethod
|
||||
def from_exact(cls, operation: Any, execution_input: str | None = None, request_id: str = "") -> "OperationRef":
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
if not isinstance(operation, ExactOperation):
|
||||
raise TypeError("Effect claims require the admitted exact operation")
|
||||
body = operation.input if execution_input is None else execution_input
|
||||
return cls(str(operation.tool), str(operation.action or ""), _sha(body), request_id or "")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"tool": self.tool, "action": self.action, "input_sha256": self.input_sha256,
|
||||
"request_id": self.request_id}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Any) -> "OperationRef":
|
||||
if not isinstance(value, dict) or set(value) != {"tool", "action", "input_sha256", "request_id"}:
|
||||
raise ValueError("Malformed persisted operation reference")
|
||||
return cls(**value)
|
||||
|
||||
|
||||
class Predicate(str, Enum):
|
||||
EXISTS = "exists"
|
||||
ABSENT = "absent"
|
||||
CONTENT_SHA256 = "content_sha256"
|
||||
# The observed content digest differs from ``expected`` (the pre-state).
|
||||
CONTENT_CHANGED = "content_changed"
|
||||
|
||||
|
||||
_PREDICATE_KINDS = {
|
||||
Predicate.EXISTS: {ResourceKind.FILESYSTEM, ResourceKind.OWNED, ResourceKind.EXTERNAL},
|
||||
Predicate.ABSENT: {ResourceKind.FILESYSTEM, ResourceKind.OWNED, ResourceKind.EXTERNAL},
|
||||
Predicate.CONTENT_SHA256: {ResourceKind.FILESYSTEM, ResourceKind.OWNED, ResourceKind.EXTERNAL},
|
||||
Predicate.CONTENT_CHANGED: {ResourceKind.FILESYSTEM, ResourceKind.OWNED, ResourceKind.EXTERNAL},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Postcondition:
|
||||
"""An explicit requested post-state predicate on one exact claimed target."""
|
||||
|
||||
target: ResourceRef
|
||||
predicate: Predicate
|
||||
expected: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.target, ResourceRef) or not isinstance(self.predicate, Predicate):
|
||||
raise ValueError("Malformed postcondition")
|
||||
if self.target.kind not in _PREDICATE_KINDS[self.predicate]:
|
||||
raise ValueError("Predicate is not supported for this resource kind")
|
||||
needs_digest = self.predicate in {Predicate.CONTENT_SHA256, Predicate.CONTENT_CHANGED}
|
||||
if needs_digest != bool(_SHA256.fullmatch(self.expected or "")) or (not needs_digest and self.expected):
|
||||
raise ValueError("Malformed postcondition expectation")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"target": self.target.to_dict(), "predicate": self.predicate.value, "expected": self.expected}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Any) -> "Postcondition":
|
||||
if not isinstance(value, dict) or set(value) != {"target", "predicate", "expected"}:
|
||||
raise ValueError("Malformed persisted postcondition")
|
||||
return cls(ResourceRef.from_dict(value["target"]), Predicate(value["predicate"]), value["expected"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectClaim:
|
||||
"""Server-owned claim, persisted before backend invocation.
|
||||
|
||||
The claim states intent and scope; it is not evidence that dispatch, the
|
||||
backend operation, or any mutation happened. ``impact_scope`` holds the
|
||||
exact admitted bindings the operation may change; empty means unknown
|
||||
scope, never no impact. ``dependencies`` are resources the predicate
|
||||
relies on without being mutation targets.
|
||||
"""
|
||||
|
||||
effect_id: str
|
||||
run_id: str
|
||||
action_id: str
|
||||
sequence: int
|
||||
operation: OperationRef
|
||||
impact_scope: tuple[ResourceRef, ...] = ()
|
||||
dependencies: tuple[ResourceRef, ...] = ()
|
||||
obligations: tuple[Postcondition, ...] = ()
|
||||
parent_run_id: str = ""
|
||||
external: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("effect_id", "run_id", "action_id"):
|
||||
_text(getattr(self, name), name)
|
||||
_text(self.parent_run_id, "parent run", optional=True)
|
||||
_position(self.sequence)
|
||||
if not isinstance(self.operation, OperationRef) or type(self.external) is not bool:
|
||||
raise ValueError("Malformed effect claim")
|
||||
for name in ("impact_scope", "dependencies"):
|
||||
refs = getattr(self, name)
|
||||
if not isinstance(refs, tuple) or any(not isinstance(r, ResourceRef) for r in refs):
|
||||
raise ValueError("Effect scope must be exact resource references")
|
||||
if (not isinstance(self.obligations, tuple)
|
||||
or any(not isinstance(o, Postcondition) for o in self.obligations)):
|
||||
raise ValueError("Malformed effect obligations")
|
||||
for obligation in self.obligations:
|
||||
if not any(obligation.target == ref for ref in self.impact_scope):
|
||||
raise ValueError("Postcondition target must be a claimed impact binding")
|
||||
|
||||
@property
|
||||
def unknown_scope(self) -> bool:
|
||||
return not self.impact_scope
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"effect_id": self.effect_id, "run_id": self.run_id, "action_id": self.action_id,
|
||||
"sequence": self.sequence, "operation": self.operation.to_dict(),
|
||||
"impact_scope": [r.to_dict() for r in self.impact_scope],
|
||||
"dependencies": [r.to_dict() for r in self.dependencies],
|
||||
"obligations": [o.to_dict() for o in self.obligations],
|
||||
"parent_run_id": self.parent_run_id, "external": self.external}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Any) -> "EffectClaim":
|
||||
keys = {"effect_id", "run_id", "action_id", "sequence", "operation", "impact_scope",
|
||||
"dependencies", "obligations", "parent_run_id", "external"}
|
||||
if not isinstance(value, dict) or set(value) != keys or any(
|
||||
not isinstance(value[k], list) for k in ("impact_scope", "dependencies", "obligations")):
|
||||
raise ValueError("Malformed persisted effect claim")
|
||||
return cls(value["effect_id"], value["run_id"], value["action_id"], value["sequence"],
|
||||
OperationRef.from_dict(value["operation"]),
|
||||
tuple(ResourceRef.from_dict(r) for r in value["impact_scope"]),
|
||||
tuple(ResourceRef.from_dict(r) for r in value["dependencies"]),
|
||||
tuple(Postcondition.from_dict(o) for o in value["obligations"]),
|
||||
value["parent_run_id"], value["external"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outcomes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ExecutionOutcome(str, Enum):
|
||||
NOT_EXECUTED = "not_executed" # refused before backend invocation
|
||||
ATTEMPTED = "attempted" # claimed; no settled outcome yet
|
||||
REPORTED_SUCCESS = "reported_success" # executor reported success; not post-state
|
||||
FAILED = "failed"
|
||||
TIMED_OUT = "timed_out"
|
||||
CANCELLED = "cancelled"
|
||||
RUNNING = "running" # admitted/background; not completed work
|
||||
INTERRUPTED = "interrupted" # unknown: lost, crashed or replayed
|
||||
|
||||
|
||||
class Impact(str, Enum):
|
||||
NONE = "none" # known no-op: the backend was never invoked
|
||||
POSSIBLE = "possible" # may have changed state, including partially
|
||||
CHANGED = "changed" # a trusted before/after capture differs
|
||||
|
||||
|
||||
class CleanupState(str, Enum):
|
||||
NOT_APPLICABLE = "not_applicable"
|
||||
VERIFIED = "verified"
|
||||
FAILED = "failed"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
_SETTLED = {ExecutionOutcome.NOT_EXECUTED, ExecutionOutcome.REPORTED_SUCCESS, ExecutionOutcome.FAILED,
|
||||
ExecutionOutcome.TIMED_OUT, ExecutionOutcome.CANCELLED, ExecutionOutcome.INTERRUPTED}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProducerFacts:
|
||||
"""Bounded typed producer facts; arbitrary returned data is never kept.
|
||||
|
||||
These are execution/lifecycle facts reported by a server producer. None of
|
||||
them is a post-state observation.
|
||||
"""
|
||||
|
||||
exit_code: int | None = None
|
||||
timed_out: bool = False
|
||||
output_truncated: bool = False
|
||||
failure_kind: str = ""
|
||||
job_state: str = ""
|
||||
remote_acknowledged: bool = False
|
||||
external: bool = False
|
||||
# The producer reached its mutation stage before reporting failure.
|
||||
mutation_attempted: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.exit_code is not None and type(self.exit_code) is not int:
|
||||
raise ValueError("Malformed producer exit code")
|
||||
for name in ("timed_out", "output_truncated", "remote_acknowledged", "external", "mutation_attempted"):
|
||||
if type(getattr(self, name)) is not bool:
|
||||
raise ValueError("Malformed producer flag")
|
||||
for name in ("failure_kind", "job_state"):
|
||||
value = getattr(self, name)
|
||||
_text(value, name, optional=True)
|
||||
if len(value) > 64 or (value and not re.fullmatch(r"[a-z0-9_.:-]+", value)):
|
||||
raise ValueError("Malformed producer label")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"exit_code": self.exit_code, "timed_out": self.timed_out,
|
||||
"output_truncated": self.output_truncated, "failure_kind": self.failure_kind,
|
||||
"job_state": self.job_state, "remote_acknowledged": self.remote_acknowledged,
|
||||
"external": self.external, "mutation_attempted": self.mutation_attempted}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Any) -> "ProducerFacts":
|
||||
if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__):
|
||||
raise ValueError("Malformed persisted producer facts")
|
||||
return cls(**value)
|
||||
|
||||
|
||||
def _label(value: Any) -> str:
|
||||
text = value.strip().lower() if isinstance(value, str) else ""
|
||||
return text if len(text) <= 64 and re.fullmatch(r"[a-z0-9_.:-]+", text) else ""
|
||||
|
||||
|
||||
def producer_facts(result: Any) -> ProducerFacts:
|
||||
"""Project a dispatcher result into typed facts without trusting its shape.
|
||||
|
||||
Only exact scalar types are copied. Anything else becomes the default, so a
|
||||
forged or malformed dictionary can only lose information, not add trust.
|
||||
"""
|
||||
if not isinstance(result, Mapping):
|
||||
return ProducerFacts()
|
||||
code = result.get("exit_code")
|
||||
containment = result.get("containment")
|
||||
external = isinstance(containment, Mapping) and containment.get("external") is True
|
||||
job = result.get("status") if isinstance(result.get("job_id"), str) else ""
|
||||
return ProducerFacts(
|
||||
exit_code=code if type(code) is int else None,
|
||||
timed_out=result.get("timed_out") is True or _label(result.get("failure_kind")) == "timeout",
|
||||
output_truncated=result.get("output_truncated") is True or result.get("truncated") is True,
|
||||
failure_kind=_label(result.get("failure_kind")),
|
||||
job_state=_label(job),
|
||||
external=external,
|
||||
mutation_attempted=result.get("mutation_attempted") is True,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectOutcome:
|
||||
"""Append-only execution outcome for one claim.
|
||||
|
||||
``impact`` must not claim no change for anything that reached a backend.
|
||||
``cleanup`` is recorded separately: cleanup success is not business-effect
|
||||
success and cleanup failure does not erase an achieved effect.
|
||||
"""
|
||||
|
||||
effect_id: str
|
||||
sequence: int
|
||||
execution: ExecutionOutcome
|
||||
impact: Impact
|
||||
facts: ProducerFacts = ProducerFacts()
|
||||
cleanup: CleanupState = CleanupState.NOT_APPLICABLE
|
||||
execution_id: str = ""
|
||||
replayed: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.effect_id, "effect identifier")
|
||||
_text(self.execution_id, "execution identifier", optional=True)
|
||||
_position(self.sequence)
|
||||
if (not isinstance(self.execution, ExecutionOutcome) or not isinstance(self.impact, Impact)
|
||||
or not isinstance(self.facts, ProducerFacts) or not isinstance(self.cleanup, CleanupState)
|
||||
or type(self.replayed) is not bool):
|
||||
raise ValueError("Malformed effect outcome")
|
||||
if self.execution is ExecutionOutcome.ATTEMPTED:
|
||||
raise ValueError("ATTEMPTED is derived from a claim without an outcome")
|
||||
if (self.impact is Impact.NONE) != (self.execution is ExecutionOutcome.NOT_EXECUTED):
|
||||
raise ValueError("Only a refused, never-invoked operation is a known no-op")
|
||||
if self.execution is ExecutionOutcome.NOT_EXECUTED and self.execution_id:
|
||||
raise ValueError("A refused operation has no execution identity")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"effect_id": self.effect_id, "sequence": self.sequence, "execution": self.execution.value,
|
||||
"impact": self.impact.value, "facts": self.facts.to_dict(), "cleanup": self.cleanup.value,
|
||||
"execution_id": self.execution_id, "replayed": self.replayed}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Any) -> "EffectOutcome":
|
||||
if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__):
|
||||
raise ValueError("Malformed persisted effect outcome")
|
||||
return cls(value["effect_id"], value["sequence"], ExecutionOutcome(value["execution"]),
|
||||
Impact(value["impact"]), ProducerFacts.from_dict(value["facts"]),
|
||||
CleanupState(value["cleanup"]), value["execution_id"], value["replayed"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ObservationMechanism(str, Enum):
|
||||
FILESYSTEM_READ = "filesystem_read" # admitted read of the exact binding
|
||||
OWNED_RECORD_READ = "owned_record_read" # admitted owner-scoped readback
|
||||
REMOTE_READBACK = "remote_readback" # admitted independent remote query
|
||||
PROCESS_OWNERSHIP = "process_ownership" # lifecycle owner's verdict
|
||||
JOB_STATE = "job_state" # background job record transition
|
||||
BROWSER_SESSION = "browser_session" # session lifecycle metadata only
|
||||
# The following are never post-state verification.
|
||||
EXECUTION_RECEIPT = "execution_receipt"
|
||||
REMOTE_ACKNOWLEDGEMENT = "remote_acknowledgement"
|
||||
|
||||
|
||||
class Coverage(str, Enum):
|
||||
COMPLETE = "complete"
|
||||
PARTIAL = "partial"
|
||||
|
||||
|
||||
# Mechanisms able to decide a postcondition for each resource kind. Process,
|
||||
# job and browser-session observations are lifecycle facts: they can make
|
||||
# earlier evidence stale but cannot verify a file/record/remote predicate.
|
||||
_VERIFYING = {
|
||||
ResourceKind.FILESYSTEM: {ObservationMechanism.FILESYSTEM_READ},
|
||||
ResourceKind.OWNED: {ObservationMechanism.OWNED_RECORD_READ},
|
||||
ResourceKind.EXTERNAL: {ObservationMechanism.REMOTE_READBACK},
|
||||
}
|
||||
_ADMITTED_READS = {ObservationMechanism.FILESYSTEM_READ, ObservationMechanism.OWNED_RECORD_READ,
|
||||
ObservationMechanism.REMOTE_READBACK}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Observation:
|
||||
"""State seen through one mechanism for one exact resource.
|
||||
|
||||
``exists``/``content_sha256`` are what the mechanism saw; ``None``/empty
|
||||
means not observed. A PARTIAL observation (offset/limit/truncated read,
|
||||
listing, existence-only probe) never decides a whole-content predicate.
|
||||
Admitted reads must name the journal action that performed them.
|
||||
"""
|
||||
|
||||
observation_id: str
|
||||
sequence: int
|
||||
resource: ResourceRef
|
||||
mechanism: ObservationMechanism
|
||||
coverage: Coverage
|
||||
source_action_id: str = ""
|
||||
source_execution_id: str = ""
|
||||
exists: bool | None = None
|
||||
content_sha256: str = ""
|
||||
evidence_event_id: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.observation_id, "observation identifier")
|
||||
for name in ("source_action_id", "source_execution_id", "evidence_event_id"):
|
||||
_text(getattr(self, name), name, optional=True)
|
||||
_position(self.sequence)
|
||||
if (not isinstance(self.resource, ResourceRef) or not isinstance(self.mechanism, ObservationMechanism)
|
||||
or not isinstance(self.coverage, Coverage)
|
||||
or (self.exists is not None and type(self.exists) is not bool)):
|
||||
raise ValueError("Malformed observation")
|
||||
if self.content_sha256 and (not _SHA256.fullmatch(self.content_sha256) or self.exists is not True):
|
||||
raise ValueError("Malformed observed content digest")
|
||||
if self.mechanism in _ADMITTED_READS and not self.source_action_id:
|
||||
raise ValueError("Readback observations require the admitted action that performed them")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"observation_id": self.observation_id, "sequence": self.sequence,
|
||||
"resource": self.resource.to_dict(), "mechanism": self.mechanism.value,
|
||||
"coverage": self.coverage.value, "source_action_id": self.source_action_id,
|
||||
"source_execution_id": self.source_execution_id, "exists": self.exists,
|
||||
"content_sha256": self.content_sha256, "evidence_event_id": self.evidence_event_id}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Any) -> "Observation":
|
||||
if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__):
|
||||
raise ValueError("Malformed persisted observation")
|
||||
return cls(**{**value, "resource": ResourceRef.from_dict(value["resource"]),
|
||||
"mechanism": ObservationMechanism(value["mechanism"]),
|
||||
"coverage": Coverage(value["coverage"])})
|
||||
|
||||
|
||||
def predicate_holds(postcondition: Postcondition, observation: Observation) -> bool | None:
|
||||
"""Decide one predicate from one observation; ``None`` means undecidable.
|
||||
|
||||
The check is performed here from the observed state, so no adapter can
|
||||
attest verification by labelling an unrelated read.
|
||||
"""
|
||||
target = postcondition.target
|
||||
if (not observation.resource.same_location(target)
|
||||
or observation.mechanism not in _VERIFYING.get(target.kind, set())):
|
||||
return None
|
||||
predicate = postcondition.predicate
|
||||
if predicate is Predicate.ABSENT:
|
||||
return None if observation.exists is None else not observation.exists
|
||||
if predicate is Predicate.EXISTS:
|
||||
return observation.exists
|
||||
if observation.exists is False:
|
||||
return False
|
||||
if observation.coverage is not Coverage.COMPLETE or not observation.content_sha256:
|
||||
return None
|
||||
if predicate is Predicate.CONTENT_SHA256:
|
||||
return observation.content_sha256 == postcondition.expected
|
||||
return observation.content_sha256 != postcondition.expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# History, invalidation and freshness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Freshness(str, Enum):
|
||||
FRESH = "fresh"
|
||||
STALE = "stale" # a later possible mutation or replacement overlaps
|
||||
UNSETTLED = "unsettled" # an overlapping effect was still in flight
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectHistory:
|
||||
"""An immutable, totally ordered view of one effect log.
|
||||
|
||||
Sequences are unique positions in one log. Duplicate positions are rejected
|
||||
rather than ordered arbitrarily.
|
||||
"""
|
||||
|
||||
claims: tuple[EffectClaim, ...] = ()
|
||||
outcomes: tuple[EffectOutcome, ...] = ()
|
||||
observations: tuple[Observation, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
positions = [r.sequence for r in (*self.claims, *self.outcomes, *self.observations)]
|
||||
if len(positions) != len(set(positions)):
|
||||
raise ValueError("Effect history positions must be unique")
|
||||
ids = [c.effect_id for c in self.claims]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("Effect claims must have unique identifiers")
|
||||
claim_at = {c.effect_id: c.sequence for c in self.claims}
|
||||
settled: set[str] = set()
|
||||
for outcome in sorted(self.outcomes, key=lambda o: o.sequence):
|
||||
if outcome.effect_id not in claim_at or outcome.sequence <= claim_at[outcome.effect_id]:
|
||||
raise ValueError("Outcome must follow its claim in one history")
|
||||
# A RUNNING effect may later settle (background continuation or
|
||||
# replay interruption); a settled outcome is never replaced.
|
||||
if outcome.effect_id in settled:
|
||||
raise ValueError("A settled effect outcome cannot be replaced")
|
||||
if outcome.execution is not ExecutionOutcome.RUNNING:
|
||||
settled.add(outcome.effect_id)
|
||||
# Derived indexes (not fields): outcomes per effect in sequence order.
|
||||
by_effect: dict[str, list[EffectOutcome]] = {}
|
||||
for outcome in sorted(self.outcomes, key=lambda o: o.sequence):
|
||||
by_effect.setdefault(outcome.effect_id, []).append(outcome)
|
||||
object.__setattr__(self, "_outcomes_by_effect", by_effect)
|
||||
object.__setattr__(self, "_claims_by_id", {c.effect_id: c for c in self.claims})
|
||||
|
||||
def claim(self, effect_id: str) -> EffectClaim | None:
|
||||
return self._claims_by_id.get(effect_id)
|
||||
|
||||
def latest_outcome(self, effect_id: str, before: int | None = None) -> EffectOutcome | None:
|
||||
for outcome in reversed(self._outcomes_by_effect.get(effect_id, ())):
|
||||
if before is None or outcome.sequence < before:
|
||||
return outcome
|
||||
return None
|
||||
|
||||
def execution(self, effect_id: str, before: int | None = None) -> ExecutionOutcome:
|
||||
outcome = self.latest_outcome(effect_id, before)
|
||||
return ExecutionOutcome.ATTEMPTED if outcome is None else outcome.execution
|
||||
|
||||
|
||||
def _claim_touches(claim: EffectClaim, resource: ResourceRef) -> bool:
|
||||
return claim.unknown_scope or any(ref.overlaps(resource) for ref in claim.impact_scope)
|
||||
|
||||
|
||||
def invalidated_by(observation: Observation, history: EffectHistory) -> tuple[str, ...]:
|
||||
"""Identifiers of later records that make ``observation`` stale.
|
||||
|
||||
Any later claim that may touch the resource invalidates it once the claim
|
||||
exists (it may already be executing), unless it settled as a known no-op.
|
||||
A later observation of the same location with a different incarnation
|
||||
reveals replacement. Execution receipts are never invalidated: they remain
|
||||
historical execution facts.
|
||||
"""
|
||||
if observation.mechanism in {ObservationMechanism.EXECUTION_RECEIPT,
|
||||
ObservationMechanism.REMOTE_ACKNOWLEDGEMENT}:
|
||||
return ()
|
||||
reasons: list[str] = []
|
||||
for claim in history.claims:
|
||||
if claim.sequence <= observation.sequence or not _claim_touches(claim, observation.resource):
|
||||
continue
|
||||
outcome = history.latest_outcome(claim.effect_id)
|
||||
if outcome is not None and outcome.impact is Impact.NONE:
|
||||
continue
|
||||
reasons.append(claim.effect_id)
|
||||
for later in history.observations:
|
||||
if (later.sequence > observation.sequence and later.resource.same_location(observation.resource)
|
||||
and later.resource.incarnation != observation.resource.incarnation):
|
||||
reasons.append(later.observation_id)
|
||||
return tuple(dict.fromkeys(reasons))
|
||||
|
||||
|
||||
def freshness(observation: Observation, history: EffectHistory) -> Freshness:
|
||||
if invalidated_by(observation, history):
|
||||
return Freshness.STALE
|
||||
for claim in history.claims:
|
||||
if claim.sequence < observation.sequence and _claim_touches(claim, observation.resource):
|
||||
state = history.execution(claim.effect_id, before=observation.sequence)
|
||||
if state in {ExecutionOutcome.ATTEMPTED, ExecutionOutcome.RUNNING}:
|
||||
return Freshness.UNSETTLED
|
||||
return Freshness.FRESH
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class EffectVerdict(str, Enum):
|
||||
NOT_EXECUTED = "not_executed"
|
||||
PENDING = "pending" # attempted/running; not settled
|
||||
VERIFIED = "verified" # reported success + fresh matching post-state
|
||||
STATE_OBSERVED = "state_observed" # matching post-state; causality unknown
|
||||
UNVERIFIED = "unverified" # no adequate fresh evidence
|
||||
CONTRADICTED = "contradicted" # latest fresh check shows the predicate false
|
||||
FAILED = "failed" # execution failed; never effect success
|
||||
|
||||
|
||||
_VERDICT_RANK = {EffectVerdict.FAILED: 0, EffectVerdict.CONTRADICTED: 1, EffectVerdict.PENDING: 2,
|
||||
EffectVerdict.UNVERIFIED: 3, EffectVerdict.NOT_EXECUTED: 4,
|
||||
EffectVerdict.STATE_OBSERVED: 5, EffectVerdict.VERIFIED: 6}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectAssessment:
|
||||
effect_id: str
|
||||
action_id: str
|
||||
execution: ExecutionOutcome
|
||||
impact: Impact | None
|
||||
verdict: EffectVerdict
|
||||
reason: str
|
||||
cleanup: CleanupState = CleanupState.NOT_APPLICABLE
|
||||
observation_ids: tuple[str, ...] = ()
|
||||
targets: tuple[ResourceRef, ...] = ()
|
||||
|
||||
@property
|
||||
def unresolved_impact(self) -> bool:
|
||||
"""Resources may have changed in a way no fresh evidence has settled."""
|
||||
return (self.impact is not Impact.NONE
|
||||
and self.execution is not ExecutionOutcome.REPORTED_SUCCESS
|
||||
and self.verdict not in {EffectVerdict.STATE_OBSERVED, EffectVerdict.CONTRADICTED})
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"effect_id": self.effect_id, "action_id": self.action_id, "execution": self.execution.value,
|
||||
"impact": None if self.impact is None else self.impact.value, "verdict": self.verdict.value,
|
||||
"reason": self.reason, "cleanup": self.cleanup.value,
|
||||
"observation_ids": list(self.observation_ids),
|
||||
"targets": [t.to_dict() for t in self.targets]}
|
||||
|
||||
|
||||
def _assess_obligation(claim: EffectClaim, settled: EffectOutcome, obligation: Postcondition,
|
||||
history: EffectHistory) -> tuple[EffectVerdict, str, str]:
|
||||
candidates = [o for o in history.observations
|
||||
if o.sequence > settled.sequence and o.resource.same_location(obligation.target)
|
||||
and o.mechanism in _VERIFYING.get(obligation.target.kind, set())]
|
||||
if not candidates:
|
||||
return EffectVerdict.UNVERIFIED, "no authorized post-settlement observation of the target", ""
|
||||
# The newest check wins. A newer partial or failed check never falls back
|
||||
# to an earlier complete one.
|
||||
latest = max(candidates, key=lambda o: o.sequence)
|
||||
state = freshness(latest, history)
|
||||
if state is not Freshness.FRESH:
|
||||
return EffectVerdict.UNVERIFIED, f"the latest target observation is {state.value}", latest.observation_id
|
||||
holds = predicate_holds(obligation, latest)
|
||||
if holds is None:
|
||||
return EffectVerdict.UNVERIFIED, "the latest observation does not decide the postcondition", latest.observation_id
|
||||
if not holds:
|
||||
return EffectVerdict.CONTRADICTED, "the latest fresh observation contradicts the postcondition", latest.observation_id
|
||||
execution = settled.execution
|
||||
if execution is ExecutionOutcome.FAILED:
|
||||
return EffectVerdict.FAILED, "execution failed; matching state is not attributed to it", latest.observation_id
|
||||
if execution is ExecutionOutcome.REPORTED_SUCCESS:
|
||||
return EffectVerdict.VERIFIED, "fresh authorized observation matches the postcondition", latest.observation_id
|
||||
return (EffectVerdict.STATE_OBSERVED,
|
||||
"state matches, but this execution's outcome is unknown; causality is not established",
|
||||
latest.observation_id)
|
||||
|
||||
|
||||
def assess(claim: EffectClaim, history: EffectHistory) -> EffectAssessment:
|
||||
"""Derive a claim's verdict from the append-only history."""
|
||||
settled = history.latest_outcome(claim.effect_id)
|
||||
targets = tuple(o.target for o in claim.obligations)
|
||||
if settled is None:
|
||||
return EffectAssessment(claim.effect_id, claim.action_id, ExecutionOutcome.ATTEMPTED, None,
|
||||
EffectVerdict.PENDING, "no settled execution outcome", targets=targets)
|
||||
base = dict(effect_id=claim.effect_id, action_id=claim.action_id, execution=settled.execution,
|
||||
impact=settled.impact, cleanup=settled.cleanup, targets=targets)
|
||||
if settled.execution is ExecutionOutcome.NOT_EXECUTED:
|
||||
return EffectAssessment(**base, verdict=EffectVerdict.NOT_EXECUTED, reason="refused before invocation")
|
||||
if settled.execution is ExecutionOutcome.RUNNING:
|
||||
return EffectAssessment(**base, verdict=EffectVerdict.PENDING,
|
||||
reason="background execution has not settled")
|
||||
if not claim.obligations:
|
||||
verdict = EffectVerdict.FAILED if settled.execution is ExecutionOutcome.FAILED else EffectVerdict.UNVERIFIED
|
||||
return EffectAssessment(**base, verdict=verdict, reason="no explicit postcondition obligation")
|
||||
results = [_assess_obligation(claim, settled, o, history) for o in claim.obligations]
|
||||
worst = min(results, key=lambda r: _VERDICT_RANK[r[0]])
|
||||
if settled.execution is ExecutionOutcome.FAILED and worst[0] is not EffectVerdict.CONTRADICTED:
|
||||
worst = (EffectVerdict.FAILED, worst[1] if worst[0] is EffectVerdict.FAILED else
|
||||
"execution failed and may have partially changed the target", worst[2])
|
||||
return EffectAssessment(**base, verdict=worst[0], reason=worst[1],
|
||||
observation_ids=tuple(dict.fromkeys(r[2] for r in results if r[2])))
|
||||
|
||||
|
||||
def assess_all(history: EffectHistory) -> tuple[EffectAssessment, ...]:
|
||||
return tuple(assess(claim, history) for claim in sorted(history.claims, key=lambda c: c.sequence))
|
||||
|
||||
|
||||
def replay_interrupted(history: EffectHistory, next_sequence: int) -> tuple[EffectOutcome, ...]:
|
||||
"""Outcomes to append for claims that never settled before a reload.
|
||||
|
||||
Unknown remains unknown: the backend may or may not have been invoked, so
|
||||
impact is POSSIBLE. Running background effects are left to their own
|
||||
lifecycle owner and are not converted here.
|
||||
"""
|
||||
_position(next_sequence)
|
||||
pending = [c for c in sorted(history.claims, key=lambda c: c.sequence)
|
||||
if history.latest_outcome(c.effect_id) is None]
|
||||
return tuple(EffectOutcome(c.effect_id, next_sequence + i, ExecutionOutcome.INTERRUPTED,
|
||||
Impact.POSSIBLE, replayed=True) for i, c in enumerate(pending))
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Canonical evidence identities; these helpers never grant filesystem access."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
from .path_policy import _is_sensitive_path
|
||||
|
||||
|
||||
TUI_PYTHON_RUNNER_SETUP = (
|
||||
"runner=''; "
|
||||
"if [ -x .venv/bin/python ]; then runner=.venv/bin/python; "
|
||||
"elif [ -x venv/bin/python ]; then runner=venv/bin/python; "
|
||||
"elif git_common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) "
|
||||
"&& [ -x \"$(dirname \"$git_common\")/.venv/bin/python\" ]; then "
|
||||
"runner=\"$(dirname \"$git_common\")/.venv/bin/python\"; "
|
||||
"else runner=python; fi; "
|
||||
)
|
||||
|
||||
|
||||
def digest(value: object) -> str:
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
|
||||
separators=(",", ":"), default=str).encode()).hexdigest()
|
||||
|
||||
|
||||
def artifact_version(value: str, workspace: str) -> str:
|
||||
"""Content version for a confined declared output; missing/unreadable is explicit."""
|
||||
identity = artifact_identity(value, workspace)
|
||||
if not workspace or not identity.startswith('workspace:'):
|
||||
return 'unobserved'
|
||||
root = Path(workspace).resolve()
|
||||
candidate = (root / identity.removeprefix('workspace:')).resolve()
|
||||
if not candidate.is_relative_to(root) or _is_sensitive_path(str(candidate)):
|
||||
return 'unobserved'
|
||||
if not hasattr(os, 'O_NOFOLLOW') or not os.supports_dir_fd:
|
||||
return 'unobserved'
|
||||
directory = None
|
||||
try:
|
||||
directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
parts = candidate.relative_to(root).parts
|
||||
if not parts:
|
||||
return 'unobserved'
|
||||
for part in parts[:-1]:
|
||||
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
|
||||
os.close(directory)
|
||||
directory = child
|
||||
descriptor = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
|
||||
with os.fdopen(descriptor, 'rb') as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size > 64 * 1024 * 1024:
|
||||
return 'unobserved'
|
||||
result = hashlib.sha256()
|
||||
remaining = 64 * 1024 * 1024
|
||||
while block := stream.read(min(1024 * 1024, remaining + 1)):
|
||||
remaining -= len(block)
|
||||
if remaining < 0:
|
||||
return 'unobserved'
|
||||
result.update(block)
|
||||
return result.hexdigest()
|
||||
except (OSError, ValueError):
|
||||
return 'missing-or-unreadable'
|
||||
finally:
|
||||
if directory is not None:
|
||||
os.close(directory)
|
||||
|
||||
|
||||
def artifact_identity(value: str, workspace: str = "") -> str:
|
||||
"""Unify relative, virtual and host aliases without basename matching.
|
||||
|
||||
Resolving symlinks is evidence bookkeeping, never a confinement check. Paths
|
||||
outside the workspace retain their absolute identity and cannot satisfy a
|
||||
workspace obligation with the same basename.
|
||||
"""
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
path = PurePosixPath(text)
|
||||
root = Path(workspace or "/workspace").resolve()
|
||||
if path.parts[:2] == ('/', 'workspace'):
|
||||
path = PurePosixPath(*path.parts[2:])
|
||||
candidate = Path(str(path))
|
||||
if not candidate.is_absolute():
|
||||
candidate = root / candidate
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
relative = resolved.relative_to(root)
|
||||
return "workspace:" + relative.as_posix()
|
||||
except ValueError:
|
||||
return "absolute:" + str(candidate.resolve())
|
||||
except (OSError, RuntimeError):
|
||||
return "unresolved:" + text
|
||||
|
||||
|
||||
def executable_words(command: str) -> tuple[str, ...]:
|
||||
"""Recognize one foreground command after exact interpreter or cd/set prefixes.
|
||||
|
||||
This is deliberately conservative evidence parsing, not shell authorization.
|
||||
Other control flow, substitutions, pipelines and status-masking tails are not proof
|
||||
that a verifier returned the recorded shell status.
|
||||
"""
|
||||
text = str(command or '').strip()
|
||||
if text.startswith(TUI_PYTHON_RUNNER_SETUP):
|
||||
remainder = text[len(TUI_PYTHON_RUNNER_SETUP):]
|
||||
# This exact server-owned prelude only selects the interpreter. The
|
||||
# trailing command must still be a single foreground invocation whose
|
||||
# status is returned unchanged; the generic discovery fallback is not.
|
||||
if remainder.startswith('"$runner" '):
|
||||
arguments = remainder[len('"$runner" '):]
|
||||
if '$' in arguments:
|
||||
return ()
|
||||
text = 'python ' + arguments
|
||||
if any(marker in text for marker in ('`', '$(', '${', '\n', '\r')):
|
||||
return ()
|
||||
try:
|
||||
lexer = shlex.shlex(text, posix=True, punctuation_chars=';&|<>()')
|
||||
lexer.whitespace_split = True
|
||||
words = list(lexer)
|
||||
except ValueError:
|
||||
return ()
|
||||
while '&&' in words:
|
||||
index = words.index('&&')
|
||||
prefix = words[:index]
|
||||
if not ((len(prefix) == 2 and prefix[0] == 'cd') or prefix == ['set', '-e']):
|
||||
return ()
|
||||
words = words[index + 1:]
|
||||
if any(word and all(c in ';&|<>()' for c in word) for word in words):
|
||||
return ()
|
||||
while words and re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*=[^\n]*', words[0]):
|
||||
words.pop(0)
|
||||
return tuple(words)
|
||||
|
||||
|
||||
def is_test_command(command: str) -> bool:
|
||||
words = executable_words(command)
|
||||
if not words:
|
||||
return False
|
||||
if any(word in {'--help', '-h', '--version', '--collect-only', '--co'} for word in words[1:]):
|
||||
return False
|
||||
binary = Path(words[0]).name
|
||||
if binary in {'pytest', 'py.test'}:
|
||||
return True
|
||||
if re.fullmatch(r'python(?:\d+(?:\.\d+)?)?', binary):
|
||||
args = list(words[1:])
|
||||
while args and args[0] in {'-I', '-S', '-s', '-E', '-B', '-u'}:
|
||||
args.pop(0)
|
||||
return len(args) >= 2 and args[:2] in (['-m', 'pytest'], ['-m', 'unittest'])
|
||||
if binary in {'npm', 'pnpm', 'yarn', 'make', 'cargo', 'go'}:
|
||||
args = words[1:]
|
||||
return bool(args and (args[0] == 'test' or binary == 'npm' and args[:2] == ('run', 'test')))
|
||||
return bool(re.match(r'^/(?:tests?|verifier)/[^/]+', words[0]))
|
||||
|
||||
|
||||
def is_validation_command(command: str) -> bool:
|
||||
words = executable_words(command)
|
||||
if not words:
|
||||
return False
|
||||
binary = Path(words[0]).name
|
||||
return (is_test_command(command)
|
||||
or binary in {'cat', 'head', 'tail', 'stat', 'wc', 'jq', 'cmp', 'diff',
|
||||
'coqc', 'gcc', 'g++', 'clang', 'clang++', 'javac', 'rustc'}
|
||||
or binary == 'test' and len(words) > 1 and words[1] in {'-e', '-f', '-s', '-d'}
|
||||
or binary in {'cargo', 'go', 'npm', 'pnpm', 'yarn'} and words[1:2] in {('build',), ('check',)}
|
||||
or binary == 'npm' and words[1:3] == ('run', 'build'))
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Run-owned action history. Model text cannot insert authoritative receipts."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from .identity import artifact_identity, artifact_version, digest
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionReceipt:
|
||||
action_id: str
|
||||
call_id: str
|
||||
proposed_tool: str
|
||||
proposed_arguments: str
|
||||
provider_arguments: Any = None
|
||||
provider_tool: str = ''
|
||||
tool: str = ""
|
||||
arguments: str = ""
|
||||
transitions: list[dict[str, Any]] = field(default_factory=list)
|
||||
execution_id: str | None = None
|
||||
operation_started: bool = False
|
||||
outcome: dict[str, Any] | None = None
|
||||
artifact_versions: dict[str, str] = field(default_factory=dict)
|
||||
artifact_changes: list[str] | None = None
|
||||
|
||||
def transition(self, stage: str, **details: Any) -> None:
|
||||
self.transitions.append({'sequence': len(self.transitions), 'stage': stage, **details})
|
||||
|
||||
def normalize(self, block: Any, reason: str) -> None:
|
||||
tool, arguments = str(block.tool_type), str(block.content)
|
||||
if tool != self.tool or arguments != self.arguments or not any(t['stage'] == 'normalized' for t in self.transitions):
|
||||
self.transition('normalized', reason=reason, tool=tool, arguments=arguments,
|
||||
previous_sha256=digest((self.tool, self.arguments)))
|
||||
self.tool, self.arguments = tool, arguments
|
||||
|
||||
def finish(self, result: dict[str, Any]) -> None:
|
||||
if self.outcome is not None:
|
||||
return
|
||||
code = result.get('exit_code')
|
||||
valid_code = isinstance(code, int) and not isinstance(code, bool)
|
||||
denied = bool(result.get('blocked') or result.get('approval_required')
|
||||
or str(result.get('failure_kind', '')).endswith('_denied'))
|
||||
self.outcome = {
|
||||
'exit_code': code if valid_code else None,
|
||||
'success': valid_code and code == 0 and not result.get('error') and not denied,
|
||||
'authoritative': self.execution_id is not None and valid_code and not denied,
|
||||
'blocked': denied,
|
||||
'output_sha256': digest(result.get('output') or result.get('error') or result.get('stdout') or ''),
|
||||
}
|
||||
self.transition('outcome', **self.outcome)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionJournal:
|
||||
run_id: str = field(default_factory=lambda: uuid4().hex)
|
||||
actions: list[ActionReceipt] = field(default_factory=list)
|
||||
workspace: str = ''
|
||||
observed_artifacts: tuple[str, ...] = ()
|
||||
parent_run_id: str | None = None
|
||||
# Durable Wave 4 effect log, shared across one run lineage; None disables.
|
||||
effects: Any = field(default=None, repr=False, compare=False)
|
||||
_dispatches: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
||||
|
||||
def effect_entries(self) -> list[dict[str, Any]]:
|
||||
"""Effect assessments ordered against this journal's actions.
|
||||
|
||||
Ordinal is the 1-based position of the action in this journal, so the
|
||||
ledger can compare effects with receipt-derived evidence. Effects from
|
||||
other journals in the lineage carry no ordinal here.
|
||||
"""
|
||||
if self.effects is None:
|
||||
return []
|
||||
order = {action.action_id: index for index, action in enumerate(self.actions, 1)}
|
||||
changes = {action.action_id: action.artifact_changes for action in self.actions}
|
||||
history = self.effects.history()
|
||||
entries = []
|
||||
for assessment in self.effects.assessments():
|
||||
claim = history.claim(assessment.effect_id)
|
||||
outcome = history.latest_outcome(assessment.effect_id)
|
||||
entries.append({
|
||||
'ordinal': order.get(assessment.action_id), 'assessment': assessment,
|
||||
'tool': claim.operation.tool, 'unknown_scope': claim.unknown_scope, 'external': claim.external,
|
||||
'paths': tuple(ref.location[-1] for ref in claim.impact_scope if ref.kind.value == 'filesystem'),
|
||||
'mutation_attempted': bool(outcome and outcome.facts.mutation_attempted),
|
||||
'artifact_changes': changes.get(assessment.action_id),
|
||||
})
|
||||
return entries
|
||||
|
||||
def partial_reads(self) -> tuple[str, ...]:
|
||||
"""Read actions in this journal whose admitted observation was partial."""
|
||||
if self.effects is None:
|
||||
return ()
|
||||
mine = {action.action_id for action in self.actions}
|
||||
return tuple(o.source_action_id for o in self.effects.history().observations
|
||||
if o.source_action_id in mine and o.mechanism.value == 'filesystem_read'
|
||||
and o.coverage.value == 'partial')
|
||||
|
||||
def capture_versions(self, action: ActionReceipt) -> None:
|
||||
if self.workspace:
|
||||
action.artifact_versions = {
|
||||
artifact_identity(path, self.workspace): artifact_version(path, self.workspace)
|
||||
for path in self.observed_artifacts
|
||||
}
|
||||
|
||||
def propose(self, block: Any, call_id: str = '', native_call: dict | None = None) -> ActionReceipt:
|
||||
native = native_call or {}
|
||||
function = native.get('function') or native
|
||||
if not isinstance(function, dict):
|
||||
function = {}
|
||||
action = ActionReceipt(
|
||||
action_id=f'{self.run_id}:action:{len(self.actions) + 1}', call_id=call_id,
|
||||
proposed_tool=str(block.tool_type), proposed_arguments=str(block.content),
|
||||
provider_arguments=deepcopy(function.get('arguments')),
|
||||
provider_tool=str(function.get('name') or ''),
|
||||
tool=str(block.tool_type), arguments=str(block.content),
|
||||
)
|
||||
action.transition('proposed')
|
||||
self.actions.append(action)
|
||||
return action
|
||||
|
||||
def to_list(self) -> list[dict[str, Any]]:
|
||||
return [action.to_dict() for action in self.actions]
|
||||
|
||||
def evidence_events(self) -> list[dict[str, Any]]:
|
||||
return [dict(tool=a.tool, command=a.arguments,
|
||||
exit_code=(a.outcome or {}).get('exit_code'),
|
||||
error=not (a.outcome or {}).get('success'),
|
||||
execution_attempted=bool((a.outcome or {}).get('authoritative')),
|
||||
blocked=(a.outcome or {}).get('blocked', False),
|
||||
action_id=a.action_id, execution_id=a.execution_id,
|
||||
artifact_versions=a.artifact_versions, artifact_changes=a.artifact_changes)
|
||||
for a in self.actions if a.outcome is not None]
|
||||
|
||||
|
||||
_JOURNAL: ContextVar[ActionJournal | None] = ContextVar('runtime_action_journal', default=None)
|
||||
_ACTION: ContextVar[ActionReceipt | None] = ContextVar('runtime_current_action', default=None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_journal(journal: ActionJournal):
|
||||
token = _JOURNAL.set(journal)
|
||||
action_token = _ACTION.set(None)
|
||||
try:
|
||||
yield journal
|
||||
finally:
|
||||
_ACTION.reset(action_token)
|
||||
_JOURNAL.reset(token)
|
||||
|
||||
|
||||
def current_journal() -> ActionJournal | None:
|
||||
return _JOURNAL.get()
|
||||
|
||||
|
||||
def propose_action(block: Any, call_id: str = '', native_call: dict | None = None) -> ActionReceipt | None:
|
||||
journal = _JOURNAL.get()
|
||||
return journal.propose(block, call_id, native_call) if journal else None
|
||||
|
||||
|
||||
def mark_authorized() -> None:
|
||||
action = _ACTION.get()
|
||||
if action is not None and not any(t['stage'] == 'authorized' for t in action.transitions):
|
||||
action.transition('authorized', authority='existing_dispatcher_policy')
|
||||
|
||||
|
||||
def mark_dispatch() -> None:
|
||||
action = _ACTION.get()
|
||||
if action is not None and action.execution_id is None:
|
||||
journal = _JOURNAL.get()
|
||||
if journal is not None and journal.effects is not None:
|
||||
# Durable claim first. If it cannot be persisted this raises and
|
||||
# the action stays undispatched: the backend is never invoked.
|
||||
from .effect_adapters import begin_effect
|
||||
capture = begin_effect(journal, action)
|
||||
journal._dispatches[action.action_id] = capture
|
||||
if capture.claim is not None:
|
||||
action.transition('effect_claimed', effect_id=capture.claim.effect_id,
|
||||
sequence=capture.claim.sequence)
|
||||
mark_authorized()
|
||||
action.execution_id = action.action_id + ':execution:1'
|
||||
action.transition('dispatched', execution_id=action.execution_id)
|
||||
|
||||
|
||||
async def dispatched(operation):
|
||||
"""Record an actual backend invocation, distinct from router admission."""
|
||||
try:
|
||||
mark_dispatch()
|
||||
except BaseException:
|
||||
close = getattr(operation, 'close', None)
|
||||
if close is not None:
|
||||
close() # never invoked; do not leave an un-awaited coroutine
|
||||
raise
|
||||
return await operation
|
||||
|
||||
|
||||
def _settle(journal: ActionJournal | None, action: ActionReceipt, **outcome: Any) -> None:
|
||||
if journal is None or journal.effects is None:
|
||||
return
|
||||
capture = journal._dispatches.pop(action.action_id, None)
|
||||
if capture is None:
|
||||
return
|
||||
from .effect_adapters import settle_effect
|
||||
try:
|
||||
settle_effect(journal, action, capture, **outcome)
|
||||
except Exception: # noqa: BLE001 - bookkeeping must not alter the tool result
|
||||
# The claim stays unsettled (ATTEMPTED), which assesses as pending
|
||||
# with possible impact: conservative, never a manufactured success.
|
||||
journal.effects.degraded = True
|
||||
logging.getLogger(__name__).warning('Effect outcome could not be recorded', exc_info=True)
|
||||
|
||||
|
||||
def mark_operation_started(backend: str, **details: Any) -> None:
|
||||
action = _ACTION.get()
|
||||
if action is not None:
|
||||
action.operation_started = True
|
||||
action.transition('operation_started', backend=backend, **details)
|
||||
|
||||
|
||||
async def execute_action(executor, action: ActionReceipt | None, block: Any, **kwargs):
|
||||
"""Adapter binds the proposal across async tool-task execution and cleanup."""
|
||||
if action is not None:
|
||||
action.normalize(block, 'agent_loop compatibility adapters')
|
||||
token = _ACTION.set(action)
|
||||
try:
|
||||
return await executor(block, **kwargs)
|
||||
finally:
|
||||
_ACTION.reset(token)
|
||||
|
||||
|
||||
def record_action(func):
|
||||
call_signature = signature(func)
|
||||
|
||||
@wraps(func)
|
||||
async def wrapped(*args, **kwargs):
|
||||
bound = call_signature.bind(*args, **kwargs)
|
||||
block = bound.arguments['block']
|
||||
action = _ACTION.get() or propose_action(block)
|
||||
token = _ACTION.set(action)
|
||||
try:
|
||||
journal = current_journal()
|
||||
before = {}
|
||||
if action is not None:
|
||||
action.normalize(block, 'dispatcher input')
|
||||
if journal is not None and journal.workspace:
|
||||
journal.capture_versions(action)
|
||||
before = dict(action.artifact_versions)
|
||||
description, result = await func(*args, **kwargs)
|
||||
if action is not None:
|
||||
journal = current_journal()
|
||||
if journal is not None:
|
||||
journal.capture_versions(action)
|
||||
if journal.workspace:
|
||||
action.artifact_changes = [key for key, value in action.artifact_versions.items()
|
||||
if before.get(key) != value]
|
||||
if 'BLOCKED' in description and action.execution_id is None:
|
||||
action.transition('authorization_denied', reason=str(result.get('error', '')))
|
||||
action.finish({**result, 'blocked': True})
|
||||
else:
|
||||
action.finish(result)
|
||||
# Structured producer facts are projected here, before the
|
||||
# receipt reduction drops them.
|
||||
_settle(journal, action, result=result)
|
||||
return description, result
|
||||
except BaseException as exc:
|
||||
if action is not None:
|
||||
action.transition('interrupted', category=type(exc).__name__)
|
||||
_settle(current_journal(), action, error=exc)
|
||||
raise
|
||||
finally:
|
||||
_ACTION.reset(token)
|
||||
|
||||
return wrapped
|
||||
@@ -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
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Resolve owned selectors before execution and consume exact server identities."""
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
|
||||
from src.agent_runtime.resources import (
|
||||
FilesystemResource, FilesystemRoot, FilesystemScope, OwnedResource,
|
||||
OWNED_TOOL_NAMESPACES, ResourceIdentityError,
|
||||
)
|
||||
|
||||
|
||||
def _args(content):
|
||||
if not isinstance(json.loads(content or "{}"), dict):
|
||||
raise ResourceIdentityError("Owned resource arguments must be an object")
|
||||
from src.tools._common import _parse_tool_args
|
||||
value = _parse_tool_args(content)
|
||||
if not isinstance(value, dict):
|
||||
raise ResourceIdentityError("Owned resource arguments must be an object")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _selector(args, keys):
|
||||
values = [args[k] for k in keys if k in args and args[k] not in (None, "")]
|
||||
if any(not isinstance(v, str) or not v.strip() for v in values):
|
||||
raise ResourceIdentityError("Record selectors must be strings")
|
||||
values = [v.strip() for v in values]
|
||||
if len(set(values)) > 1:
|
||||
raise ResourceIdentityError("Conflicting record aliases")
|
||||
return values[0] if values else ""
|
||||
|
||||
|
||||
def _revision(row, namespace):
|
||||
created = getattr(row, "created_at", None)
|
||||
updated = getattr(row, "updated_at", None)
|
||||
if created is None or not hasattr(created, "isoformat") or updated is None or not hasattr(updated, "isoformat"):
|
||||
raise ResourceIdentityError("Record has no observable revision")
|
||||
version = getattr(row, "version_count", "") if namespace == "documents" else ""
|
||||
if namespace == "documents" and type(version) is not int:
|
||||
raise ResourceIdentityError("Document version is unresolved")
|
||||
return f"{created.isoformat()}:{updated.isoformat()}:{version}"
|
||||
|
||||
|
||||
def _record(namespace, owner, thread, row):
|
||||
if (getattr(row, "owner", None) != owner or not isinstance(getattr(row, "id", None), str)
|
||||
or row.id in {"", "*"}):
|
||||
raise ResourceIdentityError("Record ownership is unresolved")
|
||||
linked = str(getattr(row, "session_id", "") or "") if namespace == "documents" else row.id if namespace == "threads" else ""
|
||||
return OwnedResource(namespace, owner, thread, namespace, row.id, _revision(row, namespace), linked)
|
||||
|
||||
|
||||
def _row(namespace, identifier, owner):
|
||||
from core.database import SessionLocal, Document, Session, Note
|
||||
model = {"documents": Document, "threads": Session, "notes": Note}[namespace]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.query(model).filter(model.id == identifier, model.owner == owner).first()
|
||||
if row is None or (namespace == "documents" and not row.is_active):
|
||||
raise ResourceIdentityError("Owned record is missing or inaccessible")
|
||||
db.expunge(row)
|
||||
return row
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AttachmentResource:
|
||||
record: OwnedResource
|
||||
file: FilesystemResource
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.record, OwnedResource) or not isinstance(self.file, FilesystemResource) or self.file.root.owner != self.record.owner:
|
||||
raise ValueError("Malformed attachment identity")
|
||||
|
||||
def to_dict(self):
|
||||
return {"record": self.record.to_dict(), "file": self.file.to_dict()}
|
||||
|
||||
|
||||
def _attachment(identifier, owner, thread):
|
||||
from src.tool_utils import get_upload_handler
|
||||
handler = get_upload_handler()
|
||||
if handler is None:
|
||||
raise ResourceIdentityError("Attachment store is unavailable")
|
||||
info = handler.resolve_upload(identifier, owner=owner, allow_admin=False)
|
||||
if not isinstance(info, dict) or info.get("id") != identifier or info.get("owner") != owner:
|
||||
raise ResourceIdentityError("Attachment ownership is unresolved")
|
||||
root = FilesystemRoot.seal(handler.upload_dir, scope=FilesystemScope.PRIVATE, owner=owner)
|
||||
file = FilesystemResource.resolve(root, info.get("path"))
|
||||
if file.identity.kind != "file":
|
||||
raise ResourceIdentityError("Attachment must identify a file")
|
||||
revision = str(info.get("checksum_sha256") or info.get("hash") or info.get("uploaded_at") or "")
|
||||
if not revision:
|
||||
raise ResourceIdentityError("Attachment has no observable revision")
|
||||
return AttachmentResource(OwnedResource("attachments", owner, thread, "attachments", identifier, revision), file)
|
||||
|
||||
|
||||
_VAULT_RECORDS = {}
|
||||
|
||||
|
||||
def _vault_revision(cfg, owner):
|
||||
from src.agent_runtime.remote_resources import endpoint_identity, configuration_incarnation
|
||||
if not isinstance(cfg, dict) or cfg.get("owner") != owner:
|
||||
raise ResourceIdentityError("Vault configuration has no matching explicit owner")
|
||||
endpoint = endpoint_identity(cfg.get("server_url") or cfg.get("url") or "")
|
||||
return endpoint + ":" + configuration_incarnation((cfg.get("server_url") or cfg.get("url"), cfg.get("email"), cfg.get("unlocked_at"), cfg.get("session")))
|
||||
|
||||
|
||||
def observe_vault_records(owner, cfg, records):
|
||||
"""Only a server search response produces record observations, not grants."""
|
||||
from src.tools.vault import _load_vault_config
|
||||
from src.agent_runtime.remote_resources import configuration_incarnation
|
||||
from uuid import UUID
|
||||
revision = _vault_revision(cfg, owner)
|
||||
if _vault_revision(_load_vault_config(), owner) != revision or not isinstance(records, list):
|
||||
raise ResourceIdentityError("Vault producer configuration changed")
|
||||
observed = {}
|
||||
for row in records:
|
||||
if not isinstance(row, dict):
|
||||
raise ResourceIdentityError("Malformed vault producer record")
|
||||
try:
|
||||
identifier = str(UUID(row.get("id", "")))
|
||||
except (ValueError, TypeError, AttributeError) as error:
|
||||
raise ResourceIdentityError("Vault producer record has no exact UUID") from error
|
||||
if identifier in observed or not isinstance(row.get("name", ""), str):
|
||||
raise ResourceIdentityError("Ambiguous vault producer identity")
|
||||
observed[identifier] = (row.get("name", ""), configuration_incarnation(json.dumps(row, sort_keys=True, allow_nan=False)))
|
||||
catalog = _VAULT_RECORDS.setdefault((owner, revision), {})
|
||||
catalog.update(observed)
|
||||
|
||||
|
||||
def _vault_resource(owner, thread, identifier):
|
||||
from src.tools.vault import _load_vault_config
|
||||
revision = _vault_revision(_load_vault_config(), owner)
|
||||
if identifier != "*":
|
||||
record = _VAULT_RECORDS.get((owner, revision), {}).get(identifier)
|
||||
if record is None:
|
||||
raise ResourceIdentityError("Vault record has no server observation; search the owner vault first")
|
||||
revision += ":" + record[1]
|
||||
return OwnedResource("vault", owner, thread, "vault", identifier, revision)
|
||||
|
||||
|
||||
def _vault_selector(owner, selector):
|
||||
from src.tools.vault import _load_vault_config
|
||||
revision = _vault_revision(_load_vault_config(), owner)
|
||||
rows = _VAULT_RECORDS.get((owner, revision), {})
|
||||
if selector in rows:
|
||||
return selector
|
||||
matches = [identifier for identifier, (name, _) in rows.items()
|
||||
if identifier.startswith(selector) or name == selector]
|
||||
if not selector or len(matches) != 1:
|
||||
raise ResourceIdentityError("Vault selector is missing or ambiguous")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _memory_record(identifier, owner, thread, *, prefix=False):
|
||||
from src.ai_interaction import _memory_manager
|
||||
if _memory_manager is None:
|
||||
raise ResourceIdentityError("Memory store is unavailable")
|
||||
rows = [row for row in _memory_manager.load(owner=owner) if isinstance(row, dict)
|
||||
and row.get("owner") == owner and isinstance(row.get("id"), str)
|
||||
and (row["id"].startswith(identifier) if prefix else row["id"] == identifier)]
|
||||
if len(rows) != 1 or not identifier or rows[0].get("timestamp") is None or rows[0]["id"] in {"", "*"}:
|
||||
raise ResourceIdentityError("Memory selector is missing or ambiguous")
|
||||
from src.agent_runtime.remote_resources import configuration_incarnation
|
||||
row = rows[0]
|
||||
# A same-second edit still changes the private revision without serializing content.
|
||||
revision = configuration_incarnation(json.dumps(row, sort_keys=True, allow_nan=False))
|
||||
return OwnedResource("memory", owner, thread, "memory", row["id"], revision)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundOwnedOperation:
|
||||
operation: "ExactOperation"
|
||||
execution_input: str
|
||||
request_id: str
|
||||
owner: str
|
||||
thread_id: str
|
||||
resources: tuple[OwnedResource, ...]
|
||||
attachments: tuple[AttachmentResource, ...] = ()
|
||||
document_id: str = ""
|
||||
document_version: int | None = None
|
||||
document_digest: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
if (not isinstance(self.operation, ExactOperation) or not self.owner or not self.thread_id
|
||||
or any(not isinstance(v, str) for v in (self.execution_input, self.request_id, self.owner, self.thread_id, self.document_id, self.document_digest))
|
||||
or not isinstance(self.resources, tuple) or not self.resources
|
||||
or any(not isinstance(r, OwnedResource) or (r.owner, r.thread_id) != (self.owner, self.thread_id) for r in self.resources)
|
||||
or not isinstance(self.attachments, tuple) or any(not isinstance(a, AttachmentResource) for a in self.attachments)):
|
||||
raise ValueError("Malformed owned resource operation")
|
||||
namespace = OWNED_TOOL_NAMESPACES.get(self.operation.tool)
|
||||
if (any(r.namespace != namespace or r.collection != namespace or (r.record_id != "*" and not r.revision) for r in self.resources)
|
||||
or tuple(a.record for a in self.attachments) != tuple(r for r in self.resources if r.namespace == "attachments")):
|
||||
raise ValueError("Malformed owned resource identity")
|
||||
if self.document_id:
|
||||
if (type(self.document_version) is not int or self.document_version < 1
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", self.document_digest)
|
||||
or not any(r.namespace == "documents" and r.record_id == self.document_id for r in self.resources)):
|
||||
raise ValueError("Malformed document binding")
|
||||
elif any(r.namespace == "documents" and r.record_id != "*" for r in self.resources):
|
||||
raise ValueError("Missing document binding")
|
||||
|
||||
def to_dict(self):
|
||||
from src.agent_runtime.remote_resources import configuration_incarnation
|
||||
return {"request_id": self.request_id, "owner": self.owner, "thread_id": self.thread_id,
|
||||
"tool": self.operation.transport_tool,
|
||||
"execution_input_digest": configuration_incarnation(self.execution_input),
|
||||
"resources": [r.to_dict() for r in self.resources],
|
||||
"attachments": [a.to_dict() for a in self.attachments],
|
||||
"document_id": self.document_id, "document_version": self.document_version,
|
||||
"document_digest": self.document_digest}
|
||||
|
||||
def validate(self):
|
||||
for resource in self.resources:
|
||||
if resource.record_id == "*":
|
||||
if resource.namespace == "vault" and _vault_resource(self.owner, self.thread_id, "*") != resource:
|
||||
raise ResourceIdentityError("Vault identity changed")
|
||||
continue
|
||||
if resource.namespace == "attachments":
|
||||
expected = next((a for a in self.attachments if a.record == resource), None)
|
||||
if expected is None or _attachment(resource.record_id, self.owner, self.thread_id) != expected:
|
||||
raise ResourceIdentityError("Attachment identity changed")
|
||||
expected.file.validate()
|
||||
elif resource.namespace == "vault":
|
||||
if _vault_resource(self.owner, self.thread_id, resource.record_id) != resource:
|
||||
raise ResourceIdentityError("Vault identity changed")
|
||||
elif resource.namespace == "memory":
|
||||
if _memory_record(resource.record_id, self.owner, self.thread_id) != resource:
|
||||
raise ResourceIdentityError("Memory identity changed")
|
||||
elif _record(resource.namespace, self.owner, self.thread_id,
|
||||
_row(resource.namespace, resource.record_id, self.owner)) != resource:
|
||||
raise ResourceIdentityError("Owned record identity changed")
|
||||
|
||||
|
||||
def needs_owned_binding(operation):
|
||||
if operation.tool == "app_api":
|
||||
# The generic internal-token bridge must not bypass migrated owner
|
||||
# namespaces. Dedicated tools carry their typed record operations.
|
||||
from urllib.parse import unquote, urlsplit
|
||||
import posixpath
|
||||
args = _args(operation.input)
|
||||
path = args.get("path", "")
|
||||
if not isinstance(path, str):
|
||||
raise ResourceIdentityError("Malformed internal resource selector")
|
||||
for _ in range(4):
|
||||
decoded = unquote(path)
|
||||
if decoded == path:
|
||||
break
|
||||
path = decoded
|
||||
if "%" in path or "\\" in path:
|
||||
raise ResourceIdentityError("Unresolved internal resource selector")
|
||||
path = posixpath.normpath(urlsplit(path).path)
|
||||
private = {"document", "documents", "session", "sessions", "history", "chat", "chats",
|
||||
"notes", "memory", "vault", "upload", "uploads", "attachments",
|
||||
"shell", "model", "cookbook"}
|
||||
segments = path.strip("/").split("/")
|
||||
if len(segments) >= 3 and segments[:3] == ["api", "codex", "cookbook"]:
|
||||
raise ResourceIdentityError("Cookbook wrappers require a dedicated resource-bound tool")
|
||||
if len(segments) >= 2 and segments[0] == "api" and segments[1].casefold() in private:
|
||||
raise ResourceIdentityError("Owned records require a dedicated resource-bound tool")
|
||||
return False
|
||||
if operation.tool not in OWNED_TOOL_NAMESPACES:
|
||||
return False
|
||||
if operation.tool in {"extract_text", "inspect_media", "transcribe_media"}:
|
||||
return "odysseus://attachment/" in operation.input
|
||||
return True
|
||||
|
||||
|
||||
def resolve_owned_operation(operation, *, owner, thread_id, request_id="", document_id=None):
|
||||
if not owner or not thread_id:
|
||||
raise ResourceIdentityError("Owned operations require an owner and invocation thread")
|
||||
if document_id is not None and (not isinstance(document_id, str) or not document_id.strip()):
|
||||
raise ResourceIdentityError("Malformed server document selector")
|
||||
namespace = OWNED_TOOL_NAMESPACES[operation.tool]
|
||||
args = _args(operation.input) if operation.tool not in {"create_document", "edit_document", "update_document", "suggest_document", "send_to_session", "create_session", "list_sessions", "search_chats", "manage_session", "manage_memory"} else {}
|
||||
execution_input = operation.input
|
||||
resources = []
|
||||
attachments = []
|
||||
doc_id = ""
|
||||
doc_version = None
|
||||
doc_digest = ""
|
||||
collection = lambda: OwnedResource(namespace, owner, thread_id, namespace, "*")
|
||||
if namespace == "documents":
|
||||
action = str(args.get("action") or "list").strip().lower()
|
||||
if operation.tool == "create_document" or (operation.tool == "manage_documents" and action in {"list", "search", "find", "tidy"}):
|
||||
resources.append(collection())
|
||||
else:
|
||||
identifier = _selector(args, ("document_id", "id", "uid")) or document_id or ""
|
||||
if identifier in {"active", "current"}:
|
||||
if not document_id or document_id in {"active", "current", "latest"}:
|
||||
raise ResourceIdentityError("Active document selector is unresolved")
|
||||
identifier = document_id
|
||||
if not identifier and operation.tool == "manage_documents" and action != "delete":
|
||||
raise ResourceIdentityError("Document selector is required")
|
||||
if not identifier or identifier == "latest":
|
||||
from core.database import SessionLocal, Document
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.query(Document).filter(Document.owner == owner, Document.is_active == True).order_by(Document.updated_at.desc(), Document.id).first()
|
||||
identifier = row.id if row is not None else ""
|
||||
finally:
|
||||
db.close()
|
||||
if not identifier:
|
||||
raise ResourceIdentityError("Document selector is unresolved")
|
||||
row = _row(namespace, identifier, owner)
|
||||
resources.append(_record(namespace, owner, thread_id, row))
|
||||
doc_id, doc_version = row.id, row.version_count
|
||||
from src.tool_approvals import document_content_digest
|
||||
doc_digest = document_content_digest(row.current_content)
|
||||
if operation.tool == "manage_documents":
|
||||
for key in ("id", "uid"):
|
||||
args.pop(key, None)
|
||||
args["document_id"] = doc_id
|
||||
execution_input = json.dumps(args, sort_keys=True)
|
||||
elif namespace == "threads":
|
||||
if operation.tool in {"list_sessions", "search_chats", "create_session"}:
|
||||
resources.append(collection())
|
||||
else:
|
||||
if operation.tool == "send_to_session":
|
||||
identifier, _, message = operation.input.partition("\n")
|
||||
identifier = identifier.strip()
|
||||
else:
|
||||
if operation.input.lstrip().startswith("{"):
|
||||
args = _args(operation.input)
|
||||
else:
|
||||
lines = operation.input.strip().split("\n", 2)
|
||||
args = {"action": lines[0], "session_id": lines[1] if len(lines) > 1 else ""}
|
||||
if len(lines) > 2:
|
||||
args["value"] = lines[2]
|
||||
if args.get("action") == "list":
|
||||
resources.append(collection())
|
||||
identifier = _selector(args, ("session_id", "session", "id"))
|
||||
if not resources:
|
||||
identifier = thread_id if identifier == "current" else identifier
|
||||
row = _row(namespace, identifier, owner)
|
||||
resources.append(_record(namespace, owner, thread_id, row))
|
||||
if operation.tool == "send_to_session":
|
||||
execution_input = row.id + "\n" + message
|
||||
else:
|
||||
args.pop("id", None)
|
||||
args.pop("session", None)
|
||||
args["session_id"] = row.id
|
||||
execution_input = json.dumps(args, sort_keys=True)
|
||||
elif namespace == "notes":
|
||||
action = str(args.get("action") or "").strip().lower().replace("-", "_")
|
||||
if action in {"list", "search", "find", "add", "create", "new", "save", "remind"}:
|
||||
resources.append(collection())
|
||||
else:
|
||||
identifier = _selector(args, ("id", "note_id", "noteId"))
|
||||
from core.database import SessionLocal, Note
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(Note).filter(Note.owner == owner)
|
||||
if identifier:
|
||||
rows = q.filter(Note.id.startswith(identifier, autoescape=True)).limit(2).all()
|
||||
else:
|
||||
title = _selector(args, ("title", "query", "text"))
|
||||
rows = q.filter(Note.title == title).limit(2).all() if title else []
|
||||
if len(rows) != 1:
|
||||
raise ResourceIdentityError("Note selector is missing or ambiguous")
|
||||
identifier = rows[0].id
|
||||
finally:
|
||||
db.close()
|
||||
row = _row(namespace, identifier, owner)
|
||||
resources.append(_record(namespace, owner, thread_id, row))
|
||||
args.pop("note_id", None)
|
||||
args.pop("noteId", None)
|
||||
args["id"] = identifier
|
||||
execution_input = json.dumps(args, sort_keys=True)
|
||||
elif namespace == "attachments":
|
||||
selector = args.get("path")
|
||||
match = re.fullmatch(r"odysseus://attachment/([A-Za-z0-9_-]+(?:\.[A-Za-z0-9]+)?)", selector or "")
|
||||
if match is None:
|
||||
raise ResourceIdentityError("Malformed attachment selector")
|
||||
attachment = _attachment(match[1], owner, thread_id)
|
||||
resources.append(attachment.record)
|
||||
attachments.append(attachment)
|
||||
elif namespace == "memory":
|
||||
from src.ai_interaction import _manage_memory_lines
|
||||
lines = _manage_memory_lines(operation.input)
|
||||
if not lines:
|
||||
raise ResourceIdentityError("Memory action is unresolved")
|
||||
action = lines[0].strip().lower()
|
||||
if action in {"list", "search", "add"}:
|
||||
resources.append(collection())
|
||||
elif action in {"edit", "delete"} and len(lines) >= 2:
|
||||
resource = _memory_record(lines[1].strip(), owner, thread_id, prefix=True)
|
||||
resources.append(resource)
|
||||
lines[1] = resource.record_id
|
||||
execution_input = "\n".join(lines)
|
||||
else:
|
||||
raise ResourceIdentityError("Memory operation is unresolved")
|
||||
elif namespace == "vault":
|
||||
identifier = "*"
|
||||
if operation.tool == "vault_get":
|
||||
identifier = _vault_selector(owner, _selector(args, ("item_id",)))
|
||||
args["item_id"] = identifier
|
||||
execution_input = json.dumps(args, sort_keys=True)
|
||||
resources.append(_vault_resource(owner, thread_id, identifier))
|
||||
bound = BoundOwnedOperation(operation, execution_input, request_id, owner, thread_id,
|
||||
tuple(resources), tuple(attachments), doc_id, doc_version, doc_digest)
|
||||
bound.validate()
|
||||
return bound
|
||||
|
||||
|
||||
def admit_owned_operation(authority, operation, *, document_id=None, approved=None, exact_admission=False):
|
||||
bound = (approved if approved is not None else resolve_owned_operation(operation, owner=authority.owner,
|
||||
thread_id=authority.session_id, request_id=authority.request_id, document_id=document_id))
|
||||
if (not isinstance(bound, BoundOwnedOperation) or bound.operation != operation
|
||||
or (bound.owner, bound.thread_id) != (authority.owner, authority.session_id)
|
||||
or (bound.request_id and bound.request_id != authority.request_id)):
|
||||
raise ResourceIdentityError("Owned operation approval binding changed")
|
||||
if not all(any(scope.permits(r) for scope in authority.owned_scopes) for r in bound.resources):
|
||||
if not (approved is not None and exact_admission and not authority.inherited and not authority.owned_scopes):
|
||||
raise ResourceIdentityError("Owned resource exceeds parent/request scope")
|
||||
bound.validate()
|
||||
return bound
|
||||
|
||||
|
||||
_ACTIVE = ContextVar("owned_resource_operation", default=None)
|
||||
|
||||
|
||||
def active_owned_operation():
|
||||
return _ACTIVE.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_owned_operation(operation):
|
||||
if operation is not None:
|
||||
if not isinstance(operation, BoundOwnedOperation):
|
||||
raise TypeError("Owned operation must be server-owned")
|
||||
operation.validate()
|
||||
token = _ACTIVE.set(operation)
|
||||
try:
|
||||
yield operation
|
||||
finally:
|
||||
_ACTIVE.reset(token)
|
||||
|
||||
|
||||
def bound_attachment_path(owner, selector):
|
||||
operation = active_owned_operation()
|
||||
if operation is None:
|
||||
return None
|
||||
operation.validate()
|
||||
for attachment in operation.attachments:
|
||||
if owner == operation.owner and selector == "odysseus://attachment/" + attachment.record.record_id:
|
||||
return attachment.file.path
|
||||
raise ResourceIdentityError("Attachment is not declared by this operation")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Existing sensitive-path policy shared by tools and evidence observation.
|
||||
|
||||
This is a deny predicate, not an authorization grant or a workspace scope.
|
||||
"""
|
||||
import os
|
||||
|
||||
_SENSITIVE_BASENAMES: set[str] = {
|
||||
".ssh", ".gnupg", ".gitconfig",
|
||||
".bashrc", ".bash_profile", ".bash_logout",
|
||||
".zshrc", ".zprofile", ".zshenv",
|
||||
".profile", ".tcshrc", ".cshrc", ".env", ".netrc",
|
||||
}
|
||||
_SENSITIVE_FILE_PATTERNS: tuple[str, ...] = (
|
||||
"authorized_keys", "id_rsa", "id_ed25519", "id_ecdsa",
|
||||
"known_hosts", "auth.json", "app.db", "settings.json",
|
||||
)
|
||||
_SENSITIVE_BASENAMES_CF = frozenset(b.casefold() for b in _SENSITIVE_BASENAMES)
|
||||
_SENSITIVE_FILE_PATTERNS_CF = frozenset(p.casefold() for p in _SENSITIVE_FILE_PATTERNS)
|
||||
|
||||
|
||||
def _is_sensitive_path(resolved: str) -> bool:
|
||||
# Case folding is required even on POSIX: default macOS volumes are
|
||||
# case insensitive but os.path.normcase there does not fold path names.
|
||||
parts = [p.casefold() for p in resolved.split(os.sep)]
|
||||
filename = parts[-1] if parts else ""
|
||||
return any(part in _SENSITIVE_BASENAMES_CF for part in parts) or filename in _SENSITIVE_FILE_PATTERNS_CF
|
||||
@@ -0,0 +1,536 @@
|
||||
"""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, field
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
from uuid import uuid4
|
||||
from core.atomic_io import store_transaction
|
||||
|
||||
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.
|
||||
# Stale/dead/unverifiable resources on EITHER side are conservatively
|
||||
# excluded from the resulting authority — a normal process exit must not
|
||||
# crash child authority intersection.
|
||||
live_parent = []
|
||||
for resource in parent:
|
||||
try:
|
||||
validate(resource)
|
||||
live_parent.append(resource)
|
||||
except ResourceIdentityError:
|
||||
continue
|
||||
live_child = set()
|
||||
for resource in child:
|
||||
try:
|
||||
validate(resource)
|
||||
live_child.add(resource)
|
||||
except ResourceIdentityError:
|
||||
continue
|
||||
return tuple(resource for resource in live_parent if resource in live_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))
|
||||
|
||||
|
||||
class _LaunchUse:
|
||||
"""Non-persisted one-use producer reservation, shared by approval copies."""
|
||||
def __init__(self):
|
||||
self.used = False
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def claim(self):
|
||||
with self.lock:
|
||||
if self.used:
|
||||
raise ResourceIdentityError("Launch reservation has already been used")
|
||||
self.used = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundProcessOperation:
|
||||
operation: object
|
||||
request_id: str
|
||||
owner: str
|
||||
thread_id: str
|
||||
launch: ProcessLaunchResource | None = None
|
||||
jobs: tuple[BackgroundJobResource, ...] = ()
|
||||
processes: tuple[ProcessResource, ...] = ()
|
||||
exact_approval: object | None = None
|
||||
_launch_use: _LaunchUse = field(default_factory=_LaunchUse, compare=False, repr=False)
|
||||
|
||||
def __post_init__(self):
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
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()
|
||||
if self._launch_use.used:
|
||||
raise ResourceIdentityError("Launch reservation has already been used")
|
||||
for job in self.jobs:
|
||||
validate_job(job, mutation=self.operation.action in {"kill", "stop", "cancel", "terminate", "ack"})
|
||||
for process in self.processes:
|
||||
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()
|
||||
if operation.launch is not None:
|
||||
# One fresh authoritative scan for each execution binding. Resolution
|
||||
# and producer entry retain cheap exact identity checks; no scan is
|
||||
# reused across independent bindings or persisted in an approval.
|
||||
guard_launch_workspace(operation.launch.scope.root)
|
||||
token = _ACTIVE.set(operation)
|
||||
try:
|
||||
yield operation
|
||||
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 import browser_identity
|
||||
from src.agent_runtime.resources import _control_plane_path, _control_plane_snapshot
|
||||
control = (Path(bg_jobs._STORE), Path(bg_jobs._JOBS_DIR), containment._store_path(), _LAUNCH_DIR,
|
||||
Path(constants.BROWSER_RESOURCES_DIR),
|
||||
browser_identity.STATE_ROOT,
|
||||
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
|
||||
snapshot = None
|
||||
for directory, dirs, files in os.walk(base, followlinks=False, onerror=unresolved):
|
||||
for name in (*dirs, *files):
|
||||
path = Path(directory) / name
|
||||
info = path.lstat()
|
||||
if path.is_symlink() or info.st_nlink > 1:
|
||||
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")
|
||||
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")
|
||||
bound = active_process_operation()
|
||||
if bound is not None:
|
||||
if bound.launch != launch:
|
||||
raise ResourceIdentityError("Publication differs from the bound launch")
|
||||
bound._launch_use.claim()
|
||||
atomic_write_json(path, {"launch": launch.to_dict(), "authority": authority.to_dict(),
|
||||
"containment_id": containment_id, "job": job.to_dict() if job else None,
|
||||
"processes": [p.to_dict() for p in processes]})
|
||||
|
||||
|
||||
@store_transaction(lambda: _LAUNCH_DIR / "publication")
|
||||
def retire_launch(launch, containment_id, *, job=None):
|
||||
"""Remove only this exact producer publication; never a replacement.
|
||||
|
||||
Callers establish the lifetime end (verified foreground teardown, or exact
|
||||
background history pruning). Missing/malformed/replaced state is retained.
|
||||
One-use launch reservations live in the bound operation, not this file.
|
||||
"""
|
||||
path = launch_path(launch.generation)
|
||||
try:
|
||||
published = json.loads(path.read_text())
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
if (not isinstance(published, dict)
|
||||
or published.get("launch") != launch.to_dict()
|
||||
or published.get("containment_id") != containment_id
|
||||
or published.get("job") != (job.to_dict() if job else None)):
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
|
||||
@store_transaction(lambda: _LAUNCH_DIR / "publication")
|
||||
def prune_foreground_publications():
|
||||
"""Startup-only recovery: retire foreground generations without a caller.
|
||||
|
||||
A dead/replaced manager cannot resume attachment. A missing receipt also
|
||||
makes attachment impossible; publication cannot reconstruct that receipt.
|
||||
Its process tree still belongs to containment recovery; deleting a
|
||||
publication never signals or asserts tree death. Live/unverifiable managers
|
||||
retain publication even after child teardown: attachment may still need it.
|
||||
Background history stays intact.
|
||||
"""
|
||||
from src import containment
|
||||
from src import process_ownership
|
||||
try:
|
||||
receipts = json.loads(containment._store_path().read_text())
|
||||
except FileNotFoundError:
|
||||
receipts = {}
|
||||
except (OSError, ValueError):
|
||||
return 0 # Unreadable state is not evidence that consumers are gone.
|
||||
if not isinstance(receipts, dict) or any(not isinstance(r, dict) for r in receipts.values()):
|
||||
return 0
|
||||
retired = 0
|
||||
for path in _LAUNCH_DIR.glob("*.json"):
|
||||
try:
|
||||
published = json.loads(path.read_text())
|
||||
launch = ProcessLaunchResource.from_dict(published["launch"])
|
||||
receipt = receipts.get(published["containment_id"])
|
||||
abandoned = (receipt is not None
|
||||
and type(receipt.get("manager_pid")) is int and receipt["manager_pid"] > 0
|
||||
and isinstance(receipt.get("manager_token"), str) and bool(receipt["manager_token"])
|
||||
and process_ownership.verify(receipt["manager_pid"], receipt["manager_token"]) in {
|
||||
process_ownership.GONE, process_ownership.FOREIGN})
|
||||
if (published.get("job") is None and path == launch_path(launch.generation)
|
||||
and (receipt is None or (
|
||||
receipt.get("launch_generation") == launch.generation
|
||||
and receipt.get("id") == published["containment_id"]
|
||||
and abandoned))):
|
||||
# Already under the publication lock; no nested file lock.
|
||||
path.unlink()
|
||||
retired += 1
|
||||
except (ValueError, TypeError, KeyError, OSError):
|
||||
continue
|
||||
return retired
|
||||
|
||||
|
||||
@store_transaction(lambda: _LAUNCH_DIR / "publication")
|
||||
def attach_containment_processes(launch, containment_id):
|
||||
"""Attach producer-frozen lifecycle records; never capture a current PID."""
|
||||
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)):
|
||||
pid = record.get(pid_key)
|
||||
token = record.get(token_key)
|
||||
if not pid or not token:
|
||||
continue
|
||||
processes.append(ProcessResource("native:containment", launch.owner, launch.request_id,
|
||||
launch.thread_id, ProcessIdentity(pid, token, record.get(group_key) if group_key else None),
|
||||
role, "", containment_id))
|
||||
from core.atomic_io import atomic_write_json
|
||||
published["processes"] = [p.to_dict() for p in processes]
|
||||
atomic_write_json(path, published)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Backend resolution and pinning, independent of transport and lifecycle.
|
||||
|
||||
Connection/configuration incarnations here are not process identities. Backend
|
||||
snapshots are captured by trusted admission; discovery never supplies a grant.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from uuid import uuid4
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import json
|
||||
|
||||
from src.agent_runtime.resources import ExternalResource, NativeBackendResource, ResourceIdentityError
|
||||
|
||||
|
||||
def endpoint_identity(url):
|
||||
"""Credential-free origin. Paths may themselves contain access tokens."""
|
||||
if not isinstance(url, str) or any(c in url for c in ("\0", "\n", "\r")):
|
||||
raise ValueError("Malformed resource endpoint")
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ValueError("Resource endpoint requires an HTTP origin")
|
||||
host = parsed.hostname.lower()
|
||||
if ":" in host:
|
||||
host = "[" + host + "]"
|
||||
port = parsed.port
|
||||
if port and port != (443 if parsed.scheme == "https" else 80):
|
||||
host += f":{port}"
|
||||
return urlunsplit((parsed.scheme, host, "", "", ""))
|
||||
|
||||
|
||||
_CLIENT_ENDPOINTS = {}
|
||||
_CONFIG_KEY = secrets.token_bytes(32)
|
||||
|
||||
|
||||
def configuration_incarnation(value):
|
||||
"""Opaque in-process configuration identity, including secret URL changes."""
|
||||
return hmac.new(_CONFIG_KEY, str(value).encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _client_resource(tool, context, *, admission):
|
||||
from src.tool_execution import _client_bridge, _tui_host_bridge_patch_url, _ROUTED_BRIDGE_TOOLS
|
||||
bridge = _client_bridge(context)
|
||||
target = _tui_host_bridge_patch_url(context) if tool == "apply_patch" else None
|
||||
if target is not None:
|
||||
url = target[0]
|
||||
elif bridge is not None and (tool in _ROUTED_BRIDGE_TOOLS or tool == "host_shell"):
|
||||
url = bridge["url"]
|
||||
else:
|
||||
return None
|
||||
endpoint = endpoint_identity(url)
|
||||
# Never cache credentials. A request cannot create a registry entry during
|
||||
# dispatch; only trusted server admission may register an endpoint.
|
||||
key = configuration_incarnation((url, bridge.get("token") if bridge else None))
|
||||
if admission:
|
||||
_CLIENT_ENDPOINTS.setdefault(key, uuid4().hex)
|
||||
incarnation = _CLIENT_ENDPOINTS.get(key)
|
||||
if incarnation is None:
|
||||
raise ResourceIdentityError("External bridge endpoint is not sealed")
|
||||
return ExternalResource("client_bridge", endpoint, "tui", tool, incarnation)
|
||||
|
||||
|
||||
def http_bridge_resource(tool, context, *, admission=False):
|
||||
config = context.get("external_execution_bridge") if isinstance(context, dict) else None
|
||||
if not isinstance(config, dict) or tool not in (config.get("supported_tools") or ()):
|
||||
return None
|
||||
url, token = config.get("url"), config.get("token")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ResourceIdentityError("External HTTP bridge has no server configuration")
|
||||
epoch = configuration_incarnation((url, token, tuple(sorted(config["supported_tools"]))))
|
||||
if admission:
|
||||
_CLIENT_ENDPOINTS.setdefault(epoch, epoch)
|
||||
if epoch not in _CLIENT_ENDPOINTS:
|
||||
raise ResourceIdentityError("External HTTP bridge configuration is not sealed")
|
||||
return ExternalResource("execution_bridge", endpoint_identity(url), "request_local_http", tool, epoch)
|
||||
|
||||
|
||||
def integration_resource(config):
|
||||
if not isinstance(config, dict) or not config.get("enabled", True) or not isinstance(config.get("id"), str) or not config["id"]:
|
||||
raise ResourceIdentityError("Integration identity is unresolved")
|
||||
endpoint = endpoint_identity(config.get("base_url"))
|
||||
epoch = configuration_incarnation(json.dumps(config, sort_keys=True, allow_nan=False))
|
||||
return ExternalResource("integration", endpoint, config["id"], "api_call", epoch)
|
||||
|
||||
|
||||
def api_arguments(content):
|
||||
if content.lstrip().startswith("{"):
|
||||
args = json.loads(content)
|
||||
else:
|
||||
lines = content.strip().split("\n", 2)
|
||||
args = {"integration": lines[0].strip()}
|
||||
if len(lines) > 1:
|
||||
method, _, path = lines[1].strip().partition(" ")
|
||||
args.update(method=method, path=path or "/")
|
||||
if len(lines) > 2:
|
||||
args["body"] = json.loads(lines[2])
|
||||
selector = args.get("integration")
|
||||
if not isinstance(selector, str) or not selector.strip():
|
||||
raise ResourceIdentityError("Integration selector is unresolved")
|
||||
return args
|
||||
|
||||
|
||||
def resolve_backend(tool, *, context=None, admission=False, content="", owner=None):
|
||||
from src.tool_execution import get_active_execution_bridge, get_mcp_manager, _MCP_TOOL_MAP
|
||||
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||
bridge = get_active_execution_bridge()
|
||||
if bridge is not None and tool in bridge.supported_tools:
|
||||
return bridge.resource_identity(tool)
|
||||
configured_bridge = http_bridge_resource(tool, context, admission=admission)
|
||||
if configured_bridge is not None:
|
||||
return configured_bridge
|
||||
client = _client_resource(tool, context, admission=admission)
|
||||
if client is not None:
|
||||
return client
|
||||
if tool == "api_call":
|
||||
from src.integrations import load_integrations
|
||||
selector = api_arguments(content)["integration"]
|
||||
rows = [row for row in load_integrations() if row.get("id") == selector
|
||||
or str(row.get("name", "")).casefold() == selector.casefold()]
|
||||
if len(rows) != 1:
|
||||
raise ResourceIdentityError("Integration alias is missing or ambiguous")
|
||||
return integration_resource(rows[0])
|
||||
qualified = tool
|
||||
required = tool.startswith("mcp__") or tool in BUILTIN_EMAIL_TOOLS
|
||||
if tool in BUILTIN_EMAIL_TOOLS:
|
||||
qualified = "mcp__email__" + tool
|
||||
elif tool in _MCP_TOOL_MAP and tool not in {"read_file", "write_file", "generate_image"}:
|
||||
server, name = _MCP_TOOL_MAP[tool]
|
||||
qualified = f"mcp__{server}__{name}"
|
||||
if qualified.startswith("mcp__"):
|
||||
manager = get_mcp_manager()
|
||||
identity = manager.resource_identity(qualified) if manager is not None else None
|
||||
if isinstance(identity, ExternalResource):
|
||||
if identity.owner and owner != identity.owner:
|
||||
raise ResourceIdentityError("MCP backend belongs to another owner")
|
||||
return identity
|
||||
if required:
|
||||
raise ResourceIdentityError("MCP backend/tool identity is unresolved")
|
||||
if tool == "host_shell":
|
||||
raise ResourceIdentityError("Host-shell backend identity is unresolved")
|
||||
return NativeBackendResource(tool)
|
||||
|
||||
|
||||
def seal_backends(tools, *, context=None, owner=None):
|
||||
result = []
|
||||
for tool in tools:
|
||||
try:
|
||||
if tool == "api_call":
|
||||
# A generic API operation grant does not select an integration.
|
||||
# Trusted admission must supply its explicit backend identity,
|
||||
# or a user can approve one fully sealed exact operation.
|
||||
continue
|
||||
result.append(resolve_backend(tool, context=context, admission=True, owner=owner))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
continue
|
||||
return tuple(dict.fromkeys(result))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundBackendOperation:
|
||||
resource: ExternalResource | NativeBackendResource
|
||||
request_id: str
|
||||
owner: str
|
||||
session_id: str
|
||||
transport_tool: str
|
||||
exact_input: str
|
||||
|
||||
def __post_init__(self):
|
||||
if not isinstance(self.resource, (ExternalResource, NativeBackendResource)):
|
||||
raise ValueError("Malformed bound backend operation")
|
||||
if any(not isinstance(v, str) for v in (self.request_id, self.owner, self.session_id, self.transport_tool, self.exact_input)):
|
||||
raise ValueError("Malformed backend operation binding")
|
||||
|
||||
def to_dict(self):
|
||||
# Exact arguments/selectors are already digest-bound by the approval's
|
||||
# original content. Keep credentials out of the identity serializer.
|
||||
return {"resource": self.resource.to_dict(), "request_id": self.request_id,
|
||||
"owner": self.owner, "session_id": self.session_id, "tool": self.transport_tool,
|
||||
"input_digest": configuration_incarnation(self.exact_input)}
|
||||
|
||||
def validate(self, context=None):
|
||||
current = resolve_backend(self.transport_tool, context=context, content=self.exact_input, owner=self.owner)
|
||||
if current != self.resource:
|
||||
# A pinned native backend remains native when MCP availability
|
||||
# changes. It cannot be upgraded to an external backend.
|
||||
if isinstance(self.resource, NativeBackendResource) and isinstance(current, ExternalResource) and current.namespace == "mcp":
|
||||
return
|
||||
raise ResourceIdentityError("Backend resource identity changed")
|
||||
|
||||
|
||||
def bind_backend_for_operation(authority, operation, *, context=None, approved=None, exact_admission=False):
|
||||
current = resolve_backend(operation.transport_tool, context=context, content=operation.input, owner=authority.owner)
|
||||
native = NativeBackendResource(operation.transport_tool)
|
||||
if approved is not None:
|
||||
if (not isinstance(approved, BoundBackendOperation)
|
||||
or (approved.request_id and approved.request_id != authority.request_id)
|
||||
or (approved.owner, approved.session_id) != (authority.owner, authority.session_id)
|
||||
or (approved.transport_tool, approved.exact_input) != (operation.transport_tool, operation.input)):
|
||||
raise ResourceIdentityError("Approved backend binding changed")
|
||||
selected = approved.resource
|
||||
elif current in authority.backend_resources:
|
||||
selected = current
|
||||
elif native in authority.backend_resources:
|
||||
selected = native
|
||||
elif isinstance(current, NativeBackendResource) and not authority.inherited:
|
||||
# Legacy operation authority can only retain the fixed local backend;
|
||||
# it cannot reconstruct any external backend from current availability.
|
||||
selected = current
|
||||
else:
|
||||
raise ResourceIdentityError("External backend is outside sealed request scope")
|
||||
if isinstance(selected, ExternalResource) and selected not in authority.backend_resources:
|
||||
if not (exact_admission and approved is not None and not authority.inherited):
|
||||
raise ResourceIdentityError("External backend exceeds parent/request scope")
|
||||
bound = BoundBackendOperation(selected, authority.request_id, authority.owner, authority.session_id,
|
||||
operation.transport_tool, operation.input)
|
||||
bound.validate(context)
|
||||
return bound
|
||||
|
||||
|
||||
_ACTIVE = ContextVar("backend_resource_operation", default=None)
|
||||
|
||||
|
||||
def active_backend_operation():
|
||||
return _ACTIVE.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_backend_operation(operation):
|
||||
if operation is not None and not isinstance(operation, BoundBackendOperation):
|
||||
raise TypeError("Backend operation must be server-owned")
|
||||
token = _ACTIVE.set(operation)
|
||||
try:
|
||||
yield operation
|
||||
finally:
|
||||
_ACTIVE.reset(token)
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Resolve native filesystem selectors once, after operation admission.
|
||||
|
||||
Resolution produces inert bindings; the dispatcher still owns authority,
|
||||
TurnContract, security and approval gates. No remote filesystem is resolved here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
|
||||
from src.agent_runtime.authority import ExactOperation
|
||||
from src.agent_runtime.resources import FilesystemResource, FilesystemRoot
|
||||
from src.path_confinement import canonical_root, confine
|
||||
|
||||
|
||||
NATIVE_FILESYSTEM_TOOLS = frozenset({
|
||||
"read_file", "write_file", "edit_file", "apply_patch", "ls", "glob", "grep",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceBinding:
|
||||
role: str
|
||||
resource: FilesystemResource
|
||||
|
||||
def __post_init__(self):
|
||||
if self.role not in {"source", "target", "destination", "search_root"} or not isinstance(self.resource, FilesystemResource):
|
||||
raise ValueError("Malformed operation resource binding")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundFilesystemOperation:
|
||||
operation: ExactOperation
|
||||
execution_input: str
|
||||
bindings: tuple[ResourceBinding, ...]
|
||||
# Empty only for inert proposal resolution without an originating request.
|
||||
request_id: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if (not isinstance(self.operation, ExactOperation)
|
||||
or not isinstance(self.execution_input, str)
|
||||
or not isinstance(self.bindings, tuple) or not self.bindings
|
||||
or any(not isinstance(b, ResourceBinding) for b in self.bindings)):
|
||||
raise ValueError("Malformed resource-bound operation")
|
||||
if not isinstance(self.request_id, str) or any(c in self.request_id for c in ("\0", "\n", "\r")):
|
||||
raise ValueError("Malformed resource operation request identity")
|
||||
if (self.operation.action in {"move", "rename"}
|
||||
and (len(self.bindings) != 2 or {b.role for b in self.bindings} != {"source", "destination"}
|
||||
or len({b.resource.path for b in self.bindings}) != 2
|
||||
or next(b for b in self.bindings if b.role == "source").resource.identity is None)):
|
||||
raise ValueError("Move/rename must bind distinct source and destination")
|
||||
|
||||
@property
|
||||
def write_intent(self):
|
||||
"""Trusted original intent; execution_input only normalizes the path."""
|
||||
if self.operation.tool != "write_file":
|
||||
return None
|
||||
from src.agent_tools.filesystem_tools import _parse_write_intent
|
||||
return _parse_write_intent(self.operation.input)
|
||||
|
||||
def validate(self):
|
||||
for binding in self.bindings:
|
||||
binding.resource.validate()
|
||||
|
||||
def to_dict(self):
|
||||
return {"request_id": self.request_id, "tool": self.operation.transport_tool, "input": self.operation.input,
|
||||
"execution_input": self.execution_input,
|
||||
"bindings": [{"role": b.role, "resource": b.resource.to_dict()} for b in self.bindings]}
|
||||
|
||||
def resolve_path(self, selector, *, search=False):
|
||||
"""Consume declared canonical targets; permit bounded search descendants."""
|
||||
if not isinstance(selector, str):
|
||||
raise ValueError("Resource selector must be a string")
|
||||
value = selector.strip()
|
||||
for binding in self.bindings:
|
||||
resource = binding.resource
|
||||
if value == resource.path or (search and not value and binding.role == "search_root"):
|
||||
resource.validate()
|
||||
return resource.path
|
||||
if not search:
|
||||
for binding in self.bindings:
|
||||
resource = binding.resource
|
||||
if binding.role == "search_root" and resource.identity.kind == "directory":
|
||||
resource.validate()
|
||||
try:
|
||||
path = confine(resource.path, value)
|
||||
return FilesystemResource.resolve(resource.root, path).path
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
continue
|
||||
raise ValueError("Path is not declared by the resource-bound operation")
|
||||
|
||||
|
||||
def _resolve(roots, selector, *, workspace, allow_missing):
|
||||
if not isinstance(selector, str) or not selector.strip():
|
||||
raise ValueError("Resource path is required and must be a string")
|
||||
value = selector.strip()
|
||||
# The virtual alias belongs to the request workspace, even when a child
|
||||
# narrows its root to a subdirectory of that workspace.
|
||||
if value == "/workspace" or value.startswith("/workspace/"):
|
||||
if not workspace:
|
||||
raise ValueError("Workspace alias has no server-owned workspace")
|
||||
base = canonical_root(workspace)
|
||||
value = base if value == "/workspace" else os.path.join(base, value[len("/workspace/"):])
|
||||
elif not os.path.isabs(os.path.expanduser(value)):
|
||||
if workspace:
|
||||
value = os.path.join(canonical_root(workspace), value)
|
||||
elif len(roots) == 1:
|
||||
value = os.path.join(roots[0].path, value)
|
||||
else:
|
||||
raise ValueError("Relative resource path has no unambiguous server root")
|
||||
for root in roots:
|
||||
try:
|
||||
return FilesystemResource.resolve(root, value, allow_missing=allow_missing)
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
continue
|
||||
boundary = "the workspace" if workspace else "the sealed roots"
|
||||
raise ValueError(f"Resource path is outside {boundary}, sensitive, missing or changed")
|
||||
|
||||
|
||||
def resolve_filesystem_operation(operation, *, roots, workspace="", request_id=""):
|
||||
"""Server adapter. This does not grant the operation or authorize its roots."""
|
||||
if not isinstance(operation, ExactOperation) or operation.tool not in NATIVE_FILESYSTEM_TOOLS:
|
||||
raise ValueError("Operation has no native filesystem adapter")
|
||||
if (not isinstance(roots, tuple) or not roots
|
||||
or any(not isinstance(r, FilesystemRoot) for r in roots)):
|
||||
raise ValueError("Native filesystem operation requires a sealed resource root")
|
||||
content = operation.input
|
||||
if operation.tool == "write_file":
|
||||
from src.agent_tools.filesystem_tools import _parse_write_intent
|
||||
original_path, _, section, _ = _parse_write_intent(content)
|
||||
if not section:
|
||||
raise ValueError("write_file: content required; missing content section")
|
||||
if original_path.endswith(("/", "\\")):
|
||||
raise ValueError("write_file: target is a directory")
|
||||
args = json.loads(content) if content.lstrip().startswith("{") else None
|
||||
if args is not None and not isinstance(args, dict):
|
||||
raise ValueError("Filesystem input must be an object")
|
||||
bindings = []
|
||||
|
||||
def bind(selector, role, *, missing=False):
|
||||
resource = _resolve(roots, selector, workspace=workspace, allow_missing=missing)
|
||||
bindings.append(ResourceBinding(role, resource))
|
||||
return resource.path
|
||||
|
||||
tool = operation.tool
|
||||
if tool == "apply_patch":
|
||||
from src.agent_tools.filesystem_tools import _parse_agent_patch
|
||||
if args is None:
|
||||
patch = content
|
||||
else:
|
||||
variants = [args[k] for k in ("patch_text", "patchText", "patch") if k in args]
|
||||
if not variants or any(not isinstance(p, str) or p != variants[0] for p in variants):
|
||||
raise ValueError("Patch requires one unambiguous patch_text")
|
||||
patch = variants[0]
|
||||
ops = _parse_agent_patch(patch)
|
||||
paths = [bind(op["path"], "destination" if op["kind"] == "add" else "target",
|
||||
missing=op["kind"] == "add") for op in ops]
|
||||
objects = [b.resource.identity for b in bindings if b.resource.identity is not None]
|
||||
if len(set(paths)) != len(paths) or len(set(objects)) != len(objects):
|
||||
raise ValueError("Patch targets resolve to the same resource")
|
||||
path_iter = iter(paths)
|
||||
lines = patch.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
for marker in ("*** Add File: ", "*** Update File: ", "*** Delete File: "):
|
||||
if line.startswith(marker):
|
||||
lines[i] = marker + next(path_iter)
|
||||
break
|
||||
execution_input = json.dumps({"patch_text": "\n".join(lines)}, sort_keys=True)
|
||||
else:
|
||||
search = tool in {"ls", "glob", "grep"}
|
||||
if args is None:
|
||||
if tool == "write_file":
|
||||
path, _, body = content.partition("\n")
|
||||
args = {"path": path.strip(), "content": body}
|
||||
elif tool == "edit_file":
|
||||
raise ValueError("edit_file requires a JSON object")
|
||||
elif tool in {"glob", "grep"}:
|
||||
args = {"pattern": content.strip()}
|
||||
else:
|
||||
args = {"path": content.split("\n", 1)[0].strip()}
|
||||
selector = args.get("path", "" if search else None)
|
||||
if search and selector == "":
|
||||
if workspace:
|
||||
selector = canonical_root(workspace)
|
||||
elif len(roots) == 1:
|
||||
selector = roots[0].path
|
||||
else:
|
||||
raise ValueError("Search root is unresolved")
|
||||
args["path"] = bind(selector, "search_root" if search else
|
||||
"source" if tool == "read_file" else "destination" if tool == "write_file" else "target",
|
||||
missing=tool in {"write_file", "read_file"})
|
||||
execution_input = json.dumps(args, sort_keys=True, allow_nan=False)
|
||||
bound = BoundFilesystemOperation(operation, execution_input, tuple(bindings), request_id)
|
||||
bound.validate()
|
||||
return bound
|
||||
|
||||
|
||||
_ACTIVE: ContextVar[BoundFilesystemOperation | None] = ContextVar("resource_operation", default=None)
|
||||
|
||||
|
||||
def active_resource_operation():
|
||||
return _ACTIVE.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_resource_operation(operation):
|
||||
if operation is not None and not isinstance(operation, BoundFilesystemOperation):
|
||||
raise TypeError("Resource operation must be server-owned")
|
||||
if operation is not None:
|
||||
operation.validate()
|
||||
token = _ACTIVE.set(operation)
|
||||
try:
|
||||
yield operation
|
||||
finally:
|
||||
_ACTIVE.reset(token)
|
||||
@@ -0,0 +1,740 @@
|
||||
"""Inert server-owned resource identities, independent of operation authority.
|
||||
|
||||
Filesystem observations detect replacement; they are not held kernel handles or
|
||||
content/effect evidence. Other producers must supply their own incarnations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import sys
|
||||
|
||||
from src.agent_runtime.path_policy import _is_sensitive_path
|
||||
from src.path_confinement import canonical_root, confine
|
||||
|
||||
|
||||
def _text(value, label, *, optional=False):
|
||||
if (not isinstance(value, str) or (not value and not optional)
|
||||
or any(c in value for c in ("\0", "\n", "\r"))):
|
||||
raise ValueError(f"Invalid resource {label}")
|
||||
|
||||
|
||||
def _absolute(value):
|
||||
_text(value, "path")
|
||||
if not os.path.isabs(value) or os.path.normpath(value) != value:
|
||||
raise ValueError("Resource path must be canonical and absolute")
|
||||
|
||||
|
||||
def _effect_store_dirs():
|
||||
from src import constants
|
||||
directories = {canonical_root(os.path.join(constants.DATA_DIR, "effects"))}
|
||||
effect_log = sys.modules.get("src.agent_runtime.effect_log")
|
||||
if effect_log is not None:
|
||||
directories.add(canonical_root(effect_log.EFFECTS_DIR))
|
||||
return directories
|
||||
|
||||
|
||||
def _aliases_effect_store(candidate, directories):
|
||||
"""Whether ``candidate`` (an ``os.stat`` result) is a hardlink into the effect store.
|
||||
|
||||
The effect log and launch index refuse any file with more than one link,
|
||||
and the store is flat. So only a multiply linked regular file on the
|
||||
store's device can alias store state, and only then is the store listed,
|
||||
one directory level, by inode. Ordinary single-link files cost nothing,
|
||||
and the cost never depends on recursive store size. Uninspectable store
|
||||
state fails closed.
|
||||
"""
|
||||
if not stat.S_ISREG(candidate.st_mode) or candidate.st_nlink < 2:
|
||||
return False
|
||||
for directory in directories:
|
||||
try:
|
||||
if os.stat(directory).st_dev != candidate.st_dev:
|
||||
continue
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
if entry.inode() != candidate.st_ino:
|
||||
continue
|
||||
observed = entry.stat(follow_symlinks=False)
|
||||
if (observed.st_dev, observed.st_ino) == (candidate.st_dev, candidate.st_ino):
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _control_plane_snapshot():
|
||||
# Execution snapshots/receipts are server state, even if a workspace root
|
||||
# contains the data directory. A writable user file cannot mint authority.
|
||||
from src import constants
|
||||
protected = {canonical_root(getattr(constants, name)) for name in (
|
||||
"BG_JOBS_FILE", "CONTAINMENT_STATE_FILE", "APP_DB", "AUTH_FILE",
|
||||
"SETTINGS_FILE", "SESSIONS_FILE", "USER_PREFS_FILE", "VAULT_FILE",
|
||||
"SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB", "MEMORY_FILE", "INTEGRATIONS_FILE",
|
||||
)}
|
||||
job_dirs = {canonical_root(constants.BG_JOBS_DIR), canonical_root(constants.PROCESS_RESOURCES_DIR),
|
||||
canonical_root(constants.BROWSER_RESOURCES_DIR)}
|
||||
browser = sys.modules.get("src.browser_identity")
|
||||
if browser is not None:
|
||||
job_dirs.add(canonical_root(browser.STATE_ROOT))
|
||||
processes = sys.modules.get("src.agent_runtime.process_resources")
|
||||
if processes is not None:
|
||||
job_dirs.add(canonical_root(processes._LAUNCH_DIR))
|
||||
# Durable effect claims/outcomes/observations are server evidence state.
|
||||
# They are prefix-protected below, but never inventoried: the store grows
|
||||
# with every run. Hardlink aliases are caught by ``_aliases_effect_store``.
|
||||
effect_dirs = _effect_store_dirs()
|
||||
# Producers may have configured paths different from the default constants.
|
||||
# Inspect already-loaded server metadata without initializing a store here.
|
||||
bg = sys.modules.get("src.bg_jobs")
|
||||
if bg is not None:
|
||||
for name, targets in (("_STORE", protected), ("_JOBS_DIR", job_dirs)):
|
||||
value = getattr(bg, name, None)
|
||||
if isinstance(value, (str, os.PathLike)):
|
||||
targets.add(canonical_root(value))
|
||||
containment = sys.modules.get("src.containment")
|
||||
if containment is not None:
|
||||
value = containment._store_path()
|
||||
if isinstance(value, (str, os.PathLike)):
|
||||
protected.add(canonical_root(value))
|
||||
database = sys.modules.get("core.database")
|
||||
url = getattr(getattr(database, "engine", None), "url", None)
|
||||
if url is not None and url.get_backend_name() == "sqlite":
|
||||
location = url.database
|
||||
if isinstance(location, str) and location not in {"", ":memory:"}:
|
||||
from urllib.parse import unquote
|
||||
if location.startswith("file:"):
|
||||
location = unquote(location[5:].split("?", 1)[0])
|
||||
protected.update(canonical_root(location + suffix) for suffix in ("", "-wal", "-shm", "-journal"))
|
||||
from src.tool_utils import get_upload_handler
|
||||
uploader = get_upload_handler()
|
||||
if uploader is not None and isinstance(getattr(uploader, "upload_dir", None), (str, os.PathLike)):
|
||||
protected.add(canonical_root(Path(uploader.upload_dir) / "uploads.json"))
|
||||
# Prefix protection covers the complete runtime trees. Public policy denies
|
||||
# every regular hardlink alias, so collecting all child inodes here would
|
||||
# add an unbounded recursive state inventory without widening protection.
|
||||
protected.update(canonical_root(getattr(constants, name) + suffix)
|
||||
for name in ("APP_DB", "SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB")
|
||||
for suffix in ("-wal", "-shm", "-journal"))
|
||||
protected.add(canonical_root(Path(constants.DATA_DIR) / ".app_key"))
|
||||
protected.add(canonical_root(Path(constants.UPLOAD_DIR) / "uploads.json"))
|
||||
identities = set()
|
||||
for control in protected:
|
||||
try:
|
||||
observed = os.stat(control)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
identities.add((observed.st_dev, observed.st_ino))
|
||||
# Effect directories are protected by prefix without inventorying children.
|
||||
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.
|
||||
# Public state and hardlink denial also applies to sealed resources. Lazy
|
||||
# import avoids coupling inert identity definitions to dispatcher startup.
|
||||
from src.tool_execution import _is_app_state_path, _is_hardlinked_regular_file
|
||||
if _is_sensitive_path(path) or _is_app_state_path(path) or _is_hardlinked_regular_file(path):
|
||||
return True
|
||||
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):
|
||||
WORKSPACE = "workspace"
|
||||
SCRATCH = "scratch"
|
||||
EXTERNAL = "external"
|
||||
PRIVATE = "private"
|
||||
|
||||
|
||||
class ResourceIdentityError(ValueError):
|
||||
"""An observed execution resource has changed or cannot be resolved."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserSessionObservation:
|
||||
producer_namespace: str
|
||||
producer_version: str
|
||||
platform: str
|
||||
binary_sha256: str
|
||||
configuration_digest: str
|
||||
session_key: str
|
||||
daemon: "ProcessIdentity"
|
||||
browser_instance_digest: str
|
||||
session_incarnation: str
|
||||
|
||||
def __post_init__(self):
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
from src.browser_identity import PRODUCER_HASHES, incarnation
|
||||
if (self.producer_namespace != "native:agent-browser"
|
||||
or self.producer_version != "0.35.0"
|
||||
or PRODUCER_HASHES.get(self.platform) != self.binary_sha256
|
||||
or not isinstance(self.daemon, ProcessIdentity)
|
||||
or type(self.daemon.pid) is not int or self.daemon.pid <= 0
|
||||
or (self.daemon.pgid is not None and (type(self.daemon.pgid) is not int or self.daemon.pgid <= 0))):
|
||||
raise ValueError("Unsupported browser producer observation")
|
||||
import re
|
||||
_text(self.daemon.start_token, "daemon incarnation")
|
||||
if not re.fullmatch(r"ody-[a-f0-9]{24}", self.session_key):
|
||||
raise ValueError("Malformed browser session selector")
|
||||
for value in (self.configuration_digest, self.browser_instance_digest, self.session_incarnation):
|
||||
if not re.fullmatch(r"[a-f0-9]{64}", value):
|
||||
raise ValueError("Malformed browser digest")
|
||||
if incarnation(self) != self.session_incarnation:
|
||||
raise ValueError("Browser incarnation digest changed")
|
||||
|
||||
def to_dict(self):
|
||||
return {**asdict(self), "daemon": self.daemon.to_record()}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__):
|
||||
raise ValueError("Malformed browser observation snapshot")
|
||||
daemon = value["daemon"]
|
||||
if not isinstance(daemon, dict) or set(daemon) != {"pid", "start_token", "pgid"}:
|
||||
raise ValueError("Malformed browser daemon observation")
|
||||
return cls(**{**value, "daemon": ProcessIdentity(**daemon)})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserSessionResource:
|
||||
owner: str
|
||||
thread_id: str
|
||||
observation: BrowserSessionObservation
|
||||
|
||||
def __post_init__(self):
|
||||
_text(self.owner, "browser owner")
|
||||
_text(self.thread_id, "browser thread")
|
||||
if not isinstance(self.observation, BrowserSessionObservation):
|
||||
raise ValueError("Missing browser session observation")
|
||||
|
||||
def validate(self):
|
||||
from src.browser_identity import validate_session
|
||||
validate_session(self)
|
||||
|
||||
def to_dict(self):
|
||||
return {"owner": self.owner, "thread_id": self.thread_id, "observation": self.observation.to_dict()}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != {"owner", "thread_id", "observation"}:
|
||||
raise ValueError("Malformed browser resource snapshot")
|
||||
return cls(value["owner"], value["thread_id"], BrowserSessionObservation.from_dict(value["observation"]))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserPageResource:
|
||||
session: BrowserSessionResource
|
||||
target_id: str
|
||||
loader_id: str
|
||||
resolved_alias: str = ""
|
||||
observed_url: str = ""
|
||||
scope: str = "document"
|
||||
|
||||
def __post_init__(self):
|
||||
import re
|
||||
if not isinstance(self.session, BrowserSessionResource) or not re.fullmatch(r"[A-F0-9]{32}", self.target_id):
|
||||
raise ValueError("Malformed browser page identity")
|
||||
if self.scope not in {"page", "document"}:
|
||||
raise ValueError("Malformed browser page scope")
|
||||
_text(self.loader_id, "document loader", optional=self.scope == "page")
|
||||
_text(self.observed_url, "observed URL", optional=True)
|
||||
if self.resolved_alias and not re.fullmatch(r"t[1-9][0-9]*", self.resolved_alias):
|
||||
raise ValueError("Malformed browser alias metadata")
|
||||
|
||||
def authority_key(self):
|
||||
return (self.session, self.target_id, self.loader_id if self.scope == "document" else None)
|
||||
|
||||
def validate(self):
|
||||
from src.browser_identity import validate_page
|
||||
validate_page(self)
|
||||
|
||||
def to_dict(self):
|
||||
return {**asdict(self), "session": self.session.to_dict()}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__):
|
||||
raise ValueError("Malformed browser page snapshot")
|
||||
return cls(**{**value, "session": BrowserSessionResource.from_dict(value["session"])})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileObjectIdentity:
|
||||
device: int
|
||||
inode: int
|
||||
kind: str
|
||||
|
||||
def __post_init__(self):
|
||||
if (type(self.device) is not int or self.device < 0
|
||||
or type(self.inode) is not int or self.inode <= 0
|
||||
or self.kind not in {"file", "directory"}):
|
||||
raise ValueError("Malformed filesystem object identity")
|
||||
|
||||
@classmethod
|
||||
def observe(cls, path):
|
||||
info = os.stat(path, follow_symlinks=False)
|
||||
kind = ("file" if stat.S_ISREG(info.st_mode) else
|
||||
"directory" if stat.S_ISDIR(info.st_mode) else None)
|
||||
if kind is None:
|
||||
raise ValueError("Filesystem resource must be a regular file or directory")
|
||||
return cls(info.st_dev, info.st_ino, kind)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilesystemRoot:
|
||||
path: str
|
||||
scope: FilesystemScope
|
||||
identity: FileObjectIdentity
|
||||
owner: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
_absolute(self.path)
|
||||
_text(self.owner, "owner", optional=True)
|
||||
if (not isinstance(self.scope, FilesystemScope)
|
||||
or not isinstance(self.identity, FileObjectIdentity)
|
||||
or self.identity.kind != "directory"
|
||||
or os.path.dirname(self.path) == self.path
|
||||
or _is_sensitive_path(self.path)
|
||||
or (self.scope is FilesystemScope.PRIVATE and not self.owner)):
|
||||
raise ValueError("Malformed filesystem root identity")
|
||||
|
||||
@classmethod
|
||||
def seal(cls, path, *, scope=FilesystemScope.WORKSPACE, owner=""):
|
||||
root = canonical_root(path)
|
||||
return cls(root, scope, FileObjectIdentity.observe(root), owner)
|
||||
|
||||
def validate(self):
|
||||
try:
|
||||
if canonical_root(self.path) != self.path or FileObjectIdentity.observe(self.path) != self.identity:
|
||||
raise ResourceIdentityError("Filesystem root identity changed")
|
||||
except (OSError, RuntimeError) as error:
|
||||
raise ResourceIdentityError("Filesystem root identity is unresolved") from error
|
||||
|
||||
def to_dict(self):
|
||||
return {**asdict(self), "scope": self.scope.value}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != {"path", "scope", "identity", "owner"}:
|
||||
raise ValueError("Malformed filesystem root snapshot")
|
||||
return cls(value["path"], FilesystemScope(value["scope"]),
|
||||
FileObjectIdentity(**value["identity"]), value["owner"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathObservation:
|
||||
path: str
|
||||
identity: FileObjectIdentity
|
||||
|
||||
def __post_init__(self):
|
||||
_absolute(self.path)
|
||||
if not isinstance(self.identity, FileObjectIdentity) or self.identity.kind != "directory":
|
||||
raise ValueError("Malformed filesystem ancestor identity")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilesystemResource:
|
||||
root: FilesystemRoot
|
||||
path: str
|
||||
identity: FileObjectIdentity | None
|
||||
ancestors: tuple[PathObservation, ...]
|
||||
|
||||
def __post_init__(self):
|
||||
_absolute(self.path)
|
||||
if (not isinstance(self.root, FilesystemRoot)
|
||||
or not Path(self.path).is_relative_to(self.root.path)
|
||||
or (self.identity is not None and not isinstance(self.identity, FileObjectIdentity))
|
||||
or not isinstance(self.ancestors, tuple)
|
||||
or any(not isinstance(a, PathObservation) for a in self.ancestors)
|
||||
or not self.ancestors
|
||||
or self.ancestors[0] != PathObservation(self.root.path, self.root.identity)):
|
||||
raise ValueError("Malformed filesystem resource identity")
|
||||
parent = Path(self.root.path)
|
||||
expected = [str(parent)]
|
||||
for part in Path(self.path).relative_to(self.root.path).parts[:-1]:
|
||||
parent /= part
|
||||
expected.append(str(parent))
|
||||
if ([a.path for a in self.ancestors] != expected[:len(self.ancestors)]
|
||||
or (self.identity is not None and len(self.ancestors) != len(expected))):
|
||||
raise ValueError("Malformed filesystem ancestor chain")
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, root, selector, *, allow_missing=False):
|
||||
root.validate()
|
||||
# Only this server-owned workspace root supplies the virtual alias.
|
||||
if not isinstance(selector, str):
|
||||
raise ValueError("Resource path must be a string")
|
||||
value = selector.strip()
|
||||
if root.scope is FilesystemScope.WORKSPACE:
|
||||
if value == "/workspace":
|
||||
value = root.path
|
||||
elif value.startswith("/workspace/"):
|
||||
value = os.path.join(root.path, value[len("/workspace/"):])
|
||||
path = confine(root.path, value)
|
||||
if _is_sensitive_path(path) or _control_plane_path(path):
|
||||
raise ValueError("Resource path is sensitive")
|
||||
ancestors = [PathObservation(root.path, root.identity)]
|
||||
relative = Path(path).relative_to(root.path)
|
||||
parent = Path(root.path)
|
||||
missing_parent = False
|
||||
for part in relative.parts[:-1]:
|
||||
parent /= part
|
||||
try:
|
||||
observed = FileObjectIdentity.observe(parent)
|
||||
except FileNotFoundError:
|
||||
missing_parent = True
|
||||
break
|
||||
ancestors.append(PathObservation(str(parent), observed))
|
||||
try:
|
||||
identity = None if missing_parent else FileObjectIdentity.observe(path)
|
||||
except FileNotFoundError:
|
||||
identity = None
|
||||
if identity is None and not allow_missing:
|
||||
raise ValueError("Filesystem resource is unresolved or missing")
|
||||
return cls(root, path, identity, tuple(ancestors))
|
||||
|
||||
def validate(self):
|
||||
try:
|
||||
if self.resolve(self.root, self.path, allow_missing=self.identity is None) != self:
|
||||
raise ResourceIdentityError("Filesystem resource identity changed")
|
||||
except (ValueError, OSError, RuntimeError) as error:
|
||||
raise ResourceIdentityError("Filesystem resource identity changed or is unresolved") from error
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def intersect_roots(parent, child):
|
||||
"""Keep the narrower root only when the observed parent's identity agrees."""
|
||||
result = []
|
||||
for left in parent:
|
||||
for right in child:
|
||||
if (left.scope, left.owner) != (right.scope, right.owner):
|
||||
continue
|
||||
try:
|
||||
left.validate()
|
||||
right.validate()
|
||||
if left == right:
|
||||
result.append(left)
|
||||
continue
|
||||
if Path(right.path).is_relative_to(left.path):
|
||||
# A newly sealed child may not renew a replaced parent root.
|
||||
result.append(right)
|
||||
elif Path(left.path).is_relative_to(right.path):
|
||||
result.append(left)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
continue
|
||||
return tuple(dict.fromkeys(result))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessResource:
|
||||
namespace: str
|
||||
owner: str
|
||||
request_id: str
|
||||
thread_id: str
|
||||
identity: "ProcessIdentity"
|
||||
role: str
|
||||
job_id: str = ""
|
||||
containment_id: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
for name in ("namespace", "request_id", "thread_id"):
|
||||
_text(getattr(self, name), name)
|
||||
for name in ("owner", "job_id", "containment_id"):
|
||||
_text(getattr(self, name), name, optional=True)
|
||||
if (not isinstance(self.identity, ProcessIdentity)
|
||||
or type(self.identity.pid) is not int or self.identity.pid <= 0
|
||||
or (self.identity.pgid is not None and (type(self.identity.pgid) is not int or self.identity.pgid <= 0))
|
||||
or self.role not in {"supervisor", "leader", "namespace_init", "manager", "pty", "service"}):
|
||||
raise ValueError("Malformed process resource identity")
|
||||
supported_roles = {"native:containment": {"leader", "namespace_init"},
|
||||
"native:bg_jobs": {"supervisor"}}
|
||||
if self.role not in supported_roles.get(self.namespace, set()):
|
||||
raise ValueError("Unsupported process producer or role")
|
||||
_text(self.identity.start_token, "process start token")
|
||||
|
||||
def validate(self):
|
||||
if not self.identity.owned() or self.identity.exited():
|
||||
raise ResourceIdentityError("Process resource is stale or unverifiable")
|
||||
|
||||
def to_dict(self):
|
||||
return {"namespace": self.namespace, "owner": self.owner, "request_id": self.request_id,
|
||||
"thread_id": self.thread_id, "identity": self.identity.to_record(), "role": self.role,
|
||||
"job_id": self.job_id, "containment_id": self.containment_id}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
if not isinstance(value, dict) or set(value) != {"namespace", "owner", "request_id", "thread_id", "identity", "role", "job_id", "containment_id"}:
|
||||
raise ValueError("Malformed process resource snapshot")
|
||||
identity = value["identity"]
|
||||
if not isinstance(identity, dict) or set(identity) != {"pid", "start_token", "pgid"}:
|
||||
raise ValueError("Malformed lifecycle identity snapshot")
|
||||
return cls(**{**value, "identity": ProcessIdentity(**identity)})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessLaunchScope:
|
||||
backend: "NativeBackendResource"
|
||||
root: FilesystemRoot
|
||||
required: frozenset[str]
|
||||
runtime_roots: tuple[PathObservation, ...] = ()
|
||||
network: str = "inherit"
|
||||
max_runtime_s: int = 3600
|
||||
|
||||
def __post_init__(self):
|
||||
if (not isinstance(self.backend, NativeBackendResource) or not isinstance(self.root, FilesystemRoot)
|
||||
or not isinstance(self.required, frozenset) or not self.required
|
||||
or any(not isinstance(v, str) or not v for v in self.required)):
|
||||
raise ValueError("Malformed process launch scope")
|
||||
if self.backend.tool_id not in {"bash", "python"}:
|
||||
raise ValueError("Unsupported native launch producer")
|
||||
if (not isinstance(self.runtime_roots, tuple) or any(not isinstance(r, PathObservation) for r in self.runtime_roots)
|
||||
or self.network not in {"inherit", "none"}
|
||||
or type(self.max_runtime_s) is not int or self.max_runtime_s <= 0):
|
||||
raise ValueError("Malformed launch boundary selectors")
|
||||
|
||||
def validate(self):
|
||||
self.root.validate()
|
||||
for runtime in self.runtime_roots:
|
||||
if canonical_root(runtime.path) != runtime.path or FileObjectIdentity.observe(runtime.path) != runtime.identity:
|
||||
raise ResourceIdentityError("Launch runtime root changed")
|
||||
|
||||
def to_dict(self):
|
||||
return {"backend": self.backend.to_dict(), "root": self.root.to_dict(), "required": sorted(self.required),
|
||||
"runtime_roots": [{"path": r.path, "identity": asdict(r.identity)} for r in self.runtime_roots],
|
||||
"network": self.network, "max_runtime_s": self.max_runtime_s}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != {"backend", "root", "required", "runtime_roots", "network", "max_runtime_s"} or not isinstance(value["required"], list) or not isinstance(value["runtime_roots"], list):
|
||||
raise ValueError("Malformed launch scope snapshot")
|
||||
return cls(backend_from_dict(value["backend"]), FilesystemRoot.from_dict(value["root"]), frozenset(value["required"]),
|
||||
tuple(PathObservation(r["path"], FileObjectIdentity(**r["identity"])) for r in value["runtime_roots"]),
|
||||
value["network"], value["max_runtime_s"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessLaunchResource:
|
||||
namespace: str
|
||||
owner: str
|
||||
request_id: str
|
||||
thread_id: str
|
||||
generation: str
|
||||
tool: str
|
||||
input_digest: str
|
||||
scope: ProcessLaunchScope
|
||||
ceiling_digest: str
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "request_id", "thread_id", "generation", "tool", "input_digest", "ceiling_digest"):
|
||||
_text(getattr(self, name), name)
|
||||
_text(self.owner, "owner", optional=True)
|
||||
if not isinstance(self.scope, ProcessLaunchScope) or self.tool != self.scope.backend.tool_id:
|
||||
raise ValueError("Malformed launch resource")
|
||||
import re
|
||||
if (self.namespace != "native:containment" or not re.fullmatch(r"[a-f0-9]{32}", self.generation)
|
||||
or any(not re.fullmatch(r"[a-f0-9]{64}", v) for v in (self.input_digest, self.ceiling_digest))):
|
||||
raise ValueError("Malformed native launch producer or generation")
|
||||
|
||||
def validate(self):
|
||||
self.scope.validate()
|
||||
|
||||
def to_dict(self):
|
||||
return {**{k: getattr(self, k) for k in ("namespace", "owner", "request_id", "thread_id", "generation", "tool", "input_digest", "ceiling_digest")},
|
||||
"scope": self.scope.to_dict()}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != {"namespace", "owner", "request_id", "thread_id", "generation", "tool", "input_digest", "scope", "ceiling_digest"}:
|
||||
raise ValueError("Malformed launch resource snapshot")
|
||||
return cls(**{**value, "scope": ProcessLaunchScope.from_dict(value["scope"])})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackgroundJobResource:
|
||||
namespace: str
|
||||
job_id: str
|
||||
generation: str
|
||||
owner: str
|
||||
request_id: str
|
||||
thread_id: str
|
||||
containment_id: str
|
||||
processes: tuple[ProcessResource, ...]
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "job_id", "generation", "request_id", "thread_id", "containment_id"):
|
||||
_text(getattr(self, name), name)
|
||||
_text(self.owner, "owner", optional=True)
|
||||
import re
|
||||
if (not re.fullmatch(r"[A-Za-z0-9_-]+", self.job_id)
|
||||
or not re.fullmatch(r"[a-f0-9]{32}", self.generation)):
|
||||
raise ValueError("Malformed job selector or launch generation")
|
||||
if (not isinstance(self.processes, tuple) or not self.processes
|
||||
or any(not isinstance(p, ProcessResource) or (p.owner, p.request_id, p.thread_id, p.job_id, p.containment_id)
|
||||
!= (self.owner, self.request_id, self.thread_id, self.job_id, self.containment_id) for p in self.processes)
|
||||
or len({p.role for p in self.processes}) != len(self.processes)):
|
||||
raise ValueError("Malformed background job resource")
|
||||
if self.namespace != "native:bg_jobs" or any(p.namespace != "native:bg_jobs" or p.role != "supervisor" for p in self.processes):
|
||||
raise ValueError("Unsupported job producer or process role")
|
||||
|
||||
def to_dict(self):
|
||||
return {**{k: getattr(self, k) for k in ("namespace", "job_id", "generation", "owner", "request_id", "thread_id", "containment_id")},
|
||||
"processes": [p.to_dict() for p in self.processes]}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != {"namespace", "job_id", "generation", "owner", "request_id", "thread_id", "containment_id", "processes"} or not isinstance(value["processes"], list):
|
||||
raise ValueError("Malformed background resource snapshot")
|
||||
return cls(**{**value, "processes": tuple(ProcessResource.from_dict(p) for p in value["processes"])})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalResource:
|
||||
namespace: str
|
||||
endpoint_id: str
|
||||
server_id: str
|
||||
tool_id: str
|
||||
incarnation: str
|
||||
external: bool = True
|
||||
contained: bool = False
|
||||
owner: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "endpoint_id", "server_id", "tool_id", "incarnation"):
|
||||
_text(getattr(self, name), name)
|
||||
if self.external is not True or self.contained is not False:
|
||||
raise ValueError("External resource cannot attest local containment")
|
||||
_text(self.owner, "external owner", optional=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {"kind": "external", **asdict(self)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeBackendResource:
|
||||
tool_id: str
|
||||
namespace: str = "native"
|
||||
external: bool = False
|
||||
contained: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
_text(self.tool_id, "native tool")
|
||||
if self.namespace != "native" or self.external is not False or self.contained is not False:
|
||||
raise ValueError("Malformed native backend identity")
|
||||
|
||||
def to_dict(self):
|
||||
return {"kind": "native", **asdict(self)}
|
||||
|
||||
|
||||
def backend_from_dict(value):
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("Malformed backend snapshot")
|
||||
fields = dict(value)
|
||||
kind = fields.pop("kind", None)
|
||||
if kind not in {"native", "external"}:
|
||||
raise ValueError("Malformed backend kind")
|
||||
return (NativeBackendResource if kind == "native" else ExternalResource)(**fields)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnedResource:
|
||||
namespace: str
|
||||
owner: str
|
||||
thread_id: str
|
||||
collection: str
|
||||
record_id: str
|
||||
revision: str = ""
|
||||
record_thread_id: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "owner", "thread_id", "collection", "record_id"):
|
||||
_text(getattr(self, name), name)
|
||||
_text(self.revision, "revision", optional=True)
|
||||
_text(self.record_thread_id, "record thread", optional=True)
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnedScope:
|
||||
namespace: str
|
||||
owner: str
|
||||
thread_id: str
|
||||
record_ids: frozenset[str] | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "owner", "thread_id"):
|
||||
_text(getattr(self, name), name)
|
||||
if self.record_ids is not None:
|
||||
if not isinstance(self.record_ids, frozenset):
|
||||
raise ValueError("Owned scope must be immutable")
|
||||
for identifier in self.record_ids:
|
||||
_text(identifier, "record identifier")
|
||||
if identifier == "*":
|
||||
raise ValueError("Collection authority must be explicit")
|
||||
|
||||
def permits(self, resource):
|
||||
return (isinstance(resource, OwnedResource)
|
||||
and (self.namespace, self.owner, self.thread_id) ==
|
||||
(resource.namespace, resource.owner, resource.thread_id)
|
||||
and resource.collection == self.namespace
|
||||
and (self.record_ids is None or resource.record_id in self.record_ids))
|
||||
|
||||
def intersect(self, other):
|
||||
if (self.namespace, self.owner, self.thread_id) != (other.namespace, other.owner, other.thread_id):
|
||||
return None
|
||||
ids = (other.record_ids if self.record_ids is None else self.record_ids if other.record_ids is None
|
||||
else self.record_ids & other.record_ids)
|
||||
return OwnedScope(self.namespace, self.owner, self.thread_id, ids)
|
||||
|
||||
def to_dict(self):
|
||||
return {"namespace": self.namespace, "owner": self.owner, "thread_id": self.thread_id,
|
||||
"record_ids": None if self.record_ids is None else sorted(self.record_ids)}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value):
|
||||
if not isinstance(value, dict) or set(value) != {"namespace", "owner", "thread_id", "record_ids"}:
|
||||
raise ValueError("Malformed owned scope snapshot")
|
||||
ids = value["record_ids"]
|
||||
if ids is not None and (not isinstance(ids, list) or any(not isinstance(v, str) for v in ids)):
|
||||
raise ValueError("Malformed owned record limits")
|
||||
return cls(value["namespace"], value["owner"], value["thread_id"],
|
||||
None if ids is None else frozenset(ids))
|
||||
|
||||
|
||||
OWNED_TOOL_NAMESPACES = {
|
||||
**{name: "documents" for name in ("create_document", "edit_document", "update_document", "suggest_document", "manage_documents")},
|
||||
**{name: "threads" for name in ("create_session", "list_sessions", "manage_session", "send_to_session", "search_chats")},
|
||||
**{name: "attachments" for name in ("extract_text", "inspect_media", "transcribe_media")},
|
||||
"manage_notes": "notes",
|
||||
"manage_memory": "memory",
|
||||
**{name: "vault" for name in ("vault_get", "vault_search", "vault_unlock")},
|
||||
}
|
||||
|
||||
|
||||
def seal_owned_scopes(owner, thread_id, tools):
|
||||
if not owner or not thread_id:
|
||||
return ()
|
||||
return tuple(OwnedScope(namespace, owner, thread_id)
|
||||
for namespace in sorted({OWNED_TOOL_NAMESPACES[t] for t in tools if t in OWNED_TOOL_NAMESPACES}))
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Whether a turn runs on the compact (clean v3) preview runtime.
|
||||
|
||||
The chat route decides this once, from facts known before context
|
||||
preparation, and uses that one value both to prepare the turn (its typed
|
||||
context resolution) and to stamp the turn contract's selection mode. The
|
||||
agent loop dispatches on that stamp. Keeping both sides here, with no other
|
||||
imports, means preparation and dispatch read one rule and cannot drift.
|
||||
|
||||
Runtime selection is not authority: it grants or denies no operation.
|
||||
"""
|
||||
|
||||
COMPACT_PREVIEW_MODE = "clean_compact_v3_preview"
|
||||
|
||||
|
||||
def uses_compact_preview_runtime(
|
||||
*,
|
||||
clean_route_requested: bool,
|
||||
turn_contract_enabled: bool,
|
||||
agent_mode: bool,
|
||||
agent_permitted: bool,
|
||||
image_generation: bool,
|
||||
) -> bool:
|
||||
"""The single compact-runtime eligibility rule for one turn.
|
||||
|
||||
``turn_contract_enabled`` is the route's contract policy for this turn
|
||||
(exact approvals, TUI surface and full-schema routes opt out).
|
||||
``agent_permitted`` is false when the user's privileges demote the turn
|
||||
to plain chat; image generation sessions run their own execution path.
|
||||
"""
|
||||
return bool(
|
||||
clean_route_requested
|
||||
and turn_contract_enabled
|
||||
and agent_mode
|
||||
and agent_permitted
|
||||
and not image_generation
|
||||
)
|
||||
|
||||
|
||||
def is_compact_preview_contract(turn_contract) -> bool:
|
||||
"""Whether a turn contract was stamped for the compact runtime."""
|
||||
return (
|
||||
turn_contract is not None
|
||||
and getattr(turn_contract, "selection_mode", None) == COMPACT_PREVIEW_MODE
|
||||
)
|
||||
Reference in New Issue
Block a user