Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ed127ea
Add website credentials and session credential fill
AHarmlessPyro Oct 13, 2026
fd52288
Add a fill_credential LLM tool and Gemini tool declarations
AHarmlessPyro Oct 13, 2026
98e4209
Document credential fill and the LLM tools
AHarmlessPyro Oct 13, 2026
2f36941
Parse secret types the API adds later, and update README models
AHarmlessPyro Oct 13, 2026
0f3a66b
Bump the version to 1.13.0
AHarmlessPyro Oct 14, 2026
e723757
Correct the browser sessions scope and SECRET_TYPE_MISMATCH status in…
AHarmlessPyro Oct 14, 2026
08c1d38
Leave the version for the release to set
AHarmlessPyro Oct 14, 2026
96a31da
List credential fill events in session event logs
AHarmlessPyro Oct 14, 2026
81c02ca
Fill secrets and vaults, and add TOTP and op_token secrets
AHarmlessPyro Oct 14, 2026
3d5ca75
Document vaults, TOTP and 1Password secrets, and fill by vault
AHarmlessPyro Oct 14, 2026
166d5a7
Name VAULT_ENTRY_INVALID for entries of the wrong kind in the README
AHarmlessPyro Oct 14, 2026
a8abb08
Read TOTP seeds as vm-service does, and refuse non-object tool input
AHarmlessPyro Oct 14, 2026
0718a89
Document SECRET_TOTP_UNSUPPORTED as 501
AHarmlessPyro Oct 14, 2026
bc83f8f
Document SECRET_ONEPASSWORD_UNSUPPORTED as 501
AHarmlessPyro Oct 14, 2026
3b5b3fe
Drop TOTP as the example of a value a host can't fill
AHarmlessPyro Oct 14, 2026
b5bed00
Take 1Password fields per request instead of linking vaults
AHarmlessPyro Oct 14, 2026
0dac769
Explain how a fill chooses its tab, with the origin check after
AHarmlessPyro Oct 14, 2026
f617848
Fill 1Password fields into browser sessions through an op_token
AHarmlessPyro Oct 14, 2026
4225fdf
Read TOTP seeds split across lines, as vm-service now does
AHarmlessPyro Oct 14, 2026
5a8ef8d
Check origins and secret env names as master does
AHarmlessPyro Oct 14, 2026
133d554
Accept tab_opened session events
AHarmlessPyro Oct 14, 2026
cb53593
Simplify credential validation and keep Gemini tools scoped to fill
AHarmlessPyro Oct 14, 2026
9a0a25a
Bump the version to 1.13.0
AHarmlessPyro Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 stored secrets
```

Each manager lives in `client/managers/sync_manager/` or `client/managers/async_manager/` with identical method signatures.
Expand All @@ -69,9 +71,10 @@ 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`
`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`, plus sync/async runnable methods
- `CredentialFillTool` also has `gemini_tool_definition`; `tools/gemini.py` defines only credential fill

## Key Patterns

Expand Down
434 changes: 431 additions & 3 deletions README.md

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions docs/credentials-cleanup.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions hyperbrowser/client/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
37 changes: 29 additions & 8 deletions hyperbrowser/client/managers/async_manager/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -79,6 +80,7 @@
completed_image_id,
)
from ..sandboxes.shared import (
OnePasswordSourceInput,
SecretReferences,
_build_sandbox_exposed_url,
_copy_model,
Expand Down Expand Up @@ -145,6 +147,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,
Expand Down Expand Up @@ -331,14 +337,20 @@ async def exec(
run_as: Optional[str] = None,
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}``). 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.
``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,
Expand All @@ -349,6 +361,8 @@ async def exec(
run_as=run_as,
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:
Expand Down Expand Up @@ -1178,16 +1192,23 @@ 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
or normalized.one_password 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
SandboxProcessStdinParams as SandboxProcessStdinParamsDict,
)
from ...sandboxes.shared import (
OnePasswordSourceInput,
SecretReferences,
_normalize_exec_params,
_runtime_process_payload,
Expand Down Expand Up @@ -354,6 +355,8 @@ 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,
one_password: Optional[OnePasswordSourceInput] = None,
) -> SandboxProcessResult:
handle = await self.start(
input,
Expand All @@ -364,6 +367,8 @@ async def exec(
run_as=run_as,
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)
Expand All @@ -383,12 +388,17 @@ 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,
one_password: Optional[OnePasswordSourceInput] = 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``, ``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(
Expand All @@ -399,8 +409,10 @@ async def start(
timeout_sec=timeout_sec,
run_as=run_as,
secrets=secrets,
vault=vault,
one_password=one_password,
)
if params.secrets:
if params.uses_secrets:
return await self._start_with_secrets(params)
events = self._transport.stream_sse(
"/sandbox/processes",
Expand Down
65 changes: 46 additions & 19 deletions hyperbrowser/client/managers/async_manager/secret.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import asyncio
from typing import Dict, Optional, Union
from typing import Dict, Optional, Sequence, Union

from ..._request import coerce_request, dump_request
from ....exceptions import HyperbrowserError
Expand All @@ -12,6 +12,7 @@
SecretListResponse,
SecretsPublicKey,
SecretVersion,
SecretValue,
SecretVersionDeleteResult,
UpdateSecretParams,
)
Expand All @@ -26,12 +27,14 @@
from ....secrets_common import (
SECRET_NAME_LOOKUP_LIMIT,
SecretsSealer,
browser_metadata,
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
Expand Down Expand Up @@ -72,14 +75,21 @@ 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.
"""
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(
Expand All @@ -88,10 +98,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})
Expand All @@ -107,20 +117,36 @@ async def update(
secret: str,
params: Union[UpdateSecretParamsDict, UpdateSecretParams],
) -> Secret:
secret_id = (await self._resolve(secret))[0].id
payload = await self._request(
"PATCH",
f"/secrets/{secret_id}",
data=dump_request(params, UpdateSecretParams),
)
"""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)
resolved = (await self._resolve(secret))[0]
payload = await self._request("PATCH", f"/secrets/{resolved.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 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)}
)

async def add_version(self, secret: str, value: SecretValue) -> SecretVersion:
"""Encrypt ``value`` locally and store it as the secret's next version.

The value is checked against the secret's type first: a ``totp``
secret takes a base32 seed or an ``otpauth://totp/`` URI.
"""
secret_payload_bytes(None, 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:
Expand All @@ -143,9 +169,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}
)
Expand Down
2 changes: 2 additions & 0 deletions hyperbrowser/client/managers/async_manager/session.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading