Skip to content

Commit f76cd4a

Browse files
committed
Simplify credential validation and keep Gemini tools scoped to fill
1 parent 6ce5a13 commit f76cd4a

19 files changed

Lines changed: 258 additions & 1048 deletions

‎CLAUDE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ Response and legacy models are re-exported from
7373

7474
`hyperbrowser/tools/` contains ready-to-use tool definitions for OpenAI, Anthropic and Gemini function calling:
7575
- `WebsiteScrapeTool`, `WebsiteScreenshotTool`, `WebsiteCrawlTool`, `WebsiteExtractTool`, `BrowserUseTool`, `CredentialFillTool`
76-
- 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
76+
- 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
7778

7879
## Key Patterns
7980

‎README.md‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Things to know:
165165
A secret takes 1-20 of at most 512 characters, https only (http for
166166
`localhost`, `127.0.0.1` and `[::1]`), with no path and `*.` only on a
167167
registrable domain such as `example.com`, never `*.com` or an IP address.
168-
They are checked locally, as the API would, before any request.
168+
The API validates and normalizes them.
169169
Change them with `client.secrets.set_allowed_origins("github-password",
170170
[...])`; `None` clears them, after which it can't be filled.
171171
`client.secrets.update()` takes `{"metadata": {"browser": None}}` too: each
@@ -238,8 +238,10 @@ Things to know:
238238
sent to 1Password. `SECRET_ONEPASSWORD_RATE_LIMITED` (429) and
239239
`SECRET_ONEPASSWORD_FAILED` (502/503) are retryable. Each has the
240240
token's `tokenSecretId` and `tokenVersion` in `details`.
241-
- Locally, invalid fill requests raise `invalid_request` and invalid values
242-
raise `invalid_secret_value`, before any request.
241+
- Local fill shape errors raise `invalid_request`; invalid secret values
242+
raise `invalid_secret_value` before upload. Fill, origin, vault-entry and
243+
1Password policy errors come from the API. Inspect the status, code (when
244+
supplied) and details; schema errors may have no machine code.
243245

244246
### Fill from 1Password
245247

@@ -291,7 +293,8 @@ for field in result.fields:
291293
- The result has no top-level `version`; each input's `secret_id` and
292294
`version` are the op_token's, and `reference` is the `op://` reference it
293295
was typed from.
294-
- All of this is checked locally before any request.
296+
- The API validates these rules. Request models still reject unknown members
297+
and invalid shapes locally.
295298

296299
### Use with LLM SDKs
297300

@@ -410,10 +413,10 @@ while True:
410413
`hyperbrowser.tools` has ready-made tools: `WebsiteScrapeTool`,
411414
`WebsiteScreenshotTool`, `WebsiteCrawlTool`, `WebsiteExtractTool`,
412415
`BrowserUseTool` and `CredentialFillTool`. Each has `openai_tool_definition`
413-
(a strict Chat Completions function tool), `anthropic_tool_definition`,
414-
`gemini_tool_definition` (a function declaration dict for google-genai), and
416+
(a strict Chat Completions function tool), `anthropic_tool_definition`, and
415417
`runnable()` / `async_runnable()` to run the model's input and return text for
416-
the model.
418+
the model. `CredentialFillTool` also provides `gemini_tool_definition`, a
419+
function declaration dict for google-genai.
417420

418421
## Usage
419422

@@ -700,9 +703,8 @@ like environment variables. A field may end with a query such as
700703
`?attribute=otp`, `?attribute=totp` or `?ssh-format=openssh`. Vaults, items,
701704
sections and fields are 1-256 characters (UTF-16 code units) without `/`, `?` or control characters, and
702705
not `.` or `..`; the whole `op://` reference is at most 512 bytes. These are
703-
checked locally before any request, as is a variable that `secrets` or `env`
704-
also sets: a variable from 1Password must come from it alone. The server also
705-
refuses one that the request's `vault` holds.
706+
checked by the API, as is a variable that `secrets`, `env` or the request's
707+
`vault` also sets: a variable from 1Password must come from it alone.
706708

707709
Things to know:
708710

