From 2e84d0ab769daf304e890273a80c8f7acd55c200 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 29 Jul 2026 21:24:47 -0400 Subject: [PATCH 1/2] feat: add Execute reference clients --- README.md | 28 +++++ examples/execute/README.md | 13 +++ examples/execute/execute-client.ts | 55 +++++++++ examples/execute/python_client.py | 29 +++++ openadapt_types/__init__.py | 3 + openadapt_types/execute_client.py | 110 ++++++++++++++++++ openadapt_types/execute_openapi.py | 16 ++- .../schemas/execute-v1-openapi.json | 13 +++ tests/test_execute_contract.py | 36 ++++++ 9 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 examples/execute/README.md create mode 100644 examples/execute/execute-client.ts create mode 100644 examples/execute/python_client.py create mode 100644 openadapt_types/execute_client.py diff --git a/README.md b/README.md index 6a07469..3d04c16 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,34 @@ 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. + +```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..6243eca --- /dev/null +++ b/openadapt_types/execute_client.py @@ -0,0 +1,110 @@ +"""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.request import Request, urlopen + +from openadapt_types.execute import ( + ExecuteAcceptedV1, + ExecuteEvidenceReceiptV1, + ExecuteRequestV1, + ExecuteStatusV1, +) + +_JSON_CONTENT_TYPE: Final = "application/json" + + +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: + if not self.base_url.strip(): + raise ValueError("base_url must not be empty") + 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/{execution_id}" + ) + 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/{execution_id}/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 urlopen(request, timeout=self.timeout_seconds) as response: # noqa: S310 + raw = response.read() + except HTTPError as exc: + detail = 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..eac0cac 100644 --- a/openadapt_types/execute_openapi.py +++ b/openadapt_types/execute_openapi.py @@ -52,6 +52,7 @@ def execute_openapi_document() -> dict[str, Any]: ), }, "x-openadapt-schema": EXECUTE_OPENAPI_SCHEMA, + "security": [{"bearerAuth": []}], "paths": { "/v1/executions": { "post": { @@ -156,5 +157,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..cbc95ae 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": { @@ -999,6 +1007,11 @@ } } }, + "security": [ + { + "bearerAuth": [] + } + ], "webhooks": { "executionDecisionRequired": { "post": { diff --git a/tests/test_execute_contract.py b/tests/test_execute_contract.py index f77d092..8c06fd4 100644 --- a/tests/test_execute_contract.py +++ b/tests/test_execute_contract.py @@ -2,6 +2,7 @@ import json from importlib.resources import files +from unittest.mock import patch import pytest from pydantic import ValidationError @@ -10,6 +11,7 @@ EffectStrengthV1, ExecuteEvidenceContractV1, ExecuteEvidenceReceiptV1, + ExecuteClient, ExecuteLifecycleStateV1, ExecuteRequestV1, ExecuteStatusV1, @@ -159,3 +161,37 @@ def test_packaged_openapi_is_the_generated_public_contract() -> None: "/v1/executions/{execution_id}", "/v1/executions/{execution_id}/receipt", } + + +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.urlopen", return_value=Response()) as urlopen: + accepted = ExecuteClient("https://example.test/api", "partner-token").create_execution( + request + ) + + sent = urlopen.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" From e6b3e10af62f2f4151f94d0a5e253d6d0b893133 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 29 Jul 2026 21:29:53 -0400 Subject: [PATCH 2/2] fix: harden Execute client transport --- README.md | 2 + openadapt_types/execute_client.py | 43 ++++++++++--- openadapt_types/execute_openapi.py | 19 +++++- .../schemas/execute-v1-openapi.json | 38 ++++++++---- tests/test_execute_contract.py | 61 ++++++++++++++++++- 5 files changed, 142 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 3d04c16..3b6f495 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,8 @@ 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 diff --git a/openadapt_types/execute_client.py b/openadapt_types/execute_client.py index 6243eca..f0794d8 100644 --- a/openadapt_types/execute_client.py +++ b/openadapt_types/execute_client.py @@ -11,7 +11,8 @@ from dataclasses import dataclass, field from typing import Any, Final from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen +from urllib.parse import quote, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener from openadapt_types.execute import ( ExecuteAcceptedV1, @@ -23,6 +24,24 @@ _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.""" @@ -45,8 +64,18 @@ class ExecuteClient: timeout_seconds: float = 30.0 def __post_init__(self) -> None: - if not self.base_url.strip(): - raise ValueError("base_url must not be empty") + 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: @@ -66,7 +95,7 @@ def get_execution(self, execution_id: str) -> ExecuteStatusV1: """Return the current public lifecycle state.""" response = self._json_request( - method="GET", path=f"/v1/executions/{execution_id}" + method="GET", path=f"/v1/executions/{quote(execution_id, safe='')}" ) return ExecuteStatusV1.model_validate(response) @@ -74,7 +103,7 @@ 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/{execution_id}/receipt" + method="GET", path=f"/v1/executions/{quote(execution_id, safe='')}/receipt" ) return ExecuteEvidenceReceiptV1.model_validate(response) @@ -93,10 +122,10 @@ def _json_request( }, ) try: - with urlopen(request, timeout=self.timeout_seconds) as response: # noqa: S310 + with _NO_REDIRECT_OPENER.open(request, timeout=self.timeout_seconds) as response: raw = response.read() except HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace") + 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 diff --git a/openadapt_types/execute_openapi.py b/openadapt_types/execute_openapi.py index eac0cac..eb4cfa7 100644 --- a/openadapt_types/execute_openapi.py +++ b/openadapt_types/execute_openapi.py @@ -52,11 +52,11 @@ def execute_openapi_document() -> dict[str, Any]: ), }, "x-openadapt-schema": EXECUTE_OPENAPI_SCHEMA, - "security": [{"bearerAuth": []}], "paths": { "/v1/executions": { "post": { "operationId": "createExecution", + "security": [{"bearerAuth": []}], "requestBody": { "required": True, "content": { @@ -80,6 +80,7 @@ def execute_openapi_document() -> dict[str, Any]: "/v1/executions/{execution_id}": { "get": { "operationId": "getExecution", + "security": [{"bearerAuth": []}], "parameters": [execution_id], "responses": { "200": { @@ -96,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": { @@ -113,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": { @@ -128,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": { @@ -143,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": { diff --git a/openadapt_types/schemas/execute-v1-openapi.json b/openadapt_types/schemas/execute-v1-openapi.json index cbc95ae..fa58876 100644 --- a/openadapt_types/schemas/execute-v1-openapi.json +++ b/openadapt_types/schemas/execute-v1-openapi.json @@ -947,7 +947,12 @@ }, "description": "Execution accepted for durable processing." } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "/v1/executions/{execution_id}": { @@ -975,7 +980,12 @@ }, "description": "Current lifecycle state." } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "/v1/executions/{execution_id}/receipt": { @@ -1003,18 +1013,19 @@ }, "description": "Terminal receipt with evidence identifiers." } - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } } }, - "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": { @@ -1029,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": { @@ -1048,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": { @@ -1067,7 +1082,8 @@ "204": { "description": "Accepted by endpoint." } - } + }, + "security": [] } } }, diff --git a/tests/test_execute_contract.py b/tests/test_execute_contract.py index 8c06fd4..1d88a56 100644 --- a/tests/test_execute_contract.py +++ b/tests/test_execute_contract.py @@ -3,6 +3,8 @@ 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 @@ -20,6 +22,7 @@ execute_openapi_document, sign_execute_webhook_hmac, ) +from openadapt_types.execute_client import _RejectRedirectHandler def _contract(**updates: object) -> ExecuteEvidenceContractV1: @@ -161,6 +164,16 @@ 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: @@ -186,12 +199,56 @@ def read(self) -> bytes: }, minimum_effect_strength=EffectStrengthV1.INDEPENDENT_SYSTEM_OF_RECORD, ) - with patch("openadapt_types.execute_client.urlopen", return_value=Response()) as urlopen: + 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 = urlopen.call_args.args[0] + 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" + )