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:
Nicky LeachandPaperclip authored and GitHub committed 2026-08-18 11:43:39 -07:00
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");
});
});