Files
PaperClipAI/server/src/services/environment-custom-image-runtime.ts
T
Devin Foley 17dde9d3f2 fix(sandbox): keep custom-image snapshots applied to config tests, probes, and saves (#9385)
## 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
2026-07-10 14:32:47 -07:00

287 lines
11 KiB
TypeScript

import { createHash } from "node:crypto";
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { environmentCustomImageTemplates } from "@paperclipai/db";
import {
ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS,
type EnvironmentCustomImageTemplate,
type EnvironmentCustomImageTemplateKind,
type SandboxEnvironmentConfig,
} from "@paperclipai/shared";
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;
unsetFields: string[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
if (isRecord(value)) {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
export function readEnvironmentCustomImageTemplateKind(
value: string | null,
): EnvironmentCustomImageTemplateKind {
return (ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_KINDS as readonly string[]).includes(value ?? "")
? value as EnvironmentCustomImageTemplateKind
: "unknown";
}
export function defaultEnvironmentCustomImageRuntimeConfigBinding(
templateKind: string | null | undefined,
): EnvironmentCustomImageRuntimeConfigBinding {
const kind = readEnvironmentCustomImageTemplateKind(templateKind ?? null);
if (kind === "snapshot") return { field: "snapshot", unsetFields: ["image"] };
if (kind === "image") return { field: "image", unsetFields: ["snapshot"] };
if (kind === "provider_template") return { field: "template", unsetFields: [] };
return { field: "templateRef", unsetFields: [] };
}
function isValidRuntimeConfigBindingField(value: unknown): value is string {
return typeof value === "string"
&& /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value)
&& value !== "provider";
}
export function normalizeEnvironmentCustomImageRuntimeConfigBinding(
value: unknown,
): EnvironmentCustomImageRuntimeConfigBinding | null {
if (!isRecord(value) || !isValidRuntimeConfigBindingField(value.field)) return null;
const unsetFields = Array.isArray(value.unsetFields)
? value.unsetFields.filter((field): field is string =>
isValidRuntimeConfigBindingField(field) && field !== value.field,
)
: [];
return {
field: value.field,
unsetFields: Array.from(new Set(unsetFields)),
};
}
export function resolveEnvironmentCustomImageRuntimeConfigBinding(input: {
templateKind: string | null | undefined;
metadata?: Record<string, unknown> | null;
}): EnvironmentCustomImageRuntimeConfigBinding {
return normalizeEnvironmentCustomImageRuntimeConfigBinding(
input.metadata?.[ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY],
) ?? defaultEnvironmentCustomImageRuntimeConfigBinding(input.templateKind);
}
export function fingerprintEnvironmentSandboxProviderConfig(
config: SandboxEnvironmentConfig,
options?: { excludePaths?: Iterable<string> },
): string {
let normalized = config as Record<string, unknown>;
for (const path of options?.excludePaths ?? []) {
normalized = writeConfigValueAtPath(normalized, path, undefined);
}
return createHash("sha256")
.update(stableStringify(normalized))
.digest("hex");
}
export function applyCustomImageTemplateToSandboxConfig(
config: SandboxEnvironmentConfig,
template: Pick<EnvironmentCustomImageTemplate, "templateKind" | "templateRef" | "metadata">,
): SandboxEnvironmentConfig {
if (!template.templateRef) return config;
const next = { ...(config as Record<string, unknown>) };
const binding = resolveEnvironmentCustomImageRuntimeConfigBinding({
templateKind: template.templateKind,
metadata: template.metadata,
});
for (const field of binding.unsetFields) {
delete next[field];
}
next[binding.field] = template.templateRef;
return next as SandboxEnvironmentConfig;
}
export function environmentCustomImageTemplateMatchesBaseConfig(input: {
template: EnvironmentCustomImageTemplate;
baseConfig: SandboxEnvironmentConfig;
secretRefExcludePaths?: Iterable<string>;
}): 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<string>;
templateIdentityPaths?: Iterable<string>;
}): 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<string>([
"provider",
binding.field,
...binding.unsetFields,
...ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS,
...(input.templateIdentityPaths ?? []),
]);
const previous = input.previousConfig as Record<string, unknown>;
const next = input.nextConfig as Record<string, unknown>;
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,
environmentId: row.environmentId,
provider: row.provider,
templateKind: readEnvironmentCustomImageTemplateKind(row.templateKind),
templateRef: row.templateRef,
sourceTemplateRef: row.sourceTemplateRef ?? null,
sourceEnvironmentConfigFingerprint: row.sourceEnvironmentConfigFingerprint ?? null,
status: row.status,
createdByUserId: row.createdByUserId ?? null,
createdByAgentId: row.createdByAgentId ?? null,
capturedAt: row.capturedAt ?? null,
lastUsedAt: row.lastUsedAt ?? null,
supersededByTemplateId: row.supersededByTemplateId ?? null,
metadata: row.metadata ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export async function resolveActiveEnvironmentCustomImageTemplateForRuntime(
db: Db,
input: {
environmentId: string;
baseConfig: SandboxEnvironmentConfig;
runtimeConfig: SandboxEnvironmentConfig;
secretRefExcludePaths?: Iterable<string>;
now?: Date;
},
): Promise<SandboxEnvironmentConfig> {
const row = await db
.select()
.from(environmentCustomImageTemplates)
.where(and(
eq(environmentCustomImageTemplates.environmentId, input.environmentId),
eq(environmentCustomImageTemplates.provider, input.baseConfig.provider),
eq(environmentCustomImageTemplates.status, "active"),
))
.orderBy(desc(environmentCustomImageTemplates.capturedAt), desc(environmentCustomImageTemplates.createdAt))
.then((rows) => rows[0] ?? null);
if (!row) return input.runtimeConfig;
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 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)
.set({ lastUsedAt: now, updatedAt: now })
.where(eq(environmentCustomImageTemplates.id, active.id));
return applyCustomImageTemplateToSandboxConfig(input.runtimeConfig, active);
}