Merge final Boat preview and CLI guards into staging candidate

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* commit '0ab48d0c467fad050800157efd9adf9ee5decab0':
  fix(ui): require CLI for legacy Claude on Boat
  fix(computers): select latest task admission for previews
  fix: select task computer by latest lease admission
This commit is contained in:
DottaandPaperclip committed 2026-10-10 15:21:33 -05:00
commit 6571144ff2
7 files changed
+91 -35

No files matched your search

+47 -2
View File
@@ -2,6 +2,8 @@ import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ZodError } from "zod";
import { PgDialect } from "drizzle-orm/pg-core";
import type { SQL } from "drizzle-orm";
import {
agents,
environmentLeases,
@@ -103,10 +105,28 @@ function fixture(
]);
const select = vi.fn(() => ({
from: (table: unknown) => {
let ordering: string[] = [];
const query = {
where: vi.fn(() => query),
orderBy: vi.fn(() => query),
limit: vi.fn(async () => rows.get(table) ?? []),
orderBy: vi.fn((...expressions: SQL[]) => {
ordering = expressions.map(expression => new PgDialect().sqlToQuery(expression).sql);
return query;
}),
limit: vi.fn(async (limit: number) => {
const result = [...(rows.get(table) ?? [])];
if (table === environmentLeases) result.sort((left, right) => {
const a = left as Record<string, unknown>;
const b = right as Record<string, unknown>;
for (const expression of ordering) {
const column = expression.includes('"created_at"') ? "createdAt"
: expression.includes('"updated_at"') ? "updatedAt" : "id";
const compare = String(b[column]).localeCompare(String(a[column]));
if (compare) return compare;
}
return 0;
});
return result.slice(0, limit);
}),
};
return query;
},
@@ -175,6 +195,31 @@ beforeEach(() => {
});
describe("Computer routes", () => {
it.each([true, false])("uses the latest admission despite an older lease's later finalization (latest live=%s)", async latestLive => {
const f = fixture();
const older = { ...f.lease, id: "older", createdAt: "2026-10-10T20:00:00Z", updatedAt: "2026-10-10T20:12:00Z" };
const latest = { ...f.lease, id: "latest", createdAt: "2026-10-10T20:06:00Z", updatedAt: "2026-10-10T20:11:00Z" };
f.rows.set(environmentLeases, [older, latest]);
mocks.getLease.mockImplementation(async (id: string) => id === latest.id ? latest : older);
if (!latestLive) f.computers.preview.mockRejectedValue(new ComputerError("conflict", "Preview owner is not live"));
const response = await request(f.app).post(`${endpoint}/preview`).send({ environmentId, port: 5173 });
expect(response.status).toBe(latestLive ? 200 : 409);
expect(mocks.getLease).toHaveBeenCalledExactlyOnceWith(latest.id);
expect(mocks.owner).toHaveBeenCalledExactlyOnceWith(latest);
expect(f.computers.preview).toHaveBeenCalledTimes(1);
if (!latestLive) expect(response.body.error).toBe("Preview owner is not live");
});
it("breaks equal admission timestamps deterministically by lease ID", async () => {
const f = fixture();
const first = { ...f.lease, id: "00000000-0000-4000-8000-000000000001", createdAt: "2026-10-10T20:00:00Z" };
const last = { ...first, id: "00000000-0000-4000-8000-000000000002" };
f.rows.set(environmentLeases, [first, last]);
mocks.getLease.mockResolvedValue(last);
expect((await request(f.app).post(`${endpoint}/preview`).send({ environmentId, port: 5173 })).status).toBe(200);
expect(mocks.getLease).toHaveBeenCalledExactlyOnceWith(last.id);
});
it("rejects agents and invalid task IDs before looking up a computer", async () => {
const agent = fixture({ actor: "agent" });
expect((await request(agent.app).get(endpoint)).status).toBe(403);
+3 -1
View File
@@ -34,7 +34,9 @@ export function computerRoutes(db: Db, computers = computerService(db)) {
}
const [lease] = await db.select().from(environmentLeases)
.where(and(eq(environmentLeases.companyId, issue.companyId), eq(environmentLeases.issueId, issue.id), eq(environmentLeases.provider, "boat")))
.orderBy(desc(environmentLeases.updatedAt)).limit(1);
// Every computer admission inserts a lease. Finalization can update an older
// lease later, but must not make it the task's current authority again.
.orderBy(desc(environmentLeases.createdAt), desc(environmentLeases.id)).limit(1);
const [agent] = issue.assigneeAgentId ? await db.select().from(agents)
.where(and(eq(agents.companyId, issue.companyId), eq(agents.id, issue.assigneeAgentId))).limit(1) : [];
const environmentId = lease?.environmentId ?? agent?.defaultEnvironmentId ?? (await settings.get()).defaultEnvironmentId;
@@ -95,7 +95,7 @@ export function ClaudeLocalAdvancedFields({
Attached computers can select an engine while the managed policy keeps
every host-path field hidden.
*/}
{(!managedSandboxOnly || allowExecutionEngineSelection) && <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.">
{(!managedSandboxOnly || allowExecutionEngineSelection) && <Field label="Execution engine" hint={allowExecutionEngineSelection ? "Boat requires Claude 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}
value={engine}
@@ -106,10 +106,13 @@ export function ClaudeLocalAdvancedFields({
: mark("adapterConfig", "engine", value === "auto" ? undefined : value);
}}
>
<option value="auto">Default (ACP)</option>
{(!allowExecutionEngineSelection || engine === "auto") && <option value="auto" disabled={allowExecutionEngineSelection}>Default (ACP){allowExecutionEngineSelection ? " — unsupported on Boat" : ""}</option>}
<option value="cli">Claude 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 Claude CLI before saving or testing.</p>
)}
</Field>}
{acpSelected && (
<>
@@ -16,6 +16,7 @@ import { defaultCreateValues } from "./agent-config-defaults";
import { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload";
import { ApiError } from "../api/client";
import { aiConnectionsApi } from "../api/ai-connections";
import { ClaudeLocalConfigFields } from "../adapters/claude-local/config-fields";
import { CodexLocalConfigFields } from "../adapters/codex-local/config-fields";
import type { AdapterConfigFieldsProps } from "../adapters/types";
import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
@@ -112,6 +113,11 @@ vi.mock("../adapters", () => ({
// adapter's fields.
ConfigFields: (props: AdapterConfigFieldsProps) => {
if (type === "paperclip_runner" || (type === "codex_local" && props.allowExecutionEngineSelection)) return <CodexLocalConfigFields {...props} />;
if (type === "claude_local" && props.allowExecutionEngineSelection) return (
<div data-testid="adapter-config-fields" data-managed-sandbox-only={String(props.managedSandboxOnly === true)} data-allow-execution-engine="true">
<ClaudeLocalConfigFields {...props} />
</div>
);
const { adapterType, hideInstructionsFile, managedSandboxOnly, allowExecutionEngineSelection } = props;
return adapterType === "hermes_gateway"
? <div data-testid="hermes-gateway-config-fields">Hermes Gateway fields</div>
@@ -3878,12 +3884,15 @@ 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 => {
it.each([
["codex_local", "Codex", "acp"], ["codex_local", "Codex", undefined],
["claude_local", "Claude", "acp"], ["claude_local", "Claude", undefined],
])("requires explicit CLI repair before saving an unsupported Boat %s engine (%s, %s)", async (adapterType, label, 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 } },
{ adapterType: adapterType as Agent["adapterType"], defaultEnvironmentId: "boat-1", adapterConfig: { engine } },
{ onSaveActionChange: saveActions, showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
@@ -3893,12 +3902,12 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
}
});
await flushReact();
expect(result.container.textContent).toContain("Choose Codex CLI before saving or testing");
expect(result.container.textContent).toContain(`Choose ${label} 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"),
Array.from(select.options).some(option => option.textContent === `${label} CLI`),
)!;
expect(engineSelect.value).toBe(engine ?? "auto");
expect(engineSelect.selectedOptions[0].disabled).toBe(true);
@@ -3907,7 +3916,8 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
engineSelect.value = "cli";
engineSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(result.container.textContent).not.toContain("Choose Codex CLI before saving or testing");
expect(result.container.textContent).not.toContain(`Choose ${label} CLI before saving or testing`);
expect(findButton(result.container, "Test")?.disabled).toBe(false);
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" }) }));
+11 -9
View File
@@ -810,10 +810,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
&& 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 unsupportedBoatLegacyEngine = attachedBoat && (adapterType === "codex_local" || adapterType === "claude_local")
&& (isCreate
? adapterType === "codex_local" ? props.values.codexEngine : props.values.claudeEngine
: eff("adapterConfig", "engine", config.engine)) !== "cli";
const handleSave = useCallback(async () => {
if (isCreate || unsupportedBoatCodexEngine) return;
if (isCreate || unsupportedBoatLegacyEngine) return;
const flushedEnv = flushEnvironmentDraft();
const nextOverlay = flushedEnv
? {
@@ -826,15 +828,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: overlay;
if (!isOverlayDirty(nextOverlay)) return;
await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay));
}, [isCreate, isDirty, overlay, props, unsupportedBoatCodexEngine]);
}, [isCreate, isDirty, overlay, props, unsupportedBoatLegacyEngine]);
useEffect(() => {
if (!isCreate) {
props.onDirtyChange?.(isDirty);
props.onSaveActionChange?.(unsupportedBoatCodexEngine ? null : handleSave);
props.onSaveActionChange?.(unsupportedBoatLegacyEngine ? null : handleSave);
props.onCancelActionChange?.(handleCancel);
}
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel, unsupportedBoatCodexEngine]);
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel, unsupportedBoatLegacyEngine]);
useEffect(() => {
if (isCreate) return;
@@ -1104,7 +1106,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 || unsupportedBoatCodexEngine;
const testEnvironmentDisabled = testActionPending || isSavePending || !selectedCompanyId || unsupportedBoatLegacyEngine;
// 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
@@ -1409,7 +1411,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 || unsupportedBoatCodexEngine}>
<Button size="sm" onClick={handleSave} disabled={props.isSaving || unsupportedBoatLegacyEngine}>
{props.isSaving ? "Saving..." : "Save"}
</Button>
</div>
@@ -1461,7 +1463,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<Button
size="sm"
onClick={handleSave}
disabled={(!isCreate && props.isSaving) || unsupportedBoatCodexEngine}
disabled={(!isCreate && props.isSaving) || unsupportedBoatLegacyEngine}
>
{!isCreate && props.isSaving ? "Saving..." : "Save"}
</Button>
@@ -146,7 +146,7 @@ function Setup({
const [effort, setEffort] = useState("");
const [modelOpen, setModelOpen] = useState(false);
const [environmentOverride, setEnvironmentOverride] = useState("");
const [boatEngines, setBoatEngines] = useState<Partial<Record<string, "cli" | "acp">>>({});
const [boatEngines, setBoatEngines] = useState<Partial<Record<string, "cli">>>({});
const boatEngine = boatEngines[adapterType];
const [provider, setProvider] = useState("openrouter");
const [apiKey, setApiKey] = useState("");
@@ -352,13 +352,12 @@ function Setup({
className={controlClass}
value={boatEngine ?? "cli"}
disabled={busy}
onChange={(event) => {
setBoatEngines(current => ({ ...current, [adapterType]: adapterType !== "codex_local" && event.target.value === "acp" ? "acp" : "cli" }));
onChange={() => {
setBoatEngines(current => ({ ...current, [adapterType]: "cli" }));
resetTest();
}}
>
<option value="cli">{adapterType === "codex_local" ? "Codex CLI" : "Claude CLI"}</option>
{adapterType !== "codex_local" && <option value="acp">ACP</option>}
</select>
</Field>
) : null;
+6 -11
View File
@@ -311,9 +311,8 @@ describe("New agent setup", () => {
expect(api.hire).not.toHaveBeenCalled();
});
it.each([
["codex_local", "OpenAI", undefined], ["claude_local", "Claude", undefined],
["claude_local", "Claude", "acp"],
])("shows Boat's CLI default before connecting and preserves an explicit engine for %s (%s, %s)", async (adapter, provider, choice) => {
["codex_local", "OpenAI"], ["claude_local", "Claude"],
])("only offers Boat CLI before connecting and saves it for %s (%s)", async (adapter, provider) => {
envApi.list.mockResolvedValue([
{ id: "boat-1", name: "Boat", driver: "computer", status: "active", config: { provider: "boat" } },
]);
@@ -323,23 +322,19 @@ 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(Array.from(engine.options).map(option => option.value)).toEqual(["cli"]);
expect(engine.selectedOptions[0].textContent).toBe(adapter === "codex_local" ? "Codex CLI" : "Claude CLI");
expect(api.testEnvironment).not.toHaveBeenCalled();
if (choice) await act(async () => {
engine.value = choice;
engine.dispatchEvent(new Event("change", { bubbles: true }));
});
await click(provider + "API key");
await fill("API key", "boat-test-key");
await click("Connect");
expect(api.testEnvironment.mock.calls[0][2]).toMatchObject({
environmentId: "boat-1", adapterConfig: { engine: choice ?? "cli" },
environmentId: "boat-1", adapterConfig: { engine: "cli" },
});
expect(container.querySelector<HTMLSelectElement>('[aria-label="Execution engine"]')?.value).toBe(choice ?? "cli");
expect(container.querySelector<HTMLSelectElement>('[aria-label="Execution engine"]')?.value).toBe("cli");
await click("Finish setup");
expect(api.hire.mock.calls[0][1]).toMatchObject({
defaultEnvironmentId: "boat-1", adapterConfig: { engine: choice ?? "cli" },
defaultEnvironmentId: "boat-1", adapterConfig: { engine: "cli" },
runtimeConfig: { heartbeat: { enabled: false } },
});
});