feat(ui): wire SecretBindingPicker into JsonSchemaForm secret-ref fields (#6339)
## 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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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;
|
||||
}) => (
|
||||
<select
|
||||
data-testid="secret-binding-picker"
|
||||
value={value?.secretId ?? ""}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
onChange(next ? { secretId: next } : null);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="">none</option>
|
||||
<option value="11111111-1111-4111-8111-111111111111">existing-secret</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<JsonSchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
},
|
||||
},
|
||||
}}
|
||||
values={{ apiKey: "11111111-1111-4111-8111-111111111111" }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<JsonSchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
},
|
||||
},
|
||||
}}
|
||||
values={{ apiKey: "" }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const picker = container.querySelector<HTMLSelectElement>(
|
||||
'[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(
|
||||
<JsonSchemaForm schema={schema} values={{ apiKey: "" }} onChange={() => {}} />,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('input[type="password"]')).toBeNull();
|
||||
|
||||
// Parent fills in a previously-saved raw value (the async load case).
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={schema}
|
||||
values={{ apiKey: "loaded-from-api" }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>('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(
|
||||
<JsonSchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
},
|
||||
},
|
||||
}}
|
||||
values={{ apiKey: "raw-value" }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'input[type="password"]',
|
||||
);
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.value).toBe("raw-value");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="relative">
|
||||
{isVisible ? (
|
||||
<Textarea
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="min-h-[140px] pr-10 font-mono text-xs"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
// Render a placeholder summary instead of the secret content while
|
||||
// hidden. This avoids exposing multi-line secrets (e.g. SSH
|
||||
// private keys) on screen-shares; clicking the eye toggle reveals
|
||||
// the editable textarea above.
|
||||
value={
|
||||
stringValue.length === 0
|
||||
? ""
|
||||
: `Sensitive — ${stringValue.length} characters hidden. Click the eye to reveal.`
|
||||
}
|
||||
readOnly
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="min-h-[140px] pr-10 font-mono text-xs italic text-muted-foreground"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isVisible ? "Hide secret" : "Show secret"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="pr-10"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isVisible ? "Hide secret" : "Show secret"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<FieldWrapper
|
||||
label={label}
|
||||
description={
|
||||
description ||
|
||||
"This secret is stored securely via the Paperclip secret provider."
|
||||
"Pick an existing company secret, or paste a raw value (Paperclip will store it as a secret on save)."
|
||||
}
|
||||
required={isRequired}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isTextArea ? (
|
||||
<div className="relative">
|
||||
{isVisible ? (
|
||||
<Textarea
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="min-h-[140px] pr-10 font-mono text-xs"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<SecretBindingPicker
|
||||
value={bindingValue}
|
||||
onChange={handlePickerChange}
|
||||
label=""
|
||||
placeholder="Select an existing secret"
|
||||
allowVersionSelector={false}
|
||||
emptyHint="No active secrets yet. Create one or paste a raw value below."
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!isBoundToSecret ? (
|
||||
showRawInput ? (
|
||||
<div className="space-y-1">
|
||||
{rawInput}
|
||||
{!hasRawValue ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setShowRawInput(false);
|
||||
setIsVisible(false);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
Hide raw value input
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
// Render a placeholder summary instead of the secret content while
|
||||
// hidden. This avoids exposing multi-line secrets (e.g. SSH
|
||||
// private keys) on screen-shares; clicking the eye toggle reveals
|
||||
// the editable textarea above.
|
||||
value={
|
||||
String(value ?? "").length === 0
|
||||
? ""
|
||||
: `Sensitive — ${String(value ?? "").length} characters hidden. Click the eye to reveal.`
|
||||
}
|
||||
readOnly
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowRawInput(true)}
|
||||
disabled={disabled}
|
||||
className="min-h-[140px] pr-10 font-mono text-xs italic text-muted-foreground"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isVisible ? "Hide secret" : "Show secret"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
disabled={disabled}
|
||||
className="pr-10"
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isVisible ? "Hide secret" : "Show secret"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
Or paste a raw value
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user