diff --git a/README.md b/README.md index 6a07469..3b6f495 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,36 @@ status, terminal evidence receipts, and signed decision and terminal webhooks. It keeps `waiting_for_reconciliation` as a lifecycle state and `reconciliation_required` as a terminal outcome. +## OpenAdapt Execute v1 + +OpenAdapt Execute is the public asynchronous contract for a qualified +workflow. A partner sends an authorized request, receives an execution ID, and +then reads a terminal receipt or receives a signed webhook. The contract does +not expose a runner, customer data, evidence bytes, application recipes, or +Cloud control-plane internals. + +The generated OpenAPI document is packaged at +`openadapt_types/schemas/execute-v1-openapi.json`. The package also includes a +small Python client. The reference Python and TypeScript clients are in +[`examples/execute`](examples/execute/). Use `https://app.openadapt.ai/api` as +the OpenAdapt Cloud base URL. It uses a partner-provisioned bearer token and +the client appends the stable `/v1` paths. +The client requires an HTTPS base URL and rejects redirects before it can send +the bearer token to another endpoint. + +```python +from openadapt_types import ExecuteClient + +client = ExecuteClient( + base_url="https://app.openadapt.ai/api", + bearer_token="partner-provisioned-token", +) +``` + +The client does not poll forever. A workflow can wait for a human decision or +reconciliation. Use the terminal receipt or signed webhook as the completion +signal. + ## Design principles - **Pydantic v2**: runtime validation, JSON Schema export, fast serialization diff --git a/examples/execute/README.md b/examples/execute/README.md new file mode 100644 index 0000000..b3cce98 --- /dev/null +++ b/examples/execute/README.md @@ -0,0 +1,13 @@ +# OpenAdapt Execute v1 reference clients + +The public contract is the generated +[`execute-v1-openapi.json`](../../openadapt_types/schemas/execute-v1-openapi.json) +document. These small clients show the three stable resource paths. + +Use a partner-provisioned endpoint and bearer token. OpenAdapt Cloud uses +`https://app.openadapt.ai/api` as its base URL. The clients add `/v1` paths. + +The clients do not expose Cloud internals. They do not create qualifications, +issue authority, select application connectors, or handle webhooks. Generate a +complete client from the OpenAPI document when your integration needs full +schema types or webhook receiver support. diff --git a/examples/execute/execute-client.ts b/examples/execute/execute-client.ts new file mode 100644 index 0000000..2b3c2d6 --- /dev/null +++ b/examples/execute/execute-client.ts @@ -0,0 +1,55 @@ +/** + * Minimal TypeScript client for the public OpenAdapt Execute v1 resource paths. + * + * Generate complete language bindings from + * openadapt_types/schemas/execute-v1-openapi.json when your application needs + * all schema types. This example keeps the runtime dependency-free. + */ + +export type ExecuteStatus = { + schema_version: "openadapt.execute-status/v1"; + execution_id: string; + state: "queued" | "running" | "decision_required" | "waiting_for_reconciliation" | "terminal"; + terminal_outcome?: "verified" | "halted_before_effect" | "reconciliation_required" | "rejected_policy" | "failed_platform" | "rolled_back_verified"; + evidence_receipt_id?: string; + updated_at: string; +}; + +export type ExecuteAccepted = { + schema_version: "openadapt.execute-accepted/v1"; + execution_id: string; + state: "queued"; +}; + +export class OpenAdaptExecuteClient { + constructor( + private readonly baseUrl: string, + private readonly bearerToken: string, + ) {} + + async createExecution(request: Record): Promise { + return this.request("/v1/executions", { method: "POST", body: JSON.stringify(request) }); + } + + async getExecution(executionId: string): Promise { + return this.request(`/v1/executions/${encodeURIComponent(executionId)}`); + } + + async getReceipt(executionId: string): Promise> { + return this.request(`/v1/executions/${encodeURIComponent(executionId)}/receipt`); + } + + private async request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${this.baseUrl.replace(/\/$/, "")}${path}`, { + ...init, + headers: { + Accept: "application/json", + Authorization: `Bearer ${this.bearerToken}`, + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...init.headers, + }, + }); + if (!response.ok) throw new Error(`OpenAdapt Execute request failed (${response.status})`); + return response.json() as Promise; + } +} diff --git a/examples/execute/python_client.py b/examples/execute/python_client.py new file mode 100644 index 0000000..077208e --- /dev/null +++ b/examples/execute/python_client.py @@ -0,0 +1,29 @@ +"""Use the published Python reference client against a partner endpoint.""" + +from openadapt_types import ( + EffectStrengthV1, + ExecuteClient, + ExecuteRequestV1, +) + +client = ExecuteClient( + base_url="https://app.openadapt.ai/api", + bearer_token="replace-with-a-partner-provisioned-token", +) + +accepted = client.create_execution( + ExecuteRequestV1( + qualification_id="qualification_12345678", + workflow_version="workflow_20260729", + workflow_digest="sha256:" + "a" * 64, + environment_id="environment_12345678", + parameters={"request_reference": "external-reference"}, + idempotency_key="caller_key_12345678", + authorization_context={ + "actor_id": "caller_agent_12345678", + "authorization_reference": "authorization_12345678", + }, + minimum_effect_strength=EffectStrengthV1.INDEPENDENT_SYSTEM_OF_RECORD, + ) +) +print(accepted.execution_id) diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index f258b1c..c2a44fd 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -105,6 +105,7 @@ sign_execute_webhook_hmac, ) from openadapt_types.execute_openapi import execute_openapi_document +from openadapt_types.execute_client import ExecuteApiError, ExecuteClient from openadapt_types.execution_requirements import ( CAPABILITY_MATCH_SCHEMA, EXECUTION_REQUIREMENTS_SCHEMA, @@ -250,6 +251,8 @@ "ExecuteWebhookV1", "sign_execute_webhook_hmac", "execute_openapi_document", + "ExecuteApiError", + "ExecuteClient", # runner capability and execution requirements "CAPABILITY_MATCH_SCHEMA", "EXECUTION_REQUIREMENTS_SCHEMA", diff --git a/openadapt_types/execute_client.py b/openadapt_types/execute_client.py new file mode 100644 index 0000000..f0794d8 --- /dev/null +++ b/openadapt_types/execute_client.py @@ -0,0 +1,139 @@ +"""Small standard-library client for the public OpenAdapt Execute v1 contract. + +The client only submits and reads the versioned public resources. It does not +implement partner enrollment, webhook delivery, permit issuance, runners, or +application connectors. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Final +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +from openadapt_types.execute import ( + ExecuteAcceptedV1, + ExecuteEvidenceReceiptV1, + ExecuteRequestV1, + ExecuteStatusV1, +) + +_JSON_CONTENT_TYPE: Final = "application/json" + + +class _RejectRedirectHandler(HTTPRedirectHandler): + """Stop before urllib can forward a bearer token to another origin.""" + + def redirect_request( # type: ignore[override] + self, + req: Request, + fp: Any, + code: int, + msg: str, + headers: Any, + newurl: str, + ) -> Request: + raise HTTPError(req.full_url, code, "Execute redirects are not allowed", headers, fp) + + +_NO_REDIRECT_OPENER: Final = build_opener(_RejectRedirectHandler()) + + +class ExecuteApiError(RuntimeError): + """A transport or HTTP error returned by an Execute endpoint.""" + + def __init__(self, status_code: int | None, message: str) -> None: + self.status_code = status_code + super().__init__(message) + + +@dataclass(frozen=True) +class ExecuteClient: + """Call a partner-provisioned Execute endpoint with its bearer token. + + ``base_url`` is the provider base URL. For OpenAdapt Cloud, use + ``https://app.openadapt.ai/api``; this client appends the stable ``/v1`` + resource paths from the public contract. + """ + + base_url: str + bearer_token: str = field(repr=False) + timeout_seconds: float = 30.0 + + def __post_init__(self) -> None: + parsed = urlsplit(self.base_url) + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError( + "base_url must be an HTTPS origin or path prefix without credentials, query, or fragment" + ) + if not self.bearer_token.strip(): + raise ValueError("bearer_token must not be empty") + if self.timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + + def create_execution(self, request: ExecuteRequestV1) -> ExecuteAcceptedV1: + """Submit one qualified execution for durable processing.""" + + response = self._json_request( + method="POST", + path="/v1/executions", + payload=request.model_dump(mode="json"), + ) + return ExecuteAcceptedV1.model_validate(response) + + def get_execution(self, execution_id: str) -> ExecuteStatusV1: + """Return the current public lifecycle state.""" + + response = self._json_request( + method="GET", path=f"/v1/executions/{quote(execution_id, safe='')}" + ) + return ExecuteStatusV1.model_validate(response) + + def get_receipt(self, execution_id: str) -> ExecuteEvidenceReceiptV1: + """Return the terminal receipt after the execution reaches TERMINAL.""" + + response = self._json_request( + method="GET", path=f"/v1/executions/{quote(execution_id, safe='')}/receipt" + ) + return ExecuteEvidenceReceiptV1.model_validate(response) + + def _json_request( + self, *, method: str, path: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: + data = None if payload is None else json.dumps(payload).encode("utf-8") + request = Request( + self.base_url.rstrip("/") + path, + data=data, + method=method, + headers={ + "Accept": _JSON_CONTENT_TYPE, + "Authorization": f"Bearer {self.bearer_token}", + **({"Content-Type": _JSON_CONTENT_TYPE} if data is not None else {}), + }, + ) + try: + with _NO_REDIRECT_OPENER.open(request, timeout=self.timeout_seconds) as response: + raw = response.read() + except HTTPError as exc: + detail = "" if exc.fp is None else exc.read().decode("utf-8", errors="replace") + raise ExecuteApiError(exc.code, detail or exc.reason) from exc + except URLError as exc: + raise ExecuteApiError(None, str(exc.reason)) from exc + + try: + decoded = json.loads(raw) + except json.JSONDecodeError as exc: + raise ExecuteApiError(None, "Execute returned a non-JSON response") from exc + if not isinstance(decoded, dict): + raise ExecuteApiError(None, "Execute returned a non-object JSON response") + return decoded diff --git a/openadapt_types/execute_openapi.py b/openadapt_types/execute_openapi.py index ac6efd8..eb4cfa7 100644 --- a/openadapt_types/execute_openapi.py +++ b/openadapt_types/execute_openapi.py @@ -56,6 +56,7 @@ def execute_openapi_document() -> dict[str, Any]: "/v1/executions": { "post": { "operationId": "createExecution", + "security": [{"bearerAuth": []}], "requestBody": { "required": True, "content": { @@ -79,6 +80,7 @@ def execute_openapi_document() -> dict[str, Any]: "/v1/executions/{execution_id}": { "get": { "operationId": "getExecution", + "security": [{"bearerAuth": []}], "parameters": [execution_id], "responses": { "200": { @@ -95,6 +97,7 @@ def execute_openapi_document() -> dict[str, Any]: "/v1/executions/{execution_id}/receipt": { "get": { "operationId": "getExecutionReceipt", + "security": [{"bearerAuth": []}], "parameters": [execution_id], "responses": { "200": { @@ -112,6 +115,11 @@ def execute_openapi_document() -> dict[str, Any]: "webhooks": { "executionStateChanged": { "post": { + "security": [], + "description": ( + "Verify the body HMAC with the published Execute v1 " + "webhook contract before accepting this delivery." + ), "requestBody": { "required": True, "content": { @@ -127,6 +135,11 @@ def execute_openapi_document() -> dict[str, Any]: }, "executionTerminal": { "post": { + "security": [], + "description": ( + "Verify the body HMAC with the published Execute v1 " + "webhook contract before accepting this delivery." + ), "requestBody": { "required": True, "content": { @@ -142,6 +155,11 @@ def execute_openapi_document() -> dict[str, Any]: }, "executionDecisionRequired": { "post": { + "security": [], + "description": ( + "Verify the body HMAC with the published Execute v1 " + "webhook contract before accepting this delivery." + ), "requestBody": { "required": True, "content": { @@ -156,5 +174,18 @@ def execute_openapi_document() -> dict[str, Any]: } }, }, - "components": {"schemas": schemas}, + "components": { + "schemas": schemas, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OpenAdapt Execute partner token", + "description": ( + "A partner-provisioned, scope-limited service token. " + "Do not put it in browser code or workflow parameters." + ), + } + }, + }, } diff --git a/openadapt_types/schemas/execute-v1-openapi.json b/openadapt_types/schemas/execute-v1-openapi.json index b958503..fa58876 100644 --- a/openadapt_types/schemas/execute-v1-openapi.json +++ b/openadapt_types/schemas/execute-v1-openapi.json @@ -905,6 +905,14 @@ "type": "object" }, "JsonValue": {} + }, + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "OpenAdapt Execute partner token", + "description": "A partner-provisioned, scope-limited service token. Do not put it in browser code or workflow parameters.", + "scheme": "bearer", + "type": "http" + } } }, "info": { @@ -939,7 +947,12 @@ }, "description": "Execution accepted for durable processing." } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "/v1/executions/{execution_id}": { @@ -967,7 +980,12 @@ }, "description": "Current lifecycle state." } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "/v1/executions/{execution_id}/receipt": { @@ -995,13 +1013,19 @@ }, "description": "Terminal receipt with evidence identifiers." } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } } }, "webhooks": { "executionDecisionRequired": { "post": { + "description": "Verify the body HMAC with the published Execute v1 webhook contract before accepting this delivery.", "requestBody": { "content": { "application/json": { @@ -1016,11 +1040,13 @@ "204": { "description": "Accepted by endpoint." } - } + }, + "security": [] } }, "executionStateChanged": { "post": { + "description": "Verify the body HMAC with the published Execute v1 webhook contract before accepting this delivery.", "requestBody": { "content": { "application/json": { @@ -1035,11 +1061,13 @@ "204": { "description": "Accepted by endpoint." } - } + }, + "security": [] } }, "executionTerminal": { "post": { + "description": "Verify the body HMAC with the published Execute v1 webhook contract before accepting this delivery.", "requestBody": { "content": { "application/json": { @@ -1054,7 +1082,8 @@ "204": { "description": "Accepted by endpoint." } - } + }, + "security": [] } } }, diff --git a/tests/test_execute_contract.py b/tests/test_execute_contract.py index f77d092..1d88a56 100644 --- a/tests/test_execute_contract.py +++ b/tests/test_execute_contract.py @@ -2,6 +2,9 @@ import json from importlib.resources import files +from unittest.mock import patch +from urllib.error import HTTPError +from urllib.request import Request import pytest from pydantic import ValidationError @@ -10,6 +13,7 @@ EffectStrengthV1, ExecuteEvidenceContractV1, ExecuteEvidenceReceiptV1, + ExecuteClient, ExecuteLifecycleStateV1, ExecuteRequestV1, ExecuteStatusV1, @@ -18,6 +22,7 @@ execute_openapi_document, sign_execute_webhook_hmac, ) +from openadapt_types.execute_client import _RejectRedirectHandler def _contract(**updates: object) -> ExecuteEvidenceContractV1: @@ -159,3 +164,91 @@ def test_packaged_openapi_is_the_generated_public_contract() -> None: "/v1/executions/{execution_id}", "/v1/executions/{execution_id}/receipt", } + assert "security" not in generated + assert all( + operation["security"] == [{"bearerAuth": []}] + for path in generated["paths"].values() + for operation in path.values() + ) + for webhook in generated["webhooks"].values(): + operation = webhook["post"] + assert operation["security"] == [] + assert "body HMAC" in operation["description"] + + +def test_python_client_uses_the_public_base_url_and_v1_contract() -> None: + class Response: + def __enter__(self) -> "Response": + return self + + def __exit__(self, *_: object) -> None: + return None + + def read(self) -> bytes: + return b'{"schema_version":"openadapt.execute-accepted/v1","execution_id":"execution_12345678","state":"queued"}' + + request = ExecuteRequestV1( + qualification_id="qualification_12345678", + workflow_version="workflow_20260729", + workflow_digest="sha256:" + "c" * 64, + environment_id="environment_12345678", + idempotency_key="caller_key_12345678", + authorization_context={ + "actor_id": "caller_agent_12345678", + "authorization_reference": "authorization_12345678", + }, + minimum_effect_strength=EffectStrengthV1.INDEPENDENT_SYSTEM_OF_RECORD, + ) + with patch( + "openadapt_types.execute_client._NO_REDIRECT_OPENER.open", return_value=Response() + ) as open_request: + accepted = ExecuteClient("https://example.test/api", "partner-token").create_execution( + request + ) + + sent = open_request.call_args.args[0] + assert accepted.execution_id == "execution_12345678" + assert sent.full_url == "https://example.test/api/v1/executions" + assert sent.get_header("Authorization") == "Bearer partner-token" + + +def test_python_client_rejects_redirect_before_token_forwarding() -> None: + original = Request( + "https://partner.example/api/v1/executions", + headers={"Authorization": "Bearer partner-token"}, + ) + + with pytest.raises(HTTPError, match="redirects are not allowed") as error: + _RejectRedirectHandler().redirect_request( + original, + None, + 302, + "Found", + {}, + "https://untrusted.example/collect-token", + ) + + assert error.value.code == 302 + + +def test_python_client_url_encodes_execution_identifiers() -> None: + class Response: + def __enter__(self) -> "Response": + return self + + def __exit__(self, *_: object) -> None: + return None + + def read(self) -> bytes: + return b'{"schema_version":"openadapt.execute-status/v1","execution_id":"execution_12345678","state":"running","updated_at":"2026-07-29T12:00:00Z"}' + + with patch( + "openadapt_types.execute_client._NO_REDIRECT_OPENER.open", return_value=Response() + ) as open_request: + ExecuteClient("https://example.test/api", "partner-token").get_execution( + "execution:with/slash" + ) + + assert open_request.call_args.args[0].full_url.endswith( + "/v1/executions/execution%3Awith%2Fslash" + )