mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
fix(runner): require a managed AI connection for plugin Runner-task environments (#19341)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A plugin can provide Runner execution through an environment driver that declares `supportsTasks: true`. Agent computers use this path. > - Core let an agent without a managed AI connection run on that path. The run then used the account that was installed on the plugin's execution host. > - That is not a supported credential source. When the host Runner did not match, the run failed three times, 30 seconds apart, and then blocked the task with a generic error. The error did not name the fix. > - The plugin cannot refuse these runs itself. `describe` has no connection data, and Core replaces plugin error text before the user sees it. > - This pull request makes a managed AI connection a requirement for every plugin Runner-task environment. Core refuses a run without one before it acquires a lease or calls the plugin. > - The benefit is one clear notice that tells the operator to choose an AI connection, and no wasted retries or plugin work. ## Linked Issues or Issue Description Refs #19326. That pull request makes a Runner revision mismatch retry. It noted that runs without a managed AI connection reach the Runner check on a different path, which still blocks after 3 attempts. This pull request removes that path for plugin Runner-task environments. **Problem or motivation** A `paperclip_runner` agent with no managed AI connection can run in a plugin environment that provides Runner tasks. The run uses the account installed on the execution host. If the host Runner does not match, the native session retries 3 times and then blocks the task with "Plugin-provided Runner execution operation unavailable". The operator cannot tell what to fix. **Proposed solution** Refuse these runs in Core before any lease, plugin call, secret resolution, or provider work. Use one stable reason, `ai_connection_required`, for run admission, the setup probe, and agent verification. Show a task notice that names the fix. **Alternatives considered** A refusal in the plugin. It cannot see the connection at `describe`, which runs first. Its error text is also replaced twice before the user sees it. A new structured plugin error code would still not cover `describe`. ## What Changed - Add `server/src/services/plugin-runner-ai-connection.ts`. It holds the `ai_connection_required` code and message, the probe result, and the manifest lookup for a Runner-task driver. The lookup moves here from `agent-runner-selection.ts`, so run admission and runner selection use the same rule. - `heartbeat/execution-configuration.ts`: refuse a new `paperclip_runner` run in a Runner-task environment when it has no managed AI connection. This also applies when `homeMode: "preconfigured"` drops the connection. The run fails as `configuration_incomplete` with reason `ai_connection_required` and an action URL to the agent's runtime settings. The check runs before observability, secrets, the managed runtime, the workspace, and the lease. A resumed native run keeps its admitted contract. - `recovery/stranded-notice.ts`: add an "AI connection required" task notice. - `run-failure-report.ts`: treat this pre-dispatch refusal as an owner action, not an unexpected failure. - `adapters/registry.ts`: the unmanaged probe for a Runner-task target now returns a failed `ai_connection_required` check. It calls no task operation. Managed probes still go through `testManagedEnvironment`. - `agent-harness-verification.ts` and `agent-lifecycle/application/failure.ts`: classify the check as `ai_connection_required` with a fixed message. Verification waits for the configuration to change instead of retrying. - Docs: `PLUGIN_RUNNER_EXECUTION.md` states the requirement. `PLUGIN_ENVIRONMENT_TASKS.md` no longer mentions installed accounts. `agent-config-ui.md` notes the requirement. - Tests: the "plugin-owned account" heartbeat test described the behavior that this change removes. It now checks the assembled heartbeat refusal: no plugin call, no native boundary, no adapter, no workspace files. ## Verification - `npx vitest run` in `server/` for these files. All tests passed: - `src/__tests__/heartbeat-execution-configuration.test.ts`. New cases: refused without a connection, refused for a preconfigured home, managed passes, resumed native input passes, non-task plugin driver passes. The refused cases also check that observability, secrets, and the managed runtime did not start. - `src/__tests__/heartbeat-plugin-environment.test.ts`. One test, "inherits the instance default environment across companies…", also fails on unmodified `master` in my environment. This change does not touch it, because its driver does not declare `supportsTasks`. - `src/adapters/registry.test.ts`, `src/__tests__/agent-harness-verification.test.ts`, `src/modules/agent-lifecycle/__tests__/diagnostics.test.ts`, `src/services/recovery/stranded-notice.test.ts`, and `src/services/__tests__/run-failure-report.test.ts`. - 17 more suites that use plugin Runner-task environments, with 705 tests: `plugin-runner-task.integration`, `plugin-warm-tasks.integration`, `task-creation-recovery.integration`, `native-startup-cleanup-recovery`, `environment-task-authority.integration`, `plugin-task-credential-writeback`, agent-lifecycle `lifecycle`, `setup`, and `environment-plugins`, `workspace-preparation`, `agent-runner-selection`, `agent-harness-verification-provider-api`, `agent-harness-verification-remote`, `plugin-environment-driver-seam`, `environment-task-runtime`, `agent-test-environment-routes`, and `adapter-registry`. - `pnpm typecheck` in `server/` reported no errors in the changed files. Its only errors came from sandbox-provider packages that my local install did not link. - Not run: `plugin-runner-probe.test.ts`, because it needs a Cargo build. This change does not modify `probePluginRunnerEnvironment`. Also not run: the full `pnpm test:run` and `pnpm build`. CI runs them. ## Risks - Behavior change: an agent in a plugin Runner-task environment with no managed AI connection now stops before dispatch. Before, it ran with the host's installed account. The agent's owner must choose an AI connection. This includes agents with `homeMode: "preconfigured"` in these environments. - Runs that were already admitted and resume from saved native input are not refused. They keep the earlier behavior until they finish. - The check reads the plugin manifest on each new run admission for plugin environments. This is the same lookup that runner selection already does. - This pull request and #19326 both edit `stranded-notice.ts` and `failure.ts`. The changes are additive, but whichever merges second needs a small rebase. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (file edits, shell, test runs). Default context window, extended thinking on. ## 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 - [x] All Paperclip CI gates are green - [x] 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: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
bf10bc695a
commit
f9d366acdf
23 files changed
+357
-48
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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/<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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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<string, unknown>, 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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<typeof import("../services/plugin-runner-ai-connection.js")>(),
|
||||
pluginEnvironmentRunnerTaskDriver: mockPluginRunnerTaskDriver,
|
||||
}));
|
||||
vi.mock("../services/plugin-environment-driver.js", async importOriginal => ({
|
||||
...(await importOriginal<typeof import("../services/plugin-environment-driver.js")>()),
|
||||
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();
|
||||
|
||||
@@ -276,6 +276,63 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
}
|
||||
});
|
||||
|
||||
async function runnerTaskEnvironment(supportsTasks = true): Promise<Environment> {
|
||||
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<ReturnType<typeof seed>>, config: Record<string, unknown>, snapshot: Record<string, unknown> = {}, 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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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 }] });
|
||||
|
||||
@@ -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."],
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -893,6 +893,33 @@ describeEmbeddedPostgres("reportRunFailure", () => {
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
function aiConnectionRequiredRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
|
||||
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<typeof heartbeatRuns.$inferSelect> = {}) {
|
||||
return buildRun({
|
||||
errorCode: "workspace_validation_failed",
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof parseEnvironmentDriverConfig>[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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof parseEnvironmentDriverConfig>[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 });
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
Reference in new issue
Block a user