From ed127ea316ee84a17e9398116d1eba2486af3635 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 16:24:50 -0700 Subject: [PATCH 01/23] Add website credentials and session credential fill Credentials are secrets of type "credential" whose value maps field names to strings. The value is checked locally, serialized as compact UTF-8 JSON and sealed with the encryption context the API returns, like any secret. - Secret gains type and metadata (browser.allowedOrigins); create accepts type, metadata and a mapping value; list filters by type; update sends metadata with null kept, so a member can be cleared. - secrets.create_credential() and secrets.set_allowed_origins(). - add_version() checks the value's shape against the secret's type. - sessions.create() takes credentials; the session lists attached ones. - sessions.credentials.fill() validates the request locally and posts it with a timeout of at least 30 seconds. - A str secret value that isn't valid UTF-8 (a lone surrogate) now raises invalid_secret_value instead of UnicodeEncodeError. Co-Authored-By: Claude Opus 5.5 --- .../client/managers/async_manager/secret.py | 86 ++- .../client/managers/async_manager/session.py | 2 + .../async_manager/session_credentials.py | 45 ++ .../client/managers/sync_manager/secret.py | 86 ++- .../client/managers/sync_manager/session.py | 2 + .../sync_manager/session_credentials.py | 45 ++ hyperbrowser/credentials_common.py | 83 +++ hyperbrowser/models/__init__.py | 24 + hyperbrowser/models/credential.py | 75 +++ hyperbrowser/models/secret.py | 89 ++- hyperbrowser/models/session.py | 20 + hyperbrowser/secrets_common.py | 120 +++- hyperbrowser/types/__init__.py | 16 + hyperbrowser/types/credential.py | 39 ++ hyperbrowser/types/secret.py | 41 +- hyperbrowser/types/session.py | 1 + tests/test_credentials.py | 619 ++++++++++++++++++ tests/typecheck/invalid_requests.py | 17 + tests/typecheck/valid_requests.py | 74 +++ 19 files changed, 1436 insertions(+), 48 deletions(-) create mode 100644 hyperbrowser/client/managers/async_manager/session_credentials.py create mode 100644 hyperbrowser/client/managers/sync_manager/session_credentials.py create mode 100644 hyperbrowser/credentials_common.py create mode 100644 hyperbrowser/models/credential.py create mode 100644 hyperbrowser/types/credential.py create mode 100644 tests/test_credentials.py diff --git a/hyperbrowser/client/managers/async_manager/secret.py b/hyperbrowser/client/managers/async_manager/secret.py index 970ab080..65d5d36e 100644 --- a/hyperbrowser/client/managers/async_manager/secret.py +++ b/hyperbrowser/client/managers/async_manager/secret.py @@ -1,5 +1,5 @@ import asyncio -from typing import Dict, Optional, Union +from typing import Dict, Mapping, Optional, Sequence, Union from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError @@ -10,8 +10,10 @@ SecretDetail, SecretListParams, SecretListResponse, + SecretMetadata, SecretsPublicKey, SecretVersion, + SecretValue, SecretVersionDeleteResult, UpdateSecretParams, ) @@ -26,12 +28,15 @@ from ....secrets_common import ( SECRET_NAME_LOOKUP_LIMIT, SecretsSealer, + browser_metadata, + check_secret_value, check_secret_version, is_not_found, looks_like_secret_id, secret_not_found_error, - secret_value_bytes, + secret_payload_bytes, secret_value_not_stored_error, + update_secret_body, ) from ....types import CreateSecretParams as CreateSecretParamsDict from ....types import SecretListParams as SecretListParamsDict @@ -76,10 +81,12 @@ async def create( so a bad value or an unavailable key leaves nothing behind. """ normalized = coerce_request(params, CreateSecretParams) - value = normalized.value + payload = None sealer = None - if value is not None: - secret_value_bytes(value) + if normalized.value is not None: + payload = secret_payload_bytes( + normalized.type or "secret", normalized.value + ) sealer = SecretsSealer(await self.get_public_key()) secret = Secret( **await self._request( @@ -88,10 +95,10 @@ async def create( data=normalized.model_dump(by_alias=True, exclude_none=True), ) ) - if sealer is None or value is None: + if sealer is None or payload is None: return secret try: - version = await self._add_version(secret, value, sealer) + version = await self._add_version(secret, payload, 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}) @@ -102,25 +109,67 @@ async def get(self, secret: str) -> SecretDetail: return detail return await self._get_by_id(detail.id) + async def create_credential( + self, + name: str, + value: Mapping[str, str], + allowed_origins: Sequence[str], + description: Optional[str] = None, + ) -> Secret: + """Create a website credential that browser sessions can fill. + + ``value`` maps field names to strings, such as + ``{"username": ..., "password": ...}``; it is encrypted locally and + stored as the first version. ``allowed_origins`` lists the origins + (``https://example.com``, ``https://*.example.com``) it may be filled on. + """ + return await self.create( + CreateSecretParams( + name=name, + description=description, + type="credential", + metadata=SecretMetadata.model_validate( + browser_metadata(allowed_origins) + ), + value=value, + ) + ) + async def update( self, secret: str, params: Union[UpdateSecretParamsDict, UpdateSecretParams], ) -> Secret: + """Update a secret's description or metadata. + + Metadata is merged by top-level member: each member sent replaces the + stored one, ``None`` removes it, and members not sent are kept. + """ + body = update_secret_body(params) secret_id = (await self._resolve(secret))[0].id - payload = await self._request( - "PATCH", - f"/secrets/{secret_id}", - data=dump_request(params, UpdateSecretParams), - ) + payload = await self._request("PATCH", f"/secrets/{secret_id}", data=body) 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) + async def set_allowed_origins( + self, secret: str, allowed_origins: Optional[Sequence[str]] + ) -> Secret: + """Replace the origins a credential may be filled on; ``None`` clears + them, after which the credential can't be filled.""" + return await self.update( + secret, {"metadata": browser_metadata(allowed_origins)} + ) + + async def add_version(self, secret: str, value: SecretValue) -> SecretVersion: + """Encrypt ``value`` locally and store it as the secret's next version. + + A credential takes a mapping of field names to strings; a plain secret + takes str or bytes. + """ + check_secret_value(value) resolved = (await self._resolve(secret))[0] + payload = secret_payload_bytes(resolved.type, value) return await self._add_version( - resolved, value, SecretsSealer(await self.get_public_key()) + resolved, payload, SecretsSealer(await self.get_public_key()) ) async def delete(self, secret: str) -> SecretDeleteResult: @@ -143,9 +192,10 @@ async def delete_version( return SecretVersionDeleteResult(**payload) async def _add_version( - self, secret: Secret, value: Union[str, bytes], sealer: SecretsSealer + self, secret: Secret, payload: bytes, sealer: SecretsSealer ) -> SecretVersion: - envelope = sealer.seal(secret.encryption_context, value) + # Always the context the API returned; it differs by secret type. + envelope = sealer.seal(secret.encryption_context, payload) payload = await self._request( "POST", f"/secrets/{secret.id}/versions", data={"envelope": envelope} ) diff --git a/hyperbrowser/client/managers/async_manager/session.py b/hyperbrowser/client/managers/async_manager/session.py index 79452284..3ebc01c5 100644 --- a/hyperbrowser/client/managers/async_manager/session.py +++ b/hyperbrowser/client/managers/async_manager/session.py @@ -1,5 +1,6 @@ import warnings +from .session_credentials import SessionCredentialsManager from .webmcp import WebMCPManager from collections.abc import Mapping from typing import IO, List, Optional, Union, overload @@ -71,6 +72,7 @@ def __init__(self, client): self._client = client self.event_logs = SessionEventLogsManager(client) self.webmcp = WebMCPManager(client) + self.credentials = SessionCredentialsManager(client) async def create( self, diff --git a/hyperbrowser/client/managers/async_manager/session_credentials.py b/hyperbrowser/client/managers/async_manager/session_credentials.py new file mode 100644 index 00000000..57a3969d --- /dev/null +++ b/hyperbrowser/client/managers/async_manager/session_credentials.py @@ -0,0 +1,45 @@ +from typing import Union +from urllib.parse import quote + +from hyperbrowser.credentials_common import ( + CREDENTIAL_FILL_REQUEST_TIMEOUT_SECONDS, + fill_credential_payload, +) +from hyperbrowser.models.credential import CredentialFillResult, FillCredentialParams +from hyperbrowser.types.credential import ( + FillCredentialParams as FillCredentialParamsDict, +) + + +class SessionCredentialsManager: + """Fill credentials attached to a session into its pages. + + Attach credentials with ``sessions.create({"credentials": [...]})``. + """ + + def __init__(self, client): + self._client = client + + async def fill( + self, + session_id: str, + params: Union[FillCredentialParamsDict, FillCredentialParams], + ) -> CredentialFillResult: + """Type a credential's fields into the inputs matching each selector. + + Fill never submits: click the login button or press Enter afterwards. + It is safe to retry. Values are never returned; each field reports an + outcome such as ``filled`` or ``not_found``. When several tabs could + be the target, the API fails with ``CREDENTIAL_PAGE_AMBIGUOUS`` and + lists them in ``error.details["details"]["pages"]``; pass one as + ``page_id``. + """ + body = fill_credential_payload(params) + response = await self._client.transport.post( + self._client._build_url( + f"/session/{quote(session_id, safe='')}/credentials/fill" + ), + data=body, + timeout=max(self._client.timeout, CREDENTIAL_FILL_REQUEST_TIMEOUT_SECONDS), + ) + return CredentialFillResult(**response.data) diff --git a/hyperbrowser/client/managers/sync_manager/secret.py b/hyperbrowser/client/managers/sync_manager/secret.py index 7895e9be..1276b269 100644 --- a/hyperbrowser/client/managers/sync_manager/secret.py +++ b/hyperbrowser/client/managers/sync_manager/secret.py @@ -1,5 +1,5 @@ import time -from typing import Dict, Optional, Union +from typing import Dict, Mapping, Optional, Sequence, Union from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError @@ -10,8 +10,10 @@ SecretDetail, SecretListParams, SecretListResponse, + SecretMetadata, SecretsPublicKey, SecretVersion, + SecretValue, SecretVersionDeleteResult, UpdateSecretParams, ) @@ -26,12 +28,15 @@ from ....secrets_common import ( SECRET_NAME_LOOKUP_LIMIT, SecretsSealer, + browser_metadata, + check_secret_value, check_secret_version, is_not_found, looks_like_secret_id, secret_not_found_error, - secret_value_bytes, + secret_payload_bytes, secret_value_not_stored_error, + update_secret_body, ) from ....types import CreateSecretParams as CreateSecretParamsDict from ....types import SecretListParams as SecretListParamsDict @@ -76,10 +81,12 @@ def create( so a bad value or an unavailable key leaves nothing behind. """ normalized = coerce_request(params, CreateSecretParams) - value = normalized.value + payload = None sealer = None - if value is not None: - secret_value_bytes(value) + if normalized.value is not None: + payload = secret_payload_bytes( + normalized.type or "secret", normalized.value + ) sealer = SecretsSealer(self.get_public_key()) secret = Secret( **self._request( @@ -88,10 +95,10 @@ def create( data=normalized.model_dump(by_alias=True, exclude_none=True), ) ) - if sealer is None or value is None: + if sealer is None or payload is None: return secret try: - version = self._add_version(secret, value, sealer) + version = self._add_version(secret, payload, 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}) @@ -102,24 +109,66 @@ def get(self, secret: str) -> SecretDetail: return detail return self._get_by_id(detail.id) + def create_credential( + self, + name: str, + value: Mapping[str, str], + allowed_origins: Sequence[str], + description: Optional[str] = None, + ) -> Secret: + """Create a website credential that browser sessions can fill. + + ``value`` maps field names to strings, such as + ``{"username": ..., "password": ...}``; it is encrypted locally and + stored as the first version. ``allowed_origins`` lists the origins + (``https://example.com``, ``https://*.example.com``) it may be filled on. + """ + return self.create( + CreateSecretParams( + name=name, + description=description, + type="credential", + metadata=SecretMetadata.model_validate( + browser_metadata(allowed_origins) + ), + value=value, + ) + ) + def update( self, secret: str, params: Union[UpdateSecretParamsDict, UpdateSecretParams], ) -> Secret: + """Update a secret's description or metadata. + + Metadata is merged by top-level member: each member sent replaces the + stored one, ``None`` removes it, and members not sent are kept. + """ + body = update_secret_body(params) secret_id = self._resolve(secret)[0].id - payload = self._request( - "PATCH", - f"/secrets/{secret_id}", - data=dump_request(params, UpdateSecretParams), - ) + payload = self._request("PATCH", f"/secrets/{secret_id}", data=body) 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) + def set_allowed_origins( + self, secret: str, allowed_origins: Optional[Sequence[str]] + ) -> Secret: + """Replace the origins a credential may be filled on; ``None`` clears + them, after which the credential can't be filled.""" + return self.update(secret, {"metadata": browser_metadata(allowed_origins)}) + + def add_version(self, secret: str, value: SecretValue) -> SecretVersion: + """Encrypt ``value`` locally and store it as the secret's next version. + + A credential takes a mapping of field names to strings; a plain secret + takes str or bytes. + """ + check_secret_value(value) resolved = self._resolve(secret)[0] - return self._add_version(resolved, value, SecretsSealer(self.get_public_key())) + payload = secret_payload_bytes(resolved.type, value) + return self._add_version( + resolved, payload, SecretsSealer(self.get_public_key()) + ) def delete(self, secret: str) -> SecretDeleteResult: """Delete a secret and all its versions. Values already delivered to @@ -135,9 +184,10 @@ def delete_version(self, secret: str, version: int) -> SecretVersionDeleteResult return SecretVersionDeleteResult(**payload) def _add_version( - self, secret: Secret, value: Union[str, bytes], sealer: SecretsSealer + self, secret: Secret, payload: bytes, sealer: SecretsSealer ) -> SecretVersion: - envelope = sealer.seal(secret.encryption_context, value) + # Always the context the API returned; it differs by secret type. + envelope = sealer.seal(secret.encryption_context, payload) payload = self._request( "POST", f"/secrets/{secret.id}/versions", data={"envelope": envelope} ) diff --git a/hyperbrowser/client/managers/sync_manager/session.py b/hyperbrowser/client/managers/sync_manager/session.py index 97713c0d..adcf782b 100644 --- a/hyperbrowser/client/managers/sync_manager/session.py +++ b/hyperbrowser/client/managers/sync_manager/session.py @@ -1,5 +1,6 @@ import warnings +from .session_credentials import SessionCredentialsManager from .webmcp import WebMCPManager from collections.abc import Mapping from typing import IO, List, Optional, Union, overload @@ -71,6 +72,7 @@ def __init__(self, client): self._client = client self.event_logs = SessionEventLogsManager(client) self.webmcp = WebMCPManager(client) + self.credentials = SessionCredentialsManager(client) def create( self, diff --git a/hyperbrowser/client/managers/sync_manager/session_credentials.py b/hyperbrowser/client/managers/sync_manager/session_credentials.py new file mode 100644 index 00000000..b9d1a5b6 --- /dev/null +++ b/hyperbrowser/client/managers/sync_manager/session_credentials.py @@ -0,0 +1,45 @@ +from typing import Union +from urllib.parse import quote + +from hyperbrowser.credentials_common import ( + CREDENTIAL_FILL_REQUEST_TIMEOUT_SECONDS, + fill_credential_payload, +) +from hyperbrowser.models.credential import CredentialFillResult, FillCredentialParams +from hyperbrowser.types.credential import ( + FillCredentialParams as FillCredentialParamsDict, +) + + +class SessionCredentialsManager: + """Fill credentials attached to a session into its pages. + + Attach credentials with ``sessions.create({"credentials": [...]})``. + """ + + def __init__(self, client): + self._client = client + + def fill( + self, + session_id: str, + params: Union[FillCredentialParamsDict, FillCredentialParams], + ) -> CredentialFillResult: + """Type a credential's fields into the inputs matching each selector. + + Fill never submits: click the login button or press Enter afterwards. + It is safe to retry. Values are never returned; each field reports an + outcome such as ``filled`` or ``not_found``. When several tabs could + be the target, the API fails with ``CREDENTIAL_PAGE_AMBIGUOUS`` and + lists them in ``error.details["details"]["pages"]``; pass one as + ``page_id``. + """ + body = fill_credential_payload(params) + response = self._client.transport.post( + self._client._build_url( + f"/session/{quote(session_id, safe='')}/credentials/fill" + ), + data=body, + timeout=max(self._client.timeout, CREDENTIAL_FILL_REQUEST_TIMEOUT_SECONDS), + ) + return CredentialFillResult(**response.data) diff --git a/hyperbrowser/credentials_common.py b/hyperbrowser/credentials_common.py new file mode 100644 index 00000000..0f6b3459 --- /dev/null +++ b/hyperbrowser/credentials_common.py @@ -0,0 +1,83 @@ +"""Credential fill rules shared by the sync and async clients and the tools.""" + +import re +from typing import Any, Dict + +from pydantic import ValidationError + +from .client._request import coerce_request +from .exceptions import HyperbrowserError +from .models.credential import FillCredentialParams + +# Credential field names: the keys of a stored credential value, which are +# also the field names a fill writes. +CREDENTIAL_FIELD_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$") +MAX_CREDENTIAL_FILL_FIELDS = 16 +MAX_CREDENTIAL_SELECTOR_BYTES = 1024 +MAX_CREDENTIAL_PAGE_ID_BYTES = 128 +MAX_CREDENTIAL_FRAME_URL_BYTES = 2048 +# The server gives a fill about 18 seconds, so wait longer than that. +CREDENTIAL_FILL_REQUEST_TIMEOUT_SECONDS = 30 + + +def _invalid(message: str) -> HyperbrowserError: + return HyperbrowserError(message, code="invalid_request") + + +def _utf8_length(value: str) -> int: + return len(value.encode("utf-8", "surrogatepass")) + + +def fill_credential_payload(params: Any) -> Dict[str, Any]: + """Check a fill request locally and return its JSON body. + + Raises ``HyperbrowserError`` with code ``invalid_request`` before any + request is sent. + """ + try: + request = coerce_request(params, FillCredentialParams) + except ValidationError as error: + problems = "; ".join( + f"{'.'.join(str(part) for part in problem['loc']) or 'params'}: " + f"{problem['msg']}" + for problem in error.errors() + ) + raise _invalid(f"Invalid credential fill request: {problems}") from None + except TypeError as error: + raise _invalid(str(error)) from None + + if not request.credential.strip(): + raise _invalid("credential must name a credential attached to the session") + if not 1 <= len(request.fields) <= MAX_CREDENTIAL_FILL_FIELDS: + raise _invalid( + f"fields must have 1 to {MAX_CREDENTIAL_FILL_FIELDS} entries, " + f"got {len(request.fields)}" + ) + seen = set() + for entry in request.fields: + if not CREDENTIAL_FIELD_PATTERN.fullmatch(entry.field): + raise _invalid( + f"Invalid field {entry.field!r}: field names are 1-64 letters, " + "digits, '_', '.' or '-'" + ) + if entry.field in seen: + raise _invalid(f"Field {entry.field!r} is listed more than once") + seen.add(entry.field) + if not entry.selector.strip(): + raise _invalid(f"The selector for field {entry.field!r} is empty") + if _utf8_length(entry.selector) > MAX_CREDENTIAL_SELECTOR_BYTES: + raise _invalid( + f"The selector for field {entry.field!r} is longer than " + f"{MAX_CREDENTIAL_SELECTOR_BYTES} bytes" + ) + if request.page_id is not None and not ( + 0 < _utf8_length(request.page_id) <= MAX_CREDENTIAL_PAGE_ID_BYTES + ): + raise _invalid( + f"page_id must be 1 to {MAX_CREDENTIAL_PAGE_ID_BYTES} bytes when set" + ) + if request.frame is not None and not ( + 0 < _utf8_length(request.frame.url) <= MAX_CREDENTIAL_FRAME_URL_BYTES + ): + raise _invalid(f"frame.url must be 1 to {MAX_CREDENTIAL_FRAME_URL_BYTES} bytes") + return request.model_dump(by_alias=True, exclude_none=True) diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index d21058bc..7475b841 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -1,3 +1,11 @@ +from .credential import ( + CredentialFieldResult, + CredentialFillField, + CredentialFillFrame, + CredentialFillOutcome, + CredentialFillResult, + FillCredentialParams, +) from .webmcp import ( WebMCPAnnotations, WebMCPFrame, @@ -256,6 +264,7 @@ ) from .secret import ( CreateSecretParams, + CredentialBrowserMetadata, SandboxResolvedSecret, Secret, SecretDeleteResult, @@ -263,9 +272,12 @@ SecretJWK, SecretListParams, SecretListResponse, + SecretMetadata, SecretReference, SecretReferenceInput, SecretsPublicKey, + SecretType, + SecretValue, SecretVersion, SecretVersionDeleteResult, UpdateSecretParams, @@ -332,6 +344,7 @@ GetSessionVideoRecordingUrlResponse, ScreenConfig, Session, + SessionCredential, SessionDetail, SessionGetParams, SessionListParams, @@ -475,6 +488,12 @@ from .team import TeamCreditInfo __all__ = [ + "CredentialFieldResult", + "CredentialFillField", + "CredentialFillFrame", + "CredentialFillOutcome", + "CredentialFillResult", + "FillCredentialParams", "WebMCPAnnotations", "WebMCPFrame", "WebMCPSource", @@ -652,6 +671,7 @@ "ProfileResponse", # secret "CreateSecretParams", + "CredentialBrowserMetadata", "SandboxResolvedSecret", "Secret", "SecretDeleteResult", @@ -659,9 +679,12 @@ "SecretJWK", "SecretListParams", "SecretListResponse", + "SecretMetadata", "SecretReference", "SecretReferenceInput", "SecretsPublicKey", + "SecretType", + "SecretValue", "SecretVersion", "SecretVersionDeleteResult", "UpdateSecretParams", @@ -698,6 +721,7 @@ "GetSessionVideoRecordingUrlResponse", "ScreenConfig", "Session", + "SessionCredential", "SessionDetail", "SessionGetParams", "SessionListParams", diff --git a/hyperbrowser/models/credential.py b/hyperbrowser/models/credential.py new file mode 100644 index 00000000..2d8b1077 --- /dev/null +++ b/hyperbrowser/models/credential.py @@ -0,0 +1,75 @@ +"""Request and response models for filling stored credentials into pages.""" + +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +# What a fill did with each requested field. New outcomes may be added, so +# results keep any string the server sends. +# filled the input now holds exactly the stored value +# not_found no input matched the selector +# ambiguous several visible inputs matched the selector +# not_editable the input is disabled, read-only, hidden or not a text input +# not_accepted the page didn't keep the value (for example maxlength) +# not_populated the stored credential has no such field +CredentialFillOutcome = Literal[ + "filled", + "not_found", + "ambiguous", + "not_editable", + "not_accepted", + "not_populated", +] + + +class CredentialFillField(BaseModel): + """Types the credential's ``field`` (a key of the stored value, such as + ``"username"``) into the input matching the CSS ``selector``.""" + + model_config = ConfigDict(populate_by_name=True) + + field: str + selector: str + + +class CredentialFillFrame(BaseModel): + """Selects an iframe by URL prefix.""" + + model_config = ConfigDict(populate_by_name=True) + + url: str + + +class FillCredentialParams(BaseModel): + """Fill a credential attached to the session into the page's inputs. + + ``credential`` is the credential's name or ID. ``page_id`` is a CDP target + ID; it is needed when several tabs could be the target. + """ + + model_config = ConfigDict(populate_by_name=True) + + credential: str + fields: List[CredentialFillField] + page_id: Optional[str] = Field(default=None, serialization_alias="pageId") + frame: Optional[CredentialFillFrame] = None + + +class CredentialFieldResult(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + field: str + # One of CredentialFillOutcome; kept as str so new outcomes still parse. + outcome: str + + +class CredentialFillResult(BaseModel): + """The outcome of a fill, per field. Values are never returned.""" + + model_config = ConfigDict(populate_by_name=True) + + # The credential version that was filled. + version: int + page_id: Optional[str] = Field(default=None, alias="pageId") + origin: Optional[str] = None + fields: List[CredentialFieldResult] = Field(default_factory=list) diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py index d4751ccc..1f6bd6bf 100644 --- a/hyperbrowser/models/secret.py +++ b/hyperbrowser/models/secret.py @@ -1,8 +1,9 @@ import re +from collections.abc import Mapping as MappingABC from datetime import datetime -from typing import List, Literal, Optional, Union +from typing import List, Literal, Mapping, Optional, Union -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator # The server's rules for secret names, the environment variables secrets are # delivered to, and version numbers. @@ -89,28 +90,99 @@ def normalize_secret_references(value): } +# A plain secret holds one string; a credential holds a website login (an +# object of strings such as {"username": ..., "password": ...}) that browser +# sessions fill into pages. The type is fixed when the secret is created. +SecretType = Literal["secret", "credential"] + +# A secret value: str or bytes for a plain secret, or a mapping of field names +# to strings for a credential. +SecretValue = Union[str, bytes, Mapping[str, str]] + + +class CredentialBrowserMetadata(SecretBaseModel): + """Where a credential may be filled. + + Origins are ``scheme://host[:port]``; a leading ``*.`` matches any + subdomain. The server normalizes and checks them. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + allowed_origins: List[str] = Field(alias="allowedOrigins") + + +class SecretMetadata(SecretBaseModel): + """Metadata stored with a secret. Credentials use ``browser``. + + Members the SDK doesn't know are kept. In an update, each member sent + replaces the stored one and ``None`` removes it. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + browser: Optional[CredentialBrowserMetadata] = None + + +def _check_secret_value_shape(value): + # Contents are checked by the secrets manager, which knows the secret's + # type; this only keeps unusable types out of the request model. + if value is None or isinstance(value, (str, bytes)): + return value + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, MappingABC): + return dict(value) + raise ValueError( + "secret value must be str or bytes, or a mapping of field names to " + "strings for a credential" + ) + + class CreateSecretParams(SecretBaseModel): model_config = ConfigDict(populate_by_name=True, hide_input_in_errors=True) name: str description: Optional[str] = None + # Omitted means "secret". + type: Optional[SecretType] = None + metadata: Optional[SecretMetadata] = 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) + value: Optional[SecretValue] = Field(default=None, exclude=True, repr=False) @field_validator("name") @classmethod def validate_name(cls, value): return validate_secret_name(value) + @field_validator("value", mode="plain") + @classmethod + def validate_value(cls, value): + return _check_secret_value_shape(value) + class UpdateSecretParams(SecretBaseModel): - description: str + """A description, metadata, or both. + + Metadata is merged by top-level member: each member sent replaces the + stored one, ``None`` removes it, and members not sent are kept. + """ + + description: Optional[str] = None + metadata: Optional[SecretMetadata] = None + + @model_validator(mode="after") + def require_a_change(self): + if self.description is None and self.metadata is None: + raise ValueError("Update a secret's description, metadata, or both") + return self 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 + type: Optional[SecretType] = None class SecretJWK(SecretBaseModel): @@ -130,11 +202,20 @@ class Secret(SecretBaseModel): id: str name: str description: str = "" + type: SecretType = "secret" + metadata: SecretMetadata = Field(default_factory=SecretMetadata) 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") + @field_validator("type", "metadata", mode="before") + @classmethod + def default_when_null(cls, value, info): + if value is not None: + return value + return "secret" if info.field_name == "type" else {} + class SecretVersion(SecretBaseModel): version: int diff --git a/hyperbrowser/models/session.py b/hyperbrowser/models/session.py index 870d6a2f..bfb3ccc3 100644 --- a/hyperbrowser/models/session.py +++ b/hyperbrowser/models/session.py @@ -253,6 +253,17 @@ def parse_timestamp(cls, value: Optional[Union[str, int]]) -> Optional[int]: return value +class SessionCredential(BaseModel): + """A credential attached to a session at creation.""" + + model_config = ConfigDict( + populate_by_alias=True, + ) + + id: str + name: str + + class SessionDetail(Session): """ Detailed session information including websocket endpoint. @@ -270,6 +281,9 @@ class SessionDetail(Session): live_url: str = Field(alias="liveUrl") live_domain: Optional[str] = Field(alias="liveDomain", default=None) token: str = Field(alias="token") + credentials: Optional[List[SessionCredential]] = Field( + default=None, alias="credentials" + ) class SessionGetParams(BaseModel): @@ -602,6 +616,12 @@ class CreateSessionParams(BaseModel): default=None, serialization_alias="startFromSnapshot", ) + credentials: Optional[List[str]] = Field( + default=None, serialization_alias="credentials" + ) + """Names of credentials (up to 20) that ``sessions.credentials.fill()`` + may fill into this session's pages. Credentials can only be attached when + the session is created.""" class CreateSessionSnapshotResponse(BaseModel): diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index 0364027a..0686a9a5 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -11,16 +11,23 @@ import os import re import time +from collections.abc import Mapping from dataclasses import dataclass -from typing import Any, Dict, Optional, Type, Union +from typing import Any, Dict, Optional, Sequence, 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 .client._request import coerce_request +from .credentials_common import CREDENTIAL_FIELD_PATTERN from .exceptions import HyperbrowserError -from .models.secret import SecretsPublicKey, validate_secret_version +from .models.secret import ( + SecretsPublicKey, + UpdateSecretParams, + validate_secret_version, +) # The largest secret value the platform accepts. MAX_SECRET_VALUE_BYTES = 32 * 1024 @@ -54,7 +61,12 @@ def secret_value_bytes(value: Union[str, bytes, bytearray]) -> bytes: Values are used byte-for-byte, including trailing newlines. """ if isinstance(value, str): - data = value.encode("utf-8") + try: + data = value.encode("utf-8") + except UnicodeEncodeError: + raise HyperbrowserError( + "Secret value must be valid UTF-8", code="invalid_secret_value" + ) from None elif isinstance(value, (bytes, bytearray)): data = bytes(value) else: @@ -81,6 +93,108 @@ def secret_value_bytes(value: Union[str, bytes, bytearray]) -> bytes: return data +def _invalid_value(message: str) -> HyperbrowserError: + return HyperbrowserError(message, code="invalid_secret_value") + + +def credential_value_bytes(value: Any) -> bytes: + """Return a credential value's bytes: its compact UTF-8 JSON. + + A credential value maps field names (such as ``"username"``) to non-empty + strings. Field names match ``^[a-zA-Z0-9_.-]{1,64}$``, and the JSON follows + the same size and UTF-8 rules as plain secret values. Errors name fields, + never values. + """ + if not isinstance(value, Mapping): + raise _invalid_value( + "Credential value must be a mapping of field names to strings, " + f"got {type(value).__name__}" + ) + if not value: + raise _invalid_value("Credential value must have at least one field") + fields: Dict[str, str] = {} + for field, field_value in value.items(): + if not isinstance(field, str) or not CREDENTIAL_FIELD_PATTERN.fullmatch(field): + raise _invalid_value( + "Credential field names must be 1-64 letters, digits, '_', '.' " + "or '-'" + ) + if not isinstance(field_value, str): + raise _invalid_value( + f'Credential field "{field}" must be a string, ' + f"got {type(field_value).__name__}" + ) + if not field_value: + raise _invalid_value(f'Credential field "{field}" is empty') + if "\x00" in field_value: + raise _invalid_value( + f'Credential field "{field}" must not contain NUL characters' + ) + fields[field] = field_value + serialized = json.dumps(fields, separators=(",", ":"), ensure_ascii=False) + return secret_value_bytes(serialized) + + +def secret_payload_bytes(secret_type: str, value: Any) -> bytes: + """Check a value against its secret's type and return the bytes to encrypt. + + Credentials take a mapping of field names to strings; plain secrets take + str or bytes. + """ + if secret_type == "credential": + if not isinstance(value, Mapping): + raise _invalid_value( + "A credential's value must be a mapping of field names to " + 'strings, such as {"username": ..., "password": ...}, got ' + f"{type(value).__name__}" + ) + return credential_value_bytes(value) + if isinstance(value, Mapping): + raise _invalid_value( + "Only credentials take a mapping value; a plain secret's value is " + "str or bytes" + ) + if not isinstance(value, (str, bytes, bytearray)): + raise _invalid_value( + f"Secret value must be str or bytes, got {type(value).__name__}" + ) + return secret_value_bytes(value) + + +def check_secret_value(value: Any) -> None: + """Check a value's contents before its secret's type is known.""" + secret_payload_bytes( + "credential" if isinstance(value, Mapping) else "secret", value + ) + + +def browser_metadata(allowed_origins: Optional[Sequence[str]]) -> Dict[str, Any]: + """Credential metadata allowing fills on ``allowed_origins``; ``None`` + clears the browser metadata in an update. The server checks the origins.""" + if allowed_origins is None: + return {"browser": None} + if isinstance(allowed_origins, (str, bytes)) or not all( + isinstance(origin, str) for origin in allowed_origins + ): + raise HyperbrowserError( + "allowed_origins must be a list of origins such as " + '["https://example.com"]', + code="invalid_secret_metadata", + ) + return {"browser": {"allowed_origins": list(allowed_origins)}} + + +def update_secret_body(params: Any) -> Dict[str, Any]: + """Serialize a secret update. + + Fields left out are not sent. Inside ``metadata``, a member set to + ``None`` is sent as ``null``, which removes it. + """ + normalized = coerce_request(params, UpdateSecretParams) + body = normalized.model_dump(by_alias=True, exclude_unset=True) + return {key: value for key, value in body.items() if value is not None} + + 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() diff --git a/hyperbrowser/types/__init__.py b/hyperbrowser/types/__init__.py index 44689479..72680669 100644 --- a/hyperbrowser/types/__init__.py +++ b/hyperbrowser/types/__init__.py @@ -5,6 +5,11 @@ as JSON Schema objects, untouched. """ +from .credential import ( + CredentialFillField, + CredentialFillFrame, + FillCredentialParams, +) from .webmcp import ( WebMCPInvokeParams, WebMCPStartParams, @@ -157,9 +162,13 @@ ) from .secret import ( CreateSecretParams, + CredentialBrowserMetadata, SecretListParams, + SecretMetadata, SecretReference, SecretReferenceInput, + SecretType, + SecretValue, UpdateSecretParams, ) from .volume import CreateVolumeParams, VolumeListParams @@ -192,6 +201,9 @@ __all__ = [ + "CredentialFillField", + "CredentialFillFrame", + "FillCredentialParams", "WebMCPInvokeParams", "WebMCPStartParams", "WebMCPResultParams", @@ -333,9 +345,13 @@ "UpdateSessionScreenParams", "UpdateSessionSolveCaptchasParams", "CreateSecretParams", + "CredentialBrowserMetadata", "SecretListParams", + "SecretMetadata", "SecretReference", "SecretReferenceInput", + "SecretType", + "SecretValue", "UpdateSecretParams", "CreateVolumeParams", "VolumeListParams", diff --git a/hyperbrowser/types/credential.py b/hyperbrowser/types/credential.py new file mode 100644 index 00000000..b45ab94b --- /dev/null +++ b/hyperbrowser/types/credential.py @@ -0,0 +1,39 @@ +"""Typed dictionary requests for filling stored credentials into pages.""" + +from typing import List, Optional + +from typing_extensions import Required, TypedDict + + +class CredentialFillField(TypedDict): + """Types the credential's ``field`` (a key of the stored value, such as + ``"username"``) into the input matching the CSS ``selector``.""" + + field: str + selector: str + + +class CredentialFillFrame(TypedDict): + """Selects an iframe by URL prefix.""" + + url: str + + +class FillCredentialParams(TypedDict, total=False): + """Fill 1-16 fields of a credential attached to the session. + + ``page_id`` is a CDP target ID, needed when several tabs could be the + target; ``frame`` selects an iframe by URL prefix. + """ + + credential: Required[str] + fields: Required[List[CredentialFillField]] + page_id: Optional[str] + frame: Optional[CredentialFillFrame] + + +__all__ = [ + "CredentialFillField", + "CredentialFillFrame", + "FillCredentialParams", +] diff --git a/hyperbrowser/types/secret.py b/hyperbrowser/types/secret.py index 0fe8a1b9..5727a3dc 100644 --- a/hyperbrowser/types/secret.py +++ b/hyperbrowser/types/secret.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Union +from typing import List, Literal, Mapping, Optional, Union from typing_extensions import Required, TypeAlias, TypedDict @@ -14,18 +14,44 @@ class SecretReference(TypedDict, total=False): SecretReferenceInput: TypeAlias = Union[str, SecretReference] +# A plain secret holds one string; a credential holds a website login that +# browser sessions fill into pages. Fixed when the secret is created. +SecretType = Literal["secret", "credential"] + +# str or bytes for a plain secret; a mapping of field names (such as +# "username" and "password") to strings for a credential. +SecretValue: TypeAlias = Union[str, bytes, Mapping[str, str]] + + +class CredentialBrowserMetadata(TypedDict): + """Origins (``scheme://host[:port]``, ``*.`` for subdomains) a credential + may be filled on.""" + + allowed_origins: List[str] + + +class SecretMetadata(TypedDict, total=False): + """Secret metadata. In an update, ``None`` removes a member.""" + + browser: Optional[CredentialBrowserMetadata] + + 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]] + type: Optional[SecretType] + metadata: Optional[SecretMetadata] + value: Optional[SecretValue] -class UpdateSecretParams(TypedDict): - """Parameters for updating a secret's description.""" +class UpdateSecretParams(TypedDict, total=False): + """A new description, metadata members to replace (``None`` removes one), + or both.""" - description: str + description: Optional[str] + metadata: Optional[SecretMetadata] class SecretListParams(TypedDict, total=False): @@ -34,12 +60,17 @@ class SecretListParams(TypedDict, total=False): page: Optional[int] limit: Optional[int] search: Optional[str] + type: Optional[SecretType] __all__ = [ "CreateSecretParams", + "CredentialBrowserMetadata", "SecretListParams", + "SecretMetadata", "SecretReference", "SecretReferenceInput", + "SecretType", + "SecretValue", "UpdateSecretParams", ] diff --git a/hyperbrowser/types/session.py b/hyperbrowser/types/session.py index 7e7a9ed1..5c97ed05 100644 --- a/hyperbrowser/types/session.py +++ b/hyperbrowser/types/session.py @@ -184,6 +184,7 @@ class CreateSessionParams(TypedDict, total=False): disable_post_quantum_key_agreement: Optional[bool] browser_memory_size: Optional[BrowserMemorySize] start_from_snapshot: Optional[StartSessionFromSnapshotParams] + credentials: Optional[List[str]] class UpdateSessionSolveCaptchasParams(TypedDict, total=False): diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 00000000..fff9c4e8 --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,619 @@ +"""Credentials: typed secrets that browser sessions fill into pages.""" + +import asyncio +import json + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from hyperbrowser import AsyncHyperbrowser, Hyperbrowser +from hyperbrowser.client.managers.async_manager.secret import ( + SecretManager as AsyncSecretManager, +) +from hyperbrowser.client.managers.sync_manager.secret import SecretManager +from hyperbrowser.exceptions import HyperbrowserError +from hyperbrowser.models import ( + CreateSecretParams, + CreateSessionParams, + FillCredentialParams, + Secret, + SecretMetadata, + UpdateSecretParams, +) +from hyperbrowser.secrets_common import credential_value_bytes +from tests.test_secrets import ( + SECRET_ID, + AsyncScriptedHTTPClient, + FakeClient, + ScriptedHTTPClient, + _open, + _public_key_payload, + _secret, +) + +CREDENTIAL_CONTEXT = f"hb-secret-v1:team_1:{SECRET_ID}:credential" +LOGIN = {"username": "ada@example.com", "password": "pässwörd-hunter2"} +ORIGINS = ["https://github.com", "https://*.github.com"] + + +@pytest.fixture(scope="module") +def private_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=3072) + + +def _credential(name="github", origins=None, latest=None): + return dict( + _secret(name=name, latest=latest), + type="credential", + encryptionContext=CREDENTIAL_CONTEXT, + metadata={"browser": {"allowedOrigins": origins or ORIGINS}}, + ) + + +def _create_routes(private_key, upload=None): + return { + ("GET", "/secrets/public-key"): (200, _public_key_payload(private_key)), + ("POST", "/secrets"): (201, _credential()), + ("POST", f"/secrets/{SECRET_ID}/versions"): upload + or (201, {"version": 1, "kid": "k", "createdAt": "2026-10-01T00:00:00Z"}), + } + + +def _assert_no_plaintext(calls): + sent = json.dumps([call["json"] for call in calls], ensure_ascii=False) + for value in LOGIN.values(): + assert value not in sent + + +# --- secret type and metadata --- + + +def test_secrets_default_to_plain_and_keep_unknown_metadata(): + plain = Secret.model_validate(_secret()) + assert plain.type == "secret" + assert plain.metadata.browser is None + + for missing in [None, {}]: + payload = dict(_secret(), type=None, metadata=missing) + assert Secret.model_validate(payload).metadata.browser is None + + credential = Secret.model_validate( + dict( + _credential(), + metadata={ + "browser": {"allowedOrigins": ORIGINS, "future": 1}, + "other": {"x": True}, + }, + ) + ) + assert credential.type == "credential" + assert credential.metadata.browser.allowed_origins == ORIGINS + dumped = credential.metadata.model_dump(by_alias=True) + assert dumped["other"] == {"x": True} + assert dumped["browser"]["future"] == 1 + + +def test_credential_values_serialize_as_compact_utf8_json(): + assert credential_value_bytes({"username": "ada", "pass.word-1_": "é x"}) == ( + '{"username":"ada","pass.word-1_":"é x"}'.encode("utf-8") + ) + assert credential_value_bytes({"a" * 64: "x"}) == b'{"' + b"a" * 64 + b'":"x"}' + + +# --- create_credential --- + + +def test_create_credential_uploads_json_bound_to_the_credential_context(private_key): + http = ScriptedHTTPClient(_create_routes(private_key)) + manager = SecretManager(FakeClient(http)) + + secret = manager.create_credential("github", LOGIN, ORIGINS, description="d") + + assert secret.type == "credential" + assert secret.latest_version == 1 + assert secret.metadata.browser.allowed_origins == ORIGINS + assert [c["path"] for c in http.calls] == [ + "/secrets/public-key", + "/secrets", + f"/secrets/{SECRET_ID}/versions", + ] + assert http.calls[1]["json"] == { + "name": "github", + "description": "d", + "type": "credential", + "metadata": {"browser": {"allowedOrigins": ORIGINS}}, + } + upload = http.calls[2]["json"] + assert set(upload) == {"envelope"} + header, value = _open(private_key, upload["envelope"]) + assert header["ctx"] == CREDENTIAL_CONTEXT + assert value == json.dumps(LOGIN, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + assert json.loads(value) == LOGIN + _assert_no_plaintext(http.calls) + + +def test_create_accepts_a_credential_type_and_metadata(private_key): + http = ScriptedHTTPClient(_create_routes(private_key)) + manager = SecretManager(FakeClient(http)) + + manager.create( + { + "name": "github", + "type": "credential", + "metadata": {"browser": {"allowed_origins": ORIGINS}}, + "value": LOGIN, + } + ) + + assert http.calls[1]["json"] == { + "name": "github", + "type": "credential", + "metadata": {"browser": {"allowedOrigins": ORIGINS}}, + } + _, value = _open(private_key, http.calls[2]["json"]["envelope"]) + assert json.loads(value) == LOGIN + + +def test_a_failed_credential_upload_reports_the_partial_create(private_key): + http = ScriptedHTTPClient( + _create_routes( + private_key, + upload=(409, {"code": "SECRET_KEY_OUTDATED", "message": "key rotated"}), + ) + ) + manager = SecretManager(FakeClient(http)) + + with pytest.raises(HyperbrowserError) as error: + manager.create_credential("github", LOGIN, ORIGINS) + + assert error.value.code == "secret_value_not_stored" + assert error.value.details == {"secretId": SECRET_ID, "name": "github"} + assert error.value.cause.code == "SECRET_KEY_OUTDATED" + + +BAD_CREDENTIAL_VALUES = [ + {}, + {"bad key": "x"}, + {"": "x"}, + {"a" * 65: "x"}, + {"user/name": "x"}, + {1: "x"}, + {"username": ""}, + {"username": 1}, + {"username": None}, + {"username": "a\x00b"}, + {"username": "\ud800"}, + {"password": "x" * (32 * 1024)}, + "a plain string", + b"plain bytes", +] + + +@pytest.mark.parametrize("value", BAD_CREDENTIAL_VALUES) +def test_bad_credential_values_fail_before_any_request(value): + http = ScriptedHTTPClient({}) + manager = SecretManager(FakeClient(http)) + + attempts = [ + lambda: manager.create_credential("github", value, ORIGINS), + lambda: manager.create( + {"name": "github", "type": "credential", "value": value} + ), + ] + if isinstance(value, dict): + # A str or bytes value is checked against the secret's type, which + # takes a lookup; see the add_version tests. + attempts.append(lambda: manager.add_version("github", value)) + for attempt in attempts: + with pytest.raises(HyperbrowserError) as error: + attempt() + assert error.value.code == "invalid_secret_value" + message = str(error.value) + for item in value.values() if isinstance(value, dict) else [value]: + if isinstance(item, str) and item: + assert item not in message + assert http.calls == [] + + +def test_a_mapping_is_refused_for_a_plain_secret_before_any_request(): + http = ScriptedHTTPClient({}) + manager = SecretManager(FakeClient(http)) + + for params in [ + {"name": "provider-key", "value": LOGIN}, + {"name": "provider-key", "type": "secret", "value": LOGIN}, + ]: + with pytest.raises(HyperbrowserError) as error: + manager.create(params) + assert error.value.code == "invalid_secret_value" + assert "credential" in str(error.value) + assert http.calls == [] + + +def test_credential_values_stay_out_of_reprs_and_dumps(): + params = CreateSecretParams(name="github", type="credential", value=LOGIN) + for text in [repr(params), str(params), json.dumps(params.model_dump())]: + assert LOGIN["password"] not in text + with pytest.raises(ValueError) as error: + CreateSecretParams.model_validate({"value": LOGIN}) + assert LOGIN["password"] not in str(error.value) + + +def test_allowed_origins_must_be_a_list(): + http = ScriptedHTTPClient({}) + manager = SecretManager(FakeClient(http)) + + for origins in ["https://github.com", [1]]: + with pytest.raises(HyperbrowserError) as error: + manager.create_credential("github", LOGIN, origins) + assert error.value.code == "invalid_secret_metadata" + with pytest.raises(HyperbrowserError): + manager.set_allowed_origins("github", origins) + assert http.calls == [] + + +# --- add_version --- + + +def test_add_version_checks_the_value_against_the_secrets_type(private_key): + versions_path = f"/secrets/{SECRET_ID}/versions" + routes = { + ("GET", "/secrets"): (200, {"secrets": [_credential()], "totalCount": 1}), + ("GET", "/secrets/public-key"): (200, _public_key_payload(private_key)), + ("POST", versions_path): (201, {"version": 2, "kid": "k"}), + } + http = ScriptedHTTPClient(routes) + manager = SecretManager(FakeClient(http)) + + assert manager.add_version("github", LOGIN).version == 2 + header, value = _open(private_key, http.calls[-1]["json"]["envelope"]) + assert header["ctx"] == CREDENTIAL_CONTEXT + assert json.loads(value) == LOGIN + _assert_no_plaintext(http.calls) + + http.calls.clear() + for value in ["plain", b"plain"]: + with pytest.raises(HyperbrowserError) as error: + manager.add_version("github", value) + assert error.value.code == "invalid_secret_value" + assert [c["path"] for c in http.calls] == ["/secrets", "/secrets"] + + routes[("GET", "/secrets")] = (200, {"secrets": [_secret()], "totalCount": 1}) + http.calls.clear() + with pytest.raises(HyperbrowserError) as error: + manager.add_version("provider-key", LOGIN) + assert error.value.code == "invalid_secret_value" + assert all(c["path"] != versions_path for c in http.calls) + + manager.add_version("provider-key", "plain") + header, value = _open(private_key, http.calls[-1]["json"]["envelope"]) + assert header["ctx"] == _secret()["encryptionContext"] and value == b"plain" + + +# --- metadata updates and listing --- + + +def test_set_allowed_origins_replaces_or_clears_the_browser_metadata(): + http = ScriptedHTTPClient( + { + ("GET", "/secrets"): (200, {"secrets": [_credential()], "totalCount": 1}), + ("PATCH", f"/secrets/{SECRET_ID}"): (200, _credential()), + } + ) + manager = SecretManager(FakeClient(http)) + + manager.set_allowed_origins("github", ("https://github.com",)) + assert http.calls[-1]["json"] == { + "metadata": {"browser": {"allowedOrigins": ["https://github.com"]}} + } + + manager.set_allowed_origins("github", None) + assert http.calls[-1]["json"] == {"metadata": {"browser": None}} + assert json.dumps(http.calls[-1]["json"]) == '{"metadata": {"browser": null}}' + + +def test_update_sends_only_the_members_given(): + http = ScriptedHTTPClient( + { + ("GET", f"/secrets/{SECRET_ID}"): (200, dict(_credential(), versions=[])), + ("PATCH", f"/secrets/{SECRET_ID}"): (200, _credential()), + } + ) + manager = SecretManager(FakeClient(http)) + + for params, body in [ + ({"description": "d"}, {"description": "d"}), + (UpdateSecretParams(description="d"), {"description": "d"}), + ({"description": "d", "metadata": None}, {"description": "d"}), + ( + {"metadata": {"browser": {"allowed_origins": ORIGINS}}}, + {"metadata": {"browser": {"allowedOrigins": ORIGINS}}}, + ), + ( + UpdateSecretParams(metadata=SecretMetadata(browser=None)), + {"metadata": {"browser": None}}, + ), + ({"metadata": {"other": None}}, {"metadata": {"other": None}}), + ]: + updated = manager.update(SECRET_ID, params) + assert http.calls[-1]["json"] == body + assert updated.type == "credential" + + http.calls.clear() + with pytest.raises(ValueError): + manager.update(SECRET_ID, {}) + assert http.calls == [] + + +def test_list_filters_by_type(): + http = ScriptedHTTPClient( + {("GET", "/secrets"): (200, {"secrets": [_credential()], "totalCount": 1})} + ) + manager = SecretManager(FakeClient(http)) + + listing = manager.list({"type": "credential"}) + + assert http.calls[0]["params"] == {"type": "credential"} + assert listing.secrets[0].type == "credential" + with pytest.raises(ValueError): + manager.list({"type": "password"}) + + +@pytest.mark.anyio +async def test_async_credentials_round_trip(private_key): + routes = _create_routes(private_key) + routes[("GET", "/secrets")] = (200, {"secrets": [_credential()], "totalCount": 1}) + routes[("PATCH", f"/secrets/{SECRET_ID}")] = (200, _credential()) + http = AsyncScriptedHTTPClient(routes) + manager = AsyncSecretManager(FakeClient(http)) + + secret = await manager.create_credential("github", LOGIN, ORIGINS) + assert secret.latest_version == 1 + assert http.calls[1]["json"]["metadata"] == {"browser": {"allowedOrigins": ORIGINS}} + header, value = _open(private_key, http.calls[2]["json"]["envelope"]) + assert header["ctx"] == CREDENTIAL_CONTEXT and json.loads(value) == LOGIN + + await manager.add_version("github", LOGIN) + _, value = _open(private_key, http.calls[-1]["json"]["envelope"]) + assert json.loads(value) == LOGIN + with pytest.raises(HyperbrowserError) as error: + await manager.add_version("github", "plain") + assert error.value.code == "invalid_secret_value" + + await manager.set_allowed_origins("github", None) + assert http.calls[-1]["json"] == {"metadata": {"browser": None}} + _assert_no_plaintext(http.calls) + + http.calls.clear() + with pytest.raises(HyperbrowserError): + await manager.create_credential("github", {"username": ""}, ORIGINS) + assert http.calls == [] + + +# --- sessions --- + + +def mock_clients(is_async): + """A client of either kind whose requests are answered from ``outcomes``.""" + loop = asyncio.new_event_loop() + client = (AsyncHyperbrowser if is_async else Hyperbrowser)(api_key="test-key") + calls = [] + outcomes = [] + + def handler(req): + calls.append(req) + outcome = outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + def call(method, *args, **kwargs): + result = method(*args, **kwargs) + return loop.run_until_complete(result) if is_async else result + + call(client.transport.close) + client.transport.client = (httpx.AsyncClient if is_async else httpx.Client)( + transport=httpx.MockTransport(handler), + headers={"x-api-key": "test-key"}, + timeout=30, + ) + if is_async: + client.transport._closed = False + yield client, call, calls, outcomes + call(client.close) + loop.close() + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +def sdk(request): + """Run the same checks through both public clients and transports.""" + yield from mock_clients(request.param) + + +def respond(outcomes, payload, status=200): + outcomes.append(httpx.Response(status, json=payload)) + + +SESSION = { + "id": "session-1", + "teamId": "team_1", + "status": "active", + "createdAt": "2026-10-01T00:00:00Z", + "updatedAt": "2026-10-01T00:00:00Z", + "sessionUrl": "https://app.hyperbrowser.ai/sessions/session-1", + "proxyDataConsumed": "0", + "liveUrl": "https://live", + "token": "t", +} +FILL_RESULT = { + "version": 3, + "pageId": "PAGE1", + "origin": "https://github.com", + "fields": [ + {"field": "username", "outcome": "filled"}, + {"field": "password", "outcome": "not_accepted"}, + ], +} +FIELDS = [ + {"field": "username", "selector": "#login_field"}, + {"field": "password", "selector": "#password"}, +] + + +def test_sessions_attach_credentials_at_creation(sdk): + client, call, calls, outcomes = sdk + respond( + outcomes, + dict(SESSION, credentials=[{"id": SECRET_ID, "name": "github"}]), + ) + respond(outcomes, SESSION) + + session = call(client.sessions.create, {"credentials": ["github"]}) + assert json.loads(calls[0].content)["credentials"] == ["github"] + assert session.credentials is not None + assert [(c.id, c.name) for c in session.credentials] == [(SECRET_ID, "github")] + + session = call(client.sessions.create, CreateSessionParams()) + assert "credentials" not in json.loads(calls[1].content) + assert session.credentials is None + + +def test_fill_posts_the_request_and_parses_outcomes(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, FILL_RESULT) + respond(outcomes, dict(FILL_RESULT, fields=[{"field": "otp", "outcome": "new"}])) + + result = call( + client.sessions.credentials.fill, + "session /1", + { + "credential": "github", + "fields": FIELDS, + "page_id": "PAGE1", + "frame": {"url": "https://login.github.com/"}, + }, + ) + + request = calls[0] + assert request.method == "POST" + assert request.url.raw_path == b"/api/session/session%20%2F1/credentials/fill" + assert json.loads(request.content) == { + "credential": "github", + "fields": FIELDS, + "pageId": "PAGE1", + "frame": {"url": "https://login.github.com/"}, + } + assert request.extensions["timeout"]["read"] == 30 + assert result.version == 3 + assert result.page_id == "PAGE1" + assert result.origin == "https://github.com" + assert [(f.field, f.outcome) for f in result.fields] == [ + ("username", "filled"), + ("password", "not_accepted"), + ] + + client.timeout = 120 + result = call( + client.sessions.credentials.fill, + "s", + FillCredentialParams(credential="github", fields=FIELDS[:1]), + ) + assert json.loads(calls[1].content) == { + "credential": "github", + "fields": FIELDS[:1], + } + assert calls[1].extensions["timeout"]["read"] == 120 + # Outcomes the SDK doesn't know yet still parse. + assert result.fields[0].outcome == "new" + + +def test_fill_errors_keep_code_retryable_and_details(sdk): + client, call, calls, outcomes = sdk + pages = [ + {"pageId": "A", "url": "https://github.com/login", "title": "Sign in"}, + {"pageId": "B", "url": "https://github.com/", "title": "GitHub"}, + ] + body = { + "code": "CREDENTIAL_PAGE_AMBIGUOUS", + "message": "Several pages could be filled", + "details": {"pages": pages}, + } + respond(outcomes, body, 409) + respond( + outcomes, + {"code": "CREDENTIAL_FILL_TIMEOUT", "message": "timed out", "retryable": True}, + 504, + ) + + with pytest.raises(HyperbrowserError) as raised: + call( + client.sessions.credentials.fill, "s", {"credential": "c", "fields": FIELDS} + ) + assert raised.value.code == "CREDENTIAL_PAGE_AMBIGUOUS" + assert raised.value.status_code == 409 + assert raised.value.retryable is False + assert raised.value.details["details"]["pages"] == pages + + with pytest.raises(HyperbrowserError) as raised: + call( + client.sessions.credentials.fill, "s", {"credential": "c", "fields": FIELDS} + ) + assert raised.value.code == "CREDENTIAL_FILL_TIMEOUT" + assert raised.value.retryable is True + # A fill is never retried automatically. + assert len(calls) == 2 + + +@pytest.mark.parametrize( + "params", + [ + {"credential": "github", "fields": []}, + { + "credential": "github", + "fields": [dict(FIELDS[0], field=f"f{i}") for i in range(17)], + }, + {"credential": "github", "fields": [FIELDS[0], FIELDS[0]]}, + {"credential": "github", "fields": [{"field": "user name", "selector": "#u"}]}, + {"credential": "github", "fields": [{"field": "f" * 65, "selector": "#u"}]}, + {"credential": "github", "fields": [{"field": "username", "selector": " "}]}, + {"credential": "github", "fields": [{"field": "u", "selector": "é" * 513}]}, + {"credential": "github", "fields": [{"field": "username"}]}, + {"credential": "github", "fields": FIELDS, "page_id": ""}, + {"credential": "github", "fields": FIELDS, "page_id": "p" * 129}, + {"credential": "github", "fields": FIELDS, "frame": {"url": ""}}, + {"credential": "github", "fields": FIELDS, "frame": {"url": "u" * 2049}}, + {"credential": "", "fields": FIELDS}, + {"fields": FIELDS}, + {"credential": "github"}, + {"credential": "github", "fields": "username"}, + "github", + ], +) +def test_invalid_fill_requests_fail_before_http(sdk, params): + client, call, calls, outcomes = sdk + with pytest.raises(HyperbrowserError) as raised: + call(client.sessions.credentials.fill, "s", params) + assert raised.value.code == "invalid_request" + assert not calls + + +def test_fill_limits_are_inclusive(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, FILL_RESULT) + call( + client.sessions.credentials.fill, + "s", + { + "credential": "github", + "fields": [ + {"field": f"f{i}" if i else "f" * 64, "selector": "é" * 512} + for i in range(16) + ], + "page_id": "p" * 128, + "frame": {"url": "u" * 2048}, + }, + ) + assert len(calls) == 1 diff --git a/tests/typecheck/invalid_requests.py b/tests/typecheck/invalid_requests.py index 5885415f..6b683169 100644 --- a/tests/typecheck/invalid_requests.py +++ b/tests/typecheck/invalid_requests.py @@ -116,3 +116,20 @@ def invalid_web_bot_auth_and_proxy_routes_requests(client: Hyperbrowser) -> None client.sessions.create({"web_bot_auth": "yes"}) # M,P client.sessions.create({"proxy_routes": [{"match": "a.com"}]}) # M,P client.sessions.create({"proxy_routes": [{"match": "*", "proxy": "up"}]}) # M,P + + +def invalid_credential_requests(client: Hyperbrowser) -> None: + client.secrets.create({"name": "github", "type": "password"}) # M,P + client.secrets.create({"name": "github", "value": {"username": 1}}) # M,P + client.secrets.list({"type": "login"}) # M,P + client.secrets.update("github", {"metadata": {"browser": {"allowedOrigins": []}}}) # M,P + client.secrets.create_credential("github", "password", ["https://x.io"]) # M,P + client.sessions.create({"credentials": "github"}) # M,P + client.sessions.credentials.fill("s", {"credential": "github"}) # M,P + client.sessions.credentials.fill("s", {"credential": "c", "fields": [{"field": "u"}]}) # M,P + client.sessions.credentials.fill("s", {"credential": "c", "fields": [], "pageId": "p"}) # M,P + + +async def invalid_async_credential_requests(client: AsyncHyperbrowser) -> None: + await client.secrets.add_version("github", {"username": None}) # M,P + await client.sessions.credentials.fill("s", {"credential": "c", "fields": [], "frame": "u"}) # M,P diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index df923b1d..aeaa9b87 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -582,3 +582,77 @@ async def valid_async_web_bot_auth_and_proxy_routes_requests( await client.sessions.create( {"use_proxy": True, "proxy_routes": [{"match": "*", "proxy": "direct"}]} ) + + +def valid_credential_requests(client: Hyperbrowser) -> None: + from hyperbrowser.models import ( + CredentialFillField, + CredentialFillFrame, + FillCredentialParams, + SecretMetadata, + UpdateSecretParams, + ) + + secret = client.secrets.create_credential( + "github", + {"username": "ada", "password": "pw"}, + ["https://github.com"], + description="GitHub login", + ) + kind: str = secret.type + origins = secret.metadata.browser.allowed_origins if secret.metadata.browser else [] + client.secrets.create( + { + "name": "github", + "type": "credential", + "metadata": {"browser": {"allowed_origins": origins}}, + "value": {"username": "ada"}, + } + ) + client.secrets.create( + LegacyCreateSecretParams(name="github", type="credential", value={"u": "a"}) + ) + client.secrets.add_version("github", {"username": "ada", "password": "pw2"}) + client.secrets.set_allowed_origins("github", ["https://github.com"]) + client.secrets.set_allowed_origins("github", None) + client.secrets.update("github", {"metadata": {"browser": None}}) + client.secrets.update( + "github", UpdateSecretParams(metadata=SecretMetadata(browser=None)) + ) + client.secrets.list({"type": "credential"}) + + session = client.sessions.create({"credentials": [kind]}) + for attached in session.credentials or []: + print(attached.id, attached.name) + client.sessions.create(LegacyCreateSessionParams(credentials=["github"])) + result = client.sessions.credentials.fill( + session.id, + { + "credential": "github", + "fields": [{"field": "username", "selector": "#login"}], + "page_id": "PAGE", + "frame": {"url": "https://login.github.com/"}, + }, + ) + outcomes = [(field.field, field.outcome) for field in result.fields] + print(result.version, result.page_id, result.origin, outcomes) + client.sessions.credentials.fill( + session.id, + FillCredentialParams( + credential="github", + fields=[CredentialFillField(field="password", selector="#pw")], + frame=CredentialFillFrame(url="https://login.github.com/"), + ), + ) + + +async def valid_async_credential_requests(client: AsyncHyperbrowser) -> None: + await client.secrets.create_credential("github", {"u": "a"}, ["https://x.io"]) + await client.secrets.set_allowed_origins("github", None) + await client.secrets.add_version("github", {"username": "ada"}) + session = await client.sessions.create({"credentials": ["github"]}) + result = await client.sessions.credentials.fill( + session.id, + {"credential": "github", "fields": [{"field": "u", "selector": "#u"}]}, + ) + print(result.fields) From fd522881da1c4db5ae5476331ec4d478306babb1 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 16:24:59 -0700 Subject: [PATCH 02/23] Add a fill_credential LLM tool and Gemini tool declarations CredentialFillTool exposes sessions.credentials.fill() to a model with the session ID bound by the application. The model picks the credential by name, the fields and their selectors, and optionally a page ID or an iframe URL; it never sees values. runnable() and async_runnable() return the fill result as compact JSON, or the error's code, message, retryable flag and server details as JSON so the model can react, for example by choosing a page after CREDENTIAL_PAGE_AMBIGUOUS. The definitions come for OpenAI (strict), Anthropic and Gemini. Gemini schemas are an OpenAPI subset, so tools/gemini.py converts the JSON schemas (nullable instead of a null type, no additionalProperties), and every existing tool gains a gemini_tool_definition too. Co-Authored-By: Claude Opus 5.5 --- hyperbrowser/tools/__init__.py | 99 ++++++++++++++- hyperbrowser/tools/anthropic.py | 8 ++ hyperbrowser/tools/gemini.py | 118 ++++++++++++++++++ hyperbrowser/tools/openai.py | 12 ++ hyperbrowser/tools/schema.py | 81 ++++++++++++ tests/test_credential_tools.py | 196 ++++++++++++++++++++++++++++++ tests/typecheck/valid_requests.py | 8 +- 7 files changed, 520 insertions(+), 2 deletions(-) create mode 100644 hyperbrowser/tools/gemini.py create mode 100644 tests/test_credential_tools.py diff --git a/hyperbrowser/tools/__init__.py b/hyperbrowser/tools/__init__.py index 4075fb36..688bef6c 100644 --- a/hyperbrowser/tools/__init__.py +++ b/hyperbrowser/tools/__init__.py @@ -1,7 +1,9 @@ import json -from typing import cast +from typing import Any, Dict, Mapping, cast from hyperbrowser import Hyperbrowser, AsyncHyperbrowser +from hyperbrowser.exceptions import HyperbrowserError +from hyperbrowser.models.credential import CredentialFillResult from hyperbrowser.types import ( StartBrowserUseTaskParams, StartCrawlJobParams, @@ -13,6 +15,7 @@ from .openai import ( BROWSER_USE_TOOL_OPENAI, EXTRACT_TOOL_OPENAI, + FILL_CREDENTIAL_TOOL_OPENAI, SCRAPE_TOOL_OPENAI, SCREENSHOT_TOOL_OPENAI, CRAWL_TOOL_OPENAI, @@ -20,15 +23,25 @@ from .anthropic import ( BROWSER_USE_TOOL_ANTHROPIC, EXTRACT_TOOL_ANTHROPIC, + FILL_CREDENTIAL_TOOL_ANTHROPIC, SCRAPE_TOOL_ANTHROPIC, SCREENSHOT_TOOL_ANTHROPIC, CRAWL_TOOL_ANTHROPIC, ) +from .gemini import ( + BROWSER_USE_TOOL_GEMINI, + CRAWL_TOOL_GEMINI, + EXTRACT_TOOL_GEMINI, + FILL_CREDENTIAL_TOOL_GEMINI, + SCRAPE_TOOL_GEMINI, + SCREENSHOT_TOOL_GEMINI, +) class WebsiteScrapeTool: openai_tool_definition = SCRAPE_TOOL_OPENAI anthropic_tool_definition = SCRAPE_TOOL_ANTHROPIC + gemini_tool_definition = SCRAPE_TOOL_GEMINI @staticmethod def runnable(hb: Hyperbrowser, params: StartScrapeJobParams) -> str: @@ -46,6 +59,7 @@ async def async_runnable( class WebsiteScreenshotTool: openai_tool_definition = SCREENSHOT_TOOL_OPENAI anthropic_tool_definition = SCREENSHOT_TOOL_ANTHROPIC + gemini_tool_definition = SCREENSHOT_TOOL_GEMINI @staticmethod def runnable(hb: Hyperbrowser, params: StartScrapeJobParams) -> str: @@ -63,6 +77,7 @@ async def async_runnable( class WebsiteCrawlTool: openai_tool_definition = CRAWL_TOOL_OPENAI anthropic_tool_definition = CRAWL_TOOL_ANTHROPIC + gemini_tool_definition = CRAWL_TOOL_GEMINI @staticmethod def runnable(hb: Hyperbrowser, params: StartCrawlJobParams) -> str: @@ -102,6 +117,7 @@ def _normalize_extract_tool_params( class WebsiteExtractTool: openai_tool_definition = EXTRACT_TOOL_OPENAI anthropic_tool_definition = EXTRACT_TOOL_ANTHROPIC + gemini_tool_definition = EXTRACT_TOOL_GEMINI @staticmethod def runnable(hb: Hyperbrowser, params: WebsiteExtractToolParams) -> str: @@ -121,6 +137,7 @@ async def async_runnable( class BrowserUseTool: openai_tool_definition = BROWSER_USE_TOOL_OPENAI anthropic_tool_definition = BROWSER_USE_TOOL_ANTHROPIC + gemini_tool_definition = BROWSER_USE_TOOL_GEMINI @staticmethod def runnable(hb: Hyperbrowser, params: StartBrowserUseTaskParams) -> str: @@ -135,7 +152,87 @@ async def async_runnable( return resp.data.final_result if resp.data and resp.data.final_result else "" +def _fill_credential_request(params: Mapping[str, Any]) -> Dict[str, Any]: + """Map the model's tool input to a fill request; null means absent.""" + request: Dict[str, Any] = { + "credential": params.get("credential"), + "fields": params.get("fields"), + } + page_id = params.get("page_id", params.get("pageId")) + if page_id is not None and page_id != "": + request["page_id"] = page_id + frame_url = params.get("frame_url", params.get("frameUrl")) + if frame_url is not None and frame_url != "": + request["frame"] = {"url": frame_url} + return request + + +def _fill_credential_result_text(result: CredentialFillResult) -> str: + return json.dumps(result.model_dump(by_alias=True), separators=(",", ":")) + + +def _fill_credential_error_text(error: HyperbrowserError) -> str: + details = error.details + if error.status_code is not None and isinstance(details, dict): + # API errors keep the whole response body; the model needs only the + # details the server added, such as the candidate pages. + details = details.get("details") + return json.dumps( + { + "error": { + "code": error.code, + "message": error.args[0] if error.args else str(error), + "retryable": error.retryable, + "details": details, + } + }, + separators=(",", ":"), + default=str, + ) + + +class CredentialFillTool: + """Let a model fill a stored login credential into a browser session. + + The model chooses the credential by name and the inputs to fill; it never + sees the values. The application binds the session:: + + result = CredentialFillTool.runnable(hb, session.id, tool_input) + + The result is JSON text for the model. API and validation errors are + returned as ``{"error": {"code", "message", "retryable", "details"}}`` + instead of raised, so the model can react; other exceptions propagate. + """ + + openai_tool_definition = FILL_CREDENTIAL_TOOL_OPENAI + anthropic_tool_definition = FILL_CREDENTIAL_TOOL_ANTHROPIC + gemini_tool_definition = FILL_CREDENTIAL_TOOL_GEMINI + + @staticmethod + def runnable(hb: Hyperbrowser, session_id: str, params: Mapping[str, Any]) -> str: + try: + result = hb.sessions.credentials.fill( + session_id, cast(Any, _fill_credential_request(params)) + ) + except HyperbrowserError as error: + return _fill_credential_error_text(error) + return _fill_credential_result_text(result) + + @staticmethod + async def async_runnable( + hb: AsyncHyperbrowser, session_id: str, params: Mapping[str, Any] + ) -> str: + try: + result = await hb.sessions.credentials.fill( + session_id, cast(Any, _fill_credential_request(params)) + ) + except HyperbrowserError as error: + return _fill_credential_error_text(error) + return _fill_credential_result_text(result) + + __all__ = [ + "CredentialFillTool", "WebsiteScrapeTool", "WebsiteScreenshotTool", "WebsiteCrawlTool", diff --git a/hyperbrowser/tools/anthropic.py b/hyperbrowser/tools/anthropic.py index 1232b979..ad18677f 100644 --- a/hyperbrowser/tools/anthropic.py +++ b/hyperbrowser/tools/anthropic.py @@ -5,6 +5,8 @@ BROWSER_USE_SCHEMA, CRAWL_SCHEMA, EXTRACT_SCHEMA, + FILL_CREDENTIAL_DESCRIPTION, + FILL_CREDENTIAL_SCHEMA, SCRAPE_SCHEMA, SCREENSHOT_SCHEMA, ) @@ -78,3 +80,9 @@ class ToolParam(TypedDict, total=False): "name": "browser_use", "description": "Have an AI agent use a browser to perform a task on the web.", } + +FILL_CREDENTIAL_TOOL_ANTHROPIC: ToolParam = { + "input_schema": FILL_CREDENTIAL_SCHEMA, + "name": "fill_credential", + "description": FILL_CREDENTIAL_DESCRIPTION, +} diff --git a/hyperbrowser/tools/gemini.py b/hyperbrowser/tools/gemini.py new file mode 100644 index 00000000..676b4b08 --- /dev/null +++ b/hyperbrowser/tools/gemini.py @@ -0,0 +1,118 @@ +"""Gemini function declarations for the Hyperbrowser tools. + +The declarations are plain dicts that google-genai accepts, for example +``types.Tool(function_declarations=[FILL_CREDENTIAL_TOOL_GEMINI])``. Gemini +schemas are a subset of OpenAPI: they have no ``additionalProperties`` and +mark optional values with ``nullable`` instead of a ``"null"`` type. +""" + +from copy import deepcopy +from typing import Any, Dict, Mapping + +from typing_extensions import Required, TypeAlias, TypedDict + +from hyperbrowser.tools.openai import ( + BROWSER_USE_TOOL_OPENAI, + CRAWL_TOOL_OPENAI, + EXTRACT_TOOL_OPENAI, + FILL_CREDENTIAL_TOOL_OPENAI, + SCRAPE_TOOL_OPENAI, + SCREENSHOT_TOOL_OPENAI, + ChatCompletionToolParam, +) + +GeminiSchema: TypeAlias = Dict[str, object] + + +class FunctionDeclaration(TypedDict, total=False): + name: Required[str] + """The function's name: letters, digits, underscores, dots and dashes, + at most 64 characters.""" + + description: str + """What the function does and when the model should call it.""" + + parameters: GeminiSchema + """The function's parameters as a Gemini (OpenAPI subset) schema.""" + + +# Schema members Gemini understands, with google-genai's camelCase names. +_GEMINI_SCHEMA_KEYS = frozenset( + { + "default", + "description", + "enum", + "example", + "format", + "maxItems", + "maxLength", + "maxProperties", + "maximum", + "minItems", + "minLength", + "minProperties", + "minimum", + "nullable", + "pattern", + "propertyOrdering", + "required", + "title", + } +) + + +def to_gemini_schema(schema: Mapping[str, Any]) -> GeminiSchema: + """Convert a tool's JSON schema to the Gemini form. + + ``"type": [T, "null"]`` becomes ``"type": T, "nullable": True``; + ``additionalProperties`` and members Gemini doesn't know are dropped. + """ + converted: GeminiSchema = {} + for key, value in schema.items(): + if key == "type": + if isinstance(value, (list, tuple)): + types = [item for item in value if item != "null"] + if len(types) != 1: + raise ValueError( + f"Gemini schemas can't express the type union {list(value)}" + ) + if len(types) != len(value): + converted["nullable"] = True + value = types[0] + converted["type"] = value + elif key == "properties": + converted["properties"] = { + name: to_gemini_schema(member) for name, member in value.items() + } + elif key == "items": + converted["items"] = to_gemini_schema(value) + elif key == "anyOf": + converted["anyOf"] = [to_gemini_schema(member) for member in value] + elif key in _GEMINI_SCHEMA_KEYS: + converted[key] = deepcopy(value) + return converted + + +def to_gemini_function_declaration( + name: str, description: str, parameters: Mapping[str, Any] +) -> FunctionDeclaration: + return { + "name": name, + "description": description, + "parameters": to_gemini_schema(parameters), + } + + +def _from_openai(tool: ChatCompletionToolParam) -> FunctionDeclaration: + function = tool["function"] + return to_gemini_function_declaration( + function["name"], function["description"], function["parameters"] + ) + + +SCRAPE_TOOL_GEMINI = _from_openai(SCRAPE_TOOL_OPENAI) +SCREENSHOT_TOOL_GEMINI = _from_openai(SCREENSHOT_TOOL_OPENAI) +CRAWL_TOOL_GEMINI = _from_openai(CRAWL_TOOL_OPENAI) +EXTRACT_TOOL_GEMINI = _from_openai(EXTRACT_TOOL_OPENAI) +BROWSER_USE_TOOL_GEMINI = _from_openai(BROWSER_USE_TOOL_OPENAI) +FILL_CREDENTIAL_TOOL_GEMINI = _from_openai(FILL_CREDENTIAL_TOOL_OPENAI) diff --git a/hyperbrowser/tools/openai.py b/hyperbrowser/tools/openai.py index b5614d9a..16641e99 100644 --- a/hyperbrowser/tools/openai.py +++ b/hyperbrowser/tools/openai.py @@ -5,6 +5,8 @@ BROWSER_USE_SCHEMA, CRAWL_SCHEMA, EXTRACT_SCHEMA, + FILL_CREDENTIAL_DESCRIPTION, + FILL_CREDENTIAL_SCHEMA, SCRAPE_SCHEMA, SCREENSHOT_SCHEMA, ) @@ -103,3 +105,13 @@ class ChatCompletionToolParam(TypedDict, total=False): "strict": True, }, } + +FILL_CREDENTIAL_TOOL_OPENAI: ChatCompletionToolParam = { + "type": "function", + "function": { + "name": "fill_credential", + "description": FILL_CREDENTIAL_DESCRIPTION, + "parameters": FILL_CREDENTIAL_SCHEMA, + "strict": True, + }, +} diff --git a/hyperbrowser/tools/schema.py b/hyperbrowser/tools/schema.py index bfad4a74..30c758c1 100644 --- a/hyperbrowser/tools/schema.py +++ b/hyperbrowser/tools/schema.py @@ -187,3 +187,84 @@ def get_scrape_options(formats: List[scrape_types] = ["markdown"]): ], "additionalProperties": False, } + +FILL_CREDENTIAL_DESCRIPTION = ( + "Fill a stored login credential into the browser session's page by typing " + "its values into the inputs you select. A credential is a saved login (for " + "example a username and password) that you refer to by name; you never see " + "its values, and they never appear in this tool's input or output. This tool " + "only types: it does NOT submit the form. After a fill, click the login " + "button or press Enter with your other browser tools.\n\n" + "The result is JSON with the origin and pageId of the page that was filled, " + "and an outcome for each field:\n" + "- filled: the input now holds the value.\n" + "- not_found: no input matched the selector. Look at the page again and fix " + "the selector; the form may not have loaded yet, or it may be inside an " + "iframe (pass frame_url).\n" + "- ambiguous: several visible inputs matched. Use a more specific selector.\n" + "- not_editable: the input is disabled, read-only, hidden or not a text " + "input. Select the real input, or reveal it first (some sites show the " + "password input only after the username step).\n" + "- not_accepted: the page did not keep the value (for example a length " + "limit). Check that you selected the right input.\n" + "- not_populated: the stored credential has no such field. Check the field " + "name; common ones are username, email and password.\n" + "Filling again is safe. Errors come back as JSON with a code, for example " + '{"error": {"code": "CREDENTIAL_PAGE_AMBIGUOUS", "message": "...", ' + '"retryable": false, "details": {"pages": [...]}}}. On ' + "CREDENTIAL_PAGE_AMBIGUOUS, call again with page_id set to the pageId of the " + "tab that shows the login form. CREDENTIAL_ORIGIN_NOT_ALLOWED means the " + "credential may not be used on this site; do not retry it there. When " + "retryable is true, the same call may succeed if you try again." +) + +FILL_CREDENTIAL_SCHEMA = { + "type": "object", + "properties": { + "credential": { + "type": "string", + "description": "The name of the stored credential to fill, as you were " + "told it. You cannot see its values; refer to it only by name.", + }, + "fields": { + "type": "array", + "description": "The inputs to fill: one entry per credential field, " + "each field at most once.", + "minItems": 1, + "maxItems": 16, + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "The credential field to type: a key of " + 'the stored login such as "username", "email" or ' + '"password".', + }, + "selector": { + "type": "string", + "description": "A CSS selector matching exactly one " + 'visible input on the page, such as "#login_field" or ' + "\"input[name='password']\".", + }, + }, + "required": ["field", "selector"], + "additionalProperties": False, + }, + }, + "page_id": { + "type": ["string", "null"], + "description": "The browser tab to fill. Use null unless a previous " + "call failed with CREDENTIAL_PAGE_AMBIGUOUS; then pass the pageId of " + "the tab showing the login form, from that error's details.pages.", + }, + "frame_url": { + "type": ["string", "null"], + "description": "For a login form inside an iframe: the start of the " + 'iframe\'s URL, such as "https://login.example.com/". Use null when ' + "the form is in the page itself.", + }, + }, + "required": ["credential", "fields", "page_id", "frame_url"], + "additionalProperties": False, +} diff --git a/tests/test_credential_tools.py b/tests/test_credential_tools.py new file mode 100644 index 00000000..c61e7b33 --- /dev/null +++ b/tests/test_credential_tools.py @@ -0,0 +1,196 @@ +"""The fill_credential LLM tool and the Gemini tool declarations.""" + +import json + +import pytest + +from hyperbrowser import AsyncHyperbrowser, Hyperbrowser +from hyperbrowser.tools import ( + BrowserUseTool, + CredentialFillTool, + WebsiteCrawlTool, + WebsiteExtractTool, + WebsiteScrapeTool, + WebsiteScreenshotTool, +) +from hyperbrowser.tools.gemini import to_gemini_schema +from tests.test_credentials import FIELDS, FILL_RESULT, mock_clients, respond + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +def sdk(request): + yield from mock_clients(request.param) + + +def _walk(schema): + yield schema + for member in schema.get("properties", {}).values(): + yield from _walk(member) + if isinstance(schema.get("items"), dict): + yield from _walk(schema["items"]) + + +TOOLS = [ + WebsiteScrapeTool, + WebsiteScreenshotTool, + WebsiteCrawlTool, + WebsiteExtractTool, + BrowserUseTool, + CredentialFillTool, +] + + +def test_fill_credential_tool_definitions(): + openai_tool = CredentialFillTool.openai_tool_definition + anthropic_tool = CredentialFillTool.anthropic_tool_definition + gemini_tool = CredentialFillTool.gemini_tool_definition + function = openai_tool["function"] + + assert openai_tool["type"] == "function" + assert function["name"] == "fill_credential" + assert function["strict"] is True + assert anthropic_tool["name"] == gemini_tool["name"] == "fill_credential" + assert anthropic_tool["input_schema"] is function["parameters"] + description = function["description"] + assert anthropic_tool["description"] == gemini_tool["description"] == description + for text in [ + "does NOT submit", + "filled", + "not_found", + "ambiguous", + "not_editable", + "not_accepted", + "not_populated", + "CREDENTIAL_PAGE_AMBIGUOUS", + "page_id", + ]: + assert text in description + + schema = function["parameters"] + assert set(schema["properties"]) == {"credential", "fields", "page_id", "frame_url"} + assert "session" not in json.dumps(schema).lower().replace("session's", "") + fields = schema["properties"]["fields"] + assert (fields["minItems"], fields["maxItems"]) == (1, 16) + assert schema["properties"]["page_id"]["type"] == ["string", "null"] + for member in _walk(schema): + assert ( + member.get("description") or member is schema or member is fields["items"] + ) + + +@pytest.mark.parametrize("tool", TOOLS, ids=lambda tool: tool.__name__) +def test_openai_schemas_are_strict_mode_compatible(tool): + for member in _walk(tool.openai_tool_definition["function"]["parameters"]): + if member.get("type") == "object": + assert member["additionalProperties"] is False + assert set(member["required"]) == set(member["properties"]) + + +@pytest.mark.parametrize("tool", TOOLS, ids=lambda tool: tool.__name__) +def test_gemini_declarations_use_the_openapi_subset(tool): + declaration = tool.gemini_tool_definition + assert declaration["name"] == tool.openai_tool_definition["function"]["name"] + assert "additionalProperties" not in json.dumps(declaration) + for member in _walk(declaration["parameters"]): + assert isinstance(member["type"], str) + + +def test_gemini_schema_conversion(): + gemini = CredentialFillTool.gemini_tool_definition["parameters"] + assert gemini["properties"]["page_id"]["type"] == "string" + assert gemini["properties"]["page_id"]["nullable"] is True + assert gemini["properties"]["frame_url"]["nullable"] is True + assert "nullable" not in gemini["properties"]["credential"] + assert gemini["properties"]["fields"]["maxItems"] == 16 + assert gemini["required"] == ["credential", "fields", "page_id", "frame_url"] + + with pytest.raises(ValueError, match="type union"): + to_gemini_schema({"type": ["string", "number"]}) + assert to_gemini_schema({"type": "object", "$schema": "x", "anyOf": []}) == { + "type": "object", + "anyOf": [], + } + + +TOOL_INPUT = { + "credential": "github", + "fields": FIELDS, + "page_id": None, + "frame_url": None, +} + + +def test_tool_runner_maps_model_input_and_returns_json(sdk): + client, call, calls, outcomes = sdk + runner = ( + CredentialFillTool.async_runnable + if isinstance(client, AsyncHyperbrowser) + else CredentialFillTool.runnable + ) + respond(outcomes, FILL_RESULT) + respond(outcomes, FILL_RESULT) + + text = call(runner, client, "session-1", TOOL_INPUT) + assert json.loads(calls[0].content) == {"credential": "github", "fields": FIELDS} + assert calls[0].url.path == "/api/session/session-1/credentials/fill" + assert json.loads(text) == FILL_RESULT + assert " " not in text.replace("https://", "") + + call( + runner, + client, + "session-1", + dict(TOOL_INPUT, page_id="PAGE2", frame_url="https://login.github.com/"), + ) + assert json.loads(calls[1].content) == { + "credential": "github", + "fields": FIELDS, + "pageId": "PAGE2", + "frame": {"url": "https://login.github.com/"}, + } + + +def test_tool_runner_returns_errors_as_json(sdk): + client, call, calls, outcomes = sdk + runner = ( + CredentialFillTool.async_runnable + if isinstance(client, AsyncHyperbrowser) + else CredentialFillTool.runnable + ) + pages = [{"pageId": "A", "url": "https://github.com/login", "title": "Sign in"}] + respond( + outcomes, + { + "code": "CREDENTIAL_PAGE_AMBIGUOUS", + "message": "Several pages could be filled", + "details": {"pages": pages}, + }, + 409, + ) + + error = json.loads(call(runner, client, "s", TOOL_INPUT)) + assert error == { + "error": { + "code": "CREDENTIAL_PAGE_AMBIGUOUS", + "message": "Several pages could be filled", + "retryable": False, + "details": {"pages": pages}, + } + } + + error = json.loads(call(runner, client, "s", dict(TOOL_INPUT, fields=[]))) + assert error["error"]["code"] == "invalid_request" + assert error["error"]["details"] is None + assert len(calls) == 1 + + +def test_tool_runner_lets_other_exceptions_propagate(monkeypatch): + client = Hyperbrowser(api_key="test-key") + + def fail(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(client.sessions.credentials, "fill", fail) + with pytest.raises(RuntimeError): + CredentialFillTool.runnable(client, "s", TOOL_INPUT) + client.close() diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index aeaa9b87..3522a0ce 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -20,7 +20,7 @@ ComputerActionResponse, ) from hyperbrowser.types import ScrollActionParams -from hyperbrowser.tools import WebsiteExtractTool +from hyperbrowser.tools import CredentialFillTool, WebsiteExtractTool class ProductResult(BaseModel): @@ -644,6 +644,8 @@ def valid_credential_requests(client: Hyperbrowser) -> None: frame=CredentialFillFrame(url="https://login.github.com/"), ), ) + text: str = CredentialFillTool.runnable(client, session.id, {"credential": "x"}) + print(text, CredentialFillTool.gemini_tool_definition["name"]) async def valid_async_credential_requests(client: AsyncHyperbrowser) -> None: @@ -656,3 +658,7 @@ async def valid_async_credential_requests(client: AsyncHyperbrowser) -> None: {"credential": "github", "fields": [{"field": "u", "selector": "#u"}]}, ) print(result.fields) + text: str = await CredentialFillTool.async_runnable( + client, session.id, {"credential": "x"} + ) + print(text) From 98e4209ebc40eb251cfeca85e1975ec7ade920cd Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 16:24:59 -0700 Subject: [PATCH 03/23] Document credential fill and the LLM tools Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- README.md | 229 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2889e373..ed724410 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,9 +69,9 @@ Response and legacy models are re-exported from ### Pre-built Tools -`hyperbrowser/tools/` contains ready-to-use tool definitions for OpenAI and Anthropic function calling: -- `WebsiteScrapeTool`, `WebsiteScreenshotTool`, `WebsiteCrawlTool`, `WebsiteExtractTool`, `BrowserUseTool` -- Each has `openai_tool_definition`, `anthropic_tool_definition`, plus sync/async runnable methods +`hyperbrowser/tools/` contains ready-to-use tool definitions for OpenAI, Anthropic and Gemini function calling: +- `WebsiteScrapeTool`, `WebsiteScreenshotTool`, `WebsiteCrawlTool`, `WebsiteExtractTool`, `BrowserUseTool`, `CredentialFillTool` +- Each has `openai_tool_definition`, `anthropic_tool_definition`, `gemini_tool_definition` (converted from the JSON schema by `tools/gemini.py`), plus sync/async runnable methods ## Key Patterns diff --git a/README.md b/README.md index b20de38e..c008ba11 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,235 @@ and cannot be combined with `allow_internet_access=False`, `allow_out`, or `deny_out`. See the [proxy routes guide](https://hyperbrowser.ai/docs/sessions/proxy-routes). +## Fill credentials into browser sessions + +A credential is a secret of type `"credential"` that holds a website login, +such as a username and password. Its value is encrypted on your machine like +any other secret value, and the platform types it into pages of a browser +session; after upload, neither the API, your code, nor an LLM sees it again. + +```python +from playwright.sync_api import sync_playwright + +from hyperbrowser import Hyperbrowser + +client = Hyperbrowser(api_key="test-key") + +# Field names (here "username" and "password") are what a fill refers to. +client.secrets.create_credential( + "github", + {"username": "ada@example.com", "password": "correct horse battery staple"}, + allowed_origins=["https://github.com"], + description="GitHub login", +) + +# Credentials can only be attached when the session is created. +session = client.sessions.create({"credentials": ["github"]}) + +with sync_playwright() as p: + browser = p.chromium.connect_over_cdp(session.ws_endpoint) + page = browser.contexts[0].pages[0] + page.goto("https://github.com/login") + + result = client.sessions.credentials.fill( + session.id, + { + "credential": "github", + "fields": [ + {"field": "username", "selector": "#login_field"}, + {"field": "password", "selector": "#password"}, + ], + }, + ) + if all(field.outcome == "filled" for field in result.fields): + # Fill only types. Submitting is up to you. + page.click("input[type=submit]") + +client.sessions.stop(session.id) +``` + +`fill()` returns the filled page's `page_id` and `origin`, the credential +`version`, and an outcome per field. Values are never returned. + +| Outcome | Meaning | +| --- | --- | +| `filled` | The input now holds exactly the stored value. | +| `not_found` | No input matched the selector. | +| `ambiguous` | Several visible inputs matched the selector. | +| `not_editable` | The input is disabled, read-only, hidden, or not a text input. | +| `not_accepted` | The page didn't keep the value, for example because of `maxlength`. | +| `not_populated` | The stored credential has no such field. | + +Things to know: + +- A credential can be filled only on its allowed origins + (`scheme://host[:port]`; `https://*.example.com` matches any subdomain but + not `example.com` itself). Change them with + `client.secrets.set_allowed_origins("github", [...])`; `None` clears them, + after which the credential can't be filled. `client.secrets.update()` takes + `{"metadata": {"browser": None}}` too: each metadata member sent replaces the + stored one and `None` removes it. +- Credential values map 1-64 character field names (letters, digits, `_`, `.` + and `-`) to non-empty strings, up to 32 KiB of JSON in total. They are + checked before anything is sent; rotate one with + `client.secrets.add_version("github", {...})`. `client.secrets.list({"type": + "credential"})` lists only credentials. +- A fill takes 1-16 fields, each at most once. Without `page_id` it picks the + only tab, else the single tab on an allowed origin, else the single visible + tab. Under Playwright every tab reports as visible, so with several tabs on + allowed origins pass the tab's CDP target ID as `page_id`: + `page.context.new_cdp_session(page).send("Target.getTargetInfo")["targetInfo"]["targetId"]`. + For a login form in an iframe, pass `"frame": {"url": "https://login.example.com/"}` + (a URL prefix). +- Fill is safe to retry and never retried automatically. It waits at least + 30 seconds for the API (the client timeout when longer). +- Attaching credentials needs an API key with `secrets.use`; filling also needs + `browser-sessions.update`. The team needs the secrets feature. Sessions with + credentials run as Firecracker browsers with Chrome's password manager off. +- Fills are recorded in the session's event log as `credential_filled` and + `credential_fill_refused`. +- Errors are raised as `HyperbrowserError` with the API's `code`, `retryable`, + and `status_code`. `details` holds the API's response body, so the + candidate tabs of an ambiguous fill are in `error.details["details"]["pages"]` + (each with `pageId`, `url` and `title`). Error codes from the API: + - `CREDENTIAL_PAGE_AMBIGUOUS` (409): several tabs could be the target; pass + one's `pageId` as `page_id`. `CREDENTIAL_FRAME_AMBIGUOUS` (409) is the same + for frames. + - `CREDENTIAL_ORIGIN_NOT_ALLOWED` (403): the page's origin (in `details`) + isn't allowed; nothing was typed. + - `CREDENTIAL_NOT_ATTACHED` (403): the credential wasn't attached when the + session was created. + - `CREDENTIAL_NOT_READY` (409): the credential has no allowed origins or no + value. + - `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND` (404), + `SECRET_NOT_FOUND` (404), `SECRET_TYPE_MISMATCH` (400, a plain secret was + named), `SESSION_NOT_ACTIVE` (409). + - `CREDENTIAL_FILL_TIMEOUT` (503/504, retryable) and + `CREDENTIAL_FILL_FAILED` (500/502, retryable as flagged). + - `CREDENTIALS_UNSUPPORTED_RUNTIME` (400) and `CREDENTIAL_FILL_UNSUPPORTED` + (501): the session's browser can't fill credentials. + - Locally, invalid fill requests raise `invalid_request` and invalid values + raise `invalid_secret_value`, before any request. + +### Use with LLM SDKs + +`CredentialFillTool` lets a model fill a credential by name while your +application binds the session ID. The model chooses only the credential, the +fields with their CSS selectors, and optionally a `page_id` or `frame_url`; it +never sees the values. `runnable()` returns JSON text for the model: the fill +result, or `{"error": {"code", "message", "retryable", "details"}}` when the +fill fails, so the model can react (for example by choosing a `page_id`). +Give the model your other browser tools too, since it must submit the form +itself. `async_runnable()` does the same with `AsyncHyperbrowser`. + +OpenAI (Chat Completions): + +```python +import json + +from openai import OpenAI + +from hyperbrowser.tools import CredentialFillTool + +openai_client = OpenAI() +tools = [CredentialFillTool.openai_tool_definition] # plus your browser tools +messages = [{"role": "user", "content": "Sign in to GitHub with the credential named github."}] +while True: + response = openai_client.chat.completions.create( + model="gpt-4.1", messages=messages, tools=tools + ) + message = response.choices[0].message + messages.append(message) + if not message.tool_calls: + break + for call in message.tool_calls: + if call.function.name == "fill_credential": + output = CredentialFillTool.runnable( + client, session.id, json.loads(call.function.arguments) + ) + else: + output = run_browser_tool(call) # your click/press/navigate tools + messages.append({"role": "tool", "tool_call_id": call.id, "content": output}) +``` + +Anthropic (Messages): + +```python +import anthropic + +from hyperbrowser.tools import CredentialFillTool + +anthropic_client = anthropic.Anthropic() +tools = [CredentialFillTool.anthropic_tool_definition] # plus your browser tools +messages = [{"role": "user", "content": "Sign in to GitHub with the credential named github."}] +while True: + response = anthropic_client.messages.create( + model="claude-sonnet-4-5", max_tokens=1024, tools=tools, messages=messages + ) + messages.append({"role": "assistant", "content": response.content}) + if response.stop_reason != "tool_use": + break + results = [] + for block in response.content: + if block.type != "tool_use": + continue + if block.name == "fill_credential": + output = CredentialFillTool.runnable(client, session.id, block.input) + else: + output = run_browser_tool(block) + results.append({"type": "tool_result", "tool_use_id": block.id, "content": output}) + messages.append({"role": "user", "content": results}) +``` + +Gemini (google-genai): + +```python +from google import genai +from google.genai import types + +from hyperbrowser.tools import CredentialFillTool + +gemini = genai.Client() +config = types.GenerateContentConfig( + tools=[ + types.Tool(function_declarations=[CredentialFillTool.gemini_tool_definition]) + ] # plus your browser tools +) +contents = [ + types.Content( + role="user", + parts=[types.Part.from_text(text="Sign in to GitHub with the credential named github.")], + ) +] +while True: + response = gemini.models.generate_content( + model="gemini-2.5-flash", contents=contents, config=config + ) + contents.append(response.candidates[0].content) + if not response.function_calls: + break + parts = [] + for call in response.function_calls: + if call.name == "fill_credential": + output = CredentialFillTool.runnable(client, session.id, call.args or {}) + else: + output = run_browser_tool(call) + parts.append( + types.Part.from_function_response(name=call.name, response={"result": output}) + ) + contents.append(types.Content(role="user", parts=parts)) +``` + +## Tools for LLM function calling + +`hyperbrowser.tools` has ready-made tools: `WebsiteScrapeTool`, +`WebsiteScreenshotTool`, `WebsiteCrawlTool`, `WebsiteExtractTool`, +`BrowserUseTool` and `CredentialFillTool`. Each has `openai_tool_definition` +(a strict Chat Completions function tool), `anthropic_tool_definition`, +`gemini_tool_definition` (a function declaration dict for google-genai), and +`runnable()` / `async_runnable()` to run the model's input and return text for +the model. + ## Usage Hyperbrowser 1.0 accepts plain dictionaries for request parameters. Method From 2f3694168f5bb4c070f0b2269680c5176b8379aa Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 16:31:00 -0700 Subject: [PATCH 04/23] Parse secret types the API adds later, and update README models Secret.type is now a str defaulting to "secret", so a new type doesn't break list() or get(). Requests still accept only the known SecretType values. The README's LLM samples use gpt-5, claude-sonnet-5-5 and gemini-3.5-flash. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- hyperbrowser/models/secret.py | 3 ++- tests/test_credentials.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c008ba11..f04d48a7 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ tools = [CredentialFillTool.openai_tool_definition] # plus your browser tools messages = [{"role": "user", "content": "Sign in to GitHub with the credential named github."}] while True: response = openai_client.chat.completions.create( - model="gpt-4.1", messages=messages, tools=tools + model="gpt-5", messages=messages, tools=tools ) message = response.choices[0].message messages.append(message) @@ -272,7 +272,7 @@ tools = [CredentialFillTool.anthropic_tool_definition] # plus your browser tool messages = [{"role": "user", "content": "Sign in to GitHub with the credential named github."}] while True: response = anthropic_client.messages.create( - model="claude-sonnet-4-5", max_tokens=1024, tools=tools, messages=messages + model="claude-sonnet-5-5", max_tokens=1024, tools=tools, messages=messages ) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": @@ -311,7 +311,7 @@ contents = [ ] while True: response = gemini.models.generate_content( - model="gemini-2.5-flash", contents=contents, config=config + model="gemini-3.5-flash", contents=contents, config=config ) contents.append(response.candidates[0].content) if not response.function_calls: diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py index 1f6bd6bf..d9e0e1de 100644 --- a/hyperbrowser/models/secret.py +++ b/hyperbrowser/models/secret.py @@ -202,7 +202,8 @@ class Secret(SecretBaseModel): id: str name: str description: str = "" - type: SecretType = "secret" + # One of SecretType; kept as str so types the API adds later still parse. + type: str = "secret" metadata: SecretMetadata = Field(default_factory=SecretMetadata) encryption_context: str = Field(alias="encryptionContext") latest_version: Optional[int] = Field(default=None, alias="latestVersion") diff --git a/tests/test_credentials.py b/tests/test_credentials.py index fff9c4e8..20aa9698 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -362,6 +362,39 @@ def test_list_filters_by_type(): manager.list({"type": "password"}) +def test_secret_types_added_later_still_parse(): + future = dict(_secret(), type="future_type", metadata={"future": {"x": 1}}) + http = ScriptedHTTPClient( + { + ("GET", "/secrets"): (200, {"secrets": [future], "totalCount": 1}), + ("GET", f"/secrets/{SECRET_ID}"): (200, dict(future, versions=[])), + } + ) + manager = SecretManager(FakeClient(http)) + + assert manager.list().secrets[0].type == "future_type" + assert manager.get(SECRET_ID).type == "future_type" + assert manager.get("provider-key").type == "future_type" + # Requests still only take the known types. + with pytest.raises(ValueError): + CreateSecretParams(name="provider-key", type="future_type") + + +@pytest.mark.anyio +async def test_async_secret_types_added_later_still_parse(): + future = dict(_secret(), type="future_type") + http = AsyncScriptedHTTPClient( + { + ("GET", "/secrets"): (200, {"secrets": [future], "totalCount": 1}), + ("GET", f"/secrets/{SECRET_ID}"): (200, dict(future, versions=[])), + } + ) + manager = AsyncSecretManager(FakeClient(http)) + + assert (await manager.list()).secrets[0].type == "future_type" + assert (await manager.get("provider-key")).type == "future_type" + + @pytest.mark.anyio async def test_async_credentials_round_trip(private_key): routes = _create_routes(private_key) From 0f3a66b3562584bac651345fd8ff1c865275b687 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 20:28:13 -0700 Subject: [PATCH 05/23] Bump the version to 1.13.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FzcEMZLVjoiueoQFxG6F7S --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 49639501..68b78e01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hyperbrowser" -version = "1.12.1" +version = "1.13.0" description = "Python SDK for hyperbrowser" authors = ["Nikhil Shahi "] license = "MIT" From e723757b3c0710dec1874e17af9d34c86bf7f753 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 20:38:32 -0700 Subject: [PATCH 06/23] Correct the browser sessions scope and SECRET_TYPE_MISMATCH status in the README Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FzcEMZLVjoiueoQFxG6F7S --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f04d48a7..1596997a 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Things to know: - Fill is safe to retry and never retried automatically. It waits at least 30 seconds for the API (the client timeout when longer). - Attaching credentials needs an API key with `secrets.use`; filling also needs - `browser-sessions.update`. The team needs the secrets feature. Sessions with + `browser.sessions.update`. The team needs the secrets feature. Sessions with credentials run as Firecracker browsers with Chrome's password manager off. - Fills are recorded in the session's event log as `credential_filled` and `credential_fill_refused`. @@ -210,7 +210,7 @@ Things to know: - `CREDENTIAL_NOT_READY` (409): the credential has no allowed origins or no value. - `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND` (404), - `SECRET_NOT_FOUND` (404), `SECRET_TYPE_MISMATCH` (400, a plain secret was + `SECRET_NOT_FOUND` (404), `SECRET_TYPE_MISMATCH` (409, a plain secret was named), `SESSION_NOT_ACTIVE` (409). - `CREDENTIAL_FILL_TIMEOUT` (503/504, retryable) and `CREDENTIAL_FILL_FAILED` (500/502, retryable as flagged). From 08c1d3806662d319b1ba5a00449a02bed6187c83 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 21:16:04 -0700 Subject: [PATCH 07/23] Leave the version for the release to set Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FzcEMZLVjoiueoQFxG6F7S --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 68b78e01..49639501 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hyperbrowser" -version = "1.13.0" +version = "1.12.1" description = "Python SDK for hyperbrowser" authors = ["Nikhil Shahi "] license = "MIT" From 96a31da7015b305e18a45742f3c049125d383da9 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:39:13 -0700 Subject: [PATCH 08/23] List credential fill events in session event logs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- hyperbrowser/models/consts.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hyperbrowser/models/consts.py b/hyperbrowser/models/consts.py index 36f703f2..e3514c00 100644 --- a/hyperbrowser/models/consts.py +++ b/hyperbrowser/models/consts.py @@ -21,7 +21,12 @@ "not_enabled", "pending", "in_progress", "completed", "failed" ] SessionEventLogType = Literal[ - "captcha_detected", "captcha_solved", "captcha_error", "file_downloaded" + "captcha_detected", + "captcha_solved", + "captcha_error", + "file_downloaded", + "credential_filled", + "credential_fill_refused", ] POLLING_ATTEMPTS = 5 From 81c02ca9416f0e6c141ad0f54cc63f8e06821688 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:39:13 -0700 Subject: [PATCH 09/23] Fill secrets and vaults, and add TOTP and op_token secrets The credential secret type is gone: a secret is fillable once it has allowed origins, and a login is usually several secrets grouped in a vault. Secrets take type totp (checked locally as the platform reads seeds) or op_token, and allowed_origins on create. client.vaults manages stored and 1Password vaults; sandboxes and processes take vault next to secrets, and report each binding's source, type and 1Password reference. Sessions attach secrets or vaults, fills take credential or vault with page_url, and fill_credential lets a model choose either. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- hyperbrowser/client/async_client.py | 2 + .../client/managers/async_manager/sandbox.py | 27 +- .../sandboxes/sandbox_processes.py | 13 +- .../client/managers/async_manager/secret.py | 51 +- .../async_manager/session_credentials.py | 24 +- .../client/managers/async_manager/vault.py | 110 +++ .../client/managers/sandboxes/shared.py | 17 +- .../client/managers/sync_manager/sandbox.py | 27 +- .../sandboxes/sandbox_processes.py | 13 +- .../client/managers/sync_manager/secret.py | 51 +- .../sync_manager/session_credentials.py | 24 +- .../client/managers/sync_manager/vault.py | 108 +++ hyperbrowser/client/sync.py | 2 + hyperbrowser/credentials_common.py | 89 ++- hyperbrowser/models/__init__.py | 53 +- hyperbrowser/models/credential.py | 64 +- hyperbrowser/models/sandbox.py | 39 +- hyperbrowser/models/secret.py | 112 ++- hyperbrowser/models/session.py | 57 +- hyperbrowser/models/vault.py | 409 +++++++++++ hyperbrowser/secrets_common.py | 172 +++-- hyperbrowser/tools/__init__.py | 47 +- hyperbrowser/tools/schema.py | 75 +- hyperbrowser/types/__init__.py | 34 +- hyperbrowser/types/credential.py | 33 +- hyperbrowser/types/sandbox.py | 2 + hyperbrowser/types/secret.py | 31 +- hyperbrowser/types/session.py | 25 +- hyperbrowser/types/vault.py | 89 +++ hyperbrowser/vaults_common.py | 73 ++ tests/test_credential_tools.py | 99 ++- tests/test_credentials.py | 670 +++++++++++------- tests/test_sandbox_wire_contract.py | 1 + tests/test_secrets.py | 4 +- tests/test_vaults.py | 504 +++++++++++++ tests/typecheck/invalid_requests.py | 15 +- tests/typecheck/valid_requests.py | 146 +++- 37 files changed, 2639 insertions(+), 673 deletions(-) create mode 100644 hyperbrowser/client/managers/async_manager/vault.py create mode 100644 hyperbrowser/client/managers/sync_manager/vault.py create mode 100644 hyperbrowser/models/vault.py create mode 100644 hyperbrowser/types/vault.py create mode 100644 hyperbrowser/vaults_common.py create mode 100644 tests/test_vaults.py diff --git a/hyperbrowser/client/async_client.py b/hyperbrowser/client/async_client.py index c30135f4..7eab0d6f 100644 --- a/hyperbrowser/client/async_client.py +++ b/hyperbrowser/client/async_client.py @@ -11,6 +11,7 @@ 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.vault import VaultManager from .managers.async_manager.scrape import ScrapeManager from .managers.async_manager.session import SessionManager from .managers.async_manager.team import TeamManager @@ -51,6 +52,7 @@ def __init__( self.sandboxes = SandboxManager(self) self.volumes = VolumeManager(self) self.secrets = SecretManager(self) + self.vaults = VaultManager(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 ad7dc7be..c992aaa7 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -2,10 +2,11 @@ import functools import time from pathlib import Path -from typing import Dict, Optional, Union +from typing import Dict, List, Optional, Union from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError +from ....models.secret import SandboxResolvedSecret from ....models.sandbox import ( CompleteSandboxImageBuildParams, CreateSandboxParams, @@ -145,6 +146,10 @@ class SandboxHandle: def __init__(self, service: "SandboxManager", detail: SandboxDetail): self._service = service self._detail = detail + # Only the create response reports them; refreshes keep them. + self.startup_secrets: Optional[List[SandboxResolvedSecret]] = ( + detail.startup_secrets + ) self._runtime_session = self._to_runtime_session(detail) self._transport = RuntimeTransport( self._resolve_runtime_connection, @@ -331,14 +336,16 @@ async def exec( run_as: Optional[str] = None, max_output_bytes: int = 64 * 1024 * 1024, secrets: Optional[SecretReferences] = None, + vault: Optional[str] = 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. + or ``{"name": ..., "version": N}``). ``vault`` names a vault whose + entries become variables too; ``secrets`` win on a clash. 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, @@ -349,6 +356,7 @@ async def exec( run_as=run_as, max_output_bytes=max_output_bytes, secrets=secrets, + vault=vault, ) async def _start_secret_process(self, payload: Dict[str, object]) -> dict: @@ -1178,16 +1186,19 @@ async def _create_detail( ) -> SandboxDetail: normalized = coerce_request(params, CreateSandboxParams) data = dump_request(normalized, CreateSandboxParams) + delivers_secrets = bool(normalized.secrets) or normalized.vault is not None 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") + if delivers_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): + if delivers_secrets and request_may_have_arrived(error): raise sandbox_create_outcome_unknown_error(error) raise diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py index e3a62232..b2d84865 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py @@ -354,6 +354,7 @@ async def exec( run_as: Optional[str] = None, max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, secrets: Optional[SecretReferences] = None, + vault: Optional[str] = None, ) -> SandboxProcessResult: handle = await self.start( input, @@ -364,6 +365,7 @@ async def exec( run_as=run_as, max_output_bytes=max_output_bytes, secrets=secrets, + vault=vault, ) if handle.secrets is not None: return await self._finish_secret_exec(handle) @@ -383,12 +385,14 @@ async def start( run_as: Optional[str] = None, max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, secrets: Optional[SecretReferences] = None, + vault: Optional[str] = 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. + With ``secrets`` or ``vault``, 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. ``secrets`` are merged over the vault's entries. """ validate_output_limit(max_output_bytes) params = _normalize_exec_params( @@ -399,8 +403,9 @@ async def start( timeout_sec=timeout_sec, run_as=run_as, secrets=secrets, + vault=vault, ) - if params.secrets: + if params.uses_secrets: return await self._start_with_secrets(params) events = self._transport.stream_sse( "/sandbox/processes", diff --git a/hyperbrowser/client/managers/async_manager/secret.py b/hyperbrowser/client/managers/async_manager/secret.py index 65d5d36e..cbbc73fa 100644 --- a/hyperbrowser/client/managers/async_manager/secret.py +++ b/hyperbrowser/client/managers/async_manager/secret.py @@ -1,5 +1,5 @@ import asyncio -from typing import Dict, Mapping, Optional, Sequence, Union +from typing import Dict, Optional, Sequence, Union from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError @@ -10,7 +10,6 @@ SecretDetail, SecretListParams, SecretListResponse, - SecretMetadata, SecretsPublicKey, SecretVersion, SecretValue, @@ -29,7 +28,7 @@ SECRET_NAME_LOOKUP_LIMIT, SecretsSealer, browser_metadata, - check_secret_value, + check_secret_metadata, check_secret_version, is_not_found, looks_like_secret_id, @@ -77,6 +76,11 @@ async def create( ) -> Secret: """Create a secret, and store its first version when ``value`` is set. + ``type`` is ``"secret"`` (the default), ``"totp"`` (a base32 seed or an + ``otpauth://totp/`` URI) or ``"op_token"`` (a 1Password service + account token, which starts with ``ops_``). ``allowed_origins`` makes + a ``secret`` or ``totp`` fillable into browser pages on those origins. + The value and the public key are checked before the secret is created, so a bad value or an unavailable key leaves nothing behind. """ @@ -109,32 +113,6 @@ async def get(self, secret: str) -> SecretDetail: return detail return await self._get_by_id(detail.id) - async def create_credential( - self, - name: str, - value: Mapping[str, str], - allowed_origins: Sequence[str], - description: Optional[str] = None, - ) -> Secret: - """Create a website credential that browser sessions can fill. - - ``value`` maps field names to strings, such as - ``{"username": ..., "password": ...}``; it is encrypted locally and - stored as the first version. ``allowed_origins`` lists the origins - (``https://example.com``, ``https://*.example.com``) it may be filled on. - """ - return await self.create( - CreateSecretParams( - name=name, - description=description, - type="credential", - metadata=SecretMetadata.model_validate( - browser_metadata(allowed_origins) - ), - value=value, - ) - ) - async def update( self, secret: str, @@ -146,15 +124,16 @@ async def update( stored one, ``None`` removes it, and members not sent are kept. """ body = update_secret_body(params) - secret_id = (await self._resolve(secret))[0].id - payload = await self._request("PATCH", f"/secrets/{secret_id}", data=body) + resolved = (await self._resolve(secret))[0] + check_secret_metadata(resolved.type, body.get("metadata")) + payload = await self._request("PATCH", f"/secrets/{resolved.id}", data=body) return Secret(**payload) async def set_allowed_origins( self, secret: str, allowed_origins: Optional[Sequence[str]] ) -> Secret: - """Replace the origins a credential may be filled on; ``None`` clears - them, after which the credential can't be filled.""" + """Replace the origins a secret may be filled on; ``None`` clears + them, after which it can't be filled. Not for ``op_token`` secrets.""" return await self.update( secret, {"metadata": browser_metadata(allowed_origins)} ) @@ -162,10 +141,10 @@ async def set_allowed_origins( async def add_version(self, secret: str, value: SecretValue) -> SecretVersion: """Encrypt ``value`` locally and store it as the secret's next version. - A credential takes a mapping of field names to strings; a plain secret - takes str or bytes. + The value is checked against the secret's type first: a ``totp`` + secret takes a base32 seed or an ``otpauth://totp/`` URI. """ - check_secret_value(value) + secret_payload_bytes(None, value) resolved = (await self._resolve(secret))[0] payload = secret_payload_bytes(resolved.type, value) return await self._add_version( diff --git a/hyperbrowser/client/managers/async_manager/session_credentials.py b/hyperbrowser/client/managers/async_manager/session_credentials.py index 57a3969d..267f034a 100644 --- a/hyperbrowser/client/managers/async_manager/session_credentials.py +++ b/hyperbrowser/client/managers/async_manager/session_credentials.py @@ -12,9 +12,10 @@ class SessionCredentialsManager: - """Fill credentials attached to a session into its pages. + """Fill secrets attached to a session into its pages. - Attach credentials with ``sessions.create({"credentials": [...]})``. + Attach secrets or vaults with + ``sessions.create({"credentials": [...]})``. """ def __init__(self, client): @@ -25,14 +26,19 @@ async def fill( session_id: str, params: Union[FillCredentialParamsDict, FillCredentialParams], ) -> CredentialFillResult: - """Type a credential's fields into the inputs matching each selector. + """Type a secret, or a vault's entries, into the inputs matching each + selector. - Fill never submits: click the login button or press Enter afterwards. - It is safe to retry. Values are never returned; each field reports an - outcome such as ``filled`` or ``not_found``. When several tabs could - be the target, the API fails with ``CREDENTIAL_PAGE_AMBIGUOUS`` and - lists them in ``error.details["details"]["pages"]``; pass one as - ``page_id``. + Pass ``credential`` (a secret's name or ID) with + ``[{"selector": ...}]``, or ``vault`` with + ``[{"field": ENTRY, "selector": ...}]``. A TOTP secret types the code + that is current at fill time. Fill never submits: click the login + button or press Enter afterwards. It is safe to retry. Values are + never returned; each input reports an outcome such as ``filled`` or + ``not_found``. When several tabs could be the target, the API fails + with ``CREDENTIAL_PAGE_AMBIGUOUS`` and lists them in + ``error.details["details"]["pages"]``; pass one's ``pageId`` as + ``page_id``, or a URL prefix as ``page_url``. """ body = fill_credential_payload(params) response = await self._client.transport.post( diff --git a/hyperbrowser/client/managers/async_manager/vault.py b/hyperbrowser/client/managers/async_manager/vault.py new file mode 100644 index 00000000..30212ce7 --- /dev/null +++ b/hyperbrowser/client/managers/async_manager/vault.py @@ -0,0 +1,110 @@ +from typing import Optional, Tuple, Union + +from ..._request import dump_request +from ....exceptions import HyperbrowserError +from ....models.vault import ( + CreateVaultParams, + UpdateVaultParams, + Vault, + VaultDeleteResult, + VaultListParams, + VaultListResponse, +) +from ....secrets_common import ( + SECRET_NAME_LOOKUP_LIMIT, + is_not_found, + looks_like_secret_id, +) +from ....types import CreateVaultParams as CreateVaultParamsDict +from ....types import UpdateVaultParams as UpdateVaultParamsDict +from ....types import VaultListParams as VaultListParamsDict +from ....vaults_common import ( + create_vault_body, + update_vault_body, + vault_not_found_error, +) +from .secret import SecretManager + + +class VaultManager: + """Manage vaults: named sets of environment variables whose values come + from stored secrets or from a 1Password vault. + + Pass a vault by name as ``vault`` when creating a sandbox or starting a + process, or as ``{"vault": name}`` in a browser session's + ``credentials``. Methods that take ``vault`` accept its ID or exact name. + """ + + def __init__(self, client): + self._client = client + # Vault routes share the secrets routes' errors and retries. + self._control = SecretManager(client) + + async def list( + self, + params: Optional[Union[VaultListParamsDict, VaultListParams]] = None, + ) -> VaultListResponse: + payload = await self._control._request( + "GET", + "/vaults", + params=dump_request(params if params is not None else {}, VaultListParams), + ) + return VaultListResponse(**payload) + + async def create( + self, + params: Union[CreateVaultParamsDict, CreateVaultParams], + ) -> Vault: + """Create a vault. + + ``source`` defaults to ``{"type": "stored"}``, whose entries are + ``{"name": secret, "version": N}`` (version optional). A 1Password + vault, ``{"type": "1password", "token_secret": op_token_secret, + "vault": name_or_id}``, takes ``{"item", "section", "field"}`` entries. + The source type can't change later. + """ + body = create_vault_body(params) + return Vault(**await self._control._request("POST", "/vaults", data=body)) + + async def get(self, vault: str) -> Vault: + return (await self._resolve(vault))[0] + + async def update( + self, + vault: str, + params: Union[UpdateVaultParamsDict, UpdateVaultParams], + ) -> Vault: + """Update a vault's description, entries or 1Password settings. + + Each entry given replaces that variable and an entry set to ``None`` + removes it; entries not given are kept. ``source`` takes + ``token_secret`` and/or ``vault``, for 1Password vaults only. + """ + body = update_vault_body(params) + vault_id = (await self._resolve(vault))[1] + payload = await self._control._request( + "PATCH", f"/vaults/{vault_id}", data=body + ) + return Vault(**payload) + + async def delete(self, vault: str) -> VaultDeleteResult: + """Delete a vault. Its secrets are kept.""" + vault_id = (await self._resolve(vault))[1] + payload = await self._control._request("DELETE", f"/vaults/{vault_id}") + return VaultDeleteResult(**payload) + + async def _resolve(self, key: str) -> Tuple[Vault, str]: + """Find a vault by ID or exact name.""" + key = key.strip() + if looks_like_secret_id(key): + try: + found = Vault(**await self._control._request("GET", f"/vaults/{key}")) + return found, found.id + 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.vaults: + if item.name == key: + return item, item.id + raise vault_not_found_error(key) diff --git a/hyperbrowser/client/managers/sandboxes/shared.py b/hyperbrowser/client/managers/sandboxes/shared.py index 620561af..74ccdd73 100644 --- a/hyperbrowser/client/managers/sandboxes/shared.py +++ b/hyperbrowser/client/managers/sandboxes/shared.py @@ -97,6 +97,7 @@ def _normalize_exec_params( timeout_sec: Optional[int] = None, run_as: Optional[str] = None, secrets: Optional[SecretReferences] = None, + vault: Optional[str] = None, ) -> SandboxExecParams: if isinstance(input, str): params = SandboxExecParams(command=input) @@ -119,9 +120,13 @@ def _normalize_exec_params( updates["secrets"] = SandboxExecParams.model_validate( {"command": params.command, "secrets": secrets} ).secrets + if vault is not None: + updates["vault"] = SandboxExecParams.model_validate( + {"command": params.command, "vault": vault} + ).vault normalized = params.model_copy(update=updates) if updates else params - if normalized.secrets and normalized.use_shell is not None: + if normalized.uses_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", @@ -132,7 +137,9 @@ def _normalize_exec_params( 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) + return dump_request( + params.model_copy(update={"secrets": None, "vault": None}), SandboxExecParams + ) def _secret_process_payload(params: SandboxExecParams) -> Dict[str, object]: @@ -155,10 +162,14 @@ def _secret_process_payload(params: SandboxExecParams) -> Dict[str, object]: "env": params.env, "timeoutMs": timeout_ms, "runAs": params.run_as, + # A vault alone needs no secrets member. "secrets": { name: reference.model_dump(exclude_none=True) for name, reference in secrets.items() - }, + } + if secrets or params.vault is None + else None, + "vault": params.vault, } return {key: value for key, value in payload.items() if value is not None} diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index f15bfe01..fb3fa155 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -1,9 +1,10 @@ import time from pathlib import Path -from typing import Dict, Optional, Union +from typing import Dict, List, Optional, Union from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError +from ....models.secret import SandboxResolvedSecret from ....models.sandbox import ( CompleteSandboxImageBuildParams, CreateSandboxParams, @@ -137,6 +138,10 @@ class SandboxHandle: def __init__(self, service: "SandboxManager", detail: SandboxDetail): self._service = service self._detail = detail + # Only the create response reports them; refreshes keep them. + self.startup_secrets: Optional[List[SandboxResolvedSecret]] = ( + detail.startup_secrets + ) self._runtime_session = self._to_runtime_session(detail) self._transport = RuntimeTransport( self._resolve_runtime_connection, @@ -323,14 +328,16 @@ def exec( run_as: Optional[str] = None, max_output_bytes: int = 64 * 1024 * 1024, secrets: Optional[SecretReferences] = None, + vault: Optional[str] = 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. + or ``{"name": ..., "version": N}``). ``vault`` names a vault whose + entries become variables too; ``secrets`` win on a clash. 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, @@ -341,6 +348,7 @@ def exec( run_as=run_as, max_output_bytes=max_output_bytes, secrets=secrets, + vault=vault, ) def _start_secret_process(self, payload: Dict[str, object]) -> dict: @@ -1156,16 +1164,19 @@ def _create_detail( ) -> SandboxDetail: normalized = coerce_request(params, CreateSandboxParams) data = dump_request(normalized, CreateSandboxParams) + delivers_secrets = bool(normalized.secrets) or normalized.vault is not None 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") + if delivers_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): + if delivers_secrets and request_may_have_arrived(error): raise sandbox_create_outcome_unknown_error(error) raise diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py index cd57ef81..d335aa17 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py @@ -353,6 +353,7 @@ def exec( run_as: Optional[str] = None, max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, secrets: Optional[SecretReferences] = None, + vault: Optional[str] = None, ) -> SandboxProcessResult: handle = self.start( input, @@ -363,6 +364,7 @@ def exec( run_as=run_as, max_output_bytes=max_output_bytes, secrets=secrets, + vault=vault, ) if handle.secrets is not None: return self._finish_secret_exec(handle) @@ -382,12 +384,14 @@ def start( run_as: Optional[str] = None, max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, secrets: Optional[SecretReferences] = None, + vault: Optional[str] = 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. + With ``secrets`` or ``vault``, 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. ``secrets`` are merged over the vault's entries. """ validate_output_limit(max_output_bytes) params = _normalize_exec_params( @@ -398,8 +402,9 @@ def start( timeout_sec=timeout_sec, run_as=run_as, secrets=secrets, + vault=vault, ) - if params.secrets: + if params.uses_secrets: return self._start_with_secrets(params) close_stream = [] events = self._transport.stream_sse( diff --git a/hyperbrowser/client/managers/sync_manager/secret.py b/hyperbrowser/client/managers/sync_manager/secret.py index 1276b269..37576196 100644 --- a/hyperbrowser/client/managers/sync_manager/secret.py +++ b/hyperbrowser/client/managers/sync_manager/secret.py @@ -1,5 +1,5 @@ import time -from typing import Dict, Mapping, Optional, Sequence, Union +from typing import Dict, Optional, Sequence, Union from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError @@ -10,7 +10,6 @@ SecretDetail, SecretListParams, SecretListResponse, - SecretMetadata, SecretsPublicKey, SecretVersion, SecretValue, @@ -29,7 +28,7 @@ SECRET_NAME_LOOKUP_LIMIT, SecretsSealer, browser_metadata, - check_secret_value, + check_secret_metadata, check_secret_version, is_not_found, looks_like_secret_id, @@ -77,6 +76,11 @@ def create( ) -> Secret: """Create a secret, and store its first version when ``value`` is set. + ``type`` is ``"secret"`` (the default), ``"totp"`` (a base32 seed or an + ``otpauth://totp/`` URI) or ``"op_token"`` (a 1Password service + account token, which starts with ``ops_``). ``allowed_origins`` makes + a ``secret`` or ``totp`` fillable into browser pages on those origins. + The value and the public key are checked before the secret is created, so a bad value or an unavailable key leaves nothing behind. """ @@ -109,32 +113,6 @@ def get(self, secret: str) -> SecretDetail: return detail return self._get_by_id(detail.id) - def create_credential( - self, - name: str, - value: Mapping[str, str], - allowed_origins: Sequence[str], - description: Optional[str] = None, - ) -> Secret: - """Create a website credential that browser sessions can fill. - - ``value`` maps field names to strings, such as - ``{"username": ..., "password": ...}``; it is encrypted locally and - stored as the first version. ``allowed_origins`` lists the origins - (``https://example.com``, ``https://*.example.com``) it may be filled on. - """ - return self.create( - CreateSecretParams( - name=name, - description=description, - type="credential", - metadata=SecretMetadata.model_validate( - browser_metadata(allowed_origins) - ), - value=value, - ) - ) - def update( self, secret: str, @@ -146,24 +124,25 @@ def update( stored one, ``None`` removes it, and members not sent are kept. """ body = update_secret_body(params) - secret_id = self._resolve(secret)[0].id - payload = self._request("PATCH", f"/secrets/{secret_id}", data=body) + resolved = self._resolve(secret)[0] + check_secret_metadata(resolved.type, body.get("metadata")) + payload = self._request("PATCH", f"/secrets/{resolved.id}", data=body) return Secret(**payload) def set_allowed_origins( self, secret: str, allowed_origins: Optional[Sequence[str]] ) -> Secret: - """Replace the origins a credential may be filled on; ``None`` clears - them, after which the credential can't be filled.""" + """Replace the origins a secret may be filled on; ``None`` clears + them, after which it can't be filled. Not for ``op_token`` secrets.""" return self.update(secret, {"metadata": browser_metadata(allowed_origins)}) def add_version(self, secret: str, value: SecretValue) -> SecretVersion: """Encrypt ``value`` locally and store it as the secret's next version. - A credential takes a mapping of field names to strings; a plain secret - takes str or bytes. + The value is checked against the secret's type first: a ``totp`` + secret takes a base32 seed or an ``otpauth://totp/`` URI. """ - check_secret_value(value) + secret_payload_bytes(None, value) resolved = self._resolve(secret)[0] payload = secret_payload_bytes(resolved.type, value) return self._add_version( diff --git a/hyperbrowser/client/managers/sync_manager/session_credentials.py b/hyperbrowser/client/managers/sync_manager/session_credentials.py index b9d1a5b6..8f20ab15 100644 --- a/hyperbrowser/client/managers/sync_manager/session_credentials.py +++ b/hyperbrowser/client/managers/sync_manager/session_credentials.py @@ -12,9 +12,10 @@ class SessionCredentialsManager: - """Fill credentials attached to a session into its pages. + """Fill secrets attached to a session into its pages. - Attach credentials with ``sessions.create({"credentials": [...]})``. + Attach secrets or vaults with + ``sessions.create({"credentials": [...]})``. """ def __init__(self, client): @@ -25,14 +26,19 @@ def fill( session_id: str, params: Union[FillCredentialParamsDict, FillCredentialParams], ) -> CredentialFillResult: - """Type a credential's fields into the inputs matching each selector. + """Type a secret, or a vault's entries, into the inputs matching each + selector. - Fill never submits: click the login button or press Enter afterwards. - It is safe to retry. Values are never returned; each field reports an - outcome such as ``filled`` or ``not_found``. When several tabs could - be the target, the API fails with ``CREDENTIAL_PAGE_AMBIGUOUS`` and - lists them in ``error.details["details"]["pages"]``; pass one as - ``page_id``. + Pass ``credential`` (a secret's name or ID) with + ``[{"selector": ...}]``, or ``vault`` with + ``[{"field": ENTRY, "selector": ...}]``. A TOTP secret types the code + that is current at fill time. Fill never submits: click the login + button or press Enter afterwards. It is safe to retry. Values are + never returned; each input reports an outcome such as ``filled`` or + ``not_found``. When several tabs could be the target, the API fails + with ``CREDENTIAL_PAGE_AMBIGUOUS`` and lists them in + ``error.details["details"]["pages"]``; pass one's ``pageId`` as + ``page_id``, or a URL prefix as ``page_url``. """ body = fill_credential_payload(params) response = self._client.transport.post( diff --git a/hyperbrowser/client/managers/sync_manager/vault.py b/hyperbrowser/client/managers/sync_manager/vault.py new file mode 100644 index 00000000..5cb2c62a --- /dev/null +++ b/hyperbrowser/client/managers/sync_manager/vault.py @@ -0,0 +1,108 @@ +from typing import Optional, Tuple, Union + +from ..._request import dump_request +from ....exceptions import HyperbrowserError +from ....models.vault import ( + CreateVaultParams, + UpdateVaultParams, + Vault, + VaultDeleteResult, + VaultListParams, + VaultListResponse, +) +from ....secrets_common import ( + SECRET_NAME_LOOKUP_LIMIT, + is_not_found, + looks_like_secret_id, +) +from ....types import CreateVaultParams as CreateVaultParamsDict +from ....types import UpdateVaultParams as UpdateVaultParamsDict +from ....types import VaultListParams as VaultListParamsDict +from ....vaults_common import ( + create_vault_body, + update_vault_body, + vault_not_found_error, +) +from .secret import SecretManager + + +class VaultManager: + """Manage vaults: named sets of environment variables whose values come + from stored secrets or from a 1Password vault. + + Pass a vault by name as ``vault`` when creating a sandbox or starting a + process, or as ``{"vault": name}`` in a browser session's + ``credentials``. Methods that take ``vault`` accept its ID or exact name. + """ + + def __init__(self, client): + self._client = client + # Vault routes share the secrets routes' errors and retries. + self._control = SecretManager(client) + + def list( + self, + params: Optional[Union[VaultListParamsDict, VaultListParams]] = None, + ) -> VaultListResponse: + payload = self._control._request( + "GET", + "/vaults", + params=dump_request(params if params is not None else {}, VaultListParams), + ) + return VaultListResponse(**payload) + + def create( + self, + params: Union[CreateVaultParamsDict, CreateVaultParams], + ) -> Vault: + """Create a vault. + + ``source`` defaults to ``{"type": "stored"}``, whose entries are + ``{"name": secret, "version": N}`` (version optional). A 1Password + vault, ``{"type": "1password", "token_secret": op_token_secret, + "vault": name_or_id}``, takes ``{"item", "section", "field"}`` entries. + The source type can't change later. + """ + body = create_vault_body(params) + return Vault(**self._control._request("POST", "/vaults", data=body)) + + def get(self, vault: str) -> Vault: + return (self._resolve(vault))[0] + + def update( + self, + vault: str, + params: Union[UpdateVaultParamsDict, UpdateVaultParams], + ) -> Vault: + """Update a vault's description, entries or 1Password settings. + + Each entry given replaces that variable and an entry set to ``None`` + removes it; entries not given are kept. ``source`` takes + ``token_secret`` and/or ``vault``, for 1Password vaults only. + """ + body = update_vault_body(params) + vault_id = (self._resolve(vault))[1] + payload = self._control._request("PATCH", f"/vaults/{vault_id}", data=body) + return Vault(**payload) + + def delete(self, vault: str) -> VaultDeleteResult: + """Delete a vault. Its secrets are kept.""" + vault_id = (self._resolve(vault))[1] + payload = self._control._request("DELETE", f"/vaults/{vault_id}") + return VaultDeleteResult(**payload) + + def _resolve(self, key: str) -> Tuple[Vault, str]: + """Find a vault by ID or exact name.""" + key = key.strip() + if looks_like_secret_id(key): + try: + found = Vault(**self._control._request("GET", f"/vaults/{key}")) + return found, found.id + 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.vaults: + if item.name == key: + return item, item.id + raise vault_not_found_error(key) diff --git a/hyperbrowser/client/sync.py b/hyperbrowser/client/sync.py index 4983ad3d..c120a89d 100644 --- a/hyperbrowser/client/sync.py +++ b/hyperbrowser/client/sync.py @@ -11,6 +11,7 @@ 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.vault import VaultManager from .managers.sync_manager.scrape import ScrapeManager from .managers.sync_manager.session import SessionManager from .managers.sync_manager.team import TeamManager @@ -51,6 +52,7 @@ def __init__( self.sandboxes = SandboxManager(self) self.volumes = VolumeManager(self) self.secrets = SecretManager(self) + self.vaults = VaultManager(self) def close(self) -> None: self.transport.close() diff --git a/hyperbrowser/credentials_common.py b/hyperbrowser/credentials_common.py index 0f6b3459..db83acb2 100644 --- a/hyperbrowser/credentials_common.py +++ b/hyperbrowser/credentials_common.py @@ -1,6 +1,5 @@ """Credential fill rules shared by the sync and async clients and the tools.""" -import re from typing import Any, Dict from pydantic import ValidationError @@ -8,14 +7,14 @@ from .client._request import coerce_request from .exceptions import HyperbrowserError from .models.credential import FillCredentialParams +from .models.secret import SECRET_ENV_NAME_PATTERN, SECRET_NAME_PATTERN +from .models.vault import MAX_VAULT_ENV_NAME_LENGTH -# Credential field names: the keys of a stored credential value, which are -# also the field names a fill writes. -CREDENTIAL_FIELD_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$") MAX_CREDENTIAL_FILL_FIELDS = 16 +MAX_CREDENTIAL_NAME_LENGTH = 255 MAX_CREDENTIAL_SELECTOR_BYTES = 1024 MAX_CREDENTIAL_PAGE_ID_BYTES = 128 -MAX_CREDENTIAL_FRAME_URL_BYTES = 2048 +MAX_CREDENTIAL_URL_BYTES = 2048 # The server gives a fill about 18 seconds, so wait longer than that. CREDENTIAL_FILL_REQUEST_TIMEOUT_SECONDS = 30 @@ -28,6 +27,11 @@ def _utf8_length(value: str) -> int: return len(value.encode("utf-8", "surrogatepass")) +def _check_bytes(value: Any, name: str, limit: int) -> None: + if value is not None and not 0 < _utf8_length(value) <= limit: + raise _invalid(f"{name} must be 1 to {limit} bytes when set") + + def fill_credential_payload(params: Any) -> Dict[str, Any]: """Check a fill request locally and return its JSON body. @@ -46,38 +50,65 @@ def fill_credential_payload(params: Any) -> Dict[str, Any]: except TypeError as error: raise _invalid(str(error)) from None - if not request.credential.strip(): - raise _invalid("credential must name a credential attached to the session") + if (request.credential is None) == (request.vault is None): + raise _invalid( + "Pass exactly one of credential (a secret attached to the session) " + "and vault (a vault attached to the session)" + ) + if request.credential is not None and not ( + request.credential.strip() + and len(request.credential) <= MAX_CREDENTIAL_NAME_LENGTH + ): + raise _invalid("credential must name a secret attached to the session") + if request.vault is not None and not SECRET_NAME_PATTERN.fullmatch(request.vault): + raise _invalid( + f"Invalid vault name {request.vault!r}: names are 1-128 letters, " + "digits, '_' or '-', starting with a letter or digit" + ) if not 1 <= len(request.fields) <= MAX_CREDENTIAL_FILL_FIELDS: raise _invalid( f"fields must have 1 to {MAX_CREDENTIAL_FILL_FIELDS} entries, " f"got {len(request.fields)}" ) - seen = set() + selectors = set() for entry in request.fields: - if not CREDENTIAL_FIELD_PATTERN.fullmatch(entry.field): - raise _invalid( - f"Invalid field {entry.field!r}: field names are 1-64 letters, " - "digits, '_', '.' or '-'" - ) - if entry.field in seen: - raise _invalid(f"Field {entry.field!r} is listed more than once") - seen.add(entry.field) if not entry.selector.strip(): - raise _invalid(f"The selector for field {entry.field!r} is empty") + raise _invalid("A field's selector is empty") if _utf8_length(entry.selector) > MAX_CREDENTIAL_SELECTOR_BYTES: raise _invalid( - f"The selector for field {entry.field!r} is longer than " + f"The selector {entry.selector[:64]!r} is longer than " f"{MAX_CREDENTIAL_SELECTOR_BYTES} bytes" ) - if request.page_id is not None and not ( - 0 < _utf8_length(request.page_id) <= MAX_CREDENTIAL_PAGE_ID_BYTES - ): - raise _invalid( - f"page_id must be 1 to {MAX_CREDENTIAL_PAGE_ID_BYTES} bytes when set" - ) - if request.frame is not None and not ( - 0 < _utf8_length(request.frame.url) <= MAX_CREDENTIAL_FRAME_URL_BYTES - ): - raise _invalid(f"frame.url must be 1 to {MAX_CREDENTIAL_FRAME_URL_BYTES} bytes") - return request.model_dump(by_alias=True, exclude_none=True) + if entry.selector in selectors: + raise _invalid(f"The selector {entry.selector!r} is listed more than once") + selectors.add(entry.selector) + if request.vault is None: + if entry.field is not None: + raise _invalid( + "field names a vault entry; leave it out when filling a " + "single credential" + ) + elif entry.field is None: + raise _invalid( + f"The field for {entry.selector!r} needs field: the vault entry " + "to type" + ) + elif ( + not SECRET_ENV_NAME_PATTERN.fullmatch(entry.field) + or len(entry.field) > MAX_VAULT_ENV_NAME_LENGTH + or entry.field == "__proto__" + ): + raise _invalid( + f"Invalid field {entry.field!r}: vault entries are environment " + "variable names (letters, digits and '_', not starting with a digit)" + ) + if request.page_id is not None and request.page_url is not None: + raise _invalid("Pass page_id or page_url, not both") + _check_bytes(request.page_id, "page_id", MAX_CREDENTIAL_PAGE_ID_BYTES) + _check_bytes(request.page_url, "page_url", MAX_CREDENTIAL_URL_BYTES) + _check_bytes(request.frame_url, "frame_url", MAX_CREDENTIAL_URL_BYTES) + + body = request.model_dump(by_alias=True, exclude_none=True) + if request.frame_url is not None: + body["frame"] = {"url": request.frame_url} + return body diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index 7475b841..164c1206 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -1,7 +1,6 @@ from .credential import ( CredentialFieldResult, CredentialFillField, - CredentialFillFrame, CredentialFillOutcome, CredentialFillResult, FillCredentialParams, @@ -264,9 +263,10 @@ ) from .secret import ( CreateSecretParams, - CredentialBrowserMetadata, SandboxResolvedSecret, Secret, + SecretBindingSource, + SecretBrowserMetadata, SecretDeleteResult, SecretDetail, SecretJWK, @@ -345,6 +345,9 @@ ScreenConfig, Session, SessionCredential, + SessionCredentialInput, + SessionCredentialSecret, + SessionCredentialVault, SessionDetail, SessionGetParams, SessionListParams, @@ -486,11 +489,30 @@ SandboxTerminalEvent, ) from .team import TeamCreditInfo +from .vault import ( + CreateVaultParams, + OnePasswordEntry, + OnePasswordSourceUpdate, + OnePasswordVaultSource, + StoredEntry, + StoredVaultSource, + UpdateVaultParams, + Vault, + VaultDeleteResult, + VaultEntry, + VaultEntryInput, + VaultListParams, + VaultListResponse, + VaultOnePasswordEntry, + VaultSource, + VaultSourceInput, + VaultStoredEntry, + VaultTokenSecret, +) __all__ = [ "CredentialFieldResult", "CredentialFillField", - "CredentialFillFrame", "CredentialFillOutcome", "CredentialFillResult", "FillCredentialParams", @@ -669,11 +691,31 @@ "ProfileListParams", "ProfileListResponse", "ProfileResponse", + # vault + "CreateVaultParams", + "OnePasswordEntry", + "OnePasswordSourceUpdate", + "OnePasswordVaultSource", + "StoredEntry", + "StoredVaultSource", + "UpdateVaultParams", + "Vault", + "VaultDeleteResult", + "VaultEntry", + "VaultEntryInput", + "VaultListParams", + "VaultListResponse", + "VaultOnePasswordEntry", + "VaultSource", + "VaultSourceInput", + "VaultStoredEntry", + "VaultTokenSecret", # secret "CreateSecretParams", - "CredentialBrowserMetadata", "SandboxResolvedSecret", "Secret", + "SecretBindingSource", + "SecretBrowserMetadata", "SecretDeleteResult", "SecretDetail", "SecretJWK", @@ -722,6 +764,9 @@ "ScreenConfig", "Session", "SessionCredential", + "SessionCredentialInput", + "SessionCredentialSecret", + "SessionCredentialVault", "SessionDetail", "SessionGetParams", "SessionListParams", diff --git a/hyperbrowser/models/credential.py b/hyperbrowser/models/credential.py index 2d8b1077..c322da08 100644 --- a/hyperbrowser/models/credential.py +++ b/hyperbrowser/models/credential.py @@ -1,75 +1,85 @@ -"""Request and response models for filling stored credentials into pages.""" +"""Request and response models for filling secrets into browser pages.""" +from datetime import datetime from typing import List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field -# What a fill did with each requested field. New outcomes may be added, so +# What a fill did with each requested input. New outcomes may be added, so # results keep any string the server sends. -# filled the input now holds exactly the stored value +# filled the input now holds the value # not_found no input matched the selector # ambiguous several visible inputs matched the selector # not_editable the input is disabled, read-only, hidden or not a text input # not_accepted the page didn't keep the value (for example maxlength) -# not_populated the stored credential has no such field CredentialFillOutcome = Literal[ "filled", "not_found", "ambiguous", "not_editable", "not_accepted", - "not_populated", ] class CredentialFillField(BaseModel): - """Types the credential's ``field`` (a key of the stored value, such as - ``"username"``) into the input matching the CSS ``selector``.""" - - model_config = ConfigDict(populate_by_name=True) - - field: str - selector: str + """Types a value into the input matching the CSS ``selector``. + With a ``vault`` fill, ``field`` names the vault entry (its environment + variable name, such as ``"PASSWORD"``) whose secret is typed; without + one it is left out. + """ -class CredentialFillFrame(BaseModel): - """Selects an iframe by URL prefix.""" + model_config = ConfigDict(populate_by_name=True, extra="forbid") - model_config = ConfigDict(populate_by_name=True) - - url: str + selector: str + field: Optional[str] = None class FillCredentialParams(BaseModel): - """Fill a credential attached to the session into the page's inputs. + """Fill a secret, or a vault's entries, attached to the session at + creation into a page's inputs. - ``credential`` is the credential's name or ID. ``page_id`` is a CDP target - ID; it is needed when several tabs could be the target. + Pass exactly one of ``credential`` (a secret's name or ID) and ``vault`` + (a vault's name). ``page_id`` (a CDP target ID) or ``page_url`` (a URL + prefix) picks the tab when several could be the target; ``frame_url`` + (a URL prefix) picks an iframe. """ - model_config = ConfigDict(populate_by_name=True) + # Unknown members fail rather than fill a page the caller didn't mean. + model_config = ConfigDict(populate_by_name=True, extra="forbid") - credential: str + credential: Optional[str] = None + vault: Optional[str] = None fields: List[CredentialFillField] page_id: Optional[str] = Field(default=None, serialization_alias="pageId") - frame: Optional[CredentialFillFrame] = None + page_url: Optional[str] = Field(default=None, serialization_alias="pageUrl") + # Sent as {"frame": {"url": ...}}. + frame_url: Optional[str] = Field(default=None, exclude=True) class CredentialFieldResult(BaseModel): + """What a fill did with one input. Values are never returned.""" + model_config = ConfigDict(populate_by_name=True) - field: str + selector: str # One of CredentialFillOutcome; kept as str so new outcomes still parse. outcome: str + secret_id: Optional[str] = Field(default=None, alias="secretId") + version: Optional[int] = None + # The vault entry, for vault fills. + field: Optional[str] = None + # When the typed TOTP code stops being valid, for TOTP secrets. + expires_at: Optional[datetime] = Field(default=None, alias="expiresAt") class CredentialFillResult(BaseModel): - """The outcome of a fill, per field. Values are never returned.""" + """The outcome of a fill, per input. Values are never returned.""" model_config = ConfigDict(populate_by_name=True) - # The credential version that was filled. - version: int + # The secret version filled; set for single-secret fills only. + version: Optional[int] = None page_id: Optional[str] = Field(default=None, alias="pageId") origin: Optional[str] = None fields: List[CredentialFieldResult] = Field(default_factory=list) diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index d709a123..8d148972 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -11,7 +11,12 @@ ) from ._parsers import _parse_optional_int -from .secret import SecretReference, normalize_secret_references +from .secret import ( + SandboxResolvedSecret, + SecretReference, + normalize_secret_references, + validate_secret_name, +) from .session import SessionLaunchState, SessionStatus SandboxStatus = SessionStatus @@ -186,6 +191,11 @@ def parse_optional_int_fields(cls, value): class SandboxDetail(Sandbox): token: Optional[str] = None token_expires_at: Optional[datetime] = Field(default=None, alias="tokenExpiresAt") + # On a create with ``secrets`` or ``vault``: what each variable of the + # startup command received. Never values. + startup_secrets: Optional[List[SandboxResolvedSecret]] = Field( + default=None, alias="startupSecrets" + ) @field_validator("token_expires_at", mode="before") @classmethod @@ -246,12 +256,20 @@ class CreateSandboxParams(_SandboxLaunchCommonParams): # Environment variable name to secret, delivered to the image's startup # command only. secrets: Optional[Dict[str, SecretReference]] = None + # A vault, by name, whose entries are delivered to the startup command; + # ``secrets`` are merged over it. + vault: Optional[str] = None @field_validator("secrets", mode="before") @classmethod def normalize_secrets(cls, value): return normalize_secret_references(value) + @field_validator("vault") + @classmethod + def validate_vault(cls, value): + return None if value is None else validate_secret_name(value) + @model_validator(mode="after") def validate_launch_source(self): if self.snapshot_id and not self.snapshot_name: @@ -271,10 +289,10 @@ 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: + if self.snapshot_name and (self.secrets or self.vault is not None): raise ValueError( - "secrets are only supported for image launches; snapshots don't " - "run a startup command" + "secrets and vault are only supported for image launches; " + "snapshots don't run a startup command" ) return self @@ -587,12 +605,25 @@ class SandboxExecParams(SandboxBaseModel): # 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 + # A vault, by name, whose entries become environment variables; ``secrets`` + # are merged over it. Starts through the control API like ``secrets``. + vault: Optional[str] = None @field_validator("secrets", mode="before") @classmethod def normalize_secrets(cls, value): return normalize_secret_references(value) + @field_validator("vault") + @classmethod + def validate_vault(cls, value): + return None if value is None else validate_secret_name(value) + + @property + def uses_secrets(self) -> bool: + """Whether the start delivers secrets, so goes through the control API.""" + return bool(self.secrets) or self.vault is not None + class SandboxProcessSummary(SandboxBaseModel): id: str diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py index d9e0e1de..289c9c9b 100644 --- a/hyperbrowser/models/secret.py +++ b/hyperbrowser/models/secret.py @@ -1,7 +1,6 @@ import re -from collections.abc import Mapping as MappingABC from datetime import datetime -from typing import List, Literal, Mapping, Optional, Union +from typing import List, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -90,18 +89,20 @@ def normalize_secret_references(value): } -# A plain secret holds one string; a credential holds a website login (an -# object of strings such as {"username": ..., "password": ...}) that browser -# sessions fill into pages. The type is fixed when the secret is created. -SecretType = Literal["secret", "credential"] +# What a secret's value is; fixed when the secret is created. +# secret an opaque string: an environment variable, and typed into pages +# on its allowed origins +# totp a TOTP seed (base32 or an otpauth://totp/ URI): delivered and +# typed as the code that is current at the time +# op_token a 1Password service account token, used only by 1Password vaults +SecretType = Literal["secret", "totp", "op_token"] -# A secret value: str or bytes for a plain secret, or a mapping of field names -# to strings for a credential. -SecretValue = Union[str, bytes, Mapping[str, str]] +# A secret value, encrypted locally before upload. +SecretValue = Union[str, bytes] -class CredentialBrowserMetadata(SecretBaseModel): - """Where a credential may be filled. +class SecretBrowserMetadata(SecretBaseModel): + """Where a secret may be typed into browser pages. Origins are ``scheme://host[:port]``; a leading ``*.`` matches any subdomain. The server normalizes and checks them. @@ -113,7 +114,7 @@ class CredentialBrowserMetadata(SecretBaseModel): class SecretMetadata(SecretBaseModel): - """Metadata stored with a secret. Credentials use ``browser``. + """Metadata stored with a secret. ``browser`` makes it fillable. Members the SDK doesn't know are kept. In an update, each member sent replaces the stored one and ``None`` removes it. @@ -121,7 +122,7 @@ class SecretMetadata(SecretBaseModel): model_config = ConfigDict(populate_by_name=True, extra="allow") - browser: Optional[CredentialBrowserMetadata] = None + browser: Optional[SecretBrowserMetadata] = None def _check_secret_value_shape(value): @@ -131,15 +132,16 @@ def _check_secret_value_shape(value): return value if isinstance(value, bytearray): return bytes(value) - if isinstance(value, MappingABC): - return dict(value) - raise ValueError( - "secret value must be str or bytes, or a mapping of field names to " - "strings for a credential" - ) + raise ValueError("secret value must be str or bytes") class CreateSecretParams(SecretBaseModel): + """A new secret, optionally with its first value. + + ``allowed_origins`` is shorthand for + ``metadata={"browser": {"allowed_origins": [...]}}``. + """ + model_config = ConfigDict(populate_by_name=True, hide_input_in_errors=True) name: str @@ -147,6 +149,7 @@ class CreateSecretParams(SecretBaseModel): # Omitted means "secret". type: Optional[SecretType] = None metadata: Optional[SecretMetadata] = None + allowed_origins: Optional[List[str]] = Field(default=None, exclude=True) # Encrypted locally and uploaded as the first version; never sent as is. value: Optional[SecretValue] = Field(default=None, exclude=True, repr=False) @@ -160,6 +163,39 @@ def validate_name(cls, value): def validate_value(cls, value): return _check_secret_value_shape(value) + @field_validator("allowed_origins", mode="before") + @classmethod + def validate_allowed_origins(cls, value): + if isinstance(value, (str, bytes)): + raise ValueError( + 'allowed_origins must be a list of origins such as ["https://example.com"]' + ) + return value + + @model_validator(mode="after") + def merge_allowed_origins(self): + if self.allowed_origins is not None: + if self.metadata is not None and self.metadata.browser is not None: + raise ValueError("Pass allowed_origins or metadata.browser, not both") + metadata = self.metadata or SecretMetadata() + self.metadata = metadata.model_copy( + update={ + "browser": SecretBrowserMetadata( + allowed_origins=list(self.allowed_origins) + ) + } + ) + if ( + self.type == "op_token" + and self.metadata is not None + and self.metadata.browser is not None + ): + raise ValueError( + "An op_token secret can't have allowed origins: it is never " + "typed into browser pages" + ) + return self + class UpdateSecretParams(SecretBaseModel): """A description, metadata, or both. @@ -246,10 +282,40 @@ class SecretVersionDeleteResult(SecretBaseModel): version: int +class SecretBindingSource(SecretBaseModel): + """Where a delivered variable came from: ``type`` is ``"vault"`` (with the + vault's ``name``) or ``"secrets"`` (the request's own ``secrets``).""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + type: str + name: Optional[str] = None + + class SandboxResolvedSecret(SecretBaseModel): - """The secret version a process environment variable received.""" + """What a process environment variable received; never its value. + + A stored secret sets ``name``, ``secret_id`` and ``version``, and + ``secret_type="totp"`` when the variable holds the code current at + process start. A 1Password vault entry sets ``reference`` (its + ``op://`` reference) and ``token_secret`` (the name of the op_token + secret used to sign in) instead. ``source`` is where the variable came + from, and ``overrides`` lists earlier sources it replaced. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") env: str - name: str - secret_id: str = Field(alias="secretId") - version: int + name: Optional[str] = None + secret_id: Optional[str] = Field(default=None, alias="secretId") + version: Optional[int] = None + secret_type: Optional[str] = Field(default=None, alias="secretType") + reference: Optional[str] = None + token_secret: Optional[str] = Field(default=None, alias="tokenSecret") + source: Optional[SecretBindingSource] = None + overrides: List[SecretBindingSource] = Field(default_factory=list) + + @field_validator("overrides", mode="before") + @classmethod + def default_overrides(cls, value): + return [] if value is None else value diff --git a/hyperbrowser/models/session.py b/hyperbrowser/models/session.py index bfb3ccc3..601dd10d 100644 --- a/hyperbrowser/models/session.py +++ b/hyperbrowser/models/session.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +from hyperbrowser.models.secret import validate_secret_name from hyperbrowser.models.consts import ( ISO639_1, Country, @@ -254,7 +255,7 @@ def parse_timestamp(cls, value: Optional[Union[str, int]]) -> Optional[int]: class SessionCredential(BaseModel): - """A credential attached to a session at creation.""" + """A secret attached to a session at creation, which fills may type.""" model_config = ConfigDict( populate_by_alias=True, @@ -264,6 +265,39 @@ class SessionCredential(BaseModel): name: str +MAX_SESSION_CREDENTIALS = 20 + + +class SessionCredentialSecret(BaseModel): + """Attaches a secret, by name, to a new session.""" + + model_config = ConfigDict(extra="forbid") + + secret: str + + @field_validator("secret") + @classmethod + def validate_secret(cls, value): + return validate_secret_name(value) + + +class SessionCredentialVault(BaseModel): + """Attaches the secrets of a stored vault, by name, to a new session.""" + + model_config = ConfigDict(extra="forbid") + + vault: str + + @field_validator("vault") + @classmethod + def validate_vault(cls, value): + return validate_secret_name(value) + + +# A plain secret name is shorthand for SessionCredentialSecret. +SessionCredentialInput = Union[str, SessionCredentialSecret, SessionCredentialVault] + + class SessionDetail(Session): """ Detailed session information including websocket endpoint. @@ -616,13 +650,28 @@ class CreateSessionParams(BaseModel): default=None, serialization_alias="startFromSnapshot", ) - credentials: Optional[List[str]] = Field( + credentials: Optional[List[SessionCredentialInput]] = Field( default=None, serialization_alias="credentials" ) - """Names of credentials (up to 20) that ``sessions.credentials.fill()`` - may fill into this session's pages. Credentials can only be attached when + """Up to 20 secrets (by name, or ``{"secret": name}``) or vaults + (``{"vault": name}``) that ``sessions.credentials.fill()`` may type into + this session's pages. A vault attaches the secrets its entries hold now. + Secrets need allowed origins to be filled, and can only be attached when the session is created.""" + @field_validator("credentials", mode="before") + @classmethod + def validate_credentials(cls, value): + if isinstance(value, (list, tuple)): + if len(value) > MAX_SESSION_CREDENTIALS: + raise ValueError( + f"At most {MAX_SESSION_CREDENTIALS} credentials can be attached" + ) + for item in value: + if isinstance(item, str): + validate_secret_name(item) + return value + class CreateSessionSnapshotResponse(BaseModel): """ diff --git a/hyperbrowser/models/vault.py b/hyperbrowser/models/vault.py new file mode 100644 index 00000000..d1b20c5d --- /dev/null +++ b/hyperbrowser/models/vault.py @@ -0,0 +1,409 @@ +"""Vaults: named sets of environment variables whose values come from stored +secrets or from a 1Password vault. A vault holds no values itself.""" + +import unicodedata +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional, Union + +from pydantic import ConfigDict, Field, field_validator, model_validator + +from .secret import ( + SECRET_ENV_NAME_PATTERN, + SecretBaseModel, + validate_secret_name, + validate_secret_version, +) + +MAX_VAULT_ENTRIES = 32 +MAX_VAULT_ENV_NAME_LENGTH = 256 +MAX_ONEPASSWORD_NAME_LENGTH = 256 +MAX_ONEPASSWORD_REFERENCE_BYTES = 512 +# Characters 1Password names can't hold in an op:// reference: control, +# format, line separator and paragraph separator characters. +_ONEPASSWORD_FORBIDDEN_CATEGORIES = frozenset({"Cc", "Cf", "Zl", "Zp"}) + + +def _utf16_length(value: str) -> int: + # The server counts string lengths in UTF-16 code units. + return len(value.encode("utf-16-le", "surrogatepass")) // 2 + + +def _is_query_part(text: str, allowed) -> bool: + return bool(text) and all(char in allowed for char in text) + + +_QUERY_KEY_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz-") +_QUERY_VALUE_CHARS = frozenset( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" +) + + +def validate_onepassword_name(value: str, what: str, query: bool = False) -> str: + """Check a 1Password vault, item, section or field name (or ID) for an + ``op://`` reference. A field may end with a query such as + ``?attribute=otp``.""" + if not isinstance(value, str) or not value: + raise ValueError(f"{what} must be a non-empty 1Password name or ID") + if _utf16_length(value) > MAX_ONEPASSWORD_NAME_LENGTH: + raise ValueError( + f"{what} is longer than {MAX_ONEPASSWORD_NAME_LENGTH} characters" + ) + name, _, params = value.partition("?") if query else (value, "", "") + if query and "?" in value: + for pair in params.split("&"): + key, equals, setting = pair.partition("=") + if not ( + equals + and _is_query_part(key, _QUERY_KEY_CHARS) + and _is_query_part(setting, _QUERY_VALUE_CHARS) + ): + raise ValueError( + f"{what} has an invalid query; expected ?key=value pairs " + "such as ?attribute=otp" + ) + if ( + not name + or name in (".", "..") + or "/" in name + or "?" in name + or any( + unicodedata.category(char) in _ONEPASSWORD_FORBIDDEN_CATEGORIES + for char in name + ) + ): + raise ValueError( + f"{what} must be a 1Password name or ID without '/' or '?', control " + "characters, or a name of '.' or '..'" + ) + return value + + +def validate_vault_env_name(env: Any) -> str: + if ( + not isinstance(env, str) + or not SECRET_ENV_NAME_PATTERN.fullmatch(env) + or len(env) > MAX_VAULT_ENV_NAME_LENGTH + or env == "__proto__" + ): + raise ValueError( + f"Invalid vault entry name {env!r}: entries are environment variable " + f"names of up to {MAX_VAULT_ENV_NAME_LENGTH} letters, digits and '_', " + "not starting with a digit" + ) + return env + + +class StoredEntry(SecretBaseModel): + """A vault entry that takes its value from a stored secret. + + An omitted ``version`` (or ``"latest"``) follows the secret's latest + version. + """ + + name: str + version: Optional[Union[int, Literal["latest"]]] = None + + +class OnePasswordEntry(SecretBaseModel): + """A vault entry that takes its value from a field of a 1Password item. + + ``item``, ``section`` and ``field`` are names or IDs; ``field`` may end + with a query such as ``?attribute=otp``. In responses, ``reference`` is + the ``op://vault/item/[section/]field`` reference the platform resolves. + """ + + item: str + section: Optional[str] = None + field: str + + +VaultEntryInput = Union[StoredEntry, OnePasswordEntry] + + +class VaultStoredEntry(StoredEntry): + """A stored entry as the API reports it: ``version`` is ``None`` when it + follows the latest version.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + secret_id: Optional[str] = Field(default=None, alias="secretId") + + +class VaultOnePasswordEntry(OnePasswordEntry): + """A 1Password entry as the API reports it, with the + ``op://vault/item/[section/]field`` reference the platform resolves.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + reference: Optional[str] = None + + +VaultEntry = Union[VaultStoredEntry, VaultOnePasswordEntry] + + +def _parse_entry(value: Any) -> Any: + if isinstance(value, (StoredEntry, OnePasswordEntry)) or not isinstance( + value, dict + ): + return value + if "item" in value: + return OnePasswordEntry.model_validate(value) + return StoredEntry.model_validate(value) + + +def _parse_reported_entry(value: Any) -> Any: + if not isinstance(value, dict): + return value + if "item" in value: + return VaultOnePasswordEntry.model_validate(value) + return VaultStoredEntry.model_validate(value) + + +class StoredVaultSource(SecretBaseModel): + """Values come from the team's stored secrets (the default).""" + + type: Literal["stored"] + + +class OnePasswordVaultSource(SecretBaseModel): + """Values come from a 1Password vault. + + ``token_secret`` names an ``op_token`` secret holding a 1Password service + account token; ``vault`` is the 1Password vault's name or ID. + """ + + type: Literal["1password"] + token_secret: str = Field(serialization_alias="tokenSecret") + vault: str + + @field_validator("token_secret") + @classmethod + def validate_token_secret(cls, value): + return validate_secret_name(value) + + @field_validator("vault") + @classmethod + def validate_vault(cls, value): + return validate_onepassword_name(value, "The 1Password vault") + + +VaultSourceInput = Union[StoredVaultSource, OnePasswordVaultSource] + + +class OnePasswordSourceUpdate(SecretBaseModel): + """New settings for a 1Password vault; ``None`` keeps a setting.""" + + token_secret: Optional[str] = Field(default=None, serialization_alias="tokenSecret") + vault: Optional[str] = None + + @field_validator("token_secret") + @classmethod + def validate_token_secret(cls, value): + return None if value is None else validate_secret_name(value) + + @field_validator("vault") + @classmethod + def validate_vault(cls, value): + return ( + None + if value is None + else validate_onepassword_name(value, "The 1Password vault") + ) + + @model_validator(mode="after") + def require_a_setting(self): + if self.token_secret is None and self.vault is None: + raise ValueError("A source update needs token_secret, vault, or both") + return self + + +def _check_entry(env: str, entry: Any) -> None: + validate_vault_env_name(env) + if isinstance(entry, StoredEntry): + validate_secret_name(entry.name) + if entry.version is not None and entry.version != "latest": + validate_secret_version(entry.version) + elif isinstance(entry, OnePasswordEntry): + validate_onepassword_name(entry.item, f"{env}: item") + if entry.section is not None: + validate_onepassword_name(entry.section, f"{env}: section") + validate_onepassword_name(entry.field, f"{env}: field", query=True) + + +def onepassword_reference(vault: str, entry: OnePasswordEntry) -> str: + """The ``op://`` reference the platform builds for an entry.""" + parts = [vault, entry.item] + ([entry.section] if entry.section else []) + return "op://" + "/".join(parts + [entry.field]) + + +class CreateVaultParams(SecretBaseModel): + """A new vault. + + ``entries`` maps environment variable names to a :class:`StoredEntry` + (stored vaults) or a :class:`OnePasswordEntry` (1Password vaults). + ``source`` is fixed at creation and defaults to stored secrets. + """ + + name: str + description: Optional[str] = None + source: Optional[VaultSourceInput] = Field(default=None, discriminator="type") + entries: Optional[Dict[str, VaultEntryInput]] = None + + @field_validator("name") + @classmethod + def validate_name(cls, value): + return validate_secret_name(value) + + @field_validator("entries", mode="before") + @classmethod + def parse_entries(cls, value): + if not isinstance(value, dict): + return value + if len(value) > MAX_VAULT_ENTRIES: + raise ValueError(f"A vault can hold at most {MAX_VAULT_ENTRIES} entries") + return {env: _parse_entry(entry) for env, entry in value.items()} + + @model_validator(mode="after") + def check_entries(self): + onepassword = isinstance(self.source, OnePasswordVaultSource) + for env, entry in (self.entries or {}).items(): + _check_entry(env, entry) + if onepassword != isinstance(entry, OnePasswordEntry): + raise ValueError( + f"{env}: a {'1Password' if onepassword else 'stored'} vault " + f"takes {'OnePasswordEntry' if onepassword else 'StoredEntry'} " + "entries" + ) + if isinstance(self.source, OnePasswordVaultSource) and isinstance( + entry, OnePasswordEntry + ): + reference = onepassword_reference(self.source.vault, entry) + if len(reference.encode("utf-8")) > MAX_ONEPASSWORD_REFERENCE_BYTES: + raise ValueError( + f"{env}: the 1Password reference is longer than " + f"{MAX_ONEPASSWORD_REFERENCE_BYTES} bytes" + ) + return self + + +class UpdateVaultParams(SecretBaseModel): + """Changes to a vault. Members left out (or ``None``) are kept. + + Each entry given replaces that variable, and an entry set to ``None`` + removes it. ``source`` changes a 1Password vault's token secret or + 1Password vault; a vault's source type can't change. + """ + + description: Optional[str] = None + source: Optional[OnePasswordSourceUpdate] = None + entries: Optional[Dict[str, Optional[VaultEntryInput]]] = None + + @field_validator("entries", mode="before") + @classmethod + def parse_entries(cls, value): + if not isinstance(value, dict): + return value + return {env: _parse_entry(entry) for env, entry in value.items()} + + @model_validator(mode="after") + def check_changes(self): + if self.description is None and self.source is None and self.entries is None: + raise ValueError("Update a vault's description, source, entries, or more") + for env, entry in (self.entries or {}).items(): + _check_entry(env, entry) + return self + + +class VaultListParams(SecretBaseModel): + page: Optional[int] = Field(default=None, ge=1) + limit: Optional[int] = Field(default=None, ge=1, le=100) + search: Optional[str] = None + + +class VaultTokenSecret(SecretBaseModel): + """The ``op_token`` secret a 1Password vault signs in with.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + name: str + secret_id: Optional[str] = Field(default=None, alias="secretId") + + +class VaultSource(SecretBaseModel): + """Where a vault's values come from. + + ``type`` is ``"stored"`` or ``"1password"``; it is kept as a string so + sources the API adds later still parse. 1Password vaults set + ``token_secret`` and ``vault``. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + type: str = "stored" + token_secret: Optional[VaultTokenSecret] = Field(default=None, alias="tokenSecret") + vault: Optional[str] = None + + +class Vault(SecretBaseModel): + id: str + name: str + description: str = "" + # APIs without 1Password vaults leave it out: the vault is stored. + source: VaultSource = Field(default_factory=VaultSource) + entries: Dict[str, VaultEntry] = Field(default_factory=dict) + created_at: Optional[datetime] = Field(default=None, alias="createdAt") + updated_at: Optional[datetime] = Field(default=None, alias="updatedAt") + + @field_validator("description", mode="before") + @classmethod + def default_description(cls, value): + return "" if value is None else value + + @field_validator("source", mode="before") + @classmethod + def default_source(cls, value): + return {"type": "stored"} if value is None else value + + @field_validator("entries", mode="before") + @classmethod + def parse_entries(cls, value): + if value is None: + return {} + if not isinstance(value, dict): + return value + return {env: _parse_reported_entry(entry) for env, entry in value.items()} + + +class VaultListResponse(SecretBaseModel): + vaults: List[Vault] + total_count: Optional[int] = Field(default=None, alias="totalCount") + page: Optional[int] = None + per_page: Optional[int] = Field(default=None, alias="perPage") + + +class VaultDeleteResult(SecretBaseModel): + deleted: bool + id: Optional[str] = None + name: Optional[str] = None + + +__all__ = [ + "CreateVaultParams", + "OnePasswordEntry", + "OnePasswordSourceUpdate", + "OnePasswordVaultSource", + "StoredEntry", + "StoredVaultSource", + "UpdateVaultParams", + "Vault", + "VaultDeleteResult", + "VaultEntry", + "VaultEntryInput", + "VaultListParams", + "VaultListResponse", + "VaultOnePasswordEntry", + "VaultSource", + "VaultSourceInput", + "VaultStoredEntry", + "VaultTokenSecret", +] diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index 0686a9a5..76ebc2c0 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -11,9 +11,9 @@ import os import re import time -from collections.abc import Mapping from dataclasses import dataclass from typing import Any, Dict, Optional, Sequence, Type, Union +from urllib.parse import parse_qs, urlsplit import httpx from cryptography.hazmat.primitives import hashes @@ -21,7 +21,6 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from .client._request import coerce_request -from .credentials_common import CREDENTIAL_FIELD_PATTERN from .exceptions import HyperbrowserError from .models.secret import ( SecretsPublicKey, @@ -97,80 +96,115 @@ def _invalid_value(message: str) -> HyperbrowserError: return HyperbrowserError(message, code="invalid_secret_value") -def credential_value_bytes(value: Any) -> bytes: - """Return a credential value's bytes: its compact UTF-8 JSON. +# The TOTP parameters an otpauth://totp/ URI may set, as vm-service reads them. +TOTP_ALGORITHMS = ("SHA1", "SHA256", "SHA512") +TOTP_DIGITS = (6, 8) +MAX_TOTP_PERIOD_SECONDS = 300 +_BASE32_PATTERN = re.compile(r"^[A-Z2-7]+$") +_ASCII_INTEGER_PATTERN = re.compile(r"^[+-]?[0-9]+$") +_URI_CONTROL_PATTERN = re.compile(r"[\x00-\x1f\x7f]") +_SCHEME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://") - A credential value maps field names (such as ``"username"``) to non-empty - strings. Field names match ``^[a-zA-Z0-9_.-]{1,64}$``, and the JSON follows - the same size and UTF-8 rules as plain secret values. Errors name fields, - never values. - """ - if not isinstance(value, Mapping): - raise _invalid_value( - "Credential value must be a mapping of field names to strings, " - f"got {type(value).__name__}" + +def _invalid_totp(reason: str) -> HyperbrowserError: + return _invalid_value( + f"Invalid TOTP seed: {reason}. Use the base32 seed a site shows, or an " + "otpauth://totp/ URI with SHA1, SHA256 or SHA512, 6 or 8 digits and a " + "period of 1 to 300 seconds" + ) + + +def _check_totp_base32(seed: str) -> None: + # Spaces, hyphens, lowercase and trailing "=" padding are accepted, as + # sites show seeds in all of these forms. + normalized = seed.replace(" ", "").replace("-", "").upper().rstrip("=") + if not normalized: + raise _invalid_totp("the seed is empty") + if not _BASE32_PATTERN.match(normalized): + raise _invalid_totp("the seed is not base32") + # Lengths of 1, 3 or 6 (mod 8) leave characters that don't make a whole + # byte; the platform would drop them and generate wrong codes, so the + # seed was most likely copied incompletely. + if len(normalized) % 8 in (1, 3, 6): + raise _invalid_totp( + "the seed's length is not valid base32; check that it was copied in full" ) - if not value: - raise _invalid_value("Credential value must have at least one field") - fields: Dict[str, str] = {} - for field, field_value in value.items(): - if not isinstance(field, str) or not CREDENTIAL_FIELD_PATTERN.fullmatch(field): - raise _invalid_value( - "Credential field names must be 1-64 letters, digits, '_', '.' " - "or '-'" - ) - if not isinstance(field_value, str): - raise _invalid_value( - f'Credential field "{field}" must be a string, ' - f"got {type(field_value).__name__}" - ) - if not field_value: - raise _invalid_value(f'Credential field "{field}" is empty') - if "\x00" in field_value: - raise _invalid_value( - f'Credential field "{field}" must not contain NUL characters' - ) - fields[field] = field_value - serialized = json.dumps(fields, separators=(",", ":"), ensure_ascii=False) - return secret_value_bytes(serialized) -def secret_payload_bytes(secret_type: str, value: Any) -> bytes: +def _totp_uri_seed(uri: str) -> str: + if _URI_CONTROL_PATTERN.search(uri): + raise _invalid_totp("the URI contains control characters") + parsed = urlsplit(uri) + host = parsed.netloc.lower() + if host == "hotp": + raise _invalid_totp("HOTP (counter-based) codes are not supported") + if host != "totp": + raise _invalid_totp("the URI must start with otpauth://totp/") + query = parse_qs(parsed.query, keep_blank_values=True) + + def param(name: str) -> str: + return query.get(name, [""])[0] + + algorithm = param("algorithm") + if algorithm and algorithm.upper() not in TOTP_ALGORITHMS: + raise _invalid_totp("algorithm must be SHA1, SHA256 or SHA512") + digits = param("digits") + if digits and ( + not _ASCII_INTEGER_PATTERN.match(digits) or int(digits) not in TOTP_DIGITS + ): + raise _invalid_totp("digits must be 6 or 8") + period = param("period") + if period and ( + not _ASCII_INTEGER_PATTERN.match(period) + or not 1 <= int(period) <= MAX_TOTP_PERIOD_SECONDS + ): + raise _invalid_totp("period must be 1 to 300 seconds") + seed = param("secret") + if not seed: + raise _invalid_totp("the URI has no secret parameter") + return seed + + +def check_totp_seed(value: str) -> None: + """Check a TOTP seed the way the platform reads it: base32, or an + ``otpauth://totp/`` URI whose ``secret`` is base32. + + The server can't see the value, so a bad seed would otherwise only fail + when it is filled or delivered. Seeds under 10 bytes (16 base32 + characters) are accepted, though they are weaker than sites usually + issue. Errors never include the value. + """ + text = value.strip() + if text.startswith("\ufeff"): + text = text[1:].strip() + if text.lower().startswith("otpauth://"): + text = _totp_uri_seed(text) + elif _SCHEME_PATTERN.match(text): + raise _invalid_totp("only otpauth://totp/ URIs are accepted") + _check_totp_base32(text) + + +def secret_payload_bytes(secret_type: Optional[str], value: Any) -> bytes: """Check a value against its secret's type and return the bytes to encrypt. - Credentials take a mapping of field names to strings; plain secrets take - str or bytes. + Every type takes str or bytes of 1 byte to 32 KiB of UTF-8 without NUL. + A ``totp`` value must also be a valid seed. An ``op_token`` is a 1Password + service account token; tokens start with ``ops_``, but others are not + refused. Types the SDK doesn't know get only the common checks. """ - if secret_type == "credential": - if not isinstance(value, Mapping): - raise _invalid_value( - "A credential's value must be a mapping of field names to " - 'strings, such as {"username": ..., "password": ...}, got ' - f"{type(value).__name__}" - ) - return credential_value_bytes(value) - if isinstance(value, Mapping): - raise _invalid_value( - "Only credentials take a mapping value; a plain secret's value is " - "str or bytes" - ) if not isinstance(value, (str, bytes, bytearray)): raise _invalid_value( f"Secret value must be str or bytes, got {type(value).__name__}" ) - return secret_value_bytes(value) - - -def check_secret_value(value: Any) -> None: - """Check a value's contents before its secret's type is known.""" - secret_payload_bytes( - "credential" if isinstance(value, Mapping) else "secret", value - ) + data = secret_value_bytes(value) + if secret_type == "totp": + check_totp_seed(data.decode("utf-8")) + return data def browser_metadata(allowed_origins: Optional[Sequence[str]]) -> Dict[str, Any]: - """Credential metadata allowing fills on ``allowed_origins``; ``None`` - clears the browser metadata in an update. The server checks the origins.""" + """Metadata allowing fills on ``allowed_origins``; ``None`` clears the + browser metadata in an update. The server checks the origins.""" if allowed_origins is None: return {"browser": None} if isinstance(allowed_origins, (str, bytes)) or not all( @@ -184,6 +218,20 @@ def browser_metadata(allowed_origins: Optional[Sequence[str]]) -> Dict[str, Any] return {"browser": {"allowed_origins": list(allowed_origins)}} +def check_secret_metadata(secret_type: Optional[str], metadata: Any) -> None: + """Refuse browser metadata on an ``op_token``, which is never filled.""" + if ( + secret_type == "op_token" + and isinstance(metadata, dict) + and metadata.get("browser") is not None + ): + raise HyperbrowserError( + "An op_token secret can't have allowed origins: it is never typed " + "into browser pages", + code="invalid_secret_metadata", + ) + + def update_secret_body(params: Any) -> Dict[str, Any]: """Serialize a secret update. diff --git a/hyperbrowser/tools/__init__.py b/hyperbrowser/tools/__init__.py index 688bef6c..6ef0fb2f 100644 --- a/hyperbrowser/tools/__init__.py +++ b/hyperbrowser/tools/__init__.py @@ -152,23 +152,41 @@ async def async_runnable( return resp.data.final_result if resp.data and resp.data.final_result else "" +def _present(value: Any) -> bool: + return value is not None and value != "" + + def _fill_credential_request(params: Mapping[str, Any]) -> Dict[str, Any]: """Map the model's tool input to a fill request; null means absent.""" - request: Dict[str, Any] = { - "credential": params.get("credential"), - "fields": params.get("fields"), - } - page_id = params.get("page_id", params.get("pageId")) - if page_id is not None and page_id != "": - request["page_id"] = page_id - frame_url = params.get("frame_url", params.get("frameUrl")) - if frame_url is not None and frame_url != "": - request["frame"] = {"url": frame_url} + request: Dict[str, Any] = {} + for key in ("credential", "vault"): + if _present(params.get(key)): + request[key] = params.get(key) + fields = params.get("fields") + if isinstance(fields, list): + fields = [ + {key: value for key, value in entry.items() if _present(value)} + if isinstance(entry, Mapping) + else entry + for entry in fields + ] + request["fields"] = fields + for key, alias in ( + ("page_id", "pageId"), + ("page_url", "pageUrl"), + ("frame_url", "frameUrl"), + ): + value = params.get(key, params.get(alias)) + if _present(value): + request[key] = value return request def _fill_credential_result_text(result: CredentialFillResult) -> str: - return json.dumps(result.model_dump(by_alias=True), separators=(",", ":")) + return json.dumps( + result.model_dump(mode="json", by_alias=True, exclude_none=True), + separators=(",", ":"), + ) def _fill_credential_error_text(error: HyperbrowserError) -> str: @@ -192,10 +210,11 @@ def _fill_credential_error_text(error: HyperbrowserError) -> str: class CredentialFillTool: - """Let a model fill a stored login credential into a browser session. + """Let a model fill a stored secret, or a vault's entries, into a browser + session. - The model chooses the credential by name and the inputs to fill; it never - sees the values. The application binds the session:: + The model chooses the secret or vault by name and the inputs to fill; it + never sees the values. The application binds the session:: result = CredentialFillTool.runnable(hb, session.id, tool_input) diff --git a/hyperbrowser/tools/schema.py b/hyperbrowser/tools/schema.py index 30c758c1..03b7908c 100644 --- a/hyperbrowser/tools/schema.py +++ b/hyperbrowser/tools/schema.py @@ -189,66 +189,73 @@ def get_scrape_options(formats: List[scrape_types] = ["markdown"]): } FILL_CREDENTIAL_DESCRIPTION = ( - "Fill a stored login credential into the browser session's page by typing " - "its values into the inputs you select. A credential is a saved login (for " - "example a username and password) that you refer to by name; you never see " - "its values, and they never appear in this tool's input or output. This tool " - "only types: it does NOT submit the form. After a fill, click the login " - "button or press Enter with your other browser tools.\n\n" - "The result is JSON with the origin and pageId of the page that was filled, " - "and an outcome for each field:\n" + "Fill a stored login into the browser session's page by typing saved " + "values into the inputs you select. You refer to the values by name and " + "never see them; they never appear in this tool's input or output. Pass " + "either credential (one saved secret, such as a password) or vault (a " + "saved login with several entries such as USERNAME and PASSWORD); with a " + "vault, each field names the entry to type into its selector. A TOTP " + "(one-time code) secret or entry types the code that is current now. " + "This tool only types: it does NOT submit the form. After a fill, click " + "the login button or press Enter with your other browser tools.\n\n" + "The result is JSON with the origin and pageId of the page that was " + "filled, and an outcome for each selector:\n" "- filled: the input now holds the value.\n" - "- not_found: no input matched the selector. Look at the page again and fix " - "the selector; the form may not have loaded yet, or it may be inside an " - "iframe (pass frame_url).\n" - "- ambiguous: several visible inputs matched. Use a more specific selector.\n" + "- not_found: no input matched the selector. Look at the page again and " + "fix the selector; the form may not have loaded yet, or it may be inside " + "an iframe (pass frame_url).\n" + "- ambiguous: several visible inputs matched. Use a more specific " + "selector.\n" "- not_editable: the input is disabled, read-only, hidden or not a text " "input. Select the real input, or reveal it first (some sites show the " "password input only after the username step).\n" "- not_accepted: the page did not keep the value (for example a length " "limit). Check that you selected the right input.\n" - "- not_populated: the stored credential has no such field. Check the field " - "name; common ones are username, email and password.\n" "Filling again is safe. Errors come back as JSON with a code, for example " '{"error": {"code": "CREDENTIAL_PAGE_AMBIGUOUS", "message": "...", ' '"retryable": false, "details": {"pages": [...]}}}. On ' - "CREDENTIAL_PAGE_AMBIGUOUS, call again with page_id set to the pageId of the " - "tab that shows the login form. CREDENTIAL_ORIGIN_NOT_ALLOWED means the " - "credential may not be used on this site; do not retry it there. When " - "retryable is true, the same call may succeed if you try again." + "CREDENTIAL_PAGE_AMBIGUOUS, call again with page_id set to the pageId of " + "the tab that shows the login form, or page_url set to the start of its " + "URL. CREDENTIAL_ORIGIN_NOT_ALLOWED means the value may not be used on " + "this site; do not retry it there. When retryable is true, the same call " + "may succeed if you try again." ) FILL_CREDENTIAL_SCHEMA = { "type": "object", "properties": { "credential": { - "type": "string", - "description": "The name of the stored credential to fill, as you were " - "told it. You cannot see its values; refer to it only by name.", + "type": ["string", "null"], + "description": "The name of the saved secret to fill, as you were " + "told it. Use null when you pass vault.", + }, + "vault": { + "type": ["string", "null"], + "description": "The name of the saved vault to fill entries from, " + "as you were told it. Use null when you pass credential.", }, "fields": { "type": "array", - "description": "The inputs to fill: one entry per credential field, " - "each field at most once.", + "description": "The inputs to fill, each selector at most once.", "minItems": 1, "maxItems": 16, "items": { "type": "object", "properties": { - "field": { - "type": "string", - "description": "The credential field to type: a key of " - 'the stored login such as "username", "email" or ' - '"password".', - }, "selector": { "type": "string", "description": "A CSS selector matching exactly one " 'visible input on the page, such as "#login_field" or ' "\"input[name='password']\".", }, + "field": { + "type": ["string", "null"], + "description": "With vault: the vault entry to type, " + 'such as "USERNAME", "PASSWORD" or "OTP". Use null ' + "with credential.", + }, }, - "required": ["field", "selector"], + "required": ["selector", "field"], "additionalProperties": False, }, }, @@ -258,6 +265,12 @@ def get_scrape_options(formats: List[scrape_types] = ["markdown"]): "call failed with CREDENTIAL_PAGE_AMBIGUOUS; then pass the pageId of " "the tab showing the login form, from that error's details.pages.", }, + "page_url": { + "type": ["string", "null"], + "description": "Instead of page_id: the start of the URL of the tab " + 'to fill, such as "https://github.com/login". Use null unless the ' + "page was ambiguous. Never pass both page_id and page_url.", + }, "frame_url": { "type": ["string", "null"], "description": "For a login form inside an iframe: the start of the " @@ -265,6 +278,6 @@ def get_scrape_options(formats: List[scrape_types] = ["markdown"]): "the form is in the page itself.", }, }, - "required": ["credential", "fields", "page_id", "frame_url"], + "required": ["credential", "vault", "fields", "page_id", "page_url", "frame_url"], "additionalProperties": False, } diff --git a/hyperbrowser/types/__init__.py b/hyperbrowser/types/__init__.py index 72680669..c10a706d 100644 --- a/hyperbrowser/types/__init__.py +++ b/hyperbrowser/types/__init__.py @@ -7,9 +7,20 @@ from .credential import ( CredentialFillField, - CredentialFillFrame, FillCredentialParams, ) +from .vault import ( + CreateVaultParams, + OnePasswordEntry, + OnePasswordSourceUpdate, + OnePasswordVaultSource, + StoredEntry, + StoredVaultSource, + UpdateVaultParams, + VaultEntryInput, + VaultListParams, + VaultSourceInput, +) from .webmcp import ( WebMCPInvokeParams, WebMCPStartParams, @@ -144,6 +155,9 @@ CaptchaEvaluationType, CaptchaSolverType, CreateSessionParams, + SessionCredentialInput, + SessionCredentialSecret, + SessionCredentialVault, CreateSessionProfile, ImageCaptchaParam, ScreenConfig, @@ -162,7 +176,7 @@ ) from .secret import ( CreateSecretParams, - CredentialBrowserMetadata, + SecretBrowserMetadata, SecretListParams, SecretMetadata, SecretReference, @@ -202,8 +216,17 @@ __all__ = [ "CredentialFillField", - "CredentialFillFrame", "FillCredentialParams", + "CreateVaultParams", + "OnePasswordEntry", + "OnePasswordSourceUpdate", + "OnePasswordVaultSource", + "StoredEntry", + "StoredVaultSource", + "UpdateVaultParams", + "VaultEntryInput", + "VaultListParams", + "VaultSourceInput", "WebMCPInvokeParams", "WebMCPStartParams", "WebMCPResultParams", @@ -329,6 +352,9 @@ "CaptchaEvaluationType", "CaptchaSolverType", "CreateSessionParams", + "SessionCredentialInput", + "SessionCredentialSecret", + "SessionCredentialVault", "CreateSessionProfile", "ImageCaptchaParam", "ScreenConfig", @@ -345,7 +371,7 @@ "UpdateSessionScreenParams", "UpdateSessionSolveCaptchasParams", "CreateSecretParams", - "CredentialBrowserMetadata", + "SecretBrowserMetadata", "SecretListParams", "SecretMetadata", "SecretReference", diff --git a/hyperbrowser/types/credential.py b/hyperbrowser/types/credential.py index b45ab94b..a7dbf277 100644 --- a/hyperbrowser/types/credential.py +++ b/hyperbrowser/types/credential.py @@ -1,39 +1,36 @@ -"""Typed dictionary requests for filling stored credentials into pages.""" +"""Typed dictionary requests for filling secrets into browser pages.""" from typing import List, Optional from typing_extensions import Required, TypedDict -class CredentialFillField(TypedDict): - """Types the credential's ``field`` (a key of the stored value, such as - ``"username"``) into the input matching the CSS ``selector``.""" +class CredentialFillField(TypedDict, total=False): + """Types a value into the input matching the CSS ``selector``. With a + ``vault`` fill, ``field`` names the vault entry to type.""" - field: str - selector: str - - -class CredentialFillFrame(TypedDict): - """Selects an iframe by URL prefix.""" - - url: str + selector: Required[str] + field: Optional[str] class FillCredentialParams(TypedDict, total=False): - """Fill 1-16 fields of a credential attached to the session. + """Fill 1-16 inputs from a secret (``credential``) or a vault's entries + (``vault``) attached to the session. - ``page_id`` is a CDP target ID, needed when several tabs could be the - target; ``frame`` selects an iframe by URL prefix. + ``page_id`` (a CDP target ID) or ``page_url`` (a URL prefix) picks the tab + when several could be the target; ``frame_url`` (a URL prefix) picks an + iframe. """ - credential: Required[str] + credential: Optional[str] + vault: Optional[str] fields: Required[List[CredentialFillField]] page_id: Optional[str] - frame: Optional[CredentialFillFrame] + page_url: Optional[str] + frame_url: Optional[str] __all__ = [ "CredentialFillField", - "CredentialFillFrame", "FillCredentialParams", ] diff --git a/hyperbrowser/types/sandbox.py b/hyperbrowser/types/sandbox.py index fd83ec1a..03bb39e0 100644 --- a/hyperbrowser/types/sandbox.py +++ b/hyperbrowser/types/sandbox.py @@ -100,6 +100,7 @@ class CreateSandboxFromImageParams(_CreateSandboxCommon, total=False): memory_mib: Optional[int] disk_mib: Optional[int] secrets: Optional[Dict[str, SecretReferenceInput]] + vault: Optional[str] CreateSandboxParams: TypeAlias = Union[ @@ -253,6 +254,7 @@ class SandboxExecParams(TypedDict, total=False): run_as: Optional[str] use_shell: Optional[bool] secrets: Optional[Dict[str, SecretReferenceInput]] + vault: Optional[str] class SandboxProcessListParams(TypedDict, total=False): diff --git a/hyperbrowser/types/secret.py b/hyperbrowser/types/secret.py index 5727a3dc..abb6e0d8 100644 --- a/hyperbrowser/types/secret.py +++ b/hyperbrowser/types/secret.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Mapping, Optional, Union +from typing import List, Literal, Optional, Union from typing_extensions import Required, TypeAlias, TypedDict @@ -14,18 +14,18 @@ class SecretReference(TypedDict, total=False): SecretReferenceInput: TypeAlias = Union[str, SecretReference] -# A plain secret holds one string; a credential holds a website login that -# browser sessions fill into pages. Fixed when the secret is created. -SecretType = Literal["secret", "credential"] +# What a secret's value is; fixed when the secret is created. "secret" is an +# opaque string, "totp" a TOTP seed (base32 or an otpauth://totp/ URI) and +# "op_token" a 1Password service account token. +SecretType = Literal["secret", "totp", "op_token"] -# str or bytes for a plain secret; a mapping of field names (such as -# "username" and "password") to strings for a credential. -SecretValue: TypeAlias = Union[str, bytes, Mapping[str, str]] +# A secret value, encrypted locally before upload. +SecretValue: TypeAlias = Union[str, bytes] -class CredentialBrowserMetadata(TypedDict): - """Origins (``scheme://host[:port]``, ``*.`` for subdomains) a credential - may be filled on.""" +class SecretBrowserMetadata(TypedDict): + """Origins (``scheme://host[:port]``, ``*.`` for subdomains) a secret may + be typed into browser pages on.""" allowed_origins: List[str] @@ -33,15 +33,20 @@ class CredentialBrowserMetadata(TypedDict): class SecretMetadata(TypedDict, total=False): """Secret metadata. In an update, ``None`` removes a member.""" - browser: Optional[CredentialBrowserMetadata] + browser: Optional[SecretBrowserMetadata] class CreateSecretParams(TypedDict, total=False): - """Parameters for creating a secret, optionally with its first value.""" + """Parameters for creating a secret, optionally with its first value. + + ``allowed_origins`` is shorthand for ``metadata.browser.allowed_origins``; + it makes a ``secret`` or ``totp`` fillable in browser sessions. + """ name: Required[str] description: Optional[str] type: Optional[SecretType] + allowed_origins: Optional[List[str]] metadata: Optional[SecretMetadata] value: Optional[SecretValue] @@ -65,7 +70,7 @@ class SecretListParams(TypedDict, total=False): __all__ = [ "CreateSecretParams", - "CredentialBrowserMetadata", + "SecretBrowserMetadata", "SecretListParams", "SecretMetadata", "SecretReference", diff --git a/hyperbrowser/types/session.py b/hyperbrowser/types/session.py index 5c97ed05..477b7ae8 100644 --- a/hyperbrowser/types/session.py +++ b/hyperbrowser/types/session.py @@ -1,6 +1,6 @@ from typing import List, Literal, Optional, Union -from typing_extensions import Required, TypedDict +from typing_extensions import Required, TypeAlias, TypedDict from hyperbrowser.models.consts import ( Country, @@ -129,6 +129,24 @@ class ProxyRoute(TypedDict): proxy: Literal["direct", "default"] +class SessionCredentialSecret(TypedDict): + """Attaches a secret, by name, to a new session.""" + + secret: str + + +class SessionCredentialVault(TypedDict): + """Attaches the secrets of a stored vault, by name, to a new session.""" + + vault: str + + +# A plain secret name is shorthand for {"secret": name}. +SessionCredentialInput: TypeAlias = Union[ + str, SessionCredentialSecret, SessionCredentialVault +] + + class CreateSessionParams(TypedDict, total=False): """Configuration for creating a browser session.""" @@ -184,7 +202,7 @@ class CreateSessionParams(TypedDict, total=False): disable_post_quantum_key_agreement: Optional[bool] browser_memory_size: Optional[BrowserMemorySize] start_from_snapshot: Optional[StartSessionFromSnapshotParams] - credentials: Optional[List[str]] + credentials: Optional[List[SessionCredentialInput]] class UpdateSessionSolveCaptchasParams(TypedDict, total=False): @@ -205,6 +223,9 @@ class SessionEventLogListParams(TypedDict, total=False): __all__ = [ + "SessionCredentialInput", + "SessionCredentialSecret", + "SessionCredentialVault", "BrowserDevice", "BrowserMemorySize", "CaptchaEvaluationParams", diff --git a/hyperbrowser/types/vault.py b/hyperbrowser/types/vault.py new file mode 100644 index 00000000..595a6698 --- /dev/null +++ b/hyperbrowser/types/vault.py @@ -0,0 +1,89 @@ +"""Typed dictionary requests for vaults.""" + +from typing import Dict, Literal, Optional, Union + +from typing_extensions import Required, TypeAlias, TypedDict + + +class StoredEntry(TypedDict, total=False): + """A stored secret for one variable; an omitted version (or ``"latest"``) + follows the latest version.""" + + name: Required[str] + version: Optional[Union[int, Literal["latest"]]] + + +class OnePasswordEntry(TypedDict, total=False): + """A field of a 1Password item, by name or ID. ``field`` may end with a + query such as ``?attribute=otp``.""" + + item: Required[str] + section: Optional[str] + field: Required[str] + + +VaultEntryInput: TypeAlias = Union[StoredEntry, OnePasswordEntry] + + +class StoredVaultSource(TypedDict): + """Values come from the team's stored secrets (the default).""" + + type: Literal["stored"] + + +class OnePasswordVaultSource(TypedDict): + """Values come from a 1Password vault, signed in to with the service + account token in the ``op_token`` secret named ``token_secret``.""" + + type: Literal["1password"] + token_secret: str + vault: str + + +VaultSourceInput: TypeAlias = Union[StoredVaultSource, OnePasswordVaultSource] + + +class OnePasswordSourceUpdate(TypedDict, total=False): + """New settings for a 1Password vault; members left out are kept.""" + + token_secret: Optional[str] + vault: Optional[str] + + +class CreateVaultParams(TypedDict, total=False): + """A new vault: environment variable names mapped to entries.""" + + name: Required[str] + description: Optional[str] + source: Optional[VaultSourceInput] + entries: Optional[Dict[str, VaultEntryInput]] + + +class UpdateVaultParams(TypedDict, total=False): + """Changes to a vault. An entry set to ``None`` removes that variable.""" + + description: Optional[str] + source: Optional[OnePasswordSourceUpdate] + entries: Optional[Dict[str, Optional[VaultEntryInput]]] + + +class VaultListParams(TypedDict, total=False): + """Filters and pagination for listing vaults.""" + + page: Optional[int] + limit: Optional[int] + search: Optional[str] + + +__all__ = [ + "CreateVaultParams", + "OnePasswordEntry", + "OnePasswordSourceUpdate", + "OnePasswordVaultSource", + "StoredEntry", + "StoredVaultSource", + "UpdateVaultParams", + "VaultEntryInput", + "VaultListParams", + "VaultSourceInput", +] diff --git a/hyperbrowser/vaults_common.py b/hyperbrowser/vaults_common.py new file mode 100644 index 00000000..fe4d4c25 --- /dev/null +++ b/hyperbrowser/vaults_common.py @@ -0,0 +1,73 @@ +"""Vault request rules shared by the sync and async clients.""" + +from typing import Any, Dict + +from .client._request import coerce_request +from .exceptions import HyperbrowserError +from .models.vault import ( + CreateVaultParams, + OnePasswordEntry, + StoredEntry, + UpdateVaultParams, +) + + +def _entry_body(entry: Any) -> Dict[str, Any]: + # Only the request members: entries read from a vault also carry + # secretId or reference, which the API doesn't take. + if isinstance(entry, OnePasswordEntry): + body: Dict[str, Any] = {"item": entry.item, "field": entry.field} + if entry.section is not None: + body["section"] = entry.section + return body + if isinstance(entry, StoredEntry): + body = {"name": entry.name} + if entry.version is not None: + body["version"] = entry.version + return body + raise TypeError(f"Unsupported vault entry {type(entry).__name__}") + + +def create_vault_body(params: Any) -> Dict[str, Any]: + """Check a vault create request locally and return its JSON body.""" + request = coerce_request(params, CreateVaultParams) + body: Dict[str, Any] = {"name": request.name} + if request.description is not None: + body["description"] = request.description + if request.source is not None: + body["source"] = request.source.model_dump(by_alias=True, exclude_none=True) + if request.entries is not None: + body["entries"] = { + env: _entry_body(entry) for env, entry in request.entries.items() + } + return body + + +def update_vault_body(params: Any) -> Dict[str, Any]: + """Check a vault update locally and return its JSON body. + + Members left out or ``None`` are not sent; an entry set to ``None`` is + sent as ``null``, which removes that variable. + """ + request = coerce_request(params, UpdateVaultParams) + body: Dict[str, Any] = {} + if request.description is not None: + body["description"] = request.description + if request.source is not None: + body["source"] = request.source.model_dump(by_alias=True, exclude_none=True) + if request.entries is not None: + body["entries"] = { + env: None if entry is None else _entry_body(entry) + for env, entry in request.entries.items() + } + return body + + +def vault_not_found_error(key: str) -> HyperbrowserError: + return HyperbrowserError( + f'Vault "{key}" not found', + status_code=404, + code="VAULT_NOT_FOUND", + retryable=False, + service="control", + ) diff --git a/tests/test_credential_tools.py b/tests/test_credential_tools.py index c61e7b33..5743c031 100644 --- a/tests/test_credential_tools.py +++ b/tests/test_credential_tools.py @@ -60,18 +60,33 @@ def test_fill_credential_tool_definitions(): "ambiguous", "not_editable", "not_accepted", - "not_populated", "CREDENTIAL_PAGE_AMBIGUOUS", "page_id", + "page_url", + "vault", + "TOTP", + "current", ]: assert text in description + assert "not_populated" not in description schema = function["parameters"] - assert set(schema["properties"]) == {"credential", "fields", "page_id", "frame_url"} + assert set(schema["properties"]) == { + "credential", + "vault", + "fields", + "page_id", + "page_url", + "frame_url", + } assert "session" not in json.dumps(schema).lower().replace("session's", "") fields = schema["properties"]["fields"] assert (fields["minItems"], fields["maxItems"]) == (1, 16) - assert schema["properties"]["page_id"]["type"] == ["string", "null"] + assert set(fields["items"]["properties"]) == {"selector", "field"} + assert fields["items"]["properties"]["field"]["type"] == ["string", "null"] + assert fields["items"]["properties"]["selector"]["type"] == "string" + for name in ["credential", "vault", "page_id", "page_url", "frame_url"]: + assert schema["properties"][name]["type"] == ["string", "null"] for member in _walk(schema): assert ( member.get("description") or member is schema or member is fields["items"] @@ -98,11 +113,20 @@ def test_gemini_declarations_use_the_openapi_subset(tool): def test_gemini_schema_conversion(): gemini = CredentialFillTool.gemini_tool_definition["parameters"] assert gemini["properties"]["page_id"]["type"] == "string" - assert gemini["properties"]["page_id"]["nullable"] is True - assert gemini["properties"]["frame_url"]["nullable"] is True - assert "nullable" not in gemini["properties"]["credential"] + for name in ["credential", "vault", "page_id", "page_url", "frame_url"]: + assert gemini["properties"][name]["nullable"] is True + items = gemini["properties"]["fields"]["items"] + assert items["properties"]["field"]["nullable"] is True + assert "nullable" not in items["properties"]["selector"] assert gemini["properties"]["fields"]["maxItems"] == 16 - assert gemini["required"] == ["credential", "fields", "page_id", "frame_url"] + assert gemini["required"] == [ + "credential", + "vault", + "fields", + "page_id", + "page_url", + "frame_url", + ] with pytest.raises(ValueError, match="type union"): to_gemini_schema({"type": ["string", "number"]}) @@ -113,11 +137,14 @@ def test_gemini_schema_conversion(): TOOL_INPUT = { - "credential": "github", - "fields": FIELDS, + "credential": None, + "vault": "github-login", + "fields": [dict(field, field=field["field"]) for field in FIELDS], "page_id": None, + "page_url": None, "frame_url": None, } +SENT_FIELDS = [{"selector": f["selector"], "field": f["field"]} for f in FIELDS] def test_tool_runner_maps_model_input_and_returns_json(sdk): @@ -127,26 +154,52 @@ def test_tool_runner_maps_model_input_and_returns_json(sdk): if isinstance(client, AsyncHyperbrowser) else CredentialFillTool.runnable ) - respond(outcomes, FILL_RESULT) - respond(outcomes, FILL_RESULT) + for _ in range(3): + respond(outcomes, FILL_RESULT) text = call(runner, client, "session-1", TOOL_INPUT) - assert json.loads(calls[0].content) == {"credential": "github", "fields": FIELDS} + assert json.loads(calls[0].content) == { + "vault": "github-login", + "fields": SENT_FIELDS, + } assert calls[0].url.path == "/api/session/session-1/credentials/fill" - assert json.loads(text) == FILL_RESULT + output = json.loads(text) + assert output["pageId"] == "PAGE1" + assert output["fields"][2] == FILL_RESULT["fields"][2] + # Members the API left out stay out, rather than appearing as null. + assert "version" not in output and "expiresAt" not in output["fields"][0] assert " " not in text.replace("https://", "") call( runner, client, "session-1", - dict(TOOL_INPUT, page_id="PAGE2", frame_url="https://login.github.com/"), + dict(TOOL_INPUT, page_url="https://github.com/login", frame_url="https://x/"), ) assert json.loads(calls[1].content) == { - "credential": "github", - "fields": FIELDS, + "vault": "github-login", + "fields": SENT_FIELDS, + "pageUrl": "https://github.com/login", + "frame": {"url": "https://x/"}, + } + + # A single secret: the model sends null for the vault and each field. + call( + runner, + client, + "session-1", + dict( + TOOL_INPUT, + credential="github-password", + vault=None, + fields=[{"selector": "#password", "field": None}], + page_id="PAGE2", + ), + ) + assert json.loads(calls[2].content) == { + "credential": "github-password", + "fields": [{"selector": "#password"}], "pageId": "PAGE2", - "frame": {"url": "https://login.github.com/"}, } @@ -178,9 +231,15 @@ def test_tool_runner_returns_errors_as_json(sdk): } } - error = json.loads(call(runner, client, "s", dict(TOOL_INPUT, fields=[]))) - assert error["error"]["code"] == "invalid_request" - assert error["error"]["details"] is None + for bad in [ + dict(TOOL_INPUT, fields=[]), + dict(TOOL_INPUT, credential="github-password"), + dict(TOOL_INPUT, page_id="A", page_url="https://github.com/"), + dict(TOOL_INPUT, fields=[{"selector": "#u", "field": None}]), + ]: + error = json.loads(call(runner, client, "s", bad)) + assert error["error"]["code"] == "invalid_request" + assert error["error"]["details"] is None assert len(calls) == 1 diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 20aa9698..0e900ea5 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -1,7 +1,8 @@ -"""Credentials: typed secrets that browser sessions fill into pages.""" +"""Typed secrets (totp, op_token), browser metadata, and credential fill.""" import asyncio import json +import warnings import httpx import pytest @@ -19,9 +20,10 @@ FillCredentialParams, Secret, SecretMetadata, + SessionCredentialVault, UpdateSecretParams, ) -from hyperbrowser.secrets_common import credential_value_bytes +from hyperbrowser.secrets_common import check_totp_seed from tests.test_secrets import ( SECRET_ID, AsyncScriptedHTTPClient, @@ -32,9 +34,10 @@ _secret, ) -CREDENTIAL_CONTEXT = f"hb-secret-v1:team_1:{SECRET_ID}:credential" -LOGIN = {"username": "ada@example.com", "password": "pässwörd-hunter2"} +TOTP_CONTEXT = f"hb-secret-v1:team_1:{SECRET_ID}:totp" +OP_TOKEN_CONTEXT = f"hb-secret-v1:team_1:{SECRET_ID}:op_token" ORIGINS = ["https://github.com", "https://*.github.com"] +SEED = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" @pytest.fixture(scope="module") @@ -42,31 +45,33 @@ def private_key(): return rsa.generate_private_key(public_exponent=65537, key_size=3072) -def _credential(name="github", origins=None, latest=None): +def _typed(secret_type, name="github-otp", origins=None, context=None): + metadata = {"browser": {"allowedOrigins": origins}} if origins else {} return dict( - _secret(name=name, latest=latest), - type="credential", - encryptionContext=CREDENTIAL_CONTEXT, - metadata={"browser": {"allowedOrigins": origins or ORIGINS}}, + _secret(name=name), + type=secret_type, + encryptionContext=context + or {"totp": TOTP_CONTEXT, "op_token": OP_TOKEN_CONTEXT}.get( + secret_type, _secret()["encryptionContext"] + ), + metadata=metadata, ) -def _create_routes(private_key, upload=None): +def _create_routes(private_key, created, upload=None): return { ("GET", "/secrets/public-key"): (200, _public_key_payload(private_key)), - ("POST", "/secrets"): (201, _credential()), + ("POST", "/secrets"): (201, created), ("POST", f"/secrets/{SECRET_ID}/versions"): upload or (201, {"version": 1, "kid": "k", "createdAt": "2026-10-01T00:00:00Z"}), } -def _assert_no_plaintext(calls): - sent = json.dumps([call["json"] for call in calls], ensure_ascii=False) - for value in LOGIN.values(): - assert value not in sent +def _assert_not_sent(calls, value): + assert value not in json.dumps([call["json"] for call in calls]) -# --- secret type and metadata --- +# --- secret types and metadata --- def test_secrets_default_to_plain_and_keep_unknown_metadata(): @@ -78,219 +83,253 @@ def test_secrets_default_to_plain_and_keep_unknown_metadata(): payload = dict(_secret(), type=None, metadata=missing) assert Secret.model_validate(payload).metadata.browser is None - credential = Secret.model_validate( + secret = Secret.model_validate( dict( - _credential(), + _typed("totp"), metadata={ "browser": {"allowedOrigins": ORIGINS, "future": 1}, "other": {"x": True}, }, ) ) - assert credential.type == "credential" - assert credential.metadata.browser.allowed_origins == ORIGINS - dumped = credential.metadata.model_dump(by_alias=True) + assert secret.type == "totp" + assert secret.metadata.browser.allowed_origins == ORIGINS + dumped = secret.metadata.model_dump(by_alias=True) assert dumped["other"] == {"x": True} assert dumped["browser"]["future"] == 1 -def test_credential_values_serialize_as_compact_utf8_json(): - assert credential_value_bytes({"username": "ada", "pass.word-1_": "é x"}) == ( - '{"username":"ada","pass.word-1_":"é x"}'.encode("utf-8") - ) - assert credential_value_bytes({"a" * 64: "x"}) == b'{"' + b"a" * 64 + b'":"x"}' - - -# --- create_credential --- - - -def test_create_credential_uploads_json_bound_to_the_credential_context(private_key): - http = ScriptedHTTPClient(_create_routes(private_key)) +def test_create_with_allowed_origins_makes_a_fillable_secret(private_key): + created = _typed("secret", name="github-password", origins=ORIGINS) + http = ScriptedHTTPClient(_create_routes(private_key, created)) manager = SecretManager(FakeClient(http)) - secret = manager.create_credential("github", LOGIN, ORIGINS, description="d") + secret = manager.create( + { + "name": "github-password", + "value": "hunter2", + "allowed_origins": ORIGINS, + "description": "d", + } + ) - assert secret.type == "credential" assert secret.latest_version == 1 - assert secret.metadata.browser.allowed_origins == ORIGINS - assert [c["path"] for c in http.calls] == [ - "/secrets/public-key", - "/secrets", - f"/secrets/{SECRET_ID}/versions", - ] assert http.calls[1]["json"] == { - "name": "github", + "name": "github-password", "description": "d", - "type": "credential", "metadata": {"browser": {"allowedOrigins": ORIGINS}}, } - upload = http.calls[2]["json"] - assert set(upload) == {"envelope"} - header, value = _open(private_key, upload["envelope"]) - assert header["ctx"] == CREDENTIAL_CONTEXT - assert value == json.dumps(LOGIN, separators=(",", ":"), ensure_ascii=False).encode( - "utf-8" + _, value = _open(private_key, http.calls[2]["json"]["envelope"]) + assert value == b"hunter2" + _assert_not_sent(http.calls, "hunter2") + + # metadata.browser works too, but not together with allowed_origins. + manager.create( + CreateSecretParams( + name="github-password", + metadata=SecretMetadata.model_validate( + {"browser": {"allowed_origins": ORIGINS}} + ), + ) ) - assert json.loads(value) == LOGIN - _assert_no_plaintext(http.calls) + assert http.calls[-1]["json"]["metadata"] == { + "browser": {"allowedOrigins": ORIGINS} + } + http.calls.clear() + with pytest.raises(ValueError, match="not both"): + manager.create( + { + "name": "x", + "allowed_origins": ORIGINS, + "metadata": {"browser": {"allowed_origins": ORIGINS}}, + } + ) + with pytest.raises(ValueError): + manager.create({"name": "x", "allowed_origins": "https://github.com"}) + assert http.calls == [] -def test_create_accepts_a_credential_type_and_metadata(private_key): - http = ScriptedHTTPClient(_create_routes(private_key)) +def test_totp_secrets_seal_the_seed_under_the_returned_context(private_key): + created = _typed("totp", origins=ORIGINS) + http = ScriptedHTTPClient(_create_routes(private_key, created)) manager = SecretManager(FakeClient(http)) - manager.create( + secret = manager.create( { - "name": "github", - "type": "credential", - "metadata": {"browser": {"allowed_origins": ORIGINS}}, - "value": LOGIN, + "name": "github-otp", + "type": "totp", + "value": SEED.lower(), + "allowed_origins": ORIGINS, } ) + assert secret.type == "totp" assert http.calls[1]["json"] == { - "name": "github", - "type": "credential", + "name": "github-otp", + "type": "totp", "metadata": {"browser": {"allowedOrigins": ORIGINS}}, } - _, value = _open(private_key, http.calls[2]["json"]["envelope"]) - assert json.loads(value) == LOGIN + header, value = _open(private_key, http.calls[2]["json"]["envelope"]) + # The API's context, never one the SDK builds; the value is sent as given. + assert header["ctx"] == TOTP_CONTEXT + assert value == SEED.lower().encode() + _assert_not_sent(http.calls, SEED.lower()) + + +GOOD_SEEDS = [ + SEED, + SEED.lower(), + "jbsw y3dp ehpk 3pxp jbsw y3dp ehpk 3pxp", + "JBSW-Y3DP-EHPK-3PXP-JBSW-Y3DP-EHPK-3PXP", + "" + SEED + "\n", + "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PX=", + f"otpauth://totp/GitHub:ada?secret={SEED}&issuer=GitHub", + f"OTPAUTH://TOTP/x?secret={SEED}&algorithm=sha512&digits=8&period=300", + f"otpauth://totp/x?secret={SEED}&algorithm=SHA256&digits=6&period=1", +] +BAD_SEEDS = [ + ("", "empty"), + ("====", "empty"), + ("not base32!", "not base32"), + ("JBSWY3D1", "not base32"), + (f"otpauth://hotp/x?secret={SEED}&counter=1", "HOTP"), + (f"otpauth://steam/x?secret={SEED}", "otpauth://totp/"), + (f"https://totp/x?secret={SEED}", "only otpauth://totp/"), + ("otpauth://totp/x?issuer=GitHub", "no secret"), + ("otpauth://totp/x?secret=", "no secret"), + ("otpauth://totp/x?secret=0189", "not base32"), + (f"otpauth://hotp/x?secret={SEED}", "HOTP"), + (f"otpauth://totp/x?secret={SEED}&issuer=a\tb", "control characters"), + (f"otpauth://totp/x?secret={SEED}\x7f", "control characters"), + ("JBSW=Y3DP", "not base32"), + ("JBSWY3DPE", "length"), + ("JBSWY3DPEHP", "length"), + ("JBSWY3DPEHPK3P", "length"), + (f"otpauth://totp/x?secret={SEED}A", "length"), + (f"otpauth://totp/x?secret={SEED}&algorithm=MD5", "algorithm"), + (f"otpauth://totp/x?secret={SEED}&digits=7", "digits"), + (f"otpauth://totp/x?secret={SEED}&digits=six", "digits"), + (f"otpauth://totp/x?secret={SEED}&period=0", "period"), + (f"otpauth://totp/x?secret={SEED}&period=301", "period"), + (f"otpauth://totp/x?secret={SEED}&period=1_0", "period"), +] -def test_a_failed_credential_upload_reports_the_partial_create(private_key): - http = ScriptedHTTPClient( - _create_routes( - private_key, - upload=(409, {"code": "SECRET_KEY_OUTDATED", "message": "key rotated"}), - ) - ) - manager = SecretManager(FakeClient(http)) - - with pytest.raises(HyperbrowserError) as error: - manager.create_credential("github", LOGIN, ORIGINS) - - assert error.value.code == "secret_value_not_stored" - assert error.value.details == {"secretId": SECRET_ID, "name": "github"} - assert error.value.cause.code == "SECRET_KEY_OUTDATED" - - -BAD_CREDENTIAL_VALUES = [ - {}, - {"bad key": "x"}, - {"": "x"}, - {"a" * 65: "x"}, - {"user/name": "x"}, - {1: "x"}, - {"username": ""}, - {"username": 1}, - {"username": None}, - {"username": "a\x00b"}, - {"username": "\ud800"}, - {"password": "x" * (32 * 1024)}, - "a plain string", - b"plain bytes", -] +@pytest.mark.parametrize("seed", GOOD_SEEDS) +def test_totp_seeds_the_platform_accepts_pass(seed): + with warnings.catch_warnings(): + warnings.simplefilter("error") + check_totp_seed(seed) -@pytest.mark.parametrize("value", BAD_CREDENTIAL_VALUES) -def test_bad_credential_values_fail_before_any_request(value): +@pytest.mark.parametrize("seed,reason", BAD_SEEDS) +def test_bad_totp_seeds_fail_before_any_request(seed, reason): http = ScriptedHTTPClient({}) manager = SecretManager(FakeClient(http)) - attempts = [ - lambda: manager.create_credential("github", value, ORIGINS), - lambda: manager.create( - {"name": "github", "type": "credential", "value": value} - ), - ] - if isinstance(value, dict): - # A str or bytes value is checked against the secret's type, which - # takes a lookup; see the add_version tests. - attempts.append(lambda: manager.add_version("github", value)) - for attempt in attempts: - with pytest.raises(HyperbrowserError) as error: - attempt() - assert error.value.code == "invalid_secret_value" - message = str(error.value) - for item in value.values() if isinstance(value, dict) else [value]: - if isinstance(item, str) and item: - assert item not in message + with pytest.raises(HyperbrowserError) as error: + manager.create({"name": "github-otp", "type": "totp", "value": seed}) + + assert error.value.code == "invalid_secret_value" + assert reason in str(error.value) + if len(seed) > 4: + assert seed not in str(error.value) assert http.calls == [] -def test_a_mapping_is_refused_for_a_plain_secret_before_any_request(): - http = ScriptedHTTPClient({}) +def test_short_totp_seeds_pass_without_warnings(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + check_totp_seed("JBSWY3DP") + check_totp_seed("MY") + + +def test_op_tokens_take_any_string_but_never_allowed_origins(private_key): + created = _typed("op_token", name="op-token") + http = ScriptedHTTPClient(_create_routes(private_key, created)) manager = SecretManager(FakeClient(http)) + # Tokens start with ops_, but other strings aren't refused. + for token in ["ops_eyJzaWduSW5BZGRyZXNz", "not-a-prefix"]: + manager.create({"name": "op-token", "type": "op_token", "value": token}) + header, value = _open(private_key, http.calls[-1]["json"]["envelope"]) + assert header["ctx"] == OP_TOKEN_CONTEXT and value == token.encode() + + http.calls.clear() for params in [ - {"name": "provider-key", "value": LOGIN}, - {"name": "provider-key", "type": "secret", "value": LOGIN}, + {"name": "op-token", "type": "op_token", "allowed_origins": ORIGINS}, + { + "name": "op-token", + "type": "op_token", + "metadata": {"browser": {"allowed_origins": ORIGINS}}, + }, ]: - with pytest.raises(HyperbrowserError) as error: + with pytest.raises(ValueError, match="op_token"): manager.create(params) - assert error.value.code == "invalid_secret_value" - assert "credential" in str(error.value) assert http.calls == [] -def test_credential_values_stay_out_of_reprs_and_dumps(): - params = CreateSecretParams(name="github", type="credential", value=LOGIN) - for text in [repr(params), str(params), json.dumps(params.model_dump())]: - assert LOGIN["password"] not in text - with pytest.raises(ValueError) as error: - CreateSecretParams.model_validate({"value": LOGIN}) - assert LOGIN["password"] not in str(error.value) +def test_updates_refuse_allowed_origins_on_an_op_token(): + http = ScriptedHTTPClient( + { + ("GET", "/secrets"): ( + 200, + {"secrets": [_typed("op_token", name="op-token")], "totalCount": 1}, + ) + } + ) + manager = SecretManager(FakeClient(http)) + with pytest.raises(HyperbrowserError) as error: + manager.set_allowed_origins("op-token", ORIGINS) + assert error.value.code == "invalid_secret_metadata" + assert all(call["method"] == "GET" for call in http.calls) -def test_allowed_origins_must_be_a_list(): - http = ScriptedHTTPClient({}) - manager = SecretManager(FakeClient(http)) - for origins in ["https://github.com", [1]]: - with pytest.raises(HyperbrowserError) as error: - manager.create_credential("github", LOGIN, origins) - assert error.value.code == "invalid_secret_metadata" - with pytest.raises(HyperbrowserError): - manager.set_allowed_origins("github", origins) - assert http.calls == [] +def test_values_stay_out_of_reprs_and_dumps(): + params = CreateSecretParams(name="github-otp", type="totp", value=SEED) + for text in [repr(params), str(params), json.dumps(params.model_dump())]: + assert SEED not in text + with pytest.raises(ValueError) as error: + CreateSecretParams.model_validate({"value": SEED}) + assert SEED not in str(error.value) + with pytest.raises(ValueError): + CreateSecretParams(name="x", value={"username": "a"}) # type: ignore[arg-type] # --- add_version --- -def test_add_version_checks_the_value_against_the_secrets_type(private_key): +def test_add_version_checks_the_value_against_the_stored_type(private_key): versions_path = f"/secrets/{SECRET_ID}/versions" routes = { - ("GET", "/secrets"): (200, {"secrets": [_credential()], "totalCount": 1}), + ("GET", "/secrets"): (200, {"secrets": [_typed("totp")], "totalCount": 1}), ("GET", "/secrets/public-key"): (200, _public_key_payload(private_key)), ("POST", versions_path): (201, {"version": 2, "kid": "k"}), } http = ScriptedHTTPClient(routes) manager = SecretManager(FakeClient(http)) - assert manager.add_version("github", LOGIN).version == 2 + assert manager.add_version("github-otp", SEED).version == 2 header, value = _open(private_key, http.calls[-1]["json"]["envelope"]) - assert header["ctx"] == CREDENTIAL_CONTEXT - assert json.loads(value) == LOGIN - _assert_no_plaintext(http.calls) + assert header["ctx"] == TOTP_CONTEXT and value == SEED.encode() - http.calls.clear() - for value in ["plain", b"plain"]: - with pytest.raises(HyperbrowserError) as error: - manager.add_version("github", value) - assert error.value.code == "invalid_secret_value" - assert [c["path"] for c in http.calls] == ["/secrets", "/secrets"] - - routes[("GET", "/secrets")] = (200, {"secrets": [_secret()], "totalCount": 1}) http.calls.clear() with pytest.raises(HyperbrowserError) as error: - manager.add_version("provider-key", LOGIN) + manager.add_version("github-otp", "hunter2!") assert error.value.code == "invalid_secret_value" - assert all(c["path"] != versions_path for c in http.calls) + assert [c["path"] for c in http.calls] == ["/secrets"] - manager.add_version("provider-key", "plain") - header, value = _open(private_key, http.calls[-1]["json"]["envelope"]) - assert header["ctx"] == _secret()["encryptionContext"] and value == b"plain" + # A plain secret takes any value; common rules apply before the lookup. + routes[("GET", "/secrets")] = (200, {"secrets": [_secret()], "totalCount": 1}) + manager.add_version("provider-key", "hunter2!") + _, value = _open(private_key, http.calls[-1]["json"]["envelope"]) + assert value == b"hunter2!" + http.calls.clear() + for bad in ["", "a\x00b", {"username": "a"}]: + with pytest.raises(HyperbrowserError): + manager.add_version("provider-key", bad) # type: ignore[arg-type] + assert http.calls == [] # --- metadata updates and listing --- @@ -299,27 +338,32 @@ def test_add_version_checks_the_value_against_the_secrets_type(private_key): def test_set_allowed_origins_replaces_or_clears_the_browser_metadata(): http = ScriptedHTTPClient( { - ("GET", "/secrets"): (200, {"secrets": [_credential()], "totalCount": 1}), - ("PATCH", f"/secrets/{SECRET_ID}"): (200, _credential()), + ("GET", "/secrets"): (200, {"secrets": [_typed("totp")], "totalCount": 1}), + ("PATCH", f"/secrets/{SECRET_ID}"): (200, _typed("totp")), } ) manager = SecretManager(FakeClient(http)) - manager.set_allowed_origins("github", ("https://github.com",)) + manager.set_allowed_origins("github-otp", ("https://github.com",)) assert http.calls[-1]["json"] == { "metadata": {"browser": {"allowedOrigins": ["https://github.com"]}} } - manager.set_allowed_origins("github", None) + manager.set_allowed_origins("github-otp", None) assert http.calls[-1]["json"] == {"metadata": {"browser": None}} assert json.dumps(http.calls[-1]["json"]) == '{"metadata": {"browser": null}}' + for origins in ["https://github.com", [1]]: + with pytest.raises(HyperbrowserError) as error: + manager.set_allowed_origins("github-otp", origins) # type: ignore[arg-type] + assert error.value.code == "invalid_secret_metadata" + def test_update_sends_only_the_members_given(): http = ScriptedHTTPClient( { - ("GET", f"/secrets/{SECRET_ID}"): (200, dict(_credential(), versions=[])), - ("PATCH", f"/secrets/{SECRET_ID}"): (200, _credential()), + ("GET", f"/secrets/{SECRET_ID}"): (200, dict(_typed("totp"), versions=[])), + ("PATCH", f"/secrets/{SECRET_ID}"): (200, _typed("totp")), } ) manager = SecretManager(FakeClient(http)) @@ -340,7 +384,7 @@ def test_update_sends_only_the_members_given(): ]: updated = manager.update(SECRET_ID, params) assert http.calls[-1]["json"] == body - assert updated.type == "credential" + assert updated.type == "totp" http.calls.clear() with pytest.raises(ValueError): @@ -348,18 +392,24 @@ def test_update_sends_only_the_members_given(): assert http.calls == [] -def test_list_filters_by_type(): +@pytest.mark.parametrize("secret_type", ["secret", "totp", "op_token"]) +def test_list_filters_by_type(secret_type): http = ScriptedHTTPClient( - {("GET", "/secrets"): (200, {"secrets": [_credential()], "totalCount": 1})} + { + ("GET", "/secrets"): ( + 200, + {"secrets": [_typed(secret_type)], "totalCount": 1}, + ) + } ) manager = SecretManager(FakeClient(http)) - listing = manager.list({"type": "credential"}) + listing = manager.list({"type": secret_type}) - assert http.calls[0]["params"] == {"type": "credential"} - assert listing.secrets[0].type == "credential" + assert http.calls[0]["params"] == {"type": secret_type} + assert listing.secrets[0].type == secret_type with pytest.raises(ValueError): - manager.list({"type": "password"}) + manager.list({"type": "credential"}) # type: ignore[typeddict-item] def test_secret_types_added_later_still_parse(): @@ -377,53 +427,47 @@ def test_secret_types_added_later_still_parse(): assert manager.get("provider-key").type == "future_type" # Requests still only take the known types. with pytest.raises(ValueError): - CreateSecretParams(name="provider-key", type="future_type") + CreateSecretParams(name="provider-key", type="future_type") # type: ignore[arg-type] @pytest.mark.anyio -async def test_async_secret_types_added_later_still_parse(): - future = dict(_secret(), type="future_type") - http = AsyncScriptedHTTPClient( - { - ("GET", "/secrets"): (200, {"secrets": [future], "totalCount": 1}), - ("GET", f"/secrets/{SECRET_ID}"): (200, dict(future, versions=[])), - } +async def test_async_typed_secrets_round_trip(private_key): + routes = _create_routes(private_key, _typed("totp", origins=ORIGINS)) + routes[("GET", "/secrets")] = ( + 200, + {"secrets": [_typed("totp", origins=ORIGINS)], "totalCount": 1}, ) - manager = AsyncSecretManager(FakeClient(http)) - - assert (await manager.list()).secrets[0].type == "future_type" - assert (await manager.get("provider-key")).type == "future_type" - - -@pytest.mark.anyio -async def test_async_credentials_round_trip(private_key): - routes = _create_routes(private_key) - routes[("GET", "/secrets")] = (200, {"secrets": [_credential()], "totalCount": 1}) - routes[("PATCH", f"/secrets/{SECRET_ID}")] = (200, _credential()) + routes[("PATCH", f"/secrets/{SECRET_ID}")] = (200, _typed("totp")) http = AsyncScriptedHTTPClient(routes) manager = AsyncSecretManager(FakeClient(http)) - secret = await manager.create_credential("github", LOGIN, ORIGINS) + secret = await manager.create( + { + "name": "github-otp", + "type": "totp", + "value": SEED, + "allowed_origins": ORIGINS, + } + ) assert secret.latest_version == 1 assert http.calls[1]["json"]["metadata"] == {"browser": {"allowedOrigins": ORIGINS}} header, value = _open(private_key, http.calls[2]["json"]["envelope"]) - assert header["ctx"] == CREDENTIAL_CONTEXT and json.loads(value) == LOGIN + assert header["ctx"] == TOTP_CONTEXT and value == SEED.encode() - await manager.add_version("github", LOGIN) - _, value = _open(private_key, http.calls[-1]["json"]["envelope"]) - assert json.loads(value) == LOGIN + await manager.add_version("github-otp", f"otpauth://totp/x?secret={SEED}") with pytest.raises(HyperbrowserError) as error: - await manager.add_version("github", "plain") + await manager.add_version("github-otp", "hunter2!") assert error.value.code == "invalid_secret_value" - await manager.set_allowed_origins("github", None) + await manager.set_allowed_origins("github-otp", None) assert http.calls[-1]["json"] == {"metadata": {"browser": None}} - _assert_no_plaintext(http.calls) + _assert_not_sent(http.calls, SEED) http.calls.clear() with pytest.raises(HyperbrowserError): - await manager.create_credential("github", {"username": ""}, ORIGINS) + await manager.create({"name": "github-otp", "type": "totp", "value": "nope!"}) assert http.calls == [] + assert (await manager.list({"type": "totp"})).secrets[0].type == "totp" # --- sessions --- @@ -481,52 +525,109 @@ def respond(outcomes, payload, status=200): "liveUrl": "https://live", "token": "t", } +FIELDS = [ + {"field": "USERNAME", "selector": "#login_field"}, + {"field": "PASSWORD", "selector": "#password"}, + {"field": "OTP", "selector": "#app_totp"}, +] FILL_RESULT = { - "version": 3, "pageId": "PAGE1", "origin": "https://github.com", "fields": [ - {"field": "username", "outcome": "filled"}, - {"field": "password", "outcome": "not_accepted"}, + { + "selector": "#login_field", + "outcome": "filled", + "secretId": SECRET_ID, + "version": 1, + "field": "USERNAME", + }, + { + "selector": "#password", + "outcome": "not_accepted", + "secretId": SECRET_ID, + "version": 3, + "field": "PASSWORD", + }, + { + "selector": "#app_totp", + "outcome": "filled", + "secretId": SECRET_ID, + "version": 1, + "field": "OTP", + "expiresAt": "2026-10-14T12:00:30Z", + }, ], } -FIELDS = [ - {"field": "username", "selector": "#login_field"}, - {"field": "password", "selector": "#password"}, -] -def test_sessions_attach_credentials_at_creation(sdk): +def test_sessions_attach_secrets_and_vaults_at_creation(sdk): client, call, calls, outcomes = sdk - respond( - outcomes, - dict(SESSION, credentials=[{"id": SECRET_ID, "name": "github"}]), - ) + attached = [{"id": SECRET_ID, "name": "github-password"}] + respond(outcomes, dict(SESSION, credentials=attached)) + respond(outcomes, SESSION) respond(outcomes, SESSION) - session = call(client.sessions.create, {"credentials": ["github"]}) - assert json.loads(calls[0].content)["credentials"] == ["github"] + session = call( + client.sessions.create, + { + "credentials": [ + "github-password", + {"secret": "github-username"}, + {"vault": "github-login"}, + ] + }, + ) + assert json.loads(calls[0].content)["credentials"] == [ + "github-password", + {"secret": "github-username"}, + {"vault": "github-login"}, + ] assert session.credentials is not None - assert [(c.id, c.name) for c in session.credentials] == [(SECRET_ID, "github")] + assert [(c.id, c.name) for c in session.credentials] == [ + (SECRET_ID, "github-password") + ] + + call( + client.sessions.create, + CreateSessionParams(credentials=[SessionCredentialVault(vault="github-login")]), + ) + assert json.loads(calls[1].content)["credentials"] == [{"vault": "github-login"}] session = call(client.sessions.create, CreateSessionParams()) - assert "credentials" not in json.loads(calls[1].content) + assert "credentials" not in json.loads(calls[2].content) assert session.credentials is None -def test_fill_posts_the_request_and_parses_outcomes(sdk): +@pytest.mark.parametrize( + "credentials", + [ + ["bad name"], + [{"vault": "a", "secret": "b"}], + [{"vault": "-a"}], + [{"login": "a"}], + ["s"] * 21, + "github", + ], +) +def test_invalid_session_credentials_fail_before_http(sdk, credentials): + client, call, calls, outcomes = sdk + with pytest.raises(ValueError): + call(client.sessions.create, {"credentials": credentials}) + assert not calls + + +def test_vault_fills_post_fields_and_parse_per_input_results(sdk): client, call, calls, outcomes = sdk respond(outcomes, FILL_RESULT) - respond(outcomes, dict(FILL_RESULT, fields=[{"field": "otp", "outcome": "new"}])) result = call( client.sessions.credentials.fill, "session /1", { - "credential": "github", + "vault": "github-login", "fields": FIELDS, - "page_id": "PAGE1", - "frame": {"url": "https://login.github.com/"}, + "page_url": "https://github.com/login", + "frame_url": "https://github.com/", }, ) @@ -534,31 +635,61 @@ def test_fill_posts_the_request_and_parses_outcomes(sdk): assert request.method == "POST" assert request.url.raw_path == b"/api/session/session%20%2F1/credentials/fill" assert json.loads(request.content) == { - "credential": "github", - "fields": FIELDS, - "pageId": "PAGE1", - "frame": {"url": "https://login.github.com/"}, + "vault": "github-login", + "fields": [{"selector": f["selector"], "field": f["field"]} for f in FIELDS], + "pageUrl": "https://github.com/login", + "frame": {"url": "https://github.com/"}, } assert request.extensions["timeout"]["read"] == 30 - assert result.version == 3 + assert result.version is None assert result.page_id == "PAGE1" assert result.origin == "https://github.com" - assert [(f.field, f.outcome) for f in result.fields] == [ - ("username", "filled"), - ("password", "not_accepted"), + assert [(f.field, f.outcome, f.version) for f in result.fields] == [ + ("USERNAME", "filled", 1), + ("PASSWORD", "not_accepted", 3), + ("OTP", "filled", 1), ] + assert result.fields[0].secret_id == SECRET_ID + assert result.fields[0].expires_at is None + assert result.fields[2].expires_at is not None + assert result.fields[2].expires_at.isoformat() == "2026-10-14T12:00:30+00:00" + + +def test_single_secret_fills_report_the_version(sdk): + client, call, calls, outcomes = sdk + single = { + "version": 3, + "pageId": "PAGE1", + "origin": "https://github.com", + "fields": [ + { + "selector": "#password", + "outcome": "new", + "secretId": SECRET_ID, + "version": 3, + } + ], + } + respond(outcomes, single) client.timeout = 120 result = call( client.sessions.credentials.fill, "s", - FillCredentialParams(credential="github", fields=FIELDS[:1]), + FillCredentialParams( + credential="github-password", + fields=[{"selector": "#password"}], # type: ignore[list-item] + page_id="PAGE1", + ), ) - assert json.loads(calls[1].content) == { - "credential": "github", - "fields": FIELDS[:1], + assert json.loads(calls[0].content) == { + "credential": "github-password", + "fields": [{"selector": "#password"}], + "pageId": "PAGE1", } - assert calls[1].extensions["timeout"]["read"] == 120 + assert calls[0].extensions["timeout"]["read"] == 120 + assert result.version == 3 + assert result.fields[0].field is None # Outcomes the SDK doesn't know yet still parse. assert result.fields[0].outcome == "new" @@ -580,48 +711,53 @@ def test_fill_errors_keep_code_retryable_and_details(sdk): {"code": "CREDENTIAL_FILL_TIMEOUT", "message": "timed out", "retryable": True}, 504, ) + params = {"credential": "c", "fields": [{"selector": "#p"}]} with pytest.raises(HyperbrowserError) as raised: - call( - client.sessions.credentials.fill, "s", {"credential": "c", "fields": FIELDS} - ) + call(client.sessions.credentials.fill, "s", params) assert raised.value.code == "CREDENTIAL_PAGE_AMBIGUOUS" assert raised.value.status_code == 409 assert raised.value.retryable is False assert raised.value.details["details"]["pages"] == pages with pytest.raises(HyperbrowserError) as raised: - call( - client.sessions.credentials.fill, "s", {"credential": "c", "fields": FIELDS} - ) + call(client.sessions.credentials.fill, "s", params) assert raised.value.code == "CREDENTIAL_FILL_TIMEOUT" assert raised.value.retryable is True # A fill is never retried automatically. assert len(calls) == 2 +ONE = [{"selector": "#p"}] + + @pytest.mark.parametrize( "params", [ - {"credential": "github", "fields": []}, - { - "credential": "github", - "fields": [dict(FIELDS[0], field=f"f{i}") for i in range(17)], - }, - {"credential": "github", "fields": [FIELDS[0], FIELDS[0]]}, - {"credential": "github", "fields": [{"field": "user name", "selector": "#u"}]}, - {"credential": "github", "fields": [{"field": "f" * 65, "selector": "#u"}]}, - {"credential": "github", "fields": [{"field": "username", "selector": " "}]}, - {"credential": "github", "fields": [{"field": "u", "selector": "é" * 513}]}, - {"credential": "github", "fields": [{"field": "username"}]}, - {"credential": "github", "fields": FIELDS, "page_id": ""}, - {"credential": "github", "fields": FIELDS, "page_id": "p" * 129}, - {"credential": "github", "fields": FIELDS, "frame": {"url": ""}}, - {"credential": "github", "fields": FIELDS, "frame": {"url": "u" * 2049}}, - {"credential": "", "fields": FIELDS}, - {"fields": FIELDS}, - {"credential": "github"}, - {"credential": "github", "fields": "username"}, + {"fields": ONE}, + {"credential": "c", "vault": "v", "fields": ONE}, + {"credential": "", "fields": ONE}, + {"credential": "c" * 256, "fields": ONE}, + {"credential": "c", "fields": [{"selector": "#p", "field": "PASSWORD"}]}, + {"vault": "v", "fields": ONE}, + {"vault": "bad name", "fields": [{"selector": "#p", "field": "P"}]}, + {"vault": "v", "fields": [{"selector": "#p", "field": "1PASSWORD"}]}, + {"vault": "v", "fields": [{"selector": "#p", "field": "__proto__"}]}, + {"credential": "c", "fields": []}, + {"credential": "c", "fields": [{"selector": f"#f{i}"} for i in range(17)]}, + {"credential": "c", "fields": [{"selector": "#p"}, {"selector": "#p"}]}, + {"credential": "c", "fields": [{"selector": " "}]}, + {"credential": "c", "fields": [{"selector": "é" * 513}]}, + {"credential": "c", "fields": [{}]}, + {"credential": "c", "fields": ONE, "page_id": "p", "page_url": "https://x"}, + {"credential": "c", "fields": ONE, "page_id": ""}, + {"credential": "c", "fields": ONE, "page_id": "p" * 129}, + {"credential": "c", "fields": ONE, "page_url": "u" * 2049}, + {"credential": "c", "fields": ONE, "frame_url": ""}, + {"credential": "c", "fields": ONE, "frame_url": "u" * 2049}, + {"credential": "c", "fields": ONE, "frame": {"url": "https://x"}}, + {"credential": "c"}, + {"credential": "c", "fields": "#p"}, "github", ], ) @@ -636,17 +772,27 @@ def test_invalid_fill_requests_fail_before_http(sdk, params): def test_fill_limits_are_inclusive(sdk): client, call, calls, outcomes = sdk respond(outcomes, FILL_RESULT) + respond(outcomes, FILL_RESULT) call( client.sessions.credentials.fill, "s", { - "credential": "github", + "vault": "v" * 128, "fields": [ - {"field": f"f{i}" if i else "f" * 64, "selector": "é" * 512} + # 256-character entry names; 1024-byte selectors. + { + "field": "F" * 256 if i == 0 else f"F{i}", + "selector": "é" * 511 + f"{i:02d}", + } for i in range(16) ], "page_id": "p" * 128, - "frame": {"url": "u" * 2048}, + "frame_url": "u" * 2048, }, ) - assert len(calls) == 1 + call( + client.sessions.credentials.fill, + "s", + {"credential": "c" * 255, "fields": ONE, "page_url": "u" * 2048}, + ) + assert len(calls) == 2 diff --git a/tests/test_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index e72f336a..96be634a 100644 --- a/tests/test_sandbox_wire_contract.py +++ b/tests/test_sandbox_wire_contract.py @@ -1690,6 +1690,7 @@ def fake_exec(input, **kwargs): "run_as": "root", "max_output_bytes": 64 * 1024 * 1024, "secrets": None, + "vault": None, }, ) ] diff --git a/tests/test_secrets.py b/tests/test_secrets.py index d7ed915c..6a9c10b7 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -1091,7 +1091,7 @@ def test_malformed_http_error_preserves_generic_error_parsing(service): "payload", [ {"process": {"id": "proc_9"}, "secrets": RESOLVED}, - {"process": AGENT_PROCESS, "secrets": [{"env": "API_KEY"}]}, + {"process": AGENT_PROCESS, "secrets": [{"name": "provider-key"}]}, ], ) def test_secret_start_preserves_process_id_when_response_metadata_is_invalid(payload): @@ -1106,7 +1106,7 @@ def test_secret_start_preserves_process_id_when_response_metadata_is_invalid(pay @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"}]} + return {"process": AGENT_PROCESS, "secrets": [{"name": "provider-key"}]} api = AsyncSandboxProcessesApi(AsyncRuntimeScript(), start, "sbx_123") with pytest.raises(HyperbrowserError) as error: diff --git a/tests/test_vaults.py b/tests/test_vaults.py new file mode 100644 index 00000000..0eb4cd89 --- /dev/null +++ b/tests/test_vaults.py @@ -0,0 +1,504 @@ +"""Vaults, and vaults as a source of sandbox secrets.""" + +import json + +import pytest + +from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_processes import ( + SandboxProcessesApi, +) +from hyperbrowser.exceptions import HyperbrowserError +from hyperbrowser.models import ( + CreateSandboxParams, + CreateVaultParams, + OnePasswordEntry, + OnePasswordVaultSource, + SandboxDetail, + SandboxResolvedSecret, + StoredEntry, + UpdateVaultParams, + Vault, + VaultOnePasswordEntry, + VaultStoredEntry, +) +from tests.test_credentials import mock_clients, respond +from tests.test_sandbox_wire_contract import SANDBOX_DETAIL_PAYLOAD +from tests.test_secrets import ( + AGENT_PROCESS, + SECRET_ID, + RuntimeScript, + _sandbox_manager, +) + +VAULT_ID = "3f1d2c4b-5a6e-4f70-8a9b-0c1d2e3f4a5b" +TOKEN_ID = "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b" +STORED_VAULT = { + "id": VAULT_ID, + "name": "github-login", + "description": "", + "source": {"type": "stored"}, + "entries": { + "PASSWORD": {"name": "github-password", "secretId": SECRET_ID, "version": 3}, + "USERNAME": {"name": "github-username", "secretId": SECRET_ID, "version": None}, + }, + "createdAt": "2026-10-01T00:00:00Z", + "updatedAt": "2026-10-01T00:00:00Z", +} +ONEPASSWORD_VAULT = { + "id": VAULT_ID, + "name": "prod-db", + "description": "", + "source": { + "type": "1password", + "tokenSecret": {"name": "op-token", "secretId": TOKEN_ID}, + "vault": "Engineering", + }, + "entries": { + "DB_PASS": { + "item": "Postgres", + "section": "prod", + "field": "password", + "reference": "op://Engineering/Postgres/prod/password", + } + }, + "createdAt": "2026-10-01T00:00:00Z", + "updatedAt": "2026-10-01T00:00:00Z", +} + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +def sdk(request): + yield from mock_clients(request.param) + + +def _body(request): + return json.loads(request.content) + + +# --- models --- + + +def test_vaults_parse_both_sources_and_entry_kinds(): + stored = Vault.model_validate(STORED_VAULT) + assert stored.source.type == "stored" + password = stored.entries["PASSWORD"] + assert isinstance(password, VaultStoredEntry) + assert isinstance(password, StoredEntry) + assert (password.name, password.secret_id, password.version) == ( + "github-password", + SECRET_ID, + 3, + ) + username = stored.entries["USERNAME"] + assert isinstance(username, StoredEntry) and username.version is None + + linked = Vault.model_validate(ONEPASSWORD_VAULT) + assert linked.source.type == "1password" + assert linked.source.vault == "Engineering" + assert linked.source.token_secret is not None + assert linked.source.token_secret.name == "op-token" + assert linked.source.token_secret.secret_id == TOKEN_ID + entry = linked.entries["DB_PASS"] + assert isinstance(entry, VaultOnePasswordEntry) + assert (entry.item, entry.section, entry.field) == ("Postgres", "prod", "password") + assert entry.reference == "op://Engineering/Postgres/prod/password" + + +def test_vaults_from_apis_without_sources_or_with_new_ones_parse(): + legacy = dict(STORED_VAULT) + del legacy["source"] + assert Vault.model_validate(legacy).source.type == "stored" + assert Vault.model_validate(dict(legacy, source=None, entries=None)).entries == {} + + future = Vault.model_validate( + dict(STORED_VAULT, source={"type": "keychain", "account": "x"}, entries={}) + ) + assert future.source.type == "keychain" + assert future.source.model_dump()["account"] == "x" + + +# --- requests --- + + +def test_create_sends_stored_and_1password_vaults(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, STORED_VAULT, 201) + respond(outcomes, ONEPASSWORD_VAULT, 201) + + vault = call( + client.vaults.create, + { + "name": "github-login", + "description": "GitHub", + "entries": { + "USERNAME": {"name": "github-username"}, + "PASSWORD": {"name": "github-password", "version": 3}, + "OTP": {"name": "github-otp", "version": "latest"}, + }, + }, + ) + assert calls[0].method == "POST" and calls[0].url.path == "/api/vaults" + assert _body(calls[0]) == { + "name": "github-login", + "description": "GitHub", + "entries": { + "USERNAME": {"name": "github-username"}, + "PASSWORD": {"name": "github-password", "version": 3}, + "OTP": {"name": "github-otp", "version": "latest"}, + }, + } + assert vault.id == VAULT_ID + + call( + client.vaults.create, + CreateVaultParams( + name="prod-db", + source=OnePasswordVaultSource( + type="1password", token_secret="op-token", vault="Engineering" + ), + entries={ + "DB_PASS": OnePasswordEntry( + item="Postgres", section="prod", field="password" + ), + "DB_OTP": OnePasswordEntry( + item="Postgres", field="one-time password?attribute=otp" + ), + }, + ), + ) + assert _body(calls[1]) == { + "name": "prod-db", + "source": { + "type": "1password", + "tokenSecret": "op-token", + "vault": "Engineering", + }, + "entries": { + "DB_PASS": {"item": "Postgres", "section": "prod", "field": "password"}, + "DB_OTP": {"item": "Postgres", "field": "one-time password?attribute=otp"}, + }, + } + + +def test_entries_read_from_a_vault_can_be_sent_back(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, STORED_VAULT, 201) + vault = Vault.model_validate(STORED_VAULT) + + call( + client.vaults.create, + CreateVaultParams(name="github-copy", entries=dict(vault.entries)), + ) + # Only request members: no secretId. + assert _body(calls[0])["entries"] == { + "PASSWORD": {"name": "github-password", "version": 3}, + "USERNAME": {"name": "github-username"}, + } + + +def _onepassword(entries): + return { + "name": "op", + "source": {"type": "1password", "token_secret": "op-token", "vault": "Eng"}, + "entries": entries, + } + + +@pytest.mark.parametrize( + "params,message", + [ + ({"name": "bad name"}, "Invalid secret name"), + ({"name": "v", "entries": {"1PASS": {"name": "s"}}}, "entry name"), + ({"name": "v", "entries": {"__proto__": {"name": "s"}}}, "entry name"), + ({"name": "v", "entries": {"P" * 257: {"name": "s"}}}, "entry name"), + ({"name": "v", "entries": {"P": {"name": "bad name"}}}, "secret name"), + ({"name": "v", "entries": {"P": {"name": "s", "version": 0}}}, "version"), + ({"name": "v", "entries": {"P": {"name": "s", "version": "next"}}}, "version"), + ( + {"name": "v", "entries": {f"E{i}": {"name": "s"} for i in range(33)}}, + "at most 32", + ), + ({"name": "v", "entries": {"P": {"item": "i", "field": "f"}}}, "stored vault"), + (_onepassword({"P": {"name": "s"}}), "1Password vault"), + (_onepassword({"P": {"item": "a/b", "field": "f"}}), "item"), + (_onepassword({"P": {"item": "a?b", "field": "f"}}), "item"), + (_onepassword({"P": {"item": "..", "field": "f"}}), "item"), + (_onepassword({"P": {"item": "a​b", "field": "f"}}), "item"), + (_onepassword({"P": {"item": "a\nb", "field": "f"}}), "item"), + (_onepassword({"P": {"item": "i" * 257, "field": "f"}}), "longer"), + (_onepassword({"P": {"item": "i", "section": "", "field": "f"}}), "section"), + (_onepassword({"P": {"item": "i", "field": "."}}), "field"), + (_onepassword({"P": {"item": "i", "field": "f?"}}), "query"), + (_onepassword({"P": {"item": "i", "field": "f?Attr=otp"}}), "query"), + (_onepassword({"P": {"item": "i", "field": "f?a=b&c"}}), "query"), + (_onepassword({"P": {"item": "i", "field": "f?a=b?c"}}), "query"), + (_onepassword({"P": {"item": "i" * 250, "field": "f" * 256}}), "512 bytes"), + ( + {"name": "op", "source": {"type": "1password", "vault": "Eng"}}, + "token_secret", + ), + ( + { + "name": "op", + "source": {"type": "1password", "token_secret": "t", "vault": "a/b"}, + }, + "1Password vault", + ), + ({"name": "op", "source": {"type": "keychain"}}, "type"), + ], +) +def test_invalid_vaults_fail_before_http(sdk, params, message): + client, call, calls, outcomes = sdk + with pytest.raises(ValueError, match=message): + call(client.vaults.create, params) + assert not calls + + +def test_1password_names_allow_what_the_server_allows(): + CreateVaultParams.model_validate( + _onepassword( + { + "A": {"item": "My Login (old)", "field": "password"}, + "B": {"item": "x" * 256, "section": "Ünïcødé ✓", "field": "f"}, + "C": {"item": "i", "field": "otp?attribute=otp&ssh-format=openssh"}, + "D": {"item": "...", "field": ".hidden"}, + } + ) + ) + + +def test_update_distinguishes_left_out_replaced_and_removed(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, {"vaults": [STORED_VAULT], "totalCount": 1}) + respond(outcomes, STORED_VAULT) + respond(outcomes, ONEPASSWORD_VAULT) + respond(outcomes, ONEPASSWORD_VAULT) + + call( + client.vaults.update, + "github-login", + { + "description": None, + "entries": {"OTP": None, "PASSWORD": {"name": "github-password"}}, + }, + ) + assert calls[0].url.path == "/api/vaults" + assert dict(calls[0].url.params) == {"search": "github-login", "limit": "100"} + assert calls[1].method == "PATCH" + assert calls[1].url.path == f"/api/vaults/{VAULT_ID}" + # None at the top level is "not given"; an entry set to None is removed. + assert calls[1].content == ( + b'{"entries":{"OTP":null,"PASSWORD":{"name":"github-password"}}}' + ) + + call( + client.vaults.update, + VAULT_ID, + UpdateVaultParams( + description="Prod", + source={"vault": "Platform"}, # type: ignore[arg-type] + ), + ) + assert calls[2].url.path == f"/api/vaults/{VAULT_ID}" + assert calls[3].method == "PATCH" + assert _body(calls[3]) == {"description": "Prod", "source": {"vault": "Platform"}} + + for bad in [{}, {"description": None}, {"source": {}}, {"entries": {"1X": None}}]: + with pytest.raises(ValueError): + call(client.vaults.update, VAULT_ID, bad) + assert len(calls) == 4 + + +def test_list_get_and_delete_resolve_names_and_ids(sdk): + client, call, calls, outcomes = sdk + listing = {"vaults": [STORED_VAULT], "totalCount": 1, "page": 1, "perPage": 20} + respond(outcomes, listing) + respond(outcomes, listing) + respond(outcomes, ONEPASSWORD_VAULT) + respond(outcomes, STORED_VAULT) + respond(outcomes, {"deleted": True, "id": VAULT_ID, "name": "github-login"}) + respond(outcomes, {"code": "VAULT_NOT_FOUND", "message": "Vault not found"}, 404) + respond(outcomes, {"vaults": [], "totalCount": 0}) + respond(outcomes, {"vaults": [dict(STORED_VAULT, name="github")], "totalCount": 1}) + + result = call(client.vaults.list, {"page": 1, "limit": 20, "search": "git"}) + assert dict(calls[0].url.params) == {"page": "1", "limit": "20", "search": "git"} + assert result.total_count == 1 and result.vaults[0].name == "github-login" + + assert call(client.vaults.get, " github-login ").id == VAULT_ID + assert call(client.vaults.get, VAULT_ID).name == "prod-db" + assert calls[2].url.path == f"/api/vaults/{VAULT_ID}" + + deleted = call(client.vaults.delete, VAULT_ID) + assert calls[4].method == "DELETE" and deleted.name == "github-login" + assert calls[4].url.path == f"/api/vaults/{VAULT_ID}" + + # A UUID-shaped key that isn't an ID falls back to a name search; names + # match exactly. + with pytest.raises(HyperbrowserError) as error: + call(client.vaults.get, VAULT_ID) + assert error.value.code == "VAULT_NOT_FOUND" + with pytest.raises(HyperbrowserError) as error: + call(client.vaults.get, "github-login") + assert error.value.code == "VAULT_NOT_FOUND" and error.value.status_code == 404 + + with pytest.raises(ValueError): + call(client.vaults.list, {"limit": 101}) + + +def test_vault_errors_keep_the_server_code(sdk): + client, call, calls, outcomes = sdk + respond( + outcomes, + { + "code": "SECRET_TYPE_MISMATCH", + "message": "a 1Password vault's token must be an op_token secret", + }, + 409, + ) + with pytest.raises(HyperbrowserError) as error: + call( + client.vaults.create, + { + "name": "op", + "source": {"type": "1password", "token_secret": "pw", "vault": "Eng"}, + }, + ) + assert error.value.code == "SECRET_TYPE_MISMATCH" + assert error.value.status_code == 409 + # Writes are never retried. + assert len(calls) == 1 + + +# --- sandboxes --- + +BINDINGS = [ + { + "env": "USERNAME", + "name": "github-username", + "secretId": SECRET_ID, + "version": 1, + "source": {"type": "vault", "name": "github-login"}, + }, + { + "env": "OTP", + "name": "github-otp", + "secretId": SECRET_ID, + "version": 2, + "secretType": "totp", + "source": {"type": "secrets"}, + "overrides": [{"type": "vault", "name": "github-login"}], + }, + { + "env": "DB_PASS", + "reference": "op://Engineering/Postgres/prod/password", + "tokenSecret": "op-token", + "source": {"type": "vault", "name": "prod-db"}, + }, +] + + +def test_bindings_describe_stored_totp_and_1password_variables(): + stored, totp, linked = [SandboxResolvedSecret.model_validate(b) for b in BINDINGS] + assert stored.source is not None and stored.source.name == "github-login" + assert stored.secret_type is None and stored.overrides == [] + assert totp.secret_type == "totp" + assert totp.source is not None and totp.source.type == "secrets" + assert [(o.type, o.name) for o in totp.overrides] == [("vault", "github-login")] + assert (linked.name, linked.secret_id, linked.version) == (None, None, None) + assert linked.reference == "op://Engineering/Postgres/prod/password" + assert linked.token_secret == "op-token" + + +def test_sandbox_create_sends_a_vault_and_keeps_the_startup_bindings(): + params = CreateSandboxParams( + image_name="node", vault="github-login", secrets={"OTP": "github-otp"} + ) + assert params.model_dump(by_alias=True, exclude_none=True) == { + "imageName": "node", + "vault": "github-login", + "secrets": {"OTP": {"name": "github-otp"}}, + } + with pytest.raises(ValueError, match="only supported for image launches"): + CreateSandboxParams(snapshot_name="snap", vault="github-login") + with pytest.raises(ValueError): + CreateSandboxParams(image_name="node", vault="bad name") + + manager, http = _sandbox_manager( + { + ("POST", "/sandbox"): ( + 200, + dict(SANDBOX_DETAIL_PAYLOAD, startupSecrets=BINDINGS), + ), + ("GET", "/sandbox/sbx_123"): (200, SANDBOX_DETAIL_PAYLOAD), + } + ) + sandbox = manager.create({"image_name": "node", "vault": "github-login"}) + assert http.calls[0]["json"] == {"imageName": "node", "vault": "github-login"} + assert sandbox.startup_secrets is not None + assert [b.env for b in sandbox.startup_secrets] == ["USERNAME", "OTP", "DB_PASS"] + sandbox.refresh() + assert sandbox.startup_secrets is not None + + +@pytest.mark.anyio +async def test_async_sandbox_create_with_a_vault_is_an_unknown_outcome_on_timeout(): + import httpx + + manager, http = _sandbox_manager( + {("POST", "/sandbox"): httpx.ReadTimeout("timed out")}, async_client=True + ) + with pytest.raises(HyperbrowserError) as error: + await manager.create({"image_name": "node", "vault": "github-login"}) + assert error.value.code == "secret_process_outcome_unknown" + assert error.value.retryable is False + + +def test_process_starts_with_a_vault_go_through_the_control_api(): + starts = [] + + def start(payload): + starts.append(payload) + return {"process": AGENT_PROCESS, "secrets": BINDINGS} + + api = SandboxProcessesApi(RuntimeScript(), start, "sbx_123") + + handle = api.start("node app.js", vault="github-login") + assert starts[-1] == {"command": "node app.js", "vault": "github-login"} + assert handle.secrets is not None + assert handle.secrets[2].reference == "op://Engineering/Postgres/prod/password" + + api.start( + {"command": "node app.js", "vault": "github-login"}, + secrets={"OTP": "github-otp"}, + ) + assert starts[-1] == { + "command": "node app.js", + "secrets": {"OTP": {"name": "github-otp"}}, + "vault": "github-login", + } + + with pytest.raises(ValueError): + api.start("node app.js", vault="bad name") + with pytest.raises(HyperbrowserError, match="use_shell"): + api.start({"command": "x", "use_shell": True}, vault="github-login") + assert len(starts) == 2 + + +@pytest.mark.anyio +async def test_async_sandbox_handles_route_vault_starts_to_the_control_api(): + manager, http = _sandbox_manager( + { + ("POST", "/sandbox/sbx_123/processes"): ( + 200, + {"process": AGENT_PROCESS, "secrets": BINDINGS}, + ) + }, + async_client=True, + ) + sandbox = manager.attach(SandboxDetail(**SANDBOX_DETAIL_PAYLOAD)) + + handle = await sandbox.processes.start("node app.js", vault="prod-db") + + assert http.calls[0]["json"] == {"command": "node app.js", "vault": "prod-db"} + assert handle.secrets is not None and handle.secrets[1].secret_type == "totp" diff --git a/tests/typecheck/invalid_requests.py b/tests/typecheck/invalid_requests.py index 6b683169..1e4bb746 100644 --- a/tests/typecheck/invalid_requests.py +++ b/tests/typecheck/invalid_requests.py @@ -119,17 +119,22 @@ def invalid_web_bot_auth_and_proxy_routes_requests(client: Hyperbrowser) -> None def invalid_credential_requests(client: Hyperbrowser) -> None: - client.secrets.create({"name": "github", "type": "password"}) # M,P - client.secrets.create({"name": "github", "value": {"username": 1}}) # M,P + client.secrets.create({"name": "github", "type": "credential"}) # M,P + client.secrets.create({"name": "github", "value": {"username": "a"}}) # M,P client.secrets.list({"type": "login"}) # M,P client.secrets.update("github", {"metadata": {"browser": {"allowedOrigins": []}}}) # M,P - client.secrets.create_credential("github", "password", ["https://x.io"]) # M,P + client.secrets.create({"name": "github", "allowed_origins": "https://x.io"}) # M,P + client.vaults.create({"name": "v", "entries": {"A": {"version": 1}}}) # M,P + client.vaults.create({"name": "v", "source": {"type": "1password"}}) # M,P + client.vaults.update("v", {"source": {"type": "stored"}}) # M,P client.sessions.create({"credentials": "github"}) # M,P + client.sessions.create({"credentials": [{"login": "github"}]}) # M,P client.sessions.credentials.fill("s", {"credential": "github"}) # M,P - client.sessions.credentials.fill("s", {"credential": "c", "fields": [{"field": "u"}]}) # M,P + client.sessions.credentials.fill("s", {"vault": "v", "fields": [{"field": "U"}]}) # M,P client.sessions.credentials.fill("s", {"credential": "c", "fields": [], "pageId": "p"}) # M,P async def invalid_async_credential_requests(client: AsyncHyperbrowser) -> None: - await client.secrets.add_version("github", {"username": None}) # M,P + await client.secrets.add_version("github", {"username": "a"}) # M,P await client.sessions.credentials.fill("s", {"credential": "c", "fields": [], "frame": "u"}) # M,P + await client.vaults.create({"entries": {}}) # M,P diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index 3522a0ce..a1739db4 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -11,6 +11,7 @@ SandboxImageBuildListParams as LegacySandboxImageBuildListParams, VolumeListParams as LegacyVolumeListParams, CreateSecretParams as LegacyCreateSecretParams, + CreateVaultParams as LegacyCreateVaultParams, SecretReference as LegacySecretReference, ScrollActionParams as LegacyScrollActionParams, ScrollAtCursorActionParams as LegacyScrollAtCursorActionParams, @@ -587,77 +588,158 @@ async def valid_async_web_bot_auth_and_proxy_routes_requests( def valid_credential_requests(client: Hyperbrowser) -> None: from hyperbrowser.models import ( CredentialFillField, - CredentialFillFrame, FillCredentialParams, + OnePasswordEntry, + OnePasswordVaultSource, SecretMetadata, + StoredEntry, UpdateSecretParams, + UpdateVaultParams, + VaultStoredEntry, ) - secret = client.secrets.create_credential( - "github", - {"username": "ada", "password": "pw"}, - ["https://github.com"], - description="GitHub login", + secret = client.secrets.create( + { + "name": "github-password", + "value": "pw", + "allowed_origins": ["https://github.com"], + "description": "GitHub password", + } ) kind: str = secret.type origins = secret.metadata.browser.allowed_origins if secret.metadata.browser else [] client.secrets.create( { - "name": "github", - "type": "credential", + "name": "github-otp", + "type": "totp", "metadata": {"browser": {"allowed_origins": origins}}, - "value": {"username": "ada"}, + "value": "JBSWY3DPEHPK3PXP", } ) + client.secrets.create({"name": "op-token", "type": "op_token", "value": "ops_x"}) client.secrets.create( - LegacyCreateSecretParams(name="github", type="credential", value={"u": "a"}) + LegacyCreateSecretParams(name="github-otp", type="totp", value="JBSWY3DP") ) - client.secrets.add_version("github", {"username": "ada", "password": "pw2"}) - client.secrets.set_allowed_origins("github", ["https://github.com"]) - client.secrets.set_allowed_origins("github", None) - client.secrets.update("github", {"metadata": {"browser": None}}) + client.secrets.add_version("github-otp", "otpauth://totp/x?secret=JBSWY3DP") + client.secrets.set_allowed_origins("github-password", ["https://github.com"]) + client.secrets.set_allowed_origins("github-password", None) + client.secrets.update("github-password", {"metadata": {"browser": None}}) client.secrets.update( - "github", UpdateSecretParams(metadata=SecretMetadata(browser=None)) + "github-password", UpdateSecretParams(metadata=SecretMetadata(browser=None)) ) - client.secrets.list({"type": "credential"}) + client.secrets.list({"type": "totp"}) - session = client.sessions.create({"credentials": [kind]}) + vault = client.vaults.create( + { + "name": "github-login", + "entries": { + "USERNAME": {"name": "github-username"}, + "PASSWORD": {"name": "github-password", "version": 2}, + "OTP": {"name": "github-otp", "version": "latest"}, + }, + } + ) + for env, entry in vault.entries.items(): + if isinstance(entry, VaultStoredEntry): + print(env, entry.name, entry.secret_id, entry.version) + else: + print(env, entry.item, entry.section, entry.field, entry.reference) + source_type: str = vault.source.type + client.vaults.create( + { + "name": "prod-db", + "source": { + "type": "1password", + "token_secret": "op-token", + "vault": "Engineering", + }, + "entries": { + "DB_PASS": {"item": "Postgres", "section": "prod", "field": "password"} + }, + } + ) + client.vaults.create( + LegacyCreateVaultParams( + name="prod-db", + source=OnePasswordVaultSource( + type="1password", token_secret="op-token", vault="Eng" + ), + entries={"DB": OnePasswordEntry(item="Postgres", field="password")}, + ) + ) + client.vaults.update("github-login", {"entries": {"OTP": None}}) + client.vaults.update("prod-db", {"source": {"vault": "Platform"}}) + client.vaults.update( + "github-login", + UpdateVaultParams(entries={"OTP": StoredEntry(name="github-otp"), "X": None}), + ) + listing = client.vaults.list({"search": "git", "limit": 10}) + print(listing.total_count, client.vaults.get("github-login").id, source_type) + client.vaults.delete("github-login") + + sandbox = client.sandboxes.create( + {"image_name": "node", "vault": "github-login", "secrets": {"A": "b"}} + ) + print(sandbox.startup_secrets) + handle = sandbox.processes.start("node app.js", vault="prod-db") + for binding in handle.secrets or []: + print(binding.env, binding.name, binding.reference, binding.token_secret) + sandbox.exec("node app.js", vault="prod-db", secrets={"A": "b"}) + + session = client.sessions.create( + {"credentials": [kind, {"secret": "github-otp"}, {"vault": "github-login"}]} + ) for attached in session.credentials or []: print(attached.id, attached.name) - client.sessions.create(LegacyCreateSessionParams(credentials=["github"])) + client.sessions.create(LegacyCreateSessionParams(credentials=["github-password"])) result = client.sessions.credentials.fill( session.id, { - "credential": "github", - "fields": [{"field": "username", "selector": "#login"}], - "page_id": "PAGE", - "frame": {"url": "https://login.github.com/"}, + "vault": "github-login", + "fields": [ + {"field": "USERNAME", "selector": "#login"}, + {"field": "OTP", "selector": "#otp"}, + ], + "page_url": "https://github.com/login", + "frame_url": "https://github.com/", }, ) - outcomes = [(field.field, field.outcome) for field in result.fields] + outcomes = [ + (field.selector, field.field, field.outcome, field.expires_at) + for field in result.fields + ] print(result.version, result.page_id, result.origin, outcomes) client.sessions.credentials.fill( session.id, FillCredentialParams( - credential="github", - fields=[CredentialFillField(field="password", selector="#pw")], - frame=CredentialFillFrame(url="https://login.github.com/"), + credential="github-password", + fields=[CredentialFillField(selector="#pw")], + page_id="PAGE", ), ) - text: str = CredentialFillTool.runnable(client, session.id, {"credential": "x"}) + text: str = CredentialFillTool.runnable(client, session.id, {"vault": "x"}) print(text, CredentialFillTool.gemini_tool_definition["name"]) async def valid_async_credential_requests(client: AsyncHyperbrowser) -> None: - await client.secrets.create_credential("github", {"u": "a"}, ["https://x.io"]) - await client.secrets.set_allowed_origins("github", None) - await client.secrets.add_version("github", {"username": "ada"}) - session = await client.sessions.create({"credentials": ["github"]}) + await client.secrets.create( + {"name": "github-otp", "type": "totp", "value": "JBSWY3DP"} + ) + await client.secrets.set_allowed_origins("github-otp", None) + await client.secrets.add_version("github-otp", "JBSWY3DP") + vault = await client.vaults.create( + {"name": "github-login", "entries": {"OTP": {"name": "github-otp"}}} + ) + await client.vaults.update(vault.id, {"entries": {"OTP": None}}) + await client.vaults.delete(vault.name) + session = await client.sessions.create({"credentials": [{"vault": "github-login"}]}) result = await client.sessions.credentials.fill( session.id, - {"credential": "github", "fields": [{"field": "u", "selector": "#u"}]}, + {"vault": "github-login", "fields": [{"field": "OTP", "selector": "#otp"}]}, ) print(result.fields) + sandbox = await client.sandboxes.create({"image_name": "node", "vault": "v"}) + await sandbox.processes.start("node app.js", vault="v") text: str = await CredentialFillTool.async_runnable( client, session.id, {"credential": "x"} ) From 3d5ca75e08af87a8ff721c3926edb344a9bb8589 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:39:13 -0700 Subject: [PATCH 10/23] Document vaults, TOTP and 1Password secrets, and fill by vault Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- CLAUDE.md | 2 + README.md | 226 +++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 166 insertions(+), 62 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed724410..13c9acd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,8 @@ client.profiles # Browser profile management client.extensions # Browser extension management client.team # Team/credit info client.computer_action # Low-level computer actions +client.secrets # Encrypted secrets (secret, totp, op_token) +client.vaults # Named sets of secrets or 1Password fields ``` Each manager lives in `client/managers/sync_manager/` or `client/managers/async_manager/` with identical method signatures. diff --git a/README.md b/README.md index 1596997a..98283fc8 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,10 @@ and cannot be combined with `allow_internet_access=False`, `allow_out`, or ## Fill credentials into browser sessions -A credential is a secret of type `"credential"` that holds a website login, -such as a username and password. Its value is encrypted on your machine like -any other secret value, and the platform types it into pages of a browser -session; after upload, neither the API, your code, nor an LLM sees it again. +Any secret with allowed origins can be typed into pages of a browser session. +A website login is usually two such secrets grouped in a vault, plus a TOTP +secret when the site asks for a one-time code. Values are encrypted on your +machine; after upload, neither the API, your code, nor an LLM sees them again. ```python from playwright.sync_api import sync_playwright @@ -123,77 +123,114 @@ from hyperbrowser import Hyperbrowser client = Hyperbrowser(api_key="test-key") -# Field names (here "username" and "password") are what a fill refers to. -client.secrets.create_credential( - "github", - {"username": "ada@example.com", "password": "correct horse battery staple"}, - allowed_origins=["https://github.com"], - description="GitHub login", +origins = ["https://github.com"] +client.secrets.create( + {"name": "github-username", "value": "ada@example.com", "allowed_origins": origins} +) +client.secrets.create( + {"name": "github-password", "value": "correct horse", "allowed_origins": origins} +) +# A TOTP seed: base32 as the site shows it, or an otpauth://totp/ URI. +client.secrets.create( + { + "name": "github-otp", + "type": "totp", + "value": "JBSW Y3DP EHPK 3PXP JBSW Y3DP EHPK 3PXP", + "allowed_origins": origins, + } +) +client.vaults.create( + { + "name": "github-login", + "entries": { + "USERNAME": {"name": "github-username"}, + "PASSWORD": {"name": "github-password"}, + "OTP": {"name": "github-otp"}, + }, + } ) -# Credentials can only be attached when the session is created. -session = client.sessions.create({"credentials": ["github"]}) +# Secrets and vaults can only be attached when the session is created. +session = client.sessions.create({"credentials": [{"vault": "github-login"}]}) with sync_playwright() as p: browser = p.chromium.connect_over_cdp(session.ws_endpoint) page = browser.contexts[0].pages[0] page.goto("https://github.com/login") + # With a vault, each field names the entry to type. result = client.sessions.credentials.fill( session.id, { - "credential": "github", + "vault": "github-login", "fields": [ - {"field": "username", "selector": "#login_field"}, - {"field": "password", "selector": "#password"}, + {"field": "USERNAME", "selector": "#login_field"}, + {"field": "PASSWORD", "selector": "#password"}, ], }, ) if all(field.outcome == "filled" for field in result.fields): - # Fill only types. Submitting is up to you. - page.click("input[type=submit]") + page.click("input[type=submit]") # Fill only types; submitting is up to you. + + page.wait_for_selector("#app_totp") + # A TOTP entry types the code that is current at fill time. + client.sessions.credentials.fill( + session.id, + {"vault": "github-login", "fields": [{"field": "OTP", "selector": "#app_totp"}]}, + ) client.sessions.stop(session.id) ``` -`fill()` returns the filled page's `page_id` and `origin`, the credential -`version`, and an outcome per field. Values are never returned. +A single secret works without a vault: attach it by name (`"credentials": +["github-password"]` or `[{"secret": "github-password"}]`) and fill it with +`{"credential": "github-password", "fields": [{"selector": "#password"}]}`. + +`fill()` returns the filled page's `page_id` and `origin`, the `version` for a +single-secret fill, and per input its `selector`, `outcome`, `secret_id`, +`version`, `field` (vault fills) and `expires_at` (TOTP codes). Values are +never returned. | Outcome | Meaning | | --- | --- | -| `filled` | The input now holds exactly the stored value. | +| `filled` | The input now holds the value. | | `not_found` | No input matched the selector. | | `ambiguous` | Several visible inputs matched the selector. | | `not_editable` | The input is disabled, read-only, hidden, or not a text input. | | `not_accepted` | The page didn't keep the value, for example because of `maxlength`. | -| `not_populated` | The stored credential has no such field. | Things to know: -- A credential can be filled only on its allowed origins - (`scheme://host[:port]`; `https://*.example.com` matches any subdomain but - not `example.com` itself). Change them with - `client.secrets.set_allowed_origins("github", [...])`; `None` clears them, - after which the credential can't be filled. `client.secrets.update()` takes - `{"metadata": {"browser": None}}` too: each metadata member sent replaces the - stored one and `None` removes it. -- Credential values map 1-64 character field names (letters, digits, `_`, `.` - and `-`) to non-empty strings, up to 32 KiB of JSON in total. They are - checked before anything is sent; rotate one with - `client.secrets.add_version("github", {...})`. `client.secrets.list({"type": - "credential"})` lists only credentials. -- A fill takes 1-16 fields, each at most once. Without `page_id` it picks the - only tab, else the single tab on an allowed origin, else the single visible - tab. Under Playwright every tab reports as visible, so with several tabs on - allowed origins pass the tab's CDP target ID as `page_id`: +- A secret can be filled only on its allowed origins (`scheme://host[:port]`; + `https://*.example.com` matches any subdomain but not `example.com` itself). + Change them with `client.secrets.set_allowed_origins("github-password", + [...])`; `None` clears them, after which it can't be filled. + `client.secrets.update()` takes `{"metadata": {"browser": None}}` too: each + metadata member sent replaces the stored one and `None` removes it. +- Secret types are `secret` (the default), `totp` and `op_token`, fixed at + creation; `client.secrets.list({"type": "totp"})` filters by type. TOTP + values are checked locally before upload: a base32 seed (spaces, hyphens, + lowercase and trailing `=` are fine) or an `otpauth://totp/` URI with + `algorithm` SHA1/SHA256/SHA512, `digits` 6 or 8 and `period` 1-300. Seeds + shorter than 10 bytes (16 characters) are accepted but weak. The platform + can't read values, so a bad seed would otherwise only fail when used. A TOTP + secret delivered to a sandbox holds the code current at process start. +- A fill takes `credential` or `vault`, and 1-16 fields with unique + selectors; `field` is required with a vault and left out without one. + Without `page_id` or `page_url`, a session with one tab fills that tab (on + another site the fill is refused with `CREDENTIAL_ORIGIN_NOT_ALLOWED`). + With several tabs, those matching `page_url` and the allowed origins of the + secrets being filled are candidates: the only one, else the single visible + one, is filled. Under Playwright every tab reports as visible, so pass + `page_url` (a URL prefix) or the tab's CDP target ID as `page_id`: `page.context.new_cdp_session(page).send("Target.getTargetInfo")["targetInfo"]["targetId"]`. - For a login form in an iframe, pass `"frame": {"url": "https://login.example.com/"}` - (a URL prefix). + For a login form in an iframe, pass `frame_url` (a URL prefix). - Fill is safe to retry and never retried automatically. It waits at least 30 seconds for the API (the client timeout when longer). -- Attaching credentials needs an API key with `secrets.use`; filling also needs +- Attaching secrets needs an API key with `secrets.use`; filling also needs `browser.sessions.update`. The team needs the secrets feature. Sessions with credentials run as Firecracker browsers with Chrome's password manager off. + A vault attaches the secrets its entries hold when the session is created. - Fills are recorded in the session's event log as `credential_filled` and `credential_fill_refused`. - Errors are raised as `HyperbrowserError` with the API's `code`, `retryable`, @@ -201,34 +238,40 @@ Things to know: candidate tabs of an ambiguous fill are in `error.details["details"]["pages"]` (each with `pageId`, `url` and `title`). Error codes from the API: - `CREDENTIAL_PAGE_AMBIGUOUS` (409): several tabs could be the target; pass - one's `pageId` as `page_id`. `CREDENTIAL_FRAME_AMBIGUOUS` (409) is the same - for frames. + one's `pageId` as `page_id`, or a `page_url`. `CREDENTIAL_FRAME_AMBIGUOUS` + (409) is the same for frames. - `CREDENTIAL_ORIGIN_NOT_ALLOWED` (403): the page's origin (in `details`) isn't allowed; nothing was typed. - - `CREDENTIAL_NOT_ATTACHED` (403): the credential wasn't attached when the - session was created. - - `CREDENTIAL_NOT_READY` (409): the credential has no allowed origins or no + - `CREDENTIAL_NOT_ATTACHED` (403): the secret wasn't attached when the + session was created, or a vault entry now points at another secret. + - `CREDENTIAL_NOT_READY` (409): the secret has no allowed origins or no value. - - `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND` (404), - `SECRET_NOT_FOUND` (404), `SECRET_TYPE_MISMATCH` (409, a plain secret was - named), `SESSION_NOT_ACTIVE` (409). + - `SECRET_TYPE_MISMATCH` (409): an `op_token` can't be filled. + `VAULT_SOURCE_INVALID` (400): a 1Password vault can't be filled. + - `SECRET_VALUE_INVALID` (422): the stored value is unusable, such as a bad + TOTP seed; add a new version. + - `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND`, + `SECRET_NOT_FOUND`, `SECRET_VERSION_NOT_FOUND`, `VAULT_NOT_FOUND`, + `VAULT_ENTRY_NOT_FOUND` (404), `SESSION_NOT_ACTIVE` (409). - `CREDENTIAL_FILL_TIMEOUT` (503/504, retryable) and `CREDENTIAL_FILL_FAILED` (500/502, retryable as flagged). - - `CREDENTIALS_UNSUPPORTED_RUNTIME` (400) and `CREDENTIAL_FILL_UNSUPPORTED` - (501): the session's browser can't fill credentials. + - `CREDENTIALS_UNSUPPORTED_RUNTIME` (400), `CREDENTIAL_FILL_UNSUPPORTED` + (501) and `CREDENTIAL_VALUE_UNSUPPORTED` (501, such as TOTP on an older + host): the session's browser can't fill this. - Locally, invalid fill requests raise `invalid_request` and invalid values raise `invalid_secret_value`, before any request. ### Use with LLM SDKs -`CredentialFillTool` lets a model fill a credential by name while your -application binds the session ID. The model chooses only the credential, the -fields with their CSS selectors, and optionally a `page_id` or `frame_url`; it -never sees the values. `runnable()` returns JSON text for the model: the fill -result, or `{"error": {"code", "message", "retryable", "details"}}` when the -fill fails, so the model can react (for example by choosing a `page_id`). -Give the model your other browser tools too, since it must submit the form -itself. `async_runnable()` does the same with `AsyncHyperbrowser`. +`CredentialFillTool` lets a model fill a secret or vault by name while your +application binds the session ID. The model chooses only `credential` or +`vault`, the fields (`selector`, and `field` with a vault), and optionally +`page_id`, `page_url` or `frame_url`; it never sees the values. `runnable()` +returns JSON text for the model: the fill result, or `{"error": {"code", +"message", "retryable", "details"}}` when the fill fails, so the model can +react (for example by choosing a `page_id`). Give the model your other +browser tools too, since it must submit the form itself. `async_runnable()` +does the same with `AsyncHyperbrowser`. OpenAI (Chat Completions): @@ -241,7 +284,7 @@ from hyperbrowser.tools import CredentialFillTool openai_client = OpenAI() tools = [CredentialFillTool.openai_tool_definition] # plus your browser tools -messages = [{"role": "user", "content": "Sign in to GitHub with the credential named github."}] +messages = [{"role": "user", "content": "Sign in to GitHub with the vault named github-login."}] while True: response = openai_client.chat.completions.create( model="gpt-5", messages=messages, tools=tools @@ -269,7 +312,7 @@ from hyperbrowser.tools import CredentialFillTool anthropic_client = anthropic.Anthropic() tools = [CredentialFillTool.anthropic_tool_definition] # plus your browser tools -messages = [{"role": "user", "content": "Sign in to GitHub with the credential named github."}] +messages = [{"role": "user", "content": "Sign in to GitHub with the vault named github-login."}] while True: response = anthropic_client.messages.create( model="claude-sonnet-5-5", max_tokens=1024, tools=tools, messages=messages @@ -306,7 +349,7 @@ config = types.GenerateContentConfig( contents = [ types.Content( role="user", - parts=[types.Part.from_text(text="Sign in to GitHub with the credential named github.")], + parts=[types.Part.from_text(text="Sign in to GitHub with the vault named github-login.")], ) ] while True: @@ -556,27 +599,58 @@ 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"}) +# A vault groups variables; sandboxes and processes take it by name. +client.vaults.create( + {"name": "app-env", "entries": {"API_KEY": {"name": "provider-key"}}} +) + sandbox = client.sandboxes.create( { "image_name": "node", # Delivered to the image's startup command (image launches only). - "secrets": {"API_KEY": "provider-key"}, + "vault": "app-env", } ) +print(sandbox.startup_secrets) # what each variable received, never values # A plain name uses the latest version; {"name": ..., "version": N} pins one. +# `secrets` are merged over the vault's entries and win on a clash. result = sandbox.exec( "node app.js", + vault="app-env", 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.vaults.delete("app-env") client.secrets.delete_version("provider-key", 1) client.secrets.delete("provider-key") client.close() ``` +A vault can take its values from 1Password instead. Store a 1Password +service account token (it starts with `ops_`) as an `op_token` secret: it is +only used to sign in to 1Password, never set as a variable or typed into a +browser, and can't have allowed origins. + +```python +client.secrets.create( + {"name": "op-token", "type": "op_token", "value": Path("op-token.txt").read_text().strip()} +) +client.vaults.create( + { + "name": "prod-db", + "source": {"type": "1password", "token_secret": "op-token", "vault": "Engineering"}, + # Items, sections and fields by name or ID: op://Engineering/Postgres/prod/password + "entries": {"DB_PASS": {"item": "Postgres", "section": "prod", "field": "password"}}, + } +) +sandbox.exec("psql -c 'select 1'", vault="prod-db") +# An entry set to None is removed; entries not given are kept. +client.vaults.update("prod-db", {"entries": {"DB_PASS": None}}) +``` + Things to know: - A secret is set only in the environment of the process it was passed to. @@ -617,6 +691,13 @@ Things to know: 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. +- Vaults hold up to 32 entries, named like environment variables; a team has + up to 100. A vault's source is fixed at creation; `update()` changes a + 1Password vault's `token_secret` or `vault` with `{"source": {...}}`. + Methods that take a vault accept its ID or exact name, and vaults need the + same scopes as secrets. A vault and + `secrets` together deliver at most 32 variables, and a process's `env` may + not set one of them. - 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 @@ -628,6 +709,27 @@ Things to know: - `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()`. + - `VAULT_NOT_FOUND` (404), `VAULT_NAME_TAKEN`, `VAULT_LIMIT_REACHED`, + `VAULT_ENTRY_LIMIT_REACHED` (409), `VAULT_ENTRY_INVALID` (400, the + message starts with the variable), and `VAULT_SOURCE_INVALID` (400: an + entry of the wrong kind for the vault's source, or a source change on a + stored vault). + - `SECRET_IN_USE` (409): a vault uses the secret, as an entry or as its + 1Password token; `SECRET_VERSION_IN_USE` (409): a vault pins the version. + - `SECRET_TYPE_MISMATCH` (409): an `op_token` used as a variable or vault + entry, or another type used as a 1Password vault's token. + - `SECRET_SOURCES_INVALID` (400): for example `env` sets a variable that + `vault` or `secrets` also sets. + - `SECRET_VALUE_INVALID` (422): a stored value is unusable, such as a bad + TOTP seed; fix it by adding a new version. + - `SECRET_TOTP_UNSUPPORTED` and `SECRET_ONEPASSWORD_UNSUPPORTED` (503): the + sandbox's host predates TOTP or 1Password, so nothing started. Retryable + on sandbox create, which may land on a newer host; not on a process start. + - `SECRET_ONEPASSWORD_REFERENCE_FAILED` (422, `details` names the + variables) and `SECRET_ONEPASSWORD_AUTH_FAILED` (422): fix the entries or + the token. `SECRET_ONEPASSWORD_RATE_LIMITED` (429) and + `SECRET_ONEPASSWORD_FAILED` (502/503) are retryable. + - `retryable` is the API's flag when it sends one. ### List sandboxes with filters From 166d5a7ccefda53796345b2973c72eac2c57401f Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:42:47 -0700 Subject: [PATCH 11/23] Name VAULT_ENTRY_INVALID for entries of the wrong kind in the README Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 98283fc8..b2f6fa25 100644 --- a/README.md +++ b/README.md @@ -710,10 +710,10 @@ Things to know: deliver `secrets` to. Create the sandbox without them, then pass them to `sandbox.exec()` or `sandbox.processes.start()`. - `VAULT_NOT_FOUND` (404), `VAULT_NAME_TAKEN`, `VAULT_LIMIT_REACHED`, - `VAULT_ENTRY_LIMIT_REACHED` (409), `VAULT_ENTRY_INVALID` (400, the - message starts with the variable), and `VAULT_SOURCE_INVALID` (400: an - entry of the wrong kind for the vault's source, or a source change on a - stored vault). + `VAULT_ENTRY_LIMIT_REACHED` (409), `VAULT_ENTRY_INVALID` (400: a bad + entry, including one of the wrong kind for the vault's source; the message + starts with the variable), and `VAULT_SOURCE_INVALID` (400: a source change + on a stored vault, or a 1Password vault used in a browser session). - `SECRET_IN_USE` (409): a vault uses the secret, as an entry or as its 1Password token; `SECRET_VERSION_IN_USE` (409): a vault pins the version. - `SECRET_TYPE_MISMATCH` (409): an `op_token` used as a variable or vault From a8abb085f247137ba93ce6040e4ee73c69cb85bd Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 06:03:02 -0700 Subject: [PATCH 12/23] Read TOTP seeds as vm-service does, and refuse non-object tool input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip one leading BOM, then Go's whitespace; uppercase per character so ß and ff don't become base32; refuse stray % outside the query, trailing newlines in digits or period, and malformed userinfo; accept userinfo before totp as Go does; read query pairs as Go's ParseQuery. The fill tool answers input that isn't an object with an invalid_request error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- hyperbrowser/secrets_common.py | 78 ++++++++++++++++++++++++---------- hyperbrowser/tools/__init__.py | 5 ++- tests/test_credential_tools.py | 4 ++ tests/test_credentials.py | 21 +++++++++ 4 files changed, 84 insertions(+), 24 deletions(-) diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index 76ebc2c0..f58cb5a6 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -13,7 +13,7 @@ import time from dataclasses import dataclass from typing import Any, Dict, Optional, Sequence, Type, Union -from urllib.parse import parse_qs, urlsplit +from urllib.parse import unquote_plus, urlsplit import httpx from cryptography.hazmat.primitives import hashes @@ -100,10 +100,16 @@ def _invalid_value(message: str) -> HyperbrowserError: TOTP_ALGORITHMS = ("SHA1", "SHA256", "SHA512") TOTP_DIGITS = (6, 8) MAX_TOTP_PERIOD_SECONDS = 300 -_BASE32_PATTERN = re.compile(r"^[A-Z2-7]+$") -_ASCII_INTEGER_PATTERN = re.compile(r"^[+-]?[0-9]+$") +_BASE32_PATTERN = re.compile(r"[A-Z2-7]+") +_ASCII_INTEGER_PATTERN = re.compile(r"[+-]?[0-9]+") _URI_CONTROL_PATTERN = re.compile(r"[\x00-\x1f\x7f]") _SCHEME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://") +# A "%" that doesn't start an escape, which Go's url.Parse refuses. +_BAD_ESCAPE_PATTERN = re.compile(r"%(?![0-9A-Fa-f]{2})") +# What Go's url.Parse allows in userinfo besides letters and digits. +_USERINFO_PATTERN = re.compile(r"[A-Za-z0-9\-._:~!$&'()*+,;=%@]*") +# Go's unicode.IsSpace, which vm-service trims seeds with. +_GO_SPACE = "\t\n\v\f\r \x85\xa0        " "    

   " def _invalid_totp(reason: str) -> HyperbrowserError: @@ -116,11 +122,16 @@ def _invalid_totp(reason: str) -> HyperbrowserError: def _check_totp_base32(seed: str) -> None: # Spaces, hyphens, lowercase and trailing "=" padding are accepted, as - # sites show seeds in all of these forms. - normalized = seed.replace(" ", "").replace("-", "").upper().rstrip("=") + # sites show seeds in all of these forms. Characters are uppercased one + # at a time, as Go does, so "ß" can't turn into "SS". + normalized = "".join( + upper if len(upper) == 1 else char + for char in seed.replace(" ", "").replace("-", "") + for upper in [char.upper()] + ).rstrip("=") if not normalized: raise _invalid_totp("the seed is empty") - if not _BASE32_PATTERN.match(normalized): + if not _BASE32_PATTERN.fullmatch(normalized): raise _invalid_totp("the seed is not base32") # Lengths of 1, 3 or 6 (mod 8) leave characters that don't make a whole # byte; the platform would drop them and generate wrong codes, so the @@ -131,35 +142,56 @@ def _check_totp_base32(seed: str) -> None: ) +def _query_params(query: str) -> Dict[str, str]: + """The first value of each query parameter, as Go's url.ParseQuery reads + them: pairs with a ";" or an invalid escape are skipped.""" + params: Dict[str, str] = {} + for pair in query.split("&"): + if not pair or ";" in pair: + continue + key, _, value = pair.partition("=") + if _BAD_ESCAPE_PATTERN.search(key) or _BAD_ESCAPE_PATTERN.search(value): + continue + params.setdefault(unquote_plus(key), unquote_plus(value)) + return params + + def _totp_uri_seed(uri: str) -> str: if _URI_CONTROL_PATTERN.search(uri): raise _invalid_totp("the URI contains control characters") - parsed = urlsplit(uri) - host = parsed.netloc.lower() - if host == "hotp": + try: + parsed = urlsplit(uri) + except ValueError: + raise _invalid_totp("the URI is malformed") from None + userinfo, at, host = parsed.netloc.rpartition("@") + if ( + _BAD_ESCAPE_PATTERN.search(parsed.path) + or _BAD_ESCAPE_PATTERN.search(parsed.fragment) + or (at and not _USERINFO_PATTERN.fullmatch(userinfo)) + or _BAD_ESCAPE_PATTERN.search(userinfo) + ): + raise _invalid_totp("the URI is malformed") + if host.lower() == "hotp": raise _invalid_totp("HOTP (counter-based) codes are not supported") - if host != "totp": + if host.lower() != "totp": raise _invalid_totp("the URI must start with otpauth://totp/") - query = parse_qs(parsed.query, keep_blank_values=True) - - def param(name: str) -> str: - return query.get(name, [""])[0] + query = _query_params(parsed.query) - algorithm = param("algorithm") + algorithm = query.get("algorithm", "") if algorithm and algorithm.upper() not in TOTP_ALGORITHMS: raise _invalid_totp("algorithm must be SHA1, SHA256 or SHA512") - digits = param("digits") + digits = query.get("digits", "") if digits and ( - not _ASCII_INTEGER_PATTERN.match(digits) or int(digits) not in TOTP_DIGITS + not _ASCII_INTEGER_PATTERN.fullmatch(digits) or int(digits) not in TOTP_DIGITS ): raise _invalid_totp("digits must be 6 or 8") - period = param("period") + period = query.get("period", "") if period and ( - not _ASCII_INTEGER_PATTERN.match(period) + not _ASCII_INTEGER_PATTERN.fullmatch(period) or not 1 <= int(period) <= MAX_TOTP_PERIOD_SECONDS ): raise _invalid_totp("period must be 1 to 300 seconds") - seed = param("secret") + seed = query.get("secret", "") if not seed: raise _invalid_totp("the URI has no secret parameter") return seed @@ -174,9 +206,9 @@ def check_totp_seed(value: str) -> None: characters) are accepted, though they are weaker than sites usually issue. Errors never include the value. """ - text = value.strip() - if text.startswith("\ufeff"): - text = text[1:].strip() + # As vm-service: one leading byte-order mark, then surrounding spaces. + text = value[1:] if value.startswith("") else value + text = text.strip(_GO_SPACE) if text.lower().startswith("otpauth://"): text = _totp_uri_seed(text) elif _SCHEME_PATTERN.match(text): diff --git a/hyperbrowser/tools/__init__.py b/hyperbrowser/tools/__init__.py index 6ef0fb2f..1aca5e5f 100644 --- a/hyperbrowser/tools/__init__.py +++ b/hyperbrowser/tools/__init__.py @@ -156,8 +156,11 @@ def _present(value: Any) -> bool: return value is not None and value != "" -def _fill_credential_request(params: Mapping[str, Any]) -> Dict[str, Any]: +def _fill_credential_request(params: Any) -> Dict[str, Any]: """Map the model's tool input to a fill request; null means absent.""" + if not isinstance(params, Mapping): + # Fails validation, so the model gets an invalid_request error. + params = {} request: Dict[str, Any] = {} for key in ("credential", "vault"): if _present(params.get(key)): diff --git a/tests/test_credential_tools.py b/tests/test_credential_tools.py index 5743c031..3f87f89a 100644 --- a/tests/test_credential_tools.py +++ b/tests/test_credential_tools.py @@ -240,6 +240,10 @@ def test_tool_runner_returns_errors_as_json(sdk): error = json.loads(call(runner, client, "s", bad)) assert error["error"]["code"] == "invalid_request" assert error["error"]["details"] is None + # Input that isn't an object, as a model may send, is an error too. + for bad in [None, "fill it", ["github-login"]]: + error = json.loads(call(runner, client, "s", bad)) + assert error["error"]["code"] == "invalid_request" assert len(calls) == 1 diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 0e900ea5..7888818a 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -186,6 +186,14 @@ def test_totp_secrets_seal_the_seed_under_the_returned_context(private_key): f"otpauth://totp/GitHub:ada?secret={SEED}&issuer=GitHub", f"OTPAUTH://TOTP/x?secret={SEED}&algorithm=sha512&digits=8&period=300", f"otpauth://totp/x?secret={SEED}&algorithm=SHA256&digits=6&period=1", + # Accepted by vm-service's parser (Go's url.Parse), so accepted here too. + f"otpauth://user@totp/x?secret={SEED}", + f"otpauth://@totp/x?secret={SEED}", + f"otpauth://totp/Acme%20Corp:alice%40x.com?secret={SEED}", + f"otpauth://totp/x?issuer=50%off&secret={SEED}", + f"otpauth://totp/x?secret={SEED}&digits=6%zz", + "\ufeff\u3000" + SEED + "\u00a0\u0085", + "\u0131" + SEED[1:], ] BAD_SEEDS = [ ("", "empty"), @@ -212,6 +220,19 @@ def test_totp_secrets_seal_the_seed_under_the_returned_context(private_key): (f"otpauth://totp/x?secret={SEED}&period=0", "period"), (f"otpauth://totp/x?secret={SEED}&period=301", "period"), (f"otpauth://totp/x?secret={SEED}&period=1_0", "period"), + (f"otpauth://totp/x?secret={SEED}&digits=6%0A", "digits"), + (f"otpauth://totp/x?secret={SEED}&period=30%0A", "period"), + (f"otpauth://totp/50%off:alice?secret={SEED}", "malformed"), + (f"otpauth://totp/x%2?secret={SEED}", "malformed"), + (f"otpauth://totp/x?secret={SEED}#50%off", "malformed"), + (f"otpauth://us er@totp/x?secret={SEED}", "malformed"), + (f"otpauth://totp@/x?secret={SEED}", "otpauth://totp/"), + (f"otpauth://totp:80/x?secret={SEED}", "otpauth://totp/"), + (" \ufeff" + SEED, "not base32"), + ("\x1c" + SEED, "not base32"), + (SEED[:-1] + "\u00df", "not base32"), + ("\ufb00AAAAAA", "not base32"), + ("JBSWY3DP\nEHPK3PXP", "not base32"), ] From 0718a89872d88963941cb80a24ccb640422eb0c2 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 11:05:02 -0700 Subject: [PATCH 13/23] Document SECRET_TOTP_UNSUPPORTED as 501 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 4 ++-- hyperbrowser/secrets_common.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b2f6fa25..421c5f7e 100644 --- a/README.md +++ b/README.md @@ -722,8 +722,8 @@ Things to know: `vault` or `secrets` also sets. - `SECRET_VALUE_INVALID` (422): a stored value is unusable, such as a bad TOTP seed; fix it by adding a new version. - - `SECRET_TOTP_UNSUPPORTED` and `SECRET_ONEPASSWORD_UNSUPPORTED` (503): the - sandbox's host predates TOTP or 1Password, so nothing started. Retryable + - `SECRET_TOTP_UNSUPPORTED` (501) and `SECRET_ONEPASSWORD_UNSUPPORTED` (503): + the sandbox's host predates TOTP or 1Password, so nothing started. Retryable on sandbox create, which may land on a newer host; not on a process start. - `SECRET_ONEPASSWORD_REFERENCE_FAILED` (422, `details` names the variables) and `SECRET_ONEPASSWORD_AUTH_FAILED` (422): fix the entries or diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index f58cb5a6..71711790 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -134,8 +134,8 @@ def _check_totp_base32(seed: str) -> None: if not _BASE32_PATTERN.fullmatch(normalized): raise _invalid_totp("the seed is not base32") # Lengths of 1, 3 or 6 (mod 8) leave characters that don't make a whole - # byte; the platform would drop them and generate wrong codes, so the - # seed was most likely copied incompletely. + # byte, so the seed was most likely copied incompletely. The platform + # refuses them too (older hosts dropped them and generated wrong codes). if len(normalized) % 8 in (1, 3, 6): raise _invalid_totp( "the seed's length is not valid base32; check that it was copied in full" From bc83f8ff29eccbaa536d4873962d519e2674e95b Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 11:09:28 -0700 Subject: [PATCH 14/23] Document SECRET_ONEPASSWORD_UNSUPPORTED as 501 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 421c5f7e..ce4c3454 100644 --- a/README.md +++ b/README.md @@ -722,8 +722,8 @@ Things to know: `vault` or `secrets` also sets. - `SECRET_VALUE_INVALID` (422): a stored value is unusable, such as a bad TOTP seed; fix it by adding a new version. - - `SECRET_TOTP_UNSUPPORTED` (501) and `SECRET_ONEPASSWORD_UNSUPPORTED` (503): - the sandbox's host predates TOTP or 1Password, so nothing started. Retryable + - `SECRET_TOTP_UNSUPPORTED` and `SECRET_ONEPASSWORD_UNSUPPORTED` (501): the + sandbox's host predates TOTP or 1Password, so nothing started. Retryable on sandbox create, which may land on a newer host; not on a process start. - `SECRET_ONEPASSWORD_REFERENCE_FAILED` (422, `details` names the variables) and `SECRET_ONEPASSWORD_AUTH_FAILED` (422): fix the entries or From 3b5b3fef232a9322fa4ffbb23a4494668b2a0ad1 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 11:22:38 -0700 Subject: [PATCH 15/23] Drop TOTP as the example of a value a host can't fill Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ce4c3454..0d825a52 100644 --- a/README.md +++ b/README.md @@ -256,8 +256,8 @@ Things to know: - `CREDENTIAL_FILL_TIMEOUT` (503/504, retryable) and `CREDENTIAL_FILL_FAILED` (500/502, retryable as flagged). - `CREDENTIALS_UNSUPPORTED_RUNTIME` (400), `CREDENTIAL_FILL_UNSUPPORTED` - (501) and `CREDENTIAL_VALUE_UNSUPPORTED` (501, such as TOTP on an older - host): the session's browser can't fill this. + (501) and `CREDENTIAL_VALUE_UNSUPPORTED` (501): the session's browser + can't fill this. - Locally, invalid fill requests raise `invalid_request` and invalid values raise `invalid_secret_value`, before any request. From b5bed001ce54dc0ab26d2ba7886a2ed26cdbff0f Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 11:44:46 -0700 Subject: [PATCH 16/23] Take 1Password fields per request instead of linking vaults browserctrl #1552 no longer links vaults to 1Password: vaults hold stored secrets only, and 1Password fields are named per request with onePassword on sandbox create and process start. - Vaults: drop source and the 1Password entry, source and token models. Entries are StoredEntry(name, version), reported as VaultEntry, and None in an update removes one. Create and update params refuse unknown members such as a leftover source; a source in a response is ignored. - Sandboxes: one_password=OnePasswordSource(token, vault, fields) on create (image launches), exec and start, sync and async, sent as onePassword. Token and env names, part rules (UTF-16 length, no '/', '?', Cc, Cf, Zl or Zp, not '.' or '..'), field queries, the 512-byte reference, 1-32 fields, strict members, and clashes with secrets or env are all checked before any request. A clash with the vault is left to the server. - Bindings document source type "onePassword" with reference and token_secret. - README: one_password examples and rules, stored-only vaults, and the error codes without VAULT_ENTRY_INVALID and VAULT_SOURCE_INVALID. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- CLAUDE.md | 2 +- README.md | 82 +++-- .../client/managers/async_manager/sandbox.py | 20 +- .../sandboxes/sandbox_processes.py | 15 +- .../client/managers/async_manager/vault.py | 14 +- .../client/managers/sandboxes/shared.py | 27 +- .../client/managers/sync_manager/sandbox.py | 20 +- .../sandboxes/sandbox_processes.py | 15 +- .../client/managers/sync_manager/vault.py | 14 +- hyperbrowser/models/__init__.py | 24 +- hyperbrowser/models/sandbox.py | 30 +- hyperbrowser/models/secret.py | 190 +++++++++- hyperbrowser/models/vault.py | 318 ++-------------- hyperbrowser/types/__init__.py | 16 +- hyperbrowser/types/sandbox.py | 4 +- hyperbrowser/types/secret.py | 22 +- hyperbrowser/types/vault.py | 53 +-- hyperbrowser/vaults_common.py | 30 +- tests/test_onepassword.py | 341 ++++++++++++++++++ tests/test_sandbox_wire_contract.py | 1 + tests/test_vaults.py | 188 +++------- tests/typecheck/invalid_requests.py | 6 +- tests/typecheck/valid_requests.py | 90 +++-- 23 files changed, 881 insertions(+), 641 deletions(-) create mode 100644 tests/test_onepassword.py diff --git a/CLAUDE.md b/CLAUDE.md index 13c9acd8..06f84cbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ client.extensions # Browser extension management client.team # Team/credit info client.computer_action # Low-level computer actions client.secrets # Encrypted secrets (secret, totp, op_token) -client.vaults # Named sets of secrets or 1Password fields +client.vaults # Named sets of stored secrets ``` Each manager lives in `client/managers/sync_manager/` or `client/managers/async_manager/` with identical method signatures. diff --git a/README.md b/README.md index 0d825a52..923f3de3 100644 --- a/README.md +++ b/README.md @@ -246,8 +246,7 @@ Things to know: session was created, or a vault entry now points at another secret. - `CREDENTIAL_NOT_READY` (409): the secret has no allowed origins or no value. - - `SECRET_TYPE_MISMATCH` (409): an `op_token` can't be filled. - `VAULT_SOURCE_INVALID` (400): a 1Password vault can't be filled. + - `SECRET_TYPE_MISMATCH` (409): an `op_token` can't be attached or filled. - `SECRET_VALUE_INVALID` (422): the stored value is unusable, such as a bad TOTP seed; add a new version. - `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND`, @@ -629,28 +628,46 @@ client.secrets.delete("provider-key") client.close() ``` -A vault can take its values from 1Password instead. Store a 1Password +Values can also come straight from a 1Password vault. Store a 1Password service account token (it starts with `ops_`) as an `op_token` secret: it is only used to sign in to 1Password, never set as a variable or typed into a -browser, and can't have allowed origins. +browser, and can't have allowed origins. Then name the fields each variable +takes with `one_password` when creating a sandbox or starting a process. +Nothing about them is stored: the sandbox reads the fields when the process +starts. ```python client.secrets.create( {"name": "op-token", "type": "op_token", "value": Path("op-token.txt").read_text().strip()} ) -client.vaults.create( - { - "name": "prod-db", - "source": {"type": "1password", "token_secret": "op-token", "vault": "Engineering"}, - # Items, sections and fields by name or ID: op://Engineering/Postgres/prod/password - "entries": {"DB_PASS": {"item": "Postgres", "section": "prod", "field": "password"}}, - } -) -sandbox.exec("psql -c 'select 1'", vault="prod-db") -# An entry set to None is removed; entries not given are kept. -client.vaults.update("prod-db", {"entries": {"DB_PASS": None}}) +one_password = { + "token": "op-token", # the op_token secret's name; its latest version signs in + "vault": "Engineering", # the 1Password vault's name or ID + # Items, sections and fields by name or ID: op://Engineering/Postgres/prod/password + "fields": { + "DB_PASS": {"item": "Postgres", "section": "prod", "field": "password"}, + "DB_OTP": {"item": "Postgres", "field": "one-time password?attribute=totp"}, + }, +} + +# Delivered to the image's startup command (image launches only). +sandbox = client.sandboxes.create({"image_name": "node", "one_password": one_password}) +print(sandbox.startup_secrets) # each with its op:// reference and token_secret + +result = sandbox.exec("psql -c 'select 1'", one_password=one_password) +process = sandbox.processes.start("node worker.js", one_password=one_password) +print(process.secrets) # source.type is "onePassword" for these variables ``` +`one_password` takes one token and vault per request and 1-32 fields, named +like environment variables. A field may end with a query such as +`?attribute=otp`, `?attribute=totp` or `?ssh-format=openssh`. Vaults, items, +sections and fields are 1-256 characters (UTF-16 code units) without `/`, `?` or control characters, and +not `.` or `..`; the whole `op://` reference is at most 512 bytes. These are +checked locally before any request, as is a variable that `secrets` or `env` +also sets: a variable from 1Password must come from it alone. The server also +refuses one that the request's `vault` holds. + Things to know: - A secret is set only in the environment of the process it was passed to. @@ -686,18 +703,17 @@ Things to know: `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 +- `client.sandboxes.create()` with `secrets`, `vault` or `one_password` + 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. -- Vaults hold up to 32 entries, named like environment variables; a team has - up to 100. A vault's source is fixed at creation; `update()` changes a - 1Password vault's `token_secret` or `vault` with `{"source": {...}}`. - Methods that take a vault accept its ID or exact name, and vaults need the - same scopes as secrets. A vault and - `secrets` together deliver at most 32 variables, and a process's `env` may - not set one of them. +- Vaults hold up to 32 stored secrets, named like environment variables; a + team has up to 100. Methods that take a vault accept its ID or exact name, + and vaults need the same scopes as secrets. A vault, `secrets` and + `one_password` together deliver at most 32 variables, and a process's `env` + may not set one of them. - 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 @@ -707,26 +723,26 @@ Things to know: - `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 + deliver `secrets`, a `vault` or `one_password` to. Create the sandbox without them, then pass them to `sandbox.exec()` or `sandbox.processes.start()`. - `VAULT_NOT_FOUND` (404), `VAULT_NAME_TAKEN`, `VAULT_LIMIT_REACHED`, - `VAULT_ENTRY_LIMIT_REACHED` (409), `VAULT_ENTRY_INVALID` (400: a bad - entry, including one of the wrong kind for the vault's source; the message - starts with the variable), and `VAULT_SOURCE_INVALID` (400: a source change - on a stored vault, or a 1Password vault used in a browser session). - - `SECRET_IN_USE` (409): a vault uses the secret, as an entry or as its - 1Password token; `SECRET_VERSION_IN_USE` (409): a vault pins the version. + `VAULT_ENTRY_LIMIT_REACHED` (409). + - `SECRET_IN_USE` (409): a vault uses the secret as an entry; + `SECRET_VERSION_IN_USE` (409): a vault pins the version. Only vaults + hold secrets, so an `op_token` can always be deleted. - `SECRET_TYPE_MISMATCH` (409): an `op_token` used as a variable or vault - entry, or another type used as a 1Password vault's token. + entry, or another type named as `one_password`'s `token`. - `SECRET_SOURCES_INVALID` (400): for example `env` sets a variable that - `vault` or `secrets` also sets. + `vault` or `secrets` also sets, a `one_password` variable is also set by + `vault`, `secrets` or `env`, or the sources deliver more than 32 + variables. - `SECRET_VALUE_INVALID` (422): a stored value is unusable, such as a bad TOTP seed; fix it by adding a new version. - `SECRET_TOTP_UNSUPPORTED` and `SECRET_ONEPASSWORD_UNSUPPORTED` (501): the sandbox's host predates TOTP or 1Password, so nothing started. Retryable on sandbox create, which may land on a newer host; not on a process start. - `SECRET_ONEPASSWORD_REFERENCE_FAILED` (422, `details` names the - variables) and `SECRET_ONEPASSWORD_AUTH_FAILED` (422): fix the entries or + variables) and `SECRET_ONEPASSWORD_AUTH_FAILED` (422): fix the fields or the token. `SECRET_ONEPASSWORD_RATE_LIMITED` (429) and `SECRET_ONEPASSWORD_FAILED` (502/503) are retryable. - `retryable` is the API's flag when it sends one. diff --git a/hyperbrowser/client/managers/async_manager/sandbox.py b/hyperbrowser/client/managers/async_manager/sandbox.py index c992aaa7..5ac14d1b 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -80,6 +80,7 @@ completed_image_id, ) from ..sandboxes.shared import ( + OnePasswordSourceInput, SecretReferences, _build_sandbox_exposed_url, _copy_model, @@ -337,15 +338,19 @@ async def exec( max_output_bytes: int = 64 * 1024 * 1024, secrets: Optional[SecretReferences] = None, vault: Optional[str] = None, + one_password: Optional[OnePasswordSourceInput] = None, ): """Run a command to completion. ``secrets`` maps environment variable names to stored secrets (a name, or ``{"name": ..., "version": N}``). ``vault`` names a vault whose - entries become variables too; ``secrets`` win on a clash. 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. + entries become variables too; ``secrets`` win on a clash. + ``one_password`` sets variables from fields of a 1Password vault + (``{"token": op_token_secret, "vault": ..., "fields": {ENV: {"item", + "section", "field"}}}``); those variables may come from nothing else. + 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, @@ -357,6 +362,7 @@ async def exec( max_output_bytes=max_output_bytes, secrets=secrets, vault=vault, + one_password=one_password, ) async def _start_secret_process(self, payload: Dict[str, object]) -> dict: @@ -1186,7 +1192,11 @@ async def _create_detail( ) -> SandboxDetail: normalized = coerce_request(params, CreateSandboxParams) data = dump_request(normalized, CreateSandboxParams) - delivers_secrets = bool(normalized.secrets) or normalized.vault is not None + delivers_secrets = ( + bool(normalized.secrets) + or normalized.vault is not None + or normalized.one_password is not None + ) try: payload = await self._request("POST", "/sandbox", data=data) detail = SandboxDetail(**payload) diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py index b2d84865..b3850153 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py @@ -26,6 +26,7 @@ SandboxProcessStdinParams as SandboxProcessStdinParamsDict, ) from ...sandboxes.shared import ( + OnePasswordSourceInput, SecretReferences, _normalize_exec_params, _runtime_process_payload, @@ -355,6 +356,7 @@ async def exec( max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, secrets: Optional[SecretReferences] = None, vault: Optional[str] = None, + one_password: Optional[OnePasswordSourceInput] = None, ) -> SandboxProcessResult: handle = await self.start( input, @@ -366,6 +368,7 @@ async def exec( max_output_bytes=max_output_bytes, secrets=secrets, vault=vault, + one_password=one_password, ) if handle.secrets is not None: return await self._finish_secret_exec(handle) @@ -386,13 +389,16 @@ async def start( max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, secrets: Optional[SecretReferences] = None, vault: Optional[str] = None, + one_password: Optional[OnePasswordSourceInput] = None, ) -> SandboxProcessHandle: """Start a process. - With ``secrets`` or ``vault``, 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. ``secrets`` are merged over the vault's entries. + With ``secrets``, ``vault`` or ``one_password``, the start goes through + the control API, which has the platform resolve them into the process + environment; output is then read with ``wait()`` rather than collected + from the start. ``secrets`` are merged over the vault's entries, and a + ``one_password`` variable may not also come from ``vault``, + ``secrets`` or ``env``. """ validate_output_limit(max_output_bytes) params = _normalize_exec_params( @@ -404,6 +410,7 @@ async def start( run_as=run_as, secrets=secrets, vault=vault, + one_password=one_password, ) if params.uses_secrets: return await self._start_with_secrets(params) diff --git a/hyperbrowser/client/managers/async_manager/vault.py b/hyperbrowser/client/managers/async_manager/vault.py index 30212ce7..7c30acb9 100644 --- a/hyperbrowser/client/managers/async_manager/vault.py +++ b/hyperbrowser/client/managers/async_manager/vault.py @@ -28,7 +28,7 @@ class VaultManager: """Manage vaults: named sets of environment variables whose values come - from stored secrets or from a 1Password vault. + from stored secrets. Pass a vault by name as ``vault`` when creating a sandbox or starting a process, or as ``{"vault": name}`` in a browser session's @@ -57,11 +57,8 @@ async def create( ) -> Vault: """Create a vault. - ``source`` defaults to ``{"type": "stored"}``, whose entries are - ``{"name": secret, "version": N}`` (version optional). A 1Password - vault, ``{"type": "1password", "token_secret": op_token_secret, - "vault": name_or_id}``, takes ``{"item", "section", "field"}`` entries. - The source type can't change later. + ``entries`` maps environment variable names to + ``{"name": secret, "version": N}`` (version optional). """ body = create_vault_body(params) return Vault(**await self._control._request("POST", "/vaults", data=body)) @@ -74,11 +71,10 @@ async def update( vault: str, params: Union[UpdateVaultParamsDict, UpdateVaultParams], ) -> Vault: - """Update a vault's description, entries or 1Password settings. + """Update a vault's description, entries, or both. Each entry given replaces that variable and an entry set to ``None`` - removes it; entries not given are kept. ``source`` takes - ``token_secret`` and/or ``vault``, for 1Password vaults only. + removes it; entries not given are kept. """ body = update_vault_body(params) vault_id = (await self._resolve(vault))[1] diff --git a/hyperbrowser/client/managers/sandboxes/shared.py b/hyperbrowser/client/managers/sandboxes/shared.py index 74ccdd73..2604c6b2 100644 --- a/hyperbrowser/client/managers/sandboxes/shared.py +++ b/hyperbrowser/client/managers/sandboxes/shared.py @@ -24,8 +24,9 @@ parse_error_payload, runtime_base_url_session_id, ) -from ....models.secret import SecretReference +from ....models.secret import OnePasswordSource, SecretReference, onepassword_clashes from ....types import ( + OnePasswordSource as OnePasswordSourceDict, SecretReference as SecretReferenceDict, SandboxExecParams as SandboxExecParamsDict, SandboxFileWriteEntry as SandboxFileWriteEntryDict, @@ -34,6 +35,8 @@ # 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]] +# Fields of a 1Password vault, as a mapping or model. +OnePasswordSourceInput = Union[OnePasswordSourceDict, OnePasswordSource] DEFAULT_WATCH_TIMEOUT_MS = 60_000 # The receiver sends process SSE keepalives every 15 seconds. @@ -98,6 +101,7 @@ def _normalize_exec_params( run_as: Optional[str] = None, secrets: Optional[SecretReferences] = None, vault: Optional[str] = None, + one_password: Optional[OnePasswordSourceInput] = None, ) -> SandboxExecParams: if isinstance(input, str): params = SandboxExecParams(command=input) @@ -124,8 +128,17 @@ def _normalize_exec_params( updates["vault"] = SandboxExecParams.model_validate( {"command": params.command, "vault": vault} ).vault + if one_password is not None: + updates["one_password"] = SandboxExecParams.model_validate( + {"command": params.command, "one_password": one_password} + ).one_password normalized = params.model_copy(update=updates) if updates else params + clash = onepassword_clashes( + normalized.one_password, secrets=normalized.secrets, env=normalized.env + ) + if clash: + raise HyperbrowserError(clash, code="invalid_secret_reference") if normalized.uses_secrets and normalized.use_shell is not None: # The control API's start has no such option; don't drop it silently. raise HyperbrowserError( @@ -138,7 +151,10 @@ def _normalize_exec_params( 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, "vault": None}), SandboxExecParams + params.model_copy( + update={"secrets": None, "vault": None, "one_password": None} + ), + SandboxExecParams, ) @@ -162,14 +178,17 @@ def _secret_process_payload(params: SandboxExecParams) -> Dict[str, object]: "env": params.env, "timeoutMs": timeout_ms, "runAs": params.run_as, - # A vault alone needs no secrets member. + # A vault or one_password alone needs no secrets member. "secrets": { name: reference.model_dump(exclude_none=True) for name, reference in secrets.items() } - if secrets or params.vault is None + if secrets or (params.vault is None and params.one_password is None) else None, "vault": params.vault, + "onePassword": params.one_password.model_dump(exclude_none=True) + if params.one_password is not None + else None, } return {key: value for key, value in payload.items() if value is not None} diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index fb3fa155..76519aef 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -78,6 +78,7 @@ completed_image_id, ) from ..sandboxes.shared import ( + OnePasswordSourceInput, SecretReferences, _build_sandbox_exposed_url, _copy_model, @@ -329,15 +330,19 @@ def exec( max_output_bytes: int = 64 * 1024 * 1024, secrets: Optional[SecretReferences] = None, vault: Optional[str] = None, + one_password: Optional[OnePasswordSourceInput] = None, ): """Run a command to completion. ``secrets`` maps environment variable names to stored secrets (a name, or ``{"name": ..., "version": N}``). ``vault`` names a vault whose - entries become variables too; ``secrets`` win on a clash. 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. + entries become variables too; ``secrets`` win on a clash. + ``one_password`` sets variables from fields of a 1Password vault + (``{"token": op_token_secret, "vault": ..., "fields": {ENV: {"item", + "section", "field"}}}``); those variables may come from nothing else. + 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, @@ -349,6 +354,7 @@ def exec( max_output_bytes=max_output_bytes, secrets=secrets, vault=vault, + one_password=one_password, ) def _start_secret_process(self, payload: Dict[str, object]) -> dict: @@ -1164,7 +1170,11 @@ def _create_detail( ) -> SandboxDetail: normalized = coerce_request(params, CreateSandboxParams) data = dump_request(normalized, CreateSandboxParams) - delivers_secrets = bool(normalized.secrets) or normalized.vault is not None + delivers_secrets = ( + bool(normalized.secrets) + or normalized.vault is not None + or normalized.one_password is not None + ) try: payload = self._request("POST", "/sandbox", data=data) detail = SandboxDetail(**payload) diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py index d335aa17..59b1e85b 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py @@ -26,6 +26,7 @@ SandboxProcessStdinParams as SandboxProcessStdinParamsDict, ) from ...sandboxes.shared import ( + OnePasswordSourceInput, SecretReferences, _normalize_exec_params, _runtime_process_payload, @@ -354,6 +355,7 @@ def exec( max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, secrets: Optional[SecretReferences] = None, vault: Optional[str] = None, + one_password: Optional[OnePasswordSourceInput] = None, ) -> SandboxProcessResult: handle = self.start( input, @@ -365,6 +367,7 @@ def exec( max_output_bytes=max_output_bytes, secrets=secrets, vault=vault, + one_password=one_password, ) if handle.secrets is not None: return self._finish_secret_exec(handle) @@ -385,13 +388,16 @@ def start( max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES, secrets: Optional[SecretReferences] = None, vault: Optional[str] = None, + one_password: Optional[OnePasswordSourceInput] = None, ) -> SandboxProcessHandle: """Start a process. - With ``secrets`` or ``vault``, 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. ``secrets`` are merged over the vault's entries. + With ``secrets``, ``vault`` or ``one_password``, the start goes through + the control API, which has the platform resolve them into the process + environment; output is then read with ``wait()`` rather than collected + from the start. ``secrets`` are merged over the vault's entries, and a + ``one_password`` variable may not also come from ``vault``, + ``secrets`` or ``env``. """ validate_output_limit(max_output_bytes) params = _normalize_exec_params( @@ -403,6 +409,7 @@ def start( run_as=run_as, secrets=secrets, vault=vault, + one_password=one_password, ) if params.uses_secrets: return self._start_with_secrets(params) diff --git a/hyperbrowser/client/managers/sync_manager/vault.py b/hyperbrowser/client/managers/sync_manager/vault.py index 5cb2c62a..73a2aa3c 100644 --- a/hyperbrowser/client/managers/sync_manager/vault.py +++ b/hyperbrowser/client/managers/sync_manager/vault.py @@ -28,7 +28,7 @@ class VaultManager: """Manage vaults: named sets of environment variables whose values come - from stored secrets or from a 1Password vault. + from stored secrets. Pass a vault by name as ``vault`` when creating a sandbox or starting a process, or as ``{"vault": name}`` in a browser session's @@ -57,11 +57,8 @@ def create( ) -> Vault: """Create a vault. - ``source`` defaults to ``{"type": "stored"}``, whose entries are - ``{"name": secret, "version": N}`` (version optional). A 1Password - vault, ``{"type": "1password", "token_secret": op_token_secret, - "vault": name_or_id}``, takes ``{"item", "section", "field"}`` entries. - The source type can't change later. + ``entries`` maps environment variable names to + ``{"name": secret, "version": N}`` (version optional). """ body = create_vault_body(params) return Vault(**self._control._request("POST", "/vaults", data=body)) @@ -74,11 +71,10 @@ def update( vault: str, params: Union[UpdateVaultParamsDict, UpdateVaultParams], ) -> Vault: - """Update a vault's description, entries or 1Password settings. + """Update a vault's description, entries, or both. Each entry given replaces that variable and an entry set to ``None`` - removes it; entries not given are kept. ``source`` takes - ``token_secret`` and/or ``vault``, for 1Password vaults only. + removes it; entries not given are kept. """ body = update_vault_body(params) vault_id = (self._resolve(vault))[1] diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index 164c1206..fe1d7fec 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -263,6 +263,8 @@ ) from .secret import ( CreateSecretParams, + OnePasswordField, + OnePasswordSource, SandboxResolvedSecret, Secret, SecretBindingSource, @@ -491,23 +493,13 @@ from .team import TeamCreditInfo from .vault import ( CreateVaultParams, - OnePasswordEntry, - OnePasswordSourceUpdate, - OnePasswordVaultSource, StoredEntry, - StoredVaultSource, UpdateVaultParams, Vault, VaultDeleteResult, VaultEntry, - VaultEntryInput, VaultListParams, VaultListResponse, - VaultOnePasswordEntry, - VaultSource, - VaultSourceInput, - VaultStoredEntry, - VaultTokenSecret, ) __all__ = [ @@ -693,25 +685,17 @@ "ProfileResponse", # vault "CreateVaultParams", - "OnePasswordEntry", - "OnePasswordSourceUpdate", - "OnePasswordVaultSource", "StoredEntry", - "StoredVaultSource", "UpdateVaultParams", "Vault", "VaultDeleteResult", "VaultEntry", - "VaultEntryInput", "VaultListParams", "VaultListResponse", - "VaultOnePasswordEntry", - "VaultSource", - "VaultSourceInput", - "VaultStoredEntry", - "VaultTokenSecret", # secret "CreateSecretParams", + "OnePasswordField", + "OnePasswordSource", "SandboxResolvedSecret", "Secret", "SecretBindingSource", diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index 8d148972..4c8f561a 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -12,9 +12,11 @@ from ._parsers import _parse_optional_int from .secret import ( + OnePasswordSource, SandboxResolvedSecret, SecretReference, normalize_secret_references, + onepassword_clashes, validate_secret_name, ) from .session import SessionLaunchState, SessionStatus @@ -259,6 +261,11 @@ class CreateSandboxParams(_SandboxLaunchCommonParams): # A vault, by name, whose entries are delivered to the startup command; # ``secrets`` are merged over it. vault: Optional[str] = None + # Fields of a 1Password vault, delivered to the startup command. A + # variable here may not also come from ``vault`` or ``secrets``. + one_password: Optional[OnePasswordSource] = Field( + default=None, serialization_alias="onePassword" + ) @field_validator("secrets", mode="before") @classmethod @@ -289,11 +296,16 @@ 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 or self.vault is not None): + if self.snapshot_name and ( + self.secrets or self.vault is not None or self.one_password is not None + ): raise ValueError( - "secrets and vault are only supported for image launches; " - "snapshots don't run a startup command" + "secrets, vault and one_password are only supported for image " + "launches; snapshots don't run a startup command" ) + clash = onepassword_clashes(self.one_password, secrets=self.secrets) + if clash: + raise ValueError(clash) return self @@ -608,6 +620,12 @@ class SandboxExecParams(SandboxBaseModel): # A vault, by name, whose entries become environment variables; ``secrets`` # are merged over it. Starts through the control API like ``secrets``. vault: Optional[str] = None + # Fields of a 1Password vault as environment variables, resolved in the + # sandbox at start. A variable here may not also come from ``vault``, + # ``secrets`` or ``env``. Starts through the control API like ``secrets``. + one_password: Optional[OnePasswordSource] = Field( + default=None, serialization_alias="onePassword" + ) @field_validator("secrets", mode="before") @classmethod @@ -622,7 +640,11 @@ def validate_vault(cls, value): @property def uses_secrets(self) -> bool: """Whether the start delivers secrets, so goes through the control API.""" - return bool(self.secrets) or self.vault is not None + return ( + bool(self.secrets) + or self.vault is not None + or self.one_password is not None + ) class SandboxProcessSummary(SandboxBaseModel): diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py index 289c9c9b..cc2c66ba 100644 --- a/hyperbrowser/models/secret.py +++ b/hyperbrowser/models/secret.py @@ -1,6 +1,7 @@ import re +import unicodedata from datetime import datetime -from typing import List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -10,6 +11,7 @@ SECRET_ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") MAX_SECRET_VERSION = 2**31 - 1 MAX_SECRET_BINDINGS = 32 +MAX_SECRET_ENV_NAME_LENGTH = 256 def validate_secret_name(name: str) -> str: @@ -89,12 +91,186 @@ def normalize_secret_references(value): } +MAX_ONEPASSWORD_NAME_LENGTH = 256 +MAX_ONEPASSWORD_REFERENCE_BYTES = 512 +# Characters 1Password names can't hold in an op:// reference: control, +# format, line separator and paragraph separator characters. +_ONEPASSWORD_FORBIDDEN_CATEGORIES = frozenset({"Cc", "Cf", "Zl", "Zp"}) +# A field may end with a query, such as ?attribute=otp or ?ssh-format=openssh. +_ONEPASSWORD_QUERY_PATTERN = re.compile( + r"\?[a-z-]+=[A-Za-z0-9_-]+(?:&[a-z-]+=[A-Za-z0-9_-]+)*" +) + + +def _utf16_length(value: str) -> int: + # The server counts string lengths in UTF-16 code units. + return len(value.encode("utf-16-le", "surrogatepass")) // 2 + + +def validate_onepassword_name(value: str, what: str, query: bool = False) -> str: + """Check a 1Password vault, item, section or field name (or ID) for an + ``op://`` reference. A field may end with a query such as + ``?attribute=otp``.""" + if not isinstance(value, str) or not value: + raise ValueError(f"{what} must be a non-empty 1Password name or ID") + if _utf16_length(value) > MAX_ONEPASSWORD_NAME_LENGTH: + raise ValueError( + f"{what} is longer than {MAX_ONEPASSWORD_NAME_LENGTH} characters" + ) + name, mark, params = value.partition("?") if query else (value, "", "") + if mark and not _ONEPASSWORD_QUERY_PATTERN.fullmatch(mark + params): + raise ValueError( + f"{what} has an invalid query; expected ?key=value pairs such as " + "?attribute=otp" + ) + if ( + not name + or name in (".", "..") + or "/" in name + or "?" in name + or any( + unicodedata.category(char) in _ONEPASSWORD_FORBIDDEN_CATEGORIES + for char in name + ) + ): + raise ValueError( + f"{what} must be a 1Password name or ID without '/' or '?', control " + "characters, or a name of '.' or '..'" + ) + return value + + +def validate_secret_env_name(env, what: str = "a secret") -> str: + if ( + not isinstance(env, str) + or not SECRET_ENV_NAME_PATTERN.fullmatch(env) + or len(env) > MAX_SECRET_ENV_NAME_LENGTH + or env == "__proto__" + ): + raise ValueError( + f"Invalid environment variable name {env!r} for {what}: expected " + f"up to {MAX_SECRET_ENV_NAME_LENGTH} letters, digits and '_', not " + "starting with a digit" + ) + return env + + +class OnePasswordField(SecretBaseModel): + """A field of an item in the request's 1Password vault. + + ``item``, ``section`` and ``field`` are names or IDs; ``field`` may end + with a query such as ``?attribute=otp`` or ``?attribute=totp``. + """ + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + item: str + section: Optional[str] = None + field: str + + @field_validator("item") + @classmethod + def validate_item(cls, value): + return validate_onepassword_name(value, "item") + + @field_validator("section") + @classmethod + def validate_section(cls, value): + return None if value is None else validate_onepassword_name(value, "section") + + @field_validator("field") + @classmethod + def validate_field(cls, value): + return validate_onepassword_name(value, "field", query=True) + + +def onepassword_reference(vault: str, field: OnePasswordField) -> str: + """The ``op://vault/item/[section/]field`` reference the platform builds.""" + parts = [vault, field.item] + ([field.section] if field.section else []) + return "op://" + "/".join(parts + [field.field]) + + +class OnePasswordSource(SecretBaseModel): + """Environment variables from fields of one 1Password vault. + + ``token`` names an ``op_token`` secret holding a 1Password service account + token (its latest version signs in); ``vault`` is the 1Password vault's + name or ID; ``fields`` maps each environment variable to the field it + takes. Nothing is stored: the sandbox resolves the fields when the + process starts. + """ + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + token: str + vault: str + fields: Dict[str, OnePasswordField] + + @field_validator("token") + @classmethod + def validate_token(cls, value): + return validate_secret_name(value) + + @field_validator("vault") + @classmethod + def validate_vault(cls, value): + return validate_onepassword_name(value, "The 1Password vault") + + @field_validator("fields", mode="before") + @classmethod + def validate_fields(cls, value): + if not isinstance(value, dict): + return value + if not 1 <= len(value) <= MAX_SECRET_BINDINGS: + raise ValueError( + f"one_password takes 1 to {MAX_SECRET_BINDINGS} fields; " + f"got {len(value)}" + ) + for env in value: + validate_secret_env_name(env, "a 1Password field") + return value + + @model_validator(mode="after") + def check_reference_sizes(self): + for env, field in self.fields.items(): + size = len( + onepassword_reference(self.vault, field).encode( + "utf-8", "surrogatepass" + ) + ) + if size > MAX_ONEPASSWORD_REFERENCE_BYTES: + raise ValueError( + f"{env}: the 1Password reference is {size} bytes; at most " + f"{MAX_ONEPASSWORD_REFERENCE_BYTES} are allowed" + ) + return self + + +def onepassword_clashes( + one_password: Optional[OnePasswordSource], **sources +) -> Optional[str]: + """Describe a variable ``one_password`` shares with another source of the + request (``secrets`` or ``env``), or return ``None``. Clashes with a vault + are only known to the server.""" + if one_password is None: + return None + for name, variables in sources.items(): + for env in one_password.fields: + if variables and env in variables: + return ( + f"{env} is set by {name} and by one_password; a 1Password " + "field's variable must come from it alone" + ) + return None + + # What a secret's value is; fixed when the secret is created. # secret an opaque string: an environment variable, and typed into pages # on its allowed origins # totp a TOTP seed (base32 or an otpauth://totp/ URI): delivered and # typed as the code that is current at the time -# op_token a 1Password service account token, used only by 1Password vaults +# op_token a 1Password service account token, used only to sign in for +# one_password fields SecretType = Literal["secret", "totp", "op_token"] # A secret value, encrypted locally before upload. @@ -284,7 +460,8 @@ class SecretVersionDeleteResult(SecretBaseModel): class SecretBindingSource(SecretBaseModel): """Where a delivered variable came from: ``type`` is ``"vault"`` (with the - vault's ``name``) or ``"secrets"`` (the request's own ``secrets``).""" + vault's ``name``), ``"secrets"`` (the request's own ``secrets``) or + ``"onePassword"`` (the request's ``one_password``).""" model_config = ConfigDict(populate_by_name=True, extra="allow") @@ -297,10 +474,11 @@ class SandboxResolvedSecret(SecretBaseModel): A stored secret sets ``name``, ``secret_id`` and ``version``, and ``secret_type="totp"`` when the variable holds the code current at - process start. A 1Password vault entry sets ``reference`` (its + process start. A ``one_password`` field sets ``reference`` (its ``op://`` reference) and ``token_secret`` (the name of the op_token - secret used to sign in) instead. ``source`` is where the variable came - from, and ``overrides`` lists earlier sources it replaced. + secret used to sign in) instead, and never overrides another source. + ``source`` is where the variable came from, and ``overrides`` lists + earlier sources it replaced. """ model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/hyperbrowser/models/vault.py b/hyperbrowser/models/vault.py index d1b20c5d..5bc9459e 100644 --- a/hyperbrowser/models/vault.py +++ b/hyperbrowser/models/vault.py @@ -1,96 +1,25 @@ """Vaults: named sets of environment variables whose values come from stored -secrets or from a 1Password vault. A vault holds no values itself.""" +secrets. A vault holds no values itself.""" -import unicodedata from datetime import datetime from typing import Any, Dict, List, Literal, Optional, Union from pydantic import ConfigDict, Field, field_validator, model_validator from .secret import ( - SECRET_ENV_NAME_PATTERN, + MAX_SECRET_ENV_NAME_LENGTH, SecretBaseModel, + validate_secret_env_name, validate_secret_name, validate_secret_version, ) MAX_VAULT_ENTRIES = 32 -MAX_VAULT_ENV_NAME_LENGTH = 256 -MAX_ONEPASSWORD_NAME_LENGTH = 256 -MAX_ONEPASSWORD_REFERENCE_BYTES = 512 -# Characters 1Password names can't hold in an op:// reference: control, -# format, line separator and paragraph separator characters. -_ONEPASSWORD_FORBIDDEN_CATEGORIES = frozenset({"Cc", "Cf", "Zl", "Zp"}) - - -def _utf16_length(value: str) -> int: - # The server counts string lengths in UTF-16 code units. - return len(value.encode("utf-16-le", "surrogatepass")) // 2 - - -def _is_query_part(text: str, allowed) -> bool: - return bool(text) and all(char in allowed for char in text) - - -_QUERY_KEY_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz-") -_QUERY_VALUE_CHARS = frozenset( - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" -) - - -def validate_onepassword_name(value: str, what: str, query: bool = False) -> str: - """Check a 1Password vault, item, section or field name (or ID) for an - ``op://`` reference. A field may end with a query such as - ``?attribute=otp``.""" - if not isinstance(value, str) or not value: - raise ValueError(f"{what} must be a non-empty 1Password name or ID") - if _utf16_length(value) > MAX_ONEPASSWORD_NAME_LENGTH: - raise ValueError( - f"{what} is longer than {MAX_ONEPASSWORD_NAME_LENGTH} characters" - ) - name, _, params = value.partition("?") if query else (value, "", "") - if query and "?" in value: - for pair in params.split("&"): - key, equals, setting = pair.partition("=") - if not ( - equals - and _is_query_part(key, _QUERY_KEY_CHARS) - and _is_query_part(setting, _QUERY_VALUE_CHARS) - ): - raise ValueError( - f"{what} has an invalid query; expected ?key=value pairs " - "such as ?attribute=otp" - ) - if ( - not name - or name in (".", "..") - or "/" in name - or "?" in name - or any( - unicodedata.category(char) in _ONEPASSWORD_FORBIDDEN_CATEGORIES - for char in name - ) - ): - raise ValueError( - f"{what} must be a 1Password name or ID without '/' or '?', control " - "characters, or a name of '.' or '..'" - ) - return value +MAX_VAULT_ENV_NAME_LENGTH = MAX_SECRET_ENV_NAME_LENGTH def validate_vault_env_name(env: Any) -> str: - if ( - not isinstance(env, str) - or not SECRET_ENV_NAME_PATTERN.fullmatch(env) - or len(env) > MAX_VAULT_ENV_NAME_LENGTH - or env == "__proto__" - ): - raise ValueError( - f"Invalid vault entry name {env!r}: entries are environment variable " - f"names of up to {MAX_VAULT_ENV_NAME_LENGTH} letters, digits and '_', " - "not starting with a digit" - ) - return env + return validate_secret_env_name(env, "a vault entry") class StoredEntry(SecretBaseModel): @@ -100,28 +29,14 @@ class StoredEntry(SecretBaseModel): version. """ + model_config = ConfigDict(populate_by_name=True, extra="forbid") + name: str version: Optional[Union[int, Literal["latest"]]] = None -class OnePasswordEntry(SecretBaseModel): - """A vault entry that takes its value from a field of a 1Password item. - - ``item``, ``section`` and ``field`` are names or IDs; ``field`` may end - with a query such as ``?attribute=otp``. In responses, ``reference`` is - the ``op://vault/item/[section/]field`` reference the platform resolves. - """ - - item: str - section: Optional[str] = None - field: str - - -VaultEntryInput = Union[StoredEntry, OnePasswordEntry] - - -class VaultStoredEntry(StoredEntry): - """A stored entry as the API reports it: ``version`` is ``None`` when it +class VaultEntry(StoredEntry): + """An entry as the API reports it: ``version`` is ``None`` when it follows the latest version.""" model_config = ConfigDict(populate_by_name=True, extra="allow") @@ -129,125 +44,26 @@ class VaultStoredEntry(StoredEntry): secret_id: Optional[str] = Field(default=None, alias="secretId") -class VaultOnePasswordEntry(OnePasswordEntry): - """A 1Password entry as the API reports it, with the - ``op://vault/item/[section/]field`` reference the platform resolves.""" - - model_config = ConfigDict(populate_by_name=True, extra="allow") - - reference: Optional[str] = None - - -VaultEntry = Union[VaultStoredEntry, VaultOnePasswordEntry] - - -def _parse_entry(value: Any) -> Any: - if isinstance(value, (StoredEntry, OnePasswordEntry)) or not isinstance( - value, dict - ): - return value - if "item" in value: - return OnePasswordEntry.model_validate(value) - return StoredEntry.model_validate(value) - - -def _parse_reported_entry(value: Any) -> Any: - if not isinstance(value, dict): - return value - if "item" in value: - return VaultOnePasswordEntry.model_validate(value) - return VaultStoredEntry.model_validate(value) - - -class StoredVaultSource(SecretBaseModel): - """Values come from the team's stored secrets (the default).""" - - type: Literal["stored"] - - -class OnePasswordVaultSource(SecretBaseModel): - """Values come from a 1Password vault. - - ``token_secret`` names an ``op_token`` secret holding a 1Password service - account token; ``vault`` is the 1Password vault's name or ID. - """ - - type: Literal["1password"] - token_secret: str = Field(serialization_alias="tokenSecret") - vault: str - - @field_validator("token_secret") - @classmethod - def validate_token_secret(cls, value): - return validate_secret_name(value) - - @field_validator("vault") - @classmethod - def validate_vault(cls, value): - return validate_onepassword_name(value, "The 1Password vault") - - -VaultSourceInput = Union[StoredVaultSource, OnePasswordVaultSource] - - -class OnePasswordSourceUpdate(SecretBaseModel): - """New settings for a 1Password vault; ``None`` keeps a setting.""" - - token_secret: Optional[str] = Field(default=None, serialization_alias="tokenSecret") - vault: Optional[str] = None - - @field_validator("token_secret") - @classmethod - def validate_token_secret(cls, value): - return None if value is None else validate_secret_name(value) - - @field_validator("vault") - @classmethod - def validate_vault(cls, value): - return ( - None - if value is None - else validate_onepassword_name(value, "The 1Password vault") - ) - - @model_validator(mode="after") - def require_a_setting(self): - if self.token_secret is None and self.vault is None: - raise ValueError("A source update needs token_secret, vault, or both") - return self - - -def _check_entry(env: str, entry: Any) -> None: +def _check_entry(env: str, entry: Optional[StoredEntry]) -> None: validate_vault_env_name(env) - if isinstance(entry, StoredEntry): - validate_secret_name(entry.name) - if entry.version is not None and entry.version != "latest": - validate_secret_version(entry.version) - elif isinstance(entry, OnePasswordEntry): - validate_onepassword_name(entry.item, f"{env}: item") - if entry.section is not None: - validate_onepassword_name(entry.section, f"{env}: section") - validate_onepassword_name(entry.field, f"{env}: field", query=True) - - -def onepassword_reference(vault: str, entry: OnePasswordEntry) -> str: - """The ``op://`` reference the platform builds for an entry.""" - parts = [vault, entry.item] + ([entry.section] if entry.section else []) - return "op://" + "/".join(parts + [entry.field]) + if entry is None: + return + validate_secret_name(entry.name) + if entry.version is not None and entry.version != "latest": + validate_secret_version(entry.version) class CreateVaultParams(SecretBaseModel): - """A new vault. + """A new vault: ``entries`` maps environment variable names to the + stored secrets they take.""" - ``entries`` maps environment variable names to a :class:`StoredEntry` - (stored vaults) or a :class:`OnePasswordEntry` (1Password vaults). - ``source`` is fixed at creation and defaults to stored secrets. - """ + # Strict like the API: a member it doesn't take, such as a removed + # ``source``, is refused instead of dropped. + model_config = ConfigDict(populate_by_name=True, extra="forbid") name: str description: Optional[str] = None - source: Optional[VaultSourceInput] = Field(default=None, discriminator="type") - entries: Optional[Dict[str, VaultEntryInput]] = None + entries: Optional[Dict[str, StoredEntry]] = None @field_validator("name") @classmethod @@ -256,33 +72,15 @@ def validate_name(cls, value): @field_validator("entries", mode="before") @classmethod - def parse_entries(cls, value): - if not isinstance(value, dict): - return value - if len(value) > MAX_VAULT_ENTRIES: + def limit_entries(cls, value): + if isinstance(value, dict) and len(value) > MAX_VAULT_ENTRIES: raise ValueError(f"A vault can hold at most {MAX_VAULT_ENTRIES} entries") - return {env: _parse_entry(entry) for env, entry in value.items()} + return value @model_validator(mode="after") def check_entries(self): - onepassword = isinstance(self.source, OnePasswordVaultSource) for env, entry in (self.entries or {}).items(): _check_entry(env, entry) - if onepassword != isinstance(entry, OnePasswordEntry): - raise ValueError( - f"{env}: a {'1Password' if onepassword else 'stored'} vault " - f"takes {'OnePasswordEntry' if onepassword else 'StoredEntry'} " - "entries" - ) - if isinstance(self.source, OnePasswordVaultSource) and isinstance( - entry, OnePasswordEntry - ): - reference = onepassword_reference(self.source.vault, entry) - if len(reference.encode("utf-8")) > MAX_ONEPASSWORD_REFERENCE_BYTES: - raise ValueError( - f"{env}: the 1Password reference is longer than " - f"{MAX_ONEPASSWORD_REFERENCE_BYTES} bytes" - ) return self @@ -290,25 +88,18 @@ class UpdateVaultParams(SecretBaseModel): """Changes to a vault. Members left out (or ``None``) are kept. Each entry given replaces that variable, and an entry set to ``None`` - removes it. ``source`` changes a 1Password vault's token secret or - 1Password vault; a vault's source type can't change. + removes it. """ - description: Optional[str] = None - source: Optional[OnePasswordSourceUpdate] = None - entries: Optional[Dict[str, Optional[VaultEntryInput]]] = None + model_config = ConfigDict(populate_by_name=True, extra="forbid") - @field_validator("entries", mode="before") - @classmethod - def parse_entries(cls, value): - if not isinstance(value, dict): - return value - return {env: _parse_entry(entry) for env, entry in value.items()} + description: Optional[str] = None + entries: Optional[Dict[str, Optional[StoredEntry]]] = None @model_validator(mode="after") def check_changes(self): - if self.description is None and self.source is None and self.entries is None: - raise ValueError("Update a vault's description, source, entries, or more") + if self.description is None and self.entries is None: + raise ValueError("Update a vault's description, entries, or both") for env, entry in (self.entries or {}).items(): _check_entry(env, entry) return self @@ -320,36 +111,10 @@ class VaultListParams(SecretBaseModel): search: Optional[str] = None -class VaultTokenSecret(SecretBaseModel): - """The ``op_token`` secret a 1Password vault signs in with.""" - - model_config = ConfigDict(populate_by_name=True, extra="allow") - - name: str - secret_id: Optional[str] = Field(default=None, alias="secretId") - - -class VaultSource(SecretBaseModel): - """Where a vault's values come from. - - ``type`` is ``"stored"`` or ``"1password"``; it is kept as a string so - sources the API adds later still parse. 1Password vaults set - ``token_secret`` and ``vault``. - """ - - model_config = ConfigDict(populate_by_name=True, extra="allow") - - type: str = "stored" - token_secret: Optional[VaultTokenSecret] = Field(default=None, alias="tokenSecret") - vault: Optional[str] = None - - class Vault(SecretBaseModel): id: str name: str description: str = "" - # APIs without 1Password vaults leave it out: the vault is stored. - source: VaultSource = Field(default_factory=VaultSource) entries: Dict[str, VaultEntry] = Field(default_factory=dict) created_at: Optional[datetime] = Field(default=None, alias="createdAt") updated_at: Optional[datetime] = Field(default=None, alias="updatedAt") @@ -359,19 +124,10 @@ class Vault(SecretBaseModel): def default_description(cls, value): return "" if value is None else value - @field_validator("source", mode="before") - @classmethod - def default_source(cls, value): - return {"type": "stored"} if value is None else value - @field_validator("entries", mode="before") @classmethod - def parse_entries(cls, value): - if value is None: - return {} - if not isinstance(value, dict): - return value - return {env: _parse_reported_entry(entry) for env, entry in value.items()} + def default_entries(cls, value): + return {} if value is None else value class VaultListResponse(SecretBaseModel): @@ -389,21 +145,11 @@ class VaultDeleteResult(SecretBaseModel): __all__ = [ "CreateVaultParams", - "OnePasswordEntry", - "OnePasswordSourceUpdate", - "OnePasswordVaultSource", "StoredEntry", - "StoredVaultSource", "UpdateVaultParams", "Vault", "VaultDeleteResult", "VaultEntry", - "VaultEntryInput", "VaultListParams", "VaultListResponse", - "VaultOnePasswordEntry", - "VaultSource", - "VaultSourceInput", - "VaultStoredEntry", - "VaultTokenSecret", ] diff --git a/hyperbrowser/types/__init__.py b/hyperbrowser/types/__init__.py index c10a706d..c404fa16 100644 --- a/hyperbrowser/types/__init__.py +++ b/hyperbrowser/types/__init__.py @@ -11,15 +11,9 @@ ) from .vault import ( CreateVaultParams, - OnePasswordEntry, - OnePasswordSourceUpdate, - OnePasswordVaultSource, StoredEntry, - StoredVaultSource, UpdateVaultParams, - VaultEntryInput, VaultListParams, - VaultSourceInput, ) from .webmcp import ( WebMCPInvokeParams, @@ -176,6 +170,8 @@ ) from .secret import ( CreateSecretParams, + OnePasswordField, + OnePasswordSource, SecretBrowserMetadata, SecretListParams, SecretMetadata, @@ -218,15 +214,9 @@ "CredentialFillField", "FillCredentialParams", "CreateVaultParams", - "OnePasswordEntry", - "OnePasswordSourceUpdate", - "OnePasswordVaultSource", "StoredEntry", - "StoredVaultSource", "UpdateVaultParams", - "VaultEntryInput", "VaultListParams", - "VaultSourceInput", "WebMCPInvokeParams", "WebMCPStartParams", "WebMCPResultParams", @@ -371,6 +361,8 @@ "UpdateSessionScreenParams", "UpdateSessionSolveCaptchasParams", "CreateSecretParams", + "OnePasswordField", + "OnePasswordSource", "SecretBrowserMetadata", "SecretListParams", "SecretMetadata", diff --git a/hyperbrowser/types/sandbox.py b/hyperbrowser/types/sandbox.py index 03bb39e0..6fc3f3ee 100644 --- a/hyperbrowser/types/sandbox.py +++ b/hyperbrowser/types/sandbox.py @@ -2,7 +2,7 @@ from typing_extensions import Required, TypeAlias, TypedDict -from .secret import SecretReferenceInput +from .secret import OnePasswordSource, SecretReferenceInput SandboxStatus: TypeAlias = Literal["active", "closed", "error", "close-error"] @@ -101,6 +101,7 @@ class CreateSandboxFromImageParams(_CreateSandboxCommon, total=False): disk_mib: Optional[int] secrets: Optional[Dict[str, SecretReferenceInput]] vault: Optional[str] + one_password: Optional[OnePasswordSource] CreateSandboxParams: TypeAlias = Union[ @@ -255,6 +256,7 @@ class SandboxExecParams(TypedDict, total=False): use_shell: Optional[bool] secrets: Optional[Dict[str, SecretReferenceInput]] vault: Optional[str] + one_password: Optional[OnePasswordSource] class SandboxProcessListParams(TypedDict, total=False): diff --git a/hyperbrowser/types/secret.py b/hyperbrowser/types/secret.py index abb6e0d8..49e6e8a2 100644 --- a/hyperbrowser/types/secret.py +++ b/hyperbrowser/types/secret.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Union from typing_extensions import Required, TypeAlias, TypedDict @@ -14,6 +14,24 @@ class SecretReference(TypedDict, total=False): SecretReferenceInput: TypeAlias = Union[str, SecretReference] +class OnePasswordField(TypedDict, total=False): + """A field of an item in the request's 1Password vault, by name or ID. + ``field`` may end with a query such as ``?attribute=totp``.""" + + item: Required[str] + section: Optional[str] + field: Required[str] + + +class OnePasswordSource(TypedDict): + """Environment variables from fields of one 1Password vault, signed in to + with the ``op_token`` secret named ``token``.""" + + token: str + vault: str + fields: Dict[str, OnePasswordField] + + # What a secret's value is; fixed when the secret is created. "secret" is an # opaque string, "totp" a TOTP seed (base32 or an otpauth://totp/ URI) and # "op_token" a 1Password service account token. @@ -70,6 +88,8 @@ class SecretListParams(TypedDict, total=False): __all__ = [ "CreateSecretParams", + "OnePasswordField", + "OnePasswordSource", "SecretBrowserMetadata", "SecretListParams", "SecretMetadata", diff --git a/hyperbrowser/types/vault.py b/hyperbrowser/types/vault.py index 595a6698..54e15ad4 100644 --- a/hyperbrowser/types/vault.py +++ b/hyperbrowser/types/vault.py @@ -2,7 +2,7 @@ from typing import Dict, Literal, Optional, Union -from typing_extensions import Required, TypeAlias, TypedDict +from typing_extensions import Required, TypedDict class StoredEntry(TypedDict, total=False): @@ -13,58 +13,19 @@ class StoredEntry(TypedDict, total=False): version: Optional[Union[int, Literal["latest"]]] -class OnePasswordEntry(TypedDict, total=False): - """A field of a 1Password item, by name or ID. ``field`` may end with a - query such as ``?attribute=otp``.""" - - item: Required[str] - section: Optional[str] - field: Required[str] - - -VaultEntryInput: TypeAlias = Union[StoredEntry, OnePasswordEntry] - - -class StoredVaultSource(TypedDict): - """Values come from the team's stored secrets (the default).""" - - type: Literal["stored"] - - -class OnePasswordVaultSource(TypedDict): - """Values come from a 1Password vault, signed in to with the service - account token in the ``op_token`` secret named ``token_secret``.""" - - type: Literal["1password"] - token_secret: str - vault: str - - -VaultSourceInput: TypeAlias = Union[StoredVaultSource, OnePasswordVaultSource] - - -class OnePasswordSourceUpdate(TypedDict, total=False): - """New settings for a 1Password vault; members left out are kept.""" - - token_secret: Optional[str] - vault: Optional[str] - - class CreateVaultParams(TypedDict, total=False): - """A new vault: environment variable names mapped to entries.""" + """A new vault: environment variable names mapped to stored secrets.""" name: Required[str] description: Optional[str] - source: Optional[VaultSourceInput] - entries: Optional[Dict[str, VaultEntryInput]] + entries: Optional[Dict[str, StoredEntry]] class UpdateVaultParams(TypedDict, total=False): """Changes to a vault. An entry set to ``None`` removes that variable.""" description: Optional[str] - source: Optional[OnePasswordSourceUpdate] - entries: Optional[Dict[str, Optional[VaultEntryInput]]] + entries: Optional[Dict[str, Optional[StoredEntry]]] class VaultListParams(TypedDict, total=False): @@ -77,13 +38,7 @@ class VaultListParams(TypedDict, total=False): __all__ = [ "CreateVaultParams", - "OnePasswordEntry", - "OnePasswordSourceUpdate", - "OnePasswordVaultSource", "StoredEntry", - "StoredVaultSource", "UpdateVaultParams", - "VaultEntryInput", "VaultListParams", - "VaultSourceInput", ] diff --git a/hyperbrowser/vaults_common.py b/hyperbrowser/vaults_common.py index fe4d4c25..184263ba 100644 --- a/hyperbrowser/vaults_common.py +++ b/hyperbrowser/vaults_common.py @@ -4,28 +4,16 @@ from .client._request import coerce_request from .exceptions import HyperbrowserError -from .models.vault import ( - CreateVaultParams, - OnePasswordEntry, - StoredEntry, - UpdateVaultParams, -) +from .models.vault import CreateVaultParams, StoredEntry, UpdateVaultParams -def _entry_body(entry: Any) -> Dict[str, Any]: +def _entry_body(entry: StoredEntry) -> Dict[str, Any]: # Only the request members: entries read from a vault also carry - # secretId or reference, which the API doesn't take. - if isinstance(entry, OnePasswordEntry): - body: Dict[str, Any] = {"item": entry.item, "field": entry.field} - if entry.section is not None: - body["section"] = entry.section - return body - if isinstance(entry, StoredEntry): - body = {"name": entry.name} - if entry.version is not None: - body["version"] = entry.version - return body - raise TypeError(f"Unsupported vault entry {type(entry).__name__}") + # secretId, which the API doesn't take. + body: Dict[str, Any] = {"name": entry.name} + if entry.version is not None: + body["version"] = entry.version + return body def create_vault_body(params: Any) -> Dict[str, Any]: @@ -34,8 +22,6 @@ def create_vault_body(params: Any) -> Dict[str, Any]: body: Dict[str, Any] = {"name": request.name} if request.description is not None: body["description"] = request.description - if request.source is not None: - body["source"] = request.source.model_dump(by_alias=True, exclude_none=True) if request.entries is not None: body["entries"] = { env: _entry_body(entry) for env, entry in request.entries.items() @@ -53,8 +39,6 @@ def update_vault_body(params: Any) -> Dict[str, Any]: body: Dict[str, Any] = {} if request.description is not None: body["description"] = request.description - if request.source is not None: - body["source"] = request.source.model_dump(by_alias=True, exclude_none=True) if request.entries is not None: body["entries"] = { env: None if entry is None else _entry_body(entry) diff --git a/tests/test_onepassword.py b/tests/test_onepassword.py new file mode 100644 index 00000000..3af36d20 --- /dev/null +++ b/tests/test_onepassword.py @@ -0,0 +1,341 @@ +"""1Password fields as a per-request source of sandbox secrets.""" + +import pytest + +from hyperbrowser.client.managers.async_manager.sandboxes.sandbox_processes import ( + SandboxProcessesApi as AsyncSandboxProcessesApi, +) +from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_processes import ( + SandboxProcessesApi, +) +from hyperbrowser.exceptions import HyperbrowserError +from hyperbrowser.models import ( + CreateSandboxParams, + OnePasswordField, + OnePasswordSource, + SandboxDetail, + SandboxExecParams, + SandboxResolvedSecret, +) +from tests.test_sandbox_wire_contract import SANDBOX_DETAIL_PAYLOAD +from tests.test_secrets import ( + AGENT_PROCESS, + RESULT, + AsyncRuntimeScript, + RuntimeScript, + _sandbox_manager, +) + +SOURCE = { + "token": "op-token", + "vault": "Dev Vault for Testing", + "fields": { + "SC_USER": {"item": "scrapingcourse login", "field": "username"}, + "SC_OTP": { + "item": "scrapingcourse login", + "section": "Security", + "field": "otp?attribute=totp", + }, + }, +} +WIRE = { + "token": "op-token", + "vault": "Dev Vault for Testing", + "fields": { + "SC_USER": {"item": "scrapingcourse login", "field": "username"}, + "SC_OTP": { + "item": "scrapingcourse login", + "section": "Security", + "field": "otp?attribute=totp", + }, + }, +} +BINDINGS = [ + { + "env": "SC_USER", + "reference": "op://Dev Vault for Testing/scrapingcourse login/username", + "tokenSecret": "op-token", + "source": {"type": "onePassword"}, + } +] + + +def _with_field(**field): + return dict(SOURCE, fields={"X": dict({"item": "i", "field": "f"}, **field)}) + + +def test_bindings_describe_1password_variables(): + binding = SandboxResolvedSecret.model_validate(BINDINGS[0]) + assert binding.reference == ( + "op://Dev Vault for Testing/scrapingcourse login/username" + ) + assert binding.token_secret == "op-token" + assert binding.source is not None and binding.source.type == "onePassword" + assert (binding.name, binding.secret_id, binding.version) == (None, None, None) + assert binding.secret_type is None and binding.overrides == [] + + +# --- sandbox create --- + + +def test_create_params_send_one_password_as_onePassword(): + model = OnePasswordSource( + token="op-token", + vault="Dev Vault for Testing", + fields={ + "SC_USER": OnePasswordField(item="scrapingcourse login", field="username"), + "SC_OTP": OnePasswordField( + item="scrapingcourse login", + section="Security", + field="otp?attribute=totp", + ), + }, + ) + for source in [SOURCE, model]: + params = CreateSandboxParams( + image_name="node", one_password=source, secrets={"API_KEY": "k"} + ) + assert params.model_dump(by_alias=True, exclude_none=True) == { + "imageName": "node", + "secrets": {"API_KEY": {"name": "k"}}, + "onePassword": WIRE, + } + with pytest.raises(ValueError, match="only supported for image launches"): + CreateSandboxParams(snapshot_name="snap", one_password=SOURCE) + + +@pytest.mark.parametrize("async_client", [False, True], ids=["sync", "async"]) +@pytest.mark.anyio +async def test_create_sends_one_password_and_keeps_the_startup_bindings(async_client): + manager, http = _sandbox_manager( + { + ("POST", "/sandbox"): ( + 200, + dict(SANDBOX_DETAIL_PAYLOAD, startupSecrets=BINDINGS), + ) + }, + async_client=async_client, + ) + result = manager.create({"image_name": "node", "one_password": SOURCE}) + sandbox = await result if async_client else result + + assert http.calls[0]["json"] == {"imageName": "node", "onePassword": WIRE} + assert sandbox.startup_secrets is not None + assert sandbox.startup_secrets[0].source.type == "onePassword" + + +@pytest.mark.anyio +async def test_async_create_with_one_password_is_an_unknown_outcome_on_timeout(): + import httpx + + manager, _ = _sandbox_manager( + {("POST", "/sandbox"): httpx.ReadTimeout("timed out")}, async_client=True + ) + with pytest.raises(HyperbrowserError) as error: + await manager.create({"image_name": "node", "one_password": SOURCE}) + assert error.value.code == "secret_process_outcome_unknown" + assert error.value.retryable is False + + +# --- process start and exec --- + + +def test_starts_and_execs_with_one_password_go_through_the_control_api(): + starts = [] + + def start(payload): + starts.append(payload) + return {"process": AGENT_PROCESS, "secrets": BINDINGS} + + api = SandboxProcessesApi(RuntimeScript(waits=[RESULT]), start, "sbx_123") + + handle = api.start("node app.js", one_password=SOURCE) + assert starts[-1] == {"command": "node app.js", "onePassword": WIRE} + assert handle.secrets is not None + assert handle.secrets[0].token_secret == "op-token" + + result = api.exec( + {"command": "node app.js", "one_password": SOURCE, "env": {"MODE": "x"}}, + vault="github-login", + secrets={"API_KEY": "provider-key"}, + ) + assert result.stdout == "ok\n" + assert starts[-1] == { + "command": "node app.js", + "env": {"MODE": "x"}, + "secrets": {"API_KEY": {"name": "provider-key"}}, + "vault": "github-login", + "onePassword": WIRE, + } + + with pytest.raises(HyperbrowserError, match="use_shell"): + api.start({"command": "x", "use_shell": True}, one_password=SOURCE) + assert len(starts) == 2 + + +@pytest.mark.anyio +async def test_async_starts_and_execs_with_one_password_go_through_the_control_api(): + starts = [] + + async def start(payload): + starts.append(payload) + return {"process": AGENT_PROCESS, "secrets": BINDINGS} + + api = AsyncSandboxProcessesApi(AsyncRuntimeScript(waits=[RESULT]), start, "sbx_123") + + handle = await api.start("node app.js", one_password=SOURCE) + assert handle.secrets is not None and handle.secrets[0].env == "SC_USER" + result = await api.exec( + "node app.js", one_password=OnePasswordSource.model_validate(SOURCE) + ) + assert result.stdout == "ok\n" + assert starts == [ + {"command": "node app.js", "onePassword": WIRE}, + {"command": "node app.js", "onePassword": WIRE}, + ] + + +@pytest.mark.parametrize("async_client", [False, True], ids=["sync", "async"]) +@pytest.mark.anyio +async def test_sandbox_handles_send_one_password_to_the_processes_route(async_client): + manager, http = _sandbox_manager( + { + ("POST", "/sandbox/sbx_123/processes"): ( + 200, + {"process": AGENT_PROCESS, "secrets": BINDINGS}, + ) + }, + async_client=async_client, + ) + sandbox = manager.attach(SandboxDetail(**SANDBOX_DETAIL_PAYLOAD)) + + result = sandbox.processes.start("node app.js", one_password=SOURCE) + handle = await result if async_client else result + + assert http.calls[0]["path"] == "/sandbox/sbx_123/processes" + assert http.calls[0]["json"] == {"command": "node app.js", "onePassword": WIRE} + assert handle.secrets is not None and handle.secrets[0].reference is not None + + +# --- local checks --- + + +def test_names_allow_what_the_server_allows(): + OnePasswordSource.model_validate( + dict( + SOURCE, + fields={ + "A": {"item": "My Login (old)", "field": "password"}, + "B": {"item": "x" * 256, "section": "Ünïcødé ✓", "field": "f"}, + "C": {"item": "i", "field": "otp?attribute=otp&ssh-format=openssh"}, + "D": {"item": "...", "field": ".hidden"}, + # An astral character is 2 UTF-16 units: 256 in all. + "E": {"item": "\U0001f600" * 64 + "e" * 128, "field": "f"}, + }, + ) + ) + # op://V/<250>/<250>/fff is exactly 512 bytes. + OnePasswordSource.model_validate( + dict( + SOURCE, + vault="V", + fields={"X": {"item": "i" * 250, "section": "s" * 250, "field": "fff"}}, + ) + ) + + +INVALID_SOURCES = [ + (_with_field(item="."), "item"), + (_with_field(item=".."), "item"), + (_with_field(item="a?b"), "item"), + (_with_field(item="a/b"), "item"), + (_with_field(field="."), "field"), + (_with_field(field="..?attribute=otp"), "field"), + (_with_field(field="otp?Attr=x"), "query"), + (_with_field(field="otp?attribute"), "query"), + (_with_field(field="otp?a=b&c"), "query"), + (_with_field(field="otp?a=b?c"), "query"), + (_with_field(item="i" * 257), "longer than 256"), + # 193 characters, but 257 UTF-16 units. + (_with_field(item="\U0001f600" * 64 + "e" * 129), "longer than 256"), + (_with_field(item="a​b"), "item"), + (_with_field(section="a
b"), "section"), + (_with_field(item="a\nb"), "item"), + (_with_field(section=""), "section"), + ( + dict( + SOURCE, + vault="V", + fields={"X": {"item": "i" * 250, "section": "s" * 250, "field": "ffff"}}, + ), + "513 bytes", + ), + (dict(SOURCE, fields={}), "1 to 32 fields"), + ( + dict(SOURCE, fields={f"E{i}": {"item": "i", "field": "f"} for i in range(33)}), + "1 to 32 fields", + ), + (dict(SOURCE, fields={"1X": {"item": "i", "field": "f"}}), "environment"), + (dict(SOURCE, fields={"__proto__": {"item": "i", "field": "f"}}), "environment"), + (dict(SOURCE, token="bad name"), "secret name"), + (dict(SOURCE, vault="a/b"), "1Password vault"), + (_with_field(label="x"), "Extra inputs"), + (dict(SOURCE, tokenSecret="op-token"), "Extra inputs"), + ({"token": "op-token", "vault": "V"}, "fields"), +] + + +@pytest.mark.parametrize("source,message", INVALID_SOURCES) +@pytest.mark.parametrize("async_client", [False, True], ids=["sync", "async"]) +@pytest.mark.anyio +async def test_invalid_sources_fail_before_any_request(async_client, source, message): + manager, http = _sandbox_manager({}, async_client=async_client) + with pytest.raises(ValueError, match=message): + result = manager.create({"image_name": "node", "one_password": source}) + if async_client: + await result + + api_type = AsyncSandboxProcessesApi if async_client else SandboxProcessesApi + api = api_type(RuntimeScript(), None, "sbx_123") + with pytest.raises(ValueError, match=message): + result = api.start("node app.js", one_password=source) + if async_client: + await result + with pytest.raises(ValueError, match=message): + SandboxExecParams(command="x", one_password=source) + assert http.calls == [] + + +def test_a_variable_from_one_password_may_not_come_from_secrets_or_env(): + with pytest.raises(ValueError, match="SC_USER is set by secrets and by one_pass"): + CreateSandboxParams( + image_name="node", one_password=SOURCE, secrets={"SC_USER": "user"} + ) + + api = SandboxProcessesApi(RuntimeScript(), None, "sbx_123") + with pytest.raises(HyperbrowserError, match="SC_OTP is set by secrets") as error: + api.start("node app.js", one_password=SOURCE, secrets={"SC_OTP": "otp"}) + assert error.value.code == "invalid_secret_reference" + with pytest.raises(HyperbrowserError, match="SC_USER is set by env"): + api.start( + {"command": "node app.js", "env": {"SC_USER": "x"}}, one_password=SOURCE + ) + + # Only the server knows a vault's entries, so a vault clash is its to refuse. + starts = [] + api = SandboxProcessesApi( + RuntimeScript(), + lambda payload: starts.append(payload) or {"process": AGENT_PROCESS}, + "sbx_123", + ) + api.start("node app.js", one_password=SOURCE, vault="github-login") + assert starts == [ + {"command": "node app.js", "vault": "github-login", "onePassword": WIRE} + ] + + +@pytest.mark.anyio +async def test_async_clashes_fail_before_any_request(): + api = AsyncSandboxProcessesApi(AsyncRuntimeScript(), None, "sbx_123") + with pytest.raises(HyperbrowserError, match="SC_USER is set by env"): + await api.start("node app.js", env={"SC_USER": "x"}, one_password=SOURCE) diff --git a/tests/test_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index 96be634a..ca6e5ba2 100644 --- a/tests/test_sandbox_wire_contract.py +++ b/tests/test_sandbox_wire_contract.py @@ -1691,6 +1691,7 @@ def fake_exec(input, **kwargs): "max_output_bytes": 64 * 1024 * 1024, "secrets": None, "vault": None, + "one_password": None, }, ) ] diff --git a/tests/test_vaults.py b/tests/test_vaults.py index 0eb4cd89..d05d6192 100644 --- a/tests/test_vaults.py +++ b/tests/test_vaults.py @@ -11,15 +11,12 @@ from hyperbrowser.models import ( CreateSandboxParams, CreateVaultParams, - OnePasswordEntry, - OnePasswordVaultSource, SandboxDetail, SandboxResolvedSecret, StoredEntry, UpdateVaultParams, Vault, - VaultOnePasswordEntry, - VaultStoredEntry, + VaultEntry, ) from tests.test_credentials import mock_clients, respond from tests.test_sandbox_wire_contract import SANDBOX_DETAIL_PAYLOAD @@ -36,7 +33,6 @@ "id": VAULT_ID, "name": "github-login", "description": "", - "source": {"type": "stored"}, "entries": { "PASSWORD": {"name": "github-password", "secretId": SECRET_ID, "version": 3}, "USERNAME": {"name": "github-username", "secretId": SECRET_ID, "version": None}, @@ -44,26 +40,6 @@ "createdAt": "2026-10-01T00:00:00Z", "updatedAt": "2026-10-01T00:00:00Z", } -ONEPASSWORD_VAULT = { - "id": VAULT_ID, - "name": "prod-db", - "description": "", - "source": { - "type": "1password", - "tokenSecret": {"name": "op-token", "secretId": TOKEN_ID}, - "vault": "Engineering", - }, - "entries": { - "DB_PASS": { - "item": "Postgres", - "section": "prod", - "field": "password", - "reference": "op://Engineering/Postgres/prod/password", - } - }, - "createdAt": "2026-10-01T00:00:00Z", - "updatedAt": "2026-10-01T00:00:00Z", -} @pytest.fixture(params=[False, True], ids=["sync", "async"]) @@ -78,52 +54,39 @@ def _body(request): # --- models --- -def test_vaults_parse_both_sources_and_entry_kinds(): - stored = Vault.model_validate(STORED_VAULT) - assert stored.source.type == "stored" - password = stored.entries["PASSWORD"] - assert isinstance(password, VaultStoredEntry) +def test_vaults_parse_stored_entries(): + vault = Vault.model_validate(STORED_VAULT) + password = vault.entries["PASSWORD"] + assert isinstance(password, VaultEntry) assert isinstance(password, StoredEntry) assert (password.name, password.secret_id, password.version) == ( "github-password", SECRET_ID, 3, ) - username = stored.entries["USERNAME"] + username = vault.entries["USERNAME"] assert isinstance(username, StoredEntry) and username.version is None + assert Vault.model_validate(dict(STORED_VAULT, entries=None)).entries == {} - linked = Vault.model_validate(ONEPASSWORD_VAULT) - assert linked.source.type == "1password" - assert linked.source.vault == "Engineering" - assert linked.source.token_secret is not None - assert linked.source.token_secret.name == "op-token" - assert linked.source.token_secret.secret_id == TOKEN_ID - entry = linked.entries["DB_PASS"] - assert isinstance(entry, VaultOnePasswordEntry) - assert (entry.item, entry.section, entry.field) == ("Postgres", "prod", "password") - assert entry.reference == "op://Engineering/Postgres/prod/password" - - -def test_vaults_from_apis_without_sources_or_with_new_ones_parse(): - legacy = dict(STORED_VAULT) - del legacy["source"] - assert Vault.model_validate(legacy).source.type == "stored" - assert Vault.model_validate(dict(legacy, source=None, entries=None)).entries == {} - - future = Vault.model_validate( - dict(STORED_VAULT, source={"type": "keychain", "account": "x"}, entries={}) - ) - assert future.source.type == "keychain" - assert future.source.model_dump()["account"] == "x" + +def test_a_stray_source_in_a_vault_response_is_ignored(): + for source in [ + {"type": "stored"}, + {"type": "1password", "tokenSecret": {"name": "op-token"}, "vault": "Eng"}, + None, + ]: + vault = Vault.model_validate(dict(STORED_VAULT, source=source)) + assert vault.name == "github-login" + assert not hasattr(vault, "source") # --- requests --- -def test_create_sends_stored_and_1password_vaults(sdk): +def test_create_sends_entries_without_a_source(sdk): client, call, calls, outcomes = sdk respond(outcomes, STORED_VAULT, 201) - respond(outcomes, ONEPASSWORD_VAULT, 201) + respond(outcomes, STORED_VAULT, 201) vault = call( client.vaults.create, @@ -152,31 +115,13 @@ def test_create_sends_stored_and_1password_vaults(sdk): call( client.vaults.create, CreateVaultParams( - name="prod-db", - source=OnePasswordVaultSource( - type="1password", token_secret="op-token", vault="Engineering" - ), - entries={ - "DB_PASS": OnePasswordEntry( - item="Postgres", section="prod", field="password" - ), - "DB_OTP": OnePasswordEntry( - item="Postgres", field="one-time password?attribute=otp" - ), - }, + name="github-otp", + entries={"OTP": StoredEntry(name="github-otp", version=2)}, ), ) assert _body(calls[1]) == { - "name": "prod-db", - "source": { - "type": "1password", - "tokenSecret": "op-token", - "vault": "Engineering", - }, - "entries": { - "DB_PASS": {"item": "Postgres", "section": "prod", "field": "password"}, - "DB_OTP": {"item": "Postgres", "field": "one-time password?attribute=otp"}, - }, + "name": "github-otp", + "entries": {"OTP": {"name": "github-otp", "version": 2}}, } @@ -196,21 +141,13 @@ def test_entries_read_from_a_vault_can_be_sent_back(sdk): } -def _onepassword(entries): - return { - "name": "op", - "source": {"type": "1password", "token_secret": "op-token", "vault": "Eng"}, - "entries": entries, - } - - @pytest.mark.parametrize( "params,message", [ ({"name": "bad name"}, "Invalid secret name"), - ({"name": "v", "entries": {"1PASS": {"name": "s"}}}, "entry name"), - ({"name": "v", "entries": {"__proto__": {"name": "s"}}}, "entry name"), - ({"name": "v", "entries": {"P" * 257: {"name": "s"}}}, "entry name"), + ({"name": "v", "entries": {"1PASS": {"name": "s"}}}, "vault entry"), + ({"name": "v", "entries": {"__proto__": {"name": "s"}}}, "vault entry"), + ({"name": "v", "entries": {"P" * 257: {"name": "s"}}}, "vault entry"), ({"name": "v", "entries": {"P": {"name": "bad name"}}}, "secret name"), ({"name": "v", "entries": {"P": {"name": "s", "version": 0}}}, "version"), ({"name": "v", "entries": {"P": {"name": "s", "version": "next"}}}, "version"), @@ -218,33 +155,19 @@ def _onepassword(entries): {"name": "v", "entries": {f"E{i}": {"name": "s"} for i in range(33)}}, "at most 32", ), - ({"name": "v", "entries": {"P": {"item": "i", "field": "f"}}}, "stored vault"), - (_onepassword({"P": {"name": "s"}}), "1Password vault"), - (_onepassword({"P": {"item": "a/b", "field": "f"}}), "item"), - (_onepassword({"P": {"item": "a?b", "field": "f"}}), "item"), - (_onepassword({"P": {"item": "..", "field": "f"}}), "item"), - (_onepassword({"P": {"item": "a​b", "field": "f"}}), "item"), - (_onepassword({"P": {"item": "a\nb", "field": "f"}}), "item"), - (_onepassword({"P": {"item": "i" * 257, "field": "f"}}), "longer"), - (_onepassword({"P": {"item": "i", "section": "", "field": "f"}}), "section"), - (_onepassword({"P": {"item": "i", "field": "."}}), "field"), - (_onepassword({"P": {"item": "i", "field": "f?"}}), "query"), - (_onepassword({"P": {"item": "i", "field": "f?Attr=otp"}}), "query"), - (_onepassword({"P": {"item": "i", "field": "f?a=b&c"}}), "query"), - (_onepassword({"P": {"item": "i", "field": "f?a=b?c"}}), "query"), - (_onepassword({"P": {"item": "i" * 250, "field": "f" * 256}}), "512 bytes"), + # Vaults hold stored secrets only: 1Password fields and sources are gone. + ({"name": "v", "entries": {"P": {"item": "i", "field": "f"}}}, "name"), ( - {"name": "op", "source": {"type": "1password", "vault": "Eng"}}, - "token_secret", + {"name": "v", "entries": {"P": {"name": "s", "item": "i"}}}, + "Extra inputs", ), ( { "name": "op", - "source": {"type": "1password", "token_secret": "t", "vault": "a/b"}, + "source": {"type": "1password", "token_secret": "t", "vault": "E"}, }, - "1Password vault", + "Extra inputs", ), - ({"name": "op", "source": {"type": "keychain"}}, "type"), ], ) def test_invalid_vaults_fail_before_http(sdk, params, message): @@ -254,25 +177,12 @@ def test_invalid_vaults_fail_before_http(sdk, params, message): assert not calls -def test_1password_names_allow_what_the_server_allows(): - CreateVaultParams.model_validate( - _onepassword( - { - "A": {"item": "My Login (old)", "field": "password"}, - "B": {"item": "x" * 256, "section": "Ünïcødé ✓", "field": "f"}, - "C": {"item": "i", "field": "otp?attribute=otp&ssh-format=openssh"}, - "D": {"item": "...", "field": ".hidden"}, - } - ) - ) - - def test_update_distinguishes_left_out_replaced_and_removed(sdk): client, call, calls, outcomes = sdk respond(outcomes, {"vaults": [STORED_VAULT], "totalCount": 1}) respond(outcomes, STORED_VAULT) - respond(outcomes, ONEPASSWORD_VAULT) - respond(outcomes, ONEPASSWORD_VAULT) + respond(outcomes, STORED_VAULT) + respond(outcomes, STORED_VAULT) call( client.vaults.update, @@ -295,15 +205,24 @@ def test_update_distinguishes_left_out_replaced_and_removed(sdk): client.vaults.update, VAULT_ID, UpdateVaultParams( - description="Prod", - source={"vault": "Platform"}, # type: ignore[arg-type] + description="GitHub", + entries={"OTP": StoredEntry(name="github-otp", version="latest")}, ), ) assert calls[2].url.path == f"/api/vaults/{VAULT_ID}" assert calls[3].method == "PATCH" - assert _body(calls[3]) == {"description": "Prod", "source": {"vault": "Platform"}} + assert _body(calls[3]) == { + "description": "GitHub", + "entries": {"OTP": {"name": "github-otp", "version": "latest"}}, + } - for bad in [{}, {"description": None}, {"source": {}}, {"entries": {"1X": None}}]: + for bad in [ + {}, + {"description": None}, + {"entries": {"1X": None}}, + {"source": {"vault": "Platform"}}, + {"entries": {"P": {"item": "i", "field": "f"}}}, + ]: with pytest.raises(ValueError): call(client.vaults.update, VAULT_ID, bad) assert len(calls) == 4 @@ -314,7 +233,7 @@ def test_list_get_and_delete_resolve_names_and_ids(sdk): listing = {"vaults": [STORED_VAULT], "totalCount": 1, "page": 1, "perPage": 20} respond(outcomes, listing) respond(outcomes, listing) - respond(outcomes, ONEPASSWORD_VAULT) + respond(outcomes, dict(STORED_VAULT, name="prod-db")) respond(outcomes, STORED_VAULT) respond(outcomes, {"deleted": True, "id": VAULT_ID, "name": "github-login"}) respond(outcomes, {"code": "VAULT_NOT_FOUND", "message": "Vault not found"}, 404) @@ -352,17 +271,14 @@ def test_vault_errors_keep_the_server_code(sdk): outcomes, { "code": "SECRET_TYPE_MISMATCH", - "message": "a 1Password vault's token must be an op_token secret", + "message": 'Secret "op-token" is an op_token; vault entries take stored secrets', }, 409, ) with pytest.raises(HyperbrowserError) as error: call( client.vaults.create, - { - "name": "op", - "source": {"type": "1password", "token_secret": "pw", "vault": "Eng"}, - }, + {"name": "op", "entries": {"TOKEN": {"name": "op-token"}}}, ) assert error.value.code == "SECRET_TYPE_MISMATCH" assert error.value.status_code == 409 @@ -393,7 +309,7 @@ def test_vault_errors_keep_the_server_code(sdk): "env": "DB_PASS", "reference": "op://Engineering/Postgres/prod/password", "tokenSecret": "op-token", - "source": {"type": "vault", "name": "prod-db"}, + "source": {"type": "onePassword"}, }, ] @@ -408,6 +324,8 @@ def test_bindings_describe_stored_totp_and_1password_variables(): assert (linked.name, linked.secret_id, linked.version) == (None, None, None) assert linked.reference == "op://Engineering/Postgres/prod/password" assert linked.token_secret == "op-token" + assert linked.source is not None and linked.source.type == "onePassword" + assert linked.source.name is None and linked.overrides == [] def test_sandbox_create_sends_a_vault_and_keeps_the_startup_bindings(): diff --git a/tests/typecheck/invalid_requests.py b/tests/typecheck/invalid_requests.py index 1e4bb746..4a8a5b80 100644 --- a/tests/typecheck/invalid_requests.py +++ b/tests/typecheck/invalid_requests.py @@ -126,7 +126,9 @@ def invalid_credential_requests(client: Hyperbrowser) -> None: client.secrets.create({"name": "github", "allowed_origins": "https://x.io"}) # M,P client.vaults.create({"name": "v", "entries": {"A": {"version": 1}}}) # M,P client.vaults.create({"name": "v", "source": {"type": "1password"}}) # M,P - client.vaults.update("v", {"source": {"type": "stored"}}) # M,P + client.vaults.update("v", {"entries": {"A": {"item": "i", "field": "f"}}}) # M,P + client.sandboxes.create({"image_name": "node", "one_password": {"token": "t", "vault": "v"}}) # M,P + client.sandboxes.create({"image_name": "node", "onePassword": {"token": "t", "vault": "v", "fields": {}}}) # M,P client.sessions.create({"credentials": "github"}) # M,P client.sessions.create({"credentials": [{"login": "github"}]}) # M,P client.sessions.credentials.fill("s", {"credential": "github"}) # M,P @@ -138,3 +140,5 @@ async def invalid_async_credential_requests(client: AsyncHyperbrowser) -> None: await client.secrets.add_version("github", {"username": "a"}) # M,P await client.sessions.credentials.fill("s", {"credential": "c", "fields": [], "frame": "u"}) # M,P await client.vaults.create({"entries": {}}) # M,P + sandbox = await client.sandboxes.get("s") + await sandbox.exec("x", one_password={"token": "t", "vault": "v", "fields": {"A": {"item": "i"}}}) # M,P diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index a1739db4..ea679dcc 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -589,13 +589,13 @@ def valid_credential_requests(client: Hyperbrowser) -> None: from hyperbrowser.models import ( CredentialFillField, FillCredentialParams, - OnePasswordEntry, - OnePasswordVaultSource, + OnePasswordField, + OnePasswordSource, SecretMetadata, StoredEntry, UpdateSecretParams, UpdateVaultParams, - VaultStoredEntry, + VaultEntry, ) secret = client.secrets.create( @@ -640,51 +640,75 @@ def valid_credential_requests(client: Hyperbrowser) -> None: } ) for env, entry in vault.entries.items(): - if isinstance(entry, VaultStoredEntry): - print(env, entry.name, entry.secret_id, entry.version) - else: - print(env, entry.item, entry.section, entry.field, entry.reference) - source_type: str = vault.source.type - client.vaults.create( - { - "name": "prod-db", - "source": { - "type": "1password", - "token_secret": "op-token", - "vault": "Engineering", - }, - "entries": { - "DB_PASS": {"item": "Postgres", "section": "prod", "field": "password"} - }, - } - ) + stored: VaultEntry = entry + print(env, stored.name, stored.secret_id, stored.version) client.vaults.create( LegacyCreateVaultParams( - name="prod-db", - source=OnePasswordVaultSource( - type="1password", token_secret="op-token", vault="Eng" - ), - entries={"DB": OnePasswordEntry(item="Postgres", field="password")}, + name="github-otp", + entries={"OTP": StoredEntry(name="github-otp", version="latest")}, ) ) client.vaults.update("github-login", {"entries": {"OTP": None}}) - client.vaults.update("prod-db", {"source": {"vault": "Platform"}}) client.vaults.update( "github-login", UpdateVaultParams(entries={"OTP": StoredEntry(name="github-otp"), "X": None}), ) listing = client.vaults.list({"search": "git", "limit": 10}) - print(listing.total_count, client.vaults.get("github-login").id, source_type) + print(listing.total_count, client.vaults.get("github-login").id) client.vaults.delete("github-login") sandbox = client.sandboxes.create( - {"image_name": "node", "vault": "github-login", "secrets": {"A": "b"}} + { + "image_name": "node", + "vault": "github-login", + "secrets": {"A": "b"}, + "one_password": { + "token": "op-token", + "vault": "Engineering", + "fields": { + "DB_PASS": { + "item": "Postgres", + "section": "prod", + "field": "password", + }, + "DB_OTP": {"item": "Postgres", "field": "otp?attribute=totp"}, + }, + }, + } ) print(sandbox.startup_secrets) handle = sandbox.processes.start("node app.js", vault="prod-db") for binding in handle.secrets or []: + source_type = binding.source.type if binding.source else None print(binding.env, binding.name, binding.reference, binding.token_secret) + print(source_type) sandbox.exec("node app.js", vault="prod-db", secrets={"A": "b"}) + sandbox.exec( + "node app.js", + one_password={ + "token": "op-token", + "vault": "Engineering", + "fields": {"DB_PASS": {"item": "Postgres", "field": "password"}}, + }, + ) + sandbox.processes.start( + { + "command": "node app.js", + "one_password": { + "token": "t", + "vault": "v", + "fields": {"A": {"item": "i", "field": "f"}}, + }, + } + ) + sandbox.processes.start( + "node app.js", + one_password=OnePasswordSource( + token="op-token", + vault="Engineering", + fields={"DB_PASS": OnePasswordField(item="Postgres", field="password")}, + ), + ) session = client.sessions.create( {"credentials": [kind, {"secret": "github-otp"}, {"vault": "github-login"}]} @@ -740,6 +764,14 @@ async def valid_async_credential_requests(client: AsyncHyperbrowser) -> None: print(result.fields) sandbox = await client.sandboxes.create({"image_name": "node", "vault": "v"}) await sandbox.processes.start("node app.js", vault="v") + await sandbox.exec( + "node app.js", + one_password={ + "token": "t", + "vault": "v", + "fields": {"A": {"item": "i", "field": "f"}}, + }, + ) text: str = await CredentialFillTool.async_runnable( client, session.id, {"credential": "x"} ) From 0dac769758e06651a9b7eb1356e9e6d635a2344a Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 11:45:19 -0700 Subject: [PATCH 17/23] Explain how a fill chooses its tab, with the origin check after Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 923f3de3..cdf7166a 100644 --- a/README.md +++ b/README.md @@ -217,11 +217,13 @@ Things to know: secret delivered to a sandbox holds the code current at process start. - A fill takes `credential` or `vault`, and 1-16 fields with unique selectors; `field` is required with a vault and left out without one. - Without `page_id` or `page_url`, a session with one tab fills that tab (on - another site the fill is refused with `CREDENTIAL_ORIGIN_NOT_ALLOWED`). - With several tabs, those matching `page_url` and the allowed origins of the - secrets being filled are candidates: the only one, else the single visible - one, is filled. Under Playwright every tab reports as visible, so pass + Without `page_id`, fill considers the tabs whose URL starts with `page_url` + (all tabs when it's not given). If one is left, that's the target. If several + are left, it prefers the tabs on an origin every secret allows, then the + visible one among them; if more than one still qualifies, it fails with + `CREDENTIAL_PAGE_AMBIGUOUS`. The chosen tab's origin is then checked: on any + other origin nothing is typed and the fill fails with + `CREDENTIAL_ORIGIN_NOT_ALLOWED`. Under Playwright every tab reports as visible, so pass `page_url` (a URL prefix) or the tab's CDP target ID as `page_id`: `page.context.new_cdp_session(page).send("Target.getTargetInfo")["targetInfo"]["targetId"]`. For a login form in an iframe, pass `frame_url` (a URL prefix). From f6178488ed7d21e5e78a7fd1d4cd7deea278c33c Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 12:52:37 -0700 Subject: [PATCH 18/23] Fill 1Password fields into browser sessions through an op_token Sessions attach an op_token with credentials=[{"one_password": name}], sent as {"onePassword": name}. sessions.credentials.fill() takes one_password={"token", "vault", "allowed_origins"} with a ref {"item", "section"?, "field"} on every field, sent as onePassword / allowedOrigins, and checks it all locally as #1552 does. Results gain each field's op:// reference; the top-level version stays optional. Allowed origins are now checked locally as the server normalizes them, for secrets (create, update, set_allowed_origins) and for 1Password fills alike. fill_credential stays without 1Password: a model must not choose the origins its values may be typed on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 94 +++- .../async_manager/session_credentials.py | 16 +- .../sync_manager/session_credentials.py | 16 +- hyperbrowser/credentials_common.py | 43 +- hyperbrowser/models/__init__.py | 4 + hyperbrowser/models/credential.py | 70 ++- hyperbrowser/models/secret.py | 94 +++- hyperbrowser/models/session.py | 33 +- hyperbrowser/secrets_common.py | 17 +- hyperbrowser/tools/__init__.py | 7 + hyperbrowser/types/__init__.py | 4 + hyperbrowser/types/credential.py | 23 +- hyperbrowser/types/session.py | 13 +- tests/test_credential_tools.py | 16 + tests/test_credentials.py | 424 ++++++++++++++++++ tests/typecheck/invalid_requests.py | 4 + tests/typecheck/valid_requests.py | 59 ++- 17 files changed, 876 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index cdf7166a..65df51ae 100644 --- a/README.md +++ b/README.md @@ -188,8 +188,8 @@ A single secret works without a vault: attach it by name (`"credentials": `fill()` returns the filled page's `page_id` and `origin`, the `version` for a single-secret fill, and per input its `selector`, `outcome`, `secret_id`, -`version`, `field` (vault fills) and `expires_at` (TOTP codes). Values are -never returned. +`version`, `field` (vault fills), `reference` (1Password fills) and +`expires_at` (TOTP codes). Values are never returned. | Outcome | Meaning | | --- | --- | @@ -203,6 +203,10 @@ Things to know: - A secret can be filled only on its allowed origins (`scheme://host[:port]`; `https://*.example.com` matches any subdomain but not `example.com` itself). + A secret takes 1-20 of at most 512 characters, https only (http for + `localhost`, `127.0.0.1` and `[::1]`), with no path and `*.` only on a + registrable domain such as `example.com`, never `*.com` or an IP address. + They are checked locally, as the API would, before any request. Change them with `client.secrets.set_allowed_origins("github-password", [...])`; `None` clears them, after which it can't be filled. `client.secrets.update()` takes `{"metadata": {"browser": None}}` too: each @@ -215,8 +219,10 @@ Things to know: shorter than 10 bytes (16 characters) are accepted but weak. The platform can't read values, so a bad seed would otherwise only fail when used. A TOTP secret delivered to a sandbox holds the code current at process start. -- A fill takes `credential` or `vault`, and 1-16 fields with unique - selectors; `field` is required with a vault and left out without one. +- A fill takes one of `credential`, `vault` and `one_password`, and 1-16 + fields with unique selectors; `field` is required with a vault and left out + without one, and `ref` is required with `one_password` and left out without + it. Without `page_id`, fill considers the tabs whose URL starts with `page_url` (all tabs when it's not given). If one is left, that's the target. If several are left, it prefers the tabs on an origin every secret allows, then the @@ -234,7 +240,9 @@ Things to know: credentials run as Firecracker browsers with Chrome's password manager off. A vault attaches the secrets its entries hold when the session is created. - Fills are recorded in the session's event log as `credential_filled` and - `credential_fill_refused`. + `credential_fill_refused`. Their metadata names the `credential` or `vault`; + for 1Password fills, `onePasswordToken` (the op_token's name) and its + `secretId`. - Errors are raised as `HyperbrowserError` with the API's `code`, `retryable`, and `status_code`. `details` holds the API's response body, so the candidate tabs of an ambiguous fill are in `error.details["details"]["pages"]` @@ -244,11 +252,14 @@ Things to know: (409) is the same for frames. - `CREDENTIAL_ORIGIN_NOT_ALLOWED` (403): the page's origin (in `details`) isn't allowed; nothing was typed. - - `CREDENTIAL_NOT_ATTACHED` (403): the secret wasn't attached when the - session was created, or a vault entry now points at another secret. + - `CREDENTIAL_NOT_ATTACHED` (403): the secret (or a 1Password fill's + op_token) wasn't attached when the session was created, or a vault entry + now points at another secret. - `CREDENTIAL_NOT_READY` (409): the secret has no allowed origins or no value. - - `SECRET_TYPE_MISMATCH` (409): an `op_token` can't be attached or filled. + - `SECRET_TYPE_MISMATCH` (409): an `op_token` can't be attached or filled + as a secret (by name, `{"secret": ...}` or a vault entry); attach it with + `{"one_password": ...}` instead. Also another type named as one. - `SECRET_VALUE_INVALID` (422): the stored value is unusable, such as a bad TOTP seed; add a new version. - `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND`, @@ -258,14 +269,74 @@ Things to know: `CREDENTIAL_FILL_FAILED` (500/502, retryable as flagged). - `CREDENTIALS_UNSUPPORTED_RUNTIME` (400), `CREDENTIAL_FILL_UNSUPPORTED` (501) and `CREDENTIAL_VALUE_UNSUPPORTED` (501): the session's browser - can't fill this. + can't fill this, such as 1Password fields on an older host. + - `SECRET_ONEPASSWORD_REFERENCE_FAILED` (422): a 1Password item or field + couldn't be read, so nothing was filled. `details` has the failing + `selectors` and `reasons` by selector. `SECRET_ONEPASSWORD_AUTH_FAILED` + (422): the token can't sign in. `SECRET_ONEPASSWORD_RATE_LIMITED` (429) + and `SECRET_ONEPASSWORD_FAILED` (502) are retryable. Each has the + token's `tokenSecretId` and `tokenVersion` in `details`. - Locally, invalid fill requests raise `invalid_request` and invalid values raise `invalid_secret_value`, before any request. +### Fill from 1Password + +A fill can also type fields of a 1Password vault, read with a 1Password +service account token stored as an `op_token` secret (see +[Manage secrets](#manage-secrets-and-pass-them-to-sandbox-processes)). Attach +the token when creating the session, then name the vault, the origins the +values may be typed on, and each input's field: + +```python +session = client.sessions.create({"credentials": [{"one_password": "op-token"}]}) +# ... open https://www.scrapingcourse.com/login in the session ... +result = client.sessions.credentials.fill( + session.id, + { + "one_password": { + "token": "op-token", # the op_token secret; its latest version signs in + "vault": "Dev Vault", # the 1Password vault's name or ID + # Nothing stored says where a 1Password value belongs, so the + # request names the origins it may be typed on. + "allowed_origins": ["https://www.scrapingcourse.com"], + }, + "fields": [ + {"selector": "#email", "ref": {"item": "scrapingcourse login", "field": "username"}}, + {"selector": "#password", "ref": {"item": "scrapingcourse login", "field": "password"}}, + { + "selector": "#otp", + "ref": {"item": "scrapingcourse login", "section": "Security", "field": "otp?attribute=totp"}, + }, + ], + "page_url": "https://www.scrapingcourse.com/login", + }, +) +for field in result.fields: + print(field.selector, field.outcome, field.reference) # op://Dev Vault/... +``` + +- `token` names the op_token secret, which must be attached to the session + (`{"one_password": name}` counts toward the 20 credentials). An op_token is + never typed itself, and attaching or filling it as a plain secret fails with + `SECRET_TYPE_MISMATCH`. +- `allowed_origins` takes 1-20 origins with the same rules as a secret's. + A page on another origin fails with + `CREDENTIAL_ORIGIN_NOT_ALLOWED`, as for stored secrets. +- `ref` is `{"item", "section"?, "field"}` by name or ID, with the same rules + as a sandbox's `one_password` fields: a field may end with a query such as + `?attribute=otp`, and `op:////[
/]` is at most + 512 bytes. Every field takes `ref` and none takes `field`. +- The result has no top-level `version`; each input's `secret_id` and + `version` are the op_token's, and `reference` is the `op://` reference it + was typed from. +- All of this is checked locally before any request. + ### Use with LLM SDKs `CredentialFillTool` lets a model fill a secret or vault by name while your -application binds the session ID. The model chooses only `credential` or +application binds the session ID. It doesn't offer 1Password fills: their +request names the allowed origins, which a model shouldn't choose, so call +`client.sessions.credentials.fill()` with `one_password` directly. The model chooses only `credential` or `vault`, the fields (`selector`, and `field` with a vault), and optionally `page_id`, `page_url` or `frame_url`; it never sees the values. `runnable()` returns JSON text for the model: the fill result, or `{"error": {"code", @@ -633,7 +704,8 @@ client.close() Values can also come straight from a 1Password vault. Store a 1Password service account token (it starts with `ops_`) as an `op_token` secret: it is only used to sign in to 1Password, never set as a variable or typed into a -browser, and can't have allowed origins. Then name the fields each variable +browser itself, and can't have allowed origins. Browser sessions can fill +1Password fields too; see [Fill from 1Password](#fill-from-1password). Then name the fields each variable takes with `one_password` when creating a sandbox or starting a process. Nothing about them is stored: the sandbox reads the fields when the process starts. diff --git a/hyperbrowser/client/managers/async_manager/session_credentials.py b/hyperbrowser/client/managers/async_manager/session_credentials.py index 267f034a..e5e52897 100644 --- a/hyperbrowser/client/managers/async_manager/session_credentials.py +++ b/hyperbrowser/client/managers/async_manager/session_credentials.py @@ -14,7 +14,7 @@ class SessionCredentialsManager: """Fill secrets attached to a session into its pages. - Attach secrets or vaults with + Attach secrets, vaults or op_token secrets (for 1Password fields) with ``sessions.create({"credentials": [...]})``. """ @@ -26,14 +26,16 @@ async def fill( session_id: str, params: Union[FillCredentialParamsDict, FillCredentialParams], ) -> CredentialFillResult: - """Type a secret, or a vault's entries, into the inputs matching each - selector. + """Type a secret, a vault's entries, or 1Password fields into the + inputs matching each selector. Pass ``credential`` (a secret's name or ID) with - ``[{"selector": ...}]``, or ``vault`` with - ``[{"field": ENTRY, "selector": ...}]``. A TOTP secret types the code - that is current at fill time. Fill never submits: click the login - button or press Enter afterwards. It is safe to retry. Values are + ``[{"selector": ...}]``, ``vault`` with + ``[{"field": ENTRY, "selector": ...}]``, or ``one_password`` + (``{"token", "vault", "allowed_origins"}``) with + ``[{"ref": {"item", "section", "field"}, "selector": ...}]``. A + TOTP secret types the code that is current at fill time. Fill never + submits: click the login button or press Enter afterwards. It is safe to retry. Values are never returned; each input reports an outcome such as ``filled`` or ``not_found``. When several tabs could be the target, the API fails with ``CREDENTIAL_PAGE_AMBIGUOUS`` and lists them in diff --git a/hyperbrowser/client/managers/sync_manager/session_credentials.py b/hyperbrowser/client/managers/sync_manager/session_credentials.py index 8f20ab15..e86d3af4 100644 --- a/hyperbrowser/client/managers/sync_manager/session_credentials.py +++ b/hyperbrowser/client/managers/sync_manager/session_credentials.py @@ -14,7 +14,7 @@ class SessionCredentialsManager: """Fill secrets attached to a session into its pages. - Attach secrets or vaults with + Attach secrets, vaults or op_token secrets (for 1Password fields) with ``sessions.create({"credentials": [...]})``. """ @@ -26,14 +26,16 @@ def fill( session_id: str, params: Union[FillCredentialParamsDict, FillCredentialParams], ) -> CredentialFillResult: - """Type a secret, or a vault's entries, into the inputs matching each - selector. + """Type a secret, a vault's entries, or 1Password fields into the + inputs matching each selector. Pass ``credential`` (a secret's name or ID) with - ``[{"selector": ...}]``, or ``vault`` with - ``[{"field": ENTRY, "selector": ...}]``. A TOTP secret types the code - that is current at fill time. Fill never submits: click the login - button or press Enter afterwards. It is safe to retry. Values are + ``[{"selector": ...}]``, ``vault`` with + ``[{"field": ENTRY, "selector": ...}]``, or ``one_password`` + (``{"token", "vault", "allowed_origins"}``) with + ``[{"ref": {"item", "section", "field"}, "selector": ...}]``. A + TOTP secret types the code that is current at fill time. Fill never + submits: click the login button or press Enter afterwards. It is safe to retry. Values are never returned; each input reports an outcome such as ``filled`` or ``not_found``. When several tabs could be the target, the API fails with ``CREDENTIAL_PAGE_AMBIGUOUS`` and lists them in diff --git a/hyperbrowser/credentials_common.py b/hyperbrowser/credentials_common.py index db83acb2..fc3fa865 100644 --- a/hyperbrowser/credentials_common.py +++ b/hyperbrowser/credentials_common.py @@ -6,8 +6,13 @@ from .client._request import coerce_request from .exceptions import HyperbrowserError -from .models.credential import FillCredentialParams -from .models.secret import SECRET_ENV_NAME_PATTERN, SECRET_NAME_PATTERN +from .models.credential import CredentialFillField, FillCredentialParams +from .models.secret import ( + MAX_ONEPASSWORD_REFERENCE_BYTES, + SECRET_ENV_NAME_PATTERN, + SECRET_NAME_PATTERN, + onepassword_reference, +) from .models.vault import MAX_VAULT_ENV_NAME_LENGTH MAX_CREDENTIAL_FILL_FIELDS = 16 @@ -32,6 +37,25 @@ def _check_bytes(value: Any, name: str, limit: int) -> None: raise _invalid(f"{name} must be 1 to {limit} bytes when set") +def _check_onepassword_field(vault: str, entry: CredentialFillField) -> None: + if entry.field is not None: + raise _invalid( + "field names a vault entry; with one_password each field takes " + "ref (the 1Password field) instead" + ) + if entry.ref is None: + raise _invalid( + f"The field for {entry.selector!r} needs ref: the 1Password field " + "to type" + ) + size = _utf8_length(onepassword_reference(vault, entry.ref)) + if size > MAX_ONEPASSWORD_REFERENCE_BYTES: + raise _invalid( + f"The 1Password reference for {entry.selector!r} is {size} bytes; " + f"at most {MAX_ONEPASSWORD_REFERENCE_BYTES} are allowed" + ) + + def fill_credential_payload(params: Any) -> Dict[str, Any]: """Check a fill request locally and return its JSON body. @@ -50,10 +74,12 @@ def fill_credential_payload(params: Any) -> Dict[str, Any]: except TypeError as error: raise _invalid(str(error)) from None - if (request.credential is None) == (request.vault is None): + sources = [request.credential, request.vault, request.one_password] + if sum(source is not None for source in sources) != 1: raise _invalid( - "Pass exactly one of credential (a secret attached to the session) " - "and vault (a vault attached to the session)" + "Pass exactly one of credential (a secret attached to the session), " + "vault (a vault attached to the session) and one_password (1Password " + "fields read with an op_token attached to the session)" ) if request.credential is not None and not ( request.credential.strip() @@ -82,6 +108,13 @@ def fill_credential_payload(params: Any) -> Dict[str, Any]: if entry.selector in selectors: raise _invalid(f"The selector {entry.selector!r} is listed more than once") selectors.add(entry.selector) + if request.one_password is not None: + _check_onepassword_field(request.one_password.vault, entry) + continue + if entry.ref is not None: + raise _invalid( + "ref names a 1Password field; pass it only with one_password" + ) if request.vault is None: if entry.field is not None: raise _invalid( diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index fe1d7fec..33c87226 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -4,6 +4,7 @@ CredentialFillOutcome, CredentialFillResult, FillCredentialParams, + OnePasswordFillSource, ) from .webmcp import ( WebMCPAnnotations, @@ -348,6 +349,7 @@ Session, SessionCredential, SessionCredentialInput, + SessionCredentialOnePassword, SessionCredentialSecret, SessionCredentialVault, SessionDetail, @@ -695,6 +697,7 @@ # secret "CreateSecretParams", "OnePasswordField", + "OnePasswordFillSource", "OnePasswordSource", "SandboxResolvedSecret", "Secret", @@ -749,6 +752,7 @@ "Session", "SessionCredential", "SessionCredentialInput", + "SessionCredentialOnePassword", "SessionCredentialSecret", "SessionCredentialVault", "SessionDetail", diff --git a/hyperbrowser/models/credential.py b/hyperbrowser/models/credential.py index c322da08..c0ecc7b6 100644 --- a/hyperbrowser/models/credential.py +++ b/hyperbrowser/models/credential.py @@ -3,7 +3,14 @@ from datetime import datetime from typing import List, Literal, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .secret import ( + OnePasswordField, + validate_allowed_origins, + validate_onepassword_name, + validate_secret_name, +) # What a fill did with each requested input. New outcomes may be added, so # results keep any string the server sends. @@ -25,24 +32,61 @@ class CredentialFillField(BaseModel): """Types a value into the input matching the CSS ``selector``. With a ``vault`` fill, ``field`` names the vault entry (its environment - variable name, such as ``"PASSWORD"``) whose secret is typed; without - one it is left out. + variable name, such as ``"PASSWORD"``) whose secret is typed; with a + ``one_password`` fill, ``ref`` names the 1Password field + (``{"item", "section", "field"}``) whose value is typed. Otherwise both + are left out. """ model_config = ConfigDict(populate_by_name=True, extra="forbid") selector: str field: Optional[str] = None + ref: Optional[OnePasswordField] = None -class FillCredentialParams(BaseModel): - """Fill a secret, or a vault's entries, attached to the session at - creation into a page's inputs. +class OnePasswordFillSource(BaseModel): + """1Password fields to fill, read with an ``op_token`` secret attached to + the session. + + ``token`` names the op_token secret (its latest version signs in); + ``vault`` is the 1Password vault's name or ID. ``allowed_origins`` lists + the 1-20 origins (``scheme://host[:port]``, a leading ``*.`` for + subdomains) the values may be typed on: unlike stored secrets, nothing + else says where a 1Password value belongs. + """ + + model_config = ConfigDict(populate_by_name=True, extra="forbid") - Pass exactly one of ``credential`` (a secret's name or ID) and ``vault`` - (a vault's name). ``page_id`` (a CDP target ID) or ``page_url`` (a URL - prefix) picks the tab when several could be the target; ``frame_url`` - (a URL prefix) picks an iframe. + token: str + vault: str + allowed_origins: List[str] = Field(serialization_alias="allowedOrigins") + + @field_validator("token") + @classmethod + def validate_token(cls, value): + return validate_secret_name(value) + + @field_validator("vault") + @classmethod + def validate_vault(cls, value): + return validate_onepassword_name(value, "The 1Password vault") + + @field_validator("allowed_origins", mode="before") + @classmethod + def validate_allowed_origins(cls, value): + return validate_allowed_origins(value) + + +class FillCredentialParams(BaseModel): + """Fill a secret, a vault's entries, or 1Password fields into a page's + inputs. + + Pass exactly one of ``credential`` (a secret's name or ID), ``vault`` (a + vault's name) and ``one_password`` (fields read with an op_token), each + attached to the session at creation. ``page_id`` (a CDP target ID) or + ``page_url`` (a URL prefix) picks the tab when several could be the + target; ``frame_url`` (a URL prefix) picks an iframe. """ # Unknown members fail rather than fill a page the caller didn't mean. @@ -50,6 +94,9 @@ class FillCredentialParams(BaseModel): credential: Optional[str] = None vault: Optional[str] = None + one_password: Optional[OnePasswordFillSource] = Field( + default=None, serialization_alias="onePassword" + ) fields: List[CredentialFillField] page_id: Optional[str] = Field(default=None, serialization_alias="pageId") page_url: Optional[str] = Field(default=None, serialization_alias="pageUrl") @@ -69,6 +116,9 @@ class CredentialFieldResult(BaseModel): version: Optional[int] = None # The vault entry, for vault fills. field: Optional[str] = None + # The op:// reference typed, for 1Password fills; secret_id and version + # are then the op_token's. + reference: Optional[str] = None # When the typed TOTP code stops being valid, for TOTP secrets. expires_at: Optional[datetime] = Field(default=None, alias="expiresAt") diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py index cc2c66ba..6e9b9ff7 100644 --- a/hyperbrowser/models/secret.py +++ b/hyperbrowser/models/secret.py @@ -1,3 +1,4 @@ +import ipaddress import re import unicodedata from datetime import datetime @@ -277,6 +278,83 @@ def onepassword_clashes( SecretValue = Union[str, bytes] +MAX_ALLOWED_ORIGINS = 20 +MAX_ALLOWED_ORIGIN_LENGTH = 512 +# The server's form for allowed origins: scheme://host[:port], where the host +# may start with "*." to match any subdomain. ASCII only, as in JavaScript +# without the u flag: no Unicode case folding or digits. +_ALLOWED_ORIGIN_PATTERN = re.compile( + r"([a-z]+)://(\*\.)?([^/:?#]+|\[[^\]]+\])(?::([0-9]{1,5}))?/?", + re.IGNORECASE | re.ASCII, +) +_ORIGIN_HOST_LABEL = re.compile(r"(?!-)[a-z0-9-]{1,63}(? str: + """Check an allowed origin as the server normalizes one, returning it + unchanged: ``scheme://host[:port]`` with no path, https only (http for + localhost, 127.0.0.1 and [::1]), and a ``*.`` wildcard only on a + registrable domain.""" + if not isinstance(entry, str): + raise ValueError( + f"Allowed origin {entry!r} must be a string such as https://example.com" + ) + if _utf16_length(entry) > MAX_ALLOWED_ORIGIN_LENGTH: + raise ValueError( + f"Allowed origin {entry[:64]!r} is longer than " + f"{MAX_ALLOWED_ORIGIN_LENGTH} characters" + ) + + def reject(reason: str) -> ValueError: + return ValueError(f"Allowed origin {entry!r} {reason}") + + match = _ALLOWED_ORIGIN_PATTERN.fullmatch(entry.strip()) + if not match: + raise reject("must be scheme://host[:port] with no path") + scheme, wildcard, host, port = match.groups() + scheme = scheme.lower() + host = host.lower() + if scheme not in ("https", "http"): + raise reject("must use https") + if scheme == "http" and (wildcard or host not in _LOCAL_ORIGIN_HOSTS): + raise reject("must use https unless it is localhost") + if port and int(port) > 65535: + raise reject("has an invalid port") + if not host.startswith("["): + labels = host.split(".") + if not all(_ORIGIN_HOST_LABEL.fullmatch(label) for label in labels): + raise reject("has an invalid host") + # "*.com" would cover a whole public suffix. + if wildcard and len(labels) < 2: + raise reject("wildcard must name a registrable domain") + # No top-level domain is numeric, so this is (part of) an IPv4 address. + if wildcard and labels[-1].isdigit(): + raise reject("cannot use a wildcard with an IP address") + elif wildcard: + raise reject("cannot use a wildcard with an IP address") + else: + try: + ipaddress.IPv6Address(host[1:-1]) + except ValueError: + raise reject("has an invalid host") from None + return entry + + +def validate_allowed_origins(origins) -> List[str]: + """Check a list of 1 to 20 allowed origins (see validate_allowed_origin).""" + if isinstance(origins, (str, bytes)) or not isinstance(origins, (list, tuple)): + raise ValueError( + 'allowed_origins must be a list of origins such as ["https://example.com"]' + ) + if not 1 <= len(origins) <= MAX_ALLOWED_ORIGINS: + raise ValueError( + f"allowed_origins takes 1 to {MAX_ALLOWED_ORIGINS} origins; " + f"got {len(origins)}" + ) + return [validate_allowed_origin(origin) for origin in origins] + + class SecretBrowserMetadata(SecretBaseModel): """Where a secret may be typed into browser pages. @@ -361,15 +439,13 @@ def merge_allowed_origins(self): ) } ) - if ( - self.type == "op_token" - and self.metadata is not None - and self.metadata.browser is not None - ): - raise ValueError( - "An op_token secret can't have allowed origins: it is never " - "typed into browser pages" - ) + if self.metadata is not None and self.metadata.browser is not None: + if self.type == "op_token": + raise ValueError( + "An op_token secret can't have allowed origins: it is never " + "typed into browser pages" + ) + validate_allowed_origins(self.metadata.browser.allowed_origins) return self diff --git a/hyperbrowser/models/session.py b/hyperbrowser/models/session.py index 601dd10d..0316a97a 100644 --- a/hyperbrowser/models/session.py +++ b/hyperbrowser/models/session.py @@ -294,8 +294,28 @@ def validate_vault(cls, value): return validate_secret_name(value) +class SessionCredentialOnePassword(BaseModel): + """Attaches an ``op_token`` secret, by name, to a new session: fills may + then type 1Password fields it can read (``one_password`` in + ``sessions.credentials.fill()``). Sent as ``{"onePassword": name}``.""" + + model_config = ConfigDict(extra="forbid") + + one_password: str = Field(serialization_alias="onePassword") + + @field_validator("one_password") + @classmethod + def validate_one_password(cls, value): + return validate_secret_name(value) + + # A plain secret name is shorthand for SessionCredentialSecret. -SessionCredentialInput = Union[str, SessionCredentialSecret, SessionCredentialVault] +SessionCredentialInput = Union[ + str, + SessionCredentialSecret, + SessionCredentialVault, + SessionCredentialOnePassword, +] class SessionDetail(Session): @@ -653,11 +673,12 @@ class CreateSessionParams(BaseModel): credentials: Optional[List[SessionCredentialInput]] = Field( default=None, serialization_alias="credentials" ) - """Up to 20 secrets (by name, or ``{"secret": name}``) or vaults - (``{"vault": name}``) that ``sessions.credentials.fill()`` may type into - this session's pages. A vault attaches the secrets its entries hold now. - Secrets need allowed origins to be filled, and can only be attached when - the session is created.""" + """Up to 20 secrets (by name, or ``{"secret": name}``), vaults + (``{"vault": name}``) or op_token secrets (``{"one_password": name}``) + that ``sessions.credentials.fill()`` may type into this session's pages. + A vault attaches the secrets its entries hold now; an op_token lets fills + type the 1Password fields it can read. Secrets need allowed origins to be + filled, and can only be attached when the session is created.""" @field_validator("credentials", mode="before") @classmethod diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index 71711790..c9bceebe 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -25,6 +25,7 @@ from .models.secret import ( SecretsPublicKey, UpdateSecretParams, + validate_allowed_origins, validate_secret_version, ) @@ -236,7 +237,8 @@ def secret_payload_bytes(secret_type: Optional[str], value: Any) -> bytes: def browser_metadata(allowed_origins: Optional[Sequence[str]]) -> Dict[str, Any]: """Metadata allowing fills on ``allowed_origins``; ``None`` clears the - browser metadata in an update. The server checks the origins.""" + browser metadata in an update. The origins are checked when the update + is serialized.""" if allowed_origins is None: return {"browser": None} if isinstance(allowed_origins, (str, bytes)) or not all( @@ -264,6 +266,18 @@ def check_secret_metadata(secret_type: Optional[str], metadata: Any) -> None: ) +def check_browser_origins(metadata: Any) -> None: + """Refuse allowed origins the server would refuse (see + ``validate_allowed_origin``) in serialized browser metadata.""" + browser = metadata.get("browser") if isinstance(metadata, dict) else None + if not isinstance(browser, dict): + return + try: + validate_allowed_origins(browser.get("allowedOrigins")) + except ValueError as error: + raise HyperbrowserError(str(error), code="invalid_secret_metadata") from None + + def update_secret_body(params: Any) -> Dict[str, Any]: """Serialize a secret update. @@ -272,6 +286,7 @@ def update_secret_body(params: Any) -> Dict[str, Any]: """ normalized = coerce_request(params, UpdateSecretParams) body = normalized.model_dump(by_alias=True, exclude_unset=True) + check_browser_origins(body.get("metadata")) return {key: value for key, value in body.items() if value is not None} diff --git a/hyperbrowser/tools/__init__.py b/hyperbrowser/tools/__init__.py index 1aca5e5f..35af3efd 100644 --- a/hyperbrowser/tools/__init__.py +++ b/hyperbrowser/tools/__init__.py @@ -162,6 +162,9 @@ def _fill_credential_request(params: Any) -> Dict[str, Any]: # Fails validation, so the model gets an invalid_request error. params = {} request: Dict[str, Any] = {} + # Never one_password: a 1Password fill names the origins its values may + # be typed on, and a model must not choose those. Apps call + # sessions.credentials.fill() directly for 1Password. for key in ("credential", "vault"): if _present(params.get(key)): request[key] = params.get(key) @@ -224,6 +227,10 @@ class CredentialFillTool: The result is JSON text for the model. API and validation errors are returned as ``{"error": {"code", "message", "retryable", "details"}}`` instead of raised, so the model can react; other exceptions propagate. + + 1Password fills aren't offered: their request names the allowed origins, + which a model shouldn't choose. Call ``sessions.credentials.fill()`` with + ``one_password`` directly. """ openai_tool_definition = FILL_CREDENTIAL_TOOL_OPENAI diff --git a/hyperbrowser/types/__init__.py b/hyperbrowser/types/__init__.py index c404fa16..0778d09c 100644 --- a/hyperbrowser/types/__init__.py +++ b/hyperbrowser/types/__init__.py @@ -8,6 +8,7 @@ from .credential import ( CredentialFillField, FillCredentialParams, + OnePasswordFillSource, ) from .vault import ( CreateVaultParams, @@ -150,6 +151,7 @@ CaptchaSolverType, CreateSessionParams, SessionCredentialInput, + SessionCredentialOnePassword, SessionCredentialSecret, SessionCredentialVault, CreateSessionProfile, @@ -343,6 +345,7 @@ "CaptchaSolverType", "CreateSessionParams", "SessionCredentialInput", + "SessionCredentialOnePassword", "SessionCredentialSecret", "SessionCredentialVault", "CreateSessionProfile", @@ -362,6 +365,7 @@ "UpdateSessionSolveCaptchasParams", "CreateSecretParams", "OnePasswordField", + "OnePasswordFillSource", "OnePasswordSource", "SecretBrowserMetadata", "SecretListParams", diff --git a/hyperbrowser/types/credential.py b/hyperbrowser/types/credential.py index a7dbf277..bb982bb5 100644 --- a/hyperbrowser/types/credential.py +++ b/hyperbrowser/types/credential.py @@ -4,18 +4,33 @@ from typing_extensions import Required, TypedDict +from .secret import OnePasswordField + class CredentialFillField(TypedDict, total=False): """Types a value into the input matching the CSS ``selector``. With a - ``vault`` fill, ``field`` names the vault entry to type.""" + ``vault`` fill, ``field`` names the vault entry to type; with a + ``one_password`` fill, ``ref`` names the 1Password field.""" selector: Required[str] field: Optional[str] + ref: Optional[OnePasswordField] + + +class OnePasswordFillSource(TypedDict): + """1Password fields read with the ``op_token`` secret named ``token`` + (attached to the session) from ``vault``, typed only on + ``allowed_origins`` (1-20 origins such as ``https://example.com``).""" + + token: str + vault: str + allowed_origins: List[str] class FillCredentialParams(TypedDict, total=False): - """Fill 1-16 inputs from a secret (``credential``) or a vault's entries - (``vault``) attached to the session. + """Fill 1-16 inputs from a secret (``credential``), a vault's entries + (``vault``) or 1Password fields (``one_password``) attached to the + session. ``page_id`` (a CDP target ID) or ``page_url`` (a URL prefix) picks the tab when several could be the target; ``frame_url`` (a URL prefix) picks an @@ -24,6 +39,7 @@ class FillCredentialParams(TypedDict, total=False): credential: Optional[str] vault: Optional[str] + one_password: Optional[OnePasswordFillSource] fields: Required[List[CredentialFillField]] page_id: Optional[str] page_url: Optional[str] @@ -33,4 +49,5 @@ class FillCredentialParams(TypedDict, total=False): __all__ = [ "CredentialFillField", "FillCredentialParams", + "OnePasswordFillSource", ] diff --git a/hyperbrowser/types/session.py b/hyperbrowser/types/session.py index 477b7ae8..72430bec 100644 --- a/hyperbrowser/types/session.py +++ b/hyperbrowser/types/session.py @@ -141,9 +141,19 @@ class SessionCredentialVault(TypedDict): vault: str +class SessionCredentialOnePassword(TypedDict): + """Attaches an ``op_token`` secret, by name, to a new session so fills + may type 1Password fields it can read. Sent as ``{"onePassword": name}``.""" + + one_password: str + + # A plain secret name is shorthand for {"secret": name}. SessionCredentialInput: TypeAlias = Union[ - str, SessionCredentialSecret, SessionCredentialVault + str, + SessionCredentialSecret, + SessionCredentialVault, + SessionCredentialOnePassword, ] @@ -224,6 +234,7 @@ class SessionEventLogListParams(TypedDict, total=False): __all__ = [ "SessionCredentialInput", + "SessionCredentialOnePassword", "SessionCredentialSecret", "SessionCredentialVault", "BrowserDevice", diff --git a/tests/test_credential_tools.py b/tests/test_credential_tools.py index 3f87f89a..35732a78 100644 --- a/tests/test_credential_tools.py +++ b/tests/test_credential_tools.py @@ -236,6 +236,22 @@ def test_tool_runner_returns_errors_as_json(sdk): dict(TOOL_INPUT, credential="github-password"), dict(TOOL_INPUT, page_id="A", page_url="https://github.com/"), dict(TOOL_INPUT, fields=[{"selector": "#u", "field": None}]), + # The tool never fills from 1Password: a model must not choose the + # allowed origins, so one_password is dropped and ref refused. + { + "one_password": { + "token": "op-token", + "vault": "Dev", + "allowed_origins": ["https://github.com"], + }, + "fields": [{"selector": "#u", "ref": {"item": "i", "field": "f"}}], + }, + dict( + TOOL_INPUT, + fields=[ + {"selector": "#u", "field": "U", "ref": {"item": "i", "field": "f"}} + ], + ), ]: error = json.loads(call(runner, client, "s", bad)) assert error["error"]["code"] == "invalid_request" diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 7888818a..9f502ef9 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -17,9 +17,13 @@ from hyperbrowser.models import ( CreateSecretParams, CreateSessionParams, + CredentialFillField, FillCredentialParams, + OnePasswordField, + OnePasswordFillSource, Secret, SecretMetadata, + SessionCredentialOnePassword, SessionCredentialVault, UpdateSecretParams, ) @@ -380,6 +384,87 @@ def test_set_allowed_origins_replaces_or_clears_the_browser_metadata(): assert error.value.code == "invalid_secret_metadata" +BAD_ORIGINS = [ + "http://example.com", + "https://*.com", + "http://*.localhost", + "https://*.10.0.0.1", + "https://*.[::1]", + "https://example.com/login", + "https://example.com?x=1", + "https://example.com#top", + "example.com", + "ftp://example.com", + "https://example.com:65536", + "https://-example.com", + "https://exa_mple.com", + "https://" + "a" * 64 + ".com", + "https://[::g]", + "https://" + "a" * 505 + ".com", + "", +] + + +@pytest.mark.parametrize("origin", BAD_ORIGINS) +def test_secrets_refuse_origins_the_server_would(private_key, origin): + http = ScriptedHTTPClient( + { + ("GET", "/secrets"): (200, {"secrets": [_typed("totp")], "totalCount": 1}), + **_create_routes(private_key, _typed("secret")), + } + ) + manager = SecretManager(FakeClient(http)) + + for params in [ + {"name": "github-password", "allowed_origins": [origin]}, + { + "name": "github-password", + "metadata": { + "browser": {"allowed_origins": ["https://github.com", origin]} + }, + }, + ]: + with pytest.raises(ValueError, match="origin"): + manager.create(params) + for update in [ + lambda: manager.set_allowed_origins("github-otp", [origin]), + lambda: manager.update( + "github-otp", {"metadata": {"browser": {"allowed_origins": [origin]}}} + ), + ]: + with pytest.raises(HyperbrowserError) as error: + update() + assert error.value.code == "invalid_secret_metadata" + assert http.calls == [] + + +def test_secrets_take_origins_the_server_accepts(private_key): + origins = [ + "https://github.com", + "https://*.github.com", + "HTTPS://GitHub.com:443/", + "http://localhost:3000", + "http://127.0.0.1", + "http://[::1]:8080", + "https://[2001:db8::1]", + "https://10.0.0.1:8443", + ] + http = ScriptedHTTPClient(_create_routes(private_key, _typed("secret"))) + SecretManager(FakeClient(http)).create( + {"name": "github-password", "allowed_origins": origins} + ) + # Sent as given: the server normalizes them. + assert http.calls[-1]["json"]["metadata"] == { + "browser": {"allowedOrigins": origins} + } + for count in [0, 21]: + with pytest.raises(ValueError, match="1 to 20"): + CreateSecretParams( + name="github-password", + allowed_origins=[f"https://a{i}.github.com" for i in range(count)], + ) + + def test_update_sends_only_the_members_given(): http = ScriptedHTTPClient( { @@ -627,6 +712,11 @@ def test_sessions_attach_secrets_and_vaults_at_creation(sdk): [{"vault": "-a"}], [{"login": "a"}], ["s"] * 21, + ["s"] * 20 + [{"one_password": "op-token"}], + [{"one_password": "bad name"}], + [{"one_password": "op-token", "vault": "v"}], + # Keys are snake_case, as everywhere else in the SDK. + [{"onePassword": "op-token"}], "github", ], ) @@ -817,3 +907,337 @@ def test_fill_limits_are_inclusive(sdk): {"credential": "c" * 255, "fields": ONE, "page_url": "u" * 2048}, ) assert len(calls) == 2 + + +# --- 1Password fills --- + +OP_SOURCE = { + "token": "op-token", + "vault": "Dev Vault for Testing", + "allowed_origins": ["https://www.scrapingcourse.com"], +} +OP_FIELDS = [ + { + "selector": "#email", + "ref": {"item": "scrapingcourse login", "field": "username"}, + }, + { + "selector": "#otp", + "ref": { + "item": "scrapingcourse login", + "section": "Security", + "field": "otp?attribute=totp", + }, + }, +] +OP_REFERENCES = [ + "op://Dev Vault for Testing/scrapingcourse login/username", + "op://Dev Vault for Testing/scrapingcourse login/Security/otp?attribute=totp", +] +OP_BODY = { + "onePassword": { + "token": "op-token", + "vault": "Dev Vault for Testing", + "allowedOrigins": ["https://www.scrapingcourse.com"], + }, + "fields": OP_FIELDS, +} +# No top-level version: each input has the op_token's. +OP_RESULT = { + "pageId": "PAGE1", + "origin": "https://www.scrapingcourse.com", + "fields": [ + { + "selector": field["selector"], + "outcome": "filled", + "secretId": SECRET_ID, + "version": 2, + "reference": reference, + } + for field, reference in zip(OP_FIELDS, OP_REFERENCES) + ], +} + + +def test_sessions_attach_op_tokens_for_onepassword_fills(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, SESSION) + respond(outcomes, SESSION) + + call( + client.sessions.create, + {"credentials": [{"one_password": "op-token"}, {"vault": "github-login"}]}, + ) + assert json.loads(calls[0].content)["credentials"] == [ + {"onePassword": "op-token"}, + {"vault": "github-login"}, + ] + call( + client.sessions.create, + CreateSessionParams( + credentials=["s"] * 19 + + [SessionCredentialOnePassword(one_password="op-token")] + ), + ) + assert json.loads(calls[1].content)["credentials"][-1] == { + "onePassword": "op-token" + } + + +def test_onepassword_fills_post_refs_and_parse_references(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, OP_RESULT) + + result = call( + client.sessions.credentials.fill, + "s", + { + "one_password": OP_SOURCE, + "fields": OP_FIELDS, + "page_url": "https://www.scrapingcourse.com/login", + }, + ) + assert json.loads(calls[0].content) == dict( + OP_BODY, pageUrl="https://www.scrapingcourse.com/login" + ) + assert result.version is None + assert result.origin == "https://www.scrapingcourse.com" + assert [(f.selector, f.reference, f.version) for f in result.fields] == [ + ("#email", OP_REFERENCES[0], 2), + ("#otp", OP_REFERENCES[1], 2), + ] + assert result.fields[0].secret_id == SECRET_ID + assert result.fields[0].field is None + assert result.fields[0].expires_at is None + + +def test_onepassword_fill_models_serialize_like_dicts(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, OP_RESULT) + + call( + client.sessions.credentials.fill, + "s", + FillCredentialParams( + one_password=OnePasswordFillSource( + token="op-token", + vault="Dev Vault for Testing", + allowed_origins=["https://www.scrapingcourse.com"], + ), + fields=[ + CredentialFillField( + selector="#email", + ref=OnePasswordField(item="scrapingcourse login", field="username"), + ), + CredentialFillField( + selector="#otp", + ref=OnePasswordField( + item="scrapingcourse login", + section="Security", + field="otp?attribute=totp", + ), + ), + ], + frame_url="https://www.scrapingcourse.com/", + ), + ) + assert json.loads(calls[0].content) == dict( + OP_BODY, frame={"url": "https://www.scrapingcourse.com/"} + ) + + +def test_onepassword_reference_failures_name_the_selectors(sdk): + client, call, calls, outcomes = sdk + details = { + "selectors": ["#otp"], + "reason": "field not found", + "reasons": {"#otp": "field not found"}, + "tokenSecretId": SECRET_ID, + "tokenVersion": 2, + } + respond( + outcomes, + { + "code": "SECRET_ONEPASSWORD_REFERENCE_FAILED", + "message": "A 1Password field could not be read; nothing was filled", + "retryable": False, + "details": details, + }, + 422, + ) + + with pytest.raises(HyperbrowserError) as raised: + call( + client.sessions.credentials.fill, + "s", + {"one_password": OP_SOURCE, "fields": OP_FIELDS}, + ) + assert raised.value.code == "SECRET_ONEPASSWORD_REFERENCE_FAILED" + assert raised.value.status_code == 422 + assert raised.value.retryable is False + assert raised.value.details["details"] == details + + +OP_REF = {"item": "i", "field": "f"} +OP_ONE = [{"selector": "#p", "ref": OP_REF}] + + +def _op(**changes): + return dict(OP_SOURCE, **changes) + + +@pytest.mark.parametrize( + "params", + [ + # Exactly one source. + {"credential": "c", "one_password": OP_SOURCE, "fields": OP_ONE}, + {"vault": "v", "one_password": OP_SOURCE, "fields": OP_ONE}, + # ref on every field with one_password, and on none without. + {"one_password": OP_SOURCE, "fields": [{"selector": "#p"}]}, + {"one_password": OP_SOURCE, "fields": OP_ONE + [{"selector": "#q"}]}, + {"credential": "c", "fields": OP_ONE}, + {"vault": "v", "fields": [{"selector": "#p", "field": "P", "ref": OP_REF}]}, + # No vault entry with one_password. + { + "one_password": OP_SOURCE, + "fields": [{"selector": "#p", "field": "P", "ref": OP_REF}], + }, + # Allowed origins, checked as the server normalizes them. + {"one_password": _op(allowed_origins=["http://example.com"]), "fields": OP_ONE}, + {"one_password": _op(allowed_origins=["https://*.com"]), "fields": OP_ONE}, + {"one_password": _op(allowed_origins=["https://*.1.2.3"]), "fields": OP_ONE}, + { + "one_password": _op(allowed_origins=["https://x.com/login"]), + "fields": OP_ONE, + }, + {"one_password": _op(allowed_origins=["ftp://x.com"]), "fields": OP_ONE}, + { + "one_password": _op(allowed_origins=["https://x.com:65536"]), + "fields": OP_ONE, + }, + {"one_password": _op(allowed_origins=["https://-x.com"]), "fields": OP_ONE}, + {"one_password": _op(allowed_origins=["https://[nope]"]), "fields": OP_ONE}, + {"one_password": _op(allowed_origins=[]), "fields": OP_ONE}, + { + "one_password": _op( + allowed_origins=[f"https://a{i}.example.com" for i in range(21)] + ), + "fields": OP_ONE, + }, + {"one_password": _op(allowed_origins="https://x.com"), "fields": OP_ONE}, + {"one_password": _op(allowed_origins=[1]), "fields": OP_ONE}, + {"one_password": {"token": "op-token", "vault": "v"}, "fields": OP_ONE}, + # The token's name and the 1Password parts. + {"one_password": _op(token="bad name"), "fields": OP_ONE}, + {"one_password": _op(vault=""), "fields": OP_ONE}, + {"one_password": _op(vault="a/b"), "fields": OP_ONE}, + {"one_password": _op(vault=".."), "fields": OP_ONE}, + {"one_password": _op(vault="v" * 257), "fields": OP_ONE}, + { + "one_password": OP_SOURCE, + "fields": [{"selector": "#p", "ref": {"item": ".", "field": "f"}}], + }, + { + "one_password": OP_SOURCE, + "fields": [{"selector": "#p", "ref": {"item": "i\u200b", "field": "f"}}], + }, + { + "one_password": OP_SOURCE, + "fields": [ + {"selector": "#p", "ref": {"item": "i", "section": "a?b", "field": "f"}} + ], + }, + { + "one_password": OP_SOURCE, + "fields": [{"selector": "#p", "ref": {"item": "i", "field": "f?Attr=x"}}], + }, + { + "one_password": OP_SOURCE, + "fields": [{"selector": "#p", "ref": {"item": "i"}}], + }, + # op://// is at most 512 bytes: this is 513. + { + "one_password": _op(vault="v" * 200), + "fields": [ + {"selector": "#p", "ref": {"item": "i" * 200, "field": "f" * 106}} + ], + }, + # Strict objects: unknown keys fail. + {"one_password": _op(allowedOrigins=["https://x.com"]), "fields": OP_ONE}, + {"one_password": _op(fields={}), "fields": OP_ONE}, + { + "one_password": OP_SOURCE, + "fields": [{"selector": "#p", "ref": dict(OP_REF, vault="v")}], + }, + {"onePassword": OP_SOURCE, "fields": OP_ONE}, + # The usual field rules. + {"one_password": OP_SOURCE, "fields": OP_ONE + OP_ONE}, + {"one_password": OP_SOURCE, "fields": []}, + { + "one_password": OP_SOURCE, + "fields": [{"selector": f"#f{i}", "ref": OP_REF} for i in range(17)], + }, + ], +) +def test_invalid_onepassword_fills_fail_before_http(sdk, params): + client, call, calls, outcomes = sdk + with pytest.raises(HyperbrowserError) as raised: + call(client.sessions.credentials.fill, "s", params) + assert raised.value.code == "invalid_request" + assert not calls + + +def test_onepassword_fill_limits_are_inclusive(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, OP_RESULT) + # 504 characters of host: a 512-character origin. + host = ".".join(["a" * 63] * 7 + ["a" * 56]) + origins = [f"https://{host}"] + [f"https://a{i}.example.com" for i in range(19)] + call( + client.sessions.credentials.fill, + "s", + { + "one_password": _op(vault="v" * 200, allowed_origins=origins), + "fields": [ + # op://<200>/<200>/<105> is 512 bytes. + {"selector": f"#f{i}", "ref": {"item": "i" * 200, "field": "f" * 105}} + for i in range(16) + ], + }, + ) + body = json.loads(calls[0].content) + assert len(body["onePassword"]["allowedOrigins"][0]) == 512 + assert len(body["onePassword"]["allowedOrigins"]) == 20 + assert len(body["fields"]) == 16 + + with pytest.raises(HyperbrowserError): + call( + client.sessions.credentials.fill, + "s", + {"one_password": _op(allowed_origins=[origins[0] + "a"]), "fields": OP_ONE}, + ) + assert len(calls) == 1 + + +@pytest.mark.parametrize( + "origin", + [ + "http://localhost:3000", + "http://127.0.0.1", + "http://[::1]:8080", + "https://[2001:db8::1]", + "https://*.example.co.uk", + "HTTPS://Example.COM:443/", + " https://example.com ", + ], +) +def test_onepassword_fill_origins_the_server_accepts_pass(sdk, origin): + client, call, calls, outcomes = sdk + respond(outcomes, OP_RESULT) + call( + client.sessions.credentials.fill, + "s", + {"one_password": _op(allowed_origins=[origin]), "fields": OP_ONE}, + ) + # Sent as given: the server normalizes it. + assert json.loads(calls[0].content)["onePassword"]["allowedOrigins"] == [origin] diff --git a/tests/typecheck/invalid_requests.py b/tests/typecheck/invalid_requests.py index 4a8a5b80..b103780a 100644 --- a/tests/typecheck/invalid_requests.py +++ b/tests/typecheck/invalid_requests.py @@ -134,6 +134,10 @@ def invalid_credential_requests(client: Hyperbrowser) -> None: client.sessions.credentials.fill("s", {"credential": "github"}) # M,P client.sessions.credentials.fill("s", {"vault": "v", "fields": [{"field": "U"}]}) # M,P client.sessions.credentials.fill("s", {"credential": "c", "fields": [], "pageId": "p"}) # M,P + client.sessions.create({"credentials": [{"onePassword": "op-token"}]}) # M,P + client.sessions.credentials.fill("s", {"one_password": {"token": "t", "vault": "v"}, "fields": []}) # M,P + client.sessions.credentials.fill("s", {"one_password": {"token": "t", "vault": "v", "allowedOrigins": []}, "fields": []}) # M,P + client.sessions.credentials.fill("s", {"credential": "c", "fields": [{"selector": "input", "ref": {"item": "i"}}]}) # M,P async def invalid_async_credential_requests(client: AsyncHyperbrowser) -> None: diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index ea679dcc..ca0b1489 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, List, Optional from pydantic import BaseModel @@ -590,8 +590,10 @@ def valid_credential_requests(client: Hyperbrowser) -> None: CredentialFillField, FillCredentialParams, OnePasswordField, + OnePasswordFillSource, OnePasswordSource, SecretMetadata, + SessionCredentialOnePassword, StoredEntry, UpdateSecretParams, UpdateVaultParams, @@ -741,6 +743,50 @@ def valid_credential_requests(client: Hyperbrowser) -> None: page_id="PAGE", ), ) + client.sessions.create( + {"credentials": [{"one_password": "op-token"}, {"secret": "github-otp"}]} + ) + filled = client.sessions.credentials.fill( + session.id, + { + "one_password": { + "token": "op-token", + "vault": "Engineering", + "allowed_origins": ["https://github.com"], + }, + "fields": [ + {"selector": "#login", "ref": {"item": "GitHub", "field": "username"}}, + { + "selector": "#otp", + "ref": {"item": "GitHub", "section": "2FA", "field": "otp"}, + }, + ], + "page_url": "https://github.com/login", + }, + ) + references: List[Optional[str]] = [field.reference for field in filled.fields] + print(filled.version, references) + client.sessions.credentials.fill( + session.id, + FillCredentialParams( + one_password=OnePasswordFillSource( + token="op-token", + vault="Engineering", + allowed_origins=["https://github.com"], + ), + fields=[ + CredentialFillField( + selector="#pw", + ref=OnePasswordField(item="GitHub", field="password"), + ) + ], + ), + ) + client.sessions.create( + LegacyCreateSessionParams( + credentials=[SessionCredentialOnePassword(one_password="op-token")] + ) + ) text: str = CredentialFillTool.runnable(client, session.id, {"vault": "x"}) print(text, CredentialFillTool.gemini_tool_definition["name"]) @@ -762,6 +808,17 @@ async def valid_async_credential_requests(client: AsyncHyperbrowser) -> None: {"vault": "github-login", "fields": [{"field": "OTP", "selector": "#otp"}]}, ) print(result.fields) + await client.sessions.credentials.fill( + session.id, + { + "one_password": { + "token": "op-token", + "vault": "v", + "allowed_origins": ["https://*.example.com"], + }, + "fields": [{"selector": "#otp", "ref": {"item": "i", "field": "otp"}}], + }, + ) sandbox = await client.sandboxes.create({"image_name": "node", "vault": "v"}) await sandbox.processes.start("node app.js", vault="v") await sandbox.exec( From 4225fdf02fcdf0d380ee921c65316d43aa734e29 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 15:17:58 -0700 Subject: [PATCH 19/23] Read TOTP seeds split across lines, as vm-service now does #1573's vm-service drops CR and LF from a seed with its spaces and hyphens before the length check (its base32 decoder skips them), so a seed pasted across lines is valid. The clients refused it; they now drop them too. A raw line break inside an otpauth URI is still refused, as Go's url.Parse does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 2 +- hyperbrowser/secrets_common.py | 7 ++++--- tests/test_credentials.py | 5 ++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 65df51ae..54a06b0c 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ Things to know: metadata member sent replaces the stored one and `None` removes it. - Secret types are `secret` (the default), `totp` and `op_token`, fixed at creation; `client.secrets.list({"type": "totp"})` filters by type. TOTP - values are checked locally before upload: a base32 seed (spaces, hyphens, + values are checked locally before upload: a base32 seed (spaces, hyphens, line breaks, lowercase and trailing `=` are fine) or an `otpauth://totp/` URI with `algorithm` SHA1/SHA256/SHA512, `digits` 6 or 8 and `period` 1-300. Seeds shorter than 10 bytes (16 characters) are accepted but weak. The platform diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index c9bceebe..f4ff9136 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -122,12 +122,13 @@ def _invalid_totp(reason: str) -> HyperbrowserError: def _check_totp_base32(seed: str) -> None: - # Spaces, hyphens, lowercase and trailing "=" padding are accepted, as - # sites show seeds in all of these forms. Characters are uppercased one + # Spaces, hyphens, line breaks, lowercase and trailing "=" padding are + # accepted, as sites show seeds in all of these forms and the platform + # skips them too. Characters are uppercased one # at a time, as Go does, so "ß" can't turn into "SS". normalized = "".join( upper if len(upper) == 1 else char - for char in seed.replace(" ", "").replace("-", "") + for char in re.sub(r"[ \r\n-]", "", seed) for upper in [char.upper()] ).rstrip("=") if not normalized: diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 9f502ef9..5b12595b 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -198,6 +198,10 @@ def test_totp_secrets_seal_the_seed_under_the_returned_context(private_key): f"otpauth://totp/x?secret={SEED}&digits=6%zz", "\ufeff\u3000" + SEED + "\u00a0\u0085", "\u0131" + SEED[1:], + # The platform skips line breaks, as Go's base32 decoder does. + SEED[:13] + "\n" + SEED[13:], + SEED[:16] + "\r\n" + SEED[16:], + f"otpauth://totp/x?secret={SEED[:16]}%0A{SEED[16:]}", ] BAD_SEEDS = [ ("", "empty"), @@ -236,7 +240,6 @@ def test_totp_secrets_seal_the_seed_under_the_returned_context(private_key): ("\x1c" + SEED, "not base32"), (SEED[:-1] + "\u00df", "not base32"), ("\ufb00AAAAAA", "not base32"), - ("JBSWY3DP\nEHPK3PXP", "not base32"), ] From 5a8ef8d97908b7db1fb2120bd79fb52297879bd8 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 15:39:03 -0700 Subject: [PATCH 20/23] Check origins and secret env names as master does Allowed origins are trimmed the way JavaScript's trim() does, so a leading byte-order mark is accepted and \x1c-\x1f or \x85 are not, and an IPv6 zone is checked as Node's isIPv6 does, which Python 3.8 refused outright. A `secrets` variable name longer than 256 characters is now refused locally, as the server refuses it. The README now says a frame fill skips the allowed-origin tab step, that SECRET_ONEPASSWORD_AUTH_FAILED also covers a malformed token or one for another sign-in host, and the 503 that CREDENTIAL_FILL_FAILED and SECRET_ONEPASSWORD_FAILED can carry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 19 ++++++++++------ hyperbrowser/models/secret.py | 43 +++++++++++++++++++++++------------ tests/test_credentials.py | 10 ++++++++ tests/test_secrets.py | 3 ++- 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 54a06b0c..7d04e43d 100644 --- a/README.md +++ b/README.md @@ -225,8 +225,9 @@ Things to know: it. Without `page_id`, fill considers the tabs whose URL starts with `page_url` (all tabs when it's not given). If one is left, that's the target. If several - are left, it prefers the tabs on an origin every secret allows, then the - visible one among them; if more than one still qualifies, it fails with + are left, it prefers the tabs on an origin every secret allows (skipped with + `frame_url`, since a login frame's tab is usually on another origin), then + the visible one among them; if more than one still qualifies, it fails with `CREDENTIAL_PAGE_AMBIGUOUS`. The chosen tab's origin is then checked: on any other origin nothing is typed and the fill fails with `CREDENTIAL_ORIGIN_NOT_ALLOWED`. Under Playwright every tab reports as visible, so pass @@ -266,15 +267,17 @@ Things to know: `SECRET_NOT_FOUND`, `SECRET_VERSION_NOT_FOUND`, `VAULT_NOT_FOUND`, `VAULT_ENTRY_NOT_FOUND` (404), `SESSION_NOT_ACTIVE` (409). - `CREDENTIAL_FILL_TIMEOUT` (503/504, retryable) and - `CREDENTIAL_FILL_FAILED` (500/502, retryable as flagged). + `CREDENTIAL_FILL_FAILED` (500/502/503, retryable as flagged). - `CREDENTIALS_UNSUPPORTED_RUNTIME` (400), `CREDENTIAL_FILL_UNSUPPORTED` (501) and `CREDENTIAL_VALUE_UNSUPPORTED` (501): the session's browser can't fill this, such as 1Password fields on an older host. - `SECRET_ONEPASSWORD_REFERENCE_FAILED` (422): a 1Password item or field couldn't be read, so nothing was filled. `details` has the failing `selectors` and `reasons` by selector. `SECRET_ONEPASSWORD_AUTH_FAILED` - (422): the token can't sign in. `SECRET_ONEPASSWORD_RATE_LIMITED` (429) - and `SECRET_ONEPASSWORD_FAILED` (502) are retryable. Each has the + (422): 1Password rejected the token, or the token is malformed or doesn't + sign in to `1password.com`, `.ca` or `.eu` (or a subdomain), so it wasn't + sent to 1Password. `SECRET_ONEPASSWORD_RATE_LIMITED` (429) and + `SECRET_ONEPASSWORD_FAILED` (502/503) are retryable. Each has the token's `tokenSecretId` and `tokenVersion` in `details`. - Locally, invalid fill requests raise `invalid_request` and invalid values raise `invalid_secret_value`, before any request. @@ -816,8 +819,10 @@ Things to know: sandbox's host predates TOTP or 1Password, so nothing started. Retryable on sandbox create, which may land on a newer host; not on a process start. - `SECRET_ONEPASSWORD_REFERENCE_FAILED` (422, `details` names the - variables) and `SECRET_ONEPASSWORD_AUTH_FAILED` (422): fix the fields or - the token. `SECRET_ONEPASSWORD_RATE_LIMITED` (429) and + variables) and `SECRET_ONEPASSWORD_AUTH_FAILED` (422, also a malformed + token, or one that doesn't sign in to `1password.com`, `.ca` or `.eu`, + which is never sent to 1Password): fix the fields or the token. + `SECRET_ONEPASSWORD_RATE_LIMITED` (429) and `SECRET_ONEPASSWORD_FAILED` (502/503) are retryable. - `retryable` is the API's flag when it sends one. diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py index 6e9b9ff7..8e8ededc 100644 --- a/hyperbrowser/models/secret.py +++ b/hyperbrowser/models/secret.py @@ -77,15 +77,7 @@ def normalize_secret_references(value): 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" - ) + validate_secret_env_name(env) return { env: {"name": reference} if isinstance(reference, str) else reference for env, reference in value.items() @@ -289,6 +281,30 @@ def onepassword_clashes( ) _ORIGIN_HOST_LABEL = re.compile(r"(?!-)[a-z0-9-]{1,63}(? bool: + """Whether Node's isIPv6 accepts ``address``. Python 3.8 knows no zone + IDs, and later versions accept zones Node refuses, so the zone is + checked here.""" + address, mark, zone = address.partition("%") + if mark and not _IPV6_ZONE.fullmatch(zone): + return False + try: + ipaddress.IPv6Address(address) + except ValueError: + return False + return True def validate_allowed_origin(entry) -> str: @@ -309,7 +325,7 @@ def validate_allowed_origin(entry) -> str: def reject(reason: str) -> ValueError: return ValueError(f"Allowed origin {entry!r} {reason}") - match = _ALLOWED_ORIGIN_PATTERN.fullmatch(entry.strip()) + match = _ALLOWED_ORIGIN_PATTERN.fullmatch(entry.strip(_JS_WHITESPACE)) if not match: raise reject("must be scheme://host[:port] with no path") scheme, wildcard, host, port = match.groups() @@ -333,11 +349,8 @@ def reject(reason: str) -> ValueError: raise reject("cannot use a wildcard with an IP address") elif wildcard: raise reject("cannot use a wildcard with an IP address") - else: - try: - ipaddress.IPv6Address(host[1:-1]) - except ValueError: - raise reject("has an invalid host") from None + elif not _is_ipv6(host[1:-1]): + raise reject("has an invalid host") return entry diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 5b12595b..13f25dc6 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -405,6 +405,12 @@ def test_set_allowed_origins_replaces_or_clears_the_browser_metadata(): "https://[::g]", "https://" + "a" * 505 + ".com", "", + # The server trims as JavaScript does, which keeps these. + "https://example.com\x1c", + "https://example.com\x85", + # Node's isIPv6 takes only these characters in a zone. + "https://[fe80::1%e_th]", + "https://[fe80::1%]", ] @@ -451,6 +457,10 @@ def test_secrets_take_origins_the_server_accepts(private_key): "http://[::1]:8080", "https://[2001:db8::1]", "https://10.0.0.1:8443", + # JavaScript's trim() removes a byte-order mark; Python's strip() doesn't. + "\ufeffhttps://github.com", + # Zones Node's isIPv6 accepts, which Python 3.8's ipaddress refuses. + "https://[fe80::1%eth0]", ] http = ScriptedHTTPClient(_create_routes(private_key, _typed("secret"))) SecretManager(FakeClient(http)).create( diff --git a/tests/test_secrets.py b/tests/test_secrets.py index 6a9c10b7..f94e1942 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -1532,11 +1532,12 @@ def test_secret_names_env_names_and_versions_are_checked_locally(): CreateSecretParams(name=name) assert CreateSecretParams(name="a" * 128).name == "a" * 128 - for env in ["1ABC", "API-KEY", "", "__proto__", "KEY\n"]: + for env in ["1ABC", "API-KEY", "", "__proto__", "KEY\n", "A" * 257]: 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"}) + assert SandboxExecParams(command="x", secrets={"A" * 256: "provider-key"}).secrets with pytest.raises(ValueError, match="Invalid secret name"): SandboxExecParams(command="x", secrets={"API_KEY": "bad name"}) From 133d554fcf01543cd4fa3be89e9e7f44be06fd50 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 16:01:11 -0700 Subject: [PATCH 21/23] Accept tab_opened session events The API has sent tab_opened events since browserctrl #559, and the event type Literal refused them, so listing such a session's events failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- hyperbrowser/models/consts.py | 1 + tests/test_session_event_logs.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/test_session_event_logs.py diff --git a/hyperbrowser/models/consts.py b/hyperbrowser/models/consts.py index e3514c00..0114acde 100644 --- a/hyperbrowser/models/consts.py +++ b/hyperbrowser/models/consts.py @@ -25,6 +25,7 @@ "captcha_solved", "captcha_error", "file_downloaded", + "tab_opened", "credential_filled", "credential_fill_refused", ] diff --git a/tests/test_session_event_logs.py b/tests/test_session_event_logs.py new file mode 100644 index 00000000..81696bd9 --- /dev/null +++ b/tests/test_session_event_logs.py @@ -0,0 +1,22 @@ +import pytest + +from hyperbrowser.models.session import SessionEventLog + + +@pytest.mark.parametrize( + "event_type", ["tab_opened", "credential_filled", "credential_fill_refused"] +) +def test_event_log_types_the_api_sends_parse(event_type): + event = SessionEventLog.model_validate( + { + "id": "e1", + "sessionId": "s1", + "targetId": None, + "pageUrl": "https://example.com/", + "teamId": "t1", + "type": event_type, + "metadata": {}, + "timestamp": 1, + } + ) + assert event.type == event_type From cb535933b66a21d9c2aaef6ddd6e8573ca76336b Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 16:28:26 -0700 Subject: [PATCH 22/23] Simplify credential validation and keep Gemini tools scoped to fill --- CLAUDE.md | 3 +- README.md | 22 +- docs/credentials-cleanup.md | 59 ++++ .../client/managers/async_manager/secret.py | 2 - .../client/managers/sandboxes/shared.py | 7 +- .../client/managers/sync_manager/secret.py | 2 - hyperbrowser/credentials_common.py | 128 +------- hyperbrowser/models/credential.py | 24 +- hyperbrowser/models/sandbox.py | 15 - hyperbrowser/models/secret.py | 242 +------------- hyperbrowser/models/session.py | 32 -- hyperbrowser/models/vault.py | 54 +-- hyperbrowser/secrets_common.py | 28 -- hyperbrowser/tools/__init__.py | 14 +- hyperbrowser/tools/gemini.py | 151 +++------ tests/test_credential_tools.py | 18 +- tests/test_credentials.py | 309 ++++-------------- tests/test_onepassword.py | 122 ++----- tests/test_vaults.py | 74 ++--- 19 files changed, 258 insertions(+), 1048 deletions(-) create mode 100644 docs/credentials-cleanup.md diff --git a/CLAUDE.md b/CLAUDE.md index 06f84cbe..96ae9415 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,8 @@ Response and legacy models are re-exported from `hyperbrowser/tools/` contains ready-to-use tool definitions for OpenAI, Anthropic and Gemini function calling: - `WebsiteScrapeTool`, `WebsiteScreenshotTool`, `WebsiteCrawlTool`, `WebsiteExtractTool`, `BrowserUseTool`, `CredentialFillTool` -- Each has `openai_tool_definition`, `anthropic_tool_definition`, `gemini_tool_definition` (converted from the JSON schema by `tools/gemini.py`), plus sync/async runnable methods +- Each has `openai_tool_definition`, `anthropic_tool_definition`, plus sync/async runnable methods +- `CredentialFillTool` also has `gemini_tool_definition`; `tools/gemini.py` defines only credential fill ## Key Patterns diff --git a/README.md b/README.md index 7d04e43d..5b5b730f 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ Things to know: A secret takes 1-20 of at most 512 characters, https only (http for `localhost`, `127.0.0.1` and `[::1]`), with no path and `*.` only on a registrable domain such as `example.com`, never `*.com` or an IP address. - They are checked locally, as the API would, before any request. + The API validates and normalizes them. Change them with `client.secrets.set_allowed_origins("github-password", [...])`; `None` clears them, after which it can't be filled. `client.secrets.update()` takes `{"metadata": {"browser": None}}` too: each @@ -279,8 +279,10 @@ Things to know: sent to 1Password. `SECRET_ONEPASSWORD_RATE_LIMITED` (429) and `SECRET_ONEPASSWORD_FAILED` (502/503) are retryable. Each has the token's `tokenSecretId` and `tokenVersion` in `details`. - - Locally, invalid fill requests raise `invalid_request` and invalid values - raise `invalid_secret_value`, before any request. + - Local fill shape errors raise `invalid_request`; invalid secret values + raise `invalid_secret_value` before upload. Fill, origin, vault-entry and + 1Password policy errors come from the API. Inspect the status, code (when + supplied) and details; schema errors may have no machine code. ### Fill from 1Password @@ -332,7 +334,8 @@ for field in result.fields: - The result has no top-level `version`; each input's `secret_id` and `version` are the op_token's, and `reference` is the `op://` reference it was typed from. -- All of this is checked locally before any request. +- The API validates these rules. Request models still reject unknown members + and invalid shapes locally. ### Use with LLM SDKs @@ -451,10 +454,10 @@ while True: `hyperbrowser.tools` has ready-made tools: `WebsiteScrapeTool`, `WebsiteScreenshotTool`, `WebsiteCrawlTool`, `WebsiteExtractTool`, `BrowserUseTool` and `CredentialFillTool`. Each has `openai_tool_definition` -(a strict Chat Completions function tool), `anthropic_tool_definition`, -`gemini_tool_definition` (a function declaration dict for google-genai), and +(a strict Chat Completions function tool), `anthropic_tool_definition`, and `runnable()` / `async_runnable()` to run the model's input and return text for -the model. +the model. `CredentialFillTool` also provides `gemini_tool_definition`, a +function declaration dict for google-genai. ## Usage @@ -741,9 +744,8 @@ like environment variables. A field may end with a query such as `?attribute=otp`, `?attribute=totp` or `?ssh-format=openssh`. Vaults, items, sections and fields are 1-256 characters (UTF-16 code units) without `/`, `?` or control characters, and not `.` or `..`; the whole `op://` reference is at most 512 bytes. These are -checked locally before any request, as is a variable that `secrets` or `env` -also sets: a variable from 1Password must come from it alone. The server also -refuses one that the request's `vault` holds. +checked by the API, as is a variable that `secrets`, `env` or the request's +`vault` also sets: a variable from 1Password must come from it alone. Things to know: diff --git a/docs/credentials-cleanup.md b/docs/credentials-cleanup.md new file mode 100644 index 00000000..ec50e091 --- /dev/null +++ b/docs/credentials-cleanup.md @@ -0,0 +1,59 @@ +# Credential SDK cleanup + +Baseline: `6ce5a135830ff18fa5a3b6e56bc7573ae479b4ce` (python-sdk PR #142). +Server reference: browserctrl master `cc9109ca5fc144b1b4e20498bbe8c90513433c89`. + +## Changes and observable differences + +- Fill source/name rules, field counts and byte limits, selector uniqueness, + page-target conflicts, allowed-origin syntax, vault entry policies and + 1Password name/reference limits are validated by the API. They no longer + have a second handwritten implementation in this SDK. +- Invalid policy inputs can now make an HTTP request. Errors retain the API's + HTTP status, optional code, retryable flag and details. A schema rejection + may have no machine code; callers must not require `invalid_request` for + every invalid input. Basic request-shape checks remain local. +- Vault create/update serialization retains omission versus explicit entry + removal (`null` / `None`). Exact-name lookup and UUID-name fallback remain. +- 1Password source conflicts with secrets, environment variables or vault + entries are rejected by the control API. Process requests with any secret + source still go through that API; uncertain outcomes remain non-retryable. +- Gemini support is scoped to `CredentialFillTool` and + `FILL_CREDENTIAL_TOOL_GEMINI`. The unreleased general schema converters, + related helper types and Gemini declarations on unrelated tools are removed. + The fill declaration reuses the shared descriptions and parameter names. +- Tests of removed local policies are replaced by API rejection/serialization + coverage. Encryption, TOTP, routing, partial-create errors, secret delivery, + startup binding preservation, and tool normalization coverage remain. + +## Deliberately retained + +- Local encryption, key verification, UTF-8/size/NUL value checks, exact + server-provided encryption contexts, and errors after partial creation. +- The complete local TOTP validator. The API cannot inspect an encrypted seed; + removing it would delay malformed-seed errors until use. +- Existing stored-secret shorthand/name/version checks and SDK launch/routing + restrictions. This cleanup does not change the older secret API wholesale. +- The LLM tool still binds its session in application code and never exposes + 1Password origin selection to a model. +- Both synchronous and asynchronous managers, TypedDict inputs and legacy + Pydantic request objects. Pydantic shape/unknown-member checks remain; + shared serializers retain aliases, nested nulls and response-entry projection. + +## Validation + +- Python 3.12: 993 tests passed, including mypy/Pyright fixtures; Ruff and + wheel/sdist builds passed. +- Python 3.8.20: 988 passed, four typing tests skipped, and one unrelated + CAPTCHA localhost integration test timed out. That test plus the complete + credential/secrets/vault/tool subset passed on retry (300 tests). The + cryptography dependency emitted its Python 3.8 deprecation warning. +- Compared 44 request cases from both SDKs against the actual Zod schemas and + metadata validators loaded from the immutable server revision above. Valid + requests agree on their wire representation; invalid policy inputs are + refused by the server validators or local Python shape checks. +- Executed master's source-merging function: 1Password clashes with vaults or + explicit secrets are refused; disjoint variables are accepted. +- Contract comparison is source-level validation, not a running API/database, + browser session or live 1Password end-to-end test. Those live checks were + not repeated for this cleanup. diff --git a/hyperbrowser/client/managers/async_manager/secret.py b/hyperbrowser/client/managers/async_manager/secret.py index cbbc73fa..6d48a410 100644 --- a/hyperbrowser/client/managers/async_manager/secret.py +++ b/hyperbrowser/client/managers/async_manager/secret.py @@ -28,7 +28,6 @@ SECRET_NAME_LOOKUP_LIMIT, SecretsSealer, browser_metadata, - check_secret_metadata, check_secret_version, is_not_found, looks_like_secret_id, @@ -125,7 +124,6 @@ async def update( """ body = update_secret_body(params) resolved = (await self._resolve(secret))[0] - check_secret_metadata(resolved.type, body.get("metadata")) payload = await self._request("PATCH", f"/secrets/{resolved.id}", data=body) return Secret(**payload) diff --git a/hyperbrowser/client/managers/sandboxes/shared.py b/hyperbrowser/client/managers/sandboxes/shared.py index 2604c6b2..51b8ce4b 100644 --- a/hyperbrowser/client/managers/sandboxes/shared.py +++ b/hyperbrowser/client/managers/sandboxes/shared.py @@ -24,7 +24,7 @@ parse_error_payload, runtime_base_url_session_id, ) -from ....models.secret import OnePasswordSource, SecretReference, onepassword_clashes +from ....models.secret import OnePasswordSource, SecretReference from ....types import ( OnePasswordSource as OnePasswordSourceDict, SecretReference as SecretReferenceDict, @@ -134,11 +134,6 @@ def _normalize_exec_params( ).one_password normalized = params.model_copy(update=updates) if updates else params - clash = onepassword_clashes( - normalized.one_password, secrets=normalized.secrets, env=normalized.env - ) - if clash: - raise HyperbrowserError(clash, code="invalid_secret_reference") if normalized.uses_secrets and normalized.use_shell is not None: # The control API's start has no such option; don't drop it silently. raise HyperbrowserError( diff --git a/hyperbrowser/client/managers/sync_manager/secret.py b/hyperbrowser/client/managers/sync_manager/secret.py index 37576196..fb32ae6b 100644 --- a/hyperbrowser/client/managers/sync_manager/secret.py +++ b/hyperbrowser/client/managers/sync_manager/secret.py @@ -28,7 +28,6 @@ SECRET_NAME_LOOKUP_LIMIT, SecretsSealer, browser_metadata, - check_secret_metadata, check_secret_version, is_not_found, looks_like_secret_id, @@ -125,7 +124,6 @@ def update( """ body = update_secret_body(params) resolved = self._resolve(secret)[0] - check_secret_metadata(resolved.type, body.get("metadata")) payload = self._request("PATCH", f"/secrets/{resolved.id}", data=body) return Secret(**payload) diff --git a/hyperbrowser/credentials_common.py b/hyperbrowser/credentials_common.py index fc3fa865..4ee29211 100644 --- a/hyperbrowser/credentials_common.py +++ b/hyperbrowser/credentials_common.py @@ -1,4 +1,4 @@ -"""Credential fill rules shared by the sync and async clients and the tools.""" +"""Credential fill serialization shared by sync and async clients.""" from typing import Any, Dict @@ -6,62 +6,13 @@ from .client._request import coerce_request from .exceptions import HyperbrowserError -from .models.credential import CredentialFillField, FillCredentialParams -from .models.secret import ( - MAX_ONEPASSWORD_REFERENCE_BYTES, - SECRET_ENV_NAME_PATTERN, - SECRET_NAME_PATTERN, - onepassword_reference, -) -from .models.vault import MAX_VAULT_ENV_NAME_LENGTH +from .models.credential import FillCredentialParams -MAX_CREDENTIAL_FILL_FIELDS = 16 -MAX_CREDENTIAL_NAME_LENGTH = 255 -MAX_CREDENTIAL_SELECTOR_BYTES = 1024 -MAX_CREDENTIAL_PAGE_ID_BYTES = 128 -MAX_CREDENTIAL_URL_BYTES = 2048 -# The server gives a fill about 18 seconds, so wait longer than that. CREDENTIAL_FILL_REQUEST_TIMEOUT_SECONDS = 30 -def _invalid(message: str) -> HyperbrowserError: - return HyperbrowserError(message, code="invalid_request") - - -def _utf8_length(value: str) -> int: - return len(value.encode("utf-8", "surrogatepass")) - - -def _check_bytes(value: Any, name: str, limit: int) -> None: - if value is not None and not 0 < _utf8_length(value) <= limit: - raise _invalid(f"{name} must be 1 to {limit} bytes when set") - - -def _check_onepassword_field(vault: str, entry: CredentialFillField) -> None: - if entry.field is not None: - raise _invalid( - "field names a vault entry; with one_password each field takes " - "ref (the 1Password field) instead" - ) - if entry.ref is None: - raise _invalid( - f"The field for {entry.selector!r} needs ref: the 1Password field " - "to type" - ) - size = _utf8_length(onepassword_reference(vault, entry.ref)) - if size > MAX_ONEPASSWORD_REFERENCE_BYTES: - raise _invalid( - f"The 1Password reference for {entry.selector!r} is {size} bytes; " - f"at most {MAX_ONEPASSWORD_REFERENCE_BYTES} are allowed" - ) - - def fill_credential_payload(params: Any) -> Dict[str, Any]: - """Check a fill request locally and return its JSON body. - - Raises ``HyperbrowserError`` with code ``invalid_request`` before any - request is sent. - """ + """Serialize aliases and frame targeting; the API validates fill policy.""" try: request = coerce_request(params, FillCredentialParams) except ValidationError as error: @@ -70,76 +21,11 @@ def fill_credential_payload(params: Any) -> Dict[str, Any]: f"{problem['msg']}" for problem in error.errors() ) - raise _invalid(f"Invalid credential fill request: {problems}") from None + raise HyperbrowserError( + f"Invalid credential fill request: {problems}", code="invalid_request" + ) from None except TypeError as error: - raise _invalid(str(error)) from None - - sources = [request.credential, request.vault, request.one_password] - if sum(source is not None for source in sources) != 1: - raise _invalid( - "Pass exactly one of credential (a secret attached to the session), " - "vault (a vault attached to the session) and one_password (1Password " - "fields read with an op_token attached to the session)" - ) - if request.credential is not None and not ( - request.credential.strip() - and len(request.credential) <= MAX_CREDENTIAL_NAME_LENGTH - ): - raise _invalid("credential must name a secret attached to the session") - if request.vault is not None and not SECRET_NAME_PATTERN.fullmatch(request.vault): - raise _invalid( - f"Invalid vault name {request.vault!r}: names are 1-128 letters, " - "digits, '_' or '-', starting with a letter or digit" - ) - if not 1 <= len(request.fields) <= MAX_CREDENTIAL_FILL_FIELDS: - raise _invalid( - f"fields must have 1 to {MAX_CREDENTIAL_FILL_FIELDS} entries, " - f"got {len(request.fields)}" - ) - selectors = set() - for entry in request.fields: - if not entry.selector.strip(): - raise _invalid("A field's selector is empty") - if _utf8_length(entry.selector) > MAX_CREDENTIAL_SELECTOR_BYTES: - raise _invalid( - f"The selector {entry.selector[:64]!r} is longer than " - f"{MAX_CREDENTIAL_SELECTOR_BYTES} bytes" - ) - if entry.selector in selectors: - raise _invalid(f"The selector {entry.selector!r} is listed more than once") - selectors.add(entry.selector) - if request.one_password is not None: - _check_onepassword_field(request.one_password.vault, entry) - continue - if entry.ref is not None: - raise _invalid( - "ref names a 1Password field; pass it only with one_password" - ) - if request.vault is None: - if entry.field is not None: - raise _invalid( - "field names a vault entry; leave it out when filling a " - "single credential" - ) - elif entry.field is None: - raise _invalid( - f"The field for {entry.selector!r} needs field: the vault entry " - "to type" - ) - elif ( - not SECRET_ENV_NAME_PATTERN.fullmatch(entry.field) - or len(entry.field) > MAX_VAULT_ENV_NAME_LENGTH - or entry.field == "__proto__" - ): - raise _invalid( - f"Invalid field {entry.field!r}: vault entries are environment " - "variable names (letters, digits and '_', not starting with a digit)" - ) - if request.page_id is not None and request.page_url is not None: - raise _invalid("Pass page_id or page_url, not both") - _check_bytes(request.page_id, "page_id", MAX_CREDENTIAL_PAGE_ID_BYTES) - _check_bytes(request.page_url, "page_url", MAX_CREDENTIAL_URL_BYTES) - _check_bytes(request.frame_url, "frame_url", MAX_CREDENTIAL_URL_BYTES) + raise HyperbrowserError(str(error), code="invalid_request") from None body = request.model_dump(by_alias=True, exclude_none=True) if request.frame_url is not None: diff --git a/hyperbrowser/models/credential.py b/hyperbrowser/models/credential.py index c0ecc7b6..e6548035 100644 --- a/hyperbrowser/models/credential.py +++ b/hyperbrowser/models/credential.py @@ -3,14 +3,9 @@ from datetime import datetime from typing import List, Literal, Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field -from .secret import ( - OnePasswordField, - validate_allowed_origins, - validate_onepassword_name, - validate_secret_name, -) +from .secret import OnePasswordField # What a fill did with each requested input. New outcomes may be added, so # results keep any string the server sends. @@ -62,21 +57,6 @@ class OnePasswordFillSource(BaseModel): vault: str allowed_origins: List[str] = Field(serialization_alias="allowedOrigins") - @field_validator("token") - @classmethod - def validate_token(cls, value): - return validate_secret_name(value) - - @field_validator("vault") - @classmethod - def validate_vault(cls, value): - return validate_onepassword_name(value, "The 1Password vault") - - @field_validator("allowed_origins", mode="before") - @classmethod - def validate_allowed_origins(cls, value): - return validate_allowed_origins(value) - class FillCredentialParams(BaseModel): """Fill a secret, a vault's entries, or 1Password fields into a page's diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index 4c8f561a..fdf6fa0b 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -16,8 +16,6 @@ SandboxResolvedSecret, SecretReference, normalize_secret_references, - onepassword_clashes, - validate_secret_name, ) from .session import SessionLaunchState, SessionStatus @@ -272,11 +270,6 @@ class CreateSandboxParams(_SandboxLaunchCommonParams): def normalize_secrets(cls, value): return normalize_secret_references(value) - @field_validator("vault") - @classmethod - def validate_vault(cls, value): - return None if value is None else validate_secret_name(value) - @model_validator(mode="after") def validate_launch_source(self): if self.snapshot_id and not self.snapshot_name: @@ -303,9 +296,6 @@ def validate_launch_source(self): "secrets, vault and one_password are only supported for image " "launches; snapshots don't run a startup command" ) - clash = onepassword_clashes(self.one_password, secrets=self.secrets) - if clash: - raise ValueError(clash) return self @@ -632,11 +622,6 @@ class SandboxExecParams(SandboxBaseModel): def normalize_secrets(cls, value): return normalize_secret_references(value) - @field_validator("vault") - @classmethod - def validate_vault(cls, value): - return None if value is None else validate_secret_name(value) - @property def uses_secrets(self) -> bool: """Whether the start delivers secrets, so goes through the control API.""" diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py index 8e8ededc..5009cf05 100644 --- a/hyperbrowser/models/secret.py +++ b/hyperbrowser/models/secret.py @@ -1,6 +1,4 @@ -import ipaddress import re -import unicodedata from datetime import datetime from typing import Dict, List, Literal, Optional, Union @@ -84,55 +82,6 @@ def normalize_secret_references(value): } -MAX_ONEPASSWORD_NAME_LENGTH = 256 -MAX_ONEPASSWORD_REFERENCE_BYTES = 512 -# Characters 1Password names can't hold in an op:// reference: control, -# format, line separator and paragraph separator characters. -_ONEPASSWORD_FORBIDDEN_CATEGORIES = frozenset({"Cc", "Cf", "Zl", "Zp"}) -# A field may end with a query, such as ?attribute=otp or ?ssh-format=openssh. -_ONEPASSWORD_QUERY_PATTERN = re.compile( - r"\?[a-z-]+=[A-Za-z0-9_-]+(?:&[a-z-]+=[A-Za-z0-9_-]+)*" -) - - -def _utf16_length(value: str) -> int: - # The server counts string lengths in UTF-16 code units. - return len(value.encode("utf-16-le", "surrogatepass")) // 2 - - -def validate_onepassword_name(value: str, what: str, query: bool = False) -> str: - """Check a 1Password vault, item, section or field name (or ID) for an - ``op://`` reference. A field may end with a query such as - ``?attribute=otp``.""" - if not isinstance(value, str) or not value: - raise ValueError(f"{what} must be a non-empty 1Password name or ID") - if _utf16_length(value) > MAX_ONEPASSWORD_NAME_LENGTH: - raise ValueError( - f"{what} is longer than {MAX_ONEPASSWORD_NAME_LENGTH} characters" - ) - name, mark, params = value.partition("?") if query else (value, "", "") - if mark and not _ONEPASSWORD_QUERY_PATTERN.fullmatch(mark + params): - raise ValueError( - f"{what} has an invalid query; expected ?key=value pairs such as " - "?attribute=otp" - ) - if ( - not name - or name in (".", "..") - or "/" in name - or "?" in name - or any( - unicodedata.category(char) in _ONEPASSWORD_FORBIDDEN_CATEGORIES - for char in name - ) - ): - raise ValueError( - f"{what} must be a 1Password name or ID without '/' or '?', control " - "characters, or a name of '.' or '..'" - ) - return value - - def validate_secret_env_name(env, what: str = "a secret") -> str: if ( not isinstance(env, str) @@ -161,36 +110,11 @@ class OnePasswordField(SecretBaseModel): section: Optional[str] = None field: str - @field_validator("item") - @classmethod - def validate_item(cls, value): - return validate_onepassword_name(value, "item") - - @field_validator("section") - @classmethod - def validate_section(cls, value): - return None if value is None else validate_onepassword_name(value, "section") - - @field_validator("field") - @classmethod - def validate_field(cls, value): - return validate_onepassword_name(value, "field", query=True) - - -def onepassword_reference(vault: str, field: OnePasswordField) -> str: - """The ``op://vault/item/[section/]field`` reference the platform builds.""" - parts = [vault, field.item] + ([field.section] if field.section else []) - return "op://" + "/".join(parts + [field.field]) - class OnePasswordSource(SecretBaseModel): - """Environment variables from fields of one 1Password vault. + """Environment variables read from one 1Password vault at process start. - ``token`` names an ``op_token`` secret holding a 1Password service account - token (its latest version signs in); ``vault`` is the 1Password vault's - name or ID; ``fields`` maps each environment variable to the field it - takes. Nothing is stored: the sandbox resolves the fields when the - process starts. + The API validates names, reference sizes, limits and source conflicts. """ model_config = ConfigDict(populate_by_name=True, extra="forbid") @@ -199,63 +123,6 @@ class OnePasswordSource(SecretBaseModel): vault: str fields: Dict[str, OnePasswordField] - @field_validator("token") - @classmethod - def validate_token(cls, value): - return validate_secret_name(value) - - @field_validator("vault") - @classmethod - def validate_vault(cls, value): - return validate_onepassword_name(value, "The 1Password vault") - - @field_validator("fields", mode="before") - @classmethod - def validate_fields(cls, value): - if not isinstance(value, dict): - return value - if not 1 <= len(value) <= MAX_SECRET_BINDINGS: - raise ValueError( - f"one_password takes 1 to {MAX_SECRET_BINDINGS} fields; " - f"got {len(value)}" - ) - for env in value: - validate_secret_env_name(env, "a 1Password field") - return value - - @model_validator(mode="after") - def check_reference_sizes(self): - for env, field in self.fields.items(): - size = len( - onepassword_reference(self.vault, field).encode( - "utf-8", "surrogatepass" - ) - ) - if size > MAX_ONEPASSWORD_REFERENCE_BYTES: - raise ValueError( - f"{env}: the 1Password reference is {size} bytes; at most " - f"{MAX_ONEPASSWORD_REFERENCE_BYTES} are allowed" - ) - return self - - -def onepassword_clashes( - one_password: Optional[OnePasswordSource], **sources -) -> Optional[str]: - """Describe a variable ``one_password`` shares with another source of the - request (``secrets`` or ``env``), or return ``None``. Clashes with a vault - are only known to the server.""" - if one_password is None: - return None - for name, variables in sources.items(): - for env in one_password.fields: - if variables and env in variables: - return ( - f"{env} is set by {name} and by one_password; a 1Password " - "field's variable must come from it alone" - ) - return None - # What a secret's value is; fixed when the secret is created. # secret an opaque string: an environment variable, and typed into pages @@ -270,104 +137,6 @@ def onepassword_clashes( SecretValue = Union[str, bytes] -MAX_ALLOWED_ORIGINS = 20 -MAX_ALLOWED_ORIGIN_LENGTH = 512 -# The server's form for allowed origins: scheme://host[:port], where the host -# may start with "*." to match any subdomain. ASCII only, as in JavaScript -# without the u flag: no Unicode case folding or digits. -_ALLOWED_ORIGIN_PATTERN = re.compile( - r"([a-z]+)://(\*\.)?([^/:?#]+|\[[^\]]+\])(?::([0-9]{1,5}))?/?", - re.IGNORECASE | re.ASCII, -) -_ORIGIN_HOST_LABEL = re.compile(r"(?!-)[a-z0-9-]{1,63}(? bool: - """Whether Node's isIPv6 accepts ``address``. Python 3.8 knows no zone - IDs, and later versions accept zones Node refuses, so the zone is - checked here.""" - address, mark, zone = address.partition("%") - if mark and not _IPV6_ZONE.fullmatch(zone): - return False - try: - ipaddress.IPv6Address(address) - except ValueError: - return False - return True - - -def validate_allowed_origin(entry) -> str: - """Check an allowed origin as the server normalizes one, returning it - unchanged: ``scheme://host[:port]`` with no path, https only (http for - localhost, 127.0.0.1 and [::1]), and a ``*.`` wildcard only on a - registrable domain.""" - if not isinstance(entry, str): - raise ValueError( - f"Allowed origin {entry!r} must be a string such as https://example.com" - ) - if _utf16_length(entry) > MAX_ALLOWED_ORIGIN_LENGTH: - raise ValueError( - f"Allowed origin {entry[:64]!r} is longer than " - f"{MAX_ALLOWED_ORIGIN_LENGTH} characters" - ) - - def reject(reason: str) -> ValueError: - return ValueError(f"Allowed origin {entry!r} {reason}") - - match = _ALLOWED_ORIGIN_PATTERN.fullmatch(entry.strip(_JS_WHITESPACE)) - if not match: - raise reject("must be scheme://host[:port] with no path") - scheme, wildcard, host, port = match.groups() - scheme = scheme.lower() - host = host.lower() - if scheme not in ("https", "http"): - raise reject("must use https") - if scheme == "http" and (wildcard or host not in _LOCAL_ORIGIN_HOSTS): - raise reject("must use https unless it is localhost") - if port and int(port) > 65535: - raise reject("has an invalid port") - if not host.startswith("["): - labels = host.split(".") - if not all(_ORIGIN_HOST_LABEL.fullmatch(label) for label in labels): - raise reject("has an invalid host") - # "*.com" would cover a whole public suffix. - if wildcard and len(labels) < 2: - raise reject("wildcard must name a registrable domain") - # No top-level domain is numeric, so this is (part of) an IPv4 address. - if wildcard and labels[-1].isdigit(): - raise reject("cannot use a wildcard with an IP address") - elif wildcard: - raise reject("cannot use a wildcard with an IP address") - elif not _is_ipv6(host[1:-1]): - raise reject("has an invalid host") - return entry - - -def validate_allowed_origins(origins) -> List[str]: - """Check a list of 1 to 20 allowed origins (see validate_allowed_origin).""" - if isinstance(origins, (str, bytes)) or not isinstance(origins, (list, tuple)): - raise ValueError( - 'allowed_origins must be a list of origins such as ["https://example.com"]' - ) - if not 1 <= len(origins) <= MAX_ALLOWED_ORIGINS: - raise ValueError( - f"allowed_origins takes 1 to {MAX_ALLOWED_ORIGINS} origins; " - f"got {len(origins)}" - ) - return [validate_allowed_origin(origin) for origin in origins] - - class SecretBrowserMetadata(SecretBaseModel): """Where a secret may be typed into browser pages. @@ -452,13 +221,6 @@ def merge_allowed_origins(self): ) } ) - if self.metadata is not None and self.metadata.browser is not None: - if self.type == "op_token": - raise ValueError( - "An op_token secret can't have allowed origins: it is never " - "typed into browser pages" - ) - validate_allowed_origins(self.metadata.browser.allowed_origins) return self diff --git a/hyperbrowser/models/session.py b/hyperbrowser/models/session.py index 0316a97a..624f54e1 100644 --- a/hyperbrowser/models/session.py +++ b/hyperbrowser/models/session.py @@ -3,7 +3,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -from hyperbrowser.models.secret import validate_secret_name from hyperbrowser.models.consts import ( ISO639_1, Country, @@ -265,9 +264,6 @@ class SessionCredential(BaseModel): name: str -MAX_SESSION_CREDENTIALS = 20 - - class SessionCredentialSecret(BaseModel): """Attaches a secret, by name, to a new session.""" @@ -275,11 +271,6 @@ class SessionCredentialSecret(BaseModel): secret: str - @field_validator("secret") - @classmethod - def validate_secret(cls, value): - return validate_secret_name(value) - class SessionCredentialVault(BaseModel): """Attaches the secrets of a stored vault, by name, to a new session.""" @@ -288,11 +279,6 @@ class SessionCredentialVault(BaseModel): vault: str - @field_validator("vault") - @classmethod - def validate_vault(cls, value): - return validate_secret_name(value) - class SessionCredentialOnePassword(BaseModel): """Attaches an ``op_token`` secret, by name, to a new session: fills may @@ -303,11 +289,6 @@ class SessionCredentialOnePassword(BaseModel): one_password: str = Field(serialization_alias="onePassword") - @field_validator("one_password") - @classmethod - def validate_one_password(cls, value): - return validate_secret_name(value) - # A plain secret name is shorthand for SessionCredentialSecret. SessionCredentialInput = Union[ @@ -680,19 +661,6 @@ class CreateSessionParams(BaseModel): type the 1Password fields it can read. Secrets need allowed origins to be filled, and can only be attached when the session is created.""" - @field_validator("credentials", mode="before") - @classmethod - def validate_credentials(cls, value): - if isinstance(value, (list, tuple)): - if len(value) > MAX_SESSION_CREDENTIALS: - raise ValueError( - f"At most {MAX_SESSION_CREDENTIALS} credentials can be attached" - ) - for item in value: - if isinstance(item, str): - validate_secret_name(item) - return value - class CreateSessionSnapshotResponse(BaseModel): """ diff --git a/hyperbrowser/models/vault.py b/hyperbrowser/models/vault.py index 5bc9459e..c2bf6f87 100644 --- a/hyperbrowser/models/vault.py +++ b/hyperbrowser/models/vault.py @@ -2,24 +2,11 @@ secrets. A vault holds no values itself.""" from datetime import datetime -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Union -from pydantic import ConfigDict, Field, field_validator, model_validator +from pydantic import ConfigDict, Field, field_validator -from .secret import ( - MAX_SECRET_ENV_NAME_LENGTH, - SecretBaseModel, - validate_secret_env_name, - validate_secret_name, - validate_secret_version, -) - -MAX_VAULT_ENTRIES = 32 -MAX_VAULT_ENV_NAME_LENGTH = MAX_SECRET_ENV_NAME_LENGTH - - -def validate_vault_env_name(env: Any) -> str: - return validate_secret_env_name(env, "a vault entry") +from .secret import SecretBaseModel class StoredEntry(SecretBaseModel): @@ -44,15 +31,6 @@ class VaultEntry(StoredEntry): secret_id: Optional[str] = Field(default=None, alias="secretId") -def _check_entry(env: str, entry: Optional[StoredEntry]) -> None: - validate_vault_env_name(env) - if entry is None: - return - validate_secret_name(entry.name) - if entry.version is not None and entry.version != "latest": - validate_secret_version(entry.version) - - class CreateVaultParams(SecretBaseModel): """A new vault: ``entries`` maps environment variable names to the stored secrets they take.""" @@ -65,24 +43,6 @@ class CreateVaultParams(SecretBaseModel): description: Optional[str] = None entries: Optional[Dict[str, StoredEntry]] = None - @field_validator("name") - @classmethod - def validate_name(cls, value): - return validate_secret_name(value) - - @field_validator("entries", mode="before") - @classmethod - def limit_entries(cls, value): - if isinstance(value, dict) and len(value) > MAX_VAULT_ENTRIES: - raise ValueError(f"A vault can hold at most {MAX_VAULT_ENTRIES} entries") - return value - - @model_validator(mode="after") - def check_entries(self): - for env, entry in (self.entries or {}).items(): - _check_entry(env, entry) - return self - class UpdateVaultParams(SecretBaseModel): """Changes to a vault. Members left out (or ``None``) are kept. @@ -96,14 +56,6 @@ class UpdateVaultParams(SecretBaseModel): description: Optional[str] = None entries: Optional[Dict[str, Optional[StoredEntry]]] = None - @model_validator(mode="after") - def check_changes(self): - if self.description is None and self.entries is None: - raise ValueError("Update a vault's description, entries, or both") - for env, entry in (self.entries or {}).items(): - _check_entry(env, entry) - return self - class VaultListParams(SecretBaseModel): page: Optional[int] = Field(default=None, ge=1) diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index f4ff9136..a910a104 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -25,7 +25,6 @@ from .models.secret import ( SecretsPublicKey, UpdateSecretParams, - validate_allowed_origins, validate_secret_version, ) @@ -253,32 +252,6 @@ def browser_metadata(allowed_origins: Optional[Sequence[str]]) -> Dict[str, Any] return {"browser": {"allowed_origins": list(allowed_origins)}} -def check_secret_metadata(secret_type: Optional[str], metadata: Any) -> None: - """Refuse browser metadata on an ``op_token``, which is never filled.""" - if ( - secret_type == "op_token" - and isinstance(metadata, dict) - and metadata.get("browser") is not None - ): - raise HyperbrowserError( - "An op_token secret can't have allowed origins: it is never typed " - "into browser pages", - code="invalid_secret_metadata", - ) - - -def check_browser_origins(metadata: Any) -> None: - """Refuse allowed origins the server would refuse (see - ``validate_allowed_origin``) in serialized browser metadata.""" - browser = metadata.get("browser") if isinstance(metadata, dict) else None - if not isinstance(browser, dict): - return - try: - validate_allowed_origins(browser.get("allowedOrigins")) - except ValueError as error: - raise HyperbrowserError(str(error), code="invalid_secret_metadata") from None - - def update_secret_body(params: Any) -> Dict[str, Any]: """Serialize a secret update. @@ -287,7 +260,6 @@ def update_secret_body(params: Any) -> Dict[str, Any]: """ normalized = coerce_request(params, UpdateSecretParams) body = normalized.model_dump(by_alias=True, exclude_unset=True) - check_browser_origins(body.get("metadata")) return {key: value for key, value in body.items() if value is not None} diff --git a/hyperbrowser/tools/__init__.py b/hyperbrowser/tools/__init__.py index 35af3efd..a8ae3080 100644 --- a/hyperbrowser/tools/__init__.py +++ b/hyperbrowser/tools/__init__.py @@ -28,20 +28,12 @@ SCREENSHOT_TOOL_ANTHROPIC, CRAWL_TOOL_ANTHROPIC, ) -from .gemini import ( - BROWSER_USE_TOOL_GEMINI, - CRAWL_TOOL_GEMINI, - EXTRACT_TOOL_GEMINI, - FILL_CREDENTIAL_TOOL_GEMINI, - SCRAPE_TOOL_GEMINI, - SCREENSHOT_TOOL_GEMINI, -) +from .gemini import FILL_CREDENTIAL_TOOL_GEMINI class WebsiteScrapeTool: openai_tool_definition = SCRAPE_TOOL_OPENAI anthropic_tool_definition = SCRAPE_TOOL_ANTHROPIC - gemini_tool_definition = SCRAPE_TOOL_GEMINI @staticmethod def runnable(hb: Hyperbrowser, params: StartScrapeJobParams) -> str: @@ -59,7 +51,6 @@ async def async_runnable( class WebsiteScreenshotTool: openai_tool_definition = SCREENSHOT_TOOL_OPENAI anthropic_tool_definition = SCREENSHOT_TOOL_ANTHROPIC - gemini_tool_definition = SCREENSHOT_TOOL_GEMINI @staticmethod def runnable(hb: Hyperbrowser, params: StartScrapeJobParams) -> str: @@ -77,7 +68,6 @@ async def async_runnable( class WebsiteCrawlTool: openai_tool_definition = CRAWL_TOOL_OPENAI anthropic_tool_definition = CRAWL_TOOL_ANTHROPIC - gemini_tool_definition = CRAWL_TOOL_GEMINI @staticmethod def runnable(hb: Hyperbrowser, params: StartCrawlJobParams) -> str: @@ -117,7 +107,6 @@ def _normalize_extract_tool_params( class WebsiteExtractTool: openai_tool_definition = EXTRACT_TOOL_OPENAI anthropic_tool_definition = EXTRACT_TOOL_ANTHROPIC - gemini_tool_definition = EXTRACT_TOOL_GEMINI @staticmethod def runnable(hb: Hyperbrowser, params: WebsiteExtractToolParams) -> str: @@ -137,7 +126,6 @@ async def async_runnable( class BrowserUseTool: openai_tool_definition = BROWSER_USE_TOOL_OPENAI anthropic_tool_definition = BROWSER_USE_TOOL_ANTHROPIC - gemini_tool_definition = BROWSER_USE_TOOL_GEMINI @staticmethod def runnable(hb: Hyperbrowser, params: StartBrowserUseTaskParams) -> str: diff --git a/hyperbrowser/tools/gemini.py b/hyperbrowser/tools/gemini.py index 676b4b08..90dc2e13 100644 --- a/hyperbrowser/tools/gemini.py +++ b/hyperbrowser/tools/gemini.py @@ -1,118 +1,55 @@ -"""Gemini function declarations for the Hyperbrowser tools. - -The declarations are plain dicts that google-genai accepts, for example -``types.Tool(function_declarations=[FILL_CREDENTIAL_TOOL_GEMINI])``. Gemini -schemas are a subset of OpenAPI: they have no ``additionalProperties`` and -mark optional values with ``nullable`` instead of a ``"null"`` type. -""" +"""Gemini declaration for credential fill, using the shared descriptions.""" from copy import deepcopy -from typing import Any, Dict, Mapping - -from typing_extensions import Required, TypeAlias, TypedDict - -from hyperbrowser.tools.openai import ( - BROWSER_USE_TOOL_OPENAI, - CRAWL_TOOL_OPENAI, - EXTRACT_TOOL_OPENAI, - FILL_CREDENTIAL_TOOL_OPENAI, - SCRAPE_TOOL_OPENAI, - SCREENSHOT_TOOL_OPENAI, - ChatCompletionToolParam, -) - -GeminiSchema: TypeAlias = Dict[str, object] +from .schema import FILL_CREDENTIAL_DESCRIPTION, FILL_CREDENTIAL_SCHEMA -class FunctionDeclaration(TypedDict, total=False): - name: Required[str] - """The function's name: letters, digits, underscores, dots and dashes, - at most 64 characters.""" +_properties = FILL_CREDENTIAL_SCHEMA["properties"] - description: str - """What the function does and when the model should call it.""" - parameters: GeminiSchema - """The function's parameters as a Gemini (OpenAPI subset) schema.""" +def _nullable_string(description): + return {"type": "string", "nullable": True, "description": description} -# Schema members Gemini understands, with google-genai's camelCase names. -_GEMINI_SCHEMA_KEYS = frozenset( +FILL_CREDENTIAL_TOOL_GEMINI = deepcopy( { - "default", - "description", - "enum", - "example", - "format", - "maxItems", - "maxLength", - "maxProperties", - "maximum", - "minItems", - "minLength", - "minProperties", - "minimum", - "nullable", - "pattern", - "propertyOrdering", - "required", - "title", - } -) - - -def to_gemini_schema(schema: Mapping[str, Any]) -> GeminiSchema: - """Convert a tool's JSON schema to the Gemini form. - - ``"type": [T, "null"]`` becomes ``"type": T, "nullable": True``; - ``additionalProperties`` and members Gemini doesn't know are dropped. - """ - converted: GeminiSchema = {} - for key, value in schema.items(): - if key == "type": - if isinstance(value, (list, tuple)): - types = [item for item in value if item != "null"] - if len(types) != 1: - raise ValueError( - f"Gemini schemas can't express the type union {list(value)}" + "name": "fill_credential", + "description": FILL_CREDENTIAL_DESCRIPTION, + "parameters": { + "type": "object", + "properties": { + **{ + name: _nullable_string(_properties[name]["description"]) + for name in ( + "credential", + "vault", + "page_id", + "page_url", + "frame_url", ) - if len(types) != len(value): - converted["nullable"] = True - value = types[0] - converted["type"] = value - elif key == "properties": - converted["properties"] = { - name: to_gemini_schema(member) for name, member in value.items() - } - elif key == "items": - converted["items"] = to_gemini_schema(value) - elif key == "anyOf": - converted["anyOf"] = [to_gemini_schema(member) for member in value] - elif key in _GEMINI_SCHEMA_KEYS: - converted[key] = deepcopy(value) - return converted - - -def to_gemini_function_declaration( - name: str, description: str, parameters: Mapping[str, Any] -) -> FunctionDeclaration: - return { - "name": name, - "description": description, - "parameters": to_gemini_schema(parameters), + }, + "fields": { + "type": "array", + "description": _properties["fields"]["description"], + "minItems": _properties["fields"]["minItems"], + "maxItems": _properties["fields"]["maxItems"], + "items": { + "type": "object", + "properties": { + "selector": _properties["fields"]["items"]["properties"][ + "selector" + ], + "field": _nullable_string( + _properties["fields"]["items"]["properties"]["field"][ + "description" + ] + ), + }, + "required": _properties["fields"]["items"]["required"], + }, + }, + }, + "required": FILL_CREDENTIAL_SCHEMA["required"], + }, } - - -def _from_openai(tool: ChatCompletionToolParam) -> FunctionDeclaration: - function = tool["function"] - return to_gemini_function_declaration( - function["name"], function["description"], function["parameters"] - ) - - -SCRAPE_TOOL_GEMINI = _from_openai(SCRAPE_TOOL_OPENAI) -SCREENSHOT_TOOL_GEMINI = _from_openai(SCREENSHOT_TOOL_OPENAI) -CRAWL_TOOL_GEMINI = _from_openai(CRAWL_TOOL_OPENAI) -EXTRACT_TOOL_GEMINI = _from_openai(EXTRACT_TOOL_OPENAI) -BROWSER_USE_TOOL_GEMINI = _from_openai(BROWSER_USE_TOOL_OPENAI) -FILL_CREDENTIAL_TOOL_GEMINI = _from_openai(FILL_CREDENTIAL_TOOL_OPENAI) +) diff --git a/tests/test_credential_tools.py b/tests/test_credential_tools.py index 35732a78..d12030ea 100644 --- a/tests/test_credential_tools.py +++ b/tests/test_credential_tools.py @@ -13,7 +13,6 @@ WebsiteScrapeTool, WebsiteScreenshotTool, ) -from hyperbrowser.tools.gemini import to_gemini_schema from tests.test_credentials import FIELDS, FILL_RESULT, mock_clients, respond @@ -101,8 +100,8 @@ def test_openai_schemas_are_strict_mode_compatible(tool): assert set(member["required"]) == set(member["properties"]) -@pytest.mark.parametrize("tool", TOOLS, ids=lambda tool: tool.__name__) -def test_gemini_declarations_use_the_openapi_subset(tool): +def test_gemini_declarations_use_the_openapi_subset(): + tool = CredentialFillTool declaration = tool.gemini_tool_definition assert declaration["name"] == tool.openai_tool_definition["function"]["name"] assert "additionalProperties" not in json.dumps(declaration) @@ -128,13 +127,6 @@ def test_gemini_schema_conversion(): "frame_url", ] - with pytest.raises(ValueError, match="type union"): - to_gemini_schema({"type": ["string", "number"]}) - assert to_gemini_schema({"type": "object", "$schema": "x", "anyOf": []}) == { - "type": "object", - "anyOf": [], - } - TOOL_INPUT = { "credential": None, @@ -253,14 +245,16 @@ def test_tool_runner_returns_errors_as_json(sdk): ], ), ]: + respond(outcomes, {"error": "Invalid body: server validation failed"}, 400) error = json.loads(call(runner, client, "s", bad)) - assert error["error"]["code"] == "invalid_request" + assert error["error"]["message"] == "Invalid body: server validation failed" + assert error["error"]["retryable"] is False assert error["error"]["details"] is None # Input that isn't an object, as a model may send, is an error too. for bad in [None, "fill it", ["github-login"]]: error = json.loads(call(runner, client, "s", bad)) assert error["error"]["code"] == "invalid_request" - assert len(calls) == 1 + assert len(calls) == 7 def test_tool_runner_lets_other_exceptions_propagate(monkeypatch): diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 13f25dc6..69f2d0e8 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -272,7 +272,7 @@ def test_short_totp_seeds_pass_without_warnings(): check_totp_seed("MY") -def test_op_tokens_take_any_string_but_never_allowed_origins(private_key): +def test_op_tokens_take_any_string_and_use_the_token_context(private_key): created = _typed("op_token", name="op-token") http = ScriptedHTTPClient(_create_routes(private_key, created)) manager = SecretManager(FakeClient(http)) @@ -283,19 +283,6 @@ def test_op_tokens_take_any_string_but_never_allowed_origins(private_key): header, value = _open(private_key, http.calls[-1]["json"]["envelope"]) assert header["ctx"] == OP_TOKEN_CONTEXT and value == token.encode() - http.calls.clear() - for params in [ - {"name": "op-token", "type": "op_token", "allowed_origins": ORIGINS}, - { - "name": "op-token", - "type": "op_token", - "metadata": {"browser": {"allowed_origins": ORIGINS}}, - }, - ]: - with pytest.raises(ValueError, match="op_token"): - manager.create(params) - assert http.calls == [] - def test_updates_refuse_allowed_origins_on_an_op_token(): http = ScriptedHTTPClient( @@ -303,15 +290,22 @@ def test_updates_refuse_allowed_origins_on_an_op_token(): ("GET", "/secrets"): ( 200, {"secrets": [_typed("op_token", name="op-token")], "totalCount": 1}, - ) + ), + ("PATCH", f"/secrets/{SECRET_ID}"): ( + 400, + { + "code": "SECRET_METADATA_INVALID", + "message": "op_token cannot have browser metadata", + }, + ), } ) manager = SecretManager(FakeClient(http)) with pytest.raises(HyperbrowserError) as error: manager.set_allowed_origins("op-token", ORIGINS) - assert error.value.code == "invalid_secret_metadata" - assert all(call["method"] == "GET" for call in http.calls) + assert error.value.code == "SECRET_METADATA_INVALID" + assert http.calls[-1]["method"] == "PATCH" def test_values_stay_out_of_reprs_and_dumps(): @@ -387,66 +381,6 @@ def test_set_allowed_origins_replaces_or_clears_the_browser_metadata(): assert error.value.code == "invalid_secret_metadata" -BAD_ORIGINS = [ - "http://example.com", - "https://*.com", - "http://*.localhost", - "https://*.10.0.0.1", - "https://*.[::1]", - "https://example.com/login", - "https://example.com?x=1", - "https://example.com#top", - "example.com", - "ftp://example.com", - "https://example.com:65536", - "https://-example.com", - "https://exa_mple.com", - "https://" + "a" * 64 + ".com", - "https://[::g]", - "https://" + "a" * 505 + ".com", - "", - # The server trims as JavaScript does, which keeps these. - "https://example.com\x1c", - "https://example.com\x85", - # Node's isIPv6 takes only these characters in a zone. - "https://[fe80::1%e_th]", - "https://[fe80::1%]", -] - - -@pytest.mark.parametrize("origin", BAD_ORIGINS) -def test_secrets_refuse_origins_the_server_would(private_key, origin): - http = ScriptedHTTPClient( - { - ("GET", "/secrets"): (200, {"secrets": [_typed("totp")], "totalCount": 1}), - **_create_routes(private_key, _typed("secret")), - } - ) - manager = SecretManager(FakeClient(http)) - - for params in [ - {"name": "github-password", "allowed_origins": [origin]}, - { - "name": "github-password", - "metadata": { - "browser": {"allowed_origins": ["https://github.com", origin]} - }, - }, - ]: - with pytest.raises(ValueError, match="origin"): - manager.create(params) - for update in [ - lambda: manager.set_allowed_origins("github-otp", [origin]), - lambda: manager.update( - "github-otp", {"metadata": {"browser": {"allowed_origins": [origin]}}} - ), - ]: - with pytest.raises(HyperbrowserError) as error: - update() - assert error.value.code == "invalid_secret_metadata" - assert http.calls == [] - - def test_secrets_take_origins_the_server_accepts(private_key): origins = [ "https://github.com", @@ -470,12 +404,6 @@ def test_secrets_take_origins_the_server_accepts(private_key): assert http.calls[-1]["json"]["metadata"] == { "browser": {"allowedOrigins": origins} } - for count in [0, 21]: - with pytest.raises(ValueError, match="1 to 20"): - CreateSecretParams( - name="github-password", - allowed_origins=[f"https://a{i}.github.com" for i in range(count)], - ) def test_update_sends_only_the_members_given(): @@ -717,29 +645,6 @@ def test_sessions_attach_secrets_and_vaults_at_creation(sdk): assert session.credentials is None -@pytest.mark.parametrize( - "credentials", - [ - ["bad name"], - [{"vault": "a", "secret": "b"}], - [{"vault": "-a"}], - [{"login": "a"}], - ["s"] * 21, - ["s"] * 20 + [{"one_password": "op-token"}], - [{"one_password": "bad name"}], - [{"one_password": "op-token", "vault": "v"}], - # Keys are snake_case, as everywhere else in the SDK. - [{"onePassword": "op-token"}], - "github", - ], -) -def test_invalid_session_credentials_fail_before_http(sdk, credentials): - client, call, calls, outcomes = sdk - with pytest.raises(ValueError): - call(client.sessions.create, {"credentials": credentials}) - assert not calls - - def test_vault_fills_post_fields_and_parse_per_input_results(sdk): client, call, calls, outcomes = sdk respond(outcomes, FILL_RESULT) @@ -855,44 +760,6 @@ def test_fill_errors_keep_code_retryable_and_details(sdk): ONE = [{"selector": "#p"}] -@pytest.mark.parametrize( - "params", - [ - {"fields": ONE}, - {"credential": "c", "vault": "v", "fields": ONE}, - {"credential": "", "fields": ONE}, - {"credential": "c" * 256, "fields": ONE}, - {"credential": "c", "fields": [{"selector": "#p", "field": "PASSWORD"}]}, - {"vault": "v", "fields": ONE}, - {"vault": "bad name", "fields": [{"selector": "#p", "field": "P"}]}, - {"vault": "v", "fields": [{"selector": "#p", "field": "1PASSWORD"}]}, - {"vault": "v", "fields": [{"selector": "#p", "field": "__proto__"}]}, - {"credential": "c", "fields": []}, - {"credential": "c", "fields": [{"selector": f"#f{i}"} for i in range(17)]}, - {"credential": "c", "fields": [{"selector": "#p"}, {"selector": "#p"}]}, - {"credential": "c", "fields": [{"selector": " "}]}, - {"credential": "c", "fields": [{"selector": "é" * 513}]}, - {"credential": "c", "fields": [{}]}, - {"credential": "c", "fields": ONE, "page_id": "p", "page_url": "https://x"}, - {"credential": "c", "fields": ONE, "page_id": ""}, - {"credential": "c", "fields": ONE, "page_id": "p" * 129}, - {"credential": "c", "fields": ONE, "page_url": "u" * 2049}, - {"credential": "c", "fields": ONE, "frame_url": ""}, - {"credential": "c", "fields": ONE, "frame_url": "u" * 2049}, - {"credential": "c", "fields": ONE, "frame": {"url": "https://x"}}, - {"credential": "c"}, - {"credential": "c", "fields": "#p"}, - "github", - ], -) -def test_invalid_fill_requests_fail_before_http(sdk, params): - client, call, calls, outcomes = sdk - with pytest.raises(HyperbrowserError) as raised: - call(client.sessions.credentials.fill, "s", params) - assert raised.value.code == "invalid_request" - assert not calls - - def test_fill_limits_are_inclusive(sdk): client, call, calls, outcomes = sdk respond(outcomes, FILL_RESULT) @@ -1099,107 +966,6 @@ def _op(**changes): return dict(OP_SOURCE, **changes) -@pytest.mark.parametrize( - "params", - [ - # Exactly one source. - {"credential": "c", "one_password": OP_SOURCE, "fields": OP_ONE}, - {"vault": "v", "one_password": OP_SOURCE, "fields": OP_ONE}, - # ref on every field with one_password, and on none without. - {"one_password": OP_SOURCE, "fields": [{"selector": "#p"}]}, - {"one_password": OP_SOURCE, "fields": OP_ONE + [{"selector": "#q"}]}, - {"credential": "c", "fields": OP_ONE}, - {"vault": "v", "fields": [{"selector": "#p", "field": "P", "ref": OP_REF}]}, - # No vault entry with one_password. - { - "one_password": OP_SOURCE, - "fields": [{"selector": "#p", "field": "P", "ref": OP_REF}], - }, - # Allowed origins, checked as the server normalizes them. - {"one_password": _op(allowed_origins=["http://example.com"]), "fields": OP_ONE}, - {"one_password": _op(allowed_origins=["https://*.com"]), "fields": OP_ONE}, - {"one_password": _op(allowed_origins=["https://*.1.2.3"]), "fields": OP_ONE}, - { - "one_password": _op(allowed_origins=["https://x.com/login"]), - "fields": OP_ONE, - }, - {"one_password": _op(allowed_origins=["ftp://x.com"]), "fields": OP_ONE}, - { - "one_password": _op(allowed_origins=["https://x.com:65536"]), - "fields": OP_ONE, - }, - {"one_password": _op(allowed_origins=["https://-x.com"]), "fields": OP_ONE}, - {"one_password": _op(allowed_origins=["https://[nope]"]), "fields": OP_ONE}, - {"one_password": _op(allowed_origins=[]), "fields": OP_ONE}, - { - "one_password": _op( - allowed_origins=[f"https://a{i}.example.com" for i in range(21)] - ), - "fields": OP_ONE, - }, - {"one_password": _op(allowed_origins="https://x.com"), "fields": OP_ONE}, - {"one_password": _op(allowed_origins=[1]), "fields": OP_ONE}, - {"one_password": {"token": "op-token", "vault": "v"}, "fields": OP_ONE}, - # The token's name and the 1Password parts. - {"one_password": _op(token="bad name"), "fields": OP_ONE}, - {"one_password": _op(vault=""), "fields": OP_ONE}, - {"one_password": _op(vault="a/b"), "fields": OP_ONE}, - {"one_password": _op(vault=".."), "fields": OP_ONE}, - {"one_password": _op(vault="v" * 257), "fields": OP_ONE}, - { - "one_password": OP_SOURCE, - "fields": [{"selector": "#p", "ref": {"item": ".", "field": "f"}}], - }, - { - "one_password": OP_SOURCE, - "fields": [{"selector": "#p", "ref": {"item": "i\u200b", "field": "f"}}], - }, - { - "one_password": OP_SOURCE, - "fields": [ - {"selector": "#p", "ref": {"item": "i", "section": "a?b", "field": "f"}} - ], - }, - { - "one_password": OP_SOURCE, - "fields": [{"selector": "#p", "ref": {"item": "i", "field": "f?Attr=x"}}], - }, - { - "one_password": OP_SOURCE, - "fields": [{"selector": "#p", "ref": {"item": "i"}}], - }, - # op://// is at most 512 bytes: this is 513. - { - "one_password": _op(vault="v" * 200), - "fields": [ - {"selector": "#p", "ref": {"item": "i" * 200, "field": "f" * 106}} - ], - }, - # Strict objects: unknown keys fail. - {"one_password": _op(allowedOrigins=["https://x.com"]), "fields": OP_ONE}, - {"one_password": _op(fields={}), "fields": OP_ONE}, - { - "one_password": OP_SOURCE, - "fields": [{"selector": "#p", "ref": dict(OP_REF, vault="v")}], - }, - {"onePassword": OP_SOURCE, "fields": OP_ONE}, - # The usual field rules. - {"one_password": OP_SOURCE, "fields": OP_ONE + OP_ONE}, - {"one_password": OP_SOURCE, "fields": []}, - { - "one_password": OP_SOURCE, - "fields": [{"selector": f"#f{i}", "ref": OP_REF} for i in range(17)], - }, - ], -) -def test_invalid_onepassword_fills_fail_before_http(sdk, params): - client, call, calls, outcomes = sdk - with pytest.raises(HyperbrowserError) as raised: - call(client.sessions.credentials.fill, "s", params) - assert raised.value.code == "invalid_request" - assert not calls - - def test_onepassword_fill_limits_are_inclusive(sdk): client, call, calls, outcomes = sdk respond(outcomes, OP_RESULT) @@ -1223,14 +989,6 @@ def test_onepassword_fill_limits_are_inclusive(sdk): assert len(body["onePassword"]["allowedOrigins"]) == 20 assert len(body["fields"]) == 16 - with pytest.raises(HyperbrowserError): - call( - client.sessions.credentials.fill, - "s", - {"one_password": _op(allowed_origins=[origins[0] + "a"]), "fields": OP_ONE}, - ) - assert len(calls) == 1 - @pytest.mark.parametrize( "origin", @@ -1254,3 +1012,48 @@ def test_onepassword_fill_origins_the_server_accepts_pass(sdk, origin): ) # Sent as given: the server normalizes it. assert json.loads(calls[0].content)["onePassword"]["allowedOrigins"] == [origin] + + +@pytest.mark.parametrize( + "params", + [ + {"fields": [{"selector": "#p"}]}, + {"credential": "c", "vault": "v", "fields": []}, + {"vault": "v", "fields": [{"selector": "#p"}]}, + { + "credential": "c", + "fields": [{"selector": "#p"}], + "page_id": "A", + "page_url": "https://x", + }, + {"one_password": _op(allowed_origins=["http://example.com"]), "fields": OP_ONE}, + {"one_password": _op(vault="a/b"), "fields": OP_ONE}, + ], +) +def test_fill_policy_errors_come_from_the_api(sdk, params): + client, call, calls, outcomes = sdk + refused = {"error": "Invalid body: server validation failed"} + respond(outcomes, refused, 400) + with pytest.raises(HyperbrowserError) as error: + call(client.sessions.credentials.fill, "s", params) + assert error.value.status_code == 400 + assert error.value.retryable is False + assert error.value.details == refused + assert len(calls) == 1 + + +@pytest.mark.parametrize( + "params", + [ + "fill", + {"credential": "c"}, + {"credential": "c", "fields": [{}]}, + {"credential": "c", "fields": ONE, "page_urll": "https://x"}, + ], +) +def test_fill_shape_errors_still_fail_locally(sdk, params): + client, call, calls, outcomes = sdk + with pytest.raises(HyperbrowserError) as error: + call(client.sessions.credentials.fill, "s", params) + assert error.value.code == "invalid_request" + assert not calls diff --git a/tests/test_onepassword.py b/tests/test_onepassword.py index 3af36d20..a0374a28 100644 --- a/tests/test_onepassword.py +++ b/tests/test_onepassword.py @@ -14,7 +14,6 @@ OnePasswordField, OnePasswordSource, SandboxDetail, - SandboxExecParams, SandboxResolvedSecret, ) from tests.test_sandbox_wire_contract import SANDBOX_DETAIL_PAYLOAD @@ -244,98 +243,41 @@ def test_names_allow_what_the_server_allows(): ) -INVALID_SOURCES = [ - (_with_field(item="."), "item"), - (_with_field(item=".."), "item"), - (_with_field(item="a?b"), "item"), - (_with_field(item="a/b"), "item"), - (_with_field(field="."), "field"), - (_with_field(field="..?attribute=otp"), "field"), - (_with_field(field="otp?Attr=x"), "query"), - (_with_field(field="otp?attribute"), "query"), - (_with_field(field="otp?a=b&c"), "query"), - (_with_field(field="otp?a=b?c"), "query"), - (_with_field(item="i" * 257), "longer than 256"), - # 193 characters, but 257 UTF-16 units. - (_with_field(item="\U0001f600" * 64 + "e" * 129), "longer than 256"), - (_with_field(item="a​b"), "item"), - (_with_field(section="a
b"), "section"), - (_with_field(item="a\nb"), "item"), - (_with_field(section=""), "section"), - ( - dict( - SOURCE, - vault="V", - fields={"X": {"item": "i" * 250, "section": "s" * 250, "field": "ffff"}}, - ), - "513 bytes", - ), - (dict(SOURCE, fields={}), "1 to 32 fields"), - ( - dict(SOURCE, fields={f"E{i}": {"item": "i", "field": "f"} for i in range(33)}), - "1 to 32 fields", - ), - (dict(SOURCE, fields={"1X": {"item": "i", "field": "f"}}), "environment"), - (dict(SOURCE, fields={"__proto__": {"item": "i", "field": "f"}}), "environment"), - (dict(SOURCE, token="bad name"), "secret name"), - (dict(SOURCE, vault="a/b"), "1Password vault"), - (_with_field(label="x"), "Extra inputs"), - (dict(SOURCE, tokenSecret="op-token"), "Extra inputs"), - ({"token": "op-token", "vault": "V"}, "fields"), -] - - -@pytest.mark.parametrize("source,message", INVALID_SOURCES) @pytest.mark.parametrize("async_client", [False, True], ids=["sync", "async"]) @pytest.mark.anyio -async def test_invalid_sources_fail_before_any_request(async_client, source, message): - manager, http = _sandbox_manager({}, async_client=async_client) - with pytest.raises(ValueError, match=message): - result = manager.create({"image_name": "node", "one_password": source}) - if async_client: - await result - - api_type = AsyncSandboxProcessesApi if async_client else SandboxProcessesApi - api = api_type(RuntimeScript(), None, "sbx_123") - with pytest.raises(ValueError, match=message): - result = api.start("node app.js", one_password=source) +async def test_source_conflicts_are_rejected_by_the_control_api(async_client): + manager, http = _sandbox_manager( + { + ("POST", "/sandbox"): ( + 400, + { + "code": "SECRET_SOURCES_INVALID", + "message": "SC_USER is set by secrets and by onePassword", + }, + ) + }, + async_client=async_client, + ) + with pytest.raises(HyperbrowserError) as error: + result = manager.create( + { + "image_name": "node", + "one_password": SOURCE, + "secrets": {"SC_USER": "user"}, + } + ) if async_client: await result - with pytest.raises(ValueError, match=message): - SandboxExecParams(command="x", one_password=source) - assert http.calls == [] - - -def test_a_variable_from_one_password_may_not_come_from_secrets_or_env(): - with pytest.raises(ValueError, match="SC_USER is set by secrets and by one_pass"): - CreateSandboxParams( - image_name="node", one_password=SOURCE, secrets={"SC_USER": "user"} - ) - - api = SandboxProcessesApi(RuntimeScript(), None, "sbx_123") - with pytest.raises(HyperbrowserError, match="SC_OTP is set by secrets") as error: - api.start("node app.js", one_password=SOURCE, secrets={"SC_OTP": "otp"}) - assert error.value.code == "invalid_secret_reference" - with pytest.raises(HyperbrowserError, match="SC_USER is set by env"): - api.start( - {"command": "node app.js", "env": {"SC_USER": "x"}}, one_password=SOURCE - ) - - # Only the server knows a vault's entries, so a vault clash is its to refuse. - starts = [] - api = SandboxProcessesApi( - RuntimeScript(), - lambda payload: starts.append(payload) or {"process": AGENT_PROCESS}, - "sbx_123", - ) - api.start("node app.js", one_password=SOURCE, vault="github-login") - assert starts == [ - {"command": "node app.js", "vault": "github-login", "onePassword": WIRE} - ] + assert error.value.code == "SECRET_SOURCES_INVALID" + assert error.value.retryable is False + assert len(http.calls) == 1 + assert http.calls[0]["json"]["onePassword"] == WIRE + assert http.calls[0]["json"]["secrets"] == {"SC_USER": {"name": "user"}} -@pytest.mark.anyio -async def test_async_clashes_fail_before_any_request(): - api = AsyncSandboxProcessesApi(AsyncRuntimeScript(), None, "sbx_123") - with pytest.raises(HyperbrowserError, match="SC_USER is set by env"): - await api.start("node app.js", env={"SC_USER": "x"}, one_password=SOURCE) +@pytest.mark.parametrize( + "source", [dict(SOURCE, tokenSecret="op-token"), _with_field(label="extra")] +) +def test_unknown_source_members_fail_locally(source): + with pytest.raises(ValueError): + OnePasswordSource.model_validate(source) diff --git a/tests/test_vaults.py b/tests/test_vaults.py index d05d6192..f492b0f8 100644 --- a/tests/test_vaults.py +++ b/tests/test_vaults.py @@ -141,42 +141,6 @@ def test_entries_read_from_a_vault_can_be_sent_back(sdk): } -@pytest.mark.parametrize( - "params,message", - [ - ({"name": "bad name"}, "Invalid secret name"), - ({"name": "v", "entries": {"1PASS": {"name": "s"}}}, "vault entry"), - ({"name": "v", "entries": {"__proto__": {"name": "s"}}}, "vault entry"), - ({"name": "v", "entries": {"P" * 257: {"name": "s"}}}, "vault entry"), - ({"name": "v", "entries": {"P": {"name": "bad name"}}}, "secret name"), - ({"name": "v", "entries": {"P": {"name": "s", "version": 0}}}, "version"), - ({"name": "v", "entries": {"P": {"name": "s", "version": "next"}}}, "version"), - ( - {"name": "v", "entries": {f"E{i}": {"name": "s"} for i in range(33)}}, - "at most 32", - ), - # Vaults hold stored secrets only: 1Password fields and sources are gone. - ({"name": "v", "entries": {"P": {"item": "i", "field": "f"}}}, "name"), - ( - {"name": "v", "entries": {"P": {"name": "s", "item": "i"}}}, - "Extra inputs", - ), - ( - { - "name": "op", - "source": {"type": "1password", "token_secret": "t", "vault": "E"}, - }, - "Extra inputs", - ), - ], -) -def test_invalid_vaults_fail_before_http(sdk, params, message): - client, call, calls, outcomes = sdk - with pytest.raises(ValueError, match=message): - call(client.vaults.create, params) - assert not calls - - def test_update_distinguishes_left_out_replaced_and_removed(sdk): client, call, calls, outcomes = sdk respond(outcomes, {"vaults": [STORED_VAULT], "totalCount": 1}) @@ -217,9 +181,6 @@ def test_update_distinguishes_left_out_replaced_and_removed(sdk): } for bad in [ - {}, - {"description": None}, - {"entries": {"1X": None}}, {"source": {"vault": "Platform"}}, {"entries": {"P": {"item": "i", "field": "f"}}}, ]: @@ -339,8 +300,6 @@ def test_sandbox_create_sends_a_vault_and_keeps_the_startup_bindings(): } with pytest.raises(ValueError, match="only supported for image launches"): CreateSandboxParams(snapshot_name="snap", vault="github-login") - with pytest.raises(ValueError): - CreateSandboxParams(image_name="node", vault="bad name") manager, http = _sandbox_manager( { @@ -396,8 +355,6 @@ def start(payload): "vault": "github-login", } - with pytest.raises(ValueError): - api.start("node app.js", vault="bad name") with pytest.raises(HyperbrowserError, match="use_shell"): api.start({"command": "x", "use_shell": True}, vault="github-login") assert len(starts) == 2 @@ -420,3 +377,34 @@ async def test_async_sandbox_handles_route_vault_starts_to_the_control_api(): assert http.calls[0]["json"] == {"command": "node app.js", "vault": "prod-db"} assert handle.secrets is not None and handle.secrets[1].secret_type == "totp" + + +@pytest.mark.parametrize( + "params", + [ + {"name": "bad name"}, + {"name": "v", "entries": {"1BAD": {"name": "s", "version": 0}}}, + ], +) +def test_vault_policy_errors_come_from_the_api(sdk, params): + client, call, calls, outcomes = sdk + respond(outcomes, {"error": "Invalid vault request"}, 400) + with pytest.raises(HyperbrowserError) as error: + call(client.vaults.create, params) + assert error.value.status_code == 400 + assert len(calls) == 1 + assert _body(calls[0]) == params + + +@pytest.mark.parametrize( + "params", + [ + {"name": "v", "source": {}}, + {"name": "v", "entries": {"A": {"item": "i", "field": "f"}}}, + ], +) +def test_vault_unknown_members_and_missing_names_fail_locally(sdk, params): + client, call, calls, outcomes = sdk + with pytest.raises(ValueError): + call(client.vaults.create, params) + assert not calls From cb24670bfc3943db6a93311552932fa53bc29048 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 23:58:21 -0700 Subject: [PATCH 23/23] Bump the version to 1.13.1 Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 49639501..66f65da1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hyperbrowser" -version = "1.12.1" +version = "1.13.1" description = "Python SDK for hyperbrowser" authors = ["Nikhil Shahi "] license = "MIT"