diff --git a/README.md b/README.md index 1bf438a..915e05a 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,409 @@ or `*` (every host). Proxy routes only apply to `sessions.create` and cannot be combined with `allowInternetAccess: false`, `allowOut`, or `denyOut`. See the [proxy routes guide](https://hyperbrowser.ai/docs/sessions/proxy-routes). +## Secrets, vaults and credential fill + +Secrets are encrypted locally before upload; after that, neither the API, your +code, nor an LLM ever sees the values. A login is two secrets (or three, with a +TOTP seed), each limited to the origins it may be filled into, grouped in a +**vault** that names them as entries such as `USERNAME` and `PASSWORD`. + +```typescript +import { chromium } from "playwright-core"; +import { Hyperbrowser } from "@hyperbrowser/sdk"; + +const client = new Hyperbrowser({ apiKey: process.env.HYPERBROWSER_API_KEY }); + +// Once: the secrets, fillable only on their allowed origins, and a vault. +const allowedOrigins = ["https://github.com"]; +await client.secrets.create({ name: "github-username", value: "ada", allowedOrigins }); +await client.secrets.create({ + name: "github-password", + value: process.env.GITHUB_PASSWORD!, + allowedOrigins, +}); +// A TOTP secret holds the seed (base32 or an otpauth://totp/ URI); fills and +// sandboxes get its current code. +await client.secrets.create({ + name: "github-otp", + type: "totp", + value: process.env.GITHUB_TOTP_SEED!, + allowedOrigins, +}); +await client.vaults.create({ + name: "github-login", + entries: { + USERNAME: { name: "github-username" }, + PASSWORD: { name: "github-password" }, + OTP: { name: "github-otp" }, + }, +}); + +// Secrets can only be attached when the session is created. +const session = await client.sessions.create({ credentials: [{ vault: "github-login" }] }); +try { + const browser = await chromium.connectOverCDP(session.wsEndpoint); + const page = await browser.contexts()[0].newPage(); + await page.goto("https://github.com/login"); + + // Fill only types into the inputs; it never submits. + const result = await client.sessions.credentials.fill(session.id, { + vault: "github-login", + fields: [ + { field: "USERNAME", selector: "#login_field" }, + { field: "PASSWORD", selector: "#password" }, + ], + }); + if (result.fields.every(({ outcome }) => outcome === "filled")) { + await page.click('input[type="submit"]'); + } else { + console.log(result.fields); // e.g. [{ selector: "#password", outcome: "not_found", ... }] + } + // On the next screen: { field: "OTP", selector: "#app_totp" } types the current code. +} finally { + await client.sessions.stop(session.id); +} +``` + +- `credentials` takes up to 20 items: a secret's name (or `{ secret }`), + `{ vault }` for the secrets a stored vault names when the session is created, + or `{ onePassword }` for an `op_token` secret (see + [Fill from 1Password](#fill-from-1password)); the API validates names and limits. + `session.credentials` lists the attached secrets as `{ id, name }`. Fill a + single secret with `{ credential: "github-password", fields: [{ selector }] }`; + with `vault`, each field names its entry in `field`. +- Each input reports an `outcome`: `filled`, `not_found` (no input matched), + `ambiguous` (several visible inputs matched), `not_editable` (disabled, + read-only, hidden or not a text input), or `not_accepted` (the page didn't + keep the value, e.g. `maxlength`), plus its `secretId` and `version`, the + vault `field`, and for TOTP inputs `expiresAt`. Submit only when the inputs + you need are `filled`. Fill is safe to retry; it is not retried automatically. +- Without `pageId`, fill considers the tabs whose URL starts with `pageUrl` + (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 (not for an + iframe fill, whose tab is usually on another origin), then the visible one + among them; if more than one still qualifies, it fails with + `CREDENTIAL_PAGE_AMBIGUOUS` and the candidates in + `error.details.details.pages` (`{ pageId, url, title }`). 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 `pageUrl` or the + page's target ID: `(await page.context().newCDPSession(page)).send("Target.getTargetInfo")` + (in Puppeteer, `page.createCDPSession()`) returns it as `targetInfo.targetId`. + For a form inside an iframe, pass `frameUrl` (a URL prefix). +- Origins are `scheme://host[:port]`, 1-20 per secret; a leading `*.` matches + subdomains only (of a domain such as `example.com`, not `*.com` or an IP + address), and `http:` is accepted only for `localhost`, `127.0.0.1` and + `[::1]`. The API validates and normalizes them. + `setAllowedOrigins(secret, origins)` replaces them and `null` + clears them (the secret then can't be filled). `list({ type })` filters by + type. +- Values are checked locally before anything is sent (`invalid_secret_value`): + 1 byte to 32 KiB of UTF-8 without NUL, and for TOTP a base32 seed (spaces, + hyphens, line breaks, lowercase and trailing `=` padding are fine) or an `otpauth://totp/` + URI with `secret` and optionally `algorithm` (SHA1, SHA256, SHA512), `digits` + (6 or 8) and `period` (1-300). HOTP is not supported, and a seed whose + length is 1, 3 or 6 characters past a multiple of 8 is refused as + incompletely copied. Seeds under 10 bytes (16 characters) are accepted, but + check that a short one was copied whole. `addVersion` applies the rules of + the secret's type. Fill, origin, vault-entry and 1Password policy errors come + from the API; inspect the error's status, code (when supplied) and details. +- Session create needs the API key scope `secrets.use`; fill also needs + `browser.sessions.update`. Attaching secrets runs the session on Firecracker + with Chrome's password manager off. Fills are recorded in the session event + log as `credential_filled` and `credential_fill_refused`. + +### Vaults + +Vaults also deliver environment variables to sandboxes (see +[Sandboxes](#sandboxes)). A vault names stored secrets and holds no values +itself. + +```typescript +await client.vaults.create({ + name: "prod-db", + entries: { + DB_USER: { name: "db-user" }, + DB_PASS: { name: "db-pass", version: 2 }, + }, +}); +await client.vaults.update("prod-db", { + entries: { DB_OTP: { name: "db-otp" }, DB_USER: null }, +}); +const vault = await client.vaults.get("prod-db"); // entries include each secret's ID +const { vaults } = await client.vaults.list({ search: "prod" }); +await client.vaults.delete("prod-db"); +``` + +- Vault methods take a vault's ID or exact name. Entry names are environment + variable names (`^[A-Za-z_][A-Za-z0-9_]*$`, at most 256 characters), and + entries are `{ name, version? }` (an omitted version, or `"latest"`, follows + the latest). `update` takes a description or entries; an entry set to `null` + is removed and entries not given are kept. Limits: 100 vaults per team, 32 + entries per vault. Vaults use the `secrets.read` and `secrets.manage` + scopes, like secrets. +- A secret a vault uses can't be deleted (`SECRET_IN_USE`), nor a version a + vault pins (`SECRET_VERSION_IN_USE`). + +### 1Password + +Sandbox processes, and an image's startup command, can read fields from a +1Password vault when they start: pass `onePassword` to `processes.start`, +`processes.exec` or `sandboxes.create`. It signs in with a service account +token stored as an `op_token` secret. Tokens start with `ops_`, but the SDK +doesn't refuse other values. `op_token` secrets are never environment +variables or typed into pages themselves, so they take no `allowedOrigins`. +Nothing about the fields is stored. Browser sessions can fill 1Password +fields too: see [Fill from 1Password](#fill-from-1password). + +```typescript +await client.secrets.create({ + name: "op-token", + type: "op_token", + value: process.env.OP_SERVICE_ACCOUNT_TOKEN!, +}); + +const onePassword = { + token: "op-token", + vault: "Engineering", + fields: { + DB_PASS: { item: "Postgres", section: "prod", field: "password" }, + DB_OTP: { item: "Postgres", field: "one-time password?attribute=totp" }, + }, +}; +const sandbox = await client.sandboxes.create({ imageName: "my-image", onePassword }); +const migrate = await sandbox.exec("node migrate.js", { + onePassword, + secrets: { API_KEY: "provider-key" }, +}); +const worker = await sandbox.processes.start("node worker.js", { onePassword }); +console.log(worker.secrets); // [{ env: "DB_PASS", reference: "op://Engineering/Postgres/prod/password", tokenSecret: "op-token", source: { type: "onePassword" } }, ...] +``` + +- `token` names the `op_token` secret (its latest version is used), `vault` + is the 1Password vault's name or ID, and `fields` maps 1 to 32 environment + variables to `{ item, section?, field }`. Names and IDs are 1-256 + characters without `/` or `?`, control or format characters, and not `.` or + `..`; the field may end with a query such as `?attribute=totp` or + `?ssh-format=openssh`. The full `op:////[
/]` + reference is at most 512 bytes. The API validates these rules and rejects + unknown members. +- A 1Password field's variable must come from it alone: `secrets` or `env` + setting it too, including a `vault` entry, is refused by the API + (`SECRET_SOURCES_INVALID`). Together with `vault` and + `secrets` it delivers at most 32 variables. +- Requirements are those of `secrets`: the `secrets.use` scope, and for + `sandboxes.create` an image launch with a startup command on Firecracker. + +### Fill from 1Password + +Attach the `op_token` secret when the session is created, then fill fields +read with it. The request names the 1Password vault, each input's field as +`ref`, and the origins the values may be typed on: nothing stored says where +a 1Password value belongs. + +```typescript +const session = await client.sessions.create({ credentials: [{ onePassword: "op-token" }] }); +// ... open https://www.scrapingcourse.com/login in the session ... +const result = await client.sessions.credentials.fill(session.id, { + onePassword: { + token: "op-token", + vault: "Dev Vault for Testing", + allowedOrigins: ["https://www.scrapingcourse.com"], + }, + fields: [ + { selector: "#email", ref: { item: "scrapingcourse login", field: "username" } }, + { selector: "#password", ref: { item: "scrapingcourse login", field: "password" } }, + ], + pageUrl: "https://www.scrapingcourse.com/login", +}); +console.log(result.fields); +// [{ selector: "#email", outcome: "filled", secretId: "", version: 1, +// reference: "op://Dev Vault for Testing/scrapingcourse login/username" }, ...] +``` + +- Pass exactly one of `credential`, `vault` and `onePassword`. With + `onePassword` every field needs `ref` (`{ item, section?, field }`) and none + may have `field`; without it, none may have `ref`. +- `token` names an `op_token` secret attached as `{ onePassword }` (its + latest version is used), and `vault` is the 1Password vault's name or ID. + `ref` and `vault` follow the rules of sandbox `onePassword` fields above, + so `{ field: "one-time password?attribute=otp" }` types the current code, + and each `op://` reference is at most 512 bytes. +- `allowedOrigins` is required: 1-20 origins with the same rules as a + secret's. A page on any other origin is refused with + `CREDENTIAL_ORIGIN_NOT_ALLOWED`, and tab choice treats these as the + allowed origins. +- The API validates these rules and rejects unknown members of `onePassword` + and `ref`, along with the usual 1-16 fields and unique selectors. +- The result has no top-level `version`. Each field's `secretId` and + `version` are the `op_token`'s, and `reference` is the field that was read. +- A missing item or field fails with `SECRET_ONEPASSWORD_REFERENCE_FAILED` + and fills nothing; `error.details.details.selectors` lists the inputs and + `reasons` says why, by selector. Without the token attached it is + `CREDENTIAL_NOT_ATTACHED`; a host that can't read 1Password for browsers + gives `CREDENTIAL_VALUE_UNSUPPORTED`. +- An `op_token` is still refused as a plain credential (`"op-token"` or + `{ secret: "op-token" }` at create, or `credential` in a fill) with + `SECRET_TYPE_MISMATCH`. +- The session event log records these fills with the token's name as + `onePasswordToken` and its `secretId` in the metadata. +- `CredentialFillTool` can't fill from 1Password; call + `sessions.credentials.fill` directly (see [Use with LLM SDKs](#use-with-llm-sdks)). + +### Errors + +API errors are `HyperbrowserError`s with `statusCode`, `code` and `retryable` +(the API's own flag when it sends one); `details` is the API's error body, whose +own `details` holds the specifics (such as `error.details.details.origin`). + +| Code | Meaning | +|---|---| +| `CREDENTIAL_NOT_ATTACHED` | The secret, vault or 1Password token wasn't attached to the session, or a vault entry was repointed since. | +| `CREDENTIAL_ORIGIN_NOT_ALLOWED` | The page isn't on the secret's allowed origins (for 1Password, the request's); nothing was typed. | +| `CREDENTIAL_NOT_READY` | The secret has no allowed origins or no value. | +| `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND` | No tab or iframe matched. | +| `CREDENTIAL_PAGE_AMBIGUOUS`, `CREDENTIAL_FRAME_AMBIGUOUS` | Several matched; pass `pageId` or `pageUrl` (`details.pages`), or a longer `frameUrl`. | +| `CREDENTIAL_FILL_TIMEOUT` | Retryable. | +| `CREDENTIAL_FILL_FAILED` | The fill failed in the browser. | +| `CREDENTIAL_FILL_UNSUPPORTED`, `CREDENTIAL_VALUE_UNSUPPORTED` | The browser image predates fill, or the host can't resolve this value type (such as 1Password). | +| `CREDENTIALS_UNSUPPORTED_RUNTIME`, `CREDENTIALS_SNAPSHOT_PASSWORD_MANAGER_ENABLED` | Session create: secrets need Firecracker, and a snapshot taken with the password manager off. | +| `SESSION_NOT_ACTIVE` | The session has stopped. | +| `SECRET_NOT_FOUND`, `SECRET_VERSION_NOT_FOUND` | | +| `SECRET_TYPE_MISMATCH` | A secret used where its type can't be, such as an `op_token` in `secrets`, a vault entry, or attached or filled as a plain credential, or another type as a `onePassword` token. | +| `SECRET_METADATA_INVALID` | A bad allowed origin, or allowed origins on an `op_token`. | +| `SECRET_VALUE_INVALID` | The stored value is unusable (such as a bad TOTP seed); add a new version. | +| `SECRET_IN_USE`, `SECRET_VERSION_IN_USE`, `SECRET_VERSION_IS_LATEST` | Delete refused: a vault uses the secret or pins the version, or the version is the latest. An `op_token` is never in use. | +| `SECRET_TOTP_UNSUPPORTED`, `SECRET_ONEPASSWORD_UNSUPPORTED` | The sandbox host predates TOTP or 1Password, so nothing started. Retryable on sandbox create (another host), not on a process start. | +| `SECRET_ONEPASSWORD_REFERENCE_FAILED` | A 1Password field couldn't be read; `details` names the variables (`envs`), or on a fill the inputs (`selectors`), with `reasons` for each. | +| `SECRET_ONEPASSWORD_AUTH_FAILED` | 1Password refused the token, or the token is malformed or signs in somewhere other than `1password.com`, `.ca` or `.eu` (or their subdomains). | +| `SECRET_ONEPASSWORD_RATE_LIMITED`, `SECRET_ONEPASSWORD_FAILED` | Retryable. | +| `SECRET_SOURCES_INVALID` | Bad `vault`/`onePassword`/`secrets`/`env` combination, such as a `onePassword` variable also set by the vault, more than 32 variables, or a vault with no entries. | +| `VAULT_NOT_FOUND`, `VAULT_ENTRY_NOT_FOUND` | | +| `VAULT_NAME_TAKEN`, `VAULT_LIMIT_REACHED`, `VAULT_ENTRY_LIMIT_REACHED` | | +| `SECRETS_FEATURE_DISABLED` | The team doesn't have secrets enabled. | + +### Use with LLM SDKs + +`CredentialFillTool` from `@hyperbrowser/sdk/tools` exposes fill as a +`fill_credential` tool. Your application binds the session ID; the model picks +only a `credential` or a `vault` (with a `field` per input), the selectors and, +when needed, `page_id`, `page_url` or `frame_url`. In OpenAI's strict mode every +property is required, so unused ones are `null`. Results and errors come back as +JSON text and never contain values; an error's `details` there is the API's own +details object (such as `pages`). Fill doesn't submit, so give the model your +other browser tools as well (to click the login button, for example). + +The tool has no `onePassword`: a 1Password fill names the origins its values +may be typed on, and a model shouldn't choose those. Call +`sessions.credentials.fill` directly for [1Password](#fill-from-1password). + +OpenAI (Chat Completions): + +```typescript +import OpenAI from "openai"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import { CredentialFillTool } from "@hyperbrowser/sdk/tools"; + +const openai = new OpenAI(); +const messages: ChatCompletionMessageParam[] = [ + { role: "user", content: "Sign in to GitHub with the github-login vault." }, +]; +for (;;) { + const completion = await openai.chat.completions.create({ + model: "gpt-5", + messages, + tools: [CredentialFillTool.openaiToolDefinition /* , your browser tools */], + }); + const message = completion.choices[0].message; + messages.push(message); + if (!message.tool_calls?.length) break; + for (const call of message.tool_calls) { + if (call.type !== "function" || call.function.name !== "fill_credential") continue; + const content = await CredentialFillTool.runnable( + client, + session.id, + JSON.parse(call.function.arguments) + ); + messages.push({ role: "tool", tool_call_id: call.id, content }); + } +} +``` + +Anthropic (Messages): + +```typescript +import Anthropic from "@anthropic-ai/sdk"; +import { CredentialFillTool, type CredentialFillToolParams } from "@hyperbrowser/sdk/tools"; + +const anthropic = new Anthropic(); +const messages: Anthropic.MessageParam[] = [ + { role: "user", content: "Sign in to GitHub with the github-login vault." }, +]; +for (;;) { + const response = await anthropic.messages.create({ + model: "claude-sonnet-5-5", + max_tokens: 4096, + tools: [CredentialFillTool.anthropicToolDefinition /* , your browser tools */], + messages, + }); + messages.push({ role: "assistant", content: response.content }); + if (response.stop_reason !== "tool_use") break; + const results: Anthropic.ToolResultBlockParam[] = []; + for (const block of response.content) { + if (block.type !== "tool_use" || block.name !== "fill_credential") continue; + const content = await CredentialFillTool.runnable( + client, + session.id, + block.input as CredentialFillToolParams + ); + results.push({ type: "tool_result", tool_use_id: block.id, content }); + } + messages.push({ role: "user", content: results }); +} +``` + +Gemini (`@google/genai`): + +```typescript +import { GoogleGenAI, type Content, type Part } from "@google/genai"; +import { CredentialFillTool, type CredentialFillToolParams } from "@hyperbrowser/sdk/tools"; + +const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); +const contents: Content[] = [ + { role: "user", parts: [{ text: "Sign in to GitHub with the github-login vault." }] }, +]; +for (;;) { + const response = await ai.models.generateContent({ + model: "gemini-3.5-flash", + contents, + config: { + tools: [{ functionDeclarations: [CredentialFillTool.geminiToolDefinition] }], + }, + }); + const calls = response.functionCalls ?? []; + if (calls.length === 0) break; + contents.push(response.candidates![0].content!); + const parts: Part[] = []; + for (const call of calls) { + if (call.name !== "fill_credential") continue; + const output = await CredentialFillTool.runnable( + client, + session.id, + call.args as unknown as CredentialFillToolParams + ); + parts.push({ + functionResponse: { id: call.id, name: call.name, response: JSON.parse(output) }, + }); + } + contents.push({ role: "user", parts }); +} +``` + +`FILL_CREDENTIAL_TOOL_OPENAI`, `FILL_CREDENTIAL_TOOL_ANTHROPIC` and +`FILL_CREDENTIAL_TOOL_GEMINI` are also exported. `CredentialFillTool` provides +`geminiToolDefinition` for the credential-fill tool. + ## Usage ### Playwright @@ -439,15 +842,36 @@ const worker = await sandbox.processes.start("node worker.js", { }); console.log(worker.secrets); +// A vault's entries as variables; `secrets` is merged over it and wins on a clash. +const migrate = await sandbox.exec("node migrate.js", { + vault: "prod-db", + secrets: { API_KEY: "provider-key" }, +}); + // Secrets for the image's startup command (image launches only). -await client.sandboxes.create({ imageName: "my-image", secrets: { API_KEY: "provider-key" } }); +const started = await client.sandboxes.create({ + imageName: "my-image", + vault: "prod-db", + secrets: { API_KEY: "provider-key" }, +}); +console.log(started.toJSON().startupSecrets); await client.secrets.deleteVersion("provider-key", 1); await client.secrets.delete("provider-key"); ``` - Secret methods take a secret's ID or exact name. Values are strings or bytes, - used byte-for-byte: 1 to 32,768 bytes of UTF-8 without NUL. + used byte-for-byte: 1 to 32,768 bytes of UTF-8 without NUL. A `totp` + secret's variable holds the code that is current when the process starts + (it isn't refreshed); `op_token` secrets can't be variables. +- `secrets` on a process handle (and `startupSecrets` on a created sandbox) + report what each variable received, never a value: a stored secret's + `name`, `secretId` and `version` (with `secretType: "totp"` for TOTP), its + `source` (`{ type: "vault", name }` or `{ type: "secrets" }`) and any + `overrides` it replaced; or for [1Password](#1password) the `reference`, + `tokenSecret` and `source: { type: "onePassword" }`. `vault`, `onePassword` + and `secrets` together hold at most 32 variables, and `env` can't set one + of them. - A secret is delivered only to the process it is passed to, but anything running as the same user (or root) in the sandbox can read that process's environment. The sandbox and its later snapshots are marked @@ -465,7 +889,7 @@ await client.secrets.delete("provider-key"); the start (`SECRET_PROCESS_OUTCOME_UNKNOWN`), the error code is `secret_process_outcome_unknown` and the API's response, if any, is in `details`: check the sandbox's processes before starting it again. -- The same applies to `sandboxes.create` with `secrets`: if the create result +- The same applies to `sandboxes.create` with `secrets`, `vault` or `onePassword`: if the create result can't be confirmed, the error code is `secret_process_outcome_unknown`, since a sandbox may already be running its startup command with the secrets. List sandboxes before creating it again. diff --git a/docs/credentials-cleanup.md b/docs/credentials-cleanup.md new file mode 100644 index 0000000..2b03297 --- /dev/null +++ b/docs/credentials-cleanup.md @@ -0,0 +1,59 @@ +# Credential SDK cleanup + +Baseline: `c2edcc71b6070de376fe77bdd031e4b4b06d8fe3` (node-sdk PR #131). +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. +- Fill input must be an object; `frameUrl` is mapped to `frame.url`. + Supplying raw `frame` now raises a local shape error instead of silently + discarding frame targeting. Other unknown fill/vault members reach the API + for rejection rather than being stripped. +- Typed optional members should be omitted when unused. Untyped direct fill + calls containing null optional members now follow server validation; + LLM-tool null/empty-string normalization is unchanged. + +## Validation + +- Full local verification: 832 tests passed across 31 unit/integration files; + TypeScript build, source/test typechecks and ESLint passed. +- 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/package.json b/package.json index 5f250a7..e57f752 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbrowser/sdk", - "version": "1.2.2", + "version": "1.3.1", "description": "Node SDK for Hyperbrowser API", "author": "", "repository": { diff --git a/src/client.ts b/src/client.ts index c83c2dd..3aeb89a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,6 +19,7 @@ import { WebService } from "./services/web"; import { SandboxesService } from "./services/sandboxes"; import { VolumesService } from "./services/volumes"; import { SecretsService } from "./services/secrets"; +import { VaultsService } from "./services/vaults"; import { HyperbrowserError } from "./error"; export { HyperbrowserError } from "./error"; @@ -47,6 +48,7 @@ export class HyperbrowserClient { public readonly sandboxes: SandboxesService; public readonly volumes: VolumesService; public readonly secrets: SecretsService; + public readonly vaults: VaultsService; constructor(config: HyperbrowserConfig = {}) { const apiKey = config.apiKey || process.env["HYPERBROWSER_API_KEY"]; @@ -72,6 +74,7 @@ export class HyperbrowserClient { this.sandboxes = new SandboxesService(apiKey, baseUrl, timeout, runtimeProxyOverride); this.volumes = new VolumesService(apiKey, baseUrl, timeout); this.secrets = new SecretsService(apiKey, baseUrl, timeout); + this.vaults = new VaultsService(apiKey, baseUrl, timeout); this.agents = { browserUse: new BrowserUseService(apiKey, baseUrl, timeout), diff --git a/src/error.ts b/src/error.ts index 74ecbb4..cda131f 100644 --- a/src/error.ts +++ b/src/error.ts @@ -53,5 +53,9 @@ export class HyperbrowserError extends Error { } } +/** @internal A request the SDK refuses to send because the API would reject it. */ +export const invalidRequest = (message: string): HyperbrowserError => + new HyperbrowserError(message, { code: "invalid_request", retryable: false }); + /** @internal The response body could not be read or decoded as JSON. */ export class ResponseDecodingError extends HyperbrowserError {} diff --git a/src/sandbox/process.ts b/src/sandbox/process.ts index ec086ac..02c4eee 100644 --- a/src/sandbox/process.ts +++ b/src/sandbox/process.ts @@ -11,6 +11,7 @@ import { import { assertNoEnvSecretOverlap, serializeSecretReferences, + WireOnePassword, WireSecretReference, } from "./secret-references"; import { @@ -59,7 +60,11 @@ export interface SecretProcessStartPayload { env?: Record; timeoutMs?: number; runAs?: string; - secrets: Record; + secrets?: Record; + /** A vault name; `secrets` is merged over its entries. */ + vault?: string; + /** 1Password fields; their variables can't come from any other source. */ + onePassword?: WireOnePassword; } export interface SecretProcessStartResponse { @@ -68,6 +73,14 @@ export interface SecretProcessStartResponse { secrets?: SandboxResolvedSecret[]; } +/** Whether a start delivers secrets, so it goes through the control API. */ +const deliversSecrets = (params: SandboxExecParams): boolean => + params.vault !== undefined || + params.onePassword !== undefined || + (params.secrets !== undefined && + params.secrets !== null && + Object.keys(params.secrets).length > 0); + /** @internal Starts processes with secrets through the control API. */ export interface SecretProcessStarter { sandboxId: string; @@ -323,7 +336,7 @@ export class SandboxProcessHandle { private closeStream: (() => void) | null = null; private readonly changed = new ChangeSignal(); - /** The secret versions delivered to a process started with `secrets`. */ + /** What a process started with `secrets`, `vault` or `onePassword` received. */ public readonly secrets?: SandboxResolvedSecret[]; constructor( @@ -665,7 +678,7 @@ export class SandboxProcessesApi { options?: SandboxExecOptions ): Promise { const params = normalizeExecParams(input, options); - if (params.secrets && Object.keys(params.secrets).length > 0) { + if (deliversSecrets(params)) { return this.execWithSecrets(params); } const handle = await this.start(params); @@ -688,7 +701,7 @@ export class SandboxProcessesApi { options?: SandboxExecOptions ): Promise { const params = normalizeExecParams(input, options); - if (params.secrets && Object.keys(params.secrets).length > 0) { + if (deliversSecrets(params)) { return this.startWithSecrets(params); } const maxOutputBytes = params.maxOutputBytes ?? DEFAULT_MAX_PROCESS_OUTPUT_BYTES; @@ -730,11 +743,11 @@ export class SandboxProcessesApi { if (!starter) { throw new HyperbrowserError("Starting a process with secrets requires a sandbox handle"); } - const secrets = serializeSecretReferences(params.secrets) as Record< - string, - WireSecretReference - >; - assertNoEnvSecretOverlap(params.env, secrets); + const secrets = serializeSecretReferences(params.secrets); + const vault = params.vault; + const onePassword = params.onePassword; + // A clash between env and the vault's variables is reported by the API. + if (secrets !== undefined) assertNoEnvSecretOverlap(params.env, secrets); const timeoutMs = secretProcessTimeoutMs(params); let response: SecretProcessStartResponse; @@ -747,6 +760,8 @@ export class SandboxProcessesApi { timeoutMs, runAs: params.runAs, secrets, + vault, + onePassword, }, params.signal ); diff --git a/src/sandbox/secret-references.ts b/src/sandbox/secret-references.ts index 35ab4ea..2300599 100644 --- a/src/sandbox/secret-references.ts +++ b/src/sandbox/secret-references.ts @@ -2,10 +2,19 @@ import { HyperbrowserError } from "../error"; import { SandboxSecrets } from "../types/sandbox"; const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** The longest environment variable name the API stores. */ +const MAX_ENV_NAME_LENGTH = 256; +/** Names of secrets and vaults. */ export const SECRET_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/; /** The largest version number the API stores. */ export const MAX_SECRET_VERSION = 2_147_483_647; const MAX_SECRETS = 32; +/** An environment variable name the API accepts for a secret or a vault entry. */ +export const isSecretEnvName = (name: unknown): name is string => + typeof name === "string" && + name.length <= MAX_ENV_NAME_LENGTH && + ENV_NAME_PATTERN.test(name) && + name !== "__proto__"; export interface WireSecretReference { name: string; @@ -34,7 +43,7 @@ export const serializeSecretReferences = ( } const wire: Record = {}; for (const [env, reference] of entries) { - if (!ENV_NAME_PATTERN.test(env) || env === "__proto__") { + if (!isSecretEnvName(env)) { throw new HyperbrowserError(`Invalid environment variable name for a secret: ${env}`); } const { name, version } = @@ -72,3 +81,5 @@ export const assertNoEnvSecretOverlap = ( ); } }; + +export type { SandboxOnePassword as WireOnePassword } from "../types/sandbox"; diff --git a/src/secret-values.ts b/src/secret-values.ts new file mode 100644 index 0000000..b55c018 --- /dev/null +++ b/src/secret-values.ts @@ -0,0 +1,165 @@ +import { invalidSecretValue, secretValueBytes } from "./secrets-envelope"; +import { SecretValue } from "./types/secret"; + +const TOTP_ALGORITHMS = new Set(["SHA1", "SHA256", "SHA512"]); +const MAX_TOTP_PERIOD_SECONDS = 300; +/** + * Unpadded base32 can't end with 1, 3 or 6 characters of a block, so such a + * seed was copied incompletely. vm-service refuses it too (older hosts + * dropped the extra characters and gave wrong codes). + */ +const BASE32_TAIL_LENGTHS = new Set([0, 2, 4, 5, 7]); +const BYTE_ORDER_MARK = "\uFEFF"; + +const INVALID_TOTP = + "A TOTP value must be a base32 seed or an otpauth://totp/ URI with a base32 secret, " + + "and optionally algorithm SHA1, SHA256 or SHA512, digits 6 or 8, and a period of 1 to 300 seconds"; + +// vm-service parses seeds with Go: these mirror strings.TrimSpace, +// url.Parse and url.ParseQuery, so a value is refused here whenever it would +// be refused there. + +/** Go's unicode.IsSpace: ASCII spaces, U+0085, U+00A0 and the Unicode White_Space spaces. */ +const GO_SPACE = "\t\n\v\f\r \u0085             

   "; + +const trimGoSpace = (value: string): string => { + let start = 0; + let end = value.length; + while (start < end && GO_SPACE.includes(value[start])) start++; + while (end > start && GO_SPACE.includes(value[end - 1])) end--; + return value.slice(start, end); +}; + +const isHex = (char: string | undefined): boolean => + char !== undefined && /^[0-9A-Fa-f]$/.test(char); + +/** Every '%' starts a two-digit hex escape, as url.Parse requires outside the query. */ +const hasValidEscapes = (text: string): boolean => { + for (let i = 0; i < text.length; i++) { + if (text[i] === "%" && !(isHex(text[i + 1]) && isHex(text[i + 2]))) return false; + } + return true; +}; + +/** url.QueryUnescape; undefined for a bad escape. Bytes are read as Latin-1. */ +const queryUnescape = (text: string): string | undefined => { + if (!hasValidEscapes(text)) return undefined; + return text + .replace(/\+/g, " ") + .replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16))); +}; + +/** url.Values.Get after url.ParseQuery: pairs with bad escapes or ';' are skipped. */ +const queryGet = (query: string, key: string): string => { + for (const pair of query.split("&")) { + if (!pair || pair.includes(";")) continue; + const separator = pair.indexOf("="); + const name = queryUnescape(separator < 0 ? pair : pair.slice(0, separator)); + const value = queryUnescape(separator < 0 ? "" : pair.slice(separator + 1)); + if (name === key && value !== undefined) return value; + } + return ""; +}; + +/** Characters url.Parse accepts in the userinfo before '@'. */ +const USERINFO_PATTERN = /^[A-Za-z0-9\-._:~!$&'()*+,;=%@]*$/; + +/** + * The otpauth:// URI's query, after the checks url.Parse and vm-service make. + * `uri` starts with "otpauth://" in any case. + */ +const otpauthQuery = (uri: string): string => { + if ([...uri].some((char) => char.charCodeAt(0) < 0x20 || char.charCodeAt(0) === 0x7f)) { + throw invalidSecretValue("A TOTP otpauth:// URI must not contain control characters"); + } + const hash = uri.indexOf("#"); + const fragment = hash < 0 ? "" : uri.slice(hash + 1); + const beforeFragment = hash < 0 ? uri : uri.slice(0, hash); + const question = beforeFragment.indexOf("?"); + const query = question < 0 ? "" : beforeFragment.slice(question + 1); + const rest = (question < 0 ? beforeFragment : beforeFragment.slice(0, question)).slice( + "otpauth://".length + ); + const slash = rest.indexOf("/"); + const authority = slash < 0 ? rest : rest.slice(0, slash); + const path = slash < 0 ? "" : rest.slice(slash); + const at = authority.lastIndexOf("@"); + const userinfo = at < 0 ? "" : authority.slice(0, at); + const host = (at < 0 ? authority : authority.slice(at + 1)).toLowerCase(); + if (!USERINFO_PATTERN.test(userinfo) || ![userinfo, path, fragment].every(hasValidEscapes)) { + throw invalidSecretValue(INVALID_TOTP); + } + if (host === "hotp") { + throw invalidSecretValue( + "HOTP (otpauth://hotp/) is not supported; a TOTP value must be a base32 seed or an otpauth://totp/ URI" + ); + } + if (host !== "totp") { + throw invalidSecretValue(INVALID_TOTP); + } + return query; +}; + +/** + * The bytes a base32 seed decodes to, read the way the platform reads it: + * spaces, '-', line breaks, lowercase and trailing '=' padding are accepted. Undefined + * when it isn't ASCII base32 or has an impossible length. + */ +const base32SeedBytes = (seed: string): number | undefined => { + // Upper-cased one character at a time, as Go does, so "ß" can't turn into + // "SS" while "ı" still reads as "I". + const upper = Array.from(seed.replace(/[ \r\n-]/g, ""), (char) => { + const mapped = char.toUpperCase(); + return mapped.length === 1 ? mapped : char; + }).join(""); + if (!/^[A-Z2-7]+=*$/.test(upper)) return undefined; + const normalized = upper.replace(/=+$/, ""); + return BASE32_TAIL_LENGTHS.has(normalized.length % 8) + ? Math.floor((normalized.length * 5) / 8) + : undefined; +}; + +const isIntegerIn = (text: string, allowed: (value: number) => boolean): boolean => + /^[+-]?\d+$/.test(text) && allowed(Number(text)); + +/** + * Check a TOTP seed as the platform will read it at fill or process time; the + * API can't see values, so a bad seed would otherwise only fail then. Seeds + * under 10 bytes (16 base32 characters) are accepted, though they are + * unusually short. + */ +export const checkTotpSeed = (value: string): void => { + let seed = trimGoSpace(value.startsWith(BYTE_ORDER_MARK) ? value.slice(1) : value); + if (seed.toLowerCase().startsWith("otpauth://")) { + const query = otpauthQuery(seed); + const algorithm = queryGet(query, "algorithm"); + const digits = queryGet(query, "digits"); + const period = queryGet(query, "period"); + if ( + (algorithm && !TOTP_ALGORITHMS.has(algorithm.toUpperCase())) || + (digits && !isIntegerIn(digits, (n) => n === 6 || n === 8)) || + (period && !isIntegerIn(period, (n) => n >= 1 && n <= MAX_TOTP_PERIOD_SECONDS)) + ) { + throw invalidSecretValue(INVALID_TOTP); + } + seed = queryGet(query, "secret"); + } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(seed)) { + throw invalidSecretValue(INVALID_TOTP); + } + if (base32SeedBytes(seed) === undefined) { + throw invalidSecretValue(INVALID_TOTP); + } +}; + +/** + * The plaintext stored for a secret of `type`, after the platform's rules + * for every value (1 byte to 32 KiB of UTF-8, no NUL) and the type's own. + * Types this SDK doesn't know get the common rules only. + */ +export const secretPlaintext = (type: string, value: SecretValue): Buffer => { + const bytes = secretValueBytes(value); + if (type === "totp") { + checkTotpSeed(bytes.toString("utf8")); + } + return bytes; +}; diff --git a/src/secrets-envelope.ts b/src/secrets-envelope.ts index e4307a4..c247822 100644 --- a/src/secrets-envelope.ts +++ b/src/secrets-envelope.ts @@ -8,7 +8,8 @@ export const MAX_SECRET_VALUE_BYTES = 32 * 1024; const MIN_SECRETS_KEY_BITS = 3072; const SECRETS_KEY_EXPONENT = 65537; -const invalid = (message: string): HyperbrowserError => +/** @internal A value the platform would refuse. Messages never include the value. */ +export const invalidSecretValue = (message: string): HyperbrowserError => new HyperbrowserError(message, { code: "invalid_secret_value", retryable: false }); const base64Url = (data: Buffer | Uint8Array): string => Buffer.from(data).toString("base64url"); @@ -19,26 +20,33 @@ const base64Url = (data: Buffer | Uint8Array): string => Buffer.from(data).toStr */ export const secretValueBytes = (value: SecretValue): Buffer => { if (typeof value !== "string" && !(value instanceof Uint8Array)) { - throw invalid("Secret value must be a string or bytes"); + throw invalidSecretValue("Secret value must be a string or bytes"); } const bytes = Buffer.from(value); if (bytes.length === 0) { - throw invalid("Secret value is empty"); + throw invalidSecretValue("Secret value is empty"); } if (bytes.length > MAX_SECRET_VALUE_BYTES) { - throw invalid(`Secret value is more than ${MAX_SECRET_VALUE_BYTES} bytes`); + throw invalidSecretValue(`Secret value is more than ${MAX_SECRET_VALUE_BYTES} bytes`); } if (bytes.includes(0)) { - throw invalid("Secret value must not contain NUL bytes"); + throw invalidSecretValue("Secret value must not contain NUL bytes"); } try { new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { - throw invalid("Secret value must be valid UTF-8"); + throw invalidSecretValue("Secret value must be valid UTF-8"); } return bytes; }; +/** A plain object, as opposed to an array, bytes or a class instance. */ +export const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + const minimalBytes = (value: Buffer): Buffer => { let start = 0; while (start < value.length - 1 && value[start] === 0) start += 1; diff --git a/src/services/credentials.ts b/src/services/credentials.ts new file mode 100644 index 0000000..0fc34b6 --- /dev/null +++ b/src/services/credentials.ts @@ -0,0 +1,50 @@ +import { invalidRequest } from "../error"; +import { isPlainObject } from "../secrets-envelope"; +import { CredentialFillResult, FillCredentialParams } from "../types/credential"; +import { BaseService } from "./base"; + +/** The API gives up on a fill after about 18 seconds. */ +const MIN_FILL_TIMEOUT_MS = 30_000; + +/** Fill secrets attached to a browser session into its pages. */ +export class CredentialsService extends BaseService { + /** + * Type a secret, a vault's entries, or 1Password fields into inputs on one + * of the session's pages. A TOTP secret types its current code. Only types; + * never submits, so click the login button or press Enter afterwards. + * Values never leave the platform: the result lists an outcome per input. + * Safe to retry; it is not retried automatically. + * + * With `onePassword`, each field's `ref` names the 1Password field, the + * `op_token` secret must have been attached at session creation as + * `{ onePassword: name }`, and the request's `allowedOrigins` are the + * origins the values may be typed on. Each field's result carries the + * `reference` read, and there is no top-level `version`. + * + * API errors are `HyperbrowserError`s whose `details` is the error body; the + * server's own details are in `details.details`, such as `details.details.pages` + * for `CREDENTIAL_PAGE_AMBIGUOUS`, or `selectors` and `reasons` (by + * selector) for `SECRET_ONEPASSWORD_REFERENCE_FAILED`. + */ + async fill(sessionId: string, params: FillCredentialParams): Promise { + if (typeof sessionId !== "string" || !sessionId.trim()) { + throw invalidRequest("A session ID is required"); + } + if (!isPlainObject(params)) { + throw invalidRequest("Fill parameters must be an object"); + } + if ("frame" in params) { + throw invalidRequest("Use frameUrl to select an iframe"); + } + // Preserve request members so the API can reject unknown or conflicting options. + const { frameUrl, ...body } = params; + return this.request( + `/session/${encodeURIComponent(sessionId)}/credentials/fill`, + { + method: "POST", + body: JSON.stringify({ ...body, frame: frameUrl == null ? undefined : { url: frameUrl } }), + timeout: Math.max(this.timeout, MIN_FILL_TIMEOUT_MS), + } + ); + } +} diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 8c0273c..1e86446 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -155,9 +155,16 @@ const serializeCreateSandboxParams = (params: CreateSandboxParams): Record { const body = serializeCreateSandboxParams(params); + const withSecrets = + body.secrets !== undefined || body.vault !== undefined || body.onePassword !== undefined; try { const detail = await this.request("/sandbox", { method: "POST", body: JSON.stringify(body), }); - if ( - body.secrets !== undefined && - (typeof detail?.id !== "string" || !detail.id.trim()) - ) { + if (withSecrets && (typeof detail?.id !== "string" || !detail.id.trim())) { throw new Error("The API accepted the create but returned no sandbox ID"); } return normalizeSandboxDetail(detail); } catch (error) { - if (body.secrets !== undefined && requestMayHaveArrived(error)) { + if (withSecrets && requestMayHaveArrived(error)) { throw new HyperbrowserError( `could not confirm the create result: ${ error instanceof Error diff --git a/src/services/secrets.ts b/src/services/secrets.ts index 3d2553f..6df0666 100644 --- a/src/services/secrets.ts +++ b/src/services/secrets.ts @@ -1,6 +1,7 @@ -import { HyperbrowserError } from "../error"; +import { HyperbrowserError, invalidRequest } from "../error"; import { MAX_SECRET_VERSION, SECRET_NAME_PATTERN } from "../sandbox/secret-references"; -import { secretValueBytes, SecretsSealer } from "../secrets-envelope"; +import { secretPlaintext } from "../secret-values"; +import { isPlainObject, secretValueBytes, SecretsSealer } from "../secrets-envelope"; import { CreateSecretParams, Secret, @@ -60,11 +61,15 @@ export class SecretsService extends BaseService { ) { throw new HyperbrowserError(`limit must be an integer from 1 to ${MAX_LIST_LIMIT}`); } + if (params.type !== undefined && (typeof params.type !== "string" || !params.type)) { + throw invalidRequest('type must be a secret type such as "secret" or "totp"'); + } try { return await this.request("/secrets", undefined, { search: params.search, page: params.page, limit: params.limit, + type: params.type, }); } catch (error) { return rethrow(error, "Failed to list secrets"); @@ -73,33 +78,40 @@ export class SecretsService extends BaseService { /** * Create a secret, and store its first version when `value` is given. The - * value and the platform key are checked before the secret is created, so a - * bad value never leaves an empty secret behind. + * value is checked against its type's rules, and the platform key is + * checked, before the secret is created, so a bad value never leaves an + * empty secret behind. */ async create(params: CreateSecretParams): Promise { - const { name, description, value } = params ?? ({} as CreateSecretParams); + const { name, description, type, allowedOrigins, value } = params ?? ({} as CreateSecretParams); if (typeof name !== "string" || !SECRET_NAME_PATTERN.test(name)) { throw new HyperbrowserError( "Secret name must be 1-128 letters, digits, '_' or '-', starting with a letter or digit" ); } - const sealer = value === undefined ? undefined : await this.prepareSealer(value); + if (type !== undefined && (typeof type !== "string" || !type)) { + throw invalidRequest('type must be "secret", "totp" or "op_token"'); + } + const plaintext = value === undefined ? undefined : secretPlaintext(type ?? "secret", value); + const sealer = + plaintext === undefined ? undefined : SecretsSealer.fromPublicKey(await this.getPublicKey()); + const metadata = allowedOrigins === undefined ? undefined : { browser: { allowedOrigins } }; let secret: Secret; try { secret = await this.request("/secrets", { method: "POST", - body: JSON.stringify({ name, description }), + body: JSON.stringify({ name, description, type, metadata }), }); } catch (error) { return rethrow(error, `Failed to create secret ${name}`); } - if (sealer === undefined) { + if (sealer === undefined || plaintext === undefined) { return secret; } try { - const version = await this.uploadVersion(secret, sealer, value as SecretValue); + const version = await this.uploadVersion(secret, sealer, plaintext); return { ...secret, latestVersion: version.version }; } catch (error) { // Retrying create would fail with "name taken"; the value has to be @@ -128,28 +140,57 @@ export class SecretsService extends BaseService { } } + /** + * Change a secret's description or metadata. Metadata members sent replace + * the stored ones, `null` removes a member, and members not sent are kept. + */ async update(secret: string, params: UpdateSecretParams): Promise { + const { description, metadata } = params ?? ({} as UpdateSecretParams); + if (description === undefined && metadata === undefined) { + throw invalidRequest("Pass a description or metadata to update"); + } + if (metadata !== undefined && !isPlainObject(metadata)) { + throw invalidRequest("Secret metadata must be an object"); + } try { const { id } = await this.resolve(secret); return await this.request(secretPath(id), { method: "PATCH", - body: JSON.stringify({ description: params.description }), + body: JSON.stringify({ description, metadata }), }); } catch (error) { return rethrow(error, `Failed to update secret ${secret}`); } } - /** Encrypt `value` locally and store it as the secret's next version. */ + /** + * Encrypt `value` locally and store it as the secret's next version. The + * value is checked against the rules of the secret's type, such as a TOTP + * seed's format, before it is sealed. + */ async addVersion(secret: string, value: SecretValue): Promise { - const sealer = await this.prepareSealer(value); + secretValueBytes(value); + const sealer = SecretsSealer.fromPublicKey(await this.getPublicKey()); try { - return await this.uploadVersion(await this.resolve(secret), sealer, value); + const resolved = await this.resolve(secret); + const plaintext = secretPlaintext(resolved.type ?? "secret", value); + return await this.uploadVersion(resolved, sealer, plaintext); } catch (error) { return rethrow(error, `Failed to add a version to secret ${secret}`); } } + /** + * Replace the origins a secret may be filled into in browser sessions, or + * clear them with `null` (it then can't be filled until origins are set + * again). Not for `op_token` secrets. + */ + async setAllowedOrigins(secret: string, allowedOrigins: string[] | null): Promise { + return this.update(secret, { + metadata: { browser: allowedOrigins === null ? null : { allowedOrigins } }, + }); + } + /** * Permanently delete a secret and its versions. Values already delivered to * sandboxes are not revoked. @@ -181,15 +222,11 @@ export class SecretsService extends BaseService { } } - private async prepareSealer(value: SecretValue): Promise { - secretValueBytes(value); - return SecretsSealer.fromPublicKey(await this.getPublicKey()); - } - - private uploadVersion(secret: Secret, sealer: SecretsSealer, value: SecretValue) { + /** Sealed to the context the API returned for the secret; never one built here. */ + private uploadVersion(secret: Secret, sealer: SecretsSealer, plaintext: Buffer) { return this.request(`${secretPath(secret.id)}/versions`, { method: "POST", - body: JSON.stringify({ envelope: sealer.seal(secret.encryptionContext, value) }), + body: JSON.stringify({ envelope: sealer.seal(secret.encryptionContext, plaintext) }), }); } diff --git a/src/services/sessions.ts b/src/services/sessions.ts index f5b0adc..9d87fc1 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -29,6 +29,7 @@ import { } from "../types/session"; import { BaseService } from "./base"; import { WebMCPService } from "./webmcp"; +import { CredentialsService } from "./credentials"; import { HyperbrowserError } from "../client"; const CAPTCHA_EVALUATION_REQUEST_TIMEOUT_MS = 185_000; @@ -67,11 +68,13 @@ class SessionEventLogsService extends BaseService { export class SessionsService extends BaseService { public readonly eventLogs: SessionEventLogsService; public readonly webmcp: WebMCPService; + public readonly credentials: CredentialsService; constructor(apiKey: string, baseUrl: string, timeout: number) { super(apiKey, baseUrl, timeout); this.eventLogs = new SessionEventLogsService(apiKey, baseUrl, timeout); this.webmcp = new WebMCPService(apiKey, baseUrl, timeout); + this.credentials = new CredentialsService(apiKey, baseUrl, timeout); } /** diff --git a/src/services/vaults.ts b/src/services/vaults.ts new file mode 100644 index 0000000..78f7369 --- /dev/null +++ b/src/services/vaults.ts @@ -0,0 +1,90 @@ +import { HyperbrowserError, invalidRequest } from "../error"; +import { + CreateVaultParams, + UpdateVaultParams, + Vault, + VaultDeleteResult, + VaultListParams, + VaultListResponse, +} from "../types/vault"; +import { BaseService } from "./base"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** Teams hold at most 100 vaults, so this covers every exact-name match. */ +const NAME_LOOKUP_LIMIT = 100; +const vaultPath = (id: string): string => `/vaults/${encodeURIComponent(id)}`; + +/** + * Vaults name a set of environment variables whose values come from stored + * secrets. Pass one as `vault` to a sandbox process + * or sandbox create, or attach it to a browser session with + * `credentials: [{ vault }]` and fill its entries by name. + * + * Methods that take `vault` accept its ID or its exact name. A UUID-shaped + * argument is tried as an ID first and then as a name. + */ +export class VaultsService extends BaseService { + async list(params: VaultListParams = {}): Promise { + const { page, limit, search } = params; + const response = await this.request("/vaults", undefined, { + search, + page, + limit, + }); + return { ...response, vaults: response.vaults ?? [] }; + } + + get(vault: string): Promise { + return this.resolve(vault); + } + + /** Create a vault. Entries name secrets, never values. */ + create(params: CreateVaultParams): Promise { + return this.request("/vaults", { method: "POST", body: JSON.stringify(params) }); + } + + /** + * Change a vault's description or its entries: + * each entry given replaces that variable, `null` removes it, and entries + * not given are kept. + */ + async update(vault: string, params: UpdateVaultParams): Promise { + const { id } = await this.resolve(vault); + return await this.request(vaultPath(id), { + method: "PATCH", + body: JSON.stringify(params), + }); + } + + /** Delete a vault. The secrets its entries name are kept. */ + async delete(vault: string): Promise { + const { id } = await this.resolve(vault); + return await this.request(vaultPath(id), { method: "DELETE" }); + } + + private async resolve(key: string): Promise { + if (typeof key !== "string" || !key.trim()) { + throw invalidRequest("A vault ID or name is required"); + } + key = key.trim(); + if (UUID_PATTERN.test(key)) { + try { + return await this.request(vaultPath(key)); + } catch (error) { + if (!(error instanceof HyperbrowserError) || error.statusCode !== 404) { + throw error; + } + } + } + const { vaults } = await this.list({ search: key, limit: NAME_LOOKUP_LIMIT }); + const vault = vaults.find((candidate) => candidate.name === key); + if (!vault) { + throw new HyperbrowserError(`Vault ${JSON.stringify(key)} not found`, { + statusCode: 404, + code: "VAULT_NOT_FOUND", + service: "control", + }); + } + return vault; + } +} diff --git a/src/tools/anthropic.ts b/src/tools/anthropic.ts index 0904957..6b7e6f6 100644 --- a/src/tools/anthropic.ts +++ b/src/tools/anthropic.ts @@ -4,6 +4,8 @@ import { CLAUDE_COMPUTER_USE_SCHEMA, CRAWL_SCHEMA, EXTRACT_SCHEMA, + FILL_CREDENTIAL_DESCRIPTION, + FILL_CREDENTIAL_SCHEMA, OPENAI_CUA_DESCRIPTION, OPENAI_CUA_SCHEMA, SCRAPE_SCHEMA, @@ -93,3 +95,9 @@ export const OPENAI_CUA_TOOL_ANTHROPIC: Tool = { name: "openai_cua", description: OPENAI_CUA_DESCRIPTION, }; + +export const FILL_CREDENTIAL_TOOL_ANTHROPIC: Tool = { + input_schema: FILL_CREDENTIAL_SCHEMA, + name: "fill_credential", + description: FILL_CREDENTIAL_DESCRIPTION, +}; diff --git a/src/tools/gemini.ts b/src/tools/gemini.ts new file mode 100644 index 0000000..60ac0e4 --- /dev/null +++ b/src/tools/gemini.ts @@ -0,0 +1,39 @@ +import { FILL_CREDENTIAL_DESCRIPTION, FILL_CREDENTIAL_SCHEMA } from "./schema"; + +// This declaration covers fill_credential only. General tool/schema conversion +// belongs in a separate change rather than expanding the credentials API. +const nullableString = (description: string) => ({ type: "STRING", nullable: true, description }); +const properties = FILL_CREDENTIAL_SCHEMA.properties; + +export const FILL_CREDENTIAL_TOOL_GEMINI = { + name: "fill_credential", + description: FILL_CREDENTIAL_DESCRIPTION, + parameters: { + type: "OBJECT", + properties: { + credential: nullableString(properties.credential.description), + vault: nullableString(properties.vault.description), + fields: { + type: "ARRAY", + description: properties.fields.description, + minItems: String(properties.fields.minItems), + maxItems: String(properties.fields.maxItems), + items: { + type: "OBJECT", + properties: { + selector: { + type: "STRING", + description: properties.fields.items.properties.selector.description, + }, + field: nullableString(properties.fields.items.properties.field.description), + }, + required: properties.fields.items.required, + }, + }, + page_id: nullableString(properties.page_id.description), + page_url: nullableString(properties.page_url.description), + frame_url: nullableString(properties.frame_url.description), + }, + required: FILL_CREDENTIAL_SCHEMA.required, + }, +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index 76b7692..13d8744 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,7 @@ import { SCREENSHOT_TOOL_OPENAI, OPENAI_CUA_TOOL_OPENAI, CLAUDE_COMPUTER_USE_TOOL_OPENAI, + FILL_CREDENTIAL_TOOL_OPENAI, } from "./openai"; import { SCRAPE_TOOL_ANTHROPIC, @@ -15,8 +16,10 @@ import { SCREENSHOT_TOOL_ANTHROPIC, CLAUDE_COMPUTER_USE_TOOL_ANTHROPIC, OPENAI_CUA_TOOL_ANTHROPIC, + FILL_CREDENTIAL_TOOL_ANTHROPIC, } from "./anthropic"; -import { HyperbrowserClient } from "../client"; +import { FILL_CREDENTIAL_TOOL_GEMINI } from "./gemini"; +import { HyperbrowserClient, HyperbrowserError } from "../client"; import { StartScrapeJobParams, StartCrawlJobParams, @@ -25,6 +28,23 @@ import { StartClaudeComputerUseTaskParams, } from "../types"; import { StartExtractJobParams } from "../types/extract"; +import { FillCredentialParams } from "../types/credential"; +import { isPlainObject } from "../secrets-envelope"; + +export { FILL_CREDENTIAL_DESCRIPTION, FILL_CREDENTIAL_SCHEMA } from "./schema"; +export { FILL_CREDENTIAL_TOOL_OPENAI } from "./openai"; +export { FILL_CREDENTIAL_TOOL_ANTHROPIC } from "./anthropic"; +export * from "./gemini"; + +/** What the model passes to `fill_credential`. Unused options are null. */ +export interface CredentialFillToolParams { + credential?: string | null; + vault?: string | null; + fields: { selector: string; field?: string | null }[]; + page_id?: string | null; + page_url?: string | null; + frame_url?: string | null; +} export class WebsiteScrapeTool { static openaiToolDefinition = SCRAPE_TOOL_OPENAI; @@ -113,3 +133,65 @@ export class OpenAICuaTool { return resp.data?.finalResult || resp.error || ""; } } + +/** + * Lets a model fill a secret, or a vault's entries, attached to a browser + * session. The application binds the session; the model chooses only names, + * selectors and which tab, and never sees values. + * + * It deliberately has no `onePassword`: a 1Password fill names its own + * allowed origins, and a model choosing them would defeat the origin check. + * Applications call `sessions.credentials.fill` directly for 1Password. + */ +export class CredentialFillTool { + static openaiToolDefinition = FILL_CREDENTIAL_TOOL_OPENAI; + static anthropicToolDefinition = FILL_CREDENTIAL_TOOL_ANTHROPIC; + static geminiToolDefinition = FILL_CREDENTIAL_TOOL_GEMINI; + + /** + * Run a `fill_credential` call in `sessionId` and return the result as JSON + * text for the model. API and validation errors come back as + * `{"error": {"code", "message", "retryable", "details"}}` rather than + * being thrown, so the model can react to them. + */ + static async runnable( + hb: HyperbrowserClient, + sessionId: string, + params: CredentialFillToolParams + ): Promise { + try { + // Model output is checked by fill; only the known keys are passed on. + // Null (strict mode's "not given") and "" (what Gemini tends to send + // for unused strings) are left out. + const input: Partial = isPlainObject(params) ? params : {}; + const given = (value: T | null | undefined): T | undefined => + value === null || value === "" ? undefined : value; + const request: FillCredentialParams = { + credential: given(input.credential), + vault: given(input.vault), + fields: (Array.isArray(input.fields) + ? input.fields.map((entry) => + isPlainObject(entry) ? { selector: entry.selector, field: given(entry.field) } : entry + ) + : input.fields) as FillCredentialParams["fields"], + pageId: given(input.page_id), + pageUrl: given(input.page_url), + frameUrl: given(input.frame_url), + }; + return JSON.stringify(await hb.sessions.credentials.fill(sessionId, request)); + } catch (error) { + if (!(error instanceof HyperbrowserError)) { + throw error; + } + return JSON.stringify({ + error: { + code: error.code ?? null, + message: error.message.replace(/^\[Hyperbrowser\]: /, ""), + retryable: error.retryable, + // details is the error body; the model gets the server's own details. + details: isPlainObject(error.details) ? (error.details.details ?? null) : null, + }, + }); + } + } +} diff --git a/src/tools/openai.ts b/src/tools/openai.ts index 35d2c38..3804c67 100644 --- a/src/tools/openai.ts +++ b/src/tools/openai.ts @@ -4,6 +4,8 @@ import { CLAUDE_COMPUTER_USE_SCHEMA, CRAWL_SCHEMA, EXTRACT_SCHEMA, + FILL_CREDENTIAL_DESCRIPTION, + FILL_CREDENTIAL_SCHEMA, OPENAI_CUA_DESCRIPTION, OPENAI_CUA_SCHEMA, SCRAPE_SCHEMA, @@ -126,3 +128,13 @@ export const OPENAI_CUA_TOOL_OPENAI: ChatCompletionTool = { strict: true, }, }; + +export const FILL_CREDENTIAL_TOOL_OPENAI: ChatCompletionTool = { + type: "function", + function: { + name: "fill_credential", + description: FILL_CREDENTIAL_DESCRIPTION, + parameters: FILL_CREDENTIAL_SCHEMA, + strict: true, + }, +}; diff --git a/src/tools/schema.ts b/src/tools/schema.ts index b9ec222..efdcd12 100644 --- a/src/tools/schema.ts +++ b/src/tools/schema.ts @@ -259,3 +259,69 @@ export const OPENAI_CUA_SCHEMA = { required: ["task", "sessionOptions"], additionalProperties: false, }; + +export const FILL_CREDENTIAL_DESCRIPTION = `Fill stored login details into inputs on the browser session's page. You never see the values: you name what to fill and give a CSS selector for each input. Pass exactly one of: +- credential: the name of one stored secret, such as a password. Each field is just {"selector"}. +- vault: the name of a vault that groups a login's secrets under entry names such as USERNAME, PASSWORD or OTP. Each field is {"selector", "field"}, where field is the entry to type into that input. A one-time-code (TOTP) entry types the code that is current at the moment of the fill. + +This tool only types. It does NOT submit the form: after a fill, click the login or submit button, or press Enter, with your other browser tools. For logins split over several screens (for example the username first, then the password), fill the inputs on the current screen, submit, and fill the rest on the next screen. Calling it again is safe; it replaces what is in the inputs. + +On success it returns JSON with an outcome per input: +- "filled": the input now holds the value. +- "not_found": no input matched the selector. Look at the page again and use a selector that matches the input. +- "ambiguous": several visible inputs matched. Use a more specific selector that matches exactly one. +- "not_editable": the element is disabled, read-only, hidden or not a text input. Select the actual visible input, or wait until it becomes editable. +- "not_accepted": the page did not keep the value (for example a length limit, or a script cleared it). Check the form, and tell the user if it keeps happening. +Submit only once every input you need is "filled". + +On failure it returns JSON {"error": {"code", "message", "retryable", "details"}}. If the code is CREDENTIAL_PAGE_AMBIGUOUS, several tabs could be the target: call again with page_id set to the pageId of the tab with the login form from details.pages, or with page_url set to the start of that tab's URL. CREDENTIAL_ORIGIN_NOT_ALLOWED means these details may not be used on the current site; nothing was typed, so do not try to work around it, and tell the user. CREDENTIAL_NOT_ATTACHED, SECRET_NOT_FOUND, VAULT_NOT_FOUND and VAULT_ENTRY_NOT_FOUND mean the name is wrong or was not made available to this session; use a name you were given. CREDENTIAL_FRAME_NOT_FOUND means no iframe matched frame_url. If retryable is true, you may try the same call again.`; + +const nullableString = (description: string) => ({ + type: ["string", "null"], + description, +}); + +export const FILL_CREDENTIAL_SCHEMA = { + type: "object" as const, + properties: { + credential: nullableString( + 'Name of a stored secret to type, for example "github-password". Null when you pass vault.' + ), + vault: nullableString( + 'Name of a vault whose entries to type, for example "github-login". Null when you pass credential.' + ), + fields: { + type: "array", + description: + "The inputs to fill on the page, each listed once: one entry per input, at most 16.", + minItems: 1, + maxItems: 16, + items: { + type: "object", + properties: { + selector: { + type: "string", + description: + 'CSS selector for the input to type into, matching exactly one visible input, for example "#login_field" or "input[name=password]".', + }, + field: nullableString( + 'With vault: the entry to type into this input, such as "USERNAME", "PASSWORD" or "OTP". Null with credential.' + ), + }, + required: ["selector", "field"], + additionalProperties: false, + }, + }, + page_id: nullableString( + "Usually null. Set it only after a call failed with CREDENTIAL_PAGE_AMBIGUOUS because several tabs could be the target: the pageId of the tab with the login form, from that error's details.pages. Not with page_url." + ), + page_url: nullableString( + 'Usually null. Instead of page_id, the URL (or the start of it) of the tab to fill, for example "https://github.com/login". Not with page_id.' + ), + frame_url: nullableString( + 'Null unless the login form is inside an iframe. Then the URL of that iframe, or the start of it, for example "https://accounts.example.com/login".' + ), + }, + required: ["credential", "vault", "fields", "page_id", "page_url", "frame_url"], + additionalProperties: false, +}; diff --git a/src/types/constants.ts b/src/types/constants.ts index e8ecf84..e28f0b7 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -2,7 +2,10 @@ export type SessionEventLogType = | "captcha_detected" | "captcha_solved" | "captcha_error" - | "file_downloaded"; + | "file_downloaded" + | "tab_opened" + | "credential_filled" + | "credential_fill_refused"; export type ScrapeFormat = "markdown" | "html" | "links" | "screenshot"; export type ScrapeJobStatus = "pending" | "running" | "completed" | "failed"; export type ExtractJobStatus = "pending" | "running" | "completed" | "failed"; diff --git a/src/types/credential.ts b/src/types/credential.ts new file mode 100644 index 0000000..74f146c --- /dev/null +++ b/src/types/credential.ts @@ -0,0 +1,133 @@ +/** A secret attached to a browser session, which fill may type into its pages. */ +export interface SessionCredential { + id: string; + name: string; +} + +/** + * What to attach to a browser session at creation: a secret by name (or + * `{ secret }`), `{ vault }` for the secrets a vault's entries name, or + * `{ onePassword }` for an `op_token` secret that fills may read 1Password + * fields with. + */ +export type SessionCredentialSource = + | string + | { secret: string } + | { vault: string } + | { onePassword: string }; + +/** + * A field of an item in the fill's 1Password vault. Names and IDs are 1-256 + * characters without '/' or '?', and not "." or "..". The field may end + * with a query such as `?attribute=otp`. + */ +export interface CredentialFillRef { + /** The item's name or ID. */ + item: string; + /** The section's name or ID, when the field is in one. */ + section?: string; + /** The field's name or ID, optionally with a query such as `?attribute=otp`. */ + field: string; +} + +/** Which input to fill and, with a vault or 1Password, from which entry or field. */ +export interface CredentialFillField { + /** A CSS selector for the input, 1-1024 UTF-8 bytes. Each selector at most once. */ + selector: string; + /** + * The vault entry (its environment variable name, such as `PASSWORD`) to + * type. Required with `vault`, not allowed otherwise. + */ + field?: string; + /** The 1Password field to type. Required with `onePassword`, not allowed otherwise. */ + ref?: CredentialFillRef; +} + +/** + * Fill 1Password fields, read with an `op_token` secret attached to the + * session. Nothing on the platform says where a 1Password value belongs, so + * the request names the origins it may be typed on. + */ +export interface CredentialFillOnePassword { + /** The `op_token` secret attached to the session, by name; its latest version is used. */ + token: string; + /** The 1Password vault's name or ID. */ + vault: string; + /** + * Origins the values may be filled into, 1-20, with the same rules as a + * secret's: `scheme://host[:port]`, https (http only for localhost), and a + * leading `*.` matching subdomains of a registrable domain. + */ + allowedOrigins: string[]; +} + +export interface FillCredentialParams { + /** A secret attached to the session, by name or ID. Pass one of this, `vault` or `onePassword`. */ + credential?: string; + /** A vault attached to the session, by name. Pass one of this, `credential` or `onePassword`. */ + vault?: string; + /** + * 1Password fields, each named by its field's `ref`. Pass one of this, + * `credential` or `vault`. + */ + onePassword?: CredentialFillOnePassword; + /** 1-16 inputs. */ + fields: CredentialFillField[]; + /** + * The tab to fill (a CDP target ID). Needed when several tabs could be the + * target; see `CREDENTIAL_PAGE_AMBIGUOUS`. Not with `pageUrl`. + */ + pageId?: string; + /** Fill the tab whose URL starts with this. Not with `pageId`. */ + pageUrl?: string; + /** Fill inside the iframe whose URL starts with this. */ + frameUrl?: string; +} + +/** + * - `filled`: the input holds the value (for a TOTP secret, the current code). + * - `not_found`: no input matched the selector. + * - `ambiguous`: several visible inputs matched. + * - `not_editable`: disabled, read-only, hidden or not a text input. + * - `not_accepted`: the page didn't keep the value (for example `maxlength`). + * + * Outcomes added later come through as other strings. + */ +export type CredentialFillOutcome = + | "filled" + | "not_found" + | "ambiguous" + | "not_editable" + | "not_accepted" + | (string & {}); + +export interface CredentialFillFieldResult { + selector: string; + outcome: CredentialFillOutcome; + /** The secret typed into this input; for 1Password, the `op_token` secret read with. */ + secretId: string; + /** Its version. */ + version: number; + /** The vault entry, for vault fills. */ + field?: string; + /** The `op://vault/item/[section/]field` reference read, for 1Password fills. */ + reference?: string; + /** For a TOTP secret, when the code that was typed stops being valid. */ + expiresAt?: string; +} + +/** Outcomes per input; never values. */ +export interface CredentialFillResult { + /** The secret version filled, for a single-secret fill. Absent for vault and 1Password fills. */ + version?: number; + pageId: string; + origin: string; + fields: CredentialFillFieldResult[]; +} + +/** One candidate tab in a `CREDENTIAL_PAGE_AMBIGUOUS` error's `details.pages`. */ +export interface CredentialFillPage { + pageId: string; + url: string; + title: string; +} diff --git a/src/types/index.ts b/src/types/index.ts index 711506f..5e2b110 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -181,6 +181,11 @@ export { SandboxSecretReference, SandboxSecrets, SandboxResolvedSecret, + SandboxStoredSecretBinding, + SandboxOnePasswordBinding, + SandboxSecretSource, + SandboxOnePassword, + SandboxOnePasswordField, SandboxListParams, SandboxListResponse, SandboxImageSource, @@ -273,6 +278,10 @@ export { } from "./volume"; export { SecretValue, + SecretType, + SecretBrowserMetadata, + SecretMetadata, + UpdateSecretMetadata, SecretsPublicKey, SecretsPublicKeyJwk, Secret, @@ -285,6 +294,28 @@ export { SecretDeleteResult, SecretVersionDeleteResult, } from "./secret"; +export { + SessionCredential, + SessionCredentialSource, + CredentialFillField, + CredentialFillRef, + CredentialFillOnePassword, + FillCredentialParams, + CredentialFillOutcome, + CredentialFillFieldResult, + CredentialFillResult, + CredentialFillPage, +} from "./credential"; +export { + Vault, + VaultEntry, + VaultEntryParams, + CreateVaultParams, + UpdateVaultParams, + VaultListParams, + VaultListResponse, + VaultDeleteResult, +} from "./vault"; export { CreateProfileParams, ForkProfileParams, diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index 412b660..786335c 100644 --- a/src/types/sandbox.ts +++ b/src/types/sandbox.ts @@ -75,6 +75,8 @@ export interface Sandbox { export interface SandboxDetail extends Sandbox { token: string | null; tokenExpiresAt: string | null; + /** On create with `secrets`, `vault` or `onePassword`: what the startup command received. */ + startupSecrets?: SandboxResolvedSecret[]; } export type SandboxVolumeMountType = "rw" | "ro"; @@ -93,14 +95,70 @@ export type SandboxSecretReference = /** Environment variable name to secret, at most 32 per process or sandbox. */ export type SandboxSecrets = Record; -/** The secret version an environment variable received. */ -export interface SandboxResolvedSecret { +/** + * Fields of one 1Password vault to deliver as environment variables, read + * with a service account token kept as an `op_token` secret when the process + * starts. Nothing about it is stored. + */ +export interface SandboxOnePassword { + /** The name of the `op_token` secret; its latest version is used. */ + token: string; + /** The 1Password vault's name or ID. */ + vault: string; + /** + * Environment variable name to field, 1-32. A variable set here can't also + * be set by `vault`, `secrets` or `env`. + */ + fields: Record; +} + +/** + * A field of an item in the 1Password vault. Names and IDs are 1-256 + * characters without '/' or '?', and not "." or "..". The field may end + * with a query such as `?attribute=totp`. + */ +export interface SandboxOnePasswordField { + /** The item's name or ID. */ + item: string; + /** The section's name or ID, when the field is in one. */ + section?: string; + /** The field's name or ID, optionally with a query such as `?attribute=otp`. */ + field: string; +} + +/** Where a delivered variable came from: a vault, `onePassword` or the request's `secrets`. */ +export type SandboxSecretSource = + | { type: "vault"; name: string } + | { type: "secrets" } + | { type: "onePassword" }; + +/** What a variable from a vault or `secrets` received: a stored secret's version. */ +export interface SandboxStoredSecretBinding { env: string; + /** The stored secret's name. */ name: string; secretId: string; version: number; + /** `"totp"` when the variable holds a TOTP secret's code from process start. */ + secretType?: string; + source: { type: "vault"; name: string } | { type: "secrets" }; + /** Earlier sources that set the same variable and were overridden, oldest first. */ + overrides?: SandboxSecretSource[]; } +/** What a variable from `onePassword` received: the `op://` reference that was read. */ +export interface SandboxOnePasswordBinding { + env: string; + /** The 1Password reference, such as `op://Engineering/Postgres/password`. */ + reference: string; + /** The name of the `op_token` secret that signed in to 1Password. */ + tokenSecret: string; + source: { type: "onePassword" }; +} + +/** What an environment variable received. Never a value. */ +export type SandboxResolvedSecret = SandboxStoredSecretBinding | SandboxOnePasswordBinding; + export interface SandboxVolumeMount { id: string; type?: SandboxVolumeMountType; @@ -129,6 +187,8 @@ export type CreateSandboxParams = memoryMiB?: never; diskMiB?: never; secrets?: never; + vault?: never; + onePassword?: never; }) | (SandboxCreateCommonParams & { snapshotName?: never; @@ -144,6 +204,14 @@ export type CreateSandboxParams = * "gvisor-cpu"`. The sandbox is marked as containing secrets. */ secrets?: SandboxSecrets; + /** + * A vault, by name, whose entries become the startup command's + * environment variables; `secrets` is merged over it and wins on a + * clash. Same requirements as `secrets`. + */ + vault?: string; + /** 1Password fields for the startup command. Same requirements as `secrets`. */ + onePassword?: SandboxOnePassword; }); export interface SandboxListParams { @@ -532,6 +600,19 @@ export interface SandboxExecParams { * start stream, so `maxOutputBytes` does not apply. */ secrets?: SandboxSecrets; + /** + * A vault, by name, whose entries become environment variables of this + * process, delivered like `secrets`. `secrets` is merged over it and wins + * on a clash; `env` can't set a variable either of them sets. Together + * they hold at most 32 variables. + */ + vault?: string; + /** + * Fields of a 1Password vault, read with an `op_token` secret when the + * process starts and delivered like `secrets`. A variable set here can't + * also be set by `vault`, `secrets` or `env`. + */ + onePassword?: SandboxOnePassword; /** @deprecated Ignored for process APIs. */ useShell?: boolean; } diff --git a/src/types/secret.ts b/src/types/secret.ts index d15e6d8..fa48de6 100644 --- a/src/types/secret.ts +++ b/src/types/secret.ts @@ -1,6 +1,32 @@ /** A secret value: a UTF-8 string or its raw bytes, used byte-for-byte. */ export type SecretValue = string | Uint8Array; +/** + * What a secret's value is, fixed at creation: + * - `"secret"`: an opaque value. It can be a sandbox environment variable, + * and once it has allowed origins it can be filled into browser pages. + * - `"totp"`: a TOTP seed, as base32 or an `otpauth://totp/` URI. Sandboxes + * and browser fills get its current one-time code, never the seed. + * - `"op_token"`: a 1Password service account token, used only as the + * `token` of a sandbox's or a browser fill's `onePassword` fields. It is + * never an environment variable or filled into a page itself. + */ +export type SecretType = "secret" | "totp" | "op_token"; + +export interface SecretBrowserMetadata { + /** + * Origins the secret may be filled into, as `scheme://host[:port]`. A + * leading `*.` matches any subdomain (not the domain itself). + */ + allowedOrigins: string[]; +} + +/** `{}` until allowed origins are set. */ +export interface SecretMetadata { + browser?: SecretBrowserMetadata; + [member: string]: unknown; +} + export interface SecretsPublicKeyJwk { kty: string; n: string; @@ -19,6 +45,12 @@ export interface Secret { id: string; name: string; description: string; + /** + * A `SecretType`. Typed as a string so secrets of types added later are + * still read. + */ + type: string; + metadata: SecretMetadata; /** Bound into every envelope for this secret; the SDK uses it when encrypting. */ encryptionContext: string; /** Null until a value is stored. */ @@ -41,19 +73,49 @@ export interface CreateSecretParams { /** 1-128 letters, digits, '_' or '-', starting with a letter or digit. */ name: string; description?: string; + /** Defaults to `"secret"`. */ + type?: SecretType; /** - * The first version's value. It is validated and the platform key is checked - * before the secret is created, then encrypted locally and uploaded. + * The first version's value. It is checked against the type's rules and + * the platform key is checked before the secret is created; then it is + * encrypted locally and uploaded. + * + * - `"secret"`: any string or bytes. + * - `"totp"`: a base32 seed (spaces, hyphens, lowercase and trailing `=` + * padding are fine) or an `otpauth://totp/...?secret=...` URI, optionally + * with `algorithm` (SHA1, SHA256 or SHA512), `digits` (6 or 8) and + * `period` (1-300). A seed whose length can't be whole base32 (1, 3 or 6 + * characters past a multiple of 8) is refused as incompletely copied. + * Seeds under 10 bytes (16 characters) are accepted but unusually short. + * - `"op_token"`: a 1Password service account token. These start with + * `ops_`, but other values are not refused. */ value?: SecretValue; + /** + * Origins the secret may be filled into in browser sessions, such as + * `["https://github.com"]`; 1-20 entries. Not for `"op_token"` secrets. + */ + allowedOrigins?: string[]; } +/** + * Metadata members sent replace the stored ones, `null` removes a member, and + * members not sent are kept. + */ +export interface UpdateSecretMetadata { + browser?: SecretBrowserMetadata | null; +} + +/** At least one field. */ export interface UpdateSecretParams { - description: string; + description?: string; + metadata?: UpdateSecretMetadata; } export interface SecretListParams { search?: string; + /** Only secrets of this type. */ + type?: SecretType; page?: number; /** At most 100. */ limit?: number; diff --git a/src/types/session.ts b/src/types/session.ts index 6ff2a55..91526b5 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -10,6 +10,7 @@ import { SessionRegion, State, } from "./constants"; +import { SessionCredential, SessionCredentialSource } from "./credential"; export type SessionStatus = "active" | "closed" | "close-error" | "error"; export type BrowserMemorySize = "small" | "medium" | "large"; @@ -91,6 +92,8 @@ export interface SessionDetail extends Session { liveUrl?: string; liveDomain?: string; token: string; + /** The secrets attached at creation, when any were requested. */ + credentials?: SessionCredential[]; } export interface SessionGetParams { @@ -265,6 +268,17 @@ export interface CreateSessionParams { disablePostQuantumKeyAgreement?: boolean; browserMemorySize?: BrowserMemorySize; startFromSnapshot?: StartSessionFromSnapshotParams; + /** + * Secrets that `sessions.credentials.fill` may type into pages, at most 20: + * a secret's name (or `{ secret: name }`), `{ vault: name }` for the + * secrets a vault's entries name when the session is created, or + * `{ onePassword: name }` for an `op_token` secret whose 1Password fields + * fills may read. + * Secrets need allowed origins to be filled. They can only be attached at + * creation; the session runs on Firecracker with Chrome's password manager + * off. + */ + credentials?: SessionCredentialSource[]; } export interface CreateSessionSnapshotResponse { diff --git a/src/types/vault.ts b/src/types/vault.ts new file mode 100644 index 0000000..73470c4 --- /dev/null +++ b/src/types/vault.ts @@ -0,0 +1,62 @@ +/** An entry of a vault: a stored secret. */ +export interface VaultEntry { + /** The secret's name. */ + name: string; + secretId: string; + /** A pinned version, or null to follow the secret's latest version. */ + version: number | null; +} + +/** Named environment variables whose values come from stored secrets. A vault holds no values itself. */ +export interface Vault { + id: string; + name: string; + description: string; + /** Environment variable name to entry. */ + entries: Record; + createdAt: string; + updatedAt: string; +} + +/** A stored secret, following its latest version unless one is pinned. */ +export interface VaultEntryParams { + /** The secret's name. */ + name: string; + version?: number | "latest"; +} + +export interface CreateVaultParams { + /** 1-128 letters, digits, '_' or '-', starting with a letter or digit. */ + name: string; + description?: string; + /** Environment variable name to secret, at most 32. */ + entries?: Record; +} + +/** At least one field. */ +export interface UpdateVaultParams { + description?: string; + /** Each entry given replaces that variable; `null` removes it. Others are kept. */ + entries?: Record; +} + +export interface VaultListParams { + /** Vaults whose name contains this. */ + search?: string; + page?: number; + /** At most 100. */ + limit?: number; +} + +export interface VaultListResponse { + vaults: Vault[]; + totalCount: number; + page: number; + perPage: number; +} + +export interface VaultDeleteResult { + deleted: boolean; + id: string; + name: string; +} diff --git a/tests/unit/credentials-fill.test.ts b/tests/unit/credentials-fill.test.ts new file mode 100644 index 0000000..88d3e70 --- /dev/null +++ b/tests/unit/credentials-fill.test.ts @@ -0,0 +1,613 @@ +import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest"; +import { Response } from "node-fetch"; + +const fetchMock = vi.fn(); +vi.mock("node-fetch", async () => { + const actual = await vi.importActual("node-fetch"); + return { ...actual, default: (...args: unknown[]) => fetchMock(...args) }; +}); + +import { HyperbrowserClient as Hyperbrowser, HyperbrowserError } from "../../src/client"; +import { + CredentialFillTool, + FILL_CREDENTIAL_SCHEMA, + FILL_CREDENTIAL_TOOL_ANTHROPIC, + FILL_CREDENTIAL_TOOL_GEMINI, + FILL_CREDENTIAL_TOOL_OPENAI, +} from "../../src/tools"; +import type { + CreateSessionParams, + CredentialFillOutcome, + CredentialFillResult, + SessionDetail, +} from "../../src/types"; + +const SESSION = "sess/1"; +const FILL_URL = "https://example.test/api/session/sess%2F1/credentials/fill"; + +const client = (timeout = 1000) => + new Hyperbrowser({ apiKey: "test-key", baseUrl: "https://example.test", timeout }); +const respond = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const lastRequest = () => { + const [url, init] = fetchMock.mock.lastCall!; + return { url, init, body: JSON.parse(init.body) }; +}; + +const filled: CredentialFillResult = { + pageId: "TARGET-1", + origin: "https://github.com", + fields: [ + { selector: "#login_field", outcome: "filled", secretId: "s1", version: 2, field: "USERNAME" }, + { selector: "#password", outcome: "not_found", secretId: "s2", version: 5, field: "PASSWORD" }, + { + selector: "#otp", + outcome: "filled", + secretId: "s3", + version: 1, + field: "OTP", + expiresAt: "2026-10-14T12:00:30Z", + }, + ], +}; +const single: CredentialFillResult = { + version: 3, + pageId: "TARGET-1", + origin: "https://github.com", + fields: [{ selector: "#password", outcome: "filled", secretId: "s2", version: 3 }], +}; +const ambiguous = { + code: "CREDENTIAL_PAGE_AMBIGUOUS", + message: "Several pages could be the target; pass pageId", + details: { + pages: [ + { pageId: "A", url: "https://github.com/login", title: "Sign in" }, + { pageId: "B", url: "https://github.com/", title: "GitHub" }, + ], + }, +}; +const vaultFields = [ + { field: "USERNAME", selector: "#login_field" }, + { field: "PASSWORD", selector: "#password" }, + { field: "OTP", selector: "#otp" }, +]; +const fields = [{ selector: "#password" }]; + +afterEach(() => fetchMock.mockReset()); + +describe("sessions", () => { + test("secrets and vaults are attached on create and the session lists the secrets", async () => { + const attached = [ + { id: "s1", name: "github-username" }, + { id: "s2", name: "github-password" }, + ]; + fetchMock.mockResolvedValue(respond({ id: "session", credentials: attached })); + const params: CreateSessionParams = { + credentials: ["github-password", { secret: "github-username" }, { vault: "github-login" }], + }; + const session = await client().sessions.create(params); + expect(lastRequest().body).toEqual({ + credentials: ["github-password", { secret: "github-username" }, { vault: "github-login" }], + }); + expect(session.credentials).toEqual(attached); + expectTypeOf().toEqualTypeOf< + { id: string; name: string }[] | undefined + >(); + }); + + test("a vault fill posts the entries with a timeout above the server's", async () => { + fetchMock.mockResolvedValue(respond(filled)); + const result = await client(1000).sessions.credentials.fill(SESSION, { + vault: "github-login", + fields: vaultFields, + pageUrl: "https://github.com/login", + frameUrl: "https://github.com/", + }); + const { url, init, body } = lastRequest(); + expect(url).toBe(FILL_URL); + expect(init.method).toBe("POST"); + expect(init.timeout).toBe(30_000); + expect(body).toEqual({ + vault: "github-login", + fields: vaultFields, + pageUrl: "https://github.com/login", + frame: { url: "https://github.com/" }, + }); + expect(result).toEqual(filled); + expectTypeOf(result.fields[0].outcome).toEqualTypeOf(); + }); + + test("a single-secret fill sends selectors only and omits absent options", async () => { + fetchMock.mockResolvedValue(respond(single)); + const result = await client(90_000).sessions.credentials.fill(SESSION, { + credential: "github-password", + fields, + pageId: "TARGET-1", + }); + const { init, body } = lastRequest(); + expect(init.timeout).toBe(90_000); + expect(body).toEqual({ credential: "github-password", fields, pageId: "TARGET-1" }); + expect(result.version).toBe(3); + }); + + test("unknown outcomes pass through", async () => { + fetchMock.mockResolvedValue( + respond({ ...single, fields: [{ ...single.fields[0], outcome: "not_visible_yet" }] }) + ); + const result = await client().sessions.credentials.fill(SESSION, { + credential: "github-password", + fields, + }); + expect(result.fields[0].outcome).toBe("not_visible_yet"); + }); + + test("errors carry the server's code, retryable flag and body as details", async () => { + fetchMock.mockResolvedValue(respond(ambiguous, 409)); + const error = await client() + .sessions.credentials.fill(SESSION, { vault: "github-login", fields: vaultFields }) + .catch((e) => e); + expect(error).toBeInstanceOf(HyperbrowserError); + expect(error).toMatchObject({ + statusCode: 409, + code: "CREDENTIAL_PAGE_AMBIGUOUS", + retryable: false, + details: ambiguous, + }); + expect(error.details.details.pages).toEqual(ambiguous.details.pages); + expect(error.message).toContain("Several pages could be the target"); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const timeout = { code: "CREDENTIAL_FILL_TIMEOUT", message: "timed out", retryable: true }; + fetchMock.mockResolvedValue(respond(timeout, 504)); + await expect( + client().sessions.credentials.fill(SESSION, { credential: "github-password", fields }) + ).rejects.toMatchObject({ + code: "CREDENTIAL_FILL_TIMEOUT", + retryable: true, + details: timeout, + }); + }); + + test("fill requires a session ID", async () => { + await expect( + client().sessions.credentials.fill("", { credential: "github-password", fields }) + ).rejects.toMatchObject({ code: "invalid_request" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("the longest allowed selector, page ID and URLs are accepted", async () => { + fetchMock.mockImplementation(async () => respond(single)); + await client().sessions.credentials.fill(SESSION, { + credential: "github-password", + fields: [{ selector: "é".repeat(512) }], + pageId: "p".repeat(128), + frameUrl: "u".repeat(2048), + }); + await client().sessions.credentials.fill(SESSION, { + credential: "github-password", + fields, + pageUrl: "u".repeat(2048), + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("1Password fills", () => { + const onePassword = { + token: "op-token", + vault: "Dev Vault for Testing", + allowedOrigins: ["https://www.scrapingcourse.com"], + }; + const refFields = [ + { selector: "#email", ref: { item: "scrapingcourse login", field: "username" } }, + { + selector: "#otp", + ref: { item: "scrapingcourse login", section: "Security", field: "otp?attribute=totp" }, + }, + ]; + const opResult: CredentialFillResult = { + pageId: "TARGET-1", + origin: "https://www.scrapingcourse.com", + fields: [ + { + selector: "#email", + outcome: "filled", + secretId: "tok", + version: 2, + reference: "op://Dev Vault for Testing/scrapingcourse login/username", + }, + { + selector: "#otp", + outcome: "filled", + secretId: "tok", + version: 2, + reference: "op://Dev Vault for Testing/scrapingcourse login/Security/otp?attribute=totp", + }, + ], + }; + + test("create attaches an op_token as { onePassword }", async () => { + fetchMock.mockResolvedValue(respond({ id: "session" })); + const params: CreateSessionParams = { + credentials: ["github-password", { vault: "github-login" }, { onePassword: "op-token" }], + }; + await client().sessions.create(params); + expect(lastRequest().body).toEqual({ + credentials: ["github-password", { vault: "github-login" }, { onePassword: "op-token" }], + }); + }); + + test("create accepts 20 credentials and no credentials", async () => { + fetchMock.mockImplementation(async () => respond({ id: "session" })); + await client().sessions.create({ + credentials: Array.from({ length: 20 }, (_, i) => ({ onePassword: `op-${i}` })), + }); + await client().sessions.create(); + await client().sessions.create({ credentials: [] }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + test("fill sends onePassword and each field's ref, and returns references", async () => { + fetchMock.mockResolvedValue(respond(opResult)); + const result = await client().sessions.credentials.fill(SESSION, { + onePassword, + fields: refFields, + pageUrl: "https://www.scrapingcourse.com/login", + frameUrl: "https://www.scrapingcourse.com/", + }); + const { url, body } = lastRequest(); + expect(url).toBe(FILL_URL); + expect(body).toEqual({ + onePassword, + fields: refFields, + pageUrl: "https://www.scrapingcourse.com/login", + frame: { url: "https://www.scrapingcourse.com/" }, + }); + expect(result).toEqual(opResult); + expect(result.version).toBeUndefined(); + expect(result.fields[1].reference).toBe( + "op://Dev Vault for Testing/scrapingcourse login/Security/otp?attribute=totp" + ); + expectTypeOf(result.fields[0].reference).toEqualTypeOf(); + expectTypeOf(result.version).toEqualTypeOf(); + }); + + test("local and wildcard origins, and a 512-byte reference, are accepted", async () => { + fetchMock.mockImplementation(async () => respond(opResult)); + await client().sessions.credentials.fill(SESSION, { + onePassword: { + token: "op-token", + vault: "v", + allowedOrigins: [ + "http://localhost:3000", + "http://127.0.0.1", + "http://[::1]:8080", + "https://*.example.com", + "HTTPS://Example.com:443/", + ...Array.from({ length: 15 }, (_, i) => `https://a${i}.example.com`), + ], + }, + // 256 characters, 503 bytes: op://v/i/ makes 512. + fields: [{ selector: "#f", ref: { item: "i", field: "é".repeat(247) + "x".repeat(9) } }], + pageId: "TARGET-1", + }); + expect(Buffer.byteLength(`op://v/i/${lastRequest().body.fields[0].ref.field}`)).toBe(512); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + const opFill = (overrides: Record = {}, fieldList: unknown = refFields) => ({ + onePassword: { ...onePassword, ...overrides }, + fields: fieldList, + }); + const ref = (value: Record) => [{ selector: "#f", ref: value }]; + + test("a reference failure keeps the selectors and reasons in details", async () => { + const failure = { + code: "SECRET_ONEPASSWORD_REFERENCE_FAILED", + message: "A 1Password field could not be read; nothing was filled", + retryable: false, + details: { + selectors: ["#otp"], + reason: "field not found", + reasons: { "#otp": "field not found" }, + tokenSecretId: "tok", + tokenVersion: 2, + }, + }; + fetchMock.mockResolvedValue(respond(failure, 422)); + await expect( + client().sessions.credentials.fill(SESSION, { onePassword, fields: refFields }) + ).rejects.toMatchObject({ + statusCode: 422, + code: "SECRET_ONEPASSWORD_REFERENCE_FAILED", + retryable: false, + details: failure, + }); + }); + + test("the fill_credential tool has no onePassword and drops one a model sends", async () => { + expect(JSON.stringify(FILL_CREDENTIAL_SCHEMA)).not.toMatch(/onePassword|one_password|"ref"/); + fetchMock.mockResolvedValue( + respond({ error: "Invalid body: pass one credential source" }, 400) + ); + const output = await CredentialFillTool.runnable(client(), SESSION, { + onePassword, + fields: refFields, + } as any); + expect(JSON.parse(output)).toMatchObject({ + error: { message: "Invalid body: pass one credential source", retryable: false }, + }); + expect(lastRequest().body).not.toHaveProperty("onePassword"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +/** Every object schema lists all its properties as required and allows no others. */ +const expectStrict = (schema: any, path = "$"): void => { + if (schema.type === "object" || (Array.isArray(schema.type) && schema.type.includes("object"))) { + expect(schema.additionalProperties, path).toBe(false); + expect([...schema.required].sort(), path).toEqual(Object.keys(schema.properties).sort()); + } + for (const [name, property] of Object.entries(schema.properties ?? {})) { + expect(typeof property.description, `${path}.${name}`).toBe("string"); + expectStrict(property, `${path}.${name}`); + } + if (schema.items) expectStrict(schema.items, `${path}[]`); +}; + +const collectKeys = (schema: unknown, keys = new Set()): Set => { + if (Array.isArray(schema)) schema.forEach((item) => collectKeys(item, keys)); + else if (schema && typeof schema === "object") { + for (const [key, value] of Object.entries(schema)) { + keys.add(key); + if (key !== "properties") collectKeys(value, keys); + else Object.values(value as object).forEach((property) => collectKeys(property, keys)); + } + } + return keys; +}; + +describe("fill_credential tool", () => { + const nullable = { type: "STRING", nullable: true, description: expect.any(String) }; + + test("the OpenAI definition is strict-compatible", () => { + expect(FILL_CREDENTIAL_TOOL_OPENAI.type).toBe("function"); + expect(FILL_CREDENTIAL_TOOL_OPENAI.function.name).toBe("fill_credential"); + expect(FILL_CREDENTIAL_TOOL_OPENAI.function.strict).toBe(true); + expect(FILL_CREDENTIAL_TOOL_OPENAI.function.parameters).toBe(FILL_CREDENTIAL_SCHEMA); + expectStrict(FILL_CREDENTIAL_SCHEMA); + expect(Object.keys(FILL_CREDENTIAL_SCHEMA.properties)).toEqual([ + "credential", + "vault", + "fields", + "page_id", + "page_url", + "frame_url", + ]); + expect(FILL_CREDENTIAL_SCHEMA.properties.fields).toMatchObject({ minItems: 1, maxItems: 16 }); + for (const name of ["credential", "vault", "page_id", "page_url", "frame_url"] as const) { + expect(FILL_CREDENTIAL_SCHEMA.properties[name].type).toEqual(["string", "null"]); + } + expect(FILL_CREDENTIAL_SCHEMA.properties.fields.items.properties.field.type).toEqual([ + "string", + "null", + ]); + // The session is bound by the application, never chosen by the model. + expect(JSON.stringify(FILL_CREDENTIAL_SCHEMA)).not.toMatch(/session/i); + }); + + test("the Anthropic definition shares the schema and description", () => { + expect(FILL_CREDENTIAL_TOOL_ANTHROPIC).toEqual({ + name: "fill_credential", + description: FILL_CREDENTIAL_TOOL_OPENAI.function.description, + input_schema: FILL_CREDENTIAL_SCHEMA, + }); + expect(CredentialFillTool.anthropicToolDefinition).toBe(FILL_CREDENTIAL_TOOL_ANTHROPIC); + expect(CredentialFillTool.openaiToolDefinition).toBe(FILL_CREDENTIAL_TOOL_OPENAI); + const description = FILL_CREDENTIAL_TOOL_ANTHROPIC.description!; + for (const word of [ + "NOT submit", + "vault", + "TOTP", + "filled", + "not_found", + "ambiguous", + "not_editable", + "not_accepted", + "CREDENTIAL_PAGE_AMBIGUOUS", + "page_id", + "page_url", + ]) { + expect(description).toContain(word); + } + expect(description).not.toContain("not_populated"); + }); + + test("the Gemini declaration uses Gemini's schema form", () => { + const declaration = CredentialFillTool.geminiToolDefinition; + expect(declaration).toBe(FILL_CREDENTIAL_TOOL_GEMINI); + expect(declaration.name).toBe("fill_credential"); + expect(declaration.description).toBe(FILL_CREDENTIAL_TOOL_OPENAI.function.description); + const keys = collectKeys(declaration.parameters); + expect(keys.has("additionalProperties")).toBe(false); + expect(JSON.stringify(declaration.parameters)).not.toContain('"null"'); + expect(declaration.parameters).toEqual({ + type: "OBJECT", + properties: { + credential: nullable, + vault: nullable, + fields: { + type: "ARRAY", + description: expect.any(String), + minItems: "1", + maxItems: "16", + items: { + type: "OBJECT", + properties: { + selector: { type: "STRING", description: expect.any(String) }, + field: nullable, + }, + required: ["selector", "field"], + }, + }, + page_id: nullable, + page_url: nullable, + frame_url: nullable, + }, + required: ["credential", "vault", "fields", "page_id", "page_url", "frame_url"], + }); + }); + + test("runnable maps a vault call to the API and returns JSON text", async () => { + fetchMock.mockResolvedValue(respond(filled)); + const output = await CredentialFillTool.runnable(client(), SESSION, { + credential: null, + vault: "github-login", + fields: [ + { field: "USERNAME", selector: "#login_field", extra: "dropped" } as any, + { field: "PASSWORD", selector: "#password" }, + ], + page_id: null, + page_url: "https://github.com/login", + frame_url: "https://github.com/", + }); + const { url, body } = lastRequest(); + expect(url).toBe(FILL_URL); + expect(body).toEqual({ + vault: "github-login", + fields: [ + { field: "USERNAME", selector: "#login_field" }, + { field: "PASSWORD", selector: "#password" }, + ], + pageUrl: "https://github.com/login", + frame: { url: "https://github.com/" }, + }); + expect(typeof output).toBe("string"); + expect(JSON.parse(output)).toEqual(filled); + }); + + test("strict mode's nulls are left out of a single-secret call", async () => { + fetchMock.mockResolvedValue(respond(single)); + await CredentialFillTool.runnable(client(), SESSION, { + credential: "github-password", + vault: null, + fields: [{ selector: "#password", field: null }], + page_id: "TARGET-1", + page_url: null, + frame_url: null, + }); + expect(lastRequest().body).toEqual({ + credential: "github-password", + fields: [{ selector: "#password" }], + pageId: "TARGET-1", + }); + }); + + test("empty strings, as Gemini sends for unused options, are left out", async () => { + fetchMock.mockResolvedValue(respond(filled)); + await CredentialFillTool.runnable(client(), SESSION, { + credential: "", + vault: "github-login", + fields: [{ selector: "#login_field", field: "USERNAME" }], + page_id: "", + page_url: "", + frame_url: "", + }); + expect(lastRequest().body).toEqual({ + vault: "github-login", + fields: [{ selector: "#login_field", field: "USERNAME" }], + }); + fetchMock.mockResolvedValue(respond(single)); + await CredentialFillTool.runnable(client(), SESSION, { + credential: "github-password", + vault: "", + fields: [{ selector: "#password", field: "" }], + }); + expect(lastRequest().body).toEqual({ + credential: "github-password", + fields: [{ selector: "#password" }], + }); + }); + + test("API errors come back as JSON with the server's own details", async () => { + fetchMock.mockResolvedValue(respond(ambiguous, 409)); + const output = await CredentialFillTool.runnable(client(), SESSION, { + credential: "github-password", + fields, + }); + expect(JSON.parse(output)).toEqual({ + error: { + code: "CREDENTIAL_PAGE_AMBIGUOUS", + message: "Several pages could be the target; pass pageId", + retryable: false, + details: ambiguous.details, + }, + }); + + fetchMock.mockResolvedValue( + respond({ code: "CREDENTIAL_FILL_TIMEOUT", message: "timed out", retryable: true }, 504) + ); + const timedOut = await CredentialFillTool.runnable(client(), SESSION, { + credential: "github-password", + fields, + }); + expect(JSON.parse(timedOut)).toEqual({ + error: { + code: "CREDENTIAL_FILL_TIMEOUT", + message: "timed out", + retryable: true, + details: null, + }, + }); + }); + + test.each([ + { fields }, + { credential: "c", vault: "v", fields }, + { + onePassword: { token: "op", vault: "v", allowedOrigins: ["http://example.com"] }, + fields: [{ selector: "#p", ref: { item: "i", field: "f" } }], + }, + { credential: "c", fields, typo: true }, + ])("fill preserves invalid policy input for API validation: %j", async (params) => { + const refused = { error: "Invalid body: server validation failed" }; + fetchMock.mockResolvedValue(respond(refused, 400)); + await expect(client().sessions.credentials.fill(SESSION, params as any)).rejects.toMatchObject({ + statusCode: 400, + retryable: false, + details: refused, + }); + expect(lastRequest().body).toEqual(params); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("raw frame targeting is rejected rather than discarded", async () => { + await expect( + client().sessions.credentials.fill(SESSION, { + credential: "c", + fields, + frame: { url: "https://auth.example.com" }, + } as any) + ).rejects.toMatchObject({ code: "invalid_request" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("tool returns server validation errors as JSON", async () => { + fetchMock.mockResolvedValue( + respond({ error: "Invalid body: pass one credential source" }, 400) + ); + const result = JSON.parse(await CredentialFillTool.runnable(client(), SESSION, { fields: [] })); + expect(result.error).toMatchObject({ + message: "Invalid body: pass one credential source", + retryable: false, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("errors other than HyperbrowserError are thrown", async () => { + const hb = client(); + vi.spyOn(hb.sessions.credentials, "fill").mockRejectedValue(new TypeError("bug")); + await expect( + CredentialFillTool.runnable(hb, SESSION, { credential: "github-password", fields }) + ).rejects.toThrow(TypeError); + }); +}); diff --git a/tests/unit/sandbox-secrets.test.ts b/tests/unit/sandbox-secrets.test.ts index 6c9c23c..043e19d 100644 --- a/tests/unit/sandbox-secrets.test.ts +++ b/tests/unit/sandbox-secrets.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest"; import { FetchError, Headers, Response } from "node-fetch"; const fetchMock = vi.fn(); @@ -12,6 +12,11 @@ import type { RuntimeTransport } from "../../src/sandbox/base"; import { SandboxProcessesApi, SecretProcessStarter } from "../../src/sandbox/process"; import { BaseService } from "../../src/services/base"; import { SandboxesService } from "../../src/services/sandboxes"; +import type { + SandboxOnePasswordBinding, + SandboxResolvedSecret, + SandboxSecretSource, +} from "../../src/types"; const wireDetail = { id: "sbx_123", @@ -33,7 +38,9 @@ const rawProcess = { started_at: 10, }; -const resolved = [{ env: "API_KEY", name: "provider-key", secretId: "s1", version: 2 }]; +const resolved: SandboxResolvedSecret[] = [ + { env: "API_KEY", name: "provider-key", secretId: "s1", version: 2, source: { type: "secrets" } }, +]; const result = { id: "proc_1", @@ -421,6 +428,244 @@ describe("processes with secrets", () => { }); }); +describe("vaults in sandboxes", () => { + afterEach(() => fetchMock.mockReset()); + + const vaultBindings: SandboxResolvedSecret[] = [ + { + env: "API_KEY", + name: "provider-key", + secretId: "s1", + version: 2, + source: { type: "secrets" }, + overrides: [{ type: "vault", name: "app-env" }], + }, + { + env: "OTP", + name: "github-otp", + secretId: "s2", + version: 1, + secretType: "totp", + source: { type: "vault", name: "app-env" }, + }, + { + env: "DB_PASS", + reference: "op://Engineering/Postgres/prod/password", + tokenSecret: "op-token", + source: { type: "onePassword" }, + }, + ]; + + test("image launches send the vault and report the startup bindings", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi + .spyOn(service as any, "request") + .mockResolvedValue({ ...wireDetail, startupSecrets: vaultBindings }); + + const sandbox = await service.create({ + imageName: "node", + vault: "app-env", + secrets: { API_KEY: "provider-key" }, + }); + + const body = JSON.parse((requestSpy.mock.calls[0][1] as any).body); + expect(body).toMatchObject({ + vault: "app-env", + secrets: { API_KEY: { name: "provider-key" } }, + }); + expect(sandbox.toJSON().startupSecrets).toEqual(vaultBindings); + }); + + test("startupSecrets survive a refresh of the handle", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + vi.spyOn(service as any, "request") + .mockResolvedValueOnce({ ...wireDetail, startupSecrets: vaultBindings }) + .mockResolvedValueOnce({ ...wireDetail, status: "active" }); + const sandbox = await service.create({ imageName: "node", vault: "app-env" }); + await sandbox.info(); + expect(sandbox.toJSON().startupSecrets).toEqual(vaultBindings); + }); + + test("vault launch restrictions are checked locally", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(wireDetail); + await expect(service.create({ snapshotName: "snap", vault: "app-env" } as any)).rejects.toThrow( + /only supported for image launches/ + ); + await expect( + service.create({ imageName: "node", runtimeClass: "gvisor-cpu", vault: "app-env" }) + ).rejects.toThrow(/not supported with runtimeClass gvisor-cpu/); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + test("a timed-out create with only a vault is an unknown outcome", async () => { + fetchMock.mockRejectedValue(new FetchError("network timeout", "request-timeout")); + const error = await new SandboxesService("test-key", "https://api.example.com", 1_000) + .create({ imageName: "node", vault: "app-env" }) + .catch((e) => e); + expect(error).toMatchObject({ code: "secret_process_outcome_unknown", retryable: false }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("a process with only a vault starts through the control API", async () => { + const start = starter(async () => ({ process: rawProcess, secrets: vaultBindings })); + const { transport, openSSE } = runtime({}); + const api = new SandboxProcessesApi(transport, start); + + const handle = await api.start("node app.js", { + env: { NODE_ENV: "production" }, + vault: "app-env", + secrets: {}, + }); + + expect(openSSE).not.toHaveBeenCalled(); + expect(start.start.mock.calls[0][0]).toEqual({ + command: "node app.js", + env: { NODE_ENV: "production" }, + vault: "app-env", + }); + expect(handle.secrets).toEqual(vaultBindings); + + expect(start.start).toHaveBeenCalledTimes(1); + }); +}); + +describe("1Password in sandboxes", () => { + const onePassword = { + 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" }, + }, + }; + + const opBinding: SandboxResolvedSecret = { + env: "SC_USER", + reference: "op://Dev Vault for Testing/scrapingcourse login/username", + tokenSecret: "op-token", + source: { type: "onePassword" }, + }; + + const createService = () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi + .spyOn(service as any, "request") + .mockResolvedValue({ ...wireDetail, startupSecrets: [opBinding] }); + return { service, requestSpy }; + }; + + test("image launches send onePassword next to vault and secrets", async () => { + const { service, requestSpy } = createService(); + const sandbox = await service.create({ + imageName: "node", + vault: "app-env", + secrets: { API_KEY: "provider-key" }, + onePassword, + }); + const body = JSON.parse((requestSpy.mock.calls[0][1] as any).body); + expect(body).toMatchObject({ + vault: "app-env", + secrets: { API_KEY: { name: "provider-key" } }, + onePassword, + }); + expect(sandbox.toJSON().startupSecrets).toEqual([opBinding]); + }); + + test("onePassword is only for Firecracker image launches", async () => { + const { service, requestSpy } = createService(); + await expect(service.create({ snapshotName: "snap", onePassword } as any)).rejects.toThrow( + /only supported for image launches/ + ); + await expect( + service.create({ imageName: "node", runtimeClass: "gvisor-cpu", onePassword }) + ).rejects.toThrow(/not supported with runtimeClass gvisor-cpu/); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + test("a timed-out create with only onePassword is an unknown outcome", async () => { + fetchMock.mockRejectedValue(new FetchError("network timeout", "request-timeout")); + const error = await new SandboxesService("test-key", "https://api.example.com", 1_000) + .create({ imageName: "node", onePassword }) + .catch((e) => e); + expect(error).toMatchObject({ code: "secret_process_outcome_unknown", retryable: false }); + expect(fetchMock).toHaveBeenCalledTimes(1); + fetchMock.mockReset(); + }); + + test("process start and exec send onePassword through the control API", async () => { + const start = starter(async () => ({ process: rawProcess, secrets: [opBinding] })); + const { transport, openSSE } = runtime({ + "/sandbox/processes/proc_1/stdin": [{}], + "/sandbox/processes/proc_1/wait": [{ result }], + }); + const api = new SandboxProcessesApi(transport, start); + + const handle = await api.start("node app.js", { env: { NODE_ENV: "test" }, onePassword }); + expect(handle.secrets).toEqual([opBinding]); + await api.exec({ command: "node app.js", onePassword, secrets: {} }); + + expect(openSSE).not.toHaveBeenCalled(); + expect(start.start.mock.calls.map(([payload]) => payload)).toEqual([ + { command: "node app.js", env: { NODE_ENV: "test" }, onePassword }, + { command: "node app.js", onePassword }, + ]); + }); + + test("sandbox handles send onePassword in the process body", async () => { + const service = new SandboxesService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi + .spyOn(service as any, "request") + .mockResolvedValueOnce(wireDetail) + .mockResolvedValueOnce({ process: rawProcess, secrets: [opBinding] }); + const sandbox = await service.get("sbx_123"); + await sandbox.processes.start({ command: "node app.js", onePassword }); + expect(requestSpy.mock.calls[1][0]).toBe("/sandbox/sbx_123/processes"); + expect(JSON.parse((requestSpy.mock.calls[1][1] as any).body)).toEqual({ + command: "node app.js", + onePassword, + }); + }); + + test("names may be IDs, use any other characters, and end fields with a query", async () => { + const start = starter(); + const api = new SandboxProcessesApi(runtime({}).transport, start); + await api.start("x", { + onePassword: { + token: "op-token", + vault: "abcdefghijklmnopqrstuvwxyz", + fields: { + A: { item: "Café · Login", section: "...x", field: "password" }, + B: { item: "i", field: "key?ssh-format=openssh&attribute=otp" }, + C: { item: "x".repeat(256), field: "otp?attribute=totp" }, + }, + }, + }); + expect(start.start).toHaveBeenCalledTimes(1); + }); + + const field = (overrides: Record) => ({ + ...onePassword, + fields: { A: { item: "Login", field: "password", ...overrides } }, + }); + + test("a onePassword binding has a reference and token instead of a secret", () => { + const bindings: SandboxResolvedSecret[] = [opBinding, ...resolved]; + const op = bindings.find(({ source }) => source.type === "onePassword"); + expect(op && "reference" in op && op.reference).toBe( + "op://Dev Vault for Testing/scrapingcourse login/username" + ); + expect(op).not.toHaveProperty("name"); + expect(op).not.toHaveProperty("overrides"); + expectTypeOf().toEqualTypeOf< + "env" | "reference" | "tokenSecret" | "source" + >(); + expectTypeOf().toEqualTypeOf< + "vault" | "secrets" | "onePassword" + >(); + }); +}); + describe("control API error fields", () => { class TestService extends BaseService { post(path: string) { diff --git a/tests/unit/secrets-contract.test.ts b/tests/unit/secrets-contract.test.ts index fc83834..fd5fc4d 100644 --- a/tests/unit/secrets-contract.test.ts +++ b/tests/unit/secrets-contract.test.ts @@ -284,3 +284,245 @@ describe("secrets service", () => { }); }); }); + +describe("typed secrets and allowed origins", () => { + const TOTP_CONTEXT = `hb-secret-v1:team:${ID}:totp`; + const OP_CONTEXT = `hb-secret-v1:team:${ID}:op_token`; + const SEED = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + const totp = (overrides: Record = {}) => + secret({ + name: "github-otp", + type: "totp", + metadata: {}, + encryptionContext: TOTP_CONTEXT, + ...overrides, + }); + const opToken = () => + secret({ name: "op-token", type: "op_token", metadata: {}, encryptionContext: OP_CONTEXT }); + const listOf = (item: unknown) => ({ secrets: [item], totalCount: 1, page: 1, perPage: 100 }); + const version = (n: number) => ({ version: n, kid: platformPublicKey.kid, createdAt: "" }); + const created = (item: unknown) => (method: string, path: string) => { + if (path === "/secrets/public-key") return platformPublicKey; + if (method === "POST" && path === "/secrets") return item; + if (method === "POST" && path === `/secrets/${ID}/versions`) return version(1); + throw new Error(`unexpected ${method} ${path}`); + }; + + test("create sends allowedOrigins as metadata.browser and seals to the returned context", async () => { + const { service, calls, requestSpy } = mockService( + created( + secret({ + name: "github-password", + metadata: { browser: { allowedOrigins: ["https://github.com"] } }, + }) + ) + ); + await service.create({ + name: "github-password", + value: "pässwörd-1", + allowedOrigins: ["https://github.com"], + description: "GitHub bot", + }); + expect(calls()).toEqual([ + "GET /secrets/public-key", + "POST /secrets", + `POST /secrets/${ID}/versions`, + ]); + expect(JSON.parse((requestSpy.mock.calls[1][1] as any).body)).toEqual({ + name: "github-password", + description: "GitHub bot", + metadata: { browser: { allowedOrigins: ["https://github.com"] } }, + }); + const { header, value } = open(JSON.parse((requestSpy.mock.calls[2][1] as any).body).envelope); + expect(header.ctx).toBe(secret().encryptionContext); + expect(value.toString("utf8")).toBe("pässwörd-1"); + }); + + test("a TOTP seed is sealed as given to the TOTP context", async () => { + const { service, requestSpy } = mockService(created(totp())); + const result = await service.create({ name: "github-otp", type: "totp", value: SEED }); + expect(JSON.parse((requestSpy.mock.calls[1][1] as any).body)).toEqual({ + name: "github-otp", + type: "totp", + }); + const { header, value } = open(JSON.parse((requestSpy.mock.calls[2][1] as any).body).envelope); + expect(header.ctx).toBe(TOTP_CONTEXT); + expect(value.toString()).toBe(SEED); + expect(result).toMatchObject({ type: "totp", latestVersion: 1 }); + }); + + test.each([ + ["base32", SEED], + ["lowercase with spaces and padding", "jbsw y3dp ehpk 3pxp===="], + ["a byte-order mark", `\uFEFF${SEED}`], + ["Go's spaces around it (NEL, NBSP, ideographic)", `\u0085\u00A0${SEED}\u3000`], + ["a valid escape in the path and fragment", `otpauth://totp/50%25off?secret=${SEED}#a%20b`], + ["a bad escape in another query parameter", `otpauth://totp/x?issuer=50%off&secret=${SEED}`], + ["a padded integer", `otpauth://totp/x?secret=${SEED}&digits=0000000008`], + ["hyphens and surrounding whitespace", " JBSW-Y3DP-EHPK-3PXP\n"], + ["a short seed (5 bytes)", "JBSWY3DP"], + // The platform skips line breaks, as Go's base32 decoder does. + ["a seed split across lines", "JBSWY3DPEHPK3\nPXP"], + ["a seed split with CRLF", "JBSWY3DP\r\nEHPK3PXP"], + // Go upper-cases one character at a time: "ı" reads as "I". + ["a dotless i", "\u0131BSWY3DPEHPK3PXP"], + ["an encoded line break in the URI secret", "otpauth://totp/x?secret=JBSWY3DP%0AEHPK3PXP"], + ["an otpauth URI", `otpauth://totp/GitHub:ada?secret=${SEED}&issuer=GitHub`], + [ + "an otpauth URI with every option", + `otpauth://TOTP/x?secret=${SEED}&algorithm=sha512&digits=8&period=60`, + ], + ])("TOTP values accept %s", async (_name, value) => { + const { service } = mockService(created(totp())); + await expect( + service.create({ name: "github-otp", type: "totp", value }) + ).resolves.toMatchObject({ + latestVersion: 1, + }); + }); + + test.each([ + ["not base32", "not-a-seed!"], + ["digits outside base32", "JBSWY3DPEH1K3PXP"], + ["a length of 1 mod 8", "JBSWY3DPE"], + // ...but "ß" and "ff" stay as they are rather than becoming "SS" and "FF". + ["an eszett", "JBSWY3DPEHPK3P\u00df"], + ["an ff ligature", "\ufb00AAAAAA"], + ["a length of 3 mod 8", "JBSWY3DPEHP"], + ["a length of 6 mod 8 after spaces, hyphens and padding", "jbsw-y3 dpeh pk3p xpjb swy3=="], + ["'=' inside the seed", "JBSW=Y3DPEHPK3PXP"], + ["another URI scheme", `https://totp/x?secret=${SEED}`], + ["a HOTP URI", `otpauth://hotp/x?secret=${SEED}`], + ["a tab in the URI", `otpauth://totp/x?secret=JBSWY3DP\tEHPK3PXP`], + ["a newline in the URI", `otpauth://totp/x?issuer=a\nb&secret=${SEED}`], + ["DEL in the URI", `otpauth://totp/x\u007f?secret=${SEED}`], + ["a stray '%' in the path", `otpauth://totp/50%off:alice?secret=${SEED}`], + ["a stray '%' in the fragment", `otpauth://totp/x?secret=${SEED}#50%off`], + ["a bad escape in the secret", `otpauth://totp/x?secret=${SEED}%zz`], + ["a byte-order mark after a space", ` \uFEFF${SEED}`], + ["a trailing byte-order mark", `${SEED}\uFEFF`], + ["a character that uppercases to ASCII", "JBSWY3DPEHPK3PXß"], + ["a ligature that uppercases to ASCII", "JBSWY3DPEHPK3Pff"], + ["userinfo Go refuses", `otpauth://a b@totp/x?secret=${SEED}`], + ["a URI secret with an impossible length", "otpauth://totp/x?secret=JBSWY3DPE"], + ["a URI without a secret", "otpauth://totp/x?issuer=GitHub"], + ["an unknown algorithm", `otpauth://totp/x?secret=${SEED}&algorithm=MD5`], + ["7 digits", `otpauth://totp/x?secret=${SEED}&digits=7`], + ["a period of 0", `otpauth://totp/x?secret=${SEED}&period=0`], + ["a period over 300", `otpauth://totp/x?secret=${SEED}&period=301`], + ])("TOTP values with %s fail locally without naming the value", async (_name, value) => { + const { service, calls } = mockService(created(totp())); + const error = await service.create({ name: "github-otp", type: "totp", value }).catch((e) => e); + expect(error).toBeInstanceOf(HyperbrowserError); + expect(error.code).toBe("invalid_secret_value"); + expect(error.message).toMatch(/TOTP/); + expect(error.message).not.toContain(value); + expect(calls()).toEqual([]); + }); + + test("short seeds are accepted without writing a warning", async () => { + const warn = vi.spyOn(process, "emitWarning"); + try { + const { service } = mockService(created(totp())); + await service.create({ name: "github-otp", type: "totp", value: "JBSWY3DP" }); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + test("HOTP URIs are refused with their own message", async () => { + const { service } = mockService(created(totp())); + await expect( + service.create({ name: "github-otp", type: "totp", value: `otpauth://hotp/x?secret=${SEED}` }) + ).rejects.toThrow(/HOTP .* not supported/); + }); + + test("op_token values are sealed to the returned context", async () => { + const { service, requestSpy } = mockService(created(opToken())); + await service.create({ name: "op-token", type: "op_token", value: "not-an-ops-token" }); + expect(open(JSON.parse((requestSpy.mock.calls[2][1] as any).body).envelope).header.ctx).toBe( + OP_CONTEXT + ); + + }); + + test("addVersion validates against the resolved secret's type", async () => { + const { service, calls, requestSpy } = mockService((method, path, _body, params) => { + if (path === "/secrets/public-key") return platformPublicKey; + if (path === "/secrets") return listOf(params.search === "github-otp" ? totp() : secret()); + if (method === "POST") return version(2); + throw new Error(`unexpected ${method} ${path}`); + }); + + await expect(service.addVersion("github-otp", "not a seed!")).rejects.toMatchObject({ + code: "invalid_secret_value", + }); + expect(calls().filter((call) => call.startsWith("POST"))).toEqual([]); + + await expect(service.addVersion("github-otp", SEED)).resolves.toMatchObject({ version: 2 }); + expect(open(JSON.parse((requestSpy.mock.lastCall![1] as any).body).envelope).header.ctx).toBe( + TOTP_CONTEXT + ); + // The same text is a fine plain secret. + await expect(service.addVersion("provider-key", "not a seed!")).resolves.toMatchObject({ + version: 2, + }); + }); + + test("types this SDK doesn't know get the common value rules only", async () => { + const { service } = mockService((method, path) => { + if (path === "/secrets/public-key") return platformPublicKey; + if (path === "/secrets") return listOf(secret({ type: "future" })); + return version(2); + }); + await expect(service.addVersion("provider-key", "anything")).resolves.toMatchObject({ + version: 2, + }); + await expect(service.addVersion("provider-key", "")).rejects.toThrow(/empty/); + }); + + test("setAllowedOrigins replaces or clears metadata.browser", async () => { + const withOrigins = secret({ + metadata: { browser: { allowedOrigins: ["https://github.com"] } }, + }); + const { service, calls, requestSpy } = mockService((method, path) => { + if (path === "/secrets") return listOf(withOrigins); + if (method === "PATCH") return secret({ metadata: {} }); + throw new Error(`unexpected ${method} ${path}`); + }); + + await service.setAllowedOrigins("provider-key", ["https://github.com", "https://*.github.com"]); + expect(JSON.parse((requestSpy.mock.calls[1][1] as any).body)).toEqual({ + metadata: { browser: { allowedOrigins: ["https://github.com", "https://*.github.com"] } }, + }); + await expect(service.setAllowedOrigins("provider-key", null)).resolves.toMatchObject({ + metadata: {}, + }); + expect((requestSpy.mock.calls[3][1] as any).body).toBe('{"metadata":{"browser":null}}'); + expect(calls()).toEqual([ + "GET /secrets", + `PATCH /secrets/${ID}`, + "GET /secrets", + `PATCH /secrets/${ID}`, + ]); + + await expect(service.update("provider-key", {})).rejects.toMatchObject({ + code: "invalid_request", + }); + expect(calls()).toHaveLength(4); + }); + + test("list filters by type and reads unknown types", async () => { + const { service, requestSpy } = mockService(() => listOf(secret({ type: "future" }))); + await expect(service.list({ type: "totp" })).resolves.toMatchObject({ + secrets: [{ type: "future" }], + }); + expect(requestSpy.mock.calls[0][2]).toEqual({ + search: undefined, + page: undefined, + limit: undefined, + type: "totp", + }); + }); +}); diff --git a/tests/unit/vaults.test.ts b/tests/unit/vaults.test.ts new file mode 100644 index 0000000..66ed927 --- /dev/null +++ b/tests/unit/vaults.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, expectTypeOf, test, vi } from "vitest"; +import { HyperbrowserClient as Hyperbrowser, HyperbrowserError } from "../../src/client"; +import { VaultsService } from "../../src/services/vaults"; +import type { Vault, VaultEntry } from "../../src/types"; + +const ID = "6f1d3c1e-5b8a-4e6f-9a51-0c2b7f3d9e10"; +const UUID_NAME = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + +const stored = (overrides: Record = {}): Vault => ({ + id: ID, + name: "github-login", + description: "", + entries: { + USERNAME: { name: "github-username", secretId: "s1", version: null }, + PASSWORD: { name: "github-password", secretId: "s2", version: 3 }, + }, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + ...overrides, +}); + +const listOf = (...vaults: unknown[]) => ({ + vaults, + totalCount: vaults.length, + page: 1, + perPage: 100, +}); + +const notFound = () => + new HyperbrowserError("Vault not found", { statusCode: 404, code: "VAULT_NOT_FOUND" }); + +/** Routes `request(path, init, params)` calls to `handler(method, path, body, params)`. */ +const mockService = ( + handler: (method: string, path: string, body: any, params: any) => unknown +) => { + const service = new VaultsService("test-key", "https://api.example.com", 30_000); + const requestSpy = vi + .spyOn(service as any, "request") + .mockImplementation(async (path: any, init: any, params: any) => { + const result = handler( + init?.method ?? "GET", + path, + init?.body ? JSON.parse(init.body) : undefined, + params + ); + if (result instanceof Error) throw result; + return result; + }); + const calls = () => + requestSpy.mock.calls.map(([path, init]: any[]) => `${init?.method ?? "GET"} ${path}`); + const bodies = () => + requestSpy.mock.calls.map(([, init]: any[]) => + init?.body ? JSON.parse(init.body) : undefined + ); + return { service, requestSpy, calls, bodies }; +}; + +describe("vaults service", () => { + test("the client exposes vaults", () => { + expect(new Hyperbrowser({ apiKey: "k" }).vaults).toBeInstanceOf(VaultsService); + expectTypeOf().toEqualTypeOf>(); + }); + + test("create sends a stored vault in the API's shape", async () => { + const { service, calls, bodies } = mockService(() => stored()); + const vault = await service.create({ + name: "github-login", + description: "GitHub bot", + entries: { + USERNAME: { name: "github-username" }, + PASSWORD: { name: "github-password", version: 3 }, + OTP: { name: "github-otp", version: "latest" }, + }, + }); + expect(calls()).toEqual(["POST /vaults"]); + expect(bodies()[0]).toEqual({ + name: "github-login", + description: "GitHub bot", + entries: { + USERNAME: { name: "github-username" }, + PASSWORD: { name: "github-password", version: 3 }, + OTP: { name: "github-otp", version: "latest" }, + }, + }); + expect(vault).toEqual(stored()); + }); + + test("a vault with a stray source still parses", async () => { + const withSource = { ...stored(), source: { type: "stored" } }; + const { service } = mockService((_method, path) => + path === "/vaults" ? listOf(withSource) : withSource + ); + await expect(service.get(ID)).resolves.toMatchObject({ entries: stored().entries }); + await expect(service.list()).resolves.toMatchObject({ + vaults: [{ name: "github-login", entries: stored().entries }], + }); + }); + + test("names resolve by exact match through a search; IDs resolve directly", async () => { + const { service, calls, requestSpy } = mockService((_method, path, _body, params) => { + if (path === "/vaults") { + expect(params).toMatchObject({ search: "github-login", limit: 100 }); + return listOf(stored({ name: "github-login-old", id: "other" }), stored()); + } + return stored(); + }); + await expect(service.get("github-login")).resolves.toMatchObject({ id: ID }); + await expect(service.get(` ${ID} `)).resolves.toMatchObject({ name: "github-login" }); + expect(calls()).toEqual(["GET /vaults", `GET /vaults/${ID}`]); + expect(requestSpy).toHaveBeenCalledTimes(2); + }); + + test("a UUID-shaped name falls back to a name search after a 404", async () => { + const { service, calls } = mockService((_method, path) => { + if (path === `/vaults/${UUID_NAME}`) return notFound(); + return listOf(stored({ name: UUID_NAME })); + }); + await expect(service.get(UUID_NAME)).resolves.toMatchObject({ name: UUID_NAME }); + expect(calls()).toEqual([`GET /vaults/${UUID_NAME}`, "GET /vaults"]); + }); + + test("a missing vault is VAULT_NOT_FOUND", async () => { + const { service } = mockService(() => listOf(stored({ name: "github-login-2" }))); + await expect(service.delete("github-login")).rejects.toMatchObject({ + statusCode: 404, + code: "VAULT_NOT_FOUND", + }); + }); + + test("update and delete use the resolved ID", async () => { + const { service, calls, bodies } = mockService((method, path) => { + if (path === "/vaults") return listOf(stored()); + if (method === "DELETE") return { deleted: true, id: ID, name: "github-login" }; + return stored(); + }); + await service.update("github-login", { + description: "new", + entries: { OTP: { name: "github-otp" }, PASSWORD: null }, + }); + await service.update("github-login", { entries: { OTP: { name: "github-otp", version: 2 } } }); + await expect(service.delete("github-login")).resolves.toEqual({ + deleted: true, + id: ID, + name: "github-login", + }); + expect(calls()).toEqual([ + "GET /vaults", + `PATCH /vaults/${ID}`, + "GET /vaults", + `PATCH /vaults/${ID}`, + "GET /vaults", + `DELETE /vaults/${ID}`, + ]); + expect(bodies()[1]).toEqual({ + description: "new", + entries: { OTP: { name: "github-otp" }, PASSWORD: null }, + }); + expect(bodies()[3]).toEqual({ entries: { OTP: { name: "github-otp", version: 2 } } }); + }); + + test("a description is limited after trimming, as the API does", async () => { + const { service, bodies } = mockService((_method, path) => + path === "/vaults" ? listOf(stored()) : stored() + ); + const description = `${"d".repeat(1024)} `; + await service.update("github-login", { description }); + expect(bodies()[1]).toEqual({ description }); + }); + + test("list passes pagination and search", async () => { + const { service, requestSpy } = mockService(() => listOf(stored())); + await service.list({ page: 2, limit: 10, search: "github" }); + expect(requestSpy.mock.calls[0][2]).toEqual({ search: "github", page: 2, limit: 10 }); + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + test("API errors pass through with their code", async () => { + const taken = new HyperbrowserError("A vault with this name already exists", { + statusCode: 409, + code: "VAULT_NAME_TAKEN", + }); + const { service } = mockService(() => taken); + await expect(service.create({ name: "github-login" })).rejects.toBe(taken); + }); +});