‎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/managers/async_manager/secret.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
SECRET_NAME_LOOKUP_LIMIT,
2929
SecretsSealer,
3030
browser_metadata,
31-
check_secret_metadata,
3231
check_secret_version,
3332
is_not_found,
3433
looks_like_secret_id,
@@ -125,7 +124,6 @@ async def update(
125124
"""
126125
body = update_secret_body(params)
127126
resolved = (await self._resolve(secret))[0]
128-
check_secret_metadata(resolved.type, body.get("metadata"))
129127
payload = await self._request("PATCH", f"/secrets/{resolved.id}", data=body)
130128
return Secret(**payload)
131129

‎hyperbrowser/client/managers/sandboxes/shared.py‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
parse_error_payload,
2525
runtime_base_url_session_id,
2626
)
27-
from ....models.secret import OnePasswordSource, SecretReference, onepassword_clashes
27+
from ....models.secret import OnePasswordSource, SecretReference
2828
from ....types import (
2929
OnePasswordSource as OnePasswordSourceDict,
3030
SecretReference as SecretReferenceDict,
@@ -134,11 +134,6 @@ def _normalize_exec_params(
134134
).one_password
135135

136136
normalized = params.model_copy(update=updates) if updates else params
137-
clash = onepassword_clashes(
138-
normalized.one_password, secrets=normalized.secrets, env=normalized.env
139-
)
140-
if clash:
141-
raise HyperbrowserError(clash, code="invalid_secret_reference")
142137
if normalized.uses_secrets and normalized.use_shell is not None:
143138
# The control API's start has no such option; don't drop it silently.
144139
raise HyperbrowserError(

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
SECRET_NAME_LOOKUP_LIMIT,
2929
SecretsSealer,
3030
browser_metadata,
31-
check_secret_metadata,
3231
check_secret_version,
3332
is_not_found,
3433
looks_like_secret_id,
@@ -125,7 +124,6 @@ def update(
125124
"""
126125
body = update_secret_body(params)
127126
resolved = self._resolve(secret)[0]
128-
check_secret_metadata(resolved.type, body.get("metadata"))
129127
payload = self._request("PATCH", f"/secrets/{resolved.id}", data=body)
130128
return Secret(**payload)
131129

‎hyperbrowser/credentials_common.py‎

Lines changed: 7 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,18 @@
1-
"""Credential fill rules shared by the sync and async clients and the tools."""
1+
"""Credential fill serialization shared by sync and async clients."""
22

33
from typing import Any, Dict
44

55
from pydantic import ValidationError
66

77
from .client._request import coerce_request
88
from .exceptions import HyperbrowserError
9-
from .models.credential import CredentialFillField, FillCredentialParams
10-
from .models.secret import (
11-
MAX_ONEPASSWORD_REFERENCE_BYTES,
12-
SECRET_ENV_NAME_PATTERN,
13-
SECRET_NAME_PATTERN,
14-
onepassword_reference,
15-
)
16-
from .models.vault import MAX_VAULT_ENV_NAME_LENGTH
9+
from .models.credential import FillCredentialParams
1710

18-
MAX_CREDENTIAL_FILL_FIELDS = 16
19-
MAX_CREDENTIAL_NAME_LENGTH = 255
20-
MAX_CREDENTIAL_SELECTOR_BYTES = 1024
21-
MAX_CREDENTIAL_PAGE_ID_BYTES = 128
22-
MAX_CREDENTIAL_URL_BYTES = 2048
23-
# The server gives a fill about 18 seconds, so wait longer than that.
2411
CREDENTIAL_FILL_REQUEST_TIMEOUT_SECONDS = 30
2512

2613

27-
def _invalid(message: str) -> HyperbrowserError:
28-
return HyperbrowserError(message, code="invalid_request")
29-
30-
31-
def _utf8_length(value: str) -> int:
32-
return len(value.encode("utf-8", "surrogatepass"))
33-
34-
35-
def _check_bytes(value: Any, name: str, limit: int) -> None:
36-
if value is not None and not 0 < _utf8_length(value) <= limit:
37-
raise _invalid(f"{name} must be 1 to {limit} bytes when set")
38-
39-
40-
def _check_onepassword_field(vault: str, entry: CredentialFillField) -> None:
41-
if entry.field is not None:
42-
raise _invalid(
43-
"field names a vault entry; with one_password each field takes "
44-
"ref (the 1Password field) instead"
45-
)
46-
if entry.ref is None:
47-
raise _invalid(
48-
f"The field for {entry.selector!r} needs ref: the 1Password field "
49-
"to type"
50-
)
51-
size = _utf8_length(onepassword_reference(vault, entry.ref))
52-
if size > MAX_ONEPASSWORD_REFERENCE_BYTES:
53-
raise _invalid(
54-
f"The 1Password reference for {entry.selector!r} is {size} bytes; "
55-
f"at most {MAX_ONEPASSWORD_REFERENCE_BYTES} are allowed"
56-
)
57-
58-
5914
def fill_credential_payload(params: Any) -> Dict[str, Any]:
60-
"""Check a fill request locally and return its JSON body.
61-
62-
Raises ``HyperbrowserError`` with code ``invalid_request`` before any
63-
request is sent.
64-
"""
15+
"""Serialize aliases and frame targeting; the API validates fill policy."""
6516
try:
6617
request = coerce_request(params, FillCredentialParams)
6718
except ValidationError as error:
@@ -70,76 +21,11 @@ def fill_credential_payload(params: Any) -> Dict[str, Any]:
7021
f"{problem['msg']}"
7122
for problem in error.errors()
7223
)
73-
raise _invalid(f"Invalid credential fill request: {problems}") from None
24+
raise HyperbrowserError(
25+
f"Invalid credential fill request: {problems}", code="invalid_request"
26+
) from None
7427
except TypeError as error:
75-
raise _invalid(str(error)) from None
76-
77-
sources = [request.credential, request.vault, request.one_password]
78-
if sum(source is not None for source in sources) != 1:
79-
raise _invalid(
80-
"Pass exactly one of credential (a secret attached to the session), "
81-
"vault (a vault attached to the session) and one_password (1Password "
82-
"fields read with an op_token attached to the session)"
83-
)
84-
if request.credential is not None and not (
85-
request.credential.strip()
86-
and len(request.credential) <= MAX_CREDENTIAL_NAME_LENGTH
87-
):
88-
raise _invalid("credential must name a secret attached to the session")
89-
if request.vault is not None and not SECRET_NAME_PATTERN.fullmatch(request.vault):
90-
raise _invalid(
91-
f"Invalid vault name {request.vault!r}: names are 1-128 letters, "
92-
"digits, '_' or '-', starting with a letter or digit"
93-
)
94-
if not 1 <= len(request.fields) <= MAX_CREDENTIAL_FILL_FIELDS:
95-
raise _invalid(
96-
f"fields must have 1 to {MAX_CREDENTIAL_FILL_FIELDS} entries, "
97-
f"got {len(request.fields)}"
98-
)
99-
selectors = set()
100-
for entry in request.fields:
101-
if not entry.selector.strip():
102-
raise _invalid("A field's selector is empty")
103-
if _utf8_length(entry.selector) > MAX_CREDENTIAL_SELECTOR_BYTES:
104-
raise _invalid(
105-
f"The selector {entry.selector[:64]!r} is longer than "
106-
f"{MAX_CREDENTIAL_SELECTOR_BYTES} bytes"
107-
)
108-
if entry.selector in selectors:
109-
raise _invalid(f"The selector {entry.selector!r} is listed more than once")
110-
selectors.add(entry.selector)
111-
if request.one_password is not None:
112-
_check_onepassword_field(request.one_password.vault, entry)
113-
continue
114-
if entry.ref is not None:
115-
raise _invalid(
116-
"ref names a 1Password field; pass it only with one_password"
117-
)
118-
if request.vault is None:
119-
if entry.field is not None:
120-
raise _invalid(
121-
"field names a vault entry; leave it out when filling a "
122-
"single credential"
123-
)
124-
elif entry.field is None:
125-
raise _invalid(
126-
f"The field for {entry.selector!r} needs field: the vault entry "
127-
"to type"
128-
)
129-
elif (
130-
not SECRET_ENV_NAME_PATTERN.fullmatch(entry.field)
131-
or len(entry.field) > MAX_VAULT_ENV_NAME_LENGTH
132-
or entry.field == "__proto__"
133-
):
134-
raise _invalid(
135-
f"Invalid field {entry.field!r}: vault entries are environment "
136-
"variable names (letters, digits and '_', not starting with a digit)"
137-
)
138-
if request.page_id is not None and request.page_url is not None:
139-
raise _invalid("Pass page_id or page_url, not both")
140-
_check_bytes(request.page_id, "page_id", MAX_CREDENTIAL_PAGE_ID_BYTES)
141-
_check_bytes(request.page_url, "page_url", MAX_CREDENTIAL_URL_BYTES)
142-
_check_bytes(request.frame_url, "frame_url", MAX_CREDENTIAL_URL_BYTES)
28+
raise HyperbrowserError(str(error), code="invalid_request") from None
14329

