Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1c2f1a0
Add credential secrets, session credential fill, and a fill_credentia…
AHarmlessPyro Oct 13, 2026
a3e68c4
Document credential fill and bump version to 1.3.0
AHarmlessPyro Oct 13, 2026
e592b87
Keep error details as the body and add Gemini definitions to every tool
AHarmlessPyro Oct 13, 2026
e682872
Use the API's spelling of the browser sessions scope in the README
AHarmlessPyro Oct 14, 2026
b7a71d3
Leave the version for the release to set
AHarmlessPyro Oct 14, 2026
d363d8e
Fill secrets and vaults into sessions with selectors
AHarmlessPyro Oct 14, 2026
f093306
Replace credential secrets with allowed origins, TOTP and op_token types
AHarmlessPyro Oct 14, 2026
ef9bcb8
Add client.vaults for stored and 1Password vaults
AHarmlessPyro Oct 14, 2026
a434489
Deliver vaults to sandboxes and report every binding kind
AHarmlessPyro Oct 14, 2026
2d519d6
Document secrets with origins, TOTP, vaults and 1Password in the README
AHarmlessPyro Oct 14, 2026
983c3c4
Refuse incomplete and HOTP TOTP values, and drop the short-seed warning
AHarmlessPyro Oct 14, 2026
bc31886
Attribute wrong-kind vault entries to VAULT_ENTRY_INVALID in the README
AHarmlessPyro Oct 14, 2026
f46ad68
Read TOTP seeds as vm-service does, and keep fill and sandbox fixes
AHarmlessPyro Oct 14, 2026
234f8e1
Refuse an empty source in a vault update, as the API now does
AHarmlessPyro Oct 14, 2026
8991b10
Drop TOTP as the example of a value a host can't fill
AHarmlessPyro Oct 14, 2026
9a23677
Move 1Password from vaults to a per-request onePassword source
AHarmlessPyro Oct 14, 2026
a902ee8
Explain how a fill chooses its tab, with the origin check after
AHarmlessPyro Oct 14, 2026
05ca387
Fill 1Password fields into browser sessions through an op_token
AHarmlessPyro Oct 14, 2026
5cade7a
Read TOTP seeds split across lines, as vm-service now does
AHarmlessPyro Oct 14, 2026
27643bf
Check a vault description's length after trimming, as the API does
AHarmlessPyro Oct 14, 2026
9d92768
Type tab_opened session events
AHarmlessPyro Oct 14, 2026
8c7a73c
Simplify credential validation and keep Gemini tools scoped to fill
AHarmlessPyro Oct 14, 2026
6899a11
Bump the version to 1.3.1
AHarmlessPyro Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
430 changes: 427 additions & 3 deletions README.md

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions docs/credentials-cleanup.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hyperbrowser/sdk",
"version": "1.2.2",
"version": "1.3.1",
"description": "Node SDK for Hyperbrowser API",
"author": "",
"repository": {
Expand Down
3 changes: 3 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"];
Expand All @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
33 changes: 24 additions & 9 deletions src/sandbox/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
assertNoEnvSecretOverlap,
serializeSecretReferences,
WireOnePassword,
WireSecretReference,
} from "./secret-references";
import {
Expand Down Expand Up @@ -59,7 +60,11 @@ export interface SecretProcessStartPayload {
env?: Record<string, string>;
timeoutMs?: number;
runAs?: string;
secrets: Record<string, WireSecretReference>;
secrets?: Record<string, WireSecretReference>;
/** 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 {
Expand All @@ -68,6 +73,14 @@ export interface SecretProcessStartResponse {
secrets?: SandboxResolvedSecret[];
}

/** Whether a start delivers secrets, so it goes through the control API. */
const deliversSecrets = (params: SandboxExecParams): boolean =>
params.vault !== undefined ||
params.onePassword !== undefined ||
(params.secrets !== undefined &&
params.secrets !== null &&
Object.keys(params.secrets).length > 0);

/** @internal Starts processes with secrets through the control API. */
export interface SecretProcessStarter {
sandboxId: string;
Expand Down Expand Up @@ -323,7 +336,7 @@ export class SandboxProcessHandle {
private closeStream: (() => void) | null = null;
private readonly changed = new ChangeSignal();

/** The secret versions delivered to a process started with `secrets`. */
/** What a process started with `secrets`, `vault` or `onePassword` received. */
public readonly secrets?: SandboxResolvedSecret[];

constructor(
Expand Down Expand Up @@ -665,7 +678,7 @@ export class SandboxProcessesApi {
options?: SandboxExecOptions
): Promise<SandboxProcessResult> {
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);
Expand All @@ -688,7 +701,7 @@ export class SandboxProcessesApi {
options?: SandboxExecOptions
): Promise<SandboxProcessHandle> {
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;
Expand Down Expand Up @@ -730,11 +743,11 @@ export class SandboxProcessesApi {
if (!starter) {
throw new HyperbrowserError("Starting a process with secrets requires a sandbox handle");
}
const secrets = serializeSecretReferences(params.secrets) as Record<
string,
WireSecretReference
>;
assertNoEnvSecretOverlap(params.env, secrets);
const secrets = serializeSecretReferences(params.secrets);
const vault = params.vault;
const onePassword = params.onePassword;
// A clash between env and the vault's variables is reported by the API.
if (secrets !== undefined) assertNoEnvSecretOverlap(params.env, secrets);
const timeoutMs = secretProcessTimeoutMs(params);

let response: SecretProcessStartResponse;
Expand All @@ -747,6 +760,8 @@ export class SandboxProcessesApi {
timeoutMs,
runAs: params.runAs,
secrets,
vault,
onePassword,
},
params.signal
);
Expand Down
13 changes: 12 additions & 1 deletion src/sandbox/secret-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@ import { HyperbrowserError } from "../error";
import { SandboxSecrets } from "../types/sandbox";

const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
/** The longest environment variable name the API stores. */
const MAX_ENV_NAME_LENGTH = 256;
/** Names of secrets and vaults. */
export const SECRET_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
/** The largest version number the API stores. */
export const MAX_SECRET_VERSION = 2_147_483_647;
const MAX_SECRETS = 32;
/** An environment variable name the API accepts for a secret or a vault entry. */
export const isSecretEnvName = (name: unknown): name is string =>
typeof name === "string" &&
name.length <= MAX_ENV_NAME_LENGTH &&
ENV_NAME_PATTERN.test(name) &&
name !== "__proto__";

export interface WireSecretReference {
name: string;
Expand Down Expand Up @@ -34,7 +43,7 @@ export const serializeSecretReferences = (
}
const wire: Record<string, WireSecretReference> = {};
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 } =
Expand Down Expand Up @@ -72,3 +81,5 @@ export const assertNoEnvSecretOverlap = (
);
}
};

export type { SandboxOnePassword as WireOnePassword } from "../types/sandbox";
165 changes: 165 additions & 0 deletions src/secret-values.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { invalidSecretValue, secretValueBytes } from "./secrets-envelope";
import { SecretValue } from "./types/secret";

const TOTP_ALGORITHMS = new Set(["SHA1", "SHA256", "SHA512"]);
const MAX_TOTP_PERIOD_SECONDS = 300;
/**
* Unpadded base32 can't end with 1, 3 or 6 characters of a block, so such a
* seed was copied incompletely. vm-service refuses it too (older hosts
* dropped the extra characters and gave wrong codes).
*/
const BASE32_TAIL_LENGTHS = new Set([0, 2, 4, 5, 7]);
const BYTE_ORDER_MARK = "\uFEFF";

const INVALID_TOTP =
"A TOTP value must be a base32 seed or an otpauth://totp/ URI with a base32 secret, " +
"and optionally algorithm SHA1, SHA256 or SHA512, digits 6 or 8, and a period of 1 to 300 seconds";

// vm-service parses seeds with Go: these mirror strings.TrimSpace,
// url.Parse and url.ParseQuery, so a value is refused here whenever it would
// be refused there.

/** Go's unicode.IsSpace: ASCII spaces, U+0085, U+00A0 and the Unicode White_Space spaces. */
const GO_SPACE = "\t\n\v\f\r \u0085             

   ";

const trimGoSpace = (value: string): string => {
let start = 0;
let end = value.length;
while (start < end && GO_SPACE.includes(value[start])) start++;
while (end > start && GO_SPACE.includes(value[end - 1])) end--;
return value.slice(start, end);
};

const isHex = (char: string | undefined): boolean =>
char !== undefined && /^[0-9A-Fa-f]$/.test(char);

/** Every '%' starts a two-digit hex escape, as url.Parse requires outside the query. */
const hasValidEscapes = (text: string): boolean => {
for (let i = 0; i < text.length; i++) {
if (text[i] === "%" && !(isHex(text[i + 1]) && isHex(text[i + 2]))) return false;
}
return true;
};

/** url.QueryUnescape; undefined for a bad escape. Bytes are read as Latin-1. */
const queryUnescape = (text: string): string | undefined => {
if (!hasValidEscapes(text)) return undefined;
return text
.replace(/\+/g, " ")
.replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
};

/** url.Values.Get after url.ParseQuery: pairs with bad escapes or ';' are skipped. */
const queryGet = (query: string, key: string): string => {
for (const pair of query.split("&")) {
if (!pair || pair.includes(";")) continue;
const separator = pair.indexOf("=");
const name = queryUnescape(separator < 0 ? pair : pair.slice(0, separator));
const value = queryUnescape(separator < 0 ? "" : pair.slice(separator + 1));
if (name === key && value !== undefined) return value;
}
return "";
};

/** Characters url.Parse accepts in the userinfo before '@'. */
const USERINFO_PATTERN = /^[A-Za-z0-9\-._:~!$&'()*+,;=%@]*$/;

/**
* The otpauth:// URI's query, after the checks url.Parse and vm-service make.
* `uri` starts with "otpauth://" in any case.
*/
const otpauthQuery = (uri: string): string => {
if ([...uri].some((char) => char.charCodeAt(0) < 0x20 || char.charCodeAt(0) === 0x7f)) {
throw invalidSecretValue("A TOTP otpauth:// URI must not contain control characters");
}
const hash = uri.indexOf("#");
const fragment = hash < 0 ? "" : uri.slice(hash + 1);
const beforeFragment = hash < 0 ? uri : uri.slice(0, hash);
const question = beforeFragment.indexOf("?");
const query = question < 0 ? "" : beforeFragment.slice(question + 1);
const rest = (question < 0 ? beforeFragment : beforeFragment.slice(0, question)).slice(
"otpauth://".length
);
const slash = rest.indexOf("/");
const authority = slash < 0 ? rest : rest.slice(0, slash);
const path = slash < 0 ? "" : rest.slice(slash);
const at = authority.lastIndexOf("@");
const userinfo = at < 0 ? "" : authority.slice(0, at);
const host = (at < 0 ? authority : authority.slice(at + 1)).toLowerCase();
if (!USERINFO_PATTERN.test(userinfo) || ![userinfo, path, fragment].every(hasValidEscapes)) {
throw invalidSecretValue(INVALID_TOTP);
}
if (host === "hotp") {
throw invalidSecretValue(
"HOTP (otpauth://hotp/) is not supported; a TOTP value must be a base32 seed or an otpauth://totp/ URI"
);
}
if (host !== "totp") {
throw invalidSecretValue(INVALID_TOTP);
}
return query;
};

/**
* The bytes a base32 seed decodes to, read the way the platform reads it:
* spaces, '-', line breaks, lowercase and trailing '=' padding are accepted. Undefined
* when it isn't ASCII base32 or has an impossible length.
*/
const base32SeedBytes = (seed: string): number | undefined => {
// Upper-cased one character at a time, as Go does, so "ß" can't turn into
// "SS" while "ı" still reads as "I".
const upper = Array.from(seed.replace(/[ \r\n-]/g, ""), (char) => {
const mapped = char.toUpperCase();
return mapped.length === 1 ? mapped : char;
}).join("");
if (!/^[A-Z2-7]+=*$/.test(upper)) return undefined;
const normalized = upper.replace(/=+$/, "");
return BASE32_TAIL_LENGTHS.has(normalized.length % 8)
? Math.floor((normalized.length * 5) / 8)
: undefined;
};

const isIntegerIn = (text: string, allowed: (value: number) => boolean): boolean =>
/^[+-]?\d+$/.test(text) && allowed(Number(text));

/**
* Check a TOTP seed as the platform will read it at fill or process time; the
* API can't see values, so a bad seed would otherwise only fail then. Seeds
* under 10 bytes (16 base32 characters) are accepted, though they are
* unusually short.
*/
export const checkTotpSeed = (value: string): void => {
let seed = trimGoSpace(value.startsWith(BYTE_ORDER_MARK) ? value.slice(1) : value);
if (seed.toLowerCase().startsWith("otpauth://")) {
const query = otpauthQuery(seed);
const algorithm = queryGet(query, "algorithm");
const digits = queryGet(query, "digits");
const period = queryGet(query, "period");
if (
(algorithm && !TOTP_ALGORITHMS.has(algorithm.toUpperCase())) ||
(digits && !isIntegerIn(digits, (n) => n === 6 || n === 8)) ||
(period && !isIntegerIn(period, (n) => n >= 1 && n <= MAX_TOTP_PERIOD_SECONDS))
) {
throw invalidSecretValue(INVALID_TOTP);
}
seed = queryGet(query, "secret");
} else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(seed)) {
throw invalidSecretValue(INVALID_TOTP);
}
if (base32SeedBytes(seed) === undefined) {
throw invalidSecretValue(INVALID_TOTP);
}
};

/**
* The plaintext stored for a secret of `type`, after the platform's rules
* for every value (1 byte to 32 KiB of UTF-8, no NUL) and the type's own.
* Types this SDK doesn't know get the common rules only.
*/
export const secretPlaintext = (type: string, value: SecretValue): Buffer => {
const bytes = secretValueBytes(value);
if (type === "totp") {
checkTotpSeed(bytes.toString("utf8"));
}
return bytes;
};
Loading