mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
feat(server): add a one-click relink action for detached custom-image templates (#11641)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip environments can use captured custom images for agent runs > - A configuration fingerprint change can detach a valid custom-image template > - Operators need a safe way to confirm that the image still matches the boot source > - This pull request adds a guarded relink action with drift classification and audit logging > - The benefit is a deliberate relink without a new sandbox boot or provider snapshot ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting environment, server, and UI behavior. **Problem or motivation** A custom-image template detaches when the environment configuration fingerprint changes. The runtime then uses the base image, even when the boot source did not change. The only prior remedy required a full re-capture. **Proposed solution** Add an operator-triggered relink action. Classify configuration drift from a server-owned boot-relevant snapshot. Relink knob-only drift without confirmation. Require explicit confirmation for boot-source or unclassified drift. Guard the route for instance administrators and record a safe activity event. **Alternatives considered** Keep requiring a full re-capture. This adds a sandbox boot and provider snapshot for cases where the image remains correct. **Roadmap alignment** The roadmap has no matching custom-image relink item. This change addresses an environment operation gap. **Additional context** The relink response exposes raw drift values only in the transient 409 response to the instance administrator. The service never persists or logs fingerprints or configuration values. Reserved identity-path segments fail closed. ## What Changed - Add `relinkActiveTemplate` with drift classification and conditional fingerprint update. - Persist a server-owned boot-relevant configuration snapshot during capture. - Add the guarded relink route with strict request validation and activity logging. - Add the relink action and confirmation flow to the environment page. - Add service, route, UI, and OpenAPI coverage. ## Verification - Run the focused service suite: `pnpm vitest run server/src/services/environment-custom-images-service.test.ts`. - Run the focused route suite: `pnpm vitest run server/src/routes/environment-custom-image-routes.test.ts`. - Run the focused UI suite: `pnpm vitest run ui/src/pages/CompanyEnvironments.test.tsx`. - Run server and UI TypeScript checks. - Confirm the OpenAPI snapshot matches the new route. - Confirm all required GitHub checks pass on commit `e46fdcfe94a719be854adf8849d30714e5b70b93`. - Confirm Greptile reports 5/5 with no unresolved review threads. ## Risks The relink action can keep an image after configuration drift. The service requires explicit confirmation for boot-source or unclassified drift. Reserved path segments produce a safe unresolved marker and never enter stored values. ## Model Used OpenAI GPT-5 Codex. The model used repository inspection, GitHub operations, and PR preparation with tool use and code execution. The runtime did not expose a context-window value or a separate reasoning-mode value. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
fe803bedf1
commit
120ae5428f
12 files changed
+1393
-15
No files matched your search
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user