From 17dde9d3f2a96adeae1576966d34a31866d0b1ff Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 10 Jul 2026 14:32:47 -0700 Subject: [PATCH] fix(sandbox): keep custom-image snapshots applied to config tests, probes, and saves (#9385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox environments can capture reusable custom images (provider snapshots) so agents boot with pre-installed tools and CLI logins > - The custom-image runtime fingerprint check included provider secret-ref paths (e.g. the Daytona `apiKey`), while capture-time fingerprinting excluded them, so any config carrying a credential never matched its captured snapshot > - As a result, agent config tests and environment probes silently booted the provider base image instead of the snapshot, test sandboxes were deleted before operators could inspect them, and any environment save orphaned the snapshot without warning > - The UI compounded the confusion by displaying an internal template id that matches nothing in the provider dashboard > - This pull request aligns runtime fingerprints with capture-time exclusions, re-stamps fingerprints on saves that cannot affect the snapshot (warning when they can), archives test/probe sandboxes instead of deleting them, and surfaces the provider snapshot ref in the UI > - The benefit is that custom images actually apply to config tests and probes, survive unrelated config edits, and are debuggable against the provider dashboard ## Linked Issues or Issue Description No public GitHub issue exists for this; describing it in-PR per the bug template. Related: Refs #9329 (saved-environment probe company context — this branch carries an equivalent fix), Refs #8794 (introduced reusable sandbox custom images). **What happened?** With a Daytona environment whose provider config stores the API key as a secret reference and an active captured custom-image snapshot: - Agent config tests and environment probes booted the provider base image (`daytonaio/sandbox:0.8.0`) instead of the captured snapshot, so CLI upgrades/logins baked into the snapshot were missing and the probe reported "login required" and an outdated CLI. - The environment card showed an internal template id (e.g. `b5be03e1-ca5…`) that does not correspond to any snapshot name in the provider dashboard, making the active image impossible to correlate. - Test/probe sandboxes were deleted immediately after the run, so the sandbox a test used could not be inspected afterwards. - Saving the environment config (even fields unrelated to the image) changed the stored fingerprint, silently detaching the snapshot with no warning. **Expected behavior** Config tests and probes boot the captured snapshot when one is active; the UI shows the provider-facing snapshot/template ref; test sandboxes stay inspectable for a short window; unrelated config edits keep the snapshot linked, and edits that genuinely invalidate it produce an explicit warning. **Steps to reproduce** 1. Configure a sandbox environment on Daytona with the API key stored as a company secret reference. 2. Capture a custom image snapshot from the environment page and mark it active (e.g. after installing/logging into a CLI in the setup sandbox). 3. Run the agent config test or an environment probe: the sandbox boots the base image, not the snapshot, and the sandbox is deleted immediately after the test. 4. Save the environment config with an unrelated field change: the snapshot silently stops applying. **Paperclip version or commit** `master` at the merge-base of this branch. **Deployment mode** Self-hosted local instance (macOS, pnpm dev server) with the Daytona sandbox provider plugin. ## What Changed - Runtime custom-image fingerprint checks now exclude provider secret-ref paths, matching capture-time exclusions, so configs carrying credentials match their captured snapshots (`environment-custom-image-runtime.ts`). - Agent config tests and saved-environment probes force fresh, non-reused sandboxes and pass company context so lease-backed probes can resolve company secrets and boot the real snapshot (`environment-probe.ts`, `routes/agents.ts`, `routes/environments.ts`). - Test/probe sandboxes are released by archiving (stop + 60-minute provider-side auto-delete) instead of immediate deletion, so operators can inspect the exact sandbox a test used (Daytona plugin). - On environment PATCH save, changes that cannot affect the captured snapshot re-stamp the template's source fingerprint so the snapshot stays linked; boot-source or provider-identity changes (new manifest field `templateIdentityPaths`) mark the template detached and the save response reports it (`environment-custom-images.ts`, shared plugin types/validators). - The custom-image overview exposes `activeTemplateMatchesConfig`; the environments UI shows the provider snapshot/template ref (internal id moved to a tooltip), warns via toast when a save detaches the snapshot, and shows a persistent "Not in use" warning when the active template no longer matches the saved config (`CompanyEnvironments.tsx`, `api/environments.ts`). ## Verification - `pnpm vitest run server/src/__tests__/environment-custom-images-service.test.ts server/src/__tests__/environment-probe.test.ts server/src/__tests__/environment-routes.test.ts server/src/__tests__/agent-test-environment-routes.test.ts` — server coverage for fingerprint exclusions, re-stamp/detach on save, probe company context, and fresh-sandbox test behavior. - `pnpm vitest run packages/plugins/sandbox-providers/daytona/src/plugin.test.ts` — archive-on-release and snapshot ref handling. - `pnpm vitest run ui/src/pages/CompanyEnvironments.test.tsx` — snapshot ref display, detach toast, and "Not in use" warning. - Manually verified end-to-end on a live self-hosted instance against real Daytona: config test boots the captured snapshot (CLI login and version persist), the test sandbox remains visible in the provider dashboard as archived, and saving unrelated fields keeps the snapshot applied. ## Risks - Fingerprint exclusion widening: a provider credential rotation alone no longer detaches a captured snapshot; that is the intended behavior (the snapshot content does not depend on the credential), and provider-identity fields (e.g. Daytona `apiUrl`) still detach via `templateIdentityPaths`. - Archived test sandboxes consume provider-side resources for up to their auto-delete window instead of being freed immediately; bounded (60 minutes) and only for test/probe sandboxes. - New optional manifest field `templateIdentityPaths` is backward-compatible; providers that omit it keep current matching behavior. ## Model Used - Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use via Claude Code / Claude Agent SDK. ## 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 --- .../sandbox-providers/daytona/src/manifest.ts | 1 + .../daytona/src/plugin.test.ts | 51 +++ .../sandbox-providers/daytona/src/plugin.ts | 26 ++ packages/shared/src/types/environment.ts | 12 + packages/shared/src/types/plugin.ts | 7 + packages/shared/src/validators/plugin.ts | 1 + .../agent-test-environment-routes.test.ts | 41 ++- .../environment-custom-images-service.test.ts | 330 +++++++++++++++++- .../src/__tests__/environment-probe.test.ts | 100 ++++++ .../src/__tests__/environment-routes.test.ts | 7 +- server/src/routes/agents.ts | 100 +++++- server/src/routes/environments.ts | 21 +- server/src/services/environment-config.ts | 6 + .../environment-custom-image-runtime.ts | 127 ++++++- .../src/services/environment-custom-images.ts | 129 ++++++- server/src/services/environment-probe.ts | 98 +++++- ui/src/api/environments.ts | 23 +- ui/src/pages/CompanyEnvironments.test.tsx | 227 +++++++++++- ui/src/pages/CompanyEnvironments.tsx | 54 ++- 19 files changed, 1317 insertions(+), 44 deletions(-) diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index 0c1ad491eb..c83a49275f 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -32,6 +32,7 @@ const manifest: PaperclipPluginManifestV1 = { field: "snapshot", unsetFields: ["image"], }, + templateIdentityPaths: ["apiUrl"], supportsTemplateDelete: true, configSchema: { type: "object", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index d8d3502a20..4e832a7670 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -48,6 +48,8 @@ function createMockSandbox(overrides: { recover: vi.fn().mockResolvedValue(undefined), resize: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), + archive: vi.fn().mockResolvedValue(undefined), + setAutoDeleteInterval: vi.fn().mockResolvedValue(undefined), createSshAccess: vi.fn().mockResolvedValue({ token: "ssh-token-secret", command: "ssh ssh-token-secret@ssh.app.daytona.io", @@ -133,6 +135,7 @@ describe("Daytona sandbox provider plugin", () => { autoArchiveInterval: 60, autoDeleteInterval: -1, reuseLease: true, + archiveOnRelease: false, }, }); }); @@ -956,6 +959,54 @@ describe("Daytona sandbox provider plugin", () => { expect(ephemeral.delete).toHaveBeenCalledWith(300); }); + it("archives instead of deleting when the lease was acquired with archiveOnRelease", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "started" }); + mockGet.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentReleaseLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-test-probe", + config: { + timeoutMs: 300000, + reuseLease: false, + archiveOnRelease: true, + }, + }); + + expect(sandbox.stop).toHaveBeenCalledWith(300); + expect(sandbox.setAutoDeleteInterval).toHaveBeenCalledWith(60); + expect(sandbox.archive).toHaveBeenCalled(); + expect(sandbox.delete).not.toHaveBeenCalled(); + }); + + it("falls back to delete when archiving an archiveOnRelease lease fails", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "stopped" }); + sandbox.archive.mockRejectedValueOnce(new Error("archive unsupported")); + mockGet.mockResolvedValue(sandbox); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await plugin.definition.onEnvironmentReleaseLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-test-probe", + config: { + timeoutMs: 300000, + reuseLease: false, + archiveOnRelease: true, + }, + }); + + expect(sandbox.stop).not.toHaveBeenCalled(); + expect(sandbox.archive).toHaveBeenCalled(); + expect(sandbox.delete).toHaveBeenCalledWith(300); + expect(warnSpy).toHaveBeenCalled(); + }); + it("falls back to delete when stopping a reusable lease from an error state fails", async () => { process.env.DAYTONA_API_KEY = "host-key"; const errored = createMockSandbox({ id: "sandbox-error", state: "error" }); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index c9fb0a565d..07e22516ac 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -51,6 +51,7 @@ interface DaytonaDriverConfig { autoArchiveInterval: number | null; autoDeleteInterval: number | null; reuseLease: boolean; + archiveOnRelease: boolean; } type WorkspaceSentinelResult = { @@ -93,6 +94,12 @@ const DEFAULT_AUTO_STOP_INTERVAL_MINUTES = 15; const DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES = 60; const DEFAULT_AUTO_DELETE_INTERVAL_MINUTES = 7 * 24 * 60; // 7 days +// Sandboxes released with `archiveOnRelease` (test/probe runs) are archived so +// operators can inspect them from the Daytona dashboard, then expired by +// Daytona itself after this interval (counted from the stop that precedes the +// archive) so debugging copies don't accumulate. +const ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES = 60; + // Fail-fast cap for git network operations (push, fetch, pull, ls-remote, etc.) // so a stalled remote or missing credential never consumes the full 900 s adapter // RPC ceiling; callers always see an actionable error within this window. @@ -145,6 +152,7 @@ function parseDriverConfig(raw: Record): DaytonaDriverConfig { autoArchiveInterval: parseOptionalInteger(raw.autoArchiveInterval) ?? DEFAULT_AUTO_ARCHIVE_INTERVAL_MINUTES, autoDeleteInterval: parseOptionalInteger(raw.autoDeleteInterval) ?? DEFAULT_AUTO_DELETE_INTERVAL_MINUTES, reuseLease: raw.reuseLease === true, + archiveOnRelease: raw.archiveOnRelease === true, }; } @@ -434,6 +442,9 @@ function leaseMetadata(input: { target: input.sandbox.target, timeoutMs: input.config.timeoutMs, reuseLease: input.config.reuseLease, + // Persisted so the release path (which rebuilds config from lease + // metadata) still knows to archive instead of delete. + ...(input.config.archiveOnRelease ? { archiveOnRelease: true } : {}), remoteCwd: input.remoteCwd, resumedLease: input.resumedLease, // Record the resources Paperclip attempted to request so future diagnosis @@ -956,6 +967,21 @@ const plugin = definePlugin({ return; } + if (config.archiveOnRelease) { + try { + if (sandbox.state !== "stopped") { + await sandbox.stop(toTimeoutSeconds(config.timeoutMs)); + } + await sandbox.setAutoDeleteInterval(ARCHIVE_ON_RELEASE_AUTO_DELETE_MINUTES); + await sandbox.archive(); + return; + } catch (error) { + console.warn( + `Failed to archive Daytona sandbox during lease release: ${formatErrorMessage(error)}. Falling back to delete.`, + ); + } + } + await sandbox.delete(toTimeoutSeconds(config.timeoutMs)); }, diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 9e38f2d558..2a2867ffc1 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -30,6 +30,12 @@ export interface FakeSandboxEnvironmentConfig { reuseLease: boolean; /** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */ streamRunLogs?: boolean; + /** + * Archive the sandbox on lease release instead of deleting it, so operators + * can inspect it from the provider dashboard. Injected by test/probe paths; + * providers without archive support delete as usual. + */ + archiveOnRelease?: boolean; } export interface PluginSandboxEnvironmentConfig { @@ -38,6 +44,12 @@ export interface PluginSandboxEnvironmentConfig { timeoutMs?: number; /** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */ streamRunLogs?: boolean; + /** + * Archive the sandbox on lease release instead of deleting it, so operators + * can inspect it from the provider dashboard. Injected by test/probe paths; + * providers without archive support delete as usual. + */ + archiveOnRelease?: boolean; [key: string]: unknown; } diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index d614061c4f..be0fb0051c 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -164,6 +164,13 @@ export interface PluginEnvironmentDriverDeclaration { * runtime config. Omit to use the standard key for `templateRefKind`. */ templateConfigBinding?: PluginEnvironmentTemplateConfigBinding; + /** + * Config paths (dot notation) that scope where captured templates live for + * this provider, such as an API endpoint. When one of these changes on a + * saved environment, captured templates cannot be re-linked to the updated + * config and a fresh capture is required. + */ + templateIdentityPaths?: string[]; /** Provider supports best-effort deletion/cleanup of captured templates. */ supportsTemplateDelete?: boolean; /** JSON Schema describing the driver's provider-specific configuration. */ diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 91535b3b88..4637519d37 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -167,6 +167,7 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({ supportsTemplateCapture: z.boolean().optional(), templateRefKind: z.string().min(1).max(100).optional(), templateConfigBinding: pluginEnvironmentTemplateConfigBindingSchema.optional(), + templateIdentityPaths: z.array(z.string().min(1).max(200)).max(20).optional(), supportsTemplateDelete: z.boolean().optional(), configSchema: jsonSchemaSchema, }); diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index dc3e7ce445..de490e11ba 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -137,7 +137,15 @@ describe("agent test-environment route", () => { mockEnvironmentRuntime.acquireRunLease.mockResolvedValue({ lease: { id: "lease-1", - metadata: { remoteCwd: "/home/user/paperclip-workspace" }, + provider: "daytona", + providerLeaseId: "provider-lease-1", + metadata: { + remoteCwd: "/home/user/paperclip-workspace", + sandboxId: "sandbox-1", + sandboxName: "paperclip-probe", + templateKind: "snapshot", + templateRef: "snapshot-1", + }, }, leaseContext: { executionWorkspaceId: null, @@ -263,6 +271,19 @@ describe("agent test-environment route", () => { expect(res.status, JSON.stringify(res.body)).toBe(200); expect(testEnvironmentSpy).toHaveBeenCalledTimes(1); + // Test leases boot fresh and stay debuggable: never resume a retained + // agent lease, archive (not delete) the sandbox on release. + expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith( + expect.objectContaining({ + applyCustomImageTemplate: true, + environment: expect.objectContaining({ + config: expect.objectContaining({ + reuseLease: false, + archiveOnRelease: true, + }), + }), + }), + ); expect(testEnvironmentSpy.mock.calls[0]?.[0]).toMatchObject({ executionTarget: expect.objectContaining({ kind: "remote", @@ -271,6 +292,24 @@ describe("agent test-environment route", () => { environmentName: "Sandbox QA", }); expect(res.body).toMatchObject({ adapterType: "external_test", status: "pass" }); + expect(res.body.checks).toEqual([ + expect.objectContaining({ + code: "sandbox_test_identity", + level: "info", + message: 'Sandbox test identity for "Sandbox QA".', + detail: expect.stringContaining("paperclipLeaseId=lease-1"), + }), + expect.objectContaining({ + code: "external_test_hello_probe_passed", + level: "info", + message: "OK", + }), + ]); + expect(res.body.checks[0].detail).toContain("providerLeaseId=provider-lease-1"); + expect(res.body.checks[0].detail).toContain("provider=daytona"); + expect(res.body.checks[0].detail).toContain("sandboxId=sandbox-1"); + expect(res.body.checks[0].detail).toContain("sandboxName=paperclip-probe"); + expect(res.body.checks[0].detail).toContain("snapshotRef=snapshot-1"); expect(mockReleaseRunLease).toHaveBeenCalledWith({ environment: expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }), lease: expect.objectContaining({ id: "lease-1" }), diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts index 883e4c7f32..51a8e4691a 100644 --- a/server/src/__tests__/environment-custom-images-service.test.ts +++ b/server/src/__tests__/environment-custom-images-service.test.ts @@ -14,6 +14,8 @@ import { environmentCustomImageService, } from "../services/environment-custom-images.js"; import { + ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + environmentCustomImageTemplateMatchesBaseConfig, fingerprintEnvironmentSandboxProviderConfig, } from "../services/environment-custom-image-runtime.js"; import { @@ -55,6 +57,7 @@ function pluginManifest() { field: "customTemplate", unsetFields: ["image"], }, + templateIdentityPaths: ["apiUrl"], supportsTemplateDelete: true, configSchema: { type: "object" }, }, @@ -436,7 +439,9 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { provider: "fake-plugin", templateKind: "snapshot", templateRef: "snapshot-active", - sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any), + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any, { + excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + }), status: "active", }); const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { @@ -448,20 +453,54 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { expect(resolved.config).toMatchObject({ snapshot: "snapshot-active" }); expect(resolved.config).not.toHaveProperty("image"); - // A stored fingerprint that no longer matches the current base config (e.g. the - // config dialog re-saved the environment, or the user tweaked resource/lease - // knobs) must NOT silently discard the captured template. - await db.update(environmentCustomImageTemplates) - .set({ sourceEnvironmentConfigFingerprint: "stale" }) - .where(eq(environmentCustomImageTemplates.templateRef, "snapshot-active")); - const afterConfigChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { - id: environment.id, + // Runtime-only resource/lease edits must NOT silently discard the captured + // template. + await db.update(environments) + .set({ + config: { + ...(environment.config as Record), + cpu: 4, + timeoutMs: 600000, + reuseLease: true, + }, + }) + .where(eq(environments.id, environment.id)); + const afterResourceChangeEnvironment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); + const afterResourceChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: afterResourceChangeEnvironment.id, driver: "sandbox", - config: environment.config, + config: afterResourceChangeEnvironment.config, }, { heartbeatRunId: randomUUID() }); - expect(afterConfigChange.driver).toBe("sandbox"); - expect(afterConfigChange.config).toMatchObject({ snapshot: "snapshot-active" }); - expect(afterConfigChange.config).not.toHaveProperty("image"); + expect(afterResourceChange.driver).toBe("sandbox"); + expect(afterResourceChange.config).toMatchObject({ snapshot: "snapshot-active" }); + expect(afterResourceChange.config).not.toHaveProperty("image"); + + // Changing the base image is a meaningful source-template change. In that + // case, the old capture must not mask the newly saved image. + await db.update(environmentCustomImageTemplates) + .set({ + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(environment.config as any, { + excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + }), + }) + .where(eq(environmentCustomImageTemplates.templateRef, "snapshot-active")); + await db.update(environments) + .set({ + config: { + ...(environment.config as Record), + image: "fake:new-base", + }, + }) + .where(eq(environments.id, environment.id)); + const afterImageChangeEnvironment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); + const afterImageChange = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: afterImageChangeEnvironment.id, + driver: "sandbox", + config: afterImageChangeEnvironment.config, + }, { heartbeatRunId: randomUUID() }); + expect(afterImageChange.driver).toBe("sandbox"); + expect(afterImageChange.config).toMatchObject({ image: "fake:new-base" }); + expect(afterImageChange.config).not.toHaveProperty("snapshot"); }); it("applies the active template for ad-hoc Test probes only when applyCustomImageTemplate is set", async () => { @@ -562,3 +601,268 @@ describe("fingerprintEnvironmentSandboxProviderConfig", () => { ); }); }); + +describe("environmentCustomImageTemplateMatchesBaseConfig", () => { + it("keeps captures across runtime-only edits but not base image changes", () => { + const baseConfig = { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + timeoutMs: 300000, + reuseLease: false, + } as any; + const template = { + id: "template-1", + environmentId: "env-1", + provider: "daytona", + templateKind: "snapshot", + templateRef: "snapshot-active", + sourceTemplateRef: "daytonaio/sandbox:0.8.0", + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(baseConfig, { + excludePaths: ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + }), + status: "active", + createdByUserId: null, + createdByAgentId: null, + capturedAt: null, + lastUsedAt: null, + supersededByTemplateId: null, + metadata: null, + createdAt: new Date("2026-07-09T00:00:00.000Z"), + updatedAt: new Date("2026-07-09T00:00:00.000Z"), + } as const; + + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: { + ...baseConfig, + timeoutMs: 600000, + reuseLease: true, + cpu: 4, + }, + })).toBe(true); + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: { + ...baseConfig, + image: "daytonaio/sandbox:0.9.0", + }, + })).toBe(false); + }); + + it("matches configs carrying a secret-ref credential when the capture excluded that path", () => { + // Capture-time fingerprints exclude the provider's secret-ref paths (e.g. + // daytona apiKey). The runtime re-check must exclude the same paths or a + // config with any credential never matches and the template is dropped. + const baseConfig = { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + apiKey: "raw-api-key-value", + } as any; + const template = { + id: "template-1", + environmentId: "env-1", + provider: "daytona", + templateKind: "snapshot", + templateRef: "snapshot-active", + sourceTemplateRef: "daytonaio/sandbox:0.8.0", + sourceEnvironmentConfigFingerprint: fingerprintEnvironmentSandboxProviderConfig(baseConfig, { + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + "apiKey", + ], + }), + status: "active", + createdByUserId: null, + createdByAgentId: null, + capturedAt: null, + lastUsedAt: null, + supersededByTemplateId: null, + metadata: null, + createdAt: new Date("2026-07-09T00:00:00.000Z"), + updatedAt: new Date("2026-07-09T00:00:00.000Z"), + } as const; + + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig, + secretRefExcludePaths: ["apiKey"], + })).toBe(true); + // A rotated credential still matches — credentials are not part of the + // captured image identity. + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: { ...baseConfig, apiKey: "rotated-key" }, + secretRefExcludePaths: ["apiKey"], + })).toBe(true); + // Without the exclusion the same config fails to match (the pre-fix bug). + expect(environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig, + })).toBe(false); + }); +}); + +describeEmbeddedPostgres("environmentCustomImageService reconciliation", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + + beforeAll(async () => { + const started = await startEmbeddedPostgresTestDatabase("environment-custom-images-reconcile"); + stopDb = started.stop; + db = createDb(started.connectionString); + }); + + afterEach(async () => { + 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() { + 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: { + provider: "fake-plugin", + image: "fake:base", + reuseLease: false, + }, + envVars: {}, + }); + await db.insert(plugins).values({ + pluginKey: "paperclip.fake-sandbox-provider", + packageName: "paperclip-plugin-fake-sandbox", + version: "0.1.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: pluginManifest(), + status: "ready", + }); + return { companyId, environmentId }; + } + + it("re-links the active template on save when only non-identity fields change", async () => { + const { companyId, environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + const baseConfig = { provider: "fake-plugin", image: "fake:base", reuseLease: false }; + const nextConfig = { ...baseConfig, region: "eu-west" }; + + const relinked = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: nextConfig }, + }); + expect(relinked.action).toBe("relinked"); + if (relinked.action !== "relinked") throw new Error("expected relink"); + expect(relinked.template.sourceEnvironmentConfigFingerprint) + .not.toBe(promoted.template.sourceEnvironmentConfigFingerprint); + + // Re-running with the same configs is a no-op: the template already + // matches the new config. + const repeat = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: nextConfig }, + }); + expect(repeat.action).toBe("none"); + + // The captured template keeps applying at runtime under the new config. + await db.update(environments) + .set({ config: nextConfig }) + .where(eq(environments.id, environmentId)); + const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: environmentId, + driver: "sandbox", + config: nextConfig, + }, { heartbeatRunId: randomUUID() }); + expect(resolved.driver).toBe("sandbox"); + expect(resolved.config).toMatchObject({ customTemplate: promoted.template.templateRef }); + expect(resolved.config).not.toHaveProperty("image"); + }); + + it("reports detached on save when a boot-source or provider identity field changes", async () => { + const { environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + const baseConfig = { provider: "fake-plugin", image: "fake:base", reuseLease: false }; + + // Base image change: the user asked for a different base, so the capture + // cannot be re-linked. + const imageChange = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: { ...baseConfig, image: "fake:other" } }, + }); + expect(imageChange.action).toBe("detached"); + + // Provider-declared identity path change (apiUrl): captured templates do + // not exist on a different provider endpoint. + const endpointChange = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: { ...baseConfig, apiUrl: "https://other.example" } }, + }); + expect(endpointChange.action).toBe("detached"); + + // Binding-field change counts as a boot-source change too. + const bindingChange = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: baseConfig }, + next: { driver: "sandbox", config: { ...baseConfig, customTemplate: "someone-elses-snapshot" } }, + }); + expect(bindingChange.action).toBe("detached"); + + // Detach never mutates the stored fingerprint; rollback/disable stay + // available and the old config still matches. + const template = await service.getActiveTemplate({ environmentId, provider: "fake-plugin" }); + expect(template?.sourceEnvironmentConfigFingerprint) + .toBe(promoted.template.sourceEnvironmentConfigFingerprint); + + // A template that was already detached before the save is left alone. + const alreadyDetached = await service.reconcileActiveTemplateForConfigChange({ + environmentId, + previous: { driver: "sandbox", config: { ...baseConfig, image: "fake:unrelated" } }, + next: { driver: "sandbox", config: { ...baseConfig, image: "fake:unrelated", region: "eu" } }, + }); + expect(alreadyDetached.action).toBe("none"); + }); + + it("reports whether the active template matches the saved config in the overview", async () => { + const { environmentId } = await seed(); + const workerManager = createWorkerManager(); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + await service.finishSetupSession({ sessionId: started.session.id }); + + const inSync = await service.getOverview({ environmentId }); + expect(inSync.activeTemplateMatchesConfig).toBe(true); + + await db.update(environments) + .set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false } }) + .where(eq(environments.id, environmentId)); + const outOfSync = await service.getOverview({ environmentId }); + expect(outOfSync.activeTemplate).not.toBeNull(); + expect(outOfSync.activeTemplateMatchesConfig).toBe(false); + }); +}); diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts index b9ca635c5f..9a29883219 100644 --- a/server/src/__tests__/environment-probe.test.ts +++ b/server/src/__tests__/environment-probe.test.ts @@ -4,6 +4,14 @@ const mockEnsureSshWorkspaceReady = vi.hoisted(() => vi.fn()); const mockProbePluginEnvironmentDriver = vi.hoisted(() => vi.fn()); const mockProbePluginSandboxProviderDriver = vi.hoisted(() => vi.fn()); const mockResolvePluginSandboxProviderDriverByKey = vi.hoisted(() => vi.fn()); +const mockRuntimeAcquireRunLease = vi.hoisted(() => vi.fn()); +const mockRuntimeReleaseRunLease = vi.hoisted(() => vi.fn()); +const mockEnvironmentRuntimeService = vi.hoisted(() => vi.fn(() => ({ + acquireRunLease: mockRuntimeAcquireRunLease, + getDriver: vi.fn(() => ({ + releaseRunLease: mockRuntimeReleaseRunLease, + })), +}))); vi.mock("@paperclipai/adapter-utils/ssh", () => ({ ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady, @@ -15,6 +23,10 @@ vi.mock("../services/plugin-environment-driver.js", () => ({ resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey, })); +vi.mock("../services/environment-runtime.js", () => ({ + environmentRuntimeService: mockEnvironmentRuntimeService, +})); + import { probeEnvironment } from "../services/environment-probe.ts"; describe("probeEnvironment", () => { @@ -24,6 +36,9 @@ describe("probeEnvironment", () => { mockProbePluginSandboxProviderDriver.mockReset(); mockResolvePluginSandboxProviderDriverByKey.mockReset(); mockResolvePluginSandboxProviderDriverByKey.mockResolvedValue(null); + mockRuntimeAcquireRunLease.mockReset(); + mockRuntimeReleaseRunLease.mockReset(); + mockEnvironmentRuntimeService.mockClear(); }); it("reports local environments as immediately available", async () => { @@ -163,6 +178,91 @@ describe("probeEnvironment", () => { }); }); + it("boots a fresh runtime lease for saved sandbox probes when requested", async () => { + mockRuntimeAcquireRunLease.mockResolvedValue({ + environment: {}, + leaseContext: { + executionWorkspaceId: null, + executionWorkspaceMode: null, + }, + lease: { + id: "lease-1", + companyId: "company-1", + environmentId: "env-sandbox-plugin", + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: null, + status: "active", + leasePolicy: "ephemeral", + provider: "daytona", + providerLeaseId: "sandbox-runtime-1", + acquiredAt: new Date(), + lastUsedAt: new Date(), + expiresAt: null, + releasedAt: null, + failureReason: null, + cleanupStatus: "pending", + metadata: { + provider: "daytona", + sandboxName: "paperclip-probe", + reuseLease: false, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const environment = { + id: "env-sandbox-plugin", + companyId: "company-1", + name: "Daytona", + description: null, + driver: "sandbox" as const, + status: "active" as const, + config: { + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + reuseLease: true, + }, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = await probeEnvironment({} as any, environment, { + companyId: "company-1", + pluginWorkerManager: {} as any, + applyCustomImageTemplate: true, + acquireSandboxRuntimeLease: true, + }); + + expect(result).toMatchObject({ + ok: true, + driver: "sandbox", + summary: "Connected to daytona sandbox paperclip-probe.", + }); + expect(mockProbePluginSandboxProviderDriver).not.toHaveBeenCalled(); + expect(mockRuntimeAcquireRunLease).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-1", + issueId: null, + agentId: null, + heartbeatRunId: null, + persistedExecutionWorkspace: null, + adapterType: null, + applyCustomImageTemplate: true, + })); + expect(mockRuntimeAcquireRunLease.mock.calls[0]?.[0].environment.config).toMatchObject({ + provider: "daytona", + image: "daytonaio/sandbox:0.8.0", + reuseLease: false, + archiveOnRelease: true, + }); + expect(mockRuntimeReleaseRunLease).toHaveBeenCalledWith(expect.objectContaining({ + lease: expect.objectContaining({ id: "lease-1" }), + status: "released", + })); + }); + it("routes plugin environment probes through the plugin worker host", async () => { mockProbePluginEnvironmentDriver.mockResolvedValue({ ok: true, diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index c59ecc6f74..5b5e1672f4 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -1597,6 +1597,7 @@ describe("environment routes", () => { companyId: null, pluginWorkerManager: undefined, applyCustomImageTemplate: false, + acquireSandboxRuntimeLease: false, }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), @@ -1693,9 +1694,10 @@ describe("environment routes", () => { expect(res.status).toBe(200); expect(res.body.driver).toBe("sandbox"); expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { - companyId: null, + companyId: "company-1", pluginWorkerManager: undefined, applyCustomImageTemplate: true, + acquireSandboxRuntimeLease: true, }); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), @@ -1748,9 +1750,10 @@ describe("environment routes", () => { expect(res.status).toBe(200); expect(res.body.driver).toBe("sandbox"); expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, { - companyId: null, + companyId: "company-1", pluginWorkerManager: undefined, applyCustomImageTemplate: true, + acquireSandboxRuntimeLease: true, }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 4ea28fad78..8de49215b6 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -263,6 +263,7 @@ export function agentRoutes( executionTarget: AdapterExecutionTarget | null; environmentName: string | null; fallbackChecks: AdapterEnvironmentCheck[]; + sandboxIdentityCheck?: AdapterEnvironmentCheck | null; release: (status?: "released" | "failed") => Promise; }> { const noopRelease = async () => {}; @@ -362,11 +363,26 @@ export function agentRoutes( // run id to heartbeat_runs.id, and we don't want to manufacture a fake // run row. Cleanup goes through the driver's `releaseRunLease` directly // (by lease record), since the batch helper queries by heartbeatRunId. + // + // Sandbox tests boot a fresh throwaway sandbox (never resume a retained + // agent lease) and archive it on release instead of deleting it, so the + // operator can inspect the exact sandbox from the provider dashboard while + // provider-side expiry reaps it later. + const testEnvironment = environment.driver === "sandbox" + ? { + ...environment, + config: { + ...(environment.config ?? {}), + reuseLease: false, + archiveOnRelease: true, + }, + } + : environment; let leaseRecord: Awaited>; try { leaseRecord = await environmentRuntime.acquireRunLease({ companyId: input.companyId, - environment, + environment: testEnvironment, issueId: null, heartbeatRunId: null, persistedExecutionWorkspace: null, @@ -398,7 +414,7 @@ export function agentRoutes( try { if (driver) { await driver.releaseRunLease({ - environment, + environment: testEnvironment, lease: leaseRecord.lease, status, }); @@ -417,7 +433,7 @@ export function agentRoutes( let realizedCwd: string | null = null; try { const realized = await environmentRuntime.realizeWorkspace({ - environment, + environment: testEnvironment, lease: leaseRecord.lease, // No host workspace to copy for a Test invocation; sandbox/plugin // realize implementations use the lease metadata's remoteCwd to @@ -458,9 +474,9 @@ export function agentRoutes( companyId: input.companyId, adapterType: input.adapterType, environment: { - id: environment.id, - driver: environment.driver, - config: environment.config ?? null, + id: testEnvironment.id, + driver: testEnvironment.driver, + config: testEnvironment.config ?? null, }, leaseId: leaseRecord.lease.id, leaseMetadata: leaseMetadataForTarget, @@ -505,10 +521,75 @@ export function agentRoutes( executionTarget: target, environmentName: environment.name, fallbackChecks: [], + sandboxIdentityCheck: buildSandboxIdentityCheck({ + environmentName: environment.name, + lease: leaseRecord.lease, + }), release: releaseLease, }; } + function readMetadataString(metadata: Record, keys: string[]): string | null { + for (const key of keys) { + const value = metadata[key]; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + } + return null; + } + + function buildSandboxIdentityCheck(input: { + environmentName: string; + lease: { + id: string; + provider?: string | null; + providerLeaseId?: string | null; + metadata?: Record | null; + }; + }): AdapterEnvironmentCheck { + const metadata = input.lease.metadata ?? {}; + const provider = input.lease.provider ?? readMetadataString(metadata, ["provider"]); + const sandboxId = readMetadataString(metadata, ["sandboxId", "sandboxID", "sandbox_id", "id"]); + const sandboxName = readMetadataString(metadata, ["sandboxName", "sandbox_name", "name"]); + const snapshotRef = readMetadataString(metadata, [ + "snapshot", + "snapshotId", + "snapshotID", + "snapshotRef", + "snapshot_ref", + "templateRef", + "template_ref", + "templateId", + "templateID", + "image", + "imageId", + "imageID", + "imageRef", + "image_ref", + ]); + const templateKind = readMetadataString(metadata, [ + "templateKind", + "template_kind", + "templateRefKind", + "template_ref_kind", + ]); + const detailParts = [ + `paperclipLeaseId=${input.lease.id}`, + input.lease.providerLeaseId ? `providerLeaseId=${input.lease.providerLeaseId}` : null, + provider ? `provider=${provider}` : null, + sandboxId ? `sandboxId=${sandboxId}` : null, + sandboxName ? `sandboxName=${sandboxName}` : null, + snapshotRef ? `${templateKind ? `${templateKind}Ref` : "snapshotOrTemplateRef"}=${snapshotRef}` : null, + ].filter((part): part is string => Boolean(part)); + + return { + code: "sandbox_test_identity", + level: "info", + message: `Sandbox test identity for "${input.environmentName}".`, + detail: detailParts.join("; "), + hint: "Use these provider-neutral IDs when comparing model-test output with provider logs or refreshed sandbox snapshots.", + }; + } + async function getCurrentUserRedactionOptions() { return { enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, @@ -1690,7 +1771,7 @@ export function agentRoutes( { adapterType: type }, ); - const { executionTarget, environmentName, fallbackChecks, release } = + const { executionTarget, environmentName, fallbackChecks, sandboxIdentityCheck, release } = await resolveAdapterTestExecutionContext({ companyId, adapterType: type, @@ -1728,7 +1809,10 @@ export function agentRoutes( }); if (result.status === "fail") releaseStatus = "failed"; - res.json(result); + res.json({ + ...result, + checks: sandboxIdentityCheck ? [sandboxIdentityCheck, ...result.checks] : result.checks, + }); } catch (err) { releaseStatus = "failed"; throw err; diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 6ea542fdcd..9ecb9c2a90 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -777,12 +777,24 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } + let customImageReconciliation: Awaited< + ReturnType + > = { action: "none" }; if (patch.config !== undefined || patch.driver !== undefined) { await secrets.syncSecretRefsForTarget( companyIdForSecrets!, { targetType: "environment", targetId: environment.id }, await collectEnvironmentSecretRefs({ db, environment }), ); + try { + customImageReconciliation = await customImages.reconcileActiveTemplateForConfigChange({ + environmentId: environment.id, + previous: existing, + next: environment, + }); + } catch { + // Reconciliation is best-effort; a failure must not fail the save. + } } if (patch.envVars !== undefined) { await secrets.syncEnvBindingsForTarget( @@ -797,7 +809,9 @@ export function environmentRoutes( entityId: environment.id, details: summarizeEnvironmentUpdate(patch as Record, environment), }); - res.json(environment); + res.json(customImageReconciliation.action === "none" + ? environment + : { ...environment, customImageReconciliation }); }); router.delete("/environments/:id", async (req, res) => { @@ -871,6 +885,8 @@ export function environmentRoutes( assertCanAccessInstanceEnvironments(req); const actor = getActorInfo(req); const companyIdForSecrets = await resolveEnvironmentSecretContextCompanyId(req, environment.id, { required: false }); + const companyIdForProbe = companyIdForSecrets + ?? (environment.driver === "sandbox" ? await resolveCustomImageCompanyId(req) : null); if (!companyIdForSecrets) { const secretRefs = await collectEnvironmentSecretRefs({ db, environment }); if (secretRefs.length > 0) { @@ -880,9 +896,10 @@ export function environmentRoutes( } } const probe = await probeEnvironment(db, environment, { - companyId: companyIdForSecrets, + companyId: companyIdForProbe, pluginWorkerManager: options.pluginWorkerManager, applyCustomImageTemplate: environment.driver === "sandbox", + acquireSandboxRuntimeLease: environment.driver === "sandbox", }); await logInstanceEnvironmentActivity({ actor, diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index 0fe977fca5..054224deb5 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -76,6 +76,7 @@ const fakeSandboxEnvironmentConfigSchema = z.object({ .default("ubuntu:24.04"), reuseLease: z.boolean().optional().default(false), streamRunLogs: z.boolean().optional(), + archiveOnRelease: z.boolean().optional(), }).strict(); const pluginSandboxProviderKeySchema = z.string() @@ -91,6 +92,7 @@ const pluginSandboxEnvironmentConfigSchema = z.object({ timeoutMs: z.coerce.number().int().min(1).max(86_400_000).optional(), reuseLease: z.boolean().optional().default(false), streamRunLogs: z.boolean().optional(), + archiveOnRelease: z.boolean().optional(), }).catchall(z.unknown()); const pluginEnvironmentConfigSchema = z.object({ @@ -633,6 +635,10 @@ export async function resolveEnvironmentDriverConfigForRuntime( environmentId, baseConfig: parsed.config, runtimeConfig, + // Match the capture-time fingerprint exclusions: secret-ref paths + // are excluded when the template's source fingerprint is computed, + // so they must be excluded when re-checking it here. + secretRefExcludePaths: collectSecretRefPaths(schema), }) : runtimeConfig, }; diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts index 9c56321cd4..254169290c 100644 --- a/server/src/services/environment-custom-image-runtime.ts +++ b/server/src/services/environment-custom-image-runtime.ts @@ -8,11 +8,24 @@ import { type EnvironmentCustomImageTemplateKind, type SandboxEnvironmentConfig, } from "@paperclipai/shared"; -import { writeConfigValueAtPath } from "./json-schema-secret-refs.js"; +import { readConfigValueAtPath, writeConfigValueAtPath } from "./json-schema-secret-refs.js"; type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect; export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding"; +export const ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS = [ + "timeoutMs", + "reuseLease", + "streamRunLogs", + "archiveOnRelease", + "cpu", + "memory", + "disk", + "gpu", + "autoStopInterval", + "autoArchiveInterval", + "autoDeleteInterval", +]; export interface EnvironmentCustomImageRuntimeConfigBinding { field: string; @@ -111,6 +124,101 @@ export function applyCustomImageTemplateToSandboxConfig( return next as SandboxEnvironmentConfig; } +export function environmentCustomImageTemplateMatchesBaseConfig(input: { + template: EnvironmentCustomImageTemplate; + baseConfig: SandboxEnvironmentConfig; + secretRefExcludePaths?: Iterable; +}): boolean { + const expectedFingerprint = input.template.sourceEnvironmentConfigFingerprint; + if (!expectedFingerprint) return true; + // Capture-time fingerprints exclude both runtime-only fields and the + // provider's secret-ref paths (see finishSetupSession); the runtime match + // must exclude the same set or configs that carry a secret ref can never + // match and the active template gets silently dropped. + const secretRefExcludePaths = [...(input.secretRefExcludePaths ?? [])]; + const normalizedFingerprint = fingerprintEnvironmentSandboxProviderConfig(input.baseConfig, { + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ...secretRefExcludePaths, + ], + }); + if (normalizedFingerprint === expectedFingerprint) return true; + // Backward compatibility for templates captured before runtime-only fields + // were excluded from the source fingerprint (secret-ref paths have always + // been excluded at capture time). + return fingerprintEnvironmentSandboxProviderConfig(input.baseConfig, { + excludePaths: secretRefExcludePaths, + }) === expectedFingerprint; +} + +// Standard boot-source fields shared across sandbox providers. A change to any +// of these means the user asked for a different base, so a captured template +// no longer reflects the saved config and cannot simply be re-linked. +export const ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS = [ + "snapshot", + "image", + "template", +] as const; + +export type EnvironmentCustomImageConfigChangeKind = "none" | "relinkable" | "breaking"; + +/** + * Classifies a saved-config change relative to an active captured template. + * + * - `none`: the template either already matched the new config, or was already + * detached before this change; nothing to reconcile. + * - `relinkable`: only fields that cannot affect the captured template's + * contents or reachability changed (for example a region hint), so the + * template's source fingerprint can be re-stamped to the new config. + * - `breaking`: a boot-source field or a provider-declared template identity + * path changed; the captured template no longer corresponds to the config + * and a fresh capture is required. + */ +export function classifyEnvironmentCustomImageConfigChange(input: { + template: EnvironmentCustomImageTemplate; + previousConfig: SandboxEnvironmentConfig; + nextConfig: SandboxEnvironmentConfig; + secretRefExcludePaths?: Iterable; + templateIdentityPaths?: Iterable; +}): EnvironmentCustomImageConfigChangeKind { + const secretRefExcludePaths = [...(input.secretRefExcludePaths ?? [])]; + if (!environmentCustomImageTemplateMatchesBaseConfig({ + template: input.template, + baseConfig: input.previousConfig, + secretRefExcludePaths, + })) { + return "none"; + } + if (environmentCustomImageTemplateMatchesBaseConfig({ + template: input.template, + baseConfig: input.nextConfig, + secretRefExcludePaths, + })) { + return "none"; + } + const binding = resolveEnvironmentCustomImageRuntimeConfigBinding({ + templateKind: input.template.templateKind, + metadata: input.template.metadata, + }); + const breakingPaths = new Set([ + "provider", + binding.field, + ...binding.unsetFields, + ...ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS, + ...(input.templateIdentityPaths ?? []), + ]); + const previous = input.previousConfig as Record; + const next = input.nextConfig as Record; + for (const path of breakingPaths) { + const before = readConfigValueAtPath(previous, path); + const after = readConfigValueAtPath(next, path); + if (stableStringify(before ?? null) !== stableStringify(after ?? null)) { + return "breaking"; + } + } + return "relinkable"; +} + export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate { return { id: row.id, @@ -138,6 +246,7 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( environmentId: string; baseConfig: SandboxEnvironmentConfig; runtimeConfig: SandboxEnvironmentConfig; + secretRefExcludePaths?: Iterable; now?: Date; }, ): Promise { @@ -155,15 +264,19 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime( const active = environmentCustomImageTemplateFromRow(row); if (!active.templateRef) return input.runtimeConfig; + if (!environmentCustomImageTemplateMatchesBaseConfig({ + template: active, + baseConfig: input.baseConfig, + secretRefExcludePaths: input.secretRefExcludePaths, + })) { + return input.runtimeConfig; + } // An active template is an explicit, environment+provider-scoped artifact: the // captured snapshot/image fully replaces the base image at create time, so it is - // applied whenever present. We deliberately do not gate on a base-config - // fingerprint — the config dialog re-saves the environment right after capture, - // and ordinary resource/lifecycle tweaks (cpu/memory/lease knobs that don't - // affect image identity and are dropped for snapshot creation) would otherwise - // silently discard the captured setup state and provider metadata. Re-running - // setup supersedes the template when the user wants a fresh capture. + // applied whenever the image/template-defining parts of the base config still + // match. Runtime-only knobs such as lease reuse, timeouts, and resource hints + // are excluded from the fingerprint so those edits do not discard the capture. const now = input.now ?? new Date(); await db .update(environmentCustomImageTemplates) diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts index 1cef716cf5..f8a8449f2f 100644 --- a/server/src/services/environment-custom-images.ts +++ b/server/src/services/environment-custom-images.ts @@ -38,9 +38,12 @@ import { } from "./plugin-environment-driver.js"; import { environmentService } from "./environments.js"; import { + ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + classifyEnvironmentCustomImageConfigChange, fingerprintEnvironmentSandboxProviderConfig, ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY, defaultEnvironmentCustomImageRuntimeConfigBinding, + environmentCustomImageTemplateMatchesBaseConfig, normalizeEnvironmentCustomImageRuntimeConfigBinding, environmentCustomImageTemplateFromRow, readEnvironmentCustomImageTemplateKind as readTemplateKind, @@ -57,10 +60,22 @@ type SetupSessionRow = typeof environmentCustomImageSetupSessions.$inferSelect; export interface EnvironmentCustomImageOverview { activeTemplate: EnvironmentCustomImageTemplate | null; + /** + * Whether the active template's captured fingerprint still matches the + * environment's saved config. `false` means runs silently fall back to the + * base image until the template is re-captured. `null` when unknown (no + * active template, or the config could not be evaluated). + */ + activeTemplateMatchesConfig: boolean | null; activeSession: EnvironmentCustomImageSetupSession | null; latestSession: EnvironmentCustomImageSetupSession | null; } +export type EnvironmentCustomImageReconciliation = + | { action: "none" } + | { action: "relinked"; template: EnvironmentCustomImageTemplate } + | { action: "detached"; template: EnvironmentCustomImageTemplate }; + export interface EnvironmentCustomImageSetupSessionResult { session: EnvironmentCustomImageSetupSession; connectionPayload: PluginEnvironmentInteractiveSetupConnectionPayload | null; @@ -595,17 +610,44 @@ export function environmentCustomImageService( } } + async function templateMatchesEnvironmentConfig( + environment: Environment, + template: EnvironmentCustomImageTemplate, + ): Promise { + try { + const parsed = parseEnvironmentDriverConfig(environment); + if (parsed.driver !== "sandbox") return false; + if (parsed.config.provider !== template.provider) return false; + return environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: parsed.config, + secretRefExcludePaths: parsed.config.provider === "fake" + ? [] + : await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + }); + } catch { + return null; + } + } + return { getOverview: async (input: { environmentId: string; }): Promise => { - await requireEnvironment(input.environmentId); + const environment = await requireEnvironment(input.environmentId); const [activeTemplate, activeSession, latestSession] = await Promise.all([ resolveActiveTemplate(db, input), getActiveSetupSession(input), getLatestSetupSession(input), ]); - return { activeTemplate, activeSession, latestSession }; + return { + activeTemplate, + activeTemplateMatchesConfig: activeTemplate + ? await templateMatchesEnvironmentConfig(environment, activeTemplate) + : null, + activeSession, + latestSession, + }; }, getActiveTemplate: async (input: { @@ -763,7 +805,10 @@ export function environmentCustomImageService( const parsed = parseEnvironmentDriverConfig(environment); const baseFingerprint = parsed.driver === "sandbox" ? fingerprintEnvironmentSandboxProviderConfig(parsed.config, { - excludePaths: await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + ], }) : null; const provider = await resolveSetupProvider({ @@ -876,6 +921,84 @@ export function environmentCustomImageService( return await cancelSession(session, input.reason ?? "cancelled", "cancelled"); }, + /** + * Keeps the active captured template consistent with a just-saved config + * change. Changes that cannot affect the captured contents (for example a + * region hint) re-stamp the template's source fingerprint so it keeps + * applying; boot-source or provider-identity changes report `detached` so + * callers can tell the user a fresh capture is required. Never throws for + * unparseable configs; the save itself must not fail on reconciliation. + */ + reconcileActiveTemplateForConfigChange: async (input: { + environmentId: string; + previous: Pick; + next: Pick; + now?: Date; + }): Promise => { + let previousParsed; + let nextParsed; + try { + previousParsed = parseEnvironmentDriverConfig(input.previous); + nextParsed = parseEnvironmentDriverConfig(input.next); + } catch { + return { action: "none" }; + } + if (previousParsed.driver !== "sandbox") return { action: "none" }; + const template = await resolveActiveTemplate(db, { + environmentId: input.environmentId, + provider: previousParsed.config.provider, + }); + if (!template?.templateRef) return { action: "none" }; + const secretRefExcludePaths = previousParsed.config.provider === "fake" + ? [] + : [...await resolveSandboxProviderSecretRefPaths(db, previousParsed.config.provider)]; + if (!environmentCustomImageTemplateMatchesBaseConfig({ + template, + baseConfig: previousParsed.config, + secretRefExcludePaths, + })) { + // Already detached before this save; leave it alone. + return { action: "none" }; + } + if (nextParsed.driver !== "sandbox" || nextParsed.config.provider !== template.provider) { + return { action: "detached", template }; + } + const resolvedDriver = await resolvePluginSandboxProviderDriverByKey({ + db, + driverKey: template.provider, + }); + if (!resolvedDriver) { + // Without driver metadata the change cannot be classified safely. + return { action: "detached", template }; + } + const changeKind = classifyEnvironmentCustomImageConfigChange({ + template, + previousConfig: previousParsed.config, + nextConfig: nextParsed.config, + secretRefExcludePaths, + templateIdentityPaths: resolvedDriver.driver.templateIdentityPaths ?? [], + }); + if (changeKind === "none") return { action: "none" }; + if (changeKind === "breaking") return { action: "detached", template }; + const now = input.now ?? new Date(); + const nextFingerprint = fingerprintEnvironmentSandboxProviderConfig(nextParsed.config, { + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ...secretRefExcludePaths, + ], + }); + const row = await db + .update(environmentCustomImageTemplates) + .set({ sourceEnvironmentConfigFingerprint: nextFingerprint, updatedAt: now }) + .where(eq(environmentCustomImageTemplates.id, template.id)) + .returning() + .then((rows) => rows[0] ?? null); + return { + action: "relinked", + template: row ? environmentCustomImageTemplateFromRow(row) : template, + }; + }, + rollbackTemplate: async (input: { environmentId: string; now?: Date; diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index eaac5b22e8..ee1bce5d4c 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -10,6 +10,7 @@ import os from "node:os"; import { isBuiltinSandboxProvider, probeSandboxProvider } from "./sandbox-provider-runtime.js"; import { probePluginEnvironmentDriver, probePluginSandboxProviderDriver } from "./plugin-environment-driver.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import { environmentRuntimeService } from "./environment-runtime.js"; export async function probeEnvironment( db: Db, @@ -19,11 +20,14 @@ export async function probeEnvironment( pluginWorkerManager?: PluginWorkerManager; resolvedConfig?: ParsedEnvironmentConfig; applyCustomImageTemplate?: boolean; + acquireSandboxRuntimeLease?: boolean; } = {}, ): Promise { const resolvedCompanyId = options.companyId ?? null; const parsed = options.resolvedConfig ?? ( - resolvedCompanyId || options.applyCustomImageTemplate === true + options.acquireSandboxRuntimeLease === true + ? parseEnvironmentDriverConfig(environment) + : resolvedCompanyId || options.applyCustomImageTemplate === true ? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment, { applyCustomImageTemplate: options.applyCustomImageTemplate === true, }) @@ -43,6 +47,98 @@ export async function probeEnvironment( } if (parsed.driver === "sandbox") { + if (options.acquireSandboxRuntimeLease) { + if (!resolvedCompanyId) { + return { + ok: false, + driver: "sandbox", + summary: "Sandbox environment probe requires a companyId context.", + details: { + provider: parsed.config.provider, + }, + }; + } + + const runtime = environmentRuntimeService(db, { + pluginWorkerManager: options.pluginWorkerManager, + }); + const probeEnvironmentConfig = { + ...environment, + config: { + ...(environment.config ?? {}), + // Test probes should prove a fresh provider boot, not resume a retained + // agent lease and report success without provider-side activity. + reuseLease: false, + // Keep the probe sandbox inspectable in the provider dashboard + // (archived, provider-side expiry) instead of deleting it the moment + // the probe finishes. + archiveOnRelease: true, + }, + }; + let leaseRecord: Awaited> | null = null; + let releaseStatus: "released" | "failed" = "released"; + try { + leaseRecord = await runtime.acquireRunLease({ + companyId: resolvedCompanyId, + environment: probeEnvironmentConfig, + issueId: null, + agentId: null, + heartbeatRunId: null, + persistedExecutionWorkspace: null, + adapterType: null, + applyCustomImageTemplate: options.applyCustomImageTemplate === true, + }); + const metadata = leaseRecord.lease.metadata ?? {}; + const provider = leaseRecord.lease.provider ?? parsed.config.provider; + const sandboxName = typeof metadata.sandboxName === "string" && metadata.sandboxName.trim().length > 0 + ? metadata.sandboxName.trim() + : null; + return { + ok: true, + driver: "sandbox", + summary: sandboxName + ? `Connected to ${provider} sandbox ${sandboxName}.` + : `Connected to ${provider} sandbox environment.`, + details: { + provider, + providerLeaseId: leaseRecord.lease.providerLeaseId, + leaseId: leaseRecord.lease.id, + leasePolicy: leaseRecord.lease.leasePolicy, + metadata, + }, + }; + } catch (error) { + releaseStatus = "failed"; + return { + ok: false, + driver: "sandbox", + summary: `Sandbox environment probe failed for provider "${parsed.config.provider}".`, + details: { + provider: parsed.config.provider, + error: error instanceof Error ? error.message : String(error), + }, + }; + } finally { + if (leaseRecord) { + const driver = runtime.getDriver(environment.driver); + try { + await driver?.releaseRunLease({ + environment: probeEnvironmentConfig, + lease: leaseRecord.lease, + status: releaseStatus, + }); + } catch (releaseError) { + // Cleanup failures must not mask the connection result shown to + // the operator, but a leaked sandbox should still be traceable. + // eslint-disable-next-line no-console + console.warn( + `[environment-probe] Failed to release lease ${leaseRecord.lease.id} for provider "${parsed.config.provider}": ${releaseError instanceof Error ? releaseError.message : String(releaseError)}`, + ); + } + } + } + } + if (!isBuiltinSandboxProvider(parsed.config.provider)) { if (!options.pluginWorkerManager) { return { diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index ab3cf09aaa..030def1198 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -15,10 +15,23 @@ import { api } from "./client"; export interface EnvironmentCustomImageOverview { activeTemplate: EnvironmentCustomImageTemplate | null; + /** + * `false` means the environment config changed since capture and runs fall + * back to the base image until a new image is captured. `null` when unknown. + */ + activeTemplateMatchesConfig?: boolean | null; activeSession: EnvironmentCustomImageSetupSession | null; latestSession: EnvironmentCustomImageSetupSession | null; } +export type EnvironmentCustomImageReconciliation = + | { action: "relinked"; template: EnvironmentCustomImageTemplate } + | { action: "detached"; template: EnvironmentCustomImageTemplate }; + +export type EnvironmentUpdateResult = Environment & { + customImageReconciliation?: EnvironmentCustomImageReconciliation; +}; + export interface EnvironmentCustomImageConnectionPayload { type: string; command?: string | null; @@ -64,8 +77,14 @@ export const environmentsApi = { status?: "active" | "archived"; config?: Record; metadata?: Record | null; - }) => api.patch(`/environments/${environmentId}`, body), - probe: (environmentId: string) => api.post(`/environments/${environmentId}/probe`, {}), + }) => api.patch(`/environments/${environmentId}`, body), + probe: (environmentId: string, companyId?: string | null) => + api.post( + companyId + ? `/environments/${environmentId}/probe?${customImageCompanyQuery(companyId)}` + : `/environments/${environmentId}/probe`, + {}, + ), probeConfig: (companyId: string, body: { name?: string; driver: "local" | "ssh" | "sandbox" | "plugin"; diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index c787bea861..7b148c7978 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -485,7 +485,96 @@ describe("CompanyEnvironments — test provider button", () => { expect(buttonsAfter[0].disabled).toBe(true); expect(buttonsAfter[1].textContent?.trim()).toBe("Test provider"); expect(buttonsAfter[1].disabled).toBe(false); - expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1"); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + }); + + it("explains that successful sandbox provider tests use a temporary sandbox", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.probe.mockResolvedValue({ + ok: true, + driver: "sandbox", + summary: "Connected to Daytona sandbox paperclip-probe.", + details: { + provider: "daytona", + diagnostics: [], + metadata: { + provider: "daytona", + sandboxId: "473167E9", + sandboxName: "paperclip-probe", + }, + }, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => { + testProviderButtons(container)[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + expect(container.textContent).toContain("Connected to Daytona sandbox paperclip-probe."); + expect(container.textContent).not.toContain("Verified temporary daytona sandbox"); + expect(container.textContent).not.toContain("Test probes clean up the validation sandbox after the check"); + expect(container.textContent).not.toContain("provider dashboard"); + }); + + it("does not show sandbox lifecycle success copy for failed sandbox provider tests", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.probe.mockResolvedValue({ + ok: false, + driver: "sandbox", + summary: "Daytona sandbox probe failed.", + details: { + provider: "daytona", + error: "Sandbox image was not found.", + metadata: { + provider: "daytona", + sandboxId: "473167E9", + sandboxName: "paperclip-probe", + }, + }, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => { + testProviderButtons(container)[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(container.textContent).toContain("Daytona sandbox probe failed."); + expect(container.textContent).toContain("Sandbox image was not found."); + expect(container.textContent).not.toContain("Verified temporary daytona sandbox"); + expect(container.textContent).not.toContain("Test probes clean up the validation sandbox after the check"); }); it("keeps the second environment's testing state when an earlier probe settles", async () => { @@ -985,6 +1074,7 @@ describe("CompanyEnvironments — test provider button", () => { it("shows active template controls for refresh, rollback, and disable", async () => { root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const activeTemplateId = "12345678-90ab-cdef-1234-567890abcdef"; mockEnvironmentsApi.list.mockResolvedValue([ { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, ]); @@ -1007,7 +1097,7 @@ describe("CompanyEnvironments — test provider button", () => { }, }); mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ - activeTemplate: createTemplate({ id: "template-active" }), + activeTemplate: createTemplate({ id: activeTemplateId }), activeSession: null, latestSession: null, }); @@ -1027,6 +1117,13 @@ describe("CompanyEnvironments — test provider button", () => { await waitForAssertion(() => { const dialog = getOpenDialog(); expect(dialog?.textContent).toContain("Active template"); + expect(dialog?.textContent).toContain("redacted-template-ref"); + expect(dialog?.textContent).not.toContain("id 12345678-90a"); + expect( + dialog?.querySelector( + "[title='Provider snapshot ref redacted-template-ref (Paperclip template 12345678-90ab-cdef-1234-567890abcdef)']", + ), + ).toBeTruthy(); expect(findButton(dialog!, "Refresh")).toBeTruthy(); expect(findButton(dialog!, "Rollback")).toBeTruthy(); expect(findButton(dialog!, "Disable")).toBeTruthy(); @@ -1038,7 +1135,7 @@ describe("CompanyEnvironments — test provider button", () => { expect(mockEnvironmentsApi.startCustomImageSetupSession).toHaveBeenCalledWith( "env-1", "company-1", - { templateId: "template-active" }, + { templateId: activeTemplateId }, ); await waitForAssertion(() => { expect(getOpenDialog()?.textContent).toContain("Browser terminal"); @@ -1048,6 +1145,130 @@ describe("CompanyEnvironments — test provider button", () => { }); }); + it("allows stale capturing setup sessions to be cancelled back to active template controls", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const activeTemplate = createTemplate({ id: "template-active" }); + let activeSession: ReturnType | null = createSession({ status: "capturing" }); + let latestSession: ReturnType | null = activeSession; + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.customImageTemplate.mockImplementation(async () => ({ + activeTemplate, + activeSession, + latestSession, + })); + mockEnvironmentsApi.customImageSetupSession.mockResolvedValue({ + session: activeSession, + connectionPayload: null, + }); + mockEnvironmentsApi.cancelCustomImageSetupSession.mockImplementation(async () => { + activeSession = null; + latestSession = createSession({ status: "cancelled", finishedAt: "2026-06-25T20:10:00.000Z" }); + return latestSession; + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getOpenDialog()!; + expect(dialog.textContent).toContain("Capturing template"); + expect(dialog.textContent).toContain("Capture is in progress."); + expect(findButton(dialog, "Finished")?.disabled).toBe(true); + expect(findButton(dialog, "Cancel")?.disabled).toBe(false); + }); + + await act(async () => click(findButton(getOpenDialog()!, "Cancel"))); + await waitForAssertion(() => { + const dialog = getOpenDialog()!; + expect(dialog.textContent).toContain("Active template"); + expect(findButton(dialog, "Refresh")).toBeTruthy(); + }); + + expect(mockEnvironmentsApi.cancelCustomImageSetupSession).toHaveBeenCalledExactlyOnceWith( + "session-1", + { reason: "operator cancelled" }, + ); + }); + + it("shows an out-of-sync warning when the active template no longer matches the saved config", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: createTemplate({ id: "template-active" }), + activeTemplateMatchesConfig: false, + activeSession: null, + latestSession: null, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getOpenDialog()!; + expect(dialog.textContent).toContain("Active template"); + expect(dialog.textContent).toContain("Not in use — the environment configuration changed"); + }); + }); + + it("does not show the out-of-sync warning when the active template matches the saved config", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: createTemplate({ id: "template-active" }), + activeTemplateMatchesConfig: true, + activeSession: null, + latestSession: null, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getOpenDialog()!; + expect(dialog.textContent).toContain("Active template"); + expect(dialog.textContent).not.toContain("Not in use"); + }); + }); + it("passes company context when rolling back and disabling an active template", 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 8b0d3c6880..42f040f3e7 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -21,6 +21,7 @@ import { environmentsApi, type EnvironmentCustomImageConnectionPayload, type EnvironmentCustomImageSetupSessionResult, + type EnvironmentUpdateResult, } from "@/api/environments"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { secretsApi } from "@/api/secrets"; @@ -205,6 +206,12 @@ function formatDateTime(value: string | Date | null | undefined): string | null return Number.isNaN(date.getTime()) ? null : date.toLocaleString(); } +function formatShortId(value: string): string { + const normalized = value.trim(); + if (normalized.length <= 12) return normalized; + return `${normalized.slice(0, 12)}…`; +} + function readConnectionCommand(payload: EnvironmentCustomImageConnectionPayload | null | undefined): string | null { return typeof payload?.command === "string" && payload.command.trim().length > 0 ? payload.command @@ -928,13 +935,18 @@ function EnvironmentImageTemplatePanel({ size="sm" variant="ghost" onClick={() => cancelSetupMutation.mutate(session.id)} - disabled={isMutating || isCapturing} + disabled={isMutating} > Cancel + {isCapturing ? ( +
+ Capture is in progress. If this state remains after a refresh or interrupted request, cancel it to return to the active template controls. +
+ ) : null} {session.status === "waiting_for_user" && connectionPayload?.type === "ssh" ? ( ) : null} @@ -961,6 +973,8 @@ function EnvironmentImageTemplatePanel({ } if (activeTemplate) { + const templateRef = activeTemplate.templateRef?.trim() || null; + const templateOutOfSync = overview?.activeTemplateMatchesConfig === false; return (
@@ -968,9 +982,28 @@ function EnvironmentImageTemplatePanel({
Active template
{providerDisplayName} · {activeTemplate.templateKind} + {" · "} + + {templateRef ?? `id ${formatShortId(activeTemplate.id)}`} + {capturedAt ? ` · captured ${capturedAt}` : ""} {lastUsedAt ? ` · last used ${lastUsedAt}` : ""}
+ {templateOutOfSync ? ( +
+ 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. +
+ ) : null}