From 88dddb8298937ad262604aa38817878cb2bf93d0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 6 Oct 2026 21:21:18 -0500 Subject: [PATCH] feat(dot): add a saved experimental setting with prerequisites Co-Authored-By: Paperclip --- doc/openai-dot-runner.md | 6 ++- packages/shared/src/feature-catalog.ts | 7 ++++ packages/shared/src/types/instance.ts | 2 + packages/shared/src/validators/instance.ts | 1 + server/src/__tests__/dot-runner.test.ts | 40 +++++++++++++++++-- .../instance-settings-service.test.ts | 9 +++++ server/src/routes/agents.ts | 3 +- server/src/routes/dot-runner.ts | 2 +- server/src/services/dot-runner-broker.ts | 17 +++++--- server/src/services/heartbeat.ts | 2 + server/src/services/instance-settings.ts | 2 + .../services/native-runtime/runtime-mode.ts | 6 ++- server/src/services/public-mcp/oauth.ts | 5 ++- .../codex-local/config-fields.test.tsx | 7 +++- ui/src/adapters/codex-local/config-fields.tsx | 3 +- ui/src/adapters/types.ts | 2 + ui/src/components/AgentConfigForm.tsx | 3 ++ ui/src/components/DotRunnerConnection.tsx | 2 +- .../InstanceExperimentalSettings.test.tsx | 14 +++++++ ui/src/pages/InstanceExperimentalSettings.tsx | 12 ++++++ 20 files changed, 126 insertions(+), 19 deletions(-) diff --git a/doc/openai-dot-runner.md b/doc/openai-dot-runner.md index faaec5bf4a3..6ae6ad149c1 100644 --- a/doc/openai-dot-runner.md +++ b/doc/openai-dot-runner.md @@ -13,8 +13,10 @@ controller deployments are not qualified. The feature is off by default. ## Enable and pair 1. Configure `PAPERCLIP_PUBLIC_URL` to the instance's stable HTTPS origin and - enable Public MCP and Paperclip Runner in experimental settings. Set - `PAPERCLIP_ENABLE_OPENAI_DOT=1` on the server. + enable **Assistant connections (MCP)**, **Paperclip Runner**, and **OpenAI Dot** + in **Instance settings → Experimental**. The instance must use authenticated + sign-in. These settings are saved and take effect without a restart; the old + `PAPERCLIP_ENABLE_OPENAI_DOT` environment flag no longer enables the provider. 2. Create an approved Paperclip Runner agent with provider **OpenAI Dot**. Acknowledge that its provider billing is external and unmetered. Save it. 3. In the agent configuration, choose **Pair Dot**. Connect a private ChatGPT diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 9cb47d5c2b5..332d2740d93 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -120,6 +120,13 @@ export const INSTANCE_FEATURE_CATALOG: Record { let temporary: Awaited>; @@ -37,14 +38,45 @@ describe("durable Dot Runner integration", () => { temporary = await startEmbeddedPostgresTestDatabase("paperclip-dot-runner-"); db = createDb(temporary.connectionString); root = await mkdtemp(join(tmpdir(), "paperclip-dot-state-")); - vi.stubEnv("PAPERCLIP_ENABLE_OPENAI_DOT", "1"); vi.stubEnv("PAPERCLIP_RUNNER_BINARY", join(runnerRoot, "runner/target/release", process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd")); vi.stubEnv("PAPERCLIP_RUNNER_STATE_DIR", join(root, "runner-state")); vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64")); - await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true }); + await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true, enableNativeRunner: true }); }, 360000); afterAll(async () => { await temporary?.cleanup(); await rm(root, { recursive: true, force: true }); vi.unstubAllEnvs(); }); + it("requires each persisted prerequisite for pairing and new work without relying on the retired environment flag", async () => { + const settings = instanceSettingsService(db); + const broker = dotRunnerBroker(db); + for (const key of ["enableOpenAiDot", "enablePublicMcp", "enableNativeRunner"] as const) { + await settings.updateExperimental({ [key]: false }); + try { + vi.stubEnv("PAPERCLIP_ENABLE_OPENAI_DOT", "1"); + expect(await broker.enabled()).toBe(false); + await expect(broker.createPairing({ companyId: randomUUID(), agentId: randomUUID(), operatorId: randomUUID() })).rejects.toThrow("experimental settings"); + } finally { await settings.updateExperimental({ [key]: true }); } + } + vi.stubEnv("PAPERCLIP_ENABLE_OPENAI_DOT", "0"); + expect(await broker.enabled()).toBe(true); + }); + + it("gates fresh Dot runtime selection while preserving the existing run recovery path", () => { + const input = { + enabled: true, + runtimeConfig: {}, + adapterConfig: { provider: "openai_dot", dotBindingId: randomUUID(), allowUnmeteredProvider: true }, + agent: { status: "active", adapterType: "paperclip_runner" }, + issue: { id: randomUUID(), workMode: "standard" }, + target: { kind: "local" }, + workspaceId: null, + }; + expect(() => resolveNativeRuntimeMode(input)).toThrow("experimental settings"); + expect(resolveNativeRuntimeMode({ ...input, dotEnabled: true })).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } }); + expect(resolveHeartbeatNativeRuntimeMode({ ...input, enabled: false, dotEnabled: false, + persisted: { runtimeMode: "native", runtimeModeReason: null, runtimeModeResolvedAt: new Date(), driverKind: "openai_dot_mcp" }, + })).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } }); + }); + async function fixture() { const userId = randomUUID(); await db.insert(authUsers).values({ id: userId, name: "Dot operator", email: userId + "@example.test", createdAt: new Date(), updatedAt: new Date() }); @@ -240,11 +272,11 @@ describe("durable Dot Runner integration", () => { const renewed = await f.oauth.token({ grant_type: "refresh_token", client_id: client.client_id, resource: f.oauth.config.resource, refresh_token: tokens.refresh_token }); expect((await f.oauth.authenticate(renewed.access_token)).grant.purpose).toBe("agent"); await expect(f.oauth.token(exchange)).rejects.toMatchObject({ code: "invalid_grant" }); - vi.stubEnv("PAPERCLIP_ENABLE_OPENAI_DOT", "0"); + await instanceSettingsService(db).updateExperimental({ enableOpenAiDot: false }); try { expect((await request(app).get("/.well-known/oauth-protected-resource/mcp/runner")).status).toBe(503); expect((await request(app).get("/.well-known/oauth-protected-resource/mcp/paperclip")).status).toBe(200); - } finally { vi.stubEnv("PAPERCLIP_ENABLE_OPENAI_DOT", "1"); } + } finally { await instanceSettingsService(db).updateExperimental({ enableOpenAiDot: true }); } await f.events.unsubscribe(f.principal, f.subscription); }, 30000); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 8ba1de4eb02..0b92d916e98 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -7,6 +7,14 @@ import { } from "../services/instance-settings.js"; describe("instance settings service", () => { + it("keeps OpenAI Dot opt-in and persists its setting independently of its prerequisites", () => { + expect(normalizeExperimentalSettings({}).enableOpenAiDot).toBe(false); + const enabled = applyExperimentalSettingsPatch({}, { enableOpenAiDot: true }); + expect(normalizeExperimentalSettings(JSON.parse(JSON.stringify(enabled)))).toMatchObject({ + enableOpenAiDot: true, enablePublicMcp: false, + }); + expect(applyExperimentalSettingsPatch(enabled, { enableOpenAiDot: false }).enableOpenAiDot).toBe(false); + }); it("keeps chat connectors opt-in across legacy storage and patches without disabling Apps", () => { for (const stored of [undefined, {}, { enableApps: true }, { enableConferenceRoomChat: true }]) { expect(normalizeExperimentalSettings(stored).enableChatConnectors).toBe(false); @@ -46,6 +54,7 @@ describe("instance settings service", () => { enableMcpAggregators: true, enableAgentChat: false, enablePublicMcp: false, + enableOpenAiDot: false, enableCombinedInboxTasks: false, enableChatConnectors: false, enableMemoryConnectors: false, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 2c40e1f0dfb..dea25efb45f 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3602,11 +3602,12 @@ export function agentRoutes( : inputAdapterConfig; } if (type === "paperclip_runner" && inputAdapterConfig.provider === "openai_dot") { + const dotEnabled = await dotRunnerBroker(db).enabled(); const binding = savedAgentId ? await dotRunnerBroker(db).bindingForAgent(companyId, savedAgentId) : null; let resource: ReturnType = null; try { resource = publicMcpConfig(process.env); } catch { /* diagnostic below */ } const checks: AdapterEnvironmentCheck[] = [ - { code: "dot_enabled", level: dotRunnerBroker(db).enabled() ? "info" : "error", message: dotRunnerBroker(db).enabled() ? "Dot is enabled." : "Enable PAPERCLIP_ENABLE_OPENAI_DOT=1 on the server." }, + { code: "dot_enabled", level: dotEnabled ? "info" : "error", message: dotEnabled ? "Dot is enabled." : "Enable OpenAI Dot, Paperclip Runner, and Assistant connections (MCP) in experimental settings." }, { code: "dot_public_endpoint", level: resource?.origin.startsWith("https://") ? "info" : "error", message: resource?.origin.startsWith("https://") ? "Public HTTPS MCP origin is configured." : "Configure a stable public HTTPS PAPERCLIP_PUBLIC_URL." }, { code: "dot_unmetered", level: inputAdapterConfig.allowUnmeteredProvider === true ? "info" : "error", message: "Dot usage and provider cost are unavailable. Explicit externally billed provider acknowledgement is required." }, { code: "dot_binding", level: binding?.status === "ready" && binding.subscriptionVerified && binding.id === inputAdapterConfig.dotBindingId ? "info" : "warn", message: binding?.status === "ready" && binding.subscriptionVerified ? "Binding has a verified event subscription and completed readiness challenge." : "Save this agent, pair it, subscribe to mailbox events and complete the event test." }, diff --git a/server/src/routes/dot-runner.ts b/server/src/routes/dot-runner.ts index 4e21bf8b8e7..11b8c8a1fd2 100644 --- a/server/src/routes/dot-runner.ts +++ b/server/src/routes/dot-runner.ts @@ -23,7 +23,7 @@ export function dotRunnerRoutes(db: Db, resourceUrl?: string) { router.get(path, async (req, res) => { const input = scope(req); const config = resourceUrl ? null : publicMcpConfig(process.env); - res.json({ enabled: broker.enabled(), resourceUrl: resourceUrl ?? (config ? config.origin + "/mcp/runner" : null), binding: await broker.bindingForAgent(input.companyId, input.agentId) }); + res.json({ enabled: await broker.enabled(), resourceUrl: resourceUrl ?? (config ? config.origin + "/mcp/runner" : null), binding: await broker.bindingForAgent(input.companyId, input.agentId) }); }); router.post(path, async (req, res) => { const input = scope(req, true); diff --git a/server/src/services/dot-runner-broker.ts b/server/src/services/dot-runner-broker.ts index 50fb8d5320e..bc52e368095 100644 --- a/server/src/services/dot-runner-broker.ts +++ b/server/src/services/dot-runner-broker.ts @@ -9,9 +9,9 @@ import { McpOAuthError, type McpPrincipal } from "./public-mcp/oauth.js"; import type { PublicMcpToolExtension } from "./public-mcp/dot-runner.js"; import { boardAuthService } from "./board-auth.js"; import { logActivity } from "./activity-log.js"; +import { instanceSettingsService } from "./instance-settings.js"; const hash = (text: string) => createHash("sha256").update(text).digest("hex"); -const enabled = () => process.env.PAPERCLIP_ENABLE_OPENAI_DOT === "1"; const fail = (message: string) => new McpOAuthError("access_denied", message, 409); const instances = new WeakMap>(); export function dotRunnerBroker(db: Db) { @@ -21,6 +21,11 @@ export function dotRunnerBroker(db: Db) { } function createBroker(db: Db) { + const settings = instanceSettingsService(db); + async function enabled() { + const experimental = await settings.getExperimental(); + return experimental.enableOpenAiDot && experimental.enableNativeRunner && experimental.enablePublicMcp; + } const ports = new Map Promise; revoke?: () => Promise }>(); async function principalBinding(principal: McpPrincipal, requireReady = true, allowPaused = false) { if (principal.grant.purpose !== "agent" || !principal.grant.scopes.includes("paperclip:agent")) throw fail("Use the dedicated Dot agent connection."); @@ -87,7 +92,7 @@ function createBroker(db: Db) { return { enabled, async createPairing(input: { companyId: string; agentId: string; operatorId: string; dotUrl?: string }) { - if (!enabled()) throw fail("OpenAI Dot is disabled on this instance."); + if (!await enabled()) throw fail("Enable OpenAI Dot, Paperclip Runner, 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 => { @@ -104,7 +109,7 @@ function createBroker(db: Db) { instructions: "Connect the private Paperclip Dot plugin at /mcp/runner, approve agent access, then call paperclip_dot_pair with this code. Subscribe to paperclip.dot.mailbox_updated for the returned binding. Run the event test before assigning work." }; }, async pair(principal: McpPrincipal, code: string) { - if (!enabled() || principal.grant.purpose !== "agent" || !principal.grant.scopes.includes("paperclip:agent")) throw fail("A dedicated Dot agent grant is required."); + if (!await enabled() || principal.grant.purpose !== "agent" || !principal.grant.scopes.includes("paperclip:agent")) throw fail("A dedicated Dot agent grant is required."); return db.transaction(async tx => { const [b] = await tx.select().from(bindings).where(and(eq(bindings.pairingCodeHash, hash(code)), eq(bindings.companyId, principal.grant.companyId), eq(bindings.operatorId, principal.grant.userId), isNull(bindings.revokedAt))).for("update"); @@ -135,7 +140,7 @@ function createBroker(db: Db) { assignment: active ? { ...active, attentionRequired: active.status === "accepted" && active.lastActivityAt.getTime() < Date.now() - 15 * 60_000 } : null }; }, async snapshot(companyId: string, agentId: string, bindingId: string): Promise { - if (!enabled()) throw fail("OpenAI Dot is disabled for new work."); + if (!await enabled()) throw fail("OpenAI Dot is disabled for new work."); const state = await this.bindingForAgent(companyId, agentId); if (!state || state.id !== bindingId || state.status !== "ready" || !state.subscriptionVerified) throw fail("Dot must be paired with a verified event subscription and a completed event test."); const [binding] = await db.select().from(bindings).where(eq(bindings.id, bindingId)); @@ -196,7 +201,7 @@ function createBroker(db: Db) { async requestWork(principal: McpPrincipal, issueId: string, requestId: string) { const b = await principalBinding(principal); if (!z.uuid().safeParse(requestId).success) throw fail("Use a stable UUID requestId."); - if (!enabled() || !(await this.bindingForAgent(b.companyId, b.agentId))?.subscriptionVerified) throw fail("Dot admission is unavailable."); + if (!await enabled() || !(await this.bindingForAgent(b.companyId, b.agentId))?.subscriptionVerified) throw fail("Dot admission is unavailable."); const [issue] = await db.select().from(issues).where(and(eq(issues.companyId, b.companyId), eq(issues.id, issueId), eq(issues.assigneeAgentId, b.agentId))); if (!issue || !["todo", "in_progress"].includes(issue.status)) throw fail("Request work only for an eligible task assigned to this agent."); const key = `dot-work:${b.id}:${b.generation}:${requestId}`; @@ -237,7 +242,7 @@ function createBroker(db: Db) { }, async challenge(companyId: string, agentId: string) { const state = await this.bindingForAgent(companyId, agentId); - if (!enabled() || !state || !state.subscriptionVerified) throw fail("Connect and subscribe the Dot first."); + if (!await enabled() || !state || !state.subscriptionVerified) throw fail("Connect and subscribe the Dot first."); const nonce = randomBytes(24).toString("base64url"); await db.transaction(async tx => { const [b] = await tx.select().from(bindings).where(eq(bindings.id, state.id)).for("update"); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index a3fa82ff5af..02df1e41ed9 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -23710,6 +23710,8 @@ export function heartbeatService( persisted: run, enabled: resolvedInstanceSettings.experimental.enableNativeRunner === true, + dotEnabled: resolvedInstanceSettings.experimental.enableOpenAiDot === true + && resolvedInstanceSettings.experimental.enablePublicMcp === true, runtimeConfig: agent.runtimeConfig, adapterConfig: agent.adapterConfig, agent: { diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 4954b0dacd3..e27fa1aa05c 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -234,6 +234,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableApps: true, enableMcpAggregators: true, enablePublicMcp: parsed.data.enablePublicMcp ?? false, + enableOpenAiDot: parsed.data.enableOpenAiDot ?? false, enableChatConnectors: parsed.data.enableChatConnectors ?? false, enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false, enablePipelines: parsed.data.enablePipelines ?? false, @@ -280,6 +281,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableApps: true, enableMcpAggregators: true, enablePublicMcp: false, + enableOpenAiDot: false, enableChatConnectors: false, enableMemoryConnectors: false, enablePipelines: false, diff --git a/server/src/services/native-runtime/runtime-mode.ts b/server/src/services/native-runtime/runtime-mode.ts index 20ca864b6cc..2144ddbcfbe 100644 --- a/server/src/services/native-runtime/runtime-mode.ts +++ b/server/src/services/native-runtime/runtime-mode.ts @@ -97,6 +97,7 @@ function ineligible( export function resolveNativeRuntimeMode(input: { enabled: boolean; + dotEnabled?: boolean; runtimeConfig: unknown; adapterConfig?: unknown; agent: { id?: string; status: string; adapterType: string | null }; @@ -131,7 +132,7 @@ export function resolveNativeRuntimeMode(input: { } throw error; } - if (runnerProfile.provider === "openai_dot" && process.env.PAPERCLIP_ENABLE_OPENAI_DOT !== "1") throw ineligible("paperclip_runner_dot_disabled", "OpenAI Dot is disabled for new work on this instance."); + if (runnerProfile.provider === "openai_dot" && input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work."); if ( input.agent.adapterType !== "paperclip_runner" || input.agent.status !== "active" && input.agent.status !== "running" @@ -183,6 +184,7 @@ export function resolveHeartbeatRuntimeMode(input: { runtimeModeResolvedAt: Date | null; }; enabled: boolean; + dotEnabled?: boolean; adapterType: string | null; adapterConfig: unknown; agentStatus: string; @@ -209,6 +211,7 @@ export function resolveHeartbeatRuntimeMode(input: { try { resolution = resolveNativeRuntimeMode({ enabled: input.enabled, + dotEnabled: input.dotEnabled, runtimeConfig: {}, adapterConfig: input.adapterConfig, agent: { @@ -264,6 +267,7 @@ export function resolveHeartbeatNativeRuntimeMode(input: { driverKind?: string | null; }; enabled: boolean; + dotEnabled?: boolean; runtimeConfig: unknown; adapterConfig?: unknown; agent: { id?: string; status: string; adapterType: string | null }; diff --git a/server/src/services/public-mcp/oauth.ts b/server/src/services/public-mcp/oauth.ts index 72b9aba44c3..96fed237dc9 100644 --- a/server/src/services/public-mcp/oauth.ts +++ b/server/src/services/public-mcp/oauth.ts @@ -71,7 +71,10 @@ export function createPublicMcpOAuth(db: Db, config: PublicMcpConfig, options: { const issuer = agentConnection ? config.origin + DOT_RUNNER_MCP_PATH + "/oauth" : config.origin; const resolveMetadata = createClientMetadataResolver(options.metadataFetch); const settings = instanceSettingsService(db); - const isEnabled = async (queryDb: Db = db) => (!agentConnection || process.env.PAPERCLIP_ENABLE_OPENAI_DOT === "1") && (await (queryDb === db ? settings : instanceSettingsService(queryDb)).getExperimental()).enablePublicMcp === true; + const isEnabled = async (queryDb: Db = db) => { + const experimental = await (queryDb === db ? settings : instanceSettingsService(queryDb)).getExperimental(); + return experimental.enablePublicMcp && (!agentConnection || experimental.enableOpenAiDot); + }; async function assertEnabled(queryDb: Db = db) { if (!await isEnabled(queryDb)) throw new PublicMcpDisabledError(); } diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 8c9f221e109..a5e0589601b 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -25,7 +25,7 @@ async function renderMarkup(node: ReactNode, expand?: string): Promise { return html; } -async function renderRunner(config: Record, expand?: string): Promise { +async function renderRunner(config: Record, expand?: string, openAiDotEnabled = false): Promise { return renderMarkup( , expand?: string): P mark={() => undefined} models={[]} hideInstructionsFile + openAiDotEnabled={openAiDotEnabled} /> , expand, @@ -46,6 +47,10 @@ async function renderRunner(config: Record, expand?: string): P } describe("Paperclip Runner Codex configuration", () => { + it("only offers Dot for new selections when its prerequisites are enabled", async () => { + expect(await renderRunner({ provider: "codex" }, "Harness")).not.toContain("OpenAI Dot"); + expect(await renderRunner({ provider: "codex" }, "Harness", true)).toContain("OpenAI Dot"); + }); it.each([ [undefined, "Full auto (approve all)"], ["approve-paperclip", "Automatic Paperclip actions"], diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index d790a0671be..e0c103d44dc 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -63,6 +63,7 @@ export function CodexLocalConfigFields({ models, hideInstructionsFile, managedSandboxOnly, + openAiDotEnabled, }: AdapterConfigFieldsProps) { const runnerManaged = adapterType === "paperclip_runner"; // The execution engine picks which binary runs on the execution host, and the @@ -252,7 +253,7 @@ export function CodexLocalConfigFields({ > - {runnerHarnessOptions.map((option) => ( + {runnerHarnessOptions.filter((option) => option.value !== "openai_dot" || openAiDotEnabled || runnerProvider === "openai_dot").map((option) => ( {option.label} diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts index a67aca136f0..d45d7486de5 100644 --- a/ui/src/adapters/types.ts +++ b/ui/src/adapters/types.ts @@ -51,6 +51,8 @@ export interface AdapterConfigFieldsProps { * so a stored path never flashes before the policy resolves. */ managedSandboxOnly?: boolean; + /** Show Dot for new selections only when its experimental prerequisites are enabled. */ + openAiDotEnabled?: boolean; } export interface UIAdapterModule extends TranscriptParserSource { diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 6dbd7dbff6c..49b3816b9a0 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -966,6 +966,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // every adapter without a per-adapter edit. hideInstructionsFile: hideInstructionsFile || hideHostPaths || isDotRunner, managedSandboxOnly: hideHostPaths || isDotRunner, + openAiDotEnabled: experimentalSettings?.enableOpenAiDot === true + && experimentalSettings?.enablePublicMcp === true + && experimentalSettings?.enableNativeRunner === true, }; // Section toggle state — advanced always starts collapsed diff --git a/ui/src/components/DotRunnerConnection.tsx b/ui/src/components/DotRunnerConnection.tsx index 23ee5bb5877..cf1bfa37dfe 100644 --- a/ui/src/components/DotRunnerConnection.tsx +++ b/ui/src/components/DotRunnerConnection.tsx @@ -41,7 +41,7 @@ export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding } const b = state.data?.binding; return

