mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
fix(ui): require CLI for legacy Codex on Boat
Restrict fresh Boat Codex setup to CLI and preserve unsupported existing ACP/default settings as explicit repair choices. Block saving or testing until CLI is selected; keep Claude and other environments unchanged. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ac09d6ecce
commit
ce1a49bb91
5 files changed
+91
-48
No files matched your search
@@ -173,7 +173,7 @@ export function CodexLocalConfigFields({
|
||||
{!hideEngineChoice && (
|
||||
<Field
|
||||
label="Execution engine"
|
||||
hint="Default uses ACP. If ACP is unavailable, the run fails with a setup error. Choose CLI explicitly to use it."
|
||||
hint={allowExecutionEngineSelection ? "Boat requires Codex CLI for its persistent workspace." : "Default uses ACP. If ACP is unavailable, the run fails with a setup error. Choose CLI explicitly to use it."}
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
@@ -194,10 +194,13 @@ export function CodexLocalConfigFields({
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="auto">Default (ACP)</option>
|
||||
{(!allowExecutionEngineSelection || engine === "auto") && <option value="auto" disabled={allowExecutionEngineSelection}>Default (ACP){allowExecutionEngineSelection ? " — unsupported on Boat" : ""}</option>}
|
||||
<option value="cli">Codex CLI</option>
|
||||
<option value="acp">ACP</option>
|
||||
{(!allowExecutionEngineSelection || engine === "acp") && <option value="acp" disabled={allowExecutionEngineSelection}>ACP{allowExecutionEngineSelection ? " — unsupported on Boat" : ""}</option>}
|
||||
</select>
|
||||
{allowExecutionEngineSelection && engine !== "cli" && (
|
||||
<p role="alert" className="text-sm text-destructive">This engine is unsupported on Boat. Choose Codex CLI before saving or testing.</p>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{runnerManaged && runnerProvider !== "openai_dot" && (
|
||||
|
||||
@@ -111,7 +111,7 @@ vi.mock("../adapters", () => ({
|
||||
// adapter, so a test can assert the plumbing without rendering a real
|
||||
// adapter's fields.
|
||||
ConfigFields: (props: AdapterConfigFieldsProps) => {
|
||||
if (type === "paperclip_runner") return <CodexLocalConfigFields {...props} />;
|
||||
if (type === "paperclip_runner" || (type === "codex_local" && props.allowExecutionEngineSelection)) return <CodexLocalConfigFields {...props} />;
|
||||
const { adapterType, hideInstructionsFile, managedSandboxOnly, allowExecutionEngineSelection } = props;
|
||||
return adapterType === "hermes_gateway"
|
||||
? <div data-testid="hermes-gateway-config-fields">Hermes Gateway fields</div>
|
||||
@@ -3878,6 +3878,41 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
|
||||
expect(result.container.textContent).not.toContain("/srv/agents/cody");
|
||||
});
|
||||
|
||||
it.each(["acp", undefined])("requires explicit CLI repair before saving an unsupported Boat Codex engine (%s)", async engine => {
|
||||
setManagedSandboxOnly(true);
|
||||
const saveActions = vi.fn();
|
||||
const result = await renderForm(
|
||||
[makeEnvironment({ id: "boat-1", name: "Boat", driver: "computer", status: "active", config: { provider: "boat" } })],
|
||||
{ adapterType: "codex_local", defaultEnvironmentId: "boat-1", adapterConfig: { engine } },
|
||||
{ onSaveActionChange: saveActions, showAdapterTestEnvironmentButton: true },
|
||||
);
|
||||
roots.push(result.root);
|
||||
await act(async () => {
|
||||
for (const button of result.container.querySelectorAll("button")) {
|
||||
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
|
||||
}
|
||||
});
|
||||
await flushReact();
|
||||
expect(result.container.textContent).toContain("Choose Codex CLI before saving or testing");
|
||||
expect(saveActions.mock.lastCall?.[0]).toBeNull();
|
||||
expect(findButton(result.container, "Test")?.disabled).toBe(true);
|
||||
expect(result.onSave).not.toHaveBeenCalled();
|
||||
const engineSelect = Array.from(result.container.querySelectorAll("select")).find(select =>
|
||||
Array.from(select.options).some(option => option.textContent === "Codex CLI"),
|
||||
)!;
|
||||
expect(engineSelect.value).toBe(engine ?? "auto");
|
||||
expect(engineSelect.selectedOptions[0].disabled).toBe(true);
|
||||
expect(Array.from(engineSelect.options).filter(option => !option.disabled).map(option => option.value)).toEqual(["cli"]);
|
||||
await act(async () => {
|
||||
engineSelect.value = "cli";
|
||||
engineSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(result.container.textContent).not.toContain("Choose Codex CLI before saving or testing");
|
||||
expect(saveActions.mock.lastCall?.[0]).toEqual(expect.any(Function));
|
||||
await act(async () => saveActions.mock.lastCall![0]());
|
||||
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ adapterConfig: expect.objectContaining({ engine: "cli" }) }));
|
||||
});
|
||||
|
||||
it.each(["active", "archived"] as const)("only exposes the engine for a resolved active Boat, keeping managed host paths hidden (%s)", async status => {
|
||||
setManagedSandboxOnly(true);
|
||||
const result = await renderForm(
|
||||
|
||||
@@ -564,39 +564,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
setEnvironmentEditorKey(key => key + 1);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isCreate) return;
|
||||
const flushedEnv = flushEnvironmentDraft();
|
||||
const nextOverlay = flushedEnv
|
||||
? {
|
||||
...overlay,
|
||||
adapterConfig: {
|
||||
...overlay.adapterConfig,
|
||||
env: flushedEnv,
|
||||
},
|
||||
}
|
||||
: overlay;
|
||||
if (!isOverlayDirty(nextOverlay)) return;
|
||||
await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay));
|
||||
}, [isCreate, isDirty, overlay, props]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreate) {
|
||||
props.onDirtyChange?.(isDirty);
|
||||
props.onSaveActionChange?.(handleSave);
|
||||
props.onCancelActionChange?.(handleCancel);
|
||||
}
|
||||
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreate) return;
|
||||
return () => {
|
||||
props.onSaveActionChange?.(null);
|
||||
props.onCancelActionChange?.(null);
|
||||
props.onDirtyChange?.(false);
|
||||
};
|
||||
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
|
||||
|
||||
// ---- Resolve values ----
|
||||
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
||||
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
|
||||
@@ -839,6 +806,45 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
() => environments.find((environment) => environment.id === effectiveLoginEnvironmentId) ?? null,
|
||||
[environments, effectiveLoginEnvironmentId],
|
||||
);
|
||||
const attachedBoat = experimentalSettings !== undefined
|
||||
&& effectiveLoginEnvironment?.status === "active"
|
||||
&& effectiveLoginEnvironment.driver === "computer"
|
||||
&& effectiveLoginEnvironment.config.provider === "boat";
|
||||
const unsupportedBoatCodexEngine = attachedBoat && adapterType === "codex_local"
|
||||
&& (isCreate ? props.values.codexEngine : eff("adapterConfig", "engine", config.engine)) !== "cli";
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isCreate || unsupportedBoatCodexEngine) return;
|
||||
const flushedEnv = flushEnvironmentDraft();
|
||||
const nextOverlay = flushedEnv
|
||||
? {
|
||||
...overlay,
|
||||
adapterConfig: {
|
||||
...overlay.adapterConfig,
|
||||
env: flushedEnv,
|
||||
},
|
||||
}
|
||||
: overlay;
|
||||
if (!isOverlayDirty(nextOverlay)) return;
|
||||
await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay));
|
||||
}, [isCreate, isDirty, overlay, props, unsupportedBoatCodexEngine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreate) {
|
||||
props.onDirtyChange?.(isDirty);
|
||||
props.onSaveActionChange?.(unsupportedBoatCodexEngine ? null : handleSave);
|
||||
props.onCancelActionChange?.(handleCancel);
|
||||
}
|
||||
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel, unsupportedBoatCodexEngine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreate) return;
|
||||
return () => {
|
||||
props.onSaveActionChange?.(null);
|
||||
props.onCancelActionChange?.(null);
|
||||
props.onDirtyChange?.(false);
|
||||
};
|
||||
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
|
||||
|
||||
// Load the sandbox provider capabilities. A login that runs on a real
|
||||
// pseudo-terminal needs a provider that advertises the login pseudo-terminal
|
||||
// capability. The login panel gate reads this to hide the panel for a provider
|
||||
@@ -969,10 +975,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
// every adapter without a per-adapter edit.
|
||||
hideInstructionsFile: hideInstructionsFile || hideHostPaths || isDotRunner,
|
||||
managedSandboxOnly: hideHostPaths || isDotRunner,
|
||||
allowExecutionEngineSelection: experimentalSettings !== undefined
|
||||
&& effectiveLoginEnvironment?.status === "active"
|
||||
&& effectiveLoginEnvironment.driver === "computer"
|
||||
&& effectiveLoginEnvironment.config.provider === "boat",
|
||||
allowExecutionEngineSelection: attachedBoat,
|
||||
openAiDotEnabled: experimentalSettings?.enableOpenAiDot === true,
|
||||
};
|
||||
|
||||
@@ -1101,7 +1104,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const [testActionError, setTestActionError] = useState<string | null>(null);
|
||||
const testActionLabel = "Test";
|
||||
const isSavePending = !isCreate && Boolean(props.isSaving);
|
||||
const testEnvironmentDisabled = testActionPending || isSavePending || !selectedCompanyId;
|
||||
const testEnvironmentDisabled = testActionPending || isSavePending || !selectedCompanyId || unsupportedBoatCodexEngine;
|
||||
|
||||
// Drop a stale Test result when the adapter type or the effective environment
|
||||
// changes. A held result would keep the login affordance visible for a target
|
||||
@@ -1406,7 +1409,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
<div className="sticky top-0 z-10 flex items-center justify-end border-b border-primary/20 bg-background/90 px-4 py-2 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
||||
<Button size="sm" onClick={handleSave} disabled={props.isSaving}>
|
||||
<Button size="sm" onClick={handleSave} disabled={props.isSaving || unsupportedBoatCodexEngine}>
|
||||
{props.isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1458,7 +1461,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!isCreate && props.isSaving}
|
||||
disabled={(!isCreate && props.isSaving) || unsupportedBoatCodexEngine}
|
||||
>
|
||||
{!isCreate && props.isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
|
||||
@@ -146,7 +146,8 @@ function Setup({
|
||||
const [effort, setEffort] = useState("");
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [environmentOverride, setEnvironmentOverride] = useState("");
|
||||
const [boatEngine, setBoatEngine] = useState<"cli" | "acp" | undefined>();
|
||||
const [boatEngines, setBoatEngines] = useState<Partial<Record<string, "cli" | "acp">>>({});
|
||||
const boatEngine = boatEngines[adapterType];
|
||||
const [provider, setProvider] = useState("openrouter");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [providerBinding, setProviderBinding] = useState<EnvBinding | null>(
|
||||
@@ -352,12 +353,12 @@ function Setup({
|
||||
value={boatEngine ?? "cli"}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setBoatEngine(event.target.value === "acp" ? "acp" : "cli");
|
||||
setBoatEngines(current => ({ ...current, [adapterType]: adapterType !== "codex_local" && event.target.value === "acp" ? "acp" : "cli" }));
|
||||
resetTest();
|
||||
}}
|
||||
>
|
||||
<option value="cli">{adapterType === "codex_local" ? "Codex CLI" : "Claude CLI"}</option>
|
||||
<option value="acp">ACP</option>
|
||||
{adapterType !== "codex_local" && <option value="acp">ACP</option>}
|
||||
</select>
|
||||
</Field>
|
||||
) : null;
|
||||
|
||||
@@ -312,7 +312,7 @@ describe("New agent setup", () => {
|
||||
});
|
||||
it.each([
|
||||
["codex_local", "OpenAI", undefined], ["claude_local", "Claude", undefined],
|
||||
["codex_local", "OpenAI", "acp"], ["claude_local", "Claude", "acp"],
|
||||
["claude_local", "Claude", "acp"],
|
||||
])("shows Boat's CLI default before connecting and preserves an explicit engine for %s (%s, %s)", async (adapter, provider, choice) => {
|
||||
envApi.list.mockResolvedValue([
|
||||
{ id: "boat-1", name: "Boat", driver: "computer", status: "active", config: { provider: "boat" } },
|
||||
@@ -323,6 +323,7 @@ describe("New agent setup", () => {
|
||||
const engine = container.querySelector<HTMLSelectElement>('[aria-label="Execution engine"]')!;
|
||||
expect(engine).not.toBeNull();
|
||||
expect(engine.value).toBe("cli");
|
||||
expect(Array.from(engine.options).some(option => option.value === "acp")).toBe(adapter === "claude_local");
|
||||
expect(engine.selectedOptions[0].textContent).toBe(adapter === "codex_local" ? "Codex CLI" : "Claude CLI");
|
||||
expect(api.testEnvironment).not.toHaveBeenCalled();
|
||||
if (choice) await act(async () => {
|
||||
|
||||
Reference in new issue
Block a user