mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +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
@@ -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,
|
||||
|
||||
@@ -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 } } });
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user