From d5fdc53b057b3080aa7ca667e02f8c86053b14ba Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:11:14 -0700 Subject: [PATCH 1/7] feat(cli): Add command to List secrets (#2946) # why We want to add secrets support to the browse cli. In order to do so, we need to first introduce a common library that is a wrapper around the REST api, and then hook it into the cli tool. For simplicity, we choose the list secrets endpoint as the first CRUD operation that will be exposed to the cli tool # what changed - Adds a library for wrapping around the secrets api - implements the first command to retrieve a list of secrets # test plan - [x] unit tests - [x] pointed this branch at a local api and confirmed listing secrets (metadata only) on test projects works --- .changeset/cli-list-project-secrets.md | 5 + packages/cli/package.json | 3 + .../cli/src/commands/cloud/secrets/list.ts | 25 ++++ packages/cli/src/lib/cloud/api.ts | 5 + packages/cli/src/lib/secrets/api.ts | 40 ++++++ packages/cli/src/lib/secrets/flags.ts | 34 +++++ .../tests/cli-secrets-list-contract.test.ts | 119 ++++++++++++++++++ packages/cli/tests/cli-surface.test.ts | 1 + packages/cli/tsconfig.local-only.json | 1 + 9 files changed, 233 insertions(+) create mode 100644 .changeset/cli-list-project-secrets.md create mode 100644 packages/cli/src/commands/cloud/secrets/list.ts create mode 100644 packages/cli/src/lib/secrets/api.ts create mode 100644 packages/cli/src/lib/secrets/flags.ts create mode 100644 packages/cli/tests/cli-secrets-list-contract.test.ts 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/packages/cli/package.json b/packages/cli/package.json index 96772ceecf..d23000a7f0 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": "List project secret metadata." + }, "cloud:projects": { "description": "Manage Browserbase projects." }, 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/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..65756f75d8 --- /dev/null +++ b/packages/cli/src/lib/secrets/api.ts @@ -0,0 +1,40 @@ +import { 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; +} diff --git a/packages/cli/src/lib/secrets/flags.ts b/packages/cli/src/lib/secrets/flags.ts new file mode 100644 index 0000000000..ea44c6c208 --- /dev/null +++ b/packages/cli/src/lib/secrets/flags.ts @@ -0,0 +1,34 @@ +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"], + }; +} 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-surface.test.ts b/packages/cli/tests/cli-surface.test.ts index e96ba53262..2f671818e5 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { runCli } from "./helpers/run-cli.js"; const cloudCommandsWithExamples = [ + ["cloud", "secrets", "list"], ["cloud", "projects", "list"], ["cloud", "projects", "get"], ["cloud", "projects", "usage"], 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", From 35b3ca71158cc4df82501010db55da1f67bdd094 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:12:29 -0700 Subject: [PATCH 2/7] feat(cli) Add commands to GET and DELETE a secret (#2949) # why We want to add secrets support to the browse cli and is a continuation of the efforts introduced in this PR: https://github.com/browserbase/stagehand/pull/2946. In this particular PR, we add support GET/DELETE on a secret # what changed - Add GET and DELETE support # test plan - [x] unit tests - [x] point cli at local secrets api --- .changeset/cli-get-delete-secrets.md | 5 + packages/cli/package.json | 2 +- .../cli/src/commands/cloud/secrets/delete.ts | 21 +++ .../cli/src/commands/cloud/secrets/get.ts | 23 ++++ packages/cli/src/lib/secrets/api.ts | 20 ++- .../cli-secrets-get-delete-contract.test.ts | 124 ++++++++++++++++++ packages/cli/tests/cli-surface.test.ts | 2 + 7 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 .changeset/cli-get-delete-secrets.md create mode 100644 packages/cli/src/commands/cloud/secrets/delete.ts create mode 100644 packages/cli/src/commands/cloud/secrets/get.ts create mode 100644 packages/cli/tests/cli-secrets-get-delete-contract.test.ts 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/packages/cli/package.json b/packages/cli/package.json index d23000a7f0..2aa8078303 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,7 +37,7 @@ "description": "Manage Browserbase cloud resources and APIs." }, "cloud:secrets": { - "description": "List project secret metadata." + "description": "List, retrieve, and delete project secrets." }, "cloud:projects": { "description": "Manage Browserbase projects." 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..8fe7e8aa7f --- /dev/null +++ b/packages/cli/src/commands/cloud/secrets/delete.ts @@ -0,0 +1,21 @@ +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 "]; + static override args = { + secretId: Args.string({ + description: "Project secret ID.", + 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..482153f6d2 --- /dev/null +++ b/packages/cli/src/commands/cloud/secrets/get.ts @@ -0,0 +1,23 @@ +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 "]; + static override args = { + secretId: Args.string({ + description: "Project secret ID.", + 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/lib/secrets/api.ts b/packages/cli/src/lib/secrets/api.ts index 65756f75d8..429df82f3c 100644 --- a/packages/cli/src/lib/secrets/api.ts +++ b/packages/cli/src/lib/secrets/api.ts @@ -1,4 +1,4 @@ -import { requestBrowserbaseJson } from "../cloud/api.js"; +import { requestBrowserbase, requestBrowserbaseJson } from "../cloud/api.js"; export interface SecretsApiOptions { apiKey?: string; @@ -38,3 +38,21 @@ function withQuery(path: string, query: ListSecretsOptions): string { 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)}`; +} 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-surface.test.ts b/packages/cli/tests/cli-surface.test.ts index 2f671818e5..4cf2a3c8ba 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -4,6 +4,8 @@ import { runCli } from "./helpers/run-cli.js"; const cloudCommandsWithExamples = [ ["cloud", "secrets", "list"], + ["cloud", "secrets", "get"], + ["cloud", "secrets", "delete"], ["cloud", "projects", "list"], ["cloud", "projects", "get"], ["cloud", "projects", "usage"], From 7827c01cc16fe87d5bf3c8f367f0cbbd63fe4f0d Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:41:09 -0700 Subject: [PATCH 3/7] feat(cli) Add command to CREATE an encrypted secret (#2967) ### why We want to add secrets support to the browse cli and is a continuation of the efforts introduced in this PR: https://github.com/browserbase/stagehand/pull/2946. In this particular PR, we add support for creating a secret by retrieving the public key for the project, reading the secret value from an env variable, a value piped to stdin, or prompting them in a password prompt (the inquire package), encrypting the value with the public key, then calling the create secret endpoint with the secret key name and the encrypted value. ### what changed - Adds a command to create a secret ### test plan - [x] unit tests - [x] point cli at local secrets api, verify encrypted secrets value lands in local db --- .changeset/cli-create-secret.md | 5 + packages/cli/package.json | 5 +- .../cli/src/commands/cloud/secrets/create.ts | 34 +++ packages/cli/src/lib/secrets/api.ts | 22 ++ packages/cli/src/lib/secrets/flags.ts | 13 ++ packages/cli/src/lib/secrets/input.ts | 39 ++++ packages/cli/src/lib/secrets/seal.ts | 35 +++ .../tests/cli-secrets-create-contract.test.ts | 220 ++++++++++++++++++ packages/cli/tests/cli-surface.test.ts | 1 + packages/cli/tests/helpers/run-cli.ts | 16 +- packages/cli/tests/secrets-input.test.ts | 72 ++++++ pnpm-lock.yaml | 31 +++ 12 files changed, 489 insertions(+), 4 deletions(-) create mode 100644 .changeset/cli-create-secret.md create mode 100644 packages/cli/src/commands/cloud/secrets/create.ts create mode 100644 packages/cli/src/lib/secrets/input.ts create mode 100644 packages/cli/src/lib/secrets/seal.ts create mode 100644 packages/cli/tests/cli-secrets-create-contract.test.ts create mode 100644 packages/cli/tests/secrets-input.test.ts 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/packages/cli/package.json b/packages/cli/package.json index 2aa8078303..6592e59a14 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,7 +37,7 @@ "description": "Manage Browserbase cloud resources and APIs." }, "cloud:secrets": { - "description": "List, retrieve, and delete project secrets." + "description": "Create, list, retrieve, and delete project secrets." }, "cloud:projects": { "description": "Manage Browserbase projects." @@ -107,6 +107,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/lib/secrets/api.ts b/packages/cli/src/lib/secrets/api.ts index 429df82f3c..a8cb8e7c52 100644 --- a/packages/cli/src/lib/secrets/api.ts +++ b/packages/cli/src/lib/secrets/api.ts @@ -1,3 +1,4 @@ +import { sealSecret } from "./seal.js"; import { requestBrowserbase, requestBrowserbaseJson } from "../cloud/api.js"; export interface SecretsApiOptions { @@ -56,3 +57,24 @@ export async function deleteSecret( function secretPath(id: string): string { return `/v1/secrets/${encodeURIComponent(id)}`; } + +export async function createSecret( + options: SecretsApiOptions, + secretKey: string, + value: Uint8Array, +): Promise { + const keypair = await requestBrowserbaseJson<{ + id: string; + publicKey: string; + }>(options, "/v1/secrets/keypair"); + const sealedSecretValue = await sealSecret(keypair.publicKey, value); + return requestBrowserbaseJson(options, "/v1/secrets", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + secretKey, + keypairId: keypair.id, + sealedSecretValue, + }), + }); +} diff --git a/packages/cli/src/lib/secrets/flags.ts b/packages/cli/src/lib/secrets/flags.ts index ea44c6c208..505030b594 100644 --- a/packages/cli/src/lib/secrets/flags.ts +++ b/packages/cli/src/lib/secrets/flags.ts @@ -32,3 +32,16 @@ export function toListSecretsOptions(flags: { 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-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-surface.test.ts b/packages/cli/tests/cli-surface.test.ts index 4cf2a3c8ba..b22bf11352 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -4,6 +4,7 @@ import { runCli } from "./helpers/run-cli.js"; const cloudCommandsWithExamples = [ ["cloud", "secrets", "list"], + ["cloud", "secrets", "create"], ["cloud", "secrets", "get"], ["cloud", "secrets", "delete"], ["cloud", "projects", "list"], 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/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 From 2cd61a62509b8b4683ad1ec509be5a9efc624ae1 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:41:59 -0700 Subject: [PATCH 4/7] feat(cli) Add command to Update a Secret (#2990) ### why We want to add secrets support to the browse cli and is a continuation of the efforts introduced in this PR: https://github.com/browserbase/stagehand/pull/2946. In this particular PR, we add support for updating a secret by retrieving the public key for the project, reading the secret value from an env variable, a value piped to stdin, or prompting them in a password prompt (the inquire package), encrypting the value with the public key, then calling the update secret endpoint via patch with the provided secret-id and the encrypted value. ### what changed - Adds a command to update a secret ### test plan - [x] unit tests - [x] point cli at local secrets api, verify encrypted secrets value lands in local db and is updated --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .changeset/cli-update-secret.md | 5 + packages/cli/package.json | 2 +- .../cli/src/commands/cloud/secrets/delete.ts | 8 +- .../cli/src/commands/cloud/secrets/get.ts | 8 +- .../cli/src/commands/cloud/secrets/update.ts | 36 +++ packages/cli/src/lib/secrets/api.ts | 31 ++- .../tests/cli-secrets-update-contract.test.ts | 217 ++++++++++++++++++ packages/cli/tests/cli-surface.test.ts | 1 + 8 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 .changeset/cli-update-secret.md create mode 100644 packages/cli/src/commands/cloud/secrets/update.ts create mode 100644 packages/cli/tests/cli-secrets-update-contract.test.ts 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 6592e59a14..0cdb9d9db9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,7 +37,7 @@ "description": "Manage Browserbase cloud resources and APIs." }, "cloud:secrets": { - "description": "Create, list, retrieve, and delete project secrets." + "description": "Create, list, retrieve, update, and delete project secrets." }, "cloud:projects": { "description": "Manage Browserbase projects." diff --git a/packages/cli/src/commands/cloud/secrets/delete.ts b/packages/cli/src/commands/cloud/secrets/delete.ts index 8fe7e8aa7f..cb8caf5921 100644 --- a/packages/cli/src/commands/cloud/secrets/delete.ts +++ b/packages/cli/src/commands/cloud/secrets/delete.ts @@ -5,10 +5,14 @@ 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 "]; + 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.", + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", required: true, }), }; diff --git a/packages/cli/src/commands/cloud/secrets/get.ts b/packages/cli/src/commands/cloud/secrets/get.ts index 482153f6d2..c3d09c3d38 100644 --- a/packages/cli/src/commands/cloud/secrets/get.ts +++ b/packages/cli/src/commands/cloud/secrets/get.ts @@ -7,10 +7,14 @@ 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 "]; + 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.", + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", required: true, }), }; 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/lib/secrets/api.ts b/packages/cli/src/lib/secrets/api.ts index a8cb8e7c52..796e591c7d 100644 --- a/packages/cli/src/lib/secrets/api.ts +++ b/packages/cli/src/lib/secrets/api.ts @@ -63,18 +63,35 @@ export async function createSecret( secretKey: string, value: Uint8Array, ): Promise { - const keypair = await requestBrowserbaseJson<{ - id: string; - publicKey: string; - }>(options, "/v1/secrets/keypair"); - const sealedSecretValue = await sealSecret(keypair.publicKey, value); + const sealed = await encryptValue(options, value); return requestBrowserbaseJson(options, "/v1/secrets", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ secretKey, - keypairId: keypair.id, - sealedSecretValue, + ...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 }; +} 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 b22bf11352..acb731b252 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -5,6 +5,7 @@ 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"], From 7a2f73510b5ec0c31a6c2703cb74515b0b09977b Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 18:12:31 -0700 Subject: [PATCH 5/7] feat(cli) Add command for ATTACH-ing a secret to a function (#3006) ### why We want to add secrets support to the browse cli and is a continuation of the efforts introduced in this PR: https://github.com/browserbase/stagehand/pull/2946. In this particular PR, we add support for attaching a secret to a function. ### what changed - Adds a command to attach a secret to a function ### test plan - [x] unit tests - [ ] point cli at local secrets api, verify encrypted secrets value lands in local db and is updated --- .changeset/cli-attach-function-secret.md | 5 + packages/cli/package.json | 3 + .../src/commands/functions/secrets/attach.ts | 30 +++++ packages/cli/src/lib/secrets/api.ts | 16 +++ ...i-function-secrets-attach-contract.test.ts | 126 ++++++++++++++++++ packages/cli/tests/cli-surface.test.ts | 1 + 6 files changed, 181 insertions(+) create mode 100644 .changeset/cli-attach-function-secret.md create mode 100644 packages/cli/src/commands/functions/secrets/attach.ts create mode 100644 packages/cli/tests/cli-function-secrets-attach-contract.test.ts 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/packages/cli/package.json b/packages/cli/package.json index 0cdb9d9db9..f14810ddf8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -57,6 +57,9 @@ "cloud:sessions:uploads": { "description": "Upload files to Browserbase sessions." }, + "functions:secrets": { + "description": "Attach project secrets to functions." + }, "functions": { "description": "Develop, publish, and invoke Browserbase Functions." }, 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/lib/secrets/api.ts b/packages/cli/src/lib/secrets/api.ts index 796e591c7d..68725fb443 100644 --- a/packages/cli/src/lib/secrets/api.ts +++ b/packages/cli/src/lib/secrets/api.ts @@ -95,3 +95,19 @@ async function encryptValue(options: SecretsApiOptions, value: Uint8Array) { 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 }), + }, + ); +} 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-surface.test.ts b/packages/cli/tests/cli-surface.test.ts index acb731b252..ac9bf53046 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -33,6 +33,7 @@ const cloudCommandsWithExamples = [ ]; const functionsCommandsWithExamples = [ + ["functions", "secrets", "attach"], ["functions", "init"], ["functions", "dev"], ["functions", "publish"], From c4b41784f984bb39d9c7e345bf180bc17eb2b302 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 18:13:24 -0700 Subject: [PATCH 6/7] feat(cli) Add command to DETACH a secret from a function (#3007) ### why We want to add secrets support to the browse cli and is a continuation of the efforts introduced in this PR: https://github.com/browserbase/stagehand/pull/2946. In this particular PR, we add support for detaching a secret from a function. ### what changed - Adds a command to detach a secret from a function ### test plan - [x] unit tests - [x] point cli at local secrets api, verify encrypted secrets value lands in local db and is updated --- .changeset/cli-detach-function-secret.md | 5 + packages/cli/package.json | 2 +- .../src/commands/functions/secrets/detach.ts | 30 +++++ packages/cli/src/lib/secrets/api.ts | 12 ++ ...i-function-secrets-detach-contract.test.ts | 126 ++++++++++++++++++ packages/cli/tests/cli-surface.test.ts | 1 + 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 .changeset/cli-detach-function-secret.md create mode 100644 packages/cli/src/commands/functions/secrets/detach.ts create mode 100644 packages/cli/tests/cli-function-secrets-detach-contract.test.ts 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/packages/cli/package.json b/packages/cli/package.json index f14810ddf8..965045ba58 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,7 +58,7 @@ "description": "Upload files to Browserbase sessions." }, "functions:secrets": { - "description": "Attach project secrets to functions." + "description": "Attach and detach project secrets from functions." }, "functions": { "description": "Develop, publish, and invoke Browserbase Functions." 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/secrets/api.ts b/packages/cli/src/lib/secrets/api.ts index 68725fb443..5d704b51a8 100644 --- a/packages/cli/src/lib/secrets/api.ts +++ b/packages/cli/src/lib/secrets/api.ts @@ -111,3 +111,15 @@ export async function attachFunctionSecret( }, ); } + +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/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-surface.test.ts b/packages/cli/tests/cli-surface.test.ts index ac9bf53046..921f56d051 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -34,6 +34,7 @@ const cloudCommandsWithExamples = [ const functionsCommandsWithExamples = [ ["functions", "secrets", "attach"], + ["functions", "secrets", "detach"], ["functions", "init"], ["functions", "dev"], ["functions", "publish"], From c874d61acec7aa2819b21435df1bd7c4b92874a6 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 23:00:13 -0700 Subject: [PATCH 7/7] feat(cli) - Add command to LIST secrets attached to a function (#3009) ### why We want to add secrets support to the browse cli and is a continuation of the efforts introduced in this PR: https://github.com/browserbase/stagehand/pull/2946. In this particular PR, we add support for listing secrets attached to a specific function ### what changed - Adds a command to list secrets attached to a function ### test plan - [x] unit tests - [x] point cli at local secrets api, verify encrypted secrets value lands in local db and is updated --- .changeset/cli-list-function-secrets.md | 5 + packages/cli/package.json | 2 +- .../src/commands/functions/secrets/list.ts | 39 ++++ packages/cli/src/lib/secrets/api.ts | 11 + ...cli-function-secrets-list-contract.test.ts | 191 ++++++++++++++++++ packages/cli/tests/cli-surface.test.ts | 1 + 6 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 .changeset/cli-list-function-secrets.md create mode 100644 packages/cli/src/commands/functions/secrets/list.ts create mode 100644 packages/cli/tests/cli-function-secrets-list-contract.test.ts 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/packages/cli/package.json b/packages/cli/package.json index 965045ba58..77ae5133a5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,7 +58,7 @@ "description": "Upload files to Browserbase sessions." }, "functions:secrets": { - "description": "Attach and detach project secrets from functions." + "description": "List, attach, and detach project secrets for functions." }, "functions": { "description": "Develop, publish, and invoke Browserbase Functions." 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/secrets/api.ts b/packages/cli/src/lib/secrets/api.ts index 5d704b51a8..72f8fd9589 100644 --- a/packages/cli/src/lib/secrets/api.ts +++ b/packages/cli/src/lib/secrets/api.ts @@ -123,3 +123,14 @@ export async function detachFunctionSecret( { 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/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-surface.test.ts b/packages/cli/tests/cli-surface.test.ts index 921f56d051..2916e4e93c 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -33,6 +33,7 @@ const cloudCommandsWithExamples = [ ]; const functionsCommandsWithExamples = [ + ["functions", "secrets", "list"], ["functions", "secrets", "attach"], ["functions", "secrets", "detach"], ["functions", "init"],