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:
parent
2eb9a09c0c
commit
b8a76081ec
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 } } });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue