From 5a1bdea4ca207ec64e024b6dbb1172e97f8697f1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 7 Oct 2026 17:52:55 -0500 Subject: [PATCH] fix(dot): honor standalone rollout in imports and dev startup Co-Authored-By: Paperclip --- doc/openai-dot-runner.md | 5 +++ .../src/__tests__/company-portability.test.ts | 29 ++++++++++++++++- server/src/dev-native-runner-status.ts | 3 +- server/src/services/company-portability.ts | 31 +++++++++++++------ server/src/services/dot-runner-broker.ts | 2 +- 5 files changed, 58 insertions(+), 12 deletions(-) diff --git a/doc/openai-dot-runner.md b/doc/openai-dot-runner.md index 51f60defdb..fa067da97f 100644 --- a/doc/openai-dot-runner.md +++ b/doc/openai-dot-runner.md @@ -65,6 +65,11 @@ Database migration `0312_broken_sentinels.sql` adds only Dot tables and extensio after the merged gateway migrations. It is safe to reapply. The earlier prototype migration number is retired; published master migrations are intact. +Company package imports use the same independent Dot option. An unpaired imported +Dot can be saved after the external billing acknowledgement; pair it before +assigning work. The dev launcher also prepares the shared Runner binary when +Dot is enabled, even when the general Runner option is off. + ## Troubleshoot plugin OAuth discovery Use the exact server URL `/mcp/runner` with OAuth authentication. The diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 70141793f4..beed0ab71e 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -103,7 +103,7 @@ const agentInstructionsSvc = { }; const instanceSettingsSvc = { - getExperimental: vi.fn(async () => ({ enableNativeRunner: false })), + getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })), }; const managedAgentProfileSvc = { @@ -6030,6 +6030,33 @@ describe("company portability", () => { expect(preview.plan.issuePlans).toHaveLength(0); }); + it("imports an unpaired Dot using its own option without enabling other Runner providers", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } }); + agentSvc.list.mockResolvedValue([]); + agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ id: "agent-created", ...input })); + const input = { + source: { type: "inline" as const, rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: true, projects: false, issues: false }, + target: { mode: "existing_company" as const, companyId: "company-1" }, + agents: "all" as const, collisionStrategy: "rename" as const, + adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true, lifecycleMode: "per_turn" } } }, + }; + instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false }); + await expect(portability.importBundle(input, "user-1")).rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_dot_disabled" } }); + expect(agentSvc.create).not.toHaveBeenCalled(); + instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true }); + await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } } } }, "user-1")) + .rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_rollout_disabled" } }); + expect(agentSvc.create).not.toHaveBeenCalled(); + await portability.importBundle(input, "user-1"); + expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ provider: "openai_dot", allowUnmeteredProvider: true }) })); + const createdConfig = agentSvc.create.mock.calls[0]![1].adapterConfig; + expect(createdConfig.dotBindingId).toBeUndefined(); + const { resolvePaperclipRunnerProviderProfile } = await import("../services/native-runtime/provider-profile.js"); + expect(() => resolvePaperclipRunnerProviderProfile(createdConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" })); + }); + it("rejects runner imports while disabled and accepts the same selection when enabled", async () => { const portability = companyPortabilityService({} as any); const exported = await portability.exportBundle("company-1", { diff --git a/server/src/dev-native-runner-status.ts b/server/src/dev-native-runner-status.ts index 6054c0dc48..d0e99be5a5 100644 --- a/server/src/dev-native-runner-status.ts +++ b/server/src/dev-native-runner-status.ts @@ -49,8 +49,9 @@ async function main(): Promise { console.log( JSON.stringify({ nativeRunnerRequired: - experimental.enableNativeRunner === true || persistedNativeRun, + experimental.enableNativeRunner === true || experimental.enableOpenAiDot === true || persistedNativeRun, rolloutEnabled: experimental.enableNativeRunner === true, + dotRolloutEnabled: experimental.enableOpenAiDot === true, persistedNativeRun, persistedActiveNativeRun, persistedRetryableFailedNativeRun, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 701894a761..ade0c7ba11 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -109,6 +109,7 @@ import type { import { PaperclipRunnerProviderProfileError, resolvePaperclipRunnerProviderProfile, + validatePaperclipRunnerDotConfig, } from "./native-runtime/provider-profile.js"; import { managedAgentProfileService } from "./managed-agent-profiles.js"; import { remoteAgentProfileService } from "./remote-agent-profiles.js"; @@ -3600,6 +3601,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (adapterType === "paperclip_runner") { let profile; try { + if (adapterConfig.provider === "openai_dot") { + validatePaperclipRunnerDotConfig(adapterConfig, false); + return; + } profile = resolvePaperclipRunnerProviderProfile(adapterConfig); } catch (error) { if (error instanceof PaperclipRunnerProviderProfileError) { @@ -5248,19 +5253,27 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { .filter((entry) => entry.action !== "skip") .map((entry) => entry.slug), ); - const selectsNativeRunner = sourceManifest.agents.some((agent) => + const runnerSelections = sourceManifest.agents.filter((agent) => importedAgentSlugs.has(agent.slug) && (input.adapterOverrides?.[agent.slug]?.adapterType ?? agent.adapterType) === "paperclip_runner", ); - if ( - selectsNativeRunner - && (await instanceSettingsService(db).getExperimental()).enableNativeRunner !== true - ) { - throw unprocessable( - "Paperclip Runner is experimental and disabled on this instance.", - { code: "paperclip_runner_rollout_disabled" }, - ); + if (runnerSelections.length > 0) { + const experimental = await instanceSettingsService(db).getExperimental(); + for (const agent of runnerSelections) { + const config = input.adapterOverrides?.[agent.slug]?.adapterConfig ?? agent.adapterConfig; + if (config.provider === "openai_dot") { + if (experimental.enableOpenAiDot !== true) throw unprocessable( + "OpenAI Dot is experimental and disabled on this instance.", + { code: "paperclip_runner_dot_disabled" }, + ); + } else if (experimental.enableNativeRunner !== true) { + throw unprocessable( + "Paperclip Runner is experimental and disabled on this instance.", + { code: "paperclip_runner_rollout_disabled" }, + ); + } + } } } diff --git a/server/src/services/dot-runner-broker.ts b/server/src/services/dot-runner-broker.ts index 670e5af72e..5335709138 100644 --- a/server/src/services/dot-runner-broker.ts +++ b/server/src/services/dot-runner-broker.ts @@ -95,7 +95,7 @@ function createBroker(db: Db) { return { enabled, async createPairing(input: { companyId: string; agentId: string; operatorId: string; dotUrl?: string }) { - if (!await enabled()) throw fail("Enable OpenAI Dot, Paperclip Runner, and Assistant connections (MCP) in experimental settings."); + if (!await enabled()) throw fail("Enable OpenAI Dot and Assistant connections (MCP) in experimental settings."); if (input.dotUrl && !/^https:\/\/chatgpt\.com\/dots\/[A-Za-z0-9-]+$/.test(input.dotUrl)) throw fail("Use the Dot's ChatGPT URL."); const code = randomBytes(24).toString("base64url"); const binding = await db.transaction(async tx => {