diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index a45feeff6bb..dcc2efacf48 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -804,6 +804,10 @@ describe("agent test-environment route", () => { expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith( expect.objectContaining({ applyCustomImageTemplate: true, + configuredEnvironment: expect.objectContaining({ + id: "11111111-1111-4111-8111-111111111111", + config: { provider: "fake-plugin" }, + }), // The Test lease re-checks the company binding, so a binding change // between the route guard and the lease cannot open a foreign sandbox. assertCompanyBinding: true, diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index b5f4964700f..b926be7e975 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -45,6 +45,7 @@ import { import * as sandboxProviderRuntime from "../services/sandbox-provider-runtime.ts"; import * as environmentsModule from "../services/environments.ts"; import { logger } from "../middleware/logger.ts"; +import { agentEnvironmentTestService } from "../services/agent-environment-test.js"; import { resolveRunnerEnvironmentForRun } from "../services/runner-environment-lifecycle.js"; import { environmentService } from "../services/environments.ts"; import { remoteExecutionHasStopped } from "../services/remote-execution-termination.ts"; @@ -358,6 +359,65 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { expect(acquired.lease.metadata?.snapshot).toBe(expectedSnapshot); }); + it.each(["qualified", "changed-base", "changed-runtime"] as const)("validates native setup against saved snapshot qualification (%s)", async state => { + const pluginId = randomUUID(); + const { companyId, environment: baseEnvironment } = await seedEnvironment(); + const config = { provider: "daytona", snapshot: "managed-snapshot", reuseLease: true, archiveOnRelease: false, timeoutMs: 1234 }; + await insertSandboxProviderPlugin(pluginId, { environmentDrivers: [{ driverKey: "daytona", kind: "sandbox_provider", displayName: "Daytona", configSchema: { type: "object" } }] }); + const svc = environmentService(db); + await svc.update(baseEnvironment.id, { driver: "sandbox", config }); + const environment = (await svc.getById(baseEnvironment.id))!; + const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); + const snapshotId = "a6b9e7d0-f886-471a-b461-640dd03e8d0f"; + const proof = { v: 2, companyId, snapshot: config.snapshot, template: null, + baseConfigFingerprint: createHash("sha256").update(stableStringify(config)).digest("hex"), + runtimeConfigFingerprint: state === "changed-runtime" ? "0".repeat(64) : createHash("sha256").update(stableStringify(parsed.config)).digest("hex"), + snapshotIdentity: { provider: "daytona", requestedRef: config.snapshot, id: snapshotId, name: config.snapshot, organizationId: "provider-account", createdAt: "2000-01-01T00:00:00.000Z" }, + }; + await db.update(environments).set({ metadata: { paperclipSandboxPlatformProbes: { [companyId]: proof } }, + ...(state === "changed-base" ? { config: { ...config, snapshot: "replacement-snapshot" } } : {}) }).where(eq(environments.id, environment.id)); + const worker = createFreshPluginLeaseWorker(pluginId); + vi.mocked(worker.call).mockImplementation(async (_id, method, params: any) => { + if (method === "environmentAcquireLease") return { providerLeaseId: "setup-snapshot-lease", metadata: { provider: "daytona", snapshot: params.config.snapshot, remoteCwd: "/workspace" } }; + if (method === "environmentRealizeWorkspace") return { cwd: "/workspace" }; + if (method === "environmentReleaseLease") return { providerLeaseId: params.providerLeaseId, state: "archived" }; + throw new Error(`Unexpected ${method}`); + }); + const result = await agentEnvironmentTestService(db, worker).resolveAdapterTestExecutionContext({ companyId, environmentId: environment.id, adapterType: "paperclip_runner" }); + if (state !== "qualified") { + expect(result.executionTarget).toBeNull(); + expect(result.fallbackChecks).toMatchObject([{ code: "environment_lease_acquire_failed", detail: expect.stringContaining("sandbox_snapshot_qualification_required") }]); + expect(worker.call).not.toHaveBeenCalled(); + return; + } + expect(result.fallbackChecks).toEqual([]); + expect(result.executionTarget).toMatchObject({ kind: "remote", transport: "sandbox" }); + expect(worker.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.objectContaining({ config: expect.objectContaining({ snapshot: snapshotId, reuseLease: false, archiveOnRelease: true }) }), expect.any(Number)); + const leases = await svc.listLeases(environment.id); + expect(leases).toHaveLength(1); + expect(leases[0]).toMatchObject({ leasePolicy: "ephemeral", heartbeatRunId: null, providerLeaseId: "setup-snapshot-lease" }); + expect(leases[0]!.metadata).toMatchObject({ snapshot: snapshotId, reuseLease: false, archiveOnRelease: true }); + await result.release("released"); + expect(worker.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.objectContaining({ config: expect.objectContaining({ archiveOnRelease: true }), providerLeaseId: "setup-snapshot-lease" }), expect.any(Number)); + expect((await svc.getById(environment.id))!.config).toEqual(config); + }); + + it.each([ + { snapshot: "another-snapshot" }, + { image: "another-image" }, + { provider: "another-provider" }, + { env: { DIFFERENT_RUNTIME: "changed" } }, + ])("rejects non-retention run overrides before provider dispatch (%j)", async override => { + const { companyId, environment: baseEnvironment } = await seedEnvironment(); + const environment = { ...baseEnvironment, driver: "sandbox", config: { provider: "daytona", snapshot: "managed-snapshot" } }; + const worker = createFreshPluginLeaseWorker(randomUUID()); + const service = environmentRuntimeService(db, { pluginWorkerManager: worker }); + await expect(service.acquireRunLease({ companyId, environment: { ...environment, config: { ...environment.config, ...override, reuseLease: false, archiveOnRelease: true } }, + configuredEnvironment: environment, issueId: null, heartbeatRunId: null, persistedExecutionWorkspace: null, adapterType: "paperclip_runner" })) + .rejects.toThrow("Run environment does not match its saved configuration."); + expect(worker.call).not.toHaveBeenCalled(); + }); + it.each(["failed-probe", "config-change", "config-and-metadata-change"] as const)("refuses a mutable snapshot after %s invalidates its proof", async invalidation => { const { companyId, environment: baseEnvironment, runId } = await seedEnvironment(); const config = { provider: "daytona", snapshot: "managed-snapshot", reuseLease: false }; diff --git a/server/src/services/agent-environment-test.ts b/server/src/services/agent-environment-test.ts index 5183beec2a8..1bf658d31df 100644 --- a/server/src/services/agent-environment-test.ts +++ b/server/src/services/agent-environment-test.ts @@ -231,6 +231,7 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin ...(environment.driver === "plugin" && input.adapterType === "paperclip_runner" ? { requestedExpiresAt: new Date(Date.now() + 180_000) } : {}), environment: testEnvironment, + configuredEnvironment: environment, issueId: null, heartbeatRunId: null, persistedExecutionWorkspace: null, diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index edf646410d4..b3c53737bef 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -1856,8 +1856,8 @@ function createSandboxEnvironmentDriver( // or account used for qualification and custom-template resolution. if (configuredEnvironment.id !== input.environment.id || configuredEnvironment.driver !== input.environment.driver - || stableStringify({ ...configuredEnvironment.config, reuseLease: undefined }) - !== stableStringify({ ...input.environment.config, reuseLease: undefined })) { + || stableStringify({ ...configuredEnvironment.config, reuseLease: undefined, archiveOnRelease: undefined }) + !== stableStringify({ ...input.environment.config, reuseLease: undefined, archiveOnRelease: undefined })) { throw new Error("Run environment does not match its saved configuration."); } const storedParsed = parseEnvironmentDriverConfig(input.environment); @@ -1877,7 +1877,7 @@ function createSandboxEnvironmentDriver( && hasSandboxSnapshotQualification(configuredEnvironment, input.companyId)) { throw new Error("sandbox_snapshot_qualification_required: The snapshot qualification is missing or stale. Test this environment again before running Paperclip Runner."); } - if (input.configuredEnvironment) parsed.config = { ...parsed.config, reuseLease: storedParsed.config.reuseLease }; + if (input.configuredEnvironment) parsed.config = { ...parsed.config, reuseLease: storedParsed.config.reuseLease, archiveOnRelease: storedParsed.config.archiveOnRelease }; if (pinnedSnapshotId) parsed.config = { ...parsed.config, snapshot: pinnedSnapshotId }; // Check if this provider should be handled by a plugin.