mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
feat(server): add a one-click relink action for detached custom-image templates (#11641)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip environments can use captured custom images for agent runs > - A configuration fingerprint change can detach a valid custom-image template > - Operators need a safe way to confirm that the image still matches the boot source > - This pull request adds a guarded relink action with drift classification and audit logging > - The benefit is a deliberate relink without a new sandbox boot or provider snapshot ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting environment, server, and UI behavior. **Problem or motivation** A custom-image template detaches when the environment configuration fingerprint changes. The runtime then uses the base image, even when the boot source did not change. The only prior remedy required a full re-capture. **Proposed solution** Add an operator-triggered relink action. Classify configuration drift from a server-owned boot-relevant snapshot. Relink knob-only drift without confirmation. Require explicit confirmation for boot-source or unclassified drift. Guard the route for instance administrators and record a safe activity event. **Alternatives considered** Keep requiring a full re-capture. This adds a sandbox boot and provider snapshot for cases where the image remains correct. **Roadmap alignment** The roadmap has no matching custom-image relink item. This change addresses an environment operation gap. **Additional context** The relink response exposes raw drift values only in the transient 409 response to the instance administrator. The service never persists or logs fingerprints or configuration values. Reserved identity-path segments fail closed. ## What Changed - Add `relinkActiveTemplate` with drift classification and conditional fingerprint update. - Persist a server-owned boot-relevant configuration snapshot during capture. - Add the guarded relink route with strict request validation and activity logging. - Add the relink action and confirmation flow to the environment page. - Add service, route, UI, and OpenAPI coverage. ## Verification - Run the focused service suite: `pnpm vitest run server/src/services/environment-custom-images-service.test.ts`. - Run the focused route suite: `pnpm vitest run server/src/routes/environment-custom-image-routes.test.ts`. - Run the focused UI suite: `pnpm vitest run ui/src/pages/CompanyEnvironments.test.tsx`. - Run server and UI TypeScript checks. - Confirm the OpenAPI snapshot matches the new route. - Confirm all required GitHub checks pass on commit `e46fdcfe94a719be854adf8849d30714e5b70b93`. - Confirm Greptile reports 5/5 with no unresolved review threads. ## Risks The relink action can keep an image after configuration drift. The service requires explicit confirmation for boot-source or unclassified drift. Reserved path segments produce a safe unresolved marker and never enter stored values. ## Model Used OpenAI GPT-5 Codex. The model used repository inspection, GitHub operations, and PR preparation with tool use and code execution. The runtime did not expose a context-window value or a separate reasoning-mode value. ## 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] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] 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
fe803bedf1
commit
120ae5428f
12 files changed
+1393
-15
No files matched your search
@@ -2,6 +2,7 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { environmentRoutes } from "../routes/environments.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import {
|
||||
environmentCustomImageTerminalConnectionRegistry,
|
||||
environmentCustomImageTerminalSessionStore,
|
||||
@@ -40,6 +41,7 @@ const mockEnvironmentCustomImageService = vi.hoisted(() => ({
|
||||
finishSetupSession: vi.fn(),
|
||||
cancelSetupSession: vi.fn(),
|
||||
rollbackTemplate: vi.fn(),
|
||||
relinkActiveTemplate: vi.fn(),
|
||||
disableTemplate: vi.fn(),
|
||||
cleanupExpiredSetupSessions: vi.fn(),
|
||||
}));
|
||||
@@ -292,6 +294,10 @@ describe("environment customImage setup routes", () => {
|
||||
templateRef: "disabled-template-secret",
|
||||
status: "revoked",
|
||||
}));
|
||||
mockEnvironmentCustomImageService.relinkActiveTemplate.mockResolvedValue({
|
||||
template: createTemplate({ id: "template-1", templateRef: "relinked-template-secret" }),
|
||||
classification: "knob_only",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a setup session, returns the live payload, and logs redacted details", async () => {
|
||||
@@ -641,4 +647,78 @@ describe("environment customImage setup routes", () => {
|
||||
expect(activity).not.toContain("old-template-secret");
|
||||
expect(activity).not.toContain("disabled-template-secret");
|
||||
});
|
||||
|
||||
it("relinks the active template through the company-scoped route", async () => {
|
||||
const res = await request(createApp(boardActor()))
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({ confirmBootSourceDrift: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.classification).toBe("knob_only");
|
||||
expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledWith({
|
||||
environmentId: "env-1",
|
||||
confirmBootSourceDrift: true,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
agentApiKeyId: null,
|
||||
},
|
||||
companyId: "company-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the confirmation flag to false and rejects unknown body keys", async () => {
|
||||
const app = createApp(boardActor());
|
||||
const withoutFlag = await request(app)
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({});
|
||||
expect(withoutFlag.status).toBe(200);
|
||||
expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ confirmBootSourceDrift: false }),
|
||||
);
|
||||
|
||||
// The strict schema rejects unknown keys before the handler runs, so the
|
||||
// relink service is never reached for the malformed body.
|
||||
const unknownKey = await request(app)
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({ confirmBootSourceDrift: false, unexpected: true });
|
||||
expect(unknownKey.status).not.toBe(200);
|
||||
expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates the drift conflict from the relink service", async () => {
|
||||
mockEnvironmentCustomImageService.relinkActiveTemplate.mockImplementationOnce(async () => {
|
||||
throw new HttpError(409, "Confirm the relink to keep the captured snapshot.", {
|
||||
classification: "boot_source_drift",
|
||||
driftedPaths: [{ path: "image", from: "fake:base", to: "fake:other" }],
|
||||
});
|
||||
});
|
||||
|
||||
const res = await request(createApp(boardActor()))
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("denies agent API key actors before the relink service is called", async () => {
|
||||
const res = await request(createApp(agentActor()))
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockEnvironmentCustomImageService.relinkActiveTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies non-admin board users before the relink service is called", async () => {
|
||||
const res = await request(createApp(boardActor({
|
||||
companyIds: ["company-2"],
|
||||
isInstanceAdmin: false,
|
||||
})))
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockEnvironmentCustomImageService.relinkActiveTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
companies,
|
||||
createDb,
|
||||
environmentCustomImageSetupSessions,
|
||||
@@ -866,3 +867,501 @@ describeEmbeddedPostgres("environmentCustomImageService reconciliation", () => {
|
||||
expect(outOfSync.activeTemplateMatchesConfig).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function secretPluginManifest() {
|
||||
return {
|
||||
id: "paperclip.fake-secret-sandbox-provider",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Fake Secret Sandbox Provider",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "fake-secret-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake Secret Sandbox Provider",
|
||||
supportsInteractiveSetup: true,
|
||||
interactiveSetupConnectionTypes: ["ssh"],
|
||||
supportsTemplateCapture: true,
|
||||
templateRefKind: "snapshot",
|
||||
templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] },
|
||||
// apiUrl overlaps a secret exactly; auth.token is a child of secret
|
||||
// `auth`; credentials is a parent of secret `credentials.secret`.
|
||||
templateIdentityPaths: ["apiUrl", "auth.token", "credentials"],
|
||||
supportsTemplateDelete: true,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
apiUrl: { type: "string", format: "secret-ref" },
|
||||
auth: { type: "string", format: "secret-ref" },
|
||||
credentials: {
|
||||
type: "object",
|
||||
properties: { secret: { type: "string", format: "secret-ref" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
}
|
||||
|
||||
function invalidIdentityPluginManifest() {
|
||||
return {
|
||||
id: "paperclip.fake-invalid-sandbox-provider",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Fake Invalid Identity Sandbox Provider",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "fake-invalid-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake Invalid Identity Sandbox Provider",
|
||||
supportsInteractiveSetup: true,
|
||||
interactiveSetupConnectionTypes: ["ssh"],
|
||||
supportsTemplateCapture: true,
|
||||
templateRefKind: "snapshot",
|
||||
templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] },
|
||||
// A valid path plus two that must fail canonicalization.
|
||||
templateIdentityPaths: ["apiUrl", "bad path!", "a..b"],
|
||||
supportsTemplateDelete: true,
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
}
|
||||
|
||||
function prototypeKeyIdentityPluginManifest() {
|
||||
return {
|
||||
id: "paperclip.fake-prototype-key-sandbox-provider",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Fake Prototype Key Sandbox Provider",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "fake-prototype-key-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake Prototype Key Sandbox Provider",
|
||||
supportsInteractiveSetup: true,
|
||||
interactiveSetupConnectionTypes: ["ssh"],
|
||||
supportsTemplateCapture: true,
|
||||
templateRefKind: "snapshot",
|
||||
templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] },
|
||||
// Every prototype key has an identifier shape but names no own field.
|
||||
// Each must fail canonicalization and record only the marker.
|
||||
templateIdentityPaths: ["__proto__", "constructor", "prototype", "nested.__proto__"],
|
||||
supportsTemplateDelete: true,
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("environmentCustomImageService relink", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("environment-custom-images-relink");
|
||||
stopDb = started.stop;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(environmentCustomImageSetupSessions);
|
||||
await db.delete(environmentCustomImageTemplates);
|
||||
await db.delete(plugins);
|
||||
await db.delete(environments);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
});
|
||||
|
||||
async function seed(opts?: {
|
||||
manifest?: ReturnType<typeof pluginManifest> | ReturnType<typeof secretPluginManifest> | ReturnType<typeof invalidIdentityPluginManifest> | ReturnType<typeof prototypeKeyIdentityPluginManifest>;
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
const manifest = opts?.manifest ?? pluginManifest();
|
||||
const provider = manifest.environmentDrivers[0]!.driverKey;
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
await db.insert(companies).values(
|
||||
{ id: companyId, name: "Acme", issuePrefix: `A${companyId.slice(0, 4)}` },
|
||||
);
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: `Fake ${environmentId.slice(0, 8)}`,
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: opts?.config ?? { provider, image: "fake:base", reuseLease: false },
|
||||
envVars: {},
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
pluginKey: manifest.id,
|
||||
packageName: `paperclip-plugin-${provider}`,
|
||||
version: "0.1.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
manifestJson: manifest,
|
||||
status: "ready",
|
||||
});
|
||||
return { companyId, environmentId, provider };
|
||||
}
|
||||
|
||||
const relinkActor = {
|
||||
actorType: "user" as const,
|
||||
actorId: "user-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
agentApiKeyId: null,
|
||||
};
|
||||
|
||||
async function activeTemplateRow(environmentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(environmentCustomImageTemplates)
|
||||
.where(eq(environmentCustomImageTemplates.environmentId, environmentId))
|
||||
.orderBy(desc(environmentCustomImageTemplates.createdAt))
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function relinkActivityRows(environmentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, environmentId));
|
||||
}
|
||||
|
||||
it("keeps secret-ref values out of metadata, the response, and activity, and relinks with the flag", 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 { companyId, 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 });
|
||||
|
||||
// The public template response never carries the server-internal snapshot.
|
||||
expect((promoted.template.metadata as Record<string, unknown>).bootRelevantConfig).toBeUndefined();
|
||||
|
||||
// The persisted snapshot lives in the row. Every secret-ref overlap is
|
||||
// excluded with no value (exact, child, parent).
|
||||
const persistedRow = await activeTemplateRow(environmentId);
|
||||
const boot = (persistedRow.metadata as Record<string, unknown>).bootRelevantConfig as {
|
||||
values: Record<string, unknown>;
|
||||
excludedPaths: string[];
|
||||
};
|
||||
expect(boot.excludedPaths).toEqual(expect.arrayContaining(["apiUrl", "auth.token", "credentials"]));
|
||||
expect(Object.keys(boot.values)).not.toContain("apiUrl");
|
||||
expect(Object.keys(boot.values)).not.toContain("auth.token");
|
||||
expect(Object.keys(boot.values)).not.toContain("credentials");
|
||||
// Neither the persisted snapshot nor the response leaks a secret value.
|
||||
for (const metadataJson of [
|
||||
JSON.stringify(persistedRow.metadata),
|
||||
JSON.stringify(promoted.template.metadata),
|
||||
]) {
|
||||
expect(metadataJson).not.toContain("secret-endpoint.example");
|
||||
expect(metadataJson).not.toContain("auth-secret-value");
|
||||
expect(metadataJson).not.toContain("cred-secret-value");
|
||||
}
|
||||
|
||||
// An excluded path forces the fail-closed unclassified result: the flag is required.
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("unclassified");
|
||||
const responseJson = JSON.stringify(relinked);
|
||||
expect(responseJson).not.toContain("secret-endpoint.example");
|
||||
expect(responseJson).not.toContain("auth-secret-value");
|
||||
expect(responseJson).not.toContain("cred-secret-value");
|
||||
|
||||
const activities = await relinkActivityRows(environmentId);
|
||||
const relinkActivity = activities.find((row) => row.action === "environment.custom_image_template.relinked");
|
||||
expect(relinkActivity).toBeDefined();
|
||||
const activityJson = JSON.stringify(relinkActivity!.details);
|
||||
expect(activityJson).not.toContain("secret-endpoint.example");
|
||||
expect(activityJson).not.toContain("auth-secret-value");
|
||||
expect(activityJson).not.toContain("cred-secret-value");
|
||||
// Drift detail carries path names only, never a fingerprint value.
|
||||
expect(activityJson).not.toContain(promoted.template.sourceEnvironmentConfigFingerprint);
|
||||
});
|
||||
|
||||
it("fails closed for legacy templates without a boot-relevant snapshot", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
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"] } },
|
||||
});
|
||||
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
const beforeRow = await activeTemplateRow(environmentId);
|
||||
expect(beforeRow.sourceEnvironmentConfigFingerprint).toBe("stale-fingerprint");
|
||||
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("unclassified");
|
||||
const afterRow = await activeTemplateRow(environmentId);
|
||||
expect(afterRow.sourceEnvironmentConfigFingerprint).not.toBe("stale-fingerprint");
|
||||
});
|
||||
|
||||
it("relinks a knob-only change without a flag and keeps the fail-closed runtime gate", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
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 non-boot field changes the fingerprint and detaches the template.
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
const detached = await service.getOverview({ environmentId });
|
||||
expect(detached.activeTemplateMatchesConfig).toBe(false);
|
||||
|
||||
const relinked = await service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId });
|
||||
expect(relinked.classification).toBe("knob_only");
|
||||
const afterRelink = await service.getOverview({ environmentId });
|
||||
expect(afterRelink.activeTemplateMatchesConfig).toBe(true);
|
||||
const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
|
||||
id: environmentId,
|
||||
driver: "sandbox",
|
||||
config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" },
|
||||
}, { heartbeatRunId: randomUUID() });
|
||||
expect(resolved.config).toMatchObject({ customTemplate: promoted.template.templateRef });
|
||||
|
||||
// The gate stays fail-closed: a base image change detaches again.
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false, region: "eu" } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
const afterImageChange = await service.getOverview({ environmentId });
|
||||
expect(afterImageChange.activeTemplateMatchesConfig).toBe(false);
|
||||
});
|
||||
|
||||
it("requires the flag for boot-source drift and re-stamps only after confirmation", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
|
||||
await service.finishSetupSession({ sessionId: started.session.id });
|
||||
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
classification: "boot_source_drift",
|
||||
driftedPaths: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "image", from: "fake:base", to: "fake:other" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("boot_source_drift");
|
||||
const afterRelink = await service.getOverview({ environmentId });
|
||||
expect(afterRelink.activeTemplateMatchesConfig).toBe(true);
|
||||
});
|
||||
|
||||
it("re-stamps only the active template and never a superseded one", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
const first = await service.finishSetupSession({
|
||||
sessionId: (await service.startSetupSession({ environmentId, actor: { userId: "user-1" } })).session.id,
|
||||
});
|
||||
const second = await service.finishSetupSession({
|
||||
sessionId: (await service.startSetupSession({ environmentId, actor: { userId: "user-1" } })).session.id,
|
||||
});
|
||||
// The first template is now superseded; the second is active.
|
||||
const supersededBefore = await db
|
||||
.select()
|
||||
.from(environmentCustomImageTemplates)
|
||||
.where(eq(environmentCustomImageTemplates.id, first.template.id))
|
||||
.then((rows) => rows[0]!);
|
||||
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
const relinked = await service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId });
|
||||
expect(relinked.template.id).toBe(second.template.id);
|
||||
|
||||
const supersededAfter = await db
|
||||
.select()
|
||||
.from(environmentCustomImageTemplates)
|
||||
.where(eq(environmentCustomImageTemplates.id, first.template.id))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(supersededAfter.status).toBe("superseded");
|
||||
expect(supersededAfter.sourceEnvironmentConfigFingerprint)
|
||||
.toBe(supersededBefore.sourceEnvironmentConfigFingerprint);
|
||||
});
|
||||
|
||||
it("aborts with a conflict and writes no active template when none exists", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 404 });
|
||||
const activities = await relinkActivityRows(environmentId);
|
||||
expect(activities.filter((row) => row.action === "environment.custom_image_template.relinked")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("classifies an invalid driver identity path as unclassified without treating it as value-bearing", async () => {
|
||||
const { companyId, environmentId } = await seed({ manifest: invalidIdentityPluginManifest(), config: {
|
||||
provider: "fake-invalid-plugin",
|
||||
image: "fake:base",
|
||||
reuseLease: false,
|
||||
} });
|
||||
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 });
|
||||
|
||||
expect((promoted.template.metadata as Record<string, unknown>).bootRelevantConfig).toBeUndefined();
|
||||
const persistedRow = await activeTemplateRow(environmentId);
|
||||
const boot = (persistedRow.metadata as Record<string, unknown>).bootRelevantConfig as {
|
||||
values: Record<string, unknown>;
|
||||
excludedPaths: string[];
|
||||
};
|
||||
expect(boot.excludedPaths).toContain("[unresolved-identity-path]");
|
||||
// The raw invalid paths are never persisted and never value-bearing.
|
||||
const metadataJson = JSON.stringify(persistedRow.metadata);
|
||||
expect(metadataJson).not.toContain("bad path!");
|
||||
expect(metadataJson).not.toContain("a..b");
|
||||
expect(Object.keys(boot.values)).not.toContain("bad path!");
|
||||
expect(Object.keys(boot.values)).not.toContain("a..b");
|
||||
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("unclassified");
|
||||
});
|
||||
|
||||
it("fails closed for prototype-key driver identity paths and never persists them", async () => {
|
||||
const { companyId, environmentId } = await seed({ manifest: prototypeKeyIdentityPluginManifest(), config: {
|
||||
provider: "fake-prototype-key-plugin",
|
||||
image: "fake:base",
|
||||
reuseLease: false,
|
||||
} });
|
||||
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 });
|
||||
|
||||
expect((promoted.template.metadata as Record<string, unknown>).bootRelevantConfig).toBeUndefined();
|
||||
const persistedRow = await activeTemplateRow(environmentId);
|
||||
const boot = (persistedRow.metadata as Record<string, unknown>).bootRelevantConfig as {
|
||||
values: Record<string, unknown>;
|
||||
excludedPaths: string[];
|
||||
};
|
||||
// Every prototype key fails canonicalization and records only the marker.
|
||||
expect(boot.excludedPaths).toContain("[unresolved-identity-path]");
|
||||
// No raw prototype-key path and no value for one persists in the snapshot.
|
||||
expect(boot.excludedPaths).not.toContain("__proto__");
|
||||
expect(boot.excludedPaths).not.toContain("constructor");
|
||||
expect(boot.excludedPaths).not.toContain("prototype");
|
||||
expect(Object.keys(boot.values)).not.toContain("__proto__");
|
||||
expect(Object.keys(boot.values)).not.toContain("constructor");
|
||||
expect(Object.keys(boot.values)).not.toContain("prototype");
|
||||
// The prototype of the values map stays clean; no key leaked onto it.
|
||||
expect(Object.getPrototypeOf(boot.values)).toBe(Object.prototype);
|
||||
const metadataJson = JSON.stringify(persistedRow.metadata);
|
||||
expect(metadataJson).not.toContain("__proto__");
|
||||
expect(metadataJson).not.toContain("nested.__proto__");
|
||||
|
||||
// An unflagged relink fails closed with 409 and the unclassified class.
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 409, details: { classification: "unclassified" } });
|
||||
// A confirmed relink succeeds and writes the relinked activity entry.
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("unclassified");
|
||||
const activities = await relinkActivityRows(environmentId);
|
||||
const relinkActivity = activities.find((row) => row.action === "environment.custom_image_template.relinked");
|
||||
expect(relinkActivity).toBeDefined();
|
||||
});
|
||||
|
||||
it("fails closed when the provider adds a boot-relevant identity path after capture", async () => {
|
||||
const { companyId, environmentId } = await seed({ config: {
|
||||
provider: "fake-plugin",
|
||||
image: "fake:base",
|
||||
region: "us",
|
||||
reuseLease: false,
|
||||
} });
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
|
||||
await service.finishSetupSession({ sessionId: started.session.id });
|
||||
|
||||
// A knob-only region change detaches the template. On its own it would
|
||||
// relink without confirmation.
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:base", region: "eu", reuseLease: false } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
|
||||
// The provider now declares `region` a boot-relevant identity path. The
|
||||
// capture snapshot never covered it, so the server cannot verify the boot
|
||||
// source and must not classify the drift as knob-only.
|
||||
const manifest = pluginManifest();
|
||||
const nextManifest = {
|
||||
...manifest,
|
||||
environmentDrivers: [
|
||||
{ ...manifest.environmentDrivers[0], templateIdentityPaths: ["apiUrl", "region"] },
|
||||
],
|
||||
};
|
||||
await db.update(plugins)
|
||||
.set({ manifestJson: nextManifest })
|
||||
.where(eq(plugins.pluginKey, manifest.id));
|
||||
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 409, details: { classification: "unclassified" } });
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("unclassified");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user