diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fb079ee85d..f4ac04960d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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"; diff --git a/packages/shared/src/validators/environment-custom-images.ts b/packages/shared/src/validators/environment-custom-images.ts index 07eb945f20..9ef97e3d69 100644 --- a/packages/shared/src/validators/environment-custom-images.ts +++ b/packages/shared/src/validators/environment-custom-images.ts @@ -94,6 +94,12 @@ export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({ export type CancelEnvironmentCustomImageSetupSession = z.infer; +export const relinkEnvironmentCustomImageTemplateSchema = z.object({ + confirmBootSourceDrift: z.boolean().optional().default(false), +}).strict(); +export type RelinkEnvironmentCustomImageTemplate = + z.infer; + export const createEnvironmentCustomImageTerminalSessionTokenSchema = z.object({}).strict().default({}); export type CreateEnvironmentCustomImageTerminalSessionToken = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 3b939c2108..2fc001d5a4 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -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"; diff --git a/server/src/__tests__/environment-custom-image-routes.test.ts b/server/src/__tests__/environment-custom-image-routes.test.ts index 649055adeb..82b8eda9d3 100644 --- a/server/src/__tests__/environment-custom-image-routes.test.ts +++ b/server/src/__tests__/environment-custom-image-routes.test.ts @@ -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(); + }); }); diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts index 51a8e4691a..335c1e6142 100644 --- a/server/src/__tests__/environment-custom-images-service.test.ts +++ b/server/src/__tests__/environment-custom-images-service.test.ts @@ -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) | null = null; + let db!: ReturnType; + + 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 | ReturnType | ReturnType | ReturnType; + config?: Record; + }) { + 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).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).bootRelevantConfig as { + values: Record; + 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).bootRelevantConfig).toBeUndefined(); + const persistedRow = await activeTemplateRow(environmentId); + const boot = (persistedRow.metadata as Record).bootRelevantConfig as { + values: Record; + 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).bootRelevantConfig).toBeUndefined(); + const persistedRow = await activeTemplateRow(environmentId); + const boot = (persistedRow.metadata as Record).bootRelevantConfig as { + values: Record; + 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"); + }); +}); diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 834f1e2407..38fe085738 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -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); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 9e47791a5f..e225b84da0 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts index 254169290c..996770ef3f 100644 --- a/server/src/services/environment-custom-image-runtime.ts +++ b/server/src/services/environment-custom-image-runtime.ts @@ -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; + 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, +): 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; +} + +/** + * 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(); + 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; + secretRefExcludePaths?: Iterable; +}): EnvironmentCustomImageBootRelevantConfig { + const config = input.config as Record; + 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 = Object.create(null); + const excludedPaths = new Set(); + + 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([ + ...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 | 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; + + 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 | null | undefined, +): Record | 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, }; diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts index f8a8449f2f..d481ebfacb 100644 --- a/server/src/services/environment-custom-images.ts +++ b/server/src/services/environment-custom-images.ts @@ -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 { +): Promise { 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 { + 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; diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index 5b1bc6e05f..46ca219291 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -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; + 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( + `/environments/${environmentId}/custom-image-template/relink?${companyIdQuery(companyId)}`, + { confirmBootSourceDrift: options.confirmBootSourceDrift === true }, + ), disableCustomImageTemplate: ( environmentId: string, companyId: string, diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index cd8a7e52b0..13b27924eb 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -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 } } }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index f974caddf8..570d0d0dd7 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -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. ) : null} @@ -1091,6 +1169,16 @@ function EnvironmentImageTemplatePanel({ Refresh +