mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## 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>
118 lines
5.1 KiB
TypeScript
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>;
|