From 6d2807ee68cc4fb005bc8501be1bbc83e45cdbc1 Mon Sep 17 00:00:00 2001 From: Scott Sunarto Date: Sun, 9 Aug 2026 16:23:41 -0700 Subject: [PATCH] feat(plugin-sdk): render long-form string settings in a textarea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declarative settings could only render a single-line input, so a plugin with a prose-shaped value — a preamble, a template, a block of custom instructions — had to abandon the descriptor API and ship its own settingsSection surface just to get a textarea. Add `multiline?: true` to the string descriptor. The stored value is still a plain string, so `PluginSettingValue`, the value-coercion paths, secret file storage, and the CLI are all unchanged; only the control changes. The field renders as a textarea on its own full-width row, since a textarea is unreadable in the narrow right-hand column an inline row gives it. A flag rather than a fifth descriptor type: a new `type` would force edits to the CLI's type enum, the two-way guard in plugin-authoring-docs, and every `type === "string"` branch. A flag touches none of them. `multiline` composes with `secret`. The two are orthogonal — `secret` governs storage (0600 file, write-only), `multiline` governs rendering — and the combination is the common case for a PEM key, an SSH key, or service-account JSON. A secret textarea stays write-only, showing the same `[set]`/`[not set]` placeholder as the secret input, and inherits the "empty draft means leave unchanged" filter, which keys off the descriptor rather than the control. It is deliberately not masked: a textarea has no password mode, and hiding a pasted key would obscure the paste without protecting a value the server never sends back. Spellcheck and autofill are disabled instead, so a pasted key reaches no spellcheck service. All three `.strict()` copies of the descriptor union move in lockstep: the real host, the SDK's fake testing host, and the wire DTO. Missing one fails differently — plugin load error, harness divergence, or a silently blank settings form. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/plugin/PluginSettings.test.tsx | 73 +++++++++++++++++++ .../src/components/plugin/PluginSettings.tsx | 28 +++++++ .../src/components/ui/settings-section.tsx | 19 ++++- .../src/services/plugins/plugin-settings.ts | 4 +- .../bb-plugin-authoring/SKILL.md | 42 +++++++---- .../plugins/plugin-settings-storage.test.ts | 67 +++++++++++++++++ .../bundled-types/bb-plugin-sdk.d.ts | 69 ++++++++++-------- packages/plugin-sdk/src/backend-contract.ts | 8 ++ .../src/testing/fake-plugin-host.ts | 2 +- packages/server-contract/src/api/plugins.ts | 1 + .../src/generated/plugin-sdk-dts.generated.ts | 2 +- 11 files changed, 262 insertions(+), 53 deletions(-) diff --git a/apps/app/src/components/plugin/PluginSettings.test.tsx b/apps/app/src/components/plugin/PluginSettings.test.tsx index 68d1ddbdaa..0cafd407bf 100644 --- a/apps/app/src/components/plugin/PluginSettings.test.tsx +++ b/apps/app/src/components/plugin/PluginSettings.test.tsx @@ -97,6 +97,79 @@ describe("PluginSettingsForm", () => { }); }); + it("renders a multiline string in a textarea and saves embedded newlines", async () => { + const view = { + ok: true, + schema: { + preamble: { type: "string", label: "Preamble", multiline: true }, + }, + values: { preamble: "first\nsecond" }, + }; + const requests: RecordedRequest[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + requests.push({ url, init }); + return jsonOk(view); + }), + ); + + const { wrapper } = createQueryClientTestHarness(); + render(, { wrapper }); + + const preamble = (await screen.findByLabelText( + "Preamble", + )) as HTMLTextAreaElement; + expect(preamble.tagName).toBe("TEXTAREA"); + expect(preamble.value).toBe("first\nsecond"); + + fireEvent.change(preamble, { target: { value: "first\nsecond\nthird" } }); + fireEvent.click(screen.getByRole("button", { name: /save settings/i })); + + const put = await vi.waitFor(() => { + const found = requests.find((request) => request.init?.method === "PUT"); + expect(found).toBeDefined(); + return found; + }); + expect(JSON.parse(String(put?.init?.body))).toEqual({ + values: { preamble: "first\nsecond\nthird" }, + }); + }); + + it("renders a secret multiline setting write-only and unmasked", async () => { + const view = { + ok: true, + schema: { + signingKey: { + type: "string", + label: "Signing key", + secret: true, + multiline: true, + }, + }, + values: { signingKey: { set: true } }, + }; + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonOk(view)), + ); + + const { wrapper } = createQueryClientTestHarness(); + render(, { wrapper }); + + const key = (await screen.findByLabelText( + "Signing key", + )) as HTMLTextAreaElement; + expect(key.tagName).toBe("TEXTAREA"); + // Write-only: the server sends `{ set }`, never the value. + expect(key.value).toBe(""); + expect(key.placeholder).toBe("[set]"); + // No password mode exists for a textarea, so browser leaks are closed off + // explicitly instead. + expect(key.getAttribute("spellcheck")).toBe("false"); + expect(key.getAttribute("autocomplete")).toBe("off"); + }); + it("never sends an untouched secret and includes a typed one", async () => { const requests: RecordedRequest[] = []; vi.stubGlobal( diff --git a/apps/app/src/components/plugin/PluginSettings.tsx b/apps/app/src/components/plugin/PluginSettings.tsx index 07f9ff064f..b5c4b2e6b8 100644 --- a/apps/app/src/components/plugin/PluginSettings.tsx +++ b/apps/app/src/components/plugin/PluginSettings.tsx @@ -13,6 +13,7 @@ import { Icon } from "@bb/shared-ui/icon"; import { Input } from "@bb/shared-ui/input"; import { SettingsWithControl } from "@/components/ui/settings-section.js"; import { Switch } from "@bb/shared-ui/switch"; +import { Textarea } from "@bb/shared-ui/textarea"; import { ResourceDetailPanel } from "@bb/shared-ui/resource-list"; import { applyPluginSettingsView } from "@/hooks/cache-owners/plugin-cache-owner"; import { @@ -177,6 +178,28 @@ function PluginSettingField({ : !isSecret && typeof storedValue === "string" ? storedValue : ""; + // A secret textarea is write-only like its input counterpart — the server + // only ever reports `{ set }`, so `value` is empty until the user types. + // It is deliberately not masked: there is no `type="password"` for a + // textarea, and masking a pasted PEM key would hide the one thing worth + // checking without protecting a value that is never rendered back. Browser + // spellcheck and autofill are turned off instead, so a pasted key is not + // shipped to a spellcheck service or offered as an autofill target. + if (descriptor.multiline === true) { + return ( +