Skip to content

Commit 60e1d94

Browse files
Add credential fill, vaults, TOTP and 1Password tokens, with LLM tools (#142)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Document credential fill and the LLM tools Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Bump the version to 1.13.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FzcEMZLVjoiueoQFxG6F7S * Correct the browser sessions scope and SECRET_TYPE_MISMATCH status in the README Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FzcEMZLVjoiueoQFxG6F7S * Leave the version for the release to set Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FzcEMZLVjoiueoQFxG6F7S * List credential fill events in session event logs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * Document vaults, TOTP and 1Password secrets, and fill by vault Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * Name VAULT_ENTRY_INVALID for entries of the wrong kind in the README Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * Read TOTP seeds as vm-service does, and refuse non-object tool input 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * Document SECRET_TOTP_UNSUPPORTED as 501 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * Document SECRET_ONEPASSWORD_UNSUPPORTED as 501 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * Drop TOTP as the example of a value a host can't fill Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * Explain how a fill chooses its tab, with the origin check after Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v * Simplify credential validation and keep Gemini tools scoped to fill * Bump the version to 1.13.1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c638258 commit 60e1d94

49 files changed

Lines changed: 4738 additions & 111 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CLAUDE.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ client.profiles # Browser profile management
5252
client.extensions # Browser extension management
5353
client.team # Team/credit info
5454
client.computer_action # Low-level computer actions
55+
client.secrets # Encrypted secrets (secret, totp, op_token)
56+
client.vaults # Named sets of stored secrets
5557
```
5658

5759
Each manager lives in `client/managers/sync_manager/` or `client/managers/async_manager/` with identical method signatures.
@@ -69,9 +71,10 @@ Response and legacy models are re-exported from
6971

7072
### Pre-built Tools
7173

72-
`hyperbrowser/tools/` contains ready-to-use tool definitions for OpenAI and Anthropic function calling:
73-
- `WebsiteScrapeTool`, `WebsiteScreenshotTool`, `WebsiteCrawlTool`, `WebsiteExtractTool`, `BrowserUseTool`
74+
`hyperbrowser/tools/` contains ready-to-use tool definitions for OpenAI, Anthropic and Gemini function calling:
75+
- `WebsiteScrapeTool`, `WebsiteScreenshotTool`, `WebsiteCrawlTool`, `WebsiteExtractTool`, `BrowserUseTool`, `CredentialFillTool`
7476
- Each has `openai_tool_definition`, `anthropic_tool_definition`, plus sync/async runnable methods
77+
- `CredentialFillTool` also has `gemini_tool_definition`; `tools/gemini.py` defines only credential fill
7578

7679
## Key Patterns
7780

‎README.md‎

Lines changed: 431 additions & 3 deletions
Large diffs are not rendered by default.

‎docs/credentials-cleanup.md‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Credential SDK cleanup
2+
3+
Baseline: `6ce5a135830ff18fa5a3b6e56bc7573ae479b4ce` (python-sdk PR #142).
4+
Server reference: browserctrl master `cc9109ca5fc144b1b4e20498bbe8c90513433c89`.
5+
6+
## Changes and observable differences
7+
8+
- Fill source/name rules, field counts and byte limits, selector uniqueness,
9+
page-target conflicts, allowed-origin syntax, vault entry policies and
10+
1Password name/reference limits are validated by the API. They no longer
11+
have a second handwritten implementation in this SDK.
12+
- Invalid policy inputs can now make an HTTP request. Errors retain the API's
13+
HTTP status, optional code, retryable flag and details. A schema rejection
14+
may have no machine code; callers must not require `invalid_request` for
15+
every invalid input. Basic request-shape checks remain local.
16+
- Vault create/update serialization retains omission versus explicit entry
17+
removal (`null` / `None`). Exact-name lookup and UUID-name fallback remain.
18+
- 1Password source conflicts with secrets, environment variables or vault
19+
entries are rejected by the control API. Process requests with any secret
20+
source still go through that API; uncertain outcomes remain non-retryable.
21+
- Gemini support is scoped to `CredentialFillTool` and
22+
`FILL_CREDENTIAL_TOOL_GEMINI`. The unreleased general schema converters,
23+
related helper types and Gemini declarations on unrelated tools are removed.
24+
The fill declaration reuses the shared descriptions and parameter names.
25+
- Tests of removed local policies are replaced by API rejection/serialization
26+
coverage. Encryption, TOTP, routing, partial-create errors, secret delivery,
27+
startup binding preservation, and tool normalization coverage remain.
28+
29+
## Deliberately retained
30+
31+
- Local encryption, key verification, UTF-8/size/NUL value checks, exact
32+
server-provided encryption contexts, and errors after partial creation.
33+
- The complete local TOTP validator. The API cannot inspect an encrypted seed;
34+
removing it would delay malformed-seed errors until use.
35+
- Existing stored-secret shorthand/name/version checks and SDK launch/routing
36+
restrictions. This cleanup does not change the older secret API wholesale.
37+
- The LLM tool still binds its session in application code and never exposes
38+
1Password origin selection to a model.
39+
- Both synchronous and asynchronous managers, TypedDict inputs and legacy
40+
Pydantic request objects. Pydantic shape/unknown-member checks remain;
41+
shared serializers retain aliases, nested nulls and response-entry projection.
42+
43+
## Validation
44+
45+
- Python 3.12: 993 tests passed, including mypy/Pyright fixtures; Ruff and
46+
wheel/sdist builds passed.
47+
- Python 3.8.20: 988 passed, four typing tests skipped, and one unrelated
48+
CAPTCHA localhost integration test timed out. That test plus the complete
49+
credential/secrets/vault/tool subset passed on retry (300 tests). The
50+
cryptography dependency emitted its Python 3.8 deprecation warning.
51+
- Compared 44 request cases from both SDKs against the actual Zod schemas and
52+
metadata validators loaded from the immutable server revision above. Valid
53+
requests agree on their wire representation; invalid policy inputs are
54+
refused by the server validators or local Python shape checks.
55+
- Executed master's source-merging function: 1Password clashes with vaults or
56+
explicit secrets are refused; disjoint variables are accepted.
57+
- Contract comparison is source-level validation, not a running API/database,
58+
browser session or live 1Password end-to-end test. Those live checks were
59+
not repeated for this cleanup.

‎hyperbrowser/client/async_client.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .managers.async_manager.profile import ProfileManager
1212
from .managers.async_manager.sandbox import SandboxManager
1313
from .managers.async_manager.secret import SecretManager
14+
from .managers.async_manager.vault import VaultManager
1415
from .managers.async_manager.scrape import ScrapeManager
1516
from .managers.async_manager.session import SessionManager
1617
from .managers.async_manager.team import TeamManager
@@ -51,6 +52,7 @@ def __init__(
5152
self.sandboxes = SandboxManager(self)
5253
self.volumes = VolumeManager(self)
5354
self.secrets = SecretManager(self)
55+
self.vaults = VaultManager(self)
5456

5557
async def close(self) -> None:
5658
await self.transport.close()

‎hyperbrowser/client/managers/async_manager/sandbox.py‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
import functools
33
import time
44
from pathlib import Path
5-
from typing import Dict, Optional, Union
5+
from typing import Dict, List, Optional, Union
66

77
from ..._request import coerce_request, dump_request
88
from ....exceptions import HyperbrowserError
9+
from ....models.secret import SandboxResolvedSecret
910
from ....models.sandbox import (
1011
CompleteSandboxImageBuildParams,
1112
CreateSandboxParams,
@@ -79,6 +80,7 @@
7980
completed_image_id,
8081
)
8182
from ..sandboxes.shared import (
83+
OnePasswordSourceInput,
8284
SecretReferences,
8385
_build_sandbox_exposed_url,
8486
_copy_model,
@@ -145,6 +147,10 @@ class SandboxHandle:
145147
def __init__(self, service: "SandboxManager", detail: SandboxDetail):
146148
self._service = service
147149
self._detail = detail
150+
# Only the create response reports them; refreshes keep them.
151+
self.startup_secrets: Optional[List[SandboxResolvedSecret]] = (
152+
detail.startup_secrets
153+
)
148154
self._runtime_session = self._to_runtime_session(detail)
149155
self._transport = RuntimeTransport(
150156
self._resolve_runtime_connection,
@@ -331,14 +337,20 @@ async def exec(
331337
run_as: Optional[str] = None,
332338
max_output_bytes: int = 64 * 1024 * 1024,
333339
secrets: Optional[SecretReferences] = None,
340+
vault: Optional[str] = None,
341+
one_password: Optional[OnePasswordSourceInput] = None,
334342
):
335343
"""Run a command to completion.
336344
337345
``secrets`` maps environment variable names to stored secrets (a name,
338-
or ``{"name": ..., "version": N}``). Anything else running as the
339-
same user, or as root, in the sandbox can read a process's
340-
environment, and the sandbox and its later snapshots are marked as
341-
containing secrets.
346+
or ``{"name": ..., "version": N}``). ``vault`` names a vault whose
347+
entries become variables too; ``secrets`` win on a clash.
348+
``one_password`` sets variables from fields of a 1Password vault
349+
(``{"token": op_token_secret, "vault": ..., "fields": {ENV: {"item",
350+
"section", "field"}}}``); those variables may come from nothing else.
351+
Anything else running as the same user, or as root, in the sandbox can
352+
read a process's environment, and the sandbox and its later snapshots
353+
are marked as containing secrets.
342354
"""
343355
return await self.processes.exec(
344356
input,
@@ -349,6 +361,8 @@ async def exec(
349361
run_as=run_as,
350362
max_output_bytes=max_output_bytes,
351363
secrets=secrets,
364+
vault=vault,
365+
one_password=one_password,
352366
)
353367

354368
async def _start_secret_process(self, payload: Dict[str, object]) -> dict:
@@ -1178,16 +1192,23 @@ async def _create_detail(
11781192
) -> SandboxDetail:
11791193
normalized = coerce_request(params, CreateSandboxParams)
11801194
data = dump_request(normalized, CreateSandboxParams)
1195+
delivers_secrets = (
1196+
bool(normalized.secrets)
1197+
or normalized.vault is not None
1198+
or normalized.one_password is not None
1199+
)
11811200
try:
11821201
payload = await self._request("POST", "/sandbox", data=data)
11831202
detail = SandboxDetail(**payload)
1184-
if normalized.secrets and not detail.id.strip():
1185-
raise ValueError("The API accepted the create but returned no sandbox ID")
1203+
if delivers_secrets and not detail.id.strip():
1204+
raise ValueError(
1205+
"The API accepted the create but returned no sandbox ID"
1206+
)
11861207
return detail
11871208
except Exception as error:
11881209
# A create that may have reached the API may be running its
11891210
# startup command with the secrets, so it must not look retryable.
1190-
if normalized.secrets and request_may_have_arrived(error):
1211+
if delivers_secrets and request_may_have_arrived(error):
11911212
raise sandbox_create_outcome_unknown_error(error)
11921213
raise
11931214

‎hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
SandboxProcessStdinParams as SandboxProcessStdinParamsDict,
2727
)
2828
from ...sandboxes.shared import (
29+
OnePasswordSourceInput,
2930
SecretReferences,
3031
_normalize_exec_params,
3132
_runtime_process_payload,
@@ -354,6 +355,8 @@ async def exec(
354355
run_as: Optional[str] = None,
355356
max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES,
356357
secrets: Optional[SecretReferences] = None,
358+
vault: Optional[str] = None,
359+
one_password: Optional[OnePasswordSourceInput] = None,
357360
) -> SandboxProcessResult:
358361
handle = await self.start(
359362
input,
@@ -364,6 +367,8 @@ async def exec(
364367
run_as=run_as,
365368
max_output_bytes=max_output_bytes,
366369
secrets=secrets,
370+
vault=vault,
371+
one_password=one_password,
367372
)
368373
if handle.secrets is not None:
369374
return await self._finish_secret_exec(handle)
@@ -383,12 +388,17 @@ async def start(
383388
run_as: Optional[str] = None,
384389
max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES,
385390
secrets: Optional[SecretReferences] = None,
391+
vault: Optional[str] = None,
392+
one_password: Optional[OnePasswordSourceInput] = None,
386393
) -> SandboxProcessHandle:
387394
"""Start a process.
388395
389-
With ``secrets``, the start goes through the control API, which has the
390-
platform decrypt them into the process environment; output is then
391-
read with ``wait()`` rather than collected from the start.
396+
With ``secrets``, ``vault`` or ``one_password``, the start goes through
397+
the control API, which has the platform resolve them into the process
398+
environment; output is then read with ``wait()`` rather than collected
399+
from the start. ``secrets`` are merged over the vault's entries, and a
400+
``one_password`` variable may not also come from ``vault``,
401+
``secrets`` or ``env``.
392402
"""
393403
validate_output_limit(max_output_bytes)
394404
params = _normalize_exec_params(
@@ -399,8 +409,10 @@ async def start(
399409
timeout_sec=timeout_sec,
400410
run_as=run_as,
401411
secrets=secrets,
412+
vault=vault,
413+
one_password=one_password,
402414
)
403-
if params.secrets:
415+
if params.uses_secrets:
404416
return await self._start_with_secrets(params)
405417
events = self._transport.stream_sse(
406418
"/sandbox/processes",

‎hyperbrowser/client/managers/async_manager/secret.py‎

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import asyncio
2-
from typing import Dict, Optional, Union
2+
from typing import Dict, Optional, Sequence, Union
33

44
from ..._request import coerce_request, dump_request
55
from ....exceptions import HyperbrowserError
@@ -12,6 +12,7 @@
1212
SecretListResponse,
1313
SecretsPublicKey,
1414
SecretVersion,
15+
SecretValue,
1516
SecretVersionDeleteResult,
1617
UpdateSecretParams,
1718
)
@@ -26,12 +27,14 @@
2627
from ....secrets_common import (
2728
SECRET_NAME_LOOKUP_LIMIT,
2829
SecretsSealer,
30+
browser_metadata,
2931
check_secret_version,
3032
is_not_found,
3133
looks_like_secret_id,
3234
secret_not_found_error,
33-
secret_value_bytes,
35+
secret_payload_bytes,
3436
secret_value_not_stored_error,
37+
update_secret_body,
3538
)
3639
from ....types import CreateSecretParams as CreateSecretParamsDict
3740
from ....types import SecretListParams as SecretListParamsDict
@@ -72,14 +75,21 @@ async def create(
7275
) -> Secret:
7376
"""Create a secret, and store its first version when ``value`` is set.
7477
78+
``type`` is ``"secret"`` (the default), ``"totp"`` (a base32 seed or an
79+
``otpauth://totp/`` URI) or ``"op_token"`` (a 1Password service
80+
account token, which starts with ``ops_``). ``allowed_origins`` makes
81+
a ``secret`` or ``totp`` fillable into browser pages on those origins.
82+
7583
The value and the public key are checked before the secret is created,
7684
so a bad value or an unavailable key leaves nothing behind.
7785
"""
7886
normalized = coerce_request(params, CreateSecretParams)
79-
value = normalized.value
87+
payload = None
8088
sealer = None
81-
if value is not None:
82-
secret_value_bytes(value)
89+
if normalized.value is not None:
90+
payload = secret_payload_bytes(
91+
normalized.type or "secret", normalized.value
92+
)
8393
sealer = SecretsSealer(await self.get_public_key())
8494
secret = Secret(
8595
**await self._request(
@@ -88,10 +98,10 @@ async def create(
8898
data=normalized.model_dump(by_alias=True, exclude_none=True),
8999
)
90100
)
91-
if sealer is None or value is None:
101+
if sealer is None or payload is None:
92102
return secret
93103
try:
94-
version = await self._add_version(secret, value, sealer)
104+
version = await self._add_version(secret, payload, sealer)
95105
except Exception as error:
96106
raise secret_value_not_stored_error(secret.id, secret.name, error)
97107
return secret.model_copy(update={"latest_version": version.version})
@@ -107,20 +117,36 @@ async def update(
107117
secret: str,
108118
params: Union[UpdateSecretParamsDict, UpdateSecretParams],
109119
) -> Secret:
110-
secret_id = (await self._resolve(secret))[0].id
111-
payload = await self._request(
112-
"PATCH",
113-
f"/secrets/{secret_id}",
114-
data=dump_request(params, UpdateSecretParams),
115-
)
120+
"""Update a secret's description or metadata.
121+
122+
Metadata is merged by top-level member: each member sent replaces the
123+
stored one, ``None`` removes it, and members not sent are kept.
124+
"""
125+
body = update_secret_body(params)
126+
resolved = (await self._resolve(secret))[0]
127+
payload = await self._request("PATCH", f"/secrets/{resolved.id}", data=body)
116128
return Secret(**payload)
117129

118-
async def add_version(self, secret: str, value: Union[str, bytes]) -> SecretVersion:
119-
"""Encrypt ``value`` locally and store it as the secret's next version."""
120-
secret_value_bytes(value)
130+
async def set_allowed_origins(
131+
self, secret: str, allowed_origins: Optional[Sequence[str]]
132+
) -> Secret:
133+
"""Replace the origins a secret may be filled on; ``None`` clears
134+
them, after which it can't be filled. Not for ``op_token`` secrets."""
135+
return await self.update(
136+
secret, {"metadata": browser_metadata(allowed_origins)}
137+
)
138+
139+
async def add_version(self, secret: str, value: SecretValue) -> SecretVersion:
140+
"""Encrypt ``value`` locally and store it as the secret's next version.
141+
142+
The value is checked against the secret's type first: a ``totp``
143+
secret takes a base32 seed or an ``otpauth://totp/`` URI.
144+
"""
145+
secret_payload_bytes(None, value)
121146
resolved = (await self._resolve(secret))[0]
147+
payload = secret_payload_bytes(resolved.type, value)
122148
return await self._add_version(
123-
resolved, value, SecretsSealer(await self.get_public_key())
149+
resolved, payload, SecretsSealer(await self.get_public_key())
124150
)
125151

126152
async def delete(self, secret: str) -> SecretDeleteResult:
@@ -143,9 +169,10 @@ async def delete_version(
143169
return SecretVersionDeleteResult(**payload)
144170

145171
async def _add_version(
146-
self, secret: Secret, value: Union[str, bytes], sealer: SecretsSealer
172+
self, secret: Secret, payload: bytes, sealer: SecretsSealer
147173
) -> SecretVersion:
148-
envelope = sealer.seal(secret.encryption_context, value)
174+
# Always the context the API returned; it differs by secret type.
175+
envelope = sealer.seal(secret.encryption_context, payload)
149176
payload = await self._request(
150177
"POST", f"/secrets/{secret.id}/versions", data={"envelope": envelope}
151178
)

‎hyperbrowser/client/managers/async_manager/session.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import warnings
22

3+
from .session_credentials import SessionCredentialsManager
34
from .webmcp import WebMCPManager
45
from collections.abc import Mapping
56
from typing import IO, List, Optional, Union, overload
@@ -71,6 +72,7 @@ def __init__(self, client):
7172
self._client = client
7273
self.event_logs = SessionEventLogsManager(client)
7374
self.webmcp = WebMCPManager(client)
75+
self.credentials = SessionCredentialsManager(client)
7476

7577
async def create(
7678
self,

0 commit comments

Comments
 (0)