From 998652ca53d038a941ceff415b7dc661c455e18d Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 17:47:58 +0000 Subject: [PATCH 1/7] feat(cli): use Browserbase-managed Context names Send Context names to the production API while preserving the local alias cache for legacy name-to-ID lookup. Write cache updates atomically through unique private temp files. --- .changeset/tidy-contexts-share.md | 5 ++ packages/cli/README.md | 8 +- packages/cli/package.json | 2 +- packages/cli/skills/browse/SKILL.md | 17 ++-- .../cli/src/commands/cloud/contexts/create.ts | 19 ++-- .../cli/src/commands/cloud/contexts/list.ts | 6 +- packages/cli/src/lib/cloud/contexts-store.ts | 36 ++++---- packages/cli/tests/contexts-named.test.ts | 89 ++++++++++++++++++- pnpm-lock.yaml | 35 +++++--- 9 files changed, 172 insertions(+), 45 deletions(-) create mode 100644 .changeset/tidy-contexts-share.md diff --git a/.changeset/tidy-contexts-share.md b/.changeset/tidy-contexts-share.md new file mode 100644 index 0000000000..8079ca9fe5 --- /dev/null +++ b/.changeset/tidy-contexts-share.md @@ -0,0 +1,5 @@ +--- +"browse": patch +--- + +store Context names in Browserbase while retaining local name-to-ID lookup compatibility and preserving legacy aliases diff --git a/packages/cli/README.md b/packages/cli/README.md index 58e8b5b142..7c0a2bc345 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -227,7 +227,7 @@ browse cloud sessions downloads get # --output ./downloads.zip browse cloud sessions uploads create ./file.pdf # Contexts -browse cloud contexts create +browse cloud contexts create --name github # name is stored in Browserbase browse cloud contexts get browse cloud contexts update # refresh the upload URL browse cloud contexts delete @@ -242,6 +242,12 @@ browse cloud fetch # markdown by default browse cloud search ``` +Names cached by earlier Browse versions remain local aliases and continue to +resolve to their saved Context IDs. They do not need to match the Context's +Browserbase-managed name. `contexts create --name` never overwrites an existing +local alias; use `contexts add --force` only after explicitly +reconciling a legacy mapping. + `browse cloud fetch` returns markdown-formatted page content by default. Use `--format raw` for the original response body, or `--format json --schema ` for structured extraction. ## Functions diff --git a/packages/cli/package.json b/packages/cli/package.json index d8abad3635..f5c0503954 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -102,7 +102,7 @@ "prepublishOnly": "pnpm build" }, "dependencies": { - "@browserbasehq/sdk": "^2.14.0", + "@browserbasehq/sdk": "^2.17.0", "@browserbasehq/stagehand": "workspace:*", "@oclif/core": "^4.11.0", "@vercel/detect-agent": "^1.2.3", diff --git a/packages/cli/skills/browse/SKILL.md b/packages/cli/skills/browse/SKILL.md index 9ddf701379..4cea63cd96 100644 --- a/packages/cli/skills/browse/SKILL.md +++ b/packages/cli/skills/browse/SKILL.md @@ -249,17 +249,22 @@ For remote sessions with context persistence: browse cloud sessions create --context-id --persist ``` -Contexts persist cookies and local storage (logins) across sessions. Name a -context once with `--name` to save a local alias, then reuse the name anywhere a -context ID is accepted instead of memorizing the ID: +Contexts persist cookies and local storage (logins) across sessions. `--name` +stores the name on the Browserbase Context and caches its returned ID on this +device, so the name can also be reused anywhere the CLI accepts a context ID: ```bash -browse cloud contexts create --name github # saves github -> ctx_... -browse cloud contexts add github # name a context you already have +browse cloud contexts create --name github # server-owned name + local ID cache +browse cloud contexts add github # add a local alias for an existing ID browse cloud sessions create --context-id github --persist -browse cloud contexts list # show saved names +browse cloud contexts list # show this device's cached names/aliases ``` +Names saved by earlier CLI versions remain valid local aliases even when they +do not match the Browserbase-managed Context name. `contexts create --name` +will not overwrite one of those mappings. Reconcile deliberately with +`contexts add --force` when needed. + Use `--verified` when the task needs Browserbase Verified browser mode. To drive a Verified/proxied session directly, prefer `browse open --remote --verified --proxies` over create-then-attach — it keeps the session identity so `browse status`/`browse doctor` can report it. Use `browse cloud sessions create` for session options the driver flags don't cover (region, keep-alive, contexts, full `--stdin` body). Use `browse cloud fetch` when the user needs a simple HTTP fetch without browser interaction. It returns markdown-formatted page content by default; pass `--format raw` for the original response body or `--format json --schema ` for structured extraction. Use `browse cloud search` when the user asks for web search results. diff --git a/packages/cli/src/commands/cloud/contexts/create.ts b/packages/cli/src/commands/cloud/contexts/create.ts index 524986e525..702e91c9c3 100644 --- a/packages/cli/src/commands/cloud/contexts/create.ts +++ b/packages/cli/src/commands/cloud/contexts/create.ts @@ -18,7 +18,7 @@ import { BrowseCommand } from "../../../base.js"; export default class ContextsCreate extends BrowseCommand { static override description = - "Create a Browserbase context. Pass --name to save a local alias you can reuse instead of the context ID."; + "Create a Browserbase context. Pass --name to store a project-scoped name in Browserbase and cache its ID locally."; static override examples = [ "browse cloud contexts create", "browse cloud contexts create --name github", @@ -30,7 +30,7 @@ export default class ContextsCreate extends BrowseCommand { ...apiCommonFlags, name: Flags.string({ description: - "Save a local alias for the new context so you can reuse it by name.", + "Set the Context name in Browserbase and cache its ID for local name lookup.", helpValue: "", }), body: Flags.string({ @@ -50,9 +50,13 @@ export default class ContextsCreate extends BrowseCommand { if (!isValidContextName(name)) { fail(`Invalid context name "${name}". ${contextNameRequirement()}`); } - if (await getContextAlias(name)) { + const existingAlias = await getContextAlias(name); + if (existingAlias) { fail( - `A context named "${name}" already exists locally. Choose another name or remove it with "browse cloud contexts delete ${name}".`, + `A context named "${name}" already exists locally and maps to ${existingAlias.id}. ` + + "Existing local aliases are preserved because they may predate Browserbase-managed Context names. " + + "Choose another name, or reconcile the alias explicitly with " + + "`browse cloud contexts add --force`.", ); } } @@ -60,7 +64,12 @@ export default class ContextsCreate extends BrowseCommand { await withBrowserbaseApi("contexts", async () => { const client = createBrowserbaseClient(toApiOptions(flags)); const body = await resolveBody({ body: flags.body, stdin: flags.stdin }); - const context = await client.contexts.create(body); + // Browserbase owns name uniqueness and canonical storage. The explicit + // flag takes precedence over a name supplied through --body/--stdin, + // matching the merge behavior of other cloud command flags. + const context = await client.contexts.create( + name === undefined ? body : { ...body, name }, + ); if (name !== undefined && context.id) { await saveContextAlias(name, { diff --git a/packages/cli/src/commands/cloud/contexts/list.ts b/packages/cli/src/commands/cloud/contexts/list.ts index d2a9ec73be..2d05fb2707 100644 --- a/packages/cli/src/commands/cloud/contexts/list.ts +++ b/packages/cli/src/commands/cloud/contexts/list.ts @@ -14,7 +14,7 @@ import { export default class ContextsList extends BrowseCommand { static override description = - "List Browserbase contexts you have saved locally with a name."; + "List Context name-to-ID mappings cached on this device."; static override examples = [ "browse cloud contexts list", "browse cloud contexts list --json", @@ -37,7 +37,7 @@ export default class ContextsList extends BrowseCommand { if (contexts.length === 0) { console.log( - "No saved contexts. Create one with: browse cloud contexts create --name ", + "No cached contexts. Create one with: browse cloud contexts create --name ", ); return; } @@ -54,7 +54,7 @@ function outputContextsTable( contexts, [ { - header: "Name", + header: "Local name", maxWidth: 24, value: (context) => context.name, }, diff --git a/packages/cli/src/lib/cloud/contexts-store.ts b/packages/cli/src/lib/cloud/contexts-store.ts index 7a8b862639..6c4911ab89 100644 --- a/packages/cli/src/lib/cloud/contexts-store.ts +++ b/packages/cli/src/lib/cloud/contexts-store.ts @@ -1,19 +1,20 @@ import { distance } from "fastest-levenshtein"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; import { resolveConfigDir } from "../identity.js"; /** - * Local name -> Browserbase context-id map. + * Local cache of Browserbase context names -> context ids. * - * Browserbase contexts are identified only by an opaque id and the platform has - * no server-side list endpoint, so to give contexts memorable names (e.g. - * `github`, `gmail`) we keep a small map on the local device. It lives next to - * the CLI's other state at `(XDG_CONFIG_HOME||~/.config)/browserbase/contexts.json` - * (honoring `BROWSERBASE_CONFIG_DIR`). This is purely a client-side convenience: - * the ids it stores are the same ids the API already returns, and a missing or - * corrupt file degrades to "no saved contexts" rather than an error. + * Browserbase stores an optional, project-scoped name on each Context. The + * public API still identifies Contexts and session persistence by opaque id and + * does not expose list or lookup-by-name endpoints, so the CLI caches the names + * it creates on this device. It lives next to the CLI's other state at + * `(XDG_CONFIG_HOME||~/.config)/browserbase/contexts.json` (honoring + * `BROWSERBASE_CONFIG_DIR`). A missing or corrupt cache degrades to "no cached + * contexts" rather than an error; Browserbase remains authoritative for names. */ const STORE_VERSION = 1; @@ -147,12 +148,17 @@ async function writeStore( // contexts.json already existed (writeFile's `mode` only applies on create). // For this single-user local config, simultaneous writers remain // last-writer-wins; cross-process locking isn't warranted here. - const tempPath = `${path}.${process.pid}.tmp`; - await writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); - await rename(tempPath, path); + const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + await rename(tempPath, path); + } finally { + await rm(tempPath, { force: true }).catch(() => undefined); + } } export async function listContextAliases( diff --git a/packages/cli/tests/contexts-named.test.ts b/packages/cli/tests/contexts-named.test.ts index 51ddccc8c6..e8f0495d39 100644 --- a/packages/cli/tests/contexts-named.test.ts +++ b/packages/cli/tests/contexts-named.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -29,8 +29,8 @@ function pathOf(request: CapturedRequest): string { /** * Drives the real built CLI through the full named-context lifecycle against a - * fake Browserbase server, proving the local name->id map is written on create - * and resolved by list / get / sessions-create / delete. + * fake Browserbase server, proving Browserbase receives the name and the local + * lookup cache resolves its returned id for list / get / sessions-create / delete. */ describe("named contexts (end to end through the CLI)", () => { it("creates by name, resolves the name everywhere, and prunes on delete", async () => { @@ -70,7 +70,8 @@ describe("named contexts (end to end through the CLI)", () => { const storePath = join(configDir, "contexts.json"); try { - // 1. create --name writes the local alias and echoes the name back. + // 1. create --name sends the server-owned name, caches the returned id, + // and echoes the name back. const created = await runCli( ["cloud", "contexts", "create", "--name", "github"], { env }, @@ -83,6 +84,13 @@ describe("named contexts (end to end through the CLI)", () => { expect(JSON.parse(await readFile(storePath, "utf8"))).toMatchObject({ contexts: { github: { id: CONTEXT_ID } }, }); + if (process.platform !== "win32") { + expect((await stat(storePath)).mode & 0o777).toBe(0o600); + } + const createRequest = server.requests.find( + (r) => r.method === "POST" && pathOf(r) === "/v1/contexts", + ); + expect(createRequest?.jsonBody).toMatchObject({ name: "github" }); // 2. list --json surfaces the saved alias. const listed = await runCli(["cloud", "contexts", "list", "--json"], { @@ -231,6 +239,79 @@ describe("named contexts (end to end through the CLI)", () => { } }); + it("preserves a legacy local alias when its Browserbase-managed name differs", async () => { + const legacyId = "00000000-0000-4000-8000-0000000000cc"; + const server = await startFakeBrowserbaseServer((request, response) => { + if ( + request.method === "GET" && + pathOf(request) === `/v1/contexts/${legacyId}` + ) { + jsonResponse(response, 200, { + id: legacyId, + name: "managed-name", + status: "ready", + }); + return; + } + if (request.method === "POST" && pathOf(request) === "/v1/contexts") { + jsonResponse(response, 200, { id: "ctx_should_not_be_created" }); + return; + } + jsonResponse(response, 200, {}); + }); + const env = { + BROWSERBASE_CONFIG_DIR: configDir, + BROWSERBASE_API_KEY: "test-key", + BROWSERBASE_BASE_URL: server.baseUrl, + }; + const storePath = join(configDir, "contexts.json"); + + try { + // A pre-managed-name CLI install may already have an arbitrary local + // alias. It remains a valid lookup even when the API reports another + // Browserbase-owned name for that Context. + const added = await runCli( + ["cloud", "contexts", "add", "legacy-login", legacyId], + { env }, + ); + expect(added.exitCode).toBe(0); + + const got = await runCli(["cloud", "contexts", "get", "legacy-login"], { + env, + }); + expect(got.exitCode).toBe(0); + expect(JSON.parse(got.stdout)).toMatchObject({ + id: legacyId, + name: "managed-name", + }); + + // Creating a new managed Context under the same local name must fail + // before the API call instead of silently repointing the legacy alias. + const duplicate = await runCli( + ["cloud", "contexts", "create", "--name", "legacy-login"], + { + env, + }, + ); + expect(duplicate.exitCode).not.toBe(0); + expect(duplicate.stderr).toContain("already exists locally"); + expect(duplicate.stderr).toContain( + "may predate Browserbase-managed Context names", + ); + expect( + server.requests.some( + (request) => + request.method === "POST" && pathOf(request) === "/v1/contexts", + ), + ).toBe(false); + expect(JSON.parse(await readFile(storePath, "utf8"))).toMatchObject({ + contexts: { "legacy-login": { id: legacyId } }, + }); + } finally { + await server.close(); + } + }); + it("passes an unrecognized raw id through to the API (raw-id compatibility)", async () => { const rawId = "legacy-id-not-a-uuid-9000"; const server = await startFakeBrowserbaseServer((request, response) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e69479a250..a47fb9db1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,8 +436,8 @@ importers: packages/cli: dependencies: '@browserbasehq/sdk': - specifier: ^2.14.0 - version: 2.16.0 + specifier: ^2.17.0 + version: 2.20.0 '@browserbasehq/stagehand': specifier: workspace:* version: link:../sdk-ts @@ -1826,6 +1826,9 @@ packages: '@browserbasehq/sdk@2.16.0': resolution: {integrity: sha512-mPAuLRU9jWR7o0KJi9+gQnOBDUSIkoKbbFv4HjrA+80qWVcFacrNPlZmf4mguQnfZ0oP2t5c3ws6yuFyAX9vpA==} + '@browserbasehq/sdk@2.20.0': + resolution: {integrity: sha512-zDDsgrwF6/iF+Ob0LIqe6EPBa4n7m+5YfKn0g0+kZR5jIncTXBPmJSW6tAsu9go3wA9tQQP+zm6qUOPVi1Gutw==} + '@browserbasehq/stagehand@3.7.1': resolution: {integrity: sha512-vAuYSZWIhh3d76BxwppNVE3dB0ztEBLBi85G6TWulZNiebdWptNoANOMuprOB/cw5dE+80b/ZZQo4G33Pc9i6w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9943,6 +9946,18 @@ snapshots: transitivePeerDependencies: - encoding + '@browserbasehq/sdk@2.20.0': + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + '@browserbasehq/stagehand@3.7.1(playwright-core@1.56.1)(supports-color@8.1.1)(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.3 @@ -12308,7 +12323,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 '@types/debug@4.1.13': dependencies: @@ -12318,7 +12333,7 @@ snapshots: '@types/es-aggregate-error@1.0.6': dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 '@types/esrecurse@4.3.1': {} @@ -12358,7 +12373,7 @@ snapshots: '@types/mute-stream@0.0.4': dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 '@types/nlcst@2.0.3': dependencies: @@ -12366,7 +12381,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 form-data: 4.0.6 '@types/node@12.20.55': {} @@ -12417,7 +12432,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 optional: true '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': @@ -13207,7 +13222,7 @@ snapshots: chrome-launcher@1.2.1(supports-color@8.1.1): dependencies: - '@types/node': 25.9.4 + '@types/node': 20.19.43 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 2.0.2(supports-color@8.1.1) @@ -13603,7 +13618,7 @@ snapshots: engine.io@6.6.9(bufferutil@4.1.0): dependencies: '@types/cors': 2.8.19 - '@types/node': 25.9.4 + '@types/node': 20.19.43 '@types/ws': 8.18.1 accepts: 1.3.8 base64id: 2.0.0 @@ -16742,7 +16757,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.2 - '@types/node': 25.9.4 + '@types/node': 20.19.43 long: 5.3.2 proxy-addr@2.0.7: From d77013f8d066b1db1f9af9cb1c83f2ddced2c4e0 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:11:14 -0700 Subject: [PATCH 2/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 f5c0503954..2a1457530f 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 96a3a40ea2038086799835e665f3c6b293e1e7ef Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:12:29 -0700 Subject: [PATCH 3/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 2a1457530f..38456f5b2b 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 646c18fd053c846a75ebaf61d7cb52a7efcc31e0 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:41:09 -0700 Subject: [PATCH 4/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 38456f5b2b..ceb830eaa4 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.17.0", "@browserbasehq/stagehand": "workspace:*", + "@hpke/core": "^1.9.0", + "@hpke/dhkem-x25519": "^1.8.0", + "@inquirer/password": "^4.0.23", "@oclif/core": "^4.11.0", "@vercel/detect-agent": "^1.2.3", "archiver": "^7.0.1", diff --git a/packages/cli/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 a47fb9db1d..1f42e8d2b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -441,6 +441,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 @@ -2183,6 +2192,18 @@ packages: peerDependencies: hono: ^4 + '@hpke/common@1.10.1': + resolution: {integrity: sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==} + engines: {node: '>=16.0.0'} + + '@hpke/core@1.9.0': + resolution: {integrity: sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==} + engines: {node: '>=16.0.0'} + + '@hpke/dhkem-x25519@1.8.0': + resolution: {integrity: sha512-S1MWWkAfu+TFxySgv5+2P3O4Mx/jk7BsoplzQaA1s3sfUJVJ2UsZsSzSsMc+FXJumLXncoJFlO6mK6mDGspfmA==} + engines: {node: '>=16.0.0'} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -10421,6 +10442,16 @@ snapshots: dependencies: hono: 4.12.31 + '@hpke/common@1.10.1': {} + + '@hpke/core@1.9.0': + dependencies: + '@hpke/common': 1.10.1 + + '@hpke/dhkem-x25519@1.8.0': + dependencies: + '@hpke/common': 1.10.1 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 From 5169775f614198c5dccc32df3e817a847a825b53 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:41:59 -0700 Subject: [PATCH 5/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 ceb830eaa4..5fdf120286 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 745026cd7658951289cbd285298d1baf141c59ed Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 18:12:31 -0700 Subject: [PATCH 6/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 5fdf120286..accee3e48d 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 9e8ff0b3023412f76334853a36c387cb175bf6c1 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 18:13:24 -0700 Subject: [PATCH 7/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 accee3e48d..2e7a3ddd64 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"],