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

+35
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,
+136
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 } } });
+91 -3
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"