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>
|
|
|
|
|
);
|
|
|
|
|
}
|