157 lines
5.5 KiB
Python
157 lines
5.5 KiB
Python
|
|
"""A browser-surface driver over the lab's HTML UI.
|
||
|
|
|
||
|
|
Each actor gets its **own** session — its own base URL binding, its own
|
||
|
|
credentials, its own cookie-equivalent. Nothing is shared between actors at
|
||
|
|
module or class level, because a shared client is exactly how isolation breaks in
|
||
|
|
practice and it never announces itself (F-0003).
|
||
|
|
|
||
|
|
The driver executes a `Plan` produced by an `ActorRuntime`. It does not decide
|
||
|
|
what to click and it does not decide whether the outcome was correct: the first
|
||
|
|
belongs to the runtime, the second to the observer and the oracle.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import urllib.error
|
||
|
|
import urllib.parse
|
||
|
|
import urllib.request
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from .actions import SemanticAction, Surface
|
||
|
|
from .agentic import ActorRuntime, RealizationFailed
|
||
|
|
from .drivers import Realization, UnsupportedAction
|
||
|
|
from .html import Document
|
||
|
|
from .world import Actor
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(slots=True)
|
||
|
|
class Session:
|
||
|
|
"""One actor's private connection to the surface. Never shared."""
|
||
|
|
|
||
|
|
base_url: str
|
||
|
|
token: str
|
||
|
|
|
||
|
|
def _open(self, method: str, path: str, body: bytes | None, content_type: str):
|
||
|
|
request = urllib.request.Request(
|
||
|
|
urllib.parse.urljoin(self.base_url, path),
|
||
|
|
data=body,
|
||
|
|
method=method,
|
||
|
|
headers={
|
||
|
|
"Authorization": f"Bearer {self.token}",
|
||
|
|
**({"Content-Type": content_type} if body else {}),
|
||
|
|
},
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||
|
|
return response.status, response.read().decode()
|
||
|
|
except urllib.error.HTTPError as error:
|
||
|
|
return error.code, error.read().decode()
|
||
|
|
|
||
|
|
def get(self, path: str) -> tuple[int, str]:
|
||
|
|
return self._open("GET", path, None, "")
|
||
|
|
|
||
|
|
def post_form(self, path: str, fields: dict[str, str]) -> tuple[int, str]:
|
||
|
|
encoded = urllib.parse.urlencode(fields).encode()
|
||
|
|
return self._open("POST", path, encoded, "application/x-www-form-urlencoded")
|
||
|
|
|
||
|
|
def post_json(self, path: str, payload: dict[str, Any]) -> tuple[int, str]:
|
||
|
|
return self._open("POST", path, json.dumps(payload).encode(), "application/json")
|
||
|
|
|
||
|
|
|
||
|
|
class BrowserDriver:
|
||
|
|
"""Realizes semantic actions by finding and using controls on an HTML page."""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
base_url: str,
|
||
|
|
tokens: dict[str, str],
|
||
|
|
runtime: ActorRuntime,
|
||
|
|
resource_id: str,
|
||
|
|
) -> None:
|
||
|
|
self._base_url = base_url
|
||
|
|
self._tokens = tokens
|
||
|
|
self._runtime = runtime
|
||
|
|
self._resource_id = resource_id
|
||
|
|
self.surface = Surface(
|
||
|
|
id="browser", kind="html", description="lab browser UI"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _session_for(self, actor: Actor) -> Session:
|
||
|
|
# Constructed per call: no actor can inherit another's connection state.
|
||
|
|
return Session(self._base_url, self._tokens[actor.id])
|
||
|
|
|
||
|
|
def _view_path(self) -> str:
|
||
|
|
return f"/resources/{self._resource_id}/view"
|
||
|
|
|
||
|
|
def realize(self, actor: Actor, action: SemanticAction) -> Realization:
|
||
|
|
action.check_surface(self.surface.id)
|
||
|
|
session = self._session_for(actor)
|
||
|
|
|
||
|
|
status, body = session.get(self._view_path())
|
||
|
|
if status != 200:
|
||
|
|
# A surface the actor cannot even load is not an adaptation problem.
|
||
|
|
return Realization(
|
||
|
|
self.surface.id,
|
||
|
|
{"action": action.name, "stage": "navigate", "status": status},
|
||
|
|
raised=f"NavigationFailed: {status}",
|
||
|
|
)
|
||
|
|
|
||
|
|
document = Document.parse(body)
|
||
|
|
args = {"resource_id": self._resource_id, **dict(action.args)}
|
||
|
|
|
||
|
|
try:
|
||
|
|
plan = self._runtime.plan(document, action.name, args)
|
||
|
|
except RealizationFailed as failure:
|
||
|
|
return Realization(
|
||
|
|
self.surface.id,
|
||
|
|
{
|
||
|
|
"action": action.name,
|
||
|
|
"stage": "discovery",
|
||
|
|
"runtime": getattr(self._runtime, "name", "?"),
|
||
|
|
"page_bytes": len(body),
|
||
|
|
},
|
||
|
|
raised=f"RealizationFailed: {failure}",
|
||
|
|
)
|
||
|
|
|
||
|
|
mechanics: dict[str, Any] = {
|
||
|
|
"action": action.describe(),
|
||
|
|
"stage": "submit",
|
||
|
|
"runtime": plan.metrics.runtime,
|
||
|
|
"rationale": plan.rationale,
|
||
|
|
"element_path": plan.element_path,
|
||
|
|
"target": plan.action,
|
||
|
|
"fields": sorted(plan.fields),
|
||
|
|
"metrics": plan.metrics.as_dict(),
|
||
|
|
"actor": actor.id,
|
||
|
|
}
|
||
|
|
|
||
|
|
status, response = session.post_form(plan.action, plan.fields)
|
||
|
|
mechanics["status"] = status
|
||
|
|
if status >= 400:
|
||
|
|
return Realization(
|
||
|
|
self.surface.id, mechanics, raised=f"Refused: {status} {response[:120]}"
|
||
|
|
)
|
||
|
|
return Realization(self.surface.id, mechanics)
|
||
|
|
|
||
|
|
|
||
|
|
def record_baseline_selectors(html: str) -> dict[str, str]:
|
||
|
|
"""Capture the control arm's selectors from a baseline page.
|
||
|
|
|
||
|
|
Deliberately generous: it takes the strongest identifier the page offers.
|
||
|
|
A weak control arm would make H-001 trivially true and worthless.
|
||
|
|
"""
|
||
|
|
document = Document.parse(html)
|
||
|
|
recordings: dict[str, str] = {}
|
||
|
|
for form in document.forms():
|
||
|
|
test_id = form.attrs.get("data-td", "")
|
||
|
|
target = form.attrs.get("action", "")
|
||
|
|
if not test_id:
|
||
|
|
continue
|
||
|
|
if target.endswith("/grant"):
|
||
|
|
recordings["grant_access"] = test_id
|
||
|
|
elif target.endswith("/revoke"):
|
||
|
|
recordings["revoke_access"] = test_id
|
||
|
|
return recordings
|