From b3dfd22f14af6ef23b424013fb0b55b9bf713187 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Fri, 4 Sep 2026 12:42:11 -0400 Subject: [PATCH] feat: PHI-safe observe and one-OK admit on AuthoringSession Agent-led demonstration already wraps Recorder and compile_recording. observe() now returns a projected tree without titles, values, screenshots, URLs, or backend pixels. Click still uses remembered pixel bounds. admit() records that a local operator accepted the compiled draft (empty/ok/yes/True). It does not mint a production admission. --- openadapt_flow/authoring.py | 502 +++++++++++++++++++++++++++++++++++- tests/test_authoring.py | 253 +++++++++++++++++- 2 files changed, 752 insertions(+), 3 deletions(-) diff --git a/openadapt_flow/authoring.py b/openadapt_flow/authoring.py index 7a076c0a..f40153ab 100644 --- a/openadapt_flow/authoring.py +++ b/openadapt_flow/authoring.py @@ -16,14 +16,27 @@ ``{status: "needs_human_admit", workflow_id}`` and never paints ``VERIFIED``. A secret-field pause with no TYPE/param event refuses compile. +``admit(confirm=None)`` records that a named local operator accepted the +compiled draft. Empty / ``ok`` / ``yes`` / ``True`` all succeed. It does +not mint a Seal or a Production admission, and it does not ask the +operator to re-supply schema, authority, effect contract, environment, or +digest. + Windows native, Citrix, and RDP are ``COACH_ONLY``: agent-drive is refused. Capture observers already drop OS-injected events; this module does not invent a ``record_injected`` flag. + +``observe`` returns a PHI-safe tree (no titles, field values, screenshots, +URLs, or backend pixels on the payload). Pixel bounds stay on the session +for later ``click`` / pause. """ from __future__ import annotations +import getpass +import hashlib import json +import re import uuid from dataclasses import dataclass from pathlib import Path @@ -35,6 +48,8 @@ __all__ = [ "COACH_ONLY", "NEEDS_HUMAN_ADMIT", + "OBSERVE_SCHEMA_VERSION", + "AdmitResult", "AuthoringError", "AuthoringSession", "CoachOnlyError", @@ -45,9 +60,127 @@ COACH_ONLY = "COACH_ONLY" NEEDS_HUMAN_ADMIT: Literal["needs_human_admit"] = "needs_human_admit" +OBSERVE_SCHEMA_VERSION = "openadapt.authoring.observe/v1" _AGENT_DRIVE_KINDS = frozenset({"web", "macos", "linux"}) _COACH_ONLY_KINDS = frozenset({"windows", "rdp"}) +_PROVIDERS = { + "web": "playwright_ax", + "macos": "macos_ax", + "linux": "linux_atspi", +} +_ELEMENT_ROLES = frozenset( + { + "button", + "text_input", + "text_static", + "label", + "link", + "checkbox", + "radio", + "combobox", + "list_item", + "menu", + "menu_item", + "tab", + "tree_item", + "image", + "icon", + "toolbar", + "scrollbar", + "slider", + "window", + "dialog", + "group", + "table", + "table_cell", + "table_row", + "heading", + "paragraph", + "unknown", + } +) +_FORBIDDEN_OBSERVE_KEYS = frozenset( + { + "value", + "text", + "title", + "window_title", + "screenshot", + "ocr", + "url", + "urls", + "backend_pixels", + "raw", + "path", + "file_path", + "pixels", + } +) +_ACCEPT_CONFIRM = frozenset({"", "ok", "yes", "true", "y"}) +_MAX_OBSERVE_NODES = 200 +_PROCESS_NAME_RE = re.compile(r"^[A-Za-z0-9 ._-]{1,64}$") +_PROJECTED_LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$") +_NODE_ID_RE = re.compile(r"^n_[0-9a-f]{8}$") +_SIX_DIGITS_RE = re.compile(r"\d{6,}") +_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+") +_SSN_RE = re.compile(r"\b\d{3}-\d{2}-\d{4}\b") +_PHONE_RE = re.compile(r"\b(?:\+?\d[\d\-\s().]{7,}\d)\b") +_PLAYWRIGHT_TREE_JS = """() => { + const vw = Math.max(window.innerWidth || 0, 1); + const vh = Math.max(window.innerHeight || 0, 1); + const selector = [ + 'button', 'a[href]', 'input', 'textarea', 'select', + '[role="button"]', '[role="link"]', '[role="textbox"]', + '[role="checkbox"]', '[role="radio"]', '[role="combobox"]', + '[role="tab"]', '[role="menuitem"]', '[contenteditable="true"]' + ].join(','); + const focused = document.activeElement; + const nodes = []; + for (const el of document.querySelectorAll(selector)) { + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) continue; + if (rect.right < 0 || rect.bottom < 0 || rect.left > vw || rect.top > vh) { + continue; + } + const roleAttr = (el.getAttribute('role') || '').toLowerCase(); + const tag = (el.tagName || '').toLowerCase(); + const type = (el.getAttribute('type') || '').toLowerCase(); + let role = 'unknown'; + if (roleAttr === 'button' || tag === 'button') role = 'button'; + else if (roleAttr === 'link' || tag === 'a') role = 'link'; + else if (roleAttr === 'checkbox' || type === 'checkbox') role = 'checkbox'; + else if (roleAttr === 'radio' || type === 'radio') role = 'radio'; + else if (roleAttr === 'combobox' || tag === 'select') role = 'combobox'; + else if (roleAttr === 'tab') role = 'tab'; + else if (roleAttr === 'menuitem') role = 'menu_item'; + else if ( + roleAttr === 'textbox' || tag === 'textarea' || tag === 'input' + ) role = 'text_input'; + const label = (el.getAttribute('aria-label') || '').trim(); + const name = label || (tag === 'button' ? (el.textContent || '').trim() : ''); + nodes.push({ + role, + control_type: tag || null, + automation_id: el.id || null, + class_name: (el.className && typeof el.className === 'string') + ? el.className.split(/\\s+/)[0] : null, + name: name.slice(0, 80), + enabled: !el.disabled, + focused: el === focused, + bounds: { + x: rect.x / vw, y: rect.y / vh, + w: rect.width / vw, h: rect.height / vh + }, + backend_pixels: { + x: Math.round(rect.x), y: Math.round(rect.y), + w: Math.round(rect.width), h: Math.round(rect.height) + } + }); + if (nodes.length >= 200) break; + } + return {tree: nodes, truncated: nodes.length >= 200}; +}""" class AuthoringError(RuntimeError): @@ -89,6 +222,14 @@ class CompileResult(TypedDict): recording_retained: bool +class AdmitResult(TypedDict, total=False): + """Local operator acceptance of a compiled draft. Not a Seal.""" + + status: Literal["accepted"] + workflow_id: str + digest: str + + @dataclass(frozen=True) class _NodePixels: """Laptop-only click target. ``x/y/w/h`` are backend pixels.""" @@ -151,6 +292,113 @@ def _load_events(recording_dir: Path) -> list[dict[str, Any]]: return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] +def _safe_process_name(value: Any) -> Optional[str]: + if not isinstance(value, str): + return None + collapsed = " ".join(value.split()) + if not collapsed or not _PROCESS_NAME_RE.fullmatch(collapsed): + return None + if "://" in collapsed or "@" in collapsed or _SIX_DIGITS_RE.search(collapsed): + return None + return collapsed + + +def _safe_label(value: Any) -> Optional[str]: + if not isinstance(value, str): + return None + collapsed = " ".join(value.split()) + if not collapsed or len(collapsed) > 80: + return None + if not _PROJECTED_LABEL_RE.fullmatch(collapsed): + return None + if "://" in collapsed or "@" in collapsed or _SIX_DIGITS_RE.search(collapsed): + return None + if ( + _EMAIL_RE.search(collapsed) + or _SSN_RE.search(collapsed) + or _PHONE_RE.search(collapsed) + ): + return None + return collapsed + + +def _confirm_is_ok(confirm: object) -> bool: + """Empty / ok / yes / True succeed. Anything else is not a one-OK admit.""" + + if confirm is True or confirm is None: + return True + if confirm is False: + return False + if isinstance(confirm, str): + return confirm.strip().casefold() in _ACCEPT_CONFIRM + return False + + +def _local_operator() -> str: + try: + name = getpass.getuser() + except Exception: + name = "" + if not isinstance(name, str) or not name.strip(): + return "local-operator" + return name.strip()[:64] + + +def _finite_unit(value: Any) -> Optional[float]: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + number = float(value) + if number != number or number in (float("inf"), float("-inf")): + return None + return number + + +def _normalized_bounds( + raw: Mapping[str, Any], viewport: tuple[int, int] +) -> Optional[dict[str, float]]: + out: dict[str, float] = {} + for key in ("x", "y", "w", "h"): + number = _finite_unit(raw.get(key)) + if number is None or number < 0: + return None + out[key] = number + vw, vh = viewport + if ( + out["x"] <= 1 + and out["y"] <= 1 + and out["w"] <= 1 + and out["h"] <= 1 + and out["x"] + out["w"] <= 1 + 1e-9 + and out["y"] + out["h"] <= 1 + 1e-9 + ): + return out + if vw <= 0 or vh <= 0: + return None + scaled = { + "x": out["x"] / vw, + "y": out["y"] / vh, + "w": out["w"] / vw, + "h": out["h"] / vh, + } + if ( + scaled["x"] < 0 + or scaled["y"] < 0 + or scaled["x"] + scaled["w"] > 1 + 1e-9 + or scaled["y"] + scaled["h"] > 1 + 1e-9 + ): + return None + return scaled + + +def _pixels_optional(raw: Any) -> Optional[_NodePixels]: + if not isinstance(raw, Mapping): + return None + try: + return _pixels_from_mapping(raw) + except AuthoringError: + return None + + class AuthoringSession: """Wrap a live backend in :class:`Recorder` for one authoring demonstration. @@ -200,6 +448,9 @@ def __init__( self._pause: Optional[_Pause] = None self._secret_pause_params: list[str] = [] self._halted = False + self._draft: Optional[CompileResult] = None + self._draft_digest: Optional[str] = None + self._draft_bundle: Optional[Path] = None @property def backend_kind(self) -> str: @@ -218,6 +469,48 @@ def remember_node( self._nodes[node_id] = _pixels_from_mapping(backend_pixels) + def observe(self) -> dict[str, Any]: + """Return a PHI-safe authoring tree of the pinned window. + + Backend pixels stay on this session (via :meth:`remember_node`) and + never appear on the payload. Titles, field values, screenshots, and + URLs are dropped even when a backend supplies them. + """ + + self._require_not_halted() + viewport = self._viewport() + raw_nodes, raw_window, truncated = self._collect_raw_tree() + tree: list[dict[str, Any]] = [] + for item in raw_nodes: + node = self._project_node(item, viewport) + if node is None: + continue + tree.append(node) + if len(tree) >= _MAX_OBSERVE_NODES: + truncated = True + break + window = self._project_window(raw_window, viewport) + payload: dict[str, Any] = { + "schema_version": OBSERVE_SCHEMA_VERSION, + "backend": self._kind, + "provider": _PROVIDERS.get(self._kind, "none"), + "mode": "authoring", + "agent_drive": window is not None, + "coach_only": False, + "recording": self._recorder is not None, + "tree": tree, + "truncated": truncated, + "node_count": len(tree), + } + if window is not None: + payload["window"] = window + if not tree: + payload["reason"] = "empty_projection" + for key in list(payload): + if key in _FORBIDDEN_OBSERVE_KEYS: + payload.pop(key, None) + return payload + def start_record(self) -> None: """Construct :class:`Recorder` over the bound backend.""" @@ -420,11 +713,58 @@ def compile( ) workflow = compile_recording(recording_dir, out_bundle, name=name) recording_id = workflow.recording_id or uuid.uuid4().hex - return { + workflow_id = f"wf_{recording_id}" + result: CompileResult = { "status": NEEDS_HUMAN_ADMIT, - "workflow_id": f"wf_{recording_id}", + "workflow_id": workflow_id, "recording_retained": True, } + digest = None + manifest = getattr(workflow, "manifest", None) + raw_digest = getattr(manifest, "content_digest", None) + if isinstance(raw_digest, str) and raw_digest: + digest = raw_digest + self._draft = result + self._draft_digest = digest + self._draft_bundle = out_bundle + return result + + def admit(self, confirm: object = None) -> AdmitResult: + """Record one-OK local acceptance of the compiled draft. + + Empty / ``ok`` / ``yes`` / ``True`` succeed. The operator does not + re-supply schema, authority, effect contract, environment, or digest. + This is not a Seal and not a Production admission. + """ + + self._require_not_halted() + if not _confirm_is_ok(confirm): + raise AuthoringError( + "admit accepts empty, ok, yes, or True", + code="admit_refused", + ) + if self._draft is None: + raise AuthoringError( + "compile first; admit records local acceptance of the draft", + code="not_compiled", + ) + workflow_id = self._draft["workflow_id"] + operator = _local_operator() + record: dict[str, Any] = { + "status": "accepted", + "kind": "local_operator_accept", + "workflow_id": workflow_id, + "operator": operator, + } + if self._draft_digest: + record["digest"] = self._draft_digest + dest = self._draft_bundle if self._draft_bundle is not None else self._out_dir + dest.mkdir(parents=True, exist_ok=True) + (dest / "admit.json").write_text(json.dumps(record, indent=2) + "\n") + public: AdmitResult = {"status": "accepted", "workflow_id": workflow_id} + if self._draft_digest: + public["digest"] = self._draft_digest + return public # -- internals ----------------------------------------------------------- @@ -535,6 +875,164 @@ def _refuse_missing_secret_type(self, recording_dir: Path) -> None: if param not in typed_params: raise MissingSecretTypeError(param) + def _viewport(self) -> tuple[int, int]: + raw = getattr(self._backend, "viewport", None) + if callable(raw): + try: + raw = raw() + except Exception: + raw = None + if isinstance(raw, (tuple, list)) and len(raw) == 2: + try: + width, height = int(raw[0]), int(raw[1]) + except (TypeError, ValueError): + width, height = 0, 0 + if width > 0 and height > 0: + return (width, height) + return (1280, 800) + + def _collect_raw_tree( + self, + ) -> tuple[list[Mapping[str, Any]], Optional[Mapping[str, Any]], bool]: + getter = getattr(self._backend, "authoring_tree", None) + if callable(getter): + try: + raw = getter() + except Exception: + raw = None + if isinstance(raw, Mapping): + tree = raw.get("tree") + nodes = ( + [item for item in tree if isinstance(item, Mapping)] + if isinstance(tree, list) + else [] + ) + window = raw.get("window") + return ( + nodes, + window if isinstance(window, Mapping) else None, + raw.get("truncated") is True or len(nodes) > _MAX_OBSERVE_NODES, + ) + if isinstance(raw, list): + nodes = [item for item in raw if isinstance(item, Mapping)] + return nodes, None, len(nodes) > _MAX_OBSERVE_NODES + page_tree = self._playwright_tree() + if page_tree is not None: + return page_tree + return [], None, False + + def _playwright_tree( + self, + ) -> Optional[tuple[list[Mapping[str, Any]], Optional[Mapping[str, Any]], bool]]: + page = getattr(self._backend, "page", None) + if page is None: + return None + evaluate = getattr(page, "evaluate", None) + if not callable(evaluate): + return None + try: + raw = evaluate(_PLAYWRIGHT_TREE_JS) + except Exception: + return None + if not isinstance(raw, Mapping): + return None + tree = raw.get("tree") + nodes = ( + [item for item in tree if isinstance(item, Mapping)] + if isinstance(tree, list) + else [] + ) + return nodes, None, raw.get("truncated") is True + + def _project_window( + self, + raw: Optional[Mapping[str, Any]], + viewport: tuple[int, int], + ) -> Optional[dict[str, Any]]: + process_name = None + bounds = None + if isinstance(raw, Mapping): + process_name = _safe_process_name(raw.get("process_name")) + raw_bounds = raw.get("bounds") + if isinstance(raw_bounds, Mapping): + bounds = _normalized_bounds(raw_bounds, viewport) + if process_name is None: + for attr in ("process_name", "app", "_app", "_owner_substr"): + process_name = _safe_process_name(getattr(self._backend, attr, None)) + if process_name is not None: + break + if process_name is None: + process_name = { + "web": "Chromium", + "macos": "App", + "linux": "App", + }.get(self._kind) + if process_name is None: + return None + if bounds is None: + bounds = {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0} + return {"process_name": process_name, "role": "window", "bounds": bounds} + + def _project_node( + self, + raw: Mapping[str, Any], + viewport: tuple[int, int], + ) -> Optional[dict[str, Any]]: + bounds_raw = raw.get("bounds") + bounds = ( + _normalized_bounds(bounds_raw, viewport) + if isinstance(bounds_raw, Mapping) + else None + ) + pixels = _pixels_optional(raw.get("backend_pixels")) + if bounds is None and pixels is not None: + bounds = _normalized_bounds( + {"x": pixels.x, "y": pixels.y, "w": pixels.w, "h": pixels.h}, + viewport, + ) + if bounds is None: + return None + if pixels is None and bounds_raw is not None: + # Normalized bounds only: map back through the viewport for click. + vw, vh = viewport + pixels = _NodePixels( + x=int(bounds["x"] * vw), + y=int(bounds["y"] * vh), + w=max(1, int(bounds["w"] * vw)), + h=max(1, int(bounds["h"] * vh)), + ) + role = raw.get("role") + if role not in _ELEMENT_ROLES: + role = "unknown" + enabled = raw.get("enabled") + focused = raw.get("focused") + if not isinstance(enabled, bool): + enabled = True + if not isinstance(focused, bool): + focused = False + node_id = raw.get("node_id") + if not isinstance(node_id, str) or not _NODE_ID_RE.fullmatch(node_id): + seed = ( + f"{role}|{raw.get('automation_id') or ''}|" + f"{bounds['x']:.4f}|{bounds['y']:.4f}|" + f"{bounds['w']:.4f}|{bounds['h']:.4f}" + ) + node_id = "n_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:8] + node: dict[str, Any] = { + "node_id": node_id, + "role": role, + "enabled": enabled, + "focused": focused, + "bounds": bounds, + } + for key in ("control_type", "class_name", "automation_id", "name"): + label = _safe_label(raw.get(key)) + if label: + node[key] = label[:64] if key == "class_name" else label + if pixels is not None: + self._nodes[node_id] = pixels + return node + def open_session( backend: Any, diff --git a/tests/test_authoring.py b/tests/test_authoring.py index f0ff72c9..ab755e81 100644 --- a/tests/test_authoring.py +++ b/tests/test_authoring.py @@ -11,7 +11,7 @@ import io import json from pathlib import Path -from typing import Iterator, Optional +from typing import Any, Iterator, Optional import pytest from PIL import Image @@ -124,6 +124,8 @@ def test_authoring_module_does_not_spawn_win_agent_or_copy_replay_mcp() -> None: assert "parallels_vm" not in src assert "emit.mcp_tool" not in src assert "emit/mcp_tool" not in src + assert "qualification_admission" not in src + assert "bundle_sealing" not in src def test_open_session_constructs_authoring_session(tmp_path: Path) -> None: @@ -134,6 +136,10 @@ def test_open_session_constructs_authoring_session(tmp_path: Path) -> None: ) assert isinstance(session, AuthoringSession) assert session.backend_kind == "macos" + observed = session.observe() + assert observed["agent_drive"] is True + assert observed["coach_only"] is False + assert observed["window"]["process_name"] == "App" def test_unknown_backend_kind_is_not_silently_web(tmp_path: Path) -> None: @@ -142,6 +148,165 @@ def test_unknown_backend_kind_is_not_silently_web(tmp_path: Path) -> None: assert excinfo.value.code == "unknown_backend" +# -- observe (PHI-safe tree; pixels stay on the session) --------------------- + + +class _TreeBackend(ScriptedBackend): + """Backend that supplies an authoring tree, including forbidden keys.""" + + def authoring_tree(self) -> dict[str, Any]: + return { + "window": { + "process_name": "Chromium", + "title": "Patient Jane Roe MRN-9911", + "bounds": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}, + }, + "tree": [ + { + "node_id": "n_9f2c001a", + "role": "button", + "name": "Save", + "automation_id": "btnContinue", + "enabled": True, + "focused": False, + "bounds": {"x": 0.72, "y": 0.88, "w": 0.14, "h": 0.05}, + "backend_pixels": {"x": 920, "y": 640, "w": 180, "h": 36}, + "value": "4111111111111111", + "title": "Chart Jane Roe", + "screenshot": "iVBORw0KGgo=", + "url": "https://example.invalid/chart", + }, + { + "node_id": "n_abcdef01", + "role": "text_input", + "name": "SSN 123-45-6789", + "enabled": True, + "focused": False, + "bounds": {"x": 0.10, "y": 0.10, "w": 0.20, "h": 0.05}, + "backend_pixels": {"x": 10, "y": 10, "w": 20, "h": 20}, + "value": "123-45-6789", + }, + ], + } + + +def test_observe_drops_titles_values_screenshots_urls_and_pixels( + tmp_path: Path, +) -> None: + session = AuthoringSession( + _TreeBackend(), + tmp_path / "rec", + backend_kind="web", + settle_interval_s=0.01, + settle_stable_frames=1, + settle_timeout_s=0.2, + ) + observed = session.observe() + blob = json.dumps(observed) + assert observed["schema_version"] == "openadapt.authoring.observe/v1" + assert observed["mode"] == "authoring" + assert observed["backend"] == "web" + assert observed["provider"] == "playwright_ax" + assert observed["agent_drive"] is True + assert observed["coach_only"] is False + assert observed["recording"] is False + assert observed["window"]["process_name"] == "Chromium" + assert "title" not in observed["window"] + assert "value" not in blob + assert "screenshot" not in blob + assert "title" not in blob + assert "backend_pixels" not in blob + assert "Jane Roe" not in blob + assert "4111111111111111" not in blob + assert "123-45-6789" not in blob + assert "https://" not in blob + node = next(item for item in observed["tree"] if item["node_id"] == "n_9f2c001a") + assert node["name"] == "Save" + assert node["automation_id"] == "btnContinue" + assert node["enabled"] is True + ssn = next(item for item in observed["tree"] if item["node_id"] == "n_abcdef01") + assert "name" not in ssn + + +def test_observe_remembers_pixels_for_later_click(tmp_path: Path) -> None: + backend = _TreeBackend() + session = AuthoringSession( + backend, + tmp_path / "rec", + backend_kind="web", + settle_interval_s=0.01, + settle_stable_frames=1, + settle_timeout_s=0.2, + ) + session.observe() + session.start_record() + session.click(node_id="n_9f2c001a") + session.stop_record() + assert ("click", 1010, 658, False) in backend.calls + + +def test_observe_empty_tree_is_not_a_raw_fallback(tmp_path: Path) -> None: + session, _backend = _session(tmp_path) + observed = session.observe() + assert observed["tree"] == [] + assert observed["reason"] == "empty_projection" + assert observed["agent_drive"] is True + assert observed["window"]["process_name"] == "Chromium" + assert "value" not in json.dumps(observed) + assert "screenshot" not in json.dumps(observed) + + +class _PageTreeBackend(ScriptedBackend): + """Playwright-shaped backend: observe reads page.evaluate, not a raw dump.""" + + def __init__(self) -> None: + super().__init__() + self.page = self + + def evaluate(self, script: str, args: Any = None) -> Any: + self.calls.append(("evaluate", args)) + if args is not None: + return None + return { + "tree": [ + { + "role": "button", + "name": "Save", + "enabled": True, + "focused": False, + "bounds": {"x": 0.1, "y": 0.1, "w": 0.1, "h": 0.1}, + "backend_pixels": {"x": 128, "y": 80, "w": 128, "h": 80}, + "value": "must-not-leak", + } + ], + "truncated": False, + } + + +def test_observe_playwright_page_tree_strips_pixels_and_values( + tmp_path: Path, +) -> None: + backend = _PageTreeBackend() + session = AuthoringSession( + backend, + tmp_path / "rec", + backend_kind="web", + settle_interval_s=0.01, + settle_stable_frames=1, + settle_timeout_s=0.2, + ) + observed = session.observe() + blob = json.dumps(observed) + assert observed["tree"][0]["name"] == "Save" + assert observed["tree"][0]["node_id"].startswith("n_") + assert "backend_pixels" not in blob + assert "must-not-leak" not in blob + session.start_record() + session.click(node_id=observed["tree"][0]["node_id"]) + session.stop_record() + assert ("click", 192, 120, False) in backend.calls + + # -- scripted actuate through Recorder --------------------------------------- @@ -512,6 +677,92 @@ def test_compile_accepts_secret_pause_after_continue(tmp_path: Path) -> None: assert "VERIFIED" not in json.dumps(result) +# -- one-OK admit (local operator; not a Seal) ------------------------------- + + +class _DraftWorkflow: + """Stand-in for compile_recording so admit tests do not run OCR.""" + + recording_id = "demo0001" + manifest = type("Manifest", (), {"content_digest": "ab" * 32})() + + +def _compiled_session( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AuthoringSession: + import openadapt_flow.authoring as authoring_mod + + session, _backend = _session(tmp_path) + session.start_record() + session.click(10, 20) + session.stop_record() + monkeypatch.setattr( + authoring_mod, "compile_recording", lambda *args, **kwargs: _DraftWorkflow() + ) + result = session.compile(tmp_path / "bundle", name="draft") + assert result["status"] == NEEDS_HUMAN_ADMIT + assert result["workflow_id"] == "wf_demo0001" + return session + + +@pytest.mark.parametrize("confirm", [None, "", "ok", "yes", True, "OK", "Yes"]) +def test_admit_one_ok_variants( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, confirm: object +) -> None: + session = _compiled_session(tmp_path, monkeypatch) + accepted = session.admit(confirm) + assert accepted["status"] == "accepted" + assert accepted["workflow_id"] == "wf_demo0001" + assert accepted["digest"] == "ab" * 32 + assert "VERIFIED" not in json.dumps(accepted) + assert "operator" not in accepted + record = json.loads((tmp_path / "bundle" / "admit.json").read_text()) + assert record["status"] == "accepted" + assert record["kind"] == "local_operator_accept" + assert record["workflow_id"] == "wf_demo0001" + assert "operator" in record + assert "Seal" not in json.dumps(record) + assert "schema" not in record + assert "authority" not in record + assert "effect" not in record + assert "environment" not in record + + +def test_admit_refuses_non_ok_confirm( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + session = _compiled_session(tmp_path, monkeypatch) + with pytest.raises(AuthoringError) as excinfo: + session.admit("no") + assert excinfo.value.code == "admit_refused" + with pytest.raises(AuthoringError) as excinfo: + session.admit(False) + assert excinfo.value.code == "admit_refused" + assert not (tmp_path / "bundle" / "admit.json").exists() + + +def test_admit_requires_compiled_draft(tmp_path: Path) -> None: + session, _backend = _session(tmp_path) + session.start_record() + session.click(10, 20) + session.stop_record() + with pytest.raises(AuthoringError) as excinfo: + session.admit("ok") + assert excinfo.value.code == "not_compiled" + + +def test_admit_does_not_require_schema_or_digest_from_the_operator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + session = _compiled_session(tmp_path, monkeypatch) + accepted = session.admit() + assert accepted == { + "status": "accepted", + "workflow_id": "wf_demo0001", + "digest": "ab" * 32, + } + + # -- Synthetic CI gate (MockMed Playwright fixture; not a user-facing job) --