Skip to content

Add credential fill, vaults, TOTP and 1Password tokens, with LLM tools - #131

Merged
AHarmlessPyro merged 23 commits into
mainfrom
credentials
Oct 15, 2026
Merged

AHarmlessPyro merged 23 commits into
mainfrom
credentials

Conversation

@AHarmlessPyro

@AHarmlessPyro AHarmlessPyro commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Adds browser credential fill, vaults and typed secrets to the SDK, plus fill_credential tool definitions for OpenAI, Anthropic and Gemini. It matches browserctrl master at 08c5d5232: #1572 (fill on plain secrets), #1551 (vaults), #1552 (op_token, per-request 1Password fields and 1Password browser fill) and #1573 (totp).

The credential secret type from #1537 is gone. A login is plain secrets that have allowed origins, usually grouped in a vault (USERNAME, PASSWORD, and optionally OTP from a totp secret). Values are encrypted locally and typed into pages by the platform, so neither the caller's code nor an LLM ever sees them.

API

Secrets

  • client.secrets.create({ name, value?, description?, type?: "secret" | "totp" | "op_token", allowedOrigins? })
    • Values are checked locally per type before anything is created.
    • totp: a base32 seed or an otpauth://totp/ URI. The parsing mirrors vm-service's parseTOTPSeed: spaces, hyphens and line breaks are ignored, = padding is allowed only at the end, and lengths 1, 3 or 6 past a multiple of 8 are refused, as vm-service also does since #1573. In a URI, algorithm, digits and period are range-checked, and HOTP is refused.
    • op_token: a 1Password service account token, named by onePassword.token. It takes no allowed origins.
    • Values are always sealed with the encryptionContext the API returns.
  • addVersion(secret, value) checks the value against the secret's stored type.
  • setAllowedOrigins(secret, origins | null), update(secret, { description?, metadata? }) and list({ type }).
  • Secret.type is a plain string, so types added later still parse.
  • createCredential and object values are removed.

Vaults (new client.vaults, by ID or exact name)

  • list, get, delete.
  • create({ name, description?, entries? }) and update(vault, { description?, entries?: { ENV: { name, version? } | null } }), where null removes an entry. Vaults hold stored secrets only, as on master.

Sandboxes

  • vault on sandboxes.create (image launches) and on process exec/start. secrets wins on a clash.
  • onePassword: { token, vault, fields: { ENV: { item, section?, field } } } on the same calls reads fields of a 1Password vault when the process starts.
  • Bindings (startupSecrets, process secrets) are either stored ({ env, name, secretId, version, secretType?, source: vault | secrets, overrides? }) or 1Password ({ env, reference, tokenSecret, source: { type: "onePassword" } }).
  • The sandbox handle keeps startupSecrets across refreshes.

Sessions

  • credentials?: Array<string | { secret } | { vault } | { onePassword }> on create. { onePassword: "op-token" } attaches an op_token so fills can read 1Password fields. The response lists the attached secrets.
  • client.sessions.credentials.fill(sessionId, { credential? | vault?, fields: [{ selector, field? }], pageId? | pageUrl?, frameUrl? })
    • It returns { version?, pageId, origin, fields: [{ selector, outcome, secretId, version, field?, expiresAt? }] }.
    • The request is checked locally, the timeout is at least 30 s, and errors keep the server's code and retryable.
  • 1Password fill: fill(sessionId, { onePassword: { token, vault, allowedOrigins }, fields: [{ selector, ref: { item, section?, field } }] }). Each field result gains reference, and there's no top-level version. One source, ref on every field, origins, part rules and 512-byte references are checked locally.

Tools: CredentialFillTool / fill_credential (OpenAI strict, Anthropic, Gemini)

  • The model picks credential or vault, fields[{selector, field}], and optionally page_id, page_url and frame_url. The application binds the session.
  • It doesn't offer 1Password fills: those name their allowed origins in the request, and a model shouldn't choose them. Apps call fill directly.
  • null and "" count as absent.
  • Results and errors come back as JSON that never contains values.
  • Every existing tool also gains a Gemini definition.

README: the secrets, vaults and credential fill section is rewritten. It covers onePassword, error codes and the tool loops.

