From a937b89a4720a135b5fbc952eb8a276ef03f7947 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 20 Jun 2026 17:58:47 -0700 Subject: [PATCH] fix(daytona): valid memory input in env config form + size presets (#8389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Environments are configured through a JSON-schema-driven form (`JsonSchemaForm`), and each sandbox provider (here, Daytona) supplies a manifest describing its fields > - In the Daytona environment config form, the "memory" field rendered as a free-text input; on save the value round-tripped to `0`, producing a server-side "number must be greater than zero" error even when the user typed a valid number like `2` > - Rather than patch the free-text input, memory is now a true dropdown of the sandbox sizes Daytona actually supports, so an invalid value can no longer be typed or coerced > - The dropdown leads with a blank "None" row that is selected by default (meaning "not configured — use Daytona's defaults"); `0` is never offered because it is not a valid configuration > - The benefit is that operators pick a valid memory size from a constrained list, it saves correctly as an integer, and leaving it unset cleanly omits the field instead of submitting `0` ## Linked Issues or Issue Description No public GitHub issue exists; describing the bug inline per the bug report template. ### What happened? In the Daytona environment configuration form, the memory field is a free-text input labelled "gigabytes of RAM". Entering a value such as `2` and saving coerced the value to `0`, and the server rejected the save with "the number must be greater than zero". There was no way to enter a valid memory size through the form. ### Expected behavior Selecting a valid memory size (e.g. `2`) keeps that value and saves cleanly as an integer. Leaving memory unset is valid and submits no value (Daytona defaults apply). `0` is never selectable. ### Steps to reproduce 1. Open the environment configuration form for a Daytona sandbox provider. 2. In the memory field, type `2`. 3. Click Save. 4. Observe the value becomes `0` and the form errors with "the number must be greater than zero". ## What Changed - Daytona manifest: `memory` is now an `enum` of the supported sandbox sizes `[1, 2, 4, 8]` (GiB). It stays optional, so "not configured" remains valid. `0` is not in the list. - `JsonSchemaForm` `EnumField`: optional enums now render a leading blank **None** row that is selected by default when no value is set, letting the user express "not configured" and clear a previous selection (Radix `Select` forbids an empty-string item value, so the unset state maps to a sentinel that translates back to `undefined`). - `JsonSchemaForm` `EnumField`: when every enum option is numeric, the selected value is coerced back to a number on change so the payload keeps the schema's integer type (a stringified `"2"` would otherwise fail server-side integer validation — this is what fixes the original bug for the dropdown path). - Tests: added `EnumField` coverage in `JsonSchemaForm.test.tsx` (blank row present, no `0`, blank selected by default, numeric coercion, blank → unset) and Daytona manifest coverage in the plugin test (memory enum is `[1,2,4,8]`, excludes `0`, stays optional). ## Verification - `cd ui && npx vitest run src/components/JsonSchemaForm.test.tsx src/pages/CompanyEnvironments.test.tsx` → 16/16 passing (14 + 2). - `vitest run` in `packages/plugins/sandbox-providers/daytona` → 19/19 passing (incl. 3 new manifest tests). - `cd ui && npx tsc --noEmit` → clean. - Behavior: the memory field now renders as a dropdown showing **None / 1 / 2 / 4 / 8**, with **None** selected by default. Picking `2` stores the integer `2`; picking **None** clears the field so it is omitted from the payload (no `0`, no "must be greater than zero" error). ## Risks - Low risk. The blank-row + numeric-coercion changes live in `EnumField`. The blank row is only added for **optional** enums (required enums are unaffected); numeric coercion only triggers when every option is numeric, so existing string enums (`egressMode`, `backend`, `sessionStrategy`) are unchanged. The manifest change is scoped to the Daytona provider. ## Model Used Claude (Anthropic), Opus-class model, used via the Paperclip agent workflow (author + reviewer agents) with tool use and code execution. ## 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 searched GitHub for duplicate or related PRs and linked them above (none found) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../sandbox-providers/daytona/src/manifest.ts | 15 +- .../daytona/src/plugin.test.ts | 22 ++ ui/src/components/JsonSchemaForm.test.tsx | 194 ++++++++++++++++++ ui/src/components/JsonSchemaForm.tsx | 159 +++++++++----- 4 files changed, 339 insertions(+), 51 deletions(-) diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index ba92c4e5..dcc68434 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -56,20 +56,25 @@ const manifest: PaperclipPluginManifestV1 = { "Optional Daytona language hint for direct code execution. If omitted, Daytona uses its default runtime.", }, cpu: { - type: "number", + type: "integer", description: "Optional CPU allocation in cores.", + minimum: 1, }, memory: { - type: "number", - description: "Optional memory allocation in GiB.", + type: "integer", + description: + "Optional memory allocation in GiB. Leave unset to use Daytona defaults; supported sandbox sizes are 1, 2, 4, and 8 GiB.", + enum: [1, 2, 4, 8], }, disk: { - type: "number", + type: "integer", description: "Optional disk allocation in GiB.", + minimum: 1, }, gpu: { - type: "number", + type: "integer", description: "Optional GPU allocation in units.", + minimum: 1, }, timeoutMs: { type: "number", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 389426a4..a9f00229 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -19,6 +19,7 @@ vi.mock("@daytonaio/sdk", () => ({ })); import plugin from "./plugin.js"; +import manifest from "./manifest.js"; function createMockSandbox(overrides: { id?: string; @@ -497,3 +498,24 @@ describe("Daytona sandbox provider plugin", () => { }); }); }); + +describe("daytona manifest memory config", () => { + const memorySchema = ( + manifest.environmentDrivers?.[0]?.configSchema as { + properties?: Record; + required?: string[]; + } + ); + + it("offers memory as a fixed dropdown of supported sandbox sizes", () => { + expect(memorySchema.properties?.memory?.enum).toEqual([1, 2, 4, 8]); + }); + + it("excludes 0 — an invalid Daytona memory configuration", () => { + expect(memorySchema.properties?.memory?.enum).not.toContain(0); + }); + + it("keeps memory optional so the blank/default selection stays valid", () => { + expect(memorySchema.required ?? []).not.toContain("memory"); + }); +}); diff --git a/ui/src/components/JsonSchemaForm.test.tsx b/ui/src/components/JsonSchemaForm.test.tsx index 755acc61..b9d6c9d3 100644 --- a/ui/src/components/JsonSchemaForm.test.tsx +++ b/ui/src/components/JsonSchemaForm.test.tsx @@ -8,6 +8,24 @@ import { JsonSchemaForm, getDefaultValues } from "./JsonSchemaForm"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +// Radix Select relies on PointerEvent, pointer capture, and ResizeObserver, +// none of which jsdom implements. Stub them so the dropdown can open in tests. +if (!globalThis.PointerEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).PointerEvent = MouseEvent; +} +if (typeof Element !== "undefined" && !Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; + Element.prototype.releasePointerCapture = () => {}; +} +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? ResizeObserverStub; + // SecretBindingPicker pulls in CompanyContext + react-query. Stub it so we can // exercise SecretField in isolation. The stub renders a select with the same // onChange contract as the real picker. @@ -358,6 +376,7 @@ describe("JsonSchemaForm secret-ref rendering", () => { sshPort: { type: "number", default: 22 }, cpu: { type: "number" }, memory: { type: "string" }, + size: { type: "string", enum: ["small", "large"] }, reuseLease: { type: "boolean", default: false }, tags: { type: "array", items: { type: "string" } }, }, @@ -373,6 +392,42 @@ describe("JsonSchemaForm secret-ref rendering", () => { expect("apiKey" in defaults).toBe(false); expect("cpu" in defaults).toBe(false); expect("memory" in defaults).toBe(false); + expect("size" in defaults).toBe(false); + }); + + it("renders datalist suggestions for numeric fields when examples are present", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + />, + ); + }); + + const input = container.querySelector('input[type="number"]'); + // The "/" in the field path is sanitized so the id is a valid CSS/HTML identifier. + expect(input?.getAttribute("list")).toBe("-memory-suggestions"); + expect(container.querySelector("datalist")?.getAttribute("id")).toBe("-memory-suggestions"); + const options = Array.from(container.querySelectorAll("datalist option")).map((option) => + option.getAttribute("value"), + ); + expect(options).toEqual(["1", "2", "4", "8"]); + + await act(async () => { + root.unmount(); + }); }); it("keeps the password fallback for short raw values", async () => { @@ -407,3 +462,142 @@ describe("JsonSchemaForm secret-ref rendering", () => { }); }); }); + +describe("JsonSchemaForm enum rendering", () => { + let container: HTMLDivElement; + + const numericEnumSchema = { + type: "object" as const, + properties: { + memory: { + type: "integer" as const, + enum: [1, 2, 4, 8], + }, + }, + }; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function openSelect() { + const trigger = container.querySelector('[role="combobox"]'); + expect(trigger).not.toBeNull(); + await act(async () => { + trigger!.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, button: 0 }), + ); + trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + } + + function optionByLabel(label: string): Element | undefined { + return Array.from(document.querySelectorAll('[role="option"]')).find( + (option) => option.textContent?.trim() === label, + ); + } + + it("renders an optional numeric enum as a dropdown with a blank row and no 0", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} />, + ); + }); + + await openSelect(); + + const labels = Array.from(document.querySelectorAll('[role="option"]')).map( + (option) => option.textContent?.trim(), + ); + // A blank "None" row is offered so the user can express "not configured". + expect(labels).toContain("None"); + expect(labels).toEqual(expect.arrayContaining(["1", "2", "4", "8"])); + // 0 is not a valid Daytona memory size and must never appear. + expect(labels).not.toContain("0"); + + await act(async () => { + root.unmount(); + }); + }); + + it("selects the blank row by default when no value is configured", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} />, + ); + }); + + await openSelect(); + + const noneOption = optionByLabel("None"); + expect(noneOption).toBeTruthy(); + // Radix marks the active selection with aria-selected / data-state checked. + expect(noneOption?.getAttribute("aria-selected")).toBe("true"); + + await act(async () => { + root.unmount(); + }); + }); + + it("coerces the selected numeric enum value back to a number", async () => { + const onChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + + await openSelect(); + + await act(async () => { + optionByLabel("2")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // Number, not the string "2", so server-side integer validation passes. + expect(onChange).toHaveBeenCalledWith({ memory: 2 }); + + await act(async () => { + root.unmount(); + }); + }); + + it("maps the blank row back to an unset (undefined) value", async () => { + const onChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + + await openSelect(); + + await act(async () => { + optionByLabel("None")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith({ memory: undefined }); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/JsonSchemaForm.tsx b/ui/src/components/JsonSchemaForm.tsx index b9f18521..c6aa30b0 100644 --- a/ui/src/components/JsonSchemaForm.tsx +++ b/ui/src/components/JsonSchemaForm.tsx @@ -47,6 +47,7 @@ export interface JsonSchemaNode { description?: string; default?: unknown; enum?: unknown[]; + examples?: unknown[]; const?: unknown; format?: string; @@ -155,7 +156,7 @@ export function getDefaultForSchema(schema: JsonSchemaNode): unknown { case "boolean": return false; case "enum": - return schema.enum?.[0] ?? ""; + return undefined; case "array": return []; case "object": { @@ -438,6 +439,13 @@ const BooleanField = React.memo(({ BooleanField.displayName = "BooleanField"; +/** + * Sentinel value for the "not configured" row of an optional enum select. + * Radix `Select` forbids an empty-string item value, so we map the unset state + * onto this sentinel and translate it back to `undefined` on change. + */ +const ENUM_UNSET_VALUE = "__paperclip_unset__"; + /** * Specialized field for enum (select) values. */ @@ -459,32 +467,63 @@ const EnumField = React.memo(({ description?: string; error?: string; options: unknown[]; -}) => ( - - - -)); + + + ); +}); EnumField.displayName = "EnumField"; @@ -689,6 +728,7 @@ SecretField.displayName = "SecretField"; * Specialized field for numeric (number/integer) values. */ const NumberField = React.memo(({ + id, value, onChange, disabled, @@ -698,7 +738,11 @@ const NumberField = React.memo(({ error, defaultValue, type, + minimum, + maximum, + suggestions, }: { + id: string; value: unknown; onChange: (val: unknown) => void; disabled: boolean; @@ -708,28 +752,47 @@ const NumberField = React.memo(({ error?: string; defaultValue?: unknown; type: "number" | "integer"; -}) => ( - - { - const val = e.target.value; - onChange(val === "" ? undefined : Number(val)); - }} - placeholder={String(defaultValue ?? "")} + minimum?: number; + maximum?: number; + suggestions?: unknown[]; +}) => { + const hasSuggestions = Array.isArray(suggestions) && suggestions.length > 0; + // Sanitize the path-based id so it is a valid CSS/HTML identifier (paths can contain "/"). + const listId = hasSuggestions ? `${id.replace(/[^a-zA-Z0-9_-]/g, "-")}-suggestions` : undefined; + return ( + - -)); + > + { + const val = e.target.value; + const trimmed = val.trim(); + onChange(trimmed === "" ? undefined : Number(trimmed)); + }} + placeholder={String(defaultValue ?? "")} + disabled={disabled} + aria-invalid={!!error} + /> + {listId ? ( + + {suggestions!.map((suggestion) => ( + + ) : null} + + ); +}); NumberField.displayName = "NumberField"; @@ -1044,6 +1107,7 @@ const FormField = React.memo(({ case "integer": return ( );