From 106265fdc6d8a19b29b3ab7da5aa4211487bbb02 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Fri, 18 Sep 2026 16:35:05 -0700 Subject: [PATCH 1/5] Add encrypted secret update command --- .changeset/cli-update-secret.md | 5 + packages/cli/package.json | 2 +- .../cli/src/commands/cloud/secrets/update.ts | 34 +++ packages/cli/src/lib/secrets/api.ts | 31 ++- .../tests/cli-secrets-update-contract.test.ts | 216 ++++++++++++++++++ packages/cli/tests/cli-surface.test.ts | 1 + 6 files changed, 281 insertions(+), 8 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 000000000..091ad39f7 --- /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 ceb830eaa..8c6ddae5c 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": "Manage project secrets." }, "cloud:projects": { "description": "Manage Browserbase projects." 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 000000000..5d4dfc863 --- /dev/null +++ b/packages/cli/src/commands/cloud/secrets/update.ts @@ -0,0 +1,34 @@ +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 --env MY_SERVICE_TOKEN", + "browse cloud secrets update --stdin < ./secret.txt", + ]; + static override args = { + secretId: Args.string({ + description: "Project secret ID.", + 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 a8cb8e7c5..796e591c7 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 000000000..79c0b251c --- /dev/null +++ b/packages/cli/tests/cli-secrets-update-contract.test.ts @@ -0,0 +1,216 @@ +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"); + }); + + 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 b22bf1135..acb731b25 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 8a00eef94f1d9e36452deae99c94626cc0c42150 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Fri, 18 Sep 2026 16:42:04 -0700 Subject: [PATCH 2/5] Clarify secrets command topic description --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8c6ddae5c..5fdf12028 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": "Manage project secrets." + "description": "Create, list, retrieve, update, and delete project secrets." }, "cloud:projects": { "description": "Manage Browserbase projects." From bab1ec70e82147845d302df9eb2686353a2d27a5 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:29:04 -0700 Subject: [PATCH 3/5] Update packages/cli/tests/cli-secrets-update-contract.test.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- packages/cli/tests/cli-secrets-update-contract.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/tests/cli-secrets-update-contract.test.ts b/packages/cli/tests/cli-secrets-update-contract.test.ts index 79c0b251c..16cb14cbb 100644 --- a/packages/cli/tests/cli-secrets-update-contract.test.ts +++ b/packages/cli/tests/cli-secrets-update-contract.test.ts @@ -142,6 +142,7 @@ describe("secret update CLI HTTP contracts", () => { 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 () => { From 5a3394080b7821d133936c2ef5303f3a34277dd0 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:35:39 -0700 Subject: [PATCH 4/5] docs(cli): add concrete secret ID examples --- packages/cli/src/commands/cloud/secrets/delete.ts | 5 ++++- packages/cli/src/commands/cloud/secrets/get.ts | 5 ++++- packages/cli/src/commands/cloud/secrets/update.ts | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/cloud/secrets/delete.ts b/packages/cli/src/commands/cloud/secrets/delete.ts index 8fe7e8aa7..1a54cb465 100644 --- a/packages/cli/src/commands/cloud/secrets/delete.ts +++ b/packages/cli/src/commands/cloud/secrets/delete.ts @@ -5,7 +5,10 @@ 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.", diff --git a/packages/cli/src/commands/cloud/secrets/get.ts b/packages/cli/src/commands/cloud/secrets/get.ts index 482153f6d..e398c50a1 100644 --- a/packages/cli/src/commands/cloud/secrets/get.ts +++ b/packages/cli/src/commands/cloud/secrets/get.ts @@ -7,7 +7,10 @@ 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.", diff --git a/packages/cli/src/commands/cloud/secrets/update.ts b/packages/cli/src/commands/cloud/secrets/update.ts index 5d4dfc863..7c08bcad8 100644 --- a/packages/cli/src/commands/cloud/secrets/update.ts +++ b/packages/cli/src/commands/cloud/secrets/update.ts @@ -11,6 +11,7 @@ export default class SecretsUpdate extends BrowseCommand { "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", ]; From 9a8a6f38e1545da4ab78616a3b344cd912f7da92 Mon Sep 17 00:00:00 2001 From: Azam Abdulkadir Date: Tue, 22 Sep 2026 17:38:50 -0700 Subject: [PATCH 5/5] docs(cli): clarify secret ID argument descriptions --- packages/cli/src/commands/cloud/secrets/delete.ts | 3 ++- packages/cli/src/commands/cloud/secrets/get.ts | 3 ++- packages/cli/src/commands/cloud/secrets/update.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/cloud/secrets/delete.ts b/packages/cli/src/commands/cloud/secrets/delete.ts index 1a54cb465..cb8caf592 100644 --- a/packages/cli/src/commands/cloud/secrets/delete.ts +++ b/packages/cli/src/commands/cloud/secrets/delete.ts @@ -11,7 +11,8 @@ export default class SecretsDelete extends BrowseCommand { ]; 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 e398c50a1..c3d09c3d3 100644 --- a/packages/cli/src/commands/cloud/secrets/get.ts +++ b/packages/cli/src/commands/cloud/secrets/get.ts @@ -13,7 +13,8 @@ export default class SecretsGet extends BrowseCommand { ]; 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 index 7c08bcad8..3a261a8a1 100644 --- a/packages/cli/src/commands/cloud/secrets/update.ts +++ b/packages/cli/src/commands/cloud/secrets/update.ts @@ -17,7 +17,8 @@ export default class SecretsUpdate extends BrowseCommand { ]; static override args = { secretId: Args.string({ - description: "Project secret ID.", + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", required: true, }), };