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

@@ -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,
};
+179 -10
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;