paperclip/ui/src/components/JsonSchemaForm.tsx

1181 lines
32 KiB
TypeScript
Raw Normal View History

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>
2026-05-18 21:17:41 -07:00
import React, { useCallback, useEffect, useMemo, useState } from "react";
2026-03-13 16:22:34 -05:00
import {
ChevronDown,
ChevronRight,
Eye,
EyeOff,
Plus,
Trash2,
} from "lucide-react";
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>
2026-05-18 21:17:41 -07:00
import { isUuidLike } from "@paperclipai/shared";
2026-03-13 16:22:34 -05:00
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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>
2026-05-18 21:17:41 -07:00
import { SecretBindingPicker, type SecretBindingValue } from "./SecretBindingPicker";
2026-03-13 16:22:34 -05:00
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
* Threshold for string length above which a Textarea is used instead of a standard Input.
*/
const TEXTAREA_THRESHOLD = 200;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Subset of JSON Schema properties we understand for form rendering.
* We intentionally keep this loose (`Record<string, unknown>`) at the top
* level to match the `JsonSchema` type in shared, but narrow internally.
*/
export interface JsonSchemaNode {
type?: string | string[];
title?: string;
description?: string;
default?: unknown;
enum?: unknown[];
const?: unknown;
format?: string;
// String constraints
minLength?: number;
maxLength?: number;
pattern?: string;
// Number constraints
minimum?: number;
maximum?: number;
exclusiveMinimum?: number;
exclusiveMaximum?: number;
multipleOf?: number;
// Object
properties?: Record<string, JsonSchemaNode>;
required?: string[];
additionalProperties?: boolean | JsonSchemaNode;
// Array
items?: JsonSchemaNode;
minItems?: number;
maxItems?: number;
// Metadata
readOnly?: boolean;
writeOnly?: boolean;
// Allow extra keys
[key: string]: unknown;
}
export interface JsonSchemaFormProps {
/** The JSON Schema to render. */
schema: JsonSchemaNode;
/** Current form values. */
values: Record<string, unknown>;
/** Called whenever any field value changes. */
onChange: (values: Record<string, unknown>) => void;
/** Validation errors keyed by JSON pointer path (e.g. "/apiKey"). */
errors?: Record<string, string>;
/** If true, all fields are disabled. */
disabled?: boolean;
/** Additional CSS class for the root container. */
className?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Resolve the primary type string from a schema node. */
export function resolveType(schema: JsonSchemaNode): string {
if (schema.enum) return "enum";
if (schema.const !== undefined) return "const";
if (schema.format === "secret-ref") return "secret-ref";
if (Array.isArray(schema.type)) {
// Use the first non-null type
return schema.type.find((t) => t !== "null") ?? "string";
}
return schema.type ?? "string";
}
/** Human-readable label from schema title or property key. */
export function labelFromKey(key: string, schema: JsonSchemaNode): string {
if (schema.title) return schema.title;
// Convert camelCase / snake_case to Title Case
return key
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Produce a sensible default value for a schema node. */
export function getDefaultForSchema(schema: JsonSchemaNode): unknown {
if (schema.default !== undefined) return schema.default;
const type = resolveType(schema);
switch (type) {
case "string":
case "secret-ref":
return "";
case "number":
case "integer":
return schema.minimum ?? 0;
case "boolean":
return false;
case "enum":
return schema.enum?.[0] ?? "";
case "array":
return [];
case "object": {
if (!schema.properties) return {};
const obj: Record<string, unknown> = {};
for (const [key, propSchema] of Object.entries(schema.properties)) {
obj[key] = getDefaultForSchema(propSchema);
}
return obj;
}
default:
return "";
}
}
/** Validate a single field value against schema constraints. Returns error string or null. */
export function validateField(
value: unknown,
schema: JsonSchemaNode,
isRequired: boolean,
): string | null {
const type = resolveType(schema);
// Required check
if (isRequired && (value === undefined || value === null || value === "")) {
return "This field is required";
}
// Skip further validation if empty and not required
if (value === undefined || value === null || value === "") return null;
if (type === "string" || type === "secret-ref") {
const str = String(value);
if (schema.minLength != null && str.length < schema.minLength) {
return `Must be at least ${schema.minLength} characters`;
}
if (schema.maxLength != null && str.length > schema.maxLength) {
return `Must be at most ${schema.maxLength} characters`;
}
if (schema.pattern) {
// Guard against ReDoS: reject overly complex patterns from plugin JSON Schemas.
// Limit pattern length and run the regex with a defensive try/catch.
const MAX_PATTERN_LENGTH = 512;
if (schema.pattern.length <= MAX_PATTERN_LENGTH) {
try {
const re = new RegExp(schema.pattern);
if (!re.test(str)) {
return `Must match pattern: ${schema.pattern}`;
}
} catch {
// Invalid regex in schema — skip
}
}
}
}
if (type === "number" || type === "integer") {
const num = Number(value);
if (isNaN(num)) return "Must be a valid number";
if (schema.minimum != null && num < schema.minimum) {
return `Must be at least ${schema.minimum}`;
}
if (schema.maximum != null && num > schema.maximum) {
return `Must be at most ${schema.maximum}`;
}
if (schema.exclusiveMinimum != null && num <= schema.exclusiveMinimum) {
return `Must be greater than ${schema.exclusiveMinimum}`;
}
if (schema.exclusiveMaximum != null && num >= schema.exclusiveMaximum) {
return `Must be less than ${schema.exclusiveMaximum}`;
}
if (type === "integer" && !Number.isInteger(num)) {
return "Must be a whole number";
}
if (schema.multipleOf != null && num % schema.multipleOf !== 0) {
return `Must be a multiple of ${schema.multipleOf}`;
}
}
if (type === "array") {
const arr = value as unknown[];
if (schema.minItems != null && arr.length < schema.minItems) {
return `Must have at least ${schema.minItems} items`;
}
if (schema.maxItems != null && arr.length > schema.maxItems) {
return `Must have at most ${schema.maxItems} items`;
}
}
return null;
}
/** Public API for validation */
export function validateJsonSchemaForm(
schema: JsonSchemaNode,
values: Record<string, unknown>,
path: string[] = [],
): Record<string, string> {
const errors: Record<string, string> = {};
const properties = schema.properties ?? {};
const requiredFields = new Set(schema.required ?? []);
for (const [key, propSchema] of Object.entries(properties)) {
const fieldPath = [...path, key];
const errorKey = `/${fieldPath.join("/")}`;
const value = values[key];
const isRequired = requiredFields.has(key);
const type = resolveType(propSchema);
// Per-field validation
const fieldErr = validateField(value, propSchema, isRequired);
if (fieldErr) {
errors[errorKey] = fieldErr;
}
// Recurse into objects
if (type === "object" && propSchema.properties && typeof value === "object" && value !== null) {
Object.assign(
errors,
validateJsonSchemaForm(propSchema, value as Record<string, unknown>, fieldPath),
);
}
// Recurse into arrays
if (type === "array" && propSchema.items && Array.isArray(value)) {
const itemSchema = propSchema.items as JsonSchemaNode;
const isObjectItem = resolveType(itemSchema) === "object";
value.forEach((item, index) => {
const itemPath = [...fieldPath, String(index)];
const itemErrorKey = `/${itemPath.join("/")}`;
if (isObjectItem) {
Object.assign(
errors,
validateJsonSchemaForm(
itemSchema,
item as Record<string, unknown>,
itemPath,
),
);
} else {
const itemErr = validateField(item, itemSchema, false);
if (itemErr) {
errors[itemErrorKey] = itemErr;
}
}
});
}
}
return errors;
}
/** Public API for default values */
export function getDefaultValues(schema: JsonSchemaNode): Record<string, unknown> {
const result: Record<string, unknown> = {};
const properties = schema.properties ?? {};
for (const [key, propSchema] of Object.entries(properties)) {
const def = getDefaultForSchema(propSchema);
if (def !== undefined) {
result[key] = def;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Internal Components
// ---------------------------------------------------------------------------
interface FieldWrapperProps {
label: string;
description?: string;
required?: boolean;
error?: string;
disabled?: boolean;
children: React.ReactNode;
}
/**
* Common wrapper for form fields that handles labels, descriptions, and error messages.
*/
const FieldWrapper = React.memo(({
label,
description,
required,
error,
disabled,
children,
}: FieldWrapperProps) => {
return (
<div className={cn("space-y-2", disabled && "opacity-60")}>
<div className="flex items-center justify-between">
{label && (
<Label className="text-sm font-medium">
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
</div>
{children}
{description && (
<p className="text-[12px] text-muted-foreground leading-relaxed">
{description}
</p>
)}
{error && (
<p className="text-[12px] font-medium text-destructive">{error}</p>
)}
</div>
);
});
FieldWrapper.displayName = "FieldWrapper";
interface FormFieldProps {
propSchema: JsonSchemaNode;
value: unknown;
onChange: (val: unknown) => void;
error?: string;
disabled?: boolean;
label: string;
isRequired?: boolean;
errors: Record<string, string>; // needed for recursion
path: string; // needed for recursion error filtering
}
/**
* Specialized field for boolean (checkbox) values.
*/
const BooleanField = React.memo(({
id,
value,
onChange,
disabled,
label,
isRequired,
description,
error,
}: {
id: string;
value: unknown;
onChange: (val: unknown) => void;
disabled: boolean;
label: string;
isRequired?: boolean;
description?: string;
error?: string;
}) => (
<div className="flex items-start space-x-3 space-y-0">
<Checkbox
id={id}
checked={!!value}
onCheckedChange={onChange}
disabled={disabled}
/>
<div className="grid gap-1.5 leading-none">
{label && (
<Label
htmlFor={id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
{isRequired && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
{error && (
<p className="text-xs font-medium text-destructive">{error}</p>
)}
</div>
</div>
));
BooleanField.displayName = "BooleanField";
/**
* Specialized field for enum (select) values.
*/
const EnumField = React.memo(({
value,
onChange,
disabled,
label,
isRequired,
description,
error,
options,
}: {
value: unknown;
onChange: (val: unknown) => void;
disabled: boolean;
label: string;
isRequired?: boolean;
description?: string;
error?: string;
options: unknown[];
}) => (
<FieldWrapper
label={label}
description={description}
required={isRequired}
error={error}
disabled={disabled}
>
<Select
value={String(value ?? "")}
onValueChange={onChange}
disabled={disabled}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={String(option)} value={String(option)}>
{String(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldWrapper>
));
EnumField.displayName = "EnumField";
/**
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>
2026-05-18 21:17:41 -07:00
* 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.
2026-03-13 16:22:34 -05:00
*/
const SecretField = React.memo(({
value,
onChange,
disabled,
label,
isRequired,
description,
error,
defaultValue,
Add exe.dev sandbox provider plugin (#5688) > _Stacked on top of #5685 → #5686 → #5687. Diff against master includes commits from earlier PRs in the stack — review focuses on the two new commits (`Add long-secret textarea variant to JsonSchemaForm SecretField` + `Add exe.dev sandbox provider plugin`)._ ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Each agent runs in a sandbox environment, and operators choose the provider — today E2B, Daytona, and (in this stack) Cloudflare > - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful for operators who want full Linux VMs (vs container/runtime-only sandboxes) > - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`, `ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access for adapters that need it > - exe.dev VMs come up bare — `node` is not preinstalled, so the Paperclip sandbox callback bridge (a Node script) needs Node 20 installed at VM init via `--setup-script`. The plugin defaults the setup script to a Nodesource install > - The auth field accepts long SSH private keys, which need a textarea variant of the existing `SecretField` in `JsonSchemaForm` — added behind a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected > - The benefit is that operators get exe.dev as a fully working sandbox provider out of the box, with no manual VM provisioning required ## What Changed **Shared UI support (`Add long-secret textarea variant to JsonSchemaForm SecretField`):** - `ui/src/components/JsonSchemaForm.tsx` + new `JsonSchemaForm.test.tsx`: when a secret-formatted field declares `maxLength` larger than the existing single-line threshold, render a monospace textarea instead of the masked input. Short secrets (API keys, tokens) keep the existing masked-input + show/hide toggle behavior. **The exe.dev plugin (`Add exe.dev sandbox provider plugin`):** - `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest, plugin runtime, README, and 19-test Vitest suite. - Manifest fields: API token (with `secret-ref` + `/exec` permission notes — needs `new`, `ls`, `rm`), API URL override, optional SSH username, optional SSH private key (uses the new `JsonSchemaForm` textarea variant via `maxLength: 4096`), optional SSH identity-file path, optional setup script. - Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs come up bare and the Paperclip sandbox callback bridge is a Node script, so without Node preinstalled the bridge can't start. Operators can override by supplying their own setup script. - `runLifecycleCommand` redacts env values from the executed command before surfacing it in error messages, so secrets passed via `--env=KEY=VALUE` don't leak into operator-visible failures. - The plugin distinguishes exe.dev's SSH onboarding failures (`Please complete registration by running: ssh exe.dev`) from general SSH failures and surfaces a clear remediation message. - `scripts/release-package-manifest.json`: register the new plugin for CI publish alongside the existing daytona / e2b providers. ## Verification - `pnpm typecheck` - `pnpm exec vitest run --no-coverage ui/src/components/JsonSchemaForm.test.tsx` - `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19 passing For an operator-side smoke test: 1. Get an exe.dev API token with `/exec` permission for `new`, `ls`, `rm`. 2. Register the plugin in your Paperclip instance, configure an environment with the token. 3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or Claude job against it. The default Node 20 setup script should bring the VM up automatically. ## Risks - Adds a new sandbox provider plugin that follows the existing daytona / e2b shape; behavior on existing providers is unchanged. - The `JsonSchemaForm` textarea variant only engages for fields that opt in via `maxLength` larger than the existing threshold. All existing secret fields (which don't declare a `maxLength`) keep their current rendering. Test coverage pins both paths. - The redaction in `runLifecycleCommand` is a defense-in-depth measure; the test suite exercises the redaction path. If the redaction misses a future env-arg shape, the worst case is restored behavior (secrets in error messages), which is what the existing daytona / e2b plugins also do today. - Default setup script downloads from `deb.nodesource.com` over HTTPS at VM init. Operators on air-gapped networks or with a different package strategy can override the setup script. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (1M context) - Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep) ## 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 — UI change is a textarea variant of an existing secret field; will attach screenshots before requesting merge - [x] I have updated relevant documentation to reflect my changes (plugin README, manifest descriptions) - [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>
2026-05-11 07:42:18 -07:00
maxLength,
2026-03-13 16:22:34 -05:00
}: {
value: unknown;
onChange: (val: unknown) => void;
disabled: boolean;
label: string;
isRequired?: boolean;
description?: string;
error?: string;
defaultValue?: unknown;
Add exe.dev sandbox provider plugin (#5688) > _Stacked on top of #5685 → #5686 → #5687. Diff against master includes commits from earlier PRs in the stack — review focuses on the two new commits (`Add long-secret textarea variant to JsonSchemaForm SecretField` + `Add exe.dev sandbox provider plugin`)._ ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Each agent runs in a sandbox environment, and operators choose the provider — today E2B, Daytona, and (in this stack) Cloudflare > - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful for operators who want full Linux VMs (vs container/runtime-only sandboxes) > - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`, `ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access for adapters that need it > - exe.dev VMs come up bare — `node` is not preinstalled, so the Paperclip sandbox callback bridge (a Node script) needs Node 20 installed at VM init via `--setup-script`. The plugin defaults the setup script to a Nodesource install > - The auth field accepts long SSH private keys, which need a textarea variant of the existing `SecretField` in `JsonSchemaForm` — added behind a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected > - The benefit is that operators get exe.dev as a fully working sandbox provider out of the box, with no manual VM provisioning required ## What Changed **Shared UI support (`Add long-secret textarea variant to JsonSchemaForm SecretField`):** - `ui/src/components/JsonSchemaForm.tsx` + new `JsonSchemaForm.test.tsx`: when a secret-formatted field declares `maxLength` larger than the existing single-line threshold, render a monospace textarea instead of the masked input. Short secrets (API keys, tokens) keep the existing masked-input + show/hide toggle behavior. **The exe.dev plugin (`Add exe.dev sandbox provider plugin`):** - `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest, plugin runtime, README, and 19-test Vitest suite. - Manifest fields: API token (with `secret-ref` + `/exec` permission notes — needs `new`, `ls`, `rm`), API URL override, optional SSH username, optional SSH private key (uses the new `JsonSchemaForm` textarea variant via `maxLength: 4096`), optional SSH identity-file path, optional setup script. - Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs come up bare and the Paperclip sandbox callback bridge is a Node script, so without Node preinstalled the bridge can't start. Operators can override by supplying their own setup script. - `runLifecycleCommand` redacts env values from the executed command before surfacing it in error messages, so secrets passed via `--env=KEY=VALUE` don't leak into operator-visible failures. - The plugin distinguishes exe.dev's SSH onboarding failures (`Please complete registration by running: ssh exe.dev`) from general SSH failures and surfaces a clear remediation message. - `scripts/release-package-manifest.json`: register the new plugin for CI publish alongside the existing daytona / e2b providers. ## Verification - `pnpm typecheck` - `pnpm exec vitest run --no-coverage ui/src/components/JsonSchemaForm.test.tsx` - `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19 passing For an operator-side smoke test: 1. Get an exe.dev API token with `/exec` permission for `new`, `ls`, `rm`. 2. Register the plugin in your Paperclip instance, configure an environment with the token. 3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or Claude job against it. The default Node 20 setup script should bring the VM up automatically. ## Risks - Adds a new sandbox provider plugin that follows the existing daytona / e2b shape; behavior on existing providers is unchanged. - The `JsonSchemaForm` textarea variant only engages for fields that opt in via `maxLength` larger than the existing threshold. All existing secret fields (which don't declare a `maxLength`) keep their current rendering. Test coverage pins both paths. - The redaction in `runLifecycleCommand` is a defense-in-depth measure; the test suite exercises the redaction path. If the redaction misses a future env-arg shape, the worst case is restored behavior (secrets in error messages), which is what the existing daytona / e2b plugins also do today. - Default setup script downloads from `deb.nodesource.com` over HTTPS at VM init. Operators on air-gapped networks or with a different package strategy can override the setup script. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (1M context) - Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep) ## 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 — UI change is a textarea variant of an existing secret field; will attach screenshots before requesting merge - [x] I have updated relevant documentation to reflect my changes (plugin README, manifest descriptions) - [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>
2026-05-11 07:42:18 -07:00
maxLength?: number;
2026-03-13 16:22:34 -05:00
}) => {
const [isVisible, setIsVisible] = useState(false);
Add exe.dev sandbox provider plugin (#5688) > _Stacked on top of #5685 → #5686 → #5687. Diff against master includes commits from earlier PRs in the stack — review focuses on the two new commits (`Add long-secret textarea variant to JsonSchemaForm SecretField` + `Add exe.dev sandbox provider plugin`)._ ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Each agent runs in a sandbox environment, and operators choose the provider — today E2B, Daytona, and (in this stack) Cloudflare > - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful for operators who want full Linux VMs (vs container/runtime-only sandboxes) > - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`, `ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access for adapters that need it > - exe.dev VMs come up bare — `node` is not preinstalled, so the Paperclip sandbox callback bridge (a Node script) needs Node 20 installed at VM init via `--setup-script`. The plugin defaults the setup script to a Nodesource install > - The auth field accepts long SSH private keys, which need a textarea variant of the existing `SecretField` in `JsonSchemaForm` — added behind a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected > - The benefit is that operators get exe.dev as a fully working sandbox provider out of the box, with no manual VM provisioning required ## What Changed **Shared UI support (`Add long-secret textarea variant to JsonSchemaForm SecretField`):** - `ui/src/components/JsonSchemaForm.tsx` + new `JsonSchemaForm.test.tsx`: when a secret-formatted field declares `maxLength` larger than the existing single-line threshold, render a monospace textarea instead of the masked input. Short secrets (API keys, tokens) keep the existing masked-input + show/hide toggle behavior. **The exe.dev plugin (`Add exe.dev sandbox provider plugin`):** - `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest, plugin runtime, README, and 19-test Vitest suite. - Manifest fields: API token (with `secret-ref` + `/exec` permission notes — needs `new`, `ls`, `rm`), API URL override, optional SSH username, optional SSH private key (uses the new `JsonSchemaForm` textarea variant via `maxLength: 4096`), optional SSH identity-file path, optional setup script. - Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs come up bare and the Paperclip sandbox callback bridge is a Node script, so without Node preinstalled the bridge can't start. Operators can override by supplying their own setup script. - `runLifecycleCommand` redacts env values from the executed command before surfacing it in error messages, so secrets passed via `--env=KEY=VALUE` don't leak into operator-visible failures. - The plugin distinguishes exe.dev's SSH onboarding failures (`Please complete registration by running: ssh exe.dev`) from general SSH failures and surfaces a clear remediation message. - `scripts/release-package-manifest.json`: register the new plugin for CI publish alongside the existing daytona / e2b providers. ## Verification - `pnpm typecheck` - `pnpm exec vitest run --no-coverage ui/src/components/JsonSchemaForm.test.tsx` - `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19 passing For an operator-side smoke test: 1. Get an exe.dev API token with `/exec` permission for `new`, `ls`, `rm`. 2. Register the plugin in your Paperclip instance, configure an environment with the token. 3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or Claude job against it. The default Node 20 setup script should bring the VM up automatically. ## Risks - Adds a new sandbox provider plugin that follows the existing daytona / e2b shape; behavior on existing providers is unchanged. - The `JsonSchemaForm` textarea variant only engages for fields that opt in via `maxLength` larger than the existing threshold. All existing secret fields (which don't declare a `maxLength`) keep their current rendering. Test coverage pins both paths. - The redaction in `runLifecycleCommand` is a defense-in-depth measure; the test suite exercises the redaction path. If the redaction misses a future env-arg shape, the worst case is restored behavior (secrets in error messages), which is what the existing daytona / e2b plugins also do today. - Default setup script downloads from `deb.nodesource.com` over HTTPS at VM init. Operators on air-gapped networks or with a different package strategy can override the setup script. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (1M context) - Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep) ## 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 — UI change is a textarea variant of an existing secret field; will attach screenshots before requesting merge - [x] I have updated relevant documentation to reflect my changes (plugin README, manifest descriptions) - [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>
2026-05-11 07:42:18 -07:00
const isTextArea = maxLength != null && maxLength > TEXTAREA_THRESHOLD;
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>
2026-05-18 21:17:41 -07:00
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>
);
2026-03-13 16:22:34 -05:00
return (
<FieldWrapper
label={label}
description={
description ||
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>
2026-05-18 21:17:41 -07:00
"Pick an existing company secret, or paste a raw value (Paperclip will store it as a secret on save)."
2026-03-13 16:22:34 -05:00
}
required={isRequired}
error={error}
disabled={disabled}
>
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>
2026-05-18 21:17:41 -07:00
<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>
2026-03-13 16:22:34 -05:00
) : (
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>
2026-05-18 21:17:41 -07:00
<button
type="button"
className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => setShowRawInput(true)}
Add exe.dev sandbox provider plugin (#5688) > _Stacked on top of #5685 → #5686 → #5687. Diff against master includes commits from earlier PRs in the stack — review focuses on the two new commits (`Add long-secret textarea variant to JsonSchemaForm SecretField` + `Add exe.dev sandbox provider plugin`)._ ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Each agent runs in a sandbox environment, and operators choose the provider — today E2B, Daytona, and (in this stack) Cloudflare > - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful for operators who want full Linux VMs (vs container/runtime-only sandboxes) > - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`, `ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access for adapters that need it > - exe.dev VMs come up bare — `node` is not preinstalled, so the Paperclip sandbox callback bridge (a Node script) needs Node 20 installed at VM init via `--setup-script`. The plugin defaults the setup script to a Nodesource install > - The auth field accepts long SSH private keys, which need a textarea variant of the existing `SecretField` in `JsonSchemaForm` — added behind a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected > - The benefit is that operators get exe.dev as a fully working sandbox provider out of the box, with no manual VM provisioning required ## What Changed **Shared UI support (`Add long-secret textarea variant to JsonSchemaForm SecretField`):** - `ui/src/components/JsonSchemaForm.tsx` + new `JsonSchemaForm.test.tsx`: when a secret-formatted field declares `maxLength` larger than the existing single-line threshold, render a monospace textarea instead of the masked input. Short secrets (API keys, tokens) keep the existing masked-input + show/hide toggle behavior. **The exe.dev plugin (`Add exe.dev sandbox provider plugin`):** - `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest, plugin runtime, README, and 19-test Vitest suite. - Manifest fields: API token (with `secret-ref` + `/exec` permission notes — needs `new`, `ls`, `rm`), API URL override, optional SSH username, optional SSH private key (uses the new `JsonSchemaForm` textarea variant via `maxLength: 4096`), optional SSH identity-file path, optional setup script. - Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs come up bare and the Paperclip sandbox callback bridge is a Node script, so without Node preinstalled the bridge can't start. Operators can override by supplying their own setup script. - `runLifecycleCommand` redacts env values from the executed command before surfacing it in error messages, so secrets passed via `--env=KEY=VALUE` don't leak into operator-visible failures. - The plugin distinguishes exe.dev's SSH onboarding failures (`Please complete registration by running: ssh exe.dev`) from general SSH failures and surfaces a clear remediation message. - `scripts/release-package-manifest.json`: register the new plugin for CI publish alongside the existing daytona / e2b providers. ## Verification - `pnpm typecheck` - `pnpm exec vitest run --no-coverage ui/src/components/JsonSchemaForm.test.tsx` - `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19 passing For an operator-side smoke test: 1. Get an exe.dev API token with `/exec` permission for `new`, `ls`, `rm`. 2. Register the plugin in your Paperclip instance, configure an environment with the token. 3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or Claude job against it. The default Node 20 setup script should bring the VM up automatically. ## Risks - Adds a new sandbox provider plugin that follows the existing daytona / e2b shape; behavior on existing providers is unchanged. - The `JsonSchemaForm` textarea variant only engages for fields that opt in via `maxLength` larger than the existing threshold. All existing secret fields (which don't declare a `maxLength`) keep their current rendering. Test coverage pins both paths. - The redaction in `runLifecycleCommand` is a defense-in-depth measure; the test suite exercises the redaction path. If the redaction misses a future env-arg shape, the worst case is restored behavior (secrets in error messages), which is what the existing daytona / e2b plugins also do today. - Default setup script downloads from `deb.nodesource.com` over HTTPS at VM init. Operators on air-gapped networks or with a different package strategy can override the setup script. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (1M context) - Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep) ## 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 — UI change is a textarea variant of an existing secret field; will attach screenshots before requesting merge - [x] I have updated relevant documentation to reflect my changes (plugin README, manifest descriptions) - [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>
2026-05-11 07:42:18 -07:00
disabled={disabled}
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>
2026-05-18 21:17:41 -07:00
>
Or paste a raw value
</button>
)
) : null}
</div>
2026-03-13 16:22:34 -05:00
</FieldWrapper>
);
});
SecretField.displayName = "SecretField";
/**
* Specialized field for numeric (number/integer) values.
*/
const NumberField = React.memo(({
value,
onChange,
disabled,
label,
isRequired,
description,
error,
defaultValue,
type,
}: {
value: unknown;
onChange: (val: unknown) => void;
disabled: boolean;
label: string;
isRequired?: boolean;
description?: string;
error?: string;
defaultValue?: unknown;
type: "number" | "integer";
}) => (
<FieldWrapper
label={label}
description={description}
required={isRequired}
error={error}
disabled={disabled}
>
<Input
type="number"
step={type === "integer" ? "1" : "any"}
value={value !== undefined ? String(value) : ""}
onChange={(e) => {
const val = e.target.value;
onChange(val === "" ? undefined : Number(val));
}}
placeholder={String(defaultValue ?? "")}
disabled={disabled}
aria-invalid={!!error}
/>
</FieldWrapper>
));
NumberField.displayName = "NumberField";
/**
* Specialized field for string values, rendering either an Input or Textarea based on length or format.
*/
const StringField = React.memo(({
value,
onChange,
disabled,
label,
isRequired,
description,
error,
defaultValue,
format,
maxLength,
}: {
value: unknown;
onChange: (val: unknown) => void;
disabled: boolean;
label: string;
isRequired?: boolean;
description?: string;
error?: string;
defaultValue?: unknown;
format?: string;
maxLength?: number;
}) => {
const isTextArea = format === "textarea" || (maxLength && maxLength > TEXTAREA_THRESHOLD);
return (
<FieldWrapper
label={label}
description={description}
required={isRequired}
error={error}
disabled={disabled}
>
{isTextArea ? (
<Textarea
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={String(defaultValue ?? "")}
disabled={disabled}
className="min-h-[100px]"
aria-invalid={!!error}
/>
) : (
<Input
type="text"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={String(defaultValue ?? "")}
disabled={disabled}
aria-invalid={!!error}
/>
)}
</FieldWrapper>
);
});
StringField.displayName = "StringField";
/**
* Specialized field for array values, handling dynamic addition and removal of items.
*/
const ArrayField = React.memo(({
propSchema,
value,
onChange,
error,
disabled,
label,
errors,
path,
}: {
propSchema: JsonSchemaNode;
value: unknown;
onChange: (val: unknown) => void;
error?: string;
disabled: boolean;
label: string;
errors: Record<string, string>;
path: string;
}) => {
const items = Array.isArray(value) ? value : [];
const itemSchema = propSchema.items as JsonSchemaNode;
const isComplex = resolveType(itemSchema) === "object";
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">{label}</Label>
{propSchema.description && (
<p className="text-xs text-muted-foreground">
{propSchema.description}
</p>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={
disabled ||
(propSchema.maxItems !== undefined &&
items.length >= (propSchema.maxItems as number))
}
onClick={() => {
const newItem = getDefaultForSchema(itemSchema);
onChange([...items, newItem]);
}}
>
<Plus className="mr-2 h-4 w-4" />
{isComplex ? "Add item" : "Add"}
</Button>
</div>
<div className="space-y-3">
{items.map((item, index) => (
<div
key={index}
className="group relative flex items-start space-x-2 rounded-lg border p-3"
>
<div className="flex-1">
<div className="mb-2 text-xs font-medium text-muted-foreground">
Item {index + 1}
</div>
<FormField
propSchema={itemSchema}
value={item}
label=""
path={`${path}/${index}`}
onChange={(newVal) => {
const newItems = [...items];
newItems[index] = newVal;
onChange(newItems);
}}
disabled={disabled}
errors={errors}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={
disabled ||
(propSchema.minItems !== undefined &&
items.length <= (propSchema.minItems as number))
}
onClick={() => {
const newItems = [...items];
newItems.splice(index, 1);
onChange(newItems);
}}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove item</span>
</Button>
</div>
))}
{items.length === 0 && (
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
No items added yet.
</div>
)}
</div>
{error && (
<p className="text-xs font-medium text-destructive">{error}</p>
)}
</div>
);
});
ArrayField.displayName = "ArrayField";
/**
* Specialized field for object values, handling recursive rendering of nested properties.
*/
const ObjectField = React.memo(({
propSchema,
value,
onChange,
disabled,
label,
errors,
path,
}: {
propSchema: JsonSchemaNode;
value: unknown;
onChange: (val: unknown) => void;
disabled: boolean;
label: string;
errors: Record<string, string>;
path: string;
}) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const handleObjectChange = (newVal: Record<string, unknown>) => {
onChange(newVal);
};
return (
<div className="space-y-3 rounded-lg border p-4">
<button
type="button"
className="flex w-full items-center justify-between"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className="text-left">
<Label className="cursor-pointer text-sm font-semibold">
{label}
</Label>
{propSchema.description && (
<p className="text-xs text-muted-foreground">
{propSchema.description}
</p>
)}
</div>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{!isCollapsed && (
<div className="pt-2">
<JsonSchemaForm
schema={propSchema}
values={(value as Record<string, unknown>) ?? {}}
onChange={handleObjectChange}
disabled={disabled}
errors={Object.fromEntries(
Object.entries(errors)
.filter(([errPath]) => errPath.startsWith(`${path}/`))
.map(([errPath, err]) => [errPath.replace(path, ""), err]),
)}
/>
</div>
)}
</div>
);
});
ObjectField.displayName = "ObjectField";
/**
* Orchestrator component that selects and renders the appropriate field type based on the schema node.
*/
const FormField = React.memo(({
propSchema,
value,
onChange,
error,
disabled,
label,
isRequired,
errors,
path,
}: FormFieldProps) => {
const type = resolveType(propSchema);
const isReadOnly = disabled || propSchema.readOnly === true;
switch (type) {
case "boolean":
return (
<BooleanField
id={path}
value={value}
onChange={onChange}
disabled={isReadOnly}
label={label}
isRequired={isRequired}
description={propSchema.description}
error={error}
/>
);
case "enum":
return (
<EnumField
value={value}
onChange={onChange}
disabled={isReadOnly}
label={label}
isRequired={isRequired}
description={propSchema.description}
error={error}
options={propSchema.enum ?? []}
/>
);
case "secret-ref":
return (
<SecretField
value={value}
onChange={onChange}
disabled={isReadOnly}
label={label}
isRequired={isRequired}
description={propSchema.description}
error={error}
defaultValue={propSchema.default}
Add exe.dev sandbox provider plugin (#5688) > _Stacked on top of #5685 → #5686 → #5687. Diff against master includes commits from earlier PRs in the stack — review focuses on the two new commits (`Add long-secret textarea variant to JsonSchemaForm SecretField` + `Add exe.dev sandbox provider plugin`)._ ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Each agent runs in a sandbox environment, and operators choose the provider — today E2B, Daytona, and (in this stack) Cloudflare > - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful for operators who want full Linux VMs (vs container/runtime-only sandboxes) > - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`, `ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access for adapters that need it > - exe.dev VMs come up bare — `node` is not preinstalled, so the Paperclip sandbox callback bridge (a Node script) needs Node 20 installed at VM init via `--setup-script`. The plugin defaults the setup script to a Nodesource install > - The auth field accepts long SSH private keys, which need a textarea variant of the existing `SecretField` in `JsonSchemaForm` — added behind a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected > - The benefit is that operators get exe.dev as a fully working sandbox provider out of the box, with no manual VM provisioning required ## What Changed **Shared UI support (`Add long-secret textarea variant to JsonSchemaForm SecretField`):** - `ui/src/components/JsonSchemaForm.tsx` + new `JsonSchemaForm.test.tsx`: when a secret-formatted field declares `maxLength` larger than the existing single-line threshold, render a monospace textarea instead of the masked input. Short secrets (API keys, tokens) keep the existing masked-input + show/hide toggle behavior. **The exe.dev plugin (`Add exe.dev sandbox provider plugin`):** - `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest, plugin runtime, README, and 19-test Vitest suite. - Manifest fields: API token (with `secret-ref` + `/exec` permission notes — needs `new`, `ls`, `rm`), API URL override, optional SSH username, optional SSH private key (uses the new `JsonSchemaForm` textarea variant via `maxLength: 4096`), optional SSH identity-file path, optional setup script. - Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs come up bare and the Paperclip sandbox callback bridge is a Node script, so without Node preinstalled the bridge can't start. Operators can override by supplying their own setup script. - `runLifecycleCommand` redacts env values from the executed command before surfacing it in error messages, so secrets passed via `--env=KEY=VALUE` don't leak into operator-visible failures. - The plugin distinguishes exe.dev's SSH onboarding failures (`Please complete registration by running: ssh exe.dev`) from general SSH failures and surfaces a clear remediation message. - `scripts/release-package-manifest.json`: register the new plugin for CI publish alongside the existing daytona / e2b providers. ## Verification - `pnpm typecheck` - `pnpm exec vitest run --no-coverage ui/src/components/JsonSchemaForm.test.tsx` - `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19 passing For an operator-side smoke test: 1. Get an exe.dev API token with `/exec` permission for `new`, `ls`, `rm`. 2. Register the plugin in your Paperclip instance, configure an environment with the token. 3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or Claude job against it. The default Node 20 setup script should bring the VM up automatically. ## Risks - Adds a new sandbox provider plugin that follows the existing daytona / e2b shape; behavior on existing providers is unchanged. - The `JsonSchemaForm` textarea variant only engages for fields that opt in via `maxLength` larger than the existing threshold. All existing secret fields (which don't declare a `maxLength`) keep their current rendering. Test coverage pins both paths. - The redaction in `runLifecycleCommand` is a defense-in-depth measure; the test suite exercises the redaction path. If the redaction misses a future env-arg shape, the worst case is restored behavior (secrets in error messages), which is what the existing daytona / e2b plugins also do today. - Default setup script downloads from `deb.nodesource.com` over HTTPS at VM init. Operators on air-gapped networks or with a different package strategy can override the setup script. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (1M context) - Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep) ## 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 — UI change is a textarea variant of an existing secret field; will attach screenshots before requesting merge - [x] I have updated relevant documentation to reflect my changes (plugin README, manifest descriptions) - [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>
2026-05-11 07:42:18 -07:00
maxLength={typeof propSchema.maxLength === "number" ? propSchema.maxLength : undefined}
2026-03-13 16:22:34 -05:00
/>
);
case "number":
case "integer":
return (
<NumberField
value={value}
onChange={onChange}
disabled={isReadOnly}
label={label}
isRequired={isRequired}
description={propSchema.description}
error={error}
defaultValue={propSchema.default}
type={type as "number" | "integer"}
/>
);
case "array":
return (
<ArrayField
propSchema={propSchema}
value={value}
onChange={onChange}
error={error}
disabled={isReadOnly}
label={label}
errors={errors}
path={path}
/>
);
case "object":
return (
<ObjectField
propSchema={propSchema}
value={value}
onChange={onChange}
disabled={isReadOnly}
label={label}
errors={errors}
path={path}
/>
);
default: // string
return (
<StringField
value={value}
onChange={onChange}
disabled={isReadOnly}
label={label}
isRequired={isRequired}
description={propSchema.description}
error={error}
defaultValue={propSchema.default}
format={propSchema.format}
maxLength={propSchema.maxLength}
/>
);
}
});
FormField.displayName = "FormField";
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
/**
* Main JsonSchemaForm component.
* Renders a form based on a subset of JSON Schema specification.
* Supports primitive types, enums, secrets, objects, and arrays with recursion.
*/
export function JsonSchemaForm({
schema,
values,
onChange,
errors = {},
disabled,
className,
}: JsonSchemaFormProps) {
const type = resolveType(schema);
const handleRootScalarChange = useCallback((newVal: unknown) => {
// If root is a scalar, values IS the value
onChange(newVal as Record<string, unknown>);
}, [onChange]);
// If it's a scalar at root, render a single FormField
if (type !== "object") {
return (
<div className={className}>
<FormField
propSchema={schema}
value={values}
label=""
path=""
onChange={handleRootScalarChange}
disabled={disabled}
errors={errors}
/>
</div>
);
}
// Memoize to avoid re-renders when parent provides new object references
const properties = useMemo(() => schema.properties ?? {}, [schema.properties]);
const requiredFields = useMemo(
() => new Set(schema.required ?? []),
[schema.required],
);
const handleFieldChange = useCallback(
(key: string, value: unknown) => {
onChange({ ...values, [key]: value });
},
[onChange, values],
);
if (Object.keys(properties).length === 0) {
return (
<div
className={cn(
"py-4 text-center text-sm text-muted-foreground",
className,
)}
>
No configuration options available.
</div>
);
}
return (
<div className={cn("space-y-6", className)}>
{Object.entries(properties).map(([key, propSchema]) => {
const value = values[key];
const isRequired = requiredFields.has(key);
const error = errors[`/${key}`];
const label = labelFromKey(key, propSchema);
const path = `/${key}`;
return (
<FormField
key={key}
propSchema={propSchema}
value={value}
onChange={(val) => handleFieldChange(key, val)}
error={error}
disabled={disabled}
label={label}
isRequired={isRequired}
errors={errors}
path={path}
/>
);
})}
</div>
);
}