mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
feat(dot): add a saved experimental setting with prerequisites
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ad1ff9b329
commit
88dddb8298
20 files changed
+126
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -120,6 +120,13 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableOpenAiDot: {
|
||||
title: "OpenAI Dot",
|
||||
description: "Let an OpenAI Dot work as a Paperclip Runner agent through MCP Events. Requires Paperclip Runner, Assistant connections (MCP), and an authenticated instance with a public HTTPS URL.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableChatConnectors: {
|
||||
title: "Chat connectors",
|
||||
description:
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface InstanceExperimentalSettings {
|
||||
enableChatConnectors: boolean;
|
||||
/** Allow person-authorized assistant tools and task event delivery. */
|
||||
enablePublicMcp: boolean;
|
||||
/** Enable the experimental OpenAI Dot provider and its dedicated agent MCP connection. */
|
||||
enableOpenAiDot: boolean;
|
||||
/** @deprecated Compatibility key only. MCP aggregators are always enabled. */
|
||||
enableMcpAggregators: boolean;
|
||||
/** Show experimental memory connection setup. Existing connections remain usable. */
|
||||
|
||||
@@ -52,6 +52,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
||||
// configs continue to load during upgrades.
|
||||
enableApps: z.boolean().default(true),
|
||||
enablePublicMcp: z.boolean().default(false),
|
||||
enableOpenAiDot: z.boolean().default(false),
|
||||
enableChatConnectors: z.boolean().default(false),
|
||||
// Compatibility only: old stored and managed values must still parse.
|
||||
enableMcpAggregators: z.boolean().default(true),
|
||||
|
||||
@@ -26,6 +26,7 @@ import { executePaperclipNativeSession } from "../services/native-runtime/native
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { setupRunnerPrpWebSocketServer } from "../realtime/runner-prp-ws.js";
|
||||
import { finalizeNativeRun } from "../services/native-runtime/native-run-finalizer.js";
|
||||
import { resolveHeartbeatNativeRuntimeMode, resolveNativeRuntimeMode } from "../services/native-runtime/runtime-mode.js";
|
||||
|
||||
describe("durable Dot Runner integration", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof publicMcpConfig> = 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." },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Db, ReturnType<typeof createBroker>>();
|
||||
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<string, { token: symbol; send: (op: ExternalProviderOperation) => Promise<void>; revoke?: () => Promise<void> }>();
|
||||
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<DotBindingSnapshot> {
|
||||
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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ async function renderMarkup(node: ReactNode, expand?: string): Promise<string> {
|
||||
return html;
|
||||
}
|
||||
|
||||
async function renderRunner(config: Record<string, unknown>, expand?: string): Promise<string> {
|
||||
async function renderRunner(config: Record<string, unknown>, expand?: string, openAiDotEnabled = false): Promise<string> {
|
||||
return renderMarkup(
|
||||
<TooltipProvider>
|
||||
<CodexLocalConfigFields
|
||||
@@ -39,6 +39,7 @@ async function renderRunner(config: Record<string, unknown>, expand?: string): P
|
||||
mark={() => undefined}
|
||||
models={[]}
|
||||
hideInstructionsFile
|
||||
openAiDotEnabled={openAiDotEnabled}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
expand,
|
||||
@@ -46,6 +47,10 @@ async function renderRunner(config: Record<string, unknown>, 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"],
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label="Harness"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{runnerHarnessOptions.map((option) => (
|
||||
{runnerHarnessOptions.filter((option) => option.value !== "openai_dot" || openAiDotEnabled || runnerProvider === "openai_dot").map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<AdapterMark type={option.adapter} className="size-4" />
|
||||
{option.label}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding }
|
||||
const b = state.data?.binding;
|
||||
return <div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">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.</p>
|
||||
{state.data && !state.data.enabled && <p className="text-sm text-muted-foreground">This instance has not enabled OpenAI Dot.</p>}
|
||||
{state.data && !state.data.enabled && <p className="text-sm text-muted-foreground">Enable OpenAI Dot, Paperclip Runner, and Assistant connections (MCP) in experimental settings.</p>}
|
||||
{state.data?.resourceUrl && <p className="text-sm break-all">Private plugin MCP URL: <code>{state.data.resourceUrl}</code></p>}
|
||||
{b && <p className="text-sm">Connection: {b.status}. Event subscription: {b.subscriptionVerified ? "verified" : "required"}.</p>}
|
||||
{b?.assignment && <p className="text-sm">Assignment: {b.assignment.status === "offered" ? "waiting for Dot to accept" : "accepted by Dot"}.</p>}
|
||||
|
||||
@@ -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<HTMLButtonElement>(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"]';
|
||||
|
||||
@@ -469,6 +469,18 @@ export function InstanceExperimentalSettings() {
|
||||
ariaLabel="Toggle memory connectors experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="OpenAI Dot"
|
||||
description="Let your OpenAI Dot take assignments and use Paperclip task tools through the Runner. Pair the Dot and verify event delivery before assigning work."
|
||||
footnote="Requires Paperclip Runner, Assistant connections (MCP), and an authenticated instance with a public HTTPS URL. Turning this off blocks Dot calls and new work; saved connections are kept."
|
||||
checked={experimentalQuery.data?.enableOpenAiDot === true}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableOpenAiDot: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableOpenAiDot"
|
||||
managed={managedKeys.enableOpenAiDot}
|
||||
ariaLabel="Toggle OpenAI Dot experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Paperclip Runner"
|
||||
description="Allow new Codex agents to select the experimental Rust Paperclip Runner, including authenticated runner ingress when a sandbox requires it. Onboarding continues to use legacy adapters. Turning this off hides the choice without affecting existing native runs."
|
||||
|
||||
Reference in new issue
Block a user