From 58ceb1e5df4780b4a00431c4b673f0af913c216b Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Sun, 11 Oct 2026 03:19:18 -0700 Subject: [PATCH 1/5] Add secrets management and secret delivery to sandboxes Adds client.secrets (sync and async) for the API-key routes: public key, list, create, get, update, add_version, delete and delete_version. Values are encrypted client-side as a compact JWE (RSA-OAEP-256 + A256GCM) bound to the secret's encryption context, after checking the key's policy and RFC 7638 key ID. Secrets resolve by ID or exact name. Sandbox image launches accept `secrets` for the startup command, and exec/processes.start accept `secrets`, starting through the control API and waiting through the runtime. Sandboxes and snapshots expose contains_secrets. Control API errors honour the server's `retryable` flag and a machine code sent as `error`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NhbFMc29f5JN5JxCciajJV --- README.md | 64 ++ hyperbrowser/client/async_client.py | 2 + .../client/managers/async_manager/sandbox.py | 48 +- .../sandboxes/sandbox_processes.py | 101 ++- .../client/managers/async_manager/secret.py | 206 +++++ .../client/managers/sandboxes/shared.py | 47 +- .../client/managers/sync_manager/sandbox.py | 46 +- .../sandboxes/sandbox_processes.py | 95 ++- .../client/managers/sync_manager/secret.py | 198 +++++ hyperbrowser/client/sync.py | 2 + hyperbrowser/models/__init__.py | 32 +- hyperbrowser/models/sandbox.py | 27 + hyperbrowser/models/secret.py | 108 +++ hyperbrowser/sandbox_common.py | 44 +- hyperbrowser/secrets_common.py | 264 +++++++ hyperbrowser/transport/async_transport.py | 11 +- hyperbrowser/transport/sync.py | 11 +- hyperbrowser/types/__init__.py | 13 +- hyperbrowser/types/sandbox.py | 4 + hyperbrowser/types/secret.py | 45 ++ poetry.lock | 529 ++++++++++++- pyproject.toml | 1 + tests/test_sandbox_wire_contract.py | 1 + tests/test_secrets.py | 741 ++++++++++++++++++ tests/typecheck/invalid_requests.py | 2 + tests/typecheck/valid_requests.py | 60 +- 26 files changed, 2662 insertions(+), 40 deletions(-) create mode 100644 hyperbrowser/client/managers/async_manager/secret.py create mode 100644 hyperbrowser/client/managers/sync_manager/secret.py create mode 100644 hyperbrowser/models/secret.py create mode 100644 hyperbrowser/secrets_common.py create mode 100644 hyperbrowser/types/secret.py create mode 100644 tests/test_secrets.py diff --git a/README.md b/README.md index 3bcf28ac..2e746980 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,70 @@ client.volumes.delete(same_volume.id) client.close() ``` +### Manage secrets and pass them to sandbox processes + +Secrets hold values such as API keys for sandbox processes. Values are +encrypted on your machine to the platform's public key before upload, and the +API never returns them. Methods that take a secret accept its ID or exact name. + +```python +from pathlib import Path + +from hyperbrowser import Hyperbrowser + +client = Hyperbrowser(api_key="test-key") + +# Values are str or bytes and are stored byte-for-byte, trailing newlines included. +client.secrets.create( + { + "name": "provider-key", + "description": "Provider API key", + "value": Path("provider-key.txt").read_bytes(), + } +) +client.secrets.add_version("provider-key", "rotated-value") # becomes version 2 +detail = client.secrets.get("provider-key") # metadata and versions, never the value +client.secrets.list({"search": "provider"}) + +sandbox = client.sandboxes.create( + { + "image_name": "node", + # Delivered to the image's startup command (image launches only). + "secrets": {"API_KEY": "provider-key"}, + } +) + +# A plain name uses the latest version; {"name": ..., "version": N} pins one. +result = sandbox.exec( + "node app.js", + secrets={"API_KEY": {"name": "provider-key", "version": 2}}, +) +process = sandbox.processes.start("node worker.js", secrets={"API_KEY": "provider-key"}) +print(process.secrets) # the version each variable received + +client.secrets.delete_version("provider-key", 1) +client.secrets.delete("provider-key") +client.close() +``` + +Things to know: + +- A secret is set only in the environment of the process it was passed to. + Anything else running as the same user, or as root, in the sandbox can read + that environment. +- A sandbox that has received secrets, and snapshots taken of it, report + `contains_secrets=True`. Restoring such a snapshot needs the same access as + passing secrets. +- Deleting a secret or version doesn't revoke a value already delivered to a + sandbox. +- Limits: 100 secrets per team, 10 versions per secret, 32 secrets per process, + and values of 1 byte to 32 KiB of UTF-8 without NUL bytes. +- API keys need the `secrets.read`, `secrets.manage` or `secrets.use` scope for + reading, changing or using secrets, and the team needs the secrets feature. +- A process start with secrets that times out raises an error with code + `secret_process_outcome_unknown`: the command may be running, so check + `sandbox.processes.list()` before retrying. + ### List sandboxes with filters ```python diff --git a/hyperbrowser/client/async_client.py b/hyperbrowser/client/async_client.py index ce4153a3..c30135f4 100644 --- a/hyperbrowser/client/async_client.py +++ b/hyperbrowser/client/async_client.py @@ -10,6 +10,7 @@ from .managers.async_manager.extract import ExtractManager from .managers.async_manager.profile import ProfileManager from .managers.async_manager.sandbox import SandboxManager +from .managers.async_manager.secret import SecretManager from .managers.async_manager.scrape import ScrapeManager from .managers.async_manager.session import SessionManager from .managers.async_manager.team import TeamManager @@ -49,6 +50,7 @@ def __init__( self.computer_action = ComputerActionManager(self) self.sandboxes = SandboxManager(self) self.volumes = VolumeManager(self) + self.secrets = SecretManager(self) async def close(self) -> None: await self.transport.close() diff --git a/hyperbrowser/client/managers/async_manager/sandbox.py b/hyperbrowser/client/managers/async_manager/sandbox.py index 1468b43d..7e3bb8bc 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -41,6 +41,11 @@ StartSandboxFromSnapshotParams, ) from ....models.session import BasicResponse +from ....secrets_common import ( + missing_process_id_error, + request_may_have_arrived, + secret_process_outcome_unknown_error, +) from ....types import ( CompleteSandboxImageBuildParams as CompleteSandboxImageBuildParamsDict, CreateSandboxImageBuildParams as CreateSandboxImageBuildParamsDict, @@ -72,6 +77,7 @@ completed_image_id, ) from ..sandboxes.shared import ( + SecretReferences, _build_sandbox_exposed_url, _copy_model, _expires_within_buffer, @@ -143,7 +149,11 @@ def __init__(self, service: "SandboxManager", detail: SandboxDetail): service.runtime_timeout, service.runtime_proxy_override, ) - self.processes = SandboxProcessesApi(self._transport) + self.processes = SandboxProcessesApi( + self._transport, + self._start_secret_process, + detail.id, + ) self.files = SandboxFilesApi( self._transport, self._resolve_runtime_socket_info, @@ -318,7 +328,16 @@ async def exec( timeout_sec: Optional[int] = None, run_as: Optional[str] = None, max_output_bytes: int = 64 * 1024 * 1024, + secrets: Optional[SecretReferences] = None, ): + """Run a command to completion. + + ``secrets`` maps environment variable names to stored secrets (a name, + or ``{"name": ..., "version": N}``). Anything else running as the + same user, or as root, in the sandbox can read a process's + environment, and the sandbox and its later snapshots are marked as + containing secrets. + """ return await self.processes.exec( input, cwd=cwd, @@ -327,8 +346,12 @@ async def exec( timeout_sec=timeout_sec, run_as=run_as, max_output_bytes=max_output_bytes, + secrets=secrets, ) + async def _start_secret_process(self, payload: Dict[str, object]) -> dict: + return await self._service.start_secret_process(self.id, payload) + async def get_process(self, process_id: str) -> SandboxProcessHandle: return await self.processes.get(process_id) @@ -1127,6 +1150,29 @@ async def unexpose(self, sandbox_id: str, port: int) -> SandboxUnexposeResult: ) return SandboxUnexposeResult(**payload) + async def start_secret_process( + self, sandbox_id: str, payload: Dict[str, object] + ) -> dict: + """Start a sandbox process with secrets through the control API. + + Returns the raw ``{"process": ..., "secrets": [...]}`` response. Prefer + ``sandbox.exec(..., secrets=...)`` or ``sandbox.processes.start``. + """ + try: + response = await self._request( + "POST", f"/sandbox/{sandbox_id}/processes", data=payload + ) + except HyperbrowserError as error: + # A start that timed out, or whose response couldn't be read, may + # be running with the secrets, so it must not look retryable. + if request_may_have_arrived(error): + raise secret_process_outcome_unknown_error(sandbox_id, error) + raise + process = response.get("process") if isinstance(response, dict) else None + if not isinstance(process, dict) or not str(process.get("id") or "").strip(): + raise missing_process_id_error(sandbox_id) + return response + async def _create_detail( self, params: Union[CreateSandboxParamsDict, CreateSandboxParams], diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py index 112920a9..30db2463 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py @@ -1,8 +1,8 @@ import asyncio import base64 -from typing import AsyncIterator, Dict, Optional, Union +from typing import AsyncIterator, Awaitable, Callable, Dict, List, Optional, Union -from ...._request import coerce_request, dump_request +from ...._request import coerce_request from .....models.sandbox import ( SandboxExecParams, SandboxProcessExitEvent, @@ -12,11 +12,19 @@ SandboxProcessStdinParams, SandboxProcessSummary, ) +from .....exceptions import HyperbrowserError +from .....models.secret import SandboxResolvedSecret +from .....secrets_common import secret_process_failed_error, secret_wait_chunk_ms from .....types import ( SandboxExecParams as SandboxExecParamsDict, SandboxProcessStdinParams as SandboxProcessStdinParamsDict, ) -from ...sandboxes.shared import _normalize_exec_params +from ...sandboxes.shared import ( + SecretReferences, + _normalize_exec_params, + _runtime_process_payload, + _secret_process_payload, +) from ...sandboxes.process_output import ( DEFAULT_MAX_PROCESS_OUTPUT_BYTES, ProcessOutput, @@ -35,6 +43,9 @@ def __init__(self, transport: RuntimeTransport, summary: SandboxProcessSummary): self._collector = None self._events = None self._changed = asyncio.Event() + # The secret versions this process received, when it was started + # with secrets; None otherwise. + self.secrets: Optional[List[SandboxResolvedSecret]] = None @property def id(self) -> str: @@ -260,8 +271,17 @@ async def result(self) -> SandboxProcessResult: class SandboxProcessesApi: - def __init__(self, transport: RuntimeTransport): + def __init__( + self, + transport: RuntimeTransport, + start_secret_process: Optional[ + Callable[[Dict[str, object]], Awaitable[dict]] + ] = None, + sandbox_id: Optional[str] = None, + ): self._transport = transport + self._start_secret_process = start_secret_process + self._sandbox_id = sandbox_id async def exec( self, @@ -273,6 +293,7 @@ async def exec( timeout_sec: Optional[int] = None, run_as: Optional[str] = None, max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, + secrets: Optional[SecretReferences] = None, ) -> SandboxProcessResult: handle = await self.start( input, @@ -282,7 +303,10 @@ async def exec( timeout_sec=timeout_sec, run_as=run_as, max_output_bytes=max_output_bytes, + secrets=secrets, ) + if handle.secrets is not None: + return await self._finish_secret_exec(handle) try: return await handle.wait() finally: @@ -298,7 +322,14 @@ async def start( timeout_sec: Optional[int] = None, run_as: Optional[str] = None, max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, + secrets: Optional[SecretReferences] = None, ) -> SandboxProcessHandle: + """Start a process. + + With ``secrets``, the start goes through the control API, which has the + platform decrypt them into the process environment; output is then + read with ``wait()`` rather than collected from the start. + """ validate_output_limit(max_output_bytes) params = _normalize_exec_params( input, @@ -307,11 +338,14 @@ async def start( timeout_ms=timeout_ms, timeout_sec=timeout_sec, run_as=run_as, + secrets=secrets, ) + if params.secrets: + return await self._start_with_secrets(params) events = self._transport.stream_sse( "/sandbox/processes", method="POST", - json_body=dump_request(params, SandboxExecParams), + json_body=_runtime_process_payload(params), ) try: started = await events.__anext__() @@ -328,6 +362,63 @@ async def start( handle._collector = asyncio.create_task(handle._collect()) return handle + async def _start_with_secrets( + self, params: SandboxExecParams + ) -> SandboxProcessHandle: + if self._start_secret_process is None: + raise HyperbrowserError( + "Starting a process with secrets needs a sandbox handle; use " + "client.sandboxes.get() and its processes API", + code="secrets_unsupported", + ) + payload = await self._start_secret_process(_secret_process_payload(params)) + handle = SandboxProcessHandle( + self._transport, SandboxProcessSummary(**payload["process"]) + ) + handle.secrets = [ + SandboxResolvedSecret(**entry) for entry in payload.get("secrets") or [] + ] + return handle + + async def _finish_secret_exec( + self, handle: SandboxProcessHandle + ) -> SandboxProcessResult: + """Close stdin and wait for the result, as one-shot exec does. + + The process may still be running with its secrets after a failure here, + so failures name it instead of looking like a safe retry. + """ + sandbox_id = self._sandbox_id or "" + try: + await handle.write_stdin(eof=True) + except HyperbrowserError as error: + # 409: the command already exited, like a pipe whose reader quit + # early. Its result is still reported. + if error.status_code != 409: + raise secret_process_failed_error( + sandbox_id, handle.id, "closing its stdin", error + ) + except Exception as error: + raise secret_process_failed_error( + sandbox_id, handle.id, "closing its stdin", error + ) + chunk_ms = secret_wait_chunk_ms(getattr(self._transport, "_timeout", None)) + while True: + try: + return await handle.wait(timeout_ms=chunk_ms) + except HyperbrowserError as error: + # The command's own timeout is enforced in the sandbox, so an + # expired wait only means it is still running. + if error.status_code == 408: + continue + raise secret_process_failed_error( + sandbox_id, handle.id, "waiting for its result", error + ) + except Exception as error: + raise secret_process_failed_error( + sandbox_id, handle.id, "waiting for its result", error + ) + async def get(self, process_id: str) -> SandboxProcessHandle: payload = await self._transport.request_json(f"/sandbox/processes/{process_id}") return SandboxProcessHandle( diff --git a/hyperbrowser/client/managers/async_manager/secret.py b/hyperbrowser/client/managers/async_manager/secret.py new file mode 100644 index 00000000..ce64c255 --- /dev/null +++ b/hyperbrowser/client/managers/async_manager/secret.py @@ -0,0 +1,206 @@ +import asyncio +from typing import Dict, Optional, Union + +from ..._request import coerce_request, dump_request +from ....exceptions import HyperbrowserError +from ....models.secret import ( + CreateSecretParams, + Secret, + SecretDeleteResult, + SecretDetail, + SecretListParams, + SecretListResponse, + SecretsPublicKey, + SecretVersion, + SecretVersionDeleteResult, + UpdateSecretParams, +) +from ....sandbox_common import ( + ensure_response_ok, + get_retry_delay_seconds, + normalize_network_error, + parse_json_response, + request_context, + should_retry_get, +) +from ....secrets_common import ( + SECRET_NAME_LOOKUP_LIMIT, + SecretsSealer, + is_not_found, + looks_like_secret_id, + secret_not_found_error, + secret_value_bytes, + secret_value_not_stored_error, +) +from ....types import CreateSecretParams as CreateSecretParamsDict +from ....types import SecretListParams as SecretListParamsDict +from ....types import UpdateSecretParams as UpdateSecretParamsDict + + +class SecretManager: + """Manage encrypted secrets for sandbox processes and startup commands. + + Values are encrypted on this machine to the platform's public key and are + never returned by the API. Methods that take ``secret`` accept a secret's + ID or its exact name. + """ + + def __init__(self, client): + self._client = client + + async def get_public_key(self) -> SecretsPublicKey: + return SecretsPublicKey(**await self._request("GET", "/secrets/public-key")) + + async def list( + self, + params: Optional[Union[SecretListParamsDict, SecretListParams]] = None, + ) -> SecretListResponse: + payload = await self._request( + "GET", + "/secrets", + params=dump_request( + params if params is not None else {}, + SecretListParams, + ), + ) + return SecretListResponse(**payload) + + async def create( + self, + params: Union[CreateSecretParamsDict, CreateSecretParams], + ) -> Secret: + """Create a secret, and store its first version when ``value`` is set. + + The value and the public key are checked before the secret is created, + so a bad value or an unavailable key leaves nothing behind. + """ + normalized = coerce_request(params, CreateSecretParams) + value = normalized.value + sealer = None + if value is not None: + secret_value_bytes(value) + sealer = SecretsSealer(await self.get_public_key()) + secret = Secret( + **await self._request( + "POST", + "/secrets", + data=normalized.model_dump(by_alias=True, exclude_none=True), + ) + ) + if sealer is None or value is None: + return secret + try: + version = await self._add_version(secret, value, sealer) + except Exception as error: + raise secret_value_not_stored_error(secret.id, secret.name, error) + return secret.model_copy(update={"latest_version": version.version}) + + async def get(self, secret: str) -> SecretDetail: + detail, complete = await self._resolve(secret) + if complete: + return detail + return await self._get_by_id(detail.id) + + async def update( + self, + secret: str, + params: Union[UpdateSecretParamsDict, UpdateSecretParams], + ) -> Secret: + secret_id = (await self._resolve(secret))[0].id + payload = await self._request( + "PATCH", + f"/secrets/{secret_id}", + data=dump_request(params, UpdateSecretParams), + ) + return Secret(**payload) + + async def add_version(self, secret: str, value: Union[str, bytes]) -> SecretVersion: + """Encrypt ``value`` locally and store it as the secret's next version.""" + secret_value_bytes(value) + resolved = (await self._resolve(secret))[0] + return await self._add_version( + resolved, value, SecretsSealer(await self.get_public_key()) + ) + + async def delete(self, secret: str) -> SecretDeleteResult: + """Delete a secret and all its versions. Values already delivered to + sandboxes are not revoked.""" + secret_id = (await self._resolve(secret))[0].id + return SecretDeleteResult( + **await self._request("DELETE", f"/secrets/{secret_id}") + ) + + async def delete_version( + self, secret: str, version: int + ) -> SecretVersionDeleteResult: + """Delete one version. The latest version can't be deleted.""" + secret_id = (await self._resolve(secret))[0].id + payload = await self._request( + "DELETE", f"/secrets/{secret_id}/versions/{version}" + ) + return SecretVersionDeleteResult(**payload) + + async def _add_version( + self, secret: Secret, value: Union[str, bytes], sealer: SecretsSealer + ) -> SecretVersion: + envelope = sealer.seal(secret.encryption_context, value) + payload = await self._request( + "POST", f"/secrets/{secret.id}/versions", data={"envelope": envelope} + ) + return SecretVersion(**payload) + + async def _get_by_id(self, secret_id: str) -> SecretDetail: + return SecretDetail(**await self._request("GET", f"/secrets/{secret_id}")) + + async def _resolve(self, key: str): + """Find a secret by ID or exact name, reporting whether the result + already includes its versions.""" + key = key.strip() + if looks_like_secret_id(key): + try: + return await self._get_by_id(key), True + except HyperbrowserError as error: + if not is_not_found(error): + raise + listing = await self.list({"search": key, "limit": SECRET_NAME_LOOKUP_LIMIT}) + for item in listing.secrets: + if item.name == key: + return SecretDetail(**item.model_dump()), False + raise secret_not_found_error(key) + + async def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, object]] = None, + data: Optional[Dict[str, object]] = None, + ): + failed_attempt = 1 + while True: + try: + response = await self._client.transport.client.request( + method, + self._client._build_url(path), + params={ + key: value + for key, value in (params or {}).items() + if value is not None + }, + json=data, + ) + ensure_response_ok(response, "control") + except BaseException as cause: + error = normalize_network_error( + cause, + "control", + "Unknown error occurred", + request_context(method, path), + ) + if not should_retry_get(method, error, failed_attempt): + raise error + await asyncio.sleep(get_retry_delay_seconds(failed_attempt)) + failed_attempt += 1 + continue + + return parse_json_response(response, "control") diff --git a/hyperbrowser/client/managers/sandboxes/shared.py b/hyperbrowser/client/managers/sandboxes/shared.py index 729502e8..d2792241 100644 --- a/hyperbrowser/client/managers/sandboxes/shared.py +++ b/hyperbrowser/client/managers/sandboxes/shared.py @@ -6,7 +6,7 @@ from typing import Dict, Optional, Union from urllib.parse import urlencode, urlsplit, urlunsplit -from ..._request import coerce_request +from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError from ....models.sandbox import ( SandboxExecParams, @@ -21,11 +21,17 @@ parse_error_payload, runtime_base_url_session_id, ) +from ....models.secret import SecretReference from ....types import ( + SecretReference as SecretReferenceDict, SandboxExecParams as SandboxExecParamsDict, SandboxFileWriteEntry as SandboxFileWriteEntryDict, ) +# Environment variable name to secret: a name, or a reference as a mapping or +# model. A missing version means the latest. +SecretReferences = Dict[str, Union[str, SecretReferenceDict, SecretReference]] + DEFAULT_WATCH_TIMEOUT_MS = 60_000 # The receiver sends process SSE keepalives every 15 seconds. PROCESS_STREAM_IDLE_TIMEOUT_SECONDS = 60.0 @@ -87,6 +93,7 @@ def _normalize_exec_params( timeout_ms: Optional[int] = None, timeout_sec: Optional[int] = None, run_as: Optional[str] = None, + secrets: Optional[SecretReferences] = None, ) -> SandboxExecParams: if isinstance(input, str): params = SandboxExecParams(command=input) @@ -104,11 +111,49 @@ def _normalize_exec_params( updates["timeout_sec"] = timeout_sec if run_as is not None: updates["run_as"] = run_as + if secrets is not None: + # Validated through the model so plain names become references. + updates["secrets"] = SandboxExecParams.model_validate( + {"command": params.command, "secrets": secrets} + ).secrets normalized = params.model_copy(update=updates) if updates else params return _normalize_legacy_process_fields(normalized) +def _runtime_process_payload(params: SandboxExecParams) -> Dict[str, object]: + """The runtime start payload; secrets only go through the control API.""" + return dump_request(params.model_copy(update={"secrets": None}), SandboxExecParams) + + +def _secret_process_payload(params: SandboxExecParams) -> Dict[str, object]: + """The control API's process start payload, which accepts only these fields.""" + secrets = params.secrets or {} + env = params.env or {} + overlap = sorted(set(env) & set(secrets)) + if overlap: + raise HyperbrowserError( + "Each environment variable must come from either env or secrets, " + f"not both: {', '.join(overlap)}", + code="invalid_secret_reference", + ) + timeout_ms = params.timeout_ms + if timeout_ms is None and params.timeout_sec is not None: + timeout_ms = params.timeout_sec * 1000 + payload: Dict[str, object] = { + "command": params.command, + "cwd": params.cwd, + "env": params.env, + "timeoutMs": timeout_ms, + "runAs": params.run_as, + "secrets": { + name: reference.model_dump(exclude_none=True) + for name, reference in secrets.items() + }, + } + return {key: value for key, value in payload.items() if value is not None} + + def _resolve_sandbox_runtime_session_host(runtime, base_url) -> str: session_id_from_base_path = runtime_base_url_session_id(base_url.path) if session_id_from_base_path and base_url.hostname: diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index e2a3709f..6c091ce9 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -39,6 +39,11 @@ StartSandboxFromSnapshotParams, ) from ....models.session import BasicResponse +from ....secrets_common import ( + missing_process_id_error, + request_may_have_arrived, + secret_process_outcome_unknown_error, +) from ....types import ( CompleteSandboxImageBuildParams as CompleteSandboxImageBuildParamsDict, CreateSandboxImageBuildParams as CreateSandboxImageBuildParamsDict, @@ -70,6 +75,7 @@ completed_image_id, ) from ..sandboxes.shared import ( + SecretReferences, _build_sandbox_exposed_url, _copy_model, _expires_within_buffer, @@ -135,7 +141,11 @@ def __init__(self, service: "SandboxManager", detail: SandboxDetail): service.runtime_timeout, service.runtime_proxy_override, ) - self.processes = SandboxProcessesApi(self._transport) + self.processes = SandboxProcessesApi( + self._transport, + self._start_secret_process, + detail.id, + ) self.files = SandboxFilesApi( self._transport, self._resolve_runtime_socket_info, @@ -310,7 +320,16 @@ def exec( timeout_sec: Optional[int] = None, run_as: Optional[str] = None, max_output_bytes: int = 64 * 1024 * 1024, + secrets: Optional[SecretReferences] = None, ): + """Run a command to completion. + + ``secrets`` maps environment variable names to stored secrets (a name, + or ``{"name": ..., "version": N}``). Anything else running as the + same user, or as root, in the sandbox can read a process's + environment, and the sandbox and its later snapshots are marked as + containing secrets. + """ return self.processes.exec( input, cwd=cwd, @@ -319,8 +338,12 @@ def exec( timeout_sec=timeout_sec, run_as=run_as, max_output_bytes=max_output_bytes, + secrets=secrets, ) + def _start_secret_process(self, payload: Dict[str, object]) -> dict: + return self._service.start_secret_process(self.id, payload) + def get_process(self, process_id: str) -> SandboxProcessHandle: return self.processes.get(process_id) @@ -1107,6 +1130,27 @@ def unexpose(self, sandbox_id: str, port: int) -> SandboxUnexposeResult: ) return SandboxUnexposeResult(**payload) + def start_secret_process(self, sandbox_id: str, payload: Dict[str, object]) -> dict: + """Start a sandbox process with secrets through the control API. + + Returns the raw ``{"process": ..., "secrets": [...]}`` response. Prefer + ``sandbox.exec(..., secrets=...)`` or ``sandbox.processes.start``. + """ + try: + response = self._request( + "POST", f"/sandbox/{sandbox_id}/processes", data=payload + ) + except HyperbrowserError as error: + # A start that timed out, or whose response couldn't be read, may + # be running with the secrets, so it must not look retryable. + if request_may_have_arrived(error): + raise secret_process_outcome_unknown_error(sandbox_id, error) + raise + process = response.get("process") if isinstance(response, dict) else None + if not isinstance(process, dict) or not str(process.get("id") or "").strip(): + raise missing_process_id_error(sandbox_id) + return response + def _create_detail( self, params: Union[CreateSandboxParamsDict, CreateSandboxParams], diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py index 9c70c56b..1ba60338 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py @@ -1,8 +1,8 @@ import base64 import threading -from typing import Dict, Optional, Union +from typing import Callable, Dict, List, Optional, Union -from ...._request import coerce_request, dump_request +from ...._request import coerce_request from .....models.sandbox import ( SandboxExecParams, SandboxProcessExitEvent, @@ -12,11 +12,19 @@ SandboxProcessStdinParams, SandboxProcessSummary, ) +from .....exceptions import HyperbrowserError +from .....models.secret import SandboxResolvedSecret +from .....secrets_common import secret_process_failed_error, secret_wait_chunk_ms from .....types import ( SandboxExecParams as SandboxExecParamsDict, SandboxProcessStdinParams as SandboxProcessStdinParamsDict, ) -from ...sandboxes.shared import _normalize_exec_params +from ...sandboxes.shared import ( + SecretReferences, + _normalize_exec_params, + _runtime_process_payload, + _secret_process_payload, +) from ...sandboxes.process_output import ( DEFAULT_MAX_PROCESS_OUTPUT_BYTES, ProcessOutput, @@ -36,6 +44,9 @@ def __init__(self, transport: RuntimeTransport, summary: SandboxProcessSummary): self._events = None self._changed = threading.Condition() self._close_stream = None + # The secret versions this process received, when it was started + # with secrets; None otherwise. + self.secrets: Optional[List[SandboxResolvedSecret]] = None @property def id(self) -> str: @@ -266,8 +277,15 @@ def result(self) -> SandboxProcessResult: class SandboxProcessesApi: - def __init__(self, transport: RuntimeTransport): + def __init__( + self, + transport: RuntimeTransport, + start_secret_process: Optional[Callable[[Dict[str, object]], dict]] = None, + sandbox_id: Optional[str] = None, + ): self._transport = transport + self._start_secret_process = start_secret_process + self._sandbox_id = sandbox_id def exec( self, @@ -279,6 +297,7 @@ def exec( timeout_sec: Optional[int] = None, run_as: Optional[str] = None, max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, + secrets: Optional[SecretReferences] = None, ) -> SandboxProcessResult: handle = self.start( input, @@ -288,7 +307,10 @@ def exec( timeout_sec=timeout_sec, run_as=run_as, max_output_bytes=max_output_bytes, + secrets=secrets, ) + if handle.secrets is not None: + return self._finish_secret_exec(handle) try: return handle.wait() finally: @@ -304,7 +326,14 @@ def start( timeout_sec: Optional[int] = None, run_as: Optional[str] = None, max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, + secrets: Optional[SecretReferences] = None, ) -> SandboxProcessHandle: + """Start a process. + + With ``secrets``, the start goes through the control API, which has the + platform decrypt them into the process environment; output is then + read with ``wait()`` rather than collected from the start. + """ validate_output_limit(max_output_bytes) params = _normalize_exec_params( input, @@ -313,12 +342,15 @@ def start( timeout_ms=timeout_ms, timeout_sec=timeout_sec, run_as=run_as, + secrets=secrets, ) + if params.secrets: + return self._start_with_secrets(params) close_stream = [] events = self._transport.stream_sse( "/sandbox/processes", method="POST", - json_body=dump_request(params, SandboxExecParams), + json_body=_runtime_process_payload(params), on_open=close_stream.append, ) try: @@ -338,6 +370,59 @@ def start( handle._collector.start() return handle + def _start_with_secrets(self, params: SandboxExecParams) -> SandboxProcessHandle: + if self._start_secret_process is None: + raise HyperbrowserError( + "Starting a process with secrets needs a sandbox handle; use " + "client.sandboxes.get() and its processes API", + code="secrets_unsupported", + ) + payload = self._start_secret_process(_secret_process_payload(params)) + handle = SandboxProcessHandle( + self._transport, SandboxProcessSummary(**payload["process"]) + ) + handle.secrets = [ + SandboxResolvedSecret(**entry) for entry in payload.get("secrets") or [] + ] + return handle + + def _finish_secret_exec(self, handle: SandboxProcessHandle) -> SandboxProcessResult: + """Close stdin and wait for the result, as one-shot exec does. + + The process may still be running with its secrets after a failure here, + so failures name it instead of looking like a safe retry. + """ + sandbox_id = self._sandbox_id or "" + try: + handle.write_stdin(eof=True) + except HyperbrowserError as error: + # 409: the command already exited, like a pipe whose reader quit + # early. Its result is still reported. + if error.status_code != 409: + raise secret_process_failed_error( + sandbox_id, handle.id, "closing its stdin", error + ) + except Exception as error: + raise secret_process_failed_error( + sandbox_id, handle.id, "closing its stdin", error + ) + chunk_ms = secret_wait_chunk_ms(getattr(self._transport, "_timeout", None)) + while True: + try: + return handle.wait(timeout_ms=chunk_ms) + except HyperbrowserError as error: + # The command's own timeout is enforced in the sandbox, so an + # expired wait only means it is still running. + if error.status_code == 408: + continue + raise secret_process_failed_error( + sandbox_id, handle.id, "waiting for its result", error + ) + except Exception as error: + raise secret_process_failed_error( + sandbox_id, handle.id, "waiting for its result", error + ) + def get(self, process_id: str) -> SandboxProcessHandle: payload = self._transport.request_json(f"/sandbox/processes/{process_id}") return SandboxProcessHandle( diff --git a/hyperbrowser/client/managers/sync_manager/secret.py b/hyperbrowser/client/managers/sync_manager/secret.py new file mode 100644 index 00000000..64b381b9 --- /dev/null +++ b/hyperbrowser/client/managers/sync_manager/secret.py @@ -0,0 +1,198 @@ +import time +from typing import Dict, Optional, Union + +from ..._request import coerce_request, dump_request +from ....exceptions import HyperbrowserError +from ....models.secret import ( + CreateSecretParams, + Secret, + SecretDeleteResult, + SecretDetail, + SecretListParams, + SecretListResponse, + SecretsPublicKey, + SecretVersion, + SecretVersionDeleteResult, + UpdateSecretParams, +) +from ....sandbox_common import ( + ensure_response_ok, + get_retry_delay_seconds, + normalize_network_error, + parse_json_response, + request_context, + should_retry_get, +) +from ....secrets_common import ( + SECRET_NAME_LOOKUP_LIMIT, + SecretsSealer, + is_not_found, + looks_like_secret_id, + secret_not_found_error, + secret_value_bytes, + secret_value_not_stored_error, +) +from ....types import CreateSecretParams as CreateSecretParamsDict +from ....types import SecretListParams as SecretListParamsDict +from ....types import UpdateSecretParams as UpdateSecretParamsDict + + +class SecretManager: + """Manage encrypted secrets for sandbox processes and startup commands. + + Values are encrypted on this machine to the platform's public key and are + never returned by the API. Methods that take ``secret`` accept a secret's + ID or its exact name. + """ + + def __init__(self, client): + self._client = client + + def get_public_key(self) -> SecretsPublicKey: + return SecretsPublicKey(**self._request("GET", "/secrets/public-key")) + + def list( + self, + params: Optional[Union[SecretListParamsDict, SecretListParams]] = None, + ) -> SecretListResponse: + payload = self._request( + "GET", + "/secrets", + params=dump_request( + params if params is not None else {}, + SecretListParams, + ), + ) + return SecretListResponse(**payload) + + def create( + self, + params: Union[CreateSecretParamsDict, CreateSecretParams], + ) -> Secret: + """Create a secret, and store its first version when ``value`` is set. + + The value and the public key are checked before the secret is created, + so a bad value or an unavailable key leaves nothing behind. + """ + normalized = coerce_request(params, CreateSecretParams) + value = normalized.value + sealer = None + if value is not None: + secret_value_bytes(value) + sealer = SecretsSealer(self.get_public_key()) + secret = Secret( + **self._request( + "POST", + "/secrets", + data=normalized.model_dump(by_alias=True, exclude_none=True), + ) + ) + if sealer is None or value is None: + return secret + try: + version = self._add_version(secret, value, sealer) + except Exception as error: + raise secret_value_not_stored_error(secret.id, secret.name, error) + return secret.model_copy(update={"latest_version": version.version}) + + def get(self, secret: str) -> SecretDetail: + detail, complete = self._resolve(secret) + if complete: + return detail + return self._get_by_id(detail.id) + + def update( + self, + secret: str, + params: Union[UpdateSecretParamsDict, UpdateSecretParams], + ) -> Secret: + secret_id = self._resolve(secret)[0].id + payload = self._request( + "PATCH", + f"/secrets/{secret_id}", + data=dump_request(params, UpdateSecretParams), + ) + return Secret(**payload) + + def add_version(self, secret: str, value: Union[str, bytes]) -> SecretVersion: + """Encrypt ``value`` locally and store it as the secret's next version.""" + secret_value_bytes(value) + resolved = self._resolve(secret)[0] + return self._add_version(resolved, value, SecretsSealer(self.get_public_key())) + + def delete(self, secret: str) -> SecretDeleteResult: + """Delete a secret and all its versions. Values already delivered to + sandboxes are not revoked.""" + secret_id = self._resolve(secret)[0].id + return SecretDeleteResult(**self._request("DELETE", f"/secrets/{secret_id}")) + + def delete_version(self, secret: str, version: int) -> SecretVersionDeleteResult: + """Delete one version. The latest version can't be deleted.""" + secret_id = self._resolve(secret)[0].id + payload = self._request("DELETE", f"/secrets/{secret_id}/versions/{version}") + return SecretVersionDeleteResult(**payload) + + def _add_version( + self, secret: Secret, value: Union[str, bytes], sealer: SecretsSealer + ) -> SecretVersion: + envelope = sealer.seal(secret.encryption_context, value) + payload = self._request( + "POST", f"/secrets/{secret.id}/versions", data={"envelope": envelope} + ) + return SecretVersion(**payload) + + def _get_by_id(self, secret_id: str) -> SecretDetail: + return SecretDetail(**self._request("GET", f"/secrets/{secret_id}")) + + def _resolve(self, key: str): + """Find a secret by ID or exact name, reporting whether the result + already includes its versions.""" + key = key.strip() + if looks_like_secret_id(key): + try: + return self._get_by_id(key), True + except HyperbrowserError as error: + if not is_not_found(error): + raise + listing = self.list({"search": key, "limit": SECRET_NAME_LOOKUP_LIMIT}) + for item in listing.secrets: + if item.name == key: + return SecretDetail(**item.model_dump()), False + raise secret_not_found_error(key) + + def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, object]] = None, + data: Optional[Dict[str, object]] = None, + ): + failed_attempt = 1 + while True: + try: + response = self._client.transport.client.request( + method, + self._client._build_url(path), + params={ + key: value + for key, value in (params or {}).items() + if value is not None + }, + json=data, + ) + ensure_response_ok(response, "control") + except BaseException as cause: + error = normalize_network_error( + cause, + "control", + "Unknown error occurred", + request_context(method, path), + ) + if not should_retry_get(method, error, failed_attempt): + raise error + time.sleep(get_retry_delay_seconds(failed_attempt)) + failed_attempt += 1 + continue + + return parse_json_response(response, "control") diff --git a/hyperbrowser/client/sync.py b/hyperbrowser/client/sync.py index 7d6821e7..4983ad3d 100644 --- a/hyperbrowser/client/sync.py +++ b/hyperbrowser/client/sync.py @@ -10,6 +10,7 @@ from .managers.sync_manager.extract import ExtractManager from .managers.sync_manager.profile import ProfileManager from .managers.sync_manager.sandbox import SandboxManager +from .managers.sync_manager.secret import SecretManager from .managers.sync_manager.scrape import ScrapeManager from .managers.sync_manager.session import SessionManager from .managers.sync_manager.team import TeamManager @@ -49,6 +50,7 @@ def __init__( self.computer_action = ComputerActionManager(self) self.sandboxes = SandboxManager(self) self.volumes = VolumeManager(self) + self.secrets = SecretManager(self) def close(self) -> None: self.transport.close() diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index 54866e6a..017c4cec 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -252,6 +252,22 @@ ProfileListResponse, ProfileResponse, ) +from .secret import ( + CreateSecretParams, + SandboxResolvedSecret, + Secret, + SecretDeleteResult, + SecretDetail, + SecretJWK, + SecretListParams, + SecretListResponse, + SecretReference, + SecretReferenceInput, + SecretsPublicKey, + SecretVersion, + SecretVersionDeleteResult, + UpdateSecretParams, +) from .volume import ( CreateVolumeParams, Volume, @@ -467,7 +483,6 @@ "WebMCPInvokeResult", "WebMCPInvocationError", "WebMCPInvocation", - # consts "ISO639_1", "POLLING_ATTEMPTS", @@ -628,6 +643,21 @@ "ProfileListParams", "ProfileListResponse", "ProfileResponse", + # secret + "CreateSecretParams", + "SandboxResolvedSecret", + "Secret", + "SecretDeleteResult", + "SecretDetail", + "SecretJWK", + "SecretListParams", + "SecretListResponse", + "SecretReference", + "SecretReferenceInput", + "SecretsPublicKey", + "SecretVersion", + "SecretVersionDeleteResult", + "UpdateSecretParams", # volume "CreateVolumeParams", "Volume", diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index a090998e..d709a123 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -11,6 +11,7 @@ ) from ._parsers import _parse_optional_int +from .secret import SecretReference, normalize_secret_references from .session import SessionLaunchState, SessionStatus SandboxStatus = SessionStatus @@ -161,6 +162,9 @@ class Sandbox(SandboxBaseModel): alias="exposedPorts", ) network: Optional[SandboxNetworkPolicy] = None + # Secrets were delivered into the sandbox, or it was restored from a + # snapshot that may hold them. + contains_secrets: bool = Field(default=False, alias="containsSecrets") @field_validator( "end_time", @@ -239,6 +243,14 @@ class CreateSandboxParams(_SandboxLaunchCommonParams): disk_mib: Optional[int] = Field( default=None, ge=1, serialization_alias="diskSizeMiB" ) + # Environment variable name to secret, delivered to the image's startup + # command only. + secrets: Optional[Dict[str, SecretReference]] = None + + @field_validator("secrets", mode="before") + @classmethod + def normalize_secrets(cls, value): + return normalize_secret_references(value) @model_validator(mode="after") def validate_launch_source(self): @@ -259,6 +271,11 @@ def validate_launch_source(self): raise ValueError( "cpu, memory_mib, and disk_mib are only supported for image launches" ) + if self.snapshot_name and self.secrets: + raise ValueError( + "secrets are only supported for image launches; snapshots don't " + "run a startup command" + ) return self @@ -332,6 +349,8 @@ class SandboxSnapshotSummary(SandboxBaseModel): compatibility_tag: Optional[str] = Field(default=None, alias="compatibilityTag") metadata: Dict[str, object] uploaded: bool + # The snapshot's memory may hold secrets. + contains_secrets: bool = Field(default=False, alias="containsSecrets") created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") @@ -565,6 +584,14 @@ class SandboxExecParams(SandboxBaseModel): timeout_sec: Optional[int] = None run_as: Optional[str] = Field(default=None, serialization_alias="runAs") use_shell: Optional[bool] = Field(default=None, serialization_alias="useShell") + # Environment variable name to secret. A start with secrets goes through + # the control API, which has the platform decrypt them in the sandbox. + secrets: Optional[Dict[str, SecretReference]] = None + + @field_validator("secrets", mode="before") + @classmethod + def normalize_secrets(cls, value): + return normalize_secret_references(value) class SandboxProcessSummary(SandboxBaseModel): diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py new file mode 100644 index 00000000..55ca6dc6 --- /dev/null +++ b/hyperbrowser/models/secret.py @@ -0,0 +1,108 @@ +from datetime import datetime +from typing import List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class SecretBaseModel(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + +class SecretReference(SecretBaseModel): + """Selects a stored secret for an environment variable. + + An omitted version (or ``"latest"``) means the latest version. + """ + + name: str + version: Optional[Union[int, Literal["latest"]]] = None + + +SecretReferenceInput = Union[str, SecretReference] + + +def normalize_secret_references(value): + """Accept a plain secret name as shorthand for ``{"name": name}``.""" + if not isinstance(value, dict): + return value + return { + env: {"name": reference} if isinstance(reference, str) else reference + for env, reference in value.items() + } + + +class CreateSecretParams(SecretBaseModel): + name: str + description: Optional[str] = None + # Encrypted locally and uploaded as the first version; never sent as is. + value: Optional[Union[str, bytes]] = Field(default=None, exclude=True) + + +class UpdateSecretParams(SecretBaseModel): + description: str + + +class SecretListParams(SecretBaseModel): + page: Optional[int] = None + limit: Optional[int] = None + search: Optional[str] = None + + +class SecretJWK(SecretBaseModel): + kty: str + n: str + e: str + + +class SecretsPublicKey(SecretBaseModel): + kid: str + alg: str + enc: str + jwk: SecretJWK + + +class Secret(SecretBaseModel): + id: str + name: str + description: str = "" + encryption_context: str = Field(alias="encryptionContext") + latest_version: Optional[int] = Field(default=None, alias="latestVersion") + created_at: Optional[datetime] = Field(default=None, alias="createdAt") + updated_at: Optional[datetime] = Field(default=None, alias="updatedAt") + + +class SecretVersion(SecretBaseModel): + version: int + kid: str + created_at: Optional[datetime] = Field(default=None, alias="createdAt") + + +class SecretDetail(Secret): + versions: List[SecretVersion] = Field(default_factory=list) + + +class SecretListResponse(SecretBaseModel): + secrets: List[Secret] + total_count: Optional[int] = Field(default=None, alias="totalCount") + page: Optional[int] = None + per_page: Optional[int] = Field(default=None, alias="perPage") + + +class SecretDeleteResult(SecretBaseModel): + deleted: bool + id: Optional[str] = None + name: Optional[str] = None + + +class SecretVersionDeleteResult(SecretBaseModel): + deleted: bool + version: int + + +class SandboxResolvedSecret(SecretBaseModel): + """The secret version a process environment variable received.""" + + env: str + name: str + secret_id: str = Field(alias="secretId") + version: int diff --git a/hyperbrowser/sandbox_common.py b/hyperbrowser/sandbox_common.py index e17310e4..84a322f6 100644 --- a/hyperbrowser/sandbox_common.py +++ b/hyperbrowser/sandbox_common.py @@ -1,5 +1,6 @@ import json import random +import re from dataclasses import dataclass from typing import Any, Dict, Optional, Tuple from urllib.parse import urljoin, urlsplit, urlunsplit @@ -69,6 +70,37 @@ def get_retry_delay_seconds(failed_attempt: int) -> float: return random.uniform(maximum_delay / 2, maximum_delay) +SERVER_ERROR_CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$") + + +def error_payload_code(payload: Any) -> Optional[str]: + """Return the API's machine code from an error body. + + Some routes send the code as ``error`` next to a message, for example + ``API_KEY_SCOPE_DENIED``. + """ + if not isinstance(payload, dict): + return None + code = payload.get("code") + if isinstance(code, str) and code.strip(): + return code + error = payload.get("error") + if ( + payload.get("message") + and isinstance(error, str) + and SERVER_ERROR_CODE_PATTERN.match(error) + ): + return error + return None + + +def error_payload_retryable(payload: Any, status_code: int) -> bool: + """Prefer the server's ``retryable`` flag over the status-code default.""" + if isinstance(payload, dict) and isinstance(payload.get("retryable"), bool): + return payload["retryable"] + return status_code in RETRYABLE_STATUS_CODES + + def parse_error_payload( raw_text: str, fallback_message: str ) -> Tuple[str, Optional[str], Any]: @@ -82,8 +114,7 @@ def parse_error_payload( if isinstance(parsed, dict): message = parsed.get("message") or parsed.get("error") or fallback_message - code = parsed.get("code") if isinstance(parsed.get("code"), str) else None - return message, code, parsed + return message, error_payload_code(parsed), parsed return fallback_message, None, parsed @@ -106,7 +137,14 @@ def ensure_response_ok( response=response, code=code, request_id=get_request_id(response), - retryable=response.status_code in RETRYABLE_STATUS_CODES, + # The control API knows when a failure is safe to retry; vm-service's + # proxy marks its own gateway errors non-retryable whatever the cause, + # so runtime requests keep the status-code default. + retryable=( + error_payload_retryable(details, response.status_code) + if service == "control" + else response.status_code in RETRYABLE_STATUS_CODES + ), service=service, details=details, ) diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py new file mode 100644 index 00000000..e105cb67 --- /dev/null +++ b/hyperbrowser/secrets_common.py @@ -0,0 +1,264 @@ +"""Client-side secret encryption and helpers shared by the sync and async clients. + +Secret values are encrypted on this machine to the platform's public key as a +compact JWE bound to the secret's encryption context. The API only ever +receives the envelope; only the sandbox host can decrypt it. +""" + +import base64 +import hashlib +import json +import os +import uuid +from typing import Any, Dict, Optional, Union + +import httpx +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from .exceptions import HyperbrowserError +from .models.secret import SecretsPublicKey + +# The largest secret value the platform accepts. +MAX_SECRET_VALUE_BYTES = 32 * 1024 +# The same key policy the server and the sandbox hosts enforce. +MIN_SECRETS_KEY_BITS = 3072 +SECRETS_KEY_EXPONENT = 65537 +# Name lookups read one page of search results, which covers every secret +# while teams are limited to 100. +SECRET_NAME_LOOKUP_LIMIT = 100 + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _int_bytes(value: int) -> bytes: + return value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big") + + +def secret_value_bytes(value: Union[str, bytes, bytearray]) -> bytes: + """Return a secret value's bytes after applying the platform's rules. + + Values are used byte-for-byte, including trailing newlines. + """ + if isinstance(value, str): + data = value.encode("utf-8") + elif isinstance(value, (bytes, bytearray)): + data = bytes(value) + else: + raise TypeError( + f"secret value must be str or bytes, got {type(value).__name__}" + ) + if not data: + raise HyperbrowserError("Secret value is empty", code="invalid_secret_value") + if len(data) > MAX_SECRET_VALUE_BYTES: + raise HyperbrowserError( + f"Secret value is {len(data)} bytes; the limit is {MAX_SECRET_VALUE_BYTES}", + code="invalid_secret_value", + ) + try: + data.decode("utf-8") + except UnicodeDecodeError: + raise HyperbrowserError( + "Secret value must be valid UTF-8", code="invalid_secret_value" + ) from None + if b"\x00" in data: + raise HyperbrowserError( + "Secret value must not contain NUL bytes", code="invalid_secret_value" + ) + return data + + +def secrets_key_id(public_key: rsa.RSAPublicKey) -> str: + """The RFC 7638 JWK SHA-256 thumbprint of an RSA key.""" + numbers = public_key.public_numbers() + thumbprint_input = '{"e":"%s","kty":"RSA","n":"%s"}' % ( + _b64url(_int_bytes(numbers.e)), + _b64url(_int_bytes(numbers.n)), + ) + return _b64url(hashlib.sha256(thumbprint_input.encode("ascii")).digest()) + + +def _invalid_key(message: str) -> HyperbrowserError: + return HyperbrowserError(message, code="invalid_secrets_public_key") + + +class SecretsSealer: + """Encrypts secret values with a verified secrets public key.""" + + def __init__(self, key: Union[SecretsPublicKey, Dict[str, Any]]): + if not isinstance(key, SecretsPublicKey): + key = SecretsPublicKey.model_validate(key) + if key.alg != "RSA-OAEP-256" or key.enc != "A256GCM" or key.jwk.kty != "RSA": + raise _invalid_key(f"Unsupported secrets key ({key.alg}/{key.enc})") + try: + n = int.from_bytes(_b64url_decode(key.jwk.n), "big") + e_bytes = _b64url_decode(key.jwk.e) + except ValueError: + raise _invalid_key("Secrets public key is not valid base64url") from None + if n == 0 or not e_bytes or len(e_bytes) > 4: + raise _invalid_key("Secrets public key has an invalid modulus or exponent") + e = int.from_bytes(e_bytes, "big") + if n.bit_length() < MIN_SECRETS_KEY_BITS or e != SECRETS_KEY_EXPONENT: + raise _invalid_key( + f"Secrets public key must be at least {MIN_SECRETS_KEY_BITS}-bit RSA " + f"with exponent {SECRETS_KEY_EXPONENT}" + ) + public_key = rsa.RSAPublicNumbers(e, n).public_key() + # The key ID is derived from the key itself, so a mismatch means a + # corrupted response or a misconfigured server. It does not prove where + # the key came from: that rests on TLS to the API. + if secrets_key_id(public_key) != key.kid: + raise _invalid_key("Secrets public key does not match its key ID") + self.kid = key.kid + self._public_key = public_key + + def seal(self, encryption_context: str, value: Union[str, bytes, bytearray]) -> str: + """Encrypt a value as a compact JWE (RSA-OAEP-256 + A256GCM) bound to a + secret's encryption context.""" + data = secret_value_bytes(value) + header = json.dumps( + { + "alg": "RSA-OAEP-256", + "enc": "A256GCM", + "kid": self.kid, + "ctx": encryption_context, + }, + separators=(",", ":"), + ) + protected = _b64url(header.encode("utf-8")) + cek = os.urandom(32) + iv = os.urandom(12) + encrypted_key = self._public_key.encrypt( + cek, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + sealed = AESGCM(cek).encrypt(iv, data, protected.encode("ascii")) + ciphertext, tag = sealed[:-16], sealed[-16:] + return ".".join( + [ + protected, + _b64url(encrypted_key), + _b64url(iv), + _b64url(ciphertext), + _b64url(tag), + ] + ) + + +def looks_like_secret_id(key: str) -> bool: + """Whether a key is shaped like a secret ID. Names may be too, so a + UUID-shaped key that isn't an ID is looked up as a name.""" + if len(key) != 36: + return False + try: + uuid.UUID(key) + except ValueError: + return False + return True + + +def secret_not_found_error(key: str) -> HyperbrowserError: + return HyperbrowserError( + f'Secret "{key}" not found', + status_code=404, + code="SECRET_NOT_FOUND", + retryable=False, + service="control", + ) + + +def is_not_found(error: HyperbrowserError) -> bool: + return error.status_code == 404 + + +def secret_value_not_stored_error( + secret_id: str, name: str, cause: BaseException +) -> HyperbrowserError: + return HyperbrowserError( + f'Secret "{name}" ({secret_id}) was created, but storing its value failed: ' + f"{cause}. Store the value with secrets.add_version(); creating it again " + "fails because the name is taken", + code="secret_value_not_stored", + retryable=False, + service="control", + details={"secretId": secret_id, "name": name}, + cause=cause, + original_error=cause if isinstance(cause, Exception) else None, + ) + + +def request_may_have_arrived(error: BaseException) -> bool: + """Whether a failed request may still have reached the API: it timed out, + or its response couldn't be read. API errors and failures to connect are + excluded.""" + if isinstance(error, HyperbrowserError): + if error.status_code is not None: + # An error status is the API's answer; a success status means the + # response body couldn't be read. + return error.status_code < 400 + cause = error.cause if error.cause is not None else error.original_error + if cause is None or cause is error: + return False + return request_may_have_arrived(cause) + return not isinstance(error, (httpx.ConnectError, httpx.ConnectTimeout)) + + +def secret_process_outcome_unknown_error( + sandbox_id: str, cause: BaseException +) -> HyperbrowserError: + return HyperbrowserError( + "The process start may have reached the API, but no result came back; " + f"the command may be running with its secrets. Check the processes of " + f"sandbox {sandbox_id} before retrying", + code="secret_process_outcome_unknown", + retryable=False, + service="control", + details={"sandboxId": sandbox_id}, + cause=cause, + original_error=cause if isinstance(cause, Exception) else None, + ) + + +def secret_process_failed_error( + sandbox_id: str, process_id: str, step: str, cause: BaseException +) -> HyperbrowserError: + return HyperbrowserError( + f"Process {process_id} started with secrets, but {step} failed: {cause}. " + "It may still be running; check or kill it with " + "sandbox.get_process() rather than running the command again", + code="secret_process_failed", + retryable=False, + service="runtime", + details={"processId": process_id, "sandboxId": sandbox_id}, + cause=cause, + original_error=cause if isinstance(cause, Exception) else None, + ) + + +def missing_process_id_error(sandbox_id: str) -> HyperbrowserError: + return HyperbrowserError( + "The API accepted the process start but returned no process ID; the " + f"command may be running, so check the processes of sandbox {sandbox_id} " + "before retrying", + code="secret_process_outcome_unknown", + retryable=False, + service="control", + details={"sandboxId": sandbox_id}, + ) + + +def secret_wait_chunk_ms(runtime_timeout_seconds: Optional[float]) -> int: + """Keep each runtime wait request well under the client's request timeout.""" + timeout = runtime_timeout_seconds or 30 + return max(1_000, min(20_000, int(timeout * 1000 / 2))) diff --git a/hyperbrowser/transport/async_transport.py b/hyperbrowser/transport/async_transport.py index 53267064..5edfad47 100644 --- a/hyperbrowser/transport/async_transport.py +++ b/hyperbrowser/transport/async_transport.py @@ -5,6 +5,8 @@ from hyperbrowser.exceptions import HyperbrowserError from hyperbrowser.sandbox_common import ( RETRYABLE_STATUS_CODES, + error_payload_code, + error_payload_retryable, get_request_id, get_retry_delay_seconds, is_retryable_network_error, @@ -72,18 +74,13 @@ async def _handle_response(self, response: httpx.Response) -> APIResponse: message = str(e) raise HyperbrowserError( message, - code=( - error_data.get("code") - if isinstance(error_data, dict) - and isinstance(error_data.get("code"), str) - else None - ), + code=error_payload_code(error_data), details=error_data, status_code=response.status_code, response=response, original_error=e, request_id=get_request_id(response), - retryable=response.status_code in RETRYABLE_STATUS_CODES, + retryable=error_payload_retryable(error_data, response.status_code), service="control", ) except httpx.RequestError as e: diff --git a/hyperbrowser/transport/sync.py b/hyperbrowser/transport/sync.py index 9a5ebd9e..6fcc340b 100644 --- a/hyperbrowser/transport/sync.py +++ b/hyperbrowser/transport/sync.py @@ -6,6 +6,8 @@ from hyperbrowser.exceptions import HyperbrowserError from hyperbrowser.sandbox_common import ( RETRYABLE_STATUS_CODES, + error_payload_code, + error_payload_retryable, get_request_id, get_retry_delay_seconds, is_retryable_network_error, @@ -50,18 +52,13 @@ def _handle_response(self, response: httpx.Response) -> APIResponse: message = str(e) raise HyperbrowserError( message, - code=( - error_data.get("code") - if isinstance(error_data, dict) - and isinstance(error_data.get("code"), str) - else None - ), + code=error_payload_code(error_data), details=error_data, status_code=response.status_code, response=response, original_error=e, request_id=get_request_id(response), - retryable=response.status_code in RETRYABLE_STATUS_CODES, + retryable=error_payload_retryable(error_data, response.status_code), service="control", ) except httpx.RequestError as e: diff --git a/hyperbrowser/types/__init__.py b/hyperbrowser/types/__init__.py index 20aa14dd..19610e47 100644 --- a/hyperbrowser/types/__init__.py +++ b/hyperbrowser/types/__init__.py @@ -153,6 +153,13 @@ UpdateSessionScreenParams, UpdateSessionSolveCaptchasParams, ) +from .secret import ( + CreateSecretParams, + SecretListParams, + SecretReference, + SecretReferenceInput, + UpdateSecretParams, +) from .volume import CreateVolumeParams, VolumeListParams from .web import ( FetchBrowserLocationOptions, @@ -186,7 +193,6 @@ "WebMCPInvokeParams", "WebMCPStartParams", "WebMCPResultParams", - "JSONSchema", "JSONSchemaInput", "JSONSchemaObject", @@ -322,6 +328,11 @@ "UpdateSessionProxyParams", "UpdateSessionScreenParams", "UpdateSessionSolveCaptchasParams", + "CreateSecretParams", + "SecretListParams", + "SecretReference", + "SecretReferenceInput", + "UpdateSecretParams", "CreateVolumeParams", "VolumeListParams", "FetchBrowserLocationOptions", diff --git a/hyperbrowser/types/sandbox.py b/hyperbrowser/types/sandbox.py index a49de8b5..fd83ec1a 100644 --- a/hyperbrowser/types/sandbox.py +++ b/hyperbrowser/types/sandbox.py @@ -2,6 +2,8 @@ from typing_extensions import Required, TypeAlias, TypedDict +from .secret import SecretReferenceInput + SandboxStatus: TypeAlias = Literal["active", "closed", "error", "close-error"] SandboxRegion: TypeAlias = Literal[ @@ -97,6 +99,7 @@ class CreateSandboxFromImageParams(_CreateSandboxCommon, total=False): cpu: Optional[int] memory_mib: Optional[int] disk_mib: Optional[int] + secrets: Optional[Dict[str, SecretReferenceInput]] CreateSandboxParams: TypeAlias = Union[ @@ -249,6 +252,7 @@ class SandboxExecParams(TypedDict, total=False): timeout_sec: Optional[int] run_as: Optional[str] use_shell: Optional[bool] + secrets: Optional[Dict[str, SecretReferenceInput]] class SandboxProcessListParams(TypedDict, total=False): diff --git a/hyperbrowser/types/secret.py b/hyperbrowser/types/secret.py new file mode 100644 index 00000000..0fe8a1b9 --- /dev/null +++ b/hyperbrowser/types/secret.py @@ -0,0 +1,45 @@ +from typing import Literal, Optional, Union + +from typing_extensions import Required, TypeAlias, TypedDict + + +class SecretReference(TypedDict, total=False): + """Selects a stored secret; an omitted version means the latest.""" + + name: Required[str] + version: Optional[Union[int, Literal["latest"]]] + + +# A plain secret name is shorthand for {"name": name}. +SecretReferenceInput: TypeAlias = Union[str, SecretReference] + + +class CreateSecretParams(TypedDict, total=False): + """Parameters for creating a secret, optionally with its first value.""" + + name: Required[str] + description: Optional[str] + value: Optional[Union[str, bytes]] + + +class UpdateSecretParams(TypedDict): + """Parameters for updating a secret's description.""" + + description: str + + +class SecretListParams(TypedDict, total=False): + """Filters and pagination for listing secrets.""" + + page: Optional[int] + limit: Optional[int] + search: Optional[str] + + +__all__ = [ + "CreateSecretParams", + "SecretListParams", + "SecretReference", + "SecretReferenceInput", + "UpdateSecretParams", +] diff --git a/poetry.lock b/poetry.lock index 1331ca39..f4b7d84f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -50,6 +50,299 @@ files = [ {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_full_version == \"3.8.*\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\" and python_full_version < \"3.14.0\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "cffi" +version = "2.1.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be"}, + {file = "cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9"}, + {file = "cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41"}, + {file = "cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa"}, + {file = "cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3"}, + {file = "cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0"}, + {file = "cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735"}, + {file = "cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e"}, + {file = "cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a"}, + {file = "cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7"}, + {file = "cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac"}, + {file = "cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d"}, + {file = "cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13"}, + {file = "cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c"}, + {file = "cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48"}, + {file = "cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f"}, + {file = "cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4"}, + {file = "cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e"}, + {file = "cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7"}, + {file = "cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac"}, + {file = "cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960"}, + {file = "cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5"}, + {file = "cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66"}, + {file = "cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3"}, + {file = "cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692"}, + {file = "cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "colorama" version = "0.4.6" @@ -63,6 +356,200 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "43.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_full_version >= \"3.9.0\" and python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "cryptography" +version = "47.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +markers = "python_full_version == \"3.8.*\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8"}, + {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318"}, + {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001"}, + {file = "cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203"}, + {file = "cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa"}, + {file = "cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7"}, + {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52"}, + {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd"}, + {file = "cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63"}, + {file = "cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b"}, + {file = "cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe"}, + {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31"}, + {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7"}, + {file = "cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310"}, + {file = "cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8"}, + {file = "cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb"}, +] + +[package.dependencies] +cffi = {version = ">=1.14", markers = "python_full_version == \"3.8.*\" and platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + +[[package]] +name = "cryptography" +version = "50.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main"] +markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079"}, + {file = "cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51"}, + {file = "cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93"}, + {file = "cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c"}, + {file = "cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05"}, + {file = "cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e"}, + {file = "cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e"}, + {file = "cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c"}, + {file = "cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c"}, + {file = "cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e"}, + {file = "cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94"}, + {file = "cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452"}, + {file = "cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -205,6 +692,32 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\" and python_full_version < \"3.14.0\" and (python_full_version == \"3.8.*\" or implementation_name != \"PyPy\")" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + +[[package]] +name = "pycparser" +version = "3.11" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80"}, + {file = "pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc"}, +] + [[package]] name = "pydantic" version = "2.10.6" @@ -466,11 +979,25 @@ description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["main"] +markers = "python_full_version >= \"3.9.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_full_version == \"3.8.*\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] + [[package]] name = "websockets" version = "13.1" @@ -570,4 +1097,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.8" -content-hash = "b2320c4f6ef20c93ed648de836ce5b3217ee9331cea6effdad54680f836926c3" +content-hash = "f46d962831b02c3a3ba8c9a3eb95c2caed6b3ec73c2513f702894a4b59b45b6f" diff --git a/pyproject.toml b/pyproject.toml index 2af1631f..a66a5409 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ httpx = ">=0.23.0,<1" jsonref = ">=1.1.0" websockets = ">=13,<16" typing-extensions = ">=4.0,<5" +cryptography = ">=41.0.0" [tool.poetry.group.dev.dependencies] diff --git a/tests/test_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index 15f8ea82..8d3b11ff 100644 --- a/tests/test_sandbox_wire_contract.py +++ b/tests/test_sandbox_wire_contract.py @@ -1679,6 +1679,7 @@ def fake_exec(input, **kwargs): "timeout_sec": None, "run_as": "root", "max_output_bytes": 64 * 1024 * 1024, + "secrets": None, }, ) ] diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 00000000..fdbabf6a --- /dev/null +++ b/tests/test_secrets.py @@ -0,0 +1,741 @@ +import base64 +import json + +import httpx +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from hyperbrowser.client.managers.async_manager.sandbox import ( + SandboxManager as AsyncSandboxManager, +) +from hyperbrowser.client.managers.async_manager.sandboxes.sandbox_processes import ( + SandboxProcessesApi as AsyncSandboxProcessesApi, +) +from hyperbrowser.client.managers.async_manager.secret import ( + SecretManager as AsyncSecretManager, +) +from hyperbrowser.client.managers.sync_manager.sandbox import SandboxManager +from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_processes import ( + SandboxProcessesApi, +) +from hyperbrowser.client.managers.sync_manager.secret import SecretManager +from hyperbrowser.exceptions import HyperbrowserError +from hyperbrowser.models import ( + CreateSandboxParams, + CreateSecretParams, + SandboxDetail, + SandboxSnapshotSummary, + StartSandboxFromSnapshotParams, +) +from hyperbrowser.sandbox_common import ensure_response_ok +from hyperbrowser.secrets_common import ( + SecretsSealer, + request_may_have_arrived, + secret_value_bytes, + secrets_key_id, +) +from tests.test_sandbox_wire_contract import ( + SANDBOX_DETAIL_PAYLOAD, + SNAPSHOT_LIST_PAYLOAD, +) + +SECRET_ID = "8a0c7a5e-4d3b-4a5e-9b8f-0b4c7e7d2a11" +UUID_SHAPED_NAME = "0f6b7c2e-1111-4222-8333-944455556666" +CONTEXT = "hb-secret:v1:team_1:" + SECRET_ID + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +@pytest.fixture(scope="module") +def private_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=3072) + + +def _public_key_payload(key, kid=None): + numbers = key.public_key().public_numbers() + n = numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big") + return { + "kid": kid or secrets_key_id(key.public_key()), + "alg": "RSA-OAEP-256", + "enc": "A256GCM", + "jwk": {"kty": "RSA", "n": _b64url(n), "e": "AQAB"}, + } + + +def _open(private_key, envelope: str): + protected, encrypted_key, iv, ciphertext, tag = envelope.split(".") + cek = private_key.decrypt( + _b64url_decode(encrypted_key), + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + value = AESGCM(cek).decrypt( + _b64url_decode(iv), + _b64url_decode(ciphertext) + _b64url_decode(tag), + protected.encode("ascii"), + ) + return json.loads(_b64url_decode(protected)), value + + +def _secret(name="provider-key", secret_id=SECRET_ID, latest=None): + return { + "id": secret_id, + "name": name, + "description": "", + "encryptionContext": CONTEXT, + "latestVersion": latest, + "createdAt": "2026-10-01T00:00:00Z", + "updatedAt": "2026-10-01T00:00:00Z", + } + + +class ScriptedHTTPClient: + """Answers control API requests from (method, path) routes.""" + + def __init__(self, routes): + self.routes = routes + self.calls = [] + + def _respond(self, method, url, params, json_body): + path = url.replace("https://api.example.com/api", "") + self.calls.append( + {"method": method, "path": path, "params": params, "json": json_body} + ) + route = self.routes.get((method, path)) + if route is None: + raise AssertionError(f"Unexpected request {method} {path}") + if callable(route): + route = route(params, json_body) + if isinstance(route, BaseException): + raise route + status, payload = route + return httpx.Response(status, json=payload, request=httpx.Request(method, url)) + + def request(self, method, url, params=None, json=None): + return self._respond(method, url, params, json) + + +class AsyncScriptedHTTPClient(ScriptedHTTPClient): + async def request(self, method, url, params=None, json=None): + return self._respond(method, url, params, json) + + +class FakeClient: + def __init__(self, http_client): + self.transport = type("Transport", (), {"client": http_client})() + self.config = type("Config", (), {"runtime_proxy_override": None})() + self.timeout = 30 + + def _build_url(self, path: str) -> str: + return f"https://api.example.com/api{path}" + + +# --- value rules and encryption --- + + +def test_secret_values_follow_the_platform_rules(): + assert secret_value_bytes("line\n") == b"line\n" + assert secret_value_bytes(b"x" * 32768) == b"x" * 32768 + for bad in ["", b"x" * 32769, b"\xff\xfe", "a\x00b"]: + with pytest.raises(HyperbrowserError) as error: + secret_value_bytes(bad) + assert error.value.code == "invalid_secret_value" + + +def test_sealed_values_round_trip_and_bind_the_encryption_context(private_key): + sealer = SecretsSealer(_public_key_payload(private_key)) + envelope = sealer.seal(CONTEXT, "s3cr3t\n") + + assert len(envelope.split(".")) == 5 + assert "=" not in envelope + header, value = _open(private_key, envelope) + assert header == { + "alg": "RSA-OAEP-256", + "enc": "A256GCM", + "kid": secrets_key_id(private_key.public_key()), + "ctx": CONTEXT, + } + assert value == b"s3cr3t\n" + + +def test_keys_that_do_not_match_their_id_or_policy_are_refused(private_key): + with pytest.raises(HyperbrowserError, match="does not match its key ID"): + SecretsSealer(_public_key_payload(private_key, kid="not-the-thumbprint")) + + small = rsa.generate_private_key(public_exponent=65537, key_size=2048) + with pytest.raises(HyperbrowserError, match="at least 3072-bit"): + SecretsSealer(_public_key_payload(small)) + + unsupported = dict(_public_key_payload(private_key), alg="RSA-OAEP") + with pytest.raises(HyperbrowserError, match="Unsupported secrets key"): + SecretsSealer(unsupported) + + +# --- the secrets manager --- + + +def test_list_get_update_and_delete_use_the_secrets_routes(): + http = ScriptedHTTPClient( + { + ("GET", "/secrets"): ( + 200, + {"secrets": [_secret()], "totalCount": 1, "page": 1, "perPage": 20}, + ), + ("GET", f"/secrets/{SECRET_ID}"): ( + 200, + dict( + _secret(latest=2), + versions=[ + {"version": 1, "kid": "k", "createdAt": "2026-10-01T00:00:00Z"}, + {"version": 2, "kid": "k", "createdAt": "2026-10-02T00:00:00Z"}, + ], + ), + ), + ("PATCH", f"/secrets/{SECRET_ID}"): ( + 200, + dict(_secret(), description="rotated monthly"), + ), + ("DELETE", f"/secrets/{SECRET_ID}/versions/1"): ( + 200, + {"deleted": True, "version": 1}, + ), + ("DELETE", f"/secrets/{SECRET_ID}"): ( + 200, + {"deleted": True, "id": SECRET_ID, "name": "provider-key"}, + ), + } + ) + manager = SecretManager(FakeClient(http)) + + listing = manager.list({"search": "provider", "page": 1, "limit": 20}) + assert listing.total_count == 1 and listing.secrets[0].name == "provider-key" + assert http.calls[0]["params"] == {"search": "provider", "page": 1, "limit": 20} + + http.calls.clear() + detail = manager.get(SECRET_ID) + assert [v.version for v in detail.versions] == [1, 2] + # Found by ID: one request, no name search. + assert [c["path"] for c in http.calls] == [f"/secrets/{SECRET_ID}"] + + updated = manager.update(SECRET_ID, {"description": "rotated monthly"}) + assert updated.description == "rotated monthly" + assert http.calls[-1]["json"] == {"description": "rotated monthly"} + + assert manager.delete_version(SECRET_ID, 1).version == 1 + assert manager.delete(SECRET_ID).name == "provider-key" + + +def test_names_resolve_by_exact_match_and_fetch_the_detail_once(): + http = ScriptedHTTPClient( + { + ("GET", "/secrets"): ( + 200, + { + "secrets": [ + _secret(name="provider-key-old", secret_id="other"), + _secret(), + ], + "totalCount": 2, + }, + ), + ("GET", f"/secrets/{SECRET_ID}"): (200, dict(_secret(), versions=[])), + } + ) + manager = SecretManager(FakeClient(http)) + + assert manager.get("provider-key").id == SECRET_ID + assert http.calls[0]["params"] == {"search": "provider-key", "limit": 100} + assert [c["path"] for c in http.calls] == ["/secrets", f"/secrets/{SECRET_ID}"] + + with pytest.raises(HyperbrowserError) as error: + manager.get("provider") + assert error.value.status_code == 404 + assert error.value.code == "SECRET_NOT_FOUND" + + +def test_a_uuid_shaped_name_falls_back_to_a_name_lookup_after_a_404(): + named = _secret(name=UUID_SHAPED_NAME) + http = ScriptedHTTPClient( + { + ("GET", f"/secrets/{UUID_SHAPED_NAME}"): ( + 404, + {"code": "SECRET_NOT_FOUND", "message": "Secret not found"}, + ), + ("GET", "/secrets"): (200, {"secrets": [named], "totalCount": 1}), + ("DELETE", f"/secrets/{SECRET_ID}"): (200, {"deleted": True}), + } + ) + manager = SecretManager(FakeClient(http)) + + manager.delete(UUID_SHAPED_NAME) + + assert [c["path"] for c in http.calls] == [ + f"/secrets/{UUID_SHAPED_NAME}", + "/secrets", + f"/secrets/{SECRET_ID}", + ] + + +def test_other_id_lookup_errors_are_reported_without_a_name_search(): + http = ScriptedHTTPClient( + {("GET", f"/secrets/{SECRET_ID}"): (500, {"message": "boom"})} + ) + manager = SecretManager(FakeClient(http)) + + with pytest.raises(HyperbrowserError) as error: + manager.get(SECRET_ID) + + assert error.value.status_code == 500 + assert all(c["path"] != "/secrets" for c in http.calls) + + +def test_create_with_a_value_uploads_only_an_envelope_bound_to_the_new_secret( + private_key, +): + http = ScriptedHTTPClient( + { + ("GET", "/secrets/public-key"): (200, _public_key_payload(private_key)), + ("POST", "/secrets"): (201, _secret()), + ("POST", f"/secrets/{SECRET_ID}/versions"): ( + 201, + {"version": 1, "kid": "k", "createdAt": "2026-10-01T00:00:00Z"}, + ), + } + ) + manager = SecretManager(FakeClient(http)) + + secret = manager.create( + CreateSecretParams(name="provider-key", description="d", value="v\n") + ) + + assert secret.latest_version == 1 + assert [c["path"] for c in http.calls] == [ + "/secrets/public-key", + "/secrets", + f"/secrets/{SECRET_ID}/versions", + ] + assert http.calls[1]["json"] == {"name": "provider-key", "description": "d"} + upload = http.calls[2]["json"] + assert set(upload) == {"envelope"} + header, value = _open(private_key, upload["envelope"]) + assert header["ctx"] == CONTEXT and value == b"v\n" + + +def test_create_checks_the_value_and_key_before_creating(private_key): + http = ScriptedHTTPClient( + { + ("GET", "/secrets/public-key"): ( + 200, + _public_key_payload(private_key, kid="wrong"), + ), + } + ) + manager = SecretManager(FakeClient(http)) + + with pytest.raises(HyperbrowserError, match="NUL"): + manager.create({"name": "provider-key", "value": "a\x00"}) + with pytest.raises(HyperbrowserError, match="key ID"): + manager.create({"name": "provider-key", "value": "ok"}) + assert all(c["path"] != "/secrets" for c in http.calls) + + +def test_a_failed_upload_after_create_reports_the_partial_create(private_key): + http = ScriptedHTTPClient( + { + ("GET", "/secrets/public-key"): (200, _public_key_payload(private_key)), + ("POST", "/secrets"): (201, _secret()), + ("POST", f"/secrets/{SECRET_ID}/versions"): ( + 409, + {"code": "SECRET_KEY_OUTDATED", "message": "key rotated"}, + ), + } + ) + manager = SecretManager(FakeClient(http)) + + with pytest.raises(HyperbrowserError) as error: + manager.create({"name": "provider-key", "value": "v"}) + + assert error.value.code == "secret_value_not_stored" + assert error.value.retryable is False + assert error.value.details == {"secretId": SECRET_ID, "name": "provider-key"} + assert error.value.cause.code == "SECRET_KEY_OUTDATED" + assert "add_version" in str(error.value) + + +@pytest.mark.anyio +async def test_async_secrets_manager_resolves_names_and_adds_versions(private_key): + http = AsyncScriptedHTTPClient( + { + ("GET", "/secrets"): (200, {"secrets": [_secret()], "totalCount": 1}), + ("GET", "/secrets/public-key"): (200, _public_key_payload(private_key)), + ("POST", f"/secrets/{SECRET_ID}/versions"): ( + 201, + {"version": 3, "kid": "k"}, + ), + ("GET", f"/secrets/{SECRET_ID}"): (200, dict(_secret(), versions=[])), + } + ) + manager = AsyncSecretManager(FakeClient(http)) + + version = await manager.add_version("provider-key", b"next") + assert version.version == 3 + _, value = _open(private_key, http.calls[-1]["json"]["envelope"]) + assert value == b"next" + assert (await manager.get("provider-key")).id == SECRET_ID + + +# --- sandbox create and response fields --- + + +def test_sandbox_create_sends_secret_references_for_image_launches(): + params = CreateSandboxParams( + image_name="node", + secrets={"API_KEY": "provider-key", "OTHER": {"name": "o", "version": 2}}, + ) + assert params.model_dump(by_alias=True, exclude_none=True) == { + "imageName": "node", + "secrets": { + "API_KEY": {"name": "provider-key"}, + "OTHER": {"name": "o", "version": 2}, + }, + } + + with pytest.raises(ValueError, match="only supported for image launches"): + CreateSandboxParams(snapshot_name="snap", secrets={"API_KEY": "k"}) + with pytest.raises(ValueError): + StartSandboxFromSnapshotParams(snapshot_name="snap", secrets={"A": "k"}) + + +def test_contains_secrets_is_decoded(): + assert SandboxDetail(**SANDBOX_DETAIL_PAYLOAD).contains_secrets is False + detail = SandboxDetail(**dict(SANDBOX_DETAIL_PAYLOAD, containsSecrets=True)) + assert detail.contains_secrets is True + snapshot = dict(SNAPSHOT_LIST_PAYLOAD["snapshots"][0], containsSecrets=True) + assert SandboxSnapshotSummary(**snapshot).contains_secrets is True + + +# --- processes with secrets --- + +AGENT_PROCESS = { + "id": "proc_9", + "status": "running", + "command": "node app.js", + "cwd": "/app", + "pid": 42, + "exit_code": None, + "started_at": 1700, + "completed_at": None, +} +RESOLVED = [ + {"env": "API_KEY", "name": "provider-key", "secretId": SECRET_ID, "version": 2} +] +RESULT = { + "id": "proc_9", + "status": "exited", + "exit_code": 0, + "stdout": "ok\n", + "stderr": "", + "started_at": 1700, + "completed_at": 1800, +} + + +class RuntimeScript: + """A runtime transport that answers stdin and wait from a script.""" + + def __init__(self, stdin=None, waits=()): + self._timeout = 30 + self.stdin = stdin + self.waits = list(waits) + self.calls = [] + + def request_json(self, path, *, method="GET", json_body=None, **_): + self.calls.append({"path": path, "method": method, "json": json_body}) + if path.endswith("/stdin"): + if self.stdin is not None: + raise self.stdin + return {"success": True} + if path.endswith("/wait"): + step = self.waits.pop(0) + if isinstance(step, BaseException): + raise step + return {"result": step} + raise AssertionError(path) + + def stream_sse(self, *args, **kwargs): + raise AssertionError("secret starts must not use the runtime start") + + +class AsyncRuntimeScript(RuntimeScript): + async def request_json(self, path, **kwargs): + return RuntimeScript.request_json(self, path, **kwargs) + + +def _status_error(status): + return HyperbrowserError("x", status_code=status, service="runtime") + + +def test_exec_with_secrets_starts_through_the_control_api_and_waits(): + starts = [] + runtime = RuntimeScript( + stdin=_status_error(409), + waits=[_status_error(408), _status_error(408), RESULT], + ) + + def start(payload): + starts.append(payload) + return {"process": AGENT_PROCESS, "secrets": RESOLVED} + + api = SandboxProcessesApi(runtime, start, "sbx_123") + result = api.exec( + {"command": "node", "args": ["app.js"], "env": {"MODE": "prod"}}, + cwd="/app", + timeout_sec=5, + run_as="root", + secrets={"API_KEY": "provider-key", "OTHER": {"name": "o", "version": 2}}, + ) + + assert result.stdout == "ok\n" and result.exit_code == 0 + assert starts == [ + { + "command": "node app.js", + "cwd": "/app", + "env": {"MODE": "prod"}, + "timeoutMs": 5000, + "runAs": "root", + "secrets": { + "API_KEY": {"name": "provider-key"}, + "OTHER": {"name": "o", "version": 2}, + }, + } + ] + assert [c["path"] for c in runtime.calls] == [ + "/sandbox/processes/proc_9/stdin", + "/sandbox/processes/proc_9/wait", + "/sandbox/processes/proc_9/wait", + "/sandbox/processes/proc_9/wait", + ] + assert runtime.calls[0]["json"] == {"eof": True} + assert runtime.calls[1]["json"]["timeoutMs"] == 15000 + + +def test_start_with_secrets_exposes_the_resolved_versions(): + api = SandboxProcessesApi( + RuntimeScript(), + lambda payload: {"process": AGENT_PROCESS, "secrets": RESOLVED}, + "sbx_123", + ) + + handle = api.start("node app.js", secrets={"API_KEY": "provider-key"}) + + assert handle.id == "proc_9" and handle.status == "running" + assert handle.secrets[0].secret_id == SECRET_ID + assert handle.secrets[0].version == 2 + + +def test_env_and_secrets_may_not_set_the_same_variable(): + api = SandboxProcessesApi(RuntimeScript(), lambda payload: {}, "sbx_123") + + with pytest.raises(HyperbrowserError, match="either env or secrets"): + api.start("env", env={"API_KEY": "x"}, secrets={"API_KEY": "provider-key"}) + + +def test_a_failure_after_the_start_names_the_running_process(): + runtime = RuntimeScript(waits=[_status_error(502)]) + api = SandboxProcessesApi( + runtime, + lambda payload: {"process": AGENT_PROCESS, "secrets": RESOLVED}, + "sbx_123", + ) + + with pytest.raises(HyperbrowserError) as error: + api.exec("node app.js", secrets={"API_KEY": "provider-key"}) + + assert error.value.code == "secret_process_failed" + assert error.value.retryable is False + assert error.value.details == {"processId": "proc_9", "sandboxId": "sbx_123"} + + +@pytest.mark.anyio +async def test_async_exec_with_secrets_waits_through_expired_waits(): + runtime = AsyncRuntimeScript(waits=[_status_error(408), RESULT]) + + async def start(payload): + return {"process": AGENT_PROCESS, "secrets": RESOLVED} + + api = AsyncSandboxProcessesApi(runtime, start, "sbx_123") + result = await api.exec("node app.js", secrets={"API_KEY": "provider-key"}) + + assert result.stdout == "ok\n" + assert len([c for c in runtime.calls if c["path"].endswith("/wait")]) == 2 + + +def _sandbox_manager(routes, async_client=False): + http = (AsyncScriptedHTTPClient if async_client else ScriptedHTTPClient)(routes) + cls = AsyncSandboxManager if async_client else SandboxManager + return cls(FakeClient(http)), http + + +def test_sandbox_handles_route_secret_starts_to_the_control_api(): + manager, http = _sandbox_manager( + { + ("POST", "/sandbox/sbx_123/processes"): ( + 200, + {"process": AGENT_PROCESS, "secrets": RESOLVED}, + ) + } + ) + sandbox = manager.attach(SandboxDetail(**SANDBOX_DETAIL_PAYLOAD)) + + handle = sandbox.processes.start("node app.js", secrets={"API_KEY": "k"}) + + assert handle.id == "proc_9" + assert http.calls[0]["json"] == { + "command": "node app.js", + "secrets": {"API_KEY": {"name": "k"}}, + } + + +def test_a_start_without_a_process_id_is_an_unknown_outcome(): + manager, _ = _sandbox_manager( + {("POST", "/sandbox/sbx_123/processes"): (200, {"process": {}})} + ) + + with pytest.raises(HyperbrowserError) as error: + manager.start_secret_process("sbx_123", {"command": "x", "secrets": {}}) + + assert error.value.code == "secret_process_outcome_unknown" + + +def test_a_timed_out_start_is_an_unknown_outcome_but_a_refused_one_is_not(): + timed_out = httpx.ReadTimeout("timed out") + manager, _ = _sandbox_manager({("POST", "/sandbox/sbx_123/processes"): timed_out}) + with pytest.raises(HyperbrowserError) as error: + manager.start_secret_process("sbx_123", {"command": "x"}) + assert error.value.code == "secret_process_outcome_unknown" + assert error.value.retryable is False + + refused = httpx.ConnectError("refused") + manager, _ = _sandbox_manager({("POST", "/sandbox/sbx_123/processes"): refused}) + with pytest.raises(HyperbrowserError) as error: + manager.start_secret_process("sbx_123", {"command": "x"}) + assert error.value.code != "secret_process_outcome_unknown" + + manager, _ = _sandbox_manager( + { + ("POST", "/sandbox/sbx_123/processes"): ( + 502, + {"code": "SECRET_DELIVERY_UNKNOWN", "message": "m", "retryable": False}, + ) + } + ) + with pytest.raises(HyperbrowserError) as error: + manager.start_secret_process("sbx_123", {"command": "x"}) + assert error.value.code == "SECRET_DELIVERY_UNKNOWN" + + +@pytest.mark.anyio +async def test_async_sandbox_handles_route_secret_starts_to_the_control_api(): + manager, http = _sandbox_manager( + { + ("POST", "/sandbox/sbx_123/processes"): ( + 200, + {"process": AGENT_PROCESS, "secrets": RESOLVED}, + ) + }, + async_client=True, + ) + sandbox = manager.attach(SandboxDetail(**SANDBOX_DETAIL_PAYLOAD)) + + handle = await sandbox.processes.start("node app.js", secrets={"API_KEY": "k"}) + + assert handle.secrets[0].env == "API_KEY" + assert http.calls[0]["path"] == "/sandbox/sbx_123/processes" + + +# --- error fields --- + + +def _error_response(status, body): + request = httpx.Request("POST", "https://api.example.com/api/x") + return httpx.Response(status, json=body, request=request) + + +def test_control_errors_honour_the_server_retryable_flag_and_code_in_error(): + with pytest.raises(HyperbrowserError) as error: + ensure_response_ok( + _error_response(502, {"message": "unknown", "retryable": False}), + "control", + ) + assert error.value.retryable is False + + with pytest.raises(HyperbrowserError) as error: + ensure_response_ok( + _error_response( + 403, + { + "error": "API_KEY_SCOPE_DENIED", + "message": "denied", + "requiredScopes": ["secrets.use"], + }, + ), + "control", + ) + assert error.value.code == "API_KEY_SCOPE_DENIED" + assert str(error.value).startswith("denied") + assert error.value.details["requiredScopes"] == ["secrets.use"] + + # A plain error message is not a code; runtime gateway errors keep the + # status-code default. + with pytest.raises(HyperbrowserError) as error: + ensure_response_ok(_error_response(400, {"error": "Bad thing"}), "control") + assert error.value.code is None + with pytest.raises(HyperbrowserError) as error: + ensure_response_ok( + _error_response(503, {"message": "proxy", "retryable": False}), + "runtime", + ) + assert error.value.retryable is True + + +def test_request_may_have_arrived(): + assert request_may_have_arrived(httpx.ReadTimeout("t")) + assert request_may_have_arrived(httpx.RemoteProtocolError("closed")) + assert not request_may_have_arrived(httpx.ConnectError("refused")) + assert not request_may_have_arrived(HyperbrowserError("x", status_code=502)) + assert request_may_have_arrived(HyperbrowserError("bad json", status_code=200)) + + +def test_transport_errors_keep_server_codes_and_retryable_flags(): + from hyperbrowser.transport.sync import SyncTransport + + transport = SyncTransport("key") + transport.client = httpx.Client( + transport=httpx.MockTransport( + lambda request: httpx.Response( + 503, + json={ + "error": "SECRETS_NOT_CONFIGURED", + "message": "Secrets are not configured", + "retryable": False, + }, + ) + ) + ) + + with pytest.raises(HyperbrowserError) as error: + transport.get("https://api.example.com/api/secrets/public-key") + + assert error.value.code == "SECRETS_NOT_CONFIGURED" + assert error.value.retryable is False diff --git a/tests/typecheck/invalid_requests.py b/tests/typecheck/invalid_requests.py index efa758af..7d2cea30 100644 --- a/tests/typecheck/invalid_requests.py +++ b/tests/typecheck/invalid_requests.py @@ -15,6 +15,8 @@ def invalid_sync_requests(client: Hyperbrowser) -> None: } ) client.sandboxes.create({"image_name": "node", "cpu": "four"}) # M,P + client.secrets.create({"description": "missing name"}) # M,P + client.sandboxes.create({"image_name": "node", "secrets": {"A": 1}}) # M,P client.sandboxes.create_image_build( { # P "image_name": "custom_node", diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index a109d569..7c20f104 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -10,6 +10,8 @@ FetchParams as LegacyFetchParams, SandboxImageBuildListParams as LegacySandboxImageBuildListParams, VolumeListParams as LegacyVolumeListParams, + CreateSecretParams as LegacyCreateSecretParams, + SecretReference as LegacySecretReference, ScrollActionParams as LegacyScrollActionParams, ScrollAtCursorActionParams as LegacyScrollAtCursorActionParams, ComputerActionResponseData, @@ -238,6 +240,29 @@ def valid_sync_requests(client: Hyperbrowser) -> None: client.volumes.list({"page": 0, "limit": -1}) client.volumes.list(LegacyVolumeListParams(page=0, limit=-1)) client.volumes.delete("2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d") + client.secrets.list({"search": "provider", "limit": 100}) + client.secrets.create({"name": "provider-key", "value": b"value"}) + client.secrets.create(LegacyCreateSecretParams(name="provider-key", value="v")) + client.secrets.update("provider-key", {"description": "rotated"}) + client.secrets.add_version("provider-key", "next value") + client.secrets.delete_version("provider-key", 1) + client.sandboxes.create( + { + "image_name": "node", + "secrets": { + "API_KEY": "provider-key", + "OTHER": {"name": "o", "version": 2}, + }, + } + ) + sandbox = client.sandboxes.get("sandbox_123") + sandbox.exec( + "node app.js", + secrets={ + "API_KEY": {"name": "provider-key", "version": "latest"}, + "OTHER": LegacySecretReference(name="o", version=2), + }, + ) client.sessions.create(LegacyCreateSessionParams(use_stealth=True, region="us")) client.web.fetch(LegacyFetchParams(url="https://example.com")) @@ -379,6 +404,10 @@ async def valid_async_requests(client: AsyncHyperbrowser) -> None: await client.sandboxes.list_image_builds({"status": "verifying", "limit": -1}) await client.volumes.list({"page": 0, "limit": -1}) await client.volumes.delete("2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d") + await client.secrets.create({"name": "provider-key", "description": "d"}) + await client.secrets.get("provider-key") + async_sandbox = await client.sandboxes.get("sandbox_123") + await async_sandbox.processes.start("node app.js", secrets={"API_KEY": "k"}) await client.sessions.create(LegacyCreateSessionParams(use_proxy=True, region="us")) await client.web.fetch(LegacyFetchParams(url="https://example.com")) @@ -469,21 +498,38 @@ async def valid_async_process_collection(client: AsyncHyperbrowser) -> None: def valid_webmcp_requests(client: Hyperbrowser) -> None: - from hyperbrowser.models import WebMCPInvokeParams, WebMCPStartParams, WebMCPResultParams + from hyperbrowser.models import ( + WebMCPInvokeParams, + WebMCPStartParams, + WebMCPResultParams, + ) + client.sessions.create({"enable_web_mcp": True}) tools = client.sessions.webmcp.list_tools("session") tool = tools.tools[0] - client.sessions.webmcp.invoke("session", {"tool_ref": tool.tool_ref, "input": {"customKey": 1}}) + client.sessions.webmcp.invoke( + "session", {"tool_ref": tool.tool_ref, "input": {"customKey": 1}} + ) client.sessions.webmcp.invoke("session", WebMCPInvokeParams(tool_ref=tool.tool_ref)) - handle = client.sessions.webmcp.start("session", WebMCPStartParams(tool_ref=tool.tool_ref, timeout_seconds=600)) - client.sessions.webmcp.get_result("session", handle.invocation_id, WebMCPResultParams(wait_seconds=30)) + handle = client.sessions.webmcp.start( + "session", WebMCPStartParams(tool_ref=tool.tool_ref, timeout_seconds=600) + ) + client.sessions.webmcp.get_result( + "session", handle.invocation_id, WebMCPResultParams(wait_seconds=30) + ) client.sessions.webmcp.cancel("session", handle.invocation_id) async def valid_async_webmcp_requests(client: AsyncHyperbrowser) -> None: await client.sessions.create({"enable_web_mcp": True}) tools = await client.sessions.webmcp.list_tools("session") - await client.sessions.webmcp.invoke("session", {"tool_ref": tools.tools[0].tool_ref}) - handle = await client.sessions.webmcp.start("session", {"tool_ref": tools.tools[0].tool_ref, "timeout_seconds": 600}) - await client.sessions.webmcp.get_result("session", handle.invocation_id, {"wait_seconds": 30}) + await client.sessions.webmcp.invoke( + "session", {"tool_ref": tools.tools[0].tool_ref} + ) + handle = await client.sessions.webmcp.start( + "session", {"tool_ref": tools.tools[0].tool_ref, "timeout_seconds": 600} + ) + await client.sessions.webmcp.get_result( + "session", handle.invocation_id, {"wait_seconds": 30} + ) await client.sessions.webmcp.cancel("session", handle.invocation_id) From 1a70ade14d0b380e118116fd3a26e225907378d5 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Sun, 11 Oct 2026 18:17:18 -0700 Subject: [PATCH 2/5] Fix secret process deadlines and ambiguous outcome handling --- README.md | 16 +- .../sandboxes/sandbox_processes.py | 106 ++- .../sandboxes/sandbox_transport.py | 10 +- .../client/managers/sandboxes/shared.py | 2 +- .../client/managers/sync_manager/sandbox.py | 103 ++- .../sandboxes/sandbox_processes.py | 104 ++- .../sandboxes/sandbox_transport.py | 160 +++- hyperbrowser/sandbox_common.py | 22 +- hyperbrowser/secrets_common.py | 68 +- tests/test_secrets.py | 824 +++++++++++++++++- 10 files changed, 1327 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 2e746980..def6d529 100644 --- a/README.md +++ b/README.md @@ -319,8 +319,20 @@ Things to know: sandbox. - Limits: 100 secrets per team, 10 versions per secret, 32 secrets per process, and values of 1 byte to 32 KiB of UTF-8 without NUL bytes. -- API keys need the `secrets.read`, `secrets.manage` or `secrets.use` scope for - reading, changing or using secrets, and the team needs the secrets feature. +- Reading secrets needs `secrets.read`; delivering them to a sandbox needs + `secrets.use`. Creating a secret needs `secrets.manage`. Updating, rotating, + and deleting secrets or versions through the SDK need both `secrets.read` + and `secrets.manage`, since it resolves IDs and names through metadata reads. + `add_version()` also reads the secret's `encryptionContext` to encrypt its + value. The team needs the secrets feature. +- Secret-started process handles wait indefinitely by default with `wait()` or + `result()`. A positive `wait(timeout_ms=...)` or `wait(timeout_sec=...)` limits + the total local wait and raises `TimeoutError` (`asyncio.TimeoutError` for + async handles); the process continues and the handle can be used to wait + again. The start's timeout limits the command's execution separately. + For strict cancellation timing, use the async client: blocking DNS, custom + transports or hooks, shared HTTP/2 control connections, and trickled headers + on a reused control connection can delay cancellation in the sync client. - A process start with secrets that times out raises an error with code `secret_process_outcome_unknown`: the command may be running, so check `sandbox.processes.list()` before retrying. diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py index 30db2463..512ac066 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py @@ -1,6 +1,7 @@ import asyncio import base64 -from typing import AsyncIterator, Awaitable, Callable, Dict, List, Optional, Union +import time +from typing import Any, AsyncIterator, Awaitable, Callable, Dict, List, Optional, Union from ...._request import coerce_request from .....models.sandbox import ( @@ -14,7 +15,11 @@ ) from .....exceptions import HyperbrowserError from .....models.secret import SandboxResolvedSecret -from .....secrets_common import secret_process_failed_error, secret_wait_chunk_ms +from .....secrets_common import ( + SecretProcessWait, + SecretProcessWaitContext, + secret_process_failed_error, +) from .....types import ( SandboxExecParams as SandboxExecParamsDict, SandboxProcessStdinParams as SandboxProcessStdinParamsDict, @@ -36,9 +41,15 @@ class SandboxProcessHandle: - def __init__(self, transport: RuntimeTransport, summary: SandboxProcessSummary): + def __init__( + self, + transport: RuntimeTransport, + summary: SandboxProcessSummary, + secret_wait_context: Optional[SecretProcessWaitContext] = None, + ): self._transport = transport self._summary = summary + self._secret_wait_context = secret_wait_context self._output = None self._collector = None self._events = None @@ -80,6 +91,53 @@ async def wait( if not self._collector.done(): await asyncio.wait_for(asyncio.shield(self._collector), timeout) return self._collected_result() + if self._secret_wait_context is not None: + return await self._wait_with_secrets(timeout_ms, timeout_sec) + return await self._wait_once(timeout_ms, timeout_sec) + + async def _wait_with_secrets( + self, timeout_ms: Optional[int], timeout_sec: Optional[int] + ) -> SandboxProcessResult: + context = self._secret_wait_context + assert context is not None + wait = SecretProcessWait( + context, timeout_ms, timeout_sec, timeout_error=asyncio.TimeoutError + ) + while True: + chunk_ms, request_timeout, remaining = wait.request_budget() + started_at = time.monotonic() + try: + request = self._wait_once(chunk_ms, None, request_timeout) + result = await asyncio.wait_for(request, remaining) + wait.remaining() + return result + except (TimeoutError, asyncio.TimeoutError) as error: + if wait.deadline is not None: + raise asyncio.TimeoutError( + "Timed out waiting for command output" + ) from error + raise secret_process_failed_error( + context.sandbox_id, self.id, "waiting for its result", error + ) + except Exception as error: + # An explicit local deadline keeps TimeoutError semantics. It + # ends only this wait; callers can wait again on the handle. + wait.remaining() + if not isinstance(error, HyperbrowserError) or error.status_code != 408: + raise secret_process_failed_error( + context.sandbox_id, self.id, "waiting for its result", error + ) + await asyncio.sleep(wait.retry_delay(started_at)) + + async def _wait_once( + self, + timeout_ms: Optional[int], + timeout_sec: Optional[int], + request_timeout: Optional[float] = None, + ) -> SandboxProcessResult: + request_options: Dict[str, Any] = {} + if request_timeout is not None: + request_options["request_timeout"] = request_timeout payload = await self._transport.request_json( f"/sandbox/processes/{self.id}/wait", method="POST", @@ -88,6 +146,7 @@ async def wait( "timeout_sec": timeout_sec, }, headers={"content-type": "application/json"}, + **request_options, ) result = SandboxProcessResult(**payload["result"]) if result.output_truncated: @@ -372,13 +431,23 @@ async def _start_with_secrets( code="secrets_unsupported", ) payload = await self._start_secret_process(_secret_process_payload(params)) - handle = SandboxProcessHandle( - self._transport, SandboxProcessSummary(**payload["process"]) - ) - handle.secrets = [ - SandboxResolvedSecret(**entry) for entry in payload.get("secrets") or [] - ] - return handle + process_id = str(payload["process"]["id"]) + try: + handle = SandboxProcessHandle( + self._transport, + SandboxProcessSummary(**payload["process"]), + SecretProcessWaitContext( + self._sandbox_id or "", getattr(self._transport, "_timeout", None) + ), + ) + handle.secrets = [ + SandboxResolvedSecret(**entry) for entry in payload.get("secrets") or [] + ] + return handle + except Exception as error: + raise secret_process_failed_error( + self._sandbox_id or "", process_id, "reading its start response", error + ) async def _finish_secret_exec( self, handle: SandboxProcessHandle @@ -402,22 +471,7 @@ async def _finish_secret_exec( raise secret_process_failed_error( sandbox_id, handle.id, "closing its stdin", error ) - chunk_ms = secret_wait_chunk_ms(getattr(self._transport, "_timeout", None)) - while True: - try: - return await handle.wait(timeout_ms=chunk_ms) - except HyperbrowserError as error: - # The command's own timeout is enforced in the sandbox, so an - # expired wait only means it is still running. - if error.status_code == 408: - continue - raise secret_process_failed_error( - sandbox_id, handle.id, "waiting for its result", error - ) - except Exception as error: - raise secret_process_failed_error( - sandbox_id, handle.id, "waiting for its result", error - ) + return await handle.wait() async def get(self, process_id: str) -> SandboxProcessHandle: payload = await self._transport.request_json(f"/sandbox/processes/{process_id}") diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py index ca1ec956..225fca0b 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py @@ -40,6 +40,7 @@ async def request_json( json_body: Optional[Dict[str, object]] = None, content: Optional[Any] = None, headers: Optional[Dict[str, str]] = None, + request_timeout: Optional[float] = None, ): response = await self._request( path, @@ -48,6 +49,7 @@ async def request_json( json_body=json_body, content=content, headers=headers, + request_timeout=request_timeout, ) return parse_json_response(response, "runtime") @@ -168,6 +170,7 @@ async def _request( content: Optional[Any] = None, headers: Optional[Dict[str, str]] = None, allow_refresh: bool = True, + request_timeout: Optional[float] = None, ) -> httpx.Response: connection = await self._resolve_connection(False) response = await self._send( @@ -178,6 +181,7 @@ async def _request( json_body=json_body, content=content, headers=headers, + request_timeout=request_timeout, ) if response.status_code == 401 and allow_refresh: @@ -197,6 +201,7 @@ async def _request( json_body=json_body, content=content, headers=headers, + request_timeout=request_timeout, ) return ensure_response_ok(retry, "runtime") @@ -293,6 +298,7 @@ async def _send( json_body: Optional[Dict[str, object]], content: Optional[Any], headers: Optional[Dict[str, str]], + request_timeout: Optional[float] = None, ) -> httpx.Response: request_path = _build_query_path(path, params) target = resolve_runtime_transport_target( @@ -301,7 +307,9 @@ async def _send( self._runtime_proxy_override, ) merged_headers = build_headers(connection.token, headers, target.host_header) - client = httpx.AsyncClient(timeout=self._timeout) + client = httpx.AsyncClient( + timeout=self._timeout if request_timeout is None else request_timeout + ) try: response = await client.request( diff --git a/hyperbrowser/client/managers/sandboxes/shared.py b/hyperbrowser/client/managers/sandboxes/shared.py index d2792241..80c224a2 100644 --- a/hyperbrowser/client/managers/sandboxes/shared.py +++ b/hyperbrowser/client/managers/sandboxes/shared.py @@ -138,7 +138,7 @@ def _secret_process_payload(params: SandboxExecParams) -> Dict[str, object]: code="invalid_secret_reference", ) timeout_ms = params.timeout_ms - if timeout_ms is None and params.timeout_sec is not None: + if params.timeout_sec is not None and params.timeout_sec > 0: timeout_ms = params.timeout_sec * 1000 payload: Dict[str, object] = { "command": params.command, diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index 6c091ce9..08cd7d1a 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -2,6 +2,8 @@ from pathlib import Path from typing import Dict, Optional, Union +import httpx + from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError from ....models.sandbox import ( @@ -111,7 +113,7 @@ SandboxTerminalConnection, SandboxTerminalHandle, ) -from .sandboxes.sandbox_transport import RuntimeTransport +from .sandboxes.sandbox_transport import RuntimeTransport, _RequestDeadline __all__ = [ "DEFAULT_PROCESS_KILL_WAIT_SECONDS", @@ -140,6 +142,7 @@ def __init__(self, service: "SandboxManager", detail: SandboxDetail): self._resolve_runtime_connection, service.runtime_timeout, service.runtime_proxy_override, + self._resolve_runtime_connection_with_deadline, ) self.processes = SandboxProcessesApi( self._transport, @@ -289,6 +292,11 @@ def get_exposed_url(self, port: int) -> str: def create_runtime_session( self, force_refresh: bool = False + ) -> SandboxRuntimeSession: + return self._create_runtime_session(force_refresh) + + def _create_runtime_session( + self, force_refresh: bool, deadline: Optional[float] = None ) -> SandboxRuntimeSession: self._assert_runtime_available() if ( @@ -298,7 +306,11 @@ def create_runtime_session( ): return _copy_model(self._runtime_session) - detail = self._service.get_detail(self.id) + detail = ( + self._service.get_detail(self.id) + if deadline is None + else self._service._get_detail_with_deadline(self.id, deadline) + ) self._hydrate(detail) if self._runtime_session is None: raise HyperbrowserError( @@ -369,6 +381,16 @@ def _resolve_runtime_socket_info(self) -> RuntimeConnection: token=session.token, ) + def _resolve_runtime_connection_with_deadline( + self, force_refresh: bool, deadline: float + ) -> RuntimeConnection: + session = self._create_runtime_session(force_refresh, deadline) + return RuntimeConnection( + sandbox_id=self.id, + base_url=session.runtime.base_url, + token=session.token, + ) + def _apply_runtime_session(self, session: SandboxRuntimeSession) -> None: self._runtime_session = _copy_model(session) self._detail = self._detail.model_copy( @@ -1068,6 +1090,14 @@ def get_detail(self, sandbox_id: str) -> SandboxDetail: payload = self._request("GET", f"/sandbox/{sandbox_id}") return SandboxDetail(**payload) + def _get_detail_with_deadline( + self, sandbox_id: str, deadline: float + ) -> SandboxDetail: + payload = self._request( + "GET", f"/sandbox/{sandbox_id}", request_deadline=deadline + ) + return SandboxDetail(**payload) + def attach(self, detail: SandboxDetail) -> SandboxHandle: return SandboxHandle(self, detail) @@ -1169,22 +1199,38 @@ def _request( *, params: Optional[Dict[str, object]] = None, data: Optional[Dict[str, object]] = None, + request_deadline: Optional[float] = None, ): failed_attempt = 1 while True: try: - response = self._client.transport.client.request( - method, - self._client._build_url(path), - params={ - key: value - for key, value in (params or {}).items() - if value is not None - }, - json=data, - ) + request_params = { + key: value + for key, value in (params or {}).items() + if value is not None + } + if request_deadline is None: + response = self._client.transport.client.request( + method, + self._client._build_url(path), + params=request_params, + json=data, + ) + else: + response = self._request_with_deadline( + method, path, request_params, data, request_deadline + ) ensure_response_ok(response, "control") except BaseException as cause: + if not isinstance(cause, Exception): + raise + if ( + request_deadline is not None + and time.monotonic() >= request_deadline + ): + raise TimeoutError( + "Timed out waiting for command output" + ) from cause error = normalize_network_error( cause, "control", @@ -1193,8 +1239,39 @@ def _request( ) if not should_retry_get(method, error, failed_attempt): raise error - time.sleep(get_retry_delay_seconds(failed_attempt)) + delay = get_retry_delay_seconds(failed_attempt) + if request_deadline is not None: + delay = min(delay, max(0, request_deadline - time.monotonic())) + time.sleep(delay) failed_attempt += 1 continue return parse_json_response(response, "control") + + def _request_with_deadline(self, method, path, params, data, deadline): + budget = _RequestDeadline(deadline, shared_connection=True) + response = None + try: + client = self._client.transport.client + remaining = budget.remaining() + timeouts = { + key: min(value, remaining) if value is not None else remaining + for key, value in client.timeout.as_dict().items() + } + request = client.build_request( + method, + self._client._build_url(path), + params=params, + json=data, + timeout=httpx.Timeout(**timeouts), + extensions={"trace": budget.trace}, + ) + response = client.send(request, stream=True) + budget.capture_response(response) + response.read() + budget.remaining() + return response + finally: + budget.close() + if response is not None: + response.close() diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py index 1ba60338..9d364ada 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py @@ -1,6 +1,7 @@ import base64 +import time import threading -from typing import Callable, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union from ...._request import coerce_request from .....models.sandbox import ( @@ -14,7 +15,11 @@ ) from .....exceptions import HyperbrowserError from .....models.secret import SandboxResolvedSecret -from .....secrets_common import secret_process_failed_error, secret_wait_chunk_ms +from .....secrets_common import ( + SecretProcessWait, + SecretProcessWaitContext, + secret_process_failed_error, +) from .....types import ( SandboxExecParams as SandboxExecParamsDict, SandboxProcessStdinParams as SandboxProcessStdinParamsDict, @@ -36,9 +41,15 @@ class SandboxProcessHandle: - def __init__(self, transport: RuntimeTransport, summary: SandboxProcessSummary): + def __init__( + self, + transport: RuntimeTransport, + summary: SandboxProcessSummary, + secret_wait_context: Optional[SecretProcessWaitContext] = None, + ): self._transport = transport self._summary = summary + self._secret_wait_context = secret_wait_context self._output = None self._collector = None self._events = None @@ -83,6 +94,51 @@ def wait(self, timeout_ms: Optional[int] = None, timeout_sec: Optional[int] = No if not ready: raise TimeoutError("Timed out waiting for command output") return self._collected_result() + if self._secret_wait_context is not None: + return self._wait_with_secrets(timeout_ms, timeout_sec) + return self._wait_once(timeout_ms, timeout_sec) + + def _wait_with_secrets( + self, timeout_ms: Optional[int], timeout_sec: Optional[int] + ) -> SandboxProcessResult: + context = self._secret_wait_context + assert context is not None + wait = SecretProcessWait(context, timeout_ms, timeout_sec) + while True: + chunk_ms, request_timeout, _ = wait.request_budget() + started_at = time.monotonic() + try: + result = self._wait_once(chunk_ms, None, request_timeout, wait.deadline) + wait.remaining() + return result + except TimeoutError as error: + if wait.deadline is not None: + raise + raise secret_process_failed_error( + context.sandbox_id, self.id, "waiting for its result", error + ) + except Exception as error: + # An explicit local deadline keeps TimeoutError semantics. It + # ends only this wait; callers can wait again on the handle. + wait.remaining() + if not isinstance(error, HyperbrowserError) or error.status_code != 408: + raise secret_process_failed_error( + context.sandbox_id, self.id, "waiting for its result", error + ) + time.sleep(wait.retry_delay(started_at)) + + def _wait_once( + self, + timeout_ms: Optional[int], + timeout_sec: Optional[int], + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, + ) -> SandboxProcessResult: + request_options: Dict[str, Any] = {} + if request_timeout is not None: + request_options["request_timeout"] = request_timeout + if request_deadline is not None: + request_options["request_deadline"] = request_deadline payload = self._transport.request_json( f"/sandbox/processes/{self.id}/wait", method="POST", @@ -91,6 +147,7 @@ def wait(self, timeout_ms: Optional[int] = None, timeout_sec: Optional[int] = No "timeout_sec": timeout_sec, }, headers={"content-type": "application/json"}, + **request_options, ) result = SandboxProcessResult(**payload["result"]) if result.output_truncated: @@ -378,13 +435,23 @@ def _start_with_secrets(self, params: SandboxExecParams) -> SandboxProcessHandle code="secrets_unsupported", ) payload = self._start_secret_process(_secret_process_payload(params)) - handle = SandboxProcessHandle( - self._transport, SandboxProcessSummary(**payload["process"]) - ) - handle.secrets = [ - SandboxResolvedSecret(**entry) for entry in payload.get("secrets") or [] - ] - return handle + process_id = str(payload["process"]["id"]) + try: + handle = SandboxProcessHandle( + self._transport, + SandboxProcessSummary(**payload["process"]), + SecretProcessWaitContext( + self._sandbox_id or "", getattr(self._transport, "_timeout", None) + ), + ) + handle.secrets = [ + SandboxResolvedSecret(**entry) for entry in payload.get("secrets") or [] + ] + return handle + except Exception as error: + raise secret_process_failed_error( + self._sandbox_id or "", process_id, "reading its start response", error + ) def _finish_secret_exec(self, handle: SandboxProcessHandle) -> SandboxProcessResult: """Close stdin and wait for the result, as one-shot exec does. @@ -406,22 +473,7 @@ def _finish_secret_exec(self, handle: SandboxProcessHandle) -> SandboxProcessRes raise secret_process_failed_error( sandbox_id, handle.id, "closing its stdin", error ) - chunk_ms = secret_wait_chunk_ms(getattr(self._transport, "_timeout", None)) - while True: - try: - return handle.wait(timeout_ms=chunk_ms) - except HyperbrowserError as error: - # The command's own timeout is enforced in the sandbox, so an - # expired wait only means it is still running. - if error.status_code == 408: - continue - raise secret_process_failed_error( - sandbox_id, handle.id, "waiting for its result", error - ) - except Exception as error: - raise secret_process_failed_error( - sandbox_id, handle.id, "waiting for its result", error - ) + return handle.wait() def get(self, process_id: str) -> SandboxProcessHandle: payload = self._transport.request_json(f"/sandbox/processes/{process_id}") diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py index 9ddf9ce6..5444e1cd 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py @@ -1,5 +1,7 @@ import json import socket +import threading +import time from typing import Any, Callable, Dict, Iterator, Optional import httpx @@ -21,16 +23,130 @@ ) +class _DeadlineResponseStream(httpx.SyncByteStream): + def __init__(self, stream, deadline): + self._stream = stream + self._deadline = deadline + + def __iter__(self): + yield from self._stream + + def close(self) -> None: + # Cancel before HTTPX releases the connection back to a shared pool. + self._deadline.close() + self._stream.close() + + +class _RequestDeadline: + """Interrupt this request's socket when its total wait budget expires.""" + + def __init__(self, deadline: float, shared_connection: bool = False): + self.deadline = deadline + self._shared_connection = shared_connection + self._may_shutdown = True + self._socket = None + self._lock = threading.Lock() + self._expired = False + self._closed = False + self._timer = threading.Timer(self.remaining(), self._expire) + self._timer.daemon = True + self._timer.start() + + def remaining(self) -> float: + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Timed out waiting for command output") + return remaining + + @staticmethod + def _shutdown(sock) -> None: + if sock is not None: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + def _expire(self) -> None: + with self._lock: + if self._closed: + return + self._expired = True + sock = self._socket + self._shutdown(sock) + + def trace(self, event, info) -> None: + if "response_closed." in event or event.endswith( + "receive_response_body.complete" + ): + # Response hooks may eagerly read/close before capture_response(). + # Cancel before the connection becomes available to another request, + # and never interrupt the cleanup that releases it to the pool. + self.close() + return + if self._shared_connection and event.startswith("http2."): + with self._lock: + self._may_shutdown = False + self._socket = None + # The HTTPX trace extension exposes the stream before response + # headers, including the replacement stream after TLS negotiation. + if event.endswith(("connect_tcp.complete", "start_tls.complete")): + self.capture_stream(info.get("return_value")) + self.remaining() + + def capture_stream(self, stream) -> None: + if stream is not None: + ssl_object = stream.get_extra_info("ssl_object") + shared_http2 = ( + self._shared_connection + and ssl_object is not None + and ssl_object.selected_alpn_protocol() == "h2" + ) + with self._lock: + if self._closed: + return + if shared_http2: + self._may_shutdown = False + self._socket = ( + stream.get_extra_info("socket") if self._may_shutdown else None + ) + expired = self._expired + sock = self._socket + if expired: + self._shutdown(sock) + + def capture_response(self, response: httpx.Response) -> None: + if response.is_closed or response.is_stream_consumed: + self.close() + return + self.capture_stream(response.extensions.get("network_stream")) + response.stream = _DeadlineResponseStream(response.stream, self) + + def close(self) -> None: + with self._lock: + self._closed = True + self._timer.cancel() + self._timer.join() + + class RuntimeTransport: def __init__( self, resolve_connection, timeout: float = 30.0, runtime_proxy_override: Optional[str] = None, + resolve_connection_with_deadline: Optional[ + Callable[[bool, float], RuntimeConnection] + ] = None, ): self._resolve_connection = resolve_connection self._timeout = timeout self._runtime_proxy_override = runtime_proxy_override + self._resolve_connection_with_deadline = resolve_connection_with_deadline + + def _connection(self, force_refresh: bool, deadline: Optional[float]): + if deadline is not None and self._resolve_connection_with_deadline is not None: + return self._resolve_connection_with_deadline(force_refresh, deadline) + return self._resolve_connection(force_refresh) def request_json( self, @@ -41,6 +157,8 @@ def request_json( json_body: Optional[Dict[str, object]] = None, content: Optional[Any] = None, headers: Optional[Dict[str, str]] = None, + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, ): response = self._request( path, @@ -49,6 +167,8 @@ def request_json( json_body=json_body, content=content, headers=headers, + request_timeout=request_timeout, + request_deadline=request_deadline, ) return parse_json_response(response, "runtime") @@ -184,8 +304,10 @@ def _request( content: Optional[Any] = None, headers: Optional[Dict[str, str]] = None, allow_refresh: bool = True, + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, ) -> httpx.Response: - connection = self._resolve_connection(False) + connection = self._connection(False, request_deadline) response = self._send( connection, path, @@ -194,6 +316,8 @@ def _request( json_body=json_body, content=content, headers=headers, + request_timeout=request_timeout, + request_deadline=request_deadline, ) if response.status_code == 401 and allow_refresh: @@ -204,7 +328,7 @@ def _request( "runtime", "Runtime token expired during a streaming request; retry with a fresh stream.", ) - refreshed = self._resolve_connection(True) + refreshed = self._connection(True, request_deadline) retry = self._send( refreshed, path, @@ -213,6 +337,8 @@ def _request( json_body=json_body, content=content, headers=headers, + request_timeout=request_timeout, + request_deadline=request_deadline, ) return ensure_response_ok(retry, "runtime") @@ -309,6 +435,8 @@ def _send( json_body: Optional[Dict[str, object]], content: Optional[Any], headers: Optional[Dict[str, str]], + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, ) -> httpx.Response: request_path = _build_query_path(path, params) target = resolve_runtime_transport_target( @@ -317,28 +445,42 @@ def _send( self._runtime_proxy_override, ) merged_headers = build_headers(connection.token, headers, target.host_header) - client = httpx.Client(timeout=self._timeout) - + timeout = self._timeout if request_timeout is None else request_timeout + deadline = None + client = None try: + if request_deadline is not None: + deadline = _RequestDeadline(request_deadline) + remaining = deadline.remaining() + timeout = min(timeout, remaining) if timeout is not None else remaining + client = httpx.Client(timeout=timeout) + request_options: Dict[str, Any] = {} + if deadline is not None: + request_options["extensions"] = {"trace": deadline.trace} response = client.request( method, target.url, headers=merged_headers, json=json_body, content=content, + **request_options, ) + if deadline is not None: + deadline.remaining() + response.read() + return response except BaseException as error: - client.close() raise normalize_network_error( error, "runtime", "Unknown runtime request error", request_context(method, path), ) - - response.read() - client.close() - return response + finally: + if deadline is not None: + deadline.close() + if client is not None: + client.close() def _send_binary_stream( self, diff --git a/hyperbrowser/sandbox_common.py b/hyperbrowser/sandbox_common.py index 84a322f6..1493cfb7 100644 --- a/hyperbrowser/sandbox_common.py +++ b/hyperbrowser/sandbox_common.py @@ -104,19 +104,28 @@ def error_payload_retryable(payload: Any, status_code: int) -> bool: def parse_error_payload( raw_text: str, fallback_message: str ) -> Tuple[str, Optional[str], Any]: + message, code, details, _ = _parse_error_payload(raw_text, fallback_message) + return message, code, details + + +def _parse_error_payload( + raw_text: str, fallback_message: str +) -> Tuple[str, Optional[str], Any, Optional[json.JSONDecodeError]]: if not raw_text: - return fallback_message, None, None + return fallback_message, None, None, json.JSONDecodeError( + "Empty response body", raw_text, 0 + ) try: parsed = json.loads(raw_text) - except json.JSONDecodeError: - return raw_text, None, raw_text + except json.JSONDecodeError as error: + return raw_text, None, raw_text, error if isinstance(parsed, dict): message = parsed.get("message") or parsed.get("error") or fallback_message - return message, error_payload_code(parsed), parsed + return message, error_payload_code(parsed), parsed, None - return fallback_message, None, parsed + return fallback_message, None, parsed, None def ensure_response_ok( @@ -130,7 +139,7 @@ def ensure_response_ok( fallback = default_message or ( f"Request failed: {response.status_code} {response.reason_phrase}" ) - message, code, details = parse_error_payload(response.text, fallback) + message, code, details, decode_error = _parse_error_payload(response.text, fallback) raise HyperbrowserError( message, status_code=response.status_code, @@ -147,6 +156,7 @@ def ensure_response_ok( ), service=service, details=details, + cause=decode_error, ) diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index e105cb67..cda34f19 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -9,8 +9,10 @@ import hashlib import json import os +import time import uuid -from typing import Any, Dict, Optional, Union +from dataclasses import dataclass +from typing import Any, Dict, Optional, Type, Union import httpx from cryptography.hazmat.primitives import hashes @@ -203,6 +205,8 @@ def request_may_have_arrived(error: BaseException) -> bool: or its response couldn't be read. API errors and failures to connect are excluded.""" if isinstance(error, HyperbrowserError): + if isinstance(error.cause, json.JSONDecodeError): + return True if error.status_code is not None: # An error status is the API's answer; a success status means the # response body couldn't be read. @@ -261,4 +265,64 @@ def missing_process_id_error(sandbox_id: str) -> HyperbrowserError: def secret_wait_chunk_ms(runtime_timeout_seconds: Optional[float]) -> int: """Keep each runtime wait request well under the client's request timeout.""" timeout = runtime_timeout_seconds or 30 - return max(1_000, min(20_000, int(timeout * 1000 / 2))) + return max(1, min(20_000, int(timeout * 1000 / 2))) + + +@dataclass(frozen=True) +class SecretProcessWaitContext: + sandbox_id: str + runtime_timeout_seconds: Optional[float] + + +class SecretProcessWait: + """One local wait deadline across bounded receiver requests.""" + + def __init__( + self, + context: SecretProcessWaitContext, + timeout_ms: Optional[int], + timeout_sec: Optional[int], + timeout_error: Type[Exception] = TimeoutError, + ): + timeout = None + if timeout_sec is not None and timeout_sec > 0: + timeout = timeout_sec + elif timeout_ms is not None and timeout_ms > 0: + timeout = timeout_ms / 1000 + self.deadline = time.monotonic() + timeout if timeout is not None else None + self.chunk_ms = secret_wait_chunk_ms(context.runtime_timeout_seconds) + self.runtime_timeout_seconds = context.runtime_timeout_seconds + self.timeout_error = timeout_error + + def remaining(self) -> Optional[float]: + if self.deadline is None: + return None + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise self.timeout_error("Timed out waiting for command output") + return remaining + + def request_budget(self): + remaining = self.remaining() + if remaining is None: + return self.chunk_ms, None, None + request_timeout = remaining + if ( + self.runtime_timeout_seconds is not None + and self.runtime_timeout_seconds > 0 + ): + request_timeout = min(request_timeout, self.runtime_timeout_seconds) + # Zero selects the receiver's default 30-second wait. Keep a positive + # millisecond even when the local budget is below its wire precision. + return ( + min(self.chunk_ms, max(1, int(remaining * 1000))), + request_timeout, + remaining, + ) + + def retry_delay(self, request_started_at: float) -> float: + # A receiver normally blocks for the chunk before replying 408. Pace + # immediate replies too, including sub-millisecond request budgets. + delay = max(0.0, 0.01 - (time.monotonic() - request_started_at)) + remaining = self.remaining() + return min(delay, remaining) if remaining is not None else delay diff --git a/tests/test_secrets.py b/tests/test_secrets.py index fdbabf6a..4bcaf328 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -1,5 +1,7 @@ import base64 import json +import asyncio +import time import httpx import pytest @@ -26,6 +28,7 @@ CreateSandboxParams, CreateSecretParams, SandboxDetail, + SandboxExecParams, SandboxSnapshotSummary, StartSandboxFromSnapshotParams, ) @@ -119,6 +122,8 @@ def _respond(self, method, url, params, json_body): route = route(params, json_body) if isinstance(route, BaseException): raise route + if isinstance(route, httpx.Response): + return route status, payload = route return httpx.Response(status, json=payload, request=httpx.Request(method, url)) @@ -460,8 +465,8 @@ def __init__(self, stdin=None, waits=()): self.waits = list(waits) self.calls = [] - def request_json(self, path, *, method="GET", json_body=None, **_): - self.calls.append({"path": path, "method": method, "json": json_body}) + def request_json(self, path, *, method="GET", json_body=None, **kwargs): + self.calls.append({"path": path, "method": method, "json": json_body, **kwargs}) if path.endswith("/stdin"): if self.stdin is not None: raise self.stdin @@ -471,6 +476,8 @@ def request_json(self, path, *, method="GET", json_body=None, **_): if isinstance(step, BaseException): raise step return {"result": step} + if path.endswith("/proc_9") and method == "GET": + return {"process": AGENT_PROCESS} raise AssertionError(path) def stream_sse(self, *args, **kwargs): @@ -739,3 +746,816 @@ def test_transport_errors_keep_server_codes_and_retryable_flags(): assert error.value.code == "SECRETS_NOT_CONFIGURED" assert error.value.retryable is False + + +@pytest.mark.parametrize("method", ["wait", "result"]) +def test_secret_started_handle_waits_indefinitely_in_short_runtime_chunks(method): + runtime = RuntimeScript(waits=[_status_error(408), _status_error(408), RESULT]) + runtime._timeout = 0.5 + api = SandboxProcessesApi( + runtime, + lambda payload: {"process": AGENT_PROCESS, "secrets": RESOLVED}, + "sbx_123", + ) + handle = api.start("node app.js", secrets={"API_KEY": "provider-key"}) + + assert getattr(handle, method)().stdout == "ok\n" + assert handle.status == "exited" + assert len(runtime.calls) == 3 + assert all(call["json"]["timeoutMs"] == 250 for call in runtime.calls) + assert all(call["path"].endswith("/wait") for call in runtime.calls) + + +@pytest.mark.anyio +@pytest.mark.parametrize("method", ["wait", "result"]) +async def test_async_secret_started_handle_uses_bounded_waits(method): + runtime = AsyncRuntimeScript(waits=[_status_error(408), RESULT]) + runtime._timeout = 0.5 + + async def start(payload): + return {"process": AGENT_PROCESS, "secrets": RESOLVED} + + handle = await AsyncSandboxProcessesApi(runtime, start, "sbx_123").start( + "node app.js", secrets={"API_KEY": "provider-key"} + ) + assert (await getattr(handle, method)()).stdout == "ok\n" + assert handle.status == "exited" + assert [call["json"]["timeoutMs"] for call in runtime.calls] == [250, 250] + + +class ClockedRuntimeScript(RuntimeScript): + def __init__(self, clock): + super().__init__() + self._timeout = 0.5 + self.clock = clock + self.finished = False + + def request_json(self, path, *, method="GET", json_body=None, **kwargs): + self.calls.append({"path": path, "method": method, "json": json_body, **kwargs}) + assert path.endswith("/wait") + if self.finished: + return {"result": RESULT} + chunk_seconds = json_body["timeoutMs"] / 1000 + assert 0 < chunk_seconds < self._timeout + assert chunk_seconds <= kwargs["request_timeout"] + self.clock[0] += chunk_seconds + raise _status_error(408) + + +class AsyncClockedRuntimeScript(ClockedRuntimeScript): + async def request_json(self, path, **kwargs): + return super().request_json(path, **kwargs) + + +@pytest.mark.parametrize( + "timeouts, expected_seconds", + [({"timeout_ms": 500}, 0.5), ({"timeout_ms": 5, "timeout_sec": 1}, 1)], +) +def test_secret_handle_wait_deadline_is_total_and_handle_can_wait_again( + monkeypatch, timeouts, expected_seconds +): + clock = [0.0] + monkeypatch.setattr(time, "monotonic", lambda: clock[0]) + runtime = ClockedRuntimeScript(clock) + handle = SandboxProcessesApi( + runtime, + lambda payload: {"process": AGENT_PROCESS, "secrets": RESOLVED}, + "sbx_123", + ).start("node app.js", secrets={"API_KEY": "provider-key"}) + + with pytest.raises(TimeoutError, match="Timed out waiting"): + handle.wait(**timeouts) + + assert clock[0] == expected_seconds + assert len(runtime.calls) == int(expected_seconds / 0.25) + runtime.finished = True + assert handle.result().stdout == "ok\n" + + +@pytest.mark.anyio +async def test_async_secret_handle_total_deadline_and_handle_reuse(monkeypatch): + clock = [0.0] + monkeypatch.setattr(time, "monotonic", lambda: clock[0]) + runtime = AsyncClockedRuntimeScript(clock) + + async def start(payload): + return {"process": AGENT_PROCESS, "secrets": RESOLVED} + + handle = await AsyncSandboxProcessesApi(runtime, start, "sbx_123").start( + "node app.js", secrets={"API_KEY": "provider-key"} + ) + with pytest.raises(asyncio.TimeoutError): + await handle.wait(timeout_ms=500) + assert clock[0] == 0.5 + assert len(runtime.calls) == 2 + runtime.finished = True + assert (await handle.result()).stdout == "ok\n" + + +@pytest.mark.anyio +async def test_async_local_wait_deadline_cancels_a_stalled_request(): + cancelled = [] + + class StalledRuntime(AsyncRuntimeScript): + async def request_json(self, path, **kwargs): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled.append(path) + raise + + async def start(payload): + return {"process": AGENT_PROCESS, "secrets": RESOLVED} + + handle = await AsyncSandboxProcessesApi(StalledRuntime(), start, "sbx_123").start( + "node app.js", secrets={"API_KEY": "provider-key"} + ) + with pytest.raises(asyncio.TimeoutError): + await handle.wait(timeout_ms=10) + assert cancelled == ["/sandbox/processes/proc_9/wait"] + + +def test_reattached_ordinary_handle_keeps_its_single_wait_request(): + runtime = RuntimeScript(waits=[_status_error(408), RESULT]) + handle = SandboxProcessesApi(runtime).get("proc_9") + with pytest.raises(HyperbrowserError) as error: + handle.wait(timeout_ms=123, timeout_sec=2) + assert error.value.status_code == 408 + assert runtime.calls[-1]["json"] == {"timeoutMs": 123, "timeout_sec": 2} + assert len(runtime.waits) == 1 + + +@pytest.mark.anyio +async def test_async_reattached_handle_keeps_its_single_wait_request(): + runtime = AsyncRuntimeScript(waits=[_status_error(408), RESULT]) + handle = await AsyncSandboxProcessesApi(runtime).get("proc_9") + with pytest.raises(HyperbrowserError) as error: + await handle.wait() + assert error.value.status_code == 408 + assert runtime.calls[-1]["json"] == {"timeoutMs": None, "timeout_sec": None} + assert len(runtime.waits) == 1 + + +@pytest.mark.parametrize("request_type", [dict, SandboxExecParams]) +@pytest.mark.parametrize( + "fields, overrides, expected", + [ + ({"timeout_ms": 0}, {"timeout_sec": 1}, 1000), + ({"timeout_ms": 2000, "timeout_sec": 1}, {}, 1000), + ({"timeout_ms": 2000, "timeout_sec": 0}, {}, 2000), + ({"timeout_ms": 0, "timeout_sec": -1}, {}, 0), + ({}, {}, None), + ], +) +def test_secret_execution_timeout_matches_receiver_precedence( + request_type, fields, overrides, expected +): + starts = [] + + def start(payload): + starts.append(payload) + return {"process": AGENT_PROCESS, "secrets": RESOLVED} + + SandboxProcessesApi(RuntimeScript(), start, "sbx_123").start( + request_type(command="sleep 2", **fields), + secrets={"API_KEY": "provider-key"}, + **overrides, + ) + assert starts[0].get("timeoutMs") == expected + + +@pytest.mark.anyio +@pytest.mark.parametrize("request_type", [dict, SandboxExecParams]) +async def test_async_secret_timeout_seconds_override_zero_ms(request_type): + starts = [] + + async def start(payload): + starts.append(payload) + return {"process": AGENT_PROCESS, "secrets": RESOLVED} + + await AsyncSandboxProcessesApi(AsyncRuntimeScript(), start, "sbx_123").start( + request_type(command="sleep 2", timeout_ms=0), + timeout_sec=1, + secrets={"API_KEY": "provider-key"}, + ) + assert starts[0]["timeoutMs"] == 1000 + + +@pytest.mark.parametrize("status", [200, 502, 503]) +@pytest.mark.parametrize("body", [b"Gateway error", b'{"message":', b""]) +def test_undecodable_secret_start_is_nonretryable_unknown_outcome(status, body): + response = httpx.Response( + status, content=body, request=httpx.Request("POST", "https://api.example.com") + ) + manager, http = _sandbox_manager({("POST", "/sandbox/sbx_123/processes"): response}) + with pytest.raises(HyperbrowserError) as error: + manager.start_secret_process("sbx_123", {"command": "x"}) + assert error.value.code == "secret_process_outcome_unknown" + assert error.value.retryable is False + assert error.value.details == {"sandboxId": "sbx_123"} + assert len(http.calls) == 1 + + +@pytest.mark.anyio +@pytest.mark.parametrize("body", [b"Gateway error", b'{"message":']) +async def test_async_undecodable_gateway_secret_start_has_unknown_outcome(body): + response = httpx.Response( + 502, content=body, request=httpx.Request("POST", "https://api.example.com") + ) + manager, http = _sandbox_manager( + {("POST", "/sandbox/sbx_123/processes"): response}, async_client=True + ) + with pytest.raises(HyperbrowserError) as error: + await manager.start_secret_process("sbx_123", {"command": "x"}) + assert error.value.code == "secret_process_outcome_unknown" + assert error.value.retryable is False + assert isinstance(error.value.cause.cause, json.JSONDecodeError) + assert len(http.calls) == 1 + + +@pytest.mark.parametrize("retryable", [True, False]) +def test_decoded_secret_start_error_keeps_server_retryable_flag(retryable): + manager, http = _sandbox_manager( + { + ("POST", "/sandbox/sbx_123/processes"): ( + 502, + {"code": "START_FAILED", "message": "x", "retryable": retryable}, + ) + } + ) + with pytest.raises(HyperbrowserError) as error: + manager.start_secret_process("sbx_123", {"command": "x"}) + assert error.value.code == "START_FAILED" + assert error.value.retryable is retryable + assert len(http.calls) == 1 + + +@pytest.mark.anyio +@pytest.mark.parametrize("stdin", [_status_error(409), _status_error(502)]) +async def test_async_secret_exec_closes_stdin_and_preserves_process_on_failure(stdin): + runtime = AsyncRuntimeScript(stdin=stdin, waits=[_status_error(502)]) + + async def start(payload): + return {"process": AGENT_PROCESS, "secrets": RESOLVED} + + with pytest.raises(HyperbrowserError) as error: + await AsyncSandboxProcessesApi(runtime, start, "sbx_123").exec( + "node app.js", secrets={"API_KEY": "provider-key"} + ) + assert error.value.code == "secret_process_failed" + assert error.value.retryable is False + assert error.value.details == {"processId": "proc_9", "sandboxId": "sbx_123"} + assert len(runtime.calls) == (2 if stdin.status_code == 409 else 1) + + +def test_secret_wait_handles_submillisecond_timeout_without_zero_or_busy_retry( + monkeypatch, +): + clock = [0.0] + monkeypatch.setattr(time, "monotonic", lambda: clock[0]) + pauses = [] + + def pause(seconds): + pauses.append(seconds) + clock[0] += seconds + + monkeypatch.setattr(time, "sleep", pause) + runtime = RuntimeScript(waits=[_status_error(408)] * 4) + runtime._timeout = 0.0005 + handle = SandboxProcessesApi( + runtime, + lambda payload: {"process": AGENT_PROCESS, "secrets": RESOLVED}, + "sbx_123", + ).start("node app.js", secrets={"API_KEY": "provider-key"}) + with pytest.raises(TimeoutError): + handle.wait(timeout_ms=1) + assert len(runtime.calls) == 1 + assert runtime.calls[0]["json"]["timeoutMs"] == 1 + assert runtime.calls[0]["request_timeout"] == 0.0005 + assert pauses == [0.001] + + +@pytest.mark.parametrize("method", ["wait", "result"]) +def test_secret_handle_wait_errors_retain_running_process_identifiers(method): + runtime = RuntimeScript(waits=[_status_error(502)]) + handle = SandboxProcessesApi( + runtime, + lambda payload: {"process": AGENT_PROCESS, "secrets": RESOLVED}, + "sbx_123", + ).start("node app.js", secrets={"API_KEY": "provider-key"}) + with pytest.raises(HyperbrowserError) as error: + getattr(handle, method)() + assert error.value.code == "secret_process_failed" + assert error.value.details == {"processId": "proc_9", "sandboxId": "sbx_123"} + assert error.value.retryable is False + + +@pytest.mark.parametrize("service", ["control", "runtime"]) +def test_malformed_http_error_preserves_generic_error_parsing(service): + response = httpx.Response( + 502, + content=b"Gateway error", + headers={"x-request-id": "req_9"}, + request=httpx.Request("POST", "https://api.example.com"), + ) + with pytest.raises(HyperbrowserError) as error: + ensure_response_ok(response, service) + assert error.value.status_code == 502 + assert str(error.value).startswith("Gateway error") + assert error.value.details == "Gateway error" + assert error.value.code is None + assert error.value.retryable is True + assert error.value.request_id == "req_9" + assert isinstance(error.value.cause, json.JSONDecodeError) + + +@pytest.mark.parametrize("trickle", ["headers", "body"]) +def test_sync_secret_wait_deadline_interrupts_trickled_http_response(trickle): + import socket + import threading + + from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_transport import ( + RuntimeTransport, + ) + from hyperbrowser.sandbox_common import RuntimeConnection + + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen() + listener.settimeout(2) + port = listener.getsockname()[1] + stop = threading.Event() + requests = [] + + def serve(): + try: + connection, _ = listener.accept() + with connection: + connection.settimeout(2) + request = b"" + while b"\r\n\r\n" not in request: + request += connection.recv(4096) + requests.append(request) + body = json.dumps({"result": RESULT}).encode() + headers = ( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + + b"Content-Length: " + + str(len(body)).encode() + + b"\r\nConnection: close\r\n\r\n" + ) + if trickle == "headers": + connection.sendall(headers[:-2]) + for _ in range(50): + if stop.wait(0.025): + return + connection.sendall(b" ") + connection.sendall(b"\r\n" + body) + else: + connection.sendall(headers) + for byte in body: + connection.sendall(bytes([byte])) + if stop.wait(0.025): + return + except (OSError, TimeoutError): + pass + + server = threading.Thread(target=serve) + server.start() + runtime = RuntimeTransport( + lambda force: RuntimeConnection("sbx_123", f"http://127.0.0.1:{port}", "token"), + timeout=2, + ) + handle = SandboxProcessesApi( + runtime, + lambda payload: {"process": AGENT_PROCESS, "secrets": RESOLVED}, + "sbx_123", + ).start("node app.js", secrets={"API_KEY": "provider-key"}) + started_at = time.monotonic() + try: + with pytest.raises(TimeoutError): + handle.wait(timeout_ms=200) + elapsed = time.monotonic() - started_at + assert 0.15 <= elapsed < 0.45 + assert len(requests) == 1 + assert handle.status == "running" + # Timers are joined before wait returns, so no cancellation can leak + # into a subsequent call or another connection. + assert not any(isinstance(t, threading.Timer) for t in threading.enumerate()) + finally: + stop.set() + listener.close() + server.join(timeout=3) + assert not server.is_alive() + + +@pytest.mark.parametrize( + "payload", + [ + {"process": {"id": "proc_9"}, "secrets": RESOLVED}, + {"process": AGENT_PROCESS, "secrets": [{"env": "API_KEY"}]}, + ], +) +def test_secret_start_preserves_process_id_when_response_metadata_is_invalid(payload): + api = SandboxProcessesApi(RuntimeScript(), lambda body: payload, "sbx_123") + with pytest.raises(HyperbrowserError) as error: + api.start("node app.js", secrets={"API_KEY": "provider-key"}) + assert error.value.code == "secret_process_failed" + assert error.value.details == {"processId": "proc_9", "sandboxId": "sbx_123"} + assert error.value.retryable is False + + +@pytest.mark.anyio +async def test_async_secret_start_keeps_process_id_on_invalid_metadata(): + async def start(payload): + return {"process": AGENT_PROCESS, "secrets": [{"env": "API_KEY"}]} + + api = AsyncSandboxProcessesApi(AsyncRuntimeScript(), start, "sbx_123") + with pytest.raises(HyperbrowserError) as error: + await api.start("node app.js", secrets={"API_KEY": "provider-key"}) + assert error.value.code == "secret_process_failed" + assert error.value.details == {"processId": "proc_9", "sandboxId": "sbx_123"} + assert error.value.retryable is False + + +@pytest.mark.anyio +@pytest.mark.parametrize("retryable", [True, False]) +async def test_async_decoded_secret_start_error_keeps_server_retryable_flag(retryable): + manager, http = _sandbox_manager( + { + ("POST", "/sandbox/sbx_123/processes"): ( + 502, + {"code": "START_FAILED", "message": "x", "retryable": retryable}, + ) + }, + async_client=True, + ) + with pytest.raises(HyperbrowserError) as error: + await manager.start_secret_process("sbx_123", {"command": "x"}) + assert error.value.code == "START_FAILED" + assert error.value.retryable is retryable + assert len(http.calls) == 1 + + +@pytest.mark.parametrize( + "refresh, response_mode", + [ + ("expired", "delayed_headers"), + ("401", "delayed_headers"), + ("expired", "trickled_body"), + ("401", "trickled_body"), + ("expired", "eager_hook"), + ], +) +def test_sync_secret_wait_bounds_sdk_control_token_refresh(refresh, response_mode): + import threading + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + from hyperbrowser import Hyperbrowser + + stop = threading.Event() + requests = [] + unrelated = [] + unrelated_started = threading.Event() + unrelated_thread = None + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def respond(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + self.wfile.flush() + + def do_POST(self): + self.rfile.read(int(self.headers.get("content-length", "0"))) + requests.append(("POST", self.path, dict(self.headers))) + if self.path == "/api/unrelated": + unrelated_started.set() + if not stop.wait(0.3): + self.respond(200, {"success": True}) + elif self.path == "/api/sandbox/sbx_123/processes": + self.respond(200, {"process": AGENT_PROCESS, "secrets": RESOLVED}) + else: + self.respond(401, {"error": "expired"}) + + def do_GET(self): + requests.append(("GET", self.path, dict(self.headers))) + try: + if response_mode == "eager_hook": + self.respond(200, SANDBOX_DETAIL_PAYLOAD) + elif response_mode == "delayed_headers": + if not stop.wait(0.5): + self.respond(200, SANDBOX_DETAIL_PAYLOAD) + else: + body = json.dumps(SANDBOX_DETAIL_PAYLOAD).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + for byte in body: + self.wfile.write(bytes([byte])) + self.wfile.flush() + if stop.wait(0.025): + return + except OSError: + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server_thread = threading.Thread( + target=server.serve_forever, kwargs={"poll_interval": 0.01} + ) + server_thread.start() + base_url = f"http://127.0.0.1:{server.server_port}" + client = Hyperbrowser(api_key="test-key", base_url=base_url) + client.transport.client.headers["x-client-setting"] = "preserved" + client.transport.client.cookies.set("sessionhint", "preserved") + if response_mode == "eager_hook": + + def eager_hook(response): + nonlocal unrelated_thread + if response.request.method != "GET": + return + response.read() + + def post_unrelated(): + try: + unrelated.append( + client.transport.client.post( + base_url + "/api/unrelated" + ).status_code + ) + except Exception as error: + unrelated.append(error) + + unrelated_thread = threading.Thread(target=post_unrelated) + unrelated_thread.start() + assert unrelated_started.wait(2) + time.sleep(0.35) + + client.transport.client.event_hooks["response"].append(eager_hook) + detail = SandboxDetail.model_validate( + { + **SANDBOX_DETAIL_PAYLOAD, + "runtime": { + "transport": "regional_proxy", + "host": base_url, + "baseUrl": base_url + "/sandbox/sbx_123", + }, + "tokenExpiresAt": "2099-01-01T00:00:00Z" + if refresh == "401" + else "2000-01-01T00:00:00Z", + } + ) + handle = client.sandboxes.attach(detail).processes.start( + "node app.js", secrets={"API_KEY": "provider-key"} + ) + started_at = time.monotonic() + try: + with pytest.raises(TimeoutError): + handle.wait(timeout_ms=200) + elapsed = time.monotonic() - started_at + if response_mode == "eager_hook": + assert 0.3 <= elapsed < 0.8 + assert unrelated == [200] + else: + assert 0.15 <= elapsed < 0.45 + gets = [request for request in requests if request[0] == "GET"] + assert len(gets) == 1 + assert gets[0][1] == "/api/sandbox/sbx_123" + assert gets[0][2]["x-api-key"] == "test-key" + assert gets[0][2]["x-client-setting"] == "preserved" + assert "sessionhint=preserved" in gets[0][2]["Cookie"] + assert len( + [request for request in requests if request[1].endswith("/wait")] + ) == (1 if refresh == "401" else 0) + assert not any(isinstance(t, threading.Timer) for t in threading.enumerate()) + assert not client.transport.client.is_closed + finally: + stop.set() + if unrelated_thread is not None: + unrelated_thread.join(timeout=3) + client.close() + server.shutdown() + server.server_close() + server_thread.join(timeout=3) + assert not server_thread.is_alive() + + +def test_deadline_refresh_preserves_custom_control_transport(monkeypatch): + from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_transport import ( + RuntimeTransport, + ) + + calls = [] + + def respond(request): + calls.append(request) + if request.method == "POST": + return httpx.Response( + 200, json={"process": AGENT_PROCESS, "secrets": RESOLVED} + ) + return httpx.Response( + 200, + json={**SANDBOX_DETAIL_PAYLOAD, "tokenExpiresAt": "2099-01-01T00:00:00Z"}, + ) + + http = httpx.Client( + transport=httpx.MockTransport(respond), headers={"x-custom": "kept"} + ) + manager = SandboxManager(FakeClient(http)) + handle = manager.attach(SandboxDetail(**SANDBOX_DETAIL_PAYLOAD)).processes.start( + "node app.js", secrets={"API_KEY": "provider-key"} + ) + runtime_calls = [] + + def runtime_send(self, connection, path, **kwargs): + runtime_calls.append((connection, kwargs)) + return httpx.Response(200, json={"result": RESULT}) + + monkeypatch.setattr(RuntimeTransport, "_send", runtime_send) + try: + assert handle.wait(timeout_ms=500).stdout == "ok\n" + assert [request.method for request in calls] == ["POST", "GET"] + assert calls[-1].headers["x-custom"] == "kept" + assert 0 < calls[-1].extensions["timeout"]["read"] <= 0.5 + assert runtime_calls[0][1]["request_deadline"] is not None + assert not http.is_closed + finally: + http.close() + + +def test_shared_http2_deadline_does_not_shutdown_multiplexed_socket(): + from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_transport import ( + _RequestDeadline, + ) + + shutdowns = [] + + class Socket: + def shutdown(self, how): + shutdowns.append(how) + + sock = Socket() + + class TLS: + def selected_alpn_protocol(self): + return "h2" + + class Stream: + def get_extra_info(self, key): + return {"socket": sock, "ssl_object": TLS()}.get(key) + + budget = _RequestDeadline(time.monotonic() + 1, shared_connection=True) + try: + budget.capture_stream(Stream()) + budget._expire() + assert shutdowns == [] + finally: + budget.close() + + +def test_deadline_cleanup_trace_never_raises_or_captures_released_socket(): + from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_transport import ( + _RequestDeadline, + ) + + shutdowns = [] + + class Stream: + def get_extra_info(self, key): + if key == "socket": + return self + return None + + def shutdown(self, how): + shutdowns.append(how) + + budget = _RequestDeadline(time.monotonic() + 1) + budget.capture_stream(Stream()) + budget.trace("http11.receive_response_body.complete", {}) + budget.deadline = time.monotonic() - 1 + budget.trace("http11.response_closed.started", {}) + budget.trace("http11.response_closed.complete", {}) + budget.capture_stream(Stream()) + budget._expire() + assert shutdowns == [] + + +@pytest.mark.parametrize("control_flow", [KeyboardInterrupt, SystemExit]) +@pytest.mark.parametrize("with_deadline", [False, True]) +def test_control_refresh_preserves_control_flow_after_deadline( + monkeypatch, control_flow, with_deadline +): + clock = [0.0] + monkeypatch.setattr(time, "monotonic", lambda: clock[0]) + + def interrupt(response): + clock[0] = 2.0 + raise control_flow("stop") + + http = httpx.Client( + transport=httpx.MockTransport(lambda request: httpx.Response(200, json={})), + event_hooks={"response": [interrupt]}, + ) + manager = SandboxManager(FakeClient(http)) + try: + with pytest.raises(control_flow, match="stop"): + manager._request( + "GET", + "/sandbox/sbx_123", + request_deadline=1.0 if with_deadline else None, + ) + finally: + http.close() + + +@pytest.mark.parametrize("method", ["wait", "result", "exec"]) +def test_default_secret_wait_timeout_error_retains_process_identifiers(method): + runtime = RuntimeScript(waits=[TimeoutError("transport timed out")]) + api = SandboxProcessesApi( + runtime, + lambda payload: {"process": AGENT_PROCESS, "secrets": RESOLVED}, + "sbx_123", + ) + with pytest.raises(HyperbrowserError) as error: + if method == "exec": + api.exec("node app.js", secrets={"API_KEY": "provider-key"}) + else: + handle = api.start("node app.js", secrets={"API_KEY": "provider-key"}) + getattr(handle, method)() + assert error.value.code == "secret_process_failed" + assert error.value.details == {"processId": "proc_9", "sandboxId": "sbx_123"} + assert error.value.retryable is False + assert isinstance(error.value.cause, TimeoutError) + + +@pytest.mark.anyio +@pytest.mark.parametrize("method", ["wait", "result", "exec"]) +async def test_async_default_secret_wait_timeout_retains_identifiers( + monkeypatch, method +): + class DistinctAsyncTimeoutError(Exception): + pass + + monkeypatch.setattr(asyncio, "TimeoutError", DistinctAsyncTimeoutError) + runtime = AsyncRuntimeScript( + waits=[DistinctAsyncTimeoutError("transport timed out")] + ) + + async def start(payload): + return {"process": AGENT_PROCESS, "secrets": RESOLVED} + + api = AsyncSandboxProcessesApi(runtime, start, "sbx_123") + with pytest.raises(HyperbrowserError) as error: + if method == "exec": + await api.exec("node app.js", secrets={"API_KEY": "provider-key"}) + else: + handle = await api.start("node app.js", secrets={"API_KEY": "provider-key"}) + await getattr(handle, method)() + assert error.value.code == "secret_process_failed" + assert error.value.details == {"processId": "proc_9", "sandboxId": "sbx_123"} + assert error.value.retryable is False + assert isinstance(error.value.cause, DistinctAsyncTimeoutError) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "expiration", ["before_request", "receiver_timeout", "stalled_request"] +) +async def test_async_deadline_preserves_distinct_asyncio_timeout_error( + monkeypatch, expiration +): + class DistinctAsyncTimeoutError(Exception): + pass + + monkeypatch.setattr(asyncio, "TimeoutError", DistinctAsyncTimeoutError) + clock = [0.0] + runtime = AsyncClockedRuntimeScript(clock) + if expiration == "before_request": + clock_reads = iter([0.0, 1.0]) + monkeypatch.setattr(time, "monotonic", lambda: next(clock_reads, 1.0)) + elif expiration == "receiver_timeout": + monkeypatch.setattr(time, "monotonic", lambda: clock[0]) + else: + + class StalledRuntime(AsyncRuntimeScript): + async def request_json(self, path, **kwargs): + await asyncio.sleep(10) + + runtime = StalledRuntime() + + async def start(payload): + return {"process": AGENT_PROCESS, "secrets": RESOLVED} + + handle = await AsyncSandboxProcessesApi(runtime, start, "sbx_123").start( + "node app.js", secrets={"API_KEY": "provider-key"} + ) + with pytest.raises(DistinctAsyncTimeoutError): + await handle.wait(timeout_ms=10 if expiration == "stalled_request" else 500) From 97c3418a0f6b6b0558008e6ba6ff01e18f913ba1 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Sun, 11 Oct 2026 22:07:02 -0700 Subject: [PATCH 3/5] Reconcile secrets with the hx CLI and current server - Treat only 8-4-4-4-12 keys as secret IDs, so UUID-like names fall back to the name search instead of failing on the server's 400. - A sandbox create with secrets whose outcome is unknown raises a non-retryable secret_process_outcome_unknown: a sandbox may be running its startup command with the secrets. - Normalize the server's SECRET_PROCESS_OUTCOME_UNKNOWN to the documented code, keeping the server body in details. - Add the sandbox and secret context to control error messages, as the CLI does. - Without a server retryable flag, SECRETS_NOT_CONFIGURED and other codes retrying can't fix are not retryable. - Reject use_shell with secrets, accept processId in start responses, and check secret names, env names, versions and list bounds locally. - Document SECRETS_ADMIN_REQUIRED, SECRETS_FEATURE_DISABLED, SECRETS_NOT_CONFIGURED and SECRET_STARTUP_UNSUPPORTED. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 +- .../client/managers/async_manager/sandbox.py | 32 +- .../sandboxes/sandbox_processes.py | 5 +- .../client/managers/async_manager/secret.py | 2 + .../client/managers/sandboxes/shared.py | 6 + .../client/managers/sync_manager/sandbox.py | 32 +- .../sandboxes/sandbox_processes.py | 5 +- .../client/managers/sync_manager/secret.py | 2 + hyperbrowser/models/secret.py | 56 ++- hyperbrowser/sandbox_common.py | 46 ++- hyperbrowser/secrets_common.py | 75 +++- hyperbrowser/transport/async_transport.py | 2 + hyperbrowser/transport/sync.py | 2 + tests/test_secrets.py | 342 ++++++++++++++++++ 14 files changed, 585 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index def6d529..4df7c81c 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,8 @@ Things to know: - Deleting a secret or version doesn't revoke a value already delivered to a sandbox. - Limits: 100 secrets per team, 10 versions per secret, 32 secrets per process, - and values of 1 byte to 32 KiB of UTF-8 without NUL bytes. + and values of 1 byte to 32 KiB of UTF-8 without NUL bytes. Names are 1-128 + letters, digits, `_` or `-`, starting with a letter or digit. - Reading secrets needs `secrets.read`; delivering them to a sandbox needs `secrets.use`. Creating a secret needs `secrets.manage`. Updating, rotating, and deleting secrets or versions through the SDK need both `secrets.read` @@ -333,9 +334,28 @@ Things to know: For strict cancellation timing, use the async client: blocking DNS, custom transports or hooks, shared HTTP/2 control connections, and trickled headers on a reused control connection can delay cancellation in the sync client. -- A process start with secrets that times out raises an error with code +- A process start with secrets that times out, or whose result the API can't + confirm, raises a non-retryable error with code `secret_process_outcome_unknown`: the command may be running, so check - `sandbox.processes.list()` before retrying. + `sandbox.processes.list()` before retrying. When the API reported the + unknown outcome itself, its response body, including its own code + (`SECRET_PROCESS_OUTCOME_UNKNOWN`), is in the error's `details`. +- `client.sandboxes.create()` with `secrets` raises the same + `secret_process_outcome_unknown` error when the create may have reached the + API but its result is unknown (a timeout, a dropped connection, or an + unreadable response): a sandbox may be running its startup command with the + secrets, so list sandboxes before retrying. +- Error codes from the API: + - `SECRETS_ADMIN_REQUIRED` (403): the request used an `hx auth login` + session of a team member who isn't an owner or admin. API keys are + governed by their scopes instead. + - `SECRETS_FEATURE_DISABLED` (403): the team doesn't have the secrets + feature. + - `SECRETS_NOT_CONFIGURED` (503): the platform has no secrets key + configured. It isn't retried. + - `SECRET_STARTUP_UNSUPPORTED` (400): the image has no startup command to + deliver `secrets` to. Create the sandbox without them, then pass them to + `sandbox.exec()` or `sandbox.processes.start()`. ### List sandboxes with filters diff --git a/hyperbrowser/client/managers/async_manager/sandbox.py b/hyperbrowser/client/managers/async_manager/sandbox.py index 7e3bb8bc..ad9ee4e9 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -44,7 +44,9 @@ from ....secrets_common import ( missing_process_id_error, request_may_have_arrived, - secret_process_outcome_unknown_error, + sandbox_create_outcome_unknown_error, + secret_process_id, + secret_process_start_error, ) from ....types import ( CompleteSandboxImageBuildParams as CompleteSandboxImageBuildParamsDict, @@ -1163,26 +1165,28 @@ async def start_secret_process( "POST", f"/sandbox/{sandbox_id}/processes", data=payload ) except HyperbrowserError as error: - # A start that timed out, or whose response couldn't be read, may - # be running with the secrets, so it must not look retryable. - if request_may_have_arrived(error): - raise secret_process_outcome_unknown_error(sandbox_id, error) - raise + raise secret_process_start_error(sandbox_id, error) process = response.get("process") if isinstance(response, dict) else None - if not isinstance(process, dict) or not str(process.get("id") or "").strip(): + process_id = secret_process_id(process) + if not process_id: raise missing_process_id_error(sandbox_id) - return response + return dict(response, process=dict(process, id=process_id)) async def _create_detail( self, params: Union[CreateSandboxParamsDict, CreateSandboxParams], ) -> SandboxDetail: - payload = await self._request( - "POST", - "/sandbox", - data=dump_request(params, CreateSandboxParams), - ) - return SandboxDetail(**payload) + normalized = coerce_request(params, CreateSandboxParams) + data = dump_request(normalized, CreateSandboxParams) + try: + payload = await self._request("POST", "/sandbox", data=data) + return SandboxDetail(**payload) + except Exception as error: + # A create that may have reached the API may be running its + # startup command with the secrets, so it must not look retryable. + if normalized.secrets and request_may_have_arrived(error): + raise sandbox_create_outcome_unknown_error(error) + raise async def _request( self, diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py index 512ac066..e3a62232 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py @@ -19,6 +19,7 @@ SecretProcessWait, SecretProcessWaitContext, secret_process_failed_error, + secret_process_id, ) from .....types import ( SandboxExecParams as SandboxExecParamsDict, @@ -431,11 +432,11 @@ async def _start_with_secrets( code="secrets_unsupported", ) payload = await self._start_secret_process(_secret_process_payload(params)) - process_id = str(payload["process"]["id"]) + process_id = secret_process_id(payload["process"]) try: handle = SandboxProcessHandle( self._transport, - SandboxProcessSummary(**payload["process"]), + SandboxProcessSummary(**dict(payload["process"], id=process_id)), SecretProcessWaitContext( self._sandbox_id or "", getattr(self._transport, "_timeout", None) ), diff --git a/hyperbrowser/client/managers/async_manager/secret.py b/hyperbrowser/client/managers/async_manager/secret.py index ce64c255..970ab080 100644 --- a/hyperbrowser/client/managers/async_manager/secret.py +++ b/hyperbrowser/client/managers/async_manager/secret.py @@ -26,6 +26,7 @@ from ....secrets_common import ( SECRET_NAME_LOOKUP_LIMIT, SecretsSealer, + check_secret_version, is_not_found, looks_like_secret_id, secret_not_found_error, @@ -134,6 +135,7 @@ async def delete_version( self, secret: str, version: int ) -> SecretVersionDeleteResult: """Delete one version. The latest version can't be deleted.""" + check_secret_version(version) secret_id = (await self._resolve(secret))[0].id payload = await self._request( "DELETE", f"/secrets/{secret_id}/versions/{version}" diff --git a/hyperbrowser/client/managers/sandboxes/shared.py b/hyperbrowser/client/managers/sandboxes/shared.py index 80c224a2..14a131b7 100644 --- a/hyperbrowser/client/managers/sandboxes/shared.py +++ b/hyperbrowser/client/managers/sandboxes/shared.py @@ -118,6 +118,12 @@ def _normalize_exec_params( ).secrets normalized = params.model_copy(update=updates) if updates else params + if normalized.secrets and normalized.use_shell is not None: + # The control API's start has no such option; don't drop it silently. + raise HyperbrowserError( + "use_shell is not supported when starting a process with secrets", + code="secrets_unsupported", + ) return _normalize_legacy_process_fields(normalized) diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index 08cd7d1a..6e9be2d9 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -44,7 +44,9 @@ from ....secrets_common import ( missing_process_id_error, request_may_have_arrived, - secret_process_outcome_unknown_error, + sandbox_create_outcome_unknown_error, + secret_process_id, + secret_process_start_error, ) from ....types import ( CompleteSandboxImageBuildParams as CompleteSandboxImageBuildParamsDict, @@ -1171,26 +1173,28 @@ def start_secret_process(self, sandbox_id: str, payload: Dict[str, object]) -> d "POST", f"/sandbox/{sandbox_id}/processes", data=payload ) except HyperbrowserError as error: - # A start that timed out, or whose response couldn't be read, may - # be running with the secrets, so it must not look retryable. - if request_may_have_arrived(error): - raise secret_process_outcome_unknown_error(sandbox_id, error) - raise + raise secret_process_start_error(sandbox_id, error) process = response.get("process") if isinstance(response, dict) else None - if not isinstance(process, dict) or not str(process.get("id") or "").strip(): + process_id = secret_process_id(process) + if not process_id: raise missing_process_id_error(sandbox_id) - return response + return dict(response, process=dict(process, id=process_id)) def _create_detail( self, params: Union[CreateSandboxParamsDict, CreateSandboxParams], ) -> SandboxDetail: - payload = self._request( - "POST", - "/sandbox", - data=dump_request(params, CreateSandboxParams), - ) - return SandboxDetail(**payload) + normalized = coerce_request(params, CreateSandboxParams) + data = dump_request(normalized, CreateSandboxParams) + try: + payload = self._request("POST", "/sandbox", data=data) + return SandboxDetail(**payload) + except Exception as error: + # A create that may have reached the API may be running its + # startup command with the secrets, so it must not look retryable. + if normalized.secrets and request_may_have_arrived(error): + raise sandbox_create_outcome_unknown_error(error) + raise def _request( self, diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py index 9d364ada..af0e70d6 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py @@ -19,6 +19,7 @@ SecretProcessWait, SecretProcessWaitContext, secret_process_failed_error, + secret_process_id, ) from .....types import ( SandboxExecParams as SandboxExecParamsDict, @@ -435,11 +436,11 @@ def _start_with_secrets(self, params: SandboxExecParams) -> SandboxProcessHandle code="secrets_unsupported", ) payload = self._start_secret_process(_secret_process_payload(params)) - process_id = str(payload["process"]["id"]) + process_id = secret_process_id(payload["process"]) try: handle = SandboxProcessHandle( self._transport, - SandboxProcessSummary(**payload["process"]), + SandboxProcessSummary(**dict(payload["process"], id=process_id)), SecretProcessWaitContext( self._sandbox_id or "", getattr(self._transport, "_timeout", None) ), diff --git a/hyperbrowser/client/managers/sync_manager/secret.py b/hyperbrowser/client/managers/sync_manager/secret.py index 64b381b9..7895e9be 100644 --- a/hyperbrowser/client/managers/sync_manager/secret.py +++ b/hyperbrowser/client/managers/sync_manager/secret.py @@ -26,6 +26,7 @@ from ....secrets_common import ( SECRET_NAME_LOOKUP_LIMIT, SecretsSealer, + check_secret_version, is_not_found, looks_like_secret_id, secret_not_found_error, @@ -128,6 +129,7 @@ def delete(self, secret: str) -> SecretDeleteResult: def delete_version(self, secret: str, version: int) -> SecretVersionDeleteResult: """Delete one version. The latest version can't be deleted.""" + check_secret_version(version) secret_id = self._resolve(secret)[0].id payload = self._request("DELETE", f"/secrets/{secret_id}/versions/{version}") return SecretVersionDeleteResult(**payload) diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py index 55ca6dc6..1bb4438f 100644 --- a/hyperbrowser/models/secret.py +++ b/hyperbrowser/models/secret.py @@ -1,7 +1,31 @@ +import re from datetime import datetime from typing import List, Literal, Optional, Union -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator + +# The server's rules for secret names, the environment variables secrets are +# delivered to, and version numbers. +SECRET_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$") +SECRET_ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +MAX_SECRET_VERSION = 2**31 - 1 + + +def validate_secret_name(name: str) -> str: + if not SECRET_NAME_PATTERN.match(name): + raise ValueError( + f"Invalid secret name {name!r}: names are 1-128 letters, digits, " + "'_' or '-', starting with a letter or digit" + ) + return name + + +def validate_secret_version(version: int) -> int: + if isinstance(version, bool) or not 1 <= version <= MAX_SECRET_VERSION: + raise ValueError( + f"Invalid secret version {version!r}: expected 1 to {MAX_SECRET_VERSION}" + ) + return version class SecretBaseModel(BaseModel): @@ -17,14 +41,33 @@ class SecretReference(SecretBaseModel): name: str version: Optional[Union[int, Literal["latest"]]] = None + @field_validator("name") + @classmethod + def validate_name(cls, value): + return validate_secret_name(value) + + @field_validator("version") + @classmethod + def validate_version(cls, value): + if isinstance(value, int): + validate_secret_version(value) + return value + SecretReferenceInput = Union[str, SecretReference] def normalize_secret_references(value): - """Accept a plain secret name as shorthand for ``{"name": name}``.""" + """Accept a plain secret name as shorthand for ``{"name": name}``, and + check the environment variable names.""" if not isinstance(value, dict): return value + for env in value: + if not isinstance(env, str) or not SECRET_ENV_NAME_PATTERN.match(env): + raise ValueError( + f"Invalid environment variable name {env!r} for a secret: " + "expected letters, digits and '_', not starting with a digit" + ) return { env: {"name": reference} if isinstance(reference, str) else reference for env, reference in value.items() @@ -37,14 +80,19 @@ class CreateSecretParams(SecretBaseModel): # Encrypted locally and uploaded as the first version; never sent as is. value: Optional[Union[str, bytes]] = Field(default=None, exclude=True) + @field_validator("name") + @classmethod + def validate_name(cls, value): + return validate_secret_name(value) + class UpdateSecretParams(SecretBaseModel): description: str class SecretListParams(SecretBaseModel): - page: Optional[int] = None - limit: Optional[int] = None + page: Optional[int] = Field(default=None, ge=1) + limit: Optional[int] = Field(default=None, ge=1, le=100) search: Optional[str] = None diff --git a/hyperbrowser/sandbox_common.py b/hyperbrowser/sandbox_common.py index 1493cfb7..75217ef6 100644 --- a/hyperbrowser/sandbox_common.py +++ b/hyperbrowser/sandbox_common.py @@ -14,6 +14,18 @@ GET_RETRY_INITIAL_DELAY_SECONDS = 0.25 GET_RETRY_MAX_DELAY_SECONDS = 1.0 RUNTIME_SESSION_REFRESH_BUFFER_MS = 60_000 +# Codes that retrying can't fix when the server sends no retryable flag. The +# server sends SECRETS_NOT_CONFIGURED as a 503 without one. +NON_RETRYABLE_ERROR_CODES = frozenset( + { + "SECRETS_ADMIN_REQUIRED", + "SECRETS_NOT_CONFIGURED", + "SECRET_KEY_UNAVAILABLE", + "SECRET_DECRYPT_FAILED", + "SECRET_ENVELOPE_INVALID", + "SECRET_VALUE_INVALID", + } +) @dataclass(frozen=True) @@ -95,12 +107,42 @@ def error_payload_code(payload: Any) -> Optional[str]: def error_payload_retryable(payload: Any, status_code: int) -> bool: - """Prefer the server's ``retryable`` flag over the status-code default.""" + """Prefer the server's ``retryable`` flag over the status-code default. + + Without a flag, codes that retrying can't fix are not retryable. + """ if isinstance(payload, dict) and isinstance(payload.get("retryable"), bool): return payload["retryable"] + if error_payload_code(payload) in NON_RETRYABLE_ERROR_CODES: + return False return status_code in RETRYABLE_STATUS_CODES +def error_payload_message(payload: Any, message: str) -> str: + """Add the context a control API error body names to its message. + + A failed create may leave a sandbox to stop, and a secret error names + which of several secrets could not be used. Only messages the body sent + get context. + """ + if not isinstance(payload, dict) or not ( + payload.get("message") or payload.get("error") + ): + return message + sandbox_id = payload.get("sandboxId") + if isinstance(sandbox_id, str) and sandbox_id.strip(): + message = f"{message} (sandbox {sandbox_id.strip()})" + secret = payload.get("details") + if isinstance(secret, dict): + env = secret.get("env") + if isinstance(env, str) and env.strip(): + message = ( + f"{message} ({env.strip()}: secret {secret.get('secretId')}, " + f"version {secret.get('version')})" + ) + return message + + def parse_error_payload( raw_text: str, fallback_message: str ) -> Tuple[str, Optional[str], Any]: @@ -140,6 +182,8 @@ def ensure_response_ok( f"Request failed: {response.status_code} {response.reason_phrase}" ) message, code, details, decode_error = _parse_error_payload(response.text, fallback) + if service == "control": + message = error_payload_message(details, message) raise HyperbrowserError( message, status_code=response.status_code, diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index cda34f19..0c075f8b 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -9,8 +9,8 @@ import hashlib import json import os +import re import time -import uuid from dataclasses import dataclass from typing import Any, Dict, Optional, Type, Union @@ -20,13 +20,17 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from .exceptions import HyperbrowserError -from .models.secret import SecretsPublicKey +from .models.secret import SecretsPublicKey, validate_secret_version # The largest secret value the platform accepts. MAX_SECRET_VALUE_BYTES = 32 * 1024 # The same key policy the server and the sandbox hosts enforce. MIN_SECRETS_KEY_BITS = 3072 SECRETS_KEY_EXPONENT = 65537 +# The server's ID check: a hyphenated UUID, without braces or a urn: prefix. +SECRET_ID_PATTERN = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE +) # Name lookups read one page of search results, which covers every secret # while teams are limited to 100. SECRET_NAME_LOOKUP_LIMIT = 100 @@ -161,13 +165,15 @@ def seal(self, encryption_context: str, value: Union[str, bytes, bytearray]) -> def looks_like_secret_id(key: str) -> bool: """Whether a key is shaped like a secret ID. Names may be too, so a UUID-shaped key that isn't an ID is looked up as a name.""" - if len(key) != 36: - return False + return SECRET_ID_PATTERN.match(key) is not None + + +def check_secret_version(version: int) -> int: + """Check a version number before it is used in a request path.""" try: - uuid.UUID(key) - except ValueError: - return False - return True + return validate_secret_version(version) + except (TypeError, ValueError) as error: + raise HyperbrowserError(str(error), code="invalid_secret_version") from None def secret_not_found_error(key: str) -> HyperbrowserError: @@ -234,6 +240,59 @@ def secret_process_outcome_unknown_error( ) +def secret_process_start_error( + sandbox_id: str, error: HyperbrowserError +) -> HyperbrowserError: + """Report a failed process start with secrets. + + A start that may have reached the API, or that the API reports as + unconfirmed, may be running with the secrets, so it must not look + retryable. + """ + if request_may_have_arrived(error): + return secret_process_outcome_unknown_error(sandbox_id, error) + if error.code == "SECRET_PROCESS_OUTCOME_UNKNOWN": + # The server's body, including its code, stays in the details. + details = dict(error.details) if isinstance(error.details, dict) else {} + details["sandboxId"] = sandbox_id + return HyperbrowserError( + error.args[0], + status_code=error.status_code, + response=error.response, + code="secret_process_outcome_unknown", + request_id=error.request_id, + retryable=False, + service="control", + details=details, + cause=error, + original_error=error, + ) + return error + + +def sandbox_create_outcome_unknown_error(cause: BaseException) -> HyperbrowserError: + """A create with startup secrets whose result is unknown: a sandbox may + be running its startup command with the secrets.""" + # A response that fails validation describes every field; one line is enough. + detail = (str(cause).splitlines() or [type(cause).__name__])[0] + return HyperbrowserError( + f"Could not confirm the create result: {detail}; a sandbox may have " + "been created with the secrets, so list sandboxes before retrying", + code="secret_process_outcome_unknown", + retryable=False, + service="control", + cause=cause, + original_error=cause if isinstance(cause, Exception) else None, + ) + + +def secret_process_id(process: Any) -> str: + """A started process's ID; the receiver may send it as ``processId``.""" + if not isinstance(process, dict): + return "" + return str(process.get("id") or process.get("processId") or "").strip() + + def secret_process_failed_error( sandbox_id: str, process_id: str, step: str, cause: BaseException ) -> HyperbrowserError: diff --git a/hyperbrowser/transport/async_transport.py b/hyperbrowser/transport/async_transport.py index 5edfad47..7b10a2ce 100644 --- a/hyperbrowser/transport/async_transport.py +++ b/hyperbrowser/transport/async_transport.py @@ -6,6 +6,7 @@ from hyperbrowser.sandbox_common import ( RETRYABLE_STATUS_CODES, error_payload_code, + error_payload_message, error_payload_retryable, get_request_id, get_retry_delay_seconds, @@ -70,6 +71,7 @@ async def _handle_response(self, response: httpx.Response) -> APIResponse: try: error_data = response.json() message = error_data.get("message") or error_data.get("error") or str(e) + message = error_payload_message(error_data, message) except Exception: message = str(e) raise HyperbrowserError( diff --git a/hyperbrowser/transport/sync.py b/hyperbrowser/transport/sync.py index 6fcc340b..5b921267 100644 --- a/hyperbrowser/transport/sync.py +++ b/hyperbrowser/transport/sync.py @@ -7,6 +7,7 @@ from hyperbrowser.sandbox_common import ( RETRYABLE_STATUS_CODES, error_payload_code, + error_payload_message, error_payload_retryable, get_request_id, get_retry_delay_seconds, @@ -48,6 +49,7 @@ def _handle_response(self, response: httpx.Response) -> APIResponse: try: error_data = response.json() message = error_data.get("message") or error_data.get("error") or str(e) + message = error_payload_message(error_data, message) except Exception: message = str(e) raise HyperbrowserError( diff --git a/tests/test_secrets.py b/tests/test_secrets.py index 4bcaf328..d364717b 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -35,6 +35,7 @@ from hyperbrowser.sandbox_common import ensure_response_ok from hyperbrowser.secrets_common import ( SecretsSealer, + looks_like_secret_id, request_may_have_arrived, secret_value_bytes, secrets_key_id, @@ -1559,3 +1560,344 @@ async def start(payload): ) with pytest.raises(DistinctAsyncTimeoutError): await handle.wait(timeout_ms=10 if expiration == "stalled_request" else 500) + + +# --- ID shapes, create outcomes, and server error codes --- + + +def test_only_hyphenated_uuids_look_like_secret_ids(): + assert looks_like_secret_id(SECRET_ID) + assert looks_like_secret_id(SECRET_ID.upper()) + for key in [ + "a-bcdefabcdef-abcd-efab-cdefabcdefab", + "8a0c7a5e4d3b4a5e9b8f0b4c7e7d2a11", + "{8a0c7a5e-4d3b-4a5e-9b8f-0b4c7e7d2a1}", + "8a0c7a5e-4d3b-4a5e-9b8f-0b4c7e7d2a1g", + ]: + assert not looks_like_secret_id(key) + + +def test_a_name_with_hyphens_in_other_places_goes_straight_to_the_name_search(): + name = "a-bcdefabcdef-abcd-efab-cdefabcdefab" + http = ScriptedHTTPClient( + { + ("GET", "/secrets"): ( + 200, + {"secrets": [_secret(name=name)], "totalCount": 1}, + ), + ("DELETE", f"/secrets/{SECRET_ID}"): (200, {"deleted": True}), + } + ) + + SecretManager(FakeClient(http)).delete(name) + + assert [c["path"] for c in http.calls] == ["/secrets", f"/secrets/{SECRET_ID}"] + + +CREATE_WITH_SECRETS = {"image_name": "node", "secrets": {"API_KEY": "provider-key"}} + + +@pytest.mark.parametrize( + "outcome", + [ + httpx.ReadTimeout("timed out"), + httpx.RemoteProtocolError("connection closed"), + httpx.Response(200, content=b"{", request=httpx.Request("POST", "https://x")), + httpx.Response( + 502, + content=b"Bad gateway", + request=httpx.Request("POST", "https://x"), + ), + (200, {"unexpected": True}), + ], +) +def test_a_create_with_secrets_that_may_have_arrived_has_an_unknown_outcome(outcome): + manager, http = _sandbox_manager({("POST", "/sandbox"): outcome}) + + with pytest.raises(HyperbrowserError) as error: + manager.create(CREATE_WITH_SECRETS) + + assert error.value.code == "secret_process_outcome_unknown" + assert error.value.retryable is False + assert "could not confirm the create result" in str(error.value).lower() + assert "list sandboxes before retrying" in str(error.value) + assert len(http.calls) == 1 + + +@pytest.mark.anyio +async def test_async_create_with_secrets_that_timed_out_has_an_unknown_outcome(): + manager, http = _sandbox_manager( + {("POST", "/sandbox"): httpx.ReadTimeout("timed out")}, async_client=True + ) + + with pytest.raises(HyperbrowserError) as error: + await manager.create(CREATE_WITH_SECRETS) + + assert error.value.code == "secret_process_outcome_unknown" + assert error.value.retryable is False + assert len(http.calls) == 1 + + +@pytest.mark.parametrize("async_client", [False, True]) +@pytest.mark.anyio +async def test_create_errors_keep_their_codes_when_the_outcome_is_known(async_client): + rejected = (400, {"code": "SECRET_STARTUP_UNSUPPORTED", "message": "no cmd"}) + for params, outcome, code in [ + (CREATE_WITH_SECRETS, rejected, "SECRET_STARTUP_UNSUPPORTED"), + (CREATE_WITH_SECRETS, httpx.ConnectError("refused"), None), + ({"image_name": "node"}, httpx.ReadTimeout("timed out"), None), + ]: + manager, _ = _sandbox_manager({("POST", "/sandbox"): outcome}, async_client) + with pytest.raises(HyperbrowserError) as error: + result = manager.create(params) + if async_client: + await result + assert error.value.code == code + + +@pytest.mark.parametrize("async_client", [False, True]) +@pytest.mark.anyio +async def test_the_servers_unknown_process_outcome_uses_the_sdk_code(async_client): + body = { + "code": "SECRET_PROCESS_OUTCOME_UNKNOWN", + "message": "The sandbox host did not confirm the process start", + "retryable": False, + } + manager, http = _sandbox_manager( + {("POST", "/sandbox/sbx_123/processes"): (502, body)}, async_client + ) + + with pytest.raises(HyperbrowserError) as error: + result = manager.start_secret_process("sbx_123", {"command": "x"}) + if async_client: + await result + + assert error.value.code == "secret_process_outcome_unknown" + assert error.value.retryable is False + assert error.value.status_code == 502 + assert error.value.details == dict(body, sandboxId="sbx_123") + assert str(error.value).startswith(body["message"]) + assert len(http.calls) == 1 + + +@pytest.mark.parametrize("async_client", [False, True]) +@pytest.mark.anyio +async def test_a_started_process_may_report_its_id_as_process_id(async_client): + process = dict(AGENT_PROCESS) + process["processId"] = process.pop("id") + manager, _ = _sandbox_manager( + { + ("POST", "/sandbox/sbx_123/processes"): ( + 200, + {"process": process, "secrets": RESOLVED}, + ) + }, + async_client, + ) + sandbox = manager.attach(SandboxDetail(**SANDBOX_DETAIL_PAYLOAD)) + + handle = sandbox.processes.start("node app.js", secrets={"API_KEY": "k"}) + if async_client: + handle = await handle + + assert handle.id == "proc_9" + assert handle.secrets[0].env == "API_KEY" + + +def test_the_processes_api_accepts_process_id_from_a_custom_start(): + process = dict(AGENT_PROCESS) + process["processId"] = process.pop("id") + api = SandboxProcessesApi( + RuntimeScript(), lambda payload: {"process": process}, "sbx_123" + ) + + assert api.start("x", secrets={"API_KEY": "k"}).id == "proc_9" + + +def test_control_error_messages_name_the_sandbox_and_the_secret(): + with pytest.raises(HyperbrowserError) as error: + ensure_response_ok( + _error_response( + 502, + { + "code": "SECRET_STARTUP_FAILED", + "message": "Stop it", + "sandboxId": "sbx_1", + }, + ), + "control", + ) + assert error.value.args[0] == "Stop it (sandbox sbx_1)" + assert error.value.details["sandboxId"] == "sbx_1" + + with pytest.raises(HyperbrowserError) as error: + ensure_response_ok( + _error_response( + 422, + { + "code": "SECRET_DECRYPT_FAILED", + "message": "Could not open a secret", + "details": {"env": "API_KEY", "secretId": SECRET_ID, "version": 2}, + }, + ), + "control", + ) + assert error.value.args[0] == ( + f"Could not open a secret (API_KEY: secret {SECRET_ID}, version 2)" + ) + + # Context is only added to a message the body sent, and only for control. + with pytest.raises(HyperbrowserError) as error: + ensure_response_ok(_error_response(500, {"sandboxId": "sbx_1"}), "control") + assert "sbx_1" not in error.value.args[0] + with pytest.raises(HyperbrowserError) as error: + ensure_response_ok( + _error_response(500, {"message": "m", "sandboxId": "sbx_1"}), "runtime" + ) + assert error.value.args[0] == "m" + + +@pytest.mark.parametrize("transport_type", ["sync", "async"]) +@pytest.mark.anyio +async def test_transport_error_messages_name_the_sandbox(transport_type): + from hyperbrowser.transport.async_transport import AsyncTransport + from hyperbrowser.transport.sync import SyncTransport + + def respond(request): + return httpx.Response( + 502, json={"code": "X_Y", "message": "failed", "sandboxId": "sbx_1"} + ) + + if transport_type == "sync": + transport = SyncTransport("key") + transport.client = httpx.Client(transport=httpx.MockTransport(respond)) + call = transport.post + else: + transport = AsyncTransport("key") + transport.client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + call = transport.post + + with pytest.raises(HyperbrowserError) as error: + result = call("https://api.example.com/api/sandbox") + if transport_type == "async": + await result + + assert error.value.args[0] == "failed (sandbox sbx_1)" + + +def test_secrets_not_configured_is_not_retried(): + http = ScriptedHTTPClient( + { + ("GET", "/secrets/public-key"): ( + 503, + {"code": "SECRETS_NOT_CONFIGURED", "message": "Not configured"}, + ) + } + ) + + with pytest.raises(HyperbrowserError) as error: + SecretManager(FakeClient(http)).get_public_key() + + assert error.value.code == "SECRETS_NOT_CONFIGURED" + assert error.value.retryable is False + assert len(http.calls) == 1 + + +@pytest.mark.parametrize("transport_type", ["sync", "async"]) +@pytest.mark.anyio +async def test_transport_does_not_retry_secrets_not_configured(transport_type): + from hyperbrowser.transport.async_transport import AsyncTransport + from hyperbrowser.transport.sync import SyncTransport + + calls = [] + + def respond(request): + calls.append(request) + return httpx.Response( + 503, json={"error": "SECRETS_NOT_CONFIGURED", "message": "Not configured"} + ) + + if transport_type == "sync": + transport = SyncTransport("key") + transport.client = httpx.Client(transport=httpx.MockTransport(respond)) + else: + transport = AsyncTransport("key") + transport.client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + + with pytest.raises(HyperbrowserError) as error: + result = transport.get("https://api.example.com/api/secrets/public-key") + if transport_type == "async": + await result + + assert error.value.retryable is False + assert len(calls) == 1 + + +@pytest.mark.parametrize("api_type", ["sync", "async"]) +@pytest.mark.anyio +async def test_use_shell_is_rejected_for_starts_with_secrets(api_type): + if api_type == "sync": + api = SandboxProcessesApi(RuntimeScript(), lambda payload: {}, "sbx_123") + else: + api = AsyncSandboxProcessesApi(AsyncRuntimeScript(), None, "sbx_123") + + with pytest.raises(HyperbrowserError, match="use_shell") as error: + result = api.start( + {"command": "echo $API_KEY", "use_shell": True}, + secrets={"API_KEY": "provider-key"}, + ) + if api_type == "async": + await result + assert error.value.code == "secrets_unsupported" + + +def test_secret_names_env_names_and_versions_are_checked_locally(): + for name in ["", "-leading", "has space", "a" * 129]: + with pytest.raises(ValueError, match="Invalid secret name"): + CreateSecretParams(name=name) + assert CreateSecretParams(name="a" * 128).name == "a" * 128 + + for env in ["1ABC", "API-KEY", ""]: + with pytest.raises(ValueError, match="environment variable name"): + SandboxExecParams(command="x", secrets={env: "provider-key"}) + with pytest.raises(ValueError, match="environment variable name"): + CreateSandboxParams(image_name="node", secrets={env: "provider-key"}) + with pytest.raises(ValueError, match="Invalid secret name"): + SandboxExecParams(command="x", secrets={"API_KEY": "bad name"}) + + for version in [0, -1, 2**31]: + with pytest.raises(ValueError, match="Invalid secret version"): + SandboxExecParams( + command="x", secrets={"A": {"name": "k", "version": version}} + ) + reference = SandboxExecParams( + command="x", secrets={"A": {"name": "k", "version": 2**31 - 1}} + ).secrets + assert reference is not None and reference["A"].version == 2**31 - 1 + + +def test_list_pages_and_version_numbers_are_checked_before_any_request(): + http = ScriptedHTTPClient({}) + manager = SecretManager(FakeClient(http)) + + for params in [{"limit": 0}, {"limit": 101}, {"page": 0}]: + with pytest.raises(ValueError): + manager.list(params) + for version in [0, 2**31, "1"]: + with pytest.raises(HyperbrowserError) as error: + manager.delete_version("provider-key", version) + assert error.value.code == "invalid_secret_version" + assert http.calls == [] + + +@pytest.mark.anyio +async def test_async_version_numbers_are_checked_before_any_request(): + http = AsyncScriptedHTTPClient({}) + manager = AsyncSecretManager(FakeClient(http)) + + with pytest.raises(HyperbrowserError) as error: + await manager.delete_version("provider-key", 0) + assert error.value.code == "invalid_secret_version" + with pytest.raises(ValueError): + await manager.list({"limit": 101}) + assert http.calls == [] From c74a6520d7e1b112894146ff4586053bbe71f4a2 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Sun, 11 Oct 2026 22:34:47 -0700 Subject: [PATCH 4/5] Drop the sync client's socket-level wait deadlines Interrupting an in-flight HTTPX request needed a timer thread that shut down sockets through trace hooks, with special cases for shared HTTP/2 connections, TLS stream swaps and trickled responses, plus deadline-aware copies of the token refresh and control request paths. The sync secret process wait now checks its limit between requests and caps each request's HTTP timeouts at the time left, like the async client's transport. A trickled response or a token refresh can overrun the limit; the README points to the async client for a strict one. The control request path is back to master's. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- .../client/managers/sync_manager/sandbox.py | 103 +---- .../sandboxes/sandbox_processes.py | 5 +- .../sandboxes/sandbox_transport.py | 156 +------- tests/test_secrets.py | 359 ------------------ 5 files changed, 28 insertions(+), 601 deletions(-) diff --git a/README.md b/README.md index 4df7c81c..96f5b145 100644 --- a/README.md +++ b/README.md @@ -331,9 +331,9 @@ Things to know: the total local wait and raises `TimeoutError` (`asyncio.TimeoutError` for async handles); the process continues and the handle can be used to wait again. The start's timeout limits the command's execution separately. - For strict cancellation timing, use the async client: blocking DNS, custom - transports or hooks, shared HTTP/2 control connections, and trickled headers - on a reused control connection can delay cancellation in the sync client. + The sync client checks the limit between requests and caps each request's + HTTP timeouts at the time left, so a slow response or a runtime token + refresh can overrun it; use the async client for a strict limit. - A process start with secrets that times out, or whose result the API can't confirm, raises a non-retryable error with code `secret_process_outcome_unknown`: the command may be running, so check diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index 6e9be2d9..641aa503 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -2,8 +2,6 @@ from pathlib import Path from typing import Dict, Optional, Union -import httpx - from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError from ....models.sandbox import ( @@ -115,7 +113,7 @@ SandboxTerminalConnection, SandboxTerminalHandle, ) -from .sandboxes.sandbox_transport import RuntimeTransport, _RequestDeadline +from .sandboxes.sandbox_transport import RuntimeTransport __all__ = [ "DEFAULT_PROCESS_KILL_WAIT_SECONDS", @@ -144,7 +142,6 @@ def __init__(self, service: "SandboxManager", detail: SandboxDetail): self._resolve_runtime_connection, service.runtime_timeout, service.runtime_proxy_override, - self._resolve_runtime_connection_with_deadline, ) self.processes = SandboxProcessesApi( self._transport, @@ -294,11 +291,6 @@ def get_exposed_url(self, port: int) -> str: def create_runtime_session( self, force_refresh: bool = False - ) -> SandboxRuntimeSession: - return self._create_runtime_session(force_refresh) - - def _create_runtime_session( - self, force_refresh: bool, deadline: Optional[float] = None ) -> SandboxRuntimeSession: self._assert_runtime_available() if ( @@ -308,11 +300,7 @@ def _create_runtime_session( ): return _copy_model(self._runtime_session) - detail = ( - self._service.get_detail(self.id) - if deadline is None - else self._service._get_detail_with_deadline(self.id, deadline) - ) + detail = self._service.get_detail(self.id) self._hydrate(detail) if self._runtime_session is None: raise HyperbrowserError( @@ -383,16 +371,6 @@ def _resolve_runtime_socket_info(self) -> RuntimeConnection: token=session.token, ) - def _resolve_runtime_connection_with_deadline( - self, force_refresh: bool, deadline: float - ) -> RuntimeConnection: - session = self._create_runtime_session(force_refresh, deadline) - return RuntimeConnection( - sandbox_id=self.id, - base_url=session.runtime.base_url, - token=session.token, - ) - def _apply_runtime_session(self, session: SandboxRuntimeSession) -> None: self._runtime_session = _copy_model(session) self._detail = self._detail.model_copy( @@ -1092,14 +1070,6 @@ def get_detail(self, sandbox_id: str) -> SandboxDetail: payload = self._request("GET", f"/sandbox/{sandbox_id}") return SandboxDetail(**payload) - def _get_detail_with_deadline( - self, sandbox_id: str, deadline: float - ) -> SandboxDetail: - payload = self._request( - "GET", f"/sandbox/{sandbox_id}", request_deadline=deadline - ) - return SandboxDetail(**payload) - def attach(self, detail: SandboxDetail) -> SandboxHandle: return SandboxHandle(self, detail) @@ -1203,38 +1173,22 @@ def _request( *, params: Optional[Dict[str, object]] = None, data: Optional[Dict[str, object]] = None, - request_deadline: Optional[float] = None, ): failed_attempt = 1 while True: try: - request_params = { - key: value - for key, value in (params or {}).items() - if value is not None - } - if request_deadline is None: - response = self._client.transport.client.request( - method, - self._client._build_url(path), - params=request_params, - json=data, - ) - else: - response = self._request_with_deadline( - method, path, request_params, data, request_deadline - ) + response = self._client.transport.client.request( + method, + self._client._build_url(path), + params={ + key: value + for key, value in (params or {}).items() + if value is not None + }, + json=data, + ) ensure_response_ok(response, "control") except BaseException as cause: - if not isinstance(cause, Exception): - raise - if ( - request_deadline is not None - and time.monotonic() >= request_deadline - ): - raise TimeoutError( - "Timed out waiting for command output" - ) from cause error = normalize_network_error( cause, "control", @@ -1243,39 +1197,8 @@ def _request( ) if not should_retry_get(method, error, failed_attempt): raise error - delay = get_retry_delay_seconds(failed_attempt) - if request_deadline is not None: - delay = min(delay, max(0, request_deadline - time.monotonic())) - time.sleep(delay) + time.sleep(get_retry_delay_seconds(failed_attempt)) failed_attempt += 1 continue return parse_json_response(response, "control") - - def _request_with_deadline(self, method, path, params, data, deadline): - budget = _RequestDeadline(deadline, shared_connection=True) - response = None - try: - client = self._client.transport.client - remaining = budget.remaining() - timeouts = { - key: min(value, remaining) if value is not None else remaining - for key, value in client.timeout.as_dict().items() - } - request = client.build_request( - method, - self._client._build_url(path), - params=params, - json=data, - timeout=httpx.Timeout(**timeouts), - extensions={"trace": budget.trace}, - ) - response = client.send(request, stream=True) - budget.capture_response(response) - response.read() - budget.remaining() - return response - finally: - budget.close() - if response is not None: - response.close() diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py index af0e70d6..cd57ef81 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py @@ -109,7 +109,7 @@ def _wait_with_secrets( chunk_ms, request_timeout, _ = wait.request_budget() started_at = time.monotonic() try: - result = self._wait_once(chunk_ms, None, request_timeout, wait.deadline) + result = self._wait_once(chunk_ms, None, request_timeout) wait.remaining() return result except TimeoutError as error: @@ -133,13 +133,10 @@ def _wait_once( timeout_ms: Optional[int], timeout_sec: Optional[int], request_timeout: Optional[float] = None, - request_deadline: Optional[float] = None, ) -> SandboxProcessResult: request_options: Dict[str, Any] = {} if request_timeout is not None: request_options["request_timeout"] = request_timeout - if request_deadline is not None: - request_options["request_deadline"] = request_deadline payload = self._transport.request_json( f"/sandbox/processes/{self.id}/wait", method="POST", diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py index 5444e1cd..dc0e8b19 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py @@ -1,7 +1,5 @@ import json import socket -import threading -import time from typing import Any, Callable, Dict, Iterator, Optional import httpx @@ -23,130 +21,16 @@ ) -class _DeadlineResponseStream(httpx.SyncByteStream): - def __init__(self, stream, deadline): - self._stream = stream - self._deadline = deadline - - def __iter__(self): - yield from self._stream - - def close(self) -> None: - # Cancel before HTTPX releases the connection back to a shared pool. - self._deadline.close() - self._stream.close() - - -class _RequestDeadline: - """Interrupt this request's socket when its total wait budget expires.""" - - def __init__(self, deadline: float, shared_connection: bool = False): - self.deadline = deadline - self._shared_connection = shared_connection - self._may_shutdown = True - self._socket = None - self._lock = threading.Lock() - self._expired = False - self._closed = False - self._timer = threading.Timer(self.remaining(), self._expire) - self._timer.daemon = True - self._timer.start() - - def remaining(self) -> float: - remaining = self.deadline - time.monotonic() - if remaining <= 0: - raise TimeoutError("Timed out waiting for command output") - return remaining - - @staticmethod - def _shutdown(sock) -> None: - if sock is not None: - try: - sock.shutdown(socket.SHUT_RDWR) - except OSError: - pass - - def _expire(self) -> None: - with self._lock: - if self._closed: - return - self._expired = True - sock = self._socket - self._shutdown(sock) - - def trace(self, event, info) -> None: - if "response_closed." in event or event.endswith( - "receive_response_body.complete" - ): - # Response hooks may eagerly read/close before capture_response(). - # Cancel before the connection becomes available to another request, - # and never interrupt the cleanup that releases it to the pool. - self.close() - return - if self._shared_connection and event.startswith("http2."): - with self._lock: - self._may_shutdown = False - self._socket = None - # The HTTPX trace extension exposes the stream before response - # headers, including the replacement stream after TLS negotiation. - if event.endswith(("connect_tcp.complete", "start_tls.complete")): - self.capture_stream(info.get("return_value")) - self.remaining() - - def capture_stream(self, stream) -> None: - if stream is not None: - ssl_object = stream.get_extra_info("ssl_object") - shared_http2 = ( - self._shared_connection - and ssl_object is not None - and ssl_object.selected_alpn_protocol() == "h2" - ) - with self._lock: - if self._closed: - return - if shared_http2: - self._may_shutdown = False - self._socket = ( - stream.get_extra_info("socket") if self._may_shutdown else None - ) - expired = self._expired - sock = self._socket - if expired: - self._shutdown(sock) - - def capture_response(self, response: httpx.Response) -> None: - if response.is_closed or response.is_stream_consumed: - self.close() - return - self.capture_stream(response.extensions.get("network_stream")) - response.stream = _DeadlineResponseStream(response.stream, self) - - def close(self) -> None: - with self._lock: - self._closed = True - self._timer.cancel() - self._timer.join() - - class RuntimeTransport: def __init__( self, resolve_connection, timeout: float = 30.0, runtime_proxy_override: Optional[str] = None, - resolve_connection_with_deadline: Optional[ - Callable[[bool, float], RuntimeConnection] - ] = None, ): self._resolve_connection = resolve_connection self._timeout = timeout self._runtime_proxy_override = runtime_proxy_override - self._resolve_connection_with_deadline = resolve_connection_with_deadline - - def _connection(self, force_refresh: bool, deadline: Optional[float]): - if deadline is not None and self._resolve_connection_with_deadline is not None: - return self._resolve_connection_with_deadline(force_refresh, deadline) - return self._resolve_connection(force_refresh) def request_json( self, @@ -158,7 +42,6 @@ def request_json( content: Optional[Any] = None, headers: Optional[Dict[str, str]] = None, request_timeout: Optional[float] = None, - request_deadline: Optional[float] = None, ): response = self._request( path, @@ -168,7 +51,6 @@ def request_json( content=content, headers=headers, request_timeout=request_timeout, - request_deadline=request_deadline, ) return parse_json_response(response, "runtime") @@ -305,9 +187,8 @@ def _request( headers: Optional[Dict[str, str]] = None, allow_refresh: bool = True, request_timeout: Optional[float] = None, - request_deadline: Optional[float] = None, ) -> httpx.Response: - connection = self._connection(False, request_deadline) + connection = self._resolve_connection(False) response = self._send( connection, path, @@ -317,7 +198,6 @@ def _request( content=content, headers=headers, request_timeout=request_timeout, - request_deadline=request_deadline, ) if response.status_code == 401 and allow_refresh: @@ -328,7 +208,7 @@ def _request( "runtime", "Runtime token expired during a streaming request; retry with a fresh stream.", ) - refreshed = self._connection(True, request_deadline) + refreshed = self._resolve_connection(True) retry = self._send( refreshed, path, @@ -338,7 +218,6 @@ def _request( content=content, headers=headers, request_timeout=request_timeout, - request_deadline=request_deadline, ) return ensure_response_ok(retry, "runtime") @@ -436,7 +315,6 @@ def _send( content: Optional[Any], headers: Optional[Dict[str, str]], request_timeout: Optional[float] = None, - request_deadline: Optional[float] = None, ) -> httpx.Response: request_path = _build_query_path(path, params) target = resolve_runtime_transport_target( @@ -445,42 +323,30 @@ def _send( self._runtime_proxy_override, ) merged_headers = build_headers(connection.token, headers, target.host_header) - timeout = self._timeout if request_timeout is None else request_timeout - deadline = None - client = None + client = httpx.Client( + timeout=self._timeout if request_timeout is None else request_timeout + ) + try: - if request_deadline is not None: - deadline = _RequestDeadline(request_deadline) - remaining = deadline.remaining() - timeout = min(timeout, remaining) if timeout is not None else remaining - client = httpx.Client(timeout=timeout) - request_options: Dict[str, Any] = {} - if deadline is not None: - request_options["extensions"] = {"trace": deadline.trace} response = client.request( method, target.url, headers=merged_headers, json=json_body, content=content, - **request_options, ) - if deadline is not None: - deadline.remaining() - response.read() - return response except BaseException as error: + client.close() raise normalize_network_error( error, "runtime", "Unknown runtime request error", request_context(method, path), ) - finally: - if deadline is not None: - deadline.close() - if client is not None: - client.close() + + response.read() + client.close() + return response def _send_binary_stream( self, diff --git a/tests/test_secrets.py b/tests/test_secrets.py index d364717b..bf376888 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -1070,85 +1070,6 @@ def test_malformed_http_error_preserves_generic_error_parsing(service): assert isinstance(error.value.cause, json.JSONDecodeError) -@pytest.mark.parametrize("trickle", ["headers", "body"]) -def test_sync_secret_wait_deadline_interrupts_trickled_http_response(trickle): - import socket - import threading - - from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_transport import ( - RuntimeTransport, - ) - from hyperbrowser.sandbox_common import RuntimeConnection - - listener = socket.socket() - listener.bind(("127.0.0.1", 0)) - listener.listen() - listener.settimeout(2) - port = listener.getsockname()[1] - stop = threading.Event() - requests = [] - - def serve(): - try: - connection, _ = listener.accept() - with connection: - connection.settimeout(2) - request = b"" - while b"\r\n\r\n" not in request: - request += connection.recv(4096) - requests.append(request) - body = json.dumps({"result": RESULT}).encode() - headers = ( - b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" - + b"Content-Length: " - + str(len(body)).encode() - + b"\r\nConnection: close\r\n\r\n" - ) - if trickle == "headers": - connection.sendall(headers[:-2]) - for _ in range(50): - if stop.wait(0.025): - return - connection.sendall(b" ") - connection.sendall(b"\r\n" + body) - else: - connection.sendall(headers) - for byte in body: - connection.sendall(bytes([byte])) - if stop.wait(0.025): - return - except (OSError, TimeoutError): - pass - - server = threading.Thread(target=serve) - server.start() - runtime = RuntimeTransport( - lambda force: RuntimeConnection("sbx_123", f"http://127.0.0.1:{port}", "token"), - timeout=2, - ) - handle = SandboxProcessesApi( - runtime, - lambda payload: {"process": AGENT_PROCESS, "secrets": RESOLVED}, - "sbx_123", - ).start("node app.js", secrets={"API_KEY": "provider-key"}) - started_at = time.monotonic() - try: - with pytest.raises(TimeoutError): - handle.wait(timeout_ms=200) - elapsed = time.monotonic() - started_at - assert 0.15 <= elapsed < 0.45 - assert len(requests) == 1 - assert handle.status == "running" - # Timers are joined before wait returns, so no cancellation can leak - # into a subsequent call or another connection. - assert not any(isinstance(t, threading.Timer) for t in threading.enumerate()) - finally: - stop.set() - listener.close() - server.join(timeout=3) - assert not server.is_alive() - - @pytest.mark.parametrize( "payload", [ @@ -1197,286 +1118,6 @@ async def test_async_decoded_secret_start_error_keeps_server_retryable_flag(retr assert len(http.calls) == 1 -@pytest.mark.parametrize( - "refresh, response_mode", - [ - ("expired", "delayed_headers"), - ("401", "delayed_headers"), - ("expired", "trickled_body"), - ("401", "trickled_body"), - ("expired", "eager_hook"), - ], -) -def test_sync_secret_wait_bounds_sdk_control_token_refresh(refresh, response_mode): - import threading - from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - - from hyperbrowser import Hyperbrowser - - stop = threading.Event() - requests = [] - unrelated = [] - unrelated_started = threading.Event() - unrelated_thread = None - - class Handler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - - def log_message(self, *args): - pass - - def respond(self, status, payload): - body = json.dumps(payload).encode() - self.send_response(status) - self.send_header("content-type", "application/json") - self.send_header("content-length", str(len(body))) - self.end_headers() - self.wfile.write(body) - self.wfile.flush() - - def do_POST(self): - self.rfile.read(int(self.headers.get("content-length", "0"))) - requests.append(("POST", self.path, dict(self.headers))) - if self.path == "/api/unrelated": - unrelated_started.set() - if not stop.wait(0.3): - self.respond(200, {"success": True}) - elif self.path == "/api/sandbox/sbx_123/processes": - self.respond(200, {"process": AGENT_PROCESS, "secrets": RESOLVED}) - else: - self.respond(401, {"error": "expired"}) - - def do_GET(self): - requests.append(("GET", self.path, dict(self.headers))) - try: - if response_mode == "eager_hook": - self.respond(200, SANDBOX_DETAIL_PAYLOAD) - elif response_mode == "delayed_headers": - if not stop.wait(0.5): - self.respond(200, SANDBOX_DETAIL_PAYLOAD) - else: - body = json.dumps(SANDBOX_DETAIL_PAYLOAD).encode() - self.send_response(200) - self.send_header("content-type", "application/json") - self.send_header("content-length", str(len(body))) - self.end_headers() - for byte in body: - self.wfile.write(bytes([byte])) - self.wfile.flush() - if stop.wait(0.025): - return - except OSError: - pass - - server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - server_thread = threading.Thread( - target=server.serve_forever, kwargs={"poll_interval": 0.01} - ) - server_thread.start() - base_url = f"http://127.0.0.1:{server.server_port}" - client = Hyperbrowser(api_key="test-key", base_url=base_url) - client.transport.client.headers["x-client-setting"] = "preserved" - client.transport.client.cookies.set("sessionhint", "preserved") - if response_mode == "eager_hook": - - def eager_hook(response): - nonlocal unrelated_thread - if response.request.method != "GET": - return - response.read() - - def post_unrelated(): - try: - unrelated.append( - client.transport.client.post( - base_url + "/api/unrelated" - ).status_code - ) - except Exception as error: - unrelated.append(error) - - unrelated_thread = threading.Thread(target=post_unrelated) - unrelated_thread.start() - assert unrelated_started.wait(2) - time.sleep(0.35) - - client.transport.client.event_hooks["response"].append(eager_hook) - detail = SandboxDetail.model_validate( - { - **SANDBOX_DETAIL_PAYLOAD, - "runtime": { - "transport": "regional_proxy", - "host": base_url, - "baseUrl": base_url + "/sandbox/sbx_123", - }, - "tokenExpiresAt": "2099-01-01T00:00:00Z" - if refresh == "401" - else "2000-01-01T00:00:00Z", - } - ) - handle = client.sandboxes.attach(detail).processes.start( - "node app.js", secrets={"API_KEY": "provider-key"} - ) - started_at = time.monotonic() - try: - with pytest.raises(TimeoutError): - handle.wait(timeout_ms=200) - elapsed = time.monotonic() - started_at - if response_mode == "eager_hook": - assert 0.3 <= elapsed < 0.8 - assert unrelated == [200] - else: - assert 0.15 <= elapsed < 0.45 - gets = [request for request in requests if request[0] == "GET"] - assert len(gets) == 1 - assert gets[0][1] == "/api/sandbox/sbx_123" - assert gets[0][2]["x-api-key"] == "test-key" - assert gets[0][2]["x-client-setting"] == "preserved" - assert "sessionhint=preserved" in gets[0][2]["Cookie"] - assert len( - [request for request in requests if request[1].endswith("/wait")] - ) == (1 if refresh == "401" else 0) - assert not any(isinstance(t, threading.Timer) for t in threading.enumerate()) - assert not client.transport.client.is_closed - finally: - stop.set() - if unrelated_thread is not None: - unrelated_thread.join(timeout=3) - client.close() - server.shutdown() - server.server_close() - server_thread.join(timeout=3) - assert not server_thread.is_alive() - - -def test_deadline_refresh_preserves_custom_control_transport(monkeypatch): - from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_transport import ( - RuntimeTransport, - ) - - calls = [] - - def respond(request): - calls.append(request) - if request.method == "POST": - return httpx.Response( - 200, json={"process": AGENT_PROCESS, "secrets": RESOLVED} - ) - return httpx.Response( - 200, - json={**SANDBOX_DETAIL_PAYLOAD, "tokenExpiresAt": "2099-01-01T00:00:00Z"}, - ) - - http = httpx.Client( - transport=httpx.MockTransport(respond), headers={"x-custom": "kept"} - ) - manager = SandboxManager(FakeClient(http)) - handle = manager.attach(SandboxDetail(**SANDBOX_DETAIL_PAYLOAD)).processes.start( - "node app.js", secrets={"API_KEY": "provider-key"} - ) - runtime_calls = [] - - def runtime_send(self, connection, path, **kwargs): - runtime_calls.append((connection, kwargs)) - return httpx.Response(200, json={"result": RESULT}) - - monkeypatch.setattr(RuntimeTransport, "_send", runtime_send) - try: - assert handle.wait(timeout_ms=500).stdout == "ok\n" - assert [request.method for request in calls] == ["POST", "GET"] - assert calls[-1].headers["x-custom"] == "kept" - assert 0 < calls[-1].extensions["timeout"]["read"] <= 0.5 - assert runtime_calls[0][1]["request_deadline"] is not None - assert not http.is_closed - finally: - http.close() - - -def test_shared_http2_deadline_does_not_shutdown_multiplexed_socket(): - from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_transport import ( - _RequestDeadline, - ) - - shutdowns = [] - - class Socket: - def shutdown(self, how): - shutdowns.append(how) - - sock = Socket() - - class TLS: - def selected_alpn_protocol(self): - return "h2" - - class Stream: - def get_extra_info(self, key): - return {"socket": sock, "ssl_object": TLS()}.get(key) - - budget = _RequestDeadline(time.monotonic() + 1, shared_connection=True) - try: - budget.capture_stream(Stream()) - budget._expire() - assert shutdowns == [] - finally: - budget.close() - - -def test_deadline_cleanup_trace_never_raises_or_captures_released_socket(): - from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_transport import ( - _RequestDeadline, - ) - - shutdowns = [] - - class Stream: - def get_extra_info(self, key): - if key == "socket": - return self - return None - - def shutdown(self, how): - shutdowns.append(how) - - budget = _RequestDeadline(time.monotonic() + 1) - budget.capture_stream(Stream()) - budget.trace("http11.receive_response_body.complete", {}) - budget.deadline = time.monotonic() - 1 - budget.trace("http11.response_closed.started", {}) - budget.trace("http11.response_closed.complete", {}) - budget.capture_stream(Stream()) - budget._expire() - assert shutdowns == [] - - -@pytest.mark.parametrize("control_flow", [KeyboardInterrupt, SystemExit]) -@pytest.mark.parametrize("with_deadline", [False, True]) -def test_control_refresh_preserves_control_flow_after_deadline( - monkeypatch, control_flow, with_deadline -): - clock = [0.0] - monkeypatch.setattr(time, "monotonic", lambda: clock[0]) - - def interrupt(response): - clock[0] = 2.0 - raise control_flow("stop") - - http = httpx.Client( - transport=httpx.MockTransport(lambda request: httpx.Response(200, json={})), - event_hooks={"response": [interrupt]}, - ) - manager = SandboxManager(FakeClient(http)) - try: - with pytest.raises(control_flow, match="stop"): - manager._request( - "GET", - "/sandbox/sbx_123", - request_deadline=1.0 if with_deadline else None, - ) - finally: - http.close() - - @pytest.mark.parametrize("method", ["wait", "result", "exec"]) def test_default_secret_wait_timeout_error_retains_process_identifiers(method): runtime = RuntimeScript(waits=[TimeoutError("transport timed out")]) From 64cfebf7d045acc0bfb7d48a88254c70582bdc32 Mon Sep 17 00:00:00 2001 From: nikhil Date: Mon, 12 Oct 2026 01:10:35 -0700 Subject: [PATCH 5/5] Harden secret validation and ambiguous sandbox outcomes --- README.md | 2 + .../client/managers/async_manager/sandbox.py | 5 +- .../client/managers/sync_manager/sandbox.py | 5 +- hyperbrowser/models/secret.py | 29 ++++++-- hyperbrowser/secrets_common.py | 3 +- tests/test_secrets.py | 74 ++++++++++++++++++- 6 files changed, 105 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 96f5b145..105d4163 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,8 @@ Things to know: The sync client checks the limit between requests and caps each request's HTTP timeouts at the time left, so a slow response or a runtime token refresh can overrun it; use the async client for a strict limit. + Secret process output comes from the receiver's retained output, so + `max_output_bytes` does not apply; incomplete retained output raises an error. - A process start with secrets that times out, or whose result the API can't confirm, raises a non-retryable error with code `secret_process_outcome_unknown`: the command may be running, so check diff --git a/hyperbrowser/client/managers/async_manager/sandbox.py b/hyperbrowser/client/managers/async_manager/sandbox.py index ad9ee4e9..ad7dc7be 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -1180,7 +1180,10 @@ async def _create_detail( data = dump_request(normalized, CreateSandboxParams) try: payload = await self._request("POST", "/sandbox", data=data) - return SandboxDetail(**payload) + detail = SandboxDetail(**payload) + if normalized.secrets and not detail.id.strip(): + raise ValueError("The API accepted the create but returned no sandbox ID") + return detail except Exception as error: # A create that may have reached the API may be running its # startup command with the secrets, so it must not look retryable. diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index 641aa503..f15bfe01 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -1158,7 +1158,10 @@ def _create_detail( data = dump_request(normalized, CreateSandboxParams) try: payload = self._request("POST", "/sandbox", data=data) - return SandboxDetail(**payload) + detail = SandboxDetail(**payload) + if normalized.secrets and not detail.id.strip(): + raise ValueError("The API accepted the create but returned no sandbox ID") + return detail except Exception as error: # A create that may have reached the API may be running its # startup command with the secrets, so it must not look retryable. diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py index 1bb4438f..d4751ccc 100644 --- a/hyperbrowser/models/secret.py +++ b/hyperbrowser/models/secret.py @@ -9,10 +9,11 @@ SECRET_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$") SECRET_ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") MAX_SECRET_VERSION = 2**31 - 1 +MAX_SECRET_BINDINGS = 32 def validate_secret_name(name: str) -> str: - if not SECRET_NAME_PATTERN.match(name): + if not SECRET_NAME_PATTERN.fullmatch(name): raise ValueError( f"Invalid secret name {name!r}: names are 1-128 letters, digits, " "'_' or '-', starting with a letter or digit" @@ -21,7 +22,11 @@ def validate_secret_name(name: str) -> str: def validate_secret_version(version: int) -> int: - if isinstance(version, bool) or not 1 <= version <= MAX_SECRET_VERSION: + if ( + not isinstance(version, int) + or isinstance(version, bool) + or not 1 <= version <= MAX_SECRET_VERSION + ): raise ValueError( f"Invalid secret version {version!r}: expected 1 to {MAX_SECRET_VERSION}" ) @@ -46,10 +51,10 @@ class SecretReference(SecretBaseModel): def validate_name(cls, value): return validate_secret_name(value) - @field_validator("version") + @field_validator("version", mode="before") @classmethod def validate_version(cls, value): - if isinstance(value, int): + if value is not None and value != "latest": validate_secret_version(value) return value @@ -62,8 +67,18 @@ def normalize_secret_references(value): check the environment variable names.""" if not isinstance(value, dict): return value + if not value: + return None + if len(value) > MAX_SECRET_BINDINGS: + raise ValueError( + f"At most {MAX_SECRET_BINDINGS} secrets can be delivered at once" + ) for env in value: - if not isinstance(env, str) or not SECRET_ENV_NAME_PATTERN.match(env): + if ( + not isinstance(env, str) + or not SECRET_ENV_NAME_PATTERN.fullmatch(env) + or env == "__proto__" + ): raise ValueError( f"Invalid environment variable name {env!r} for a secret: " "expected letters, digits and '_', not starting with a digit" @@ -75,10 +90,12 @@ def normalize_secret_references(value): class CreateSecretParams(SecretBaseModel): + model_config = ConfigDict(populate_by_name=True, hide_input_in_errors=True) + name: str description: Optional[str] = None # Encrypted locally and uploaded as the first version; never sent as is. - value: Optional[Union[str, bytes]] = Field(default=None, exclude=True) + value: Optional[Union[str, bytes]] = Field(default=None, exclude=True, repr=False) @field_validator("name") @classmethod diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index 0c075f8b..0364027a 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -290,7 +290,8 @@ def secret_process_id(process: Any) -> str: """A started process's ID; the receiver may send it as ``processId``.""" if not isinstance(process, dict): return "" - return str(process.get("id") or process.get("processId") or "").strip() + process_id = process.get("id") or process.get("processId") + return process_id.strip() if isinstance(process_id, str) else "" def secret_process_failed_error( diff --git a/tests/test_secrets.py b/tests/test_secrets.py index bf376888..d7ed915c 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -626,6 +626,23 @@ def test_a_start_without_a_process_id_is_an_unknown_outcome(): assert error.value.code == "secret_process_outcome_unknown" +@pytest.mark.anyio +@pytest.mark.parametrize("async_client", [False, True]) +@pytest.mark.parametrize("process_id", [42, True, " "]) +async def test_invalid_process_ids_are_unknown_outcomes(async_client, process_id): + manager, http = _sandbox_manager( + {("POST", "/sandbox/sbx_123/processes"): (200, {"process": {"id": process_id}})}, + async_client=async_client, + ) + with pytest.raises(HyperbrowserError) as error: + result = manager.start_secret_process("sbx_123", {"command": "x"}) + if async_client: + await result + assert error.value.code == "secret_process_outcome_unknown" + assert error.value.retryable is False + assert len(http.calls) == 1 + + def test_a_timed_out_start_is_an_unknown_outcome_but_a_refused_one_is_not(): timed_out = httpx.ReadTimeout("timed out") manager, _ = _sandbox_manager({("POST", "/sandbox/sbx_123/processes"): timed_out}) @@ -1238,6 +1255,23 @@ def test_a_name_with_hyphens_in_other_places_goes_straight_to_the_name_search(): CREATE_WITH_SECRETS = {"image_name": "node", "secrets": {"API_KEY": "provider-key"}} +@pytest.mark.anyio +@pytest.mark.parametrize("async_client", [False, True]) +@pytest.mark.parametrize("sandbox_id", ["", " "]) +async def test_empty_sandbox_ids_are_unknown_outcomes(async_client, sandbox_id): + manager, http = _sandbox_manager( + {("POST", "/sandbox"): (200, dict(SANDBOX_DETAIL_PAYLOAD, id=sandbox_id))}, + async_client=async_client, + ) + with pytest.raises(HyperbrowserError) as error: + result = manager.create(CREATE_WITH_SECRETS) + if async_client: + await result + assert error.value.code == "secret_process_outcome_unknown" + assert error.value.retryable is False + assert len(http.calls) == 1 + + @pytest.mark.parametrize( "outcome", [ @@ -1493,12 +1527,12 @@ async def test_use_shell_is_rejected_for_starts_with_secrets(api_type): def test_secret_names_env_names_and_versions_are_checked_locally(): - for name in ["", "-leading", "has space", "a" * 129]: + for name in ["", "-leading", "has space", "a" * 129, "name\n"]: with pytest.raises(ValueError, match="Invalid secret name"): CreateSecretParams(name=name) assert CreateSecretParams(name="a" * 128).name == "a" * 128 - for env in ["1ABC", "API-KEY", ""]: + for env in ["1ABC", "API-KEY", "", "__proto__", "KEY\n"]: with pytest.raises(ValueError, match="environment variable name"): SandboxExecParams(command="x", secrets={env: "provider-key"}) with pytest.raises(ValueError, match="environment variable name"): @@ -1506,7 +1540,7 @@ def test_secret_names_env_names_and_versions_are_checked_locally(): with pytest.raises(ValueError, match="Invalid secret name"): SandboxExecParams(command="x", secrets={"API_KEY": "bad name"}) - for version in [0, -1, 2**31]: + for version in [0, -1, 2**31, True, 1.0, "1"]: with pytest.raises(ValueError, match="Invalid secret version"): SandboxExecParams( command="x", secrets={"A": {"name": "k", "version": version}} @@ -1524,7 +1558,7 @@ def test_list_pages_and_version_numbers_are_checked_before_any_request(): for params in [{"limit": 0}, {"limit": 101}, {"page": 0}]: with pytest.raises(ValueError): manager.list(params) - for version in [0, 2**31, "1"]: + for version in [0, 2**31, "1", True, 1.0, 1.5]: with pytest.raises(HyperbrowserError) as error: manager.delete_version("provider-key", version) assert error.value.code == "invalid_secret_version" @@ -1542,3 +1576,35 @@ async def test_async_version_numbers_are_checked_before_any_request(): with pytest.raises(ValueError): await manager.list({"limit": 101}) assert http.calls == [] + + +def test_secret_request_repr_does_not_expose_the_value(): + params = CreateSecretParams(name="key", value="dummy-sensitive-value") + assert "dummy-sensitive-value" not in repr(params) + assert "dummy-sensitive-value" not in str(params) + assert "value" not in params.model_dump() + assert params.value == "dummy-sensitive-value" + + +def test_secret_request_validation_message_does_not_expose_the_value(): + with pytest.raises(ValueError) as error: + CreateSecretParams.model_validate({"value": "dummy-sensitive-value"}) + assert "dummy-sensitive-value" not in str(error.value) + + +@pytest.mark.parametrize( + "model,params", + [ + (CreateSandboxParams, {"image_name": "node"}), + (SandboxExecParams, {"command": "x"}), + ], +) +def test_secret_bindings_enforce_the_limit_and_omit_empty_maps(model, params): + from hyperbrowser.client._request import dump_request + + assert "secrets" not in dump_request(dict(params, secrets={}), model) + assert ( + len(model(**params, secrets={f"KEY_{i}": "k" for i in range(32)}).secrets) == 32 + ) + with pytest.raises(ValueError, match="32"): + model(**params, secrets={f"KEY_{i}": "k" for i in range(33)})