Two decisions taken with the operator: stdlib HTML driver instead of Playwright (F-0004), and a deterministic discovery runtime instead of a live model. Both sit behind interfaces so the alternatives drop in later. - html.py: stdlib DOM parse and query - agentic.py: DiscoveryRuntime (agentic arm, ignores data-td by construction) and RecordedSelectorRuntime (control arm, uses the strongest identifier the page offers) - browser.py: per-actor sessions over real HTTP, constructed per call so no actor inherits another's connection state - cost/nondeterminism metrics recorded from the first run F-0005 (CONCEPT_DRIFT): the H-001 result is a narrowing. Where test ids are preserved, discovery 9/9 and recorded selectors 9/9 - the semantic action buys nothing. Where they are dropped, discovery 2/3 and recorded 0/3. The concept model presents semantic actions as generally superior; the evidence says conditionally superior. M21 and M22 added mid-task: the deciding side of the axis was N=1. M22 (field names renamed) defeats the heuristic and is the first concrete evidence that a live model would add capability, not just cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 1629012@bnt-lap001 Assistant-Session: 78d4fb13-8a1e-474b-87a3-9b9261c49a39
156 lines
5.5 KiB
Python
156 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
|