diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts index 335c1e6142..f3f986c406 100644 --- a/server/src/__tests__/environment-custom-images-service.test.ts +++ b/server/src/__tests__/environment-custom-images-service.test.ts @@ -865,6 +865,52 @@ describeEmbeddedPostgres("environmentCustomImageService reconciliation", () => { const outOfSync = await service.getOverview({ environmentId }); expect(outOfSync.activeTemplate).not.toBeNull(); expect(outOfSync.activeTemplateMatchesConfig).toBe(false); + // A boot-source field changed, so the overview attributes the drift and + // names the field with its `from`/`to` values. + expect(outOfSync.activeTemplateDrift?.classification).toBe("boot_source_drift"); + expect(outOfSync.activeTemplateDrift?.driftedPaths).toEqual( + expect.arrayContaining([{ path: "image", from: "fake:base", to: "fake:other" }]), + ); + }); + + it("attributes a knob-only overview change without naming a boot-source field", 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 }); + + // A non-boot-relevant field changes. The fingerprint no longer matches, but + // every boot-source value still matches, so the drift is knob-only. + await db.update(environments) + .set({ config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" } }) + .where(eq(environments.id, environmentId)); + const overview = await service.getOverview({ environmentId }); + expect(overview.activeTemplateMatchesConfig).toBe(false); + expect(overview.activeTemplateDrift?.classification).toBe("knob_only"); + expect(overview.activeTemplateDrift?.driftedPaths).toEqual([]); + }); + + it("attributes an unclassified overview drift for a legacy template with no snapshot", async () => { + const { environmentId } = await seed(); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + // A legacy template carries no boot-relevant snapshot. The overview must + // fail closed and give no false "safe to relink" signal. + await db.insert(environmentCustomImageTemplates).values({ + environmentId, + provider: "fake-plugin", + templateKind: "snapshot", + templateRef: "snapshot-legacy", + sourceEnvironmentConfigFingerprint: "stale-fingerprint", + status: "active", + metadata: { runtimeConfigBinding: { field: "customTemplate", unsetFields: ["image"] } }, + }); + + const overview = await service.getOverview({ environmentId }); + expect(overview.activeTemplate).not.toBeNull(); + expect(overview.activeTemplateDrift?.classification).toBe("unclassified"); + expect(overview.activeTemplateDrift?.driftedPaths).toEqual([]); }); }); @@ -1107,6 +1153,45 @@ describeEmbeddedPostgres("environmentCustomImageService relink", () => { expect(activityJson).not.toContain(promoted.template.sourceEnvironmentConfigFingerprint); }); + it("keeps secret values and the fingerprint out of the overview payload", async () => { + const config = { + provider: "fake-secret-plugin", + image: "fake:base", + apiUrl: "https://secret-endpoint.example", + auth: "auth-secret-value", + credentials: { secret: "cred-secret-value", region: "eu" }, + reuseLease: false, + }; + const { environmentId } = await seed({ manifest: secretPluginManifest(), config }); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + + // A boot-source field changes, so the overview reports drift and carries the + // drifted paths. The payload must never leak a secret value or a fingerprint. + await db.update(environments) + .set({ config: { ...config, image: "fake:other" } }) + .where(eq(environments.id, environmentId)); + const overview = await service.getOverview({ environmentId }); + // An excluded secret-ref path forces the fail-closed unclassified result. + expect(overview.activeTemplateDrift?.classification).toBe("unclassified"); + // The full overview never leaks a secret value, and the server-internal + // snapshot never reaches the template response. + const overviewJson = JSON.stringify(overview); + expect(overviewJson).not.toContain("secret-endpoint.example"); + expect(overviewJson).not.toContain("auth-secret-value"); + expect(overviewJson).not.toContain("cred-secret-value"); + expect(overviewJson).not.toContain("bootRelevantConfig"); + // The drift attribution carries path names and non-secret values only, never + // a fingerprint value. + const driftJson = JSON.stringify(overview.activeTemplateDrift); + expect(driftJson).not.toContain(promoted.template.sourceEnvironmentConfigFingerprint); + expect(driftJson).not.toContain("secret-endpoint.example"); + expect(driftJson).not.toContain("auth-secret-value"); + expect(driftJson).not.toContain("cred-secret-value"); + }); + it("fails closed for legacy templates without a boot-relevant snapshot", async () => { const { companyId, environmentId } = await seed(); const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts index d481ebfacb..b7101ec01b 100644 --- a/server/src/services/environment-custom-images.ts +++ b/server/src/services/environment-custom-images.ts @@ -52,6 +52,7 @@ import { environmentCustomImageTemplateFromRow, readEnvironmentCustomImageTemplateKind as readTemplateKind, type EnvironmentCustomImageRelinkClassification, + type EnvironmentCustomImageDriftedPath, } from "./environment-custom-image-runtime.js"; import { logActivity } from "./activity-log.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; @@ -73,10 +74,23 @@ export interface EnvironmentCustomImageOverview { * active template, or the config could not be evaluated). */ activeTemplateMatchesConfig: boolean | null; + /** + * Boot-relevant drift attribution for the active template. It classifies the + * drift between the capture-time boot-relevant snapshot and the current + * config, and lists the drifted paths with their `from`/`to` values. The UI + * uses it to name the changed field instead of only "configuration changed". + * `null` when there is no active template or the driver is not `sandbox`. + */ + activeTemplateDrift: EnvironmentCustomImageActiveTemplateDrift | null; activeSession: EnvironmentCustomImageSetupSession | null; latestSession: EnvironmentCustomImageSetupSession | null; } +export interface EnvironmentCustomImageActiveTemplateDrift { + classification: EnvironmentCustomImageRelinkClassification; + driftedPaths: EnvironmentCustomImageDriftedPath[]; +} + export type EnvironmentCustomImageReconciliation = | { action: "none" } | { action: "relinked"; template: EnvironmentCustomImageTemplate } @@ -643,21 +657,75 @@ export function environmentCustomImageService( } } + /** + * Computes the boot-relevant drift attribution for the active template row. + * It reuses the relink wiring: it reads the snapshot from the row metadata, + * resolves the current provider contract, and passes the current parsed + * config. A driver that no longer resolves fails closed (null contract, so + * `unclassified`). Returns `null` when there is no active template or the + * driver is not `sandbox`. + */ + async function computeActiveTemplateDrift( + environment: Environment, + activeRow: typeof environmentCustomImageTemplates.$inferSelect | null, + ): Promise { + if (!activeRow) return null; + let parsed: ReturnType; + try { + parsed = parseEnvironmentDriverConfig(environment); + } catch { + return null; + } + if (parsed.driver !== "sandbox") return null; + const active = environmentCustomImageTemplateFromRow(activeRow); + // Resolve the current provider contract so the classifier can reject a + // snapshot captured against a different binding or identity-path set. A + // driver that no longer resolves fails closed (null contract). + const resolvedDriver = await resolvePluginSandboxProviderDriverByKey({ + db, + driverKey: active.provider, + }); + const currentContract = resolvedDriver + ? { + binding: templateConfigBindingFromDriver({ + templateRefKind: active.templateKind, + templateConfigBinding: resolvedDriver.driver.templateConfigBinding, + }), + templateIdentityPaths: resolvedDriver.driver.templateIdentityPaths ?? [], + } + : null; + // The persisted snapshot is server-internal; read it from the row, not the + // sanitized template response. + const drift = classifyEnvironmentCustomImageBootRelevantDrift({ + bootRelevantConfig: readEnvironmentCustomImageBootRelevantConfig(activeRow.metadata), + currentConfig: parsed.config, + currentContract, + }); + return { + classification: drift.classification, + driftedPaths: drift.driftedPaths, + }; + } + return { getOverview: async (input: { environmentId: string; }): Promise => { const environment = await requireEnvironment(input.environmentId); - const [activeTemplate, activeSession, latestSession] = await Promise.all([ - resolveActiveTemplate(db, input), + const [activeRow, activeSession, latestSession] = await Promise.all([ + resolveActiveTemplateRow(db, input), getActiveSetupSession(input), getLatestSetupSession(input), ]); + const activeTemplate = activeRow + ? environmentCustomImageTemplateFromRow(activeRow) + : null; return { activeTemplate, activeTemplateMatchesConfig: activeTemplate ? await templateMatchesEnvironmentConfig(environment, activeTemplate) : null, + activeTemplateDrift: await computeActiveTemplateDrift(environment, activeRow), activeSession, latestSession, }; diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index 46ca219291..b823ccd299 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -20,10 +20,22 @@ export interface EnvironmentCustomImageOverview { * back to the base image until a new image is captured. `null` when unknown. */ activeTemplateMatchesConfig?: boolean | null; + /** + * Boot-relevant drift attribution for the active template. It names the + * classification and the drifted paths with their `from`/`to` values, so the + * banner can name the changed field. `null` or absent when there is no active + * template or the driver is not `sandbox`. + */ + activeTemplateDrift?: EnvironmentCustomImageActiveTemplateDrift | null; activeSession: EnvironmentCustomImageSetupSession | null; latestSession: EnvironmentCustomImageSetupSession | null; } +export interface EnvironmentCustomImageActiveTemplateDrift { + classification: EnvironmentCustomImageRelinkClassification; + driftedPaths: EnvironmentCustomImageDriftedPath[]; +} + export type EnvironmentCustomImageReconciliation = | { action: "relinked"; template: EnvironmentCustomImageTemplate } | { action: "detached"; template: EnvironmentCustomImageTemplate }; diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index 13b27924eb..e99e2ea236 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -1316,6 +1316,65 @@ describe("CompanyEnvironments — test provider button", () => { }); }); + it("names the changed boot-source field in the out-of-sync banner for a boot-source drift", 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, + activeTemplateDrift: { + classification: "boot_source_drift", + driftedPaths: [{ path: "snapshot", from: "a", to: "b" }], + }, + activeSession: null, + latestSession: null, + }); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient)); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getEnvironmentFormPage()!; + expect(dialog.textContent).toContain("Not in use — Base image changed: snapshot `a` -> `b`"); + expect(dialog.textContent).not.toContain("the environment configuration changed"); + }); + }); + + it("keeps the generic out-of-sync banner for an unclassified drift", 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, + activeTemplateDrift: { classification: "unclassified", driftedPaths: [{ path: "apiUrl" }] }, + activeSession: null, + latestSession: null, + }); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient)); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getEnvironmentFormPage()!; + expect(dialog.textContent).toContain("Not in use — the environment configuration changed"); + expect(dialog.textContent).not.toContain("Base image 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 } } }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 570d0d0dd7..0113f2913a 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -20,6 +20,7 @@ import { } from "@paperclipai/shared"; import { environmentsApi, + type EnvironmentCustomImageActiveTemplateDrift, type EnvironmentCustomImageConnectionPayload, type EnvironmentCustomImageRelinkConflict, type EnvironmentCustomImageSetupSessionResult, @@ -290,6 +291,33 @@ function formatShortId(value: string): string { return `${normalized.slice(0, 12)}…`; } +function formatBootSourceDriftValue(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return "none"; + return JSON.stringify(value); +} + +/** + * Builds the drift summary for a `boot_source_drift` overview. It names each + * changed boot-source field with its `from` and `to` values (example: "snapshot + * `a` -> `b`"). It uses only value-bearing paths; an excluded path carries the + * name only, so the summary omits it. Returns `null` when no value-bearing path + * is present, so the banner keeps the generic text. + */ +function formatBootSourceDriftSummary( + drift: EnvironmentCustomImageActiveTemplateDrift | null | undefined, +): string | null { + if (!drift || drift.classification !== "boot_source_drift") return null; + const parts = drift.driftedPaths + .filter((entry) => "from" in entry || "to" in entry) + .map( + (entry) => + `${entry.path} \`${formatBootSourceDriftValue(entry.from)}\` -> \`${formatBootSourceDriftValue(entry.to)}\``, + ); + if (parts.length === 0) return null; + return `Base image changed: ${parts.join("; ")}`; +} + function readConnectionCommand(payload: EnvironmentCustomImageConnectionPayload | null | undefined): string | null { return typeof payload?.command === "string" && payload.command.trim().length > 0 ? payload.command @@ -1129,6 +1157,7 @@ function EnvironmentImageTemplatePanel({ if (activeTemplate) { const templateRef = activeTemplate.templateRef?.trim() || null; const templateOutOfSync = overview?.activeTemplateMatchesConfig === false; + const bootSourceDriftSummary = formatBootSourceDriftSummary(overview?.activeTemplateDrift); return (
@@ -1153,9 +1182,9 @@ function EnvironmentImageTemplatePanel({ className="text-xs text-destructive" data-testid={`custom-image-template-out-of-sync-${environment.id}`} > - Not in use — the environment configuration changed since this image was - captured. Runs fall back to the base configuration until you relink this - image or capture a new one. + {bootSourceDriftSummary + ? `Not in use — ${bootSourceDriftSummary}. Runs fall back to the base configuration until you relink this image or capture a new one.` + : "Not in use — the environment configuration changed since this image was captured. Runs fall back to the base configuration until you relink this image or capture a new one."}
) : null}