Validation split: the SDK checks request shapes, secret values (UTF-8, size, no NUL) and TOTP seeds locally, since the API can't see an encrypted value. Policy rules (allowed-origin syntax, 1Password names and the 512-byte reference, field and entry limits, source clashes) are left to the API, which returns its status, code, retryable flag and details unchanged. See docs/credentials-cleanup.md.

Version

The package is bumped to 1.3.1 (published: 1.2.2).

Validation

  • Local checks: after the rebase onto main and the validation cleanup, yarn test passes 837 tests. Lint, typecheck and build are clean.
  • Audit against master 08c5d5232 (before the validation cleanup): every request, response, error and README claim was checked against the merged server. Fixes:
    • a vault description is now limited after trimming, as the server does;
    • the README says a fill with frameUrl doesn't narrow tabs by allowed origin;
    • the README says SECRET_ONEPASSWORD_AUTH_FAILED also covers a malformed token.
  • End to end (before the validation cleanup): 33/33 checks passed against a local stack running #1552 and #1573:
    • secrets with origins;
    • TOTP create and add-version, with bad seeds refused locally;
    • a stored vault, and a session with [{ vault }];
    • vault, single-secret and TOTP fills (expiresAt);
    • the ambiguous-page retry with pageId, and origin refusal;
    • SECRET_IN_USE;
    • a real login on practicetestautomation.com.
  • Model loop: one gpt-5-mini loop logged in by vault fill.
  • TOTP parsing: compared with #1573's parseTOTPSeed (including its line-break fix, 456995310) on 143 edge cases, alongside python-sdk, hx and the dashboard: no differences.
  • 1Password, live (run before the validation cleanup; not repeated since): 26/26 checks passed against the same stack with a real 1Password service account:
    • onePassword on sandbox create (the startup command), exec and processes.start; the values matched by hash;
    • the bindings and the clash, missing-item, wrong-type and missing-token errors;
    • an op_token refused as a plain credential;
    • 1Password fill with origin, not-attached and missing-item refusals, then a real login on scrapingcourse.com;
    • no response contains the token.

Needs browserctrl #1552 and #1573 (both merged) to be deployed for op_token, onePassword and totp.

🤖 Generated with Claude Code

https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v

@AHarmlessPyro
AHarmlessPyro marked this pull request as ready for review October 14, 2026 00:46
@AHarmlessPyro AHarmlessPyro changed the title Add website credentials, credential fill and LLM tools Add credential fill, vaults, TOTP and 1Password tokens, with LLM tools Oct 14, 2026

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/sandbox/secret-references.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 80e2b69. Configure here.

Comment thread src/services/secrets.ts Outdated
AHarmlessPyro and others added 21 commits October 14, 2026 18:22
…l tool

Website logins are stored as typed secrets and filled into browser
sessions without the caller or a model seeing the values.

Secrets:
- Secret gains type ("secret" | "credential") and metadata
  ({ browser: { allowedOrigins } }). create accepts type and metadata, and
  list filters by type.
- Credential values are objects of non-empty strings with keys matching
  ^[a-zA-Z0-9_.-]{1,64}$, at most 32 KiB as UTF-8 JSON. They are checked
  locally (invalid_secret_value) before the key is fetched or anything is
  created, and sealed as JSON to the encryption context the API returns.
- create and addVersion require an object for credentials and a string or
  bytes for plain secrets; addVersion checks against the resolved type.
- update sends metadata with the API's merge semantics (null removes a
  member).
- New createCredential({ name, value, allowedOrigins, description? }) and
  setAllowedOrigins(secret, origins | null).