Dot manages its model and external tools. Paperclip supplies task tools through a dedicated agent connection. Provider usage and cost are unavailable; stopping a Paperclip run revokes access without confirming an external stop.

- {state.data && !state.data.enabled &&

This instance has not enabled OpenAI Dot.

} + {state.data && !state.data.enabled &&

Enable OpenAI Dot, Paperclip Runner, and Assistant connections (MCP) in experimental settings.

} {state.data?.resourceUrl &&

Private plugin MCP URL: {state.data.resourceUrl}

} {b &&

Connection: {b.status}. Event subscription: {b.subscriptionVerified ? "verified" : "required"}.

} {b?.assignment &&

Assignment: {b.assignment.status === "offered" ? "waiting for Dot to accept" : "accepted by Dot"}.

} diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 5656f7b6f2f..d51e3ca49c3 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -86,6 +86,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableApps: true, enableMcpAggregators: true, enablePublicMcp: false, + enableOpenAiDot: false, enableChatConnectors: false, enableMemoryConnectors: false, enablePipelines: false, @@ -269,6 +270,19 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) } }); + it("names Dot prerequisites and saves its opt-in independently in both directions", async () => { + await renderPage(); + const selector = 'button[aria-label="Toggle OpenAI Dot experimental setting"]'; + expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false"); + expect(container.textContent).toContain("Requires Paperclip Runner, Assistant connections (MCP), and an authenticated instance with a public HTTPS URL"); + for (const enabled of [true, false]) { + await act(() => container.querySelector(selector)!.click()); + await flushReact(); + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableOpenAiDot: enabled }); + expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe(String(enabled)); + } + }); + it("defaults chat connectors off and persists an explicit toggle in both directions", async () => { await renderPage(); const selector = 'button[aria-label="Toggle chat connectors experimental setting"]'; diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index f6b77ded435..b8cc584cb71 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -469,6 +469,18 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle memory connectors experimental setting" /> + toggleMutation.mutate({ enableOpenAiDot: checked })} + disabled={toggleMutation.isPending} + settingKey="enableOpenAiDot" + managed={managedKeys.enableOpenAiDot} + ariaLabel="Toggle OpenAI Dot experimental setting" + /> +