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:
parent
fe803bedf1
commit
120ae5428f
|
|
@ -2438,6 +2438,7 @@ export {
|
|||
startEnvironmentCustomImageSetupSessionSchema,
|
||||
finishEnvironmentCustomImageSetupSessionSchema,
|
||||
cancelEnvironmentCustomImageSetupSessionSchema,
|
||||
relinkEnvironmentCustomImageTemplateSchema,
|
||||
createEnvironmentCustomImageTerminalSessionTokenSchema,
|
||||
environmentCustomImageTerminalSessionTokenSchema,
|
||||
type EnvironmentCustomImageSetupConnectionSummary,
|
||||
|
|
@ -2446,6 +2447,7 @@ export {
|
|||
type StartEnvironmentCustomImageSetupSession,
|
||||
type FinishEnvironmentCustomImageSetupSession,
|
||||
type CancelEnvironmentCustomImageSetupSession,
|
||||
type RelinkEnvironmentCustomImageTemplate,
|
||||
type CreateEnvironmentCustomImageTerminalSessionToken,
|
||||
type EnvironmentCustomImageTerminalSessionToken,
|
||||
} from "./validators/environment-custom-images.js";
|
||||
|
|
|
|||
|
|
@ -94,6 +94,12 @@ export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({
|
|||
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>;
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export {
|
|||
startEnvironmentCustomImageSetupSessionSchema,
|
||||
finishEnvironmentCustomImageSetupSessionSchema,
|
||||
cancelEnvironmentCustomImageSetupSessionSchema,
|
||||
relinkEnvironmentCustomImageTemplateSchema,
|
||||
createEnvironmentCustomImageTerminalSessionTokenSchema,
|
||||
environmentCustomImageTerminalSessionTokenSchema,
|
||||
type EnvironmentCustomImageSetupConnectionSummary,
|
||||
|
|
@ -97,6 +98,7 @@ export {
|
|||
type StartEnvironmentCustomImageSetupSession,
|
||||
type FinishEnvironmentCustomImageSetupSession,
|
||||
type CancelEnvironmentCustomImageSetupSession,
|
||||
type RelinkEnvironmentCustomImageTemplate,
|
||||
type CreateEnvironmentCustomImageTerminalSessionToken,
|
||||
type EnvironmentCustomImageTerminalSessionToken,
|
||||
} from "./environment-custom-images.js";
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import express from "express";
|
|||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { environmentRoutes } from "../routes/environments.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import {
|
||||
environmentCustomImageTerminalConnectionRegistry,
|
||||
environmentCustomImageTerminalSessionStore,
|
||||
|
|
@ -40,6 +41,7 @@ const mockEnvironmentCustomImageService = vi.hoisted(() => ({
|
|||
finishSetupSession: vi.fn(),
|
||||
cancelSetupSession: vi.fn(),
|
||||
rollbackTemplate: vi.fn(),
|
||||
relinkActiveTemplate: vi.fn(),
|
||||
disableTemplate: vi.fn(),
|
||||
cleanupExpiredSetupSessions: vi.fn(),
|
||||
}));
|
||||
|
|
@ -292,6 +294,10 @@ describe("environment customImage setup routes", () => {
|
|||
templateRef: "disabled-template-secret",
|
||||
status: "revoked",
|
||||
}));
|
||||
mockEnvironmentCustomImageService.relinkActiveTemplate.mockResolvedValue({
|
||||
template: createTemplate({ id: "template-1", templateRef: "relinked-template-secret" }),
|
||||
classification: "knob_only",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a setup session, returns the live payload, and logs redacted details", async () => {
|
||||
|
|
@ -641,4 +647,78 @@ describe("environment customImage setup routes", () => {
|
|||
expect(activity).not.toContain("old-template-secret");
|
||||
expect(activity).not.toContain("disabled-template-secret");
|
||||
});
|
||||
|
||||
it("relinks the active template through the company-scoped route", async () => {
|
||||
const res = await request(createApp(boardActor()))
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({ confirmBootSourceDrift: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.classification).toBe("knob_only");
|
||||
expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledWith({
|
||||
environmentId: "env-1",
|
||||
confirmBootSourceDrift: true,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
agentApiKeyId: null,
|
||||
},
|
||||
companyId: "company-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the confirmation flag to false and rejects unknown body keys", async () => {
|
||||
const app = createApp(boardActor());
|
||||
const withoutFlag = await request(app)
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({});
|
||||
expect(withoutFlag.status).toBe(200);
|
||||
expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ confirmBootSourceDrift: false }),
|
||||
);
|
||||
|
||||
// The strict schema rejects unknown keys before the handler runs, so the
|
||||
// relink service is never reached for the malformed body.
|
||||
const unknownKey = await request(app)
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({ confirmBootSourceDrift: false, unexpected: true });
|
||||
expect(unknownKey.status).not.toBe(200);
|
||||
expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates the drift conflict from the relink service", async () => {
|
||||
mockEnvironmentCustomImageService.relinkActiveTemplate.mockImplementationOnce(async () => {
|
||||
throw new HttpError(409, "Confirm the relink to keep the captured snapshot.", {
|
||||
classification: "boot_source_drift",
|
||||
driftedPaths: [{ path: "image", from: "fake:base", to: "fake:other" }],
|
||||
});
|
||||
});
|
||||
|
||||
const res = await request(createApp(boardActor()))
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("denies agent API key actors before the relink service is called", async () => {
|
||||
const res = await request(createApp(agentActor()))
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockEnvironmentCustomImageService.relinkActiveTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies non-admin board users before the relink service is called", async () => {
|
||||
const res = await request(createApp(boardActor({
|
||||
companyIds: ["company-2"],
|
||||
isInstanceAdmin: false,
|
||||
})))
|
||||
.post("/api/environments/env-1/custom-image-template/relink?companyId=company-1")
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockEnvironmentCustomImageService.relinkActiveTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
companies,
|
||||
createDb,
|
||||
environmentCustomImageSetupSessions,
|
||||
|
|
@ -866,3 +867,501 @@ describeEmbeddedPostgres("environmentCustomImageService reconciliation", () => {
|
|||
expect(outOfSync.activeTemplateMatchesConfig).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function secretPluginManifest() {
|
||||
return {
|
||||
id: "paperclip.fake-secret-sandbox-provider",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Fake Secret Sandbox Provider",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "fake-secret-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake Secret Sandbox Provider",
|
||||
supportsInteractiveSetup: true,
|
||||
interactiveSetupConnectionTypes: ["ssh"],
|
||||
supportsTemplateCapture: true,
|
||||
templateRefKind: "snapshot",
|
||||
templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] },
|
||||
// apiUrl overlaps a secret exactly; auth.token is a child of secret
|
||||
// `auth`; credentials is a parent of secret `credentials.secret`.
|
||||
templateIdentityPaths: ["apiUrl", "auth.token", "credentials"],
|
||||
supportsTemplateDelete: true,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
apiUrl: { type: "string", format: "secret-ref" },
|
||||
auth: { type: "string", format: "secret-ref" },
|
||||
credentials: {
|
||||
type: "object",
|
||||
properties: { secret: { type: "string", format: "secret-ref" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
}
|
||||
|
||||
function invalidIdentityPluginManifest() {
|
||||
return {
|
||||
id: "paperclip.fake-invalid-sandbox-provider",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Fake Invalid Identity Sandbox Provider",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "fake-invalid-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake Invalid Identity Sandbox Provider",
|
||||
supportsInteractiveSetup: true,
|
||||
interactiveSetupConnectionTypes: ["ssh"],
|
||||
supportsTemplateCapture: true,
|
||||
templateRefKind: "snapshot",
|
||||
templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] },
|
||||
// A valid path plus two that must fail canonicalization.
|
||||
templateIdentityPaths: ["apiUrl", "bad path!", "a..b"],
|
||||
supportsTemplateDelete: true,
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
}
|
||||
|
||||
function prototypeKeyIdentityPluginManifest() {
|
||||
return {
|
||||
id: "paperclip.fake-prototype-key-sandbox-provider",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Fake Prototype Key Sandbox Provider",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "fake-prototype-key-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake Prototype Key Sandbox Provider",
|
||||
supportsInteractiveSetup: true,
|
||||
interactiveSetupConnectionTypes: ["ssh"],
|
||||
supportsTemplateCapture: true,
|
||||
templateRefKind: "snapshot",
|
||||
templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] },
|
||||
// Every prototype key has an identifier shape but names no own field.
|
||||
// Each must fail canonicalization and record only the marker.
|
||||
templateIdentityPaths: ["__proto__", "constructor", "prototype", "nested.__proto__"],
|
||||
supportsTemplateDelete: true,
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("environmentCustomImageService relink", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("environment-custom-images-relink");
|
||||
stopDb = started.stop;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(environmentCustomImageSetupSessions);
|
||||
await db.delete(environmentCustomImageTemplates);
|
||||
await db.delete(plugins);
|
||||
await db.delete(environments);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
});
|
||||
|
||||
async function seed(opts?: {
|
||||
manifest?: ReturnType<typeof pluginManifest> | ReturnType<typeof secretPluginManifest> | ReturnType<typeof invalidIdentityPluginManifest> | ReturnType<typeof prototypeKeyIdentityPluginManifest>;
|
||||
config?: Record<string, unknown>;
|
||||
}) {
|
||||
const manifest = opts?.manifest ?? pluginManifest();
|
||||
const provider = manifest.environmentDrivers[0]!.driverKey;
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
await db.insert(companies).values(
|
||||
{ id: companyId, name: "Acme", issuePrefix: `A${companyId.slice(0, 4)}` },
|
||||
);
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: `Fake ${environmentId.slice(0, 8)}`,
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: opts?.config ?? { provider, image: "fake:base", reuseLease: false },
|
||||
envVars: {},
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
pluginKey: manifest.id,
|
||||
packageName: `paperclip-plugin-${provider}`,
|
||||
version: "0.1.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
manifestJson: manifest,
|
||||
status: "ready",
|
||||
});
|
||||
return { companyId, environmentId, provider };
|
||||
}
|
||||
|
||||
const relinkActor = {
|
||||
actorType: "user" as const,
|
||||
actorId: "user-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
agentApiKeyId: null,
|
||||
};
|
||||
|
||||
async function activeTemplateRow(environmentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(environmentCustomImageTemplates)
|
||||
.where(eq(environmentCustomImageTemplates.environmentId, environmentId))
|
||||
.orderBy(desc(environmentCustomImageTemplates.createdAt))
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function relinkActivityRows(environmentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, environmentId));
|
||||
}
|
||||
|
||||
it("keeps secret-ref values out of metadata, the response, and activity, and relinks with the flag", async () => {
|
||||
const config = {
|
||||
provider: "fake-secret-plugin",
|
||||
image: "fake:base",
|
||||
apiUrl: "https://secret-endpoint.example",
|
||||
auth: "auth-secret-value",
|
||||
credentials: { secret: "cred-secret-value", region: "eu" },
|
||||
reuseLease: false,
|
||||
};
|
||||
const { companyId, environmentId } = await seed({ manifest: secretPluginManifest(), config });
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
|
||||
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
|
||||
const promoted = await service.finishSetupSession({ sessionId: started.session.id });
|
||||
|
||||
// The public template response never carries the server-internal snapshot.
|
||||
expect((promoted.template.metadata as Record<string, unknown>).bootRelevantConfig).toBeUndefined();
|
||||
|
||||
// The persisted snapshot lives in the row. Every secret-ref overlap is
|
||||
// excluded with no value (exact, child, parent).
|
||||
const persistedRow = await activeTemplateRow(environmentId);
|
||||
const boot = (persistedRow.metadata as Record<string, unknown>).bootRelevantConfig as {
|
||||
values: Record<string, unknown>;
|
||||
excludedPaths: string[];
|
||||
};
|
||||
expect(boot.excludedPaths).toEqual(expect.arrayContaining(["apiUrl", "auth.token", "credentials"]));
|
||||
expect(Object.keys(boot.values)).not.toContain("apiUrl");
|
||||
expect(Object.keys(boot.values)).not.toContain("auth.token");
|
||||
expect(Object.keys(boot.values)).not.toContain("credentials");
|
||||
// Neither the persisted snapshot nor the response leaks a secret value.
|
||||
for (const metadataJson of [
|
||||
JSON.stringify(persistedRow.metadata),
|
||||
JSON.stringify(promoted.template.metadata),
|
||||
]) {
|
||||
expect(metadataJson).not.toContain("secret-endpoint.example");
|
||||
expect(metadataJson).not.toContain("auth-secret-value");
|
||||
expect(metadataJson).not.toContain("cred-secret-value");
|
||||
}
|
||||
|
||||
// An excluded path forces the fail-closed unclassified result: the flag is required.
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("unclassified");
|
||||
const responseJson = JSON.stringify(relinked);
|
||||
expect(responseJson).not.toContain("secret-endpoint.example");
|
||||
expect(responseJson).not.toContain("auth-secret-value");
|
||||
expect(responseJson).not.toContain("cred-secret-value");
|
||||
|
||||
const activities = await relinkActivityRows(environmentId);
|
||||
const relinkActivity = activities.find((row) => row.action === "environment.custom_image_template.relinked");
|
||||
expect(relinkActivity).toBeDefined();
|
||||
const activityJson = JSON.stringify(relinkActivity!.details);
|
||||
expect(activityJson).not.toContain("secret-endpoint.example");
|
||||
expect(activityJson).not.toContain("auth-secret-value");
|
||||
expect(activityJson).not.toContain("cred-secret-value");
|
||||
// Drift detail carries path names only, never a fingerprint value.
|
||||
expect(activityJson).not.toContain(promoted.template.sourceEnvironmentConfigFingerprint);
|
||||
});
|
||||
|
||||
it("fails closed for legacy templates without a boot-relevant snapshot", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
await db.insert(environmentCustomImageTemplates).values({
|
||||
environmentId,
|
||||
provider: "fake-plugin",
|
||||
templateKind: "snapshot",
|
||||
templateRef: "snapshot-legacy",
|
||||
sourceEnvironmentConfigFingerprint: "stale-fingerprint",
|
||||
status: "active",
|
||||
metadata: { runtimeConfigBinding: { field: "customTemplate", unsetFields: ["image"] } },
|
||||
});
|
||||
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
const beforeRow = await activeTemplateRow(environmentId);
|
||||
expect(beforeRow.sourceEnvironmentConfigFingerprint).toBe("stale-fingerprint");
|
||||
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("unclassified");
|
||||
const afterRow = await activeTemplateRow(environmentId);
|
||||
expect(afterRow.sourceEnvironmentConfigFingerprint).not.toBe("stale-fingerprint");
|
||||
});
|
||||
|
||||
it("relinks a knob-only change without a flag and keeps the fail-closed runtime gate", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
|
||||
const promoted = await service.finishSetupSession({ sessionId: started.session.id });
|
||||
|
||||
// A non-boot field changes the fingerprint and detaches the template.
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
const detached = await service.getOverview({ environmentId });
|
||||
expect(detached.activeTemplateMatchesConfig).toBe(false);
|
||||
|
||||
const relinked = await service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId });
|
||||
expect(relinked.classification).toBe("knob_only");
|
||||
const afterRelink = await service.getOverview({ environmentId });
|
||||
expect(afterRelink.activeTemplateMatchesConfig).toBe(true);
|
||||
const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, {
|
||||
id: environmentId,
|
||||
driver: "sandbox",
|
||||
config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" },
|
||||
}, { heartbeatRunId: randomUUID() });
|
||||
expect(resolved.config).toMatchObject({ customTemplate: promoted.template.templateRef });
|
||||
|
||||
// The gate stays fail-closed: a base image change detaches again.
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false, region: "eu" } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
const afterImageChange = await service.getOverview({ environmentId });
|
||||
expect(afterImageChange.activeTemplateMatchesConfig).toBe(false);
|
||||
});
|
||||
|
||||
it("requires the flag for boot-source drift and re-stamps only after confirmation", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
|
||||
await service.finishSetupSession({ sessionId: started.session.id });
|
||||
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
classification: "boot_source_drift",
|
||||
driftedPaths: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "image", from: "fake:base", to: "fake:other" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("boot_source_drift");
|
||||
const afterRelink = await service.getOverview({ environmentId });
|
||||
expect(afterRelink.activeTemplateMatchesConfig).toBe(true);
|
||||
});
|
||||
|
||||
it("re-stamps only the active template and never a superseded one", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
const first = await service.finishSetupSession({
|
||||
sessionId: (await service.startSetupSession({ environmentId, actor: { userId: "user-1" } })).session.id,
|
||||
});
|
||||
const second = await service.finishSetupSession({
|
||||
sessionId: (await service.startSetupSession({ environmentId, actor: { userId: "user-1" } })).session.id,
|
||||
});
|
||||
// The first template is now superseded; the second is active.
|
||||
const supersededBefore = await db
|
||||
.select()
|
||||
.from(environmentCustomImageTemplates)
|
||||
.where(eq(environmentCustomImageTemplates.id, first.template.id))
|
||||
.then((rows) => rows[0]!);
|
||||
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
const relinked = await service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId });
|
||||
expect(relinked.template.id).toBe(second.template.id);
|
||||
|
||||
const supersededAfter = await db
|
||||
.select()
|
||||
.from(environmentCustomImageTemplates)
|
||||
.where(eq(environmentCustomImageTemplates.id, first.template.id))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(supersededAfter.status).toBe("superseded");
|
||||
expect(supersededAfter.sourceEnvironmentConfigFingerprint)
|
||||
.toBe(supersededBefore.sourceEnvironmentConfigFingerprint);
|
||||
});
|
||||
|
||||
it("aborts with a conflict and writes no active template when none exists", async () => {
|
||||
const { companyId, environmentId } = await seed();
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 404 });
|
||||
const activities = await relinkActivityRows(environmentId);
|
||||
expect(activities.filter((row) => row.action === "environment.custom_image_template.relinked")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("classifies an invalid driver identity path as unclassified without treating it as value-bearing", async () => {
|
||||
const { companyId, environmentId } = await seed({ manifest: invalidIdentityPluginManifest(), config: {
|
||||
provider: "fake-invalid-plugin",
|
||||
image: "fake:base",
|
||||
reuseLease: false,
|
||||
} });
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
|
||||
const promoted = await service.finishSetupSession({ sessionId: started.session.id });
|
||||
|
||||
expect((promoted.template.metadata as Record<string, unknown>).bootRelevantConfig).toBeUndefined();
|
||||
const persistedRow = await activeTemplateRow(environmentId);
|
||||
const boot = (persistedRow.metadata as Record<string, unknown>).bootRelevantConfig as {
|
||||
values: Record<string, unknown>;
|
||||
excludedPaths: string[];
|
||||
};
|
||||
expect(boot.excludedPaths).toContain("[unresolved-identity-path]");
|
||||
// The raw invalid paths are never persisted and never value-bearing.
|
||||
const metadataJson = JSON.stringify(persistedRow.metadata);
|
||||
expect(metadataJson).not.toContain("bad path!");
|
||||
expect(metadataJson).not.toContain("a..b");
|
||||
expect(Object.keys(boot.values)).not.toContain("bad path!");
|
||||
expect(Object.keys(boot.values)).not.toContain("a..b");
|
||||
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("unclassified");
|
||||
});
|
||||
|
||||
it("fails closed for prototype-key driver identity paths and never persists them", async () => {
|
||||
const { companyId, environmentId } = await seed({ manifest: prototypeKeyIdentityPluginManifest(), config: {
|
||||
provider: "fake-prototype-key-plugin",
|
||||
image: "fake:base",
|
||||
reuseLease: false,
|
||||
} });
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
|
||||
const promoted = await service.finishSetupSession({ sessionId: started.session.id });
|
||||
|
||||
expect((promoted.template.metadata as Record<string, unknown>).bootRelevantConfig).toBeUndefined();
|
||||
const persistedRow = await activeTemplateRow(environmentId);
|
||||
const boot = (persistedRow.metadata as Record<string, unknown>).bootRelevantConfig as {
|
||||
values: Record<string, unknown>;
|
||||
excludedPaths: string[];
|
||||
};
|
||||
// Every prototype key fails canonicalization and records only the marker.
|
||||
expect(boot.excludedPaths).toContain("[unresolved-identity-path]");
|
||||
// No raw prototype-key path and no value for one persists in the snapshot.
|
||||
expect(boot.excludedPaths).not.toContain("__proto__");
|
||||
expect(boot.excludedPaths).not.toContain("constructor");
|
||||
expect(boot.excludedPaths).not.toContain("prototype");
|
||||
expect(Object.keys(boot.values)).not.toContain("__proto__");
|
||||
expect(Object.keys(boot.values)).not.toContain("constructor");
|
||||
expect(Object.keys(boot.values)).not.toContain("prototype");
|
||||
// The prototype of the values map stays clean; no key leaked onto it.
|
||||
expect(Object.getPrototypeOf(boot.values)).toBe(Object.prototype);
|
||||
const metadataJson = JSON.stringify(persistedRow.metadata);
|
||||
expect(metadataJson).not.toContain("__proto__");
|
||||
expect(metadataJson).not.toContain("nested.__proto__");
|
||||
|
||||
// An unflagged relink fails closed with 409 and the unclassified class.
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 409, details: { classification: "unclassified" } });
|
||||
// A confirmed relink succeeds and writes the relinked activity entry.
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("unclassified");
|
||||
const activities = await relinkActivityRows(environmentId);
|
||||
const relinkActivity = activities.find((row) => row.action === "environment.custom_image_template.relinked");
|
||||
expect(relinkActivity).toBeDefined();
|
||||
});
|
||||
|
||||
it("fails closed when the provider adds a boot-relevant identity path after capture", async () => {
|
||||
const { companyId, environmentId } = await seed({ config: {
|
||||
provider: "fake-plugin",
|
||||
image: "fake:base",
|
||||
region: "us",
|
||||
reuseLease: false,
|
||||
} });
|
||||
const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() });
|
||||
const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } });
|
||||
await service.finishSetupSession({ sessionId: started.session.id });
|
||||
|
||||
// A knob-only region change detaches the template. On its own it would
|
||||
// relink without confirmation.
|
||||
await db.update(environments)
|
||||
.set({ config: { provider: "fake-plugin", image: "fake:base", region: "eu", reuseLease: false } })
|
||||
.where(eq(environments.id, environmentId));
|
||||
|
||||
// The provider now declares `region` a boot-relevant identity path. The
|
||||
// capture snapshot never covered it, so the server cannot verify the boot
|
||||
// source and must not classify the drift as knob-only.
|
||||
const manifest = pluginManifest();
|
||||
const nextManifest = {
|
||||
...manifest,
|
||||
environmentDrivers: [
|
||||
{ ...manifest.environmentDrivers[0], templateIdentityPaths: ["apiUrl", "region"] },
|
||||
],
|
||||
};
|
||||
await db.update(plugins)
|
||||
.set({ manifestJson: nextManifest })
|
||||
.where(eq(plugins.pluginKey, manifest.id));
|
||||
|
||||
await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }))
|
||||
.rejects.toMatchObject({ status: 409, details: { classification: "unclassified" } });
|
||||
const relinked = await service.relinkActiveTemplate({
|
||||
environmentId,
|
||||
confirmBootSourceDrift: true,
|
||||
actor: relinkActor,
|
||||
companyId,
|
||||
});
|
||||
expect(relinked.classification).toBe("unclassified");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
resolveDeclaredSandboxCapabilities,
|
||||
redactEnvironmentCustomImageSetupSession,
|
||||
redactEnvironmentCustomImageTemplate,
|
||||
relinkEnvironmentCustomImageTemplateSchema,
|
||||
startEnvironmentCustomImageSetupSessionSchema,
|
||||
type EnvironmentDeleteBlastRadius,
|
||||
updateEnvironmentSchema,
|
||||
|
|
@ -952,6 +953,31 @@ export function environmentRoutes(
|
|||
res.json(result);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/environments/:environmentId/custom-image-template/relink",
|
||||
validate(relinkEnvironmentCustomImageTemplateSchema),
|
||||
async (req, res) => {
|
||||
assertCanAccessInstanceEnvironments(req);
|
||||
const companyId = await resolveCustomImageCompanyId(req);
|
||||
const actor = getActorInfo(req);
|
||||
// The service classifies drift, re-stamps the fingerprint, and writes the
|
||||
// activity row in one transaction. The route never classifies.
|
||||
const result = await customImages.relinkActiveTemplate({
|
||||
environmentId: req.params.environmentId as string,
|
||||
confirmBootSourceDrift: req.body.confirmBootSourceDrift === true,
|
||||
actor: {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
agentApiKeyId: actor.agentApiKeyId,
|
||||
},
|
||||
companyId,
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/environments/:environmentId/custom-image-template", async (req, res) => {
|
||||
assertCanAccessInstanceEnvironments(req);
|
||||
const companyId = await resolveCustomImageCompanyId(req);
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ import {
|
|||
environmentCustomImageTerminalSessionTokenSchema,
|
||||
environmentCustomImageTemplateSchema,
|
||||
finishEnvironmentCustomImageSetupSessionSchema,
|
||||
relinkEnvironmentCustomImageTemplateSchema,
|
||||
updateEnvironmentSchema,
|
||||
probeEnvironmentConfigSchema,
|
||||
startEnvironmentCustomImageSetupSessionSchema,
|
||||
|
|
@ -635,6 +636,11 @@ const environmentCustomImageTemplateRollbackResultSchema = z.object({
|
|||
supersededTemplate: environmentCustomImageTemplateSchema,
|
||||
}).strict();
|
||||
|
||||
const environmentCustomImageTemplateRelinkResultSchema = z.object({
|
||||
template: environmentCustomImageTemplateSchema,
|
||||
classification: z.enum(["knob_only", "boot_source_drift", "unclassified"]),
|
||||
}).strict();
|
||||
|
||||
const workTimelineQuerySchema = z.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
|
|
@ -5585,6 +5591,26 @@ registry.registerPath({
|
|||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/environments/{environmentId}/custom-image-template/relink",
|
||||
tags: ["environments"],
|
||||
summary: "Relink a detached environment customImage template to the current config",
|
||||
request: {
|
||||
params: z.object({ environmentId: z.string() }),
|
||||
query: environmentCustomImageCompanyQuerySchema,
|
||||
body: jsonBody(relinkEnvironmentCustomImageTemplateSchema),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(environmentCustomImageTemplateRelinkResultSchema),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/api/environments/{environmentId}/custom-image-template",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue