Files
PaperClipAI/packages/shared/src/validators/environment-custom-images.ts
T
Nicky LeachandPaperclip 120ae5428f 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>
2026-08-18 11:43:39 -07:00

118 lines
5.1 KiB
TypeScript

import { z } from "zod";
import {
ENVIRONMENT_CUSTOM_IMAGE_SETUP_CONNECTION_TYPES,
ENVIRONMENT_CUSTOM_IMAGE_SETUP_SESSION_STATUSES,
ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS,
ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_STATUSES,
} from "../constants.js";
const isoDateTime = z.union([z.date(), z.string().datetime()]);
const providerKeySchema = z.string().min(1).max(200);
const optionalRecordSchema = z.record(z.string(), z.unknown()).optional().nullable();
export const environmentCustomImageTemplateKindSchema = z.enum(ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS);
export const environmentCustomImageTemplateStatusSchema = z.enum(ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_STATUSES);
export const environmentCustomImageSetupSessionStatusSchema = z.enum(
ENVIRONMENT_CUSTOM_IMAGE_SETUP_SESSION_STATUSES,
);
export const environmentCustomImageSetupConnectionTypeSchema = z.enum(
ENVIRONMENT_CUSTOM_IMAGE_SETUP_CONNECTION_TYPES,
);
export const environmentCustomImageSetupConnectionSummarySchema = z.object({
type: environmentCustomImageSetupConnectionTypeSchema,
username: z.string().min(1).max(200).optional().nullable(),
hostRedacted: z.literal(true).optional().default(true),
portRedacted: z.literal(true).optional().default(true),
label: z.string().min(1).max(200).optional().nullable(),
instructions: z.string().min(1).max(1000).optional().nullable(),
}).strict();
export type EnvironmentCustomImageSetupConnectionSummary =
z.infer<typeof environmentCustomImageSetupConnectionSummarySchema>;
export const environmentCustomImageTemplateSchema = z.object({
id: z.string().uuid(),
environmentId: z.string().uuid(),
provider: providerKeySchema,
templateKind: environmentCustomImageTemplateKindSchema,
templateRef: z.string().min(1).nullable(),
sourceTemplateRef: z.string().min(1).nullable(),
sourceEnvironmentConfigFingerprint: z.string().min(1).nullable(),
status: environmentCustomImageTemplateStatusSchema,
createdByUserId: z.string().min(1).nullable(),
createdByAgentId: z.string().uuid().nullable(),
capturedAt: isoDateTime.nullable(),
lastUsedAt: isoDateTime.nullable(),
supersededByTemplateId: z.string().uuid().nullable(),
metadata: optionalRecordSchema,
createdAt: isoDateTime,
updatedAt: isoDateTime,
}).strict();
export type EnvironmentCustomImageTemplate =
z.infer<typeof environmentCustomImageTemplateSchema>;
export const environmentCustomImageSetupSessionSchema = z.object({
id: z.string().uuid(),
environmentId: z.string().uuid(),
templateId: z.string().uuid().nullable(),
promotedTemplateId: z.string().uuid().nullable(),
provider: providerKeySchema,
providerLeaseId: z.string().min(1).nullable(),
environmentLeaseId: z.string().uuid().nullable(),
status: environmentCustomImageSetupSessionStatusSchema,
startedByUserId: z.string().min(1).nullable(),
startedByAgentId: z.string().uuid().nullable(),
baseTemplateRef: z.string().min(1).nullable(),
expiresAt: isoDateTime.nullable(),
finishedAt: isoDateTime.nullable(),
failureReason: z.string().min(1).nullable(),
connectionSummary: environmentCustomImageSetupConnectionSummarySchema.nullable(),
connectionSecretRef: z.string().min(1).nullable(),
metadata: optionalRecordSchema,
createdAt: isoDateTime,
updatedAt: isoDateTime,
}).strict();
export type EnvironmentCustomImageSetupSession =
z.infer<typeof environmentCustomImageSetupSessionSchema>;
export const startEnvironmentCustomImageSetupSessionSchema = z.object({
templateId: z.string().uuid().optional().nullable(),
ttlSeconds: z.number().int().min(60).max(24 * 60 * 60).optional(),
}).strict();
export type StartEnvironmentCustomImageSetupSession =
z.infer<typeof startEnvironmentCustomImageSetupSessionSchema>;
export const finishEnvironmentCustomImageSetupSessionSchema = z.object({
metadata: z.record(z.string(), z.unknown()).optional(),
}).strict();
export type FinishEnvironmentCustomImageSetupSession =
z.infer<typeof finishEnvironmentCustomImageSetupSessionSchema>;
export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({
reason: z.string().min(1).max(1000).optional(),
}).strict();
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>;
export const environmentCustomImageTerminalSessionTokenSchema = z.object({
id: z.string().min(1),
token: z.string().min(32),
expiresAt: isoDateTime,
setupSessionId: z.string().min(1),
environmentId: z.string().min(1),
connectionType: z.literal("ssh"),
websocketPath: z.string().min(1),
}).strict();
export type EnvironmentCustomImageTerminalSessionToken =
z.infer<typeof environmentCustomImageTerminalSessionTokenSchema>;