Sessions:
- CreateSessionParams.credentials and SessionDetail.credentials.
- sessions.credentials.fill(sessionId, { credential, fields, pageId?,
  frame? }) posts to /session/:id/credentials/fill with a request timeout
  of at least 30 s (the server's own is about 18 s). Requests the API
  would reject fail locally with invalid_request. Errors keep the
  server's code and retryable flag, and expose the body's details (such
  as details.pages for CREDENTIAL_PAGE_AMBIGUOUS) as error.details.
- credential_filled and credential_fill_refused event log types.

Tools (@hyperbrowser/sdk/tools):
- FILL_CREDENTIAL_SCHEMA (OpenAI strict compatible) with OpenAI,
  Anthropic and Gemini definitions, and CredentialFillTool whose runnable
  takes the session ID from the application, maps the model's snake_case
  input to the API, and returns the result or the error as JSON text.
- A gemini module with its own FunctionDeclaration type and a converter
  from the SDK's JSON Schemas to Gemini's schema form (nullable instead of
  type arrays, no additionalProperties).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover creating credentials, attaching them at session create, the
fill-then-submit flow with Playwright and Puppeteer, outcomes, choosing a
page with pageId, error codes, and tool loops for OpenAI, Anthropic and
Gemini using CredentialFillTool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- sessions.credentials.fill no longer replaces HyperbrowserError.details:
  as elsewhere in the SDK it is the whole error body, so the candidate
  tabs for CREDENTIAL_PAGE_AMBIGUOUS are at error.details.details.pages.
  CredentialFillTool's error JSON still gives the model the server's own
  details object (null when the body has none).
- Every tool class has a static geminiToolDefinition, built from its
  OpenAI definition with toGeminiFunctionDeclaration and exported as
  <NAME>_TOOL_GEMINI. All existing schemas convert without changes to the
  converter.
- README: the details path, Gemini definitions on all tools, and
  claude-sonnet-5-5 in the Anthropic sample.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FzcEMZLVjoiueoQFxG6F7S
The API no longer has credential secrets with JSON-object values: any
secret with allowed origins can be filled, and a vault groups a login's
secrets under entry names.

- sessions.create takes credentials as secret names, { secret } or
  { vault }; the session lists the attached secrets.
- sessions.credentials.fill takes credential (one secret) or vault, and
  fields as { selector } or, with a vault, { selector, field } naming the
  entry. pageUrl picks a tab by URL prefix (not with pageId), and
  frameUrl replaces frame.url. All of it is checked locally first:
  exactly one of credential and vault, field iff vault, unique
  selectors, 1-16 fields, and the API's length limits.
- Results report per input the selector, outcome, secretId, version, the
  vault field and, for TOTP inputs, expiresAt; version is top-level only
  for single-secret fills. not_populated is gone.
- fill_credential lets the model pass credential or vault, a nullable
  field per input, and page_url next to page_id and frame_url; every
  property stays required for OpenAI strict mode. The description covers
  vaults, TOTP entries and the retry with page_id or page_url.
- Environment variable names for secrets are limited to 256 characters,
  as the API stores them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
- secrets.create takes allowedOrigins (sent as metadata.browser) and a
  type of "secret", "totp" or "op_token". createCredential,
  CredentialValue and object values are gone.
- Values are checked per type before anything is created: every type
  gets the common rules, and a TOTP value must be a base32 seed or an
  otpauth://totp/ URI with a valid secret, algorithm, digits and period,
  read the way vm-service reads it. Seeds under 10 bytes are accepted
  with a process warning. op_token values are not checked for ops_.
- An op_token with allowedOrigins is refused locally.
- addVersion applies the rules of the resolved secret's type; types the
  SDK doesn't know get the common rules only.
- Secret.type is a string so later types are read, and list passes any
  type filter through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
- list({ page, limit, search }), get, create, update and delete, taking
  a vault's ID or exact name like secrets do.
- create takes a source ({ type: "stored" } by default, or
  { type: "1password", tokenSecret, vault }) and entries: { name,
  version? } for stored vaults, { item, section?, field } for 1Password.
  update takes description, the 1Password source settings and entries,
  where null removes one.
- Entry names, secret names, entry kinds for the source, 1Password names
  (no '/' or '?', control or format characters, '.' or '..'; a field may
  end with a query), the 512-byte op:// reference and the 32-entry limit
  are checked locally.
- A vault without source, as APIs before 1Password vaults send it, reads
  as { type: "stored" }; unknown source types pass through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
- sandboxes.create (image launches) and process exec/start take vault, a
  vault name, next to secrets; a vault alone starts the process through
  the control API. A create with a vault whose result is unknown is
  reported like one with secrets, never retried.
- Bindings (process.secrets and the new SandboxDetail.startupSecrets)
  make name, secretId and version optional and add secretType (TOTP),
  reference and tokenSecret (1Password), source and overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
- The credentials section becomes secrets, vaults and credential fill: a
  login as secrets with allowed origins grouped in a vault, a TOTP entry,
  fill by vault, and the tab rules with pageUrl.
- Vaults and 1Password: an op_token secret and a 1Password vault.
- An error table covering fill, TOTP, 1Password and vault codes.
- Sandboxes: vault on exec and create, TOTP variables and the binding
  fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
- Base32 seeds whose length after removing spaces, hyphens and trailing
  '=' is 1, 3 or 6 past a multiple of 8 are refused: vm-service would
  silently drop those characters and type wrong codes.
- otpauth URIs with control characters, HOTP URIs (with their own
  message) and other URI schemes are refused.
- Short seeds are accepted without a process warning; the README and
  JSDoc note them instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
The API reports an entry of the wrong kind for a vault's source as
VAULT_ENTRY_INVALID; VAULT_SOURCE_INVALID is for a source change on a
stored vault and for a 1Password vault used in a browser session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
- The TOTP check mirrors Go: one leading BOM is stripped, then Go's
  IsSpace set (including U+0085, but not U+FEFF) is trimmed. otpauth
  URIs are split as url.Parse does: a '%' that doesn't start a two-digit
  hex escape is refused outside the query, and query parameters are read
  as url.ParseQuery reads them, skipping pairs with bad escapes. Seeds
  must be ASCII base32 before uppercasing, so characters like "ß" or "ff"
  no longer become valid. The SDK stays stricter than vm-service on
  seeds with impossible lengths, newlines, or "ı".
- fill_credential treats "" like null for credential, vault, field,
  page_id, page_url and frame_url, as Gemini sends "" for unused strings.
- A sandbox handle keeps startupSecrets when it refreshes its details,
  such as on token renewal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
The API (#1552) no longer links vaults to 1Password; sandbox create and
process start take onePassword next to vault and secrets instead.

- Vaults are stored only again: no source in params or responses (a stray
  one still parses), entries are { name, version? }, and update needs a
  description or entries. The 1Password entry and source types are gone.
- sandboxes.create (image launches), processes.start and processes.exec
  take onePassword: { token, vault, fields }. It is checked locally before
  any request: the token name, each part's length and pattern, the 512-byte
  op:// reference, 1-32 fields with env names, unknown members, and a
  variable also set by secrets or env. A vault clash is left to the API.
- Bindings are a stored-secret shape with source vault or secrets, or a
  1Password shape { env, reference, tokenSecret, source: onePassword }.
- README: a 1Password section for onePassword, stored-only vaults, and the
  error table 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
Sessions attach an op_token with credentials: [{ onePassword: name }], and
sessions.credentials.fill takes onePassword: { token, vault, allowedOrigins }
with a ref ({ item, section?, field }) per input. Each field result carries
the op:// reference read; the result has no top-level version.

Fill requests are checked locally as the API checks them: one source, ref on
every field with onePassword and on none without, no field with onePassword,
the 1Password part rules and 512-byte references (shared with sandbox
onePassword), and unknown members. Allowed origins, for fills and secrets,
are now checked by the API's rules (https except localhost, no path, *. only
on a registrable domain), and session credentials by name and shape.

fill_credential stays without onePassword: a 1Password fill names its own
allowed origins, and a model shouldn't choose them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
#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.

Seeds are also upper-cased one character at a time, as Go does, so a dotless
"ı" reads as "I" like on the platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
The API trims a vault description before limiting it to 1024 characters,
so a longer one with surrounding whitespace was refused here but accepted
there. The README also now says that an iframe fill doesn't narrow tabs by
origin, and that a malformed 1Password token fails with
SECRET_ONEPASSWORD_AUTH_FAILED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
The API has sent tab_opened events since browserctrl #559.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v
@AHarmlessPyro
AHarmlessPyro merged commit 4800742 into main Oct 15, 2026
3 checks passed
@AHarmlessPyro
AHarmlessPyro deleted the credentials branch October 15, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants