fix(runner): make task-capable plugin environments Runner-only (#17380)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - An environment plugin can run Paperclip Runner tasks through a
`driver: "plugin"` environment.
> - Core lets only the `paperclip_runner` adapter use a plugin
environment.
> - A Claude agent cannot get onto the Runner there. Runner selection
needs a reported platform for Claude, and a plugin target reports none.
> - The adapter Test route also checks the driver against the requested
harness before it resolves the runner.
> - This pull request makes task-capable plugin environments Runner-only
and checks the Test driver after runner selection.
> - The benefit is that Claude and Codex agents can use a Runner task
environment, and the UI cannot produce a legacy agent there.

## Linked Issues or Issue Description

Refs #17029. Refs #16978.

**What happened?**

A Claude Code agent with a plugin environment that runs Runner tasks
fails the connection test. The error is: `Environment driver "plugin" is
not allowed here. Allowed drivers: local, ssh, sandbox`. The Runner
selector in Advanced shows Paperclip Runner as unavailable, so the user
cannot fix the agent in the form.

**Expected behavior**

A Claude or Codex agent on that environment uses Paperclip Runner. The
connection test passes the driver check. Legacy is not offered, because
a legacy adapter cannot run on a plugin environment.

**Steps to reproduce**

1. Install an environment plugin whose driver declares `supportsTasks:
true`.
2. Create a plugin environment for that driver.
3. Open an agent with the Claude Code harness and select that
environment.
4. Click Test. The test fails with the driver error above.

**Agent adapter(s) involved**

Claude Code, Codex (through Paperclip Runner).

**Additional context**

Two gates caused the failure:

- `agentRunnerAvailability` requires a platform for `claude_local`. A
plugin target is `{ driver: "plugin" }`, so Claude resolves to Legacy.
- The Test route ran `assertAdapterTestEnvironmentForCompany` with the
URL adapter type before `resolveNewAgentRunnerForCompany`. The plugin
driver is allowed only for `paperclip_runner`, so the check failed
before selection could change the adapter.

## What Changed

- Runner target resolution marks a plugin environment as `runnerTasks`
when its plugin manifest declares `supportsTasks` for the driver key and
has the `environment.drivers.register` capability. The check reads the
manifest only. Run admission still verifies a ready plugin and a live
worker.
- `agentRunnerAvailability` returns Paperclip Runner as the only runner
for a `runnerTasks` target. It skips the platform check, because the
plugin provisions the host and launches its own pinned Runner. Harnesses
that the Runner does not support keep the Legacy result.
- The adapter Test route runs the company-binding check first, as
before. It runs the driver check after runner selection, with the
resolved adapter type. The driver check still runs before secret
resolution, leases, and the adapter probe.
- A saved adapter that cannot run on the requested environment no longer
keeps its saved execution in the Test route. It re-resolves its runner.
- `assertAdapterTestEnvironmentBinding` is a new helper that holds the
binding half of the guard. `assertAdapterTestEnvironmentForCompany`
keeps its behavior and calls it.
- The agent form switches a Legacy runner choice to Paperclip Runner
when the selected environment offers no Legacy runner. The test and the
save then send the Runner choice.
- `docs/specs/agent-config-ui.md` records the Runner-only rule and the
Run test order.

## Verification

- `pnpm --filter @paperclipai/server exec tsc --noEmit` passes.
- `pnpm --filter @paperclipai/ui exec tsc -b` passes.
- Server suites pass: `agent-runner-selection.test.ts`,
`agent-test-environment-routes.test.ts`,
`agent-permissions-routes.test.ts`,
`environment-selection-route-guards.test.ts`,
`agent-harness-verification.test.ts`,
`plugin-runner-task.integration.test.ts`, `adapter-routes.test.ts`,
`adapter-auth-signal-routes.test.ts`,
`agent-adapter-validation-routes.test.ts`,
`agent-hire-ai-connections.test.ts`.
- UI suites pass: `AgentConfigForm.render.test.tsx` and
`NewAgent.test.tsx`.
- New tests cover these cases:
- A task-capable plugin target offers only Paperclip Runner for Codex
and Claude.
  - Claude saves as `paperclip_runner` with ACPX on that target.
- A driver without `supportsTasks`, a different driver key, a missing
capability, an unknown plugin, or a malformed config keeps the existing
plugin target.
  - The Test route checks the driver against the resolved adapter.
- The Test route still rejects a plugin environment for a legacy
adapter, before any secret resolution.
- A saved adapter re-resolves only when it cannot run on the requested
environment.
- The form switches a saved or new Legacy choice to Paperclip Runner on
a Runner-only environment.
- I ran the new route and form tests against the old code. They fail
without the change.
- The full server `typecheck` script did not complete locally. Its
Runner vendor step needs `cargo`, which this machine does not have. CI
runs the full script.
- I did not run a live plugin environment end to end.

## Risks

- Behavior change: Codex on a task-capable plugin environment now offers
only Paperclip Runner. Before, the selector also showed Legacy, but Core
rejected a legacy save on that environment. No working setup loses an
option.
- The `runnerTasks` decision reads the plugin manifest. If the plugin is
down, the agent still selects the Runner, and the run fails at admission
with the existing plugin error.
- The adapter auth-signal route still checks the driver with the
requested harness. This PR does not change that route.
- An explicit Legacy save through the API on a Runner-only environment
still fails with the existing driver error. The UI no longer offers that
choice.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) wrote the change and the tests in
Claude Code, with shell, file editing, and local test runs.
- Claude Fable 5.1 (`claude-fable-5-1`) did the earlier diagnosis in the
same session.
- The session does not expose the context window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-23 15:21:09 -07:00
1 parent b5bf86ea09
commit e0d90339bf
8 files changed
+229 -21

No files matched your search

+3 -1
View File
@@ -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/<provider>/<model>` 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.
@@ -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<typeof import("../services/agent-runner-selection.js")>();
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();
+14 -7
View File
@@ -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") {
+15 -9
View File
@@ -472,21 +472,27 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
environmentId: string,
adapterType?: string,
): Promise<void> {
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<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding, managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>>, agentId?: string) {
@@ -579,5 +585,5 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
}
return { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, testManagedEnvironment };
return { resolveAdapterTestEnvironmentId, resolveAdapterTestExecutionContext, assertAdapterTestEnvironmentForCompany, assertAdapterTestEnvironmentBinding, testManagedEnvironment };
}
@@ -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<string>(), settings: {} as Record<string, unknown>, 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<string>(), settings: {} as Record<string, unknown>, 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<typeof import("./environment-config.js")>("./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<string, unknown>, 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(); });
+33 -2
View File
@@ -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<typeof parseEnvironmentDriverConfig>[0]): Promise<boolean> {
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));
}
@@ -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([]);
+10
View File
@@ -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"