14430
body = request.model_dump(by_alias=True, exclude_none=True)
14531
if request.frame_url is not None:

‎hyperbrowser/models/credential.py‎

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,9 @@
33
from datetime import datetime
44
from typing import List, Literal, Optional
55

6-
from pydantic import BaseModel, ConfigDict, Field, field_validator
6+
from pydantic import BaseModel, ConfigDict, Field
77

8-
from .secret import (
9-
OnePasswordField,
10-
validate_allowed_origins,
11-
validate_onepassword_name,
12-
validate_secret_name,
13-
)
8+
from .secret import OnePasswordField
149

1510
# What a fill did with each requested input. New outcomes may be added, so
1611
# results keep any string the server sends.
@@ -62,21 +57,6 @@ class OnePasswordFillSource(BaseModel):
6257
vault: str
6358
allowed_origins: List[str] = Field(serialization_alias="allowedOrigins")
6459

65-
@field_validator("token")
66-
@classmethod
67-
def validate_token(cls, value):
68-
return validate_secret_name(value)
69-
70-
@field_validator("vault")
71-
@classmethod
72-
def validate_vault(cls, value):
73-
return validate_onepassword_name(value, "The 1Password vault")
74-
75-
@field_validator("allowed_origins", mode="before")
76-
@classmethod
77-
def validate_allowed_origins(cls, value):
78-
return validate_allowed_origins(value)
79-
8060

8161
class FillCredentialParams(BaseModel):
8262
"""Fill a secret, a vault's entries, or 1Password fields into a page's

0 commit comments

Comments
 (0)