Files
PaperClipAI/server/src/services/environment-config.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

698 lines
24 KiB
TypeScript

import { z } from "zod";
import { randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
import type {
Environment,
EnvironmentDriver,
FakeSandboxEnvironmentConfig,
LocalEnvironmentConfig,
PluginEnvironmentConfig,
PluginSandboxEnvironmentConfig,
SandboxEnvironmentConfig,
SecretProvider,
SecretVersionSelector,
SshEnvironmentConfig,
} from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
import { parseObject } from "../adapters/utils.js";
import { secretService } from "./secrets.js";
import {
resolvePluginSandboxProviderDriverByKey,
validatePluginEnvironmentDriverConfig,
validatePluginSandboxProviderConfig,
} from "./plugin-environment-driver.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import {
collectSecretRefPaths,
isUuidSecretRef,
readConfigValueAtPath,
writeConfigValueAtPath,
} from "./json-schema-secret-refs.js";
import { resolveActiveEnvironmentCustomImageTemplateForRuntime } from "./environment-custom-image-runtime.js";
const secretRefSchema = z.object({
type: z.literal("secret_ref"),
secretId: z.string().uuid(),
version: z.union([z.literal("latest"), z.number().int().positive()]).optional().default("latest"),
}).strict();
const sshEnvironmentConfigSchema = z.object({
host: z.string({ required_error: "SSH environments require a host." }).trim().min(1, "SSH environments require a host."),
port: z.coerce.number().int().min(1).max(65535).default(22),
username: z.string({ required_error: "SSH environments require a username." }).trim().min(1, "SSH environments require a username."),
remoteWorkspacePath: z
.string({ required_error: "SSH environments require a remote workspace path." })
.trim()
.min(1, "SSH environments require a remote workspace path.")
.refine((value) => value.startsWith("/"), "SSH remote workspace path must be absolute."),
privateKey: z.null().optional().default(null),
privateKeySecretRef: secretRefSchema.optional().nullable().default(null),
knownHosts: z
.string()
.trim()
.optional()
.nullable()
.transform((value) => (value && value.length > 0 ? value : null)),
strictHostKeyChecking: z.boolean().optional().default(true),
}).strict();
const sshEnvironmentConfigProbeSchema = sshEnvironmentConfigSchema.extend({
privateKey: z
.string()
.trim()
.optional()
.nullable()
.transform((value) => (value && value.length > 0 ? value : null)),
}).strict();
const sshEnvironmentConfigPersistenceSchema = sshEnvironmentConfigProbeSchema;
const fakeSandboxEnvironmentConfigSchema = z.object({
provider: z.literal("fake").default("fake"),
image: z
.string()
.trim()
.min(1, "Fake sandbox environments require an image.")
.default("ubuntu:24.04"),
reuseLease: z.boolean().optional().default(false),
streamRunLogs: z.boolean().optional(),
archiveOnRelease: z.boolean().optional(),
}).strict();
const pluginSandboxProviderKeySchema = z.string()
.trim()
.min(1, "Sandbox provider is required.")
.regex(
/^[a-z0-9][a-z0-9._-]*$/,
"Sandbox provider key must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores",
);
const pluginSandboxEnvironmentConfigSchema = z.object({
provider: pluginSandboxProviderKeySchema,
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({
pluginKey: z.string().min(1),
driverKey: z.string().min(1).regex(
/^[a-z0-9][a-z0-9._-]*$/,
"Environment driver key must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores",
),
driverConfig: z.record(z.unknown()).optional().default({}),
}).strict();
export type ParsedEnvironmentConfig =
| { driver: "local"; config: LocalEnvironmentConfig }
| { driver: "ssh"; config: SshEnvironmentConfig }
| { driver: "sandbox"; config: SandboxEnvironmentConfig }
| { driver: "plugin"; config: PluginEnvironmentConfig };
function toErrorMessage(error: z.ZodError) {
const first = error.issues[0];
if (!first) return "Invalid environment config.";
return first.message;
}
function getSandboxProvider(raw: Record<string, unknown>) {
return typeof raw.provider === "string" && raw.provider.trim().length > 0 ? raw.provider.trim() : "fake";
}
function parseSandboxEnvironmentConfig(
input: Record<string, unknown> | null | undefined,
) {
const raw = parseObject(input);
const provider = getSandboxProvider(raw);
if (provider === "fake") {
const parsed = fakeSandboxEnvironmentConfigSchema.safeParse(raw);
return parsed.success
? ({ success: true as const, data: parsed.data satisfies FakeSandboxEnvironmentConfig })
: ({ success: false as const, error: parsed.error });
}
const parsed = pluginSandboxEnvironmentConfigSchema.safeParse(raw);
return parsed.success
? ({ success: true as const, data: parsed.data satisfies PluginSandboxEnvironmentConfig })
: ({ success: false as const, error: parsed.error });
}
async function getSandboxProviderConfigSchema(
db: Db,
provider: string,
): Promise<Record<string, unknown> | null> {
const resolved = await resolvePluginSandboxProviderDriverByKey({
db,
driverKey: provider,
});
const schema = resolved?.driver.configSchema;
return schema && typeof schema === "object" && !Array.isArray(schema)
? schema as Record<string, unknown>
: null;
}
export async function resolveSandboxProviderSecretRefPaths(
db: Db,
provider: string,
): Promise<Set<string>> {
return collectSecretRefPaths(await getSandboxProviderConfigSchema(db, provider));
}
function secretName(input: {
environmentName: string;
driver: EnvironmentDriver;
field: string;
}) {
const slug = input.environmentName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "environment";
return `environment-${input.driver}-${slug}-${input.field}-${randomUUID().slice(0, 8)}`;
}
async function createEnvironmentSecret(input: {
db: Db;
companyId: string;
environmentName: string;
driver: EnvironmentDriver;
field: string;
provider: SecretProvider;
value: string;
actor?: { userId?: string | null; agentId?: string | null };
}) {
const created = await secretService(input.db).create(
input.companyId,
{
name: secretName(input),
provider: input.provider,
value: input.value,
description: `Secret for ${input.environmentName} ${input.field}.`,
},
input.actor,
);
return {
type: "secret_ref" as const,
secretId: created.id,
version: "latest" as const,
};
}
async function persistConfigSecretRefs(input: {
db: Db;
companyId: string;
environmentName: string;
driver: EnvironmentDriver;
secretProvider: SecretProvider;
config: Record<string, unknown>;
schema: Record<string, unknown> | null;
actor?: { userId?: string | null; agentId?: string | null };
}): Promise<Record<string, unknown>> {
let nextConfig = { ...input.config };
for (const path of collectSecretRefPaths(input.schema)) {
const rawValue = readConfigValueAtPath(nextConfig, path);
if (typeof rawValue !== "string") continue;
const trimmed = rawValue.trim();
if (trimmed.length === 0) {
nextConfig = writeConfigValueAtPath(nextConfig, path, undefined);
continue;
}
if (isUuidSecretRef(trimmed)) {
nextConfig = writeConfigValueAtPath(nextConfig, path, trimmed);
continue;
}
const created = await createEnvironmentSecret({
db: input.db,
companyId: input.companyId,
environmentName: input.environmentName,
driver: input.driver,
field: path.replace(/[^a-z0-9]+/gi, "-").toLowerCase(),
provider: input.secretProvider,
value: trimmed,
actor: input.actor,
});
nextConfig = writeConfigValueAtPath(nextConfig, path, created.secretId);
}
return nextConfig;
}
async function resolveConfigSecretRefsForRuntime(input: {
db: Db;
companyId: string;
config: Record<string, unknown>;
schema: Record<string, unknown> | null;
context: {
consumerId: string;
issueId?: string | null;
heartbeatRunId?: string | null;
};
}): Promise<Record<string, unknown>> {
const secrets = secretService(input.db);
let nextConfig = { ...input.config };
for (const path of collectSecretRefPaths(input.schema)) {
const current = readConfigValueAtPath(nextConfig, path);
if (typeof current !== "string") continue;
const trimmed = current.trim();
if (!isUuidSecretRef(trimmed)) continue;
if (!input.context.consumerId) {
throw unprocessable("Runtime secret resolution requires an environment id");
}
nextConfig = writeConfigValueAtPath(
nextConfig,
path,
await secrets.resolveSecretValue(input.companyId, trimmed, "latest", {
consumerType: "environment",
consumerId: input.context.consumerId,
actorType: "system",
actorId: null,
issueId: input.context.issueId ?? null,
heartbeatRunId: input.context.heartbeatRunId ?? null,
configPath: path,
}),
);
}
return nextConfig;
}
async function resolveConfigSecretRefsForProbe(input: {
db: Db;
companyId: string;
config: Record<string, unknown>;
schema: Record<string, unknown> | null;
accessContext?: {
actorType: "agent" | "user";
actorId: string;
actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant";
heartbeatRunId?: string | null;
};
}): Promise<Record<string, unknown>> {
const secrets = secretService(input.db);
let nextConfig = { ...input.config };
for (const path of collectSecretRefPaths(input.schema)) {
const current = readConfigValueAtPath(nextConfig, path);
if (typeof current !== "string") continue;
const trimmed = current.trim();
if (!isUuidSecretRef(trimmed)) continue;
// Unsaved draft probes do not have an environment record yet, so they
// cannot rely on environment-bound secret resolution. Resolve directly for
// this ephemeral board-only probe and never persist the plaintext value.
nextConfig = writeConfigValueAtPath(
nextConfig,
path,
await secrets.resolveSecretValueForEphemeralAccess(input.companyId, trimmed, "latest", {
consumerType: "system",
consumerId: "environment-probe-config",
configPath: path,
actorType: input.accessContext?.actorType ?? "system",
actorId: input.accessContext?.actorId ?? null,
actorSource: input.accessContext?.actorSource,
heartbeatRunId: input.accessContext?.heartbeatRunId ?? null,
}),
);
}
return nextConfig;
}
export async function collectEnvironmentSecretRefs(input: {
db: Db;
environment: Pick<Environment, "id" | "driver" | "config">;
}): Promise<Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }>> {
const parsed = parseEnvironmentDriverConfig(input.environment);
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
return [{
secretId: parsed.config.privateKeySecretRef.secretId,
configPath: "privateKeySecretRef",
versionSelector: parsed.config.privateKeySecretRef.version ?? "latest",
}];
}
if (parsed.driver === "sandbox" && parsed.config.provider !== "fake") {
const schema = await getSandboxProviderConfigSchema(input.db, parsed.config.provider);
const refs: Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector }> = [];
for (const path of collectSecretRefPaths(schema)) {
const current = readConfigValueAtPath(parsed.config as Record<string, unknown>, path);
if (typeof current === "string" && isUuidSecretRef(current.trim())) {
refs.push({ secretId: current.trim(), configPath: path, versionSelector: "latest" });
}
}
return refs;
}
return [];
}
export function stripSandboxProviderEnvelope(config: SandboxEnvironmentConfig): Record<string, unknown> {
const { provider: _provider, ...driverConfig } = config as Record<string, unknown>;
return driverConfig;
}
export function normalizeEnvironmentConfig(input: {
driver: EnvironmentDriver;
config: Record<string, unknown> | null | undefined;
}): Record<string, unknown> {
if (input.driver === "local") {
return { ...parseObject(input.config) };
}
if (input.driver === "ssh") {
const parsed = sshEnvironmentConfigSchema.safeParse(parseObject(input.config));
if (!parsed.success) {
throw unprocessable(toErrorMessage(parsed.error), {
issues: parsed.error.issues,
});
}
return parsed.data satisfies SshEnvironmentConfig;
}
if (input.driver === "sandbox") {
const parsed = parseSandboxEnvironmentConfig(input.config);
if (!parsed.success) {
throw unprocessable(toErrorMessage(parsed.error), {
issues: parsed.error.issues,
});
}
return parsed.data;
}
if (input.driver === "plugin") {
const parsed = pluginEnvironmentConfigSchema.safeParse(parseObject(input.config));
if (!parsed.success) {
throw unprocessable(toErrorMessage(parsed.error), {
issues: parsed.error.issues,
});
}
return parsed.data satisfies PluginEnvironmentConfig;
}
throw unprocessable(`Unsupported environment driver "${input.driver}".`);
}
export function normalizeEnvironmentConfigForProbe(input: {
db: Db;
companyId: string;
driver: EnvironmentDriver;
config: Record<string, unknown> | null | undefined;
accessContext?: {
actorType: "agent" | "user";
actorId: string;
actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant";
heartbeatRunId?: string | null;
};
pluginWorkerManager?: PluginWorkerManager;
}): Promise<Record<string, unknown>> | Record<string, unknown> {
if (input.driver === "ssh") {
const parsed = sshEnvironmentConfigProbeSchema.safeParse(parseObject(input.config));
if (!parsed.success) {
throw unprocessable(toErrorMessage(parsed.error), {
issues: parsed.error.issues,
});
}
return parsed.data satisfies SshEnvironmentConfig;
}
if (input.driver === "sandbox") {
const parsed = parseSandboxEnvironmentConfig(input.config);
if (!parsed.success) {
throw unprocessable(toErrorMessage(parsed.error), {
issues: parsed.error.issues,
});
}
if (parsed.data.provider === "fake") {
return parsed.data;
}
if (!input.pluginWorkerManager) {
throw unprocessable("Sandbox provider config validation requires a running plugin worker manager.");
}
return validatePluginSandboxProviderConfig({
db: input.db,
workerManager: input.pluginWorkerManager,
provider: parsed.data.provider,
config: stripSandboxProviderEnvelope(parsed.data),
}).then(async (validated) => ({
provider: parsed.data.provider,
...(await resolveConfigSecretRefsForProbe({
db: input.db,
companyId: input.companyId,
config: validated.normalizedConfig,
accessContext: input.accessContext,
schema:
validated.driver.configSchema &&
typeof validated.driver.configSchema === "object" &&
!Array.isArray(validated.driver.configSchema)
? validated.driver.configSchema as Record<string, unknown>
: null,
})),
}));
}
return normalizeEnvironmentConfig({
driver: input.driver,
config: input.config,
});
}
export async function normalizeEnvironmentConfigForPersistence(input: {
db: Db;
companyId: string;
environmentName: string;
driver: EnvironmentDriver;
secretProvider: SecretProvider;
config: Record<string, unknown> | null | undefined;
actor?: { userId?: string | null; agentId?: string | null };
pluginWorkerManager?: PluginWorkerManager;
}): Promise<Record<string, unknown>> {
if (input.driver === "ssh") {
const parsed = sshEnvironmentConfigPersistenceSchema.safeParse(parseObject(input.config));
if (!parsed.success) {
throw unprocessable(toErrorMessage(parsed.error), {
issues: parsed.error.issues,
});
}
const secrets = secretService(input.db);
const { privateKey, ...stored } = parsed.data;
let nextPrivateKeySecretRef = stored.privateKeySecretRef;
if (privateKey) {
nextPrivateKeySecretRef = await createEnvironmentSecret({
db: input.db,
companyId: input.companyId,
environmentName: input.environmentName,
driver: input.driver,
field: "private-key",
provider: input.secretProvider,
value: privateKey,
actor: input.actor,
});
if (
stored.privateKeySecretRef &&
stored.privateKeySecretRef.secretId !== nextPrivateKeySecretRef.secretId
) {
await secrets.remove(stored.privateKeySecretRef.secretId);
}
}
return {
...stored,
privateKey: null,
privateKeySecretRef: nextPrivateKeySecretRef,
} satisfies SshEnvironmentConfig;
}
if (input.driver === "sandbox") {
const parsed = parseSandboxEnvironmentConfig(input.config);
if (!parsed.success) {
throw unprocessable(toErrorMessage(parsed.error), {
issues: parsed.error.issues,
});
}
if (parsed.data.provider === "fake") {
throw unprocessable(
"Built-in fake sandbox environments are reserved for internal probes and cannot be saved.",
);
}
if (!input.pluginWorkerManager) {
throw unprocessable("Sandbox provider config validation requires a running plugin worker manager.");
}
const validated = await validatePluginSandboxProviderConfig({
db: input.db,
workerManager: input.pluginWorkerManager,
provider: parsed.data.provider,
config: stripSandboxProviderEnvelope(parsed.data),
});
return await persistConfigSecretRefs({
db: input.db,
companyId: input.companyId,
environmentName: input.environmentName,
driver: input.driver,
secretProvider: input.secretProvider,
config: {
provider: parsed.data.provider,
...validated.normalizedConfig,
},
schema:
validated.driver.configSchema && typeof validated.driver.configSchema === "object" && !Array.isArray(validated.driver.configSchema)
? validated.driver.configSchema as Record<string, unknown>
: null,
actor: input.actor,
});
}
if (input.driver === "plugin") {
const parsed = pluginEnvironmentConfigSchema.safeParse(parseObject(input.config));
if (!parsed.success) {
throw unprocessable(toErrorMessage(parsed.error), {
issues: parsed.error.issues,
});
}
if (!input.pluginWorkerManager) {
throw unprocessable("Plugin environment config validation requires a running plugin worker manager.");
}
return { ...(await validatePluginEnvironmentDriverConfig({
db: input.db,
workerManager: input.pluginWorkerManager,
config: parsed.data,
})) };
}
return normalizeEnvironmentConfig({
driver: input.driver,
config: input.config,
});
}
export async function resolveEnvironmentDriverConfigForRuntime(
db: Db,
companyId: string | null,
environment: Pick<Environment, "driver" | "config"> & Partial<Pick<Environment, "id">>,
context?: {
issueId?: string | null;
heartbeatRunId?: string | null;
// Force applying the active custom-image template even without a run/issue
// context. Operator-initiated `Test` probes have no issueId/heartbeatRunId
// but must still resolve the active custom image as prepared runtime
// configuration and tooling so the test reflects what real agent runs use.
applyCustomImageTemplate?: boolean;
},
): Promise<ParsedEnvironmentConfig> {
const parsed = parseEnvironmentDriverConfig(environment);
const secrets = secretService(db);
const environmentId = environment.id;
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef && !environmentId) {
throw unprocessable("Runtime secret resolution requires an environment id");
}
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
if (!companyId) {
throw unprocessable("Runtime secret resolution requires a companyId context");
}
return {
driver: "ssh",
config: {
...parsed.config,
privateKey: await secrets.resolveSecretValue(
companyId,
parsed.config.privateKeySecretRef.secretId,
parsed.config.privateKeySecretRef.version ?? "latest",
{
consumerType: "environment",
consumerId: environmentId!,
actorType: "system",
actorId: null,
issueId: context?.issueId ?? null,
heartbeatRunId: context?.heartbeatRunId ?? null,
configPath: "privateKeySecretRef",
},
),
},
};
}
if (parsed.driver === "sandbox" && parsed.config.provider !== "fake") {
const schema = await getSandboxProviderConfigSchema(db, parsed.config.provider);
let runtimeConfig = parsed.config;
if (companyId) {
runtimeConfig = await resolveConfigSecretRefsForRuntime({
db,
companyId,
config: parsed.config as Record<string, unknown>,
schema,
context: {
consumerId: environmentId!,
issueId: context?.issueId ?? null,
heartbeatRunId: context?.heartbeatRunId ?? null,
},
}) as SandboxEnvironmentConfig;
} else {
for (const path of collectSecretRefPaths(schema)) {
const current = readConfigValueAtPath(parsed.config as Record<string, unknown>, path);
if (typeof current === "string" && isUuidSecretRef(current.trim())) {
throw unprocessable("Runtime secret resolution requires a companyId context");
}
}
}
return {
driver: "sandbox",
config: environmentId && (context?.issueId || context?.heartbeatRunId || context?.applyCustomImageTemplate)
? await resolveActiveEnvironmentCustomImageTemplateForRuntime(db, {
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,
};
}
return parsed;
}
export function readSshEnvironmentPrivateKeySecretId(
environment: Pick<Environment, "driver" | "config">,
): string | null {
if (environment.driver !== "ssh") return null;
const parsed = sshEnvironmentConfigSchema.safeParse(parseObject(environment.config));
if (!parsed.success) return null;
return parsed.data.privateKeySecretRef?.secretId ?? null;
}
export function parseEnvironmentDriverConfig(
environment: Pick<Environment, "driver" | "config">,
): ParsedEnvironmentConfig {
if (environment.driver === "local") {
return {
driver: "local",
config: { ...parseObject(environment.config) },
};
}
if (environment.driver === "ssh") {
const parsed = sshEnvironmentConfigSchema.parse(parseObject(environment.config));
return {
driver: "ssh",
config: parsed,
};
}
if (environment.driver === "sandbox") {
const parsed = parseSandboxEnvironmentConfig(environment.config);
if (!parsed.success) {
throw parsed.error;
}
return {
driver: "sandbox",
config: parsed.data,
};
}
if (environment.driver === "plugin") {
const parsed = pluginEnvironmentConfigSchema.parse(parseObject(environment.config));
return {
driver: "plugin",
config: parsed,
};
}
throw new Error(`Unsupported environment driver "${environment.driver}".`);
}