diff --git a/docs/specs/agent-config-ui.md b/docs/specs/agent-config-ui.md index 1b1090de778..8820c96653c 100644 --- a/docs/specs/agent-config-ui.md +++ b/docs/specs/agent-config-ui.md @@ -8,7 +8,9 @@ On Cloud, the new-agent picker and direct setup links allow Claude, Codex, OpenC Paperclip Runner offers native Codex (app server), Claude via ACPX, and OpenCode. The selected provider is passed through the existing runner configuration builder. Codex's default is the adapter catalog default. OpenCode and Pi require a provider/model ID; OpenRouter uses `openrouter//` and `OPENROUTER_API_KEY`. Entered keys are tested through the probe-only `testCredentials` field without storage. Finishing setup saves each key as an isolated user secret so a failed test cannot overwrite another agent’s credential, and an existing organization secret can also be bound. Agent configuration and revisions contain references, never the entered key. -**Run test** uses the chosen environment and current configuration. For Claude/Codex readiness checks that do not make a model request, setup also invokes the adapter's existing CLI hello probe with the same environment and credentials. Runtime failures, provider failures, warnings, and in-progress tests use the same compact result card in setup and full configuration. Warnings remain distinguishable from blocking failures. +A plugin environment whose driver declares `supportsTasks` runs agents only through Paperclip Runner. On that environment the Runner selector offers no Legacy runner, Codex and Claude need no reported platform, and a Legacy choice switches to Paperclip Runner. + +**Run test** uses the chosen environment and current configuration. It resolves the runner before it checks that the environment driver allows the resulting adapter. For Claude/Codex readiness checks that do not make a model request, setup also invokes the adapter's existing CLI hello probe with the same environment and credentials. Runtime failures, provider failures, warnings, and in-progress tests use the same compact result card in setup and full configuration. Warnings remain distinguishable from blocking failures. The new-agent dialog also retains an external-agent invitation link, with an optional message, one-time onboarding prompt, and clipboard fallback. External agents still require organization-admin approval. diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index 7f30baaa0ed..a45feeff6bb 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -39,6 +39,7 @@ const mockReleaseRunLease = vi.hoisted(() => vi.fn(async () => undefined)); const mockEnvironmentRuntime = vi.hoisted(() => ({ acquireRunLease: vi.fn(), realizeWorkspace: vi.fn(), + supportsTasks: vi.fn(async () => false), getDriver: vi.fn(() => ({ releaseRunLease: mockReleaseRunLease, })), @@ -123,6 +124,13 @@ vi.mock("../services/ai-connection-runtime.js", async (importOriginal) => ({ try { return await probe(runtime); } finally { await runtime.cleanup(); } }, })); +// Runner selection stays real unless a test overrides the resolved adapter. +const mockResolveNewAgentRunnerForCompany = vi.hoisted(() => vi.fn()); +vi.mock("../services/agent-runner-selection.js", async (importOriginal) => { + const actual = await importOriginal(); + mockResolveNewAgentRunnerForCompany.mockImplementation(actual.resolveNewAgentRunnerForCompany); + return { ...actual, resolveNewAgentRunnerForCompany: mockResolveNewAgentRunnerForCompany }; +}); const mockValidateAiApiKey = vi.hoisted(() => vi.fn(async () => undefined)); vi.mock("../services/ai-api-key-test.js", () => ({ validateAiApiKey: mockValidateAiApiKey })); const mockMarkAuthenticationFailed = vi.hoisted(() => vi.fn(async () => undefined)); @@ -1025,6 +1033,80 @@ describe("agent test-environment route", () => { }); }); + describe("driver guard after runner selection", () => { + const pluginEnvironment = { + id: "33333333-3333-4333-8333-333333333333", + companyId: "company-1", + name: "Agent computer", + driver: "plugin", + status: "active", + config: { pluginKey: "acme.computers", driverKey: "tasks", driverConfig: {} }, + }; + const savedAgentId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + + beforeEach(() => { + mockEnvironmentService.getById.mockResolvedValue(pluginEnvironment); + mockResolveNewAgentRunnerForCompany.mockClear(); + }); + + it("checks the plugin driver against the resolved Runner adapter, not the requested harness", async () => { + mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude" } }); + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/adapters/claude_local/test-environment") + .send({ adapterConfig: {}, environmentId: pluginEnvironment.id }); + expect(mockResolveNewAgentRunnerForCompany).toHaveBeenCalledWith(expect.anything(), "company-1", + expect.objectContaining({ adapterType: "claude_local", defaultEnvironmentId: pluginEnvironment.id })); + // The driver check passes, and the resolved Runner leases the plugin + // environment. The probe stops at the controlled task-capability check. + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ + adapterType: "paperclip_runner", + environment: expect.objectContaining({ id: pluginEnvironment.id, driver: "plugin" }), + })); + expect(mockEnvironmentRuntime.supportsTasks).toHaveBeenCalledWith({ lease: expect.objectContaining({ id: "lease-1" }) }); + expect(res.body).toMatchObject({ + adapterType: "paperclip_runner", + status: "fail", + checks: [expect.objectContaining({ detail: "Plugin environment does not provide native Runner task execution" })], + }); + expect(testEnvironmentSpy).not.toHaveBeenCalled(); + }); + + it("still rejects a plugin environment when selection keeps a legacy adapter, before resolving secrets", async () => { + mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "external_test", adapterConfig: {} }); + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/adapters/external_test/test-environment") + .send({ adapterConfig: { env: { FOO: "bar" } }, environmentId: pluginEnvironment.id }); + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body.error).toContain('Environment driver "plugin" is not allowed here'); + expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled(); + expect(mockSecretService.resolveAdapterConfigForRuntime).not.toHaveBeenCalled(); + expect(testEnvironmentSpy).not.toHaveBeenCalled(); + }); + + it("re-resolves a saved adapter that cannot run on the requested environment", async () => { + mockAgentService.getById.mockResolvedValue({ id: savedAgentId, companyId: "company-1", adapterType: "external_test", adapterConfig: {}, runtimeConfig: {} }); + mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "external_test", adapterConfig: {} }); + const app = await createApp(); + await request(app) + .post("/api/companies/company-1/adapters/external_test/test-environment") + .send({ agentId: savedAgentId, adapterConfig: {}, environmentId: pluginEnvironment.id }); + expect(mockResolveNewAgentRunnerForCompany).toHaveBeenCalledTimes(1); + }); + + it("keeps a saved adapter's execution when it can run on the requested environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ ...pluginEnvironment, driver: "local", config: {} }); + mockAgentService.getById.mockResolvedValue({ id: savedAgentId, companyId: "company-1", adapterType: "external_test", adapterConfig: {}, runtimeConfig: {} }); + const app = await createApp(); + await request(app) + .post("/api/companies/company-1/adapters/external_test/test-environment") + .send({ agentId: savedAgentId, adapterConfig: {}, environmentId: pluginEnvironment.id }); + expect(mockResolveNewAgentRunnerForCompany).not.toHaveBeenCalled(); + }); + }); + describe("tenant-binding guard", () => { async function postForeignEnvironmentTest() { const app = await createApp(); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f2edb56ac0d..a8408a07496 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,7 +1,7 @@ import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js"; import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js"; import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js"; -import { agentHarnessType, type AgentRunnerChoice } from "@paperclipai/shared"; +import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, type AgentRunnerChoice } from "@paperclipai/shared"; import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js"; import { agentEnvironmentTestService } from "../services/agent-environment-test.js"; import { createAgentLifecycle } from "../services/agent-lifecycle.js"; @@ -587,7 +587,7 @@ export function agentRoutes( const approvalsSvc = approvalService(db); const budgets = budgetService(db); const environmentsSvc = environmentService(db); - const { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, testManagedEnvironment } = agentEnvironmentTestService(db, options.pluginWorkerManager); + const { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, assertAdapterTestEnvironmentBinding, testManagedEnvironment } = agentEnvironmentTestService(db, options.pluginWorkerManager); const environmentRuntime = environmentRuntimeService(db, { pluginWorkerManager: options.pluginWorkerManager, }); @@ -3218,10 +3218,12 @@ export function agentRoutes( aiBinding?.provider === "github", ); // Fail closed on a foreign environment before any secret resolution, env - // merge, target resolution, sandbox lease, or adapter test runs. - if (requestedEnvironmentId) { - await assertAdapterTestEnvironmentForCompany(companyId, requestedEnvironmentId, type); - } + // merge, target resolution, sandbox lease, or adapter test runs. The + // driver check waits for runner selection: a Claude harness on a plugin + // environment is only allowed once it resolves to Paperclip Runner. + const requestedEnvironment = requestedEnvironmentId + ? await assertAdapterTestEnvironmentBinding(companyId, requestedEnvironmentId) + : null; // Agent reads redact every plain environment value. When this is a saved // agent test, restore those display-only placeholders from the // server-side config before validating or resolving secrets; otherwise @@ -3241,9 +3243,13 @@ export function agentRoutes( ? restoreRedactedAgentEnv(inputAdapterConfig, savedAgent.adapterConfig) : inputAdapterConfig; } + // A saved adapter that cannot run on the requested environment re-resolves + // its runner, so testing a Claude agent on a Runner-only environment + // tests the Runner configuration a save would produce. const retainsSavedExecution = savedAgent && req.body.runner === undefined && savedAgent.adapterType === type - && agentHarnessType(type, adapterConfigForTest) === agentHarnessType(savedAgent.adapterType, savedAgent.adapterConfig); + && agentHarnessType(type, adapterConfigForTest) === agentHarnessType(savedAgent.adapterType, savedAgent.adapterConfig) + && (!requestedEnvironment || isEnvironmentDriverSupportedForAdapter(type, requestedEnvironment.driver)); const selection = retainsSavedExecution ? { adapterType: type, adapterConfig: adapterConfigForTest } : await resolveNewAgentRunnerForCompany(db, companyId, { @@ -3253,6 +3259,7 @@ export function agentRoutes( }); type = selection.adapterType; adapterConfigForTest = selection.adapterConfig; + if (requestedEnvironment) await assertAdapterTestEnvironmentForCompany(companyId, requestedEnvironment.id, type); const adapter = requireServerAdapter(type); if (!retainsSavedExecution) await assertSelectableAdapterType(type, adapterConfigForTest); if (poolBinding?.mode === "router") { diff --git a/server/src/services/agent-environment-test.ts b/server/src/services/agent-environment-test.ts index f079e736f2d..5183beec2a8 100644 --- a/server/src/services/agent-environment-test.ts +++ b/server/src/services/agent-environment-test.ts @@ -472,21 +472,27 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin environmentId: string, adapterType?: string, ): Promise { + if (!await assertAdapterTestEnvironmentBinding(companyId, environmentId)) return; + await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, { + allowedDrivers: adapterType === "paperclip_runner" ? [...ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS, "plugin"] : ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS, + }); + } + + // The company-binding half of the guard. A route that must resolve the + // runner before it knows the adapter type runs this first, then runs the + // full guard with the resolved adapter type. Returns the environment, or + // null for a missing one, which leaks no tenant state; the execution-context + // resolver surfaces the existing environment_not_found check. + async function assertAdapterTestEnvironmentBinding(companyId: string, environmentId: string) { const environment = await environmentsSvc.getById(environmentId); - if (!environment) { - // A missing environment leaks no tenant state. The execution-context - // resolver surfaces the existing environment_not_found check. - return; - } + if (!environment) return null; const boundCompanyIds = await environmentsSvc.listBoundCompanyIds(environmentId); if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) { throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch", }); } - await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, { - allowedDrivers: adapterType === "paperclip_runner" ? [...ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS, "plugin"] : ADAPTER_TEST_ALLOWED_ENVIRONMENT_DRIVERS, - }); + return environment; } async function testManagedEnvironment(adapterType: string, context: Parameters["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited>, agentId?: string) { @@ -579,5 +585,5 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin } - return { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, testManagedEnvironment }; + return { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, assertAdapterTestEnvironmentBinding, testManagedEnvironment }; } diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index 8c011509774..3edd2377540 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -1,12 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared"; -const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), kubernetesEnvironment: vi.fn(), ensureKubernetesEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn() })); +const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), kubernetesEnvironment: vi.fn(), ensureKubernetesEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn(), pluginByKey: vi.fn() })); vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} })); vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled })); vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => state.settings }) })); vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.kubernetesEnvironment, ensureKubernetesEnvironment: state.ensureKubernetesEnvironment }) })); vi.mock("./environment-probe.js", () => ({ readSandboxPlatformQualification: state.sandboxQualification })); -vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment })); +vi.mock("./environment-config.js", async () => ({ + parseEnvironmentDriverConfig: (await vi.importActual("./environment-config.js")).parseEnvironmentDriverConfig, + resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment, +})); +vi.mock("./plugin-registry.js", () => ({ pluginRegistryService: () => ({ getByKey: state.pluginByKey }) })); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); import { AGENT_RUNNER_SSH_PLATFORM_COMMAND, agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js"; import { buildCodexAcpConfig, resolveCodexExecutionEngine } from "@paperclipai/adapter-codex-local/server"; @@ -497,6 +501,51 @@ describe("Codex creation runner contract", () => { }); +describe("Runner-only plugin environments", () => { + const pluginEnvironment = { id: "computer", driver: "plugin", status: "active", config: { pluginKey: "acme.computers", driverKey: "tasks", driverConfig: {} } }; + const plugin = (driver: Record, capabilities = ["environment.drivers.register"]) => ({ + id: "plugin-id", status: "ready", manifestJson: { capabilities, environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", configSchema: {}, ...driver }] }, + }); + beforeEach(() => { + state.disabled = []; state.overridden.clear(); state.settings = {}; + state.getEnvironment.mockReset().mockResolvedValue(pluginEnvironment); state.bindings.mockReset().mockResolvedValue([]); + state.kubernetesEnvironment.mockReset().mockResolvedValue(null); state.ssh.mockReset(); state.sandboxQualification.mockReset(); + state.pluginByKey.mockReset().mockResolvedValue(plugin({ supportsTasks: true })); + }); + it("offers only Paperclip Runner for Codex and Claude without a platform", async () => { + const target = await resolveAgentRunnerTargetForCompany({} as never, "company", "computer"); + expect(target).toEqual({ driver: "plugin", runnerTasks: true }); + expect(state.pluginByKey).toHaveBeenCalledWith("acme.computers"); + for (const harness of ["codex_local", "claude_local"]) { + expect(agentRunnerAvailability(harness, target)).toEqual({ supportedRunners: ["paperclip"], defaultRunner: "paperclip" }); + } + expect(agentRunnerAvailability("gemini_local", target)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" }); + expect(state.ssh).not.toHaveBeenCalled(); + }); + it("saves a Claude agent on the plugin environment as Paperclip Runner", async () => { + const saved = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "claude_local", defaultEnvironmentId: "computer" }); + expect(saved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude" } }); + const explicit = await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "claude_local", runner: "paperclip", defaultEnvironmentId: "computer" }); + expect(explicit.adapterType).toBe("paperclip_runner"); + }); + it.each([ + ["a driver without Runner tasks", plugin({})], + ["a different driver key", { ...plugin({ supportsTasks: true }), manifestJson: { capabilities: ["environment.drivers.register"], environmentDrivers: [{ driverKey: "other", supportsTasks: true }] } }], + ["a plugin without the driver capability", plugin({ supportsTasks: true }, [])], + ["an unknown plugin", null], + ])("keeps the existing plugin target for %s", async (_label, record) => { + state.pluginByKey.mockResolvedValue(record); + const target = await resolveAgentRunnerTargetForCompany({} as never, "company", "computer"); + expect(target).toEqual({ driver: "plugin" }); + expect(agentRunnerAvailability("claude_local", target)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" }); + }); + it("ignores a malformed plugin environment config", async () => { + state.getEnvironment.mockResolvedValue({ ...pluginEnvironment, config: { pluginKey: "acme.computers" } }); + expect(await resolveAgentRunnerTargetForCompany({} as never, "company", "computer")).toEqual({ driver: "plugin" }); + expect(state.pluginByKey).not.toHaveBeenCalled(); + }); +}); + describe("Claude runner defaults", () => { const mac = { driver: "local", platform: "darwin", architecture: "arm64" }; beforeEach(() => { state.disabled = []; state.overridden.clear(); }); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index 2727a602f0c..250686d56b6 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -7,10 +7,14 @@ import { getDisabledAdapterTypes } from "./adapter-plugin-store.js"; import { claudeRunnerUnsupportedSettings, PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js"; import { forbidden, unprocessable } from "../errors.js"; import { runSshCommand } from "@paperclipai/adapter-utils/ssh"; -import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; +import { parseEnvironmentDriverConfig, resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js"; -export type RunnerTarget = { driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string }; +export type RunnerTarget = { + driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string; + /** A plugin environment whose driver declares Runner task execution. It runs agents only through Paperclip Runner. */ + runnerTasks?: boolean; +}; export const AGENT_RUNNER_SSH_PLATFORM_COMMAND = "uname -s; uname -m; if paperclip_glibc_version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$paperclip_glibc_version\" ]; then printf 'glibc\\n%s\\n' \"$paperclip_glibc_version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi"; export interface AgentRunnerSelection { adapterType?: string | null; @@ -31,6 +35,14 @@ function runnerAdaptersAvailable(harness: string, driver: string): boolean { } export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability { + if (target.runnerTasks) { + // The plugin provisions the host and launches its own pinned Runner, so + // Core has no platform to qualify. Legacy adapters cannot run on a plugin + // environment, so Paperclip Runner is the only choice. + return runnerAdaptersAvailable(harness, target.driver) + ? { supportedRunners: ["paperclip"], defaultRunner: "paperclip" } + : { supportedRunners: ["legacy"], defaultRunner: "legacy" }; + } const supported = runnerAdaptersAvailable(harness, target.driver) && (target.driver !== "sandbox" || Boolean(target.platform && target.architecture && (target.platform !== "linux" || (target.libc === "glibc" && target.glibcVersion)))) @@ -290,6 +302,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri const driver = environment?.driver ?? "local"; let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget() : { driver }; + if (driver === "plugin" && environment && await pluginEnvironmentRunsRunnerTasks(db, environment)) target = { driver, runnerTasks: true }; if (driver === "sandbox" && environment) { const { readSandboxPlatformQualification } = await import("./environment-probe.js"); const qualified = await readSandboxPlatformQualification(db, environment, companyId); @@ -306,3 +319,21 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri } return target; } + +// The plugin's manifest declaration decides whether its environment runs +// Runner tasks. Run admission separately verifies a ready plugin and a live +// worker, so a plugin that is down still selects the Runner and fails its run. +async function pluginEnvironmentRunsRunnerTasks(db: Db, environment: Parameters[0]): Promise { + let config: { pluginKey: string; driverKey: string }; + try { + const parsed = parseEnvironmentDriverConfig(environment); + if (parsed.driver !== "plugin") return false; + config = parsed.config; + } catch { + return false; + } + const { pluginRegistryService } = await import("./plugin-registry.js"); + const plugin = await pluginRegistryService(db).getByKey(config.pluginKey); + return Boolean(plugin?.manifestJson.capabilities.includes("environment.drivers.register") + && plugin.manifestJson.environmentDrivers?.some(driver => driver.driverKey === config.driverKey && driver.supportsTasks === true)); +} diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index f1919aad986..25ceb0e56fb 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -787,6 +787,27 @@ describe("AgentConfigForm environment selector", () => { expect(findButton(container, "Upgrade runner")).toBeDefined(); }); + it("switches a saved legacy Claude agent to Paperclip Runner on a Runner-only environment", async () => { + mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: ["paperclip"], defaultRunner: "paperclip" }]); + try { + const result = await renderForm([], { adapterType: "claude_local", adapterConfig: { model: "claude-opus-5-5" } }); + roots.push(result.root); + await clickByText(result.container, "Advanced"); + expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain("Paperclip Runner"); + await clickByText(result.container, "Save"); + expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ runner: "paperclip" })); + } finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); } + }); + + it("switches a new agent's Legacy choice to Paperclip Runner on a Runner-only environment", async () => { + mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: ["paperclip"], defaultRunner: "paperclip" }]); + try { + const result = await renderCreateForm([], { ...defaultCreateValues, adapterType: "claude_local", runner: "legacy" }); + roots.push(result.root); + expect(result.onChange).toHaveBeenCalledWith({ runner: "paperclip" }); + } finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); } + }); + it("does not offer an upgrade when the target only supports Legacy", async () => { mockAdaptersApi.list.mockResolvedValueOnce([{ type: "codex_local", supportedRunners: ["legacy"], defaultRunner: "legacy" }]); const result = await renderForm([]); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 140449f0b22..c8d54a6789e 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -669,6 +669,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) { if (props.values.resolvedRunner !== resolvedRunner) props.onChange({ resolvedRunner }); }, [props.mode, harnessType, isRunnerHarness, runner, runnerAvailability?.defaultRunner, isCreate ? props.values.resolvedRunner : undefined, isCreate ? props.onChange : undefined]); + // A Runner-only environment, such as a plugin environment that runs Runner + // tasks, offers no Legacy runner. Switch a Legacy choice to Paperclip Runner + // so the test and the save both resolve the Runner. + const legacyRunnerUnavailable = isRunnerHarness && runner === "legacy" + && runnerAvailability?.supportedRunners !== undefined && !runnerAvailability.supportedRunners.includes("legacy"); + useEffect(() => { + if (!legacyRunnerUnavailable) return; + if (props.mode === "create") props.onChange({ runner: "paperclip" }); + else setOverlay(prev => ({ ...prev, runner: "paperclip" })); + }, [legacyRunnerUnavailable, props.mode, props.mode === "create" ? props.onChange : undefined]); const getCapabilities = useAdapterCapabilities(); const adapterCaps = getCapabilities(isRunnerHarness ? harnessType : adapterType); const runnerProvider = adapterType === "paperclip_runner"