From 1c2f1a0e60ef95bd51d2cbba7bff6ff7a681373b Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 16:18:42 -0700 Subject: [PATCH 01/23] Add credential secrets, session credential fill, and a fill_credential tool Website logins are stored as typed secrets and filled into browser sessions without the caller or a model seeing the values. Secrets: - Secret gains type ("secret" | "credential") and metadata ({ browser: { allowedOrigins } }). create accepts type and metadata, and list filters by type. - Credential values are objects of non-empty strings with keys matching ^[a-zA-Z0-9_.-]{1,64}$, at most 32 KiB as UTF-8 JSON. They are checked locally (invalid_secret_value) before the key is fetched or anything is created, and sealed as JSON to the encryption context the API returns. - create and addVersion require an object for credentials and a string or bytes for plain secrets; addVersion checks against the resolved type. - update sends metadata with the API's merge semantics (null removes a member). - New createCredential({ name, value, allowedOrigins, description? }) and setAllowedOrigins(secret, origins | null). Sessions: - CreateSessionParams.credentials and SessionDetail.credentials. - sessions.credentials.fill(sessionId, { credential, fields, pageId?, frame? }) posts to /session/:id/credentials/fill with a request timeout of at least 30 s (the server's own is about 18 s). Requests the API would reject fail locally with invalid_request. Errors keep the server's code and retryable flag, and expose the body's details (such as details.pages for CREDENTIAL_PAGE_AMBIGUOUS) as error.details. - credential_filled and credential_fill_refused event log types. Tools (@hyperbrowser/sdk/tools): - FILL_CREDENTIAL_SCHEMA (OpenAI strict compatible) with OpenAI, Anthropic and Gemini definitions, and CredentialFillTool whose runnable takes the session ID from the application, maps the model's snake_case input to the API, and returns the result or the error as JSON text. - A gemini module with its own FunctionDeclaration type and a converter from the SDK's JSON Schemas to Gemini's schema form (nullable instead of type arrays, no additionalProperties). Co-Authored-By: Claude Opus 5.5 --- src/error.ts | 4 + src/secrets-envelope.ts | 53 +++- src/services/credentials.ts | 120 +++++++++ src/services/secrets.ts | 161 ++++++++++-- src/services/sessions.ts | 3 + src/tools/anthropic.ts | 8 + src/tools/gemini.ts | 125 +++++++++ src/tools/index.ts | 72 +++++- src/tools/openai.ts | 12 + src/tools/schema.ts | 62 +++++ src/types/constants.ts | 4 +- src/types/credential.ts | 65 +++++ src/types/index.ts | 15 ++ src/types/secret.ts | 61 ++++- src/types/session.ts | 9 + tests/unit/credentials-fill.test.ts | 378 ++++++++++++++++++++++++++++ tests/unit/secrets-contract.test.ts | 226 +++++++++++++++++ 17 files changed, 1353 insertions(+), 25 deletions(-) create mode 100644 src/services/credentials.ts create mode 100644 src/tools/gemini.ts create mode 100644 src/types/credential.ts create mode 100644 tests/unit/credentials-fill.test.ts 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/secrets-envelope.ts b/src/secrets-envelope.ts index e4307a4..a3f8648 100644 --- a/src/secrets-envelope.ts +++ b/src/secrets-envelope.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; import { HyperbrowserError } from "./error"; -import { SecretsPublicKey, SecretValue } from "./types/secret"; +import { CredentialValue, SecretsPublicKey, SecretValue } from "./types/secret"; /** The largest secret value the platform accepts. */ export const MAX_SECRET_VALUE_BYTES = 32 * 1024; @@ -39,6 +39,57 @@ export const secretValueBytes = (value: SecretValue): Buffer => { return bytes; }; +/** Credential field names: the keys a fill can write. */ +export const CREDENTIAL_FIELD_PATTERN = /^[a-zA-Z0-9_.-]{1,64}$/; + +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? => { + if (value === null || typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Apply the platform's credential rules locally and return the UTF-8 JSON + * that is encrypted. Messages name fields, never values. + */ +export const credentialValueBytes = (value: CredentialValue): Buffer => { + if (!isPlainObject(value)) { + throw invalid("Credential value must be an object of field names to strings"); + } + const fields = Object.keys(value); + if (fields.length === 0) { + throw invalid("Credential value has no fields"); + } + for (const field of fields) { + if (!CREDENTIAL_FIELD_PATTERN.test(field)) { + throw invalid( + `Credential field name ${JSON.stringify(field.slice(0, 64))} must be 1-64 letters, digits, '_', '.' or '-'` + ); + } + const fieldValue: unknown = value[field]; + if (typeof fieldValue !== "string") { + throw invalid(`Credential field ${field} must be a string`); + } + if (fieldValue.length === 0) { + throw invalid(`Credential field ${field} is empty`); + } + if (fieldValue.includes("\0")) { + throw invalid(`Credential field ${field} must not contain NUL characters`); + } + if (LONE_SURROGATE.test(fieldValue)) { + throw invalid(`Credential field ${field} must be valid UTF-8 text`); + } + } + const bytes = Buffer.from(JSON.stringify(value), "utf8"); + if (bytes.length > MAX_SECRET_VALUE_BYTES) { + throw invalid(`Credential value is more than ${MAX_SECRET_VALUE_BYTES} bytes as JSON`); + } + return bytes; +}; + 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..df0f658 --- /dev/null +++ b/src/services/credentials.ts @@ -0,0 +1,120 @@ +import { HyperbrowserError, invalidRequest, ResponseDecodingError } from "../error"; +import { CREDENTIAL_FIELD_PATTERN, isPlainObject } from "../secrets-envelope"; +import { CredentialFillResult, FillCredentialParams } from "../types/credential"; +import { BaseService } from "./base"; + +const MAX_FIELDS = 16; +const MAX_SELECTOR_BYTES = 1024; +const MAX_PAGE_ID_BYTES = 128; +const MAX_FRAME_URL_BYTES = 2048; +/** The API gives up on a fill after about 18 seconds. */ +const MIN_FILL_TIMEOUT_MS = 30_000; + +const isNonEmptyString = (value: unknown, maxBytes: number): value is string => + typeof value === "string" && value.length > 0 && Buffer.byteLength(value, "utf8") <= maxBytes; + +const fillBody = (params: FillCredentialParams): string => { + if (!isPlainObject(params)) { + throw invalidRequest("Fill parameters must be an object"); + } + const { credential, fields, pageId, frame } = params; + if (typeof credential !== "string" || !credential.trim()) { + throw invalidRequest("credential must be the name or ID of a credential"); + } + if (!Array.isArray(fields) || fields.length < 1 || fields.length > MAX_FIELDS) { + throw invalidRequest(`fields must list 1-${MAX_FIELDS} { field, selector } pairs`); + } + const seen = new Set(); + const wireFields = fields.map((entry, index) => { + if (!isPlainObject(entry)) { + throw invalidRequest(`fields[${index}] must be a { field, selector } object`); + } + const { field, selector } = entry; + if (typeof field !== "string" || !CREDENTIAL_FIELD_PATTERN.test(field)) { + throw invalidRequest(`fields[${index}].field must be 1-64 letters, digits, '_', '.' or '-'`); + } + if (seen.has(field)) { + throw invalidRequest(`fields lists ${field} more than once`); + } + seen.add(field); + if (!isNonEmptyString(selector, MAX_SELECTOR_BYTES) || !selector.trim()) { + throw invalidRequest( + `fields[${index}].selector must be a CSS selector of 1-${MAX_SELECTOR_BYTES} bytes` + ); + } + return { field, selector }; + }); + if (pageId !== undefined && pageId !== null && !isNonEmptyString(pageId, MAX_PAGE_ID_BYTES)) { + throw invalidRequest(`pageId must be a page ID of 1-${MAX_PAGE_ID_BYTES} bytes`); + } + let wireFrame: { url: string } | undefined; + if (frame !== undefined && frame !== null) { + if (!isPlainObject(frame) || !isNonEmptyString(frame.url, MAX_FRAME_URL_BYTES)) { + throw invalidRequest( + `frame.url must be a URL or URL prefix of 1-${MAX_FRAME_URL_BYTES} bytes` + ); + } + wireFrame = { url: frame.url }; + } + return JSON.stringify({ + credential, + fields: wireFields, + pageId: pageId ?? undefined, + frame: wireFrame, + }); +}; + +/** + * The fill route's errors carry their data in the body's `details`, such as + * `details.pages` for `CREDENTIAL_PAGE_AMBIGUOUS`; surface that as the error's + * `details` rather than the whole body. + */ +const withServerDetails = (error: unknown): unknown => { + if ( + !(error instanceof HyperbrowserError) || + error instanceof ResponseDecodingError || + !isPlainObject(error.details) + ) { + return error; + } + const details = error.details.details; + return new HyperbrowserError(error.message.replace(/^\[Hyperbrowser\]: /, ""), { + statusCode: error.statusCode, + code: error.code, + requestId: error.requestId, + retryable: error.retryable, + service: error.service, + details: details === null ? undefined : details, + cause: error.cause ?? error, + method: error.method, + path: error.path, + }); +}; + +/** Fill credentials attached to a browser session into its pages. */ +export class CredentialsService extends BaseService { + /** + * Type a stored credential's fields into inputs on one of the session's + * pages. Only types; never submits, so click the login button or press + * Enter afterwards. Values never leave the platform: the result lists an + * outcome per field. Safe to retry; it is not retried automatically. + */ + async fill(sessionId: string, params: FillCredentialParams): Promise { + if (typeof sessionId !== "string" || !sessionId.trim()) { + throw invalidRequest("A session ID is required"); + } + const body = fillBody(params); + try { + return await this.request( + `/session/${encodeURIComponent(sessionId)}/credentials/fill`, + { + method: "POST", + body, + timeout: Math.max(this.timeout, MIN_FILL_TIMEOUT_MS), + } + ); + } catch (error) { + throw withServerDetails(error); + } + } +} diff --git a/src/services/secrets.ts b/src/services/secrets.ts index 3d2553f..282be0c 100644 --- a/src/services/secrets.ts +++ b/src/services/secrets.ts @@ -1,14 +1,22 @@ -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 { + credentialValueBytes, + isPlainObject, + secretValueBytes, + SecretsSealer, +} from "../secrets-envelope"; +import { + CreateCredentialParams, CreateSecretParams, + CredentialValue, Secret, SecretDeleteResult, SecretDetail, SecretListParams, SecretListResponse, SecretsPublicKey, + SecretType, SecretValue, SecretVersion, SecretVersionDeleteResult, @@ -24,6 +32,10 @@ const NAME_LOOKUP_LIMIT = MAX_LIST_LIMIT; const secretPath = (id: string): string => `/secrets/${encodeURIComponent(id)}`; +const SECRET_TYPES: readonly SecretType[] = ["secret", "credential"]; +/** The most allowed origins a credential can have. */ +const MAX_ALLOWED_ORIGINS = 20; + const rethrow = (error: unknown, message: string): never => { if (error instanceof HyperbrowserError) { throw error; @@ -31,6 +43,63 @@ const rethrow = (error: unknown, message: string): never => { throw new HyperbrowserError(message, { cause: error }); }; +const checkType = (type: unknown): void => { + if (type !== undefined && !SECRET_TYPES.includes(type as SecretType)) { + throw invalidRequest(`Secret type must be one of ${SECRET_TYPES.join(", ")}`); + } +}; + +/** Light checks only; the API normalizes origins and reports bad entries. */ +const checkAllowedOrigins = (allowedOrigins: unknown): void => { + if ( + !Array.isArray(allowedOrigins) || + allowedOrigins.length < 1 || + allowedOrigins.length > MAX_ALLOWED_ORIGINS || + allowedOrigins.some((origin) => typeof origin !== "string" || !origin.trim()) + ) { + throw invalidRequest( + `allowedOrigins must be 1-${MAX_ALLOWED_ORIGINS} origins such as "https://example.com"` + ); + } +}; + +const isBytesOrString = (value: unknown): value is SecretValue => + typeof value === "string" || value instanceof Uint8Array; + +const wrongShape = (message: string): HyperbrowserError => + new HyperbrowserError(message, { code: "invalid_secret_value", retryable: false }); + +/** + * Check a value against the platform rules for either shape, so a bad value + * is reported before anything is fetched or created. + */ +const valueBytes = (value: SecretValue | CredentialValue): Buffer => { + if (isBytesOrString(value)) { + return secretValueBytes(value); + } + if (isPlainObject(value)) { + return credentialValueBytes(value as CredentialValue); + } + throw wrongShape( + "Secret value must be a string or bytes, or for a credential an object of field names to strings" + ); +}; + +/** The plaintext stored for a secret of `type`: raw bytes, or a credential's UTF-8 JSON. */ +const versionPlaintext = (type: SecretType, value: SecretValue | CredentialValue): Buffer => { + if (type === "credential" && isBytesOrString(value)) { + throw wrongShape( + "A credential's value must be an object of field names to strings, such as { username, password }" + ); + } + if (type !== "credential" && !isBytesOrString(value)) { + throw wrongShape( + 'A secret\'s value must be a string or bytes; create a type "credential" secret for an object of fields' + ); + } + return valueBytes(value); +}; + /** * Secrets are encrypted locally to the platform's public key; the API only * ever receives envelopes, and values are never returned. @@ -60,11 +129,13 @@ export class SecretsService extends BaseService { ) { throw new HyperbrowserError(`limit must be an integer from 1 to ${MAX_LIST_LIMIT}`); } + checkType(params.type); 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"); @@ -77,29 +148,35 @@ export class SecretsService extends BaseService { * bad value never leaves an empty secret behind. */ async create(params: CreateSecretParams): Promise { - const { name, description, value } = params ?? ({} as CreateSecretParams); + const { name, description, type, metadata, 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); + checkType(type); + if (metadata !== undefined && !isPlainObject(metadata)) { + throw invalidRequest("Secret metadata must be an object"); + } + const plaintext = value === undefined ? undefined : versionPlaintext(type ?? "secret", value); + const sealer = + plaintext === undefined ? undefined : SecretsSealer.fromPublicKey(await this.getPublicKey()); 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 +205,78 @@ 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. */ - async addVersion(secret: string, value: SecretValue): Promise { - const sealer = await this.prepareSealer(value); + /** + * Encrypt `value` locally and store it as the secret's next version: a + * `CredentialValue` object for credentials, a string or bytes otherwise. + */ + async addVersion(secret: string, value: SecretValue | CredentialValue): Promise { + valueBytes(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 = versionPlaintext(resolved.type ?? "secret", value); + return await this.uploadVersion(resolved, sealer, plaintext); } catch (error) { return rethrow(error, `Failed to add a version to secret ${secret}`); } } + /** + * Create a website login credential that browser sessions can fill into + * pages on `allowedOrigins`, and store its first value. The value is checked + * and encrypted locally; it is never returned. + */ + async createCredential(params: CreateCredentialParams): Promise { + const { name, value, allowedOrigins, description } = params ?? ({} as CreateCredentialParams); + if (value === undefined || value === null) { + throw wrongShape("A credential needs a value, such as { username, password }"); + } + checkAllowedOrigins(allowedOrigins); + return this.create({ + name, + description, + type: "credential", + metadata: { browser: { allowedOrigins } }, + value, + }); + } + + /** + * Replace the origins a credential may be filled into, or clear them with + * `null` (the credential then can't be filled until origins are set again). + */ + async setAllowedOrigins(secret: string, allowedOrigins: string[] | null): Promise { + if (allowedOrigins !== null) { + checkAllowedOrigins(allowedOrigins); + } + 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 +308,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/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..a3d6c0b --- /dev/null +++ b/src/tools/gemini.ts @@ -0,0 +1,125 @@ +import { FILL_CREDENTIAL_DESCRIPTION, FILL_CREDENTIAL_SCHEMA } from "./schema"; + +/** Gemini's schema types, as `@google/genai`'s `Type` enum spells them. */ +export type GeminiType = "STRING" | "NUMBER" | "INTEGER" | "BOOLEAN" | "ARRAY" | "OBJECT"; + +/** + * Gemini's OpenAPI-subset schema: no `additionalProperties`, and `nullable` + * instead of type arrays. Integer limits are strings, as in the API. + */ +export interface GeminiSchema { + /** + * A `GeminiType`. Typed loosely so declarations can be passed where + * `@google/genai` expects its `Type` enum, which string literals can't be + * assigned to. + */ + type?: any; + description?: string; + nullable?: boolean; + enum?: string[]; + format?: string; + title?: string; + default?: unknown; + properties?: Record; + required?: string[]; + items?: GeminiSchema; + minItems?: string; + maxItems?: string; + minLength?: string; + maxLength?: string; + minimum?: number; + maximum?: number; + pattern?: string; +} + +/** A function declaration for Gemini: `tools: [{ functionDeclarations: [...] }]`. */ +export interface FunctionDeclaration { + name: string; + description?: string; + parameters?: GeminiSchema; +} + +const GEMINI_TYPES: Record = { + string: "STRING", + number: "NUMBER", + integer: "INTEGER", + boolean: "BOOLEAN", + array: "ARRAY", + object: "OBJECT", +}; + +/** Keywords Gemini accepts unchanged. Anything else is dropped. */ +const COPIED_KEYWORDS = [ + "description", + "enum", + "format", + "title", + "default", + "required", + "minimum", + "maximum", + "pattern", +] as const; +/** Integer keywords Gemini takes as strings (int64). */ +const INTEGER_KEYWORDS = ["minItems", "maxItems", "minLength", "maxLength"] as const; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Convert one of the SDK's JSON Schemas (as used for OpenAI and Anthropic) to + * Gemini's schema form: `["string", "null"]` becomes `"STRING"` with + * `nullable: true`, and `additionalProperties` is dropped. + */ +export function toGeminiSchema(schema: Record): GeminiSchema { + if (!isRecord(schema)) { + throw new TypeError("A JSON Schema object is required"); + } + const result: GeminiSchema = {}; + if (schema.type !== undefined) { + const types: unknown[] = Array.isArray(schema.type) ? schema.type : [schema.type]; + const nonNull = types.filter((type) => type !== "null"); + if (nonNull.length !== 1 || typeof nonNull[0] !== "string" || !GEMINI_TYPES[nonNull[0]]) { + throw new TypeError(`Unsupported schema type for Gemini: ${JSON.stringify(schema.type)}`); + } + result.type = GEMINI_TYPES[nonNull[0]]; + if (nonNull.length < types.length) { + result.nullable = true; + } + } + if (schema.nullable === true) { + result.nullable = true; + } + for (const keyword of COPIED_KEYWORDS) { + if (schema[keyword] !== undefined) { + (result as Record)[keyword] = schema[keyword]; + } + } + for (const keyword of INTEGER_KEYWORDS) { + if (schema[keyword] !== undefined) { + result[keyword] = String(schema[keyword]); + } + } + if (isRecord(schema.properties)) { + result.properties = Object.fromEntries( + Object.entries(schema.properties).map(([name, property]) => [name, toGeminiSchema(property)]) + ); + } + if (isRecord(schema.items)) { + result.items = toGeminiSchema(schema.items); + } + return result; +} + +/** Build a Gemini function declaration from a JSON Schema tool definition. */ +export const toGeminiFunctionDeclaration = ( + name: string, + description: string, + parameters: Record +): FunctionDeclaration => ({ name, description, parameters: toGeminiSchema(parameters) }); + +export const FILL_CREDENTIAL_TOOL_GEMINI: FunctionDeclaration = toGeminiFunctionDeclaration( + "fill_credential", + FILL_CREDENTIAL_DESCRIPTION, + FILL_CREDENTIAL_SCHEMA +); diff --git a/src/tools/index.ts b/src/tools/index.ts index 76b7692..bee6e8a 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,20 @@ import { StartClaudeComputerUseTaskParams, } from "../types"; import { StartExtractJobParams } from "../types/extract"; +import { FillCredentialParams } from "../types/credential"; + +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`. */ +export interface CredentialFillToolParams { + credential: string; + fields: { field: string; selector: string }[]; + page_id?: string | null; + frame_url?: string | null; +} export class WebsiteScrapeTool { static openaiToolDefinition = SCRAPE_TOOL_OPENAI; @@ -113,3 +130,56 @@ export class OpenAICuaTool { return resp.data?.finalResult || resp.error || ""; } } + +/** + * Lets a model fill a credential attached to a browser session. The + * application binds the session; the model chooses only the credential name, + * fields and selectors, and never sees values. + */ +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. + const input: Partial = params ?? {}; + const request: FillCredentialParams = { + credential: input.credential as string, + fields: (Array.isArray(input.fields) + ? input.fields.map((entry) => ({ field: entry?.field, selector: entry?.selector })) + : input.fields) as FillCredentialParams["fields"], + }; + if (input.page_id !== undefined && input.page_id !== null) { + request.pageId = input.page_id; + } + if (input.frame_url !== undefined && input.frame_url !== null) { + request.frame = { url: 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: error.details ?? 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..74650b6 100644 --- a/src/tools/schema.ts +++ b/src/tools/schema.ts @@ -259,3 +259,65 @@ export const OPENAI_CUA_SCHEMA = { required: ["task", "sessionOptions"], additionalProperties: false, }; + +export const FILL_CREDENTIAL_DESCRIPTION = `Fill a stored login credential into the browser session's current page by typing its values into input fields you choose. You never see the values: refer to the credential by its name and to each stored field (such as "username" or "password") by its key, and give a CSS selector for the input each one goes into. + +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 fields 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 field: +- "filled": the input now holds the stored 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_populated": the stored credential has no such field. Use a field it does have, or tell the user what is missing. +- "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 field 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, taken from details.pages. CREDENTIAL_ORIGIN_NOT_ALLOWED means this credential may not be used on the current site; nothing was typed, so do not try to work around it, and tell the user. CREDENTIAL_FRAME_NOT_FOUND means no iframe matched frame_url. If retryable is true, you may try the same call again.`; + +export const FILL_CREDENTIAL_SCHEMA = { + type: "object" as const, + properties: { + credential: { + type: "string", + description: + 'Name of the stored login credential to fill, for example "github". A credential is a saved login (such as a username and password) that you cannot see; you only refer to it by name.', + }, + fields: { + type: "array", + description: + "Which stored field to type into which input, one entry per input on the current page. Each stored field at most once.", + minItems: 1, + maxItems: 16, + items: { + type: "object", + properties: { + field: { + type: "string", + description: + 'Key of the stored credential value to type, such as "username", "email" or "password".', + }, + 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]".', + }, + }, + required: ["field", "selector"], + additionalProperties: false, + }, + }, + page_id: { + type: ["string", "null"], + description: + "Usually null. Set it only after a previous call failed with CREDENTIAL_PAGE_AMBIGUOUS because several tabs could be the target: use the pageId of the tab with the login form from that error's details.pages.", + }, + frame_url: { + type: ["string", "null"], + description: + '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", "fields", "page_id", "frame_url"], + additionalProperties: false, +}; diff --git a/src/types/constants.ts b/src/types/constants.ts index e8ecf84..108dd14 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -2,7 +2,9 @@ export type SessionEventLogType = | "captcha_detected" | "captcha_solved" | "captcha_error" - | "file_downloaded"; + | "file_downloaded" + | "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..d0f10e5 --- /dev/null +++ b/src/types/credential.ts @@ -0,0 +1,65 @@ +/** A credential attached to a browser session. */ +export interface SessionCredential { + id: string; + name: string; +} + +/** Which stored field to type into which input. */ +export interface CredentialFillField { + /** A key of the credential's value, such as `username`; `^[a-zA-Z0-9_.-]{1,64}$`. */ + field: string; + /** A CSS selector for the input, 1-1024 UTF-8 bytes. */ + selector: string; +} + +export interface FillCredentialParams { + /** The credential's name (or ID); it must be attached to the session. */ + credential: string; + /** 1-16 fields, each field name at most once. */ + fields: CredentialFillField[]; + /** + * The tab to fill (a CDP target ID). Needed when several tabs could be the + * target; see `CREDENTIAL_PAGE_AMBIGUOUS`. + */ + pageId?: string; + /** Fill inside the iframe whose URL starts with `url`. */ + frame?: { url: string }; +} + +/** + * - `filled`: the input holds exactly the value. + * - `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`). + * - `not_populated`: the stored value has no such field. + */ +export type CredentialFillOutcome = + | "filled" + | "not_found" + | "ambiguous" + | "not_editable" + | "not_accepted" + | "not_populated" + | (string & {}); + +export interface CredentialFillFieldResult { + field: string; + outcome: CredentialFillOutcome; +} + +/** Outcomes per field; never values. */ +export interface CredentialFillResult { + /** The credential version that was filled. */ + 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..4a45bd8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -273,6 +273,12 @@ export { } from "./volume"; export { SecretValue, + SecretType, + CredentialValue, + SecretBrowserMetadata, + SecretMetadata, + CreateCredentialParams, + UpdateSecretMetadata, SecretsPublicKey, SecretsPublicKeyJwk, Secret, @@ -285,6 +291,15 @@ export { SecretDeleteResult, SecretVersionDeleteResult, } from "./secret"; +export { + SessionCredential, + CredentialFillField, + FillCredentialParams, + CredentialFillOutcome, + CredentialFillFieldResult, + CredentialFillResult, + CredentialFillPage, +} from "./credential"; export { CreateProfileParams, ForkProfileParams, diff --git a/src/types/secret.ts b/src/types/secret.ts index d15e6d8..8530b33 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; +/** + * `"secret"` values are delivered to sandboxes; `"credential"` values are + * website logins filled into browser sessions. Fixed at creation. + */ +export type SecretType = "secret" | "credential"; + +/** + * A credential's value: field names (such as `username` and `password`) to + * non-empty strings. Keys match `^[a-zA-Z0-9_.-]{1,64}$`. + */ +export type CredentialValue = Record; + +export interface SecretBrowserMetadata { + /** + * Origins a credential may be filled into, as `scheme://host[:port]`. A + * leading `*.` matches any subdomain (not the domain itself). + */ + allowedOrigins: string[]; +} + +/** Plain secrets always have `{}`. */ +export interface SecretMetadata { + browser?: SecretBrowserMetadata; + [member: string]: unknown; +} + export interface SecretsPublicKeyJwk { kty: string; n: string; @@ -19,6 +45,8 @@ export interface Secret { id: string; name: string; description: string; + type: SecretType; + 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 +69,46 @@ export interface CreateSecretParams { /** 1-128 letters, digits, '_' or '-', starting with a letter or digit. */ name: string; description?: string; + /** Defaults to `"secret"`. */ + type?: SecretType; + /** For credentials, `{ browser: { allowedOrigins } }`. */ + metadata?: SecretMetadata; /** - * The first version's value. It is validated and the platform key is checked + * The first version's value: a `CredentialValue` object for credentials, a + * string or bytes otherwise. It is validated and the platform key is checked * before the secret is created, then encrypted locally and uploaded. */ - value?: SecretValue; + value?: SecretValue | CredentialValue; } +export interface CreateCredentialParams { + /** 1-128 letters, digits, '_' or '-', starting with a letter or digit. */ + name: string; + /** Field names to values, such as `{ username, password }`. */ + value: CredentialValue; + /** Origins the credential may be filled into, such as `["https://github.com"]`. */ + allowedOrigins: string[]; + description?: 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..719f157 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -10,6 +10,7 @@ import { SessionRegion, State, } from "./constants"; +import { SessionCredential } 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; + /** Credentials attached at creation, when there are any. */ + credentials?: SessionCredential[]; } export interface SessionGetParams { @@ -265,6 +268,12 @@ export interface CreateSessionParams { disablePostQuantumKeyAgreement?: boolean; browserMemorySize?: BrowserMemorySize; startFromSnapshot?: StartSessionFromSnapshotParams; + /** + * Names of credentials (at most 20) that `sessions.credentials.fill` may type + * into pages. Credentials can only be attached at creation; the session runs + * on Firecracker with Chrome's password manager off. + */ + credentials?: string[]; } export interface CreateSessionSnapshotResponse { diff --git a/tests/unit/credentials-fill.test.ts b/tests/unit/credentials-fill.test.ts new file mode 100644 index 0000000..31b5a16 --- /dev/null +++ b/tests/unit/credentials-fill.test.ts @@ -0,0 +1,378 @@ +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, + toGeminiSchema, +} 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 = { + version: 3, + pageId: "TARGET-1", + origin: "https://github.com", + fields: [ + { field: "username", outcome: "filled" }, + { field: "password", outcome: "not_found" }, + ], +}; +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 fields = [ + { field: "username", selector: "#login_field" }, + { field: "password", selector: "#password" }, +]; + +afterEach(() => fetchMock.mockReset()); + +describe("sessions", () => { + test("credentials are sent on create and returned on the session", async () => { + const attached = [{ id: "c1", name: "github" }]; + fetchMock.mockResolvedValue(respond({ id: "session", credentials: attached })); + const params: CreateSessionParams = { credentials: ["github"] }; + const session = await client().sessions.create(params); + expect(lastRequest().body).toEqual({ credentials: ["github"] }); + expect(session.credentials).toEqual(attached); + expectTypeOf().toEqualTypeOf< + { id: string; name: string }[] | undefined + >(); + }); + + test("fill posts to the session's route with a timeout above the server's", async () => { + fetchMock.mockResolvedValue(respond(filled)); + const result = await client(1000).sessions.credentials.fill(SESSION, { + credential: "github", + fields, + pageId: "TARGET-1", + frame: { url: "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({ + credential: "github", + fields, + pageId: "TARGET-1", + frame: { url: "https://github.com/" }, + }); + expect(result).toEqual(filled); + expectTypeOf(result.fields[0].outcome).toEqualTypeOf(); + }); + + test("fill keeps a longer client timeout and omits absent options", async () => { + fetchMock.mockResolvedValue(respond(filled)); + await client(90_000).sessions.credentials.fill(SESSION, { credential: "github", fields }); + const { init, body } = lastRequest(); + expect(init.timeout).toBe(90_000); + expect(body).toEqual({ credential: "github", fields }); + }); + + test("unknown outcomes pass through", async () => { + fetchMock.mockResolvedValue( + respond({ version: 1, fields: [{ field: "otp", outcome: "not_visible_yet" }] }) + ); + const result = await client().sessions.credentials.fill(SESSION, { + credential: "github", + fields: [{ field: "otp", selector: "#otp" }], + }); + expect(result.fields[0].outcome).toBe("not_visible_yet"); + }); + + test("errors carry the server's code, retryable flag and details", async () => { + fetchMock.mockResolvedValue(respond(ambiguous, 409)); + const error = await client() + .sessions.credentials.fill(SESSION, { credential: "github", fields }) + .catch((e) => e); + expect(error).toBeInstanceOf(HyperbrowserError); + expect(error).toMatchObject({ + statusCode: 409, + code: "CREDENTIAL_PAGE_AMBIGUOUS", + retryable: false, + details: ambiguous.details, + }); + expect(error.message).toContain("Several pages could be the target"); + expect(fetchMock).toHaveBeenCalledTimes(1); + + fetchMock.mockResolvedValue( + respond({ code: "CREDENTIAL_FILL_TIMEOUT", message: "timed out", retryable: true }, 504) + ); + await expect( + client().sessions.credentials.fill(SESSION, { credential: "github", fields }) + ).rejects.toMatchObject({ + code: "CREDENTIAL_FILL_TIMEOUT", + retryable: true, + details: undefined, + }); + }); + + test.each([ + ["no credential", { credential: "", fields }], + ["no fields", { credential: "github", fields: [] }], + [ + "17 fields", + { + credential: "github", + fields: Array.from({ length: 17 }, (_, i) => ({ field: `f${i}`, selector: "#x" })), + }, + ], + [ + "a repeated field", + { credential: "github", fields: [fields[0], { field: "username", selector: "#other" }] }, + ], + [ + "a bad field name", + { credential: "github", fields: [{ field: "user name", selector: "#u" }] }, + ], + ["an empty selector", { credential: "github", fields: [{ field: "username", selector: "" }] }], + [ + "a selector over 1024 bytes", + { credential: "github", fields: [{ field: "username", selector: "é".repeat(513) }] }, + ], + ["an empty page ID", { credential: "github", fields, pageId: "" }], + ["a page ID over 128 bytes", { credential: "github", fields, pageId: "p".repeat(129) }], + ["a frame without a URL", { credential: "github", fields, frame: {} }], + [ + "a frame URL over 2048 bytes", + { credential: "github", fields, frame: { url: `https://a.test/${"x".repeat(2040)}` } }, + ], + ])("fill rejects %s before any request", async (_name, params) => { + await expect(client().sessions.credentials.fill(SESSION, params as any)).rejects.toMatchObject({ + code: "invalid_request", + retryable: false, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("fill requires a session ID", async () => { + await expect( + client().sessions.credentials.fill("", { credential: "github", fields }) + ).rejects.toMatchObject({ code: "invalid_request" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("the longest allowed selector, page ID and frame URL are accepted", async () => { + fetchMock.mockResolvedValue(respond(filled)); + await client().sessions.credentials.fill(SESSION, { + credential: "github", + fields: [{ field: "username", selector: "é".repeat(512) }], + pageId: "p".repeat(128), + frame: { url: "u".repeat(2048) }, + }); + 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", () => { + 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", + "fields", + "page_id", + "frame_url", + ]); + expect(FILL_CREDENTIAL_SCHEMA.properties.fields).toMatchObject({ minItems: 1, maxItems: 16 }); + expect(FILL_CREDENTIAL_SCHEMA.properties.page_id.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", + "filled", + "not_found", + "ambiguous", + "not_editable", + "not_accepted", + "not_populated", + "CREDENTIAL_PAGE_AMBIGUOUS", + "page_id", + ]) { + expect(description).toContain(word); + } + }); + + 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: { type: "STRING", description: expect.any(String) }, + fields: { + type: "ARRAY", + description: expect.any(String), + minItems: "1", + maxItems: "16", + items: { + type: "OBJECT", + properties: { + field: { type: "STRING", description: expect.any(String) }, + selector: { type: "STRING", description: expect.any(String) }, + }, + required: ["field", "selector"], + }, + }, + page_id: { type: "STRING", nullable: true, description: expect.any(String) }, + frame_url: { type: "STRING", nullable: true, description: expect.any(String) }, + }, + required: ["credential", "fields", "page_id", "frame_url"], + }); + }); + + test("the Gemini converter refuses schemas it can't express", () => { + expect(() => toGeminiSchema({ type: ["string", "number"] })).toThrow(/Unsupported/); + expect(toGeminiSchema({ type: "integer", enum: ["1"], default: 1 })).toEqual({ + type: "INTEGER", + enum: ["1"], + default: 1, + }); + }); + + test("runnable maps model input to the API and returns JSON text", async () => { + fetchMock.mockResolvedValue(respond(filled)); + const output = await CredentialFillTool.runnable(client(), SESSION, { + credential: "github", + fields: [{ field: "username", selector: "#login_field", extra: "dropped" } as any], + page_id: "TARGET-1", + frame_url: "https://github.com/", + }); + const { url, body } = lastRequest(); + expect(url).toBe(FILL_URL); + expect(body).toEqual({ + credential: "github", + fields: [{ field: "username", selector: "#login_field" }], + pageId: "TARGET-1", + frame: { url: "https://github.com/" }, + }); + expect(typeof output).toBe("string"); + expect(JSON.parse(output)).toEqual(filled); + }); + + test("null page_id and frame_url are left out", async () => { + fetchMock.mockResolvedValue(respond(filled)); + await CredentialFillTool.runnable(client(), SESSION, { + credential: "github", + fields, + page_id: null, + frame_url: null, + }); + expect(lastRequest().body).toEqual({ credential: "github", fields }); + }); + + test("API errors come back as JSON for the model", async () => { + fetchMock.mockResolvedValue(respond(ambiguous, 409)); + const output = await CredentialFillTool.runnable(client(), SESSION, { + credential: "github", + fields, + page_id: null, + frame_url: null, + }); + expect(JSON.parse(output)).toEqual({ + error: { + code: "CREDENTIAL_PAGE_AMBIGUOUS", + message: "Several pages could be the target; pass pageId", + retryable: false, + details: ambiguous.details, + }, + }); + }); + + test("invalid model input comes back as JSON without a request", async () => { + const output = await CredentialFillTool.runnable(client(), SESSION, { + credential: "github", + fields: [{ field: "password", selector: "" }], + }); + expect(JSON.parse(output)).toMatchObject({ + error: { code: "invalid_request", retryable: false, details: null }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + 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", fields }) + ).rejects.toThrow(TypeError); + }); +}); diff --git a/tests/unit/secrets-contract.test.ts b/tests/unit/secrets-contract.test.ts index fc83834..63476dd 100644 --- a/tests/unit/secrets-contract.test.ts +++ b/tests/unit/secrets-contract.test.ts @@ -284,3 +284,229 @@ describe("secrets service", () => { }); }); }); + +describe("credentials", () => { + const CREDENTIAL_CONTEXT = `hb-secret-v1:team:${ID}:credential`; + const credential = (overrides: Record = {}) => + secret({ + name: "github", + type: "credential", + metadata: { browser: { allowedOrigins: ["https://github.com"] } }, + encryptionContext: CREDENTIAL_CONTEXT, + ...overrides, + }); + const login = { username: "lovelace-1815", password: "pässwörd-1" }; + const listOf = (item: unknown) => ({ secrets: [item], totalCount: 1, page: 1, perPage: 100 }); + const version = (n: number) => ({ version: n, kid: platformPublicKey.kid, createdAt: "" }); + + test("createCredential creates a typed secret and seals its JSON to the returned context", async () => { + const { service, calls, requestSpy } = mockService((method, path) => { + if (path === "/secrets/public-key") return platformPublicKey; + if (method === "POST" && path === "/secrets") return credential(); + if (method === "POST" && path === `/secrets/${ID}/versions`) return version(1); + throw new Error(`unexpected ${method} ${path}`); + }); + + const created = await service.createCredential({ + name: "github", + value: login, + allowedOrigins: ["https://github.com"], + description: "GitHub bot", + }); + + expect(calls()).toEqual([ + "GET /secrets/public-key", + "POST /secrets", + `POST /secrets/${ID}/versions`, + ]); + const createBody = (requestSpy.mock.calls[1][1] as any).body as string; + expect(JSON.parse(createBody)).toEqual({ + name: "github", + description: "GitHub bot", + type: "credential", + metadata: { browser: { allowedOrigins: ["https://github.com"] } }, + }); + const uploadBody = (requestSpy.mock.calls[2][1] as any).body as string; + const { header, value } = open(JSON.parse(uploadBody).envelope); + expect(header.ctx).toBe(CREDENTIAL_CONTEXT); + expect(value.toString("utf8")).toBe(JSON.stringify(login)); + // The plaintext is nowhere else on the wire. + for (const [, init] of requestSpy.mock.calls as any[]) { + expect(String(init?.body ?? "")).not.toContain("lovelace"); + expect(String(init?.body ?? "")).not.toContain("pässwörd"); + } + expect(created).toMatchObject({ type: "credential", latestVersion: 1 }); + }); + + test("create with type credential takes the same path", async () => { + const { service, requestSpy } = mockService((method, path) => { + if (path === "/secrets/public-key") return platformPublicKey; + if (method === "POST" && path === "/secrets") return credential(); + return version(1); + }); + await service.create({ name: "github", type: "credential", value: { token: "t" } }); + expect( + open(JSON.parse((requestSpy.mock.calls[2][1] as any).body).envelope).value.toString() + ).toBe('{"token":"t"}'); + }); + + test.each([ + ["an empty object", {}, /no fields/], + ["a bad key", { "user name": "ada" }, /field name "user name"/], + ["a long key", { ["k".repeat(65)]: "x" }, /must be 1-64/], + ["an empty value", { username: "" }, /username is empty/], + ["a non-string value", { pin: 1234 }, /pin must be a string/], + ["a NUL", { password: "a\0b" }, /NUL/], + ["a lone surrogate", { password: "a\uD800b" }, /valid UTF-8/], + ["an array", ["ada"], /object of field names/], + ["a string", "ada:pw", /must be an object of field names/], + ["bytes", new Uint8Array([1]), /must be an object of field names/], + ["an oversized value", { password: "x".repeat(32 * 1024) }, /more than 32768 bytes/], + ])("credential values with %s fail locally", async (_name, value, message) => { + const { service, calls } = mockService(() => platformPublicKey); + const error = await service + .create({ name: "github", type: "credential", value: value as any }) + .catch((e) => e); + expect(error).toBeInstanceOf(HyperbrowserError); + expect(error.code).toBe("invalid_secret_value"); + expect(error.message).toMatch(message); + expect(error.message).not.toContain("a\0b"); + expect(calls()).toEqual([]); + }); + + test("plain secrets reject objects, and createCredential checks its inputs, before any request", async () => { + const { service, calls } = mockService(() => platformPublicKey); + await expect(service.create({ name: "k", value: login })).rejects.toMatchObject({ + code: "invalid_secret_value", + }); + await expect( + service.createCredential({ name: "github", value: login, allowedOrigins: [] }) + ).rejects.toMatchObject({ code: "invalid_request" }); + await expect( + service.createCredential({ name: "github", value: login, allowedOrigins: [""] }) + ).rejects.toMatchObject({ code: "invalid_request" }); + await expect( + service.createCredential({ + name: "github", + value: "secret" as any, + allowedOrigins: ["https://github.com"], + }) + ).rejects.toMatchObject({ code: "invalid_secret_value" }); + await expect(service.create({ name: "k", type: "password" as any })).rejects.toMatchObject({ + code: "invalid_request", + }); + expect(calls()).toEqual([]); + }); + + test("the largest credential value is accepted", async () => { + const { service } = mockService((method, path) => { + if (path === "/secrets/public-key") return platformPublicKey; + if (method === "POST" && path === "/secrets") return credential(); + return version(1); + }); + const padding = 32 * 1024 - JSON.stringify({ p: "" }).length; + await expect( + service.create({ name: "github", type: "credential", value: { p: "x".repeat(padding) } }) + ).resolves.toMatchObject({ latestVersion: 1 }); + }); + + test("a failed upload after creating a credential reports the partial create", async () => { + const { service } = mockService((method, path) => { + if (path === "/secrets/public-key") return platformPublicKey; + if (method === "POST" && path === "/secrets") return credential(); + return new HyperbrowserError("key outdated", { statusCode: 409 }); + }); + await expect( + service.createCredential({ + name: "github", + value: login, + allowedOrigins: ["https://github.com"], + }) + ).rejects.toMatchObject({ code: "secret_value_not_stored", details: { secret: credential() } }); + }); + + test("addVersion requires the shape of the resolved secret's type", async () => { + const plain = secret(); + const { service, calls, requestSpy } = mockService((method, path, _body, params) => { + if (path === "/secrets/public-key") return platformPublicKey; + if (path === "/secrets") return listOf(params.search === "github" ? credential() : plain); + if (method === "POST") return version(2); + throw new Error(`unexpected ${method} ${path}`); + }); + + await expect(service.addVersion("github", "just-a-string")).rejects.toMatchObject({ + code: "invalid_secret_value", + }); + await expect(service.addVersion("provider-key", login)).rejects.toMatchObject({ + code: "invalid_secret_value", + }); + expect(calls().filter((call) => call.startsWith("POST"))).toEqual([]); + + await expect(service.addVersion("github", login)).resolves.toMatchObject({ version: 2 }); + const { header, value } = open(JSON.parse((requestSpy.mock.lastCall![1] as any).body).envelope); + expect(header.ctx).toBe(CREDENTIAL_CONTEXT); + expect(value.toString("utf8")).toBe(JSON.stringify(login)); + + await expect(service.addVersion("provider-key", "next")).resolves.toMatchObject({ version: 2 }); + expect( + open(JSON.parse((requestSpy.mock.lastCall![1] as any).body).envelope).value.toString() + ).toBe("next"); + }); + + test("addVersion rejects invalid values of either shape before any request", async () => { + const { service, calls } = mockService(() => platformPublicKey); + await expect(service.addVersion("github", { username: "" })).rejects.toThrow(/empty/); + await expect(service.addVersion("github", 42 as any)).rejects.toMatchObject({ + code: "invalid_secret_value", + }); + expect(calls()).toEqual([]); + }); + + test("setAllowedOrigins replaces or clears metadata.browser", async () => { + const { service, calls, requestSpy } = mockService((method, path) => { + if (path === "/secrets") return listOf(credential()); + if (method === "PATCH") return credential({ metadata: {} }); + throw new Error(`unexpected ${method} ${path}`); + }); + + await service.setAllowedOrigins("github", ["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("github", 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.setAllowedOrigins("github", [])).rejects.toMatchObject({ + code: "invalid_request", + }); + await expect( + service.setAllowedOrigins("github", Array(21).fill("https://github.com")) + ).rejects.toMatchObject({ code: "invalid_request" }); + await expect(service.update("github", {})).rejects.toMatchObject({ code: "invalid_request" }); + expect(calls()).toHaveLength(4); + }); + + test("list filters by type", async () => { + const { service, requestSpy } = mockService(() => listOf(credential())); + await expect(service.list({ type: "credential" })).resolves.toMatchObject({ + secrets: [{ type: "credential" }], + }); + expect(requestSpy.mock.calls[0][2]).toEqual({ + search: undefined, + page: undefined, + limit: undefined, + type: "credential", + }); + await expect(service.list({ type: "login" as any })).rejects.toMatchObject({ + code: "invalid_request", + }); + }); +}); From a3e68c4ae51885e3ca0580de3c6f06bed4743683 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 16:18:42 -0700 Subject: [PATCH 02/23] Document credential fill and bump version to 1.3.0 Cover creating credentials, attaching them at session create, the fill-then-submit flow with Playwright and Puppeteer, outcomes, choosing a page with pageId, error codes, and tool loops for OpenAI, Anthropic and Gemini using CredentialFillTool. Co-Authored-By: Claude Opus 5.5 --- README.md | 215 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1bf438a..2539bd9 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,221 @@ 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). +## Fill credentials into browser sessions + +Store a website login as a credential, attach it when you create a session, and +fill it into the page's login form. The value is encrypted locally before upload; +after that, neither the API, your code, nor an LLM ever sees it. + +```typescript +import { chromium } from "playwright-core"; +import { Hyperbrowser } from "@hyperbrowser/sdk"; + +const client = new Hyperbrowser({ apiKey: process.env.HYPERBROWSER_API_KEY }); + +// Once: a credential is a secret of type "credential" whose value is an object +// of fields, usable only on its allowed origins. +await client.secrets.createCredential({ + name: "github", + value: { username: "ada", password: process.env.GITHUB_PASSWORD! }, + allowedOrigins: ["https://github.com"], +}); + +// Credentials can only be attached when the session is created. +const session = await client.sessions.create({ credentials: ["github"] }); +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, { + credential: "github", + 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. [{ field: "password", outcome: "not_found" }] + } +} finally { + await client.sessions.stop(session.id); +} +``` + +With Puppeteer, connect as usual (`connect({ browserWSEndpoint: session.wsEndpoint })`), +call `sessions.credentials.fill` the same way, then `page.click(...)` or +`page.keyboard.press("Enter")` to submit. + +- Each field reports an outcome: `filled` (the input holds exactly the value), + `not_found` (no input matched), `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, e.g. `maxlength`), or `not_populated` (the + stored value has no such field). Submit only when the fields you need are + `filled`. Fill is safe to retry; it is not retried automatically. +- For logins split over several screens, fill the fields on each screen and + submit before filling the next. +- Without `pageId`, fill uses the only tab, else the single tab on an allowed + origin, else the single visible tab; otherwise it fails with + `CREDENTIAL_PAGE_AMBIGUOUS` and the candidate tabs in `error.details.pages` + (`{ pageId, url, title }`). Under Playwright every tab reports as visible, so + with several tabs on allowed origins, pass the page's target ID: + + ```typescript + const cdp = await page.context().newCDPSession(page); + const { targetInfo } = await cdp.send("Target.getTargetInfo"); + await client.sessions.credentials.fill(session.id, { + credential: "github", + fields: [{ field: "password", selector: "#password" }], + pageId: targetInfo.targetId, + }); + ``` + + In Puppeteer, `(await page.createCDPSession()).send("Target.getTargetInfo")` + returns the same ID. For a login form inside an iframe, pass + `frame: { url: "https://accounts.example.com/" }` (matched as a URL prefix). +- Manage credentials with the other `secrets` methods: `addVersion(name, value)` + stores a new value object, `setAllowedOrigins(name, origins)` replaces the + allowed origins (`null` clears them, so the credential can't be filled), and + `list({ type: "credential" })` lists credentials. Origins are + `scheme://host[:port]`; a leading `*.` matches subdomains only, and `http:` is + accepted only for `localhost`, `127.0.0.1` and `[::1]`. Values are objects of + non-empty strings whose keys match `^[a-zA-Z0-9_.-]{1,64}$`, at most 32 KiB as + JSON; they are checked locally (`invalid_secret_value`) before anything is sent. +- Session create needs the API key scope `secrets.use`; fill also needs + `browser-sessions.update`. Attaching credentials 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`. +- Fill errors are `HyperbrowserError`s with `code`, `retryable` and `details`: + `CREDENTIAL_NOT_ATTACHED`, `CREDENTIAL_ORIGIN_NOT_ALLOWED` (`details.origin`; + nothing was typed), `CREDENTIAL_NOT_READY` (no allowed origins or no value), + `SECRET_TYPE_MISMATCH`, `SECRET_NOT_FOUND`, `SESSION_NOT_ACTIVE`, + `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND`, + `CREDENTIAL_PAGE_AMBIGUOUS`, `CREDENTIAL_FRAME_AMBIGUOUS`, + `CREDENTIAL_FILL_TIMEOUT` (retryable), `CREDENTIAL_FILL_FAILED`, + `CREDENTIAL_FILL_UNSUPPORTED` (the browser image predates fill), + `CREDENTIALS_UNSUPPORTED_RUNTIME`, `SECRET_VALUE_INVALID` and + `SECRETS_FEATURE_DISABLED`. Requests the API would reject fail locally with + `invalid_request`. + +### 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 the credential name, fields, selectors and, when needed, `page_id` or +`frame_url`. Results and errors come back as JSON text, and never contain values. +Fill doesn't submit, so give the model your other browser tools as well (to +click the login button, for example). + +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 credential." }, +]; +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 credential." }, +]; +for (;;) { + const response = await anthropic.messages.create({ + model: "claude-sonnet-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 credential." }] }, +]; +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, along with `toGeminiSchema` for +converting the SDK's JSON Schemas to Gemini's schema form. + ## Usage ### Playwright diff --git a/package.json b/package.json index 5f250a7..106b6ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbrowser/sdk", - "version": "1.2.2", + "version": "1.3.0", "description": "Node SDK for Hyperbrowser API", "author": "", "repository": { From e592b874afc6cda581f1dee4fa69bb508178b931 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 16:29:45 -0700 Subject: [PATCH 03/23] Keep error details as the body and add Gemini definitions to every tool - sessions.credentials.fill no longer replaces HyperbrowserError.details: as elsewhere in the SDK it is the whole error body, so the candidate tabs for CREDENTIAL_PAGE_AMBIGUOUS are at error.details.details.pages. CredentialFillTool's error JSON still gives the model the server's own details object (null when the body has none). - Every tool class has a static geminiToolDefinition, built from its OpenAI definition with toGeminiFunctionDeclaration and exported as _TOOL_GEMINI. All existing schemas convert without changes to the converter. - README: the details path, Gemini definitions on all tools, and claude-sonnet-5-5 in the Anthropic sample. Co-Authored-By: Claude Opus 5.5 --- README.md | 20 ++++++---- src/services/credentials.ts | 54 +++++++-------------------- src/tools/gemini.ts | 26 +++++++++++++ src/tools/index.ts | 22 ++++++++++- tests/unit/credentials-fill.test.ts | 57 +++++++++++++++++++++++++---- 5 files changed, 121 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 2539bd9..4181ff6 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,8 @@ call `sessions.credentials.fill` the same way, then `page.click(...)` or submit before filling the next. - Without `pageId`, fill uses the only tab, else the single tab on an allowed origin, else the single visible tab; otherwise it fails with - `CREDENTIAL_PAGE_AMBIGUOUS` and the candidate tabs in `error.details.pages` - (`{ pageId, url, title }`). Under Playwright every tab reports as visible, so + `CREDENTIAL_PAGE_AMBIGUOUS` and the candidate tabs in + `error.details.details.pages` (`{ pageId, url, title }`). Under Playwright every tab reports as visible, so with several tabs on allowed origins, pass the page's target ID: ```typescript @@ -177,9 +177,10 @@ call `sessions.credentials.fill` the same way, then `page.click(...)` or `browser-sessions.update`. Attaching credentials 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`. -- Fill errors are `HyperbrowserError`s with `code`, `retryable` and `details`: - `CREDENTIAL_NOT_ATTACHED`, `CREDENTIAL_ORIGIN_NOT_ALLOWED` (`details.origin`; - nothing was typed), `CREDENTIAL_NOT_READY` (no allowed origins or no value), +- Fill errors are `HyperbrowserError`s with `code` and `retryable`; `details` + is the API's error body, whose own `details` holds the specifics (such as + `error.details.details.origin`). Codes: `CREDENTIAL_NOT_ATTACHED`, + `CREDENTIAL_ORIGIN_NOT_ALLOWED` (nothing was typed), `CREDENTIAL_NOT_READY` (no allowed origins or no value), `SECRET_TYPE_MISMATCH`, `SECRET_NOT_FOUND`, `SESSION_NOT_ACTIVE`, `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND`, `CREDENTIAL_PAGE_AMBIGUOUS`, `CREDENTIAL_FRAME_AMBIGUOUS`, @@ -194,7 +195,8 @@ call `sessions.credentials.fill` the same way, then `page.click(...)` or `CredentialFillTool` from `@hyperbrowser/sdk/tools` exposes fill as a `fill_credential` tool. Your application binds the session ID; the model picks only the credential name, fields, selectors and, when needed, `page_id` or -`frame_url`. Results and errors come back as JSON text, and never contain values. +`frame_url`. 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). @@ -242,7 +244,7 @@ const messages: Anthropic.MessageParam[] = [ ]; for (;;) { const response = await anthropic.messages.create({ - model: "claude-sonnet-5", + model: "claude-sonnet-5-5", max_tokens: 4096, tools: [CredentialFillTool.anthropicToolDefinition /* , your browser tools */], messages, @@ -302,7 +304,9 @@ for (;;) { `FILL_CREDENTIAL_TOOL_OPENAI`, `FILL_CREDENTIAL_TOOL_ANTHROPIC` and `FILL_CREDENTIAL_TOOL_GEMINI` are also exported, along with `toGeminiSchema` for -converting the SDK's JSON Schemas to Gemini's schema form. +converting the SDK's JSON Schemas to Gemini's schema form. Every tool class in +`@hyperbrowser/sdk/tools` (such as `WebsiteScrapeTool`) has a +`geminiToolDefinition` next to its OpenAI and Anthropic definitions. ## Usage diff --git a/src/services/credentials.ts b/src/services/credentials.ts index df0f658..ab5de8d 100644 --- a/src/services/credentials.ts +++ b/src/services/credentials.ts @@ -1,4 +1,4 @@ -import { HyperbrowserError, invalidRequest, ResponseDecodingError } from "../error"; +import { invalidRequest } from "../error"; import { CREDENTIAL_FIELD_PATTERN, isPlainObject } from "../secrets-envelope"; import { CredentialFillResult, FillCredentialParams } from "../types/credential"; import { BaseService } from "./base"; @@ -64,33 +64,6 @@ const fillBody = (params: FillCredentialParams): string => { }); }; -/** - * The fill route's errors carry their data in the body's `details`, such as - * `details.pages` for `CREDENTIAL_PAGE_AMBIGUOUS`; surface that as the error's - * `details` rather than the whole body. - */ -const withServerDetails = (error: unknown): unknown => { - if ( - !(error instanceof HyperbrowserError) || - error instanceof ResponseDecodingError || - !isPlainObject(error.details) - ) { - return error; - } - const details = error.details.details; - return new HyperbrowserError(error.message.replace(/^\[Hyperbrowser\]: /, ""), { - statusCode: error.statusCode, - code: error.code, - requestId: error.requestId, - retryable: error.retryable, - service: error.service, - details: details === null ? undefined : details, - cause: error.cause ?? error, - method: error.method, - path: error.path, - }); -}; - /** Fill credentials attached to a browser session into its pages. */ export class CredentialsService extends BaseService { /** @@ -98,23 +71,22 @@ export class CredentialsService extends BaseService { * pages. Only types; never submits, so click the login button or press * Enter afterwards. Values never leave the platform: the result lists an * outcome per field. Safe to retry; it is not retried automatically. + * + * 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`. */ async fill(sessionId: string, params: FillCredentialParams): Promise { if (typeof sessionId !== "string" || !sessionId.trim()) { throw invalidRequest("A session ID is required"); } - const body = fillBody(params); - try { - return await this.request( - `/session/${encodeURIComponent(sessionId)}/credentials/fill`, - { - method: "POST", - body, - timeout: Math.max(this.timeout, MIN_FILL_TIMEOUT_MS), - } - ); - } catch (error) { - throw withServerDetails(error); - } + return this.request( + `/session/${encodeURIComponent(sessionId)}/credentials/fill`, + { + method: "POST", + body: fillBody(params), + timeout: Math.max(this.timeout, MIN_FILL_TIMEOUT_MS), + } + ); } } diff --git a/src/tools/gemini.ts b/src/tools/gemini.ts index a3d6c0b..de192e9 100644 --- a/src/tools/gemini.ts +++ b/src/tools/gemini.ts @@ -1,3 +1,13 @@ +import { + BROWSER_USE_TOOL_OPENAI, + ChatCompletionTool, + CLAUDE_COMPUTER_USE_TOOL_OPENAI, + CRAWL_TOOL_OPENAI, + EXTRACT_TOOL_OPENAI, + OPENAI_CUA_TOOL_OPENAI, + SCRAPE_TOOL_OPENAI, + SCREENSHOT_TOOL_OPENAI, +} from "./openai"; import { FILL_CREDENTIAL_DESCRIPTION, FILL_CREDENTIAL_SCHEMA } from "./schema"; /** Gemini's schema types, as `@google/genai`'s `Type` enum spells them. */ @@ -118,6 +128,22 @@ export const toGeminiFunctionDeclaration = ( parameters: Record ): FunctionDeclaration => ({ name, description, parameters: toGeminiSchema(parameters) }); +/** The Gemini form of an OpenAI tool definition: same name, description and schema. */ +const fromOpenAI = ({ function: definition }: ChatCompletionTool): FunctionDeclaration => + toGeminiFunctionDeclaration( + definition.name, + definition.description ?? "", + definition.parameters ?? { type: "object", properties: {} } + ); + +export const SCRAPE_TOOL_GEMINI = fromOpenAI(SCRAPE_TOOL_OPENAI); +export const SCREENSHOT_TOOL_GEMINI = fromOpenAI(SCREENSHOT_TOOL_OPENAI); +export const CRAWL_TOOL_GEMINI = fromOpenAI(CRAWL_TOOL_OPENAI); +export const EXTRACT_TOOL_GEMINI = fromOpenAI(EXTRACT_TOOL_OPENAI); +export const BROWSER_USE_TOOL_GEMINI = fromOpenAI(BROWSER_USE_TOOL_OPENAI); +export const CLAUDE_COMPUTER_USE_TOOL_GEMINI = fromOpenAI(CLAUDE_COMPUTER_USE_TOOL_OPENAI); +export const OPENAI_CUA_TOOL_GEMINI = fromOpenAI(OPENAI_CUA_TOOL_OPENAI); + export const FILL_CREDENTIAL_TOOL_GEMINI: FunctionDeclaration = toGeminiFunctionDeclaration( "fill_credential", FILL_CREDENTIAL_DESCRIPTION, diff --git a/src/tools/index.ts b/src/tools/index.ts index bee6e8a..dbac39e 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -18,7 +18,16 @@ import { OPENAI_CUA_TOOL_ANTHROPIC, FILL_CREDENTIAL_TOOL_ANTHROPIC, } from "./anthropic"; -import { FILL_CREDENTIAL_TOOL_GEMINI } from "./gemini"; +import { + SCRAPE_TOOL_GEMINI, + SCREENSHOT_TOOL_GEMINI, + CRAWL_TOOL_GEMINI, + EXTRACT_TOOL_GEMINI, + BROWSER_USE_TOOL_GEMINI, + CLAUDE_COMPUTER_USE_TOOL_GEMINI, + OPENAI_CUA_TOOL_GEMINI, + FILL_CREDENTIAL_TOOL_GEMINI, +} from "./gemini"; import { HyperbrowserClient, HyperbrowserError } from "../client"; import { StartScrapeJobParams, @@ -29,6 +38,7 @@ import { } 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"; @@ -46,6 +56,7 @@ export interface CredentialFillToolParams { export class WebsiteScrapeTool { static openaiToolDefinition = SCRAPE_TOOL_OPENAI; static anthropicToolDefinition = SCRAPE_TOOL_ANTHROPIC; + static geminiToolDefinition = SCRAPE_TOOL_GEMINI; static async runnable(hb: HyperbrowserClient, params: StartScrapeJobParams): Promise { const resp = await hb.scrape.startAndWait(params); @@ -56,6 +67,7 @@ export class WebsiteScrapeTool { export class WebsiteScreenshotTool { static openaiToolDefinition = SCREENSHOT_TOOL_OPENAI; static anthropicToolDefinition = SCREENSHOT_TOOL_ANTHROPIC; + static geminiToolDefinition = SCREENSHOT_TOOL_GEMINI; static async runnable(hb: HyperbrowserClient, params: StartScrapeJobParams): Promise { const resp = await hb.scrape.startAndWait(params); @@ -66,6 +78,7 @@ export class WebsiteScreenshotTool { export class WebsiteCrawlTool { static openaiToolDefinition = CRAWL_TOOL_OPENAI; static anthropicToolDefinition = CRAWL_TOOL_ANTHROPIC; + static geminiToolDefinition = CRAWL_TOOL_GEMINI; static async runnable(hb: HyperbrowserClient, params: StartCrawlJobParams): Promise { const resp = await hb.crawl.startAndWait(params); @@ -85,6 +98,7 @@ export class WebsiteCrawlTool { export class WebsiteExtractTool { static openaiToolDefinition = EXTRACT_TOOL_OPENAI; static anthropicToolDefinition = EXTRACT_TOOL_ANTHROPIC; + static geminiToolDefinition = EXTRACT_TOOL_GEMINI; static async runnable(hb: HyperbrowserClient, params: StartExtractJobParams): Promise { if (params.schema && typeof params.schema === "string") { @@ -98,6 +112,7 @@ export class WebsiteExtractTool { export class BrowserUseTool { static openaiToolDefinition = BROWSER_USE_TOOL_OPENAI; static anthropicToolDefinition = BROWSER_USE_TOOL_ANTHROPIC; + static geminiToolDefinition = BROWSER_USE_TOOL_GEMINI; static async runnable( hb: HyperbrowserClient, @@ -111,6 +126,7 @@ export class BrowserUseTool { export class ClaudeComputerUseTool { static openaiToolDefinition = CLAUDE_COMPUTER_USE_TOOL_OPENAI; static anthropicToolDefinition = CLAUDE_COMPUTER_USE_TOOL_ANTHROPIC; + static geminiToolDefinition = CLAUDE_COMPUTER_USE_TOOL_GEMINI; static async runnable( hb: HyperbrowserClient, @@ -124,6 +140,7 @@ export class ClaudeComputerUseTool { export class OpenAICuaTool { static openaiToolDefinition = OPENAI_CUA_TOOL_OPENAI; static anthropicToolDefinition = OPENAI_CUA_TOOL_ANTHROPIC; + static geminiToolDefinition = OPENAI_CUA_TOOL_GEMINI; static async runnable(hb: HyperbrowserClient, params: StartCuaTaskParams): Promise { const resp = await hb.agents.cua.startAndWait(params); @@ -177,7 +194,8 @@ export class CredentialFillTool { code: error.code ?? null, message: error.message.replace(/^\[Hyperbrowser\]: /, ""), retryable: error.retryable, - details: error.details ?? null, + // 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/tests/unit/credentials-fill.test.ts b/tests/unit/credentials-fill.test.ts index 31b5a16..529c64c 100644 --- a/tests/unit/credentials-fill.test.ts +++ b/tests/unit/credentials-fill.test.ts @@ -9,7 +9,14 @@ vi.mock("node-fetch", async () => { import { HyperbrowserClient as Hyperbrowser, HyperbrowserError } from "../../src/client"; import { + BrowserUseTool, + ClaudeComputerUseTool, CredentialFillTool, + OpenAICuaTool, + WebsiteCrawlTool, + WebsiteExtractTool, + WebsiteScrapeTool, + WebsiteScreenshotTool, FILL_CREDENTIAL_SCHEMA, FILL_CREDENTIAL_TOOL_ANTHROPIC, FILL_CREDENTIAL_TOOL_GEMINI, @@ -114,7 +121,7 @@ describe("sessions", () => { expect(result.fields[0].outcome).toBe("not_visible_yet"); }); - test("errors carry the server's code, retryable flag and details", async () => { + 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, { credential: "github", fields }) @@ -124,20 +131,20 @@ describe("sessions", () => { statusCode: 409, code: "CREDENTIAL_PAGE_AMBIGUOUS", retryable: false, - details: ambiguous.details, + 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); - fetchMock.mockResolvedValue( - respond({ code: "CREDENTIAL_FILL_TIMEOUT", message: "timed out", retryable: true }, 504) - ); + 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", fields }) ).rejects.toMatchObject({ code: "CREDENTIAL_FILL_TIMEOUT", retryable: true, - details: undefined, + details: timeout, }); }); @@ -299,6 +306,26 @@ describe("fill_credential tool", () => { }); }); + test.each([ + WebsiteScrapeTool, + WebsiteScreenshotTool, + WebsiteCrawlTool, + WebsiteExtractTool, + BrowserUseTool, + ClaudeComputerUseTool, + OpenAICuaTool, + CredentialFillTool, + ])("$name has a Gemini declaration matching its OpenAI definition", (tool) => { + const declaration = tool.geminiToolDefinition; + expect(declaration.name).toBe(tool.openaiToolDefinition.function.name); + expect(declaration.description).toBe(tool.openaiToolDefinition.function.description); + expect(declaration.parameters?.type).toBe("OBJECT"); + expect(collectKeys(declaration.parameters).has("additionalProperties")).toBe(false); + expect(Object.keys(declaration.parameters?.properties ?? {})).toEqual( + Object.keys((tool.openaiToolDefinition.function.parameters as any).properties) + ); + }); + test("the Gemini converter refuses schemas it can't express", () => { expect(() => toGeminiSchema({ type: ["string", "number"] })).toThrow(/Unsupported/); expect(toGeminiSchema({ type: "integer", enum: ["1"], default: 1 })).toEqual({ @@ -339,7 +366,7 @@ describe("fill_credential tool", () => { expect(lastRequest().body).toEqual({ credential: "github", fields }); }); - test("API errors come back as JSON for the model", async () => { + 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", @@ -355,6 +382,22 @@ describe("fill_credential tool", () => { 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", + fields, + }); + expect(JSON.parse(timedOut)).toEqual({ + error: { + code: "CREDENTIAL_FILL_TIMEOUT", + message: "timed out", + retryable: true, + details: null, + }, + }); }); test("invalid model input comes back as JSON without a request", async () => { From e6828720d172ff367555a5a061f199c3959c2176 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 20:38:29 -0700 Subject: [PATCH 04/23] Use the API's spelling of the browser sessions scope in the README Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FzcEMZLVjoiueoQFxG6F7S --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4181ff6..718dd38 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ call `sessions.credentials.fill` the same way, then `page.click(...)` or non-empty strings whose keys match `^[a-zA-Z0-9_.-]{1,64}$`, at most 32 KiB as JSON; they are checked locally (`invalid_secret_value`) before anything is sent. - Session create needs the API key scope `secrets.use`; fill also needs - `browser-sessions.update`. Attaching credentials runs the session on + `browser.sessions.update`. Attaching credentials 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`. - Fill errors are `HyperbrowserError`s with `code` and `retryable`; `details` From b7a71d30584b3542f0e9f37965694f7452beba80 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Tue, 13 Oct 2026 21:16:01 -0700 Subject: [PATCH 05/23] Leave the version for the release to set Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FzcEMZLVjoiueoQFxG6F7S --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 106b6ca..5f250a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbrowser/sdk", - "version": "1.3.0", + "version": "1.2.2", "description": "Node SDK for Hyperbrowser API", "author": "", "repository": { From d363d8ea00bdec25529056d0f3b87a85021bd98e Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:27:56 -0700 Subject: [PATCH 06/23] Fill secrets and vaults into sessions with selectors The API no longer has credential secrets with JSON-object values: any secret with allowed origins can be filled, and a vault groups a login's secrets under entry names. - sessions.create takes credentials as secret names, { secret } or { vault }; the session lists the attached secrets. - sessions.credentials.fill takes credential (one secret) or vault, and fields as { selector } or, with a vault, { selector, field } naming the entry. pageUrl picks a tab by URL prefix (not with pageId), and frameUrl replaces frame.url. All of it is checked locally first: exactly one of credential and vault, field iff vault, unique selectors, 1-16 fields, and the API's length limits. - Results report per input the selector, outcome, secretId, version, the vault field and, for TOTP inputs, expiresAt; version is top-level only for single-secret fills. not_populated is gone. - fill_credential lets the model pass credential or vault, a nullable field per input, and page_url next to page_id and frame_url; every property stays required for OpenAI strict mode. The description covers vaults, TOTP entries and the retry with page_id or page_url. - Environment variable names for secrets are limited to 256 characters, as the API stores them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- src/sandbox/secret-references.ts | 23 ++- src/services/credentials.ts | 92 +++++++---- src/tools/index.ts | 37 +++-- src/tools/schema.ts | 64 ++++---- src/types/credential.ts | 61 ++++--- src/types/index.ts | 1 + src/types/session.ts | 15 +- tests/unit/credentials-fill.test.ts | 239 +++++++++++++++++++--------- 8 files changed, 347 insertions(+), 185 deletions(-) diff --git a/src/sandbox/secret-references.ts b/src/sandbox/secret-references.ts index 35ab4ea..512b590 100644 --- a/src/sandbox/secret-references.ts +++ b/src/sandbox/secret-references.ts @@ -2,11 +2,32 @@ 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__"; + +/** A vault name, checked locally before it is sent. */ +export const checkVaultName = (vault: unknown, what = "vault"): string => { + if (typeof vault !== "string" || !SECRET_NAME_PATTERN.test(vault)) { + throw new HyperbrowserError( + `${what} must be a vault name: 1-128 letters, digits, '_' or '-', starting with a letter or digit`, + { code: "invalid_request", retryable: false } + ); + } + return vault; +}; + export interface WireSecretReference { name: string; version?: number | "latest"; @@ -34,7 +55,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 } = diff --git a/src/services/credentials.ts b/src/services/credentials.ts index ab5de8d..a903353 100644 --- a/src/services/credentials.ts +++ b/src/services/credentials.ts @@ -1,76 +1,102 @@ import { invalidRequest } from "../error"; -import { CREDENTIAL_FIELD_PATTERN, isPlainObject } from "../secrets-envelope"; +import { checkVaultName, isSecretEnvName } from "../sandbox/secret-references"; +import { isPlainObject } from "../secrets-envelope"; import { CredentialFillResult, FillCredentialParams } from "../types/credential"; import { BaseService } from "./base"; const MAX_FIELDS = 16; +const MAX_CREDENTIAL_LENGTH = 255; const MAX_SELECTOR_BYTES = 1024; const MAX_PAGE_ID_BYTES = 128; -const MAX_FRAME_URL_BYTES = 2048; +const MAX_URL_BYTES = 2048; /** The API gives up on a fill after about 18 seconds. */ const MIN_FILL_TIMEOUT_MS = 30_000; const isNonEmptyString = (value: unknown, maxBytes: number): value is string => typeof value === "string" && value.length > 0 && Buffer.byteLength(value, "utf8") <= maxBytes; +const isGiven = (value: unknown): boolean => value !== undefined && value !== null; + const fillBody = (params: FillCredentialParams): string => { if (!isPlainObject(params)) { throw invalidRequest("Fill parameters must be an object"); } - const { credential, fields, pageId, frame } = params; - if (typeof credential !== "string" || !credential.trim()) { - throw invalidRequest("credential must be the name or ID of a credential"); + const { credential, vault, fields, pageId, pageUrl, frameUrl } = params; + const withVault = isGiven(vault); + if (isGiven(credential) === withVault) { + throw invalidRequest("Pass exactly one of credential (a secret) and vault"); + } + if (withVault) { + checkVaultName(vault); + } else if ( + typeof credential !== "string" || + !credential.trim() || + credential.length > MAX_CREDENTIAL_LENGTH + ) { + throw invalidRequest("credential must be the name or ID of a secret attached to the session"); } if (!Array.isArray(fields) || fields.length < 1 || fields.length > MAX_FIELDS) { - throw invalidRequest(`fields must list 1-${MAX_FIELDS} { field, selector } pairs`); + throw invalidRequest(`fields must list 1-${MAX_FIELDS} inputs`); } - const seen = new Set(); + const selectors = new Set(); const wireFields = fields.map((entry, index) => { if (!isPlainObject(entry)) { - throw invalidRequest(`fields[${index}] must be a { field, selector } object`); - } - const { field, selector } = entry; - if (typeof field !== "string" || !CREDENTIAL_FIELD_PATTERN.test(field)) { - throw invalidRequest(`fields[${index}].field must be 1-64 letters, digits, '_', '.' or '-'`); - } - if (seen.has(field)) { - throw invalidRequest(`fields lists ${field} more than once`); + throw invalidRequest(`fields[${index}] must be a { selector, field? } object`); } - seen.add(field); + const { selector, field } = entry; if (!isNonEmptyString(selector, MAX_SELECTOR_BYTES) || !selector.trim()) { throw invalidRequest( `fields[${index}].selector must be a CSS selector of 1-${MAX_SELECTOR_BYTES} bytes` ); } - return { field, selector }; - }); - if (pageId !== undefined && pageId !== null && !isNonEmptyString(pageId, MAX_PAGE_ID_BYTES)) { - throw invalidRequest(`pageId must be a page ID of 1-${MAX_PAGE_ID_BYTES} bytes`); - } - let wireFrame: { url: string } | undefined; - if (frame !== undefined && frame !== null) { - if (!isPlainObject(frame) || !isNonEmptyString(frame.url, MAX_FRAME_URL_BYTES)) { + if (selectors.has(selector)) { + throw invalidRequest(`fields lists the selector ${selector.slice(0, 64)} more than once`); + } + selectors.add(selector); + if (!withVault) { + if (isGiven(field)) { + throw invalidRequest( + `fields[${index}].field is only for vault fills; a credential fill types the secret itself` + ); + } + return { selector }; + } + if (!isSecretEnvName(field)) { throw invalidRequest( - `frame.url must be a URL or URL prefix of 1-${MAX_FRAME_URL_BYTES} bytes` + `fields[${index}].field must name the vault entry to type, such as "PASSWORD"` ); } - wireFrame = { url: frame.url }; + return { selector, field }; + }); + if (isGiven(pageId) && !isNonEmptyString(pageId, MAX_PAGE_ID_BYTES)) { + throw invalidRequest(`pageId must be a page ID of 1-${MAX_PAGE_ID_BYTES} bytes`); + } + if (isGiven(pageUrl) && !isNonEmptyString(pageUrl, MAX_URL_BYTES)) { + throw invalidRequest(`pageUrl must be a URL or URL prefix of 1-${MAX_URL_BYTES} bytes`); + } + if (isGiven(pageId) && isGiven(pageUrl)) { + throw invalidRequest("Pass pageId or pageUrl, not both"); + } + if (isGiven(frameUrl) && !isNonEmptyString(frameUrl, MAX_URL_BYTES)) { + throw invalidRequest(`frameUrl must be a URL or URL prefix of 1-${MAX_URL_BYTES} bytes`); } return JSON.stringify({ - credential, + ...(withVault ? { vault } : { credential }), fields: wireFields, pageId: pageId ?? undefined, - frame: wireFrame, + pageUrl: pageUrl ?? undefined, + frame: isGiven(frameUrl) ? { url: frameUrl } : undefined, }); }; -/** Fill credentials attached to a browser session into its pages. */ +/** Fill secrets attached to a browser session into its pages. */ export class CredentialsService extends BaseService { /** - * Type a stored credential's fields into inputs on one of the session's - * pages. Only types; never submits, so click the login button or press - * Enter afterwards. Values never leave the platform: the result lists an - * outcome per field. Safe to retry; it is not retried automatically. + * Type a secret, or a vault's entries, 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. * * 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` diff --git a/src/tools/index.ts b/src/tools/index.ts index dbac39e..c663617 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -45,11 +45,13 @@ export { FILL_CREDENTIAL_TOOL_OPENAI } from "./openai"; export { FILL_CREDENTIAL_TOOL_ANTHROPIC } from "./anthropic"; export * from "./gemini"; -/** What the model passes to `fill_credential`. */ +/** What the model passes to `fill_credential`. Unused options are null. */ export interface CredentialFillToolParams { - credential: string; - fields: { field: string; selector: string }[]; + credential?: string | null; + vault?: string | null; + fields: { selector: string; field?: string | null }[]; page_id?: string | null; + page_url?: string | null; frame_url?: string | null; } @@ -149,9 +151,9 @@ export class OpenAICuaTool { } /** - * Lets a model fill a credential attached to a browser session. The - * application binds the session; the model chooses only the credential name, - * fields and selectors, and never sees values. + * 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. */ export class CredentialFillTool { static openaiToolDefinition = FILL_CREDENTIAL_TOOL_OPENAI; @@ -170,20 +172,23 @@ export class CredentialFillTool { params: CredentialFillToolParams ): Promise { try { - // Model output is checked by fill; only the known keys are passed on. - const input: Partial = params ?? {}; + // Model output is checked by fill; only the known keys are passed on, + // and nulls (strict mode's "not given") are left out. + const input: Partial = isPlainObject(params) ? params : {}; + const given = (value: T | null | undefined): T | undefined => + value === null ? undefined : value; const request: FillCredentialParams = { - credential: input.credential as string, + credential: given(input.credential), + vault: given(input.vault), fields: (Array.isArray(input.fields) - ? input.fields.map((entry) => ({ field: entry?.field, selector: entry?.selector })) + ? 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), }; - if (input.page_id !== undefined && input.page_id !== null) { - request.pageId = input.page_id; - } - if (input.frame_url !== undefined && input.frame_url !== null) { - request.frame = { url: input.frame_url }; - } return JSON.stringify(await hb.sessions.credentials.fill(sessionId, request)); } catch (error) { if (!(error instanceof HyperbrowserError)) { diff --git a/src/tools/schema.ts b/src/tools/schema.ts index 74650b6..efdcd12 100644 --- a/src/tools/schema.ts +++ b/src/tools/schema.ts @@ -260,64 +260,68 @@ export const OPENAI_CUA_SCHEMA = { additionalProperties: false, }; -export const FILL_CREDENTIAL_DESCRIPTION = `Fill a stored login credential into the browser session's current page by typing its values into input fields you choose. You never see the values: refer to the credential by its name and to each stored field (such as "username" or "password") by its key, and give a CSS selector for the input each one goes into. +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 fields 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. +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 field: -- "filled": the input now holds the stored value. +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_populated": the stored credential has no such field. Use a field it does have, or tell the user what is missing. - "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 field you need is "filled". +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, taken from details.pages. CREDENTIAL_ORIGIN_NOT_ALLOWED means this credential may not be used on the current site; nothing was typed, so do not try to work around it, and tell the user. CREDENTIAL_FRAME_NOT_FOUND means no iframe matched frame_url. If retryable is true, you may try the same call again.`; +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: { - type: "string", - description: - 'Name of the stored login credential to fill, for example "github". A credential is a saved login (such as a username and password) that you cannot see; you only refer to it by name.', - }, + 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: - "Which stored field to type into which input, one entry per input on the current page. Each stored field at most once.", + "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: { - field: { - type: "string", - description: - 'Key of the stored credential value to type, such as "username", "email" or "password".', - }, 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: ["field", "selector"], + required: ["selector", "field"], additionalProperties: false, }, }, - page_id: { - type: ["string", "null"], - description: - "Usually null. Set it only after a previous call failed with CREDENTIAL_PAGE_AMBIGUOUS because several tabs could be the target: use the pageId of the tab with the login form from that error's details.pages.", - }, - frame_url: { - type: ["string", "null"], - description: - '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".', - }, + 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", "fields", "page_id", "frame_url"], + required: ["credential", "vault", "fields", "page_id", "page_url", "frame_url"], additionalProperties: false, }; diff --git a/src/types/credential.ts b/src/types/credential.ts index d0f10e5..013e136 100644 --- a/src/types/credential.ts +++ b/src/types/credential.ts @@ -1,38 +1,52 @@ -/** A credential attached to a browser session. */ +/** A secret attached to a browser session, which fill may type into its pages. */ export interface SessionCredential { id: string; name: string; } -/** Which stored field to type into which input. */ +/** + * What to attach to a browser session at creation: a secret by name (or + * `{ secret }`), or `{ vault }` for the secrets a stored vault's entries name. + */ +export type SessionCredentialSource = string | { secret: string } | { vault: string }; + +/** Which input to fill and, with a vault, from which entry. */ export interface CredentialFillField { - /** A key of the credential's value, such as `username`; `^[a-zA-Z0-9_.-]{1,64}$`. */ - field: string; - /** A CSS selector for the input, 1-1024 UTF-8 bytes. */ + /** 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 with `credential`. + */ + field?: string; } export interface FillCredentialParams { - /** The credential's name (or ID); it must be attached to the session. */ - credential: string; - /** 1-16 fields, each field name at most once. */ + /** A secret attached to the session, by name or ID. Pass this or `vault`. */ + credential?: string; + /** A vault attached to the session, by name. Pass this or `credential`. */ + vault?: string; + /** 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`. + * target; see `CREDENTIAL_PAGE_AMBIGUOUS`. Not with `pageUrl`. */ pageId?: string; - /** Fill inside the iframe whose URL starts with `url`. */ - frame?: { url: 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 exactly the value. + * - `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`). - * - `not_populated`: the stored value has no such field. + * + * Outcomes added later come through as other strings. */ export type CredentialFillOutcome = | "filled" @@ -40,20 +54,27 @@ export type CredentialFillOutcome = | "ambiguous" | "not_editable" | "not_accepted" - | "not_populated" | (string & {}); export interface CredentialFillFieldResult { - field: string; + selector: string; outcome: CredentialFillOutcome; + /** The secret typed into this input. */ + secretId: string; + /** Its version. */ + version: number; + /** The vault entry, for vault fills. */ + field?: string; + /** For a TOTP secret, when the code that was typed stops being valid. */ + expiresAt?: string; } -/** Outcomes per field; never values. */ +/** Outcomes per input; never values. */ export interface CredentialFillResult { - /** The credential version that was filled. */ - version: number; - pageId?: string; - origin?: string; + /** The secret version filled, for a single-secret fill. */ + version?: number; + pageId: string; + origin: string; fields: CredentialFillFieldResult[]; } diff --git a/src/types/index.ts b/src/types/index.ts index 4a45bd8..41429fc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -293,6 +293,7 @@ export { } from "./secret"; export { SessionCredential, + SessionCredentialSource, CredentialFillField, FillCredentialParams, CredentialFillOutcome, diff --git a/src/types/session.ts b/src/types/session.ts index 719f157..cca661f 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -10,7 +10,7 @@ import { SessionRegion, State, } from "./constants"; -import { SessionCredential } from "./credential"; +import { SessionCredential, SessionCredentialSource } from "./credential"; export type SessionStatus = "active" | "closed" | "close-error" | "error"; export type BrowserMemorySize = "small" | "medium" | "large"; @@ -92,7 +92,7 @@ export interface SessionDetail extends Session { liveUrl?: string; liveDomain?: string; token: string; - /** Credentials attached at creation, when there are any. */ + /** The secrets attached at creation, when any were requested. */ credentials?: SessionCredential[]; } @@ -269,11 +269,14 @@ export interface CreateSessionParams { browserMemorySize?: BrowserMemorySize; startFromSnapshot?: StartSessionFromSnapshotParams; /** - * Names of credentials (at most 20) that `sessions.credentials.fill` may type - * into pages. Credentials can only be attached at creation; the session runs - * on Firecracker with Chrome's password manager off. + * Secrets that `sessions.credentials.fill` may type into pages, at most 20: + * a secret's name (or `{ secret: name }`), or `{ vault: name }` for the + * secrets a stored vault's entries name when the session is created. + * 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?: string[]; + credentials?: SessionCredentialSource[]; } export interface CreateSessionSnapshotResponse { diff --git a/tests/unit/credentials-fill.test.ts b/tests/unit/credentials-fill.test.ts index 529c64c..ff0c80d 100644 --- a/tests/unit/credentials-fill.test.ts +++ b/tests/unit/credentials-fill.test.ts @@ -42,14 +42,27 @@ const lastRequest = () => { }; const filled: CredentialFillResult = { - version: 3, pageId: "TARGET-1", origin: "https://github.com", fields: [ - { field: "username", outcome: "filled" }, - { field: "password", outcome: "not_found" }, + { 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", @@ -60,63 +73,77 @@ const ambiguous = { ], }, }; -const fields = [ - { field: "username", selector: "#login_field" }, - { field: "password", selector: "#password" }, +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("credentials are sent on create and returned on the session", async () => { - const attached = [{ id: "c1", name: "github" }]; + 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"] }; + 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"] }); + 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("fill posts to the session's route with a timeout above the server's", async () => { + 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, { - credential: "github", - fields, - pageId: "TARGET-1", - frame: { url: "https://github.com/" }, + 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({ - credential: "github", - fields, - pageId: "TARGET-1", + 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("fill keeps a longer client timeout and omits absent options", async () => { - fetchMock.mockResolvedValue(respond(filled)); - await client(90_000).sessions.credentials.fill(SESSION, { credential: "github", fields }); + 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", fields }); + expect(body).toEqual({ credential: "github-password", fields, pageId: "TARGET-1" }); + expect(result.version).toBe(3); }); test("unknown outcomes pass through", async () => { fetchMock.mockResolvedValue( - respond({ version: 1, fields: [{ field: "otp", outcome: "not_visible_yet" }] }) + respond({ ...single, fields: [{ ...single.fields[0], outcome: "not_visible_yet" }] }) ); const result = await client().sessions.credentials.fill(SESSION, { - credential: "github", - fields: [{ field: "otp", selector: "#otp" }], + credential: "github-password", + fields, }); expect(result.fields[0].outcome).toBe("not_visible_yet"); }); @@ -124,7 +151,7 @@ describe("sessions", () => { 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, { credential: "github", fields }) + .sessions.credentials.fill(SESSION, { vault: "github-login", fields: vaultFields }) .catch((e) => e); expect(error).toBeInstanceOf(HyperbrowserError); expect(error).toMatchObject({ @@ -140,7 +167,7 @@ describe("sessions", () => { 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", fields }) + client().sessions.credentials.fill(SESSION, { credential: "github-password", fields }) ).rejects.toMatchObject({ code: "CREDENTIAL_FILL_TIMEOUT", retryable: true, @@ -149,34 +176,54 @@ describe("sessions", () => { }); test.each([ - ["no credential", { credential: "", fields }], - ["no fields", { credential: "github", fields: [] }], + ["neither credential nor vault", { fields }], + ["both credential and vault", { credential: "a", vault: "b", fields: vaultFields }], + ["an empty credential", { credential: "", fields }], + ["a credential over 255 characters", { credential: "c".repeat(256), fields }], + ["a vault that isn't a vault name", { vault: "github login", fields: vaultFields }], + ["no fields", { credential: "github-password", fields: [] }], [ "17 fields", { - credential: "github", - fields: Array.from({ length: 17 }, (_, i) => ({ field: `f${i}`, selector: "#x" })), + credential: "github-password", + fields: Array.from({ length: 17 }, (_, i) => ({ selector: `#f${i}` })), }, ], [ - "a repeated field", - { credential: "github", fields: [fields[0], { field: "username", selector: "#other" }] }, + "a repeated selector", + { + vault: "github-login", + fields: [vaultFields[0], { field: "PASSWORD", selector: "#login_field" }], + }, ], + ["a field with a credential", { credential: "github-password", fields: [vaultFields[1]] }], + ["a vault fill without field", { vault: "github-login", fields }], [ - "a bad field name", - { credential: "github", fields: [{ field: "user name", selector: "#u" }] }, + "a field that isn't an entry name", + { vault: "github-login", fields: [{ field: "pass word", selector: "#p" }] }, ], - ["an empty selector", { credential: "github", fields: [{ field: "username", selector: "" }] }], + ["an empty selector", { credential: "github-password", fields: [{ selector: "" }] }], [ "a selector over 1024 bytes", - { credential: "github", fields: [{ field: "username", selector: "é".repeat(513) }] }, + { credential: "github-password", fields: [{ selector: "é".repeat(513) }] }, + ], + ["an empty page ID", { credential: "github-password", fields, pageId: "" }], + [ + "a page ID over 128 bytes", + { credential: "github-password", fields, pageId: "p".repeat(129) }, + ], + [ + "pageId with pageUrl", + { credential: "github-password", fields, pageId: "A", pageUrl: "https://a" }, + ], + [ + "a page URL over 2048 bytes", + { credential: "github-password", fields, pageUrl: "u".repeat(2049) }, ], - ["an empty page ID", { credential: "github", fields, pageId: "" }], - ["a page ID over 128 bytes", { credential: "github", fields, pageId: "p".repeat(129) }], - ["a frame without a URL", { credential: "github", fields, frame: {} }], + ["an empty frame URL", { credential: "github-password", fields, frameUrl: "" }], [ "a frame URL over 2048 bytes", - { credential: "github", fields, frame: { url: `https://a.test/${"x".repeat(2040)}` } }, + { credential: "github-password", fields, frameUrl: "u".repeat(2049) }, ], ])("fill rejects %s before any request", async (_name, params) => { await expect(client().sessions.credentials.fill(SESSION, params as any)).rejects.toMatchObject({ @@ -188,20 +235,25 @@ describe("sessions", () => { test("fill requires a session ID", async () => { await expect( - client().sessions.credentials.fill("", { credential: "github", fields }) + 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 frame URL are accepted", async () => { - fetchMock.mockResolvedValue(respond(filled)); + 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", - fields: [{ field: "username", selector: "é".repeat(512) }], + credential: "github-password", + fields: [{ selector: "é".repeat(512) }], pageId: "p".repeat(128), - frame: { url: "u".repeat(2048) }, + frameUrl: "u".repeat(2048), }); - expect(fetchMock).toHaveBeenCalledTimes(1); + await client().sessions.credentials.fill(SESSION, { + credential: "github-password", + fields, + pageUrl: "u".repeat(2048), + }); + expect(fetchMock).toHaveBeenCalledTimes(2); }); }); @@ -231,6 +283,8 @@ const collectKeys = (schema: unknown, keys = new Set()): Set => }; 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"); @@ -239,12 +293,20 @@ describe("fill_credential tool", () => { 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 }); - expect(FILL_CREDENTIAL_SCHEMA.properties.page_id.type).toEqual(["string", "null"]); + 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); }); @@ -260,17 +322,20 @@ describe("fill_credential tool", () => { const description = FILL_CREDENTIAL_TOOL_ANTHROPIC.description!; for (const word of [ "NOT submit", + "vault", + "TOTP", "filled", "not_found", "ambiguous", "not_editable", "not_accepted", - "not_populated", "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", () => { @@ -284,7 +349,8 @@ describe("fill_credential tool", () => { expect(declaration.parameters).toEqual({ type: "OBJECT", properties: { - credential: { type: "STRING", description: expect.any(String) }, + credential: nullable, + vault: nullable, fields: { type: "ARRAY", description: expect.any(String), @@ -293,16 +359,17 @@ describe("fill_credential tool", () => { items: { type: "OBJECT", properties: { - field: { type: "STRING", description: expect.any(String) }, selector: { type: "STRING", description: expect.any(String) }, + field: nullable, }, - required: ["field", "selector"], + required: ["selector", "field"], }, }, - page_id: { type: "STRING", nullable: true, description: expect.any(String) }, - frame_url: { type: "STRING", nullable: true, description: expect.any(String) }, + page_id: nullable, + page_url: nullable, + frame_url: nullable, }, - required: ["credential", "fields", "page_id", "frame_url"], + required: ["credential", "vault", "fields", "page_id", "page_url", "frame_url"], }); }); @@ -335,44 +402,56 @@ describe("fill_credential tool", () => { }); }); - test("runnable maps model input to the API and returns JSON text", async () => { + 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: "github", - fields: [{ field: "username", selector: "#login_field", extra: "dropped" } as any], - page_id: "TARGET-1", + 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({ - credential: "github", - fields: [{ field: "username", selector: "#login_field" }], - pageId: "TARGET-1", + 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("null page_id and frame_url are left out", async () => { - fetchMock.mockResolvedValue(respond(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", - fields, - page_id: null, + 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", fields }); + expect(lastRequest().body).toEqual({ + credential: "github-password", + fields: [{ selector: "#password" }], + pageId: "TARGET-1", + }); }); 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", + credential: "github-password", fields, - page_id: null, - frame_url: null, }); expect(JSON.parse(output)).toEqual({ error: { @@ -387,7 +466,7 @@ describe("fill_credential tool", () => { respond({ code: "CREDENTIAL_FILL_TIMEOUT", message: "timed out", retryable: true }, 504) ); const timedOut = await CredentialFillTool.runnable(client(), SESSION, { - credential: "github", + credential: "github-password", fields, }); expect(JSON.parse(timedOut)).toEqual({ @@ -400,11 +479,13 @@ describe("fill_credential tool", () => { }); }); - test("invalid model input comes back as JSON without a request", async () => { - const output = await CredentialFillTool.runnable(client(), SESSION, { - credential: "github", - fields: [{ field: "password", selector: "" }], - }); + test.each([ + ["an empty selector", { credential: "github-password", fields: [{ selector: "" }] }], + ["both names", { credential: "a", vault: "b", fields: vaultFields }], + ["a vault without fields' entries", { vault: "github-login", fields }], + ["no input at all", undefined], + ])("invalid model input (%s) comes back as JSON without a request", async (_name, input) => { + const output = await CredentialFillTool.runnable(client(), SESSION, input as any); expect(JSON.parse(output)).toMatchObject({ error: { code: "invalid_request", retryable: false, details: null }, }); @@ -415,7 +496,7 @@ describe("fill_credential tool", () => { const hb = client(); vi.spyOn(hb.sessions.credentials, "fill").mockRejectedValue(new TypeError("bug")); await expect( - CredentialFillTool.runnable(hb, SESSION, { credential: "github", fields }) + CredentialFillTool.runnable(hb, SESSION, { credential: "github-password", fields }) ).rejects.toThrow(TypeError); }); }); From f0933061d753192c2617d46ea4aed8b800037a7b Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:28:20 -0700 Subject: [PATCH 07/23] Replace credential secrets with allowed origins, TOTP and op_token types - secrets.create takes allowedOrigins (sent as metadata.browser) and a type of "secret", "totp" or "op_token". createCredential, CredentialValue and object values are gone. - Values are checked per type before anything is created: every type gets the common rules, and a TOTP value must be a base32 seed or an otpauth://totp/ URI with a valid secret, algorithm, digits and period, read the way vm-service reads it. Seeds under 10 bytes are accepted with a process warning. op_token values are not checked for ops_. - An op_token with allowedOrigins is refused locally. - addVersion applies the rules of the resolved secret's type; types the SDK doesn't know get the common rules only. - Secret.type is a string so later types are read, and list passes any type filter through. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- src/secret-values.ts | 85 ++++++++ src/secrets-envelope.ts | 59 +----- src/services/secrets.ts | 126 ++++-------- src/types/index.ts | 2 - src/types/secret.ts | 59 +++--- tests/unit/secrets-contract.test.ts | 299 ++++++++++++++-------------- 6 files changed, 315 insertions(+), 315 deletions(-) create mode 100644 src/secret-values.ts diff --git a/src/secret-values.ts b/src/secret-values.ts new file mode 100644 index 0000000..ea86ed1 --- /dev/null +++ b/src/secret-values.ts @@ -0,0 +1,85 @@ +import { invalidSecretValue, secretValueBytes } from "./secrets-envelope"; +import { SecretValue } from "./types/secret"; + +/** Seeds shorter than this (80 bits) are weak; RFC 4226 recommends 160. */ +const MIN_RECOMMENDED_TOTP_SEED_BYTES = 10; +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. */ +const BASE32_TAIL_LENGTHS = new Set([0, 2, 4, 5, 7]); +const BYTE_ORDER_MARK = ""; + +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"; + +/** + * The bytes a base32 seed decodes to, read the way the platform reads it: + * spaces, '-', lowercase and trailing '=' padding are accepted. Undefined + * when it isn't base32. + */ +const base32SeedBytes = (seed: string): number | undefined => { + const normalized = seed.replace(/[ -]/g, "").toUpperCase().replace(/=+$/, ""); + if (!/^[A-Z2-7]+$/.test(normalized) || !BASE32_TAIL_LENGTHS.has(normalized.length % 8)) { + return undefined; + } + return Math.floor((normalized.length * 5) / 8); +}; + +const isIntegerIn = (text: string, allowed: (value: number) => boolean): boolean => + /^[+-]?\d{1,9}$/.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. Short + * seeds are accepted with a process warning. + */ +export const checkTotpSeed = (value: string): void => { + let seed = value.startsWith(BYTE_ORDER_MARK) ? value.slice(1) : value; + seed = seed.trim(); + if (seed.toLowerCase().startsWith("otpauth://")) { + let url: URL; + try { + url = new URL(seed); + } catch { + throw invalidSecretValue(INVALID_TOTP); + } + if (url.protocol !== "otpauth:" || url.host.toLowerCase() !== "totp") { + throw invalidSecretValue(INVALID_TOTP); + } + const algorithm = url.searchParams.get("algorithm"); + const digits = url.searchParams.get("digits"); + const period = url.searchParams.get("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 = url.searchParams.get("secret") ?? ""; + } + const bytes = base32SeedBytes(seed); + if (bytes === undefined || bytes === 0) { + throw invalidSecretValue(INVALID_TOTP); + } + if (bytes < MIN_RECOMMENDED_TOTP_SEED_BYTES) { + process.emitWarning( + `The TOTP seed is ${bytes} bytes; seeds are usually at least ${MIN_RECOMMENDED_TOTP_SEED_BYTES} (16 base32 characters). Check that it was copied whole.`, + { code: "HYPERBROWSER_TOTP_SEED_SHORT" } + ); + } +}; + +/** + * 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 a3f8648..c247822 100644 --- a/src/secrets-envelope.ts +++ b/src/secrets-envelope.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; import { HyperbrowserError } from "./error"; -import { CredentialValue, SecretsPublicKey, SecretValue } from "./types/secret"; +import { SecretsPublicKey, SecretValue } from "./types/secret"; /** The largest secret value the platform accepts. */ export const MAX_SECRET_VALUE_BYTES = 32 * 1024; @@ -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,31 +20,26 @@ 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; }; -/** Credential field names: the keys a fill can write. */ -export const CREDENTIAL_FIELD_PATTERN = /^[a-zA-Z0-9_.-]{1,64}$/; - -const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? => { if (value === null || typeof value !== "object") return false; @@ -51,45 +47,6 @@ export const isPlainObject = (value: unknown): value is Record return prototype === Object.prototype || prototype === null; }; -/** - * Apply the platform's credential rules locally and return the UTF-8 JSON - * that is encrypted. Messages name fields, never values. - */ -export const credentialValueBytes = (value: CredentialValue): Buffer => { - if (!isPlainObject(value)) { - throw invalid("Credential value must be an object of field names to strings"); - } - const fields = Object.keys(value); - if (fields.length === 0) { - throw invalid("Credential value has no fields"); - } - for (const field of fields) { - if (!CREDENTIAL_FIELD_PATTERN.test(field)) { - throw invalid( - `Credential field name ${JSON.stringify(field.slice(0, 64))} must be 1-64 letters, digits, '_', '.' or '-'` - ); - } - const fieldValue: unknown = value[field]; - if (typeof fieldValue !== "string") { - throw invalid(`Credential field ${field} must be a string`); - } - if (fieldValue.length === 0) { - throw invalid(`Credential field ${field} is empty`); - } - if (fieldValue.includes("\0")) { - throw invalid(`Credential field ${field} must not contain NUL characters`); - } - if (LONE_SURROGATE.test(fieldValue)) { - throw invalid(`Credential field ${field} must be valid UTF-8 text`); - } - } - const bytes = Buffer.from(JSON.stringify(value), "utf8"); - if (bytes.length > MAX_SECRET_VALUE_BYTES) { - throw invalid(`Credential value is more than ${MAX_SECRET_VALUE_BYTES} bytes as JSON`); - } - return bytes; -}; - const minimalBytes = (value: Buffer): Buffer => { let start = 0; while (start < value.length - 1 && value[start] === 0) start += 1; diff --git a/src/services/secrets.ts b/src/services/secrets.ts index 282be0c..53b1546 100644 --- a/src/services/secrets.ts +++ b/src/services/secrets.ts @@ -1,22 +1,15 @@ import { HyperbrowserError, invalidRequest } from "../error"; import { MAX_SECRET_VERSION, SECRET_NAME_PATTERN } from "../sandbox/secret-references"; +import { secretPlaintext } from "../secret-values"; +import { isPlainObject, secretValueBytes, SecretsSealer } from "../secrets-envelope"; import { - credentialValueBytes, - isPlainObject, - secretValueBytes, - SecretsSealer, -} from "../secrets-envelope"; -import { - CreateCredentialParams, CreateSecretParams, - CredentialValue, Secret, SecretDeleteResult, SecretDetail, SecretListParams, SecretListResponse, SecretsPublicKey, - SecretType, SecretValue, SecretVersion, SecretVersionDeleteResult, @@ -32,9 +25,9 @@ const NAME_LOOKUP_LIMIT = MAX_LIST_LIMIT; const secretPath = (id: string): string => `/secrets/${encodeURIComponent(id)}`; -const SECRET_TYPES: readonly SecretType[] = ["secret", "credential"]; -/** The most allowed origins a credential can have. */ +/** The most allowed origins a secret can have, and the longest one. */ const MAX_ALLOWED_ORIGINS = 20; +const MAX_ALLOWED_ORIGIN_LENGTH = 512; const rethrow = (error: unknown, message: string): never => { if (error instanceof HyperbrowserError) { @@ -43,19 +36,16 @@ const rethrow = (error: unknown, message: string): never => { throw new HyperbrowserError(message, { cause: error }); }; -const checkType = (type: unknown): void => { - if (type !== undefined && !SECRET_TYPES.includes(type as SecretType)) { - throw invalidRequest(`Secret type must be one of ${SECRET_TYPES.join(", ")}`); - } -}; - /** Light checks only; the API normalizes origins and reports bad entries. */ const checkAllowedOrigins = (allowedOrigins: unknown): void => { if ( !Array.isArray(allowedOrigins) || allowedOrigins.length < 1 || allowedOrigins.length > MAX_ALLOWED_ORIGINS || - allowedOrigins.some((origin) => typeof origin !== "string" || !origin.trim()) + allowedOrigins.some( + (origin) => + typeof origin !== "string" || !origin.trim() || origin.length > MAX_ALLOWED_ORIGIN_LENGTH + ) ) { throw invalidRequest( `allowedOrigins must be 1-${MAX_ALLOWED_ORIGINS} origins such as "https://example.com"` @@ -63,43 +53,6 @@ const checkAllowedOrigins = (allowedOrigins: unknown): void => { } }; -const isBytesOrString = (value: unknown): value is SecretValue => - typeof value === "string" || value instanceof Uint8Array; - -const wrongShape = (message: string): HyperbrowserError => - new HyperbrowserError(message, { code: "invalid_secret_value", retryable: false }); - -/** - * Check a value against the platform rules for either shape, so a bad value - * is reported before anything is fetched or created. - */ -const valueBytes = (value: SecretValue | CredentialValue): Buffer => { - if (isBytesOrString(value)) { - return secretValueBytes(value); - } - if (isPlainObject(value)) { - return credentialValueBytes(value as CredentialValue); - } - throw wrongShape( - "Secret value must be a string or bytes, or for a credential an object of field names to strings" - ); -}; - -/** The plaintext stored for a secret of `type`: raw bytes, or a credential's UTF-8 JSON. */ -const versionPlaintext = (type: SecretType, value: SecretValue | CredentialValue): Buffer => { - if (type === "credential" && isBytesOrString(value)) { - throw wrongShape( - "A credential's value must be an object of field names to strings, such as { username, password }" - ); - } - if (type !== "credential" && !isBytesOrString(value)) { - throw wrongShape( - 'A secret\'s value must be a string or bytes; create a type "credential" secret for an object of fields' - ); - } - return valueBytes(value); -}; - /** * Secrets are encrypted locally to the platform's public key; the API only * ever receives envelopes, and values are never returned. @@ -129,7 +82,9 @@ export class SecretsService extends BaseService { ) { throw new HyperbrowserError(`limit must be an integer from 1 to ${MAX_LIST_LIMIT}`); } - checkType(params.type); + 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, @@ -144,23 +99,32 @@ 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, type, metadata, 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" ); } - checkType(type); - if (metadata !== undefined && !isPlainObject(metadata)) { - throw invalidRequest("Secret metadata must be an object"); + if (type !== undefined && (typeof type !== "string" || !type)) { + throw invalidRequest('type must be "secret", "totp" or "op_token"'); } - const plaintext = value === undefined ? undefined : versionPlaintext(type ?? "secret", value); + if (allowedOrigins !== undefined) { + if (type === "op_token") { + throw invalidRequest( + "op_token secrets can't have allowedOrigins: they are never filled into browsers" + ); + } + checkAllowedOrigins(allowedOrigins); + } + 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 { @@ -229,15 +193,16 @@ export class SecretsService extends BaseService { } /** - * Encrypt `value` locally and store it as the secret's next version: a - * `CredentialValue` object for credentials, a string or bytes otherwise. + * 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 | CredentialValue): Promise { - valueBytes(value); + async addVersion(secret: string, value: SecretValue): Promise { + secretValueBytes(value); const sealer = SecretsSealer.fromPublicKey(await this.getPublicKey()); try { const resolved = await this.resolve(secret); - const plaintext = versionPlaintext(resolved.type ?? "secret", value); + 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}`); @@ -245,28 +210,9 @@ export class SecretsService extends BaseService { } /** - * Create a website login credential that browser sessions can fill into - * pages on `allowedOrigins`, and store its first value. The value is checked - * and encrypted locally; it is never returned. - */ - async createCredential(params: CreateCredentialParams): Promise { - const { name, value, allowedOrigins, description } = params ?? ({} as CreateCredentialParams); - if (value === undefined || value === null) { - throw wrongShape("A credential needs a value, such as { username, password }"); - } - checkAllowedOrigins(allowedOrigins); - return this.create({ - name, - description, - type: "credential", - metadata: { browser: { allowedOrigins } }, - value, - }); - } - - /** - * Replace the origins a credential may be filled into, or clear them with - * `null` (the credential then can't be filled until origins are set again). + * 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 { if (allowedOrigins !== null) { diff --git a/src/types/index.ts b/src/types/index.ts index 41429fc..6463cbf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -274,10 +274,8 @@ export { export { SecretValue, SecretType, - CredentialValue, SecretBrowserMetadata, SecretMetadata, - CreateCredentialParams, UpdateSecretMetadata, SecretsPublicKey, SecretsPublicKeyJwk, diff --git a/src/types/secret.ts b/src/types/secret.ts index 8530b33..225e225 100644 --- a/src/types/secret.ts +++ b/src/types/secret.ts @@ -2,26 +2,25 @@ export type SecretValue = string | Uint8Array; /** - * `"secret"` values are delivered to sandboxes; `"credential"` values are - * website logins filled into browser sessions. Fixed at creation. + * 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 by 1Password + * vaults. It is never an environment variable or filled into a page. */ -export type SecretType = "secret" | "credential"; - -/** - * A credential's value: field names (such as `username` and `password`) to - * non-empty strings. Keys match `^[a-zA-Z0-9_.-]{1,64}$`. - */ -export type CredentialValue = Record; +export type SecretType = "secret" | "totp" | "op_token"; export interface SecretBrowserMetadata { /** - * Origins a credential may be filled into, as `scheme://host[:port]`. A + * Origins the secret may be filled into, as `scheme://host[:port]`. A * leading `*.` matches any subdomain (not the domain itself). */ allowedOrigins: string[]; } -/** Plain secrets always have `{}`. */ +/** `{}` until allowed origins are set. */ export interface SecretMetadata { browser?: SecretBrowserMetadata; [member: string]: unknown; @@ -45,7 +44,11 @@ export interface Secret { id: string; name: string; description: string; - type: SecretType; + /** + * 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; @@ -71,24 +74,24 @@ export interface CreateSecretParams { description?: string; /** Defaults to `"secret"`. */ type?: SecretType; - /** For credentials, `{ browser: { allowedOrigins } }`. */ - metadata?: SecretMetadata; /** - * The first version's value: a `CredentialValue` object for credentials, a - * string or bytes otherwise. 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, lowercase and `=` padding are fine) or + * an `otpauth://totp/...?secret=...` URI, optionally with `algorithm` + * (SHA1, SHA256 or SHA512), `digits` (6 or 8) and `period` (1-300). + * - `"op_token"`: a 1Password service account token. These start with + * `ops_`, but other values are not refused. */ - value?: SecretValue | CredentialValue; -} - -export interface CreateCredentialParams { - /** 1-128 letters, digits, '_' or '-', starting with a letter or digit. */ - name: string; - /** Field names to values, such as `{ username, password }`. */ - value: CredentialValue; - /** Origins the credential may be filled into, such as `["https://github.com"]`. */ - allowedOrigins: string[]; - description?: string; + 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[]; } /** diff --git a/tests/unit/secrets-contract.test.ts b/tests/unit/secrets-contract.test.ts index 63476dd..a8e5999 100644 --- a/tests/unit/secrets-contract.test.ts +++ b/tests/unit/secrets-contract.test.ts @@ -285,195 +285,210 @@ describe("secrets service", () => { }); }); -describe("credentials", () => { - const CREDENTIAL_CONTEXT = `hb-secret-v1:team:${ID}:credential`; - const credential = (overrides: Record = {}) => +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", - type: "credential", - metadata: { browser: { allowedOrigins: ["https://github.com"] } }, - encryptionContext: CREDENTIAL_CONTEXT, + name: "github-otp", + type: "totp", + metadata: {}, + encryptionContext: TOTP_CONTEXT, ...overrides, }); - const login = { username: "lovelace-1815", password: "pässwörd-1" }; + 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("createCredential creates a typed secret and seals its JSON to the returned context", async () => { - const { service, calls, requestSpy } = mockService((method, path) => { - if (path === "/secrets/public-key") return platformPublicKey; - if (method === "POST" && path === "/secrets") return credential(); - if (method === "POST" && path === `/secrets/${ID}/versions`) return version(1); - throw new Error(`unexpected ${method} ${path}`); - }); - - const created = await service.createCredential({ - name: "github", - value: login, + 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`, ]); - const createBody = (requestSpy.mock.calls[1][1] as any).body as string; - expect(JSON.parse(createBody)).toEqual({ - name: "github", + expect(JSON.parse((requestSpy.mock.calls[1][1] as any).body)).toEqual({ + name: "github-password", description: "GitHub bot", - type: "credential", metadata: { browser: { allowedOrigins: ["https://github.com"] } }, }); - const uploadBody = (requestSpy.mock.calls[2][1] as any).body as string; - const { header, value } = open(JSON.parse(uploadBody).envelope); - expect(header.ctx).toBe(CREDENTIAL_CONTEXT); - expect(value.toString("utf8")).toBe(JSON.stringify(login)); - // The plaintext is nowhere else on the wire. - for (const [, init] of requestSpy.mock.calls as any[]) { - expect(String(init?.body ?? "")).not.toContain("lovelace"); - expect(String(init?.body ?? "")).not.toContain("pässwörd"); - } - expect(created).toMatchObject({ type: "credential", latestVersion: 1 }); + 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("create with type credential takes the same path", async () => { - const { service, requestSpy } = mockService((method, path) => { - if (path === "/secrets/public-key") return platformPublicKey; - if (method === "POST" && path === "/secrets") return credential(); - return version(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", }); - await service.create({ name: "github", type: "credential", value: { token: "t" } }); - expect( - open(JSON.parse((requestSpy.mock.calls[2][1] as any).body).envelope).value.toString() - ).toBe('{"token":"t"}'); + 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([ - ["an empty object", {}, /no fields/], - ["a bad key", { "user name": "ada" }, /field name "user name"/], - ["a long key", { ["k".repeat(65)]: "x" }, /must be 1-64/], - ["an empty value", { username: "" }, /username is empty/], - ["a non-string value", { pin: 1234 }, /pin must be a string/], - ["a NUL", { password: "a\0b" }, /NUL/], - ["a lone surrogate", { password: "a\uD800b" }, /valid UTF-8/], - ["an array", ["ada"], /object of field names/], - ["a string", "ada:pw", /must be an object of field names/], - ["bytes", new Uint8Array([1]), /must be an object of field names/], - ["an oversized value", { password: "x".repeat(32 * 1024) }, /more than 32768 bytes/], - ])("credential values with %s fail locally", async (_name, value, message) => { - const { service, calls } = mockService(() => platformPublicKey); - const error = await service - .create({ name: "github", type: "credential", value: value as any }) - .catch((e) => e); + ["base32", SEED], + ["lowercase with spaces and padding", "jbsw y3dp ehpk 3pxp===="], + ["a byte-order mark", `${SEED}`], + ["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"], + ["an impossible length", "JBSWY3DPE"], + ["a HOTP URI", `otpauth://hotp/x?secret=${SEED}`], + ["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(message); - expect(error.message).not.toContain("a\0b"); + expect(error.message).toMatch(/TOTP/); + expect(error.message).not.toContain(value); expect(calls()).toEqual([]); }); - test("plain secrets reject objects, and createCredential checks its inputs, before any request", async () => { - const { service, calls } = mockService(() => platformPublicKey); - await expect(service.create({ name: "k", value: login })).rejects.toMatchObject({ - code: "invalid_secret_value", - }); - await expect( - service.createCredential({ name: "github", value: login, allowedOrigins: [] }) - ).rejects.toMatchObject({ code: "invalid_request" }); - await expect( - service.createCredential({ name: "github", value: login, allowedOrigins: [""] }) - ).rejects.toMatchObject({ code: "invalid_request" }); - await expect( - service.createCredential({ - name: "github", - value: "secret" as any, - allowedOrigins: ["https://github.com"], - }) - ).rejects.toMatchObject({ code: "invalid_secret_value" }); - await expect(service.create({ name: "k", type: "password" as any })).rejects.toMatchObject({ - code: "invalid_request", - }); - expect(calls()).toEqual([]); + test("a short TOTP seed is accepted with a warning", async () => { + const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined); + try { + const { service } = mockService(created(totp())); + await service.create({ name: "github-otp", type: "totp", value: "JBSWY3DP" }); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/5 bytes/), { + code: "HYPERBROWSER_TOTP_SEED_SHORT", + }); + warn.mockClear(); + await service.create({ name: "github-otp", type: "totp", value: SEED }); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } }); - test("the largest credential value is accepted", async () => { - const { service } = mockService((method, path) => { - if (path === "/secrets/public-key") return platformPublicKey; - if (method === "POST" && path === "/secrets") return credential(); - return version(1); - }); - const padding = 32 * 1024 - JSON.stringify({ p: "" }).length; + test("op_token values are any string, and op_token secrets can't have allowed origins", async () => { + const { service, calls, 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 + ); + requestSpy.mockClear(); await expect( - service.create({ name: "github", type: "credential", value: { p: "x".repeat(padding) } }) - ).resolves.toMatchObject({ latestVersion: 1 }); + service.create({ + name: "op-token", + type: "op_token", + value: "ops_x", + allowedOrigins: ["https://a.com"], + }) + ).rejects.toMatchObject({ code: "invalid_request" }); + expect(calls()).toEqual([]); }); - test("a failed upload after creating a credential reports the partial create", async () => { - const { service } = mockService((method, path) => { - if (path === "/secrets/public-key") return platformPublicKey; - if (method === "POST" && path === "/secrets") return credential(); - return new HyperbrowserError("key outdated", { statusCode: 409 }); - }); - await expect( - service.createCredential({ - name: "github", - value: login, - allowedOrigins: ["https://github.com"], - }) - ).rejects.toMatchObject({ code: "secret_value_not_stored", details: { secret: credential() } }); + test("bad allowedOrigins fail before any request", async () => { + const { service, calls } = mockService(created(secret())); + for (const allowedOrigins of [ + [], + [""], + ["x".repeat(513)], + Array(21).fill("https://a.com"), + "https://a.com", + ]) { + await expect( + service.create({ name: "k", value: "v", allowedOrigins: allowedOrigins as any }) + ).rejects.toMatchObject({ code: "invalid_request" }); + } + expect(calls()).toEqual([]); }); - test("addVersion requires the shape of the resolved secret's type", async () => { - const plain = secret(); + 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" ? credential() : plain); + 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", "just-a-string")).rejects.toMatchObject({ - code: "invalid_secret_value", - }); - await expect(service.addVersion("provider-key", login)).rejects.toMatchObject({ + 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", login)).resolves.toMatchObject({ version: 2 }); - const { header, value } = open(JSON.parse((requestSpy.mock.lastCall![1] as any).body).envelope); - expect(header.ctx).toBe(CREDENTIAL_CONTEXT); - expect(value.toString("utf8")).toBe(JSON.stringify(login)); - - await expect(service.addVersion("provider-key", "next")).resolves.toMatchObject({ version: 2 }); - expect( - open(JSON.parse((requestSpy.mock.lastCall![1] as any).body).envelope).value.toString() - ).toBe("next"); + 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("addVersion rejects invalid values of either shape before any request", async () => { - const { service, calls } = mockService(() => platformPublicKey); - await expect(service.addVersion("github", { username: "" })).rejects.toThrow(/empty/); - await expect(service.addVersion("github", 42 as any)).rejects.toMatchObject({ - code: "invalid_secret_value", + 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); }); - expect(calls()).toEqual([]); + 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(credential()); - if (method === "PATCH") return credential({ metadata: {} }); + if (path === "/secrets") return listOf(withOrigins); + if (method === "PATCH") return secret({ metadata: {} }); throw new Error(`unexpected ${method} ${path}`); }); - await service.setAllowedOrigins("github", ["https://github.com", "https://*.github.com"]); + 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("github", null)).resolves.toMatchObject({ + await expect(service.setAllowedOrigins("provider-key", null)).resolves.toMatchObject({ metadata: {}, }); expect((requestSpy.mock.calls[3][1] as any).body).toBe('{"metadata":{"browser":null}}'); @@ -484,29 +499,25 @@ describe("credentials", () => { `PATCH /secrets/${ID}`, ]); - await expect(service.setAllowedOrigins("github", [])).rejects.toMatchObject({ + await expect(service.setAllowedOrigins("provider-key", [])).rejects.toMatchObject({ + code: "invalid_request", + }); + await expect(service.update("provider-key", {})).rejects.toMatchObject({ code: "invalid_request", }); - await expect( - service.setAllowedOrigins("github", Array(21).fill("https://github.com")) - ).rejects.toMatchObject({ code: "invalid_request" }); - await expect(service.update("github", {})).rejects.toMatchObject({ code: "invalid_request" }); expect(calls()).toHaveLength(4); }); - test("list filters by type", async () => { - const { service, requestSpy } = mockService(() => listOf(credential())); - await expect(service.list({ type: "credential" })).resolves.toMatchObject({ - secrets: [{ type: "credential" }], + 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: "credential", - }); - await expect(service.list({ type: "login" as any })).rejects.toMatchObject({ - code: "invalid_request", + type: "totp", }); }); }); From ef9bcb89a5660d57c26fd91274a804ed41f86c26 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:28:40 -0700 Subject: [PATCH 08/23] Add client.vaults for stored and 1Password vaults - list({ page, limit, search }), get, create, update and delete, taking a vault's ID or exact name like secrets do. - create takes a source ({ type: "stored" } by default, or { type: "1password", tokenSecret, vault }) and entries: { name, version? } for stored vaults, { item, section?, field } for 1Password. update takes description, the 1Password source settings and entries, where null removes one. - Entry names, secret names, entry kinds for the source, 1Password names (no '/' or '?', control or format characters, '.' or '..'; a field may end with a query), the 512-byte op:// reference and the 32-entry limit are checked locally. - A vault without source, as APIs before 1Password vaults send it, reads as { type: "stored" }; unknown source types pass through. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- src/client.ts | 3 + src/services/vaults.ts | 336 ++++++++++++++++++++++++++++++++++++++ src/types/index.ts | 19 +++ src/types/vault.ts | 133 +++++++++++++++ tests/unit/vaults.test.ts | 327 +++++++++++++++++++++++++++++++++++++ 5 files changed, 818 insertions(+) create mode 100644 src/services/vaults.ts create mode 100644 src/types/vault.ts create mode 100644 tests/unit/vaults.test.ts 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/services/vaults.ts b/src/services/vaults.ts new file mode 100644 index 0000000..3456fdf --- /dev/null +++ b/src/services/vaults.ts @@ -0,0 +1,336 @@ +import { HyperbrowserError, invalidRequest } from "../error"; +import { + checkVaultName, + isSecretEnvName, + MAX_SECRET_VERSION, + SECRET_NAME_PATTERN, +} from "../sandbox/secret-references"; +import { isPlainObject } from "../secrets-envelope"; +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; +const MAX_LIST_LIMIT = 100; +/** Teams hold at most 100 vaults, so one page of search results covers every match. */ +const NAME_LOOKUP_LIMIT = MAX_LIST_LIMIT; +const MAX_VAULT_ENTRIES = 32; +const MAX_DESCRIPTION_LENGTH = 1024; +const MAX_ONEPASSWORD_PART_LENGTH = 256; +/** The longest op:// reference the platform resolves. */ +const MAX_ONEPASSWORD_REFERENCE_BYTES = 512; + +// A 1Password name or ID as the platform accepts it in an op:// reference: no +// "/" or "?", no control, format or line-separator characters, and not "." +// or "..". A field may end with a query such as ?attribute=otp. +const ONEPASSWORD_NAME = String.raw`(?!\.{1,2}(?:$|\?))[^/?\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+`; +const ONEPASSWORD_QUERY = String.raw`(?:\?[a-z-]+=[A-Za-z0-9_-]+(?:&[a-z-]+=[A-Za-z0-9_-]+)*)?`; +const ONEPASSWORD_PART_PATTERN = new RegExp(`^${ONEPASSWORD_NAME}$`, "u"); +const ONEPASSWORD_FIELD_PATTERN = new RegExp(`^${ONEPASSWORD_NAME}${ONEPASSWORD_QUERY}$`, "u"); + +const vaultPath = (id: string): string => `/vaults/${encodeURIComponent(id)}`; + +const rethrow = (error: unknown, message: string): never => { + if (error instanceof HyperbrowserError) { + throw error; + } + throw new HyperbrowserError(message, { cause: error }); +}; + +/** Vaults from APIs that predate 1Password vaults have no source. */ +const normalizeVault = (vault: Vault): Vault => + vault && typeof vault === "object" && vault.source == null + ? { ...vault, source: { type: "stored" } } + : vault; + +const checkOnePasswordPart = (value: unknown, what: string, pattern = ONEPASSWORD_PART_PATTERN) => { + if ( + typeof value !== "string" || + value.length > MAX_ONEPASSWORD_PART_LENGTH || + !pattern.test(value) + ) { + throw invalidRequest( + `${what} must be a 1Password name or ID of 1-${MAX_ONEPASSWORD_PART_LENGTH} characters without '/' or '?'${ + pattern === ONEPASSWORD_FIELD_PATTERN + ? ", optionally with a query such as ?attribute=otp" + : "" + }` + ); + } +}; + +const checkDescription = (description: unknown): void => { + if ( + description !== undefined && + (typeof description !== "string" || description.length > MAX_DESCRIPTION_LENGTH) + ) { + throw invalidRequest( + `description must be a string of at most ${MAX_DESCRIPTION_LENGTH} characters` + ); + } +}; + +type EntryKind = "stored" | "1password"; + +/** Check one entry and return it in the API's shape, with its kind. */ +const serializeEntry = (env: string, entry: unknown): { kind: EntryKind; wire: object } => { + if (!isPlainObject(entry)) { + throw invalidRequest( + `Entry ${env} must be { name, version? } for a secret or { item, section?, field } for 1Password` + ); + } + if ("item" in entry || "field" in entry || "section" in entry) { + if ("name" in entry || "version" in entry) { + throw invalidRequest(`Entry ${env} mixes a secret's name with 1Password fields`); + } + const { item, section, field } = entry; + checkOnePasswordPart(item, `${env}.item`); + if (section !== undefined) checkOnePasswordPart(section, `${env}.section`); + checkOnePasswordPart(field, `${env}.field`, ONEPASSWORD_FIELD_PATTERN); + return { + kind: "1password", + wire: section === undefined ? { item, field } : { item, section, field }, + }; + } + const { name, version } = entry; + if (typeof name !== "string" || !SECRET_NAME_PATTERN.test(name)) { + throw invalidRequest(`Entry ${env} must name a secret: ${String(name)} is not a secret name`); + } + if ( + version !== undefined && + version !== "latest" && + (!Number.isSafeInteger(version) || + (version as number) < 1 || + (version as number) > MAX_SECRET_VERSION) + ) { + throw invalidRequest( + `Entry ${env} version must be an integer from 1 to ${MAX_SECRET_VERSION} or "latest"` + ); + } + return { kind: "stored", wire: version === undefined ? { name } : { name, version } }; +}; + +/** Check entries locally; `null` (remove) is allowed only for updates. */ +const serializeEntries = ( + entries: unknown, + allowRemovals: boolean +): { wire: Record; kinds: Map } | undefined => { + if (entries === undefined) { + return undefined; + } + if (!isPlainObject(entries)) { + throw invalidRequest("entries must map environment variable names to entries"); + } + const wire: Record = {}; + const kinds = new Map(); + for (const [env, entry] of Object.entries(entries)) { + if (!isSecretEnvName(env)) { + throw invalidRequest( + `Invalid environment variable name for a vault entry: ${(env as string).slice(0, 64)}` + ); + } + if (entry === null && allowRemovals) { + wire[env] = null; + continue; + } + const { kind, wire: serialized } = serializeEntry(env, entry); + wire[env] = serialized; + kinds.set(env, kind); + } + return { wire, kinds }; +}; + +const serializeCreate = (params: CreateVaultParams): Record => { + if (!isPlainObject(params)) { + throw invalidRequest("Vault parameters must be an object"); + } + const { name, description, source, entries } = params; + checkVaultName(name, "Vault name"); + checkDescription(description); + let wireSource: Record | undefined; + let onePasswordVault: string | undefined; + if (source !== undefined) { + if (!isPlainObject(source) || (source.type !== "stored" && source.type !== "1password")) { + throw invalidRequest( + 'source must be { type: "stored" } or { type: "1password", tokenSecret, vault }' + ); + } + if (source.type === "1password") { + if (typeof source.tokenSecret !== "string" || !SECRET_NAME_PATTERN.test(source.tokenSecret)) { + throw invalidRequest("source.tokenSecret must be the name of an op_token secret"); + } + checkOnePasswordPart(source.vault, "source.vault"); + onePasswordVault = source.vault; + wireSource = { type: "1password", tokenSecret: source.tokenSecret, vault: source.vault }; + } else { + wireSource = { type: "stored" }; + } + } + const serialized = serializeEntries(entries, false); + if (serialized) { + if (serialized.kinds.size > MAX_VAULT_ENTRIES) { + throw invalidRequest(`A vault can hold at most ${MAX_VAULT_ENTRIES} entries`); + } + const expected: EntryKind = onePasswordVault === undefined ? "stored" : "1password"; + for (const [env, kind] of serialized.kinds) { + if (kind !== expected) { + throw invalidRequest( + expected === "stored" + ? `Entry ${env} is a 1Password field, but this vault's values come from stored secrets` + : `Entry ${env} names a secret, but this vault's values come from 1Password` + ); + } + if (onePasswordVault !== undefined) { + const { item, section, field } = serialized.wire[env] as Record; + const reference = `op://${onePasswordVault}/${item}/${section === undefined ? "" : `${section}/`}${field}`; + if (Buffer.byteLength(reference, "utf8") > MAX_ONEPASSWORD_REFERENCE_BYTES) { + throw invalidRequest( + `Entry ${env}: the 1Password reference is more than ${MAX_ONEPASSWORD_REFERENCE_BYTES} bytes` + ); + } + } + } + } + return { name, description, source: wireSource, entries: serialized?.wire }; +}; + +const serializeUpdate = (params: UpdateVaultParams): Record => { + if (!isPlainObject(params)) { + throw invalidRequest("Vault update parameters must be an object"); + } + const { description, source, entries } = params; + if (description === undefined && source === undefined && entries === undefined) { + throw invalidRequest("Pass a description, source or entries to update"); + } + checkDescription(description); + let wireSource: Record | undefined; + if (source !== undefined) { + if (!isPlainObject(source) || "type" in source) { + throw invalidRequest( + "source must be { tokenSecret?, vault? }: a vault's source type can't change" + ); + } + if ( + source.tokenSecret !== undefined && + (typeof source.tokenSecret !== "string" || !SECRET_NAME_PATTERN.test(source.tokenSecret)) + ) { + throw invalidRequest("source.tokenSecret must be the name of an op_token secret"); + } + if (source.vault !== undefined) checkOnePasswordPart(source.vault, "source.vault"); + wireSource = { tokenSecret: source.tokenSecret, vault: source.vault }; + } + return { description, source: wireSource, entries: serializeEntries(entries, true)?.wire }; +}; + +/** + * Vaults name a set of environment variables whose values come from stored + * secrets or from a 1Password vault. 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; + if (page !== undefined && (!Number.isSafeInteger(page) || page < 1)) { + throw invalidRequest("page must be a positive integer"); + } + if ( + limit !== undefined && + (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) + ) { + throw invalidRequest(`limit must be an integer from 1 to ${MAX_LIST_LIMIT}`); + } + try { + const response = await this.request("/vaults", undefined, { + search, + page, + limit, + }); + return { ...response, vaults: (response.vaults ?? []).map(normalizeVault) }; + } catch (error) { + return rethrow(error, "Failed to list vaults"); + } + } + + async get(vault: string): Promise { + try { + return await this.resolve(vault); + } catch (error) { + return rethrow(error, `Failed to get vault ${vault}`); + } + } + + /** Create a vault. Entries name secrets (or 1Password fields), never values. */ + async create(params: CreateVaultParams): Promise { + const body = serializeCreate(params); + try { + return normalizeVault( + await this.request("/vaults", { method: "POST", body: JSON.stringify(body) }) + ); + } catch (error) { + return rethrow(error, `Failed to create vault ${params.name}`); + } + } + + /** + * Change a vault's description, its 1Password settings, 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 body = serializeUpdate(params); + try { + const { id } = await this.resolve(vault); + return normalizeVault( + await this.request(vaultPath(id), { method: "PATCH", body: JSON.stringify(body) }) + ); + } catch (error) { + return rethrow(error, `Failed to update vault ${vault}`); + } + } + + /** Delete a vault. The secrets its entries name are kept. */ + async delete(vault: string): Promise { + try { + const { id } = await this.resolve(vault); + return await this.request(vaultPath(id), { method: "DELETE" }); + } catch (error) { + return rethrow(error, `Failed to delete vault ${vault}`); + } + } + + 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 normalizeVault(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/types/index.ts b/src/types/index.ts index 6463cbf..463e401 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -299,6 +299,25 @@ export { CredentialFillResult, CredentialFillPage, } from "./credential"; +export { + Vault, + VaultEntry, + VaultSecretEntry, + VaultOnePasswordEntry, + VaultSource, + VaultStoredSource, + VaultOnePasswordSource, + VaultOtherSource, + VaultEntryParams, + VaultSecretEntryParams, + VaultOnePasswordEntryParams, + VaultSourceParams, + CreateVaultParams, + UpdateVaultParams, + VaultListParams, + VaultListResponse, + VaultDeleteResult, +} from "./vault"; export { CreateProfileParams, ForkProfileParams, diff --git a/src/types/vault.ts b/src/types/vault.ts new file mode 100644 index 0000000..1616373 --- /dev/null +++ b/src/types/vault.ts @@ -0,0 +1,133 @@ +/** An entry of a stored vault: a stored secret. */ +export interface VaultSecretEntry { + /** The secret's name. */ + name: string; + secretId: string; + /** A pinned version, or null to follow the secret's latest version. */ + version: number | null; +} + +/** An entry of a 1Password vault: a field of a 1Password item. */ +export interface VaultOnePasswordEntry { + /** 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; + /** The reference the field is read from: `op:////[
/]`. */ + reference: string; +} + +export type VaultEntry = VaultSecretEntry | VaultOnePasswordEntry; + +/** Values come from stored secrets. */ +export interface VaultStoredSource { + type: "stored"; +} + +/** Values are read from a 1Password vault when a sandbox process starts. */ +export interface VaultOnePasswordSource { + type: "1password"; + /** The `op_token` secret that signs in to 1Password. */ + tokenSecret: { name: string; secretId: string }; + /** The 1Password vault's name or ID. */ + vault: string; +} + +/** A source of a type this SDK doesn't know yet, passed through as sent. */ +export interface VaultOtherSource { + type: string; + [member: string]: unknown; +} + +export type VaultSource = VaultStoredSource | VaultOnePasswordSource | VaultOtherSource; + +/** + * Named environment variables whose values come from stored secrets or a + * 1Password vault. A vault holds no values itself. + */ +export interface Vault { + id: string; + name: string; + description: string; + /** Where values come from. APIs that predate sources omit it; it is then `{ type: "stored" }`. */ + source: VaultSource; + /** Environment variable name to entry. */ + entries: Record; + createdAt: string; + updatedAt: string; +} + +/** A stored secret for a stored vault, following its latest version unless one is pinned. */ +export interface VaultSecretEntryParams { + /** The secret's name. */ + name: string; + version?: number | "latest"; +} + +/** + * A field of a 1Password item for a 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 VaultOnePasswordEntryParams { + item: string; + section?: string; + field: string; +} + +export type VaultEntryParams = VaultSecretEntryParams | VaultOnePasswordEntryParams; + +export type VaultSourceParams = + | { type: "stored" } + | { + type: "1password"; + /** The name of an `op_token` secret holding the service account token. */ + tokenSecret: string; + /** The 1Password vault's name or ID. */ + vault: string; + }; + +export interface CreateVaultParams { + /** 1-128 letters, digits, '_' or '-', starting with a letter or digit. */ + name: string; + description?: string; + /** Defaults to `{ type: "stored" }`. Fixed at creation. */ + source?: VaultSourceParams; + /** + * Environment variable name to entry, at most 32: secrets for a stored + * vault, 1Password fields for a 1Password vault. + */ + entries?: Record; +} + +/** At least one field. */ +export interface UpdateVaultParams { + description?: string; + /** A 1Password vault's settings. The source type can't change. */ + source?: { tokenSecret?: string; vault?: 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/vaults.test.ts b/tests/unit/vaults.test.ts new file mode 100644 index 0000000..26031ad --- /dev/null +++ b/tests/unit/vaults.test.ts @@ -0,0 +1,327 @@ +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, VaultSource } 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: "", + source: { type: "stored" }, + 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 onePassword = (): Vault => + stored({ + name: "prod-db", + source: { + type: "1password", + tokenSecret: { name: "op-token", secretId: "t1" }, + vault: "Engineering", + }, + entries: { + DB_PASS: { + item: "Postgres", + section: "prod", + field: "password", + reference: "op://Engineering/Postgres/prod/password", + }, + }, + }); + +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(); + 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("create sends a 1Password vault with its source", async () => { + const { service, bodies } = mockService(() => onePassword()); + await service.create({ + name: "prod-db", + source: { type: "1password", tokenSecret: "op-token", vault: "Engineering" }, + entries: { + DB_PASS: { item: "Postgres", section: "prod", field: "password" }, + OTP: { item: "GitHub", field: "one-time password?attribute=otp" }, + }, + }); + expect(bodies()[0]).toEqual({ + name: "prod-db", + source: { type: "1password", tokenSecret: "op-token", vault: "Engineering" }, + entries: { + DB_PASS: { item: "Postgres", section: "prod", field: "password" }, + OTP: { item: "GitHub", field: "one-time password?attribute=otp" }, + }, + }); + }); + + test.each<[string, unknown]>([ + ["a bad vault name", { name: "github login" }], + ["a bad env name", { name: "v", entries: { "1BAD": { name: "s" } } }], + ["__proto__", { name: "v", entries: JSON.parse('{"__proto__": {"name": "s"}}') }], + ["a long env name", { name: "v", entries: { ["A".repeat(257)]: { name: "s" } } }], + ["a bad secret name", { name: "v", entries: { A: { name: "-s" } } }], + ["version 0", { name: "v", entries: { A: { name: "s", version: 0 } } }], + ["a removal on create", { name: "v", entries: { A: null } }], + ["a mixed entry", { name: "v", entries: { A: { name: "s", item: "i", field: "f" } } }], + [ + "a 1Password entry in a stored vault", + { name: "v", entries: { A: { item: "i", field: "f" } } }, + ], + [ + "a secret entry in a 1Password vault", + { + name: "v", + source: { type: "1password", tokenSecret: "op-token", vault: "Eng" }, + entries: { A: { name: "s" } }, + }, + ], + ["an unknown source", { name: "v", source: { type: "aws" } }], + [ + "a 1Password source without a token", + { name: "v", source: { type: "1password", vault: "Eng" } }, + ], + [ + "a 1Password vault name with '/'", + { name: "v", source: { type: "1password", tokenSecret: "t", vault: "a/b" } }, + ], + ...(["a/b", "a?b", ".", "..", "", "x".repeat(257), "a\nb", "a​b"] as const).map( + (item) => + [ + `the item ${JSON.stringify(item.slice(0, 8))}`, + { + name: "v", + source: { type: "1password", tokenSecret: "t", vault: "Eng" }, + entries: { A: { item, field: "password" } }, + }, + ] as [string, unknown] + ), + [ + "a field with a bad query", + { + name: "v", + source: { type: "1password", tokenSecret: "t", vault: "Eng" }, + entries: { A: { item: "i", field: "otp?Attribute=OTP!" } }, + }, + ], + [ + "a reference over 512 bytes", + { + name: "v", + source: { type: "1password", tokenSecret: "t", vault: "v".repeat(256) }, + entries: { A: { item: "i".repeat(256), field: "password" } }, + }, + ], + [ + "33 entries", + { + name: "v", + entries: Object.fromEntries(Array.from({ length: 33 }, (_, i) => [`E${i}`, { name: "s" }])), + }, + ], + ])("create rejects %s before any request", async (_name, params) => { + const { service, calls } = mockService(() => stored()); + await expect(service.create(params as any)).rejects.toMatchObject({ + code: "invalid_request", + retryable: false, + }); + expect(calls()).toEqual([]); + }); + + test("1Password names may be IDs, use any other characters, and end fields with a query", async () => { + const { service, calls } = mockService(() => onePassword()); + await service.create({ + name: "v", + source: { type: "1password", tokenSecret: "op-token", vault: "abcdefghijklmnopqrstuvwxyz" }, + entries: { + A: { item: "Café · Login", section: "...x", field: "password" }, + B: { item: "i", field: "key?ssh-format=openssh&attribute=otp" }, + }, + }); + expect(calls()).toEqual(["POST /vaults"]); + }); + + test("a vault without a source is read as stored", async () => { + const { source: _omitted, ...legacy } = stored(); + const { service } = mockService((_method, path) => + path === "/vaults" ? listOf(legacy) : legacy + ); + await expect(service.get(ID)).resolves.toMatchObject({ source: { type: "stored" } }); + await expect(service.list()).resolves.toMatchObject({ + vaults: [{ source: { type: "stored" } }], + }); + }); + + test("unknown source types are passed through", async () => { + const future = stored({ source: { type: "aws-secrets-manager", region: "us-east-1" } }); + const { service } = mockService(() => future); + await expect(service.get(ID)).resolves.toMatchObject({ + source: { type: "aws-secrets-manager", region: "us-east-1" }, + }); + }); + + 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", { source: { tokenSecret: "op-token-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({ source: { tokenSecret: "op-token-2" } }); + }); + + test.each([ + ["nothing", {}], + ["a source type change", { source: { type: "stored" } }], + ["a bad token name", { source: { tokenSecret: "-t" } }], + ["a bad 1Password vault", { source: { vault: "a/b" } }], + ["a bad entry", { entries: { A: { name: "" } } }], + ["a long description", { description: "d".repeat(1025) }], + ])("update rejects %s before any request", async (_name, params) => { + const { service, calls } = mockService(() => stored()); + await expect(service.update("github-login", params as any)).rejects.toMatchObject({ + code: "invalid_request", + }); + expect(calls()).toEqual([]); + }); + + test("list passes pagination and search, and checks them", 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 }); + await expect(service.list({ limit: 101 })).rejects.toMatchObject({ code: "invalid_request" }); + await expect(service.list({ page: 0 })).rejects.toMatchObject({ code: "invalid_request" }); + 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); + }); +}); From a434489bcccedb2d04b8fb7979527bc0835d229f Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:28:58 -0700 Subject: [PATCH 09/23] Deliver vaults to sandboxes and report every binding kind - sandboxes.create (image launches) and process exec/start take vault, a vault name, next to secrets; a vault alone starts the process through the control API. A create with a vault whose result is unknown is reported like one with secrets, never retried. - Bindings (process.secrets and the new SandboxDetail.startupSecrets) make name, secretId and version optional and add secretType (TOTP), reference and tokenSecret (1Password), source and overrides. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- src/sandbox/process.ts | 28 +++++--- src/services/sandboxes.ts | 26 ++++---- src/types/index.ts | 1 + src/types/sandbox.ts | 43 ++++++++++-- tests/unit/sandbox-secrets.test.ts | 101 ++++++++++++++++++++++++++++- 5 files changed, 173 insertions(+), 26 deletions(-) diff --git a/src/sandbox/process.ts b/src/sandbox/process.ts index ec086ac..70aac97 100644 --- a/src/sandbox/process.ts +++ b/src/sandbox/process.ts @@ -10,6 +10,7 @@ import { } from "./process-output"; import { assertNoEnvSecretOverlap, + checkVaultName, serializeSecretReferences, WireSecretReference, } from "./secret-references"; @@ -59,7 +60,9 @@ export interface SecretProcessStartPayload { env?: Record; timeoutMs?: number; runAs?: string; - secrets: Record; + secrets?: Record; + /** A vault name; `secrets` is merged over its entries. */ + vault?: string; } export interface SecretProcessStartResponse { @@ -68,6 +71,13 @@ 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.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 +333,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` or `vault` received. */ public readonly secrets?: SandboxResolvedSecret[]; constructor( @@ -665,7 +675,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 +698,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 +740,10 @@ 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 === undefined ? undefined : checkVaultName(params.vault); + // 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 +756,7 @@ export class SandboxProcessesApi { timeoutMs, runAs: params.runAs, secrets, + vault, }, params.signal ); diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 8c0273c..230e798 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -9,7 +9,7 @@ import { SecretProcessStartPayload, SecretProcessStartResponse, } from "../sandbox/process"; -import { serializeSecretReferences } from "../sandbox/secret-references"; +import { checkVaultName, serializeSecretReferences } from "../sandbox/secret-references"; import { SandboxTerminalApi } from "../sandbox/terminal"; import { buildDockerImageFromDockerfile, @@ -155,9 +155,10 @@ const serializeCreateSandboxParams = (params: CreateSandboxParams): Record { const body = serializeCreateSandboxParams(params); + const withSecrets = body.secrets !== undefined || body.vault !== 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/types/index.ts b/src/types/index.ts index 463e401..58e592c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -181,6 +181,7 @@ export { SandboxSecretReference, SandboxSecrets, SandboxResolvedSecret, + SandboxSecretSource, SandboxListParams, SandboxListResponse, SandboxImageSource, diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index 412b660..5d0a622 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` or `vault`: what the startup command received. */ + startupSecrets?: SandboxResolvedSecret[]; } export type SandboxVolumeMountType = "rw" | "ro"; @@ -93,12 +95,31 @@ 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. */ +/** Where a delivered variable came from: a vault or the request's `secrets`. */ +export type SandboxSecretSource = + | { type: "vault"; name: string } + | { type: "secrets" } + | { type: string; [member: string]: unknown }; + +/** + * What an environment variable received: a stored secret's version, or for + * a 1Password vault the `op://` reference that was read. Never a value. + */ export interface SandboxResolvedSecret { env: string; - name: string; - secretId: string; - version: number; + /** The stored secret's name; absent for 1Password values. */ + name?: string; + secretId?: string; + version?: number; + /** `"totp"` when the variable holds a TOTP secret's code from process start. */ + secretType?: 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: SandboxSecretSource; + /** Earlier sources that set the same variable and were overridden, oldest first. */ + overrides?: SandboxSecretSource[]; } export interface SandboxVolumeMount { @@ -129,6 +150,7 @@ export type CreateSandboxParams = memoryMiB?: never; diskMiB?: never; secrets?: never; + vault?: never; }) | (SandboxCreateCommonParams & { snapshotName?: never; @@ -144,6 +166,12 @@ 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; }); export interface SandboxListParams { @@ -532,6 +560,13 @@ 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; /** @deprecated Ignored for process APIs. */ useShell?: boolean; } diff --git a/tests/unit/sandbox-secrets.test.ts b/tests/unit/sandbox-secrets.test.ts index 6c9c23c..9443220 100644 --- a/tests/unit/sandbox-secrets.test.ts +++ b/tests/unit/sandbox-secrets.test.ts @@ -12,6 +12,7 @@ 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 { SandboxResolvedSecret } from "../../src/types"; const wireDetail = { id: "sbx_123", @@ -33,7 +34,9 @@ const rawProcess = { started_at: 10, }; -const resolved = [{ env: "API_KEY", name: "provider-key", secretId: "s1", version: 2 }]; +const resolved = [ + { env: "API_KEY", name: "provider-key", secretId: "s1", version: 2, source: { type: "secrets" } }, +]; const result = { id: "proc_1", @@ -421,6 +424,102 @@ 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: "vault", name: "app-env" }, + }, + ]; + + 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("vault is checked locally like secrets", 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", vault: "app env" })).rejects.toThrow( + /vault name/ + ); + 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); + + await expect(api.start("x", { vault: "" })).rejects.toThrow(/vault name/); + expect(start.start).toHaveBeenCalledTimes(1); + }); +}); + describe("control API error fields", () => { class TestService extends BaseService { post(path: string) { From 2d519d64cda7ef9e23268d44da1d47bd5d7d4573 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:29:05 -0700 Subject: [PATCH 10/23] Document secrets with origins, TOTP, vaults and 1Password in the README - The credentials section becomes secrets, vaults and credential fill: a login as secrets with allowed origins grouped in a vault, a TOTP entry, fill by vault, and the tab rules with pageUrl. - Vaults and 1Password: an op_token secret and a 1Password vault. - An error table covering fill, TOTP, 1Password and vault codes. - Sandboxes: vault on exec and create, TOTP variables and the binding fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 261 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 180 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 718dd38..5bce94a 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,12 @@ 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). -## Fill credentials into browser sessions +## Secrets, vaults and credential fill -Store a website login as a credential, attach it when you create a session, and -fill it into the page's login form. The value is encrypted locally before upload; -after that, neither the API, your code, nor an LLM ever sees it. +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"; @@ -101,16 +102,33 @@ import { Hyperbrowser } from "@hyperbrowser/sdk"; const client = new Hyperbrowser({ apiKey: process.env.HYPERBROWSER_API_KEY }); -// Once: a credential is a secret of type "credential" whose value is an object -// of fields, usable only on its allowed origins. -await client.secrets.createCredential({ - name: "github", - value: { username: "ada", password: process.env.GITHUB_PASSWORD! }, - allowedOrigins: ["https://github.com"], +// 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" }, + }, }); -// Credentials can only be attached when the session is created. -const session = await client.sessions.create({ credentials: ["github"] }); +// 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(); @@ -118,87 +136,148 @@ try { // Fill only types into the inputs; it never submits. const result = await client.sessions.credentials.fill(session.id, { - credential: "github", + vault: "github-login", fields: [ - { field: "username", selector: "#login_field" }, - { field: "password", selector: "#password" }, + { 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. [{ field: "password", outcome: "not_found" }] + 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); } ``` -With Puppeteer, connect as usual (`connect({ browserWSEndpoint: session.wsEndpoint })`), -call `sessions.credentials.fill` the same way, then `page.click(...)` or -`page.keyboard.press("Enter")` to submit. - -- Each field reports an outcome: `filled` (the input holds exactly the value), - `not_found` (no input matched), `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, e.g. `maxlength`), or `not_populated` (the - stored value has no such field). Submit only when the fields you need are - `filled`. Fill is safe to retry; it is not retried automatically. -- For logins split over several screens, fill the fields on each screen and - submit before filling the next. -- Without `pageId`, fill uses the only tab, else the single tab on an allowed - origin, else the single visible tab; otherwise it fails with - `CREDENTIAL_PAGE_AMBIGUOUS` and the candidate tabs in - `error.details.details.pages` (`{ pageId, url, title }`). Under Playwright every tab reports as visible, so - with several tabs on allowed origins, pass the page's target ID: - - ```typescript - const cdp = await page.context().newCDPSession(page); - const { targetInfo } = await cdp.send("Target.getTargetInfo"); - await client.sessions.credentials.fill(session.id, { - credential: "github", - fields: [{ field: "password", selector: "#password" }], - pageId: targetInfo.targetId, - }); - ``` - - In Puppeteer, `(await page.createCDPSession()).send("Target.getTargetInfo")` - returns the same ID. For a login form inside an iframe, pass - `frame: { url: "https://accounts.example.com/" }` (matched as a URL prefix). -- Manage credentials with the other `secrets` methods: `addVersion(name, value)` - stores a new value object, `setAllowedOrigins(name, origins)` replaces the - allowed origins (`null` clears them, so the credential can't be filled), and - `list({ type: "credential" })` lists credentials. Origins are - `scheme://host[:port]`; a leading `*.` matches subdomains only, and `http:` is - accepted only for `localhost`, `127.0.0.1` and `[::1]`. Values are objects of - non-empty strings whose keys match `^[a-zA-Z0-9_.-]{1,64}$`, at most 32 KiB as - JSON; they are checked locally (`invalid_secret_value`) before anything is sent. +- `credentials` takes up to 20 items: a secret's name (or `{ secret }`), or + `{ vault }` for the secrets a stored vault names when the session is created. + `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 uses the only tab (among those whose URL starts with + `pageUrl`, when given), else the single tab on the secrets' allowed origins, + else the single visible tab; otherwise it fails with + `CREDENTIAL_PAGE_AMBIGUOUS` and the candidates in + `error.details.details.pages` (`{ pageId, url, title }`). + 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, and `http:` is accepted only for `localhost`, `127.0.0.1` + and `[::1]`. `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, + lowercase and `=` padding are fine) or an `otpauth://totp/` URI with + `secret` and optionally `algorithm` (SHA1, SHA256, SHA512), `digits` (6 or 8) + and `period` (1-300). A seed under 10 bytes is accepted with a process + warning (`HYPERBROWSER_TOTP_SEED_SHORT`). `addVersion` applies the rules of + the secret's type. Other invalid requests fail locally with `invalid_request`. - Session create needs the API key scope `secrets.use`; fill also needs - `browser.sessions.update`. Attaching credentials 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`. -- Fill errors are `HyperbrowserError`s with `code` and `retryable`; `details` - is the API's error body, whose own `details` holds the specifics (such as - `error.details.details.origin`). Codes: `CREDENTIAL_NOT_ATTACHED`, - `CREDENTIAL_ORIGIN_NOT_ALLOWED` (nothing was typed), `CREDENTIAL_NOT_READY` (no allowed origins or no value), - `SECRET_TYPE_MISMATCH`, `SECRET_NOT_FOUND`, `SESSION_NOT_ACTIVE`, - `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND`, - `CREDENTIAL_PAGE_AMBIGUOUS`, `CREDENTIAL_FRAME_AMBIGUOUS`, - `CREDENTIAL_FILL_TIMEOUT` (retryable), `CREDENTIAL_FILL_FAILED`, - `CREDENTIAL_FILL_UNSUPPORTED` (the browser image predates fill), - `CREDENTIALS_UNSUPPORTED_RUNTIME`, `SECRET_VALUE_INVALID` and - `SECRETS_FEATURE_DISABLED`. Requests the API would reject fail locally with - `invalid_request`. + `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 and 1Password + +Vaults also deliver environment variables to sandboxes (see +[Sandboxes](#sandboxes)). A 1Password vault reads its entries from a 1Password +vault when a sandbox process starts, signing 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 and +are never filled into browsers, so they take no `allowedOrigins`, and +1Password vaults can't be attached to browser sessions. + +```typescript +await client.secrets.create({ + name: "op-token", + type: "op_token", + value: process.env.OP_SERVICE_ACCOUNT_TOKEN!, +}); +await client.vaults.create({ + name: "prod-db", + source: { type: "1password", tokenSecret: "op-token", vault: "Engineering" }, + entries: { + DB_PASS: { item: "Postgres", section: "prod", field: "password" }, + DB_OTP: { item: "Postgres", field: "one-time password?attribute=otp" }, + }, +}); +await client.vaults.update("prod-db", { + entries: { DB_USER: { item: "Postgres", field: "username" }, DB_OTP: null }, +}); +const vault = await client.vaults.get("prod-db"); // entries include their op:// reference +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). Stored + entries are `{ name, version? }` (an omitted version, or `"latest"`, follows + the latest); 1Password entries are `{ item, section?, field }`, names or IDs + without `/` or `?`, and the field may end with a query such as + `?attribute=otp`. A vault's `source` is fixed at creation; `update` can + change a 1Password vault's `tokenSecret` and `vault`. 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`). + +### 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 or vault 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; 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 TOTP). | +| `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` as an environment variable, vault entry or fill, or another type as a 1Password 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. | +| `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 entry couldn't be read; `details` names the variables. | +| `SECRET_ONEPASSWORD_AUTH_FAILED` | 1Password refused the token. | +| `SECRET_ONEPASSWORD_RATE_LIMITED`, `SECRET_ONEPASSWORD_FAILED` | Retryable. | +| `SECRET_SOURCES_INVALID` | Bad `vault`/`secrets`/`env` combination, or a vault with no entries. | +| `VAULT_NOT_FOUND`, `VAULT_ENTRY_NOT_FOUND` | | +| `VAULT_NAME_TAKEN`, `VAULT_LIMIT_REACHED`, `VAULT_ENTRY_LIMIT_REACHED` | | +| `VAULT_ENTRY_INVALID` | The message starts with the entry's name. | +| `VAULT_SOURCE_INVALID` | An entry of the wrong kind for the vault's source, a source change on a stored vault, or a 1Password vault used in a browser session. | +| `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 the credential name, fields, selectors and, when needed, `page_id` or -`frame_url`. 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). +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). OpenAI (Chat Completions): @@ -209,7 +288,7 @@ import { CredentialFillTool } from "@hyperbrowser/sdk/tools"; const openai = new OpenAI(); const messages: ChatCompletionMessageParam[] = [ - { role: "user", content: "Sign in to GitHub with the github credential." }, + { role: "user", content: "Sign in to GitHub with the github-login vault." }, ]; for (;;) { const completion = await openai.chat.completions.create({ @@ -240,7 +319,7 @@ import { CredentialFillTool, type CredentialFillToolParams } from "@hyperbrowser const anthropic = new Anthropic(); const messages: Anthropic.MessageParam[] = [ - { role: "user", content: "Sign in to GitHub with the github credential." }, + { role: "user", content: "Sign in to GitHub with the github-login vault." }, ]; for (;;) { const response = await anthropic.messages.create({ @@ -273,7 +352,7 @@ import { CredentialFillTool, type CredentialFillToolParams } from "@hyperbrowser const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); const contents: Content[] = [ - { role: "user", parts: [{ text: "Sign in to GitHub with the github credential." }] }, + { role: "user", parts: [{ text: "Sign in to GitHub with the github-login vault." }] }, ]; for (;;) { const response = await ai.models.generateContent({ @@ -658,15 +737,35 @@ 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), or a + 1Password `reference` and `tokenSecret`; plus its `source` (`{ type: + "vault", name }` or `{ type: "secrets" }`) and any `overrides` it replaced. + `vault` 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 @@ -684,7 +783,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` or `vault`: 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. From 983c3c43ea5ad856e90088259c70f372344d9e0c Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:32:26 -0700 Subject: [PATCH 11/23] Refuse incomplete and HOTP TOTP values, and drop the short-seed warning - Base32 seeds whose length after removing spaces, hyphens and trailing '=' is 1, 3 or 6 past a multiple of 8 are refused: vm-service would silently drop those characters and type wrong codes. - otpauth URIs with control characters, HOTP URIs (with their own message) and other URI schemes are refused. - Short seeds are accepted without a process warning; the README and JSDoc note them instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 10 +++++---- src/secret-values.ts | 34 ++++++++++++++++++----------- src/types/secret.ts | 9 +++++--- tests/unit/secrets-contract.test.ts | 30 +++++++++++++++++-------- 4 files changed, 54 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 5bce94a..eb98a34 100644 --- a/README.md +++ b/README.md @@ -180,10 +180,12 @@ try { 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, - lowercase and `=` padding are fine) or an `otpauth://totp/` URI with - `secret` and optionally `algorithm` (SHA1, SHA256, SHA512), `digits` (6 or 8) - and `period` (1-300). A seed under 10 bytes is accepted with a process - warning (`HYPERBROWSER_TOTP_SEED_SHORT`). `addVersion` applies the rules of + hyphens, 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. Other invalid requests fail locally with `invalid_request`. - Session create needs the API key scope `secrets.use`; fill also needs `browser.sessions.update`. Attaching secrets runs the session on Firecracker diff --git a/src/secret-values.ts b/src/secret-values.ts index ea86ed1..9c409e0 100644 --- a/src/secret-values.ts +++ b/src/secret-values.ts @@ -1,13 +1,15 @@ import { invalidSecretValue, secretValueBytes } from "./secrets-envelope"; import { SecretValue } from "./types/secret"; -/** Seeds shorter than this (80 bits) are weak; RFC 4226 recommends 160. */ -const MIN_RECOMMENDED_TOTP_SEED_BYTES = 10; 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. */ +/** + * Unpadded base32 can't end with 1, 3 or 6 characters of a block. vm-service + * would silently drop such trailing characters, so a seed copied incompletely + * would give wrong codes; it is refused instead. + */ const BASE32_TAIL_LENGTHS = new Set([0, 2, 4, 5, 7]); -const BYTE_ORDER_MARK = ""; +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, " + @@ -16,7 +18,7 @@ const INVALID_TOTP = /** * The bytes a base32 seed decodes to, read the way the platform reads it: * spaces, '-', lowercase and trailing '=' padding are accepted. Undefined - * when it isn't base32. + * when it isn't base32 or has an impossible length. */ const base32SeedBytes = (seed: string): number | undefined => { const normalized = seed.replace(/[ -]/g, "").toUpperCase().replace(/=+$/, ""); @@ -31,19 +33,29 @@ const isIntegerIn = (text: string, allowed: (value: number) => boolean): boolean /** * 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. Short - * seeds are accepted with a process warning. + * 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 = value.startsWith(BYTE_ORDER_MARK) ? value.slice(1) : value; seed = seed.trim(); if (seed.toLowerCase().startsWith("otpauth://")) { + // URL parsing would silently drop tabs and newlines. + if ([...seed].some((char) => char.charCodeAt(0) < 0x20 || char.charCodeAt(0) === 0x7f)) { + throw invalidSecretValue("A TOTP otpauth:// URI must not contain control characters"); + } let url: URL; try { url = new URL(seed); } catch { throw invalidSecretValue(INVALID_TOTP); } + if (url.host.toLowerCase() === "hotp") { + throw invalidSecretValue( + "HOTP (otpauth://hotp/) is not supported; a TOTP value must be a base32 seed or an otpauth://totp/ URI" + ); + } if (url.protocol !== "otpauth:" || url.host.toLowerCase() !== "totp") { throw invalidSecretValue(INVALID_TOTP); } @@ -58,17 +70,13 @@ export const checkTotpSeed = (value: string): void => { throw invalidSecretValue(INVALID_TOTP); } seed = url.searchParams.get("secret") ?? ""; + } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(seed)) { + throw invalidSecretValue(INVALID_TOTP); } const bytes = base32SeedBytes(seed); if (bytes === undefined || bytes === 0) { throw invalidSecretValue(INVALID_TOTP); } - if (bytes < MIN_RECOMMENDED_TOTP_SEED_BYTES) { - process.emitWarning( - `The TOTP seed is ${bytes} bytes; seeds are usually at least ${MIN_RECOMMENDED_TOTP_SEED_BYTES} (16 base32 characters). Check that it was copied whole.`, - { code: "HYPERBROWSER_TOTP_SEED_SHORT" } - ); - } }; /** diff --git a/src/types/secret.ts b/src/types/secret.ts index 225e225..5c485ec 100644 --- a/src/types/secret.ts +++ b/src/types/secret.ts @@ -80,9 +80,12 @@ export interface CreateSecretParams { * encrypted locally and uploaded. * * - `"secret"`: any string or bytes. - * - `"totp"`: a base32 seed (spaces, lowercase and `=` padding are fine) or - * an `otpauth://totp/...?secret=...` URI, optionally with `algorithm` - * (SHA1, SHA256 or SHA512), `digits` (6 or 8) and `period` (1-300). + * - `"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. */ diff --git a/tests/unit/secrets-contract.test.ts b/tests/unit/secrets-contract.test.ts index a8e5999..725e1de 100644 --- a/tests/unit/secrets-contract.test.ts +++ b/tests/unit/secrets-contract.test.ts @@ -354,7 +354,9 @@ describe("typed secrets and allowed origins", () => { test.each([ ["base32", SEED], ["lowercase with spaces and padding", "jbsw y3dp ehpk 3pxp===="], - ["a byte-order mark", `${SEED}`], + ["a byte-order mark", `\uFEFF${SEED}`], + ["hyphens and surrounding whitespace", " JBSW-Y3DP-EHPK-3PXP\n"], + ["a short seed (5 bytes)", "JBSWY3DP"], ["an otpauth URI", `otpauth://totp/GitHub:ada?secret=${SEED}&issuer=GitHub`], [ "an otpauth URI with every option", @@ -372,8 +374,16 @@ describe("typed secrets and allowed origins", () => { test.each([ ["not base32", "not-a-seed!"], ["digits outside base32", "JBSWY3DPEH1K3PXP"], - ["an impossible length", "JBSWY3DPE"], + ["a length of 1 mod 8", "JBSWY3DPE"], + ["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 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`], @@ -389,22 +399,24 @@ describe("typed secrets and allowed origins", () => { expect(calls()).toEqual([]); }); - test("a short TOTP seed is accepted with a warning", async () => { - const warn = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined); + 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).toHaveBeenCalledWith(expect.stringMatching(/5 bytes/), { - code: "HYPERBROWSER_TOTP_SEED_SHORT", - }); - warn.mockClear(); - await service.create({ name: "github-otp", type: "totp", value: SEED }); 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 any string, and op_token secrets can't have allowed origins", async () => { const { service, calls, requestSpy } = mockService(created(opToken())); await service.create({ name: "op-token", type: "op_token", value: "not-an-ops-token" }); From bc318864089691a2fbf092ea64496a7547c4f31d Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 05:35:40 -0700 Subject: [PATCH 12/23] Attribute wrong-kind vault entries to VAULT_ENTRY_INVALID in the README The API reports an entry of the wrong kind for a vault's source as VAULT_ENTRY_INVALID; VAULT_SOURCE_INVALID is for a source change on a stored vault and for a 1Password vault used in a browser session. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eb98a34..4948634 100644 --- a/README.md +++ b/README.md @@ -266,8 +266,8 @@ own `details` holds the specifics (such as `error.details.details.origin`). | `SECRET_SOURCES_INVALID` | Bad `vault`/`secrets`/`env` combination, or a vault with no entries. | | `VAULT_NOT_FOUND`, `VAULT_ENTRY_NOT_FOUND` | | | `VAULT_NAME_TAKEN`, `VAULT_LIMIT_REACHED`, `VAULT_ENTRY_LIMIT_REACHED` | | -| `VAULT_ENTRY_INVALID` | The message starts with the entry's name. | -| `VAULT_SOURCE_INVALID` | An entry of the wrong kind for the vault's source, a source change on a stored vault, or a 1Password vault used in a browser session. | +| `VAULT_ENTRY_INVALID` | A bad entry, including one of the wrong kind for the vault's source; the message starts with the entry's name. | +| `VAULT_SOURCE_INVALID` | A source change on a stored vault, or a 1Password vault used in a browser session. | | `SECRETS_FEATURE_DISABLED` | The team doesn't have secrets enabled. | ### Use with LLM SDKs From f46ad683cc80bc86d0fd90f3e90e013a1e35838b Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 06:02:28 -0700 Subject: [PATCH 13/23] Read TOTP seeds as vm-service does, and keep fill and sandbox fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The TOTP check mirrors Go: one leading BOM is stripped, then Go's IsSpace set (including U+0085, but not U+FEFF) is trimmed. otpauth URIs are split as url.Parse does: a '%' that doesn't start a two-digit hex escape is refused outside the query, and query parameters are read as url.ParseQuery reads them, skipping pairs with bad escapes. Seeds must be ASCII base32 before uppercasing, so characters like "ß" or "ff" no longer become valid. The SDK stays stricter than vm-service on seeds with impossible lengths, newlines, or "ı". - fill_credential treats "" like null for credential, vault, field, page_id, page_url and frame_url, as Gemini sends "" for unused strings. - A sandbox handle keeps startupSecrets when it refreshes its details, such as on token renewal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- src/secret-values.ts | 134 +++++++++++++++++++++------- src/services/sandboxes.ts | 4 +- src/tools/index.ts | 7 +- tests/unit/credentials-fill.test.ts | 26 ++++++ tests/unit/sandbox-secrets.test.ts | 10 +++ tests/unit/secrets-contract.test.ts | 12 +++ 6 files changed, 156 insertions(+), 37 deletions(-) diff --git a/src/secret-values.ts b/src/secret-values.ts index 9c409e0..47359f6 100644 --- a/src/secret-values.ts +++ b/src/secret-values.ts @@ -15,21 +15,108 @@ 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, '-', lowercase and trailing '=' padding are accepted. Undefined - * when it isn't base32 or has an impossible length. + * when it isn't ASCII base32 or has an impossible length. */ const base32SeedBytes = (seed: string): number | undefined => { - const normalized = seed.replace(/[ -]/g, "").toUpperCase().replace(/=+$/, ""); - if (!/^[A-Z2-7]+$/.test(normalized) || !BASE32_TAIL_LENGTHS.has(normalized.length % 8)) { - return undefined; - } - return Math.floor((normalized.length * 5) / 8); + const compact = seed.replace(/[ -]/g, ""); + // Checked before uppercasing, which maps characters such as "ß" to ASCII. + if (!/^[A-Za-z2-7]+=*$/.test(compact)) return undefined; + const normalized = compact.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{1,9}$/.test(text) && allowed(Number(text)); + /^[+-]?\d+$/.test(text) && allowed(Number(text)); /** * Check a TOTP seed as the platform will read it at fill or process time; the @@ -38,30 +125,12 @@ const isIntegerIn = (text: string, allowed: (value: number) => boolean): boolean * unusually short. */ export const checkTotpSeed = (value: string): void => { - let seed = value.startsWith(BYTE_ORDER_MARK) ? value.slice(1) : value; - seed = seed.trim(); + let seed = trimGoSpace(value.startsWith(BYTE_ORDER_MARK) ? value.slice(1) : value); if (seed.toLowerCase().startsWith("otpauth://")) { - // URL parsing would silently drop tabs and newlines. - if ([...seed].some((char) => char.charCodeAt(0) < 0x20 || char.charCodeAt(0) === 0x7f)) { - throw invalidSecretValue("A TOTP otpauth:// URI must not contain control characters"); - } - let url: URL; - try { - url = new URL(seed); - } catch { - throw invalidSecretValue(INVALID_TOTP); - } - if (url.host.toLowerCase() === "hotp") { - throw invalidSecretValue( - "HOTP (otpauth://hotp/) is not supported; a TOTP value must be a base32 seed or an otpauth://totp/ URI" - ); - } - if (url.protocol !== "otpauth:" || url.host.toLowerCase() !== "totp") { - throw invalidSecretValue(INVALID_TOTP); - } - const algorithm = url.searchParams.get("algorithm"); - const digits = url.searchParams.get("digits"); - const period = url.searchParams.get("period"); + 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)) || @@ -69,12 +138,11 @@ export const checkTotpSeed = (value: string): void => { ) { throw invalidSecretValue(INVALID_TOTP); } - seed = url.searchParams.get("secret") ?? ""; + seed = queryGet(query, "secret"); } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(seed)) { throw invalidSecretValue(INVALID_TOTP); } - const bytes = base32SeedBytes(seed); - if (bytes === undefined || bytes === 0) { + if (base32SeedBytes(seed) === undefined) { throw invalidSecretValue(INVALID_TOTP); } }; diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 230e798..b4fd11f 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -502,7 +502,9 @@ export class SandboxHandle { } private hydrate(detail: SandboxDetail) { - this.detail = detail; + // Only the create response reports what the startup command received. + const startupSecrets = detail.startupSecrets ?? this.detail?.startupSecrets; + this.detail = startupSecrets === undefined ? detail : { ...detail, startupSecrets }; this.runtimeSession = SandboxHandle.toRuntimeSession(detail); } diff --git a/src/tools/index.ts b/src/tools/index.ts index c663617..27031eb 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -172,11 +172,12 @@ export class CredentialFillTool { params: CredentialFillToolParams ): Promise { try { - // Model output is checked by fill; only the known keys are passed on, - // and nulls (strict mode's "not given") are left out. + // 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 ? undefined : value; + value === null || value === "" ? undefined : value; const request: FillCredentialParams = { credential: given(input.credential), vault: given(input.vault), diff --git a/tests/unit/credentials-fill.test.ts b/tests/unit/credentials-fill.test.ts index ff0c80d..d3d5379 100644 --- a/tests/unit/credentials-fill.test.ts +++ b/tests/unit/credentials-fill.test.ts @@ -447,6 +447,32 @@ describe("fill_credential tool", () => { }); }); + 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, { diff --git a/tests/unit/sandbox-secrets.test.ts b/tests/unit/sandbox-secrets.test.ts index 9443220..bd658ff 100644 --- a/tests/unit/sandbox-secrets.test.ts +++ b/tests/unit/sandbox-secrets.test.ts @@ -472,6 +472,16 @@ describe("vaults in sandboxes", () => { 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 is checked locally like secrets", async () => { const service = new SandboxesService("test-key", "https://api.example.com", 30_000); const requestSpy = vi.spyOn(service as any, "request").mockResolvedValue(wireDetail); diff --git a/tests/unit/secrets-contract.test.ts b/tests/unit/secrets-contract.test.ts index 725e1de..9ce6677 100644 --- a/tests/unit/secrets-contract.test.ts +++ b/tests/unit/secrets-contract.test.ts @@ -355,6 +355,10 @@ describe("typed secrets and allowed origins", () => { ["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"], ["an otpauth URI", `otpauth://totp/GitHub:ada?secret=${SEED}&issuer=GitHub`], @@ -383,6 +387,14 @@ describe("typed secrets and allowed origins", () => { ["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`], From 234f8e100a862dac0e5581af8238d1825f450a23 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 11:05:00 -0700 Subject: [PATCH 14/23] Refuse an empty source in a vault update, as the API now does Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- src/secret-values.ts | 6 +++--- src/services/vaults.ts | 3 +++ tests/unit/vaults.test.ts | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/secret-values.ts b/src/secret-values.ts index 47359f6..4f0689c 100644 --- a/src/secret-values.ts +++ b/src/secret-values.ts @@ -4,9 +4,9 @@ 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. vm-service - * would silently drop such trailing characters, so a seed copied incompletely - * would give wrong codes; it is refused instead. + * 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"; diff --git a/src/services/vaults.ts b/src/services/vaults.ts index 3456fdf..3e13f59 100644 --- a/src/services/vaults.ts +++ b/src/services/vaults.ts @@ -216,6 +216,9 @@ const serializeUpdate = (params: UpdateVaultParams): Record => "source must be { tokenSecret?, vault? }: a vault's source type can't change" ); } + if (source.tokenSecret === undefined && source.vault === undefined) { + throw invalidRequest("source must name a tokenSecret, a vault, or both"); + } if ( source.tokenSecret !== undefined && (typeof source.tokenSecret !== "string" || !SECRET_NAME_PATTERN.test(source.tokenSecret)) diff --git a/tests/unit/vaults.test.ts b/tests/unit/vaults.test.ts index 26031ad..e86abf8 100644 --- a/tests/unit/vaults.test.ts +++ b/tests/unit/vaults.test.ts @@ -295,6 +295,7 @@ describe("vaults service", () => { test.each([ ["nothing", {}], ["a source type change", { source: { type: "stored" } }], + ["an empty source", { source: {} }], ["a bad token name", { source: { tokenSecret: "-t" } }], ["a bad 1Password vault", { source: { vault: "a/b" } }], ["a bad entry", { entries: { A: { name: "" } } }], From 8991b10009d707991b11691d1c86d47cfe41c66b Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 11:22:36 -0700 Subject: [PATCH 15/23] Drop TOTP as the example of a value a host can't fill Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4948634..8f52593 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ own `details` holds the specifics (such as `error.details.details.origin`). | `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 TOTP). | +| `CREDENTIAL_FILL_UNSUPPORTED`, `CREDENTIAL_VALUE_UNSUPPORTED` | The browser image predates fill, or the host can't resolve this value type. | | `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` | | From 9a236779c31057d7c5d8ef9e697eedb26523ef53 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 11:41:10 -0700 Subject: [PATCH 16/23] Move 1Password from vaults to a per-request onePassword source The API (#1552) no longer links vaults to 1Password; sandbox create and process start take onePassword next to vault and secrets instead. - Vaults are stored only again: no source in params or responses (a stray one still parses), entries are { name, version? }, and update needs a description or entries. The 1Password entry and source types are gone. - sandboxes.create (image launches), processes.start and processes.exec take onePassword: { token, vault, fields }. It is checked locally before any request: the token name, each part's length and pattern, the 512-byte op:// reference, 1-32 fields with env names, unknown members, and a variable also set by secrets or env. A vault clash is left to the API. - Bindings are a stored-secret shape with source vault or secrets, or a 1Password shape { env, reference, tokenSecret, source: onePassword }. - README: a 1Password section for onePassword, stored-only vaults, and the error table without VAULT_ENTRY_INVALID and VAULT_SOURCE_INVALID. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 111 +++++++++----- src/sandbox/process.ts | 12 +- src/sandbox/secret-references.ts | 129 +++++++++++++++- src/services/sandboxes.ts | 35 ++++- src/services/vaults.ts | 169 ++++----------------- src/types/credential.ts | 2 +- src/types/index.ts | 13 +- src/types/sandbox.ts | 80 +++++++--- src/types/secret.ts | 5 +- src/types/session.ts | 2 +- src/types/vault.ts | 83 +---------- tests/unit/sandbox-secrets.test.ts | 231 ++++++++++++++++++++++++++++- tests/unit/vaults.test.ts | 134 ++--------------- 13 files changed, 584 insertions(+), 422 deletions(-) diff --git a/README.md b/README.md index 8f52593..472da9c 100644 --- a/README.md +++ b/README.md @@ -192,50 +192,88 @@ try { with Chrome's password manager off. Fills are recorded in the session event log as `credential_filled` and `credential_fill_refused`. -### Vaults and 1Password +### Vaults Vaults also deliver environment variables to sandboxes (see -[Sandboxes](#sandboxes)). A 1Password vault reads its entries from a 1Password -vault when a sandbox process starts, signing 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 and -are never filled into browsers, so they take no `allowedOrigins`, and -1Password vaults can't be attached to browser sessions. +[Sandboxes](#sandboxes)). A vault names stored secrets and holds no values +itself. ```typescript -await client.secrets.create({ - name: "op-token", - type: "op_token", - value: process.env.OP_SERVICE_ACCOUNT_TOKEN!, -}); await client.vaults.create({ name: "prod-db", - source: { type: "1password", tokenSecret: "op-token", vault: "Engineering" }, entries: { - DB_PASS: { item: "Postgres", section: "prod", field: "password" }, - DB_OTP: { item: "Postgres", field: "one-time password?attribute=otp" }, + DB_USER: { name: "db-user" }, + DB_PASS: { name: "db-pass", version: 2 }, }, }); await client.vaults.update("prod-db", { - entries: { DB_USER: { item: "Postgres", field: "username" }, DB_OTP: null }, + entries: { DB_OTP: { name: "db-otp" }, DB_USER: null }, }); -const vault = await client.vaults.get("prod-db"); // entries include their op:// reference +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). Stored + 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); 1Password entries are `{ item, section?, field }`, names or IDs - without `/` or `?`, and the field may end with a query such as - `?attribute=otp`. A vault's `source` is fixed at creation; `update` can - change a 1Password vault's `tokenSecret` and `vault`. Limits: 100 vaults per - team, 32 entries per vault. Vaults use the `secrets.read` and - `secrets.manage` scopes, like secrets. + 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 and are never filled into browsers, so they take no +`allowedOrigins`. Nothing about the fields is stored, and browser sessions +can't use 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. These are checked locally + (`invalid_request`), as are unknown members. +- A 1Password field's variable must come from it alone: `secrets` or `env` + setting it too is refused locally, and a `vault` entry setting it 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. + ### Errors API errors are `HyperbrowserError`s with `statusCode`, `code` and `retryable` @@ -255,19 +293,17 @@ own `details` holds the specifics (such as `error.details.details.origin`). | `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` as an environment variable, vault entry or fill, or another type as a 1Password token. | +| `SECRET_TYPE_MISMATCH` | A secret used where its type can't be, such as an `op_token` in `secrets`, a vault entry or a fill, 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. | +| `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 entry couldn't be read; `details` names the variables. | -| `SECRET_ONEPASSWORD_AUTH_FAILED` | 1Password refused the token. | +| `SECRET_ONEPASSWORD_REFERENCE_FAILED` | A 1Password field couldn't be read; `details` names the variables. | +| `SECRET_ONEPASSWORD_AUTH_FAILED` | 1Password refused the token, or it signs in to a host other than `*.1password.com`, `.ca` or `.eu`. | | `SECRET_ONEPASSWORD_RATE_LIMITED`, `SECRET_ONEPASSWORD_FAILED` | Retryable. | -| `SECRET_SOURCES_INVALID` | Bad `vault`/`secrets`/`env` combination, or a vault with no entries. | +| `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` | | -| `VAULT_ENTRY_INVALID` | A bad entry, including one of the wrong kind for the vault's source; the message starts with the entry's name. | -| `VAULT_SOURCE_INVALID` | A source change on a stored vault, or a 1Password vault used in a browser session. | | `SECRETS_FEATURE_DISABLED` | The team doesn't have secrets enabled. | ### Use with LLM SDKs @@ -763,11 +799,12 @@ await client.secrets.delete("provider-key"); (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), or a - 1Password `reference` and `tokenSecret`; plus its `source` (`{ type: - "vault", name }` or `{ type: "secrets" }`) and any `overrides` it replaced. - `vault` and `secrets` together hold at most 32 variables, and `env` can't set - one of them. + `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 @@ -785,7 +822,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` or `vault`: 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/src/sandbox/process.ts b/src/sandbox/process.ts index 70aac97..13477ae 100644 --- a/src/sandbox/process.ts +++ b/src/sandbox/process.ts @@ -10,8 +10,11 @@ import { } from "./process-output"; import { assertNoEnvSecretOverlap, + assertOnePasswordAlone, checkVaultName, + serializeOnePassword, serializeSecretReferences, + WireOnePassword, WireSecretReference, } from "./secret-references"; import { @@ -63,6 +66,8 @@ export interface SecretProcessStartPayload { 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 { @@ -74,6 +79,7 @@ export interface SecretProcessStartResponse { /** 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); @@ -333,7 +339,7 @@ export class SandboxProcessHandle { private closeStream: (() => void) | null = null; private readonly changed = new ChangeSignal(); - /** What a process started with `secrets` or `vault` received. */ + /** What a process started with `secrets`, `vault` or `onePassword` received. */ public readonly secrets?: SandboxResolvedSecret[]; constructor( @@ -742,8 +748,11 @@ export class SandboxProcessesApi { } const secrets = serializeSecretReferences(params.secrets); const vault = params.vault === undefined ? undefined : checkVaultName(params.vault); + const onePassword = serializeOnePassword(params.onePassword); // A clash between env and the vault's variables is reported by the API. if (secrets !== undefined) assertNoEnvSecretOverlap(params.env, secrets); + if (onePassword !== undefined) + assertOnePasswordAlone(onePassword, { secrets, env: params.env }); const timeoutMs = secretProcessTimeoutMs(params); let response: SecretProcessStartResponse; @@ -757,6 +766,7 @@ export class SandboxProcessesApi { runAs: params.runAs, secrets, vault, + onePassword, }, params.signal ); diff --git a/src/sandbox/secret-references.ts b/src/sandbox/secret-references.ts index 512b590..66f4561 100644 --- a/src/sandbox/secret-references.ts +++ b/src/sandbox/secret-references.ts @@ -1,4 +1,5 @@ -import { HyperbrowserError } from "../error"; +import { HyperbrowserError, invalidRequest } from "../error"; +import { isPlainObject } from "../secrets-envelope"; import { SandboxSecrets } from "../types/sandbox"; const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -9,6 +10,17 @@ 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; +const MAX_ONEPASSWORD_PART_LENGTH = 256; +/** The longest op:// reference the platform resolves. */ +const MAX_ONEPASSWORD_REFERENCE_BYTES = 512; + +// A 1Password name or ID as the platform accepts it in an op:// reference: no +// "/" or "?", no control, format or line-separator characters, and not "." +// or "..". A field may end with a query such as ?attribute=otp. +const ONEPASSWORD_NAME = String.raw`(?!\.{1,2}(?:$|\?))[^/?\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+`; +const ONEPASSWORD_QUERY = String.raw`(?:\?[a-z-]+=[A-Za-z0-9_-]+(?:&[a-z-]+=[A-Za-z0-9_-]+)*)?`; +const ONEPASSWORD_PART_PATTERN = new RegExp(`^${ONEPASSWORD_NAME}$`, "u"); +const ONEPASSWORD_FIELD_PATTERN = new RegExp(`^${ONEPASSWORD_NAME}${ONEPASSWORD_QUERY}$`, "u"); /** An environment variable name the API accepts for a secret or a vault entry. */ export const isSecretEnvName = (name: unknown): name is string => @@ -93,3 +105,118 @@ export const assertNoEnvSecretOverlap = ( ); } }; + +export interface WireOnePasswordField { + item: string; + section?: string; + field: string; +} + +export interface WireOnePassword { + token: string; + vault: string; + fields: Record; +} + +const checkOnePasswordPart = (value: unknown, what: string, pattern = ONEPASSWORD_PART_PATTERN) => { + if ( + typeof value !== "string" || + value.length > MAX_ONEPASSWORD_PART_LENGTH || + !pattern.test(value) + ) { + throw invalidRequest( + `${what} must be a 1Password name or ID of 1-${MAX_ONEPASSWORD_PART_LENGTH} characters without '/' or '?'${ + pattern === ONEPASSWORD_FIELD_PATTERN + ? ", optionally with a query such as ?attribute=otp" + : "" + }` + ); + } +}; + +/** The API refuses unknown members, so refuse them before sending. */ +const checkMembers = (value: Record, allowed: string[], what: string): void => { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) { + throw invalidRequest(`${what} has unknown members: ${unknown.sort().join(", ")}`); + } +}; + +/** + * Check `onePassword` locally and convert it to the API's shape. Returns + * undefined when it isn't given. + */ +export const serializeOnePassword = (onePassword: unknown): WireOnePassword | undefined => { + if (onePassword === undefined) { + return undefined; + } + if (!isPlainObject(onePassword)) { + throw invalidRequest("onePassword must be { token, vault, fields }"); + } + checkMembers(onePassword, ["token", "vault", "fields"], "onePassword"); + const { token, vault, fields } = onePassword; + if (typeof token !== "string" || !SECRET_NAME_PATTERN.test(token)) { + throw invalidRequest("onePassword.token must be the name of an op_token secret"); + } + checkOnePasswordPart(vault, "onePassword.vault"); + if (!isPlainObject(fields)) { + throw invalidRequest("onePassword.fields must map environment variable names to fields"); + } + const entries = Object.entries(fields); + if (entries.length < 1 || entries.length > MAX_SECRETS) { + throw invalidRequest(`onePassword.fields must name 1 to ${MAX_SECRETS} fields`); + } + const wire: Record = {}; + for (const [env, entry] of entries) { + if (!isSecretEnvName(env)) { + throw invalidRequest( + `Invalid environment variable name for a 1Password field: ${(env as string).slice(0, 64)}` + ); + } + const what = `onePassword.fields.${env}`; + if (!isPlainObject(entry)) { + throw invalidRequest(`${what} must be { item, section?, field }`); + } + checkMembers(entry, ["item", "section", "field"], what); + const { item, section, field } = entry; + checkOnePasswordPart(item, `${what}.item`); + if (section !== undefined) checkOnePasswordPart(section, `${what}.section`); + checkOnePasswordPart(field, `${what}.field`, ONEPASSWORD_FIELD_PATTERN); + const reference = `op://${[vault, item, ...(section === undefined ? [] : [section]), field].join("/")}`; + if (Buffer.byteLength(reference, "utf8") > MAX_ONEPASSWORD_REFERENCE_BYTES) { + throw invalidRequest( + `${what}: the 1Password reference is more than ${MAX_ONEPASSWORD_REFERENCE_BYTES} bytes` + ); + } + wire[env] = ( + section === undefined ? { item, field } : { item, section, field } + ) as WireOnePasswordField; + } + return { token, vault: vault as string, fields: wire }; +}; + +/** + * A 1Password field's variable must come from it alone: not also from + * `secrets` or `env`. A clash with a vault is reported by the API. + */ +export const assertOnePasswordAlone = ( + onePassword: WireOnePassword, + { secrets, env }: { secrets?: Record; env?: Record } +): void => { + for (const name of Object.keys(onePassword.fields)) { + for (const [what, other] of [ + ["secrets", secrets], + ["env", env], + ] as const) { + if (other && Object.prototype.hasOwnProperty.call(other, name)) { + throw invalidRequest( + `${name} is set by ${what} and by onePassword; a 1Password field's variable must come from it alone` + ); + } + } + } + const total = Object.keys(onePassword.fields).length + Object.keys(secrets ?? {}).length; + if (total > MAX_SECRETS) { + throw invalidRequest(`At most ${MAX_SECRETS} secrets can be delivered at once`); + } +}; diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index b4fd11f..96579de 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -9,7 +9,12 @@ import { SecretProcessStartPayload, SecretProcessStartResponse, } from "../sandbox/process"; -import { checkVaultName, serializeSecretReferences } from "../sandbox/secret-references"; +import { + assertOnePasswordAlone, + checkVaultName, + serializeOnePassword, + serializeSecretReferences, +} from "../sandbox/secret-references"; import { SandboxTerminalApi } from "../sandbox/terminal"; import { buildDockerImageFromDockerfile, @@ -156,9 +161,16 @@ const serializeCreateSandboxParams = (params: CreateSandboxParams): Record { const body = serializeCreateSandboxParams(params); - const withSecrets = body.secrets !== undefined || body.vault !== undefined; + const withSecrets = + body.secrets !== undefined || body.vault !== undefined || body.onePassword !== undefined; try { const detail = await this.request("/sandbox", { method: "POST", diff --git a/src/services/vaults.ts b/src/services/vaults.ts index 3e13f59..d7c4e9e 100644 --- a/src/services/vaults.ts +++ b/src/services/vaults.ts @@ -22,17 +22,6 @@ const MAX_LIST_LIMIT = 100; const NAME_LOOKUP_LIMIT = MAX_LIST_LIMIT; const MAX_VAULT_ENTRIES = 32; const MAX_DESCRIPTION_LENGTH = 1024; -const MAX_ONEPASSWORD_PART_LENGTH = 256; -/** The longest op:// reference the platform resolves. */ -const MAX_ONEPASSWORD_REFERENCE_BYTES = 512; - -// A 1Password name or ID as the platform accepts it in an op:// reference: no -// "/" or "?", no control, format or line-separator characters, and not "." -// or "..". A field may end with a query such as ?attribute=otp. -const ONEPASSWORD_NAME = String.raw`(?!\.{1,2}(?:$|\?))[^/?\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+`; -const ONEPASSWORD_QUERY = String.raw`(?:\?[a-z-]+=[A-Za-z0-9_-]+(?:&[a-z-]+=[A-Za-z0-9_-]+)*)?`; -const ONEPASSWORD_PART_PATTERN = new RegExp(`^${ONEPASSWORD_NAME}$`, "u"); -const ONEPASSWORD_FIELD_PATTERN = new RegExp(`^${ONEPASSWORD_NAME}${ONEPASSWORD_QUERY}$`, "u"); const vaultPath = (id: string): string => `/vaults/${encodeURIComponent(id)}`; @@ -43,28 +32,6 @@ const rethrow = (error: unknown, message: string): never => { throw new HyperbrowserError(message, { cause: error }); }; -/** Vaults from APIs that predate 1Password vaults have no source. */ -const normalizeVault = (vault: Vault): Vault => - vault && typeof vault === "object" && vault.source == null - ? { ...vault, source: { type: "stored" } } - : vault; - -const checkOnePasswordPart = (value: unknown, what: string, pattern = ONEPASSWORD_PART_PATTERN) => { - if ( - typeof value !== "string" || - value.length > MAX_ONEPASSWORD_PART_LENGTH || - !pattern.test(value) - ) { - throw invalidRequest( - `${what} must be a 1Password name or ID of 1-${MAX_ONEPASSWORD_PART_LENGTH} characters without '/' or '?'${ - pattern === ONEPASSWORD_FIELD_PATTERN - ? ", optionally with a query such as ?attribute=otp" - : "" - }` - ); - } -}; - const checkDescription = (description: unknown): void => { if ( description !== undefined && @@ -76,27 +43,10 @@ const checkDescription = (description: unknown): void => { } }; -type EntryKind = "stored" | "1password"; - -/** Check one entry and return it in the API's shape, with its kind. */ -const serializeEntry = (env: string, entry: unknown): { kind: EntryKind; wire: object } => { +/** Check one entry and return it in the API's shape. */ +const serializeEntry = (env: string, entry: unknown): object => { if (!isPlainObject(entry)) { - throw invalidRequest( - `Entry ${env} must be { name, version? } for a secret or { item, section?, field } for 1Password` - ); - } - if ("item" in entry || "field" in entry || "section" in entry) { - if ("name" in entry || "version" in entry) { - throw invalidRequest(`Entry ${env} mixes a secret's name with 1Password fields`); - } - const { item, section, field } = entry; - checkOnePasswordPart(item, `${env}.item`); - if (section !== undefined) checkOnePasswordPart(section, `${env}.section`); - checkOnePasswordPart(field, `${env}.field`, ONEPASSWORD_FIELD_PATTERN); - return { - kind: "1password", - wire: section === undefined ? { item, field } : { item, section, field }, - }; + throw invalidRequest(`Entry ${env} must be { name, version? }`); } const { name, version } = entry; if (typeof name !== "string" || !SECRET_NAME_PATTERN.test(name)) { @@ -113,14 +63,14 @@ const serializeEntry = (env: string, entry: unknown): { kind: EntryKind; wire: o `Entry ${env} version must be an integer from 1 to ${MAX_SECRET_VERSION} or "latest"` ); } - return { kind: "stored", wire: version === undefined ? { name } : { name, version } }; + return version === undefined ? { name } : { name, version }; }; /** Check entries locally; `null` (remove) is allowed only for updates. */ const serializeEntries = ( entries: unknown, allowRemovals: boolean -): { wire: Record; kinds: Map } | undefined => { +): Record | undefined => { if (entries === undefined) { return undefined; } @@ -128,112 +78,46 @@ const serializeEntries = ( throw invalidRequest("entries must map environment variable names to entries"); } const wire: Record = {}; - const kinds = new Map(); for (const [env, entry] of Object.entries(entries)) { if (!isSecretEnvName(env)) { throw invalidRequest( `Invalid environment variable name for a vault entry: ${(env as string).slice(0, 64)}` ); } - if (entry === null && allowRemovals) { - wire[env] = null; - continue; - } - const { kind, wire: serialized } = serializeEntry(env, entry); - wire[env] = serialized; - kinds.set(env, kind); + wire[env] = entry === null && allowRemovals ? null : serializeEntry(env, entry); } - return { wire, kinds }; + return wire; }; const serializeCreate = (params: CreateVaultParams): Record => { if (!isPlainObject(params)) { throw invalidRequest("Vault parameters must be an object"); } - const { name, description, source, entries } = params; + const { name, description, entries } = params; checkVaultName(name, "Vault name"); checkDescription(description); - let wireSource: Record | undefined; - let onePasswordVault: string | undefined; - if (source !== undefined) { - if (!isPlainObject(source) || (source.type !== "stored" && source.type !== "1password")) { - throw invalidRequest( - 'source must be { type: "stored" } or { type: "1password", tokenSecret, vault }' - ); - } - if (source.type === "1password") { - if (typeof source.tokenSecret !== "string" || !SECRET_NAME_PATTERN.test(source.tokenSecret)) { - throw invalidRequest("source.tokenSecret must be the name of an op_token secret"); - } - checkOnePasswordPart(source.vault, "source.vault"); - onePasswordVault = source.vault; - wireSource = { type: "1password", tokenSecret: source.tokenSecret, vault: source.vault }; - } else { - wireSource = { type: "stored" }; - } + const wire = serializeEntries(entries, false); + if (wire && Object.keys(wire).length > MAX_VAULT_ENTRIES) { + throw invalidRequest(`A vault can hold at most ${MAX_VAULT_ENTRIES} entries`); } - const serialized = serializeEntries(entries, false); - if (serialized) { - if (serialized.kinds.size > MAX_VAULT_ENTRIES) { - throw invalidRequest(`A vault can hold at most ${MAX_VAULT_ENTRIES} entries`); - } - const expected: EntryKind = onePasswordVault === undefined ? "stored" : "1password"; - for (const [env, kind] of serialized.kinds) { - if (kind !== expected) { - throw invalidRequest( - expected === "stored" - ? `Entry ${env} is a 1Password field, but this vault's values come from stored secrets` - : `Entry ${env} names a secret, but this vault's values come from 1Password` - ); - } - if (onePasswordVault !== undefined) { - const { item, section, field } = serialized.wire[env] as Record; - const reference = `op://${onePasswordVault}/${item}/${section === undefined ? "" : `${section}/`}${field}`; - if (Buffer.byteLength(reference, "utf8") > MAX_ONEPASSWORD_REFERENCE_BYTES) { - throw invalidRequest( - `Entry ${env}: the 1Password reference is more than ${MAX_ONEPASSWORD_REFERENCE_BYTES} bytes` - ); - } - } - } - } - return { name, description, source: wireSource, entries: serialized?.wire }; + return { name, description, entries: wire }; }; const serializeUpdate = (params: UpdateVaultParams): Record => { if (!isPlainObject(params)) { throw invalidRequest("Vault update parameters must be an object"); } - const { description, source, entries } = params; - if (description === undefined && source === undefined && entries === undefined) { - throw invalidRequest("Pass a description, source or entries to update"); + const { description, entries } = params; + if (description === undefined && entries === undefined) { + throw invalidRequest("Pass a description or entries to update"); } checkDescription(description); - let wireSource: Record | undefined; - if (source !== undefined) { - if (!isPlainObject(source) || "type" in source) { - throw invalidRequest( - "source must be { tokenSecret?, vault? }: a vault's source type can't change" - ); - } - if (source.tokenSecret === undefined && source.vault === undefined) { - throw invalidRequest("source must name a tokenSecret, a vault, or both"); - } - if ( - source.tokenSecret !== undefined && - (typeof source.tokenSecret !== "string" || !SECRET_NAME_PATTERN.test(source.tokenSecret)) - ) { - throw invalidRequest("source.tokenSecret must be the name of an op_token secret"); - } - if (source.vault !== undefined) checkOnePasswordPart(source.vault, "source.vault"); - wireSource = { tokenSecret: source.tokenSecret, vault: source.vault }; - } - return { description, source: wireSource, entries: serializeEntries(entries, true)?.wire }; + return { description, entries: serializeEntries(entries, true) }; }; /** * Vaults name a set of environment variables whose values come from stored - * secrets or from a 1Password vault. Pass one as `vault` to a sandbox process + * 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. * @@ -258,7 +142,7 @@ export class VaultsService extends BaseService { page, limit, }); - return { ...response, vaults: (response.vaults ?? []).map(normalizeVault) }; + return { ...response, vaults: response.vaults ?? [] }; } catch (error) { return rethrow(error, "Failed to list vaults"); } @@ -272,20 +156,18 @@ export class VaultsService extends BaseService { } } - /** Create a vault. Entries name secrets (or 1Password fields), never values. */ + /** Create a vault. Entries name secrets, never values. */ async create(params: CreateVaultParams): Promise { const body = serializeCreate(params); try { - return normalizeVault( - await this.request("/vaults", { method: "POST", body: JSON.stringify(body) }) - ); + return await this.request("/vaults", { method: "POST", body: JSON.stringify(body) }); } catch (error) { return rethrow(error, `Failed to create vault ${params.name}`); } } /** - * Change a vault's description, its 1Password settings, or its entries: + * Change a vault's description or its entries: * each entry given replaces that variable, `null` removes it, and entries * not given are kept. */ @@ -293,9 +175,10 @@ export class VaultsService extends BaseService { const body = serializeUpdate(params); try { const { id } = await this.resolve(vault); - return normalizeVault( - await this.request(vaultPath(id), { method: "PATCH", body: JSON.stringify(body) }) - ); + return await this.request(vaultPath(id), { + method: "PATCH", + body: JSON.stringify(body), + }); } catch (error) { return rethrow(error, `Failed to update vault ${vault}`); } @@ -318,7 +201,7 @@ export class VaultsService extends BaseService { key = key.trim(); if (UUID_PATTERN.test(key)) { try { - return normalizeVault(await this.request(vaultPath(key))); + return await this.request(vaultPath(key)); } catch (error) { if (!(error instanceof HyperbrowserError) || error.statusCode !== 404) { throw error; diff --git a/src/types/credential.ts b/src/types/credential.ts index 013e136..1502543 100644 --- a/src/types/credential.ts +++ b/src/types/credential.ts @@ -6,7 +6,7 @@ export interface SessionCredential { /** * What to attach to a browser session at creation: a secret by name (or - * `{ secret }`), or `{ vault }` for the secrets a stored vault's entries name. + * `{ secret }`), or `{ vault }` for the secrets a vault's entries name. */ export type SessionCredentialSource = string | { secret: string } | { vault: string }; diff --git a/src/types/index.ts b/src/types/index.ts index 58e592c..bdebda5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -181,7 +181,11 @@ export { SandboxSecretReference, SandboxSecrets, SandboxResolvedSecret, + SandboxStoredSecretBinding, + SandboxOnePasswordBinding, SandboxSecretSource, + SandboxOnePassword, + SandboxOnePasswordField, SandboxListParams, SandboxListResponse, SandboxImageSource, @@ -303,16 +307,7 @@ export { export { Vault, VaultEntry, - VaultSecretEntry, - VaultOnePasswordEntry, - VaultSource, - VaultStoredSource, - VaultOnePasswordSource, - VaultOtherSource, VaultEntryParams, - VaultSecretEntryParams, - VaultOnePasswordEntryParams, - VaultSourceParams, CreateVaultParams, UpdateVaultParams, VaultListParams, diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index 5d0a622..786335c 100644 --- a/src/types/sandbox.ts +++ b/src/types/sandbox.ts @@ -75,7 +75,7 @@ export interface Sandbox { export interface SandboxDetail extends Sandbox { token: string | null; tokenExpiresAt: string | null; - /** On create with `secrets` or `vault`: what the startup command received. */ + /** On create with `secrets`, `vault` or `onePassword`: what the startup command received. */ startupSecrets?: SandboxResolvedSecret[]; } @@ -95,33 +95,70 @@ export type SandboxSecretReference = /** Environment variable name to secret, at most 32 per process or sandbox. */ export type SandboxSecrets = Record; -/** Where a delivered variable came from: a vault or the request's `secrets`. */ +/** + * 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: string; [member: string]: unknown }; + | { type: "onePassword" }; -/** - * What an environment variable received: a stored secret's version, or for - * a 1Password vault the `op://` reference that was read. Never a value. - */ -export interface SandboxResolvedSecret { +/** What a variable from a vault or `secrets` received: a stored secret's version. */ +export interface SandboxStoredSecretBinding { env: string; - /** The stored secret's name; absent for 1Password values. */ - name?: string; - secretId?: string; - version?: number; + /** 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; - /** 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: SandboxSecretSource; + 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; @@ -151,6 +188,7 @@ export type CreateSandboxParams = diskMiB?: never; secrets?: never; vault?: never; + onePassword?: never; }) | (SandboxCreateCommonParams & { snapshotName?: never; @@ -172,6 +210,8 @@ export type CreateSandboxParams = * clash. Same requirements as `secrets`. */ vault?: string; + /** 1Password fields for the startup command. Same requirements as `secrets`. */ + onePassword?: SandboxOnePassword; }); export interface SandboxListParams { @@ -567,6 +607,12 @@ export interface SandboxExecParams { * 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 5c485ec..b896b56 100644 --- a/src/types/secret.ts +++ b/src/types/secret.ts @@ -7,8 +7,9 @@ export type SecretValue = string | Uint8Array; * 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 by 1Password - * vaults. It is never an environment variable or filled into a page. + * - `"op_token"`: a 1Password service account token, used only as the + * `token` of a sandbox's `onePassword` fields. It is never an environment + * variable or filled into a page. */ export type SecretType = "secret" | "totp" | "op_token"; diff --git a/src/types/session.ts b/src/types/session.ts index cca661f..62296e6 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -271,7 +271,7 @@ export interface CreateSessionParams { /** * Secrets that `sessions.credentials.fill` may type into pages, at most 20: * a secret's name (or `{ secret: name }`), or `{ vault: name }` for the - * secrets a stored vault's entries name when the session is created. + * secrets a vault's entries name when the session is created. * 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. diff --git a/src/types/vault.ts b/src/types/vault.ts index 1616373..73470c4 100644 --- a/src/types/vault.ts +++ b/src/types/vault.ts @@ -1,5 +1,5 @@ -/** An entry of a stored vault: a stored secret. */ -export interface VaultSecretEntry { +/** An entry of a vault: a stored secret. */ +export interface VaultEntry { /** The secret's name. */ name: string; secretId: string; @@ -7,106 +7,35 @@ export interface VaultSecretEntry { version: number | null; } -/** An entry of a 1Password vault: a field of a 1Password item. */ -export interface VaultOnePasswordEntry { - /** 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; - /** The reference the field is read from: `op:////[
/]`. */ - reference: string; -} - -export type VaultEntry = VaultSecretEntry | VaultOnePasswordEntry; - -/** Values come from stored secrets. */ -export interface VaultStoredSource { - type: "stored"; -} - -/** Values are read from a 1Password vault when a sandbox process starts. */ -export interface VaultOnePasswordSource { - type: "1password"; - /** The `op_token` secret that signs in to 1Password. */ - tokenSecret: { name: string; secretId: string }; - /** The 1Password vault's name or ID. */ - vault: string; -} - -/** A source of a type this SDK doesn't know yet, passed through as sent. */ -export interface VaultOtherSource { - type: string; - [member: string]: unknown; -} - -export type VaultSource = VaultStoredSource | VaultOnePasswordSource | VaultOtherSource; - -/** - * Named environment variables whose values come from stored secrets or a - * 1Password vault. A vault holds no values itself. - */ +/** Named environment variables whose values come from stored secrets. A vault holds no values itself. */ export interface Vault { id: string; name: string; description: string; - /** Where values come from. APIs that predate sources omit it; it is then `{ type: "stored" }`. */ - source: VaultSource; /** Environment variable name to entry. */ entries: Record; createdAt: string; updatedAt: string; } -/** A stored secret for a stored vault, following its latest version unless one is pinned. */ -export interface VaultSecretEntryParams { +/** A stored secret, following its latest version unless one is pinned. */ +export interface VaultEntryParams { /** The secret's name. */ name: string; version?: number | "latest"; } -/** - * A field of a 1Password item for a 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 VaultOnePasswordEntryParams { - item: string; - section?: string; - field: string; -} - -export type VaultEntryParams = VaultSecretEntryParams | VaultOnePasswordEntryParams; - -export type VaultSourceParams = - | { type: "stored" } - | { - type: "1password"; - /** The name of an `op_token` secret holding the service account token. */ - tokenSecret: string; - /** The 1Password vault's name or ID. */ - vault: string; - }; - export interface CreateVaultParams { /** 1-128 letters, digits, '_' or '-', starting with a letter or digit. */ name: string; description?: string; - /** Defaults to `{ type: "stored" }`. Fixed at creation. */ - source?: VaultSourceParams; - /** - * Environment variable name to entry, at most 32: secrets for a stored - * vault, 1Password fields for a 1Password vault. - */ + /** Environment variable name to secret, at most 32. */ entries?: Record; } /** At least one field. */ export interface UpdateVaultParams { description?: string; - /** A 1Password vault's settings. The source type can't change. */ - source?: { tokenSecret?: string; vault?: string }; /** Each entry given replaces that variable; `null` removes it. Others are kept. */ entries?: Record; } diff --git a/tests/unit/sandbox-secrets.test.ts b/tests/unit/sandbox-secrets.test.ts index bd658ff..c12f9ca 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,7 +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 { SandboxResolvedSecret } from "../../src/types"; +import type { + SandboxOnePasswordBinding, + SandboxResolvedSecret, + SandboxSecretSource, +} from "../../src/types"; const wireDetail = { id: "sbx_123", @@ -34,7 +38,7 @@ const rawProcess = { started_at: 10, }; -const resolved = [ +const resolved: SandboxResolvedSecret[] = [ { env: "API_KEY", name: "provider-key", secretId: "s1", version: 2, source: { type: "secrets" } }, ]; @@ -448,7 +452,7 @@ describe("vaults in sandboxes", () => { env: "DB_PASS", reference: "op://Engineering/Postgres/prod/password", tokenSecret: "op-token", - source: { type: "vault", name: "app-env" }, + source: { type: "onePassword" }, }, ]; @@ -530,6 +534,225 @@ describe("vaults in sandboxes", () => { }); }); +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.each<[string, unknown, Record?]>([ + ["a bad token name", { ...onePassword, token: "-t" }], + ["a missing token", { vault: "v", fields: onePassword.fields }], + ["a vault with '/'", { ...onePassword, vault: "a/b" }], + ["the vault '.'", { ...onePassword, vault: "." }], + ["the item '.'", field({ item: "." })], + ["the item '..'", field({ item: ".." })], + ["the item 'a?b'", field({ item: "a?b" })], + ["an empty section", field({ section: "" })], + ["the field '..'", field({ field: ".." })], + ["the field '.?attribute=otp'", field({ field: ".?attribute=otp" })], + ["a field query with an uppercase key", field({ field: "otp?Attr=x" })], + ["a field query without a value", field({ field: "otp?attribute=" })], + ["a 257-character item", field({ item: "x".repeat(257) })], + ["a 257-character field", field({ field: "x".repeat(257) })], + ["U+200B in an item", field({ item: "a\u200bb" })], + ["U+2028 in a field", field({ field: "a\u2028b" })], + ["a newline in a section", field({ section: "a\nb" })], + [ + "a reference over 512 bytes", + { + ...onePassword, + vault: "v".repeat(256), + fields: { A: { item: "é".repeat(128), field: "f" } }, + }, + ], + ["no fields", { ...onePassword, fields: {} }], + [ + "33 fields", + { + ...onePassword, + fields: Object.fromEntries( + Array.from({ length: 33 }, (_, i) => [`E${i}`, { item: "i", field: "f" }]) + ), + }, + ], + ["a bad env name", { ...onePassword, fields: { "1BAD": { item: "i", field: "f" } } }], + ["an unknown member", { ...onePassword, account: "my" }], + ["an unknown field member", field({ reference: "op://v/i/f" })], + ["a field that isn't an object", { ...onePassword, fields: { A: "op://v/i/f" } }], + ["null", null], + ["a variable also in secrets", onePassword, { secrets: { SC_USER: "provider-key" } }], + ["a variable also in env", onePassword, { env: { SC_OTP: "123456" } }], + [ + "more than 32 variables with secrets", + { + ...onePassword, + fields: Object.fromEntries( + Array.from({ length: 30 }, (_, i) => [`OP${i}`, { item: "i", field: "f" }]) + ), + }, + { secrets: { A: "a", B: "b", C: "c" } }, + ], + ])("%s is refused locally before any request", async (_name, op, extra = {}) => { + const start = starter(); + const api = new SandboxProcessesApi(runtime({}).transport, start); + await expect( + api.start({ command: "x", ...extra, onePassword: op as any }) + ).rejects.toMatchObject({ code: "invalid_request", retryable: false }); + await expect( + api.exec({ command: "x", ...extra, onePassword: op as any }) + ).rejects.toMatchObject({ code: "invalid_request", retryable: false }); + expect(start.start).not.toHaveBeenCalled(); + + if (!("env" in extra)) { + const { service, requestSpy } = createService(); + await expect( + service.create({ imageName: "node", ...extra, onePassword: op as any }) + ).rejects.toMatchObject({ code: "invalid_request", retryable: false }); + expect(requestSpy).not.toHaveBeenCalled(); + } + }); + + test("clashes name the variable and both sources", async () => { + const api = new SandboxProcessesApi(runtime({}).transport, starter()); + await expect(api.start({ command: "x", env: { SC_OTP: "1" }, onePassword })).rejects.toThrow( + "SC_OTP is set by env and by onePassword; a 1Password field's variable must come from it alone" + ); + await expect( + api.start({ command: "x", secrets: { SC_USER: "s" }, onePassword }) + ).rejects.toThrow(/SC_USER is set by secrets and by onePassword/); + }); + + 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/vaults.test.ts b/tests/unit/vaults.test.ts index e86abf8..d20db1c 100644 --- a/tests/unit/vaults.test.ts +++ b/tests/unit/vaults.test.ts @@ -1,7 +1,7 @@ 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, VaultSource } from "../../src/types"; +import type { Vault, VaultEntry } from "../../src/types"; const ID = "6f1d3c1e-5b8a-4e6f-9a51-0c2b7f3d9e10"; const UUID_NAME = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; @@ -10,7 +10,6 @@ const stored = (overrides: Record = {}): Vault => ({ id: ID, name: "github-login", description: "", - source: { type: "stored" }, entries: { USERNAME: { name: "github-username", secretId: "s1", version: null }, PASSWORD: { name: "github-password", secretId: "s2", version: 3 }, @@ -20,24 +19,6 @@ const stored = (overrides: Record = {}): Vault => ({ ...overrides, }); -const onePassword = (): Vault => - stored({ - name: "prod-db", - source: { - type: "1password", - tokenSecret: { name: "op-token", secretId: "t1" }, - vault: "Engineering", - }, - entries: { - DB_PASS: { - item: "Postgres", - section: "prod", - field: "password", - reference: "op://Engineering/Postgres/prod/password", - }, - }, - }); - const listOf = (...vaults: unknown[]) => ({ vaults, totalCount: vaults.length, @@ -77,7 +58,6 @@ const mockService = ( describe("vaults service", () => { test("the client exposes vaults", () => { expect(new Hyperbrowser({ apiKey: "k" }).vaults).toBeInstanceOf(VaultsService); - expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf>(); }); @@ -105,26 +85,6 @@ describe("vaults service", () => { expect(vault).toEqual(stored()); }); - test("create sends a 1Password vault with its source", async () => { - const { service, bodies } = mockService(() => onePassword()); - await service.create({ - name: "prod-db", - source: { type: "1password", tokenSecret: "op-token", vault: "Engineering" }, - entries: { - DB_PASS: { item: "Postgres", section: "prod", field: "password" }, - OTP: { item: "GitHub", field: "one-time password?attribute=otp" }, - }, - }); - expect(bodies()[0]).toEqual({ - name: "prod-db", - source: { type: "1password", tokenSecret: "op-token", vault: "Engineering" }, - entries: { - DB_PASS: { item: "Postgres", section: "prod", field: "password" }, - OTP: { item: "GitHub", field: "one-time password?attribute=otp" }, - }, - }); - }); - test.each<[string, unknown]>([ ["a bad vault name", { name: "github login" }], ["a bad env name", { name: "v", entries: { "1BAD": { name: "s" } } }], @@ -133,55 +93,8 @@ describe("vaults service", () => { ["a bad secret name", { name: "v", entries: { A: { name: "-s" } } }], ["version 0", { name: "v", entries: { A: { name: "s", version: 0 } } }], ["a removal on create", { name: "v", entries: { A: null } }], - ["a mixed entry", { name: "v", entries: { A: { name: "s", item: "i", field: "f" } } }], - [ - "a 1Password entry in a stored vault", - { name: "v", entries: { A: { item: "i", field: "f" } } }, - ], - [ - "a secret entry in a 1Password vault", - { - name: "v", - source: { type: "1password", tokenSecret: "op-token", vault: "Eng" }, - entries: { A: { name: "s" } }, - }, - ], - ["an unknown source", { name: "v", source: { type: "aws" } }], - [ - "a 1Password source without a token", - { name: "v", source: { type: "1password", vault: "Eng" } }, - ], - [ - "a 1Password vault name with '/'", - { name: "v", source: { type: "1password", tokenSecret: "t", vault: "a/b" } }, - ], - ...(["a/b", "a?b", ".", "..", "", "x".repeat(257), "a\nb", "a​b"] as const).map( - (item) => - [ - `the item ${JSON.stringify(item.slice(0, 8))}`, - { - name: "v", - source: { type: "1password", tokenSecret: "t", vault: "Eng" }, - entries: { A: { item, field: "password" } }, - }, - ] as [string, unknown] - ), - [ - "a field with a bad query", - { - name: "v", - source: { type: "1password", tokenSecret: "t", vault: "Eng" }, - entries: { A: { item: "i", field: "otp?Attribute=OTP!" } }, - }, - ], - [ - "a reference over 512 bytes", - { - name: "v", - source: { type: "1password", tokenSecret: "t", vault: "v".repeat(256) }, - entries: { A: { item: "i".repeat(256), field: "password" } }, - }, - ], + ["a 1Password entry", { name: "v", entries: { A: { item: "i", field: "f" } } }], + ["a non-object entry", { name: "v", entries: { A: "s" } }], [ "33 entries", { @@ -198,35 +111,14 @@ describe("vaults service", () => { expect(calls()).toEqual([]); }); - test("1Password names may be IDs, use any other characters, and end fields with a query", async () => { - const { service, calls } = mockService(() => onePassword()); - await service.create({ - name: "v", - source: { type: "1password", tokenSecret: "op-token", vault: "abcdefghijklmnopqrstuvwxyz" }, - entries: { - A: { item: "Café · Login", section: "...x", field: "password" }, - B: { item: "i", field: "key?ssh-format=openssh&attribute=otp" }, - }, - }); - expect(calls()).toEqual(["POST /vaults"]); - }); - - test("a vault without a source is read as stored", async () => { - const { source: _omitted, ...legacy } = 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(legacy) : legacy + path === "/vaults" ? listOf(withSource) : withSource ); - await expect(service.get(ID)).resolves.toMatchObject({ source: { type: "stored" } }); + await expect(service.get(ID)).resolves.toMatchObject({ entries: stored().entries }); await expect(service.list()).resolves.toMatchObject({ - vaults: [{ source: { type: "stored" } }], - }); - }); - - test("unknown source types are passed through", async () => { - const future = stored({ source: { type: "aws-secrets-manager", region: "us-east-1" } }); - const { service } = mockService(() => future); - await expect(service.get(ID)).resolves.toMatchObject({ - source: { type: "aws-secrets-manager", region: "us-east-1" }, + vaults: [{ name: "github-login", entries: stored().entries }], }); }); @@ -271,7 +163,7 @@ describe("vaults service", () => { description: "new", entries: { OTP: { name: "github-otp" }, PASSWORD: null }, }); - await service.update("github-login", { source: { tokenSecret: "op-token-2" } }); + await service.update("github-login", { entries: { OTP: { name: "github-otp", version: 2 } } }); await expect(service.delete("github-login")).resolves.toEqual({ deleted: true, id: ID, @@ -289,15 +181,13 @@ describe("vaults service", () => { description: "new", entries: { OTP: { name: "github-otp" }, PASSWORD: null }, }); - expect(bodies()[3]).toEqual({ source: { tokenSecret: "op-token-2" } }); + expect(bodies()[3]).toEqual({ entries: { OTP: { name: "github-otp", version: 2 } } }); }); test.each([ ["nothing", {}], - ["a source type change", { source: { type: "stored" } }], - ["an empty source", { source: {} }], - ["a bad token name", { source: { tokenSecret: "-t" } }], - ["a bad 1Password vault", { source: { vault: "a/b" } }], + ["only a source", { source: { tokenSecret: "op-token" } }], + ["a 1Password entry", { entries: { A: { item: "i", field: "f" } } }], ["a bad entry", { entries: { A: { name: "" } } }], ["a long description", { description: "d".repeat(1025) }], ])("update rejects %s before any request", async (_name, params) => { From a902ee89ed77359d2682e9dad9aec447748b02cf Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 11:41:42 -0700 Subject: [PATCH 17/23] Explain how a fill chooses its tab, with the origin check after Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 472da9c..4ffa3c4 100644 --- a/README.md +++ b/README.md @@ -164,11 +164,14 @@ try { 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 uses the only tab (among those whose URL starts with - `pageUrl`, when given), else the single tab on the secrets' allowed origins, - else the single visible tab; otherwise it fails with +- 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, 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 }`). + `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`. From 05ca3876ab32b26f7a6b0dd70f7e25eb40d37d74 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 12:48:17 -0700 Subject: [PATCH 18/23] Fill 1Password fields into browser sessions through an op_token Sessions attach an op_token with credentials: [{ onePassword: name }], and sessions.credentials.fill takes onePassword: { token, vault, allowedOrigins } with a ref ({ item, section?, field }) per input. Each field result carries the op:// reference read; the result has no top-level version. Fill requests are checked locally as the API checks them: one source, ref on every field with onePassword and on none without, no field with onePassword, the 1Password part rules and 512-byte references (shared with sandbox onePassword), and unknown members. Allowed origins, for fills and secrets, are now checked by the API's rules (https except localhost, no path, *. only on a registrable domain), and session credentials by name and shape. fill_credential stays without onePassword: a 1Password fill names its own allowed origins, and a model shouldn't choose them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 89 +++++++++-- src/sandbox/secret-references.ts | 77 +++++++--- src/services/credentials.ts | 113 ++++++++++++-- src/services/secrets.ts | 68 +++++++- src/services/sessions.ts | 3 +- src/tools/index.ts | 4 + src/types/credential.ts | 63 +++++++- src/types/index.ts | 2 + src/types/secret.ts | 4 +- src/types/session.ts | 6 +- tests/unit/credentials-fill.test.ts | 231 ++++++++++++++++++++++++++++ tests/unit/secrets-contract.test.ts | 6 + 12 files changed, 597 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 4ffa3c4..a2ac628 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,10 @@ try { } ``` -- `credentials` takes up to 20 items: a secret's name (or `{ secret }`), or - `{ vault }` for the secrets a stored vault names when the session is created. +- `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)); names are checked locally. `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`. @@ -177,8 +179,10 @@ try { (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, and `http:` is accepted only for `localhost`, `127.0.0.1` - and `[::1]`. `setAllowedOrigins(secret, origins)` replaces them and `null` + 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]`. They are checked locally (`invalid_request`). + `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`): @@ -234,9 +238,9 @@ Sandbox processes, and an image's startup command, can read fields from a `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 and are never filled into browsers, so they take no -`allowedOrigins`. Nothing about the fields is stored, and browser sessions -can't use 1Password. +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({ @@ -277,6 +281,63 @@ console.log(worker.secrets); // [{ env: "DB_PASS", reference: "op://Engineering/ - 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. +- All of this is checked locally (`invalid_request`), including 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` @@ -285,23 +346,23 @@ own `details` holds the specifics (such as `error.details.details.origin`). | Code | Meaning | |---|---| -| `CREDENTIAL_NOT_ATTACHED` | The secret or vault 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; nothing was typed. | +| `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. | +| `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 a fill, or another type as a `onePassword` token. | +| `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. | +| `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 it signs in to a host other than `*.1password.com`, `.ca` or `.eu`. | | `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. | @@ -320,6 +381,10 @@ 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 diff --git a/src/sandbox/secret-references.ts b/src/sandbox/secret-references.ts index 66f4561..f3b63d6 100644 --- a/src/sandbox/secret-references.ts +++ b/src/sandbox/secret-references.ts @@ -135,13 +135,59 @@ const checkOnePasswordPart = (value: unknown, what: string, pattern = ONEPASSWOR }; /** The API refuses unknown members, so refuse them before sending. */ -const checkMembers = (value: Record, allowed: string[], what: string): void => { +export const checkMembers = ( + value: Record, + allowed: string[], + what: string +): void => { const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); if (unknown.length > 0) { throw invalidRequest(`${what} has unknown members: ${unknown.sort().join(", ")}`); } }; +/** The `op_token` secret a 1Password source signs in with, by name. */ +export const checkOnePasswordToken = (token: unknown, what = "onePassword.token"): string => { + if (typeof token !== "string" || !SECRET_NAME_PATTERN.test(token)) { + throw invalidRequest(`${what} must be the name of an op_token secret`); + } + return token; +}; + +/** The 1Password vault's name or ID. */ +export const checkOnePasswordVault = (vault: unknown, what = "onePassword.vault"): string => { + checkOnePasswordPart(vault, what); + return vault as string; +}; + +/** + * Check a field of an item in `vault`, `{ item, section?, field }`, and its + * full op:// reference, and convert it to the API's shape. + */ +export const serializeOnePasswordField = ( + entry: unknown, + vault: string, + what: string +): WireOnePasswordField => { + if (!isPlainObject(entry)) { + throw invalidRequest(`${what} must be { item, section?, field }`); + } + checkMembers(entry, ["item", "section", "field"], what); + const { item, section, field } = entry; + checkOnePasswordPart(item, `${what}.item`); + if (section !== undefined) checkOnePasswordPart(section, `${what}.section`); + checkOnePasswordPart(field, `${what}.field`, ONEPASSWORD_FIELD_PATTERN); + const reference = `op://${[vault, item, ...(section === undefined ? [] : [section]), field].join("/")}`; + if (Buffer.byteLength(reference, "utf8") > MAX_ONEPASSWORD_REFERENCE_BYTES) { + throw invalidRequest( + `${what}: the 1Password reference is more than ${MAX_ONEPASSWORD_REFERENCE_BYTES} bytes` + ); + } + return ( + section === undefined ? { item, field } : { item, section, field } + ) as WireOnePasswordField; +}; + /** * Check `onePassword` locally and convert it to the API's shape. Returns * undefined when it isn't given. @@ -154,11 +200,9 @@ export const serializeOnePassword = (onePassword: unknown): WireOnePassword | un throw invalidRequest("onePassword must be { token, vault, fields }"); } checkMembers(onePassword, ["token", "vault", "fields"], "onePassword"); - const { token, vault, fields } = onePassword; - if (typeof token !== "string" || !SECRET_NAME_PATTERN.test(token)) { - throw invalidRequest("onePassword.token must be the name of an op_token secret"); - } - checkOnePasswordPart(vault, "onePassword.vault"); + const token = checkOnePasswordToken(onePassword.token); + const vault = checkOnePasswordVault(onePassword.vault); + const { fields } = onePassword; if (!isPlainObject(fields)) { throw invalidRequest("onePassword.fields must map environment variable names to fields"); } @@ -173,26 +217,9 @@ export const serializeOnePassword = (onePassword: unknown): WireOnePassword | un `Invalid environment variable name for a 1Password field: ${(env as string).slice(0, 64)}` ); } - const what = `onePassword.fields.${env}`; - if (!isPlainObject(entry)) { - throw invalidRequest(`${what} must be { item, section?, field }`); - } - checkMembers(entry, ["item", "section", "field"], what); - const { item, section, field } = entry; - checkOnePasswordPart(item, `${what}.item`); - if (section !== undefined) checkOnePasswordPart(section, `${what}.section`); - checkOnePasswordPart(field, `${what}.field`, ONEPASSWORD_FIELD_PATTERN); - const reference = `op://${[vault, item, ...(section === undefined ? [] : [section]), field].join("/")}`; - if (Buffer.byteLength(reference, "utf8") > MAX_ONEPASSWORD_REFERENCE_BYTES) { - throw invalidRequest( - `${what}: the 1Password reference is more than ${MAX_ONEPASSWORD_REFERENCE_BYTES} bytes` - ); - } - wire[env] = ( - section === undefined ? { item, field } : { item, section, field } - ) as WireOnePasswordField; + wire[env] = serializeOnePasswordField(entry, vault, `onePassword.fields.${env}`); } - return { token, vault: vault as string, fields: wire }; + return { token, vault, fields: wire }; }; /** diff --git a/src/services/credentials.ts b/src/services/credentials.ts index a903353..820f2df 100644 --- a/src/services/credentials.ts +++ b/src/services/credentials.ts @@ -1,10 +1,22 @@ import { invalidRequest } from "../error"; -import { checkVaultName, isSecretEnvName } from "../sandbox/secret-references"; +import { + checkMembers, + checkOnePasswordToken, + checkOnePasswordVault, + checkVaultName, + isSecretEnvName, + SECRET_NAME_PATTERN, + serializeOnePasswordField, +} from "../sandbox/secret-references"; import { isPlainObject } from "../secrets-envelope"; import { CredentialFillResult, FillCredentialParams } from "../types/credential"; import { BaseService } from "./base"; +import { checkAllowedOrigins } from "./secrets"; const MAX_FIELDS = 16; +/** The most items `credentials` can attach to one session. */ +const MAX_SESSION_CREDENTIALS = 20; +const SESSION_CREDENTIAL_KINDS = ["secret", "vault", "onePassword"]; const MAX_CREDENTIAL_LENGTH = 255; const MAX_SELECTOR_BYTES = 1024; const MAX_PAGE_ID_BYTES = 128; @@ -17,16 +29,59 @@ const isNonEmptyString = (value: unknown, maxBytes: number): value is string => const isGiven = (value: unknown): boolean => value !== undefined && value !== null; +/** + * @internal Check a session's `credentials` locally as the API would: at + * most 20 items, each a secret name, or exactly one of `{ secret }`, + * `{ vault }` or `{ onePassword }` with a name. + */ +export const checkSessionCredentials = (credentials: unknown): void => { + if (credentials === undefined) { + return; + } + if (!Array.isArray(credentials) || credentials.length > MAX_SESSION_CREDENTIALS) { + throw invalidRequest(`credentials must be a list of at most ${MAX_SESSION_CREDENTIALS} items`); + } + credentials.forEach((entry, index) => { + const [kind, name] = + typeof entry === "string" + ? ["secret", entry] + : isPlainObject(entry) && Object.keys(entry).length === 1 + ? Object.entries(entry)[0] + : [undefined, undefined]; + if ( + kind === undefined || + !SESSION_CREDENTIAL_KINDS.includes(kind) || + typeof name !== "string" || + !SECRET_NAME_PATTERN.test(name) + ) { + throw invalidRequest( + `credentials[${index}] must be a secret name, or one of { secret }, { vault } or { onePassword } with a name` + ); + } + }); +}; + const fillBody = (params: FillCredentialParams): string => { if (!isPlainObject(params)) { throw invalidRequest("Fill parameters must be an object"); } - const { credential, vault, fields, pageId, pageUrl, frameUrl } = params; + const { credential, vault, onePassword, fields, pageId, pageUrl, frameUrl } = params; const withVault = isGiven(vault); - if (isGiven(credential) === withVault) { - throw invalidRequest("Pass exactly one of credential (a secret) and vault"); + const withOnePassword = isGiven(onePassword); + if ([credential, vault, onePassword].filter(isGiven).length !== 1) { + throw invalidRequest("Pass exactly one of credential (a secret), vault or onePassword"); } - if (withVault) { + let wireOnePassword: { token: string; vault: string; allowedOrigins: string[] } | undefined; + if (withOnePassword) { + if (!isPlainObject(onePassword)) { + throw invalidRequest("onePassword must be { token, vault, allowedOrigins }"); + } + checkMembers(onePassword, ["token", "vault", "allowedOrigins"], "onePassword"); + const token = checkOnePasswordToken(onePassword.token); + const opVault = checkOnePasswordVault(onePassword.vault); + checkAllowedOrigins(onePassword.allowedOrigins, "onePassword.allowedOrigins"); + wireOnePassword = { token, vault: opVault, allowedOrigins: onePassword.allowedOrigins }; + } else if (withVault) { checkVaultName(vault); } else if ( typeof credential !== "string" || @@ -41,9 +96,9 @@ const fillBody = (params: FillCredentialParams): string => { const selectors = new Set(); const wireFields = fields.map((entry, index) => { if (!isPlainObject(entry)) { - throw invalidRequest(`fields[${index}] must be a { selector, field? } object`); + throw invalidRequest(`fields[${index}] must be a { selector, field?, ref? } object`); } - const { selector, field } = entry; + const { selector, field, ref } = entry; if (!isNonEmptyString(selector, MAX_SELECTOR_BYTES) || !selector.trim()) { throw invalidRequest( `fields[${index}].selector must be a CSS selector of 1-${MAX_SELECTOR_BYTES} bytes` @@ -53,6 +108,25 @@ const fillBody = (params: FillCredentialParams): string => { throw invalidRequest(`fields lists the selector ${selector.slice(0, 64)} more than once`); } selectors.add(selector); + if (wireOnePassword) { + if (isGiven(field)) { + throw invalidRequest( + `fields[${index}].field is only for vault fills; with onePassword, name the 1Password field in ref` + ); + } + if (!isGiven(ref)) { + throw invalidRequest( + `fields[${index}].ref must name the 1Password field to type, as { item, section?, field }` + ); + } + return { + selector, + ref: serializeOnePasswordField(ref, wireOnePassword.vault, `fields[${index}].ref`), + }; + } + if (isGiven(ref)) { + throw invalidRequest(`fields[${index}].ref is only for onePassword fills`); + } if (!withVault) { if (isGiven(field)) { throw invalidRequest( @@ -81,7 +155,11 @@ const fillBody = (params: FillCredentialParams): string => { throw invalidRequest(`frameUrl must be a URL or URL prefix of 1-${MAX_URL_BYTES} bytes`); } return JSON.stringify({ - ...(withVault ? { vault } : { credential }), + ...(wireOnePassword + ? { onePassword: wireOnePassword } + : withVault + ? { vault } + : { credential }), fields: wireFields, pageId: pageId ?? undefined, pageUrl: pageUrl ?? undefined, @@ -92,15 +170,22 @@ const fillBody = (params: FillCredentialParams): string => { /** Fill secrets attached to a browser session into its pages. */ export class CredentialsService extends BaseService { /** - * Type a secret, or a vault's entries, 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. + * 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`. + * 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()) { diff --git a/src/services/secrets.ts b/src/services/secrets.ts index 53b1546..b283925 100644 --- a/src/services/secrets.ts +++ b/src/services/secrets.ts @@ -1,3 +1,4 @@ +import { isIPv6 } from "node:net"; import { HyperbrowserError, invalidRequest } from "../error"; import { MAX_SECRET_VERSION, SECRET_NAME_PATTERN } from "../sandbox/secret-references"; import { secretPlaintext } from "../secret-values"; @@ -25,10 +26,13 @@ const NAME_LOOKUP_LIMIT = MAX_LIST_LIMIT; const secretPath = (id: string): string => `/secrets/${encodeURIComponent(id)}`; -/** The most allowed origins a secret can have, and the longest one. */ +/** The most allowed origins a secret or a 1Password fill can have, and the longest one. */ const MAX_ALLOWED_ORIGINS = 20; const MAX_ALLOWED_ORIGIN_LENGTH = 512; +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); +const HOST_LABEL = /^(?!-)[a-z0-9-]{1,63}(? { if (error instanceof HyperbrowserError) { throw error; @@ -36,8 +40,56 @@ const rethrow = (error: unknown, message: string): never => { throw new HyperbrowserError(message, { cause: error }); }; -/** Light checks only; the API normalizes origins and reports bad entries. */ -const checkAllowedOrigins = (allowedOrigins: unknown): void => { +/** + * Why the API would refuse an allowed origin, or undefined if it accepts it. + * Mirrors the API's rules: `scheme://host[:port]` with no path, https only + * (http for localhost, 127.0.0.1 and [::1]), and a leading `*.` only on a + * domain of two or more labels, never on an IP address. The API normalizes + * accepted origins (case, default ports). + */ +const allowedOriginProblem = (origin: string): string | undefined => { + const match = /^([a-z]+):\/\/(\*\.)?([^/:?#]+|\[[^\]]+\])(?::(\d{1,5}))?\/?$/i.exec( + origin.trim() + ); + if (!match) { + return "must be scheme://host[:port] with no path"; + } + const scheme = match[1].toLowerCase(); + const wildcard = Boolean(match[2]); + const host = match[3].toLowerCase(); + if (scheme !== "https" && scheme !== "http") { + return "must use https"; + } + if (scheme === "http" && (wildcard || !LOCAL_HOSTS.has(host))) { + return "must use https unless it is localhost"; + } + if (match[4] && Number(match[4]) > 65535) { + return "has an invalid port"; + } + if (host.startsWith("[")) { + if (wildcard) return "cannot use a wildcard with an IP address"; + return isIPv6(host.slice(1, -1)) ? undefined : "has an invalid host"; + } + const labels = host.split("."); + if (!labels.every((label) => HOST_LABEL.test(label))) { + return "has an invalid host"; + } + // `*.com` would cover a whole public suffix. + if (wildcard && labels.length < 2) { + return "wildcard must name a registrable domain"; + } + // No top-level domain is numeric, so this is (part of) an IPv4 address. + if (wildcard && /^\d+$/.test(labels[labels.length - 1])) { + return "cannot use a wildcard with an IP address"; + } + return undefined; +}; + +/** + * @internal Check allowed origins locally, as the API would: 1-20 origins of + * at most 512 characters, each by the API's rules (see allowedOriginProblem). + */ +export const checkAllowedOrigins = (allowedOrigins: unknown, what = "allowedOrigins"): void => { if ( !Array.isArray(allowedOrigins) || allowedOrigins.length < 1 || @@ -48,9 +100,15 @@ const checkAllowedOrigins = (allowedOrigins: unknown): void => { ) ) { throw invalidRequest( - `allowedOrigins must be 1-${MAX_ALLOWED_ORIGINS} origins such as "https://example.com"` + `${what} must be 1-${MAX_ALLOWED_ORIGINS} origins such as "https://example.com"` ); } + for (const origin of allowedOrigins as string[]) { + const problem = allowedOriginProblem(origin); + if (problem) { + throw invalidRequest(`${what}: "${origin}" ${problem}`); + } + } }; /** @@ -116,7 +174,7 @@ export class SecretsService extends BaseService { if (allowedOrigins !== undefined) { if (type === "op_token") { throw invalidRequest( - "op_token secrets can't have allowedOrigins: they are never filled into browsers" + "op_token secrets can't have allowedOrigins: a 1Password fill names its origins in the request" ); } checkAllowedOrigins(allowedOrigins); diff --git a/src/services/sessions.ts b/src/services/sessions.ts index 9d87fc1..53867b7 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -29,7 +29,7 @@ import { } from "../types/session"; import { BaseService } from "./base"; import { WebMCPService } from "./webmcp"; -import { CredentialsService } from "./credentials"; +import { checkSessionCredentials, CredentialsService } from "./credentials"; import { HyperbrowserError } from "../client"; const CAPTCHA_EVALUATION_REQUEST_TIMEOUT_MS = 185_000; @@ -82,6 +82,7 @@ export class SessionsService extends BaseService { * @param params Configuration parameters for the new session */ async create(params?: CreateSessionParams): Promise { + checkSessionCredentials(params?.credentials); try { return await this.request("/session", { method: "POST", diff --git a/src/tools/index.ts b/src/tools/index.ts index 27031eb..c88873f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -154,6 +154,10 @@ export class OpenAICuaTool { * 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; diff --git a/src/types/credential.ts b/src/types/credential.ts index 1502543..74f146c 100644 --- a/src/types/credential.ts +++ b/src/types/credential.ts @@ -6,26 +6,71 @@ export interface SessionCredential { /** * What to attach to a browser session at creation: a secret by name (or - * `{ secret }`), or `{ vault }` for the secrets a vault's entries name. + * `{ 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 }; +export type SessionCredentialSource = + | string + | { secret: string } + | { vault: string } + | { onePassword: string }; -/** Which input to fill and, with a vault, from which entry. */ +/** + * 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 with `credential`. + * 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 this or `vault`. */ + /** 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 this or `credential`. */ + /** 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[]; /** @@ -59,19 +104,21 @@ export type CredentialFillOutcome = export interface CredentialFillFieldResult { selector: string; outcome: CredentialFillOutcome; - /** The secret typed into this input. */ + /** 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. */ + /** The secret version filled, for a single-secret fill. Absent for vault and 1Password fills. */ version?: number; pageId: string; origin: string; diff --git a/src/types/index.ts b/src/types/index.ts index bdebda5..5e2b110 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -298,6 +298,8 @@ export { SessionCredential, SessionCredentialSource, CredentialFillField, + CredentialFillRef, + CredentialFillOnePassword, FillCredentialParams, CredentialFillOutcome, CredentialFillFieldResult, diff --git a/src/types/secret.ts b/src/types/secret.ts index b896b56..fa48de6 100644 --- a/src/types/secret.ts +++ b/src/types/secret.ts @@ -8,8 +8,8 @@ export type SecretValue = string | Uint8Array; * - `"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 `onePassword` fields. It is never an environment - * variable or filled into a page. + * `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"; diff --git a/src/types/session.ts b/src/types/session.ts index 62296e6..91526b5 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -270,8 +270,10 @@ export interface CreateSessionParams { startFromSnapshot?: StartSessionFromSnapshotParams; /** * Secrets that `sessions.credentials.fill` may type into pages, at most 20: - * a secret's name (or `{ secret: name }`), or `{ vault: name }` for the - * secrets a vault's entries name when the session is created. + * 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. diff --git a/tests/unit/credentials-fill.test.ts b/tests/unit/credentials-fill.test.ts index d3d5379..cc48c0c 100644 --- a/tests/unit/credentials-fill.test.ts +++ b/tests/unit/credentials-fill.test.ts @@ -257,6 +257,237 @@ describe("sessions", () => { }); }); +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.each([ + ["21 items", Array.from({ length: 21 }, (_, i) => `s-${i}`)], + ["a name with a space", [{ onePassword: "op token" }]], + ["an empty name", [""]], + ["a non-string name", [{ onePassword: 1 }]], + ["two kinds in one item", [{ onePassword: "op-token", secret: "s" }]], + ["an unknown kind", [{ token: "op-token" }]], + ["an empty object", [{}]], + ["not a list", "op-token"], + ])("create rejects credentials with %s before any request", async (_name, credentials) => { + await expect( + client().sessions.create({ credentials: credentials as any }) + ).rejects.toMatchObject({ code: "invalid_request", retryable: false }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + 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.each([ + ["onePassword with credential", { ...opFill(), credential: "github-password" }], + ["onePassword with vault", { ...opFill(), vault: "github-login" }], + ["a field without ref", opFill({}, [refFields[0], { selector: "#otp" }])], + ["a ref without onePassword", { credential: "github-password", fields: refFields }], + [ + "a ref in a vault fill", + { vault: "github-login", fields: [{ ...vaultFields[0], ref: refFields[0].ref }] }, + ], + ["a field with onePassword", opFill({}, [{ ...refFields[0], field: "USERNAME" }])], + ["onePassword that isn't an object", { onePassword: "op-token", fields: refFields }], + ["a token that isn't a secret name", opFill({ token: "op token" })], + ["no token", opFill({ token: undefined })], + ["an empty vault", opFill({ vault: "" })], + ["a vault with a slash", opFill({ vault: "Dev/Vault" })], + ["a vault of '..'", opFill({ vault: ".." })], + ["a vault over 256 characters", opFill({ vault: "v".repeat(257) })], + ["no allowedOrigins", opFill({ allowedOrigins: undefined })], + ["0 allowedOrigins", opFill({ allowedOrigins: [] })], + [ + "21 allowedOrigins", + opFill({ allowedOrigins: Array.from({ length: 21 }, (_, i) => `https://a${i}.example.com`) }), + ], + ["an http origin", opFill({ allowedOrigins: ["http://example.com"] })], + ["a wildcard on a single label", opFill({ allowedOrigins: ["https://*.com"] })], + ["a wildcard on an IP address", opFill({ allowedOrigins: ["https://*.10.0.0.1"] })], + ["an origin with a path", opFill({ allowedOrigins: ["https://example.com/login"] })], + ["an origin without a scheme", opFill({ allowedOrigins: ["example.com"] })], + ["an origin over 512 characters", opFill({ allowedOrigins: [`https://${"a".repeat(505)}`] })], + ["a bad IPv6 origin", opFill({ allowedOrigins: ["https://[nope]"] })], + ["an unknown member of onePassword", opFill({ fields: {} })], + ["an unknown member of ref", opFill({}, ref({ item: "i", field: "f", vault: "v" }))], + ["a ref that isn't an object", opFill({}, [{ selector: "#f", ref: "op://v/i/f" }])], + ["a ref without item", opFill({}, ref({ field: "password" }))], + ["a ref without field", opFill({}, ref({ item: "i" }))], + ["an item with a slash", opFill({}, ref({ item: "a/b", field: "f" }))], + ["an item with a question mark", opFill({}, ref({ item: "a?b", field: "f" }))], + ["an item with a control character", opFill({}, ref({ item: "a\u0007", field: "f" }))], + ["an empty section", opFill({}, ref({ item: "i", section: "", field: "f" }))], + ["a section of '.'", opFill({}, ref({ item: "i", section: ".", field: "f" }))], + ["a field over 256 characters", opFill({}, ref({ item: "i", field: "f".repeat(257) }))], + [ + "a field query with an uppercase key", + opFill({}, ref({ item: "i", field: "otp?Attribute=otp" })), + ], + ["a field with an empty query", opFill({}, ref({ item: "i", field: "otp?" }))], + ["a field of '..'", opFill({}, ref({ item: "i", field: "..?attribute=otp" }))], + [ + "a 513-byte reference", + opFill({ vault: "v" }, ref({ item: "i", field: "é".repeat(248) + "x".repeat(8) })), + ], + [ + "a repeated selector", + opFill({}, [refFields[0], { selector: "#email", ref: refFields[1].ref }]), + ], + ["no fields", opFill({}, [])], + [ + "17 fields", + opFill( + {}, + Array.from({ length: 17 }, (_, i) => ({ selector: `#f${i}`, ref: refFields[0].ref })) + ), + ], + ])("fill rejects %s before any request", async (_name, params) => { + await expect(client().sessions.credentials.fill(SESSION, params as any)).rejects.toMatchObject({ + code: "invalid_request", + retryable: false, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + 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"/); + const output = await CredentialFillTool.runnable(client(), SESSION, { + onePassword, + fields: refFields, + } as any); + expect(JSON.parse(output)).toMatchObject({ error: { code: "invalid_request" } }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + /** 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"))) { diff --git a/tests/unit/secrets-contract.test.ts b/tests/unit/secrets-contract.test.ts index 9ce6677..8cada36 100644 --- a/tests/unit/secrets-contract.test.ts +++ b/tests/unit/secrets-contract.test.ts @@ -455,6 +455,12 @@ describe("typed secrets and allowed origins", () => { ["x".repeat(513)], Array(21).fill("https://a.com"), "https://a.com", + ["http://example.com"], + ["https://*.com"], + ["https://example.com/login"], + ["ftp://example.com"], + ["https://-a.com"], + ["https://example.com:70000"], ]) { await expect( service.create({ name: "k", value: "v", allowedOrigins: allowedOrigins as any }) From 5cade7a895d465acf01fcdb8d13e244ddb165bb6 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 15:17:58 -0700 Subject: [PATCH 19/23] Read TOTP seeds split across lines, as vm-service now does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1573's vm-service drops CR and LF from a seed with its spaces and hyphens before the length check (its base32 decoder skips them), so a seed pasted across lines is valid. The clients refused it; they now drop them too. A raw line break inside an otpauth URI is still refused, as Go's url.Parse does. Seeds are also upper-cased one character at a time, as Go does, so a dotless "ı" reads as "I" like on the platform. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 2 +- src/secret-values.ts | 14 +++++++++----- tests/unit/secrets-contract.test.ts | 9 +++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a2ac628..e6ce6b1 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ try { 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, lowercase and trailing `=` padding are fine) or an `otpauth://totp/` + 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 diff --git a/src/secret-values.ts b/src/secret-values.ts index 4f0689c..b55c018 100644 --- a/src/secret-values.ts +++ b/src/secret-values.ts @@ -102,14 +102,18 @@ const otpauthQuery = (uri: string): string => { /** * The bytes a base32 seed decodes to, read the way the platform reads it: - * spaces, '-', lowercase and trailing '=' padding are accepted. Undefined + * 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 => { - const compact = seed.replace(/[ -]/g, ""); - // Checked before uppercasing, which maps characters such as "ß" to ASCII. - if (!/^[A-Za-z2-7]+=*$/.test(compact)) return undefined; - const normalized = compact.replace(/=+$/, ""); + // 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; diff --git a/tests/unit/secrets-contract.test.ts b/tests/unit/secrets-contract.test.ts index 8cada36..c4a81cf 100644 --- a/tests/unit/secrets-contract.test.ts +++ b/tests/unit/secrets-contract.test.ts @@ -361,6 +361,12 @@ describe("typed secrets and allowed origins", () => { ["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", @@ -379,6 +385,9 @@ describe("typed secrets and allowed origins", () => { ["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"], From 27643bf8aea4b683eb926750f9f403d80215c29d Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 15:32:58 -0700 Subject: [PATCH 20/23] Check a vault description's length after trimming, as the API does The API trims a vault description before limiting it to 1024 characters, so a longer one with surrounding whitespace was refused here but accepted there. The README also now says that an iframe fill doesn't narrow tabs by origin, and that a malformed 1Password token fails with SECRET_ONEPASSWORD_AUTH_FAILED. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- README.md | 7 ++++--- src/services/vaults.ts | 3 ++- tests/unit/vaults.test.ts | 9 +++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e6ce6b1..4e34909 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,9 @@ try { 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, then the - visible one among them; if more than one still qualifies, it fails with + 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 @@ -363,7 +364,7 @@ own `details` holds the specifics (such as `error.details.details.origin`). | `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 it signs in to a host other than `*.1password.com`, `.ca` or `.eu`. | +| `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` | | diff --git a/src/services/vaults.ts b/src/services/vaults.ts index d7c4e9e..5b327b9 100644 --- a/src/services/vaults.ts +++ b/src/services/vaults.ts @@ -32,10 +32,11 @@ const rethrow = (error: unknown, message: string): never => { throw new HyperbrowserError(message, { cause: error }); }; +/** The API trims a description before checking its length. */ const checkDescription = (description: unknown): void => { if ( description !== undefined && - (typeof description !== "string" || description.length > MAX_DESCRIPTION_LENGTH) + (typeof description !== "string" || description.trim().length > MAX_DESCRIPTION_LENGTH) ) { throw invalidRequest( `description must be a string of at most ${MAX_DESCRIPTION_LENGTH} characters` diff --git a/tests/unit/vaults.test.ts b/tests/unit/vaults.test.ts index d20db1c..da92717 100644 --- a/tests/unit/vaults.test.ts +++ b/tests/unit/vaults.test.ts @@ -184,6 +184,15 @@ describe("vaults service", () => { 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.each([ ["nothing", {}], ["only a source", { source: { tokenSecret: "op-token" } }], From 9d92768b4d1ef5af7352143a79b7c8ea5ad97c4a Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 16:01:13 -0700 Subject: [PATCH 21/23] Type tab_opened session events The API has sent tab_opened events since browserctrl #559. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019dbwbLvQGyzwkxNetvqf3v --- src/types/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types/constants.ts b/src/types/constants.ts index 108dd14..e28f0b7 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -3,6 +3,7 @@ export type SessionEventLogType = | "captcha_solved" | "captcha_error" | "file_downloaded" + | "tab_opened" | "credential_filled" | "credential_fill_refused"; export type ScrapeFormat = "markdown" | "html" | "links" | "screenshot"; From 8c7a73ceda65c37bca5a2e06d8fe23c06320f9ab Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 16:28:26 -0700 Subject: [PATCH 22/23] Simplify credential validation and keep Gemini tools scoped to fill --- README.md | 26 ++- docs/credentials-cleanup.md | 59 +++++++ src/sandbox/process.ts | 9 +- src/sandbox/secret-references.ts | 168 +------------------- src/services/credentials.ts | 171 ++------------------ src/services/sandboxes.ts | 12 +- src/services/secrets.ts | 90 ----------- src/services/sessions.ts | 3 +- src/services/vaults.ts | 171 +++----------------- src/tools/gemini.ts | 184 +++++---------------- src/tools/index.ts | 18 +-- tests/unit/credentials-fill.test.ts | 238 ++++++---------------------- tests/unit/sandbox-secrets.test.ts | 93 +---------- tests/unit/secrets-contract.test.ts | 38 +---- tests/unit/vaults.test.ts | 44 +---- 15 files changed, 196 insertions(+), 1128 deletions(-) create mode 100644 docs/credentials-cleanup.md diff --git a/README.md b/README.md index 4e34909..915e05a 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ try { - `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)); names are checked locally. + [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`. @@ -182,7 +182,7 @@ try { - 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]`. They are checked locally (`invalid_request`). + `[::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. @@ -194,7 +194,8 @@ try { 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. Other invalid requests fail locally with `invalid_request`. + 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 @@ -273,11 +274,11 @@ console.log(worker.secrets); // [{ env: "DB_PASS", reference: "op://Engineering/ 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. These are checked locally - (`invalid_request`), as are unknown members. + 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 is refused locally, and a `vault` entry setting it is - refused by the API (`SECRET_SOURCES_INVALID`). Together with `vault` and + 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. @@ -321,9 +322,8 @@ console.log(result.fields); secret's. A page on any other origin is refused with `CREDENTIAL_ORIGIN_NOT_ALLOWED`, and tab choice treats these as the allowed origins. -- All of this is checked locally (`invalid_request`), including unknown - members of `onePassword` and `ref`, along with the usual 1-16 fields and - unique selectors. +- 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` @@ -489,10 +489,8 @@ for (;;) { ``` `FILL_CREDENTIAL_TOOL_OPENAI`, `FILL_CREDENTIAL_TOOL_ANTHROPIC` and -`FILL_CREDENTIAL_TOOL_GEMINI` are also exported, along with `toGeminiSchema` for -converting the SDK's JSON Schemas to Gemini's schema form. Every tool class in -`@hyperbrowser/sdk/tools` (such as `WebsiteScrapeTool`) has a -`geminiToolDefinition` next to its OpenAI and Anthropic definitions. +`FILL_CREDENTIAL_TOOL_GEMINI` are also exported. `CredentialFillTool` provides +`geminiToolDefinition` for the credential-fill tool. ## Usage 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/src/sandbox/process.ts b/src/sandbox/process.ts index 13477ae..02c4eee 100644 --- a/src/sandbox/process.ts +++ b/src/sandbox/process.ts @@ -10,9 +10,6 @@ import { } from "./process-output"; import { assertNoEnvSecretOverlap, - assertOnePasswordAlone, - checkVaultName, - serializeOnePassword, serializeSecretReferences, WireOnePassword, WireSecretReference, @@ -747,12 +744,10 @@ export class SandboxProcessesApi { throw new HyperbrowserError("Starting a process with secrets requires a sandbox handle"); } const secrets = serializeSecretReferences(params.secrets); - const vault = params.vault === undefined ? undefined : checkVaultName(params.vault); - const onePassword = serializeOnePassword(params.onePassword); + 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); - if (onePassword !== undefined) - assertOnePasswordAlone(onePassword, { secrets, env: params.env }); const timeoutMs = secretProcessTimeoutMs(params); let response: SecretProcessStartResponse; diff --git a/src/sandbox/secret-references.ts b/src/sandbox/secret-references.ts index f3b63d6..2300599 100644 --- a/src/sandbox/secret-references.ts +++ b/src/sandbox/secret-references.ts @@ -1,5 +1,4 @@ -import { HyperbrowserError, invalidRequest } from "../error"; -import { isPlainObject } from "../secrets-envelope"; +import { HyperbrowserError } from "../error"; import { SandboxSecrets } from "../types/sandbox"; const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -10,18 +9,6 @@ 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; -const MAX_ONEPASSWORD_PART_LENGTH = 256; -/** The longest op:// reference the platform resolves. */ -const MAX_ONEPASSWORD_REFERENCE_BYTES = 512; - -// A 1Password name or ID as the platform accepts it in an op:// reference: no -// "/" or "?", no control, format or line-separator characters, and not "." -// or "..". A field may end with a query such as ?attribute=otp. -const ONEPASSWORD_NAME = String.raw`(?!\.{1,2}(?:$|\?))[^/?\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+`; -const ONEPASSWORD_QUERY = String.raw`(?:\?[a-z-]+=[A-Za-z0-9_-]+(?:&[a-z-]+=[A-Za-z0-9_-]+)*)?`; -const ONEPASSWORD_PART_PATTERN = new RegExp(`^${ONEPASSWORD_NAME}$`, "u"); -const ONEPASSWORD_FIELD_PATTERN = new RegExp(`^${ONEPASSWORD_NAME}${ONEPASSWORD_QUERY}$`, "u"); - /** 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" && @@ -29,17 +16,6 @@ export const isSecretEnvName = (name: unknown): name is string => ENV_NAME_PATTERN.test(name) && name !== "__proto__"; -/** A vault name, checked locally before it is sent. */ -export const checkVaultName = (vault: unknown, what = "vault"): string => { - if (typeof vault !== "string" || !SECRET_NAME_PATTERN.test(vault)) { - throw new HyperbrowserError( - `${what} must be a vault name: 1-128 letters, digits, '_' or '-', starting with a letter or digit`, - { code: "invalid_request", retryable: false } - ); - } - return vault; -}; - export interface WireSecretReference { name: string; version?: number | "latest"; @@ -106,144 +82,4 @@ export const assertNoEnvSecretOverlap = ( } }; -export interface WireOnePasswordField { - item: string; - section?: string; - field: string; -} - -export interface WireOnePassword { - token: string; - vault: string; - fields: Record; -} - -const checkOnePasswordPart = (value: unknown, what: string, pattern = ONEPASSWORD_PART_PATTERN) => { - if ( - typeof value !== "string" || - value.length > MAX_ONEPASSWORD_PART_LENGTH || - !pattern.test(value) - ) { - throw invalidRequest( - `${what} must be a 1Password name or ID of 1-${MAX_ONEPASSWORD_PART_LENGTH} characters without '/' or '?'${ - pattern === ONEPASSWORD_FIELD_PATTERN - ? ", optionally with a query such as ?attribute=otp" - : "" - }` - ); - } -}; - -/** The API refuses unknown members, so refuse them before sending. */ -export const checkMembers = ( - value: Record, - allowed: string[], - what: string -): void => { - const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); - if (unknown.length > 0) { - throw invalidRequest(`${what} has unknown members: ${unknown.sort().join(", ")}`); - } -}; - -/** The `op_token` secret a 1Password source signs in with, by name. */ -export const checkOnePasswordToken = (token: unknown, what = "onePassword.token"): string => { - if (typeof token !== "string" || !SECRET_NAME_PATTERN.test(token)) { - throw invalidRequest(`${what} must be the name of an op_token secret`); - } - return token; -}; - -/** The 1Password vault's name or ID. */ -export const checkOnePasswordVault = (vault: unknown, what = "onePassword.vault"): string => { - checkOnePasswordPart(vault, what); - return vault as string; -}; - -/** - * Check a field of an item in `vault`, `{ item, section?, field }`, and its - * full op:// reference, and convert it to the API's shape. - */ -export const serializeOnePasswordField = ( - entry: unknown, - vault: string, - what: string -): WireOnePasswordField => { - if (!isPlainObject(entry)) { - throw invalidRequest(`${what} must be { item, section?, field }`); - } - checkMembers(entry, ["item", "section", "field"], what); - const { item, section, field } = entry; - checkOnePasswordPart(item, `${what}.item`); - if (section !== undefined) checkOnePasswordPart(section, `${what}.section`); - checkOnePasswordPart(field, `${what}.field`, ONEPASSWORD_FIELD_PATTERN); - const reference = `op://${[vault, item, ...(section === undefined ? [] : [section]), field].join("/")}`; - if (Buffer.byteLength(reference, "utf8") > MAX_ONEPASSWORD_REFERENCE_BYTES) { - throw invalidRequest( - `${what}: the 1Password reference is more than ${MAX_ONEPASSWORD_REFERENCE_BYTES} bytes` - ); - } - return ( - section === undefined ? { item, field } : { item, section, field } - ) as WireOnePasswordField; -}; - -/** - * Check `onePassword` locally and convert it to the API's shape. Returns - * undefined when it isn't given. - */ -export const serializeOnePassword = (onePassword: unknown): WireOnePassword | undefined => { - if (onePassword === undefined) { - return undefined; - } - if (!isPlainObject(onePassword)) { - throw invalidRequest("onePassword must be { token, vault, fields }"); - } - checkMembers(onePassword, ["token", "vault", "fields"], "onePassword"); - const token = checkOnePasswordToken(onePassword.token); - const vault = checkOnePasswordVault(onePassword.vault); - const { fields } = onePassword; - if (!isPlainObject(fields)) { - throw invalidRequest("onePassword.fields must map environment variable names to fields"); - } - const entries = Object.entries(fields); - if (entries.length < 1 || entries.length > MAX_SECRETS) { - throw invalidRequest(`onePassword.fields must name 1 to ${MAX_SECRETS} fields`); - } - const wire: Record = {}; - for (const [env, entry] of entries) { - if (!isSecretEnvName(env)) { - throw invalidRequest( - `Invalid environment variable name for a 1Password field: ${(env as string).slice(0, 64)}` - ); - } - wire[env] = serializeOnePasswordField(entry, vault, `onePassword.fields.${env}`); - } - return { token, vault, fields: wire }; -}; - -/** - * A 1Password field's variable must come from it alone: not also from - * `secrets` or `env`. A clash with a vault is reported by the API. - */ -export const assertOnePasswordAlone = ( - onePassword: WireOnePassword, - { secrets, env }: { secrets?: Record; env?: Record } -): void => { - for (const name of Object.keys(onePassword.fields)) { - for (const [what, other] of [ - ["secrets", secrets], - ["env", env], - ] as const) { - if (other && Object.prototype.hasOwnProperty.call(other, name)) { - throw invalidRequest( - `${name} is set by ${what} and by onePassword; a 1Password field's variable must come from it alone` - ); - } - } - } - const total = Object.keys(onePassword.fields).length + Object.keys(secrets ?? {}).length; - if (total > MAX_SECRETS) { - throw invalidRequest(`At most ${MAX_SECRETS} secrets can be delivered at once`); - } -}; +export type { SandboxOnePassword as WireOnePassword } from "../types/sandbox"; diff --git a/src/services/credentials.ts b/src/services/credentials.ts index 820f2df..0fc34b6 100644 --- a/src/services/credentials.ts +++ b/src/services/credentials.ts @@ -1,172 +1,11 @@ import { invalidRequest } from "../error"; -import { - checkMembers, - checkOnePasswordToken, - checkOnePasswordVault, - checkVaultName, - isSecretEnvName, - SECRET_NAME_PATTERN, - serializeOnePasswordField, -} from "../sandbox/secret-references"; import { isPlainObject } from "../secrets-envelope"; import { CredentialFillResult, FillCredentialParams } from "../types/credential"; import { BaseService } from "./base"; -import { checkAllowedOrigins } from "./secrets"; -const MAX_FIELDS = 16; -/** The most items `credentials` can attach to one session. */ -const MAX_SESSION_CREDENTIALS = 20; -const SESSION_CREDENTIAL_KINDS = ["secret", "vault", "onePassword"]; -const MAX_CREDENTIAL_LENGTH = 255; -const MAX_SELECTOR_BYTES = 1024; -const MAX_PAGE_ID_BYTES = 128; -const MAX_URL_BYTES = 2048; /** The API gives up on a fill after about 18 seconds. */ const MIN_FILL_TIMEOUT_MS = 30_000; -const isNonEmptyString = (value: unknown, maxBytes: number): value is string => - typeof value === "string" && value.length > 0 && Buffer.byteLength(value, "utf8") <= maxBytes; - -const isGiven = (value: unknown): boolean => value !== undefined && value !== null; - -/** - * @internal Check a session's `credentials` locally as the API would: at - * most 20 items, each a secret name, or exactly one of `{ secret }`, - * `{ vault }` or `{ onePassword }` with a name. - */ -export const checkSessionCredentials = (credentials: unknown): void => { - if (credentials === undefined) { - return; - } - if (!Array.isArray(credentials) || credentials.length > MAX_SESSION_CREDENTIALS) { - throw invalidRequest(`credentials must be a list of at most ${MAX_SESSION_CREDENTIALS} items`); - } - credentials.forEach((entry, index) => { - const [kind, name] = - typeof entry === "string" - ? ["secret", entry] - : isPlainObject(entry) && Object.keys(entry).length === 1 - ? Object.entries(entry)[0] - : [undefined, undefined]; - if ( - kind === undefined || - !SESSION_CREDENTIAL_KINDS.includes(kind) || - typeof name !== "string" || - !SECRET_NAME_PATTERN.test(name) - ) { - throw invalidRequest( - `credentials[${index}] must be a secret name, or one of { secret }, { vault } or { onePassword } with a name` - ); - } - }); -}; - -const fillBody = (params: FillCredentialParams): string => { - if (!isPlainObject(params)) { - throw invalidRequest("Fill parameters must be an object"); - } - const { credential, vault, onePassword, fields, pageId, pageUrl, frameUrl } = params; - const withVault = isGiven(vault); - const withOnePassword = isGiven(onePassword); - if ([credential, vault, onePassword].filter(isGiven).length !== 1) { - throw invalidRequest("Pass exactly one of credential (a secret), vault or onePassword"); - } - let wireOnePassword: { token: string; vault: string; allowedOrigins: string[] } | undefined; - if (withOnePassword) { - if (!isPlainObject(onePassword)) { - throw invalidRequest("onePassword must be { token, vault, allowedOrigins }"); - } - checkMembers(onePassword, ["token", "vault", "allowedOrigins"], "onePassword"); - const token = checkOnePasswordToken(onePassword.token); - const opVault = checkOnePasswordVault(onePassword.vault); - checkAllowedOrigins(onePassword.allowedOrigins, "onePassword.allowedOrigins"); - wireOnePassword = { token, vault: opVault, allowedOrigins: onePassword.allowedOrigins }; - } else if (withVault) { - checkVaultName(vault); - } else if ( - typeof credential !== "string" || - !credential.trim() || - credential.length > MAX_CREDENTIAL_LENGTH - ) { - throw invalidRequest("credential must be the name or ID of a secret attached to the session"); - } - if (!Array.isArray(fields) || fields.length < 1 || fields.length > MAX_FIELDS) { - throw invalidRequest(`fields must list 1-${MAX_FIELDS} inputs`); - } - const selectors = new Set(); - const wireFields = fields.map((entry, index) => { - if (!isPlainObject(entry)) { - throw invalidRequest(`fields[${index}] must be a { selector, field?, ref? } object`); - } - const { selector, field, ref } = entry; - if (!isNonEmptyString(selector, MAX_SELECTOR_BYTES) || !selector.trim()) { - throw invalidRequest( - `fields[${index}].selector must be a CSS selector of 1-${MAX_SELECTOR_BYTES} bytes` - ); - } - if (selectors.has(selector)) { - throw invalidRequest(`fields lists the selector ${selector.slice(0, 64)} more than once`); - } - selectors.add(selector); - if (wireOnePassword) { - if (isGiven(field)) { - throw invalidRequest( - `fields[${index}].field is only for vault fills; with onePassword, name the 1Password field in ref` - ); - } - if (!isGiven(ref)) { - throw invalidRequest( - `fields[${index}].ref must name the 1Password field to type, as { item, section?, field }` - ); - } - return { - selector, - ref: serializeOnePasswordField(ref, wireOnePassword.vault, `fields[${index}].ref`), - }; - } - if (isGiven(ref)) { - throw invalidRequest(`fields[${index}].ref is only for onePassword fills`); - } - if (!withVault) { - if (isGiven(field)) { - throw invalidRequest( - `fields[${index}].field is only for vault fills; a credential fill types the secret itself` - ); - } - return { selector }; - } - if (!isSecretEnvName(field)) { - throw invalidRequest( - `fields[${index}].field must name the vault entry to type, such as "PASSWORD"` - ); - } - return { selector, field }; - }); - if (isGiven(pageId) && !isNonEmptyString(pageId, MAX_PAGE_ID_BYTES)) { - throw invalidRequest(`pageId must be a page ID of 1-${MAX_PAGE_ID_BYTES} bytes`); - } - if (isGiven(pageUrl) && !isNonEmptyString(pageUrl, MAX_URL_BYTES)) { - throw invalidRequest(`pageUrl must be a URL or URL prefix of 1-${MAX_URL_BYTES} bytes`); - } - if (isGiven(pageId) && isGiven(pageUrl)) { - throw invalidRequest("Pass pageId or pageUrl, not both"); - } - if (isGiven(frameUrl) && !isNonEmptyString(frameUrl, MAX_URL_BYTES)) { - throw invalidRequest(`frameUrl must be a URL or URL prefix of 1-${MAX_URL_BYTES} bytes`); - } - return JSON.stringify({ - ...(wireOnePassword - ? { onePassword: wireOnePassword } - : withVault - ? { vault } - : { credential }), - fields: wireFields, - pageId: pageId ?? undefined, - pageUrl: pageUrl ?? undefined, - frame: isGiven(frameUrl) ? { url: frameUrl } : undefined, - }); -}; - /** Fill secrets attached to a browser session into its pages. */ export class CredentialsService extends BaseService { /** @@ -191,11 +30,19 @@ export class CredentialsService extends BaseService { 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: fillBody(params), + 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 96579de..1e86446 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -9,12 +9,7 @@ import { SecretProcessStartPayload, SecretProcessStartResponse, } from "../sandbox/process"; -import { - assertOnePasswordAlone, - checkVaultName, - serializeOnePassword, - serializeSecretReferences, -} from "../sandbox/secret-references"; +import { serializeSecretReferences } from "../sandbox/secret-references"; import { SandboxTerminalApi } from "../sandbox/terminal"; import { buildDockerImageFromDockerfile, @@ -160,9 +155,8 @@ const serializeCreateSandboxParams = (params: CreateSandboxParams): Record `/secrets/${encodeURIComponent(id)}`; -/** The most allowed origins a secret or a 1Password fill can have, and the longest one. */ -const MAX_ALLOWED_ORIGINS = 20; -const MAX_ALLOWED_ORIGIN_LENGTH = 512; - -const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); -const HOST_LABEL = /^(?!-)[a-z0-9-]{1,63}(? { if (error instanceof HyperbrowserError) { throw error; @@ -40,77 +32,6 @@ const rethrow = (error: unknown, message: string): never => { throw new HyperbrowserError(message, { cause: error }); }; -/** - * Why the API would refuse an allowed origin, or undefined if it accepts it. - * Mirrors the API's rules: `scheme://host[:port]` with no path, https only - * (http for localhost, 127.0.0.1 and [::1]), and a leading `*.` only on a - * domain of two or more labels, never on an IP address. The API normalizes - * accepted origins (case, default ports). - */ -const allowedOriginProblem = (origin: string): string | undefined => { - const match = /^([a-z]+):\/\/(\*\.)?([^/:?#]+|\[[^\]]+\])(?::(\d{1,5}))?\/?$/i.exec( - origin.trim() - ); - if (!match) { - return "must be scheme://host[:port] with no path"; - } - const scheme = match[1].toLowerCase(); - const wildcard = Boolean(match[2]); - const host = match[3].toLowerCase(); - if (scheme !== "https" && scheme !== "http") { - return "must use https"; - } - if (scheme === "http" && (wildcard || !LOCAL_HOSTS.has(host))) { - return "must use https unless it is localhost"; - } - if (match[4] && Number(match[4]) > 65535) { - return "has an invalid port"; - } - if (host.startsWith("[")) { - if (wildcard) return "cannot use a wildcard with an IP address"; - return isIPv6(host.slice(1, -1)) ? undefined : "has an invalid host"; - } - const labels = host.split("."); - if (!labels.every((label) => HOST_LABEL.test(label))) { - return "has an invalid host"; - } - // `*.com` would cover a whole public suffix. - if (wildcard && labels.length < 2) { - return "wildcard must name a registrable domain"; - } - // No top-level domain is numeric, so this is (part of) an IPv4 address. - if (wildcard && /^\d+$/.test(labels[labels.length - 1])) { - return "cannot use a wildcard with an IP address"; - } - return undefined; -}; - -/** - * @internal Check allowed origins locally, as the API would: 1-20 origins of - * at most 512 characters, each by the API's rules (see allowedOriginProblem). - */ -export const checkAllowedOrigins = (allowedOrigins: unknown, what = "allowedOrigins"): void => { - if ( - !Array.isArray(allowedOrigins) || - allowedOrigins.length < 1 || - allowedOrigins.length > MAX_ALLOWED_ORIGINS || - allowedOrigins.some( - (origin) => - typeof origin !== "string" || !origin.trim() || origin.length > MAX_ALLOWED_ORIGIN_LENGTH - ) - ) { - throw invalidRequest( - `${what} must be 1-${MAX_ALLOWED_ORIGINS} origins such as "https://example.com"` - ); - } - for (const origin of allowedOrigins as string[]) { - const problem = allowedOriginProblem(origin); - if (problem) { - throw invalidRequest(`${what}: "${origin}" ${problem}`); - } - } -}; - /** * Secrets are encrypted locally to the platform's public key; the API only * ever receives envelopes, and values are never returned. @@ -171,14 +92,6 @@ export class SecretsService extends BaseService { if (type !== undefined && (typeof type !== "string" || !type)) { throw invalidRequest('type must be "secret", "totp" or "op_token"'); } - if (allowedOrigins !== undefined) { - if (type === "op_token") { - throw invalidRequest( - "op_token secrets can't have allowedOrigins: a 1Password fill names its origins in the request" - ); - } - checkAllowedOrigins(allowedOrigins); - } const plaintext = value === undefined ? undefined : secretPlaintext(type ?? "secret", value); const sealer = plaintext === undefined ? undefined : SecretsSealer.fromPublicKey(await this.getPublicKey()); @@ -273,9 +186,6 @@ export class SecretsService extends BaseService { * again). Not for `op_token` secrets. */ async setAllowedOrigins(secret: string, allowedOrigins: string[] | null): Promise { - if (allowedOrigins !== null) { - checkAllowedOrigins(allowedOrigins); - } return this.update(secret, { metadata: { browser: allowedOrigins === null ? null : { allowedOrigins } }, }); diff --git a/src/services/sessions.ts b/src/services/sessions.ts index 53867b7..9d87fc1 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -29,7 +29,7 @@ import { } from "../types/session"; import { BaseService } from "./base"; import { WebMCPService } from "./webmcp"; -import { checkSessionCredentials, CredentialsService } from "./credentials"; +import { CredentialsService } from "./credentials"; import { HyperbrowserError } from "../client"; const CAPTCHA_EVALUATION_REQUEST_TIMEOUT_MS = 185_000; @@ -82,7 +82,6 @@ export class SessionsService extends BaseService { * @param params Configuration parameters for the new session */ async create(params?: CreateSessionParams): Promise { - checkSessionCredentials(params?.credentials); try { return await this.request("/session", { method: "POST", diff --git a/src/services/vaults.ts b/src/services/vaults.ts index 5b327b9..78f7369 100644 --- a/src/services/vaults.ts +++ b/src/services/vaults.ts @@ -1,11 +1,4 @@ import { HyperbrowserError, invalidRequest } from "../error"; -import { - checkVaultName, - isSecretEnvName, - MAX_SECRET_VERSION, - SECRET_NAME_PATTERN, -} from "../sandbox/secret-references"; -import { isPlainObject } from "../secrets-envelope"; import { CreateVaultParams, UpdateVaultParams, @@ -17,105 +10,10 @@ import { 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; -const MAX_LIST_LIMIT = 100; -/** Teams hold at most 100 vaults, so one page of search results covers every match. */ -const NAME_LOOKUP_LIMIT = MAX_LIST_LIMIT; -const MAX_VAULT_ENTRIES = 32; -const MAX_DESCRIPTION_LENGTH = 1024; - +/** 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)}`; -const rethrow = (error: unknown, message: string): never => { - if (error instanceof HyperbrowserError) { - throw error; - } - throw new HyperbrowserError(message, { cause: error }); -}; - -/** The API trims a description before checking its length. */ -const checkDescription = (description: unknown): void => { - if ( - description !== undefined && - (typeof description !== "string" || description.trim().length > MAX_DESCRIPTION_LENGTH) - ) { - throw invalidRequest( - `description must be a string of at most ${MAX_DESCRIPTION_LENGTH} characters` - ); - } -}; - -/** Check one entry and return it in the API's shape. */ -const serializeEntry = (env: string, entry: unknown): object => { - if (!isPlainObject(entry)) { - throw invalidRequest(`Entry ${env} must be { name, version? }`); - } - const { name, version } = entry; - if (typeof name !== "string" || !SECRET_NAME_PATTERN.test(name)) { - throw invalidRequest(`Entry ${env} must name a secret: ${String(name)} is not a secret name`); - } - if ( - version !== undefined && - version !== "latest" && - (!Number.isSafeInteger(version) || - (version as number) < 1 || - (version as number) > MAX_SECRET_VERSION) - ) { - throw invalidRequest( - `Entry ${env} version must be an integer from 1 to ${MAX_SECRET_VERSION} or "latest"` - ); - } - return version === undefined ? { name } : { name, version }; -}; - -/** Check entries locally; `null` (remove) is allowed only for updates. */ -const serializeEntries = ( - entries: unknown, - allowRemovals: boolean -): Record | undefined => { - if (entries === undefined) { - return undefined; - } - if (!isPlainObject(entries)) { - throw invalidRequest("entries must map environment variable names to entries"); - } - const wire: Record = {}; - for (const [env, entry] of Object.entries(entries)) { - if (!isSecretEnvName(env)) { - throw invalidRequest( - `Invalid environment variable name for a vault entry: ${(env as string).slice(0, 64)}` - ); - } - wire[env] = entry === null && allowRemovals ? null : serializeEntry(env, entry); - } - return wire; -}; - -const serializeCreate = (params: CreateVaultParams): Record => { - if (!isPlainObject(params)) { - throw invalidRequest("Vault parameters must be an object"); - } - const { name, description, entries } = params; - checkVaultName(name, "Vault name"); - checkDescription(description); - const wire = serializeEntries(entries, false); - if (wire && Object.keys(wire).length > MAX_VAULT_ENTRIES) { - throw invalidRequest(`A vault can hold at most ${MAX_VAULT_ENTRIES} entries`); - } - return { name, description, entries: wire }; -}; - -const serializeUpdate = (params: UpdateVaultParams): Record => { - if (!isPlainObject(params)) { - throw invalidRequest("Vault update parameters must be an object"); - } - const { description, entries } = params; - if (description === undefined && entries === undefined) { - throw invalidRequest("Pass a description or entries to update"); - } - checkDescription(description); - return { description, entries: serializeEntries(entries, true) }; -}; - /** * Vaults name a set of environment variables whose values come from stored * secrets. Pass one as `vault` to a sandbox process @@ -128,43 +26,21 @@ const serializeUpdate = (params: UpdateVaultParams): Record => export class VaultsService extends BaseService { async list(params: VaultListParams = {}): Promise { const { page, limit, search } = params; - if (page !== undefined && (!Number.isSafeInteger(page) || page < 1)) { - throw invalidRequest("page must be a positive integer"); - } - if ( - limit !== undefined && - (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) - ) { - throw invalidRequest(`limit must be an integer from 1 to ${MAX_LIST_LIMIT}`); - } - try { - const response = await this.request("/vaults", undefined, { - search, - page, - limit, - }); - return { ...response, vaults: response.vaults ?? [] }; - } catch (error) { - return rethrow(error, "Failed to list vaults"); - } + const response = await this.request("/vaults", undefined, { + search, + page, + limit, + }); + return { ...response, vaults: response.vaults ?? [] }; } - async get(vault: string): Promise { - try { - return await this.resolve(vault); - } catch (error) { - return rethrow(error, `Failed to get vault ${vault}`); - } + get(vault: string): Promise { + return this.resolve(vault); } /** Create a vault. Entries name secrets, never values. */ - async create(params: CreateVaultParams): Promise { - const body = serializeCreate(params); - try { - return await this.request("/vaults", { method: "POST", body: JSON.stringify(body) }); - } catch (error) { - return rethrow(error, `Failed to create vault ${params.name}`); - } + create(params: CreateVaultParams): Promise { + return this.request("/vaults", { method: "POST", body: JSON.stringify(params) }); } /** @@ -173,26 +49,17 @@ export class VaultsService extends BaseService { * not given are kept. */ async update(vault: string, params: UpdateVaultParams): Promise { - const body = serializeUpdate(params); - try { - const { id } = await this.resolve(vault); - return await this.request(vaultPath(id), { - method: "PATCH", - body: JSON.stringify(body), - }); - } catch (error) { - return rethrow(error, `Failed to update vault ${vault}`); - } + 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 { - try { - const { id } = await this.resolve(vault); - return await this.request(vaultPath(id), { method: "DELETE" }); - } catch (error) { - return rethrow(error, `Failed to delete vault ${vault}`); - } + const { id } = await this.resolve(vault); + return await this.request(vaultPath(id), { method: "DELETE" }); } private async resolve(key: string): Promise { diff --git a/src/tools/gemini.ts b/src/tools/gemini.ts index de192e9..60ac0e4 100644 --- a/src/tools/gemini.ts +++ b/src/tools/gemini.ts @@ -1,151 +1,39 @@ -import { - BROWSER_USE_TOOL_OPENAI, - ChatCompletionTool, - CLAUDE_COMPUTER_USE_TOOL_OPENAI, - CRAWL_TOOL_OPENAI, - EXTRACT_TOOL_OPENAI, - OPENAI_CUA_TOOL_OPENAI, - SCRAPE_TOOL_OPENAI, - SCREENSHOT_TOOL_OPENAI, -} from "./openai"; import { FILL_CREDENTIAL_DESCRIPTION, FILL_CREDENTIAL_SCHEMA } from "./schema"; -/** Gemini's schema types, as `@google/genai`'s `Type` enum spells them. */ -export type GeminiType = "STRING" | "NUMBER" | "INTEGER" | "BOOLEAN" | "ARRAY" | "OBJECT"; - -/** - * Gemini's OpenAPI-subset schema: no `additionalProperties`, and `nullable` - * instead of type arrays. Integer limits are strings, as in the API. - */ -export interface GeminiSchema { - /** - * A `GeminiType`. Typed loosely so declarations can be passed where - * `@google/genai` expects its `Type` enum, which string literals can't be - * assigned to. - */ - type?: any; - description?: string; - nullable?: boolean; - enum?: string[]; - format?: string; - title?: string; - default?: unknown; - properties?: Record; - required?: string[]; - items?: GeminiSchema; - minItems?: string; - maxItems?: string; - minLength?: string; - maxLength?: string; - minimum?: number; - maximum?: number; - pattern?: string; -} - -/** A function declaration for Gemini: `tools: [{ functionDeclarations: [...] }]`. */ -export interface FunctionDeclaration { - name: string; - description?: string; - parameters?: GeminiSchema; -} - -const GEMINI_TYPES: Record = { - string: "STRING", - number: "NUMBER", - integer: "INTEGER", - boolean: "BOOLEAN", - array: "ARRAY", - object: "OBJECT", +// 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, + }, }; - -/** Keywords Gemini accepts unchanged. Anything else is dropped. */ -const COPIED_KEYWORDS = [ - "description", - "enum", - "format", - "title", - "default", - "required", - "minimum", - "maximum", - "pattern", -] as const; -/** Integer keywords Gemini takes as strings (int64). */ -const INTEGER_KEYWORDS = ["minItems", "maxItems", "minLength", "maxLength"] as const; - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -/** - * Convert one of the SDK's JSON Schemas (as used for OpenAI and Anthropic) to - * Gemini's schema form: `["string", "null"]` becomes `"STRING"` with - * `nullable: true`, and `additionalProperties` is dropped. - */ -export function toGeminiSchema(schema: Record): GeminiSchema { - if (!isRecord(schema)) { - throw new TypeError("A JSON Schema object is required"); - } - const result: GeminiSchema = {}; - if (schema.type !== undefined) { - const types: unknown[] = Array.isArray(schema.type) ? schema.type : [schema.type]; - const nonNull = types.filter((type) => type !== "null"); - if (nonNull.length !== 1 || typeof nonNull[0] !== "string" || !GEMINI_TYPES[nonNull[0]]) { - throw new TypeError(`Unsupported schema type for Gemini: ${JSON.stringify(schema.type)}`); - } - result.type = GEMINI_TYPES[nonNull[0]]; - if (nonNull.length < types.length) { - result.nullable = true; - } - } - if (schema.nullable === true) { - result.nullable = true; - } - for (const keyword of COPIED_KEYWORDS) { - if (schema[keyword] !== undefined) { - (result as Record)[keyword] = schema[keyword]; - } - } - for (const keyword of INTEGER_KEYWORDS) { - if (schema[keyword] !== undefined) { - result[keyword] = String(schema[keyword]); - } - } - if (isRecord(schema.properties)) { - result.properties = Object.fromEntries( - Object.entries(schema.properties).map(([name, property]) => [name, toGeminiSchema(property)]) - ); - } - if (isRecord(schema.items)) { - result.items = toGeminiSchema(schema.items); - } - return result; -} - -/** Build a Gemini function declaration from a JSON Schema tool definition. */ -export const toGeminiFunctionDeclaration = ( - name: string, - description: string, - parameters: Record -): FunctionDeclaration => ({ name, description, parameters: toGeminiSchema(parameters) }); - -/** The Gemini form of an OpenAI tool definition: same name, description and schema. */ -const fromOpenAI = ({ function: definition }: ChatCompletionTool): FunctionDeclaration => - toGeminiFunctionDeclaration( - definition.name, - definition.description ?? "", - definition.parameters ?? { type: "object", properties: {} } - ); - -export const SCRAPE_TOOL_GEMINI = fromOpenAI(SCRAPE_TOOL_OPENAI); -export const SCREENSHOT_TOOL_GEMINI = fromOpenAI(SCREENSHOT_TOOL_OPENAI); -export const CRAWL_TOOL_GEMINI = fromOpenAI(CRAWL_TOOL_OPENAI); -export const EXTRACT_TOOL_GEMINI = fromOpenAI(EXTRACT_TOOL_OPENAI); -export const BROWSER_USE_TOOL_GEMINI = fromOpenAI(BROWSER_USE_TOOL_OPENAI); -export const CLAUDE_COMPUTER_USE_TOOL_GEMINI = fromOpenAI(CLAUDE_COMPUTER_USE_TOOL_OPENAI); -export const OPENAI_CUA_TOOL_GEMINI = fromOpenAI(OPENAI_CUA_TOOL_OPENAI); - -export const FILL_CREDENTIAL_TOOL_GEMINI: FunctionDeclaration = toGeminiFunctionDeclaration( - "fill_credential", - FILL_CREDENTIAL_DESCRIPTION, - FILL_CREDENTIAL_SCHEMA -); diff --git a/src/tools/index.ts b/src/tools/index.ts index c88873f..13d8744 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -18,16 +18,7 @@ import { OPENAI_CUA_TOOL_ANTHROPIC, FILL_CREDENTIAL_TOOL_ANTHROPIC, } from "./anthropic"; -import { - SCRAPE_TOOL_GEMINI, - SCREENSHOT_TOOL_GEMINI, - CRAWL_TOOL_GEMINI, - EXTRACT_TOOL_GEMINI, - BROWSER_USE_TOOL_GEMINI, - CLAUDE_COMPUTER_USE_TOOL_GEMINI, - OPENAI_CUA_TOOL_GEMINI, - FILL_CREDENTIAL_TOOL_GEMINI, -} from "./gemini"; +import { FILL_CREDENTIAL_TOOL_GEMINI } from "./gemini"; import { HyperbrowserClient, HyperbrowserError } from "../client"; import { StartScrapeJobParams, @@ -58,7 +49,6 @@ export interface CredentialFillToolParams { export class WebsiteScrapeTool { static openaiToolDefinition = SCRAPE_TOOL_OPENAI; static anthropicToolDefinition = SCRAPE_TOOL_ANTHROPIC; - static geminiToolDefinition = SCRAPE_TOOL_GEMINI; static async runnable(hb: HyperbrowserClient, params: StartScrapeJobParams): Promise { const resp = await hb.scrape.startAndWait(params); @@ -69,7 +59,6 @@ export class WebsiteScrapeTool { export class WebsiteScreenshotTool { static openaiToolDefinition = SCREENSHOT_TOOL_OPENAI; static anthropicToolDefinition = SCREENSHOT_TOOL_ANTHROPIC; - static geminiToolDefinition = SCREENSHOT_TOOL_GEMINI; static async runnable(hb: HyperbrowserClient, params: StartScrapeJobParams): Promise { const resp = await hb.scrape.startAndWait(params); @@ -80,7 +69,6 @@ export class WebsiteScreenshotTool { export class WebsiteCrawlTool { static openaiToolDefinition = CRAWL_TOOL_OPENAI; static anthropicToolDefinition = CRAWL_TOOL_ANTHROPIC; - static geminiToolDefinition = CRAWL_TOOL_GEMINI; static async runnable(hb: HyperbrowserClient, params: StartCrawlJobParams): Promise { const resp = await hb.crawl.startAndWait(params); @@ -100,7 +88,6 @@ export class WebsiteCrawlTool { export class WebsiteExtractTool { static openaiToolDefinition = EXTRACT_TOOL_OPENAI; static anthropicToolDefinition = EXTRACT_TOOL_ANTHROPIC; - static geminiToolDefinition = EXTRACT_TOOL_GEMINI; static async runnable(hb: HyperbrowserClient, params: StartExtractJobParams): Promise { if (params.schema && typeof params.schema === "string") { @@ -114,7 +101,6 @@ export class WebsiteExtractTool { export class BrowserUseTool { static openaiToolDefinition = BROWSER_USE_TOOL_OPENAI; static anthropicToolDefinition = BROWSER_USE_TOOL_ANTHROPIC; - static geminiToolDefinition = BROWSER_USE_TOOL_GEMINI; static async runnable( hb: HyperbrowserClient, @@ -128,7 +114,6 @@ export class BrowserUseTool { export class ClaudeComputerUseTool { static openaiToolDefinition = CLAUDE_COMPUTER_USE_TOOL_OPENAI; static anthropicToolDefinition = CLAUDE_COMPUTER_USE_TOOL_ANTHROPIC; - static geminiToolDefinition = CLAUDE_COMPUTER_USE_TOOL_GEMINI; static async runnable( hb: HyperbrowserClient, @@ -142,7 +127,6 @@ export class ClaudeComputerUseTool { export class OpenAICuaTool { static openaiToolDefinition = OPENAI_CUA_TOOL_OPENAI; static anthropicToolDefinition = OPENAI_CUA_TOOL_ANTHROPIC; - static geminiToolDefinition = OPENAI_CUA_TOOL_GEMINI; static async runnable(hb: HyperbrowserClient, params: StartCuaTaskParams): Promise { const resp = await hb.agents.cua.startAndWait(params); diff --git a/tests/unit/credentials-fill.test.ts b/tests/unit/credentials-fill.test.ts index cc48c0c..88d3e70 100644 --- a/tests/unit/credentials-fill.test.ts +++ b/tests/unit/credentials-fill.test.ts @@ -9,19 +9,11 @@ vi.mock("node-fetch", async () => { import { HyperbrowserClient as Hyperbrowser, HyperbrowserError } from "../../src/client"; import { - BrowserUseTool, - ClaudeComputerUseTool, CredentialFillTool, - OpenAICuaTool, - WebsiteCrawlTool, - WebsiteExtractTool, - WebsiteScrapeTool, - WebsiteScreenshotTool, FILL_CREDENTIAL_SCHEMA, FILL_CREDENTIAL_TOOL_ANTHROPIC, FILL_CREDENTIAL_TOOL_GEMINI, FILL_CREDENTIAL_TOOL_OPENAI, - toGeminiSchema, } from "../../src/tools"; import type { CreateSessionParams, @@ -175,64 +167,6 @@ describe("sessions", () => { }); }); - test.each([ - ["neither credential nor vault", { fields }], - ["both credential and vault", { credential: "a", vault: "b", fields: vaultFields }], - ["an empty credential", { credential: "", fields }], - ["a credential over 255 characters", { credential: "c".repeat(256), fields }], - ["a vault that isn't a vault name", { vault: "github login", fields: vaultFields }], - ["no fields", { credential: "github-password", fields: [] }], - [ - "17 fields", - { - credential: "github-password", - fields: Array.from({ length: 17 }, (_, i) => ({ selector: `#f${i}` })), - }, - ], - [ - "a repeated selector", - { - vault: "github-login", - fields: [vaultFields[0], { field: "PASSWORD", selector: "#login_field" }], - }, - ], - ["a field with a credential", { credential: "github-password", fields: [vaultFields[1]] }], - ["a vault fill without field", { vault: "github-login", fields }], - [ - "a field that isn't an entry name", - { vault: "github-login", fields: [{ field: "pass word", selector: "#p" }] }, - ], - ["an empty selector", { credential: "github-password", fields: [{ selector: "" }] }], - [ - "a selector over 1024 bytes", - { credential: "github-password", fields: [{ selector: "é".repeat(513) }] }, - ], - ["an empty page ID", { credential: "github-password", fields, pageId: "" }], - [ - "a page ID over 128 bytes", - { credential: "github-password", fields, pageId: "p".repeat(129) }, - ], - [ - "pageId with pageUrl", - { credential: "github-password", fields, pageId: "A", pageUrl: "https://a" }, - ], - [ - "a page URL over 2048 bytes", - { credential: "github-password", fields, pageUrl: "u".repeat(2049) }, - ], - ["an empty frame URL", { credential: "github-password", fields, frameUrl: "" }], - [ - "a frame URL over 2048 bytes", - { credential: "github-password", fields, frameUrl: "u".repeat(2049) }, - ], - ])("fill rejects %s before any request", async (_name, params) => { - await expect(client().sessions.credentials.fill(SESSION, params as any)).rejects.toMatchObject({ - code: "invalid_request", - retryable: false, - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - test("fill requires a session ID", async () => { await expect( client().sessions.credentials.fill("", { credential: "github-password", fields }) @@ -312,22 +246,6 @@ describe("1Password fills", () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); - test.each([ - ["21 items", Array.from({ length: 21 }, (_, i) => `s-${i}`)], - ["a name with a space", [{ onePassword: "op token" }]], - ["an empty name", [""]], - ["a non-string name", [{ onePassword: 1 }]], - ["two kinds in one item", [{ onePassword: "op-token", secret: "s" }]], - ["an unknown kind", [{ token: "op-token" }]], - ["an empty object", [{}]], - ["not a list", "op-token"], - ])("create rejects credentials with %s before any request", async (_name, credentials) => { - await expect( - client().sessions.create({ credentials: credentials as any }) - ).rejects.toMatchObject({ code: "invalid_request", retryable: false }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - 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, { @@ -382,77 +300,6 @@ describe("1Password fills", () => { }); const ref = (value: Record) => [{ selector: "#f", ref: value }]; - test.each([ - ["onePassword with credential", { ...opFill(), credential: "github-password" }], - ["onePassword with vault", { ...opFill(), vault: "github-login" }], - ["a field without ref", opFill({}, [refFields[0], { selector: "#otp" }])], - ["a ref without onePassword", { credential: "github-password", fields: refFields }], - [ - "a ref in a vault fill", - { vault: "github-login", fields: [{ ...vaultFields[0], ref: refFields[0].ref }] }, - ], - ["a field with onePassword", opFill({}, [{ ...refFields[0], field: "USERNAME" }])], - ["onePassword that isn't an object", { onePassword: "op-token", fields: refFields }], - ["a token that isn't a secret name", opFill({ token: "op token" })], - ["no token", opFill({ token: undefined })], - ["an empty vault", opFill({ vault: "" })], - ["a vault with a slash", opFill({ vault: "Dev/Vault" })], - ["a vault of '..'", opFill({ vault: ".." })], - ["a vault over 256 characters", opFill({ vault: "v".repeat(257) })], - ["no allowedOrigins", opFill({ allowedOrigins: undefined })], - ["0 allowedOrigins", opFill({ allowedOrigins: [] })], - [ - "21 allowedOrigins", - opFill({ allowedOrigins: Array.from({ length: 21 }, (_, i) => `https://a${i}.example.com`) }), - ], - ["an http origin", opFill({ allowedOrigins: ["http://example.com"] })], - ["a wildcard on a single label", opFill({ allowedOrigins: ["https://*.com"] })], - ["a wildcard on an IP address", opFill({ allowedOrigins: ["https://*.10.0.0.1"] })], - ["an origin with a path", opFill({ allowedOrigins: ["https://example.com/login"] })], - ["an origin without a scheme", opFill({ allowedOrigins: ["example.com"] })], - ["an origin over 512 characters", opFill({ allowedOrigins: [`https://${"a".repeat(505)}`] })], - ["a bad IPv6 origin", opFill({ allowedOrigins: ["https://[nope]"] })], - ["an unknown member of onePassword", opFill({ fields: {} })], - ["an unknown member of ref", opFill({}, ref({ item: "i", field: "f", vault: "v" }))], - ["a ref that isn't an object", opFill({}, [{ selector: "#f", ref: "op://v/i/f" }])], - ["a ref without item", opFill({}, ref({ field: "password" }))], - ["a ref without field", opFill({}, ref({ item: "i" }))], - ["an item with a slash", opFill({}, ref({ item: "a/b", field: "f" }))], - ["an item with a question mark", opFill({}, ref({ item: "a?b", field: "f" }))], - ["an item with a control character", opFill({}, ref({ item: "a\u0007", field: "f" }))], - ["an empty section", opFill({}, ref({ item: "i", section: "", field: "f" }))], - ["a section of '.'", opFill({}, ref({ item: "i", section: ".", field: "f" }))], - ["a field over 256 characters", opFill({}, ref({ item: "i", field: "f".repeat(257) }))], - [ - "a field query with an uppercase key", - opFill({}, ref({ item: "i", field: "otp?Attribute=otp" })), - ], - ["a field with an empty query", opFill({}, ref({ item: "i", field: "otp?" }))], - ["a field of '..'", opFill({}, ref({ item: "i", field: "..?attribute=otp" }))], - [ - "a 513-byte reference", - opFill({ vault: "v" }, ref({ item: "i", field: "é".repeat(248) + "x".repeat(8) })), - ], - [ - "a repeated selector", - opFill({}, [refFields[0], { selector: "#email", ref: refFields[1].ref }]), - ], - ["no fields", opFill({}, [])], - [ - "17 fields", - opFill( - {}, - Array.from({ length: 17 }, (_, i) => ({ selector: `#f${i}`, ref: refFields[0].ref })) - ), - ], - ])("fill rejects %s before any request", async (_name, params) => { - await expect(client().sessions.credentials.fill(SESSION, params as any)).rejects.toMatchObject({ - code: "invalid_request", - retryable: false, - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - test("a reference failure keeps the selectors and reasons in details", async () => { const failure = { code: "SECRET_ONEPASSWORD_REFERENCE_FAILED", @@ -479,12 +326,18 @@ describe("1Password fills", () => { 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: { code: "invalid_request" } }); - expect(fetchMock).not.toHaveBeenCalled(); + 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); }); }); @@ -604,35 +457,6 @@ describe("fill_credential tool", () => { }); }); - test.each([ - WebsiteScrapeTool, - WebsiteScreenshotTool, - WebsiteCrawlTool, - WebsiteExtractTool, - BrowserUseTool, - ClaudeComputerUseTool, - OpenAICuaTool, - CredentialFillTool, - ])("$name has a Gemini declaration matching its OpenAI definition", (tool) => { - const declaration = tool.geminiToolDefinition; - expect(declaration.name).toBe(tool.openaiToolDefinition.function.name); - expect(declaration.description).toBe(tool.openaiToolDefinition.function.description); - expect(declaration.parameters?.type).toBe("OBJECT"); - expect(collectKeys(declaration.parameters).has("additionalProperties")).toBe(false); - expect(Object.keys(declaration.parameters?.properties ?? {})).toEqual( - Object.keys((tool.openaiToolDefinition.function.parameters as any).properties) - ); - }); - - test("the Gemini converter refuses schemas it can't express", () => { - expect(() => toGeminiSchema({ type: ["string", "number"] })).toThrow(/Unsupported/); - expect(toGeminiSchema({ type: "integer", enum: ["1"], default: 1 })).toEqual({ - type: "INTEGER", - enum: ["1"], - default: 1, - }); - }); - 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, { @@ -737,18 +561,48 @@ describe("fill_credential tool", () => { }); test.each([ - ["an empty selector", { credential: "github-password", fields: [{ selector: "" }] }], - ["both names", { credential: "a", vault: "b", fields: vaultFields }], - ["a vault without fields' entries", { vault: "github-login", fields }], - ["no input at all", undefined], - ])("invalid model input (%s) comes back as JSON without a request", async (_name, input) => { - const output = await CredentialFillTool.runnable(client(), SESSION, input as any); - expect(JSON.parse(output)).toMatchObject({ - error: { code: "invalid_request", retryable: false, details: null }, + { 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")); diff --git a/tests/unit/sandbox-secrets.test.ts b/tests/unit/sandbox-secrets.test.ts index c12f9ca..043e19d 100644 --- a/tests/unit/sandbox-secrets.test.ts +++ b/tests/unit/sandbox-secrets.test.ts @@ -486,14 +486,11 @@ describe("vaults in sandboxes", () => { expect(sandbox.toJSON().startupSecrets).toEqual(vaultBindings); }); - test("vault is checked locally like secrets", async () => { + 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", vault: "app env" })).rejects.toThrow( - /vault name/ + 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" }) @@ -529,7 +526,6 @@ describe("vaults in sandboxes", () => { }); expect(handle.secrets).toEqual(vaultBindings); - await expect(api.start("x", { vault: "" })).rejects.toThrow(/vault name/); expect(start.start).toHaveBeenCalledTimes(1); }); }); @@ -653,89 +649,6 @@ describe("1Password in sandboxes", () => { fields: { A: { item: "Login", field: "password", ...overrides } }, }); - test.each<[string, unknown, Record?]>([ - ["a bad token name", { ...onePassword, token: "-t" }], - ["a missing token", { vault: "v", fields: onePassword.fields }], - ["a vault with '/'", { ...onePassword, vault: "a/b" }], - ["the vault '.'", { ...onePassword, vault: "." }], - ["the item '.'", field({ item: "." })], - ["the item '..'", field({ item: ".." })], - ["the item 'a?b'", field({ item: "a?b" })], - ["an empty section", field({ section: "" })], - ["the field '..'", field({ field: ".." })], - ["the field '.?attribute=otp'", field({ field: ".?attribute=otp" })], - ["a field query with an uppercase key", field({ field: "otp?Attr=x" })], - ["a field query without a value", field({ field: "otp?attribute=" })], - ["a 257-character item", field({ item: "x".repeat(257) })], - ["a 257-character field", field({ field: "x".repeat(257) })], - ["U+200B in an item", field({ item: "a\u200bb" })], - ["U+2028 in a field", field({ field: "a\u2028b" })], - ["a newline in a section", field({ section: "a\nb" })], - [ - "a reference over 512 bytes", - { - ...onePassword, - vault: "v".repeat(256), - fields: { A: { item: "é".repeat(128), field: "f" } }, - }, - ], - ["no fields", { ...onePassword, fields: {} }], - [ - "33 fields", - { - ...onePassword, - fields: Object.fromEntries( - Array.from({ length: 33 }, (_, i) => [`E${i}`, { item: "i", field: "f" }]) - ), - }, - ], - ["a bad env name", { ...onePassword, fields: { "1BAD": { item: "i", field: "f" } } }], - ["an unknown member", { ...onePassword, account: "my" }], - ["an unknown field member", field({ reference: "op://v/i/f" })], - ["a field that isn't an object", { ...onePassword, fields: { A: "op://v/i/f" } }], - ["null", null], - ["a variable also in secrets", onePassword, { secrets: { SC_USER: "provider-key" } }], - ["a variable also in env", onePassword, { env: { SC_OTP: "123456" } }], - [ - "more than 32 variables with secrets", - { - ...onePassword, - fields: Object.fromEntries( - Array.from({ length: 30 }, (_, i) => [`OP${i}`, { item: "i", field: "f" }]) - ), - }, - { secrets: { A: "a", B: "b", C: "c" } }, - ], - ])("%s is refused locally before any request", async (_name, op, extra = {}) => { - const start = starter(); - const api = new SandboxProcessesApi(runtime({}).transport, start); - await expect( - api.start({ command: "x", ...extra, onePassword: op as any }) - ).rejects.toMatchObject({ code: "invalid_request", retryable: false }); - await expect( - api.exec({ command: "x", ...extra, onePassword: op as any }) - ).rejects.toMatchObject({ code: "invalid_request", retryable: false }); - expect(start.start).not.toHaveBeenCalled(); - - if (!("env" in extra)) { - const { service, requestSpy } = createService(); - await expect( - service.create({ imageName: "node", ...extra, onePassword: op as any }) - ).rejects.toMatchObject({ code: "invalid_request", retryable: false }); - expect(requestSpy).not.toHaveBeenCalled(); - } - }); - - test("clashes name the variable and both sources", async () => { - const api = new SandboxProcessesApi(runtime({}).transport, starter()); - await expect(api.start({ command: "x", env: { SC_OTP: "1" }, onePassword })).rejects.toThrow( - "SC_OTP is set by env and by onePassword; a 1Password field's variable must come from it alone" - ); - await expect( - api.start({ command: "x", secrets: { SC_USER: "s" }, onePassword }) - ).rejects.toThrow(/SC_USER is set by secrets and by onePassword/); - }); - 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"); diff --git a/tests/unit/secrets-contract.test.ts b/tests/unit/secrets-contract.test.ts index c4a81cf..fd5fc4d 100644 --- a/tests/unit/secrets-contract.test.ts +++ b/tests/unit/secrets-contract.test.ts @@ -438,44 +438,13 @@ describe("typed secrets and allowed origins", () => { ).rejects.toThrow(/HOTP .* not supported/); }); - test("op_token values are any string, and op_token secrets can't have allowed origins", async () => { - const { service, calls, requestSpy } = mockService(created(opToken())); + 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 ); - requestSpy.mockClear(); - await expect( - service.create({ - name: "op-token", - type: "op_token", - value: "ops_x", - allowedOrigins: ["https://a.com"], - }) - ).rejects.toMatchObject({ code: "invalid_request" }); - expect(calls()).toEqual([]); - }); - test("bad allowedOrigins fail before any request", async () => { - const { service, calls } = mockService(created(secret())); - for (const allowedOrigins of [ - [], - [""], - ["x".repeat(513)], - Array(21).fill("https://a.com"), - "https://a.com", - ["http://example.com"], - ["https://*.com"], - ["https://example.com/login"], - ["ftp://example.com"], - ["https://-a.com"], - ["https://example.com:70000"], - ]) { - await expect( - service.create({ name: "k", value: "v", allowedOrigins: allowedOrigins as any }) - ).rejects.toMatchObject({ code: "invalid_request" }); - } - expect(calls()).toEqual([]); }); test("addVersion validates against the resolved secret's type", async () => { @@ -538,9 +507,6 @@ describe("typed secrets and allowed origins", () => { `PATCH /secrets/${ID}`, ]); - await expect(service.setAllowedOrigins("provider-key", [])).rejects.toMatchObject({ - code: "invalid_request", - }); await expect(service.update("provider-key", {})).rejects.toMatchObject({ code: "invalid_request", }); diff --git a/tests/unit/vaults.test.ts b/tests/unit/vaults.test.ts index da92717..66ed927 100644 --- a/tests/unit/vaults.test.ts +++ b/tests/unit/vaults.test.ts @@ -85,32 +85,6 @@ describe("vaults service", () => { expect(vault).toEqual(stored()); }); - test.each<[string, unknown]>([ - ["a bad vault name", { name: "github login" }], - ["a bad env name", { name: "v", entries: { "1BAD": { name: "s" } } }], - ["__proto__", { name: "v", entries: JSON.parse('{"__proto__": {"name": "s"}}') }], - ["a long env name", { name: "v", entries: { ["A".repeat(257)]: { name: "s" } } }], - ["a bad secret name", { name: "v", entries: { A: { name: "-s" } } }], - ["version 0", { name: "v", entries: { A: { name: "s", version: 0 } } }], - ["a removal on create", { name: "v", entries: { A: null } }], - ["a 1Password entry", { name: "v", entries: { A: { item: "i", field: "f" } } }], - ["a non-object entry", { name: "v", entries: { A: "s" } }], - [ - "33 entries", - { - name: "v", - entries: Object.fromEntries(Array.from({ length: 33 }, (_, i) => [`E${i}`, { name: "s" }])), - }, - ], - ])("create rejects %s before any request", async (_name, params) => { - const { service, calls } = mockService(() => stored()); - await expect(service.create(params as any)).rejects.toMatchObject({ - code: "invalid_request", - retryable: false, - }); - expect(calls()).toEqual([]); - }); - test("a vault with a stray source still parses", async () => { const withSource = { ...stored(), source: { type: "stored" } }; const { service } = mockService((_method, path) => @@ -193,26 +167,10 @@ describe("vaults service", () => { expect(bodies()[1]).toEqual({ description }); }); - test.each([ - ["nothing", {}], - ["only a source", { source: { tokenSecret: "op-token" } }], - ["a 1Password entry", { entries: { A: { item: "i", field: "f" } } }], - ["a bad entry", { entries: { A: { name: "" } } }], - ["a long description", { description: "d".repeat(1025) }], - ])("update rejects %s before any request", async (_name, params) => { - const { service, calls } = mockService(() => stored()); - await expect(service.update("github-login", params as any)).rejects.toMatchObject({ - code: "invalid_request", - }); - expect(calls()).toEqual([]); - }); - - test("list passes pagination and search, and checks them", async () => { + 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 }); - await expect(service.list({ limit: 101 })).rejects.toMatchObject({ code: "invalid_request" }); - await expect(service.list({ page: 0 })).rejects.toMatchObject({ code: "invalid_request" }); expect(requestSpy).toHaveBeenCalledTimes(1); }); From 6899a1107ff1a7274a5ef53e59dee0c3ac9995b1 Mon Sep 17 00:00:00 2001 From: AHarmlessPyro Date: Wed, 14 Oct 2026 23:58:21 -0700 Subject: [PATCH 23/23] Bump the version to 1.3.1 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": {