diff --git a/CLAUDE.md b/CLAUDE.md index 2889e373..96ae9415 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 stored secrets ``` 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 ### 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 diff --git a/README.md b/README.md index b20de38e..5b5b730f 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,356 @@ 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 + +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 + +from hyperbrowser import Hyperbrowser + +client = Hyperbrowser(api_key="test-key") + +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"}, + }, + } +) + +# 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, + { + "vault": "github-login", + "fields": [ + {"field": "USERNAME", "selector": "#login_field"}, + {"field": "PASSWORD", "selector": "#password"}, + ], + }, + ) + if all(field.outcome == "filled" for field in result.fields): + 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) +``` + +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), `reference` (1Password fills) and +`expires_at` (TOTP codes). Values are never returned. + +| Outcome | Meaning | +| --- | --- | +| `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`. | + +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. + 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 + 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, 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 + 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 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 (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 + `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). +- Fill is safe to retry and never retried automatically. It waits at least + 30 seconds for the API (the client timeout when longer). +- 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`. 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"]` + (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`, 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 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 + 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`, + `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/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): 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`. + - 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 + +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. +- The API validates these rules. Request models still reject unknown members + and invalid shapes locally. + +### Use with LLM SDKs + +`CredentialFillTool` lets a model fill a secret or vault by name while your +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", +"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 vault named github-login."}] +while True: + response = openai_client.chat.completions.create( + model="gpt-5", 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 vault named github-login."}] +while True: + response = anthropic_client.messages.create( + 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": + 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 vault named github-login.")], + ) +] +while True: + response = gemini.models.generate_content( + model="gemini-3.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`, and +`runnable()` / `async_runnable()` to run the model's input and return text for +the model. `CredentialFillTool` also provides `gemini_tool_definition`, a +function declaration dict for google-genai. + ## Usage Hyperbrowser 1.0 accepts plain dictionaries for request parameters. Method @@ -327,27 +677,76 @@ 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() ``` +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 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. + +```python +client.secrets.create( + {"name": "op-token", "type": "op_token", "value": Path("op-token.txt").read_text().strip()} +) +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 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: - A secret is set only in the environment of the process it was passed to. @@ -383,11 +782,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 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 @@ -397,8 +802,31 @@ 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). + - `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 named as `one_password`'s `token`. + - `SECRET_SOURCES_INVALID` (400): for example `env` sets a variable that + `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, 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. ### List sandboxes with filters 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/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..5ac14d1b 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, @@ -79,6 +80,7 @@ completed_image_id, ) from ..sandboxes.shared import ( + OnePasswordSourceInput, SecretReferences, _build_sandbox_exposed_url, _copy_model, @@ -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, @@ -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, @@ -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: @@ -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 diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py index e3a62232..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, @@ -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, @@ -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) @@ -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( @@ -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", diff --git a/hyperbrowser/client/managers/async_manager/secret.py b/hyperbrowser/client/managers/async_manager/secret.py index 970ab080..6d48a410 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, Optional, Sequence, Union from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError @@ -12,6 +12,7 @@ SecretListResponse, SecretsPublicKey, SecretVersion, + SecretValue, SecretVersionDeleteResult, UpdateSecretParams, ) @@ -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 @@ -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( @@ -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}) @@ -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: @@ -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} ) 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..e5e52897 --- /dev/null +++ b/hyperbrowser/client/managers/async_manager/session_credentials.py @@ -0,0 +1,53 @@ +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 secrets attached to a session into its pages. + + Attach secrets, vaults or op_token secrets (for 1Password fields) 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 secret, a vault's entries, or 1Password fields into the + inputs matching each selector. + + Pass ``credential`` (a secret's name or ID) with + ``[{"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 + ``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( + 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/async_manager/vault.py b/hyperbrowser/client/managers/async_manager/vault.py new file mode 100644 index 00000000..7c30acb9 --- /dev/null +++ b/hyperbrowser/client/managers/async_manager/vault.py @@ -0,0 +1,106 @@ +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. + + 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. + + ``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)) + + 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 both. + + Each entry given replaces that variable and an entry set to ``None`` + removes it; entries not given are kept. + """ + 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..51b8ce4b 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 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. @@ -97,6 +100,8 @@ def _normalize_exec_params( timeout_sec: Optional[int] = None, 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) @@ -119,9 +124,17 @@ 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 + 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 - 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 +145,12 @@ 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, "one_password": None} + ), + SandboxExecParams, + ) def _secret_process_payload(params: SandboxExecParams) -> Dict[str, object]: @@ -155,10 +173,17 @@ def _secret_process_payload(params: SandboxExecParams) -> Dict[str, object]: "env": params.env, "timeoutMs": timeout_ms, "runAs": params.run_as, + # 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 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 f15bfe01..76519aef 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, @@ -77,6 +78,7 @@ completed_image_id, ) from ..sandboxes.shared import ( + OnePasswordSourceInput, SecretReferences, _build_sandbox_exposed_url, _copy_model, @@ -137,6 +139,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 +329,20 @@ 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 self.processes.exec( input, @@ -341,6 +353,8 @@ def exec( run_as=run_as, max_output_bytes=max_output_bytes, secrets=secrets, + vault=vault, + one_password=one_password, ) def _start_secret_process(self, payload: Dict[str, object]) -> dict: @@ -1156,16 +1170,23 @@ 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 = 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..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, @@ -353,6 +354,8 @@ 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 = self.start( input, @@ -363,6 +366,8 @@ 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 self._finish_secret_exec(handle) @@ -382,12 +387,17 @@ 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( @@ -398,8 +408,10 @@ 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 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 7895e9be..fb32ae6b 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, Optional, Sequence, Union from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError @@ -12,6 +12,7 @@ SecretListResponse, SecretsPublicKey, SecretVersion, + SecretValue, SecretVersionDeleteResult, UpdateSecretParams, ) @@ -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 @@ -72,14 +75,21 @@ 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(self.get_public_key()) secret = Secret( **self._request( @@ -88,10 +98,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}) @@ -107,19 +117,35 @@ def update( secret: str, params: Union[UpdateSecretParamsDict, UpdateSecretParams], ) -> Secret: - secret_id = self._resolve(secret)[0].id - payload = 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 = self._resolve(secret)[0] + payload = self._request("PATCH", f"/secrets/{resolved.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 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. + + 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 = 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 +161,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..e86d3af4 --- /dev/null +++ b/hyperbrowser/client/managers/sync_manager/session_credentials.py @@ -0,0 +1,53 @@ +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 secrets attached to a session into its pages. + + Attach secrets, vaults or op_token secrets (for 1Password fields) with + ``sessions.create({"credentials": [...]})``. + """ + + def __init__(self, client): + self._client = client + + def fill( + self, + session_id: str, + params: Union[FillCredentialParamsDict, FillCredentialParams], + ) -> CredentialFillResult: + """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": ...}]``, ``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 + ``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( + 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/vault.py b/hyperbrowser/client/managers/sync_manager/vault.py new file mode 100644 index 00000000..73a2aa3c --- /dev/null +++ b/hyperbrowser/client/managers/sync_manager/vault.py @@ -0,0 +1,104 @@ +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. + + 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. + + ``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)) + + 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 both. + + Each entry given replaces that variable and an entry set to ``None`` + removes it; entries not given are kept. + """ + 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 new file mode 100644 index 00000000..4ee29211 --- /dev/null +++ b/hyperbrowser/credentials_common.py @@ -0,0 +1,33 @@ +"""Credential fill serialization shared by sync and async clients.""" + +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_FILL_REQUEST_TIMEOUT_SECONDS = 30 + + +def fill_credential_payload(params: Any) -> Dict[str, Any]: + """Serialize aliases and frame targeting; the API validates fill policy.""" + 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 HyperbrowserError( + f"Invalid credential fill request: {problems}", code="invalid_request" + ) from None + except TypeError as error: + 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: + body["frame"] = {"url": request.frame_url} + return body diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index d21058bc..33c87226 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -1,3 +1,11 @@ +from .credential import ( + CredentialFieldResult, + CredentialFillField, + CredentialFillOutcome, + CredentialFillResult, + FillCredentialParams, + OnePasswordFillSource, +) from .webmcp import ( WebMCPAnnotations, WebMCPFrame, @@ -256,16 +264,23 @@ ) from .secret import ( CreateSecretParams, + OnePasswordField, + OnePasswordSource, SandboxResolvedSecret, Secret, + SecretBindingSource, + SecretBrowserMetadata, SecretDeleteResult, SecretDetail, SecretJWK, SecretListParams, SecretListResponse, + SecretMetadata, SecretReference, SecretReferenceInput, SecretsPublicKey, + SecretType, + SecretValue, SecretVersion, SecretVersionDeleteResult, UpdateSecretParams, @@ -332,6 +347,11 @@ GetSessionVideoRecordingUrlResponse, ScreenConfig, Session, + SessionCredential, + SessionCredentialInput, + SessionCredentialOnePassword, + SessionCredentialSecret, + SessionCredentialVault, SessionDetail, SessionGetParams, SessionListParams, @@ -473,8 +493,23 @@ SandboxTerminalEvent, ) from .team import TeamCreditInfo +from .vault import ( + CreateVaultParams, + StoredEntry, + UpdateVaultParams, + Vault, + VaultDeleteResult, + VaultEntry, + VaultListParams, + VaultListResponse, +) __all__ = [ + "CredentialFieldResult", + "CredentialFillField", + "CredentialFillOutcome", + "CredentialFillResult", + "FillCredentialParams", "WebMCPAnnotations", "WebMCPFrame", "WebMCPSource", @@ -650,18 +685,35 @@ "ProfileListParams", "ProfileListResponse", "ProfileResponse", + # vault + "CreateVaultParams", + "StoredEntry", + "UpdateVaultParams", + "Vault", + "VaultDeleteResult", + "VaultEntry", + "VaultListParams", + "VaultListResponse", # secret "CreateSecretParams", + "OnePasswordField", + "OnePasswordFillSource", + "OnePasswordSource", "SandboxResolvedSecret", "Secret", + "SecretBindingSource", + "SecretBrowserMetadata", "SecretDeleteResult", "SecretDetail", "SecretJWK", "SecretListParams", "SecretListResponse", + "SecretMetadata", "SecretReference", "SecretReferenceInput", "SecretsPublicKey", + "SecretType", + "SecretValue", "SecretVersion", "SecretVersionDeleteResult", "UpdateSecretParams", @@ -698,6 +750,11 @@ "GetSessionVideoRecordingUrlResponse", "ScreenConfig", "Session", + "SessionCredential", + "SessionCredentialInput", + "SessionCredentialOnePassword", + "SessionCredentialSecret", + "SessionCredentialVault", "SessionDetail", "SessionGetParams", "SessionListParams", diff --git a/hyperbrowser/models/consts.py b/hyperbrowser/models/consts.py index 36f703f2..0114acde 100644 --- a/hyperbrowser/models/consts.py +++ b/hyperbrowser/models/consts.py @@ -21,7 +21,13 @@ "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", + "tab_opened", + "credential_filled", + "credential_fill_refused", ] POLLING_ATTEMPTS = 5 diff --git a/hyperbrowser/models/credential.py b/hyperbrowser/models/credential.py new file mode 100644 index 00000000..e6548035 --- /dev/null +++ b/hyperbrowser/models/credential.py @@ -0,0 +1,115 @@ +"""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 + +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. +# 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) +CredentialFillOutcome = Literal[ + "filled", + "not_found", + "ambiguous", + "not_editable", + "not_accepted", +] + + +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; 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 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") + + token: str + vault: str + allowed_origins: List[str] = Field(serialization_alias="allowedOrigins") + + +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. + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + 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") + # 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) + + 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 + # 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") + + +class CredentialFillResult(BaseModel): + """The outcome of a fill, per input. Values are never returned.""" + + model_config = ConfigDict(populate_by_name=True) + + # 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..fdf6fa0b 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 ( + OnePasswordSource, + SandboxResolvedSecret, + SecretReference, + normalize_secret_references, +) 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,6 +256,14 @@ 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 + # 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 @@ -271,10 +289,12 @@ 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 or self.one_password is not None + ): raise ValueError( - "secrets 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" ) return self @@ -587,12 +607,30 @@ 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 + # 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 def normalize_secrets(cls, value): return normalize_secret_references(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 + or self.one_password is not None + ) + class SandboxProcessSummary(SandboxBaseModel): id: str diff --git a/hyperbrowser/models/secret.py b/hyperbrowser/models/secret.py index d4751ccc..5009cf05 100644 --- a/hyperbrowser/models/secret.py +++ b/hyperbrowser/models/secret.py @@ -1,8 +1,8 @@ import re 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 +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. @@ -10,6 +10,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: @@ -74,43 +75,177 @@ 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() } +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 + + +class OnePasswordSource(SecretBaseModel): + """Environment variables read from one 1Password vault at process start. + + The API validates names, reference sizes, limits and source conflicts. + """ + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + token: str + vault: str + fields: Dict[str, OnePasswordField] + + +# 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 to sign in for +# one_password fields +SecretType = Literal["secret", "totp", "op_token"] + +# A secret value, encrypted locally before upload. +SecretValue = Union[str, bytes] + + +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. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + allowed_origins: List[str] = Field(alias="allowedOrigins") + + +class SecretMetadata(SecretBaseModel): + """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. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + browser: Optional[SecretBrowserMetadata] = 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) + 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 description: Optional[str] = None + # 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[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) + + @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) + ) + } + ) + return self + 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 +265,21 @@ class Secret(SecretBaseModel): id: str name: str description: str = "" + # 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") 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 @@ -164,10 +309,42 @@ class SecretVersionDeleteResult(SecretBaseModel): version: int +class SecretBindingSource(SecretBaseModel): + """Where a delivered variable came from: ``type`` is ``"vault"`` (with the + 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") + + 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 ``one_password`` field sets ``reference`` (its + ``op://`` reference) and ``token_secret`` (the name of the op_token + 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") 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 870d6a2f..624f54e1 100644 --- a/hyperbrowser/models/session.py +++ b/hyperbrowser/models/session.py @@ -253,6 +253,52 @@ def parse_timestamp(cls, value: Optional[Union[str, int]]) -> Optional[int]: return value +class SessionCredential(BaseModel): + """A secret attached to a session at creation, which fills may type.""" + + model_config = ConfigDict( + populate_by_alias=True, + ) + + id: str + name: str + + +class SessionCredentialSecret(BaseModel): + """Attaches a secret, by name, to a new session.""" + + model_config = ConfigDict(extra="forbid") + + secret: str + + +class SessionCredentialVault(BaseModel): + """Attaches the secrets of a stored vault, by name, to a new session.""" + + model_config = ConfigDict(extra="forbid") + + vault: str + + +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") + + +# A plain secret name is shorthand for SessionCredentialSecret. +SessionCredentialInput = Union[ + str, + SessionCredentialSecret, + SessionCredentialVault, + SessionCredentialOnePassword, +] + + class SessionDetail(Session): """ Detailed session information including websocket endpoint. @@ -270,6 +316,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 +651,15 @@ class CreateSessionParams(BaseModel): default=None, serialization_alias="startFromSnapshot", ) + credentials: Optional[List[SessionCredentialInput]] = Field( + default=None, serialization_alias="credentials" + ) + """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.""" class CreateSessionSnapshotResponse(BaseModel): diff --git a/hyperbrowser/models/vault.py b/hyperbrowser/models/vault.py new file mode 100644 index 00000000..c2bf6f87 --- /dev/null +++ b/hyperbrowser/models/vault.py @@ -0,0 +1,107 @@ +"""Vaults: named sets of environment variables whose values come from stored +secrets. A vault holds no values itself.""" + +from datetime import datetime +from typing import Dict, List, Literal, Optional, Union + +from pydantic import ConfigDict, Field, field_validator + +from .secret import SecretBaseModel + + +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. + """ + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + name: str + version: Optional[Union[int, Literal["latest"]]] = None + + +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") + + secret_id: Optional[str] = Field(default=None, alias="secretId") + + +class CreateVaultParams(SecretBaseModel): + """A new vault: ``entries`` maps environment variable names to the + stored secrets they take.""" + + # 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 + entries: Optional[Dict[str, StoredEntry]] = None + + +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. + """ + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + description: Optional[str] = None + entries: Optional[Dict[str, Optional[StoredEntry]]] = None + + +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 Vault(SecretBaseModel): + id: str + name: str + description: str = "" + 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("entries", mode="before") + @classmethod + def default_entries(cls, value): + return {} if value is None else value + + +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", + "StoredEntry", + "UpdateVaultParams", + "Vault", + "VaultDeleteResult", + "VaultEntry", + "VaultListParams", + "VaultListResponse", +] diff --git a/hyperbrowser/secrets_common.py b/hyperbrowser/secrets_common.py index 0364027a..a910a104 100644 --- a/hyperbrowser/secrets_common.py +++ b/hyperbrowser/secrets_common.py @@ -12,15 +12,21 @@ import re import time from dataclasses import dataclass -from typing import Any, Dict, Optional, Type, Union +from typing import Any, Dict, Optional, Sequence, Type, Union +from urllib.parse import unquote_plus, urlsplit 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 .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 +60,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 +92,177 @@ 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") + + +# 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 "%" 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: + 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, 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 re.sub(r"[ \r\n-]", "", seed) + for upper in [char.upper()] + ).rstrip("=") + if not normalized: + raise _invalid_totp("the seed is empty") + 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, 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" + ) + + +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") + 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.lower() != "totp": + raise _invalid_totp("the URI must start with otpauth://totp/") + query = _query_params(parsed.query) + + algorithm = query.get("algorithm", "") + if algorithm and algorithm.upper() not in TOTP_ALGORITHMS: + raise _invalid_totp("algorithm must be SHA1, SHA256 or SHA512") + digits = query.get("digits", "") + if digits and ( + not _ASCII_INTEGER_PATTERN.fullmatch(digits) or int(digits) not in TOTP_DIGITS + ): + raise _invalid_totp("digits must be 6 or 8") + period = query.get("period", "") + if period and ( + 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 = query.get("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. + """ + # 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): + 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. + + 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 not isinstance(value, (str, bytes, bytearray)): + raise _invalid_value( + f"Secret value must be str or bytes, got {type(value).__name__}" + ) + 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]: + """Metadata allowing fills on ``allowed_origins``; ``None`` clears the + 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( + 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/tools/__init__.py b/hyperbrowser/tools/__init__.py index 4075fb36..a8ae3080 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,10 +23,12 @@ 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 FILL_CREDENTIAL_TOOL_GEMINI class WebsiteScrapeTool: @@ -135,7 +140,116 @@ 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: 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] = {} + # 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) + 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(mode="json", by_alias=True, exclude_none=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 secret, or a vault's entries, into a browser + 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) + + 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 + 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..90dc2e13 --- /dev/null +++ b/hyperbrowser/tools/gemini.py @@ -0,0 +1,55 @@ +"""Gemini declaration for credential fill, using the shared descriptions.""" + +from copy import deepcopy + +from .schema import FILL_CREDENTIAL_DESCRIPTION, FILL_CREDENTIAL_SCHEMA + +_properties = FILL_CREDENTIAL_SCHEMA["properties"] + + +def _nullable_string(description): + return {"type": "string", "nullable": True, "description": description} + + +FILL_CREDENTIAL_TOOL_GEMINI = deepcopy( + { + "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", + ) + }, + "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"], + }, + } +) 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..03b7908c 100644 --- a/hyperbrowser/tools/schema.py +++ b/hyperbrowser/tools/schema.py @@ -187,3 +187,97 @@ def get_scrape_options(formats: List[scrape_types] = ["markdown"]): ], "additionalProperties": False, } + +FILL_CREDENTIAL_DESCRIPTION = ( + "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_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" + "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, 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", "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, each selector at most once.", + "minItems": 1, + "maxItems": 16, + "items": { + "type": "object", + "properties": { + "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": ["selector", "field"], + "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.", + }, + "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 " + 'iframe\'s URL, such as "https://login.example.com/". Use null when ' + "the form is in the page itself.", + }, + }, + "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 44689479..0778d09c 100644 --- a/hyperbrowser/types/__init__.py +++ b/hyperbrowser/types/__init__.py @@ -5,6 +5,17 @@ as JSON Schema objects, untouched. """ +from .credential import ( + CredentialFillField, + FillCredentialParams, + OnePasswordFillSource, +) +from .vault import ( + CreateVaultParams, + StoredEntry, + UpdateVaultParams, + VaultListParams, +) from .webmcp import ( WebMCPInvokeParams, WebMCPStartParams, @@ -139,6 +150,10 @@ CaptchaEvaluationType, CaptchaSolverType, CreateSessionParams, + SessionCredentialInput, + SessionCredentialOnePassword, + SessionCredentialSecret, + SessionCredentialVault, CreateSessionProfile, ImageCaptchaParam, ScreenConfig, @@ -157,9 +172,15 @@ ) from .secret import ( CreateSecretParams, + OnePasswordField, + OnePasswordSource, + SecretBrowserMetadata, SecretListParams, + SecretMetadata, SecretReference, SecretReferenceInput, + SecretType, + SecretValue, UpdateSecretParams, ) from .volume import CreateVolumeParams, VolumeListParams @@ -192,6 +213,12 @@ __all__ = [ + "CredentialFillField", + "FillCredentialParams", + "CreateVaultParams", + "StoredEntry", + "UpdateVaultParams", + "VaultListParams", "WebMCPInvokeParams", "WebMCPStartParams", "WebMCPResultParams", @@ -317,6 +344,10 @@ "CaptchaEvaluationType", "CaptchaSolverType", "CreateSessionParams", + "SessionCredentialInput", + "SessionCredentialOnePassword", + "SessionCredentialSecret", + "SessionCredentialVault", "CreateSessionProfile", "ImageCaptchaParam", "ScreenConfig", @@ -333,9 +364,16 @@ "UpdateSessionScreenParams", "UpdateSessionSolveCaptchasParams", "CreateSecretParams", + "OnePasswordField", + "OnePasswordFillSource", + "OnePasswordSource", + "SecretBrowserMetadata", "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..bb982bb5 --- /dev/null +++ b/hyperbrowser/types/credential.py @@ -0,0 +1,53 @@ +"""Typed dictionary requests for filling secrets into browser pages.""" + +from typing import List, Optional + +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; 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``), 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 + iframe. + """ + + credential: Optional[str] + vault: Optional[str] + one_password: Optional[OnePasswordFillSource] + fields: Required[List[CredentialFillField]] + page_id: Optional[str] + page_url: Optional[str] + frame_url: Optional[str] + + +__all__ = [ + "CredentialFillField", + "FillCredentialParams", + "OnePasswordFillSource", +] diff --git a/hyperbrowser/types/sandbox.py b/hyperbrowser/types/sandbox.py index fd83ec1a..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"] @@ -100,6 +100,8 @@ class CreateSandboxFromImageParams(_CreateSandboxCommon, total=False): memory_mib: Optional[int] disk_mib: Optional[int] secrets: Optional[Dict[str, SecretReferenceInput]] + vault: Optional[str] + one_password: Optional[OnePasswordSource] CreateSandboxParams: TypeAlias = Union[ @@ -253,6 +255,8 @@ class SandboxExecParams(TypedDict, total=False): run_as: Optional[str] 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 0fe8a1b9..49e6e8a2 100644 --- a/hyperbrowser/types/secret.py +++ b/hyperbrowser/types/secret.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Union +from typing import Dict, List, Literal, Optional, Union from typing_extensions import Required, TypeAlias, TypedDict @@ -14,18 +14,67 @@ 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. +SecretType = Literal["secret", "totp", "op_token"] + +# A secret value, encrypted locally before upload. +SecretValue: TypeAlias = Union[str, bytes] + + +class SecretBrowserMetadata(TypedDict): + """Origins (``scheme://host[:port]``, ``*.`` for subdomains) a secret may + be typed into browser pages on.""" + + allowed_origins: List[str] + + +class SecretMetadata(TypedDict, total=False): + """Secret metadata. In an update, ``None`` removes a member.""" + + 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] - value: Optional[Union[str, bytes]] + type: Optional[SecretType] + allowed_origins: Optional[List[str]] + 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 +83,19 @@ class SecretListParams(TypedDict, total=False): page: Optional[int] limit: Optional[int] search: Optional[str] + type: Optional[SecretType] __all__ = [ "CreateSecretParams", + "OnePasswordField", + "OnePasswordSource", + "SecretBrowserMetadata", "SecretListParams", + "SecretMetadata", "SecretReference", "SecretReferenceInput", + "SecretType", + "SecretValue", "UpdateSecretParams", ] diff --git a/hyperbrowser/types/session.py b/hyperbrowser/types/session.py index 7e7a9ed1..72430bec 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,34 @@ 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 + + +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, + SessionCredentialOnePassword, +] + + class CreateSessionParams(TypedDict, total=False): """Configuration for creating a browser session.""" @@ -184,6 +212,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[SessionCredentialInput]] class UpdateSessionSolveCaptchasParams(TypedDict, total=False): @@ -204,6 +233,10 @@ class SessionEventLogListParams(TypedDict, total=False): __all__ = [ + "SessionCredentialInput", + "SessionCredentialOnePassword", + "SessionCredentialSecret", + "SessionCredentialVault", "BrowserDevice", "BrowserMemorySize", "CaptchaEvaluationParams", diff --git a/hyperbrowser/types/vault.py b/hyperbrowser/types/vault.py new file mode 100644 index 00000000..54e15ad4 --- /dev/null +++ b/hyperbrowser/types/vault.py @@ -0,0 +1,44 @@ +"""Typed dictionary requests for vaults.""" + +from typing import Dict, Literal, Optional, Union + +from typing_extensions import Required, 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 CreateVaultParams(TypedDict, total=False): + """A new vault: environment variable names mapped to stored secrets.""" + + name: Required[str] + description: Optional[str] + 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] + entries: Optional[Dict[str, Optional[StoredEntry]]] + + +class VaultListParams(TypedDict, total=False): + """Filters and pagination for listing vaults.""" + + page: Optional[int] + limit: Optional[int] + search: Optional[str] + + +__all__ = [ + "CreateVaultParams", + "StoredEntry", + "UpdateVaultParams", + "VaultListParams", +] diff --git a/hyperbrowser/vaults_common.py b/hyperbrowser/vaults_common.py new file mode 100644 index 00000000..184263ba --- /dev/null +++ b/hyperbrowser/vaults_common.py @@ -0,0 +1,57 @@ +"""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, StoredEntry, UpdateVaultParams + + +def _entry_body(entry: StoredEntry) -> Dict[str, Any]: + # Only the request members: entries read from a vault also carry + # 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]: + """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.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.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/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" diff --git a/tests/test_credential_tools.py b/tests/test_credential_tools.py new file mode 100644 index 00000000..d12030ea --- /dev/null +++ b/tests/test_credential_tools.py @@ -0,0 +1,269 @@ +"""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 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", + "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", + "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 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"] + ) + + +@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"]) + + +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) + 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" + 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", + "vault", + "fields", + "page_id", + "page_url", + "frame_url", + ] + + +TOOL_INPUT = { + "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): + client, call, calls, outcomes = sdk + runner = ( + CredentialFillTool.async_runnable + if isinstance(client, AsyncHyperbrowser) + else CredentialFillTool.runnable + ) + for _ in range(3): + respond(outcomes, FILL_RESULT) + + text = call(runner, client, "session-1", TOOL_INPUT) + assert json.loads(calls[0].content) == { + "vault": "github-login", + "fields": SENT_FIELDS, + } + assert calls[0].url.path == "/api/session/session-1/credentials/fill" + 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_url="https://github.com/login", frame_url="https://x/"), + ) + assert json.loads(calls[1].content) == { + "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", + } + + +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}, + } + } + + 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}]), + # 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"}} + ], + ), + ]: + respond(outcomes, {"error": "Invalid body: server validation failed"}, 400) + error = json.loads(call(runner, client, "s", bad)) + 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) == 7 + + +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/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 00000000..69f2d0e8 --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,1059 @@ +"""Typed secrets (totp, op_token), browser metadata, and credential fill.""" + +import asyncio +import json +import warnings + +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, + CredentialFillField, + FillCredentialParams, + OnePasswordField, + OnePasswordFillSource, + Secret, + SecretMetadata, + SessionCredentialOnePassword, + SessionCredentialVault, + UpdateSecretParams, +) +from hyperbrowser.secrets_common import check_totp_seed +from tests.test_secrets import ( + SECRET_ID, + AsyncScriptedHTTPClient, + FakeClient, + ScriptedHTTPClient, + _open, + _public_key_payload, + _secret, +) + +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") +def private_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=3072) + + +def _typed(secret_type, name="github-otp", origins=None, context=None): + metadata = {"browser": {"allowedOrigins": origins}} if origins else {} + return dict( + _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, created, upload=None): + return { + ("GET", "/secrets/public-key"): (200, _public_key_payload(private_key)), + ("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_not_sent(calls, value): + assert value not in json.dumps([call["json"] for call in calls]) + + +# --- secret types 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 + + secret = Secret.model_validate( + dict( + _typed("totp"), + metadata={ + "browser": {"allowedOrigins": ORIGINS, "future": 1}, + "other": {"x": 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_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( + { + "name": "github-password", + "value": "hunter2", + "allowed_origins": ORIGINS, + "description": "d", + } + ) + + assert secret.latest_version == 1 + assert http.calls[1]["json"] == { + "name": "github-password", + "description": "d", + "metadata": {"browser": {"allowedOrigins": ORIGINS}}, + } + _, 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 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_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)) + + secret = manager.create( + { + "name": "github-otp", + "type": "totp", + "value": SEED.lower(), + "allowed_origins": ORIGINS, + } + ) + + assert secret.type == "totp" + assert http.calls[1]["json"] == { + "name": "github-otp", + "type": "totp", + "metadata": {"browser": {"allowedOrigins": ORIGINS}}, + } + 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", + # 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:], + # 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"), + ("====", "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"), + (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"), +] + + +@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("seed,reason", BAD_SEEDS) +def test_bad_totp_seeds_fail_before_any_request(seed, reason): + http = ScriptedHTTPClient({}) + manager = SecretManager(FakeClient(http)) + + 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_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_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)) + + # 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() + + +def test_updates_refuse_allowed_origins_on_an_op_token(): + http = ScriptedHTTPClient( + { + ("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 == "SECRET_METADATA_INVALID" + assert http.calls[-1]["method"] == "PATCH" + + +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_stored_type(private_key): + versions_path = f"/secrets/{SECRET_ID}/versions" + routes = { + ("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-otp", SEED).version == 2 + header, value = _open(private_key, http.calls[-1]["json"]["envelope"]) + assert header["ctx"] == TOTP_CONTEXT and value == SEED.encode() + + http.calls.clear() + with pytest.raises(HyperbrowserError) as error: + manager.add_version("github-otp", "hunter2!") + assert error.value.code == "invalid_secret_value" + assert [c["path"] for c in http.calls] == ["/secrets"] + + # 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 --- + + +def test_set_allowed_origins_replaces_or_clears_the_browser_metadata(): + http = ScriptedHTTPClient( + { + ("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-otp", ("https://github.com",)) + assert http.calls[-1]["json"] == { + "metadata": {"browser": {"allowedOrigins": ["https://github.com"]}} + } + + 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_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", + # 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( + {"name": "github-password", "allowed_origins": origins} + ) + # Sent as given: the server normalizes them. + assert http.calls[-1]["json"]["metadata"] == { + "browser": {"allowedOrigins": origins} + } + + +def test_update_sends_only_the_members_given(): + http = ScriptedHTTPClient( + { + ("GET", f"/secrets/{SECRET_ID}"): (200, dict(_typed("totp"), versions=[])), + ("PATCH", f"/secrets/{SECRET_ID}"): (200, _typed("totp")), + } + ) + 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 == "totp" + + http.calls.clear() + with pytest.raises(ValueError): + manager.update(SECRET_ID, {}) + assert http.calls == [] + + +@pytest.mark.parametrize("secret_type", ["secret", "totp", "op_token"]) +def test_list_filters_by_type(secret_type): + http = ScriptedHTTPClient( + { + ("GET", "/secrets"): ( + 200, + {"secrets": [_typed(secret_type)], "totalCount": 1}, + ) + } + ) + manager = SecretManager(FakeClient(http)) + + listing = manager.list({"type": secret_type}) + + assert http.calls[0]["params"] == {"type": secret_type} + assert listing.secrets[0].type == secret_type + with pytest.raises(ValueError): + manager.list({"type": "credential"}) # type: ignore[typeddict-item] + + +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") # type: ignore[arg-type] + + +@pytest.mark.anyio +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}, + ) + routes[("PATCH", f"/secrets/{SECRET_ID}")] = (200, _typed("totp")) + http = AsyncScriptedHTTPClient(routes) + manager = AsyncSecretManager(FakeClient(http)) + + 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"] == TOTP_CONTEXT and value == SEED.encode() + + await manager.add_version("github-otp", f"otpauth://totp/x?secret={SEED}") + with pytest.raises(HyperbrowserError) as error: + await manager.add_version("github-otp", "hunter2!") + assert error.value.code == "invalid_secret_value" + + await manager.set_allowed_origins("github-otp", None) + assert http.calls[-1]["json"] == {"metadata": {"browser": None}} + _assert_not_sent(http.calls, SEED) + + http.calls.clear() + with pytest.raises(HyperbrowserError): + await manager.create({"name": "github-otp", "type": "totp", "value": "nope!"}) + assert http.calls == [] + assert (await manager.list({"type": "totp"})).secrets[0].type == "totp" + + +# --- 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", +} +FIELDS = [ + {"field": "USERNAME", "selector": "#login_field"}, + {"field": "PASSWORD", "selector": "#password"}, + {"field": "OTP", "selector": "#app_totp"}, +] +FILL_RESULT = { + "pageId": "PAGE1", + "origin": "https://github.com", + "fields": [ + { + "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", + }, + ], +} + + +def test_sessions_attach_secrets_and_vaults_at_creation(sdk): + client, call, calls, outcomes = sdk + 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-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-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[2].content) + assert session.credentials is None + + +def test_vault_fills_post_fields_and_parse_per_input_results(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, FILL_RESULT) + + result = call( + client.sessions.credentials.fill, + "session /1", + { + "vault": "github-login", + "fields": FIELDS, + "page_url": "https://github.com/login", + "frame_url": "https://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) == { + "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 is None + assert result.page_id == "PAGE1" + assert result.origin == "https://github.com" + 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-password", + fields=[{"selector": "#password"}], # type: ignore[list-item] + page_id="PAGE1", + ), + ) + assert json.loads(calls[0].content) == { + "credential": "github-password", + "fields": [{"selector": "#password"}], + "pageId": "PAGE1", + } + 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" + + +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, + ) + params = {"credential": "c", "fields": [{"selector": "#p"}]} + + with pytest.raises(HyperbrowserError) as raised: + 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", 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"}] + + +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", + { + "vault": "v" * 128, + "fields": [ + # 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, + }, + ) + call( + client.sessions.credentials.fill, + "s", + {"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) + + +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 + + +@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] + + +@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 new file mode 100644 index 00000000..a0374a28 --- /dev/null +++ b/tests/test_onepassword.py @@ -0,0 +1,283 @@ +"""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, + 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"}}, + ) + ) + + +@pytest.mark.parametrize("async_client", [False, True], ids=["sync", "async"]) +@pytest.mark.anyio +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 + 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.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_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index e72f336a..ca6e5ba2 100644 --- a/tests/test_sandbox_wire_contract.py +++ b/tests/test_sandbox_wire_contract.py @@ -1690,6 +1690,8 @@ def fake_exec(input, **kwargs): "run_as": "root", "max_output_bytes": 64 * 1024 * 1024, "secrets": None, + "vault": None, + "one_password": None, }, ) ] diff --git a/tests/test_secrets.py b/tests/test_secrets.py index d7ed915c..f94e1942 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: @@ -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"}) 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 diff --git a/tests/test_vaults.py b/tests/test_vaults.py new file mode 100644 index 00000000..f492b0f8 --- /dev/null +++ b/tests/test_vaults.py @@ -0,0 +1,410 @@ +"""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, + SandboxDetail, + SandboxResolvedSecret, + StoredEntry, + UpdateVaultParams, + Vault, + VaultEntry, +) +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": "", + "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", +} + + +@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_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 = vault.entries["USERNAME"] + assert isinstance(username, StoredEntry) and username.version is None + assert Vault.model_validate(dict(STORED_VAULT, entries=None)).entries == {} + + +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_entries_without_a_source(sdk): + client, call, calls, outcomes = sdk + respond(outcomes, STORED_VAULT, 201) + respond(outcomes, STORED_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="github-otp", + entries={"OTP": StoredEntry(name="github-otp", version=2)}, + ), + ) + assert _body(calls[1]) == { + "name": "github-otp", + "entries": {"OTP": {"name": "github-otp", "version": 2}}, + } + + +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 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, STORED_VAULT) + respond(outcomes, STORED_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="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": "GitHub", + "entries": {"OTP": {"name": "github-otp", "version": "latest"}}, + } + + for bad in [ + {"source": {"vault": "Platform"}}, + {"entries": {"P": {"item": "i", "field": "f"}}}, + ]: + 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, 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) + 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": '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", "entries": {"TOKEN": {"name": "op-token"}}}, + ) + 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": "onePassword"}, + }, +] + + +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" + 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(): + 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") + + 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(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" + + +@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 diff --git a/tests/typecheck/invalid_requests.py b/tests/typecheck/invalid_requests.py index 5885415f..b103780a 100644 --- a/tests/typecheck/invalid_requests.py +++ b/tests/typecheck/invalid_requests.py @@ -116,3 +116,33 @@ 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": "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({"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", {"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 + 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: + 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 df923b1d..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 @@ -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, @@ -20,7 +21,7 @@ ComputerActionResponse, ) from hyperbrowser.types import ScrollActionParams -from hyperbrowser.tools import WebsiteExtractTool +from hyperbrowser.tools import CredentialFillTool, WebsiteExtractTool class ProductResult(BaseModel): @@ -582,3 +583,253 @@ 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, + FillCredentialParams, + OnePasswordField, + OnePasswordFillSource, + OnePasswordSource, + SecretMetadata, + SessionCredentialOnePassword, + StoredEntry, + UpdateSecretParams, + UpdateVaultParams, + VaultEntry, + ) + + 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-otp", + "type": "totp", + "metadata": {"browser": {"allowed_origins": origins}}, + "value": "JBSWY3DPEHPK3PXP", + } + ) + client.secrets.create({"name": "op-token", "type": "op_token", "value": "ops_x"}) + client.secrets.create( + LegacyCreateSecretParams(name="github-otp", type="totp", value="JBSWY3DP") + ) + 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-password", UpdateSecretParams(metadata=SecretMetadata(browser=None)) + ) + client.secrets.list({"type": "totp"}) + + 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(): + stored: VaultEntry = entry + print(env, stored.name, stored.secret_id, stored.version) + client.vaults.create( + LegacyCreateVaultParams( + name="github-otp", + entries={"OTP": StoredEntry(name="github-otp", version="latest")}, + ) + ) + client.vaults.update("github-login", {"entries": {"OTP": None}}) + 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) + client.vaults.delete("github-login") + + sandbox = client.sandboxes.create( + { + "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"}]} + ) + for attached in session.credentials or []: + print(attached.id, attached.name) + client.sessions.create(LegacyCreateSessionParams(credentials=["github-password"])) + result = client.sessions.credentials.fill( + session.id, + { + "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.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-password", + fields=[CredentialFillField(selector="#pw")], + 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"]) + + +async def valid_async_credential_requests(client: AsyncHyperbrowser) -> None: + 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, + {"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( + "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"} + ) + print(text)