From c0c5a8263dc7e43e6f39bb69504659ed257ac5c7 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 18 May 2026 21:17:41 -0700 Subject: [PATCH] feat(ui): wire SecretBindingPicker into JsonSchemaForm secret-ref fields (#6339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Plugin authors expose configuration via JSON schemas, including secret fields marked `format: "secret-ref"` > - At the same time, Paperclip already has a first-class secrets store, and `SecretBindingPicker` is the canonical UI for binding to one of those stored secrets > - But `JsonSchemaForm`'s `SecretField` rendered only a plain password input, so configuring an E2B (or Modal / Cloudflare / Daytona) sandbox required leaving the form, copying a secret UUID, and pasting it back > - This pull request wires `SecretBindingPicker` into `SecretField` so every plugin secret-ref field gets the picker plus an optional raw-value fallback > - The benefit is that secret reuse becomes one click instead of a tab switch, and the raw-paste path still works for one-off keys or long SSH-style secrets ## What Changed - `ui/src/components/JsonSchemaForm.tsx` `SecretField` now renders `SecretBindingPicker` above the existing password/textarea input. UUID-shaped values are treated as bound refs (no raw input shown). Non-UUID values keep the password/textarea visible (auto-opened) for SSH keys and other long secrets. Empty fields show the picker plus a small "Or paste a raw value" toggle. - Selecting a secret writes the secret UUID to the form value — the server-side resolution in `server/src/services/environment-config.ts` (`resolveConfigSecretRefsForRuntime` / `collectEnvironmentSecretRefs`) is unchanged. The version selector on the picker is suppressed (`allowVersionSelector={false}`) because plugin secret refs always resolve at `"latest"`. - `ui/src/components/JsonSchemaForm.test.tsx` mocks the picker (which requires `CompanyContext` + `QueryClient` providers) and adds coverage for: picker render, UUID-bound state hides the raw input, picker selection writes the UUID through `onChange`, raw text keeps the password fallback. The original multiline (SSH key) case still asserts a textarea + no password input. ## Verification - `pnpm --filter @paperclipai/ui test src/components/JsonSchemaForm.test.tsx` → 4/4 passing - `pnpm --filter @paperclipai/ui test src/pages/PluginSettings.test.tsx` → 5/5 passing (existing consumer of `JsonSchemaForm`) - `pnpm --filter @paperclipai/ui exec tsc --noEmit` → clean - Manual: in the company Environments page, edit an environment with a sandbox driver that exposes a `secret-ref` field (e.g., E2B `apiKey`). The field should render the secret dropdown above the raw-value toggle; selecting an active secret persists its UUID, and saving the form continues to resolve the secret at runtime. Before/after screenshots: deferred — change was validated by [@devinfoley](https://github.com/devinfoley) on the main Paperclip instance before this PR was opened. Happy to add screenshots if a reviewer wants them. ## Risks - Low risk. The change is additive in the SecretField: the raw-value password/textarea path is preserved and auto-opens whenever the stored value is not a UUID, so existing SSH-key entries and unsaved raw values are untouched. - The new heuristic is "if `value` is a UUID, treat it as a bound secret". A user who somehow pasted a UUID as a literal value (not as a secret ref) would now see it rendered as a bound (possibly missing) secret in the picker. The previous UI already treated UUID values as opaque secret refs at save time (server converts UUIDs straight through), so the runtime behavior is unchanged. - Picker pulls company secrets via the existing `secretsApi.list` query. No new endpoints, no migrations. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (`claude-opus-4-7`) - Capabilities: tool use, extended reasoning - Surfaced through: Claude Code via Paperclip heartbeat (issue PAPA-377) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — deferred; user validated locally before opening the PR. Will add if requested. - [x] I have updated relevant documentation to reflect my changes (no docs needed — internal behavior of an existing form field) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/components/JsonSchemaForm.test.tsx | 186 ++++++++++++++++- ui/src/components/JsonSchemaForm.tsx | 235 +++++++++++++++------- 2 files changed, 342 insertions(+), 79 deletions(-) diff --git a/ui/src/components/JsonSchemaForm.test.tsx b/ui/src/components/JsonSchemaForm.test.tsx index 9102bde1..458d2d19 100644 --- a/ui/src/components/JsonSchemaForm.test.tsx +++ b/ui/src/components/JsonSchemaForm.test.tsx @@ -8,6 +8,34 @@ import { JsonSchemaForm } from "./JsonSchemaForm"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +// SecretBindingPicker pulls in CompanyContext + react-query. Stub it so we can +// exercise SecretField in isolation. The stub renders a select with the same +// onChange contract as the real picker. +vi.mock("./SecretBindingPicker", () => ({ + SecretBindingPicker: ({ + value, + onChange, + disabled, + }: { + value: { secretId: string } | null; + onChange: (next: { secretId: string } | null) => void; + disabled?: boolean; + }) => ( + + ), +})); + describe("JsonSchemaForm secret-ref rendering", () => { let container: HTMLDivElement; @@ -22,7 +50,7 @@ describe("JsonSchemaForm secret-ref rendering", () => { vi.clearAllMocks(); }); - it("renders multiline secret-ref fields as textareas", async () => { + it("renders multiline secret-ref fields as textareas alongside the picker", async () => { const root = createRoot(container); await act(async () => { @@ -44,6 +72,9 @@ describe("JsonSchemaForm secret-ref rendering", () => { ); }); + // The picker is always rendered, and a non-UUID raw value auto-opens the + // textarea fallback. + expect(container.querySelector('[data-testid="secret-binding-picker"]')).not.toBeNull(); expect(container.querySelector("textarea")).not.toBeNull(); expect(container.querySelector('input[type="password"]')).toBeNull(); @@ -51,4 +82,157 @@ describe("JsonSchemaForm secret-ref rendering", () => { root.unmount(); }); }); + + it("renders the picker and hides the raw input when the value is a UUID secret ref", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + />, + ); + }); + + expect( + container.querySelector('[data-testid="secret-binding-picker"]'), + ).not.toBeNull(); + // No raw input or textarea is visible while a secret is bound. + expect(container.querySelector('input[type="password"]')).toBeNull(); + expect(container.querySelector("textarea")).toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + + it("writes the secret id to form values when the picker selects an existing secret", async () => { + const root = createRoot(container); + const onChange = vi.fn(); + + await act(async () => { + root.render( + , + ); + }); + + const picker = container.querySelector( + '[data-testid="secret-binding-picker"]', + ); + expect(picker).not.toBeNull(); + + const setSelectValue = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + "value", + )?.set; + expect(setSelectValue).toBeTruthy(); + + await act(async () => { + setSelectValue!.call(picker!, "11111111-1111-4111-8111-111111111111"); + picker!.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith({ + apiKey: "11111111-1111-4111-8111-111111111111", + }); + + await act(async () => { + root.unmount(); + }); + }); + + it("auto-opens the raw input when a raw value arrives after mount", async () => { + const root = createRoot(container); + + const schema = { + type: "object" as const, + properties: { + apiKey: { + type: "string" as const, + format: "secret-ref" as const, + }, + }, + }; + + // First render with empty value — picker visible, no raw input. + await act(async () => { + root.render( + {}} />, + ); + }); + expect(container.querySelector('input[type="password"]')).toBeNull(); + + // Parent fills in a previously-saved raw value (the async load case). + await act(async () => { + root.render( + {}} + />, + ); + }); + + const input = container.querySelector('input[type="password"]'); + expect(input).not.toBeNull(); + expect(input?.value).toBe("loaded-from-api"); + + await act(async () => { + root.unmount(); + }); + }); + + it("keeps the password fallback for short raw values", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + />, + ); + }); + + const input = container.querySelector( + 'input[type="password"]', + ); + expect(input).not.toBeNull(); + expect(input?.value).toBe("raw-value"); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/JsonSchemaForm.tsx b/ui/src/components/JsonSchemaForm.tsx index e2926299..458f5a5f 100644 --- a/ui/src/components/JsonSchemaForm.tsx +++ b/ui/src/components/JsonSchemaForm.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDown, ChevronRight, @@ -7,6 +7,7 @@ import { Plus, Trash2, } from "lucide-react"; +import { isUuidLike } from "@paperclipai/shared"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -20,6 +21,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SecretBindingPicker, type SecretBindingValue } from "./SecretBindingPicker"; // --------------------------------------------------------------------------- // Constants @@ -467,7 +469,10 @@ const EnumField = React.memo(({ EnumField.displayName = "EnumField"; /** - * Specialized field for secret-ref values, providing a toggleable password input. + * Specialized field for secret-ref values. Renders a picker for existing + * company secrets plus a raw-value fallback. A UUID-shaped value is treated + * as a bound secret reference; anything else is a raw value that the server + * converts to a stored secret on save. */ const SecretField = React.memo(({ value, @@ -492,94 +497,168 @@ const SecretField = React.memo(({ }) => { const [isVisible, setIsVisible] = useState(false); const isTextArea = maxLength != null && maxLength > TEXTAREA_THRESHOLD; + + const stringValue = typeof value === "string" ? value : ""; + const trimmed = stringValue.trim(); + const isBoundToSecret = trimmed.length > 0 && isUuidLike(trimmed); + const hasRawValue = stringValue.length > 0 && !isBoundToSecret; + + const [showRawInput, setShowRawInput] = useState(hasRawValue); + + // Keep the raw-input panel open when the parent loads a raw value after + // mount (e.g. an environment-config form rendering with empty defaults + // before its API response arrives). We only promote to `true` here; manual + // toggles off are still preserved as long as `hasRawValue` is false. + useEffect(() => { + if (hasRawValue) setShowRawInput(true); + }, [hasRawValue]); + + const bindingValue: SecretBindingValue | null = isBoundToSecret + ? { secretId: trimmed } + : null; + + const handlePickerChange = useCallback( + (next: SecretBindingValue | null) => { + if (next) { + onChange(next.secretId); + setShowRawInput(false); + setIsVisible(false); + } else { + onChange(""); + } + }, + [onChange], + ); + + const rawInput = isTextArea ? ( +
+ {isVisible ? ( +