diff --git a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md index 18a5fdd888..32f05cc9b1 100644 --- a/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md +++ b/doc/plugins/PLUGIN_ENVIRONMENT_TASKS.md @@ -177,8 +177,7 @@ change, reject submission and require new preparation and validation. Return `project_auth` with the same configuration ID and provider. Set `allowed: true` only after the checks pass. Core saves this result on the task lease. Core requires the exact configuration, -provider, and project set before each submission. This also applies when the -provider uses an installed account. +provider, and project set before each submission. A new preparation or check removes the prior validation. A failed check does not authorize credential transfer. The dispatch update also checks that validation has not changed while Core waited for database work. @@ -268,9 +267,10 @@ after the probe and its descendants stop. A lost reply keeps the task available for `stop`, `status`, and `complete`. Core checks the profile and model before it accepts the account. Do not store the token in task metadata or logs. -For a native hello probe, select a saved AI account or use the installed account. -Core rejects local environment overrides when no saved account is selected. -This includes draft credentials. The probe uses the installed VM environment. Core does not test a different installed account in their place. +A native hello probe requires a managed AI connection and an isolated Runner home. +Without them, the probe fails the `ai_connection_required` check before Core +acquires a lease or calls a task operation. See +[Managed AI connection required](PLUGIN_RUNNER_EXECUTION.md#managed-ai-connection-required). Only Core can supply the selected account identity and local credential home. ## Fresh attempts diff --git a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md index 837aa2bdf0..b4c75d8a22 100644 --- a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md +++ b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md @@ -4,6 +4,23 @@ Core can run `paperclip_runner` tasks in a plugin environment. The plugin owns the execution host and its files. Core owns run identity, selected policy, assigned tools, and the PRP journal. +## Managed AI connection required + +A task-capable environment runs only agents with a managed AI connection. +Core does not use an account installed on the plugin's execution host. +Before it acquires a lease or calls the plugin, Core fails a new run that has +no managed AI connection. A `homeMode: "preconfigured"` Runner home does not use +the agent's AI connection, so Core refuses it in the same way. The run fails as +`configuration_incomplete` with reason `ai_connection_required`. The task is +blocked with a notice that names the fix: choose an AI connection, or switch to +an isolated Runner home. Agent verification applies the same rule before it +resolves the probe target, so it acquires no lease. The agent Test route also +checks before it resolves secrets or allocates a test target. A setup probe without a +managed AI connection, or with a preconfigured home, fails the +`ai_connection_required` check and calls no task operation. Verification then +waits for the configuration to change. +A resumed native run keeps the contract it was admitted under. + ## Required hooks Set `supportsTasks: true` on the environment driver. @@ -224,8 +241,7 @@ plugin runs without an explicit policy prefer warm execution. There is no provider-specific warm gate or additional setting. Existing runs retain their admitted lifecycle policy during restart recovery. The existing `runnerIdleTimeoutMs` controls bounded retention. Fresh warm admission requires -a Core-managed credential identity before lease acquisition. Plugin-owned installed -accounts continue per-turn until such an identity is available; the saved warm +a Core-managed credential identity before lease acquisition. The saved warm preference does not bypass account ownership checks. Drivers without warm capability and setup probes remain ephemeral. diff --git a/docs/specs/agent-config-ui.md b/docs/specs/agent-config-ui.md index 8820c96653..7a27f13ddd 100644 --- a/docs/specs/agent-config-ui.md +++ b/docs/specs/agent-config-ui.md @@ -8,7 +8,7 @@ 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. -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. +A plugin environment whose driver declares `supportsTasks` runs agents only through Paperclip Runner, and only with a managed AI connection. 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. diff --git a/server/src/__tests__/agent-harness-verification-preflight.test.ts b/server/src/__tests__/agent-harness-verification-preflight.test.ts index b03d955e42..95f2236af5 100644 --- a/server/src/__tests__/agent-harness-verification-preflight.test.ts +++ b/server/src/__tests__/agent-harness-verification-preflight.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; -import { agents, aiProviderDefaults, companies, companyMemberships, connectionGrants, createDb } from "@paperclipai/db"; +import { agents, aiProviderDefaults, companies, companyMemberships, connectionGrants, createDb, environments, plugins } from "@paperclipai/db"; import type { AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils"; import { agentHarnessVerificationService } from "../services/agent-harness-verification.js"; import * as environmentTests from "../services/agent-environment-test.js"; @@ -125,4 +125,46 @@ describePostgres("saved harness account preflight", () => { await expect(verify()).rejects.toThrow("Fixture probe failure"); expect(release).toHaveBeenCalledExactlyOnceWith("failed"); }); + + async function useRunnerTaskEnvironment(runtimeConfig: Record, homeMode?: string) { + const pluginId = randomUUID(), pluginKey = `test.runner-tasks.${pluginId}`; + await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: "@test/runner-tasks", version: "1.0.0", apiVersion: 1, + categories: ["automation"], status: "ready", manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0", + displayName: "Runner tasks", description: "Runner task fixture", author: "Paperclip", categories: ["automation"], + capabilities: ["environment.drivers.register"], entrypoints: { worker: "worker.js" }, + environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }] } } as any); + const [environment] = await db.insert(environments).values({ name: `Runner tasks ${pluginId}`, driver: "plugin", status: "active", + config: { pluginKey, driverKey: "tasks", driverConfig: {} } }).returning(); + vi.mocked(environmentTests.agentEnvironmentTestService).mockImplementation((database, manager) => ({ + ...realEnvironmentTests(database, manager), + resolveAdapterTestEnvironmentId: vi.fn().mockResolvedValue(environment.id), + assertAdapterTestEnvironmentForCompany: vi.fn().mockResolvedValue(undefined), + resolveAdapterTestExecutionContext: allocate, + testManagedEnvironment: probe, + })); + [agent] = await db.update(agents).set({ adapterType: "paperclip_runner", runtimeConfig, + adapterConfig: { provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.5", ...(homeMode ? { homeMode } : {}) } }) + .where(eq(agents.id, agent.id)).returning(); + } + + it("refuses a plugin Runner-task agent without a managed AI connection before allocating a target", async () => { + await useRunnerTaskEnvironment({}); + await expect(verify()).rejects.toMatchObject({ code: "ai_connection_required", message: expect.stringContaining("Choose an AI connection") }); + expect(allocate).not.toHaveBeenCalled(); + expect(probe).not.toHaveBeenCalled(); + }); + + it("refuses a preconfigured Runner home with a saved connection before allocating a target", async () => { + await useRunnerTaskEnvironment({ aiConnection: binding }, "preconfigured"); + await expect(verify()).rejects.toMatchObject({ code: "ai_connection_required", message: expect.stringContaining("isolated Runner home") }); + expect(allocate).not.toHaveBeenCalled(); + expect(probe).not.toHaveBeenCalled(); + }); + + it("verifies a plugin Runner-task agent with a managed AI connection", async () => { + await useRunnerTaskEnvironment({ aiConnection: binding }); + await expect(verify()).resolves.toBe("complete"); + expect(allocate).toHaveBeenCalledOnce(); + expect(probe).toHaveBeenCalledOnce(); + }); }); diff --git a/server/src/__tests__/agent-harness-verification.test.ts b/server/src/__tests__/agent-harness-verification.test.ts index 307e63929e..fc83b322db 100644 --- a/server/src/__tests__/agent-harness-verification.test.ts +++ b/server/src/__tests__/agent-harness-verification.test.ts @@ -24,6 +24,11 @@ describe("saved harness verification", () => { expect(error).toMatchObject({ code, message: "The harness test failed" }); } }); + it("classifies a plugin Runner-task target without a managed AI connection before retryable checks", () => { + const refused = result("fail", ["ai_connection_required", "opencode_cwd_invalid"]); + refused.checks[1] = { ...refused.checks[1], level: "error", retryable: true }; + expect(() => assertHarnessTestPassed(refused)).toThrow(expect.objectContaining({ code: "ai_connection_required" })); + }); it("preserves a retryable failed check before classifying configuration failures", () => { const temporary = result("fail", ["opencode_cwd_invalid", "opencode_command_unresolvable"]); temporary.checks[0] = { ...temporary.checks[0], level: "error", retryable: true }; diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index 536b8b16a1..76e22776f1 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -48,6 +48,11 @@ const mockEnvironmentRuntime = vi.hoisted(() => ({ const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn()); const mockPluginEnvironmentRequiresAgent = vi.hoisted(() => vi.fn(async () => false)); +const mockPluginRunnerTaskDriver = vi.hoisted(() => vi.fn(async (): Promise<{ driverKey: string } | undefined> => undefined)); +vi.mock("../services/plugin-runner-ai-connection.js", async importOriginal => ({ + ...await importOriginal(), + pluginEnvironmentRunnerTaskDriver: mockPluginRunnerTaskDriver, +})); vi.mock("../services/plugin-environment-driver.js", async importOriginal => ({ ...(await importOriginal()), pluginEnvironmentRequiresAgent: mockPluginEnvironmentRequiresAgent, @@ -1303,6 +1308,25 @@ describe("agent test-environment route", () => { expect(testEnvironmentSpy).not.toHaveBeenCalled(); }); + it.each([ + { name: "without a managed AI connection", adapterConfig: { provider: "codex" }, message: "Choose an AI connection" }, + { name: "with a preconfigured Runner home", adapterConfig: { provider: "codex", homeMode: "preconfigured" }, message: "isolated Runner home" }, + ])("refuses a Runner-task environment $name before secrets or a lease", async ({ adapterConfig, message }) => { + mockPluginRunnerTaskDriver.mockResolvedValueOnce({ driverKey: "tasks" }); + mockResolveNewAgentRunnerForCompany.mockResolvedValueOnce({ adapterType: "paperclip_runner", adapterConfig }); + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/adapters/codex_local/test-environment") + .send({ adapterConfig: {}, environmentId: pluginEnvironment.id }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ adapterType: "paperclip_runner", status: "fail", + checks: [{ code: "ai_connection_required", level: "error", message: expect.stringContaining(message) }] }); + expect(mockPluginRunnerTaskDriver).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ id: pluginEnvironment.id })); + expect(mockSecretService.resolveAdapterConfigForRuntime).not.toHaveBeenCalled(); + expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled(); + 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(); diff --git a/server/src/__tests__/heartbeat-execution-configuration.test.ts b/server/src/__tests__/heartbeat-execution-configuration.test.ts index cf040c6f5f..f8a2ac0ac5 100644 --- a/server/src/__tests__/heartbeat-execution-configuration.test.ts +++ b/server/src/__tests__/heartbeat-execution-configuration.test.ts @@ -276,6 +276,63 @@ describePostgres("heartbeat execution configuration through adapter dispatch", ( } }); + async function runnerTaskEnvironment(supportsTasks = true): Promise { + const pluginId = randomUUID(), pluginKey = `test.runner-tasks.${pluginId}`; + await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: "@test/runner-tasks", version: "1.0.0", apiVersion: 1, + categories: ["automation"], status: "ready", manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0", + displayName: "Runner tasks", description: "Runner task fixture", author: "Paperclip", categories: ["automation"], + capabilities: ["environment.drivers.register"], entrypoints: { worker: "worker.js" }, + environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks, configSchema: { type: "object" } }] } }); + const [environment] = await db.insert(environments).values({ name: `Runner tasks ${pluginId}`, driver: "plugin", status: "active", + config: { pluginKey, driverKey: "tasks", driverConfig: {} } }).returning(); + return environment as unknown as Environment; + } + + it.each([ + { name: "a run without a managed AI connection", managed: false, homeMode: undefined, persisted: false, supportsTasks: true, refused: true }, + { name: "a preconfigured home that drops the managed AI connection", managed: true, homeMode: "preconfigured", persisted: false, supportsTasks: true, refused: true }, + { name: "a run with a managed AI connection", managed: true, homeMode: undefined, persisted: false, supportsTasks: true, refused: false }, + { name: "resumed native input admitted before the rule", managed: false, homeMode: undefined, persisted: true, supportsTasks: true, refused: false }, + { name: "a plugin environment without Runner tasks", managed: false, homeMode: undefined, persisted: false, supportsTasks: false, refused: false }, + ] as const)("plugin Runner-task environments require a managed AI connection: $name", async ({ managed, homeMode, persisted, supportsTasks, refused }) => { + const f = await seed(managed); + const environment = await runnerTaskEnvironment(supportsTasks); + const config = { provider: "codex", model: "gpt-6.1-sol", ...(homeMode ? { homeMode } : {}) }; + const [agent] = await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: config }).where(eq(agents.id, f.agentId)).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, scopeKind: "issue", + status: "running", runtimeMode: "native", contextSnapshot: { issueId: f.issueId } }).returning(); + const reached = "fixture passed managed AI admission"; + const prepare = vi.spyOn(aiRuntime, "prepareManagedAiRuntime").mockRejectedValue(new Error(reached)); + skillFailures.set(f.companyId, reached); + const secretsResolved = vi.fn(), redactorPrepared = vi.fn(); + const opening = prepareHeartbeatExecutionConfiguration(db, { + run, agent, task: { issueId: f.issueId, context: {}, responsibleUserId: f.userId, + acceptedPlanContinuationWake: false, acceptedPlanWakeRoutingDecision: null, taskProjectContext: null, + routineEnvContext: { routineId: null, env: null, responsibleUserId: null } }, + config: { sourceWorkspaceConfig: config, aiBinding: managed ? aiConnectionBindingSchema.parse(agent.runtimeConfig!.aiConnection) : undefined }, + policy: { trustPreset: { kind: "standard", preset: "standard", boundary: null, sourcePresets: {} }, + useIsolatedTaskDirectory: false, explicitTaskDirectory: false, requestedExecutionWorkspaceMode: "shared_workspace" }, + environment: { selectedEnvironmentId: environment.id, selectedEnvironmentForConfig: environment, + resolvedInstanceSettings: await instanceSettingsService(db).get(), + persistedNativeExecutionInput: persisted ? { schema: "paperclip.native-execution-input.v5" } as unknown as HeartbeatExecutionConfigurationInput["environment"]["persistedNativeExecutionInput"] : null }, + services: { secretsSvc: secretService(db), companySkills: companySkillService(db), appendRunEvent: vi.fn(), finalizeAiConnectionBusyDeferral: vi.fn() }, + onFailureSecretsResolved: secretsResolved, onManagedAiRuntimeAcquired: vi.fn(), onIdentityRedactorPrepared: redactorPrepared, + }); + if (refused) { + await expect(opening).rejects.toMatchObject({ name: "ConfigurationIncompleteFailure", code: "configuration_incomplete", + message: expect.stringContaining(homeMode ? "isolated Runner home" : "Choose an AI connection"), + resultJson: { configurationIncomplete: { reason: "ai_connection_required", actionUrl: `/agents/${f.agentId}/runtime`, + ...(homeMode ? { homeMode: "preconfigured" } : {}) } } }); + // Refused before observability, secrets, or the managed runtime. + expect(redactorPrepared).not.toHaveBeenCalled(); + expect(secretsResolved).not.toHaveBeenCalled(); + expect(prepare).not.toHaveBeenCalled(); + } else { + await expect(opening).rejects.toThrow(reached); + expect(redactorPrepared).toHaveBeenCalledOnce(); + } + }); + async function configuration(f: Awaited>, config: Record, snapshot: Record = {}, type = "paperclip_runner", explicitTaskDirectory = false, requestedExecutionWorkspaceMode: HeartbeatExecutionConfigurationInput["policy"]["requestedExecutionWorkspaceMode"] = "shared_workspace", selectedEnvironmentForConfig: HeartbeatExecutionConfigurationInput["environment"]["selectedEnvironmentForConfig"] = null, pinnedSkillTest?: HeartbeatExecutionConfigurationInput["task"]["pinnedSkillTest"]) { const [agent] = await db.update(agents).set({ adapterType: type, adapterConfig: config }).where(eq(agents.id, f.agentId)).returning(); const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, scopeKind: "issue", status: "running", contextSnapshot: snapshot }).returning(); diff --git a/server/src/__tests__/heartbeat-plugin-environment.test.ts b/server/src/__tests__/heartbeat-plugin-environment.test.ts index 28b5335ca0..5122b683b3 100644 --- a/server/src/__tests__/heartbeat-plugin-environment.test.ts +++ b/server/src/__tests__/heartbeat-plugin-environment.test.ts @@ -93,7 +93,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { await stopDb?.(); }); - it("dispatches a plugin-owned account per-turn without Core workspace files through the native heartbeat boundary", async () => { + it("refuses a plugin Runner-task run without a managed AI connection before any plugin call", async () => { const companyId = randomUUID(), projectId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), sourceId = randomUUID(); const environmentId = randomUUID(), pluginId = randomUUID(), pluginKey = `test.tasks.${pluginId}`; const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-task-heartbeat-")); tempRoots.push(root); @@ -120,15 +120,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { defaultEnvironmentId: environmentId, permissions: {} }); await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId: sourceId, title: "Task-only dispatch", status: "in_progress", assigneeAgentId: agentId, issueNumber: 1, identifier: "TASK-1" }); - const call = vi.fn(async (_id: string, method: string, params?: any) => { - if (method === "workspaceOperation") return { kind: "prepared", binding: { pluginId, projectId, - executionWorkspaceId: params.executionWorkspaceId, projectWorkspaceId: params.operation.source.id, - sourceRevision: params.operation.source.revision, workspaceRef: "provider:heartbeat-workspace" }, - branchName: params.operation.branchName, baseSha: "a".repeat(40), created: true, branchCreatedByRuntime: false }; - if (method === "environmentAcquireLease") return { providerLeaseId: "heartbeat-task-attempt", metadata: { remoteCwd: "/project" } }; - if (method === "environmentReleaseLease" || method === "environmentDestroyLease") return undefined; - throw new Error(`Unexpected task provider call: ${method}`); - }); + const call = vi.fn(async (_id: string, method: string) => { throw new Error(`Unexpected task provider call: ${method}`); }); const workerManager = { isRunning: () => true, getWorker: () => ({ supportedMethods: ["workspaceOperation", "environmentTask", "environmentAcquireLease", "environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] }), call } as unknown as PluginWorkerManager; const entered = vi.fn(() => { throw new Error("synthetic_native_boundary_reached"); }); const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager, nativeSessionBackendFactory: entered }); @@ -136,13 +128,13 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId, projectId } }); expect(run).not.toBeNull(); await vi.waitFor(async () => { expect((await heartbeat.getRun(run!.id))?.status).toBe("failed"); }, { timeout: 10_000 }); - expect(entered).toHaveBeenCalledWith(expect.objectContaining({ binding: expect.objectContaining({ runId: run!.id, companyId, issueId }), - session: expect.objectContaining({ lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }) })); - expect((await heartbeat.getRun(run!.id))?.error).toContain("synthetic_native_boundary_reached"); await heartbeat.drainActiveRunExecutions(); - expect(call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", expect.objectContaining({ providerLeaseId: "heartbeat-task-attempt" })); - expect(call.mock.calls.every(([, method]) => ["workspaceOperation", "environmentAcquireLease", "environmentDestroyLease"].includes(method))).toBe(true); - expect(call.mock.calls.filter(([, method]) => method === "workspaceOperation")).toHaveLength(1); + const failed = await heartbeat.getRun(run!.id); + expect(failed).toMatchObject({ errorCode: "configuration_incomplete", error: expect.stringContaining("requires a managed AI connection"), + resultJson: { configurationIncomplete: { reason: "ai_connection_required", actionUrl: `/agents/${agentId}/runtime` } } }); + // No workspace, lease, task, or native Runner work starts. + expect(call).not.toHaveBeenCalled(); + expect(entered).not.toHaveBeenCalled(); expect(adapterExecute).not.toHaveBeenCalled(); await expect(stat(pluginOnlyCwd)).rejects.toMatchObject({ code: "ENOENT" }); }, 20_000); diff --git a/server/src/__tests__/plugin-runner-task.integration.test.ts b/server/src/__tests__/plugin-runner-task.integration.test.ts index f612b29514..403eba17f2 100644 --- a/server/src/__tests__/plugin-runner-task.integration.test.ts +++ b/server/src/__tests__/plugin-runner-task.integration.test.ts @@ -221,6 +221,14 @@ databaseSuite("Core plugin Runner task lifecycle", () => { await setup.testManagedEnvironment("paperclip_runner", { companyId, adapterType: "paperclip_runner", config: {}, executionTarget: probe.executionTarget }, { provider: "openai" } as any, selected as any); expect(verify).toHaveBeenCalledWith(expect.anything(), { identity: selected.identity, accountIdentity: selected.sessionIdentity, home: "/core-owned-home/provider" }); + verify.mockClear(); + // A preconfigured Runner home does not use the selected connection, so the probe refuses it. + const operationsBefore = calls.length; + await expect(setup.testManagedEnvironment("paperclip_runner", { companyId, adapterType: "paperclip_runner", config: { homeMode: "preconfigured" }, executionTarget: probe.executionTarget }, + { provider: "openai" } as any, selected as any)).resolves.toMatchObject({ status: "fail", + checks: [{ code: "ai_connection_required", message: expect.stringContaining("isolated Runner home") }] }); + expect(verify).not.toHaveBeenCalled(); + expect(calls).toHaveLength(operationsBefore); } finally { verify.mockRestore(); } await probe.executionTarget.runnerTask!({ kind: "check_project_auth", configurationId: "probe-config", provider: "openai", projectIds: [] }); await probe.executionTarget.runnerTask!({ kind: "submit", preparedLaunchId: "probe-config", bootstrapTicket: "synthetic-probe-ticket", projectIds: [], diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts index 4c9210bafa..1390111519 100644 --- a/server/src/adapters/registry.test.ts +++ b/server/src/adapters/registry.test.ts @@ -384,3 +384,19 @@ function authorizeKimi(model: string) { platform: "linux", architecture: "x64", nativeClosureDigest: `sha256:${KIMI_DISTRIBUTION_CLOSURE_SHA256["linux-x64"]}`, expiresAt: Date.now() + 60_000 })); } + +describe("plugin Runner-task environment checks", () => { + it.each([ + { provider: "codex", model: "gpt-6.1-sol" }, + { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" }, + ])("requires a managed AI connection before any task operation: %j", async config => { + const runnerTask = vi.fn(); + const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ companyId: "company-test", adapterType: "paperclip_runner", config, + executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: ".", leaseId: "probe-lease", + runnerTaskProbe: { runId: "probe-run", sessionId: "probe-session" }, runnerTask } as never }); + expect(result).toMatchObject({ adapterType: "paperclip_runner", status: "fail", + checks: [{ code: "ai_connection_required", level: "error", message: expect.stringContaining("requires a managed AI connection") }] }); + expect(runnerTask).not.toHaveBeenCalled(); + expect(() => assertHarnessTestPassed(result)).toThrow(expect.objectContaining({ code: "ai_connection_required" })); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index a96f57763b..d5b38bea0b 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,4 +1,4 @@ -import { probePluginRunnerEnvironment } from "../services/native-runtime/plugin-runner-probe.js"; +import { pluginRunnerAiConnectionRequiredTestResult } from "../services/plugin-runner-ai-connection.js"; import { HttpError } from "../errors.js"; import type { AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js"; import { parseAdapterModelsEnv } from "../services/adapter-models-env.js"; @@ -440,7 +440,9 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }; } if (profile.provider !== "cursor_cloud" && context.executionTarget?.kind === "remote" && context.executionTarget.transport === "sandbox" && context.executionTarget.runnerTask) { - return probePluginRunnerEnvironment(context); + // Managed connections probe through testManagedEnvironment. This path has + // none, which a plugin Runner-task environment never runs. + return pluginRunnerAiConnectionRequiredTestResult("paperclip_runner"); } if (profile.provider === "cursor_cloud") { const failure = (code: string, message: string) => ({ adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(), checks: [{ code, level: "error" as const, message }] }); diff --git a/server/src/modules/agent-lifecycle/__tests__/diagnostics.test.ts b/server/src/modules/agent-lifecycle/__tests__/diagnostics.test.ts index 3f59f82d6f..fe9410c111 100644 --- a/server/src/modules/agent-lifecycle/__tests__/diagnostics.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/diagnostics.test.ts @@ -67,7 +67,7 @@ describe("lifecycle readiness handoff", () => { }); describe("lifecycle failure diagnostics", () => { - it.each(["harness_auth_failed", "harness_model_unavailable", "harness_command_unavailable", "harness_cwd_invalid", "harness_probe_unexpected_output"])("waits for repair only for a host verification failure: %s", code => { + it.each(["ai_connection_required", "harness_auth_failed", "harness_model_unavailable", "harness_command_unavailable", "harness_cwd_invalid", "harness_probe_unexpected_output"])("waits for repair only for a host verification failure: %s", code => { const error = { code }; expect(lifecycleVerificationNeedsRetry({ stage: "host", phase: "verifying" }, error)).toBe(true); expect(lifecycleVerificationNeedsRetry({ stage: "plugin", phase: "verifying" }, error)).toBe(false); @@ -83,6 +83,7 @@ describe("lifecycle failure diagnostics", () => { it.each([ ["host", "harness_model_unavailable", "Execution configuration check failed. The selected model is unavailable."], + ["host", "ai_connection_required", "Execution configuration check failed. This agent's environment requires a managed AI connection and an isolated Runner home."], ["plugin", "required_plugin_unavailable", "Execution configuration check: required plugin step failed. A required plugin is unavailable."], ["select_plugins", "unknown private code", "Execution configuration check: finding required plugins failed. Paperclip could not complete this step."], ["transition", "40P01", "Execution configuration check: saving progress failed. Paperclip could not complete this step."], diff --git a/server/src/modules/agent-lifecycle/application/failure.ts b/server/src/modules/agent-lifecycle/application/failure.ts index 897d7440b9..905f7fd203 100644 --- a/server/src/modules/agent-lifecycle/application/failure.ts +++ b/server/src/modules/agent-lifecycle/application/failure.ts @@ -3,6 +3,7 @@ import type { LifecycleFailure } from "./ports.js"; // Only fixed text may leave this boundary. Provider errors can contain secrets. const reasons = { harness_test_failed: "The execution test did not pass. Open Configuration and run the agent test to inspect the failed checks.", + ai_connection_required: "This agent's environment requires a managed AI connection and an isolated Runner home. Check both in Configuration, then retry.", harness_auth_failed: "Authentication failed. Check the agent's AI connection or sign in to its execution tool, then retry.", harness_model_unavailable: "The selected model is unavailable. Choose a model supported by the agent's AI connection in Configuration, then retry.", harness_command_unavailable: "The execution command was not found. Check the command and its installation in the agent's environment, then retry.", @@ -17,7 +18,7 @@ const reasons = { const transientCodes = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "40001", "40P01", "53300", "57P01"]); const configurationFailures = new Set([ - "harness_auth_failed", "harness_model_unavailable", + "ai_connection_required", "harness_auth_failed", "harness_model_unavailable", "harness_command_unavailable", "harness_cwd_invalid", "harness_probe_unexpected_output", ]); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 5b3339fd54..d0625789a9 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -11,6 +11,7 @@ import { connectionManagementGuard, connectionRequestContext, connectionRequestU import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js"; import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, normalizeClaudeRunnerOptions, type AgentConfigurationWarning, type AgentRunnerChoice } from "@paperclipai/shared"; import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js"; +import { PLUGIN_RUNNER_AI_CONNECTION_REQUIRED, pluginEnvironmentRunnerTaskDriver, pluginRunnerAiConnectionRequiredMessage, pluginRunnerAiConnectionRequiredTestResult } from "../services/plugin-runner-ai-connection.js"; import { agentEnvironmentTestService } from "../services/agent-environment-test.js"; import { qualifyAgentRunner } from "../services/agent-runner-qualification.js"; import { createAgentLifecycle } from "../services/agent-lifecycle.js"; @@ -3235,6 +3236,11 @@ export function agentRoutes( // The lifecycle verifies the saved agent after its plugin can create the agent-owned computer. return { connectionId: selection.connection.id }; } + const testEnvironment = testEnvironmentId ? await environmentsSvc.getById(testEnvironmentId) : null; + if (adapterType === "paperclip_runner" && config.homeMode === "preconfigured" && testEnvironment + && await pluginEnvironmentRunnerTaskDriver(db, testEnvironment)) { + throw unprocessable(pluginRunnerAiConnectionRequiredMessage(true), { code: PLUGIN_RUNNER_AI_CONNECTION_REQUIRED }); + } const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, agentId: hasSavedAgent ? agentId : undefined, environmentId: testEnvironmentId }); try { @@ -3384,6 +3390,14 @@ export function agentRoutes( res.json({ adapterType: type, status: checks.some(c => c.level === "error") ? "fail" : checks.some(c => c.level === "warn") ? "warn" : "pass", testedAt: new Date().toISOString(), checks }); return; } + // A plugin Runner-task environment runs only managed AI connections in an isolated + // home. Refuse before secret resolution or a computer allocation it cannot use. + const preconfiguredTestHome = type === "paperclip_runner" && adapterConfigForTest.homeMode === "preconfigured"; + if (type === "paperclip_runner" && requestedEnvironment && (preconfiguredTestHome || !aiBinding) + && await pluginEnvironmentRunnerTaskDriver(db, requestedEnvironment)) { + res.json(pluginRunnerAiConnectionRequiredTestResult(type, preconfiguredTestHome)); + return; + } const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, adapterConfigForTest, diff --git a/server/src/services/__tests__/run-failure-report.test.ts b/server/src/services/__tests__/run-failure-report.test.ts index c533e59fee..54bdd58878 100644 --- a/server/src/services/__tests__/run-failure-report.test.ts +++ b/server/src/services/__tests__/run-failure-report.test.ts @@ -893,6 +893,33 @@ describeEmbeddedPostgres("reportRunFailure", () => { expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1); }); + function aiConnectionRequiredRun(overrides: Partial = {}) { + return buildRun({ + errorCode: "configuration_incomplete", + executionStage: "preparing", + resultJson: { + configurationIncomplete: { reason: "ai_connection_required", actionUrl: "/agents/fixture/runtime" }, + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }, + ...overrides, + }); + } + + it("keeps a plugin Runner-task run without a managed AI connection actionable on the task", async () => { + await seedCompanyAndAgent(); + await reportRunFailure(db, aiConnectionRequiredRun(), { phase: "setup" }); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + }); + + it.each([ + { executionStage: "executing" }, + { resultJson: { configurationIncomplete: { reason: "ai_connection_required" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: true } } }, + ])("reports AI-connection-required markers without an exact pre-dispatch outcome: %j", async (overrides) => { + await seedCompanyAndAgent(); + await reportRunFailure(db, aiConnectionRequiredRun(overrides), { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1); + }); + function localPathConfigurationRun(overrides: Partial = {}) { return buildRun({ errorCode: "workspace_validation_failed", diff --git a/server/src/services/agent-environment-test.ts b/server/src/services/agent-environment-test.ts index c60fd3e5a1..ade8bb8709 100644 --- a/server/src/services/agent-environment-test.ts +++ b/server/src/services/agent-environment-test.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import { probePluginRunnerEnvironment } from "./native-runtime/plugin-runner-probe.js"; +import { pluginRunnerAiConnectionRequiredTestResult } from "./plugin-runner-ai-connection.js"; import { registerPluginTaskCredentialHandoff } from "./plugin-task-credential-writeback.js"; import { randomUUID } from "node:crypto"; import { agents, companies, type Db } from "@paperclipai/db"; @@ -574,12 +575,17 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin return { adapterType, status: "fail" as const, testedAt: new Date().toISOString(), checks: [{ code: error instanceof HttpError ? String(asRecord(error.details)?.code ?? "COPILOT_REQUEST_FAILED") : "COPILOT_REQUEST_FAILED", level: "error" as const, message: error instanceof HttpError ? error.message : "Copilot metadata verification failed." }] }; } } + const target = context.executionTarget; + // A preconfigured Runner home does not use the managed connection, so run admission refuses it. + if (adapterType === "paperclip_runner" && context.config.homeMode === "preconfigured" + && target?.kind === "remote" && target.transport === "sandbox" && target.runnerTask) { + return pluginRunnerAiConnectionRequiredTestResult(adapterType, true); + } try { await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget); } catch (error) { if (!isRunnerRevisionMismatch(error)) throw error; return runnerRevisionMismatchTestResult(adapterType); } - const target = context.executionTarget; const result = adapterType === "paperclip_runner" && target?.kind === "remote" && target.transport === "sandbox" && target.runnerTask ? await probePluginRunnerEnvironment(context, { identity: managed.identity, accountIdentity: managed.sessionIdentity, home: managed.home ? join(managed.home, "provider") : undefined }) diff --git a/server/src/services/agent-harness-verification.ts b/server/src/services/agent-harness-verification.ts index 4ebc7da16d..6f01156227 100644 --- a/server/src/services/agent-harness-verification.ts +++ b/server/src/services/agent-harness-verification.ts @@ -8,6 +8,7 @@ import { agentEnvironmentTestService } from "./agent-environment-test.js"; import { aiConnectionRouterService } from "./ai-connection-router.js"; import { aiConnectionService } from "./ai-connections.js"; import { withManagedAiProbe, stripAiAuthBindings } from "./ai-connection-runtime.js"; +import { PLUGIN_RUNNER_AI_CONNECTION_REQUIRED, pluginEnvironmentRunnerTaskDriver, pluginRunnerAiConnectionRequiredFailure } from "./plugin-runner-ai-connection.js"; import { environmentService } from "./environments.js"; import { secretService } from "./secrets.js"; import { instanceSettingsService } from "./instance-settings.js"; @@ -27,7 +28,8 @@ export function assertHarnessTestPassed(result: AdapterEnvironmentTestResult) { const incomplete = !verified && (result.status === "warn" || result.checks.some(check => check.code.includes("hello_probe"))); if (result.status === "fail" || authenticationFailed || incomplete) { const has = (suffix: string) => result.checks.some(check => check.code.endsWith(suffix)); - const code = result.checks.some(check => check.code === RUNNER_REVISION_MISMATCH_CODE) ? RUNNER_REVISION_MISMATCH_CODE + const code = result.checks.some(check => check.code === PLUGIN_RUNNER_AI_CONNECTION_REQUIRED) ? PLUGIN_RUNNER_AI_CONNECTION_REQUIRED + : result.checks.some(check => check.code === RUNNER_REVISION_MISMATCH_CODE) ? RUNNER_REVISION_MISMATCH_CODE : result.checks.some(check => check.level === "error" && check.retryable === true) ? "test_environment_unavailable" : authenticationFailed ? "harness_auth_failed" : has("_hello_probe_model_unavailable") || has("_model_invalid") ? "harness_model_unavailable" @@ -76,6 +78,10 @@ export function agentHarnessVerificationService(db: Db, manager: PluginWorkerMan if (environmentId) { await tests.assertAdapterTestEnvironmentForCompany(agent.companyId, environmentId, agent.adapterType); const environment = await environments.getById(environmentId); + // Run admission refuses these agents before any lease, so verification must too. + const preconfiguredHome = agent.adapterType === "paperclip_runner" && config.homeMode === "preconfigured"; + if (agent.adapterType === "paperclip_runner" && environment && (preconfiguredHome || !binding) + && await pluginEnvironmentRunnerTaskDriver(db, environment)) throw pluginRunnerAiConnectionRequiredFailure(preconfiguredHome); const env = Object.fromEntries(Object.entries(binding ? stripAiAuthBindings(environment?.envVars) : parseObject(environment?.envVars)) .filter(([key]) => !isForbiddenConfigEnvKey(key))); const resolved = await secrets.resolveEnvBindings(agent.companyId, env, diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index b532af64eb..fb902c668f 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -18,6 +18,7 @@ import { normalizeClaudeRunnerOptions, type AgentConfigurationWarning } from "@p import { usesManagedCloudSandboxImage } from "./sandbox-snapshot-qualification.js"; import { environmentCustomImageTemplateMatchesBaseConfig } from "./environment-custom-image-runtime.js"; import type { ExecutionPolicy } from "./execution-allowlist.js"; +import { pluginEnvironmentRunnerTaskDriver } from "./plugin-runner-ai-connection.js"; export type RunnerTarget = { driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string; @@ -496,20 +497,3 @@ async function resolveAgentEnvironmentForCompany(db: Db, companyId: string, defa return { environment, driver: environment?.driver ?? "local", executionMode }; } -// 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 pluginEnvironmentRunnerTaskDriver(db: Db, environment: Parameters[0]) { - let config: { pluginKey: string; driverKey: string }; - try { - const parsed = parseEnvironmentDriverConfig(environment); - if (parsed.driver !== "plugin") return undefined; - config = parsed.config; - } catch { - return undefined; - } - const { pluginRegistryService } = await import("./plugin-registry.js"); - const plugin = await pluginRegistryService(db).getByKey(config.pluginKey); - return plugin?.manifestJson.capabilities.includes("environment.drivers.register") - ? plugin.manifestJson.environmentDrivers?.find(driver => driver.driverKey === config.driverKey && driver.supportsTasks === true) : undefined; -} diff --git a/server/src/services/heartbeat/execution-configuration.ts b/server/src/services/heartbeat/execution-configuration.ts index 6d88d5d779..262a68fcdf 100644 --- a/server/src/services/heartbeat/execution-configuration.ts +++ b/server/src/services/heartbeat/execution-configuration.ts @@ -19,6 +19,7 @@ import { AI_AUTH_ENV_KEYS, isAiConnectionBusy, prepareManagedAiRuntime, stripAiA import { managedAiSessionIdentityCompatible } from "../ai-connection-session.js"; import type { companySkillService } from "../company-skills.js"; import { connectionIntentService } from "../connection-intents.js"; +import { PLUGIN_RUNNER_AI_CONNECTION_REQUIRED, pluginEnvironmentRunnerTaskDriver, pluginRunnerAiConnectionRequiredMessage } from "../plugin-runner-ai-connection.js"; import { prepareConnectionInstructionDelivery } from "../connection-instructions.js"; import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "../connector-runtime.js"; import { resolveManagedGitHubIdentitySelection } from "../git-credentials.js"; @@ -106,6 +107,16 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear configurationIncomplete: { reason: "skill_unavailable", actionUrl: `/agents/${agent.id}/runtime` }, }); } + // A plugin Runner-task environment runs only managed AI connections. Refuse a new + // admission before secrets, leases, or provider calls. Resumed native input keeps + // the contract it was admitted under. + if (agent.adapterType === "paperclip_runner" && (preconfiguredHome || !input.config.aiBinding) && !persistedNativeExecutionInput + && selectedEnvironmentForConfig && await pluginEnvironmentRunnerTaskDriver(db, selectedEnvironmentForConfig)) { + throw new ConfigurationIncompleteFailure(pluginRunnerAiConnectionRequiredMessage(preconfiguredHome), { + configurationIncomplete: { reason: PLUGIN_RUNNER_AI_CONNECTION_REQUIRED, actionUrl: `/agents/${agent.id}/runtime`, + ...(preconfiguredHome ? { homeMode: "preconfigured" } : {}) }, + }); + } const initialHarness = agentHarnessType(agent.adapterType, sourceWorkspaceConfig); const observabilityKind = (harness: string) => harness === "codex_local" ? "codex" as const : harness === "claude_local" ? "claude" as const diff --git a/server/src/services/plugin-runner-ai-connection.ts b/server/src/services/plugin-runner-ai-connection.ts new file mode 100644 index 0000000000..22202500df --- /dev/null +++ b/server/src/services/plugin-runner-ai-connection.ts @@ -0,0 +1,51 @@ +import type { AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils"; +import type { Db } from "@paperclipai/db"; +import { parseEnvironmentDriverConfig } from "./environment-config.js"; + +/** + * A plugin environment that runs Runner tasks executes only runs with a + * managed AI connection. The account installed on the provider's machine is + * not a supported credential source, so Core refuses these runs and setup + * probes before it leases or calls the provider. + */ +export const PLUGIN_RUNNER_AI_CONNECTION_REQUIRED = "ai_connection_required"; + +export const PLUGIN_RUNNER_AI_CONNECTION_REQUIRED_MESSAGE = + "This agent's environment requires a managed AI connection. Choose an AI connection for this agent in Configuration, then retry."; + +/** A preconfigured Runner home does not use the agent's AI connection, even when one is saved. */ +export const PLUGIN_RUNNER_ISOLATED_HOME_REQUIRED_MESSAGE = + "This agent's environment requires a managed AI connection, which a preconfigured Runner home does not use. Switch this agent to an isolated Runner home in Configuration, then retry."; + +export function pluginRunnerAiConnectionRequiredMessage(preconfiguredHome: boolean) { + return preconfiguredHome ? PLUGIN_RUNNER_ISOLATED_HOME_REQUIRED_MESSAGE : PLUGIN_RUNNER_AI_CONNECTION_REQUIRED_MESSAGE; +} + +// 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. +export async function pluginEnvironmentRunnerTaskDriver(db: Db, environment: Parameters[0]) { + let config: { pluginKey: string; driverKey: string }; + try { + const parsed = parseEnvironmentDriverConfig(environment); + if (parsed.driver !== "plugin") return undefined; + config = parsed.config; + } catch { + return undefined; + } + const { pluginRegistryService } = await import("./plugin-registry.js"); + const plugin = await pluginRegistryService(db).getByKey(config.pluginKey); + return plugin?.manifestJson.capabilities.includes("environment.drivers.register") + ? plugin.manifestJson.environmentDrivers?.find(driver => driver.driverKey === config.driverKey && driver.supportsTasks === true) : undefined; +} + +/** The setup probe result for a plugin Runner-task target without a managed AI connection. */ +export function pluginRunnerAiConnectionRequiredTestResult(adapterType: string, preconfiguredHome = false, testedAt = new Date().toISOString()): AdapterEnvironmentTestResult { + return { adapterType, status: "fail", testedAt, checks: [{ code: PLUGIN_RUNNER_AI_CONNECTION_REQUIRED, level: "error", + message: pluginRunnerAiConnectionRequiredMessage(preconfiguredHome) }] }; +} + +/** Lifecycle verification throws this before it resolves a probe target or acquires a lease. */ +export function pluginRunnerAiConnectionRequiredFailure(preconfiguredHome: boolean) { + return Object.assign(new Error(pluginRunnerAiConnectionRequiredMessage(preconfiguredHome)), { code: PLUGIN_RUNNER_AI_CONNECTION_REQUIRED }); +} diff --git a/server/src/services/recovery/stranded-notice.test.ts b/server/src/services/recovery/stranded-notice.test.ts index d00131f7d9..8f810f0e1b 100644 --- a/server/src/services/recovery/stranded-notice.test.ts +++ b/server/src/services/recovery/stranded-notice.test.ts @@ -343,3 +343,18 @@ it("names a remote Runner that was still not updated after its retries", () => { expect(notice.body).not.toContain("authentication"); expect(JSON.stringify(notice.metadata)).toContain("remote Runner received the update"); }); + +it("names the managed AI connection a plugin Runner-task environment requires", () => { + const notice = buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "ai_connection_required" }); + expect(notice.title).toBe("AI connection required"); + expect(notice.body).toContain("requires a managed AI connection"); + expect(notice.nextAction).toContain("Choose an AI connection"); + expect(notice.body).not.toContain("secret/env"); +}); + +it("tells the operator to switch a preconfigured Runner home to an isolated one", () => { + const notice = buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "ai_connection_required", homeMode: "preconfigured" }); + expect(notice.title).toBe("Isolated Runner home required"); + expect(notice.nextAction).toContain("isolated Runner home"); + expect(notice.nextAction).not.toContain("Choose an AI connection"); +}); diff --git a/server/src/services/recovery/stranded-notice.ts b/server/src/services/recovery/stranded-notice.ts index 3a302bf3fe..d6f731b9f4 100644 --- a/server/src/services/recovery/stranded-notice.ts +++ b/server/src/services/recovery/stranded-notice.ts @@ -164,6 +164,22 @@ export function buildConfigurationIncompleteRecoveryNoticeSeed( tone: "danger", }; } + if (readNonEmptyStringField(configurationIncomplete, "reason") === "ai_connection_required") { + if (readNonEmptyStringField(configurationIncomplete, "homeMode") === "preconfigured") { + return { + title: "Isolated Runner home required", + body: "Paperclip stopped before the agent started because this agent runs in a plugin-provided environment, which requires a managed AI connection. A preconfigured Runner home does not use the agent's AI connection. The task is blocked until the agent uses an isolated Runner home.", + nextAction: "Switch this agent to an isolated Runner home in Configuration, then retry the task.", + tone: "danger", + }; + } + return { + title: "AI connection required", + body: "Paperclip stopped before the agent started because this agent runs in a plugin-provided environment, which requires a managed AI connection. The task is blocked until the agent has one.", + nextAction: "Choose an AI connection for this agent in Configuration, then retry the task.", + tone: "danger", + }; + } if (readNonEmptyStringField(configurationIncomplete, "reason") === "ai_connection_unavailable") { return { title: "AI connection needs attention", diff --git a/server/src/services/run-failure-report.ts b/server/src/services/run-failure-report.ts index fcf4baee0a..c553ad3db4 100644 --- a/server/src/services/run-failure-report.ts +++ b/server/src/services/run-failure-report.ts @@ -15,6 +15,7 @@ import { readConnectionFailure, type ConnectionFailure } from "@paperclipai/adap import { isAiConnectionConfigurationReason } from "./ai-connection-configuration-failure.js"; import { readRunFailureReportingMode, type RunFailureReportingMode } from "../sentry-run-failure-policy.js"; import { RESTRICTED_WORKSPACE_SOURCE_REASON } from "./workspace-source-restriction.js"; +import { PLUGIN_RUNNER_AI_CONNECTION_REQUIRED } from "./plugin-runner-ai-connection.js"; import { NATIVE_PROVIDER_OVERLOADED_CODE } from "./native-runtime/native-provider-failure.js"; type HeartbeatRun = typeof heartbeatRuns.$inferSelect; @@ -159,6 +160,19 @@ function isRestrictedWorkspaceSourceBlocker(run: HeartbeatRun, options: RunFailu recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false; } +/** A plugin Runner-task run without a managed AI connection, refused before dispatch; the task notice names the owner action. */ +function isPluginRunnerAiConnectionRequiredBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean { + if ( + run.status !== "failed" || run.errorCode !== "configuration_incomplete" || + run.executionStage !== "preparing" || options.phase !== "setup" || + run.exitCode != null || run.signal != null + ) return false; + const configuration = asRecord(run.resultJson?.configurationIncomplete); + const recovery = asRecord(run.resultJson?.executionRecovery); + return configuration?.reason === PLUGIN_RUNNER_AI_CONNECTION_REQUIRED && + recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false; +} + /** Only the managed-clone producer can attach this bounded pre-dispatch evidence. */ function isGitConnectionFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean { const connection = readConnectionFailure(run.resultJson?.connectionFailure); @@ -318,6 +332,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR isAiConnectionSelectionBlocker(run, options) || isLocalPathWorkspaceConfigurationBlocker(run, options) || isRestrictedWorkspaceSourceBlocker(run, options) || + isPluginRunnerAiConnectionRequiredBlocker(run, options) || isGitConnectionFailure(run, options) || isProviderQuotaFailure(run, options) || isCodexAcpModelCapacityFailure(run, options) ||