diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 977cc19d330..98ae8845ccb 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -37,6 +37,11 @@ const mockAgentsApi = vi.hoisted(() => ({ const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn(), })); +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), + getGeneral: vi.fn(), + get: vi.fn(), +})); const mockRoutinesApi = vi.hoisted(() => ({ update: vi.fn(), })); @@ -73,6 +78,10 @@ vi.mock("../api/adapters", () => ({ adaptersApi: mockAdaptersApi, })); +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + vi.mock("../api/routines", () => ({ routinesApi: mockRoutinesApi, })); @@ -268,6 +277,9 @@ describe("CompanyImport", () => { document.body.appendChild(container); mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); mockAgentsApi.list.mockResolvedValue([]); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: false }); + mockInstanceSettingsApi.getGeneral.mockResolvedValue({}); + mockInstanceSettingsApi.get.mockResolvedValue({}); mockAdaptersApi.list.mockResolvedValue([ { type: "claude_local", disabled: false }, { type: "codex_local", disabled: false }, @@ -1059,7 +1071,7 @@ describe("CompanyImport", () => { function lastImportMeta() { const call = mockCompaniesApi.importBundleAsync.mock.calls.at(-1); expect(call).toBeTruthy(); - return call![0] as { adapterOverrides?: Record }; + return call![0] as { adapterOverrides?: Record }> }; } it("keeps manifest adapters and sends no overrides when the user touches nothing", async () => { @@ -1111,6 +1123,7 @@ describe("CompanyImport", () => { }); it("offers Paperclip Runner import configuration after its experimental flag is enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false }); mockAdaptersApi.list.mockResolvedValue([ { type: "claude_local", disabled: false }, { type: "codex_local", disabled: false }, @@ -1124,6 +1137,82 @@ describe("CompanyImport", () => { } }); + it("keeps imported Dot agents and falls back other Runner providers under Dot-only opt-in", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false }, + { type: "paperclip_runner", disabled: false }, + ]); + const preview = buildMixedAdapterPreviewResult(); + preview.manifest.agents[0]!.adapterType = "paperclip_runner"; + preview.manifest.agents[0]!.adapterConfig = { provider: "openai_dot", allowUnmeteredProvider: true }; + preview.manifest.agents[1]!.adapterType = "paperclip_runner"; + preview.manifest.agents[1]!.adapterConfig = { provider: "codex" }; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton((text) => text === "Preview import"); + + expect(findAdapterSelects().map((select) => select.value)).toEqual(["openai_dot", "claude_local"]); + for (const select of findAdapterSelects()) { + const choices = Array.from(select.options).map((option) => option.value); + expect(choices).toContain("openai_dot"); + expect(choices).not.toContain("paperclip_runner"); + } + await clickButton((text) => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides).toEqual({ researcher: { adapterType: "claude_local" } }); + }); + + it("requires billing acknowledgement and serializes the standalone Dot import choice as Runner", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false }, + { type: "paperclip_runner", disabled: false }, + ]); + await previewMixedAdapterPackage(); + const select = findAdapterSelects()[0]!; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!; + setter.call(select, "openai_dot"); + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await settle(); + expect(container.textContent).toContain("Allow externally billed provider"); + expect(container.textContent).not.toContain("Harness"); + await clickButton((text) => text.startsWith("Import 3 file")); + await settle(); + expect(mockCompaniesApi.importBundleAsync).not.toHaveBeenCalled(); + expect(container.textContent).toContain("OpenAI Dot requires acknowledgement of external billing"); + + const billingToggle = Array.from(container.querySelectorAll('button[role="switch"]')) + .find((button) => button.closest("label")?.textContent?.includes("Allow externally billed provider") + || button.parentElement?.textContent?.includes("Allow externally billed provider")); + expect(billingToggle).toBeTruthy(); + await act(async () => billingToggle!.click()); + await flushReact(); + await clickButton((text) => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides?.coder).toEqual({ + adapterType: "paperclip_runner", + adapterConfig: { + provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true, + dotAttachmentAccess: false, dotWorkspaceAccess: false, + }, + }); + }); + + it("does not expose Dot when only the general Runner option is enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false }); + mockAdaptersApi.list.mockResolvedValue([{ type: "paperclip_runner", disabled: false }]); + await previewMixedAdapterPackage(); + for (const select of findAdapterSelects()) { + const choices = Array.from(select.options).map((option) => option.value); + expect(choices).toContain("paperclip_runner"); + expect(choices).not.toContain("openai_dot"); + } + }); + it("falls back to the CEO adapter with a visible warning when a manifest adapter is not installed", async () => { // The destination has no codex_local adapter; the CEO fallback (an empty // agent list defaults to claude_local) takes over — never silently. diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index f7f989c9ec3..f42abd0afdb 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -15,6 +15,7 @@ import { authApi } from "../api/auth"; import { ApiError } from "../api/client"; import { companiesApi, type CompanyImportJobAccepted } from "../api/companies"; import { adaptersApi } from "../api/adapters"; +import { instanceSettingsApi } from "../api/instanceSettings"; import { agentsApi } from "../api/agents"; import { routinesApi } from "../api/routines"; import { sidebarPreferencesApi } from "../api/sidebarPreferences"; @@ -557,10 +558,29 @@ function ConflictResolutionList({ // ── Adapter type options for import ─────────────────────────────────── const FALLBACK_IMPORT_ADAPTER_TYPE = "claude_local"; -const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = listUIAdapters().map((adapter) => ({ - value: adapter.type, - label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type), -})); +const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = [ + ...listUIAdapters().map((adapter) => ({ + value: adapter.type, + label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type), + })), + { value: "openai_dot", label: "OpenAI Dot (experimental)" }, +]; + +// Dot is a picker choice backed by the shared Runner API adapter. +function importAdapterValues(choice: string, sourceConfig?: Record): CreateConfigValues { + return { + ...defaultCreateValues, + adapterType: choice === "openai_dot" ? "paperclip_runner" : choice, + ...(choice === "openai_dot" ? { + adapterSchemaValues: { + provider: "openai_dot", + allowUnmeteredProvider: sourceConfig?.allowUnmeteredProvider === true, + dotAttachmentAccess: sourceConfig?.dotAttachmentAccess === true, + dotWorkspaceAccess: sourceConfig?.dotWorkspaceAccess === true, + }, + } : {}), + }; +} // ── Adapter picker for imported agents ─────────────────────────────── @@ -569,11 +589,14 @@ interface AdapterPickerItem { name: string; /** Adapter type from the package manifest (the source's adapter). */ adapterType: string; + /** Display choice, including the separate Dot provider. */ + adapterChoice: string; + adapterConfig: Record; /** * Set when the manifest adapter is not installed on the destination: the * adapter type the agent falls back to unless the user picks another one. * Null when the manifest adapter is usable here (or availability is unknown, - * which fails open to the manifest adapter except for native runner). + * which fails open to the manifest adapter except for experimental Runner providers). */ fallbackAdapterType: string | null; } @@ -611,9 +634,11 @@ function AdapterPickerList({
{agents.map((agent) => { const selectedType = - adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterType; + adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterChoice; const isExpanded = expandedSlugs.has(agent.slug); - const vals = configValues[agent.slug] ?? { ...defaultCreateValues, adapterType: selectedType }; + const vals = configValues[agent.slug] ?? importAdapterValues( + selectedType, selectedType === agent.adapterChoice ? agent.adapterConfig : undefined, + ); return (
@@ -656,7 +681,7 @@ function AdapterPickerList({ {agent.fallbackAdapterType && (

- source adapter {agent.adapterType} is not installed here — this agent + source adapter {agent.adapterChoice === "openai_dot" ? "OpenAI Dot" : agent.adapterType} is not installed here — this agent will use {adapterLabels[selectedType] ?? getAdapterLabel(selectedType)}

@@ -1028,17 +1053,22 @@ export function CompanyImport() { if (!installedAdapters) return null; return new Set(installedAdapters.filter((a) => !a.disabled).map((a) => a.type)); }, [installedAdapters]); - // Native runner is the one adapter that fails closed in the importer. Other - // adapter choices preserve the importer's existing fail-open behavior when - // availability cannot be read, but Paperclip Runner only appears after the - // server explicitly reports that its experimental flag is enabled. - const nativeRunnerAvailable = - availableAdapterTypes?.has("paperclip_runner") === true; + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + retry: false, + }); + // Each Runner provider choice fails closed until its own opt-in and the + // shared adapter's availability are known. Dot does not enable other providers. + const runnerInstalled = availableAdapterTypes?.has("paperclip_runner") === true; + const nativeRunnerAvailable = runnerInstalled && experimentalSettings?.enableNativeRunner === true; + const dotAvailable = runnerInstalled && experimentalSettings?.enableOpenAiDot === true; const importAdapterOptions = useMemo( - () => IMPORT_ADAPTER_OPTIONS.filter( - (option) => option.value !== "paperclip_runner" || nativeRunnerAvailable, + () => IMPORT_ADAPTER_OPTIONS.filter((option) => + option.value === "paperclip_runner" ? nativeRunnerAvailable + : option.value === "openai_dot" ? dotAvailable : true, ), - [nativeRunnerAvailable], + [nativeRunnerAvailable, dotAvailable], ); const localZipHelpText = @@ -1519,9 +1549,16 @@ export function CompanyImport() { // Reset config values when adapter type changes setAdapterConfigValues((prev) => { const next = { ...prev }; - delete next[slug]; + if (adapterType === "openai_dot" || adapterType === "paperclip_runner") { + next[slug] = importAdapterValues(adapterType); + } else { + delete next[slug]; + } return next; }); + if (adapterType === "openai_dot") { + setAdapterExpandedSlugs((prev) => new Set([...prev, slug])); + } } function handleAdapterToggleExpand(slug: string) { @@ -1539,7 +1576,9 @@ export function CompanyImport() { const currentType = agent ? effectiveAdapterType(agent) : adapterOverrides[slug] ?? "claude_local"; setAdapterConfigValues((prev) => ({ ...prev, - [slug]: { ...(prev[slug] ?? { ...defaultCreateValues, adapterType: currentType }), ...patch }, + [slug]: { ...(prev[slug] ?? importAdapterValues( + currentType, currentType === agent?.adapterChoice ? agent.adapterConfig : undefined, + )), ...patch }, })); } @@ -1589,8 +1628,10 @@ export function CompanyImport() { const adapterAgents = useMemo(() => { if (!importPreview) return []; return importPreview.manifest.agents.map((a) => { + const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot" + ? "openai_dot" : a.adapterType; let fallbackAdapterType: string | null = null; - if (a.adapterType === "paperclip_runner" && !nativeRunnerAvailable) { + if (a.adapterType === "paperclip_runner" && !(adapterChoice === "openai_dot" ? dotAvailable : nativeRunnerAvailable)) { const firstEnabledLegacyAdapter = availableAdapterTypes ? [...availableAdapterTypes].find((type) => type !== "paperclip_runner") ?? null : null; @@ -1603,23 +1644,28 @@ export function CompanyImport() { // The fallback must itself be installed: the CEO's adapter when it is, // else any installed adapter, else null so the manifest adapter stands // and the server's unknown-adapter rejection is the backstop. - fallbackAdapterType = availableAdapterTypes.has(ceoAdapterType) + const selectableTypes = [...availableAdapterTypes].filter((type) => + type !== "paperclip_runner" || nativeRunnerAvailable, + ); + fallbackAdapterType = selectableTypes.includes(ceoAdapterType) ? ceoAdapterType - : [...availableAdapterTypes][0] ?? null; + : selectableTypes[0] ?? null; } return { slug: a.slug, name: a.name, adapterType: a.adapterType, + adapterChoice, + adapterConfig: a.adapterConfig ?? {}, fallbackAdapterType, }; }); - }, [importPreview, availableAdapterTypes, ceoAdapterType, nativeRunnerAvailable]); + }, [importPreview, availableAdapterTypes, ceoAdapterType, nativeRunnerAvailable, dotAvailable]); /** The adapter type an imported agent will actually use: an explicit user pick, else the availability fallback, else the manifest adapter. */ function effectiveAdapterType(agent: AdapterPickerItem): string { - return adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterType; + return adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterChoice; } // Build final adapterOverrides for import request. Only agents that diverge @@ -1631,11 +1677,14 @@ export function CompanyImport() { for (const agent of adapterAgents) { const selectedType = effectiveAdapterType(agent); const configVals = adapterConfigValues[agent.slug]; - if (selectedType === agent.adapterType && !configVals) continue; - const override: CompanyPortabilityAdapterOverride = { adapterType: selectedType }; + if (selectedType === agent.adapterChoice && !configVals) continue; + const apiType = selectedType === "openai_dot" ? "paperclip_runner" : selectedType; + const override: CompanyPortabilityAdapterOverride = { adapterType: apiType }; if (configVals) { - const uiAdapter = getUIAdapter(selectedType); - override.adapterConfig = uiAdapter.buildAdapterConfig(configVals); + if (selectedType === "openai_dot" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) { + throw new Error("OpenAI Dot requires acknowledgement of external billing. Configure the Dot adapter and enable Allow externally billed provider."); + } + override.adapterConfig = getUIAdapter(apiType).buildAdapterConfig(configVals); } overrides[agent.slug] = override; }