Files
PaperClipAI/server/src/services/environment-config.ts
T
3abe9e2134 build(deps): bump zod from 3.25.76 to 4.4.3 (#11719)
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.4.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/colinhacks/zod/releases">zod's
releases</a>.</em></p>
<blockquote>
<h2>v4.4.3</h2>
<h2>Commits:</h2>
<ul>
<li>4c2fa95ce3f3390fbc522324e406b4e9e89b88f9 docs: use Zernio primary
wordmark for gold sponsor logo</li>
<li>2aeec83eb135e3a83756e973ef44845fc5a455d2 docs: prune lapsed gold
sponsors and rebalance logo sizing</li>
<li>7391be88ac1ee5cd02057f5ccc012a1f5df4efd0 docs: prune lapsed
silver/bronze sponsors and add active ones</li>
<li>2c703322a21b4e2b12f33f49ea8430c451a68b4f docs: normalize bronze
sponsor logos to github avatar pattern</li>
<li>9195250cab0e7950efe39c3926d6c203b4b0a170 docs: remove Mintlify from
bronze sponsors (churned)</li>
<li>b8dffe9e62f17e6571e6249d05cc5102b54d94e4 docs: remove Numeric and
Speakeasy (2+ missed monthly cycles)</li>
<li>1cab69383fcdeae2a366d5e2a2fc4d8fc765d168 fix(v4): restore catch
handling for absent object keys (<a
href="https://redirect.github.com/colinhacks/zod/issues/5937">#5937</a>)
(<a
href="https://redirect.github.com/colinhacks/zod/issues/5939">#5939</a>)</li>
<li>c2be4f819064eed62c7c350a2d399b5faecd15f8 fix(v4): generalize
optin/fallback to transform; restore preprocess on absent keys (<a
href="https://redirect.github.com/colinhacks/zod/issues/5941">#5941</a>)</li>
<li>f3c9ec03ba7a28ae72d25cc295f38674bee0f559 4.4.3</li>
<li>1fb56a5c18c27102dbc92260a4007c7732a0ccca docs: document release
procedure in AGENTS.md</li>
</ul>
<h2>v4.4.2</h2>
<h2>Commits:</h2>
<ul>
<li>0c62df0ea19fd05abdf90473e9eef7eea530fab2 Clean up docs navigation
and stale labels (<a
href="https://redirect.github.com/colinhacks/zod/issues/5901">#5901</a>)</li>
<li>20cc794895cc8604fe0c87d83a5d1c3f89fad0ac chore: add security policy
and refresh tooling deps</li>
<li>6fbe07b0177efdd1bf1c0b05160e70d7a0702337 fix(docs): heading anchor
links now include the hash so it doesnt scoll all the way up, follows
navbar logic (<a
href="https://redirect.github.com/colinhacks/zod/issues/5791">#5791</a>)</li>
<li>4bbed1b1c73eca4ce9e59b1189ed236aa6c8b5bd Tighten discriminated union
option typing</li>
<li>bbac3e567e7fccfaaf7cdc97f1ce30c295e2c908 Update PR guidance for
agents</li>
<li>cf0dc942a32805c292fff59ade20a7ace980735a Merge remote-tracking
branch 'origin/main' into fix-discriminated-union-key-constraint</li>
<li>292c894a5fd2aa42e527900b83d8d7a3009a709c docs: add Zernio gold
sponsor</li>
<li>1fc9f311c28dcf80d0bb5a36b177086cbc3d8eca docs: document codec
inversion</li>
<li>1373c85da9aeff704a9762d27bc58699618aefb7 docs: remove AI disclosure
guidance</li>
<li>e20d02b473c08e3a4e557bc610b1b5fac079b649 chore: ignore triage
notes</li>
<li>e58ea4d91b1dfe8194b73508203213cbc7e9c936 docs: test Zod Mini tab
code heights</li>
<li>905761a5d127e8d5dd2ebb3bc88c75cb0b8149ff docs: document preprocess
input type narrowing</li>
<li>bf64bac850d4dee2b7dde7e64909d5d796d32043 chore: tighten test
guidance in AGENTS.md</li>
<li>8ec4e73f4c4693b6361ad591be40fb41eb8a9f95 chore: update play.ts
scratch</li>
<li>02c2baf7d0d615872fa4528a8020603b71211702 Make z.preprocess defer
optionality to inner schema (<a
href="https://redirect.github.com/colinhacks/zod/issues/5929">#5929</a>)</li>
<li>88015df8e25c44fb5385eb3ef28935119cd5edea fix(docs): drop deprecated
<code>baseUrl</code> from tsconfig</li>
<li>c59d4474e3b4cad1b323462186cf607178ce8267 4.4.2</li>
</ul>
<h2>v4.4.1</h2>
<h2>Commits:</h2>
<ul>
<li>481f7be4238c83ed58183f921b2646f340a91c6a ci: gate release publishing
on full test workflow</li>
<li>95ccab423aec720b2523c3a64cdc7e3204537cc7 test(v3): restore optional
undefined expectations</li>
<li>cede2c63739a5823d6aa5093d291e9a111da943d fix(v4): reject tuple holes
before required defaults (<a
href="https://redirect.github.com/colinhacks/zod/issues/5900">#5900</a>)</li>
<li>edd0bf0f5ada4a8dc581c259407d7bbad0a71ea7 release: 4.4.1</li>
<li>180d83d1dbe6a59260710cc8637a3dea2281ee56 docs: remove Jazz featured
sponsor</li>
</ul>
<h2>v4.4.0</h2>
<h2>4.4.0</h2>
<p>This is a minor release with a wide set of correctness and soundness
fixes. Some fixes intentionally make Zod stricter, so code that depended
on previously accepted invalid or ambiguous inputs may need small
updates.</p>
<h2>Potentially breaking bug fixes</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/colinhacks/zod/commit/1fb56a5c18c27102dbc92260a4007c7732a0ccca"><code>1fb56a5</code></a>
docs: document release procedure in AGENTS.md</li>
<li><a
href="https://github.com/colinhacks/zod/commit/f3c9ec03ba7a28ae72d25cc295f38674bee0f559"><code>f3c9ec0</code></a>
4.4.3</li>
<li><a
href="https://github.com/colinhacks/zod/commit/c2be4f819064eed62c7c350a2d399b5faecd15f8"><code>c2be4f8</code></a>
fix(v4): generalize optin/fallback to transform; restore preprocess on
absent...</li>
<li><a
href="https://github.com/colinhacks/zod/commit/1cab69383fcdeae2a366d5e2a2fc4d8fc765d168"><code>1cab693</code></a>
fix(v4): restore catch handling for absent object keys (<a
href="https://redirect.github.com/colinhacks/zod/issues/5937">#5937</a>)
(<a
href="https://redirect.github.com/colinhacks/zod/issues/5939">#5939</a>)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/b8dffe9e62f17e6571e6249d05cc5102b54d94e4"><code>b8dffe9</code></a>
docs: remove Numeric and Speakeasy (2+ missed monthly cycles)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/9195250cab0e7950efe39c3926d6c203b4b0a170"><code>9195250</code></a>
docs: remove Mintlify from bronze sponsors (churned)</li>
<li><a
href="https://github.com/colinhacks/zod/commit/2c703322a21b4e2b12f33f49ea8430c451a68b4f"><code>2c70332</code></a>
docs: normalize bronze sponsor logos to github avatar pattern</li>
<li><a
href="https://github.com/colinhacks/zod/commit/7391be88ac1ee5cd02057f5ccc012a1f5df4efd0"><code>7391be8</code></a>
docs: prune lapsed silver/bronze sponsors and add active ones</li>
<li><a
href="https://github.com/colinhacks/zod/commit/2aeec83eb135e3a83756e973ef44845fc5a455d2"><code>2aeec83</code></a>
docs: prune lapsed gold sponsors and rebalance logo sizing</li>
<li><a
href="https://github.com/colinhacks/zod/commit/4c2fa95ce3f3390fbc522324e406b4e9e89b88f9"><code>4c2fa95</code></a>
docs: use Zernio primary wordmark for gold sponsor logo</li>
<li>Additional commits viewable in <a
href="https://github.com/colinhacks/zod/compare/v3.25.76...v4.4.3">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for zod since your current version.</p>
</details>
<br />

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Priya Raman <priya@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-21 00:04:14 -07:00

804 lines
28 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,
parseSecretRefBindingObject,
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().guid(),
version: z.union([z.literal("latest"), z.number().int().positive()]).optional().default("latest"),
}).strict();
const sshEnvironmentConfigSchema = z.object({
host: z.string({ 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({ error: "SSH environments require a username." }).trim().min(1, "SSH environments require a username."),
remoteWorkspacePath: z
.string({ 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.string(), 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";
}
// Operator flags removed when session-output streaming moved to the verified
// capability snapshot. A saved environment config can still carry a removed key.
// The server now decides session-output streaming from the effective capability
// snapshot alone, so no consumer reads these flags. Strip a removed key before
// validation so a strict schema (the fake sandbox) still loads an old config,
// and so the removed flag never reaches the parsed config.
const REMOVED_SANDBOX_CONFIG_KEYS = ["streamAgentSessionOutput"] as const;
function stripRemovedSandboxConfigKeys(raw: Record<string, unknown>): Record<string, unknown> {
if (!REMOVED_SANDBOX_CONFIG_KEYS.some((key) => key in raw)) {
return raw;
}
const next = { ...raw };
for (const key of REMOVED_SANDBOX_CONFIG_KEYS) {
delete next[key];
}
return next;
}
function parseSandboxEnvironmentConfig(
input: Record<string, unknown> | null | undefined,
) {
const raw = stripRemovedSandboxConfigKeys(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,
};
}
/**
* Secret pickers submit `{ type: "secret_ref", secretId, version }` binding
* objects for `format: "secret-ref"` fields, while persisted configs store the
* bare secret id. Collapse binding objects to the secret id so every consumer
* downstream deals with one shape. Sandbox provider references always resolve
* the latest version, so pinned bindings are rejected rather than silently
* resolved to a different version than the caller asked for.
*/
function canonicalizeSecretRefValue(value: unknown, path: string): unknown {
const binding = parseSecretRefBindingObject(value);
if (!binding) return value;
if (binding.version !== "latest") {
throw unprocessable(
`Secret binding at ${path} pins version ${binding.version}; sandbox provider secret references always resolve the latest version.`,
);
}
return binding.secretId;
}
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 = canonicalizeSecretRefValue(readConfigValueAtPath(nextConfig, path), 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 = canonicalizeSecretRefValue(readConfigValueAtPath(nextConfig, path), 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 = canonicalizeSecretRefValue(readConfigValueAtPath(nextConfig, path), 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);
const binding = parseSecretRefBindingObject(current);
if (binding) {
refs.push({ secretId: binding.secretId, configPath: path, versionSelector: binding.version });
continue;
}
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;
}
// The host owns this sandbox run-behavior flag, not the provider plugin. The
// host reads it to select the run-log stream. The host passes the whole config
// to the plugin, so a plugin that allowlists its own driver fields drops the
// flag from its normalized config. Re-apply it from the parsed envelope after
// the plugin normalizes, or a saved environment loses the operator opt-in and
// the stream never starts.
const HOST_OWNED_SANDBOX_STREAM_FLAGS = [
"streamRunLogs",
] as const;
function applyHostOwnedSandboxStreamFlags(
normalizedConfig: Record<string, unknown>,
envelope: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...normalizedConfig };
for (const key of HOST_OWNED_SANDBOX_STREAM_FLAGS) {
if (envelope[key] !== undefined) {
merged[key] = envelope[key];
}
}
return merged;
}
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: applyHostOwnedSandboxStreamFlags(validated.normalizedConfig, parsed.data),
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,
...applyHostOwnedSandboxStreamFlags(validated.normalizedConfig, parsed.data),
},
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 (parseSecretRefBindingObject(current) || (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;
}
/**
* Resolve the connection secrets of a recorded sandbox config for a durable
* orphan-sandbox teardown. The retry reads the recorded config from the durable
* `pending_cleanup` lease row, not from the current environment. So this
* resolver must not require the environment binding: a delete removed the
* environment, or a provider change replaced the binding. It resolves each
* schema-declared secret ref by id at the latest version through
* `resolveSecretValueForSandboxCleanup`, which authorizes the read from the
* durable orphan record and skips the binding check. The resolved values are
* used once for the teardown RPC and never persisted.
*/
export async function resolveSandboxCleanupConfigSecrets(
db: Db,
companyId: string,
config: SandboxEnvironmentConfig,
context?: { issueId?: string | null; heartbeatRunId?: string | null },
): Promise<SandboxEnvironmentConfig> {
if (config.provider === "fake") return config;
const schema = await getSandboxProviderConfigSchema(db, config.provider);
const secrets = secretService(db);
let nextConfig = { ...config } as Record<string, unknown>;
for (const path of collectSecretRefPaths(schema)) {
const current = canonicalizeSecretRefValue(readConfigValueAtPath(nextConfig, path), path);
if (typeof current !== "string") continue;
const trimmed = current.trim();
if (!isUuidSecretRef(trimmed)) continue;
nextConfig = writeConfigValueAtPath(
nextConfig,
path,
await secrets.resolveSecretValueForSandboxCleanup(companyId, trimmed, "latest", {
configPath: path,
issueId: context?.issueId ?? null,
heartbeatRunId: context?.heartbeatRunId ?? null,
}),
);
}
return nextConfig as SandboxEnvironmentConfig;
}
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}".`);
}