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-function-secrets.md b/.changeset/cli-list-function-secrets.md new file mode 100644 index 0000000000..f18c20c3b9 --- /dev/null +++ b/.changeset/cli-list-function-secrets.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse functions secrets list` to list attached secret metadata with cursor pagination and creation-time filters. 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/packages/cli/package.json b/packages/cli/package.json index 96772ceecf..77ae5133a5 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": "List, attach, and detach project secrets for functions." + }, "functions": { "description": "Develop, publish, and invoke Browserbase Functions." }, @@ -104,6 +110,9 @@ "dependencies": { "@browserbasehq/sdk": "^2.14.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/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/commands/functions/secrets/list.ts b/packages/cli/src/commands/functions/secrets/list.ts new file mode 100644 index 0000000000..375ba7935e --- /dev/null +++ b/packages/cli/src/commands/functions/secrets/list.ts @@ -0,0 +1,39 @@ +import { Args } from "@oclif/core"; +import { BrowseCommand } from "../../../base.js"; +import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; +import { listFunctionSecrets } from "../../../lib/secrets/api.js"; +import { + listSecretsFlags, + toListSecretsOptions, +} from "../../../lib/secrets/flags.js"; +import { outputJson } from "../../../lib/output.js"; + +export default class FunctionSecretsList extends BrowseCommand { + static override description = + "List metadata for secrets attached to a function with cursor pagination."; + static override examples = [ + "browse functions secrets list ", + "browse functions secrets list 7b6e1c42-8d93-4a15-b2f0-9c6d3e8a5041", + "browse functions secrets list --start-at 2026-01-01T00:00:00Z", + "browse functions secrets list --start-at 2026-01-01T00:00:00Z --end-at 2026-02-01T00:00:00Z", + "browse functions secrets list --limit 10", + ]; + static override args = { + functionId: Args.string({ + description: "Function ID (e.g. 7b6e1c42-8d93-4a15-b2f0-9c6d3e8a5041).", + required: true, + }), + }; + static override flags = { ...apiCommonFlags, ...listSecretsFlags }; + async run(): Promise { + const { args, flags } = await this.parse(FunctionSecretsList); + const options = toApiOptions(flags); + outputJson( + await listFunctionSecrets( + options, + args.functionId, + toListSecretsOptions(flags), + ), + ); + } +} 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/secrets/api.ts b/packages/cli/src/lib/secrets/api.ts new file mode 100644 index 0000000000..72f8fd9589 --- /dev/null +++ b/packages/cli/src/lib/secrets/api.ts @@ -0,0 +1,136 @@ +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" }, + ); +} + +export function listFunctionSecrets( + options: SecretsApiOptions, + functionId: string, + query: ListSecretsOptions, +): Promise { + return requestBrowserbaseJson( + options, + withQuery(`/v1/functions/${encodeURIComponent(functionId)}/secrets`, query), + ); +} 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-function-secrets-list-contract.test.ts b/packages/cli/tests/cli-function-secrets-list-contract.test.ts new file mode 100644 index 0000000000..dbdbd00b35 --- /dev/null +++ b/packages/cli/tests/cli-function-secrets-list-contract.test.ts @@ -0,0 +1,191 @@ +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 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 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( + [ + "functions", + "secrets", + "list", + functionId, + "--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/functions/${functionId}/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( + [ + "functions", + "secrets", + "list", + functionId, + "--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/functions/${functionId}/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( + [ + "functions", + "secrets", + "list", + functionId, + "--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( + [ + "functions", + "secrets", + "list", + functionId, + "--base-url", + server.baseUrl, + "--limit", + limit, + ], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(0); + }, + ); +}); + +describe("function secret list argument handling", () => { + it("requires a function ID before requesting", async () => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, {}), + ); + const result = await runCli( + ["functions", "secrets", "list", "--base-url", server.baseUrl], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(0); + }); + + it("encodes the function ID as one path segment", async () => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, { data: [], limit: 20, nextCursor: null }), + ); + const result = await runCli( + [ + "functions", + "secrets", + "list", + "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/functions/id%2Fwith%3Fquery%23fragment/secrets", + ); + }); + + it("reports a missing function", async () => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 404, { message: "Function not found" }), + ); + const result = await runCli( + [ + "functions", + "secrets", + "list", + functionId, + "--base-url", + server.baseUrl, + ], + { env }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Function not found"); + }); +}); 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..2916e4e93c 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,9 @@ const cloudCommandsWithExamples = [ ]; const functionsCommandsWithExamples = [ + ["functions", "secrets", "list"], + ["functions", "secrets", "attach"], + ["functions", "secrets", "detach"], ["functions", "init"], ["functions", "dev"], ["functions", "publish"], 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 09689acd09..20be5da91d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,6 +471,15 @@ importers: '@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 @@ -2241,6 +2250,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'} @@ -10513,6 +10534,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