diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index 0c1ad491eb..c83a49275f 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -32,6 +32,7 @@ const manifest: PaperclipPluginManifestV1 = { field: "snapshot", unsetFields: ["image"], }, + templateIdentityPaths: ["apiUrl"], supportsTemplateDelete: true, configSchema: { type: "object", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index d8d3502a20..4e832a7670 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -48,6 +48,8 @@ function createMockSandbox(overrides: { recover: vi.fn().mockResolvedValue(undefined), resize: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), + archive: vi.fn().mockResolvedValue(undefined), + setAutoDeleteInterval: vi.fn().mockResolvedValue(undefined), createSshAccess: vi.fn().mockResolvedValue({ token: "ssh-token-secret", command: "ssh ssh-token-secret@ssh.app.daytona.io", @@ -133,6 +135,7 @@ describe("Daytona sandbox provider plugin", () => { autoArchiveInterval: 60, autoDeleteInterval: -1, reuseLease: true, + archiveOnRelease: false, }, }); }); @@ -956,6 +959,54 @@ describe("Daytona sandbox provider plugin", () => { expect(ephemeral.delete).toHaveBeenCalledWith(300); }); + it("archives instead of deleting when the lease was acquired with archiveOnRelease", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "started" }); + mockGet.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentReleaseLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-test-probe", + config: { + timeoutMs: 300000, + reuseLease: false, + archiveOnRelease: true, + }, + }); + + expect(sandbox.stop).toHaveBeenCalledWith(300); + expect(sandbox.setAutoDeleteInterval).toHaveBeenCalledWith(60); + expect(sandbox.archive).toHaveBeenCalled(); + expect(sandbox.delete).not.toHaveBeenCalled(); + }); + + it("falls back to delete when archiving an archiveOnRelease lease fails", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "stopped" }); + sandbox.archive.mockRejectedValueOnce(new Error("archive unsupported")); + mockGet.mockResolvedValue(sandbox); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await plugin.definition.onEnvironmentReleaseLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-test-probe", + config: { + timeoutMs: 300000, + reuseLease: false, + archiveOnRelease: true, + }, + }); + + expect(sandbox.stop).not.toHaveBeenCalled(); + expect(sandbox.archive).toHaveBeenCalled(); + expect(sandbox.delete).toHaveBeenCalledWith(300); + expect(warnSpy).toHaveBeenCalled(); + }); + it("falls back to delete when stopping a reusable lease from an error state fails", async () => { process.env.DAYTONA_API_KEY = "host-key"; const errored = createMockSandbox({ id: "sandbox-error", state: "error" }); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index c9fb0a565d..07e22516ac 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -51,6 +51,7 @@ interface DaytonaDriverConfig { autoArchiveInterval: number | null; autoDeleteInterval: number | null; reuseLease: boolean; + archiveOnRelease: boolean; } type WorkspaceSentinelResult = { @@ -93,6 +94,12 @@ const DEFAULT_AUTO_STOP_INTERVAL_MINUTES = 15; const DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES = 60; const DEFAULT_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days +// Sandboxes released with `archiveOnRelease` (test/probe runs) are archived so +// operators can inspect them from the Daytona dashboard, then expired by +// Daytona itself after this interval (counted from the stop that precedes the +// archive) so debugging copies don't accumulate. +const ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES = 60; + // Fail-fast cap for git network operations (push, fetch, pull, ls-remote, etc.) // so a stalled remote or missing credential never consumes the full 900 s adapter // RPC ceiling; callers always see an actionable error within this window. @@ -145,6 +152,7 @@ function parseDriverConfig(raw: Record): DaytonaDriverConfig { autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval) ?? DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES, autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval) ?? DEFAULT_AUTO_DELETE_INTERVAL_MINUTES, reuseLease: raw.reuseLease === true, + archiveOnRelease: raw.archiveOnRelease === true, }; } @@ -434,6 +442,9 @@ function leaseMetadata(input: { target: input.sandbox.target, timeoutMs: input.config.timeoutMs, reuseLease: input.config.reuseLease, + // Persisted so the release path (which rebuilds config from lease + // metadata) still knows to archive instead of delete. + ...(input.config.archiveOnRelease ? { archiveOnRelease: true } : {}), remoteCwd: input.remoteCwd, resumedLease: input.resumedLease, // Record the resources Paperclip attempted to request so future diagnosis @@ -956,6 +967,21 @@ const plugin = definePlugin({ return; } + if (config.archiveOnRelease) { + try { + if (sandbox.state !== "stopped") { + await sandbox.stop(toTimeoutSeconds(config.timeoutMs)); + } + await sandbox.setAutoDeleteInterval(ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES); + await sandbox.archive(); + return; + } catch (error) { + console.warn( + `Failed to archive Daytona sandbox during lease release: ${formatErrorMessage(error)}. Falling back to delete.`, + ); + } + } + await sandbox.delete(toTimeoutSeconds(config.timeoutMs)); }, diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 9e38f2d558..2a2867ffc1 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -30,6 +30,12 @@ export interface FakeSandboxEnvironmentConfig { reuseLease: boolean; /** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */ streamRunLogs?: boolean; + /** + * Archive the sandbox on lease release instead of deleting it, so operators + * can inspect it from the provider dashboard. Injected by test/probe paths; + * providers without archive support delete as usual. + */ + archiveOnRelease?: boolean; } export interface PluginSandboxEnvironmentConfig { @@ -38,6 +44,12 @@ export interface PluginSandboxEnvironmentConfig { timeoutMs?: number; /** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */ streamRunLogs?: boolean; + /** + * Archive the sandbox on lease release instead of deleting it, so operators + * can inspect it from the provider dashboard. Injected by test/probe paths; + * providers without archive support delete as usual. + */ + archiveOnRelease?: boolean; [key: string]: unknown; } diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index d614061c4f..be0fb0051c 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -164,6 +164,13 @@ export interface PluginEnvironmentDriverDeclaration { * runtime config. Omit to use the standard key for `templateRefKind`. */ templateConfigBinding?: PluginEnvironmentTemplateConfigBinding; + /** + * Config paths (dot notation) that scope where captured templates live for + * this provider, such as an API endpoint. When one of these changes on a + * saved environment, captured templates cannot be re-linked to the updated + * config and a fresh capture is required. + */ + templateIdentityPaths?: string[]; /** Provider supports best-effort deletion/cleanup of captured templates. */ supportsTemplateDelete?: boolean; /** JSON Schema describing the driver's provider-specific configuration. */ diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 91535b3b88..4637519d37 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -167,6 +167,7 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({ supportsTemplateCapture: z.boolean().optional(), templateRefKind: z.string().min(1).max(100).optional(), templateConfigBinding: pluginEnvironmentTemplateConfigBindingSchema.optional(), + templateIdentityPaths: z.array(z.string().min(1).max(200)).max(20).optional(), supportsTemplateDelete: z.boolean().optional(), configSchema: jsonSchemaSchema, }); diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index dc3e7ce445..de490e11ba 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -137,7 +137,15 @@ describe("agent test-environment route", () => { mockEnvironmentRuntime.acquireRunLease.mockResolvedValue({ lease: { id: "lease-1", - metadata: { remoteCwd: "/home/user/paperclip-workspace" }, + provider: "daytona", + providerLeaseId: "provider-lease-1", + metadata: { + remoteCwd: "/home/user/paperclip-workspace", + sandboxId: "sandbox-1", + sandboxName: "paperclip-probe", + templateKind: "snapshot", + templateRef: "snapshot-1", + }, }, leaseContext: { executionWorkspaceId: null, @@ -263,6 +271,19 @@ describe("agent test-environment route", () => { expect(res.status, JSON.stringify(res.body)).toBe(200); expect(testEnvironmentSpy).toHaveBeenCalledTimes(1); + // Test leases boot fresh and stay debuggable: never resume a retained + // agent lease, archive (not delete) the sandbox on release. + expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith( + expect.objectContaining({ + applyCustomImageTemplate: true, + environment: expect.objectContaining({ + config: expect.objectContaining({ + reuseLease: false, + archiveOnRelease: true, + }), + }), + }), + ); expect(testEnvironmentSpy.mock.calls[0]?.[0]).toMatchObject({ executionTarget: expect.objectContaining({ kind: "remote", @@ -271,6 +292,24 @@ describe("agent test-environment route", () => { environmentName: "Sandbox QA", }); expect(res.body).toMatchObject({ adapterType: "external_test", status: "pass" }); + expect(res.body.checks).toEqual([ + expect.objectContaining({ + code: "sandbox_test_identity", + level: "info", + message: 'Sandbox test identity for "Sandbox QA".', + detail: expect.stringContaining("paperclipLeaseId=lease-1"), + }), + expect.objectContaining({ + code: "external_test_hello_probe_passed", + level: "info", + message: "OK", + }), + ]); + expect(res.body.checks[0].detail).toContain("providerLeaseId=provider-lease-1"); + expect(res.body.checks[0].detail).toContain("provider=daytona"); + expect(res.body.checks[0].detail).toContain("sandboxId=sandbox-1"); + expect(res.body.checks[0].detail).toContain("sandboxName=paperclip-probe"); + expect(res.body.checks[0].detail).toContain("snapshotRef=snapshot-1"); expect(mockReleaseRunLease).toHaveBeenCalledWith({ environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }), lease: expect.objectContaining({ id: "lease-1" }), diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts index 883e4c7f32..51a8e4691a 100644 --- a/server/src/__tests__/environment-custom-images-service.test.ts +++ b/server/src/__tests__/environment-custom-images-service.test.ts @@ -14,6 +14,8 @@ import { environmentCustomImageService, } from "../services/environment-custom-images.js"; import { + ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + environmentCustomImageTemplateMatchesBaseConfig, fingerprintEnvironmentSandboxProviderConfig, } from "../services/environment-custom-image-runtime.js"; import { @@ -55,6 +57,7 @@ function pluginManifest() { field: "customTemplate", unsetFields: ["image"], }, + templateIdentityPaths: ["apiUrl"], supportsTemplateDelete: true, configSchema: { type: "object" }, }, @@ -436,7 +439,9 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { provider: "fake-plugin", templateKind: "snapshot", templateRef: "snapshot-active", - sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any), + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any, { + excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + }), status: "active", }); const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { @@ -448,20 +453,54 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { expect(resolved.config).toMatchObject({ snapshot: "snapshot-active" }); expect(resolved.config).not.toHaveProperty("image"); - // A stored fingerprint that no longer matches the current base config (e.g. the - // config dialog re-saved the environment, or the user tweaked resource/lease - // knobs) must NOT silently discard the captured template. - await db.update(environmentCustomImageTemplates) - .set({ sourceEnvironmentConfigFingerprint: "stale" }) - .where(eq(environmentCustomImageTemplates.templateRef, "snapshot-active")); - const afterConfigChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { - id: environment.id, + // Runtime-only resource/lease edits must NOT silently discard the captured + // template. + await db.update(environments) + .set({ + config: { + ...(environment.config as Record), + cpu: 4, + timeoutMs: 600000, + reuseLease: true, + }, + }) + .where(eq(environments.id, environment.id)); + const afterResourceChangeEnvironment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); + const afterResourceChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: afterResourceChangeEnvironment.id, driver: "sandbox", - config: environment.config, + config: afterResourceChangeEnvironment.config, }, { heartbeatRunId: randomUUID() }); - expect(afterConfigChange.driver).toBe("sandbox"); - expect(afterConfigChange.config).toMatchObject({ snapshot: "snapshot-active" }); - expect(afterConfigChange.config).not.toHaveProperty("image"); + expect(afterResourceChange.driver).toBe("sandbox"); + expect(afterResourceChange.config).toMatchObject({ snapshot: "snapshot-active" }); + expect(afterResourceChange.config).not.toHaveProperty("image"); + + // Changing the base image is a meaningful source-template change. In that + // case, the old capture must not mask the newly saved image. + await db.update(environmentCustomImageTemplates) + .set({ + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any, { + excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + }), + }) + .where(eq(environmentCustomImageTemplates.templateRef, "snapshot-active")); + await db.update(environments) + .set({ + config: { + ...(environment.config as Record), + image: "fake:new-base", + }, + }) + .where(eq(environments.id, environment.id)); + const afterImageChangeEnvironment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); + const afterImageChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: afterImageChangeEnvironment.id, + driver: "sandbox", + config: afterImageChangeEnvironment.config, + }, { heartbeatRunId: randomUUID() }); + expect(afterImageChange.driver).toBe("sandbox"); + expect(afterImageChange.config).toMatchObject({ image: "fake:new-base" }); + expect(afterImageChange.config).not.toHaveProperty("snapshot"); }); it("applies the active template for ad-hoc Test probes only when applyCustomImageTemplate is set", async () => { @@ -562,3 +601,268 @@ describe("fingerprintEnvironmentSandboxProviderConfig", () => { ); }); }); + +describe("environmentCustomImageTemplateMatchesBaseConfig", () => { + it("keeps captures across runtime-only edits but not base image changes", () => { + const baseConfig = { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + timeoutMs: 300000, + reuseLease: false, + } as any; + const template = { + id: "template-1", + environmentId: "env-1", + provider: "daytona", + templateKind: "snapshot", + templateRef: "snapshot-active", + sourceTemplateRef: "daytonaio/sandbox:0.8.0", + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(baseConfig, { + excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + }), + status: "active", + createdByUserId: null, + createdByAgentId: null, + capturedAt: null, + lastUsedAt: null, + supersededByTemplateId: null, + metadata: null, + createdAt: new Date("2026-07-09T00:00:00.000Z"), + updatedAt: new Date("2026-07-09T00:00:00.000Z"), + } as const; + + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: { + ...baseConfig, + timeoutMs: 600000, + reuseLease: true, + cpu: 4, + }, + })).toBe(true); + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: { + ...baseConfig, + image: "daytonaio/sandbox:0.9.0", + }, + })).toBe(false); + }); + + it("matches configs carrying a secret-ref credential when the capture excluded that path", () => { + // Capture-time fingerprints exclude the provider's secret-ref paths (e.g. + // daytona apiKey). The runtime re-check must exclude the same paths or a + // config with any credential never matches and the template is dropped. + const baseConfig = { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + apiKey: "raw-api-key-value", + } as any; + const template = { + id: "template-1", + environmentId: "env-1", + provider: "daytona", + templateKind: "snapshot", + templateRef: "snapshot-active", + sourceTemplateRef: "daytonaio/sandbox:0.8.0", + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(baseConfig, { + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + "apiKey", + ], + }), + status: "active", + createdByUserId: null, + createdByAgentId: null, + capturedAt: null, + lastUsedAt: null, + supersededByTemplateId: null, + metadata: null, + createdAt: new Date("2026-07-09T00:00:00.000Z"), + updatedAt: new Date("2026-07-09T00:00:00.000Z"), + } as const; + + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig, + secretRefExcludePaths: ["apiKey"], + })).toBe(true); + // A rotated credential still matches — credentials are not part of the + // captured image identity. + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: { ...baseConfig, apiKey: "rotated-key" }, + secretRefExcludePaths: ["apiKey"], + })).toBe(true); + // Without the exclusion the same config fails to match (the pre-fix bug). + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig, + })).toBe(false); + }); +}); + +describeEmbeddedPostgres("environmentCustomImageService reconciliation", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + + beforeAll(async () => { + const started = await startEmbeddedPostgresTestDatabase("environment-custom-images-reconcile"); + stopDb = started.stop; + db = createDb(started.connectionString); + }); + + afterEach(async () => { + await db.delete(environmentCustomImageSetupSessions); + await db.delete(environmentCustomImageTemplates); + await db.delete(plugins); + await db.delete(environments); + await db.delete(companies); + }); + + afterAll(async () => { + await stopDb?.(); + }); + + async function seed() { + const companyId = randomUUID(); + const environmentId = randomUUID(); + await db.insert(companies).values( + { id: companyId, name: "Acme", issuePrefix: `A${companyId.slice(0, 4)}` }, + ); + await db.insert(environments).values({ + id: environmentId, + name: `Fake ${environmentId.slice(0, 8)}`, + driver: "sandbox", + status: "active", + config: { + provider: "fake-plugin", + image: "fake:base", + reuseLease: false, + }, + envVars: {}, + }); + await db.insert(plugins).values({ + pluginKey: "paperclip.fake-sandbox-provider", + packageName: "paperclip-plugin-fake-sandbox", + version: "0.1.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: pluginManifest(), + status: "ready", + }); + return { companyId, environmentId }; + } + + it("re-links the active template on save when only non-identity fields change", async () => { + const { companyId, environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + const baseConfig = { provider: "fake-plugin", image: "fake:base", reuseLease: false }; + const nextConfig = { ...baseConfig, region: "eu-west" }; + + const relinked = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: nextConfig }, + }); + expect(relinked.action).toBe("relinked"); + if (relinked.action !== "relinked") throw new Error("expected relink"); + expect(relinked.template.sourceEnvironmentConfigFingerprint) + .not.toBe(promoted.template.sourceEnvironmentConfigFingerprint); + + // Re-running with the same configs is a no-op: the template already + // matches the new config. + const repeat = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: nextConfig }, + }); + expect(repeat.action).toBe("none"); + + // The captured template keeps applying at runtime under the new config. + await db.update(environments) + .set({ config: nextConfig }) + .where(eq(environments.id, environmentId)); + const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: environmentId, + driver: "sandbox", + config: nextConfig, + }, { heartbeatRunId: randomUUID() }); + expect(resolved.driver).toBe("sandbox"); + expect(resolved.config).toMatchObject({ customTemplate: promoted.template.templateRef }); + expect(resolved.config).not.toHaveProperty("image"); + }); + + it("reports detached on save when a boot-source or provider identity field changes", async () => { + const { environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + const baseConfig = { provider: "fake-plugin", image: "fake:base", reuseLease: false }; + + // Base image change: the user asked for a different base, so the capture + // cannot be re-linked. + const imageChange = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: { ...baseConfig, image: "fake:other" } }, + }); + expect(imageChange.action).toBe("detached"); + + // Provider-declared identity path change (apiUrl): captured templates do + // not exist on a different provider endpoint. + const endpointChange = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: { ...baseConfig, apiUrl: "https://other.example" } }, + }); + expect(endpointChange.action).toBe("detached"); + + // Binding-field change counts as a boot-source change too. + const bindingChange = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: { ...baseConfig, customTemplate: "someone-elses-snapshot" } }, + }); + expect(bindingChange.action).toBe("detached"); + + // Detach never mutates the stored fingerprint; rollback/disable stay + // available and the old config still matches. + const template = await service.getActiveTemplate({ environmentId, provider: "fake-plugin" }); + expect(template?.sourceEnvironmentConfigFingerprint) + .toBe(promoted.template.sourceEnvironmentConfigFingerprint); + + // A template that was already detached before the save is left alone. + const alreadyDetached = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: { ...baseConfig, image: "fake:unrelated" } }, + next: { driver: "sandbox", config: { ...baseConfig, image: "fake:unrelated", region: "eu" } }, + }); + expect(alreadyDetached.action).toBe("none"); + }); + + it("reports whether the active template matches the saved config in the overview", async () => { + const { environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + await service.finishSetupSession({ sessionId: started.session.id }); + + const inSync = await service.getOverview({ environmentId }); + expect(inSync.activeTemplateMatchesConfig).toBe(true); + + await db.update(environments) + .set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false } }) + .where(eq(environments.id, environmentId)); + const outOfSync = await service.getOverview({ environmentId }); + expect(outOfSync.activeTemplate).not.toBeNull(); + expect(outOfSync.activeTemplateMatchesConfig).toBe(false); + }); +}); diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts index b9ca635c5f..9a29883219 100644 --- a/server/src/__tests__/environment-probe.test.ts +++ b/server/src/__tests__/environment-probe.test.ts @@ -4,6 +4,14 @@ const mockEnsureSshWorkspaceReady = vi.hoisted(() => vi.fn()); const mockProbePluginEnvironmentDriver = vi.hoisted(() => vi.fn()); const mockProbePluginSandboxProviderDriver = vi.hoisted(() => vi.fn()); const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn()); +const mockRuntimeAcquireRunLease = vi.hoisted(() => vi.fn()); +const mockRuntimeReleaseRunLease = vi.hoisted(() => vi.fn()); +const mockEnvironmentRuntimeService = vi.hoisted(() => vi.fn(() => ({ + acquireRunLease: mockRuntimeAcquireRunLease, + getDriver: vi.fn(() => ({ + releaseRunLease: mockRuntimeReleaseRunLease, + })), +}))); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady, @@ -15,6 +23,10 @@ vi.mock("../services/plugin-environment-driver.js", () => ({ resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey, })); +vi.mock("../services/environment-runtime.js", () => ({ + environmentRuntimeService: mockEnvironmentRuntimeService, +})); + import { probeEnvironment } from "../services/environment-probe.ts"; describe("probeEnvironment", () => { @@ -24,6 +36,9 @@ describe("probeEnvironment", () => { mockProbePluginSandboxProviderDriver.mockReset(); mockResolvePluginSandboxProviderDriverByKey.mockReset(); mockResolvePluginSandboxProviderDriverByKey.mockResolvedValue(null); + mockRuntimeAcquireRunLease.mockReset(); + mockRuntimeReleaseRunLease.mockReset(); + mockEnvironmentRuntimeService.mockClear(); }); it("reports local environments as immediately available", async () => { @@ -163,6 +178,91 @@ describe("probeEnvironment", () => { }); }); + it("boots a fresh runtime lease for saved sandbox probes when requested", async () => { + mockRuntimeAcquireRunLease.mockResolvedValue({ + environment: {}, + leaseContext: { + executionWorkspaceId: null, + executionWorkspaceMode: null, + }, + lease: { + id: "lease-1", + companyId: "company-1", + environmentId: "env-sandbox-plugin", + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: null, + status: "active", + leasePolicy: "ephemeral", + provider: "daytona", + providerLeaseId: "sandbox-runtime-1", + acquiredAt: new Date(), + lastUsedAt: new Date(), + expiresAt: null, + releasedAt: null, + failureReason: null, + cleanupStatus: "pending", + metadata: { + provider: "daytona", + sandboxName: "paperclip-probe", + reuseLease: false, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const environment = { + id: "env-sandbox-plugin", + companyId: "company-1", + name: "Daytona", + description: null, + driver: "sandbox" as const, + status: "active" as const, + config: { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + reuseLease: true, + }, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = await probeEnvironment({} as any, environment, { + companyId: "company-1", + pluginWorkerManager: {} as any, + applyCustomImageTemplate: true, + acquireSandboxRuntimeLease: true, + }); + + expect(result).toMatchObject({ + ok: true, + driver: "sandbox", + summary: "Connected to daytona sandbox paperclip-probe.", + }); + expect(mockProbePluginSandboxProviderDriver).not.toHaveBeenCalled(); + expect(mockRuntimeAcquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-1", + issueId: null, + agentId: null, + heartbeatRunId: null, + persistedExecutionWorkspace: null, + adapterType: null, + applyCustomImageTemplate: true, + })); + expect(mockRuntimeAcquireRunLease.mock.calls[0]?.[0].environment.config).toMatchObject({ + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + reuseLease: false, + archiveOnRelease: true, + }); + expect(mockRuntimeReleaseRunLease).toHaveBeenCalledWith(expect.objectContaining({ + lease: expect.objectContaining({ id: "lease-1" }), + status: "released", + })); + }); + it("routes plugin environment probes through the plugin worker host", async () => { mockProbePluginEnvironmentDriver.mockResolvedValue({ ok: true, diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index c59ecc6f74..5b5e1672f4 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -1597,6 +1597,7 @@ describe("environment routes", () => { companyId: null, pluginWorkerManager: undefined, applyCustomImageTemplate: false, + acquireSandboxRuntimeLease: false, }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), @@ -1693,9 +1694,10 @@ describe("environment routes", () => { expect(res.status).toBe(200); expect(res.body.driver).toBe("sandbox"); expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { - companyId: null, + companyId: "company-1", pluginWorkerManager: undefined, applyCustomImageTemplate: true, + acquireSandboxRuntimeLease: true, }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), @@ -1748,9 +1750,10 @@ describe("environment routes", () => { expect(res.status).toBe(200); expect(res.body.driver).toBe("sandbox"); expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { - companyId: null, + companyId: "company-1", pluginWorkerManager: undefined, applyCustomImageTemplate: true, + acquireSandboxRuntimeLease: true, }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 4ea28fad78..8de49215b6 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -263,6 +263,7 @@ export function agentRoutes( executionTarget: AdapterExecutionTarget | null; environmentName: string | null; fallbackChecks: AdapterEnvironmentCheck[]; + sandboxIdentityCheck?: AdapterEnvironmentCheck | null; release: (status?: "released" | "failed") => Promise; }> { const noopRelease = async () => {}; @@ -362,11 +363,26 @@ export function agentRoutes( // run id to heartbeat_runs.id, and we don't want to manufacture a fake // run row. Cleanup goes through the driver's `releaseRunLease` directly // (by lease record), since the batch helper queries by heartbeatRunId. + // + // Sandbox tests boot a fresh throwaway sandbox (never resume a retained + // agent lease) and archive it on release instead of deleting it, so the + // operator can inspect the exact sandbox from the provider dashboard while + // provider-side expiry reaps it later. + const testEnvironment = environment.driver === "sandbox" + ? { + ...environment, + config: { + ...(environment.config ?? {}), + reuseLease: false, + archiveOnRelease: true, + }, + } + : environment; let leaseRecord: Awaited>; try { leaseRecord = await environmentRuntime.acquireRunLease({ companyId: input.companyId, - environment, + environment: testEnvironment, issueId: null, heartbeatRunId: null, persistedExecutionWorkspace: null, @@ -398,7 +414,7 @@ export function agentRoutes( try { if (driver) { await driver.releaseRunLease({ - environment, + environment: testEnvironment, lease: leaseRecord.lease, status, }); @@ -417,7 +433,7 @@ export function agentRoutes( let realizedCwd: string | null = null; try { const realized = await environmentRuntime.realizeWorkspace({ - environment, + environment: testEnvironment, lease: leaseRecord.lease, // No host workspace to copy for a Test invocation; sandbox/plugin // realize implementations use the lease metadata's remoteCwd to @@ -458,9 +474,9 @@ export function agentRoutes( companyId: input.companyId, adapterType: input.adapterType, environment: { - id: environment.id, - driver: environment.driver, - config: environment.config ?? null, + id: testEnvironment.id, + driver: testEnvironment.driver, + config: testEnvironment.config ?? null, }, leaseId: leaseRecord.lease.id, leaseMetadata: leaseMetadataForTarget, @@ -505,10 +521,75 @@ export function agentRoutes( executionTarget: target, environmentName: environment.name, fallbackChecks: [], + sandboxIdentityCheck: buildSandboxIdentityCheck({ + environmentName: environment.name, + lease: leaseRecord.lease, + }), release: releaseLease, }; } + function readMetadataString(metadata: Record, keys: string[]): string | null { + for (const key of keys) { + const value = metadata[key]; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + } + return null; + } + + function buildSandboxIdentityCheck(input: { + environmentName: string; + lease: { + id: string; + provider?: string | null; + providerLeaseId?: string | null; + metadata?: Record | null; + }; + }): AdapterEnvironmentCheck { + const metadata = input.lease.metadata ?? {}; + const provider = input.lease.provider ?? readMetadataString(metadata, ["provider"]); + const sandboxId = readMetadataString(metadata, ["sandboxId", "sandboxID", "sandbox_id", "id"]); + const sandboxName = readMetadataString(metadata, ["sandboxName", "sandbox_name", "name"]); + const snapshotRef = readMetadataString(metadata, [ + "snapshot", + "snapshotId", + "snapshotID", + "snapshotRef", + "snapshot_ref", + "templateRef", + "template_ref", + "templateId", + "templateID", + "image", + "imageId", + "imageID", + "imageRef", + "image_ref", + ]); + const templateKind = readMetadataString(metadata, [ + "templateKind", + "template_kind", + "templateRefKind", + "template_ref_kind", + ]); + const detailParts = [ + `paperclipLeaseId=${input.lease.id}`, + input.lease.providerLeaseId ? `providerLeaseId=${input.lease.providerLeaseId}` : null, + provider ? `provider=${provider}` : null, + sandboxId ? `sandboxId=${sandboxId}` : null, + sandboxName ? `sandboxName=${sandboxName}` : null, + snapshotRef ? `${templateKind ? `${templateKind}Ref` : "snapshotOrTemplateRef"}=${snapshotRef}` : null, + ].filter((part): part is string => Boolean(part)); + + return { + code: "sandbox_test_identity", + level: "info", + message: `Sandbox test identity for "${input.environmentName}".`, + detail: detailParts.join("; "), + hint: "Use these provider-neutral IDs when comparing model-test output with provider logs or refreshed sandbox snapshots.", + }; + } + async function getCurrentUserRedactionOptions() { return { enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, @@ -1690,7 +1771,7 @@ export function agentRoutes( { adapterType: type }, ); - const { executionTarget, environmentName, fallbackChecks, release } = + const { executionTarget, environmentName, fallbackChecks, sandboxIdentityCheck, release } = await resolveAdapterTestExecutionContext({ companyId, adapterType: type, @@ -1728,7 +1809,10 @@ export function agentRoutes( }); if (result.status === "fail") releaseStatus = "failed"; - res.json(result); + res.json({ + ...result, + checks: sandboxIdentityCheck ? [sandboxIdentityCheck, ...result.checks] : result.checks, + }); } catch (err) { releaseStatus = "failed"; throw err; diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 6ea542fdcd..9ecb9c2a90 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -777,12 +777,24 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } + let customImageReconciliation: Awaited< + ReturnType + > = { action: "none" }; if (patch.config !== undefined || patch.driver !== undefined) { await secrets.syncSecretRefsForTarget( companyIdForSecrets!, { targetType: "environment", targetId: environment.id }, await collectEnvironmentSecretRefs({ db, environment }), ); + try { + customImageReconciliation = await customImages.reconcileActiveTemplateForConfigChange({ + environmentId: environment.id, + previous: existing, + next: environment, + }); + } catch { + // Reconciliation is best-effort; a failure must not fail the save. + } } if (patch.envVars !== undefined) { await secrets.syncEnvBindingsForTarget( @@ -797,7 +809,9 @@ export function environmentRoutes( entityId: environment.id, details: summarizeEnvironmentUpdate(patch as Record, environment), }); - res.json(environment); + res.json(customImageReconciliation.action === "none" + ? environment + : { ...environment, customImageReconciliation }); }); router.delete("/environments/:id", async (req, res) => { @@ -871,6 +885,8 @@ export function environmentRoutes( assertCanAccessInstanceEnvironments(req); const actor = getActorInfo(req); const companyIdForSecrets = await resolveEnvironmentSecretContextCompanyId(req, environment.id, { required: false }); + const companyIdForProbe = companyIdForSecrets + ?? (environment.driver === "sandbox" ? await resolveCustomImageCompanyId(req) : null); if (!companyIdForSecrets) { const secretRefs = await collectEnvironmentSecretRefs({ db, environment }); if (secretRefs.length > 0) { @@ -880,9 +896,10 @@ export function environmentRoutes( } } const probe = await probeEnvironment(db, environment, { - companyId: companyIdForSecrets, + companyId: companyIdForProbe, pluginWorkerManager: options.pluginWorkerManager, applyCustomImageTemplate: environment.driver === "sandbox", + acquireSandboxRuntimeLease: environment.driver === "sandbox", }); await logInstanceEnvironmentActivity({ actor, diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 0fe977fca5..054224deb5 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -76,6 +76,7 @@ const fakeSandboxEnvironmentConfigSchema = z.object({ .default("ubuntu:24.04"), reuseLease: z.boolean().optional().default(false), streamRunLogs: z.boolean().optional(), + archiveOnRelease: z.boolean().optional(), }).strict(); const pluginSandboxProviderKeySchema = z.string() @@ -91,6 +92,7 @@ const pluginSandboxEnvironmentConfigSchema = z.object({ timeoutMs: z.coerce.number().int().min(1).max(86_400_000).optional(), reuseLease: z.boolean().optional().default(false), streamRunLogs: z.boolean().optional(), + archiveOnRelease: z.boolean().optional(), }).catchall(z.unknown()); const pluginEnvironmentConfigSchema = z.object({ @@ -633,6 +635,10 @@ export async function resolveEnvironmentDriverConfigForRuntime( environmentId, baseConfig: parsed.config, runtimeConfig, + // Match the capture-time fingerprint exclusions: secret-ref paths + // are excluded when the template's source fingerprint is computed, + // so they must be excluded when re-checking it here. + secretRefExcludePaths: collectSecretRefPaths(schema), }) : runtimeConfig, }; diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts index 9c56321cd4..254169290c 100644 --- a/server/src/services/environment-custom-image-runtime.ts +++ b/server/src/services/environment-custom-image-runtime.ts @@ -8,11 +8,24 @@ import { type EnvironmentCustomImageTemplateKind, type SandboxEnvironmentConfig, } from "@paperclipai/shared"; -import { writeConfigValueAtPath } from "./json-schema-secret-refs.js"; +import { readConfigValueAtPath, writeConfigValueAtPath } from "./json-schema-secret-refs.js"; type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect; export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding"; +export const ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS = [ + "timeoutMs", + "reuseLease", + "streamRunLogs", + "archiveOnRelease", + "cpu", + "memory", + "disk", + "gpu", + "autoStopInterval", + "autoArchiveInterval", + "autoDeleteInterval", +]; export interface EnvironmentCustomImageRuntimeConfigBinding { field: string; @@ -111,6 +124,101 @@ export function applyCustomImageTemplateToSandboxConfig( return next as SandboxEnvironmentConfig; } +export function environmentCustomImageTemplateMatchesBaseConfig(input: { + template: EnvironmentCustomImageTemplate; + baseConfig: SandboxEnvironmentConfig; + secretRefExcludePaths?: Iterable; +}): boolean { + const expectedFingerprint = input.template.sourceEnvironmentConfigFingerprint; + if (!expectedFingerprint) return true; + // Capture-time fingerprints exclude both runtime-only fields and the + // provider's secret-ref paths (see finishSetupSession); the runtime match + // must exclude the same set or configs that carry a secret ref can never + // match and the active template gets silently dropped. + const secretRefExcludePaths = [...(input.secretRefExcludePaths ?? [])]; + const normalizedFingerprint = fingerprintEnvironmentSandboxProviderConfig(input.baseConfig, { + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ...secretRefExcludePaths, + ], + }); + if (normalizedFingerprint === expectedFingerprint) return true; + // Backward compatibility for templates captured before runtime-only fields + // were excluded from the source fingerprint (secret-ref paths have always + // been excluded at capture time). + return fingerprintEnvironmentSandboxProviderConfig(input.baseConfig, { + excludePaths: secretRefExcludePaths, + }) === expectedFingerprint; +} + +// Standard boot-source fields shared across sandbox providers. A change to any +// of these means the user asked for a different base, so a captured template +// no longer reflects the saved config and cannot simply be re-linked. +export const ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS = [ + "snapshot", + "image", + "template", +] as const; + +export type EnvironmentCustomImageConfigChangeKind = "none" | "relinkable" | "breaking"; + +/** + * Classifies a saved-config change relative to an active captured template. + * + * - `none`: the template either already matched the new config, or was already + * detached before this change; nothing to reconcile. + * - `relinkable`: only fields that cannot affect the captured template's + * contents or reachability changed (for example a region hint), so the + * template's source fingerprint can be re-stamped to the new config. + * - `breaking`: a boot-source field or a provider-declared template identity + * path changed; the captured template no longer corresponds to the config + * and a fresh capture is required. + */ +export function classifyEnvironmentCustomImageConfigChange(input: { + template: EnvironmentCustomImageTemplate; + previousConfig: SandboxEnvironmentConfig; + nextConfig: SandboxEnvironmentConfig; + secretRefExcludePaths?: Iterable; + templateIdentityPaths?: Iterable; +}): EnvironmentCustomImageConfigChangeKind { + const secretRefExcludePaths = [...(input.secretRefExcludePaths ?? [])]; + if (!environmentCustomImageTemplateMatchesBaseConfig({ + template: input.template, + baseConfig: input.previousConfig, + secretRefExcludePaths, + })) { + return "none"; + } + if (environmentCustomImageTemplateMatchesBaseConfig({ + template: input.template, + baseConfig: input.nextConfig, + secretRefExcludePaths, + })) { + return "none"; + } + const binding = resolveEnvironmentCustomImageRuntimeConfigBinding({ + templateKind: input.template.templateKind, + metadata: input.template.metadata, + }); + const breakingPaths = new Set([ + "provider", + binding.field, + ...binding.unsetFields, + ...ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS, + ...(input.templateIdentityPaths ?? []), + ]); + const previous = input.previousConfig as Record; + const next = input.nextConfig as Record; + for (const path of breakingPaths) { + const before = readConfigValueAtPath(previous, path); + const after = readConfigValueAtPath(next, path); + if (stableStringify(before ?? null) !== stableStringify(after ?? null)) { + return "breaking"; + } + } + return "relinkable"; +} + export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate { return { id: row.id, @@ -138,6 +246,7 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( environmentId: string; baseConfig: SandboxEnvironmentConfig; runtimeConfig: SandboxEnvironmentConfig; + secretRefExcludePaths?: Iterable; now?: Date; }, ): Promise { @@ -155,15 +264,19 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( const active = environmentCustomImageTemplateFromRow(row); if (!active.templateRef) return input.runtimeConfig; + if (!environmentCustomImageTemplateMatchesBaseConfig({ + template: active, + baseConfig: input.baseConfig, + secretRefExcludePaths: input.secretRefExcludePaths, + })) { + return input.runtimeConfig; + } // An active template is an explicit, environment+provider-scoped artifact: the // captured snapshot/image fully replaces the base image at create time, so it is - // applied whenever present. We deliberately do not gate on a base-config - // fingerprint — the config dialog re-saves the environment right after capture, - // and ordinary resource/lifecycle tweaks (cpu/memory/lease knobs that don't - // affect image identity and are dropped for snapshot creation) would otherwise - // silently discard the captured setup state and provider metadata. Re-running - // setup supersedes the template when the user wants a fresh capture. + // applied whenever the image/template-defining parts of the base config still + // match. Runtime-only knobs such as lease reuse, timeouts, and resource hints + // are excluded from the fingerprint so those edits do not discard the capture. const now = input.now ?? new Date(); await db .update(environmentCustomImageTemplates) diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts index 1cef716cf5..f8a8449f2f 100644 --- a/server/src/services/environment-custom-images.ts +++ b/server/src/services/environment-custom-images.ts @@ -38,9 +38,12 @@ import { } from "./plugin-environment-driver.js"; import { environmentService } from "./environments.js"; import { + ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + classifyEnvironmentCustomImageConfigChange, fingerprintEnvironmentSandboxProviderConfig, ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY, defaultEnvironmentCustomImageRuntimeConfigBinding, + environmentCustomImageTemplateMatchesBaseConfig, normalizeEnvironmentCustomImageRuntimeConfigBinding, environmentCustomImageTemplateFromRow, readEnvironmentCustomImageTemplateKind as readTemplateKind, @@ -57,10 +60,22 @@ type SetupSessionRow = typeof environmentCustomImageSetupSessions.$inferSelect; export interface EnvironmentCustomImageOverview { activeTemplate: EnvironmentCustomImageTemplate | null; + /** + * Whether the active template's captured fingerprint still matches the + * environment's saved config. `false` means runs silently fall back to the + * base image until the template is re-captured. `null` when unknown (no + * active template, or the config could not be evaluated). + */ + activeTemplateMatchesConfig: boolean | null; activeSession: EnvironmentCustomImageSetupSession | null; latestSession: EnvironmentCustomImageSetupSession | null; } +export type EnvironmentCustomImageReconciliation = + | { action: "none" } + | { action: "relinked"; template: EnvironmentCustomImageTemplate } + | { action: "detached"; template: EnvironmentCustomImageTemplate }; + export interface EnvironmentCustomImageSetupSessionResult { session: EnvironmentCustomImageSetupSession; connectionPayload: PluginEnvironmentInteractiveSetupConnectionPayload | null; @@ -595,17 +610,44 @@ export function environmentCustomImageService( } } + async function templateMatchesEnvironmentConfig( + environment: Environment, + template: EnvironmentCustomImageTemplate, + ): Promise { + try { + const parsed = parseEnvironmentDriverConfig(environment); + if (parsed.driver !== "sandbox") return false; + if (parsed.config.provider !== template.provider) return false; + return environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: parsed.config, + secretRefExcludePaths: parsed.config.provider === "fake" + ? [] + : await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + }); + } catch { + return null; + } + } + return { getOverview: async (input: { environmentId: string; }): Promise => { - await requireEnvironment(input.environmentId); + const environment = await requireEnvironment(input.environmentId); const [activeTemplate, activeSession, latestSession] = await Promise.all([ resolveActiveTemplate(db, input), getActiveSetupSession(input), getLatestSetupSession(input), ]); - return { activeTemplate, activeSession, latestSession }; + return { + activeTemplate, + activeTemplateMatchesConfig: activeTemplate + ? await templateMatchesEnvironmentConfig(environment, activeTemplate) + : null, + activeSession, + latestSession, + }; }, getActiveTemplate: async (input: { @@ -763,7 +805,10 @@ export function environmentCustomImageService( const parsed = parseEnvironmentDriverConfig(environment); const baseFingerprint = parsed.driver === "sandbox" ? fingerprintEnvironmentSandboxProviderConfig(parsed.config, { - excludePaths: await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + ], }) : null; const provider = await resolveSetupProvider({ @@ -876,6 +921,84 @@ export function environmentCustomImageService( return await cancelSession(session, input.reason ?? "cancelled", "cancelled"); }, + /** + * Keeps the active captured template consistent with a just-saved config + * change. Changes that cannot affect the captured contents (for example a + * region hint) re-stamp the template's source fingerprint so it keeps + * applying; boot-source or provider-identity changes report `detached` so + * callers can tell the user a fresh capture is required. Never throws for + * unparseable configs; the save itself must not fail on reconciliation. + */ + reconcileActiveTemplateForConfigChange: async (input: { + environmentId: string; + previous: Pick; + next: Pick; + now?: Date; + }): Promise => { + let previousParsed; + let nextParsed; + try { + previousParsed = parseEnvironmentDriverConfig(input.previous); + nextParsed = parseEnvironmentDriverConfig(input.next); + } catch { + return { action: "none" }; + } + if (previousParsed.driver !== "sandbox") return { action: "none" }; + const template = await resolveActiveTemplate(db, { + environmentId: input.environmentId, + provider: previousParsed.config.provider, + }); + if (!template?.templateRef) return { action: "none" }; + const secretRefExcludePaths = previousParsed.config.provider === "fake" + ? [] + : [...await resolveSandboxProviderSecretRefPaths(db, previousParsed.config.provider)]; + if (!environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: previousParsed.config, + secretRefExcludePaths, + })) { + // Already detached before this save; leave it alone. + return { action: "none" }; + } + if (nextParsed.driver !== "sandbox" || nextParsed.config.provider !== template.provider) { + return { action: "detached", template }; + } + const resolvedDriver = await resolvePluginSandboxProviderDriverByKey({ + db, + driverKey: template.provider, + }); + if (!resolvedDriver) { + // Without driver metadata the change cannot be classified safely. + return { action: "detached", template }; + } + const changeKind = classifyEnvironmentCustomImageConfigChange({ + template, + previousConfig: previousParsed.config, + nextConfig: nextParsed.config, + secretRefExcludePaths, + templateIdentityPaths: resolvedDriver.driver.templateIdentityPaths ?? [], + }); + if (changeKind === "none") return { action: "none" }; + if (changeKind === "breaking") return { action: "detached", template }; + const now = input.now ?? new Date(); + const nextFingerprint = fingerprintEnvironmentSandboxProviderConfig(nextParsed.config, { + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ...secretRefExcludePaths, + ], + }); + const row = await db + .update(environmentCustomImageTemplates) + .set({ sourceEnvironmentConfigFingerprint: nextFingerprint, updatedAt: now }) + .where(eq(environmentCustomImageTemplates.id, template.id)) + .returning() + .then((rows) => rows[0] ?? null); + return { + action: "relinked", + template: row ? environmentCustomImageTemplateFromRow(row) : template, + }; + }, + rollbackTemplate: async (input: { environmentId: string; now?: Date; diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index eaac5b22e8..ee1bce5d4c 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -10,6 +10,7 @@ import os from "node:os"; import { isBuiltinSandboxProvider, probeSandboxProvider } from "./sandbox-provider-runtime.js"; import { probePluginEnvironmentDriver, probePluginSandboxProviderDriver } from "./plugin-environment-driver.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import { environmentRuntimeService } from "./environment-runtime.js"; export async function probeEnvironment( db: Db, @@ -19,11 +20,14 @@ export async function probeEnvironment( pluginWorkerManager?: PluginWorkerManager; resolvedConfig?: ParsedEnvironmentConfig; applyCustomImageTemplate?: boolean; + acquireSandboxRuntimeLease?: boolean; } = {}, ): Promise { const resolvedCompanyId = options.companyId ?? null; const parsed = options.resolvedConfig ?? ( - resolvedCompanyId || options.applyCustomImageTemplate === true + options.acquireSandboxRuntimeLease === true + ? parseEnvironmentDriverConfig(environment) + : resolvedCompanyId || options.applyCustomImageTemplate === true ? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment, { applyCustomImageTemplate: options.applyCustomImageTemplate === true, }) @@ -43,6 +47,98 @@ export async function probeEnvironment( } if (parsed.driver === "sandbox") { + if (options.acquireSandboxRuntimeLease) { + if (!resolvedCompanyId) { + return { + ok: false, + driver: "sandbox", + summary: "Sandbox environment probe requires a companyId context.", + details: { + provider: parsed.config.provider, + }, + }; + } + + const runtime = environmentRuntimeService(db, { + pluginWorkerManager: options.pluginWorkerManager, + }); + const probeEnvironmentConfig = { + ...environment, + config: { + ...(environment.config ?? {}), + // Test probes should prove a fresh provider boot, not resume a retained + // agent lease and report success without provider-side activity. + reuseLease: false, + // Keep the probe sandbox inspectable in the provider dashboard + // (archived, provider-side expiry) instead of deleting it the moment + // the probe finishes. + archiveOnRelease: true, + }, + }; + let leaseRecord: Awaited> | null = null; + let releaseStatus: "released" | "failed" = "released"; + try { + leaseRecord = await runtime.acquireRunLease({ + companyId: resolvedCompanyId, + environment: probeEnvironmentConfig, + issueId: null, + agentId: null, + heartbeatRunId: null, + persistedExecutionWorkspace: null, + adapterType: null, + applyCustomImageTemplate: options.applyCustomImageTemplate === true, + }); + const metadata = leaseRecord.lease.metadata ?? {}; + const provider = leaseRecord.lease.provider ?? parsed.config.provider; + const sandboxName = typeof metadata.sandboxName === "string" && metadata.sandboxName.trim().length > 0 + ? metadata.sandboxName.trim() + : null; + return { + ok: true, + driver: "sandbox", + summary: sandboxName + ? `Connected to ${provider} sandbox ${sandboxName}.` + : `Connected to ${provider} sandbox environment.`, + details: { + provider, + providerLeaseId: leaseRecord.lease.providerLeaseId, + leaseId: leaseRecord.lease.id, + leasePolicy: leaseRecord.lease.leasePolicy, + metadata, + }, + }; + } catch (error) { + releaseStatus = "failed"; + return { + ok: false, + driver: "sandbox", + summary: `Sandbox environment probe failed for provider "${parsed.config.provider}".`, + details: { + provider: parsed.config.provider, + error: error instanceof Error ? error.message : String(error), + }, + }; + } finally { + if (leaseRecord) { + const driver = runtime.getDriver(environment.driver); + try { + await driver?.releaseRunLease({ + environment: probeEnvironmentConfig, + lease: leaseRecord.lease, + status: releaseStatus, + }); + } catch (releaseError) { + // Cleanup failures must not mask the connection result shown to + // the operator, but a leaked sandbox should still be traceable. + // eslint-disable-next-line no-console + console.warn( + `[environment-probe] Failed to release lease ${leaseRecord.lease.id} for provider "${parsed.config.provider}": ${releaseError instanceof Error ? releaseError.message : String(releaseError)}`, + ); + } + } + } + } + if (!isBuiltinSandboxProvider(parsed.config.provider)) { if (!options.pluginWorkerManager) { return { diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index ab3cf09aaa..030def1198 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -15,10 +15,23 @@ import { api } from "./client"; export interface EnvironmentCustomImageOverview { activeTemplate: EnvironmentCustomImageTemplate | null; + /** + * `false` means the environment config changed since capture and runs fall + * back to the base image until a new image is captured. `null` when unknown. + */ + activeTemplateMatchesConfig?: boolean | null; activeSession: EnvironmentCustomImageSetupSession | null; latestSession: EnvironmentCustomImageSetupSession | null; } +export type EnvironmentCustomImageReconciliation = + | { action: "relinked"; template: EnvironmentCustomImageTemplate } + | { action: "detached"; template: EnvironmentCustomImageTemplate }; + +export type EnvironmentUpdateResult = Environment & { + customImageReconciliation?: EnvironmentCustomImageReconciliation; +}; + export interface EnvironmentCustomImageConnectionPayload { type: string; command?: string | null; @@ -64,8 +77,14 @@ export const environmentsApi = { status?: "active" | "archived"; config?: Record; metadata?: Record | null; - }) => api.patch(`/environments/${environmentId}`, body), - probe: (environmentId: string) => api.post(`/environments/${environmentId}/probe`, {}), + }) => api.patch(`/environments/${environmentId}`, body), + probe: (environmentId: string, companyId?: string | null) => + api.post( + companyId + ? `/environments/${environmentId}/probe?${customImageCompanyQuery(companyId)}` + : `/environments/${environmentId}/probe`, + {}, + ), probeConfig: (companyId: string, body: { name?: string; driver: "local" | "ssh" | "sandbox" | "plugin"; diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index c787bea861..7b148c7978 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -485,7 +485,96 @@ describe("CompanyEnvironments — test provider button", () => { expect(buttonsAfter[0].disabled).toBe(true); expect(buttonsAfter[1].textContent?.trim()).toBe("Test provider"); expect(buttonsAfter[1].disabled).toBe(false); - expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1"); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + }); + + it("explains that successful sandbox provider tests use a temporary sandbox", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.probe.mockResolvedValue({ + ok: true, + driver: "sandbox", + summary: "Connected to Daytona sandbox paperclip-probe.", + details: { + provider: "daytona", + diagnostics: [], + metadata: { + provider: "daytona", + sandboxId: "473167E9", + sandboxName: "paperclip-probe", + }, + }, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => { + testProviderButtons(container)[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + expect(container.textContent).toContain("Connected to Daytona sandbox paperclip-probe."); + expect(container.textContent).not.toContain("Verified temporary daytona sandbox"); + expect(container.textContent).not.toContain("Test probes clean up the validation sandbox after the check"); + expect(container.textContent).not.toContain("provider dashboard"); + }); + + it("does not show sandbox lifecycle success copy for failed sandbox provider tests", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.probe.mockResolvedValue({ + ok: false, + driver: "sandbox", + summary: "Daytona sandbox probe failed.", + details: { + provider: "daytona", + error: "Sandbox image was not found.", + metadata: { + provider: "daytona", + sandboxId: "473167E9", + sandboxName: "paperclip-probe", + }, + }, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => { + testProviderButtons(container)[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(container.textContent).toContain("Daytona sandbox probe failed."); + expect(container.textContent).toContain("Sandbox image was not found."); + expect(container.textContent).not.toContain("Verified temporary daytona sandbox"); + expect(container.textContent).not.toContain("Test probes clean up the validation sandbox after the check"); }); it("keeps the second environment's testing state when an earlier probe settles", async () => { @@ -985,6 +1074,7 @@ describe("CompanyEnvironments — test provider button", () => { it("shows active template controls for refresh, rollback, and disable", async () => { root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const activeTemplateId = "12345678-90ab-cdef-1234-567890abcdef"; mockEnvironmentsApi.list.mockResolvedValue([ { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, ]); @@ -1007,7 +1097,7 @@ describe("CompanyEnvironments — test provider button", () => { }, }); mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ - activeTemplate: createTemplate({ id: "template-active" }), + activeTemplate: createTemplate({ id: activeTemplateId }), activeSession: null, latestSession: null, }); @@ -1027,6 +1117,13 @@ describe("CompanyEnvironments — test provider button", () => { await waitForAssertion(() => { const dialog = getOpenDialog(); expect(dialog?.textContent).toContain("Active template"); + expect(dialog?.textContent).toContain("redacted-template-ref"); + expect(dialog?.textContent).not.toContain("id 12345678-90a"); + expect( + dialog?.querySelector( + "[title='Provider snapshot ref redacted-template-ref (Paperclip template 12345678-90ab-cdef-1234-567890abcdef)']", + ), + ).toBeTruthy(); expect(findButton(dialog!, "Refresh")).toBeTruthy(); expect(findButton(dialog!, "Rollback")).toBeTruthy(); expect(findButton(dialog!, "Disable")).toBeTruthy(); @@ -1038,7 +1135,7 @@ describe("CompanyEnvironments — test provider button", () => { expect(mockEnvironmentsApi.startCustomImageSetupSession).toHaveBeenCalledWith( "env-1", "company-1", - { templateId: "template-active" }, + { templateId: activeTemplateId }, ); await waitForAssertion(() => { expect(getOpenDialog()?.textContent).toContain("Browser terminal"); @@ -1048,6 +1145,130 @@ describe("CompanyEnvironments — test provider button", () => { }); }); + it("allows stale capturing setup sessions to be cancelled back to active template controls", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const activeTemplate = createTemplate({ id: "template-active" }); + let activeSession: ReturnType | null = createSession({ status: "capturing" }); + let latestSession: ReturnType | null = activeSession; + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.customImageTemplate.mockImplementation(async () => ({ + activeTemplate, + activeSession, + latestSession, + })); + mockEnvironmentsApi.customImageSetupSession.mockResolvedValue({ + session: activeSession, + connectionPayload: null, + }); + mockEnvironmentsApi.cancelCustomImageSetupSession.mockImplementation(async () => { + activeSession = null; + latestSession = createSession({ status: "cancelled", finishedAt: "2026-06-25T20:10:00.000Z" }); + return latestSession; + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getOpenDialog()!; + expect(dialog.textContent).toContain("Capturing template"); + expect(dialog.textContent).toContain("Capture is in progress."); + expect(findButton(dialog, "Finished")?.disabled).toBe(true); + expect(findButton(dialog, "Cancel")?.disabled).toBe(false); + }); + + await act(async () => click(findButton(getOpenDialog()!, "Cancel"))); + await waitForAssertion(() => { + const dialog = getOpenDialog()!; + expect(dialog.textContent).toContain("Active template"); + expect(findButton(dialog, "Refresh")).toBeTruthy(); + }); + + expect(mockEnvironmentsApi.cancelCustomImageSetupSession).toHaveBeenCalledExactlyOnceWith( + "session-1", + { reason: "operator cancelled" }, + ); + }); + + it("shows an out-of-sync warning when the active template no longer matches the saved config", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: createTemplate({ id: "template-active" }), + activeTemplateMatchesConfig: false, + activeSession: null, + latestSession: null, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getOpenDialog()!; + expect(dialog.textContent).toContain("Active template"); + expect(dialog.textContent).toContain("Not in use — the environment configuration changed"); + }); + }); + + it("does not show the out-of-sync warning when the active template matches the saved config", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: createTemplate({ id: "template-active" }), + activeTemplateMatchesConfig: true, + activeSession: null, + latestSession: null, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getOpenDialog()!; + expect(dialog.textContent).toContain("Active template"); + expect(dialog.textContent).not.toContain("Not in use"); + }); + }); + it("passes company context when rolling back and disabling an active template", async () => { root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 8b0d3c6880..42f040f3e7 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -21,6 +21,7 @@ import { environmentsApi, type EnvironmentCustomImageConnectionPayload, type EnvironmentCustomImageSetupSessionResult, + type EnvironmentUpdateResult, } from "@/api/environments"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { secretsApi } from "@/api/secrets"; @@ -205,6 +206,12 @@ function formatDateTime(value: string | Date | null | undefined): string | null return Number.isNaN(date.getTime()) ? null : date.toLocaleString(); } +function formatShortId(value: string): string { + const normalized = value.trim(); + if (normalized.length <= 12) return normalized; + return `${normalized.slice(0, 12)}…`; +} + function readConnectionCommand(payload: EnvironmentCustomImageConnectionPayload | null | undefined): string | null { return typeof payload?.command === "string" && payload.command.trim().length > 0 ? payload.command @@ -928,13 +935,18 @@ function EnvironmentImageTemplatePanel({ size="sm" variant="ghost" onClick={() => cancelSetupMutation.mutate(session.id)} - disabled={isMutating || isCapturing} + disabled={isMutating} > Cancel + {isCapturing ? ( +
+ Capture is in progress. If this state remains after a refresh or interrupted request, cancel it to return to the active template controls. +
+ ) : null} {session.status === "waiting_for_user" && connectionPayload?.type === "ssh" ? ( ) : null} @@ -961,6 +973,8 @@ function EnvironmentImageTemplatePanel({ } if (activeTemplate) { + const templateRef = activeTemplate.templateRef?.trim() || null; + const templateOutOfSync = overview?.activeTemplateMatchesConfig === false; return (
@@ -968,9 +982,28 @@ function EnvironmentImageTemplatePanel({
Active template
{providerDisplayName} · {activeTemplate.templateKind} + {" · "} + + {templateRef ?? `id ${formatShortId(activeTemplate.id)}`} + {capturedAt ? ` · captured ${capturedAt}` : ""} {lastUsedAt ? ` · last used ${lastUsedAt}` : ""}
+ {templateOutOfSync ? ( +
+ Not in use — the environment configuration changed since this image was + captured. Runs fall back to the base configuration until you capture a new + image. +
+ ) : null}