diff --git a/README.md b/README.md index fdebdbe..864d148 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ 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 | +| `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, @@ -95,7 +98,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/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 bc66bdf..616af24 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -28,6 +28,63 @@ ActionTarget, ActionType, ) +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, + AuthoringCompileResultV1, + AuthoringEmptyProjectionReason, + AuthoringEnqueueAcceptedV1, + 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, + LEASE_SECRET_PATTERN, + parse_authoring_bind_token, + parse_authoring_lease_secret, + parse_authoring_runner_uri, +) from openadapt_types.benchmark import ( BenchmarkAction, BenchmarkAgent, @@ -262,6 +319,62 @@ "ElementRole", "ProcessInfo", "UINode", + # 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", + "AuthoringCompileResultV1", + "AuthoringEmptyProjectionReason", + "AuthoringEnqueueAcceptedV1", + "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", + "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..5abacf9 --- /dev/null +++ b/openadapt_types/authoring.py @@ -0,0 +1,790 @@ +"""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 json +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" +) +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 + +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}") +_SSN_RE = re.compile(r"(? 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) + 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") + 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.""" + + 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 + 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="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): + 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 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) + recording_retained: StrictBool + + +class AuthoringPauseResultV1(_StrictContract): + recorded: StrictBool + 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) + + +class AuthoringEnqueueAcceptedV1(_StrictContract): + status: Literal["pending"] = "pending" + 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 +) + + +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.""" + + 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) + 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 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") + 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 ( + 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: + 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 + 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 + + +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 + 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": + 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.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 new file mode 100644 index 0000000..5866931 --- /dev/null +++ b/openadapt_types/schemas/authoring-bind-v1.json @@ -0,0 +1,105 @@ +{ + "$defs": { + "AuthoringAllowStateV1": { + "enum": [ + "none", + "pending", + "granted" + ], + "title": "AuthoringAllowStateV1", + "type": "string" + }, + "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": { + "allow": { + "$ref": "#/$defs/AuthoringAllowStateV1" + }, + "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" + }, + "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", + "type": "string" + }, + "schema_version": { + "const": "openadapt.authoring.bind/v1", + "default": "openadapt.authoring.bind/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "pack_id", + "bound", + "allow", + "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..8432854 --- /dev/null +++ b/openadapt_types/schemas/authoring-command-v1.json @@ -0,0 +1,733 @@ +{ + "$defs": { + "AuthoringBackendV1": { + "enum": [ + "macos", + "linux", + "windows", + "web", + "rdp", + "citrix" + ], + "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": { + "node_id": { + "pattern": "^n_[0-9a-f]{8}$", + "title": "Node Id", + "type": "string" + } + }, + "required": [ + "node_id" + ], + "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", + "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", + "not_allowed", + "RECONCILIATION_REQUIRED", + "missing_secret_type", + "unknown_command", + "unknown_pack", + "unknown_tool", + "denied", + "halted" + ], + "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" + }, + "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": { + "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", + "x-openadapt-max-bytes": 32768, + "x-openadapt-max-nodes": 200 + }, + "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" + }, + "AuthoringRecordingResultV1": { + "additionalProperties": false, + "properties": { + "recording": { + "title": "Recording", + "type": "boolean" + } + }, + "required": [ + "recording" + ], + "title": "AuthoringRecordingResultV1", + "type": "object" + }, + "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" + }, + "AuthoringSetCoachResultV1": { + "additionalProperties": false, + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "AuthoringSetCoachResultV1", + "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/AuthoringBindPackArgsV1" + }, + { + "$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/AuthoringGetCoachResultV1" + }, + { + "$ref": "#/$defs/AuthoringBindPackResultV1" + }, + { + "$ref": "#/$defs/AuthoringRecordingResultV1" + }, + { + "$ref": "#/$defs/AuthoringClickResultV1" + }, + { + "$ref": "#/$defs/AuthoringHaltResultV1" + }, + { + "$ref": "#/$defs/AuthoringSetCoachResultV1" + }, + { + "$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", + "x-openadapt-max-enqueue-bytes": 8192 +} diff --git a/openadapt_types/schemas/authoring-observe-v1.json b/openadapt_types/schemas/authoring-observe-v1.json new file mode 100644 index 0000000..531cc0b --- /dev/null +++ b/openadapt_types/schemas/authoring-observe-v1.json @@ -0,0 +1,304 @@ +{ + "$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", + "x-openadapt-max-bytes": 32768, + "x-openadapt-max-nodes": 200 +} 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..560e8b8 --- /dev/null +++ b/tests/test_authoring.py @@ -0,0 +1,677 @@ +"""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_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, + 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}, + "provider_runtime_id": "ax-elem-secret", +} + + +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] + 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: + 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, + "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, + "allow": "none", + "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 + 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)