From b8a76081ece9d790c5e5bba746cbb3d368e383bf Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 19 Aug 2026 16:36:18 -0700 Subject: [PATCH] feat(environments): expose boot-relevant drift attribution in the custom-image overview (#11751) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip lets operators prepare and use custom images for sandbox environments > - The custom-image overview detected drift but did not show which boot source changed > - Operators need the changed field and values to understand why a template no longer matches > - This pull request adds safe drift attribution to the overview API and the out-of-sync banner > - The benefit is faster diagnosis without exposing secrets or internal snapshot data ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting (server and UI). **Problem or motivation** The custom-image overview reported drift without identifying the changed boot source. Operators had to inspect other data to find the cause. **Proposed solution** Return a classified drift summary with changed paths and their prior and current values. Show the boot-source field in the UI banner. Keep legacy templates and unclassified drift on the generic message. **Alternatives considered** The change does not expose the full snapshot or fingerprint. This keeps the overview contract small and avoids secret disclosure. **Roadmap alignment** The change supports the existing custom-image environment workflow and does not duplicate a roadmap item. ## What Changed - Add `activeTemplateDrift` to the custom-image overview response. - Classify drift as `boot_source_drift`, `knob_only`, or `unclassified`. - Return drifted paths with safe `from` and `to` values. - Show the changed boot-source field and values in the out-of-sync banner. - Keep legacy templates fail-closed and exclude secrets, fingerprints, and raw snapshots. - Add server and UI tests for the new behavior. ## Verification - `npx vitest run server/src/__tests__/environment-custom-images-service.test.ts` passes with 27 tests. - `npx vitest run ui/src/pages/CompanyEnvironments.test.tsx` passes with 27 tests. - `pnpm --filter @paperclipai/ui exec tsc --noEmit` passes. - Review the overview response and banner cases for boot-source, knob-only, and legacy drift. ## Risks The overview response gains one optional field. Legacy templates remain compatible because they return `unclassified` and keep the generic banner. The service excludes secret values, fingerprints, and raw snapshots. ## Model Used OpenAI Codex, GPT-5, tool use and code execution enabled. The model reviewed the handoff and managed the pull request. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../environment-custom-images-service.test.ts | 85 +++++++++++++++++++ .../src/services/environment-custom-images.ts | 72 +++++++++++++++- ui/src/api/environments.ts | 12 +++ ui/src/pages/CompanyEnvironments.test.tsx | 59 +++++++++++++ ui/src/pages/CompanyEnvironments.tsx | 35 +++++++- 5 files changed, 258 insertions(+), 5 deletions(-) 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}