mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
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:
1 parent
2eb9a09c0c
commit
b8a76081ec
5 files changed
+258
-5
No files matched your search
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user