diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 27d934c879a..035d338de5c 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1038,6 +1038,22 @@ snapshot lasts for one request only; the next request checks current grants, membership, and run restrictions again. Adapters that expose only `decide` retain the individual-read path with duplicate bindings shared within the request. +### Managed Cloud runner images + +Paperclip-managed Cloud sandbox images do not require a saved platform or +snapshot qualification. Discovery offers Paperclip Runner, and execution uses +the current platform-selected image even when an older qualification exists. +The real runtime startup and handshake report compatibility failures. Test goes +directly to that runtime check when discovery already offers Paperclip Runner. +Company access, adapter availability, credentials and runtime integrity checks +still apply. Self-hosted sandboxes, custom environments and active captured +custom images keep their existing qualification and snapshot binding. +The active Cloud configuration must still provision the sandbox provider; a +leftover managed marker alone does not enable this policy. Discovery reads +custom-image metadata without resolving provider credentials. +Outdated captures that no longer match the base configuration do not change +the managed runner default, consistent with execution's template selection. + ### Local Linux runner qualification Before serving requests, the server reads `getconf GNU_LIBC_VERSION` once with diff --git a/maps/agent-access-map/execution-environments.md b/maps/agent-access-map/execution-environments.md index 59c3a4afabb..8fe4571a675 100644 --- a/maps/agent-access-map/execution-environments.md +++ b/maps/agent-access-map/execution-environments.md @@ -109,6 +109,14 @@ only confirmed cleanup permits recording it. A draft probe does not record saved qualification. Reading stored qualification does not lease/provision a target and does not grant an agent permission to probe or manage that target. +Managed Cloud images do not require saved qualification for runner discovery +or execution. The server checks company access before applying this policy. +Execution uses the platform-selected image and keeps runtime startup checks. +Self-hosted environments and active captured custom images keep qualification. +This exception does not grant access to the probe endpoints. +The active Cloud provisioner configuration must cover the provider; a leftover +metadata marker is insufficient. Discovery does not resolve provider secrets. + Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) at the reviewed commit above. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 7031173d4ec..9fc5dbd9431 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -64,7 +64,9 @@ for eligible agents. Scoped source-only evidence: [adapter discovery](../../server/src/routes/adapters.ts) reports supported runners/effective default for the selected company/environment; [selection policy](../../server/src/services/agent-runner-selection.ts) reads -server-owned immutable sandbox facts without provisioning. New Codex defaults +server-owned immutable sandbox facts without provisioning. Managed Cloud images +skip saved qualification after company access checks; custom environments and +active captured custom images retain the existing policy. New Codex defaults and explicit overrides follow the [shared runner contract](../../packages/shared/src/agent-runner.ts); Claude follows its qualified rollout described above. The board inventory does not grant a native agent an equivalent discovery binding. See diff --git a/maps/feature-map/execution-environments.md b/maps/feature-map/execution-environments.md index 4ee333927ad..15a35332c67 100644 --- a/maps/feature-map/execution-environments.md +++ b/maps/feature-map/execution-environments.md @@ -52,7 +52,17 @@ the live lease and cleanup is confirmed. Inspect `platformQualificationRecorded` and its diagnostic in the probe result: connection success alone does not mean the image has native runtime qualification. -The measured record is server-owned, company-bound and tied to the effective +Paperclip-managed Cloud images use the current platform-selected image without +a saved qualification prerequisite. A missing, stale or revoked qualification +does not block execution or pin an older snapshot. Runtime startup and the +handshake detect real incompatibility. This exception excludes custom +environments and active captured custom images. +The active Cloud configuration must provision the sandbox provider. A stale +managed marker alone keeps the normal qualification rules. +An outdated captured image that execution ignores must not block discovery of +the current managed image. A matching custom capture keeps qualification. + +For other images, the measured record is server-owned, company-bound and tied to the effective configuration and active custom-image identity. Mutable tags, provider defaults and snapshots without verified provider identities do not qualify. Daytona qualification creates by provider ID and binds native execution to that same ID; @@ -71,6 +81,11 @@ qualification is not reused. A mutable-image connection can succeed while qualification remains absent. Assert ordinary agent and non-admin board probe requests are denied, and environment create/PATCH payloads cannot supply qualification metadata. +For a managed Cloud image, replace the platform-selected snapshot while an old +qualification remains. Discovery must still offer Paperclip Runner, and Test +must check the runtime directly. Verify execution selects the replacement image +and still reports a real provider startup failure. Keep a custom-image control +that requires its existing qualification. This recipe and its [probe controls](../../server/src/__tests__/environment-probe.test.ts) are not claims that a live provider or agent-access journey ran for this map update. diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 407ccd8c1ec..e8d3f27196a 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -191,7 +191,12 @@ an explicit Paperclip request must give target-qualification guidance. Begin the ordinary task to exercise the existing lazy environment path. This live journey remains separate from the resolver's source controls. -For sandbox defaults, follow the [environment probe](./execution-environments.md#environment-settings) +Managed Cloud images default to Paperclip Runner without a saved platform +qualification. A platform snapshot update must not change that default or +require a separate qualification request before Test. Disabled or overridden +adapters still restrict availability, and actual runtime startup can still fail. + +For other sandbox defaults, follow the [environment probe](./execution-environments.md#environment-settings) recipe first. Discovery/creation read company-bound immutable-image evidence without allocating a target. A mutable image or snapshot name does not establish native compatibility. Then test the actual selected runtime with its selected diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index 14b968a0ba5..fda31d55fb8 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -65,6 +65,7 @@ import { createNativeHarnessBackupStamp } from "../services/native-runtime/nativ const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const sshFixtureSupport = await getSshEnvLabSupport(); +const managedCloudConfig = { v: 1, mode: "cloud", catalogVersion: "test", features: {}, plugins: { autoInstall: ["daytona"] } }; if (!embeddedPostgresSupport.supported) { console.warn( @@ -211,6 +212,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }); afterEach(async () => { + vi.unstubAllEnvs(); if (orphanCleanupSpoolDir) { await rm(orphanCleanupSpoolDir, { recursive: true, force: true }).catch(() => undefined); orphanCleanupSpoolDir = null; @@ -327,6 +329,56 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { } as unknown as PluginWorkerManager; } + it.each(["missing", "stale", "invalidated", "qualified", "provider-failure", "unowned-marker"])("uses the current managed Cloud snapshot with %s qualification", async qualification => { + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({ ...managedCloudConfig, + environments: qualification === "unowned-marker" ? [] : [{ name: "Paperclip Computer", provider: "daytona", config: {} }] })); + const pluginId = randomUUID(); + const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); + const config = { provider: "daytona", snapshot: "replacement-snapshot", timeoutMs: 1234 }; + await insertSandboxProviderPlugin(pluginId, { environmentDrivers: [{ driverKey: "daytona", kind: "sandbox_provider", displayName: "Daytona", configSchema: { type: "object" } }] }); + const fingerprint = createHash("sha256").update(stableStringify(config)).digest("hex"); + const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { ...baseEnvironment, driver: "sandbox", config }); + const runtimeFingerprint = createHash("sha256").update(stableStringify(parsed.config)).digest("hex"); + await db.update(environments).set({ driver: "sandbox", config, metadata: { + managedByPaperclip: true, + ...(qualification === "missing" ? {} : { paperclipSandboxPlatformProbes: { [companyId]: { + v: 2, companyId, snapshot: "previous-snapshot", template: null, + baseConfigFingerprint: qualification === "qualified" ? fingerprint : "previous-config", + runtimeConfigFingerprint: qualification === "qualified" ? runtimeFingerprint : "previous-config", + ...(qualification === "qualified" ? { snapshot: config.snapshot, snapshotIdentity: { + provider: "daytona", requestedRef: config.snapshot, id: "a6b9e7d0-f886-471a-b461-640dd03e8d0f", + name: config.snapshot, organizationId: "provider-account", createdAt: "2026-10-07T23:19:57.133Z", + } } : {}), + ...(qualification === "invalidated" ? { invalidated: true } : {}), + } } }), + } }).where(eq(environments.id, baseEnvironment.id)); + const environment = (await environmentService(db).getById(baseEnvironment.id))!; + const worker = createFreshPluginLeaseWorker(pluginId); + vi.mocked(worker.call).mockImplementation(async (_id, method, params: any) => { + if (method !== "environmentAcquireLease") throw new Error(`Unexpected ${method}`); + if (qualification === "provider-failure") throw new Error("Provider image startup failed."); + return { providerLeaseId: "managed-snapshot-lease", metadata: { provider: "daytona", snapshot: params.config.snapshot, remoteCwd: "/workspace" } }; + }); + const acquire = environmentRuntimeService(db, { pluginWorkerManager: worker }).acquireRunLease({ + companyId, environment, issueId: null, heartbeatRunId: runId, + persistedExecutionWorkspace: null, adapterType: "paperclip_runner", + }); + if (qualification === "unowned-marker") { + await expect(acquire).rejects.toThrow("sandbox_snapshot_qualification_required"); + expect(worker.call).not.toHaveBeenCalled(); + return; + } + if (qualification === "provider-failure") { + await expect(acquire).rejects.toThrow("Provider image startup failed."); + return; + } + const acquired = await acquire; + expect(worker.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.objectContaining({ + config: expect.objectContaining({ snapshot: config.snapshot }), + }), expect.any(Number)); + expect(acquired.lease.metadata?.snapshot).toBe(config.snapshot); + }); + it.each([["paperclip_runner", false, false], ["paperclip_runner", true, false], ["paperclip_runner", true, undefined], ["claude_local", false, false], [null, false, false]] as const)("pins qualified snapshots only for native execution (%s, warm=%s, savedReuse=%s)", async (adapterType, warm, savedReuse) => { const pluginId = randomUUID(); const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index 697aa4be978..8b6fe94df2a 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -1,14 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, createAgentSchema, updateAgentSchema, testAdapterEnvironmentSchema } from "@paperclipai/shared"; -const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), managedEnvironment: vi.fn(), kubernetesEnvironment: vi.fn(), ensureKubernetesEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn(), pluginByKey: vi.fn() })); +const state = vi.hoisted(() => ({ disabled: [] as string[], overridden: new Set(), settings: {} as Record, getEnvironment: vi.fn(), bindings: vi.fn(), activeTemplate: vi.fn().mockResolvedValue(null), secretPaths: vi.fn().mockResolvedValue(new Set(["apiKey"])), managedEnvironment: vi.fn(), kubernetesEnvironment: vi.fn(), ensureKubernetesEnvironment: vi.fn(), ssh: vi.fn(), resolveEnvironment: vi.fn(), sandboxQualification: vi.fn(), pluginByKey: vi.fn() })); vi.mock("../adapters/registry.js", () => ({ findActiveServerAdapter: (type: string) => ({ type }), hasActiveAdapterOverride: (type: string) => state.overridden.has(type), waitForExternalAdapters: async () => {} })); vi.mock("./adapter-plugin-store.js", () => ({ getDisabledAdapterTypes: () => state.disabled })); vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ get: async () => state.settings }) })); vi.mock("./environments.js", () => ({ environmentService: () => ({ getById: state.getEnvironment, listBoundCompanyIds: state.bindings, findManagedSandboxEnvironment: state.managedEnvironment, findKubernetesEnvironment: state.kubernetesEnvironment, ensureKubernetesEnvironment: state.ensureKubernetesEnvironment }) })); +vi.mock("./environment-custom-images.js", () => ({ environmentCustomImageService: () => ({ getActiveTemplate: state.activeTemplate }) })); vi.mock("./environment-probe.js", () => ({ readSandboxPlatformQualification: state.sandboxQualification })); vi.mock("./environment-config.js", async () => ({ parseEnvironmentDriverConfig: (await vi.importActual("./environment-config.js")).parseEnvironmentDriverConfig, resolveEnvironmentDriverConfigForRuntime: state.resolveEnvironment, + resolveSandboxProviderSecretRefPaths: state.secretPaths, })); vi.mock("./plugin-registry.js", () => ({ pluginRegistryService: () => ({ getByKey: state.pluginByKey }) })); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh })); @@ -16,13 +18,16 @@ import { AGENT_RUNNER_SSH_PLATFORM_COMMAND, agentRunnerAvailability, resolveAgen import { buildCodexAcpConfig, resolveCodexExecutionEngine } from "@paperclipai/adapter-codex-local/server"; import { secretService } from "./secrets.js"; import * as localPlatform from "../lib/agent-runner-platform.js"; +import { fingerprintEnvironmentSandboxProviderConfig as fingerprint } from "./environment-custom-image-runtime.js"; const target = { driver: "local", platform: "linux", architecture: "x64" }; const qualifiedTargets = [target, { driver: "local", platform: "linux", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "x64" }]; const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; const originalArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; -afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(process, "platform", originalPlatform); Object.defineProperty(process, "arch", originalArchitecture); }); +const managedCloudConfig = { v: 1, mode: "cloud", catalogVersion: "test", features: {}, plugins: { autoInstall: ["daytona"] } }; +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); Object.defineProperty(process, "platform", originalPlatform); Object.defineProperty(process, "arch", originalArchitecture); }); describe("server-owned Codex runner selection", () => { + beforeEach(() => { state.activeTemplate.mockReset().mockResolvedValue(null); }); beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.kubernetesEnvironment.mockReset().mockResolvedValue(null); state.ensureKubernetesEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.sandboxQualification.mockReset().mockResolvedValue(null); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); }); it.each(["x64", "arm64"])("keeps detected musl/unknown Linux %s legacy and preserves saved execution", architecture => { for (const libc of ["musl", "unknown"] as const) { @@ -466,6 +471,64 @@ describe("server-owned Codex runner selection", () => { expect(state.sandboxQualification).toHaveBeenCalledWith({}, expect.objectContaining({ id: "sandbox" }), "company"); await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } }); }); + it.each(["codex_local", "claude_local", "opencode_local"])("selects %s on a managed Cloud image without saved platform qualification", async adapterType => { + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({ ...managedCloudConfig, environments: [{ name: "Paperclip Computer", provider: "daytona", config: {} }] })); + state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", + config: { provider: "daytona", snapshot: "replacement-snapshot" }, metadata: { managedByPaperclip: true } }); + state.resolveEnvironment.mockRejectedValue(new Error("Saved provider secret is unavailable.")); + const selected = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox"); + expect(agentRunnerAvailability(adapterType, selected).defaultRunner).toBe("paperclip"); + expect(await resolveNewAgentRunnerForCompany({} as never, "company", { + adapterType, defaultEnvironmentId: "sandbox", adapterConfig: { model: adapterType === "opencode_local" ? "provider/model" : undefined }, + })).toMatchObject({ adapterType: "paperclip_runner" }); + expect(state.sandboxQualification).not.toHaveBeenCalled(); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + state.disabled.push("paperclip_runner"); + expect(agentRunnerAvailability(adapterType, selected).defaultRunner).toBe("legacy"); + }); + it.each(["self-hosted", "custom-environment", "captured-template", "stale-marker"])("keeps qualification for a %s sandbox", async kind => { + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", kind === "self-hosted" ? undefined : JSON.stringify({ ...managedCloudConfig, + environments: kind === "stale-marker" ? [] : [{ name: "Paperclip Computer", provider: "daytona", config: {} }] })); + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined); + state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", + config: { provider: "daytona", snapshot: "custom-snapshot" }, + metadata: { managedByPaperclip: kind !== "custom-environment" } }); + state.activeTemplate.mockResolvedValue(kind === "captured-template" ? { id: "template", provider: "daytona", templateRef: "custom-snapshot" } : null); + const selected = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox"); + expect(agentRunnerAvailability("codex_local", selected).defaultRunner).toBe("legacy"); + expect(state.sandboxQualification).toHaveBeenCalled(); + }); + it("ignores a stale captured image and preserves a matching custom image without resolving credentials", async () => { + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({ ...managedCloudConfig, environments: [{ name: "Paperclip Computer", provider: "daytona", config: {} }] })); + const config = { provider: "daytona", snapshot: "replacement-snapshot", reuseLease: false, apiKey: "a6b9e7d0-f886-471a-b461-640dd03e8d0f" }; + state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config, metadata: { managedByPaperclip: true } }); + state.resolveEnvironment.mockRejectedValue(new Error("Saved provider secret is unavailable.")); + const template = { id: "template", provider: "daytona", templateRef: "custom-snapshot", + sourceEnvironmentConfigFingerprint: fingerprint({ ...config, snapshot: "previous-snapshot" }, { excludePaths: ["apiKey"] }) }; + state.activeTemplate.mockResolvedValue(template); + const current = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox"); + expect(agentRunnerAvailability("codex_local", current).defaultRunner).toBe("paperclip"); + expect(await resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "codex_local", runner: "paperclip", defaultEnvironmentId: "sandbox" })) + .toMatchObject({ adapterType: "paperclip_runner" }); + expect(state.sandboxQualification).not.toHaveBeenCalled(); + state.activeTemplate.mockResolvedValue({ ...template, sourceEnvironmentConfigFingerprint: fingerprint(config, { excludePaths: ["apiKey"] }) }); + const custom = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox"); + expect(agentRunnerAvailability("codex_local", custom).defaultRunner).toBe("legacy"); + expect(state.sandboxQualification).toHaveBeenCalled(); + state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { ...config, timeoutMs: "invalid" }, metadata: { managedByPaperclip: true } }); + const invalid = await resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox"); + expect(agentRunnerAvailability("codex_local", invalid).defaultRunner).toBe("legacy"); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + }); + it("checks company access before trusting a managed Cloud image", async () => { + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}"); + state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", + config: { provider: "daytona" }, metadata: { managedByPaperclip: true } }); + state.bindings.mockResolvedValue(["another-company"]); + await expect(resolveAgentRunnerTargetForCompany({} as never, "company", "sandbox")) + .rejects.toMatchObject({ status: 403, details: { code: "environment_company_mismatch" } }); + expect(state.resolveEnvironment).not.toHaveBeenCalled(); + }); }); describe("Grok runner migration", () => { diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index a2c477ad803..7515b40dc81 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -8,14 +8,17 @@ import { getDisabledAdapterTypes } from "./adapter-plugin-store.js"; import { claudeRunnerUnsupportedSettings, PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js"; import { forbidden, unprocessable } from "../errors.js"; import { runSshCommand } from "@paperclipai/adapter-utils/ssh"; -import { parseEnvironmentDriverConfig, resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; +import { parseEnvironmentDriverConfig, resolveEnvironmentDriverConfigForRuntime, resolveSandboxProviderSecretRefPaths } from "./environment-config.js"; import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js"; import { normalizeClaudeRunnerOptions, type AgentConfigurationWarning } from "@paperclipai/shared"; +import { usesManagedCloudSandboxImage } from "./sandbox-snapshot-qualification.js"; +import { environmentCustomImageTemplateMatchesBaseConfig } from "./environment-custom-image-runtime.js"; export type RunnerTarget = { driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string; /** A plugin environment whose driver declares Runner task execution. It runs agents only through Paperclip Runner. */ runnerTasks?: boolean; + managedCloudImage?: boolean; warmTaskVersion?: 1; }; export const AGENT_RUNNER_SSH_PLATFORM_COMMAND = "uname -s; uname -m; if paperclip_glibc_version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$paperclip_glibc_version\" ]; then printf 'glibc\\n%s\\n' \"$paperclip_glibc_version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi"; @@ -39,12 +42,9 @@ function runnerAdaptersAvailable(harness: string, driver: string): boolean { } export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability { - if (target.runnerTasks) { - // The plugin provisions the host and launches its own pinned Runner, so - // Core has no platform to qualify. Legacy adapters cannot run on a plugin - // environment, so Paperclip Runner is the only choice. + if (target.runnerTasks || target.managedCloudImage) { return runnerAdaptersAvailable(harness, target.driver) - ? { supportedRunners: ["paperclip"], defaultRunner: "paperclip" } + ? { supportedRunners: target.runnerTasks ? ["paperclip"] : ["paperclip", "legacy"], defaultRunner: "paperclip" } : { supportedRunners: ["legacy"], defaultRunner: "legacy" }; } const supported = runnerAdaptersAvailable(harness, target.driver) @@ -334,6 +334,24 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri if (taskDriver) target = { driver, runnerTasks: true, ...(taskDriver.warmTaskVersion === 1 ? { warmTaskVersion: 1 as const } : {}) }; } if (driver === "sandbox" && environment) { + if (usesManagedCloudSandboxImage(environment, null)) { + const { environmentCustomImageService } = await import("./environment-custom-images.js"); + const template = await environmentCustomImageService(db).getActiveTemplate({ + environmentId: environment.id, + provider: typeof environment.config.provider === "string" ? environment.config.provider : null, + }); + if (!template?.templateRef) return { driver, managedCloudImage: true }; + try { + const parsed = parseEnvironmentDriverConfig(environment); + if (parsed.driver === "sandbox" && !environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: parsed.config, + secretRefExcludePaths: await resolveSandboxProviderSecretRefPaths(db, template.provider), + })) return { driver, managedCloudImage: true }; + } catch { + return { driver }; + } + } const { readSandboxPlatformQualification } = await import("./environment-probe.js"); const qualified = await readSandboxPlatformQualification(db, environment, companyId); if (qualified) target = { driver, ...qualified }; diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 58a27c668a8..8503a30a49e 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -51,7 +51,7 @@ import { } from "@paperclipai/adapter-utils/acpx-engine/startup-timing"; import { environmentService } from "./environments.js"; import { instanceSettingsService } from "./instance-settings.js"; -import { hasSandboxSnapshotQualification, qualifiedSandboxSnapshotId, type SandboxTemplateIdentity } from "./sandbox-snapshot-qualification.js"; +import { hasSandboxSnapshotQualification, qualifiedSandboxSnapshotId, usesManagedCloudSandboxImage, type SandboxTemplateIdentity } from "./sandbox-snapshot-qualification.js"; import { verifyNativeHarnessBackupStamp } from "./native-runtime/native-harness-backup-stamp.js"; import { collectEnvironmentSecretRefs, @@ -1871,11 +1871,12 @@ function createSandboxEnvironmentDriver( if (parsed.driver !== "sandbox" || storedParsed.driver !== "sandbox") { throw new Error(`Expected sandbox environment config for driver "${input.environment.driver}".`); } - const pinnedSnapshotId = input.adapterType === "paperclip_runner" - ? qualifiedSandboxSnapshotId(configuredEnvironment, input.companyId, parsed.config, selectedTemplate.value) : null; - if (input.adapterType === "paperclip_runner" && !pinnedSnapshotId - && hasSandboxSnapshotQualification(configuredEnvironment, input.companyId)) { - throw new Error("sandbox_snapshot_qualification_required: The snapshot qualification is missing or stale. Open the agent's Harness / Runtime page and select Run test before running Paperclip Runner."); + let pinnedSnapshotId: string | null = null; + if (input.adapterType === "paperclip_runner" && !usesManagedCloudSandboxImage(configuredEnvironment, selectedTemplate.value)) { + pinnedSnapshotId = qualifiedSandboxSnapshotId(configuredEnvironment, input.companyId, parsed.config, selectedTemplate.value); + if (!pinnedSnapshotId && hasSandboxSnapshotQualification(configuredEnvironment, input.companyId)) { + throw new Error("sandbox_snapshot_qualification_required: The snapshot qualification is missing or stale. Open the agent's Harness / Runtime page and select Run test before running Paperclip Runner."); + } } if (input.configuredEnvironment) parsed.config = { ...parsed.config, reuseLease: storedParsed.config.reuseLease, archiveOnRelease: storedParsed.config.archiveOnRelease }; if (pinnedSnapshotId) parsed.config = { ...parsed.config, snapshot: pinnedSnapshotId }; diff --git a/server/src/services/sandbox-snapshot-qualification.ts b/server/src/services/sandbox-snapshot-qualification.ts index 6ef439a7cda..19b480c1b38 100644 --- a/server/src/services/sandbox-snapshot-qualification.ts +++ b/server/src/services/sandbox-snapshot-qualification.ts @@ -1,7 +1,16 @@ import type { Environment, SandboxEnvironmentConfig } from "@paperclipai/shared"; import { fingerprintEnvironmentSandboxProviderConfig, SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js"; +import { isCloudManagedInstance } from "./cloud-instance.js"; +import { getManagedInstanceConfig } from "./managed-config.js"; export type SandboxTemplateIdentity = { id: string; capturedAt: string | null; templateKind: string; bindingField: string } | null; + +export function usesManagedCloudSandboxImage(environment: Environment, template: SandboxTemplateIdentity): boolean { + return isCloudManagedInstance() && environment.driver === "sandbox" + && environment.metadata?.managedByPaperclip === true && template === null + && !!getManagedInstanceConfig()?.environments.some(spec => spec.provider === environment.config.provider); +} + export type SandboxSnapshotIdentity = { provider: "daytona"; requestedRef: string; diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index e6b2164137f..ff200b73b96 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -1661,6 +1661,23 @@ describe("AgentConfigForm environment selector", () => { expect(result.onSave).not.toHaveBeenCalled(); }); + it("tests an available managed runner directly without a separate qualification request", async () => { + const computer = makeEnvironment({ id: "managed-computer", driver: "sandbox", status: "active", + config: { provider: "daytona", snapshot: "replacement-snapshot" }, metadata: { managedByPaperclip: true } }); + mockAdaptersApi.list.mockResolvedValue([ + { type: "opencode_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "paperclip" }, + { type: "paperclip_runner", supportedRunners: ["paperclip"], defaultRunner: "paperclip" }, + ]); + mockAgentsApi.qualifyRunner.mockRejectedValue(new Error("Separate qualification is unavailable.")); + const result = await renderForm([computer], { adapterType: "paperclip_runner", defaultEnvironmentId: computer.id, + adapterConfig: { provider: "opencode", model: "example/model" } }, { showAdapterTestEnvironmentButton: true }); + roots.push(result.root); + await runTest(result.container); + expect(mockAgentsApi.qualifyRunner).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-1", "paperclip_runner", + expect.objectContaining({ environmentId: computer.id })); + }); + it.each(["failed", "missing-proof"])("does not run the native runtime test after %s saved-computer qualification", async state => { const computer = makeEnvironment({ id: "saved-computer", driver: "sandbox", status: "active", config: { provider: "daytona", snapshot: "managed-snapshot" } }); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 56b223ab862..84eb5d0f1d0 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1209,6 +1209,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { (overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? props.agent.runtimeConfig.aiConnection, ).data; if (!isCreate && props.agent.adapterType === "paperclip_runner" && adapterType === "paperclip_runner" + && runnerAvailability?.defaultRunner !== "paperclip" && !isExternalRunner && aiConnection?.mode !== "router" && !isDirty && environmentId && canQualifyRunnerEnvironment(environmentList.find(environment => environment.id === environmentId))) { const target = runnerSettingsTargetRef.current; // Qualify only the saved computer. The strict agent-scoped endpoint