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 <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-08-19 16:36:18 -07:00 committed by GitHub
parent 2eb9a09c0c
commit b8a76081ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 258 additions and 5 deletions

View File

@ -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() });

View File

@ -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<EnvironmentCustomImageActiveTemplateDrift | null> {
if (!activeRow) return null;
let parsed: ReturnType<typeof parseEnvironmentDriverConfig>;
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<EnvironmentCustomImageOverview> => {
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,
};

View File

@ -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 };

View File

@ -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 } } });

View File

@ -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 (
<div className="mt-3 border-t border-border/60 pt-3" data-testid={`custom-image-template-state-${environment.id}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
@ -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."}
</div>
) : null}
</div>