mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
Introduce company-scoped computer attachments, bounded runner and viewer ownership, persistent files and worktrees, Boat ingress, and native and legacy runtime integration behind the experimental Boat flag. Keep desktop UI and extended acceptance cases in the stacked feature branch. Co-Authored-By: Paperclip <noreply@paperclip.ing>
863 lines
31 KiB
TypeScript
863 lines
31 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 computerEnvironmentConfigSchema = z.object({
|
|
provider: z.literal("boat"),
|
|
sandboxId: z.string().trim().regex(/^bx_[a-zA-Z0-9]+$/, "Enter an existing Boat ID."),
|
|
apiKeySecretRef: secretRefSchema,
|
|
runnerIdleTimeoutMs: z.number().int().min(1_000).max(86_400_000).optional(),
|
|
}).strict();
|
|
|
|
const computerEnvironmentInputSchema = computerEnvironmentConfigSchema.extend({
|
|
apiKeySecretRef: secretRefSchema.optional(),
|
|
apiKey: z.string().trim().min(1).optional(),
|
|
}).refine((value) => value.apiKey || value.apiKeySecretRef, "A Boat API key is required.");
|
|
|
|
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(),
|
|
runnerLifecycleMode: z.enum(["inherit", "per_turn", "warm"]).optional(),
|
|
runnerIdleTimeoutMs: z.coerce
|
|
.number()
|
|
.int()
|
|
.min(1_000)
|
|
.max(86_400_000)
|
|
.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(),
|
|
runnerLifecycleMode: z.enum(["inherit", "per_turn", "warm"]).optional(),
|
|
runnerIdleTimeoutMs: z.coerce
|
|
.number()
|
|
.int()
|
|
.min(1_000)
|
|
.max(86_400_000)
|
|
.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: "computer"; config: z.infer<typeof computerEnvironmentConfigSchema> }
|
|
| { 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" | "mcp_oauth" | "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 === "computer") {
|
|
return [{ secretId: parsed.config.apiKeySecretRef.secretId,
|
|
configPath: "apiKeySecretRef", versionSelector: parsed.config.apiKeySecretRef.version }];
|
|
}
|
|
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,
|
|
streamRunLogs: _streamRunLogs,
|
|
...driverConfig
|
|
} = config as Record<string, unknown>;
|
|
return driverConfig;
|
|
}
|
|
|
|
// The host owns these sandbox run-behavior flags, not the provider plugin. The
|
|
// host passes the whole config to the plugin, so a plugin that allowlists only
|
|
// its provider fields may drop them from its normalized config. Re-apply them
|
|
// from the parsed envelope after plugin normalization. Otherwise a saved
|
|
// environment can silently fall back to per-turn runner behavior even though
|
|
// the caller explicitly selected a warm lifecycle.
|
|
const HOST_OWNED_SANDBOX_FLAGS = [
|
|
"streamRunLogs",
|
|
"runnerLifecycleMode",
|
|
"runnerIdleTimeoutMs",
|
|
] as const;
|
|
|
|
function applyHostOwnedSandboxFlags(
|
|
normalizedConfig: Record<string, unknown>,
|
|
envelope: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const merged: Record<string, unknown> = { ...normalizedConfig };
|
|
for (const key of HOST_OWNED_SANDBOX_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 === "computer") {
|
|
const parsed = computerEnvironmentConfigSchema.safeParse(parseObject(input.config));
|
|
if (!parsed.success) throw unprocessable(toErrorMessage(parsed.error));
|
|
return parsed.data;
|
|
}
|
|
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" | "mcp_oauth" | "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: applyHostOwnedSandboxFlags(
|
|
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 === "computer") {
|
|
const parsed = computerEnvironmentInputSchema.safeParse(parseObject(input.config));
|
|
if (!parsed.success) throw unprocessable(toErrorMessage(parsed.error));
|
|
const { apiKey, ...stored } = parsed.data;
|
|
const apiKeySecretRef = apiKey ? await createEnvironmentSecret({
|
|
db: input.db, companyId: input.companyId, environmentName: input.environmentName,
|
|
driver: input.driver, field: "boat-api-key", provider: input.secretProvider,
|
|
value: apiKey, actor: input.actor,
|
|
}) : stored.apiKeySecretRef;
|
|
return computerEnvironmentConfigSchema.parse({ ...stored, apiKeySecretRef });
|
|
}
|
|
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,
|
|
...applyHostOwnedSandboxFlags(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 === "computer") {
|
|
return { driver: "computer", config: computerEnvironmentConfigSchema.parse(environment.config) };
|
|
}
|
|
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}".`);
|
|
}
|