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 Leach 2026-08-18 11:43:39 -07:00 committed by GitHub
parent fe803bedf1
commit 120ae5428f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1393 additions and 15 deletions

View File

@ -2438,6 +2438,7 @@ export {
startEnvironmentCustomImageSetupSessionSchema,
finishEnvironmentCustomImageSetupSessionSchema,
cancelEnvironmentCustomImageSetupSessionSchema,
relinkEnvironmentCustomImageTemplateSchema,
createEnvironmentCustomImageTerminalSessionTokenSchema,
environmentCustomImageTerminalSessionTokenSchema,
type EnvironmentCustomImageSetupConnectionSummary,
@ -2446,6 +2447,7 @@ export {
type StartEnvironmentCustomImageSetupSession,
type FinishEnvironmentCustomImageSetupSession,
type CancelEnvironmentCustomImageSetupSession,
type RelinkEnvironmentCustomImageTemplate,
type CreateEnvironmentCustomImageTerminalSessionToken,
type EnvironmentCustomImageTerminalSessionToken,
} from "./validators/environment-custom-images.js";

View File

@ -94,6 +94,12 @@ export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({
export type CancelEnvironmentCustomImageSetupSession =
z.infer<typeof cancelEnvironmentCustomImageSetupSessionSchema>;
export const relinkEnvironmentCustomImageTemplateSchema = z.object({
confirmBootSourceDrift: z.boolean().optional().default(false),
}).strict();
export type RelinkEnvironmentCustomImageTemplate =
z.infer<typeof relinkEnvironmentCustomImageTemplateSchema>;
export const createEnvironmentCustomImageTerminalSessionTokenSchema = z.object({}).strict().default({});
export type CreateEnvironmentCustomImageTerminalSessionToken =
z.infer<typeof createEnvironmentCustomImageTerminalSessionTokenSchema>;

View File

@ -89,6 +89,7 @@ export {
startEnvironmentCustomImageSetupSessionSchema,
finishEnvironmentCustomImageSetupSessionSchema,
cancelEnvironmentCustomImageSetupSessionSchema,
relinkEnvironmentCustomImageTemplateSchema,
createEnvironmentCustomImageTerminalSessionTokenSchema,
environmentCustomImageTerminalSessionTokenSchema,
type EnvironmentCustomImageSetupConnectionSummary,
@ -97,6 +98,7 @@ export {
type StartEnvironmentCustomImageSetupSession,
type FinishEnvironmentCustomImageSetupSession,
type CancelEnvironmentCustomImageSetupSession,
type RelinkEnvironmentCustomImageTemplate,
type CreateEnvironmentCustomImageTerminalSessionToken,
type EnvironmentCustomImageTerminalSessionToken,
} from "./environment-custom-images.js";

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import {
resolveDeclaredSandboxCapabilities,
redactEnvironmentCustomImageSetupSession,
redactEnvironmentCustomImageTemplate,
relinkEnvironmentCustomImageTemplateSchema,
startEnvironmentCustomImageSetupSessionSchema,
type EnvironmentDeleteBlastRadius,
updateEnvironmentSchema,
@ -952,6 +953,31 @@ export function environmentRoutes(
res.json(result);
});
router.post(
"/environments/:environmentId/custom-image-template/relink",
validate(relinkEnvironmentCustomImageTemplateSchema),
async (req, res) => {
assertCanAccessInstanceEnvironments(req);
const companyId = await resolveCustomImageCompanyId(req);
const actor = getActorInfo(req);
// The service classifies drift, re-stamps the fingerprint, and writes the
// activity row in one transaction. The route never classifies.
const result = await customImages.relinkActiveTemplate({
environmentId: req.params.environmentId as string,
confirmBootSourceDrift: req.body.confirmBootSourceDrift === true,
actor: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
},
companyId,
});
res.json(result);
},
);
router.delete("/environments/:environmentId/custom-image-template", async (req, res) => {
assertCanAccessInstanceEnvironments(req);
const companyId = await resolveCustomImageCompanyId(req);

View File

@ -112,6 +112,7 @@ import {
environmentCustomImageTerminalSessionTokenSchema,
environmentCustomImageTemplateSchema,
finishEnvironmentCustomImageSetupSessionSchema,
relinkEnvironmentCustomImageTemplateSchema,
updateEnvironmentSchema,
probeEnvironmentConfigSchema,
startEnvironmentCustomImageSetupSessionSchema,
@ -635,6 +636,11 @@ const environmentCustomImageTemplateRollbackResultSchema = z.object({
supersededTemplate: environmentCustomImageTemplateSchema,
}).strict();
const environmentCustomImageTemplateRelinkResultSchema = z.object({
template: environmentCustomImageTemplateSchema,
classification: z.enum(["knob_only", "boot_source_drift", "unclassified"]),
}).strict();
const workTimelineQuerySchema = z.object({
from: z.string().optional(),
to: z.string().optional(),
@ -5585,6 +5591,26 @@ registry.registerPath({
},
});
registry.registerPath({
method: "post",
path: "/api/environments/{environmentId}/custom-image-template/relink",
tags: ["environments"],
summary: "Relink a detached environment customImage template to the current config",
request: {
params: z.object({ environmentId: z.string() }),
query: environmentCustomImageCompanyQuerySchema,
body: jsonBody(relinkEnvironmentCustomImageTemplateSchema),
},
responses: {
200: r.ok(environmentCustomImageTemplateRelinkResultSchema),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
},
});
registry.registerPath({
method: "delete",
path: "/api/environments/{environmentId}/custom-image-template",

View File

@ -219,6 +219,313 @@ export function classifyEnvironmentCustomImageConfigChange(input: {
return "relinkable";
}
// --- Operator relink action (server-owned boot-source classification) --------
//
// A saved config change moves the fingerprint and detaches an otherwise-valid
// template. The relink action re-stamps the fingerprint, but only after the
// server decides the boot source did not change. To decide that without the
// capture-time config, the capture persists a small, server-owned snapshot of
// the boot-relevant fields. The relink then compares that snapshot to the
// current config. The comparison runs on the server; the client never
// classifies.
export const ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY = "bootRelevantConfig";
export const ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION = 1;
// A driver-declared identity path that fails canonicalization is never trusted
// and never persisted verbatim. It records this fixed marker instead, which
// forces the fail-closed `unclassified` result at relink time.
export const ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_UNRESOLVED_PATH = "[unresolved-identity-path]";
const BOOT_RELEVANT_CONFIG_MAX_PATH_DEPTH = 8;
const BOOT_RELEVANT_CONFIG_PATH_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
// Prototype keys have an identifier shape but never name an own config field.
// A driver that declares one is hostile or broken. Reject it so the path fails
// closed as an unresolved identity path.
const BOOT_RELEVANT_CONFIG_RESERVED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
export type EnvironmentCustomImageRelinkClassification =
| "knob_only"
| "boot_source_drift"
| "unclassified";
/**
* Server-owned snapshot of the boot-relevant config fields at capture time. It
* carries the field values that decide the boot source, with every secret-ref
* path and every unresolvable driver path excluded (path name only, no value).
*/
export interface EnvironmentCustomImageBootRelevantConfig {
version: typeof ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION;
provider: string;
bindingField: string;
values: Record<string, unknown>;
excludedPaths: string[];
}
export interface EnvironmentCustomImageDriftedPath {
path: string;
from?: unknown;
to?: unknown;
}
/**
* Canonicalizes a config path as a bounded dot-path. Each segment must be a
* plain identifier and the depth is bounded. An invalid, ambiguous, or
* unresolvable path returns `null`; the caller must never persist the raw
* driver-supplied string.
*/
export function canonicalizeEnvironmentCustomImageConfigPath(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const segments = trimmed.split(".");
if (segments.length === 0 || segments.length > BOOT_RELEVANT_CONFIG_MAX_PATH_DEPTH) return null;
for (const segment of segments) {
if (!BOOT_RELEVANT_CONFIG_PATH_SEGMENT_RE.test(segment)) return null;
if (BOOT_RELEVANT_CONFIG_RESERVED_SEGMENTS.has(segment)) return null;
}
return segments.join(".");
}
/**
* Reports whether a candidate path overlaps any secret-ref path by exact,
* ancestor, or descendant containment.
*/
function environmentCustomImageConfigPathOverlapsSecret(
candidate: string,
secretPaths: Iterable<string>,
): boolean {
for (const raw of secretPaths) {
const secret = canonicalizeEnvironmentCustomImageConfigPath(raw);
if (!secret) continue;
if (candidate === secret) return true;
if (candidate.startsWith(`${secret}.`)) return true;
if (secret.startsWith(`${candidate}.`)) return true;
}
return false;
}
/**
* The current boot-relevant path contract a provider driver declares. It names
* the config binding and the driver identity paths that decide the boot source
* now. A relink compares a persisted snapshot against this contract to detect a
* provider change since capture.
*/
export interface EnvironmentCustomImageBootRelevantContract {
binding: EnvironmentCustomImageRuntimeConfigBinding;
templateIdentityPaths?: Iterable<string>;
}
/**
* Computes the canonical candidate boot-relevant paths for a binding and its
* driver identity paths. Both the capture snapshot and the relink staleness
* check use this function, so they share one path set. `hasUnresolved` is true
* when any raw path fails canonicalization.
*/
export function environmentCustomImageBootRelevantCandidatePaths(
contract: EnvironmentCustomImageBootRelevantContract,
): { canonicalPaths: string[]; hasUnresolved: boolean } {
const canonicalPaths: string[] = [];
const seen = new Set<string>();
let hasUnresolved = false;
const rawCandidatePaths = [
contract.binding.field,
...contract.binding.unsetFields,
...ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS,
...(contract.templateIdentityPaths ?? []),
];
for (const rawPath of rawCandidatePaths) {
const canonical = canonicalizeEnvironmentCustomImageConfigPath(rawPath);
if (!canonical) {
hasUnresolved = true;
continue;
}
if (seen.has(canonical)) continue;
seen.add(canonical);
canonicalPaths.push(canonical);
}
return { canonicalPaths, hasUnresolved };
}
/**
* Builds the capture-time boot-relevant snapshot from the parsed config only.
* Candidate paths are the runtime binding field, the binding unset fields, the
* standard boot-source fields, and every driver identity path. A secret-ref
* overlap or an unresolvable path is recorded in `excludedPaths` with no value.
*/
export function buildEnvironmentCustomImageBootRelevantConfig(input: {
config: SandboxEnvironmentConfig;
binding: EnvironmentCustomImageRuntimeConfigBinding;
templateIdentityPaths?: Iterable<string>;
secretRefExcludePaths?: Iterable<string>;
}): EnvironmentCustomImageBootRelevantConfig {
const config = input.config as Record<string, unknown>;
const secretPaths = [...(input.secretRefExcludePaths ?? [])];
// A null-prototype map is a second guard: a reserved segment assignment can
// never mutate the prototype, so a hostile path cannot vanish from `values`.
const values: Record<string, unknown> = Object.create(null);
const excludedPaths = new Set<string>();
const { canonicalPaths, hasUnresolved } = environmentCustomImageBootRelevantCandidatePaths({
binding: input.binding,
templateIdentityPaths: input.templateIdentityPaths,
});
if (hasUnresolved) {
excludedPaths.add(ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_UNRESOLVED_PATH);
}
for (const canonical of canonicalPaths) {
if (environmentCustomImageConfigPathOverlapsSecret(canonical, secretPaths)) {
excludedPaths.add(canonical);
continue;
}
const value = readConfigValueAtPath(config, canonical);
// An absent field is stored as `null` so that "absent then, absent now"
// compares equal at relink time.
values[canonical] = value === undefined ? null : value;
}
return {
version: ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION,
provider: input.config.provider,
bindingField: input.binding.field,
values,
excludedPaths: [...excludedPaths],
};
}
/**
* Reports whether a persisted snapshot no longer matches the current provider
* boot-relevant contract. A provider plugin can change its config binding or
* its identity paths after capture. When it does, a field that is boot-relevant
* now but absent from the snapshot would compare equal by absence and hide real
* drift. The check fails closed on:
*
* - a binding field that differs from the captured `bindingField`;
* - a current identity path the snapshot never covered (neither a value nor an
* excluded path);
* - a current contract that itself carries an unresolvable identity path.
*/
export function environmentCustomImageBootRelevantSnapshotIsStale(input: {
bootRelevantConfig: EnvironmentCustomImageBootRelevantConfig;
currentContract: EnvironmentCustomImageBootRelevantContract;
}): boolean {
const boot = input.bootRelevantConfig;
if (input.currentContract.binding.field !== boot.bindingField) return true;
const { canonicalPaths, hasUnresolved } = environmentCustomImageBootRelevantCandidatePaths(
input.currentContract,
);
if (hasUnresolved) return true;
const covered = new Set<string>([
...Object.keys(boot.values),
...boot.excludedPaths,
]);
return canonicalPaths.some((path) => !covered.has(path));
}
/**
* Reads and validates the persisted boot-relevant snapshot. Returns `null` for
* a legacy template with no snapshot or for a malformed shape; both classify as
* `unclassified` (fail closed).
*/
export function readEnvironmentCustomImageBootRelevantConfig(
metadata: Record<string, unknown> | null | undefined,
): EnvironmentCustomImageBootRelevantConfig | null {
const raw = metadata?.[ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY];
if (!isRecord(raw)) return null;
if (raw.version !== ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION) return null;
if (typeof raw.provider !== "string" || typeof raw.bindingField !== "string") return null;
if (!isRecord(raw.values)) return null;
if (!Array.isArray(raw.excludedPaths)) return null;
const excludedPaths = raw.excludedPaths.filter(
(entry): entry is string => typeof entry === "string",
);
return {
version: ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION,
provider: raw.provider,
bindingField: raw.bindingField,
values: raw.values,
excludedPaths,
};
}
/**
* Classifies the drift between the capture-time boot-relevant snapshot and the
* current parsed config.
*
* - `knob_only`: the snapshot is present, no path was excluded, and every value
* still matches. The fingerprint can be re-stamped without confirmation.
* - `boot_source_drift`: a value-bearing path differs. The operator must
* confirm before the re-stamp.
* - `unclassified`: no snapshot, an excluded path, a provider mismatch, or a
* snapshot that no longer matches the current provider contract. The server
* cannot verify the boot source; the operator must confirm (fail closed).
*
* `driftedPaths` carries raw `from`/`to` values only for value-bearing paths
* that passed containment at capture. Excluded paths carry the path name only.
*
* `currentContract` is the current provider boot-relevant contract. When the
* caller cannot resolve the driver, it passes `null`; the server then cannot
* verify the boot source and the result is `unclassified` (fail closed).
*/
export function classifyEnvironmentCustomImageBootRelevantDrift(input: {
bootRelevantConfig: EnvironmentCustomImageBootRelevantConfig | null;
currentConfig: SandboxEnvironmentConfig;
currentContract: EnvironmentCustomImageBootRelevantContract | null;
}): {
classification: EnvironmentCustomImageRelinkClassification;
driftedPaths: EnvironmentCustomImageDriftedPath[];
} {
const boot = input.bootRelevantConfig;
if (!boot) return { classification: "unclassified", driftedPaths: [] };
// Without the current provider contract the boot source cannot be verified
// against a possible identity-path change; fail closed.
if (!input.currentContract) return { classification: "unclassified", driftedPaths: [] };
// A provider change since capture (new binding or new identity path) makes
// the persisted snapshot untrustworthy; fail closed.
if (environmentCustomImageBootRelevantSnapshotIsStale({
bootRelevantConfig: boot,
currentContract: input.currentContract,
})) {
return { classification: "unclassified", driftedPaths: [] };
}
const current = input.currentConfig as Record<string, unknown>;
const driftedPaths: EnvironmentCustomImageDriftedPath[] = [];
let hasValueDrift = false;
for (const [path, capturedValue] of Object.entries(boot.values)) {
const currentValue = readConfigValueAtPath(current, path);
if (stableStringify(capturedValue ?? null) !== stableStringify(currentValue ?? null)) {
hasValueDrift = true;
driftedPaths.push({ path, from: capturedValue ?? null, to: currentValue ?? null });
}
}
for (const path of boot.excludedPaths) {
driftedPaths.push({ path });
}
const providerMismatch = boot.provider !== input.currentConfig.provider;
if (boot.excludedPaths.length > 0 || providerMismatch) {
return { classification: "unclassified", driftedPaths };
}
if (hasValueDrift) {
return { classification: "boot_source_drift", driftedPaths };
}
return { classification: "knob_only", driftedPaths: [] };
}
/**
* Removes the server-only boot-relevant snapshot from a template's metadata.
* The snapshot exists to classify a later relink and holds raw boot-source
* config values. It must never reach an API response. The runtime keeps it in
* the persisted row and reads it straight from the row at relink time.
*/
export function stripInternalEnvironmentCustomImageTemplateMetadata(
metadata: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
if (!metadata) return null;
if (!(ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY in metadata)) return metadata;
const rest = { ...metadata };
delete rest[ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY];
return rest;
}
export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate {
return {
id: row.id,
@ -234,7 +541,9 @@ export function environmentCustomImageTemplateFromRow(row: TemplateRow): Environ
capturedAt: row.capturedAt ?? null,
lastUsedAt: row.lastUsedAt ?? null,
supersededByTemplateId: row.supersededByTemplateId ?? null,
metadata: row.metadata ?? null,
// The boot-relevant snapshot is server-internal; keep it out of every
// template response. The relink path reads it from the persisted row.
metadata: stripInternalEnvironmentCustomImageTemplateMetadata(row.metadata),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};

View File

@ -39,7 +39,11 @@ import {
import { environmentService } from "./environments.js";
import {
ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY,
classifyEnvironmentCustomImageConfigChange,
classifyEnvironmentCustomImageBootRelevantDrift,
buildEnvironmentCustomImageBootRelevantConfig,
readEnvironmentCustomImageBootRelevantConfig,
fingerprintEnvironmentSandboxProviderConfig,
ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY,
defaultEnvironmentCustomImageRuntimeConfigBinding,
@ -47,7 +51,9 @@ import {
normalizeEnvironmentCustomImageRuntimeConfigBinding,
environmentCustomImageTemplateFromRow,
readEnvironmentCustomImageTemplateKind as readTemplateKind,
type EnvironmentCustomImageRelinkClassification,
} from "./environment-custom-image-runtime.js";
import { logActivity } from "./activity-log.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
const ACTIVE_SETUP_STATUSES = ["starting", "waiting_for_user", "capturing"] as const;
@ -230,10 +236,10 @@ function sourceTemplateFromConfig(
return { sourceTemplateRef: null, sourceTemplateKind: null };
}
async function resolveActiveTemplate(
async function resolveActiveTemplateRow(
db: Db,
input: { environmentId: string; provider?: string | null },
): Promise<EnvironmentCustomImageTemplate | null> {
): Promise<typeof environmentCustomImageTemplates.$inferSelect | null> {
const conditions = [
eq(environmentCustomImageTemplates.environmentId, input.environmentId),
eq(environmentCustomImageTemplates.status, "active"),
@ -241,12 +247,19 @@ async function resolveActiveTemplate(
if (input.provider) {
conditions.push(eq(environmentCustomImageTemplates.provider, input.provider));
}
const row = await db
return db
.select()
.from(environmentCustomImageTemplates)
.where(and(...conditions))
.orderBy(desc(environmentCustomImageTemplates.capturedAt), desc(environmentCustomImageTemplates.createdAt))
.then((rows) => rows[0] ?? null);
}
async function resolveActiveTemplate(
db: Db,
input: { environmentId: string; provider?: string | null },
): Promise<EnvironmentCustomImageTemplate | null> {
const row = await resolveActiveTemplateRow(db, input);
return row ? environmentCustomImageTemplateFromRow(row) : null;
}
@ -803,11 +816,14 @@ export function environmentCustomImageService(
const captured = await callProviderCapture({ session, previousTemplate: currentActive });
const environment = await requireEnvironment(session.environmentId);
const parsed = parseEnvironmentDriverConfig(environment);
const captureSecretRefExcludePaths = parsed.driver === "sandbox"
? [...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider)]
: [];
const baseFingerprint = parsed.driver === "sandbox"
? fingerprintEnvironmentSandboxProviderConfig(parsed.config, {
excludePaths: [
...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider),
...captureSecretRefExcludePaths,
],
})
: null;
@ -821,6 +837,32 @@ export function environmentCustomImageService(
templateRefKind: captured.templateKind,
templateConfigBinding: provider.driver.templateConfigBinding,
});
// Server-owned boot-relevant snapshot from the parsed config only. It
// lets a later relink decide, without the capture-time config, whether
// the boot source changed. Secret-ref paths carry no value.
const bootRelevantConfig = parsed.driver === "sandbox"
? buildEnvironmentCustomImageBootRelevantConfig({
config: parsed.config,
binding: runtimeConfigBinding,
templateIdentityPaths: provider.driver.templateIdentityPaths ?? [],
secretRefExcludePaths: captureSecretRefExcludePaths,
})
: null;
const baseTemplateMetadata = normalizeProviderMetadata({
...(captured.metadata ?? {}),
...(input.metadata ? { userMetadata: input.metadata } : {}),
...persistedSetupMetadata(session.metadata),
[ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY]: runtimeConfigBinding,
}) ?? {};
// The boot-relevant snapshot bypasses the provider-metadata redactor:
// its config field names (for example `apiUrl`) would otherwise be
// redacted by key and the relink comparison could never match.
const templateMetadata = bootRelevantConfig
? {
...baseTemplateMetadata,
[ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY]: bootRelevantConfig,
}
: baseTemplateMetadata;
const now = input.now ?? new Date();
const templateRow = await db.transaction(async (tx) => {
const templateId = randomUUID();
@ -851,12 +893,7 @@ export function environmentCustomImageService(
createdByUserId: session.startedByUserId,
createdByAgentId: session.startedByAgentId,
capturedAt: now,
metadata: normalizeProviderMetadata({
...(captured.metadata ?? {}),
...(input.metadata ? { userMetadata: input.metadata } : {}),
...persistedSetupMetadata(session.metadata),
[ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY]: runtimeConfigBinding,
}),
metadata: templateMetadata,
createdAt: now,
updatedAt: now,
})
@ -999,6 +1036,138 @@ export function environmentCustomImageService(
};
},
/**
* Re-stamps the active template's source fingerprint so a detached-but-valid
* template applies again, without a sandbox boot or a new provider snapshot.
* The server classifies the drift between the capture-time boot-relevant
* snapshot and the current config. A `boot_source_drift` or `unclassified`
* result needs the `confirmBootSourceDrift` flag; the client never
* classifies. The re-stamp UPDATE and the activity row run in one
* transaction. Zero updated rows abort with a conflict and write no activity
* row.
*/
relinkActiveTemplate: async (input: {
environmentId: string;
confirmBootSourceDrift?: boolean;
actor: {
actorType: "agent" | "user" | "system" | "plugin";
actorId: string;
agentId?: string | null;
runId?: string | null;
agentApiKeyId?: string | null;
};
companyId: string;
now?: Date;
}): Promise<{
template: EnvironmentCustomImageTemplate;
classification: EnvironmentCustomImageRelinkClassification;
}> => {
const environment = await requireEnvironment(input.environmentId);
const parsed = parseEnvironmentDriverConfig(environment);
if (parsed.driver !== "sandbox") {
throw unprocessable("Environment customImage relink is only supported for sandbox environments.");
}
const activeRow = await resolveActiveTemplateRow(db, {
environmentId: input.environmentId,
provider: parsed.config.provider,
});
if (!activeRow) throw notFound("Active environment customImage template not found");
const active = environmentCustomImageTemplateFromRow(activeRow);
const secretRefExcludePaths = parsed.config.provider === "fake"
? []
: [...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider)];
// 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,
});
const confirmBootSourceDrift = input.confirmBootSourceDrift === true;
if (drift.classification !== "knob_only" && !confirmBootSourceDrift) {
throw conflict(
drift.classification === "boot_source_drift"
? "The base image changed since this template was captured. Confirm the relink to keep the captured snapshot."
: "The server cannot verify the boot source for this template. Confirm the relink to keep the captured snapshot.",
{
classification: drift.classification,
driftedPaths: drift.driftedPaths,
},
);
}
const nextFingerprint = fingerprintEnvironmentSandboxProviderConfig(parsed.config, {
excludePaths: [
...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS,
...secretRefExcludePaths,
],
});
const now = input.now ?? new Date();
// Activity details carry the sanitized classification and canonical path
// names only. They never carry a fingerprint or a config value.
const activityDetails = {
environmentId: input.environmentId,
templateId: active.id,
provider: active.provider,
classification: drift.classification,
driftedPaths: drift.driftedPaths.map((entry) => entry.path),
confirmBootSourceDrift,
};
const row = await db.transaction(async (tx) => {
const updated = await tx
.update(environmentCustomImageTemplates)
.set({ sourceEnvironmentConfigFingerprint: nextFingerprint, updatedAt: now })
.where(and(
eq(environmentCustomImageTemplates.id, active.id),
eq(environmentCustomImageTemplates.status, "active"),
eq(environmentCustomImageTemplates.environmentId, input.environmentId),
eq(environmentCustomImageTemplates.provider, active.provider),
))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) {
throw conflict("Active environment customImage template changed before relink; retry.");
}
await logActivity(tx as unknown as Db, {
companyId: input.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId ?? null,
runId: input.actor.runId ?? null,
agentApiKeyId: input.actor.agentApiKeyId ?? null,
action: "environment.custom_image_template.relinked",
entityType: "environment",
entityId: input.environmentId,
details: activityDetails,
});
return updated;
});
return {
template: environmentCustomImageTemplateFromRow(row),
classification: drift.classification,
};
},
rollbackTemplate: async (input: {
environmentId: string;
now?: Date;

View File

@ -54,6 +54,32 @@ export interface EnvironmentCustomImageRollbackResult {
supersededTemplate: EnvironmentCustomImageTemplate;
}
export type EnvironmentCustomImageRelinkClassification =
| "knob_only"
| "boot_source_drift"
| "unclassified";
export interface EnvironmentCustomImageRelinkResult {
template: EnvironmentCustomImageTemplate;
classification: EnvironmentCustomImageRelinkClassification;
}
export interface EnvironmentCustomImageDriftedPath {
path: string;
from?: unknown;
to?: unknown;
}
/**
* The 409 conflict body a relink returns when the server cannot re-stamp without
* an operator confirmation. `driftedPaths` carries `from`/`to` only for paths
* that passed the secret containment check; excluded paths carry the name only.
*/
export interface EnvironmentCustomImageRelinkConflict {
classification: Exclude<EnvironmentCustomImageRelinkClassification, "knob_only">;
driftedPaths: EnvironmentCustomImageDriftedPath[];
}
function companyIdQuery(companyId: string): string {
return `companyId=${encodeURIComponent(companyId)}`;
}
@ -162,6 +188,15 @@ export const environmentsApi = {
`/environments/${environmentId}/custom-image-template/rollback?${companyIdQuery(companyId)}`,
{},
),
relinkCustomImageTemplate: (
environmentId: string,
companyId: string,
options: { confirmBootSourceDrift?: boolean } = {},
) =>
api.post<EnvironmentCustomImageRelinkResult>(
`/environments/${environmentId}/custom-image-template/relink?${companyIdQuery(companyId)}`,
{ confirmBootSourceDrift: options.confirmBootSourceDrift === true },
),
disableCustomImageTemplate: (
environmentId: string,
companyId: string,

View File

@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CompanyEnvironments } from "./CompanyEnvironments";
import { ApiError } from "@/api/client";
const xtermMocks = vi.hoisted(() => {
class MockTerminal {
@ -138,6 +139,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({
finishCustomImageSetupSession: vi.fn(),
cancelCustomImageSetupSession: vi.fn(),
rollbackCustomImageTemplate: vi.fn(),
relinkCustomImageTemplate: vi.fn(),
disableCustomImageTemplate: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
@ -432,6 +434,10 @@ describe("CompanyEnvironments — test provider button", () => {
activeTemplate: createTemplate({ id: "template-previous" }),
supersededTemplate: createTemplate({ id: "template-current", status: "superseded" }),
});
mockEnvironmentsApi.relinkCustomImageTemplate.mockResolvedValue({
template: createTemplate({ id: "template-relinked" }),
classification: "knob_only",
});
mockEnvironmentsApi.disableCustomImageTemplate.mockResolvedValue(
createTemplate({ status: "revoked" }),
);
@ -1391,6 +1397,136 @@ describe("CompanyEnvironments — test provider button", () => {
});
});
function setupOutOfSyncTemplatePanel() {
mockEnvironmentsApi.list.mockResolvedValue([
{ id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } },
]);
mockEnvironmentsApi.capabilities.mockResolvedValue({
adapters: [],
drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" },
sandboxProviders: {
daytona: {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
supportsInteractiveSetup: true,
interactiveSetupConnectionTypes: ["ssh"],
supportsTemplateCapture: true,
supportsTemplateDelete: true,
displayName: "Daytona",
},
},
});
mockEnvironmentsApi.customImageTemplate.mockResolvedValue({
activeTemplate: createTemplate({ id: "template-active" }),
activeTemplateMatchesConfig: false,
activeSession: null,
latestSession: null,
});
}
it("relinks an out-of-sync template and names both remedies in the copy", async () => {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setupOutOfSyncTemplatePanel();
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("relink this image or capture a new one");
expect(findButton(dialog, "Relink")).toBeTruthy();
});
await act(async () => click(findButton(getEnvironmentFormPage()!, "Relink")));
await waitForAssertion(() => {
expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1");
});
});
it("confirms boot-source drift before re-sending the relink with the flag", async () => {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setupOutOfSyncTemplatePanel();
mockEnvironmentsApi.relinkCustomImageTemplate
.mockRejectedValueOnce(new ApiError("Confirm the relink.", 409, {
error: "Confirm the relink.",
details: {
classification: "boot_source_drift",
driftedPaths: [{ path: "image", from: "fake:base", to: "fake:other" }],
},
}))
.mockResolvedValueOnce({
template: createTemplate({ id: "template-relinked" }),
classification: "boot_source_drift",
});
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
try {
await act(async () => {
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
expect(findButton(getEnvironmentFormPage()!, "Relink")).toBeTruthy();
});
await act(async () => click(findButton(getEnvironmentFormPage()!, "Relink")));
await waitForAssertion(() => {
expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenNthCalledWith(1, "env-1", "company-1");
expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenNthCalledWith(
2,
"env-1",
"company-1",
{ confirmBootSourceDrift: true },
);
});
expect(confirmSpy).toHaveBeenCalledTimes(1);
expect(confirmSpy.mock.calls[0]![0]).toContain("image fake:base -> fake:other");
} finally {
confirmSpy.mockRestore();
}
});
it("does not re-send the relink when the operator declines the confirmation", async () => {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setupOutOfSyncTemplatePanel();
mockEnvironmentsApi.relinkCustomImageTemplate.mockRejectedValueOnce(new ApiError("Cannot verify.", 409, {
error: "Cannot verify.",
details: { classification: "unclassified", driftedPaths: [{ path: "apiUrl" }] },
}));
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
try {
await act(async () => {
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
expect(findButton(getEnvironmentFormPage()!, "Relink")).toBeTruthy();
});
await act(async () => click(findButton(getEnvironmentFormPage()!, "Relink")));
await waitForAssertion(() => {
expect(confirmSpy).toHaveBeenCalledTimes(1);
});
expect(confirmSpy.mock.calls[0]![0]).toContain("cannot verify the boot source");
expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenCalledTimes(1);
expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenCalledWith("env-1", "company-1");
} finally {
confirmSpy.mockRestore();
}
});
it("offers the implicit Local option in the default picker by default", async () => {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

View File

@ -6,7 +6,7 @@ import {
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, Lock, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react";
import { ArrowLeft, Check, Link2, Lock, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal as XTermTerminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
@ -21,9 +21,11 @@ import {
import {
environmentsApi,
type EnvironmentCustomImageConnectionPayload,
type EnvironmentCustomImageRelinkConflict,
type EnvironmentCustomImageSetupSessionResult,
type EnvironmentUpdateResult,
} from "@/api/environments";
import { ApiError } from "@/api/client";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { secretsApi } from "@/api/secrets";
import { Button } from "@/components/ui/button";
@ -761,6 +763,37 @@ function sessionStatusCopy(status: EnvironmentCustomImageSetupSession["status"])
}
}
// The operator declined the drift confirmation prompt. It is not a failure, so
// the relink mutation stays quiet instead of showing an error toast.
class RelinkConfirmationDeclined extends Error {
constructor() {
super("relink confirmation declined");
this.name = "RelinkConfirmationDeclined";
}
}
function formatRelinkDriftValue(value: unknown): string {
if (value === null || value === undefined) return "(none)";
if (typeof value === "string") return value;
return JSON.stringify(value);
}
// Turns the sanitized 409 drift body into the operator warning. Value-bearing
// drift shows the changed field; an unclassified result warns that the snapshot
// will override the current base image.
function relinkDriftWarning(conflict: EnvironmentCustomImageRelinkConflict): string {
if (conflict.classification === "boot_source_drift") {
const valued = conflict.driftedPaths.find(
(entry) => entry.from !== undefined || entry.to !== undefined,
);
if (valued) {
return `The base image changed: ${valued.path} ${formatRelinkDriftValue(valued.from)} -> ${formatRelinkDriftValue(valued.to)}.`;
}
return "The base image changed since this image was captured.";
}
return "The server cannot verify the boot source; the snapshot will override the current base image.";
}
function EnvironmentImageTemplatePanel({
environment,
companyId,
@ -909,6 +942,50 @@ function EnvironmentImageTemplatePanel({
},
});
const relinkTemplateMutation = useMutation({
// The route is called without the flag first. A 409 carries the sanitized
// drift detail; the operator must confirm before the flagged retry.
mutationFn: async () => {
try {
return await environmentsApi.relinkCustomImageTemplate(environment.id, companyId);
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
const conflict = (error.body as { details?: EnvironmentCustomImageRelinkConflict } | null)?.details;
const warning = conflict ? relinkDriftWarning(conflict) : error.message;
if (!window.confirm(`${warning}\n\nRelink this image anyway?`)) {
throw new RelinkConfirmationDeclined();
}
return await environmentsApi.relinkCustomImageTemplate(environment.id, companyId, {
confirmBootSourceDrift: true,
});
}
throw error;
}
},
onSuccess: (result) => {
queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
activeTemplate: result.template,
activeTemplateMatchesConfig: true,
activeSession: current?.activeSession ?? null,
latestSession: current?.latestSession ?? null,
}));
invalidateOverview();
pushToast({
title: "Template relinked",
body: "Runs use the captured image again.",
tone: "success",
});
},
onError: (error) => {
if (error instanceof RelinkConfirmationDeclined) return;
pushToast({
title: "Failed to relink template",
body: error instanceof Error ? error.message : "Relink failed.",
tone: "error",
});
},
});
const disableTemplateMutation = useMutation({
mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id, companyId),
onSuccess: (template) => {
@ -983,6 +1060,7 @@ function EnvironmentImageTemplatePanel({
startSetupMutation.isPending ||
finishSetupMutation.isPending ||
cancelSetupMutation.isPending ||
relinkTemplateMutation.isPending ||
rollbackTemplateMutation.isPending ||
disableTemplateMutation.isPending;
@ -1076,8 +1154,8 @@ function EnvironmentImageTemplatePanel({
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 capture a new
image.
captured. Runs fall back to the base configuration until you relink this
image or capture a new one.
</div>
) : null}
</div>
@ -1091,6 +1169,16 @@ function EnvironmentImageTemplatePanel({
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Refresh
</Button>
<Button
size="sm"
variant="outline"
onClick={() => relinkTemplateMutation.mutate()}
disabled={isMutating}
data-testid={`custom-image-template-relink-${environment.id}`}
>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
Relink
</Button>
<Button
size="sm"
variant="ghost"