exe.dev config UX: advanced-options disclosure, form-default fix, SSH key handling (PAPA-407) (#7025)
## Thinking Path
> - Paperclip orchestrates AI agents and provisions sandboxed execution
environments for them; one of those provisioners is the exe.dev plugin,
which runs each agent inside a long-lived VM reached over SSH.
> - The instance-config form for that plugin is rendered generically by
`JsonSchemaForm` from the plugin's `instanceConfigSchema`, so any UX
problem with the form is split between the shared form component and the
plugin's schema/runtime code.
> - Users coming in cold hit a 12-field flat config they couldn't reason
about (PAPA-407), a form that silently submitted `cpu: 0` for untouched
optional fields (PAPA-407 root cause), a `sshPrivateKey` textarea that
truncated RSA-4096 keys at 4096 chars (PAPA-449), a save flow that
accepted clearly-malformed keys and only blew up at lease time with raw
SSH stderr (PAPA-450, PAPA-451), and a manifest that didn't distinguish
"essential" from "advanced" knobs (PAPA-410 / PAPA-411 — duplicate
sub-issues with identical scope; PAPA-418 reconciliation kept PAPA-410
canonical).
> - These problems all point at the same surface (exe.dev sandbox
config) and are tightly coupled in code — PAPA-449/450/451 patch fields
that PAPA-410/411 introduce — so they get reviewed together.
> - This pull request lands the shared-form changes (advanced-options
disclosure, optional-scalar defaults) and the exe.dev-specific changes
(manifest restructure, longer `maxLength`, stderr translation, save-time
key validation) as five focused commits stacked on `master`.
> - The benefit is a config form that defaults to the two fields a new
user actually needs (API key + SSH private key) with a collapsible
disclosure for the rest, no silent truncation or zero-default
submissions, and SSH key problems surfaced at save time with actionable
messages instead of cryptic post-provision failures.
## What Changed
- **JsonSchemaForm advanced-options disclosure** (PAPA-410, PAPA-411 —
same scope, see note above): adds `x-paperclip-advanced` /
`x-paperclip-group` schema annotations and renders flagged fields behind
a collapsible "Advanced options" disclosure that auto-opens when a
hidden field has a validation error. Exe.dev manifest is restructured to
use the new annotations, so essentials (`apiKey`, `sshPrivateKey`) show
by default while the long tail of optional knobs is grouped under "SSH
access" / "VM resources" / "More options" headings.
- **Omit optional scalar defaults** (PAPA-407): `getDefaultForSchema` no
longer materialises `0` / `""` for optional
`number`/`integer`/`string`/`secret-ref` fields without an explicit
`default`. Object recursion drops properties whose default is
`undefined`. Fields that declare a `default` (e.g. `sshPort: 22`) still
round-trip. Adds a regression test against `getDefaultValues`.
- **Raise `sshPrivateKey` `maxLength`** (PAPA-449): bumps the exe.dev
manifest cap from 4096 to 8192 so RSA-4096 OpenSSH private keys (which
can exceed 4 KB with comments/metadata) aren't silently truncated at
submit.
- **Translate `invalid format` SSH stderr** (PAPA-450):
`formatSshFailure` now recognises `Load key … invalid format` in
combined stderr/stdout and returns a specific message naming the
key-format problem ("isn't an OpenSSH/PEM private key — confirm the
secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub`
or a PuTTY `.ppk` export") instead of dumping the raw stderr.
- **Save-time SSH key validation** (PAPA-451):
`onEnvironmentValidateConfig` inline-parses `sshPrivateKey` and rejects
common failure modes — pasted public keys, PuTTY `.ppk` format, missing
`-----END-----` footer, non-base64 body — so the form surfaces an inline
error before any VM is provisioned. Secret-ref bindings (UUIDs) are
still passed through unchanged.
## Verification
CI gates (`pnpm typecheck`, `pnpm test`, the targeted vitest suites
below) all pass.
Run locally:
```bash
# Shared form
pnpm --filter @paperclipai/ui exec vitest run src/components/JsonSchemaForm
# 9 tests pass — includes the new "omits optional scalar fields" regression
# and the three advanced-options-disclosure tests.
# exe.dev plugin
cd packages/plugins/sandbox-providers/exe-dev && pnpm test
# 32 tests pass — includes the new sshPrivateKey-validation cases
# and the new "invalid format" stderr-translation case.
```
Manual smoke (after reinstalling the plugin so the DB manifest
refreshes):
1. Open the exe.dev environment config page. **Default view shows API
Key + SSH Private Key only**, with an "Advanced options" disclosure for
everything else (PAPA-410 / PAPA-411).
2. Paste a `.pub` file's contents into SSH Private Key, click Save.
**Inline error** rejecting the wrong-format key (PAPA-451).
3. Re-paste a valid OpenSSH/PEM private key longer than 4096 bytes —
saves cleanly (PAPA-449).
4. Save the form with everything optional left blank — server no longer
rejects with `"cpu must be greater than 0 when provided"` (PAPA-407).
5. Force a bad key through via a stored secret-ref binding and lease a
VM — failure message names the key-format problem instead of dumping raw
SSH stderr (PAPA-450).
## Risks
- **PAPA-410 / PAPA-411 manifest restructure** is the largest surface
here. Schemas using `x-paperclip-*` extensions are forward-compatible
with stricter JSON Schema validators (extensions are ignored by
default), and the form gracefully renders a flat layout when no field
opts in.
- **PAPA-407** changes form-default behaviour: optional scalar fields
that previously round-tripped as `""` / `0` will now be `undefined` and
absent from the submitted payload. Downstream consumers that expected
the empty-string/zero shape need to treat the field as optional.
Spot-checked the existing exe.dev driver — it already uses
`parseOptionalString` / `parseOptionalInteger`, which treat missing
fields as `null` rather than `0`/`""`.
- **PAPA-451** adds a save-time check, so a
previously-saved-but-malformed `sshPrivateKey` raw value will now fail
to re-save. Bound secret-refs are unaffected, matching how the user
reaches the bad-key state today (via the secrets picker).
- **PAPA-449** simply raises a cap; no semantic risk.
- **PAPA-450** only kicks in on the "invalid format" code path; existing
onboarding-marker branch is untouched.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities used: code reading, code editing, test execution, git/PR
mechanics, Paperclip API for issue coordination
## Checklist
- [x] PR body sections present (Thinking Path, What Changed,
Verification, Risks, Model Used, Checklist)
- [x] Unit tests added for the new behaviours (JsonSchemaForm
default-value omission + advanced disclosure; exe.dev plugin validation
+ stderr translation)
- [x] Existing tests still pass locally (`vitest run` on both packages)
- [x] No raw secrets, IP addresses, or machine-local config in commits
or PR body
- [x] Commits are atomic per linked issue (PAPA-410 / PAPA-411,
PAPA-407, PAPA-449, PAPA-450, PAPA-451)
- [x] Branch is up-to-date with `origin/master`
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { JsonSchemaForm } from "./JsonSchemaForm";
|
||||
import { JsonSchemaForm, getDefaultValues } from "./JsonSchemaForm";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -204,6 +204,177 @@ describe("JsonSchemaForm secret-ref rendering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders no Advanced disclosure when no field opts in", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
region: { type: "string" },
|
||||
},
|
||||
}}
|
||||
values={{ apiKey: "", region: "" }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
// No disclosure button should be present in the passthrough case.
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const advancedButton = buttons.find((b) =>
|
||||
b.textContent?.includes("Advanced options"),
|
||||
);
|
||||
expect(advancedButton).toBeUndefined();
|
||||
|
||||
// Both fields render in the flat layout: the secret picker (rendered as
|
||||
// a <select> stub) for apiKey and a text input for region.
|
||||
expect(
|
||||
container.querySelector('[data-testid="secret-binding-picker"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.querySelector('input[type="text"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides advanced fields behind a collapsed disclosure with group headings", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
sshPort: {
|
||||
type: "number",
|
||||
"x-paperclip-advanced": true,
|
||||
"x-paperclip-group": "SSH access",
|
||||
},
|
||||
namePrefix: {
|
||||
type: "string",
|
||||
"x-paperclip-advanced": true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
values={{ apiKey: "", sshPort: 22, namePrefix: "paperclip" }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const advancedButton = buttons.find((b) =>
|
||||
b.textContent?.includes("Advanced options"),
|
||||
);
|
||||
expect(advancedButton).toBeDefined();
|
||||
expect(advancedButton!.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
// Collapsed: number/text inputs from advanced fields aren't rendered.
|
||||
expect(container.querySelector('input[type="number"]')).toBeNull();
|
||||
// Group headings aren't visible while collapsed.
|
||||
expect(container.textContent).not.toContain("SSH access");
|
||||
expect(container.textContent).not.toContain("More options");
|
||||
|
||||
// Expand and verify both groups + the default bucket appear.
|
||||
await act(async () => {
|
||||
advancedButton!.click();
|
||||
});
|
||||
|
||||
expect(advancedButton!.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(container.querySelector('input[type="number"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("SSH access");
|
||||
expect(container.textContent).toContain("More options");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("force-opens the disclosure when an error lands on a hidden advanced field", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
const schema = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
apiKey: { type: "string" as const, format: "secret-ref" as const },
|
||||
sshPort: {
|
||||
type: "number" as const,
|
||||
"x-paperclip-advanced": true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// No errors -> collapsed
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={schema}
|
||||
values={{ apiKey: "", sshPort: 22 }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
let advancedButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Advanced options"),
|
||||
);
|
||||
expect(advancedButton!.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
// Submit validation error on the hidden advanced field -> forced open
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={schema}
|
||||
values={{ apiKey: "", sshPort: 22 }}
|
||||
onChange={() => {}}
|
||||
errors={{ "/sshPort": "Must be at least 1" }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
advancedButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Advanced options"),
|
||||
);
|
||||
expect(advancedButton!.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(container.textContent).toContain("Must be at least 1");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("omits optional scalar fields from getDefaultValues so empty inputs aren't submitted as 0/''", () => {
|
||||
const defaults = getDefaultValues({
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
sshPort: { type: "number", default: 22 },
|
||||
cpu: { type: "number" },
|
||||
memory: { type: "string" },
|
||||
reuseLease: { type: "boolean", default: false },
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
});
|
||||
|
||||
// Fields with explicit defaults round-trip.
|
||||
expect(defaults.sshPort).toBe(22);
|
||||
expect(defaults.reuseLease).toBe(false);
|
||||
expect(defaults.tags).toEqual([]);
|
||||
|
||||
// Optional scalars without explicit defaults stay out of the payload so
|
||||
// the server doesn't see e.g. `cpu: 0` and reject the submission.
|
||||
expect("apiKey" in defaults).toBe(false);
|
||||
expect("cpu" in defaults).toBe(false);
|
||||
expect("memory" in defaults).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the password fallback for short raw values", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
||||
@@ -76,6 +76,19 @@ export interface JsonSchemaNode {
|
||||
readOnly?: boolean;
|
||||
writeOnly?: boolean;
|
||||
|
||||
// Paperclip extensions
|
||||
/**
|
||||
* When true, the field is hidden behind an "Advanced options" disclosure
|
||||
* in the top-level `JsonSchemaForm`. Defaults to false (essential).
|
||||
*/
|
||||
"x-paperclip-advanced"?: boolean;
|
||||
/**
|
||||
* Optional sub-section name used to group advanced fields under headings
|
||||
* inside the disclosure (e.g. "SSH access", "VM resources"). Ignored when
|
||||
* `x-paperclip-advanced` is not true.
|
||||
*/
|
||||
"x-paperclip-group"?: string;
|
||||
|
||||
// Allow extra keys
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -121,7 +134,14 @@ export function labelFromKey(key: string, schema: JsonSchemaNode): string {
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Produce a sensible default value for a schema node. */
|
||||
/**
|
||||
* Produce a sensible default value for a schema node.
|
||||
*
|
||||
* Optional scalar fields (string, number, integer, secret-ref) without an
|
||||
* explicit `default` return `undefined` so they stay out of the submitted
|
||||
* payload — otherwise an empty field would round-trip as `""` or `0` and
|
||||
* trip server-side "X must be greater than 0 when provided" style validators.
|
||||
*/
|
||||
export function getDefaultForSchema(schema: JsonSchemaNode): unknown {
|
||||
if (schema.default !== undefined) return schema.default;
|
||||
|
||||
@@ -129,10 +149,9 @@ export function getDefaultForSchema(schema: JsonSchemaNode): unknown {
|
||||
switch (type) {
|
||||
case "string":
|
||||
case "secret-ref":
|
||||
return "";
|
||||
case "number":
|
||||
case "integer":
|
||||
return schema.minimum ?? 0;
|
||||
return undefined;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "enum":
|
||||
@@ -143,12 +162,13 @@ export function getDefaultForSchema(schema: JsonSchemaNode): unknown {
|
||||
if (!schema.properties) return {};
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
||||
obj[key] = getDefaultForSchema(propSchema);
|
||||
const def = getDefaultForSchema(propSchema);
|
||||
if (def !== undefined) obj[key] = def;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1138,6 +1158,64 @@ export function JsonSchemaForm({
|
||||
[onChange, values],
|
||||
);
|
||||
|
||||
const { essentials, advancedGroups, advancedKeys } = useMemo(() => {
|
||||
const essentials: Array<[string, JsonSchemaNode]> = [];
|
||||
// Preserve original key order while bucketing into groups.
|
||||
const groupOrder: string[] = [];
|
||||
const groups = new Map<string, Array<[string, JsonSchemaNode]>>();
|
||||
const advancedKeys = new Set<string>();
|
||||
const DEFAULT_GROUP = "More options";
|
||||
|
||||
for (const entry of Object.entries(properties)) {
|
||||
const [key, propSchema] = entry;
|
||||
if (propSchema["x-paperclip-advanced"] === true) {
|
||||
advancedKeys.add(key);
|
||||
const rawGroup = propSchema["x-paperclip-group"];
|
||||
const group = typeof rawGroup === "string" && rawGroup.length > 0
|
||||
? rawGroup
|
||||
: DEFAULT_GROUP;
|
||||
if (!groups.has(group)) {
|
||||
groups.set(group, []);
|
||||
groupOrder.push(group);
|
||||
}
|
||||
groups.get(group)!.push(entry);
|
||||
} else {
|
||||
essentials.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
essentials,
|
||||
advancedGroups: groupOrder.map((group) => ({
|
||||
group,
|
||||
fields: groups.get(group)!,
|
||||
})),
|
||||
advancedKeys,
|
||||
};
|
||||
}, [properties]);
|
||||
|
||||
const hasAdvanced = advancedGroups.length > 0;
|
||||
|
||||
const hasAdvancedError = useMemo(() => {
|
||||
if (!hasAdvanced) return false;
|
||||
for (const errorKey of Object.keys(errors)) {
|
||||
// Top-level errors arrive as "/<key>" or "/<key>/<...>".
|
||||
const stripped = errorKey.startsWith("/") ? errorKey.slice(1) : errorKey;
|
||||
const topKey = stripped.split("/")[0];
|
||||
if (advancedKeys.has(topKey)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [errors, advancedKeys, hasAdvanced]);
|
||||
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
|
||||
// Force the disclosure open when a validation error lands on a hidden field
|
||||
// so the user can see and fix it. Never auto-close — once open, the user
|
||||
// controls collapse.
|
||||
useEffect(() => {
|
||||
if (hasAdvancedError) setIsAdvancedOpen(true);
|
||||
}, [hasAdvancedError]);
|
||||
|
||||
if (Object.keys(properties).length === 0) {
|
||||
return (
|
||||
<div
|
||||
@@ -1151,30 +1229,65 @@ export function JsonSchemaForm({
|
||||
);
|
||||
}
|
||||
|
||||
const renderField = ([key, propSchema]: [string, JsonSchemaNode]) => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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}`;
|
||||
{essentials.map(renderField)}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{hasAdvanced && (
|
||||
<div className="space-y-3 rounded-lg border border-dashed">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left"
|
||||
onClick={() => setIsAdvancedOpen((open) => !open)}
|
||||
aria-expanded={isAdvancedOpen}
|
||||
>
|
||||
<span className="text-sm font-medium">Advanced options</span>
|
||||
{isAdvancedOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isAdvancedOpen && (
|
||||
<div className="space-y-6 px-4 pb-4">
|
||||
{advancedGroups.map(({ group, fields }) => (
|
||||
<div key={group} className="space-y-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{fields.map(renderField)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -709,7 +709,7 @@ export function CompanyEnvironments() {
|
||||
) : null}
|
||||
|
||||
{environmentForm.driver === "sandbox" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<Field label="Provider" hint="Installed run-capable sandbox provider plugins appear here.">
|
||||
<select
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||
@@ -736,26 +736,24 @@ export function CompanyEnvironments() {
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="md:col-span-2 space-y-3">
|
||||
{selectedSandboxProvider?.description ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedSandboxProvider.description}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedSandboxSchema ? (
|
||||
<JsonSchemaForm
|
||||
schema={selectedSandboxSchema as any}
|
||||
values={environmentForm.sandboxConfig}
|
||||
onChange={(values) =>
|
||||
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
|
||||
errors={sandboxConfigErrors}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
This provider does not declare additional configuration fields.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedSandboxProvider?.description ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedSandboxProvider.description}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedSandboxSchema ? (
|
||||
<JsonSchemaForm
|
||||
schema={selectedSandboxSchema as any}
|
||||
values={environmentForm.sandboxConfig}
|
||||
onChange={(values) =>
|
||||
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
|
||||
errors={sandboxConfigErrors}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
This provider does not declare additional configuration fields.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user