diff --git a/README.md b/README.md index 3bcf28ac..105d4163 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,104 @@ 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. 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` + 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. + 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 + `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 ```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..ad7dc7be 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -41,6 +41,13 @@ StartSandboxFromSnapshotParams, ) from ....models.session import BasicResponse +from ....secrets_common import ( + missing_process_id_error, + request_may_have_arrived, + sandbox_create_outcome_unknown_error, + secret_process_id, + secret_process_start_error, +) from ....types import ( CompleteSandboxImageBuildParams as CompleteSandboxImageBuildParamsDict, CreateSandboxImageBuildParams as CreateSandboxImageBuildParamsDict, @@ -72,6 +79,7 @@ completed_image_id, ) from ..sandboxes.shared import ( + SecretReferences, _build_sandbox_exposed_url, _copy_model, _expires_within_buffer, @@ -143,7 +151,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 +330,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 +348,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,16 +1152,44 @@ 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: + raise secret_process_start_error(sandbox_id, error) + process = response.get("process") if isinstance(response, dict) else None + process_id = secret_process_id(process) + if not process_id: + raise missing_process_id_error(sandbox_id) + 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) + 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. + 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 112920a9..e3a62232 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py @@ -1,8 +1,9 @@ import asyncio import base64 -from typing import AsyncIterator, Dict, Optional, Union +import time +from typing import Any, 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 +13,24 @@ SandboxProcessStdinParams, SandboxProcessSummary, ) +from .....exceptions import HyperbrowserError +from .....models.secret import SandboxResolvedSecret +from .....secrets_common import ( + SecretProcessWait, + SecretProcessWaitContext, + secret_process_failed_error, + secret_process_id, +) 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, @@ -28,13 +42,22 @@ 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 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: @@ -69,6 +92,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", @@ -77,6 +147,7 @@ async def wait( "timeout_sec": timeout_sec, }, headers={"content-type": "application/json"}, + **request_options, ) result = SandboxProcessResult(**payload["result"]) if result.output_truncated: @@ -260,8 +331,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 +353,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 +363,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 +382,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 +398,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 +422,58 @@ 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)) + process_id = secret_process_id(payload["process"]) + try: + handle = SandboxProcessHandle( + self._transport, + SandboxProcessSummary(**dict(payload["process"], id=process_id)), + 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 + ) -> 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 + ) + return await handle.wait() + 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/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/async_manager/secret.py b/hyperbrowser/client/managers/async_manager/secret.py new file mode 100644 index 00000000..970ab080 --- /dev/null +++ b/hyperbrowser/client/managers/async_manager/secret.py @@ -0,0 +1,208 @@ +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, + check_secret_version, + 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.""" + check_secret_version(version) + 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..14a131b7 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,55 @@ 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 + 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) +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 params.timeout_sec is not None and params.timeout_sec > 0: + 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..f15bfe01 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -39,6 +39,13 @@ StartSandboxFromSnapshotParams, ) from ....models.session import BasicResponse +from ....secrets_common import ( + missing_process_id_error, + request_may_have_arrived, + sandbox_create_outcome_unknown_error, + secret_process_id, + secret_process_start_error, +) from ....types import ( CompleteSandboxImageBuildParams as CompleteSandboxImageBuildParamsDict, CreateSandboxImageBuildParams as CreateSandboxImageBuildParamsDict, @@ -70,6 +77,7 @@ completed_image_id, ) from ..sandboxes.shared import ( + SecretReferences, _build_sandbox_exposed_url, _copy_model, _expires_within_buffer, @@ -135,7 +143,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 +322,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 +340,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,16 +1132,42 @@ 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: + raise secret_process_start_error(sandbox_id, error) + process = response.get("process") if isinstance(response, dict) else None + process_id = secret_process_id(process) + if not process_id: + raise missing_process_id_error(sandbox_id) + 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) + 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. + 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 9c70c56b..cd57ef81 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py @@ -1,8 +1,9 @@ import base64 +import time import threading -from typing import Dict, Optional, Union +from typing import Any, 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 +13,24 @@ SandboxProcessStdinParams, SandboxProcessSummary, ) +from .....exceptions import HyperbrowserError +from .....models.secret import SandboxResolvedSecret +from .....secrets_common import ( + SecretProcessWait, + SecretProcessWaitContext, + secret_process_failed_error, + secret_process_id, +) 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, @@ -28,14 +42,23 @@ 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 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: @@ -72,6 +95,48 @@ 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.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, + ) -> SandboxProcessResult: + request_options: Dict[str, Any] = {} + if request_timeout is not None: + request_options["request_timeout"] = request_timeout payload = self._transport.request_json( f"/sandbox/processes/{self.id}/wait", method="POST", @@ -80,6 +145,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: @@ -266,8 +332,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 +352,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 +362,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 +381,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 +397,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 +425,54 @@ 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)) + process_id = secret_process_id(payload["process"]) + try: + handle = SandboxProcessHandle( + self._transport, + SandboxProcessSummary(**dict(payload["process"], id=process_id)), + 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. + + 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 + ) + return handle.wait() + 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/sandboxes/sandbox_transport.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py index 9ddf9ce6..dc0e8b19 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py @@ -41,6 +41,7 @@ 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 = self._request( path, @@ -49,6 +50,7 @@ def request_json( json_body=json_body, content=content, headers=headers, + request_timeout=request_timeout, ) return parse_json_response(response, "runtime") @@ -184,6 +186,7 @@ def _request( content: Optional[Any] = None, headers: Optional[Dict[str, str]] = None, allow_refresh: bool = True, + request_timeout: Optional[float] = None, ) -> httpx.Response: connection = self._resolve_connection(False) response = self._send( @@ -194,6 +197,7 @@ def _request( json_body=json_body, content=content, headers=headers, + request_timeout=request_timeout, ) if response.status_code == 401 and allow_refresh: @@ -213,6 +217,7 @@ def _request( json_body=json_body, content=content, headers=headers, + request_timeout=request_timeout, ) return ensure_response_ok(retry, "runtime") @@ -309,6 +314,7 @@ 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( @@ -317,7 +323,9 @@ def _send( self._runtime_proxy_override, ) merged_headers = build_headers(connection.token, headers, target.host_header) - client = httpx.Client(timeout=self._timeout) + client = httpx.Client( + timeout=self._timeout if request_timeout is None else request_timeout + ) try: response = client.request( diff --git a/hyperbrowser/client/managers/sync_manager/secret.py b/hyperbrowser/client/managers/sync_manager/secret.py new file mode 100644 index 00000000..7895e9be --- /dev/null +++ b/hyperbrowser/client/managers/sync_manager/secret.py @@ -0,0 +1,200 @@ +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, + check_secret_version, + 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.""" + check_secret_version(version) + 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..d4751ccc --- /dev/null +++ b/hyperbrowser/models/secret.py @@ -0,0 +1,173 @@ +import re +from datetime import datetime +from typing import List, Literal, Optional, Union + +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 +MAX_SECRET_BINDINGS = 32 + + +def validate_secret_name(name: str) -> str: + 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" + ) + return name + + +def validate_secret_version(version: int) -> int: + 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}" + ) + return version + + +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 + + @field_validator("name") + @classmethod + def validate_name(cls, value): + return validate_secret_name(value) + + @field_validator("version", mode="before") + @classmethod + def validate_version(cls, value): + if value is not None and value != "latest": + 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}``, and + 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.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" + ) + return { + env: {"name": reference} if isinstance(reference, str) else reference + for env, reference in value.items() + } + + +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, repr=False) + + @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] = Field(default=None, ge=1) + limit: Optional[int] = Field(default=None, ge=1, le=100) + 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..75217ef6 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 @@ -13,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) @@ -69,23 +82,92 @@ 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. + + 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]: + 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 - code = parsed.get("code") if isinstance(parsed.get("code"), str) else None - return message, code, parsed + return message, error_payload_code(parsed), parsed, None - return fallback_message, None, parsed + return fallback_message, None, parsed, None def ensure_response_ok( @@ -99,16 +181,26 @@ 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) + if service == "control": + message = error_payload_message(details, message) raise HyperbrowserError( message, status_code=response.status_code, 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, + cause=decode_error, ) diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py new file mode 100644 index 00000000..0364027a --- /dev/null +++ b/hyperbrowser/secrets_common.py @@ -0,0 +1,388 @@ +"""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 re +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional, Type, 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, 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 + + +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.""" + 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: + 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: + 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 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. + 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_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 "" + process_id = process.get("id") or process.get("processId") + return process_id.strip() if isinstance(process_id, str) else "" + + +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, 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/hyperbrowser/transport/async_transport.py b/hyperbrowser/transport/async_transport.py index 53267064..7b10a2ce 100644 --- a/hyperbrowser/transport/async_transport.py +++ b/hyperbrowser/transport/async_transport.py @@ -5,6 +5,9 @@ from hyperbrowser.exceptions import HyperbrowserError from hyperbrowser.sandbox_common import ( RETRYABLE_STATUS_CODES, + error_payload_code, + error_payload_message, + error_payload_retryable, get_request_id, get_retry_delay_seconds, is_retryable_network_error, @@ -68,22 +71,18 @@ 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( 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..5b921267 100644 --- a/hyperbrowser/transport/sync.py +++ b/hyperbrowser/transport/sync.py @@ -6,6 +6,9 @@ from hyperbrowser.exceptions import HyperbrowserError from hyperbrowser.sandbox_common import ( RETRYABLE_STATUS_CODES, + error_payload_code, + error_payload_message, + error_payload_retryable, get_request_id, get_retry_delay_seconds, is_retryable_network_error, @@ -46,22 +49,18 @@ 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( 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..d7ed915c --- /dev/null +++ b/tests/test_secrets.py @@ -0,0 +1,1610 @@ +import base64 +import json +import asyncio +import time + +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, + SandboxExecParams, + SandboxSnapshotSummary, + StartSandboxFromSnapshotParams, +) +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, +) +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 + if isinstance(route, httpx.Response): + return 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, **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 + return {"success": True} + if path.endswith("/wait"): + step = self.waits.pop(0) + 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): + 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" + + +@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}) + 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 + + +@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( + "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("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) + + +# --- 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.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", + [ + 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, "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", "", "__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"): + 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, True, 1.0, "1"]: + 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", True, 1.0, 1.5]: + 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 == [] + + +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)}) 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)