mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Align native setup isolation and finish shared runtime pickers
Use the server-resolved sandbox transport for native setup, retaining local and SSH isolation and stripping injected flags. Preserve the complete local and managed E2E roster and existing graders. Replace the remaining scoped native dropdowns with the shared picker without changing saved runner or account choices. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
448db859e3
commit
30bc9e40ce
17 files changed
+299
-112
No files matched your search
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { act, useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -54,6 +55,71 @@ async function renderRunner(config: Record<string, unknown>, expand?: string, in
|
||||
);
|
||||
}
|
||||
|
||||
async function selectField(container: HTMLElement, label: string, value: string) {
|
||||
const picker = container.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!;
|
||||
await act(async () => {
|
||||
picker.focus();
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
|
||||
const option = document.body.querySelector<HTMLButtonElement>(`[role="listbox"][aria-label="${label}"] [data-value="${value}"]`)!;
|
||||
expect(option).not.toBeNull();
|
||||
await act(async () => {
|
||||
option.click();
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function editFields(
|
||||
mode: "create" | "edit",
|
||||
adapterType: "codex_local" | "paperclip_runner",
|
||||
inspect: (container: HTMLElement, saved: () => Record<string, unknown>) => Promise<void>,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
let currentConfig: Record<string, unknown> = {
|
||||
...(adapterType === "paperclip_runner" ? { provider: "codex", lifecycleMode: "warm", idleTimeoutMs: 45_000 } : { engine: "acp", mode: "persistent", nonInteractivePermissions: "deny" }),
|
||||
env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } },
|
||||
};
|
||||
let currentValues: CreateConfigValues = {
|
||||
...defaultCreateValues,
|
||||
adapterType,
|
||||
runner: adapterType === "paperclip_runner" ? "paperclip" : "legacy",
|
||||
adapterSchemaValues: { provider: "codex" },
|
||||
envBindings: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } },
|
||||
paperclipRunnerLifecycleMode: "warm",
|
||||
paperclipRunnerIdleTimeoutMs: 45_000,
|
||||
codexEngine: "acp",
|
||||
codexAcpMode: "persistent",
|
||||
codexAcpNonInteractivePermissions: "deny",
|
||||
};
|
||||
function Fixture() {
|
||||
const [config, setConfig] = useState(currentConfig);
|
||||
const [values, setValues] = useState(currentValues);
|
||||
currentConfig = config;
|
||||
currentValues = values;
|
||||
return <CodexLocalConfigFields mode={mode} isCreate={mode === "create"} adapterType={adapterType}
|
||||
values={mode === "create" ? values : null} set={patch => setValues(previous => ({ ...previous, ...patch }))}
|
||||
config={config} eff={(_group, _field, original) => original}
|
||||
mark={(_group, field, value) => setConfig(previous => ({ ...previous, [field]: value }))}
|
||||
models={[]} hideInstructionsFile hideRunnerHarness />;
|
||||
}
|
||||
try {
|
||||
await act(async () => root.render(<QueryClientProvider client={new QueryClient()}><TooltipProvider><Fixture /></TooltipProvider></QueryClientProvider>));
|
||||
await inspect(container, () => mode === "create"
|
||||
? adapterType === "paperclip_runner" ? buildPaperclipRunnerConfig(currentValues) : buildCodexRunnerConfig(currentValues)
|
||||
: currentConfig);
|
||||
if (mode === "create") {
|
||||
expect(currentValues.adapterType).toBe(adapterType);
|
||||
expect(currentValues.runner).toBe(adapterType === "paperclip_runner" ? "paperclip" : "legacy");
|
||||
}
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
}
|
||||
|
||||
describe("Paperclip Runner Codex configuration", () => {
|
||||
it.each(["", "max"])("preserves or overrides saved native Codex reasoning effort (%s)", thinkingEffort => {
|
||||
const values = { ...defaultCreateValues, thinkingEffort, adapterSchemaValues: { provider: "codex", modelReasoningEffort: "high" } };
|
||||
@@ -122,6 +188,32 @@ describe("Paperclip Runner Codex configuration", () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
it.each(["create", "edit"] as const)("changes native lifecycle while retaining timeout and account in %s mode", async mode => {
|
||||
await editFields(mode, "paperclip_runner", async (container, saved) => {
|
||||
expect(container.querySelector('[aria-label="Runner lifecycle"]')?.textContent).toContain("Warm session");
|
||||
expect(container.textContent).toContain("Warm idle timeout (ms)");
|
||||
const account = saved().env;
|
||||
await selectField(container, "Runner lifecycle", "per_turn");
|
||||
expect(container.textContent).not.toContain("Warm idle timeout (ms)");
|
||||
expect(saved()).toMatchObject({ provider: "codex", lifecycleMode: "per_turn", env: account });
|
||||
await selectField(container, "Runner lifecycle", "warm");
|
||||
expect(container.textContent).toContain("Warm idle timeout (ms)");
|
||||
expect(saved()).toMatchObject({ provider: "codex", lifecycleMode: "warm", idleTimeoutMs: 45_000, env: account });
|
||||
});
|
||||
});
|
||||
it.each(["create", "edit"] as const)("changes Legacy ACP session and permissions without replacing the account in %s mode", async mode => {
|
||||
await editFields(mode, "codex_local", async (container, saved) => {
|
||||
expect(container.querySelector('[aria-label="ACP session mode"]')?.textContent).toContain("Persistent");
|
||||
expect(container.querySelector('[aria-label="ACP non-interactive permissions"]')?.textContent).toContain("Deny");
|
||||
const account = saved().env;
|
||||
await selectField(container, "ACP session mode", "oneshot");
|
||||
await selectField(container, "ACP non-interactive permissions", "fail");
|
||||
expect(saved()).toMatchObject({ engine: "acp", mode: "oneshot", nonInteractivePermissions: "fail", env: account });
|
||||
await selectField(container, "ACP session mode", "persistent");
|
||||
await selectField(container, "ACP non-interactive permissions", "deny");
|
||||
expect(saved()).toMatchObject({ engine: "acp", mode: "persistent", nonInteractivePermissions: "deny", env: account });
|
||||
});
|
||||
});
|
||||
it("preserves an unknown native provider instead of presenting it as Codex", async () => {
|
||||
const html = await renderRunner({ provider: "future-provider" });
|
||||
expect(html).toContain("future-provider");
|
||||
|
||||
@@ -544,19 +544,20 @@ export function CodexLocalConfigFields({
|
||||
label="Runner lifecycle"
|
||||
hint="Turn by turn suspends after each run. Warm keeps the same provider process available between governed runs."
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
<SelectPopover
|
||||
aria-label="Runner lifecycle"
|
||||
value={runnerLifecycleMode}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value === "warm" ? "warm" : "per_turn";
|
||||
onValueChange={(next) => {
|
||||
const value = next === "warm" ? "warm" : "per_turn";
|
||||
isCreate
|
||||
? set!({ paperclipRunnerLifecycleMode: value })
|
||||
: mark("adapterConfig", "lifecycleMode", value);
|
||||
}}
|
||||
>
|
||||
<option value="per_turn">Turn by turn</option>
|
||||
<option value="warm">Warm session</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: "per_turn", label: "Turn by turn" },
|
||||
{ value: "warm", label: "Warm session" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{runnerManaged && runnerProvider !== "openai_dot" && runnerLifecycleMode === "warm" && (
|
||||
@@ -630,8 +631,8 @@ export function CodexLocalConfigFields({
|
||||
label="ACP session mode"
|
||||
hint="Persistent keeps ACP session state between runs. One-shot starts fresh each run."
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
<SelectPopover
|
||||
aria-label="ACP session mode"
|
||||
value={
|
||||
isCreate
|
||||
? (values!.codexAcpMode ?? "persistent")
|
||||
@@ -641,24 +642,25 @@ export function CodexLocalConfigFields({
|
||||
String(config.mode ?? "persistent"),
|
||||
)
|
||||
}
|
||||
onChange={(e) => {
|
||||
onValueChange={(next) => {
|
||||
const value =
|
||||
e.target.value === "oneshot" ? "oneshot" : "persistent";
|
||||
next === "oneshot" ? "oneshot" : "persistent";
|
||||
isCreate
|
||||
? set!({ codexAcpMode: value })
|
||||
: mark("adapterConfig", "mode", value);
|
||||
}}
|
||||
>
|
||||
<option value="persistent">Persistent</option>
|
||||
<option value="oneshot">One-shot</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: "persistent", label: "Persistent" },
|
||||
{ value: "oneshot", label: "One-shot" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="ACP non-interactive permissions"
|
||||
hint="Fallback if the ACP agent asks for input outside an interactive session."
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
<SelectPopover
|
||||
aria-label="ACP non-interactive permissions"
|
||||
value={
|
||||
isCreate
|
||||
? (values!.codexAcpNonInteractivePermissions ?? "deny")
|
||||
@@ -668,16 +670,17 @@ export function CodexLocalConfigFields({
|
||||
String(config.nonInteractivePermissions ?? "deny"),
|
||||
)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === "fail" ? "fail" : "deny";
|
||||
onValueChange={(next) => {
|
||||
const value = next === "fail" ? "fail" : "deny";
|
||||
isCreate
|
||||
? set!({ codexAcpNonInteractivePermissions: value })
|
||||
: mark("adapterConfig", "nonInteractivePermissions", value);
|
||||
}}
|
||||
>
|
||||
<option value="deny">Deny</option>
|
||||
<option value="fail">Fail</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: "deny", label: "Deny" },
|
||||
{ value: "fail", label: "Fail" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
{!managedSandboxOnly && (
|
||||
<Field
|
||||
|
||||
@@ -576,16 +576,20 @@ async function renderStatefulCreateClaudeSandbox(environments: Environment[]) {
|
||||
return { container, root, valuesRef };
|
||||
}
|
||||
|
||||
async function environmentOptions(container: HTMLElement) {
|
||||
const picker = container.querySelector('[aria-label="Environment override"]');
|
||||
expect(picker).not.toBeNull();
|
||||
await clickElement(picker);
|
||||
const options = document.body.querySelector('[role="listbox"][aria-label="Environment override"]');
|
||||
expect(options).not.toBeNull();
|
||||
return options!;
|
||||
}
|
||||
|
||||
async function selectEnvironment(container: HTMLElement, environmentId: string) {
|
||||
const select = container.querySelector("select");
|
||||
await act(async () => {
|
||||
if (select) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
||||
setter?.call(select, environmentId);
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
await flushReact();
|
||||
const options = await environmentOptions(container);
|
||||
const option = options.querySelector(`[role="option"][data-value="${environmentId}"]`);
|
||||
expect(option).not.toBeNull();
|
||||
await clickElement(option);
|
||||
}
|
||||
|
||||
async function clickByText(container: HTMLElement, label: string) {
|
||||
@@ -674,6 +678,40 @@ describe("AgentConfigForm environment selector", () => {
|
||||
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Cody" });
|
||||
});
|
||||
|
||||
it.each(["codex_local", "paperclip_runner"])("changes and clears the environment without changing a saved %s runner or account", async adapterType => {
|
||||
const adapterConfig = {
|
||||
model: "gpt-5.6-sol",
|
||||
...(adapterType === "paperclip_runner" ? { provider: "codex", reasoningEffort: "high" } : { modelReasoningEffort: "high" }),
|
||||
env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } },
|
||||
};
|
||||
const result = await renderForm([
|
||||
...daytonaEnvironments(),
|
||||
makeEnvironment({ id: "sandbox-2", name: "Daytona Two", driver: "sandbox", config: { provider: "daytona" } }),
|
||||
], { adapterType, adapterConfig, defaultEnvironmentId: "sandbox-1" }, { showAdapterTypeField: true });
|
||||
roots.push(result.root);
|
||||
await clickByText(result.container, "Advanced");
|
||||
const runnerLabel = adapterType === "paperclip_runner" ? "Paperclip Runner" : "Legacy runner";
|
||||
|
||||
await selectEnvironment(result.container, "sandbox-2");
|
||||
expect(result.container.querySelector('[aria-label="Environment override"]')?.textContent).toContain("Daytona Two");
|
||||
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(runnerLabel);
|
||||
await clickByText(result.container, "Save");
|
||||
expect(result.onSave).toHaveBeenLastCalledWith({ defaultEnvironmentId: "sandbox-2" });
|
||||
|
||||
await selectEnvironment(result.container, "");
|
||||
expect(result.container.querySelector('[aria-label="Environment override"]')?.textContent).toContain("Default: Local");
|
||||
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(runnerLabel);
|
||||
await clickByText(result.container, "Save");
|
||||
expect(result.onSave).toHaveBeenLastCalledWith({ defaultEnvironmentId: null });
|
||||
});
|
||||
|
||||
it("clears a new agent's environment override to the empty draft value", async () => {
|
||||
const result = await renderCreateForm(daytonaEnvironments(), { defaultEnvironmentId: "sandbox-1", runner: "legacy" });
|
||||
roots.push(result.root);
|
||||
await selectEnvironment(result.container, "");
|
||||
expect(result.onChange).toHaveBeenLastCalledWith({ defaultEnvironmentId: "" });
|
||||
});
|
||||
|
||||
it("submits only an explicit runner change and retains saved configuration for server translation", async () => {
|
||||
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", reasoningEffort: "high", env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } } } });
|
||||
roots.push(result.root);
|
||||
@@ -910,7 +948,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
roots.push(result.root);
|
||||
|
||||
expect(result.container.textContent).not.toContain("Environment override");
|
||||
expect(result.container.querySelector("select")).toBeNull();
|
||||
expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders GPT-6 Astra and its model-specific reasoning efforts", async () => {
|
||||
@@ -1029,7 +1067,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
roots.push(result.root);
|
||||
|
||||
const text = result.container.textContent ?? "";
|
||||
const selector = result.container.querySelector("select");
|
||||
const selector = await environmentOptions(result.container);
|
||||
|
||||
expect(text).toContain("Environment");
|
||||
expect(text).toContain("Environment override");
|
||||
@@ -1048,7 +1086,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
roots.push(result.root);
|
||||
|
||||
const text = result.container.textContent ?? "";
|
||||
const selector = result.container.querySelector("select");
|
||||
const selector = await environmentOptions(result.container);
|
||||
|
||||
expect(text).toContain("Environment override");
|
||||
expect(selector?.textContent).toContain("E2B · sandbox");
|
||||
@@ -1062,7 +1100,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
roots.push(result.root);
|
||||
|
||||
const text = result.container.textContent ?? "";
|
||||
const selector = result.container.querySelector("select");
|
||||
const selector = await environmentOptions(result.container);
|
||||
|
||||
expect(text).toContain("Environment override");
|
||||
expect(selector?.textContent).toContain("E2B · sandbox");
|
||||
@@ -1084,7 +1122,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
roots.push(result.root);
|
||||
|
||||
const text = result.container.textContent ?? "";
|
||||
const selector = result.container.querySelector("select");
|
||||
const selector = await environmentOptions(result.container);
|
||||
|
||||
expect(text).toContain("Environment override");
|
||||
expect(selector?.textContent).toContain("Default: Local");
|
||||
@@ -1104,7 +1142,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
]);
|
||||
roots.push(result.root);
|
||||
|
||||
const selector = result.container.querySelector("select");
|
||||
const selector = await environmentOptions(result.container);
|
||||
|
||||
expect(selector?.textContent).toContain("Default: Paperclip Computer");
|
||||
expect(selector?.textContent).toContain("Paperclip Computer");
|
||||
@@ -2488,15 +2526,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
await runTest(result.container);
|
||||
expect(findButton(result.container, "Sign in")).toBeTruthy();
|
||||
|
||||
const select = result.container.querySelector("select");
|
||||
await act(async () => {
|
||||
if (select) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
||||
setter?.call(select, "");
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
await flushReact();
|
||||
await selectEnvironment(result.container, "");
|
||||
|
||||
expect(findButton(result.container, "Sign in")).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -1579,25 +1579,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||
<Field label="Environment override">
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className={inputClass}
|
||||
<SelectPopover
|
||||
aria-label="Environment override"
|
||||
value={currentDefaultEnvironmentId}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
onValueChange={(nextValue) => {
|
||||
if (isCreate) {
|
||||
set!({ defaultEnvironmentId: nextValue });
|
||||
return;
|
||||
}
|
||||
mark("identity", "defaultEnvironmentId", nextValue || null);
|
||||
}}
|
||||
>
|
||||
<option value="">Default: {inheritedEnvironmentLabel}</option>
|
||||
{environmentOptions.map((environment) => (
|
||||
<option key={environment.id} value={environment.id}>
|
||||
{environmentDisplayLabel(environment)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={[
|
||||
{ value: "", label: `Default: ${inheritedEnvironmentLabel}` },
|
||||
...environmentOptions.map((environment) => ({
|
||||
value: environment.id,
|
||||
label: environmentDisplayLabel(environment),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user