From 3abf298b04da7a44205648c3ab3138dc4ded2a2e Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 31 Aug 2026 18:48:50 -0400 Subject: [PATCH 1/2] feat(types): authoring observe, command, and bind schemas Add the public MCP wire for hosted authoring. Observe, command, and bind are strict contracts with additionalProperties false. They do not reuse ComputerState or UINode. Extra keys and value, title, and screenshot fail validation. Bind tokens are oab_, not oar_. --- README.md | 3 +- openadapt_types/__init__.py | 69 +++ openadapt_types/authoring.py | 576 +++++++++++++++++ .../schemas/authoring-bind-v1.json | 92 +++ .../schemas/authoring-command-v1.json | 581 ++++++++++++++++++ .../schemas/authoring-observe-v1.json | 302 +++++++++ scripts/export_authoring_schemas.py | 34 + tests/test_authoring.py | 462 ++++++++++++++ 8 files changed, 2118 insertions(+), 1 deletion(-) create mode 100644 openadapt_types/authoring.py create mode 100644 openadapt_types/schemas/authoring-bind-v1.json create mode 100644 openadapt_types/schemas/authoring-command-v1.json create mode 100644 openadapt_types/schemas/authoring-observe-v1.json create mode 100644 scripts/export_authoring_schemas.py create mode 100644 tests/test_authoring.py diff --git a/README.md b/README.md index fdebdbe..1e5ce7a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ out the pixels. Coordinates are the thing that breaks when a window moves. | `CodeCapabilityManifestV1` | Exact Python, locked dependencies, typed I/O, permissions, and verifier bindings | | `ProcessEvidenceReceiptV1` | One signed root over child receipts, human receipts, and the artifact graph | | `AuthenticationTaskContractV1` | A value-free login requirement bound to an existing attended task | +| `AuthoringObserveV1` | PHI-safe authoring observe tree for the hosted MCP wire | Plus the versioned wire contracts: `ControlOverlayFrameV1`/`V2` and `ControlOverlayTimelineV1`/`V2` for PHI-safe execution overlays, @@ -95,7 +96,7 @@ print(json.dumps(ComputerState.model_json_schema(), indent=2)) ``` The same schemas ship as JSON under `openadapt_types/schemas/` for TypeScript, -Rust, and anything else that isn't Python. Twenty-four files, including +Rust, and anything else that isn't Python. Twenty-seven files, including `execute-v1-openapi.json`, the public OpenAdapt Execute contract. ## Converting from the older formats diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index bc66bdf..27c55aa 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -28,6 +28,41 @@ ActionTarget, ActionType, ) +from openadapt_types.authoring import ( + AUTHORING_BIND_SCHEMA, + AUTHORING_COMMAND_SCHEMA, + AUTHORING_OBSERVE_SCHEMA, + AuthoringBackendV1, + AuthoringBindClaimV1, + AuthoringBindMintV1, + AuthoringBindV1, + AuthoringClickArgsV1, + AuthoringClientDisplayV1, + AuthoringCommandLookupV1, + AuthoringCommandStatusV1, + AuthoringCommandV1, + AuthoringCompileResultV1, + AuthoringEmptyProjectionReason, + AuthoringEnqueueAcceptedV1, + AuthoringEnqueueToolV1, + AuthoringErrorCodeV1, + AuthoringErrorResultV1, + AuthoringNodeV1, + AuthoringNormalizedBoundsV1, + AuthoringObserveV1, + AuthoringPauseArgsV1, + AuthoringPauseResultV1, + AuthoringProviderV1, + AuthoringRunnerUriV1, + AuthoringSetCoachArgsV1, + AuthoringTokenError, + AuthoringWindowV1, + BIND_TOKEN_PATTERN, + LEASE_SECRET_PATTERN, + parse_authoring_bind_token, + parse_authoring_lease_secret, + parse_authoring_runner_uri, +) from openadapt_types.benchmark import ( BenchmarkAction, BenchmarkAgent, @@ -262,6 +297,40 @@ "ElementRole", "ProcessInfo", "UINode", + # authoring MCP wire + "AUTHORING_BIND_SCHEMA", + "AUTHORING_COMMAND_SCHEMA", + "AUTHORING_OBSERVE_SCHEMA", + "AuthoringBackendV1", + "AuthoringBindClaimV1", + "AuthoringBindMintV1", + "AuthoringBindV1", + "AuthoringClickArgsV1", + "AuthoringClientDisplayV1", + "AuthoringCommandLookupV1", + "AuthoringCommandStatusV1", + "AuthoringCommandV1", + "AuthoringCompileResultV1", + "AuthoringEmptyProjectionReason", + "AuthoringEnqueueAcceptedV1", + "AuthoringEnqueueToolV1", + "AuthoringErrorCodeV1", + "AuthoringErrorResultV1", + "AuthoringNodeV1", + "AuthoringNormalizedBoundsV1", + "AuthoringObserveV1", + "AuthoringPauseArgsV1", + "AuthoringPauseResultV1", + "AuthoringProviderV1", + "AuthoringRunnerUriV1", + "AuthoringSetCoachArgsV1", + "AuthoringTokenError", + "AuthoringWindowV1", + "BIND_TOKEN_PATTERN", + "LEASE_SECRET_PATTERN", + "parse_authoring_bind_token", + "parse_authoring_lease_secret", + "parse_authoring_runner_uri", # control_overlay "CONTROL_OVERLAY_FRAME_SCHEMA", "CONTROL_OVERLAY_STATE_ID_COMPONENTS", diff --git a/openadapt_types/authoring.py b/openadapt_types/authoring.py new file mode 100644 index 0000000..bf3077a --- /dev/null +++ b/openadapt_types/authoring.py @@ -0,0 +1,576 @@ +"""Public MCP wire contracts for hosted and stdio authoring. + +These models are the vendor-facing observe, command, and bind shapes. They do +not reuse :class:`~openadapt_types.computer_state.ComputerState` or +:class:`~openadapt_types.computer_state.UINode`. Sharing +:class:`~openadapt_types.computer_state.ElementRole` is the only computer-state +type that may appear here. + +The projector that drops field values, titles, and screenshots lives in +Capture. This module only refuses those keys on the wire. +""" + +from __future__ import annotations + +import re +from enum import Enum +from math import isfinite +from typing import Any, Literal +from urllib.parse import parse_qs, urlparse + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictInt, + StrictStr, + field_validator, + model_validator, +) + +from openadapt_types.computer_state import ElementRole + +AUTHORING_OBSERVE_SCHEMA: Literal["openadapt.authoring.observe/v1"] = ( + "openadapt.authoring.observe/v1" +) +AUTHORING_COMMAND_SCHEMA: Literal["openadapt.authoring.command/v1"] = ( + "openadapt.authoring.command/v1" +) +AUTHORING_BIND_SCHEMA: Literal["openadapt.authoring.bind/v1"] = ( + "openadapt.authoring.bind/v1" +) + +AUTHORING_ORIGIN = "https://openadapt.ai" +AUTHORING_RUNNER_SCHEME = "openadapt" +AUTHORING_RUNNER_ACTION = "runner" +AUTHORING_MAX_URI_BYTES = 2048 +AUTHORING_MAX_NODES = 200 +AUTHORING_LEASE_S = 900 +AUTHORING_RETRY_AFTER_MS = 1000 + +BIND_TOKEN_PREFIX = "oab_" +LEASE_SECRET_PREFIX = "oals_" +BIND_TOKEN_PATTERN = r"^oab_[A-Za-z0-9_-]{43}$" +LEASE_SECRET_PATTERN = r"^oals_[a-f0-9]{64}$" +_CLOUD_RUNNER_TOKEN_PATTERN = r"^oar_[a-f0-9]{64}$" +_PAIRING_SECRET_PATTERN = r"^oap_[A-Za-z0-9_-]{43}$" +_BIND_HEX_BODY_PATTERN = r"^oab_[a-f0-9]{64}$" +_LEASE_BASE64URL_BODY_PATTERN = r"^oals_[A-Za-z0-9_-]{43}$" + +_BIND_TOKEN_RE = re.compile(BIND_TOKEN_PATTERN) +_LEASE_SECRET_RE = re.compile(LEASE_SECRET_PATTERN) +_CLOUD_RUNNER_TOKEN_RE = re.compile(_CLOUD_RUNNER_TOKEN_PATTERN) +_PAIRING_SECRET_RE = re.compile(_PAIRING_SECRET_PATTERN) +_BIND_HEX_BODY_RE = re.compile(_BIND_HEX_BODY_PATTERN) +_LEASE_BASE64URL_BODY_RE = re.compile(_LEASE_BASE64URL_BODY_PATTERN) + +_NODE_ID_PATTERN = r"^n_[0-9a-f]{8}$" +_COMMAND_ID_PATTERN = r"^cmd_[0-9A-HJKMNP-TV-Z]{26}$" +_WORKFLOW_ID_PATTERN = r"^wf_[A-Za-z0-9_-]{8,64}$" +_PACK_ID_PATTERN = r"^(p\.[A-Za-z0-9_-]{12}|v1\.[A-Za-z0-9_-]{38,512})$" +_PROCESS_NAME_PATTERN = r"^[A-Za-z0-9 ._-]{1,64}$" +_PROJECTED_LABEL_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$" +_PARAM_NAME_PATTERN = r"^[a-z][a-z0-9_]{0,31}$" +_SIX_DIGITS_RE = re.compile(r"\d{6}") +_SHA256_HEX_PATTERN = r"^[a-f0-9]{64}$" +_TIMESTAMP_PATTERN = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$" +_RUNNER_FIELDS = frozenset({"pack", "bind", "origin"}) +_COACH_ONLY_BACKENDS = frozenset({"windows", "rdp", "citrix"}) +_DEEP_LINK_PATTERN = r"^openadapt://runner\?[A-Za-z0-9._~=&%:/+-]{1,2000}$" + + +class _StrictContract(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class AuthoringBackendV1(str, Enum): + MACOS = "macos" + LINUX = "linux" + WINDOWS = "windows" + WEB = "web" + RDP = "rdp" + CITRIX = "citrix" + + +class AuthoringProviderV1(str, Enum): + PLAYWRIGHT_AX = "playwright_ax" + MACOS_AX = "macos_ax" + WINDOWS_UIA = "windows_uia" + LINUX_ATSPI = "linux_atspi" + NONE = "none" + + +class AuthoringEmptyProjectionReason(str, Enum): + EMPTY_PROJECTION = "empty_projection" + + +class AuthoringClientDisplayV1(str, Enum): + CHATGPT = "ChatGPT" + CLAUDE = "Claude" + + +class AuthoringEnqueueToolV1(str, Enum): + OBSERVE = "observe" + START_RECORD = "start_record" + CLICK = "click" + HALT = "halt" + STOP_RECORD = "stop_record" + COMPILE = "compile" + PAUSE_FOR_INPUT = "pause_for_input" + SET_COACH = "set_coach" + GET_COACH = "get_coach" + BIND_PACK = "bind_pack" + + +class AuthoringCommandStatusV1(str, Enum): + PENDING = "pending" + RUNNING = "running" + DONE = "done" + ERROR = "error" + EXPIRED = "expired" + HALTED = "halted" + + +class AuthoringErrorCodeV1(str, Enum): + STALE_NODE = "stale_node" + IN_FLIGHT = "in_flight" + COACH_ONLY = "COACH_ONLY" + NOT_BOUND = "not_bound" + RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED" + MISSING_SECRET_TYPE = "missing_secret_type" + + +class AuthoringTokenError(ValueError): + """A bind token, lease secret, or runner URI failed exact parsing.""" + + +def parse_authoring_bind_token(value: object) -> str: + """Accept only ``oab_`` + 43 unreserved characters; fail closed otherwise.""" + + if not isinstance(value, str): + raise AuthoringTokenError("bind token is malformed") + if ( + _CLOUD_RUNNER_TOKEN_RE.fullmatch(value) + or _PAIRING_SECRET_RE.fullmatch(value) + or _BIND_HEX_BODY_RE.fullmatch(value) + or not _BIND_TOKEN_RE.fullmatch(value) + ): + raise AuthoringTokenError("bind token is malformed") + return value + + +def parse_authoring_lease_secret(value: object) -> str: + """Accept only ``oals_`` + 64 lowercase hex characters.""" + + if not isinstance(value, str): + raise AuthoringTokenError("lease secret is malformed") + if ( + _CLOUD_RUNNER_TOKEN_RE.fullmatch(value) + or _PAIRING_SECRET_RE.fullmatch(value) + or _LEASE_BASE64URL_BODY_RE.fullmatch(value) + or not _LEASE_SECRET_RE.fullmatch(value) + ): + raise AuthoringTokenError("lease secret is malformed") + return value + + +def _canonical_origin(raw: str) -> str: + parsed = urlparse(raw.strip()) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.path not in ("", "/") + or parsed.params + or parsed.query + or parsed.fragment + ): + raise AuthoringTokenError("runner origin is malformed") + if parsed.hostname.endswith("."): + raise AuthoringTokenError("runner origin is malformed") + try: + port = parsed.port + except ValueError as exc: + raise AuthoringTokenError("runner origin is malformed") from exc + if port not in (None, 443): + raise AuthoringTokenError("runner origin is malformed") + host = parsed.hostname.lower() + return f"https://{host}" + + +class AuthoringRunnerUriV1(_StrictContract): + pack: StrictStr = Field(pattern=_PACK_ID_PATTERN) + bind: StrictStr = Field(pattern=BIND_TOKEN_PATTERN) + origin: Literal["https://openadapt.ai"] = AUTHORING_ORIGIN + + @model_validator(mode="after") + def _bind_is_authoring(self) -> "AuthoringRunnerUriV1": + parse_authoring_bind_token(self.bind) + return self + + +def parse_authoring_runner_uri(uri: object) -> AuthoringRunnerUriV1: + """Parse ``openadapt://runner`` and reject any other scheme or field.""" + + if ( + not isinstance(uri, str) + or not uri + or len(uri.encode("utf-8")) > AUTHORING_MAX_URI_BYTES + ): + raise AuthoringTokenError("runner URI is malformed") + parsed = urlparse(uri) + if ( + parsed.scheme != AUTHORING_RUNNER_SCHEME + or parsed.netloc != AUTHORING_RUNNER_ACTION + or parsed.path not in ("", "/") + or parsed.params + or parsed.fragment + or parsed.username + or parsed.password + ): + raise AuthoringTokenError("runner URI is malformed") + try: + query = parse_qs(parsed.query, keep_blank_values=True, strict_parsing=True) + except ValueError as exc: + raise AuthoringTokenError("runner URI is malformed") from exc + if set(query) != _RUNNER_FIELDS or any(len(values) != 1 for values in query.values()): + raise AuthoringTokenError("runner URI contains unknown or duplicate fields") + origin = _canonical_origin(query["origin"][0]) + if origin != AUTHORING_ORIGIN: + raise AuthoringTokenError("runner origin is not pinned") + return AuthoringRunnerUriV1( + pack=query["pack"][0], + bind=parse_authoring_bind_token(query["bind"][0]), + origin=AUTHORING_ORIGIN, + ) + + +def _projected_label(value: object) -> str: + if not isinstance(value, str) or not re.fullmatch(_PROJECTED_LABEL_PATTERN, value): + raise ValueError("projected label is not allowed on the authoring wire") + if _SIX_DIGITS_RE.search(value): + raise ValueError("projected label is not allowed on the authoring wire") + return value + + +def _finite_unit(value: object) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError("normalized bounds must be finite numbers") + number = float(value) + if not isfinite(number): + raise ValueError("normalized bounds must be finite") + return number + + +class AuthoringNormalizedBoundsV1(_StrictContract): + """Viewport-normalized overlay coordinates. Not backend pixels.""" + + x: float = Field(ge=0, le=1) + y: float = Field(ge=0, le=1) + w: float = Field(ge=0, le=1) + h: float = Field(ge=0, le=1) + + @field_validator("x", "y", "w", "h", mode="before") + @classmethod + def _coerce_unit(cls, value: object) -> float: + return _finite_unit(value) + + @model_validator(mode="after") + def _inside_viewport(self) -> "AuthoringNormalizedBoundsV1": + if self.x + self.w > 1 + 1e-9 or self.y + self.h > 1 + 1e-9: + raise ValueError("normalized bounds exceed the viewport") + return self + + +class AuthoringWindowV1(_StrictContract): + process_name: StrictStr = Field(pattern=_PROCESS_NAME_PATTERN) + role: Literal["window"] = "window" + bounds: AuthoringNormalizedBoundsV1 + + +class AuthoringNodeV1(_StrictContract): + node_id: StrictStr = Field(pattern=_NODE_ID_PATTERN) + role: ElementRole + control_type: StrictStr | None = Field(default=None, pattern=_PROJECTED_LABEL_PATTERN) + automation_id: StrictStr | None = Field( + default=None, pattern=_PROJECTED_LABEL_PATTERN + ) + class_name: StrictStr | None = Field(default=None, pattern=_PROJECTED_LABEL_PATTERN) + name: StrictStr | None = Field(default=None, pattern=_PROJECTED_LABEL_PATTERN) + enabled: StrictBool + focused: StrictBool + bounds: AuthoringNormalizedBoundsV1 + + @field_validator("control_type", "automation_id", "class_name", "name") + @classmethod + def _labels(cls, value: str | None) -> str | None: + if value is None: + return None + return _projected_label(value) + + +class AuthoringObserveV1(_StrictContract): + """PHI-safe projected tree. No screenshots, titles, or field values.""" + + schema_version: Literal["openadapt.authoring.observe/v1"] = AUTHORING_OBSERVE_SCHEMA + backend: AuthoringBackendV1 + provider: AuthoringProviderV1 + mode: Literal["authoring"] = "authoring" + agent_drive: StrictBool + coach_only: StrictBool + recording: StrictBool + window: AuthoringWindowV1 | None = None + tree: tuple[AuthoringNodeV1, ...] = Field(default=(), max_length=AUTHORING_MAX_NODES) + truncated: StrictBool + node_count: StrictInt = Field(ge=0, le=AUTHORING_MAX_NODES) + reason: AuthoringEmptyProjectionReason | None = None + + @model_validator(mode="after") + def _consistent_projection(self) -> "AuthoringObserveV1": + if self.node_count != len(self.tree): + raise ValueError("node_count must equal the projected tree length") + if self.reason is not None and self.tree: + raise ValueError("empty_projection cannot carry a tree") + if self.coach_only and self.agent_drive: + raise ValueError("coach_only observations cannot advertise agent_drive") + if self.backend.value in _COACH_ONLY_BACKENDS and not self.coach_only: + raise ValueError("this backend is coach_only on the authoring wire") + if self.agent_drive and self.window is None: + raise ValueError("agent_drive observations require a window") + return self + + +class AuthoringEmptyArgsV1(_StrictContract): + pass + + +class AuthoringClickArgsV1(_StrictContract): + node_id: StrictStr = Field(pattern=_NODE_ID_PATTERN) + + +class AuthoringPauseArgsV1(_StrictContract): + param: StrictStr | None = Field(default=None, pattern=_PARAM_NAME_PATTERN) + secret: StrictBool | None = None + + +class AuthoringSetCoachArgsV1(_StrictContract): + hint: StrictStr = Field(pattern=_PROJECTED_LABEL_PATTERN) + + @field_validator("hint") + @classmethod + def _hint(cls, value: str) -> str: + return _projected_label(value) + + +class AuthoringCompileResultV1(_StrictContract): + status: Literal["needs_human_admit"] = "needs_human_admit" + workflow_id: StrictStr = Field(pattern=_WORKFLOW_ID_PATTERN) + recording_retained: StrictBool + + +class AuthoringPauseResultV1(_StrictContract): + recorded: StrictBool + param: StrictStr = Field(pattern=_PARAM_NAME_PATTERN) + + +class AuthoringErrorResultV1(_StrictContract): + error: AuthoringErrorCodeV1 + command_id: StrictStr | None = Field(default=None, pattern=_COMMAND_ID_PATTERN) + + +class AuthoringEnqueueAcceptedV1(_StrictContract): + status: Literal["pending"] = "pending" + command_id: StrictStr = Field(pattern=_COMMAND_ID_PATTERN) + + +_ARGS_BY_TOOL: dict[AuthoringEnqueueToolV1, type[_StrictContract]] = { + AuthoringEnqueueToolV1.CLICK: AuthoringClickArgsV1, + AuthoringEnqueueToolV1.PAUSE_FOR_INPUT: AuthoringPauseArgsV1, + AuthoringEnqueueToolV1.SET_COACH: AuthoringSetCoachArgsV1, +} + +AuthoringCommandArgsV1 = ( + AuthoringClickArgsV1 + | AuthoringPauseArgsV1 + | AuthoringSetCoachArgsV1 + | AuthoringEmptyArgsV1 +) +AuthoringCommandResultV1 = ( + AuthoringObserveV1 + | AuthoringCompileResultV1 + | AuthoringPauseResultV1 + | AuthoringErrorResultV1 +) + + +def _parse_args(tool: AuthoringEnqueueToolV1, args: object) -> AuthoringCommandArgsV1: + model = _ARGS_BY_TOOL.get(tool, AuthoringEmptyArgsV1) + if args is None: + args = {} + parsed = model.model_validate(args) + return parsed + + +class AuthoringCommandV1(_StrictContract): + """Mailbox envelope. Result is PHI-free; args never carry typed values.""" + + schema_version: Literal["openadapt.authoring.command/v1"] = AUTHORING_COMMAND_SCHEMA + command_id: StrictStr = Field(pattern=_COMMAND_ID_PATTERN) + pack_id: StrictStr = Field(pattern=_PACK_ID_PATTERN) + tool: AuthoringEnqueueToolV1 + args: AuthoringCommandArgsV1 + enqueued_at: StrictStr = Field(pattern=_TIMESTAMP_PATTERN) + expires_at: StrictStr = Field(pattern=_TIMESTAMP_PATTERN) + status: AuthoringCommandStatusV1 + result: AuthoringCommandResultV1 | None = None + oauth_sub_sha256: StrictStr = Field(pattern=_SHA256_HEX_PATTERN) + client_id_sha256: StrictStr = Field(pattern=_SHA256_HEX_PATTERN) + + @model_validator(mode="before") + @classmethod + def _typed_args(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + tool = data.get("tool") + try: + parsed_tool = AuthoringEnqueueToolV1(tool) + except ValueError: + return data + payload = dict(data) + payload["args"] = _parse_args(parsed_tool, data.get("args", {})) + return payload + + @model_validator(mode="after") + def _status_and_result(self) -> "AuthoringCommandV1": + expected_args = type(_parse_args(self.tool, self.args.model_dump(mode="json"))) + if type(self.args) is not expected_args: + raise ValueError("command args do not match tool") + if self.expires_at <= self.enqueued_at: + raise ValueError("expires_at must be after enqueued_at") + if self.status is AuthoringCommandStatusV1.ERROR: + if not isinstance(self.result, AuthoringErrorResultV1): + raise ValueError("error status requires an error result") + return self + if self.status is not AuthoringCommandStatusV1.DONE: + if self.result is not None: + raise ValueError("only done or error commands may carry a result") + return self + if self.tool is AuthoringEnqueueToolV1.OBSERVE: + if not isinstance(self.result, AuthoringObserveV1): + raise ValueError("observe result must be authoring observe/v1") + elif self.tool is AuthoringEnqueueToolV1.COMPILE: + if not isinstance(self.result, AuthoringCompileResultV1): + raise ValueError("compile result must be needs_human_admit") + elif self.tool is AuthoringEnqueueToolV1.PAUSE_FOR_INPUT: + if not isinstance(self.result, AuthoringPauseResultV1): + raise ValueError("pause result carries param name only") + elif self.result is not None: + raise ValueError("this tool has no result payload") + return self + + +class AuthoringCommandLookupV1(_StrictContract): + """Non-blocking ``get_command_result`` body. No tree unless observe is done.""" + + command_id: StrictStr = Field(pattern=_COMMAND_ID_PATTERN) + status: AuthoringCommandStatusV1 + retry_after_ms: Literal[1000] | None = None + result: AuthoringCommandResultV1 | None = None + + @model_validator(mode="after") + def _retry_matches_status(self) -> "AuthoringCommandLookupV1": + waiting = self.status in { + AuthoringCommandStatusV1.PENDING, + AuthoringCommandStatusV1.RUNNING, + } + if waiting: + if self.retry_after_ms != AUTHORING_RETRY_AFTER_MS or self.result is not None: + raise ValueError("pending lookup result is null and retry_after_ms is 1000") + return self + if self.retry_after_ms is not None: + raise ValueError("terminal lookup has no retry_after_ms") + if self.status is AuthoringCommandStatusV1.ERROR: + if not isinstance(self.result, AuthoringErrorResultV1): + raise ValueError("error lookup requires an error result") + return self + + +class AuthoringBindMintV1(_StrictContract): + schema_version: Literal["openadapt.authoring.bind/v1"] = AUTHORING_BIND_SCHEMA + bind: StrictStr = Field(pattern=BIND_TOKEN_PATTERN) + deep_link: StrictStr = Field( + min_length=1, + max_length=AUTHORING_MAX_URI_BYTES, + pattern=_DEEP_LINK_PATTERN, + ) + + @field_validator("bind") + @classmethod + def _bind_token(cls, value: str) -> str: + return parse_authoring_bind_token(value) + + @field_validator("deep_link") + @classmethod + def _runner_uri(cls, value: str) -> str: + parse_authoring_runner_uri(value) + return value + + @model_validator(mode="after") + def _bind_matches_link(self) -> "AuthoringBindMintV1": + parsed = parse_authoring_runner_uri(self.deep_link) + if parsed.bind != self.bind: + raise ValueError("deep_link bind does not match bind") + return self + + +class AuthoringBindClaimV1(_StrictContract): + schema_version: Literal["openadapt.authoring.bind/v1"] = AUTHORING_BIND_SCHEMA + leaseSecret: StrictStr = Field(pattern=LEASE_SECRET_PATTERN) + lease_s: Literal[900] = AUTHORING_LEASE_S + + @field_validator("leaseSecret") + @classmethod + def _lease(cls, value: str) -> str: + return parse_authoring_lease_secret(value) + + +class AuthoringBindV1(_StrictContract): + """``bind_status`` body. No token, lease secret, tree, hint, or command args.""" + + model_config = ConfigDict( + extra="forbid", + frozen=True, + json_schema_extra={ + "x-openadapt-bind-token-pattern": BIND_TOKEN_PATTERN, + "x-openadapt-lease-secret-pattern": LEASE_SECRET_PATTERN, + "x-openadapt-bind-token-rejects-hex-body": True, + "x-openadapt-lease-secret-rejects-base64url-body": True, + "x-openadapt-rejected-token-patterns": [ + _CLOUD_RUNNER_TOKEN_PATTERN, + _PAIRING_SECRET_PATTERN, + ], + "x-openadapt-deep-link-scheme": "openadapt://runner", + "x-openadapt-origin": AUTHORING_ORIGIN, + "x-openadapt-lease-s": AUTHORING_LEASE_S, + }, + ) + + schema_version: Literal["openadapt.authoring.bind/v1"] = AUTHORING_BIND_SCHEMA + pack_id: StrictStr = Field(pattern=_PACK_ID_PATTERN) + bound: StrictBool + allowed: StrictBool + client_display: AuthoringClientDisplayV1 | None = None + backend: AuthoringBackendV1 | None = None + coach_only: StrictBool + + @model_validator(mode="after") + def _status_shape(self) -> "AuthoringBindV1": + if not self.bound and self.allowed: + raise ValueError("an unbound laptop cannot be allowed") + if self.client_display is not None and not self.allowed: + raise ValueError("client_display is only present after Allow") + if self.backend is not None and not self.bound: + raise ValueError("backend is only present on a bound laptop") + return self diff --git a/openadapt_types/schemas/authoring-bind-v1.json b/openadapt_types/schemas/authoring-bind-v1.json new file mode 100644 index 0000000..1e304d5 --- /dev/null +++ b/openadapt_types/schemas/authoring-bind-v1.json @@ -0,0 +1,92 @@ +{ + "$defs": { + "AuthoringBackendV1": { + "enum": [ + "macos", + "linux", + "windows", + "web", + "rdp", + "citrix" + ], + "title": "AuthoringBackendV1", + "type": "string" + }, + "AuthoringClientDisplayV1": { + "enum": [ + "ChatGPT", + "Claude" + ], + "title": "AuthoringClientDisplayV1", + "type": "string" + } + }, + "additionalProperties": false, + "description": "``bind_status`` body. No token, lease secret, tree, hint, or command args.", + "properties": { + "allowed": { + "title": "Allowed", + "type": "boolean" + }, + "backend": { + "anyOf": [ + { + "$ref": "#/$defs/AuthoringBackendV1" + }, + { + "type": "null" + } + ], + "default": null + }, + "bound": { + "title": "Bound", + "type": "boolean" + }, + "client_display": { + "anyOf": [ + { + "$ref": "#/$defs/AuthoringClientDisplayV1" + }, + { + "type": "null" + } + ], + "default": null + }, + "coach_only": { + "title": "Coach Only", + "type": "boolean" + }, + "pack_id": { + "pattern": "^(p\\.[A-Za-z0-9_-]{12}|v1\\.[A-Za-z0-9_-]{38,512})$", + "title": "Pack Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.authoring.bind/v1", + "default": "openadapt.authoring.bind/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "pack_id", + "bound", + "allowed", + "coach_only" + ], + "title": "AuthoringBindV1", + "type": "object", + "x-openadapt-bind-token-pattern": "^oab_[A-Za-z0-9_-]{43}$", + "x-openadapt-bind-token-rejects-hex-body": true, + "x-openadapt-deep-link-scheme": "openadapt://runner", + "x-openadapt-lease-s": 900, + "x-openadapt-lease-secret-pattern": "^oals_[a-f0-9]{64}$", + "x-openadapt-lease-secret-rejects-base64url-body": true, + "x-openadapt-origin": "https://openadapt.ai", + "x-openadapt-rejected-token-patterns": [ + "^oar_[a-f0-9]{64}$", + "^oap_[A-Za-z0-9_-]{43}$" + ] +} diff --git a/openadapt_types/schemas/authoring-command-v1.json b/openadapt_types/schemas/authoring-command-v1.json new file mode 100644 index 0000000..d821b2b --- /dev/null +++ b/openadapt_types/schemas/authoring-command-v1.json @@ -0,0 +1,581 @@ +{ + "$defs": { + "AuthoringBackendV1": { + "enum": [ + "macos", + "linux", + "windows", + "web", + "rdp", + "citrix" + ], + "title": "AuthoringBackendV1", + "type": "string" + }, + "AuthoringClickArgsV1": { + "additionalProperties": false, + "properties": { + "node_id": { + "pattern": "^n_[0-9a-f]{8}$", + "title": "Node Id", + "type": "string" + } + }, + "required": [ + "node_id" + ], + "title": "AuthoringClickArgsV1", + "type": "object" + }, + "AuthoringCommandStatusV1": { + "enum": [ + "pending", + "running", + "done", + "error", + "expired", + "halted" + ], + "title": "AuthoringCommandStatusV1", + "type": "string" + }, + "AuthoringCompileResultV1": { + "additionalProperties": false, + "properties": { + "recording_retained": { + "title": "Recording Retained", + "type": "boolean" + }, + "status": { + "const": "needs_human_admit", + "default": "needs_human_admit", + "title": "Status", + "type": "string" + }, + "workflow_id": { + "pattern": "^wf_[A-Za-z0-9_-]{8,64}$", + "title": "Workflow Id", + "type": "string" + } + }, + "required": [ + "workflow_id", + "recording_retained" + ], + "title": "AuthoringCompileResultV1", + "type": "object" + }, + "AuthoringEmptyArgsV1": { + "additionalProperties": false, + "properties": {}, + "title": "AuthoringEmptyArgsV1", + "type": "object" + }, + "AuthoringEmptyProjectionReason": { + "enum": [ + "empty_projection" + ], + "title": "AuthoringEmptyProjectionReason", + "type": "string" + }, + "AuthoringEnqueueToolV1": { + "enum": [ + "observe", + "start_record", + "click", + "halt", + "stop_record", + "compile", + "pause_for_input", + "set_coach", + "get_coach", + "bind_pack" + ], + "title": "AuthoringEnqueueToolV1", + "type": "string" + }, + "AuthoringErrorCodeV1": { + "enum": [ + "stale_node", + "in_flight", + "COACH_ONLY", + "not_bound", + "RECONCILIATION_REQUIRED", + "missing_secret_type" + ], + "title": "AuthoringErrorCodeV1", + "type": "string" + }, + "AuthoringErrorResultV1": { + "additionalProperties": false, + "properties": { + "command_id": { + "anyOf": [ + { + "pattern": "^cmd_[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Command Id" + }, + "error": { + "$ref": "#/$defs/AuthoringErrorCodeV1" + } + }, + "required": [ + "error" + ], + "title": "AuthoringErrorResultV1", + "type": "object" + }, + "AuthoringNodeV1": { + "additionalProperties": false, + "properties": { + "automation_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Automation Id" + }, + "bounds": { + "$ref": "#/$defs/AuthoringNormalizedBoundsV1" + }, + "class_name": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Class Name" + }, + "control_type": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Control Type" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "focused": { + "title": "Focused", + "type": "boolean" + }, + "name": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "node_id": { + "pattern": "^n_[0-9a-f]{8}$", + "title": "Node Id", + "type": "string" + }, + "role": { + "$ref": "#/$defs/ElementRole" + } + }, + "required": [ + "node_id", + "role", + "enabled", + "focused", + "bounds" + ], + "title": "AuthoringNodeV1", + "type": "object" + }, + "AuthoringNormalizedBoundsV1": { + "additionalProperties": false, + "description": "Viewport-normalized overlay coordinates. Not backend pixels.", + "properties": { + "h": { + "maximum": 1, + "minimum": 0, + "title": "H", + "type": "number" + }, + "w": { + "maximum": 1, + "minimum": 0, + "title": "W", + "type": "number" + }, + "x": { + "maximum": 1, + "minimum": 0, + "title": "X", + "type": "number" + }, + "y": { + "maximum": 1, + "minimum": 0, + "title": "Y", + "type": "number" + } + }, + "required": [ + "x", + "y", + "w", + "h" + ], + "title": "AuthoringNormalizedBoundsV1", + "type": "object" + }, + "AuthoringObserveV1": { + "additionalProperties": false, + "description": "PHI-safe projected tree. No screenshots, titles, or field values.", + "properties": { + "agent_drive": { + "title": "Agent Drive", + "type": "boolean" + }, + "backend": { + "$ref": "#/$defs/AuthoringBackendV1" + }, + "coach_only": { + "title": "Coach Only", + "type": "boolean" + }, + "mode": { + "const": "authoring", + "default": "authoring", + "title": "Mode", + "type": "string" + }, + "node_count": { + "maximum": 200, + "minimum": 0, + "title": "Node Count", + "type": "integer" + }, + "provider": { + "$ref": "#/$defs/AuthoringProviderV1" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/$defs/AuthoringEmptyProjectionReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "recording": { + "title": "Recording", + "type": "boolean" + }, + "schema_version": { + "const": "openadapt.authoring.observe/v1", + "default": "openadapt.authoring.observe/v1", + "title": "Schema Version", + "type": "string" + }, + "tree": { + "default": [], + "items": { + "$ref": "#/$defs/AuthoringNodeV1" + }, + "maxItems": 200, + "title": "Tree", + "type": "array" + }, + "truncated": { + "title": "Truncated", + "type": "boolean" + }, + "window": { + "anyOf": [ + { + "$ref": "#/$defs/AuthoringWindowV1" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "backend", + "provider", + "agent_drive", + "coach_only", + "recording", + "truncated", + "node_count" + ], + "title": "AuthoringObserveV1", + "type": "object" + }, + "AuthoringPauseArgsV1": { + "additionalProperties": false, + "properties": { + "param": { + "anyOf": [ + { + "pattern": "^[a-z][a-z0-9_]{0,31}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Param" + }, + "secret": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Secret" + } + }, + "title": "AuthoringPauseArgsV1", + "type": "object" + }, + "AuthoringPauseResultV1": { + "additionalProperties": false, + "properties": { + "param": { + "pattern": "^[a-z][a-z0-9_]{0,31}$", + "title": "Param", + "type": "string" + }, + "recorded": { + "title": "Recorded", + "type": "boolean" + } + }, + "required": [ + "recorded", + "param" + ], + "title": "AuthoringPauseResultV1", + "type": "object" + }, + "AuthoringProviderV1": { + "enum": [ + "playwright_ax", + "macos_ax", + "windows_uia", + "linux_atspi", + "none" + ], + "title": "AuthoringProviderV1", + "type": "string" + }, + "AuthoringSetCoachArgsV1": { + "additionalProperties": false, + "properties": { + "hint": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$", + "title": "Hint", + "type": "string" + } + }, + "required": [ + "hint" + ], + "title": "AuthoringSetCoachArgsV1", + "type": "object" + }, + "AuthoringWindowV1": { + "additionalProperties": false, + "properties": { + "bounds": { + "$ref": "#/$defs/AuthoringNormalizedBoundsV1" + }, + "process_name": { + "pattern": "^[A-Za-z0-9 ._-]{1,64}$", + "title": "Process Name", + "type": "string" + }, + "role": { + "const": "window", + "default": "window", + "title": "Role", + "type": "string" + } + }, + "required": [ + "process_name", + "bounds" + ], + "title": "AuthoringWindowV1", + "type": "object" + }, + "ElementRole": { + "description": "Normalized UI element roles across platforms.\n\nCovers Windows UIA, macOS AX, web ARIA, and OCR-detected elements.", + "enum": [ + "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" + ], + "title": "ElementRole", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Mailbox envelope. Result is PHI-free; args never carry typed values.", + "properties": { + "args": { + "anyOf": [ + { + "$ref": "#/$defs/AuthoringClickArgsV1" + }, + { + "$ref": "#/$defs/AuthoringPauseArgsV1" + }, + { + "$ref": "#/$defs/AuthoringSetCoachArgsV1" + }, + { + "$ref": "#/$defs/AuthoringEmptyArgsV1" + } + ], + "title": "Args" + }, + "client_id_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Client Id Sha256", + "type": "string" + }, + "command_id": { + "pattern": "^cmd_[0-9A-HJKMNP-TV-Z]{26}$", + "title": "Command Id", + "type": "string" + }, + "enqueued_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$", + "title": "Enqueued At", + "type": "string" + }, + "expires_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$", + "title": "Expires At", + "type": "string" + }, + "oauth_sub_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Oauth Sub Sha256", + "type": "string" + }, + "pack_id": { + "pattern": "^(p\\.[A-Za-z0-9_-]{12}|v1\\.[A-Za-z0-9_-]{38,512})$", + "title": "Pack Id", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/AuthoringObserveV1" + }, + { + "$ref": "#/$defs/AuthoringCompileResultV1" + }, + { + "$ref": "#/$defs/AuthoringPauseResultV1" + }, + { + "$ref": "#/$defs/AuthoringErrorResultV1" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Result" + }, + "schema_version": { + "const": "openadapt.authoring.command/v1", + "default": "openadapt.authoring.command/v1", + "title": "Schema Version", + "type": "string" + }, + "status": { + "$ref": "#/$defs/AuthoringCommandStatusV1" + }, + "tool": { + "$ref": "#/$defs/AuthoringEnqueueToolV1" + } + }, + "required": [ + "command_id", + "pack_id", + "tool", + "args", + "enqueued_at", + "expires_at", + "status", + "oauth_sub_sha256", + "client_id_sha256" + ], + "title": "AuthoringCommandV1", + "type": "object" +} diff --git a/openadapt_types/schemas/authoring-observe-v1.json b/openadapt_types/schemas/authoring-observe-v1.json new file mode 100644 index 0000000..84f4a1d --- /dev/null +++ b/openadapt_types/schemas/authoring-observe-v1.json @@ -0,0 +1,302 @@ +{ + "$defs": { + "AuthoringBackendV1": { + "enum": [ + "macos", + "linux", + "windows", + "web", + "rdp", + "citrix" + ], + "title": "AuthoringBackendV1", + "type": "string" + }, + "AuthoringEmptyProjectionReason": { + "enum": [ + "empty_projection" + ], + "title": "AuthoringEmptyProjectionReason", + "type": "string" + }, + "AuthoringNodeV1": { + "additionalProperties": false, + "properties": { + "automation_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Automation Id" + }, + "bounds": { + "$ref": "#/$defs/AuthoringNormalizedBoundsV1" + }, + "class_name": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Class Name" + }, + "control_type": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Control Type" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "focused": { + "title": "Focused", + "type": "boolean" + }, + "name": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "node_id": { + "pattern": "^n_[0-9a-f]{8}$", + "title": "Node Id", + "type": "string" + }, + "role": { + "$ref": "#/$defs/ElementRole" + } + }, + "required": [ + "node_id", + "role", + "enabled", + "focused", + "bounds" + ], + "title": "AuthoringNodeV1", + "type": "object" + }, + "AuthoringNormalizedBoundsV1": { + "additionalProperties": false, + "description": "Viewport-normalized overlay coordinates. Not backend pixels.", + "properties": { + "h": { + "maximum": 1, + "minimum": 0, + "title": "H", + "type": "number" + }, + "w": { + "maximum": 1, + "minimum": 0, + "title": "W", + "type": "number" + }, + "x": { + "maximum": 1, + "minimum": 0, + "title": "X", + "type": "number" + }, + "y": { + "maximum": 1, + "minimum": 0, + "title": "Y", + "type": "number" + } + }, + "required": [ + "x", + "y", + "w", + "h" + ], + "title": "AuthoringNormalizedBoundsV1", + "type": "object" + }, + "AuthoringProviderV1": { + "enum": [ + "playwright_ax", + "macos_ax", + "windows_uia", + "linux_atspi", + "none" + ], + "title": "AuthoringProviderV1", + "type": "string" + }, + "AuthoringWindowV1": { + "additionalProperties": false, + "properties": { + "bounds": { + "$ref": "#/$defs/AuthoringNormalizedBoundsV1" + }, + "process_name": { + "pattern": "^[A-Za-z0-9 ._-]{1,64}$", + "title": "Process Name", + "type": "string" + }, + "role": { + "const": "window", + "default": "window", + "title": "Role", + "type": "string" + } + }, + "required": [ + "process_name", + "bounds" + ], + "title": "AuthoringWindowV1", + "type": "object" + }, + "ElementRole": { + "description": "Normalized UI element roles across platforms.\n\nCovers Windows UIA, macOS AX, web ARIA, and OCR-detected elements.", + "enum": [ + "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" + ], + "title": "ElementRole", + "type": "string" + } + }, + "additionalProperties": false, + "description": "PHI-safe projected tree. No screenshots, titles, or field values.", + "properties": { + "agent_drive": { + "title": "Agent Drive", + "type": "boolean" + }, + "backend": { + "$ref": "#/$defs/AuthoringBackendV1" + }, + "coach_only": { + "title": "Coach Only", + "type": "boolean" + }, + "mode": { + "const": "authoring", + "default": "authoring", + "title": "Mode", + "type": "string" + }, + "node_count": { + "maximum": 200, + "minimum": 0, + "title": "Node Count", + "type": "integer" + }, + "provider": { + "$ref": "#/$defs/AuthoringProviderV1" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/$defs/AuthoringEmptyProjectionReason" + }, + { + "type": "null" + } + ], + "default": null + }, + "recording": { + "title": "Recording", + "type": "boolean" + }, + "schema_version": { + "const": "openadapt.authoring.observe/v1", + "default": "openadapt.authoring.observe/v1", + "title": "Schema Version", + "type": "string" + }, + "tree": { + "default": [], + "items": { + "$ref": "#/$defs/AuthoringNodeV1" + }, + "maxItems": 200, + "title": "Tree", + "type": "array" + }, + "truncated": { + "title": "Truncated", + "type": "boolean" + }, + "window": { + "anyOf": [ + { + "$ref": "#/$defs/AuthoringWindowV1" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "backend", + "provider", + "agent_drive", + "coach_only", + "recording", + "truncated", + "node_count" + ], + "title": "AuthoringObserveV1", + "type": "object" +} diff --git a/scripts/export_authoring_schemas.py b/scripts/export_authoring_schemas.py new file mode 100644 index 0000000..0616211 --- /dev/null +++ b/scripts/export_authoring_schemas.py @@ -0,0 +1,34 @@ +"""Export the authoring MCP JSON Schemas into the package.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from openadapt_types.authoring import ( + AuthoringBindV1, + AuthoringCommandV1, + AuthoringObserveV1, +) + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_DIR = ROOT / "openadapt_types" / "schemas" +SCHEMAS = { + "authoring-observe-v1.json": AuthoringObserveV1, + "authoring-command-v1.json": AuthoringCommandV1, + "authoring-bind-v1.json": AuthoringBindV1, +} + + +def main() -> int: + SCHEMA_DIR.mkdir(parents=True, exist_ok=True) + for filename, model in SCHEMAS.items(): + (SCHEMA_DIR / filename).write_text( + json.dumps(model.model_json_schema(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_authoring.py b/tests/test_authoring.py new file mode 100644 index 0000000..ae7e0d6 --- /dev/null +++ b/tests/test_authoring.py @@ -0,0 +1,462 @@ +"""Authoring MCP wire contracts refuse extra keys and omitted PHI fields.""" + +from __future__ import annotations + +import json +from importlib.resources import files + +import pytest +from pydantic import ValidationError + +from openadapt_types import ( + AUTHORING_BIND_SCHEMA, + AUTHORING_COMMAND_SCHEMA, + AUTHORING_OBSERVE_SCHEMA, + AuthoringBindClaimV1, + AuthoringBindMintV1, + AuthoringBindV1, + AuthoringClickArgsV1, + AuthoringCommandLookupV1, + AuthoringCommandV1, + AuthoringCompileResultV1, + AuthoringEnqueueAcceptedV1, + AuthoringErrorResultV1, + AuthoringNormalizedBoundsV1, + AuthoringObserveV1, + AuthoringPauseResultV1, + ComputerState, + ElementRole, + UINode, + parse_authoring_bind_token, + parse_authoring_lease_secret, + parse_authoring_runner_uri, +) +from openadapt_types.authoring import AuthoringTokenError + + +VALID_BIND = "oab_" + "G" * 43 +VALID_LEASE = "oals_" + "a" * 64 +VALID_PACK = "p.abcdefghijkl" +VALID_COMMAND_ID = "cmd_01JABCDEFGHJKMNPQRSTVWXYZ0" +VALID_NODE = "n_9f2c3a10" +VALID_SUB = "b" * 64 +VALID_CLIENT = "c" * 64 +VALID_DEEP_LINK = ( + f"openadapt://runner?pack={VALID_PACK}&bind={VALID_BIND}" + "&origin=https%3A%2F%2Fopenadapt.ai" +) +FORBIDDEN_FIELDS = { + "value": "secret-ssn", + "title": "Patient chart", + "screenshot": "data:image/png;base64,secret", + "text": "typed note", + "backend_pixels": {"x": 920, "y": 640, "w": 180, "h": 36}, +} + + +def _bounds() -> dict[str, float]: + return {"x": 0.72, "y": 0.88, "w": 0.14, "h": 0.05} + + +def _observe_payload(**updates: object) -> dict[str, object]: + payload: dict[str, object] = { + "schema_version": AUTHORING_OBSERVE_SCHEMA, + "backend": "web", + "provider": "playwright_ax", + "mode": "authoring", + "agent_drive": True, + "coach_only": False, + "recording": False, + "window": { + "process_name": "Chromium", + "role": "window", + "bounds": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}, + }, + "tree": [ + { + "node_id": VALID_NODE, + "role": "button", + "control_type": "button", + "automation_id": "btnContinue", + "enabled": True, + "focused": False, + "bounds": _bounds(), + } + ], + "truncated": False, + "node_count": 1, + } + payload.update(updates) + return payload + + +def _command_payload(**updates: object) -> dict[str, object]: + payload: dict[str, object] = { + "schema_version": AUTHORING_COMMAND_SCHEMA, + "command_id": VALID_COMMAND_ID, + "pack_id": VALID_PACK, + "tool": "click", + "args": {"node_id": VALID_NODE}, + "enqueued_at": "2026-08-31T12:00:00Z", + "expires_at": "2026-08-31T12:15:00Z", + "status": "pending", + "result": None, + "oauth_sub_sha256": VALID_SUB, + "client_id_sha256": VALID_CLIENT, + } + payload.update(updates) + return payload + + +def _unconstrained_string_paths(schema: object) -> list[str]: + paths: list[str] = [] + + def visit(node: object, path: str) -> None: + if isinstance(node, dict): + if node.get("type") == "string" and not ( + {"pattern", "const", "enum"} & set(node) + ): + paths.append(path) + for key, value in node.items(): + visit(value, f"{path}/{key}") + elif isinstance(node, list): + for index, value in enumerate(node): + visit(value, f"{path}/{index}") + + visit(schema, "") + return paths + + +def test_spec_observe_example_is_accepted() -> None: + observe = AuthoringObserveV1.model_validate(_observe_payload()) + assert observe.schema_version == AUTHORING_OBSERVE_SCHEMA + assert observe.tree[0].node_id == VALID_NODE + assert observe.tree[0].role is ElementRole.BUTTON + assert observe.window is not None + assert observe.window.role == "window" + + +def test_observe_does_not_reuse_computer_state_or_ui_node() -> None: + assert not issubclass(AuthoringObserveV1, ComputerState) + assert not issubclass(AuthoringObserveV1, UINode) + dumped = AuthoringObserveV1.model_validate(_observe_payload()).model_dump() + assert "nodes" not in dumped + assert "screenshot_png" not in dumped + assert "active_window" not in dumped + + +@pytest.mark.parametrize("field,value", sorted(FORBIDDEN_FIELDS.items())) +def test_observe_refuses_value_title_screenshot_and_other_phi(field: str, value: object) -> None: + payload = _observe_payload() + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringObserveV1.model_validate({**payload, field: value}) + + window = dict(payload["window"]) # type: ignore[arg-type] + window[field] = value + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringObserveV1.model_validate({**payload, "window": window}) + + node = dict(payload["tree"][0]) # type: ignore[index] + node[field] = value + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringObserveV1.model_validate({**payload, "tree": [node], "node_count": 1}) + + +def test_observe_refuses_unknown_keys() -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringObserveV1.model_validate({**_observe_payload(), "url": "https://example"}) + + +def test_observe_refuses_six_digit_and_at_sign_labels() -> None: + payload = _observe_payload() + node = dict(payload["tree"][0]) # type: ignore[index] + node["name"] = "acct 009321" + with pytest.raises(ValidationError): + AuthoringObserveV1.model_validate({**payload, "tree": [node]}) + node["name"] = "user@example.com" + with pytest.raises(ValidationError): + AuthoringObserveV1.model_validate({**payload, "tree": [node]}) + node["name"] = "https://example" + with pytest.raises(ValidationError): + AuthoringObserveV1.model_validate({**payload, "tree": [node]}) + + +def test_citrix_observe_is_coach_only_with_empty_tree() -> None: + observe = AuthoringObserveV1.model_validate( + { + "backend": "citrix", + "provider": "none", + "agent_drive": False, + "coach_only": True, + "recording": False, + "tree": [], + "truncated": False, + "node_count": 0, + } + ) + assert observe.agent_drive is False + assert observe.tree == () + with pytest.raises(ValidationError, match="coach_only"): + AuthoringObserveV1.model_validate( + { + "backend": "windows", + "provider": "windows_uia", + "agent_drive": True, + "coach_only": False, + "recording": False, + "window": { + "process_name": "App", + "role": "window", + "bounds": {"x": 0, "y": 0, "w": 1, "h": 1}, + }, + "tree": [], + "truncated": False, + "node_count": 0, + } + ) + + +def test_normalized_bounds_are_not_pixels() -> None: + with pytest.raises(ValidationError): + AuthoringNormalizedBoundsV1.model_validate({"x": 920, "y": 640, "w": 180, "h": 36}) + with pytest.raises(ValidationError): + AuthoringNormalizedBoundsV1.model_validate( + {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0} + ) + + +def test_command_click_is_node_id_only() -> None: + command = AuthoringCommandV1.model_validate(_command_payload()) + assert isinstance(command.args, AuthoringClickArgsV1) + assert command.args.node_id == VALID_NODE + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringCommandV1.model_validate( + _command_payload(args={"node_id": VALID_NODE, "x": 12, "y": 40}) + ) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringCommandV1.model_validate( + _command_payload(args={"node_id": VALID_NODE, "value": "typed"}) + ) + + +@pytest.mark.parametrize("field,value", sorted(FORBIDDEN_FIELDS.items())) +def test_command_refuses_value_title_screenshot_and_extra_keys( + field: str, value: object +) -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringCommandV1.model_validate(_command_payload(**{field: value})) + + +def test_pause_result_has_param_name_and_no_value() -> None: + command = AuthoringCommandV1.model_validate( + _command_payload( + tool="pause_for_input", + args={"param": "note", "secret": True}, + status="done", + result={"recorded": True, "param": "note"}, + ) + ) + assert isinstance(command.result, AuthoringPauseResultV1) + assert command.result.param == "note" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringCommandV1.model_validate( + _command_payload( + tool="pause_for_input", + args={"param": "note", "secret": True, "value": "typed"}, + status="pending", + ) + ) + + +def test_compile_result_is_needs_human_admit_not_verified() -> None: + command = AuthoringCommandV1.model_validate( + _command_payload( + tool="compile", + args={}, + status="done", + result={ + "status": "needs_human_admit", + "workflow_id": "wf_recording01", + "recording_retained": True, + }, + ) + ) + assert isinstance(command.result, AuthoringCompileResultV1) + with pytest.raises(ValidationError): + AuthoringCommandV1.model_validate( + _command_payload( + tool="compile", + args={}, + status="done", + result={ + "status": "VERIFIED", + "workflow_id": "wf_recording01", + "recording_retained": True, + }, + ) + ) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringCompileResultV1.model_validate( + { + "status": "needs_human_admit", + "workflow_id": "wf_recording01", + "recording_retained": True, + "success": True, + } + ) + + +def test_pending_lookup_has_null_result() -> None: + lookup = AuthoringCommandLookupV1.model_validate( + { + "command_id": VALID_COMMAND_ID, + "status": "pending", + "retry_after_ms": 1000, + "result": None, + } + ) + assert lookup.result is None + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringCommandLookupV1.model_validate( + { + "command_id": VALID_COMMAND_ID, + "status": "pending", + "retry_after_ms": 1000, + "result": None, + "screenshot": "x", + } + ) + + +def test_enqueue_ack_is_pending_command_id() -> None: + ack = AuthoringEnqueueAcceptedV1.model_validate( + {"status": "pending", "command_id": VALID_COMMAND_ID} + ) + assert ack.command_id == VALID_COMMAND_ID + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringEnqueueAcceptedV1.model_validate( + { + "status": "pending", + "command_id": VALID_COMMAND_ID, + "title": "running", + } + ) + + +def test_bind_status_has_no_secrets_or_tree() -> None: + status = AuthoringBindV1.model_validate( + { + "pack_id": VALID_PACK, + "bound": True, + "allowed": True, + "client_display": "ChatGPT", + "backend": "web", + "coach_only": False, + } + ) + assert status.schema_version == AUTHORING_BIND_SCHEMA + dumped = status.model_dump() + assert "bind" not in dumped + assert "leaseSecret" not in dumped + assert "tree" not in dumped + assert "args" not in dumped + for field, value in FORBIDDEN_FIELDS.items(): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringBindV1.model_validate( + { + "pack_id": VALID_PACK, + "bound": False, + "allowed": False, + "coach_only": True, + field: value, + } + ) + + +def test_bind_token_parser_is_exact_prefix_alphabet_and_length() -> None: + assert parse_authoring_bind_token(VALID_BIND) == VALID_BIND + assert parse_authoring_lease_secret(VALID_LEASE) == VALID_LEASE + rejected = ( + "oar_" + "a" * 64, + "oap_" + "A" * 43, + "oab_" + "a" * 64, + "oals_" + "A" * 43, + "oa", + "oab", + "oals", + "oab_" + "A" * 42, + "oals_" + "a" * 63, + "oab_" + "A" * 43 + "!", + True, + 1, + ) + for value in rejected: + with pytest.raises(AuthoringTokenError): + parse_authoring_bind_token(value) + with pytest.raises(AuthoringTokenError): + parse_authoring_lease_secret(value) + + +def test_runner_uri_accepts_only_runner_fields() -> None: + parsed = parse_authoring_runner_uri(VALID_DEEP_LINK) + assert parsed.pack == VALID_PACK + assert parsed.bind == VALID_BIND + assert parsed.origin == "https://openadapt.ai" + mint = AuthoringBindMintV1.model_validate( + {"bind": VALID_BIND, "deep_link": VALID_DEEP_LINK} + ) + assert mint.bind == VALID_BIND + claim = AuthoringBindClaimV1.model_validate( + {"leaseSecret": VALID_LEASE, "lease_s": 900} + ) + assert claim.lease_s == 900 + with pytest.raises(AuthoringTokenError): + parse_authoring_runner_uri( + f"openadapt://connect?pack={VALID_PACK}&bind={VALID_BIND}" + "&origin=https://openadapt.ai" + ) + with pytest.raises(AuthoringTokenError): + parse_authoring_runner_uri(VALID_DEEP_LINK + "&command=run") + with pytest.raises(AuthoringTokenError): + parse_authoring_runner_uri( + f"openadapt://runner?pack={VALID_PACK}&bind={VALID_BIND}" + "&origin=https://preview.openadapt.ai" + ) + + +def test_error_result_names_stale_node_without_pixels() -> None: + command = AuthoringCommandV1.model_validate( + _command_payload( + status="error", + result={"error": "stale_node"}, + ) + ) + assert isinstance(command.result, AuthoringErrorResultV1) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringErrorResultV1.model_validate( + {"error": "stale_node", "backend_pixels": {"x": 1}} + ) + + +@pytest.mark.parametrize( + ("model", "filename"), + [ + (AuthoringObserveV1, "authoring-observe-v1.json"), + (AuthoringCommandV1, "authoring-command-v1.json"), + (AuthoringBindV1, "authoring-bind-v1.json"), + ], +) +def test_packaged_authoring_schemas_are_strict( + model: type[AuthoringObserveV1 | AuthoringCommandV1 | AuthoringBindV1], + filename: str, +) -> None: + schema = model.model_json_schema() + assert schema["additionalProperties"] is False + encoded = json.dumps(schema) + assert "openadapt.authoring" in encoded + properties = schema.get("properties", {}) + for forbidden in ("value", "title", "screenshot", "text", "backend_pixels"): + assert forbidden not in properties + assert _unconstrained_string_paths(schema) == [] + packaged = files("openadapt_types.schemas").joinpath(filename) + assert json.loads(packaged.read_text(encoding="utf-8")) == schema From 46d08e4533ec98a70524913576bd55a7e26d9570 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 1 Sep 2026 14:38:30 -0400 Subject: [PATCH 2/2] feat(types): close remaining authoring MCP wire holes Bind status now has allow none|pending|granted. get_coach and bind_pack have PHI-free results. Observe is capped at 32 KiB, enqueue at 8 KiB. Hosted click args stay pack_id plus node_id. Extra keys and value, title, and screenshot still fail. Still not ComputerState. --- README.md | 2 + docs/CONTRACTS.md | 19 ++ openadapt_types/__init__.py | 44 ++++ openadapt_types/authoring.py | 244 ++++++++++++++++-- .../schemas/authoring-bind-v1.json | 21 +- .../schemas/authoring-command-v1.json | 158 +++++++++++- .../schemas/authoring-observe-v1.json | 4 +- tests/test_authoring.py | 237 ++++++++++++++++- 8 files changed, 695 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 1e5ce7a..864d148 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ out the pixels. Coordinates are the thing that breaks when a window moves. | `ProcessEvidenceReceiptV1` | One signed root over child receipts, human receipts, and the artifact graph | | `AuthenticationTaskContractV1` | A value-free login requirement bound to an existing attended task | | `AuthoringObserveV1` | PHI-safe authoring observe tree for the hosted MCP wire | +| `AuthoringCommandV1` | Mailbox envelope. Hosted click is `node_id` only; compile is `needs_human_admit` | +| `AuthoringBindV1` | Bind status plus exact `oab_` / `oals_` parsers. No tree, tokens, or secrets | Plus the versioned wire contracts: `ControlOverlayFrameV1`/`V2` and `ControlOverlayTimelineV1`/`V2` for PHI-safe execution overlays, diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 28d0bf6..96c39be 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -121,3 +121,22 @@ freshness rule, user-presence result, MFA result, and verifier result agrees. This is a wire contract, not a complete authentication feature. Capture, Flow, and the operator surface must share the protected interval before a release can claim the complete path. + +## Authoring MCP wire + +`AuthoringObserveV1`, `AuthoringCommandV1`, and `AuthoringBindV1` are the +public hosted-authoring contracts. They do not reuse `ComputerState` or +`UINode`. Sharing `ElementRole` is the only computer-state type on this +wire. The projector that drops field values, titles, and screenshots lives +in Capture. These models refuse those keys. + +Observe is a PHI-safe projected tree: roles, automation ids, normalized +bounds, and `node_id`. It does not carry `value`, `title`, `screenshot`, +`text`, window titles, URLs, backend pixels, or extra keys. Cap is 200 +nodes and 32 KiB. Windows native, RDP, and Citrix are `coach_only`. + +The mailbox envelope is `openadapt.authoring.command/v1`. Hosted `click` +is `{ node_id }` only. Pause results name a param and never a value. +Compile returns `needs_human_admit`, never `VERIFIED`. Bind tokens are +`oab_` plus 43 unreserved characters. Lease secrets are `oals_` plus 64 +hex characters. Cloud `oar_` and pairing `oap_` are refused. diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index 27c55aa..616af24 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -31,13 +31,24 @@ from openadapt_types.authoring import ( AUTHORING_BIND_SCHEMA, AUTHORING_COMMAND_SCHEMA, + AUTHORING_MAX_COMMAND_BYTES, + AUTHORING_MAX_OBSERVE_BYTES, AUTHORING_OBSERVE_SCHEMA, + BIND_SCHEMA_VERSION, + COMMAND_SCHEMA_VERSION, + OBSERVE_SCHEMA_VERSION, + AuthoringAllowStateV1, AuthoringBackendV1, AuthoringBindClaimV1, AuthoringBindMintV1, + AuthoringBindPackArgsV1, + AuthoringBindPackResultV1, AuthoringBindV1, + AuthoringCallbackV1, AuthoringClickArgsV1, + AuthoringClickResultV1, AuthoringClientDisplayV1, + AuthoringCommandLookupArgsV1, AuthoringCommandLookupV1, AuthoringCommandStatusV1, AuthoringCommandV1, @@ -47,14 +58,25 @@ AuthoringEnqueueToolV1, AuthoringErrorCodeV1, AuthoringErrorResultV1, + AuthoringGetCoachResultV1, + AuthoringHaltResultV1, + AuthoringHostedClickArgsV1, + AuthoringHostedPackArgsV1, + AuthoringHostedPauseArgsV1, + AuthoringHostedSetCoachArgsV1, + AuthoringInFlightV1, AuthoringNodeV1, AuthoringNormalizedBoundsV1, + AuthoringNotBoundV1, AuthoringObserveV1, AuthoringPauseArgsV1, AuthoringPauseResultV1, + AuthoringPollRequestV1, AuthoringProviderV1, + AuthoringRecordingResultV1, AuthoringRunnerUriV1, AuthoringSetCoachArgsV1, + AuthoringSetCoachResultV1, AuthoringTokenError, AuthoringWindowV1, BIND_TOKEN_PATTERN, @@ -300,13 +322,24 @@ # authoring MCP wire "AUTHORING_BIND_SCHEMA", "AUTHORING_COMMAND_SCHEMA", + "AUTHORING_MAX_COMMAND_BYTES", + "AUTHORING_MAX_OBSERVE_BYTES", "AUTHORING_OBSERVE_SCHEMA", + "BIND_SCHEMA_VERSION", + "COMMAND_SCHEMA_VERSION", + "OBSERVE_SCHEMA_VERSION", + "AuthoringAllowStateV1", "AuthoringBackendV1", "AuthoringBindClaimV1", "AuthoringBindMintV1", + "AuthoringBindPackArgsV1", + "AuthoringBindPackResultV1", "AuthoringBindV1", + "AuthoringCallbackV1", "AuthoringClickArgsV1", + "AuthoringClickResultV1", "AuthoringClientDisplayV1", + "AuthoringCommandLookupArgsV1", "AuthoringCommandLookupV1", "AuthoringCommandStatusV1", "AuthoringCommandV1", @@ -316,14 +349,25 @@ "AuthoringEnqueueToolV1", "AuthoringErrorCodeV1", "AuthoringErrorResultV1", + "AuthoringGetCoachResultV1", + "AuthoringHaltResultV1", + "AuthoringHostedClickArgsV1", + "AuthoringHostedPackArgsV1", + "AuthoringHostedPauseArgsV1", + "AuthoringHostedSetCoachArgsV1", + "AuthoringInFlightV1", "AuthoringNodeV1", "AuthoringNormalizedBoundsV1", + "AuthoringNotBoundV1", "AuthoringObserveV1", "AuthoringPauseArgsV1", "AuthoringPauseResultV1", + "AuthoringPollRequestV1", "AuthoringProviderV1", + "AuthoringRecordingResultV1", "AuthoringRunnerUriV1", "AuthoringSetCoachArgsV1", + "AuthoringSetCoachResultV1", "AuthoringTokenError", "AuthoringWindowV1", "BIND_TOKEN_PATTERN", diff --git a/openadapt_types/authoring.py b/openadapt_types/authoring.py index bf3077a..5abacf9 100644 --- a/openadapt_types/authoring.py +++ b/openadapt_types/authoring.py @@ -12,6 +12,7 @@ from __future__ import annotations +import json import re from enum import Enum from math import isfinite @@ -40,12 +41,17 @@ AUTHORING_BIND_SCHEMA: Literal["openadapt.authoring.bind/v1"] = ( "openadapt.authoring.bind/v1" ) +OBSERVE_SCHEMA_VERSION = AUTHORING_OBSERVE_SCHEMA +COMMAND_SCHEMA_VERSION = AUTHORING_COMMAND_SCHEMA +BIND_SCHEMA_VERSION = AUTHORING_BIND_SCHEMA AUTHORING_ORIGIN = "https://openadapt.ai" AUTHORING_RUNNER_SCHEME = "openadapt" AUTHORING_RUNNER_ACTION = "runner" AUTHORING_MAX_URI_BYTES = 2048 AUTHORING_MAX_NODES = 200 +AUTHORING_MAX_OBSERVE_BYTES = 32 * 1024 +AUTHORING_MAX_COMMAND_BYTES = 8 * 1024 AUTHORING_LEASE_S = 900 AUTHORING_RETRY_AFTER_MS = 1000 @@ -73,6 +79,11 @@ _PROJECTED_LABEL_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$" _PARAM_NAME_PATTERN = r"^[a-z][a-z0-9_]{0,31}$" _SIX_DIGITS_RE = re.compile(r"\d{6}") +_SSN_RE = re.compile(r"(? AuthoringRunnerUriV1: def _projected_label(value: object) -> str: if not isinstance(value, str) or not re.fullmatch(_PROJECTED_LABEL_PATTERN, value): raise ValueError("projected label is not allowed on the authoring wire") - if _SIX_DIGITS_RE.search(value): + if ( + _SIX_DIGITS_RE.search(value) + or _SSN_RE.search(value) + or _PHONE_RE.search(value) + or _EMAIL_RE.search(value) + or "://" in value + or "@" in value + ): raise ValueError("projected label is not allowed on the authoring wire") return value +def _utf8_bytes(value: object) -> int: + return len(json.dumps(value, separators=(",", ":")).encode("utf-8")) + + +def _reject_oversize(data: Any, limit: int, label: str) -> Any: + if isinstance(data, dict) and _utf8_bytes(data) > limit: + raise ValueError(f"{label} exceeds {limit} bytes") + return data + + def _finite_unit(value: object) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError("normalized bounds must be finite numbers") @@ -314,6 +354,15 @@ def _labels(cls, value: str | None) -> str | None: class AuthoringObserveV1(_StrictContract): """PHI-safe projected tree. No screenshots, titles, or field values.""" + model_config = ConfigDict( + extra="forbid", + frozen=True, + json_schema_extra={ + "x-openadapt-max-bytes": AUTHORING_MAX_OBSERVE_BYTES, + "x-openadapt-max-nodes": AUTHORING_MAX_NODES, + }, + ) + schema_version: Literal["openadapt.authoring.observe/v1"] = AUTHORING_OBSERVE_SCHEMA backend: AuthoringBackendV1 provider: AuthoringProviderV1 @@ -327,6 +376,11 @@ class AuthoringObserveV1(_StrictContract): node_count: StrictInt = Field(ge=0, le=AUTHORING_MAX_NODES) reason: AuthoringEmptyProjectionReason | None = None + @model_validator(mode="before") + @classmethod + def _cap_wire_bytes(cls, data: Any) -> Any: + return _reject_oversize(data, AUTHORING_MAX_OBSERVE_BYTES, "observe") + @model_validator(mode="after") def _consistent_projection(self) -> "AuthoringObserveV1": if self.node_count != len(self.tree): @@ -364,6 +418,43 @@ def _hint(cls, value: str) -> str: return _projected_label(value) +class AuthoringBindPackArgsV1(_StrictContract): + pack_id: StrictStr = Field(pattern=_PACK_ID_PATTERN) + + +class AuthoringHostedPackArgsV1(_StrictContract): + """Hosted MCP arguments for tools that only take a pack id.""" + + pack_id: StrictStr = Field(pattern=_PACK_ID_PATTERN) + + +class AuthoringHostedClickArgsV1(_StrictContract): + """Hosted ``click`` arguments. ``node_id`` only; never pixels or a value.""" + + pack_id: StrictStr = Field(pattern=_PACK_ID_PATTERN) + node_id: StrictStr = Field(pattern=_NODE_ID_PATTERN) + + +class AuthoringHostedPauseArgsV1(_StrictContract): + pack_id: StrictStr = Field(pattern=_PACK_ID_PATTERN) + param: StrictStr | None = Field(default=None, pattern=_PARAM_NAME_PATTERN) + secret: StrictBool | None = None + + +class AuthoringHostedSetCoachArgsV1(_StrictContract): + pack_id: StrictStr = Field(pattern=_PACK_ID_PATTERN) + hint: StrictStr = Field(pattern=_PROJECTED_LABEL_PATTERN) + + @field_validator("hint") + @classmethod + def _hint(cls, value: str) -> str: + return _projected_label(value) + + +class AuthoringCommandLookupArgsV1(_StrictContract): + command_id: StrictStr = Field(pattern=_COMMAND_ID_PATTERN) + + class AuthoringCompileResultV1(_StrictContract): status: Literal["needs_human_admit"] = "needs_human_admit" workflow_id: StrictStr = Field(pattern=_WORKFLOW_ID_PATTERN) @@ -375,6 +466,44 @@ class AuthoringPauseResultV1(_StrictContract): param: StrictStr = Field(pattern=_PARAM_NAME_PATTERN) +class AuthoringRecordingResultV1(_StrictContract): + recording: StrictBool + + +class AuthoringClickResultV1(_StrictContract): + clicked: Literal[True] = True + + +class AuthoringHaltResultV1(_StrictContract): + halted: Literal[True] = True + + +class AuthoringSetCoachResultV1(_StrictContract): + ok: StrictBool + + +class AuthoringGetCoachResultV1(_StrictContract): + hint: StrictStr | None = Field(default=None, pattern=_PROJECTED_LABEL_PATTERN) + + @field_validator("hint") + @classmethod + def _hint(cls, value: str | None) -> str | None: + if value is None: + return None + return _projected_label(value) + + +class AuthoringBindPackResultV1(_StrictContract): + allowed: StrictBool + client_display: AuthoringClientDisplayV1 | None = None + + @model_validator(mode="after") + def _display_after_allow(self) -> "AuthoringBindPackResultV1": + if self.client_display is not None and not self.allowed: + raise ValueError("client_display is only present after Allow") + return self + + class AuthoringErrorResultV1(_StrictContract): error: AuthoringErrorCodeV1 command_id: StrictStr | None = Field(default=None, pattern=_COMMAND_ID_PATTERN) @@ -385,22 +514,62 @@ class AuthoringEnqueueAcceptedV1(_StrictContract): command_id: StrictStr = Field(pattern=_COMMAND_ID_PATTERN) +class AuthoringNotBoundV1(_StrictContract): + status: Literal["not_bound"] = "not_bound" + + +class AuthoringInFlightV1(_StrictContract): + error: Literal["in_flight"] = "in_flight" + command_id: StrictStr = Field(pattern=_COMMAND_ID_PATTERN) + + _ARGS_BY_TOOL: dict[AuthoringEnqueueToolV1, type[_StrictContract]] = { AuthoringEnqueueToolV1.CLICK: AuthoringClickArgsV1, AuthoringEnqueueToolV1.PAUSE_FOR_INPUT: AuthoringPauseArgsV1, AuthoringEnqueueToolV1.SET_COACH: AuthoringSetCoachArgsV1, + AuthoringEnqueueToolV1.BIND_PACK: AuthoringBindPackArgsV1, +} + +_RESULT_BY_TOOL: dict[AuthoringEnqueueToolV1, type[_StrictContract]] = { + AuthoringEnqueueToolV1.OBSERVE: AuthoringObserveV1, + AuthoringEnqueueToolV1.COMPILE: AuthoringCompileResultV1, + AuthoringEnqueueToolV1.PAUSE_FOR_INPUT: AuthoringPauseResultV1, + AuthoringEnqueueToolV1.GET_COACH: AuthoringGetCoachResultV1, + AuthoringEnqueueToolV1.BIND_PACK: AuthoringBindPackResultV1, + AuthoringEnqueueToolV1.START_RECORD: AuthoringRecordingResultV1, + AuthoringEnqueueToolV1.STOP_RECORD: AuthoringRecordingResultV1, + AuthoringEnqueueToolV1.CLICK: AuthoringClickResultV1, + AuthoringEnqueueToolV1.HALT: AuthoringHaltResultV1, + AuthoringEnqueueToolV1.SET_COACH: AuthoringSetCoachResultV1, } +_OPTIONAL_RESULT_TOOLS = frozenset( + { + AuthoringEnqueueToolV1.START_RECORD, + AuthoringEnqueueToolV1.STOP_RECORD, + AuthoringEnqueueToolV1.CLICK, + AuthoringEnqueueToolV1.HALT, + AuthoringEnqueueToolV1.SET_COACH, + } +) + AuthoringCommandArgsV1 = ( AuthoringClickArgsV1 | AuthoringPauseArgsV1 | AuthoringSetCoachArgsV1 + | AuthoringBindPackArgsV1 | AuthoringEmptyArgsV1 ) AuthoringCommandResultV1 = ( AuthoringObserveV1 | AuthoringCompileResultV1 | AuthoringPauseResultV1 + | AuthoringGetCoachResultV1 + | AuthoringBindPackResultV1 + | AuthoringRecordingResultV1 + | AuthoringClickResultV1 + | AuthoringHaltResultV1 + | AuthoringSetCoachResultV1 | AuthoringErrorResultV1 ) @@ -416,6 +585,14 @@ def _parse_args(tool: AuthoringEnqueueToolV1, args: object) -> AuthoringCommandA class AuthoringCommandV1(_StrictContract): """Mailbox envelope. Result is PHI-free; args never carry typed values.""" + model_config = ConfigDict( + extra="forbid", + frozen=True, + json_schema_extra={ + "x-openadapt-max-enqueue-bytes": AUTHORING_MAX_COMMAND_BYTES, + }, + ) + schema_version: Literal["openadapt.authoring.command/v1"] = AUTHORING_COMMAND_SCHEMA command_id: StrictStr = Field(pattern=_COMMAND_ID_PATTERN) pack_id: StrictStr = Field(pattern=_PACK_ID_PATTERN) @@ -431,6 +608,10 @@ class AuthoringCommandV1(_StrictContract): @model_validator(mode="before") @classmethod def _typed_args(cls, data: Any) -> Any: + if isinstance(data, dict): + enqueue = dict(data) + enqueue.pop("result", None) + _reject_oversize(enqueue, AUTHORING_MAX_COMMAND_BYTES, "command envelope") if not isinstance(data, dict): return data tool = data.get("tool") @@ -447,6 +628,11 @@ def _status_and_result(self) -> "AuthoringCommandV1": expected_args = type(_parse_args(self.tool, self.args.model_dump(mode="json"))) if type(self.args) is not expected_args: raise ValueError("command args do not match tool") + if ( + isinstance(self.args, AuthoringBindPackArgsV1) + and self.args.pack_id != self.pack_id + ): + raise ValueError("bind_pack args pack_id must match the envelope") if self.expires_at <= self.enqueued_at: raise ValueError("expires_at must be after enqueued_at") if self.status is AuthoringCommandStatusV1.ERROR: @@ -457,17 +643,17 @@ def _status_and_result(self) -> "AuthoringCommandV1": if self.result is not None: raise ValueError("only done or error commands may carry a result") return self - if self.tool is AuthoringEnqueueToolV1.OBSERVE: - if not isinstance(self.result, AuthoringObserveV1): - raise ValueError("observe result must be authoring observe/v1") - elif self.tool is AuthoringEnqueueToolV1.COMPILE: - if not isinstance(self.result, AuthoringCompileResultV1): - raise ValueError("compile result must be needs_human_admit") - elif self.tool is AuthoringEnqueueToolV1.PAUSE_FOR_INPUT: - if not isinstance(self.result, AuthoringPauseResultV1): - raise ValueError("pause result carries param name only") - elif self.result is not None: - raise ValueError("this tool has no result payload") + expected_result = _RESULT_BY_TOOL.get(self.tool) + if expected_result is None: + if self.result is not None: + raise ValueError("this tool has no result payload") + return self + if self.result is None: + if self.tool in _OPTIONAL_RESULT_TOOLS: + return self + raise ValueError("this tool requires a PHI-free result") + if not isinstance(self.result, expected_result): + raise ValueError("command result does not match tool") return self @@ -560,17 +746,45 @@ class AuthoringBindV1(_StrictContract): schema_version: Literal["openadapt.authoring.bind/v1"] = AUTHORING_BIND_SCHEMA pack_id: StrictStr = Field(pattern=_PACK_ID_PATTERN) bound: StrictBool - allowed: StrictBool + allow: AuthoringAllowStateV1 client_display: AuthoringClientDisplayV1 | None = None backend: AuthoringBackendV1 | None = None coach_only: StrictBool + halted: StrictBool = False @model_validator(mode="after") def _status_shape(self) -> "AuthoringBindV1": - if not self.bound and self.allowed: + granted = self.allow is AuthoringAllowStateV1.GRANTED + if not self.bound and self.allow is not AuthoringAllowStateV1.NONE: raise ValueError("an unbound laptop cannot be allowed") - if self.client_display is not None and not self.allowed: + if self.allow is AuthoringAllowStateV1.PENDING and not self.bound: + raise ValueError("pending allow requires a bound laptop") + if self.client_display is not None and not granted: raise ValueError("client_display is only present after Allow") if self.backend is not None and not self.bound: raise ValueError("backend is only present on a bound laptop") + if not self.bound and not self.coach_only: + raise ValueError("an unbound laptop is coach_only") + return self + + +class AuthoringPollRequestV1(_StrictContract): + wait_seconds: Literal[0] = 0 + lease_seconds: Literal[900] = AUTHORING_LEASE_S + + +class AuthoringCallbackV1(_StrictContract): + """Desktop mailbox callback. PHI-free result only.""" + + command_id: StrictStr | None = Field(default=None, pattern=_COMMAND_ID_PATTERN) + status: AuthoringCommandStatusV1 | None = None + result: AuthoringCommandResultV1 | None = None + halted: StrictBool | None = None + + @model_validator(mode="after") + def _callback_shape(self) -> "AuthoringCallbackV1": + if self.halted is True and self.command_id is None: + return self + if self.command_id is None: + raise ValueError("callback requires command_id unless it is unsigned halt") return self diff --git a/openadapt_types/schemas/authoring-bind-v1.json b/openadapt_types/schemas/authoring-bind-v1.json index 1e304d5..5866931 100644 --- a/openadapt_types/schemas/authoring-bind-v1.json +++ b/openadapt_types/schemas/authoring-bind-v1.json @@ -1,5 +1,14 @@ { "$defs": { + "AuthoringAllowStateV1": { + "enum": [ + "none", + "pending", + "granted" + ], + "title": "AuthoringAllowStateV1", + "type": "string" + }, "AuthoringBackendV1": { "enum": [ "macos", @@ -24,9 +33,8 @@ "additionalProperties": false, "description": "``bind_status`` body. No token, lease secret, tree, hint, or command args.", "properties": { - "allowed": { - "title": "Allowed", - "type": "boolean" + "allow": { + "$ref": "#/$defs/AuthoringAllowStateV1" }, "backend": { "anyOf": [ @@ -58,6 +66,11 @@ "title": "Coach Only", "type": "boolean" }, + "halted": { + "default": false, + "title": "Halted", + "type": "boolean" + }, "pack_id": { "pattern": "^(p\\.[A-Za-z0-9_-]{12}|v1\\.[A-Za-z0-9_-]{38,512})$", "title": "Pack Id", @@ -73,7 +86,7 @@ "required": [ "pack_id", "bound", - "allowed", + "allow", "coach_only" ], "title": "AuthoringBindV1", diff --git a/openadapt_types/schemas/authoring-command-v1.json b/openadapt_types/schemas/authoring-command-v1.json index d821b2b..8432854 100644 --- a/openadapt_types/schemas/authoring-command-v1.json +++ b/openadapt_types/schemas/authoring-command-v1.json @@ -12,6 +12,46 @@ "title": "AuthoringBackendV1", "type": "string" }, + "AuthoringBindPackArgsV1": { + "additionalProperties": false, + "properties": { + "pack_id": { + "pattern": "^(p\\.[A-Za-z0-9_-]{12}|v1\\.[A-Za-z0-9_-]{38,512})$", + "title": "Pack Id", + "type": "string" + } + }, + "required": [ + "pack_id" + ], + "title": "AuthoringBindPackArgsV1", + "type": "object" + }, + "AuthoringBindPackResultV1": { + "additionalProperties": false, + "properties": { + "allowed": { + "title": "Allowed", + "type": "boolean" + }, + "client_display": { + "anyOf": [ + { + "$ref": "#/$defs/AuthoringClientDisplayV1" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "allowed" + ], + "title": "AuthoringBindPackResultV1", + "type": "object" + }, "AuthoringClickArgsV1": { "additionalProperties": false, "properties": { @@ -27,6 +67,27 @@ "title": "AuthoringClickArgsV1", "type": "object" }, + "AuthoringClickResultV1": { + "additionalProperties": false, + "properties": { + "clicked": { + "const": true, + "default": true, + "title": "Clicked", + "type": "boolean" + } + }, + "title": "AuthoringClickResultV1", + "type": "object" + }, + "AuthoringClientDisplayV1": { + "enum": [ + "ChatGPT", + "Claude" + ], + "title": "AuthoringClientDisplayV1", + "type": "string" + }, "AuthoringCommandStatusV1": { "enum": [ "pending", @@ -100,8 +161,14 @@ "in_flight", "COACH_ONLY", "not_bound", + "not_allowed", "RECONCILIATION_REQUIRED", - "missing_secret_type" + "missing_secret_type", + "unknown_command", + "unknown_pack", + "unknown_tool", + "denied", + "halted" ], "title": "AuthoringErrorCodeV1", "type": "string" @@ -132,6 +199,39 @@ "title": "AuthoringErrorResultV1", "type": "object" }, + "AuthoringGetCoachResultV1": { + "additionalProperties": false, + "properties": { + "hint": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hint" + } + }, + "title": "AuthoringGetCoachResultV1", + "type": "object" + }, + "AuthoringHaltResultV1": { + "additionalProperties": false, + "properties": { + "halted": { + "const": true, + "default": true, + "title": "Halted", + "type": "boolean" + } + }, + "title": "AuthoringHaltResultV1", + "type": "object" + }, "AuthoringNodeV1": { "additionalProperties": false, "properties": { @@ -341,7 +441,9 @@ "node_count" ], "title": "AuthoringObserveV1", - "type": "object" + "type": "object", + "x-openadapt-max-bytes": 32768, + "x-openadapt-max-nodes": 200 }, "AuthoringPauseArgsV1": { "additionalProperties": false, @@ -406,6 +508,20 @@ "title": "AuthoringProviderV1", "type": "string" }, + "AuthoringRecordingResultV1": { + "additionalProperties": false, + "properties": { + "recording": { + "title": "Recording", + "type": "boolean" + } + }, + "required": [ + "recording" + ], + "title": "AuthoringRecordingResultV1", + "type": "object" + }, "AuthoringSetCoachArgsV1": { "additionalProperties": false, "properties": { @@ -421,6 +537,20 @@ "title": "AuthoringSetCoachArgsV1", "type": "object" }, + "AuthoringSetCoachResultV1": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "AuthoringSetCoachResultV1", + "type": "object" + }, "AuthoringWindowV1": { "additionalProperties": false, "properties": { @@ -495,6 +625,9 @@ { "$ref": "#/$defs/AuthoringSetCoachArgsV1" }, + { + "$ref": "#/$defs/AuthoringBindPackArgsV1" + }, { "$ref": "#/$defs/AuthoringEmptyArgsV1" } @@ -542,6 +675,24 @@ { "$ref": "#/$defs/AuthoringPauseResultV1" }, + { + "$ref": "#/$defs/AuthoringGetCoachResultV1" + }, + { + "$ref": "#/$defs/AuthoringBindPackResultV1" + }, + { + "$ref": "#/$defs/AuthoringRecordingResultV1" + }, + { + "$ref": "#/$defs/AuthoringClickResultV1" + }, + { + "$ref": "#/$defs/AuthoringHaltResultV1" + }, + { + "$ref": "#/$defs/AuthoringSetCoachResultV1" + }, { "$ref": "#/$defs/AuthoringErrorResultV1" }, @@ -577,5 +728,6 @@ "client_id_sha256" ], "title": "AuthoringCommandV1", - "type": "object" + "type": "object", + "x-openadapt-max-enqueue-bytes": 8192 } diff --git a/openadapt_types/schemas/authoring-observe-v1.json b/openadapt_types/schemas/authoring-observe-v1.json index 84f4a1d..531cc0b 100644 --- a/openadapt_types/schemas/authoring-observe-v1.json +++ b/openadapt_types/schemas/authoring-observe-v1.json @@ -298,5 +298,7 @@ "node_count" ], "title": "AuthoringObserveV1", - "type": "object" + "type": "object", + "x-openadapt-max-bytes": 32768, + "x-openadapt-max-nodes": 200 } diff --git a/tests/test_authoring.py b/tests/test_authoring.py index ae7e0d6..560e8b8 100644 --- a/tests/test_authoring.py +++ b/tests/test_authoring.py @@ -11,19 +11,33 @@ from openadapt_types import ( AUTHORING_BIND_SCHEMA, AUTHORING_COMMAND_SCHEMA, + AUTHORING_MAX_COMMAND_BYTES, + AUTHORING_MAX_OBSERVE_BYTES, AUTHORING_OBSERVE_SCHEMA, + OBSERVE_SCHEMA_VERSION, + AuthoringAllowStateV1, AuthoringBindClaimV1, AuthoringBindMintV1, + AuthoringBindPackResultV1, AuthoringBindV1, + AuthoringCallbackV1, AuthoringClickArgsV1, + AuthoringCommandLookupArgsV1, AuthoringCommandLookupV1, AuthoringCommandV1, AuthoringCompileResultV1, AuthoringEnqueueAcceptedV1, AuthoringErrorResultV1, + AuthoringGetCoachResultV1, + AuthoringHaltResultV1, + AuthoringHostedClickArgsV1, + AuthoringHostedPackArgsV1, + AuthoringInFlightV1, AuthoringNormalizedBoundsV1, + AuthoringNotBoundV1, AuthoringObserveV1, AuthoringPauseResultV1, + AuthoringPollRequestV1, ComputerState, ElementRole, UINode, @@ -51,6 +65,7 @@ "screenshot": "data:image/png;base64,secret", "text": "typed note", "backend_pixels": {"x": 920, "y": 640, "w": 180, "h": 36}, + "provider_runtime_id": "ax-elem-secret", } @@ -170,15 +185,16 @@ def test_observe_refuses_unknown_keys() -> None: def test_observe_refuses_six_digit_and_at_sign_labels() -> None: payload = _observe_payload() node = dict(payload["tree"][0]) # type: ignore[index] - node["name"] = "acct 009321" - with pytest.raises(ValidationError): - AuthoringObserveV1.model_validate({**payload, "tree": [node]}) - node["name"] = "user@example.com" - with pytest.raises(ValidationError): - AuthoringObserveV1.model_validate({**payload, "tree": [node]}) - node["name"] = "https://example" - with pytest.raises(ValidationError): - AuthoringObserveV1.model_validate({**payload, "tree": [node]}) + for name in ( + "acct 009321", + "user@example.com", + "https://example", + "123-45-6789", + "555-123-4567", + ): + node["name"] = name + with pytest.raises(ValidationError): + AuthoringObserveV1.model_validate({**payload, "tree": [node]}) def test_citrix_observe_is_coach_only_with_empty_tree() -> None: @@ -348,25 +364,38 @@ def test_bind_status_has_no_secrets_or_tree() -> None: { "pack_id": VALID_PACK, "bound": True, - "allowed": True, + "allow": "granted", "client_display": "ChatGPT", "backend": "web", "coach_only": False, + "halted": False, } ) assert status.schema_version == AUTHORING_BIND_SCHEMA + assert status.allow is AuthoringAllowStateV1.GRANTED dumped = status.model_dump() assert "bind" not in dumped assert "leaseSecret" not in dumped assert "tree" not in dumped assert "args" not in dumped + pending = AuthoringBindV1.model_validate( + { + "pack_id": VALID_PACK, + "bound": True, + "allow": "pending", + "backend": "web", + "coach_only": False, + } + ) + assert pending.allow is AuthoringAllowStateV1.PENDING + assert pending.client_display is None for field, value in FORBIDDEN_FIELDS.items(): with pytest.raises(ValidationError, match="Extra inputs are not permitted"): AuthoringBindV1.model_validate( { "pack_id": VALID_PACK, "bound": False, - "allowed": False, + "allow": "none", "coach_only": True, field: value, } @@ -454,9 +483,195 @@ def test_packaged_authoring_schemas_are_strict( assert schema["additionalProperties"] is False encoded = json.dumps(schema) assert "openadapt.authoring" in encoded + if filename == "authoring-observe-v1.json": + assert schema["x-openadapt-max-bytes"] == AUTHORING_MAX_OBSERVE_BYTES + assert schema["x-openadapt-max-nodes"] == 200 + if filename == "authoring-command-v1.json": + assert schema["x-openadapt-max-enqueue-bytes"] == AUTHORING_MAX_COMMAND_BYTES properties = schema.get("properties", {}) for forbidden in ("value", "title", "screenshot", "text", "backend_pixels"): assert forbidden not in properties assert _unconstrained_string_paths(schema) == [] packaged = files("openadapt_types.schemas").joinpath(filename) assert json.loads(packaged.read_text(encoding="utf-8")) == schema + + +def test_schema_version_alias_matches_observe_contract() -> None: + assert OBSERVE_SCHEMA_VERSION == AUTHORING_OBSERVE_SCHEMA + assert OBSERVE_SCHEMA_VERSION == "openadapt.authoring.observe/v1" + + +def test_linux_unique_title_may_agent_drive() -> None: + observe = AuthoringObserveV1.model_validate( + _observe_payload(backend="linux", provider="linux_atspi") + ) + assert observe.agent_drive is True + assert observe.coach_only is False + + +def test_empty_projection_is_empty_tree_never_raw() -> None: + observe = AuthoringObserveV1.model_validate( + { + "backend": "web", + "provider": "playwright_ax", + "agent_drive": False, + "coach_only": True, + "recording": False, + "tree": [], + "truncated": False, + "node_count": 0, + "reason": "empty_projection", + } + ) + dumped = observe.model_dump() + assert dumped["tree"] == () + assert "raw" not in dumped + with pytest.raises(ValidationError): + AuthoringObserveV1.model_validate( + { + **_observe_payload(), + "reason": "empty_projection", + } + ) + + +def test_observe_refuses_payloads_over_32kib() -> None: + payload = _observe_payload(value="x" * AUTHORING_MAX_OBSERVE_BYTES) + with pytest.raises(ValidationError, match="32"): + AuthoringObserveV1.model_validate(payload) + + +def test_get_coach_result_is_hint_only() -> None: + command = AuthoringCommandV1.model_validate( + _command_payload( + tool="get_coach", + args={}, + status="done", + result={"hint": "Click Continue"}, + ) + ) + assert isinstance(command.result, AuthoringGetCoachResultV1) + assert command.result.hint == "Click Continue" + empty = AuthoringCommandV1.model_validate( + _command_payload( + tool="get_coach", + args={}, + status="done", + result={"hint": None}, + ) + ) + assert empty.result is not None and empty.result.hint is None + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringGetCoachResultV1.model_validate({"hint": "Click Continue", "value": "ssn"}) + + +def test_bind_pack_args_and_result_are_pack_and_allow_only() -> None: + command = AuthoringCommandV1.model_validate( + _command_payload( + tool="bind_pack", + args={"pack_id": VALID_PACK}, + status="done", + result={"allowed": True, "client_display": "ChatGPT"}, + ) + ) + assert isinstance(command.result, AuthoringBindPackResultV1) + assert command.result.allowed is True + with pytest.raises(ValidationError): + AuthoringCommandV1.model_validate( + _command_payload( + tool="bind_pack", + args={"pack_id": "p.otherpackid1"}, + status="pending", + ) + ) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringBindPackResultV1.model_validate( + {"allowed": True, "client_display": "ChatGPT", "sub": VALID_SUB} + ) + + +def test_halt_and_recording_acks_are_closed() -> None: + halt = AuthoringCommandV1.model_validate( + _command_payload(tool="halt", args={}, status="done", result={"halted": True}) + ) + assert isinstance(halt.result, AuthoringHaltResultV1) + started = AuthoringCommandV1.model_validate( + _command_payload( + tool="start_record", + args={}, + status="done", + result={"recording": True}, + ) + ) + assert started.result is not None and started.result.recording is True + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringHaltResultV1.model_validate({"halted": True, "screenshot": "x"}) + + +def test_hosted_click_args_are_pack_and_node_id() -> None: + args = AuthoringHostedClickArgsV1.model_validate( + {"pack_id": VALID_PACK, "node_id": VALID_NODE} + ) + assert args.node_id == VALID_NODE + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringHostedClickArgsV1.model_validate( + {"pack_id": VALID_PACK, "node_id": VALID_NODE, "x": 12, "y": 40} + ) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringHostedClickArgsV1.model_validate( + {"pack_id": VALID_PACK, "node_id": VALID_NODE, "value": "typed"} + ) + pack = AuthoringHostedPackArgsV1.model_validate({"pack_id": VALID_PACK}) + assert pack.pack_id == VALID_PACK + lookup = AuthoringCommandLookupArgsV1.model_validate({"command_id": VALID_COMMAND_ID}) + assert lookup.command_id == VALID_COMMAND_ID + + +def test_enqueue_not_bound_and_in_flight_are_closed() -> None: + unbound = AuthoringNotBoundV1.model_validate({"status": "not_bound"}) + assert unbound.status == "not_bound" + busy = AuthoringInFlightV1.model_validate( + {"error": "in_flight", "command_id": VALID_COMMAND_ID} + ) + assert busy.command_id == VALID_COMMAND_ID + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringNotBoundV1.model_validate({"status": "not_bound", "tree": []}) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringInFlightV1.model_validate( + {"error": "in_flight", "command_id": VALID_COMMAND_ID, "title": "x"} + ) + + +def test_poll_is_wait_zero_and_callback_is_phi_free() -> None: + poll = AuthoringPollRequestV1.model_validate( + {"wait_seconds": 0, "lease_seconds": 900} + ) + assert poll.wait_seconds == 0 + with pytest.raises(ValidationError): + AuthoringPollRequestV1.model_validate({"wait_seconds": 25, "lease_seconds": 900}) + callback = AuthoringCallbackV1.model_validate( + { + "command_id": VALID_COMMAND_ID, + "status": "done", + "result": {"recorded": True, "param": "note"}, + } + ) + assert callback.result is not None + halt = AuthoringCallbackV1.model_validate({"halted": True}) + assert halt.halted is True + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + AuthoringCallbackV1.model_validate( + { + "command_id": VALID_COMMAND_ID, + "status": "done", + "result": {"recorded": True, "param": "note"}, + "screenshot": "x", + } + ) + + +def test_command_enqueue_without_result_is_capped_at_8kib() -> None: + payload = _command_payload() + payload["value"] = "x" * AUTHORING_MAX_COMMAND_BYTES + with pytest.raises(ValidationError, match="8"): + AuthoringCommandV1.model_validate(payload)