diff --git a/server/src/__tests__/computer-routes.test.ts b/server/src/__tests__/computer-routes.test.ts index df727a7afe..9fcc6c256c 100644 --- a/server/src/__tests__/computer-routes.test.ts +++ b/server/src/__tests__/computer-routes.test.ts @@ -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; + const b = right as Record; + 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); diff --git a/server/src/routes/computers.ts b/server/src/routes/computers.ts index 719848f69a..591db369f0 100644 --- a/server/src/routes/computers.ts +++ b/server/src/routes/computers.ts @@ -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; diff --git a/ui/src/adapters/claude-local/config-fields.tsx b/ui/src/adapters/claude-local/config-fields.tsx index 82be8f6a7b..c012747b90 100644 --- a/ui/src/adapters/claude-local/config-fields.tsx +++ b/ui/src/adapters/claude-local/config-fields.tsx @@ -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) && + {(!managedSandboxOnly || allowExecutionEngineSelection) && + {allowExecutionEngineSelection && engine !== "cli" && ( +

This engine is unsupported on Boat. Choose Claude CLI before saving or testing.

+ )}
} {acpSelected && ( <> diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 5ba555ded8..e61f003ead 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -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 ; + if (type === "claude_local" && props.allowExecutionEngineSelection) return ( +
+ +
+ ); const { adapterType, hideInstructionsFile, managedSandboxOnly, allowExecutionEngineSelection } = props; return adapterType === "hermes_gateway" ?
Hermes Gateway fields
@@ -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" }) })); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 144cded17c..6d4b78304c 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -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(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) {
Unsaved changes -
@@ -1461,7 +1463,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 0e623bc77b..f6d4c23ece 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -146,7 +146,7 @@ function Setup({ const [effort, setEffort] = useState(""); const [modelOpen, setModelOpen] = useState(false); const [environmentOverride, setEnvironmentOverride] = useState(""); - const [boatEngines, setBoatEngines] = useState>>({}); + const [boatEngines, setBoatEngines] = useState>>({}); 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(); }} > - {adapterType !== "codex_local" && } ) : null; diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index a5e729d601..9cae84c78a 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -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('[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('[aria-label="Execution engine"]')?.value).toBe(choice ?? "cli"); + expect(container.querySelector('[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 } }, }); });