fix(dot): allow independent Dot selection on agent routes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 17:39:39 -05:00
1 parent 72de4984a9
commit 3168b2c0ca
5 files changed
+72 -17

No files matched your search

@@ -67,7 +67,7 @@ const mockApprovalService = vi.hoisted(() => ({
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({ defaultEnvironmentId: null })),
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
getExperimental: vi.fn(async () => ({ enableNativeRunner: false })),
getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })),
}));
const mockManagedAgentProfileService = vi.hoisted(() => ({
@@ -673,6 +673,7 @@ describe("agent routes adapter validation", () => {
});
it("rejects a new paperclip_runner selection while the rollout flag is off", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
const app = await createApp();
const res = await requestApp(app, (baseUrl) =>
request(baseUrl)
@@ -737,19 +738,45 @@ describe("agent routes adapter validation", () => {
expect(mockAgentService.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterConfig: expect.objectContaining({ dotAttachmentAccess: true, dotWorkspaceAccess: false }) }), expect.anything());
});
it.each(["create", "convert"])("saves an unpaired Dot configuration for %s while refusing task admission", async mode => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
it.each(["create", "hire", "convert"])("saves an unpaired Dot configuration independently of the Runner rollout for %s while refusing task admission", async mode => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
const app = await createApp();
const config = { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: true };
const response = await requestApp(app, baseUrl => mode === "create"
? request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Dot", adapterType: "paperclip_runner", adapterConfig: config })
: mode === "hire" ? request(baseUrl).post("/api/companies/company-1/agent-hires").send({ name: "Dot", role: "engineer", adapterType: "paperclip_runner", adapterConfig: config })
: request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterType: "paperclip_runner", replaceAdapterConfig: true, adapterConfig: config }));
expect(response.status, JSON.stringify(response.body)).toBe(mode === "create" ? 201 : 200);
expect(response.body.adapterConfig).toMatchObject(config);
expect(response.body.adapterConfig.model).toBeUndefined();
expect(response.body.adapterConfig.codexPermissionMode).toBeUndefined();
expect(response.status, JSON.stringify(response.body)).toBe(mode === "convert" ? 200 : 201);
const saved = mode === "hire" ? response.body.agent : response.body;
expect(saved.adapterConfig).toMatchObject(config);
expect(saved.adapterConfig.model).toBeUndefined();
expect(saved.adapterConfig.codexPermissionMode).toBeUndefined();
const { resolvePaperclipRunnerProviderProfile } = await import("../services/native-runtime/provider-profile.js");
expect(() => resolvePaperclipRunnerProviderProfile(response.body.adapterConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" }));
expect(() => resolvePaperclipRunnerProviderProfile(saved.adapterConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" }));
});
it.each(["create", "hire", "convert"])("rejects a new Dot selection when only the general Runner rollout is enabled for %s", async mode => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableOpenAiDot: false });
const app = await createApp();
const body = { name: "Dot", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } };
const response = await requestApp(app, baseUrl => mode === "convert"
? request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send(body)
: request(baseUrl).post(`/api/companies/company-1/${mode === "hire" ? "agent-hires" : "agents"}`).send(body));
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details).toMatchObject({ code: "paperclip_runner_dot_disabled" });
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("requires the general Runner rollout when changing an existing Dot to Codex", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } });
const app = await createApp();
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
.send({ adapterConfig: { provider: "codex" }, replaceAdapterConfig: true }));
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("normalizes legacy skills and permissions when switching to paperclip_runner", async () => {
+23 -8
View File
@@ -2202,11 +2202,16 @@ export function agentRoutes(
* (listEnabledServerAdapters documents the same rule: hidden from selection,
* still functional for agents that already use them).
*/
async function assertSelectableAdapterType(type: string | null | undefined): Promise<string> {
async function assertSelectableAdapterType(type: string | null | undefined, config?: unknown): Promise<string> {
const adapterType = assertKnownAdapterType(type);
if (adapterType === "paperclip_runner") {
const experimental = await instanceSettings.getExperimental();
if (experimental.enableNativeRunner !== true) {
if (asRecord(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" },
@@ -4515,10 +4520,13 @@ export function agentRoutes(
? rollbackConfig.adapterType
: null,
);
if (rollbackAdapterType !== existing.adapterType) {
await assertSelectableAdapterType(rollbackAdapterType);
}
const rollbackAdapterConfig = asRecord(rollbackConfig.adapterConfig) ?? {};
const existingRollbackProvider = asRecord(existing.adapterConfig)?.provider;
if (rollbackAdapterType !== existing.adapterType || (rollbackAdapterType === "paperclip_runner"
&& rollbackAdapterConfig.provider !== existingRollbackProvider
&& (rollbackAdapterConfig.provider === "openai_dot" || existingRollbackProvider === "openai_dot"))) {
await assertSelectableAdapterType(rollbackAdapterType, rollbackAdapterConfig);
}
assertNoAgentAdapterConfigMutation(req, rollbackAdapterConfig);
assertNoAgentLocalAdapterHostCommandMutation(req, rollbackAdapterType, rollbackAdapterConfig);
assertNoAgentProcessAdapterMutation(
@@ -4675,7 +4683,7 @@ export function agentRoutes(
hireInput.adapterConfig = inheritNativeRunnerAdapterConfig(caller.adapterConfig);
hireInput.defaultEnvironmentId = caller.defaultEnvironmentId ?? null;
}
hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType);
hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType, hireInput.adapterConfig);
const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, hireInput.runtimeConfig);
await assertFreshPaperclipRunnerProvider(
@@ -4987,7 +4995,7 @@ export function agentRoutes(
onboardingFirstAgent: createOnboardingFirstAgent,
...createInput
} = req.body;
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType);
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig);
const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
assertProviderTraceSettingTransition(req, createInput.runtimeConfig);
await assertFreshPaperclipRunnerProvider(
@@ -5631,7 +5639,7 @@ export function agentRoutes(
: existing.adapterType;
const requestedAdapterType = nextAdapterType === existing.adapterType
? nextAdapterType
: await assertSelectableAdapterType(nextAdapterType);
: await assertSelectableAdapterType(nextAdapterType, patchData.adapterConfig);
let requestedRuntimeConfig: Record<string, unknown> | null = null;
if (hasOwn(patchData, "runtimeConfig")) {
const runtimeConfig = asRecord(patchData.runtimeConfig);
@@ -5711,6 +5719,13 @@ export function agentRoutes(
existing.adapterType === "paperclip_runner"
? existingAdapterConfig.provider
: undefined;
// Moving between the standalone Dot choice and general Runner is a new
// selection. Preserve historical edits within the general Runner rollout.
if (!changingAdapterType && requestedAdapterType === "paperclip_runner"
&& rawEffectiveAdapterConfig.provider !== existingRunnerProvider
&& (rawEffectiveAdapterConfig.provider === "openai_dot" || existingRunnerProvider === "openai_dot")) {
await assertSelectableAdapterType(requestedAdapterType, rawEffectiveAdapterConfig);
}
if (
changingAdapterType ||
(requestedAdapterType === "paperclip_runner" &&
+11 -1
View File
@@ -38,20 +38,30 @@ const copy = {
},
} as const;
const prerequisiteCopy = {
idle: { title: "Check prerequisites", description: "Check instance settings and public connection configuration.", action: "Check setup" },
running: { title: "Checking prerequisites", description: "Checking instance settings and public connection configuration…", action: "Checking…" },
pass: { title: "Setup checks passed", description: "Complete pairing and verify event delivery before assigning work.", action: "Check again" },
warn: { title: "Pairing required", description: "Create this agent, then pair it and verify event delivery.", action: "Check again" },
fail: { title: "Prerequisite check failed", description: "Review the setup details and retry.", action: "Retry check" },
} as const;
export function RuntimeTestCard({
state,
result,
error,
onTest,
disabled = false,
variant = "connection",
}: {
state: TestState | "warn";
result: AdapterEnvironmentTestResult | null;
error?: string | null;
onTest: () => void;
disabled?: boolean;
variant?: "connection" | "prerequisites";
}) {
const content = copy[state];
const content = (variant === "prerequisites" ? prerequisiteCopy : copy)[state];
const Icon =
state === "running"
? Loader2
@@ -1161,6 +1161,7 @@ function Setup({
)}
</fieldset>
<RuntimeTestCard
variant={isDot ? "prerequisites" : "connection"}
state={testState}
result={result}
error={error}
+2
View File
@@ -206,6 +206,8 @@ describe("New agent setup", () => {
expect(container.textContent).toContain("OpenAI Dot");
expect(container.querySelector('[aria-label="Model"]')).toBeNull();
expect(container.textContent).not.toContain("Connect a model");
expect(container.textContent).toContain("Check prerequisites");
expect(container.textContent).not.toContain("runtime and model can respond");
await click("Finish setup");
expect(api.hire).not.toHaveBeenCalled();
expect(container.textContent).toContain("Acknowledge external provider billing");