mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-07 07:22:21 +02:00
feat(runtime): bind browser resources to authority
This commit is contained in:
@@ -14,6 +14,7 @@ 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
|
||||
@@ -124,6 +125,8 @@ class RequestAuthority:
|
||||
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
|
||||
@@ -178,11 +181,24 @@ class RequestAuthority:
|
||||
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=())
|
||||
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 "")
|
||||
@@ -211,6 +227,7 @@ class RequestAuthority:
|
||||
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]
|
||||
@@ -222,11 +239,15 @@ class RequestAuthority:
|
||||
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)
|
||||
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."""
|
||||
@@ -236,10 +257,12 @@ class RequestAuthority:
|
||||
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))
|
||||
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": 4, "request_id": self.request_id, "owner": self.owner,
|
||||
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),
|
||||
@@ -251,12 +274,14 @@ class RequestAuthority:
|
||||
"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]}
|
||||
"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}):
|
||||
or value["version"] not in {1, 2, 3, 4, 5}):
|
||||
raise ValueError("Unsupported authority snapshot")
|
||||
def limits(value):
|
||||
if value is None:
|
||||
@@ -275,6 +300,8 @@ class RequestAuthority:
|
||||
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"]),
|
||||
@@ -283,11 +310,13 @@ class RequestAuthority:
|
||||
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(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"})
|
||||
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -505,7 +534,9 @@ def seal_task_authority(prompt, task_type, action, *, owner=None, parent_authori
|
||||
owned_scopes=parent.owned_scopes,
|
||||
launch_scopes=parent.launch_scopes,
|
||||
process_resources=parent.process_resources,
|
||||
job_resources=parent.job_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()})
|
||||
|
||||
|
||||
|
||||
@@ -347,8 +347,11 @@ def guard_launch_workspace(root):
|
||||
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 = (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):
|
||||
|
||||
+115
-30
@@ -37,7 +37,11 @@ def _control_plane_path(path):
|
||||
"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)}
|
||||
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))
|
||||
@@ -73,7 +77,7 @@ def _control_plane_path(path):
|
||||
return True
|
||||
if jobs.exists():
|
||||
# Uninspectable state fails closed; hardlinks retain object identity.
|
||||
protected.update(canonical_root(p) for p in jobs.iterdir())
|
||||
protected.update(canonical_root(p) for p in jobs.rglob("*") if p.is_file())
|
||||
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"))
|
||||
@@ -106,6 +110,115 @@ 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
|
||||
@@ -439,34 +552,6 @@ class BackgroundJobResource:
|
||||
return cls(**{**value, "processes": tuple(ProcessResource.from_dict(p) for p in value["processes"])})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserProducer:
|
||||
namespace: str
|
||||
owner: str
|
||||
thread_id: str
|
||||
session_id: str
|
||||
incarnation: str
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ("namespace", "owner", "thread_id", "session_id", "incarnation"):
|
||||
_text(getattr(self, name), name)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserPageResource:
|
||||
producer: BrowserProducer
|
||||
page_id: str
|
||||
navigation_generation: int
|
||||
observed_url: str
|
||||
|
||||
def __post_init__(self):
|
||||
if (not isinstance(self.producer, BrowserProducer)
|
||||
or type(self.navigation_generation) is not int or self.navigation_generation < 0):
|
||||
raise ValueError("Malformed browser page identity")
|
||||
_text(self.page_id, "page")
|
||||
_text(self.observed_url, "observed URL")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalResource:
|
||||
namespace: str
|
||||
|
||||
Reference in New Issue
Block a user