diff --git a/.changeset/cli-attach-function-secret.md b/.changeset/cli-attach-function-secret.md new file mode 100644 index 0000000000..0be12d2363 --- /dev/null +++ b/.changeset/cli-attach-function-secret.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse functions secrets attach` to attach an existing project secret to a function by ID. diff --git a/.changeset/cli-create-secret.md b/.changeset/cli-create-secret.md new file mode 100644 index 0000000000..104d8afb01 --- /dev/null +++ b/.changeset/cli-create-secret.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse cloud secrets create` with public-key lookup, local encryption, and secret input from stdin, a named environment variable, or a hidden prompt. diff --git a/.changeset/cli-detach-function-secret.md b/.changeset/cli-detach-function-secret.md new file mode 100644 index 0000000000..29270d2388 --- /dev/null +++ b/.changeset/cli-detach-function-secret.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse functions secrets detach` to remove a function-secret attachment without deleting the project secret. diff --git a/.changeset/cli-get-delete-secrets.md b/.changeset/cli-get-delete-secrets.md new file mode 100644 index 0000000000..e5f4e8bad4 --- /dev/null +++ b/.changeset/cli-get-delete-secrets.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add commands to retrieve project secret metadata and delete a project secret by ID. diff --git a/.changeset/cli-list-project-secrets.md b/.changeset/cli-list-project-secrets.md new file mode 100644 index 0000000000..10fab00be2 --- /dev/null +++ b/.changeset/cli-list-project-secrets.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse cloud secrets list` to list project secret metadata with pagination and date filters. diff --git a/.changeset/cli-update-secret.md b/.changeset/cli-update-secret.md new file mode 100644 index 0000000000..091ad39f7e --- /dev/null +++ b/.changeset/cli-update-secret.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse cloud secrets update` to replace a secret value by ID with local encryption and stdin, environment variable, or hidden prompt input. diff --git a/.changeset/tidy-contexts-share.md b/.changeset/tidy-contexts-share.md new file mode 100644 index 0000000000..8079ca9fe5 --- /dev/null +++ b/.changeset/tidy-contexts-share.md @@ -0,0 +1,5 @@ +--- +"browse": patch +--- + +store Context names in Browserbase while retaining local name-to-ID lookup compatibility and preserving legacy aliases diff --git a/packages/cli/README.md b/packages/cli/README.md index 58e8b5b142..7c0a2bc345 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -227,7 +227,7 @@ browse cloud sessions downloads get # --output ./downloads.zip browse cloud sessions uploads create ./file.pdf # Contexts -browse cloud contexts create +browse cloud contexts create --name github # name is stored in Browserbase browse cloud contexts get browse cloud contexts update # refresh the upload URL browse cloud contexts delete @@ -242,6 +242,12 @@ browse cloud fetch # markdown by default browse cloud search ``` +Names cached by earlier Browse versions remain local aliases and continue to +resolve to their saved Context IDs. They do not need to match the Context's +Browserbase-managed name. `contexts create --name` never overwrites an existing +local alias; use `contexts add --force` only after explicitly +reconciling a legacy mapping. + `browse cloud fetch` returns markdown-formatted page content by default. Use `--format raw` for the original response body, or `--format json --schema ` for structured extraction. ## Functions diff --git a/packages/cli/package.json b/packages/cli/package.json index d8abad3635..2e7a3ddd64 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,6 +36,9 @@ "cloud": { "description": "Manage Browserbase cloud resources and APIs." }, + "cloud:secrets": { + "description": "Create, list, retrieve, update, and delete project secrets." + }, "cloud:projects": { "description": "Manage Browserbase projects." }, @@ -54,6 +57,9 @@ "cloud:sessions:uploads": { "description": "Upload files to Browserbase sessions." }, + "functions:secrets": { + "description": "Attach and detach project secrets from functions." + }, "functions": { "description": "Develop, publish, and invoke Browserbase Functions." }, @@ -102,8 +108,11 @@ "prepublishOnly": "pnpm build" }, "dependencies": { - "@browserbasehq/sdk": "^2.14.0", + "@browserbasehq/sdk": "^2.17.0", "@browserbasehq/stagehand": "workspace:*", + "@hpke/core": "^1.9.0", + "@hpke/dhkem-x25519": "^1.8.0", + "@inquirer/password": "^4.0.23", "@oclif/core": "^4.11.0", "@vercel/detect-agent": "^1.2.3", "archiver": "^7.0.1", diff --git a/packages/cli/skills/browse/SKILL.md b/packages/cli/skills/browse/SKILL.md index 9ddf701379..4cea63cd96 100644 --- a/packages/cli/skills/browse/SKILL.md +++ b/packages/cli/skills/browse/SKILL.md @@ -249,17 +249,22 @@ For remote sessions with context persistence: browse cloud sessions create --context-id --persist ``` -Contexts persist cookies and local storage (logins) across sessions. Name a -context once with `--name` to save a local alias, then reuse the name anywhere a -context ID is accepted instead of memorizing the ID: +Contexts persist cookies and local storage (logins) across sessions. `--name` +stores the name on the Browserbase Context and caches its returned ID on this +device, so the name can also be reused anywhere the CLI accepts a context ID: ```bash -browse cloud contexts create --name github # saves github -> ctx_... -browse cloud contexts add github # name a context you already have +browse cloud contexts create --name github # server-owned name + local ID cache +browse cloud contexts add github # add a local alias for an existing ID browse cloud sessions create --context-id github --persist -browse cloud contexts list # show saved names +browse cloud contexts list # show this device's cached names/aliases ``` +Names saved by earlier CLI versions remain valid local aliases even when they +do not match the Browserbase-managed Context name. `contexts create --name` +will not overwrite one of those mappings. Reconcile deliberately with +`contexts add --force` when needed. + Use `--verified` when the task needs Browserbase Verified browser mode. To drive a Verified/proxied session directly, prefer `browse open --remote --verified --proxies` over create-then-attach — it keeps the session identity so `browse status`/`browse doctor` can report it. Use `browse cloud sessions create` for session options the driver flags don't cover (region, keep-alive, contexts, full `--stdin` body). Use `browse cloud fetch` when the user needs a simple HTTP fetch without browser interaction. It returns markdown-formatted page content by default; pass `--format raw` for the original response body or `--format json --schema ` for structured extraction. Use `browse cloud search` when the user asks for web search results. diff --git a/packages/cli/src/commands/cloud/contexts/create.ts b/packages/cli/src/commands/cloud/contexts/create.ts index 524986e525..702e91c9c3 100644 --- a/packages/cli/src/commands/cloud/contexts/create.ts +++ b/packages/cli/src/commands/cloud/contexts/create.ts @@ -18,7 +18,7 @@ import { BrowseCommand } from "../../../base.js"; export default class ContextsCreate extends BrowseCommand { static override description = - "Create a Browserbase context. Pass --name to save a local alias you can reuse instead of the context ID."; + "Create a Browserbase context. Pass --name to store a project-scoped name in Browserbase and cache its ID locally."; static override examples = [ "browse cloud contexts create", "browse cloud contexts create --name github", @@ -30,7 +30,7 @@ export default class ContextsCreate extends BrowseCommand { ...apiCommonFlags, name: Flags.string({ description: - "Save a local alias for the new context so you can reuse it by name.", + "Set the Context name in Browserbase and cache its ID for local name lookup.", helpValue: "", }), body: Flags.string({ @@ -50,9 +50,13 @@ export default class ContextsCreate extends BrowseCommand { if (!isValidContextName(name)) { fail(`Invalid context name "${name}". ${contextNameRequirement()}`); } - if (await getContextAlias(name)) { + const existingAlias = await getContextAlias(name); + if (existingAlias) { fail( - `A context named "${name}" already exists locally. Choose another name or remove it with "browse cloud contexts delete ${name}".`, + `A context named "${name}" already exists locally and maps to ${existingAlias.id}. ` + + "Existing local aliases are preserved because they may predate Browserbase-managed Context names. " + + "Choose another name, or reconcile the alias explicitly with " + + "`browse cloud contexts add --force`.", ); } } @@ -60,7 +64,12 @@ export default class ContextsCreate extends BrowseCommand { await withBrowserbaseApi("contexts", async () => { const client = createBrowserbaseClient(toApiOptions(flags)); const body = await resolveBody({ body: flags.body, stdin: flags.stdin }); - const context = await client.contexts.create(body); + // Browserbase owns name uniqueness and canonical storage. The explicit + // flag takes precedence over a name supplied through --body/--stdin, + // matching the merge behavior of other cloud command flags. + const context = await client.contexts.create( + name === undefined ? body : { ...body, name }, + ); if (name !== undefined && context.id) { await saveContextAlias(name, { diff --git a/packages/cli/src/commands/cloud/contexts/list.ts b/packages/cli/src/commands/cloud/contexts/list.ts index d2a9ec73be..2d05fb2707 100644 --- a/packages/cli/src/commands/cloud/contexts/list.ts +++ b/packages/cli/src/commands/cloud/contexts/list.ts @@ -14,7 +14,7 @@ import { export default class ContextsList extends BrowseCommand { static override description = - "List Browserbase contexts you have saved locally with a name."; + "List Context name-to-ID mappings cached on this device."; static override examples = [ "browse cloud contexts list", "browse cloud contexts list --json", @@ -37,7 +37,7 @@ export default class ContextsList extends BrowseCommand { if (contexts.length === 0) { console.log( - "No saved contexts. Create one with: browse cloud contexts create --name ", + "No cached contexts. Create one with: browse cloud contexts create --name ", ); return; } @@ -54,7 +54,7 @@ function outputContextsTable( contexts, [ { - header: "Name", + header: "Local name", maxWidth: 24, value: (context) => context.name, }, diff --git a/packages/cli/src/commands/cloud/secrets/create.ts b/packages/cli/src/commands/cloud/secrets/create.ts new file mode 100644 index 0000000000..9fc3291f25 --- /dev/null +++ b/packages/cli/src/commands/cloud/secrets/create.ts @@ -0,0 +1,34 @@ +import { Args } from "@oclif/core"; +import { BrowseCommand } from "../../../base.js"; +import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; +import { createSecret } from "../../../lib/secrets/api.js"; +import { readSecretValue } from "../../../lib/secrets/input.js"; +import { secretInputFlags } from "../../../lib/secrets/flags.js"; +import { outputJson } from "../../../lib/output.js"; + +export default class SecretsCreate extends BrowseCommand { + static override description = + "Create a project secret. Encrypts the value locally with the project public key."; + static override examples = [ + "browse cloud secrets create SERVICE_TOKEN", + "browse cloud secrets create SERVICE_TOKEN --env MY_SERVICE_TOKEN", + "browse cloud secrets create SERVICE_TOKEN --stdin < ./secret.txt", + ]; + static override args = { + key: Args.string({ + description: "Name exposed in the function context.secrets object.", + required: true, + }), + }; + static override flags = { ...apiCommonFlags, ...secretInputFlags }; + async run(): Promise { + const { args, flags } = await this.parse(SecretsCreate); + const options = toApiOptions(flags); + const value = await readSecretValue({ stdin: flags.stdin, env: flags.env }); + try { + outputJson(await createSecret(options, args.key, value)); + } finally { + value.fill(0); + } + } +} diff --git a/packages/cli/src/commands/cloud/secrets/delete.ts b/packages/cli/src/commands/cloud/secrets/delete.ts new file mode 100644 index 0000000000..cb8caf5921 --- /dev/null +++ b/packages/cli/src/commands/cloud/secrets/delete.ts @@ -0,0 +1,25 @@ +import { Args } from "@oclif/core"; +import { BrowseCommand } from "../../../base.js"; +import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; +import { deleteSecret } from "../../../lib/secrets/api.js"; + +export default class SecretsDelete extends BrowseCommand { + static override description = "Delete a project secret."; + static override examples = [ + "browse cloud secrets delete ", + "browse cloud secrets delete d2c4f48f-38e9-4b82-a36a-2b373fd14a65", + ]; + static override args = { + secretId: Args.string({ + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", + required: true, + }), + }; + static override flags = { ...apiCommonFlags }; + async run(): Promise { + const { args, flags } = await this.parse(SecretsDelete); + const options = toApiOptions(flags); + await deleteSecret(options, args.secretId); + } +} diff --git a/packages/cli/src/commands/cloud/secrets/get.ts b/packages/cli/src/commands/cloud/secrets/get.ts new file mode 100644 index 0000000000..c3d09c3d38 --- /dev/null +++ b/packages/cli/src/commands/cloud/secrets/get.ts @@ -0,0 +1,27 @@ +import { Args } from "@oclif/core"; +import { BrowseCommand } from "../../../base.js"; +import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; +import { getSecret } from "../../../lib/secrets/api.js"; +import { outputJson } from "../../../lib/output.js"; + +export default class SecretsGet extends BrowseCommand { + static override description = + "Get project secret metadata. Does not return the secret value."; + static override examples = [ + "browse cloud secrets get ", + "browse cloud secrets get d2c4f48f-38e9-4b82-a36a-2b373fd14a65", + ]; + static override args = { + secretId: Args.string({ + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", + required: true, + }), + }; + static override flags = { ...apiCommonFlags }; + async run(): Promise { + const { args, flags } = await this.parse(SecretsGet); + const options = toApiOptions(flags); + outputJson(await getSecret(options, args.secretId)); + } +} diff --git a/packages/cli/src/commands/cloud/secrets/list.ts b/packages/cli/src/commands/cloud/secrets/list.ts new file mode 100644 index 0000000000..771a4486ee --- /dev/null +++ b/packages/cli/src/commands/cloud/secrets/list.ts @@ -0,0 +1,25 @@ +import { BrowseCommand } from "../../../base.js"; +import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; +import { listSecrets } from "../../../lib/secrets/api.js"; +import { + listSecretsFlags, + toListSecretsOptions, +} from "../../../lib/secrets/flags.js"; +import { outputJson } from "../../../lib/output.js"; + +export default class SecretsList extends BrowseCommand { + static override description = + "List project secret metadata with cursor pagination."; + static override examples = [ + "browse cloud secrets list", + "browse cloud secrets list --start-at 2026-01-01T00:00:00Z", + "browse cloud secrets list --start-at 2026-01-01T00:00:00Z --end-at 2026-02-01T00:00:00Z", + "browse cloud secrets list --limit 10", + ]; + static override flags = { ...apiCommonFlags, ...listSecretsFlags }; + async run(): Promise { + const { flags } = await this.parse(SecretsList); + const options = toApiOptions(flags); + outputJson(await listSecrets(options, toListSecretsOptions(flags))); + } +} diff --git a/packages/cli/src/commands/cloud/secrets/update.ts b/packages/cli/src/commands/cloud/secrets/update.ts new file mode 100644 index 0000000000..3a261a8a1c --- /dev/null +++ b/packages/cli/src/commands/cloud/secrets/update.ts @@ -0,0 +1,36 @@ +import { Args } from "@oclif/core"; +import { BrowseCommand } from "../../../base.js"; +import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; +import { updateSecret } from "../../../lib/secrets/api.js"; +import { readSecretValue } from "../../../lib/secrets/input.js"; +import { secretInputFlags } from "../../../lib/secrets/flags.js"; +import { outputJson } from "../../../lib/output.js"; + +export default class SecretsUpdate extends BrowseCommand { + static override description = + "Replace a secret value, encrypting it locally with the current project public key."; + static override examples = [ + "browse cloud secrets update ", + "browse cloud secrets update d2c4f48f-38e9-4b82-a36a-2b373fd14a65", + "browse cloud secrets update --env MY_SERVICE_TOKEN", + "browse cloud secrets update --stdin < ./secret.txt", + ]; + static override args = { + secretId: Args.string({ + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", + required: true, + }), + }; + static override flags = { ...apiCommonFlags, ...secretInputFlags }; + async run(): Promise { + const { args, flags } = await this.parse(SecretsUpdate); + const options = toApiOptions(flags); + const value = await readSecretValue({ stdin: flags.stdin, env: flags.env }); + try { + outputJson(await updateSecret(options, args.secretId, value)); + } finally { + value.fill(0); + } + } +} diff --git a/packages/cli/src/commands/functions/secrets/attach.ts b/packages/cli/src/commands/functions/secrets/attach.ts new file mode 100644 index 0000000000..78224b164f --- /dev/null +++ b/packages/cli/src/commands/functions/secrets/attach.ts @@ -0,0 +1,30 @@ +import { Args } from "@oclif/core"; +import { BrowseCommand } from "../../../base.js"; +import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; +import { attachFunctionSecret } from "../../../lib/secrets/api.js"; + +export default class FunctionSecretsAttach extends BrowseCommand { + static override description = + "Attach an existing project secret to a function."; + static override examples = [ + "browse functions secrets attach ", + "browse functions secrets attach 7b6e1c42-8d93-4a15-b2f0-9c6d3e8a5041 d2c4f48f-38e9-4b82-a36a-2b373fd14a65", + ]; + static override args = { + functionId: Args.string({ + description: "Function ID (e.g. 7b6e1c42-8d93-4a15-b2f0-9c6d3e8a5041).", + required: true, + }), + secretId: Args.string({ + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", + required: true, + }), + }; + static override flags = { ...apiCommonFlags }; + async run(): Promise { + const { args, flags } = await this.parse(FunctionSecretsAttach); + const options = toApiOptions(flags); + await attachFunctionSecret(options, args.functionId, args.secretId); + } +} diff --git a/packages/cli/src/commands/functions/secrets/detach.ts b/packages/cli/src/commands/functions/secrets/detach.ts new file mode 100644 index 0000000000..f164150799 --- /dev/null +++ b/packages/cli/src/commands/functions/secrets/detach.ts @@ -0,0 +1,30 @@ +import { Args } from "@oclif/core"; +import { BrowseCommand } from "../../../base.js"; +import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; +import { detachFunctionSecret } from "../../../lib/secrets/api.js"; + +export default class FunctionSecretsDetach extends BrowseCommand { + static override description = + "Detach a secret from a function without deleting the secret."; + static override examples = [ + "browse functions secrets detach ", + "browse functions secrets detach 7b6e1c42-8d93-4a15-b2f0-9c6d3e8a5041 d2c4f48f-38e9-4b82-a36a-2b373fd14a65", + ]; + static override args = { + functionId: Args.string({ + description: "Function ID (e.g. 7b6e1c42-8d93-4a15-b2f0-9c6d3e8a5041).", + required: true, + }), + secretId: Args.string({ + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", + required: true, + }), + }; + static override flags = { ...apiCommonFlags }; + async run(): Promise { + const { args, flags } = await this.parse(FunctionSecretsDetach); + const options = toApiOptions(flags); + await detachFunctionSecret(options, args.functionId, args.secretId); + } +} diff --git a/packages/cli/src/lib/cloud/api.ts b/packages/cli/src/lib/cloud/api.ts index b857c21b12..8a5b4f2e17 100644 --- a/packages/cli/src/lib/cloud/api.ts +++ b/packages/cli/src/lib/cloud/api.ts @@ -45,6 +45,7 @@ export type BrowserbaseApiCommand = | "contexts" | "extensions" | "functions" + | "secrets" | "sessions"; export function resolveApiKey(args: { apiKey?: string }): string { @@ -442,6 +443,10 @@ function resolveCommandFromPathname( return "extensions"; } + if (pathname.startsWith("/v1/secrets")) { + return "secrets"; + } + if (pathname.startsWith("/v1/functions")) { return "functions"; } diff --git a/packages/cli/src/lib/cloud/contexts-store.ts b/packages/cli/src/lib/cloud/contexts-store.ts index 7a8b862639..6c4911ab89 100644 --- a/packages/cli/src/lib/cloud/contexts-store.ts +++ b/packages/cli/src/lib/cloud/contexts-store.ts @@ -1,19 +1,20 @@ import { distance } from "fastest-levenshtein"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; import { resolveConfigDir } from "../identity.js"; /** - * Local name -> Browserbase context-id map. + * Local cache of Browserbase context names -> context ids. * - * Browserbase contexts are identified only by an opaque id and the platform has - * no server-side list endpoint, so to give contexts memorable names (e.g. - * `github`, `gmail`) we keep a small map on the local device. It lives next to - * the CLI's other state at `(XDG_CONFIG_HOME||~/.config)/browserbase/contexts.json` - * (honoring `BROWSERBASE_CONFIG_DIR`). This is purely a client-side convenience: - * the ids it stores are the same ids the API already returns, and a missing or - * corrupt file degrades to "no saved contexts" rather than an error. + * Browserbase stores an optional, project-scoped name on each Context. The + * public API still identifies Contexts and session persistence by opaque id and + * does not expose list or lookup-by-name endpoints, so the CLI caches the names + * it creates on this device. It lives next to the CLI's other state at + * `(XDG_CONFIG_HOME||~/.config)/browserbase/contexts.json` (honoring + * `BROWSERBASE_CONFIG_DIR`). A missing or corrupt cache degrades to "no cached + * contexts" rather than an error; Browserbase remains authoritative for names. */ const STORE_VERSION = 1; @@ -147,12 +148,17 @@ async function writeStore( // contexts.json already existed (writeFile's `mode` only applies on create). // For this single-user local config, simultaneous writers remain // last-writer-wins; cross-process locking isn't warranted here. - const tempPath = `${path}.${process.pid}.tmp`; - await writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); - await rename(tempPath, path); + const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + await rename(tempPath, path); + } finally { + await rm(tempPath, { force: true }).catch(() => undefined); + } } export async function listContextAliases( diff --git a/packages/cli/src/lib/secrets/api.ts b/packages/cli/src/lib/secrets/api.ts new file mode 100644 index 0000000000..5d704b51a8 --- /dev/null +++ b/packages/cli/src/lib/secrets/api.ts @@ -0,0 +1,125 @@ +import { sealSecret } from "./seal.js"; +import { requestBrowserbase, requestBrowserbaseJson } from "../cloud/api.js"; + +export interface SecretsApiOptions { + apiKey?: string; + baseUrl?: string; +} + +export interface Secret { + id: string; + secretKey: string; +} + +export interface SecretPage { + data: Secret[]; + limit: number; + nextCursor: string | null; +} + +export interface ListSecretsOptions { + limit?: number; + cursor?: string; + startAt?: string; + endAt?: string; +} + +export function listSecrets( + options: SecretsApiOptions, + query: ListSecretsOptions, +): Promise { + return requestBrowserbaseJson(options, withQuery("/v1/secrets", query)); +} + +function withQuery(path: string, query: ListSecretsOptions): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) params.set(key, String(value)); + } + const search = params.toString(); + return search ? `${path}?${search}` : path; +} + +export function getSecret( + options: SecretsApiOptions, + secretId: string, +): Promise { + return requestBrowserbaseJson(options, secretPath(secretId)); +} + +export async function deleteSecret( + options: SecretsApiOptions, + secretId: string, +): Promise { + await requestBrowserbase(options, secretPath(secretId), { method: "DELETE" }); +} + +function secretPath(id: string): string { + return `/v1/secrets/${encodeURIComponent(id)}`; +} + +export async function createSecret( + options: SecretsApiOptions, + secretKey: string, + value: Uint8Array, +): Promise { + const sealed = await encryptValue(options, value); + return requestBrowserbaseJson(options, "/v1/secrets", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + secretKey, + ...sealed, + }), + }); +} + +export async function updateSecret( + options: SecretsApiOptions, + secretId: string, + value: Uint8Array, +): Promise { + const sealed = await encryptValue(options, value); + return requestBrowserbaseJson(options, secretPath(secretId), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(sealed), + }); +} + +async function encryptValue(options: SecretsApiOptions, value: Uint8Array) { + const keypair = await requestBrowserbaseJson<{ + id: string; + publicKey: string; + }>(options, "/v1/secrets/keypair"); + const sealedSecretValue = await sealSecret(keypair.publicKey, value); + return { keypairId: keypair.id, sealedSecretValue }; +} + +export async function attachFunctionSecret( + options: SecretsApiOptions, + functionId: string, + secretId: string, +): Promise { + await requestBrowserbase( + options, + `/v1/functions/${encodeURIComponent(functionId)}/secrets`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ secretId }), + }, + ); +} + +export async function detachFunctionSecret( + options: SecretsApiOptions, + functionId: string, + secretId: string, +): Promise { + await requestBrowserbase( + options, + `/v1/functions/${encodeURIComponent(functionId)}/secrets/${encodeURIComponent(secretId)}`, + { method: "DELETE" }, + ); +} diff --git a/packages/cli/src/lib/secrets/flags.ts b/packages/cli/src/lib/secrets/flags.ts new file mode 100644 index 0000000000..505030b594 --- /dev/null +++ b/packages/cli/src/lib/secrets/flags.ts @@ -0,0 +1,47 @@ +import { Flags } from "@oclif/core"; +import type { ListSecretsOptions } from "./api.js"; + +export const listSecretsFlags = { + limit: Flags.integer({ + min: 1, + max: 1000, + description: "Maximum results per page (API default: 20).", + }), + cursor: Flags.string({ + description: "nextCursor from the previous page. Keep the same filters.", + }), + "start-at": Flags.string({ + description: "Include secrets created on or after this RFC 3339 timestamp.", + }), + "end-at": Flags.string({ + description: + "Include secrets created on or before this RFC 3339 timestamp.", + }), +}; + +export function toListSecretsOptions(flags: { + limit?: number; + cursor?: string; + "start-at"?: string; + "end-at"?: string; +}): ListSecretsOptions { + return { + limit: flags.limit, + cursor: flags.cursor, + startAt: flags["start-at"], + endAt: flags["end-at"], + }; +} + +export const secretInputFlags = { + env: Flags.string({ + description: "Read the secret value from the named environment variable.", + helpValue: "VARIABLE_NAME", + exclusive: ["stdin"], + }), + stdin: Flags.boolean({ + description: + "Read the exact secret value from stdin, preserving whitespace.", + exclusive: ["env"], + }), +}; diff --git a/packages/cli/src/lib/secrets/input.ts b/packages/cli/src/lib/secrets/input.ts new file mode 100644 index 0000000000..3acc59bb0b --- /dev/null +++ b/packages/cli/src/lib/secrets/input.ts @@ -0,0 +1,39 @@ +import password from "@inquirer/password"; +import { fail } from "../errors.js"; + +export async function readSecretValue(options: { + stdin?: boolean; + env?: string; +}): Promise { + if (options.env !== undefined) { + if (options.stdin) fail("--env and --stdin cannot be used together."); + if (!options.env) fail("--env requires an environment variable name."); + const value = Object.prototype.hasOwnProperty.call(process.env, options.env) + ? process.env[options.env] + : undefined; + if (value === undefined) + fail("The environment variable selected by --env is not set."); + return Buffer.from(value, "utf8"); + } + if (options.stdin) { + if (process.stdin.isTTY) + fail("--stdin requires piped input or file redirection."); + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); + } + if (!process.stdin.isTTY) + fail( + "Use --stdin for piped input or --env to read an environment variable.", + ); + try { + return Buffer.from( + await password({ message: "Secret value:" }, { output: process.stderr }), + "utf8", + ); + } catch { + fail("Secret input cancelled."); + } +} diff --git a/packages/cli/src/lib/secrets/seal.ts b/packages/cli/src/lib/secrets/seal.ts new file mode 100644 index 0000000000..3340134afd --- /dev/null +++ b/packages/cli/src/lib/secrets/seal.ts @@ -0,0 +1,35 @@ +import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core"; +import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519"; +import { fail } from "../errors.js"; + +export async function sealSecret( + publicKey: unknown, + value: Uint8Array, +): Promise { + if (typeof publicKey !== "string") { + fail("The secrets API returned an invalid X25519 public key."); + } + const rawKey = Buffer.from(publicKey, "base64"); + if (rawKey.length !== 32 || rawKey.toString("base64") !== publicKey) { + fail("The secrets API returned an invalid X25519 public key."); + } + const suite = new CipherSuite({ + kem: new DhkemX25519HkdfSha256(), + kdf: new HkdfSha256(), + aead: new Aes256Gcm(), + }); + try { + const recipientPublicKey = await suite.kem.deserializePublicKey( + new Uint8Array(rawKey).buffer, + ); + const sender = await suite.createSenderContext({ recipientPublicKey }); + const ciphertext = await sender.seal(new Uint8Array(value).buffer); + // Go's crypto/hpke.Open expects the encapsulated key followed by ciphertext. + return Buffer.concat([ + Buffer.from(sender.enc), + Buffer.from(ciphertext), + ]).toString("base64"); + } catch { + fail("Failed to encrypt the secret with the project's public key."); + } +} diff --git a/packages/cli/tests/cli-function-secrets-attach-contract.test.ts b/packages/cli/tests/cli-function-secrets-attach-contract.test.ts new file mode 100644 index 0000000000..19e81ad245 --- /dev/null +++ b/packages/cli/tests/cli-function-secrets-attach-contract.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + jsonResponse, + startFakeBrowserbaseServer, + type FakeBrowserbaseServer, +} from "./helpers/fake-browserbase-server.js"; +import { runCli } from "./helpers/run-cli.js"; + +const functionId = "ab76f718-0c41-4130-8b02-c926721801fc"; +const secretId = "d2c4f48f-38e9-4b82-a36a-2b373fd14a65"; +const env = { + BROWSERBASE_API_KEY: "test-key", + BROWSE_LOAD_DOTENV: "0", + BROWSERBASE_TELEMETRY_DISABLED: "1", +}; +let server: FakeBrowserbaseServer | undefined; +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +describe("function secret attach HTTP contract", () => { + it.each([false, true])( + "posts the IDs and handles 204 (key override: %s)", + async (override) => { + server = await startFakeBrowserbaseServer((_request, response) => { + response.writeHead(204); + response.end(); + }); + const result = await runCli( + [ + "functions", + "secrets", + "attach", + functionId, + secretId, + "--base-url", + server.baseUrl, + ...(override ? ["--api-key", "override-key"] : []), + ], + { env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(""); + expect(server.requests).toHaveLength(1); + expect(server.requests[0]).toMatchObject({ + method: "POST", + path: `/v1/functions/${functionId}/secrets`, + headers: { + "x-bb-api-key": override ? "override-key" : "test-key", + "content-type": "application/json", + }, + }); + expect(server.requests[0]!.jsonBody).toEqual({ secretId }); + }, + ); + + it("escapes the function ID as one path segment", async () => { + server = await startFakeBrowserbaseServer((_request, response) => { + response.writeHead(204); + response.end(); + }); + const result = await runCli( + [ + "functions", + "secrets", + "attach", + "id/with?query#fragment", + secretId, + "--base-url", + server.baseUrl, + ], + { env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(server.requests[0]?.path).toBe( + "/v1/functions/id%2Fwith%3Fquery%23fragment/secrets", + ); + }); + + it.each([400, 403, 404])( + "reports HTTP %s without retrying", + async (status) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, status, { message: "Attachment rejected" }), + ); + const result = await runCli( + [ + "functions", + "secrets", + "attach", + functionId, + secretId, + "--base-url", + server.baseUrl, + ], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Attachment rejected"); + expect(server.requests).toHaveLength(1); + }, + ); + + it.each([{ ids: [] }, { ids: [functionId] }])( + "requires both IDs: $ids", + async ({ ids }) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, {}), + ); + const result = await runCli( + [ + "functions", + "secrets", + "attach", + ...ids, + "--base-url", + server.baseUrl, + ], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(0); + }, + ); +}); diff --git a/packages/cli/tests/cli-function-secrets-detach-contract.test.ts b/packages/cli/tests/cli-function-secrets-detach-contract.test.ts new file mode 100644 index 0000000000..c8225fac49 --- /dev/null +++ b/packages/cli/tests/cli-function-secrets-detach-contract.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + jsonResponse, + startFakeBrowserbaseServer, + type FakeBrowserbaseServer, +} from "./helpers/fake-browserbase-server.js"; +import { runCli } from "./helpers/run-cli.js"; + +const functionId = "ab76f718-0c41-4130-8b02-c926721801fc"; +const secretId = "d2c4f48f-38e9-4b82-a36a-2b373fd14a65"; +const env = { + BROWSERBASE_API_KEY: "test-key", + BROWSE_LOAD_DOTENV: "0", + BROWSERBASE_TELEMETRY_DISABLED: "1", +}; +let server: FakeBrowserbaseServer | undefined; +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +describe("function secret detach HTTP contract", () => { + it.each([false, true])( + "deletes the attachment and handles 204 (key override: %s)", + async (override) => { + server = await startFakeBrowserbaseServer((_request, response) => { + response.writeHead(204); + response.end(); + }); + const result = await runCli( + [ + "functions", + "secrets", + "detach", + functionId, + secretId, + "--base-url", + server.baseUrl, + ...(override ? ["--api-key", "override-key"] : []), + ], + { env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(""); + expect(server.requests).toHaveLength(1); + expect(server.requests[0]).toMatchObject({ + method: "DELETE", + path: `/v1/functions/${functionId}/secrets/${secretId}`, + headers: { + "x-bb-api-key": override ? "override-key" : "test-key", + }, + }); + expect(server.requests[0]!.bodyText).toBe(""); + expect(server.requests[0]!.jsonBody).toBeUndefined(); + }, + ); + + it("escapes both IDs as individual path segments", async () => { + server = await startFakeBrowserbaseServer((_request, response) => { + response.writeHead(204); + response.end(); + }); + const result = await runCli( + [ + "functions", + "secrets", + "detach", + "id/with?query#fragment", + "secret/with?query#fragment", + "--base-url", + server.baseUrl, + ], + { env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(server.requests[0]?.path).toBe( + "/v1/functions/id%2Fwith%3Fquery%23fragment/secrets/secret%2Fwith%3Fquery%23fragment", + ); + }); + + it.each([400, 403, 404])( + "reports HTTP %s without retrying", + async (status) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, status, { message: "Attachment rejected" }), + ); + const result = await runCli( + [ + "functions", + "secrets", + "detach", + functionId, + secretId, + "--base-url", + server.baseUrl, + ], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Attachment rejected"); + expect(server.requests).toHaveLength(1); + }, + ); + + it.each([{ ids: [] }, { ids: [functionId] }])( + "requires both IDs: $ids", + async ({ ids }) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, {}), + ); + const result = await runCli( + [ + "functions", + "secrets", + "detach", + ...ids, + "--base-url", + server.baseUrl, + ], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(0); + }, + ); +}); diff --git a/packages/cli/tests/cli-secrets-create-contract.test.ts b/packages/cli/tests/cli-secrets-create-contract.test.ts new file mode 100644 index 0000000000..474b5882a9 --- /dev/null +++ b/packages/cli/tests/cli-secrets-create-contract.test.ts @@ -0,0 +1,220 @@ +import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core"; +import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519"; +import { afterEach, describe, expect, it } from "vitest"; +import { + jsonResponse, + startFakeBrowserbaseServer, + type FakeBrowserbaseServer, +} from "./helpers/fake-browserbase-server.js"; +import { runCli } from "./helpers/run-cli.js"; + +const secretId = "d2c4f48f-38e9-4b82-a36a-2b373fd14a65"; +const metadata = { id: secretId, secretKey: "SERVICE_TOKEN" }; +const env = { + BROWSERBASE_API_KEY: "test-key", + BROWSE_LOAD_DOTENV: "0", + BROWSERBASE_TELEMETRY_DISABLED: "1", +}; +let server: FakeBrowserbaseServer | undefined; +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +function suite() { + return new CipherSuite({ + kem: new DhkemX25519HkdfSha256(), + kdf: new HkdfSha256(), + aead: new Aes256Gcm(), + }); +} + +async function keypair() { + const crypto = suite(); + const pair = await crypto.kem.generateKeyPair(); + const publicKey = Buffer.from( + await crypto.kem.serializePublicKey(pair.publicKey), + ).toString("base64"); + return { crypto, pair, publicKey }; +} + +describe("secret CLI HTTP contracts", () => { + it.each(["stdin", "env"])( + "fetches the public key and encrypts exact %s bytes", + async (source) => { + const { crypto, pair, publicKey } = await keypair(); + const value = " token-🔑\nwith-whitespace\r\n"; + server = await startFakeBrowserbaseServer((request, response) => { + if (request.path === "/v1/secrets/keypair") + jsonResponse(response, 200, { id: "keypair-1", publicKey }); + else jsonResponse(response, 201, metadata); + }); + const result = await runCli( + [ + "cloud", + "secrets", + "create", + "SERVICE_TOKEN", + ...(source === "stdin" + ? ["--stdin"] + : ["--env", "BROWSE_TEST_SECRET_VALUE"]), + "--base-url", + server.baseUrl, + ], + source === "stdin" + ? { env, stdin: value } + : { env: { ...env, BROWSE_TEST_SECRET_VALUE: value } }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(metadata); + expect(server.requests.map((r) => [r.method, r.path])).toEqual([ + ["GET", "/v1/secrets/keypair"], + ["POST", "/v1/secrets"], + ]); + for (const request of server.requests) + expect(request.headers["x-bb-api-key"]).toBe("test-key"); + const body = server.requests[1]!.jsonBody as { + keypairId: string; + sealedSecretValue: string; + secretKey?: string; + }; + expect(body.keypairId).toBe("keypair-1"); + expect(Object.keys(body).sort()).toEqual( + ["secretKey", "sealedSecretValue", "keypairId"].sort(), + ); + expect(body.secretKey).toBe("SERVICE_TOKEN"); + expect(server.requests[1]!.headers["content-type"]).toBe( + "application/json", + ); + const blob = Buffer.from(body.sealedSecretValue, "base64"); + const recipient = await crypto.createRecipientContext({ + recipientKey: pair.privateKey, + enc: new Uint8Array(blob.subarray(0, 32)).buffer, + }); + expect( + Buffer.from( + await recipient.open(new Uint8Array(blob.subarray(32)).buffer), + ).toString("utf8"), + ).toBe(value); + expect(server.requests[1]!.bodyText).not.toContain("token-🔑"); + expect(result.stdout + result.stderr).not.toContain("token-🔑"); + }, + ); + + it("stops if key retrieval fails", async () => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 403, { message: "Forbidden" }), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "create", + "TOKEN", + "--stdin", + "--base-url", + server.baseUrl, + ], + { env, stdin: "private-value" }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(1); + expect(result.stderr).toContain("Forbidden"); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); + + it.each(["bad", undefined, null, 123, true, {}, []].map((key) => [key]))( + "rejects malformed public key %j without submitting a secret", + async (publicKey) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, { id: "keypair-1", publicKey }), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "create", + "TOKEN", + "--stdin", + "--base-url", + server.baseUrl, + ], + { env, stdin: "private-value" }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(1); + expect(result.stderr).toContain("invalid X25519 public key"); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }, + ); + + it("requires --stdin for noninteractive input", async () => { + const result = await runCli(["cloud", "secrets", "create", "TOKEN"], { + env, + stdin: "private-value", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Use --stdin"); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); + + it("reports a duplicate key without retrying creation", async () => { + const { publicKey } = await keypair(); + server = await startFakeBrowserbaseServer((request, response) => { + if (request.path === "/v1/secrets/keypair") { + jsonResponse(response, 200, { id: "keypair-1", publicKey }); + } else { + jsonResponse(response, 409, { message: "Secret already exists" }); + } + }); + const result = await runCli( + [ + "cloud", + "secrets", + "create", + "TOKEN", + "--stdin", + "--base-url", + server.baseUrl, + ], + { env, stdin: "private-value" }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Secret already exists"); + expect(server.requests).toHaveLength(2); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); +}); + +describe("environment secret input validation", () => { + it.each([ + ["--env", "BROWSE_TEST_MISSING_SECRET_VALUE"], + ["--env", ""], + ["--env", "BROWSE_TEST_SECRET_VALUE", "--stdin"], + ])("rejects invalid input flags %j before requesting", async (...flags) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, {}), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "create", + "TOKEN", + "--base-url", + server.baseUrl, + ...flags, + ], + { + env: { + ...env, + BROWSE_TEST_MISSING_SECRET_VALUE: undefined, + BROWSE_TEST_SECRET_VALUE: "private-value", + }, + }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(0); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); +}); diff --git a/packages/cli/tests/cli-secrets-get-delete-contract.test.ts b/packages/cli/tests/cli-secrets-get-delete-contract.test.ts new file mode 100644 index 0000000000..940df86916 --- /dev/null +++ b/packages/cli/tests/cli-secrets-get-delete-contract.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + jsonResponse, + startFakeBrowserbaseServer, + type FakeBrowserbaseServer, +} from "./helpers/fake-browserbase-server.js"; +import { runCli } from "./helpers/run-cli.js"; + +const env = { + BROWSERBASE_API_KEY: "test-key", + BROWSE_LOAD_DOTENV: "0", + BROWSERBASE_TELEMETRY_DISABLED: "1", +}; +let server: FakeBrowserbaseServer | undefined; +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +describe("project secrets get/delete HTTP contracts", () => { + it("gets metadata by ID and prints the API response", async () => { + const metadata = { id: "secret-1", secretKey: "SERVICE_TOKEN" }; + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, metadata), + ); + const result = await runCli( + ["cloud", "secrets", "get", "secret-1", "--base-url", server.baseUrl], + { env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(metadata); + expect(server.requests).toHaveLength(1); + expect(server.requests[0]).toMatchObject({ + method: "GET", + path: "/v1/secrets/secret-1", + bodyText: "", + headers: { "x-bb-api-key": "test-key" }, + }); + }); + + it("deletes by ID and handles an empty 204 response", async () => { + server = await startFakeBrowserbaseServer((_request, response) => { + response.writeHead(204); + response.end(); + }); + const result = await runCli( + [ + "cloud", + "secrets", + "delete", + "secret-1", + "--base-url", + server.baseUrl, + "--api-key", + "override-key", + ], + { env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(""); + expect(server.requests).toHaveLength(1); + expect(server.requests[0]).toMatchObject({ + method: "DELETE", + path: "/v1/secrets/secret-1", + bodyText: "", + headers: { "x-bb-api-key": "override-key" }, + }); + }); + + it.each(["get", "delete"])( + "%s escapes the ID as a single URL segment", + async (command) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, {}), + ); + const result = await runCli( + [ + "cloud", + "secrets", + command, + "id/with?query#fragment", + "--base-url", + server.baseUrl, + ], + { env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(server.requests).toHaveLength(1); + expect(server.requests[0]?.path).toBe( + "/v1/secrets/id%2Fwith%3Fquery%23fragment", + ); + }, + ); + + it.each([ + ["get", 404, "Secret not found"], + ["delete", 403, "Forbidden"], + ] as const)("%s reports API errors", async (command, status, message) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, status, { message }), + ); + const result = await runCli( + ["cloud", "secrets", command, "secret-1", "--base-url", server.baseUrl], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain(message); + }); + + it.each(["get", "delete"])( + "%s requires a secret ID before requesting", + async (command) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, {}), + ); + const result = await runCli( + ["cloud", "secrets", command, "--base-url", server.baseUrl], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(0); + }, + ); +}); diff --git a/packages/cli/tests/cli-secrets-list-contract.test.ts b/packages/cli/tests/cli-secrets-list-contract.test.ts new file mode 100644 index 0000000000..f918277e85 --- /dev/null +++ b/packages/cli/tests/cli-secrets-list-contract.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + jsonResponse, + startFakeBrowserbaseServer, + type FakeBrowserbaseServer, +} from "./helpers/fake-browserbase-server.js"; +import { runCli } from "./helpers/run-cli.js"; + +const env = { + BROWSERBASE_API_KEY: "test-key", + BROWSE_LOAD_DOTENV: "0", + BROWSERBASE_TELEMETRY_DISABLED: "1", +}; +let server: FakeBrowserbaseServer | undefined; +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +describe("project secrets list HTTP contract", () => { + it("gets metadata and preserves the pagination response", async () => { + const page = { + data: [{ id: "secret-1", secretKey: "SERVICE_TOKEN" }], + limit: 20, + nextCursor: "next-page", + }; + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, page), + ); + const result = await runCli( + ["cloud", "secrets", "list", "--base-url", server.baseUrl], + { env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(page); + expect(server.requests).toHaveLength(1); + expect(server.requests[0]).toMatchObject({ + method: "GET", + path: "/v1/secrets", + bodyText: "", + headers: { "x-bb-api-key": "test-key" }, + }); + }); + + it("encodes pagination and filters and honors the API key override", async () => { + const page = { data: [], limit: 2, nextCursor: null }; + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, page), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "list", + "--base-url", + server.baseUrl, + "--api-key", + "override-key", + "--limit", + "2", + "--cursor", + "a+b/==", + "--start-at", + "2026-01-01T00:00:00Z", + "--end-at", + "2026-02-01T00:00:00Z", + ], + { env }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(page); + expect(server.requests).toHaveLength(1); + const request = server.requests[0]!; + const url = new URL(request.path, server.baseUrl); + expect(url.pathname).toBe("/v1/secrets"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + limit: "2", + cursor: "a+b/==", + startAt: "2026-01-01T00:00:00Z", + endAt: "2026-02-01T00:00:00Z", + }); + expect(request.headers["x-bb-api-key"]).toBe("override-key"); + }); + + it("reports API failures with a failing exit status", async () => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 403, { message: "Forbidden" }), + ); + const result = await runCli( + ["cloud", "secrets", "list", "--base-url", server.baseUrl], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }); + + it.each(["0", "1001"])( + "rejects invalid limit %s before requesting", + async (limit) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, {}), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "list", + "--base-url", + server.baseUrl, + "--limit", + limit, + ], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(0); + }, + ); +}); diff --git a/packages/cli/tests/cli-secrets-update-contract.test.ts b/packages/cli/tests/cli-secrets-update-contract.test.ts new file mode 100644 index 0000000000..16cb14cbb6 --- /dev/null +++ b/packages/cli/tests/cli-secrets-update-contract.test.ts @@ -0,0 +1,217 @@ +import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core"; +import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519"; +import { afterEach, describe, expect, it } from "vitest"; +import { + jsonResponse, + startFakeBrowserbaseServer, + type FakeBrowserbaseServer, +} from "./helpers/fake-browserbase-server.js"; +import { runCli } from "./helpers/run-cli.js"; + +const secretId = "d2c4f48f-38e9-4b82-a36a-2b373fd14a65"; +const metadata = { id: secretId, secretKey: "SERVICE_TOKEN" }; +const env = { + BROWSERBASE_API_KEY: "test-key", + BROWSE_LOAD_DOTENV: "0", + BROWSERBASE_TELEMETRY_DISABLED: "1", +}; +let server: FakeBrowserbaseServer | undefined; +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +function suite() { + return new CipherSuite({ + kem: new DhkemX25519HkdfSha256(), + kdf: new HkdfSha256(), + aead: new Aes256Gcm(), + }); +} + +async function keypair() { + const crypto = suite(); + const pair = await crypto.kem.generateKeyPair(); + const publicKey = Buffer.from( + await crypto.kem.serializePublicKey(pair.publicKey), + ).toString("base64"); + return { crypto, pair, publicKey }; +} + +describe("secret update CLI HTTP contracts", () => { + it.each(["stdin", "env"])( + "fetches the public key and encrypts exact %s bytes", + async (source) => { + const { crypto, pair, publicKey } = await keypair(); + const value = " token-🔑\nwith-whitespace\r\n"; + server = await startFakeBrowserbaseServer((request, response) => { + if (request.path === "/v1/secrets/keypair") + jsonResponse(response, 200, { id: "keypair-1", publicKey }); + else jsonResponse(response, 200, metadata); + }); + const result = await runCli( + [ + "cloud", + "secrets", + "update", + secretId, + ...(source === "stdin" + ? ["--stdin"] + : ["--env", "BROWSE_TEST_SECRET_VALUE"]), + "--base-url", + server.baseUrl, + ], + source === "stdin" + ? { env, stdin: value } + : { env: { ...env, BROWSE_TEST_SECRET_VALUE: value } }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(metadata); + expect(server.requests.map((r) => [r.method, r.path])).toEqual([ + ["GET", "/v1/secrets/keypair"], + ["PATCH", `/v1/secrets/${secretId}`], + ]); + for (const request of server.requests) + expect(request.headers["x-bb-api-key"]).toBe("test-key"); + const body = server.requests[1]!.jsonBody as { + keypairId: string; + sealedSecretValue: string; + secretKey?: string; + }; + expect(body.keypairId).toBe("keypair-1"); + expect(Object.keys(body).sort()).toEqual( + ["sealedSecretValue", "keypairId"].sort(), + ); + expect(body.secretKey).toBeUndefined(); + expect(server.requests[1]!.headers["content-type"]).toBe( + "application/json", + ); + const blob = Buffer.from(body.sealedSecretValue, "base64"); + const recipient = await crypto.createRecipientContext({ + recipientKey: pair.privateKey, + enc: new Uint8Array(blob.subarray(0, 32)).buffer, + }); + expect( + Buffer.from( + await recipient.open(new Uint8Array(blob.subarray(32)).buffer), + ).toString("utf8"), + ).toBe(value); + expect(server.requests[1]!.bodyText).not.toContain("token-🔑"); + expect(result.stdout + result.stderr).not.toContain("token-🔑"); + }, + ); + + it("stops if key retrieval fails", async () => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 403, { message: "Forbidden" }), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "update", + "TOKEN", + "--stdin", + "--base-url", + server.baseUrl, + ], + { env, stdin: "private-value" }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(1); + expect(result.stderr).toContain("Forbidden"); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); + + it("rejects a malformed public key without submitting a secret", async () => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, { id: "keypair-1", publicKey: "bad" }), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "update", + "TOKEN", + "--stdin", + "--base-url", + server.baseUrl, + ], + { env, stdin: "private-value" }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(1); + expect(result.stderr).toContain("invalid X25519 public key"); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); + + it("requires --stdin for noninteractive input", async () => { + const result = await runCli(["cloud", "secrets", "update", "TOKEN"], { + env, + stdin: "private-value", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Use --stdin"); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); + + it("reports a missing secret without retrying the update", async () => { + const { publicKey } = await keypair(); + server = await startFakeBrowserbaseServer((request, response) => { + if (request.path === "/v1/secrets/keypair") { + jsonResponse(response, 200, { id: "keypair-1", publicKey }); + } else { + jsonResponse(response, 404, { message: "Secret not found" }); + } + }); + const result = await runCli( + [ + "cloud", + "secrets", + "update", + "TOKEN", + "--stdin", + "--base-url", + server.baseUrl, + ], + { env, stdin: "private-value" }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Secret not found"); + expect(server.requests).toHaveLength(2); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); +}); + +describe("environment secret input validation", () => { + it.each([ + ["--env", "BROWSE_TEST_MISSING_SECRET_VALUE"], + ["--env", ""], + ["--env", "BROWSE_TEST_SECRET_VALUE", "--stdin"], + ])("rejects invalid input flags %j before requesting", async (...flags) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, {}), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "update", + "TOKEN", + "--base-url", + server.baseUrl, + ...flags, + ], + { + env: { + ...env, + BROWSE_TEST_MISSING_SECRET_VALUE: undefined, + BROWSE_TEST_SECRET_VALUE: "private-value", + }, + }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(0); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); +}); diff --git a/packages/cli/tests/cli-surface.test.ts b/packages/cli/tests/cli-surface.test.ts index e96ba53262..921f56d051 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"; import { runCli } from "./helpers/run-cli.js"; const cloudCommandsWithExamples = [ + ["cloud", "secrets", "list"], + ["cloud", "secrets", "create"], + ["cloud", "secrets", "update"], + ["cloud", "secrets", "get"], + ["cloud", "secrets", "delete"], ["cloud", "projects", "list"], ["cloud", "projects", "get"], ["cloud", "projects", "usage"], @@ -28,6 +33,8 @@ const cloudCommandsWithExamples = [ ]; const functionsCommandsWithExamples = [ + ["functions", "secrets", "attach"], + ["functions", "secrets", "detach"], ["functions", "init"], ["functions", "dev"], ["functions", "publish"], diff --git a/packages/cli/tests/contexts-named.test.ts b/packages/cli/tests/contexts-named.test.ts index 51ddccc8c6..e8f0495d39 100644 --- a/packages/cli/tests/contexts-named.test.ts +++ b/packages/cli/tests/contexts-named.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -29,8 +29,8 @@ function pathOf(request: CapturedRequest): string { /** * Drives the real built CLI through the full named-context lifecycle against a - * fake Browserbase server, proving the local name->id map is written on create - * and resolved by list / get / sessions-create / delete. + * fake Browserbase server, proving Browserbase receives the name and the local + * lookup cache resolves its returned id for list / get / sessions-create / delete. */ describe("named contexts (end to end through the CLI)", () => { it("creates by name, resolves the name everywhere, and prunes on delete", async () => { @@ -70,7 +70,8 @@ describe("named contexts (end to end through the CLI)", () => { const storePath = join(configDir, "contexts.json"); try { - // 1. create --name writes the local alias and echoes the name back. + // 1. create --name sends the server-owned name, caches the returned id, + // and echoes the name back. const created = await runCli( ["cloud", "contexts", "create", "--name", "github"], { env }, @@ -83,6 +84,13 @@ describe("named contexts (end to end through the CLI)", () => { expect(JSON.parse(await readFile(storePath, "utf8"))).toMatchObject({ contexts: { github: { id: CONTEXT_ID } }, }); + if (process.platform !== "win32") { + expect((await stat(storePath)).mode & 0o777).toBe(0o600); + } + const createRequest = server.requests.find( + (r) => r.method === "POST" && pathOf(r) === "/v1/contexts", + ); + expect(createRequest?.jsonBody).toMatchObject({ name: "github" }); // 2. list --json surfaces the saved alias. const listed = await runCli(["cloud", "contexts", "list", "--json"], { @@ -231,6 +239,79 @@ describe("named contexts (end to end through the CLI)", () => { } }); + it("preserves a legacy local alias when its Browserbase-managed name differs", async () => { + const legacyId = "00000000-0000-4000-8000-0000000000cc"; + const server = await startFakeBrowserbaseServer((request, response) => { + if ( + request.method === "GET" && + pathOf(request) === `/v1/contexts/${legacyId}` + ) { + jsonResponse(response, 200, { + id: legacyId, + name: "managed-name", + status: "ready", + }); + return; + } + if (request.method === "POST" && pathOf(request) === "/v1/contexts") { + jsonResponse(response, 200, { id: "ctx_should_not_be_created" }); + return; + } + jsonResponse(response, 200, {}); + }); + const env = { + BROWSERBASE_CONFIG_DIR: configDir, + BROWSERBASE_API_KEY: "test-key", + BROWSERBASE_BASE_URL: server.baseUrl, + }; + const storePath = join(configDir, "contexts.json"); + + try { + // A pre-managed-name CLI install may already have an arbitrary local + // alias. It remains a valid lookup even when the API reports another + // Browserbase-owned name for that Context. + const added = await runCli( + ["cloud", "contexts", "add", "legacy-login", legacyId], + { env }, + ); + expect(added.exitCode).toBe(0); + + const got = await runCli(["cloud", "contexts", "get", "legacy-login"], { + env, + }); + expect(got.exitCode).toBe(0); + expect(JSON.parse(got.stdout)).toMatchObject({ + id: legacyId, + name: "managed-name", + }); + + // Creating a new managed Context under the same local name must fail + // before the API call instead of silently repointing the legacy alias. + const duplicate = await runCli( + ["cloud", "contexts", "create", "--name", "legacy-login"], + { + env, + }, + ); + expect(duplicate.exitCode).not.toBe(0); + expect(duplicate.stderr).toContain("already exists locally"); + expect(duplicate.stderr).toContain( + "may predate Browserbase-managed Context names", + ); + expect( + server.requests.some( + (request) => + request.method === "POST" && pathOf(request) === "/v1/contexts", + ), + ).toBe(false); + expect(JSON.parse(await readFile(storePath, "utf8"))).toMatchObject({ + contexts: { "legacy-login": { id: legacyId } }, + }); + } finally { + await server.close(); + } + }); + it("passes an unrecognized raw id through to the API (raw-id compatibility)", async () => { const rawId = "legacy-id-not-a-uuid-9000"; const server = await startFakeBrowserbaseServer((request, response) => { diff --git a/packages/cli/tests/helpers/run-cli.ts b/packages/cli/tests/helpers/run-cli.ts index 8a9fafa9d2..e13ac73bc1 100644 --- a/packages/cli/tests/helpers/run-cli.ts +++ b/packages/cli/tests/helpers/run-cli.ts @@ -12,6 +12,7 @@ export interface CliResult { export interface RunCliOptions { cwd?: string; + stdin?: string; env?: NodeJS.ProcessEnv; } @@ -31,16 +32,25 @@ export function runCli( NODE_ENV: "test", ...options.env, }, - stdio: ["ignore", "pipe", "pipe"], + stdio: [ + options.stdin === undefined ? "ignore" : "pipe", + "pipe", + "pipe", + ], }, ); + if (options.stdin !== undefined) { + child.stdin?.on("error", () => {}); + child.stdin?.end(options.stdin); + } + let stdout = ""; let stderr = ""; - child.stdout.on("data", (chunk) => { + child.stdout!.on("data", (chunk) => { stdout += chunk.toString(); }); - child.stderr.on("data", (chunk) => { + child.stderr!.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", reject); diff --git a/packages/cli/tests/secrets-input.test.ts b/packages/cli/tests/secrets-input.test.ts new file mode 100644 index 0000000000..586432b364 --- /dev/null +++ b/packages/cli/tests/secrets-input.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import password from "@inquirer/password"; +import { readSecretValue } from "../src/lib/secrets/input.js"; + +vi.mock("@inquirer/password", () => ({ default: vi.fn() })); + +const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +beforeEach(() => { + vi.mocked(password).mockReset(); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); +}); +afterEach(() => { + vi.unstubAllEnvs(); + if (originalIsTTY) + Object.defineProperty(process.stdin, "isTTY", originalIsTTY); + else Reflect.deleteProperty(process.stdin, "isTTY"); +}); + +describe("interactive secret input", () => { + it("uses a hidden prompt on stderr and preserves whitespace", async () => { + vi.mocked(password).mockResolvedValue(" secret value "); + expect(Buffer.from(await readSecretValue({})).toString()).toBe( + " secret value ", + ); + expect(password).toHaveBeenCalledWith( + { message: "Secret value:" }, + { output: process.stderr }, + ); + }); + + it("reports cancellation without echoing the prompt error", async () => { + vi.mocked(password).mockRejectedValue(new Error("private-value")); + await expect(readSecretValue({})).rejects.toMatchObject({ + message: "Secret input cancelled.", + }); + }); +}); + +describe("environment secret input", () => { + it.each(["constructor", "toString"])( + "rejects an unset inherited environment property %s", + async (env) => { + vi.stubEnv(env, undefined); + await expect(readSecretValue({ env })).rejects.toMatchObject({ + name: "CommandFailure", + message: "The environment variable selected by --env is not set.", + }); + expect(password).not.toHaveBeenCalled(); + }, + ); + + it.each(["constructor", "toString"])( + "reads an explicitly set environment property %s", + async (env) => { + vi.stubEnv(env, "private-value"); + expect(Buffer.from(await readSecretValue({ env })).toString()).toBe( + "private-value", + ); + }, + ); + + it("preserves an explicitly empty value without prompting", async () => { + vi.stubEnv("BROWSE_TEST_SECRET_VALUE", ""); + expect( + await readSecretValue({ env: "BROWSE_TEST_SECRET_VALUE" }), + ).toHaveLength(0); + expect(password).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/tsconfig.local-only.json b/packages/cli/tsconfig.local-only.json index ecc707bc21..95ae2288aa 100644 --- a/packages/cli/tsconfig.local-only.json +++ b/packages/cli/tsconfig.local-only.json @@ -6,6 +6,7 @@ "src/commands/skills", "src/commands/templates", "src/lib/cloud", + "src/lib/secrets", "src/lib/functions", "src/lib/skills", "src/lib/templates", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e69479a250..1f42e8d2b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,11 +436,20 @@ importers: packages/cli: dependencies: '@browserbasehq/sdk': - specifier: ^2.14.0 - version: 2.16.0 + specifier: ^2.17.0 + version: 2.20.0 '@browserbasehq/stagehand': specifier: workspace:* version: link:../sdk-ts + '@hpke/core': + specifier: ^1.9.0 + version: 1.9.0 + '@hpke/dhkem-x25519': + specifier: ^1.8.0 + version: 1.8.0 + '@inquirer/password': + specifier: ^4.0.23 + version: 4.0.23(@types/node@20.19.43) '@oclif/core': specifier: ^4.11.0 version: 4.13.0 @@ -1826,6 +1835,9 @@ packages: '@browserbasehq/sdk@2.16.0': resolution: {integrity: sha512-mPAuLRU9jWR7o0KJi9+gQnOBDUSIkoKbbFv4HjrA+80qWVcFacrNPlZmf4mguQnfZ0oP2t5c3ws6yuFyAX9vpA==} + '@browserbasehq/sdk@2.20.0': + resolution: {integrity: sha512-zDDsgrwF6/iF+Ob0LIqe6EPBa4n7m+5YfKn0g0+kZR5jIncTXBPmJSW6tAsu9go3wA9tQQP+zm6qUOPVi1Gutw==} + '@browserbasehq/stagehand@3.7.1': resolution: {integrity: sha512-vAuYSZWIhh3d76BxwppNVE3dB0ztEBLBi85G6TWulZNiebdWptNoANOMuprOB/cw5dE+80b/ZZQo4G33Pc9i6w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2180,6 +2192,18 @@ packages: peerDependencies: hono: ^4 + '@hpke/common@1.10.1': + resolution: {integrity: sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==} + engines: {node: '>=16.0.0'} + + '@hpke/core@1.9.0': + resolution: {integrity: sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==} + engines: {node: '>=16.0.0'} + + '@hpke/dhkem-x25519@1.8.0': + resolution: {integrity: sha512-S1MWWkAfu+TFxySgv5+2P3O4Mx/jk7BsoplzQaA1s3sfUJVJ2UsZsSzSsMc+FXJumLXncoJFlO6mK6mDGspfmA==} + engines: {node: '>=16.0.0'} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -9943,6 +9967,18 @@ snapshots: transitivePeerDependencies: - encoding + '@browserbasehq/sdk@2.20.0': + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + '@browserbasehq/stagehand@3.7.1(playwright-core@1.56.1)(supports-color@8.1.1)(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.3 @@ -10406,6 +10442,16 @@ snapshots: dependencies: hono: 4.12.31 + '@hpke/common@1.10.1': {} + + '@hpke/core@1.9.0': + dependencies: + '@hpke/common': 1.10.1 + + '@hpke/dhkem-x25519@1.8.0': + dependencies: + '@hpke/common': 1.10.1 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -12308,7 +12354,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 '@types/debug@4.1.13': dependencies: @@ -12318,7 +12364,7 @@ snapshots: '@types/es-aggregate-error@1.0.6': dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 '@types/esrecurse@4.3.1': {} @@ -12358,7 +12404,7 @@ snapshots: '@types/mute-stream@0.0.4': dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 '@types/nlcst@2.0.3': dependencies: @@ -12366,7 +12412,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 form-data: 4.0.6 '@types/node@12.20.55': {} @@ -12417,7 +12463,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 optional: true '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': @@ -13207,7 +13253,7 @@ snapshots: chrome-launcher@1.2.1(supports-color@8.1.1): dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 2.0.2(supports-color@8.1.1) @@ -13603,7 +13649,7 @@ snapshots: engine.io@6.6.9(bufferutil@4.1.0): dependencies: '@types/cors': 2.8.19 - '@types/node': 25.9.4 + '@types/node': 20.19.43 '@types/ws': 8.18.1 accepts: 1.3.8 base64id: 2.0.0 @@ -16742,7 +16788,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.2 - '@types/node': 25.9.4 + '@types/node': 20.19.43 long: 5.3.2 proxy-addr@2.0.7: