From c7ca51b50f557729e0fc44c34a3c5b292a8c23a2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 24 Jul 2026 23:50:40 +0000 Subject: [PATCH 1/3] feat: add external MySQL runtime provider --- package.json | 2 +- packages/cli/src/commands/recipe-run.ts | 7 +- packages/cli/src/recipe-validation.ts | 13 +- packages/cli/src/runtime-services.ts | 214 ++++++++++++++++-- packages/runtime-core/src/recipe-schema.ts | 9 + .../runtime-core/src/runtime-contracts.ts | 5 + tests/external-mysql-runtime-service.test.ts | 178 +++++++++++++++ 7 files changed, 409 insertions(+), 19 deletions(-) create mode 100644 tests/external-mysql-runtime-service.test.ts diff --git a/package.json b/package.json index cdbeaffd7..697157534 100644 --- a/package.json +++ b/package.json @@ -247,7 +247,7 @@ "test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts", "test:provider-runtime-contracts": "tsx tests/provider-runtime-contracts.test.ts", "test:runtime-requirements-readiness": "tsx tests/runtime-requirements-readiness.test.ts", - "test:runtime-services": "tsx tests/runtime-services.test.ts", + "test:runtime-services": "tsx tests/runtime-services.test.ts && tsx tests/external-mysql-runtime-service.test.ts", "test:runtime-services-lifecycle": "tsx tests/runtime-services-lifecycle.test.ts", "test:disposable-mysql-mysqli-e2e": "tsx tests/disposable-mysql-mysqli.integration.test.ts", "test:runtime-contract-manifest": "tsx tests/runtime-contract-manifest.test.ts", diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index c0fd6316b..5b20e7eb0 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -140,7 +140,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe } const secretEnvResolution = resolveRecipeSecretEnv(recipe.inputs?.secretEnv ?? [], { field: "--secret-env name" }) const secretEnv = secretEnvResolution.values - const effectivePolicy = Object.keys(secretEnv).length > 0 ? { ...policy, secrets: "connector-scoped" as const } : policy + let effectivePolicy = Object.keys(secretEnv).length > 0 ? { ...policy, secrets: "connector-scoped" as const } : policy let workspaceMounts: PreparedWorkspaceMount[] = [] let extraPlugins: PreparedExtraPlugin[] = [] let dependencyOverlays: PreparedDependencyOverlay[] = [] @@ -198,6 +198,11 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe )) serviceEvidence = managedServices.evidence Object.assign(runtimeEnv, managedServices.env) + for (const [name, value] of Object.entries(managedServices.secretEnv)) { + delete runtimeEnv[name] + secretEnv[name] = value + } + if (Object.keys(managedServices.secretEnv).length > 0) effectivePolicy = { ...policy, secrets: "connector-scoped" } const runtimeEnvironment = { kind: "wordpress" as const, name: plan.runtime.name, diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index d3fd54b37..8e0a7adf8 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -733,17 +733,28 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe, function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: string, path: string, message: string) => void): void { const ids = new Set() - const environment = new Set([ + const exposedEnvironment = new Set([ ...Object.keys(recipe.distribution?.env ?? {}), ...Object.keys(recipe.inputs?.runtimeEnv ?? {}), ...(recipe.inputs?.secretEnv ?? []), ]) + const environment = new Set(exposedEnvironment) for (const [index, service] of (recipe.inputs?.services ?? []).entries()) { const path = `$.inputs.services[${index}]` if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(service.id)) addIssue("invalid-runtime-service-id", `${path}.id`, "Runtime service ids must be stable identifiers.") if (ids.has(service.id)) addIssue("duplicate-runtime-service-id", `${path}.id`, `Runtime service ids must be unique: ${service.id}`) ids.add(service.id) if (!["mysql", "redis", "smtp", "http"].includes(service.kind)) addIssue("unsupported-runtime-service-kind", `${path}.kind`, `Unsupported managed runtime service kind: ${service.kind}`) + if (service.configuration?.provider === "external") { + if (service.kind !== "mysql") addIssue("unsupported-runtime-service-provider", `${path}.configuration.provider`, "The external provider supports only MySQL-compatible services.") + for (const field of ["hostEnv", "portEnv", "usernameEnv", "passwordEnv"] as const) { + const name = service.configuration[field] + if (name && exposedEnvironment.has(name)) addIssue("runtime-service-admin-env-exposed", `${path}.configuration.${field}`, `External service administration environment must remain host-only: ${name}`) + } + for (const field of ["image", "rootAuthentication", "foreignKeyTargetPolicy"] as const) { + if (service.configuration[field] !== undefined) addIssue("unsupported-external-runtime-service-option", `${path}.configuration.${field}`, `External MySQL services do not support ${field}.`) + } + } const supportedOutputs: Record = { mysql: /^(host|port|username|password|database)$/, redis: /^(host|port|url)$/, diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index 811205d5b..58d437387 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -1,10 +1,8 @@ -import { execFile } from "node:child_process" +import { spawn } from "node:child_process" import { randomBytes } from "node:crypto" import { createConnection } from "node:net" -import { promisify } from "node:util" import type { WorkspaceRecipeRuntimeService } from "@automattic/wp-codebox-core" -const execFileAsync = promisify(execFile) const MYSQL_IMAGES = { mysql: "mysql:8.4", mariadb: "mariadb:11.4" } as const const SERVICE_IMAGES = { redis: "redis:7.4-alpine", smtp: "axllent/mailpit:v1.27", http: "hashicorp/http-echo:1.0" } as const @@ -50,15 +48,17 @@ export function runtimeServiceEvidenceFromError(error: unknown): RuntimeServiceE interface ManagedRuntimeService { env: Record + secretEnv: Record evidence: RuntimeServiceEvidence release(): Promise control(action: RuntimeServiceControlAction, options?: Record): Promise } export interface RuntimeServiceDependencies { - execute(command: string, args: string[], options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeout: number }): Promise<{ stdout: string }> + execute(command: string, args: string[], options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeout: number; stdin?: string }): Promise<{ stdout: string }> waitForReady(host: string, port: number, timeoutMs: number, signal?: AbortSignal): Promise randomBytes(size: number): Buffer + environment?: Record } export interface RuntimeServiceProvider { @@ -69,25 +69,27 @@ export interface RuntimeServiceProvider { } const defaultDependencies: RuntimeServiceDependencies = { - execute: async (command, args, options) => await execFileAsync(command, args, options), + execute: executeProcess, waitForReady: waitForTcpProtocol, randomBytes, + environment: process.env, } -export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback"; port: "ephemeral"; persistentVolume: false; configuration?: WorkspaceRecipeRuntimeService["configuration"]; outputs: Record }> { +export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback" | "configured"; port: "ephemeral" | "configured"; persistentVolume: false; configuration?: WorkspaceRecipeRuntimeService["configuration"]; outputs: Record }> { return services.map((service) => { - const provider = runtimeServiceProvider(service.kind) - return { id: service.id, kind: service.kind, provider: provider.name, version: provider.version(service), bind: "loopback", port: "ephemeral", persistentVolume: false, ...(service.configuration ? { configuration: service.configuration } : {}), outputs: service.outputs } + const provider = runtimeServiceProvider(service) + const external = provider.name === "external" + return { id: service.id, kind: service.kind, provider: provider.name, version: provider.version(service), bind: external ? "configured" : "loopback", port: external ? "configured" : "ephemeral", persistentVolume: false, ...(service.configuration ? { configuration: service.configuration } : {}), outputs: service.outputs } }) } -export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: { signal?: AbortSignal; dependencies?: RuntimeServiceDependencies } = {}): Promise<{ env: Record; evidence: RuntimeServiceEvidence[]; control(serviceId: string, action: RuntimeServiceControlAction, controlOptions?: Record): Promise; release(): Promise }> { +export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: { signal?: AbortSignal; dependencies?: RuntimeServiceDependencies } = {}): Promise<{ env: Record; secretEnv: Record; evidence: RuntimeServiceEvidence[]; control(serviceId: string, action: RuntimeServiceControlAction, controlOptions?: Record): Promise; release(): Promise }> { const dependencies = options.dependencies ?? defaultDependencies const provisioned: ManagedRuntimeService[] = [] const evidence: RuntimeServiceEvidence[] = [] try { for (const service of services) { - const managed = await runtimeServiceProvider(service.kind).provision(service, dependencies, options.signal, evidence) + const managed = await runtimeServiceProvider(service).provision(service, dependencies, options.signal, evidence) provisioned.push(managed) } } catch (error) { @@ -103,6 +105,7 @@ export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeS return { env: Object.assign({}, ...provisioned.map((service) => service.env)), + secretEnv: Object.assign({}, ...provisioned.map((service) => service.secretEnv)), evidence, async control(serviceId, action, controlOptions) { const service = provisioned.find((candidate) => candidate.evidence.id === serviceId) @@ -157,6 +160,13 @@ const mysqlDockerProvider: RuntimeServiceProvider = { provision: provisionMysqlDockerService, } +const mysqlExternalProvider: RuntimeServiceProvider = { + name: "external", + kind: "mysql", + version: (service) => `mysql-compatible:${service.configuration?.engine ?? "mysql"}`, + provision: provisionMysqlExternalService, +} + const redisDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "redis", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.redis, provision: provisionRedisDockerService } const smtpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "smtp", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.smtp, provision: provisionSmtpDockerService } const httpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "http", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.http, provision: provisionHttpDockerService } @@ -165,12 +175,13 @@ function mysqlDockerImage(service: WorkspaceRecipeRuntimeService): string { return MYSQL_IMAGES[service.configuration?.engine ?? "mysql"] } -function runtimeServiceProvider(kind: string): RuntimeServiceProvider { - if (kind === mysqlDockerProvider.kind) return mysqlDockerProvider - if (kind === redisDockerProvider.kind) return redisDockerProvider - if (kind === smtpDockerProvider.kind) return smtpDockerProvider - if (kind === httpDockerProvider.kind) return httpDockerProvider - throw new Error(`Unsupported managed runtime service kind: ${kind}`) +function runtimeServiceProvider(service: WorkspaceRecipeRuntimeService): RuntimeServiceProvider { + if (service.kind === mysqlDockerProvider.kind) return service.configuration?.provider === "external" ? mysqlExternalProvider : mysqlDockerProvider + if (service.configuration?.provider === "external") throw new Error(`Managed runtime service kind does not support the external provider: ${service.kind}`) + if (service.kind === redisDockerProvider.kind) return redisDockerProvider + if (service.kind === smtpDockerProvider.kind) return smtpDockerProvider + if (service.kind === httpDockerProvider.kind) return httpDockerProvider + throw new Error(`Unsupported managed runtime service kind: ${service.kind}`) } async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise { @@ -204,6 +215,7 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic const values: Record = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" } return { env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])), + secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {}, evidence, async control(action, options) { return await controlDockerService(container, evidence, dependencies, action, options, async (customAction) => { if (customAction === "flush") { @@ -228,6 +240,149 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic } } +async function provisionMysqlExternalService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise { + const engine = service.configuration?.engine ?? "mysql" + const version = `mysql-compatible:${engine}` + const evidence: RuntimeServiceEvidence = { id: service.id, kind: service.kind, provider: "external", version, readiness: "pending", lifecycle: "provisioning", controls: [] } + evidenceList.push(evidence) + + let connection: ReturnType + try { + connection = externalMysqlConnection(service, dependencies.environment ?? process.env) + } catch { + evidence.readiness = "failed" + evidence.lifecycle = "failed" + evidence.diagnostic = { code: "provision-failed" } + throw new RuntimeServiceProvisionError(`Managed runtime service failed: ${service.id}`, evidenceList) + } + const client = engine === "mariadb" ? "mariadb" : "mysql" + const suffix = dependencies.randomBytes(12).toString("hex") + const database = validateGeneratedMysqlIdentifier(`wp_codebox_${suffix}`) + const username = validateGeneratedMysqlIdentifier(`wpcb_${suffix}`) + const password = dependencies.randomBytes(24).toString("base64url") + const adminArgs = mysqlConnectionArgs(connection.host, connection.port, connection.username) + const adminEnvironment = { ...process.env, MYSQL_PWD: connection.password } + let namespaceProven = false + let databaseAttempted = false + let userAttempted = false + let readinessAttempted = false + + const executeAdminSql = async (sql: string, cleanup = false): Promise<{ stdout: string }> => await dependencies.execute(client, adminArgs, { + env: adminEnvironment, + signal: cleanup ? undefined : signal, + timeout: 30_000, + stdin: sql, + }) + + const cleanup = async (): Promise => { + if (evidence.teardown === "completed") return + const results: PromiseSettledResult[] = [] + if (databaseAttempted) results.push(await settle(executeAdminSql(`DROP DATABASE IF EXISTS ${quoteMysqlIdentifier(database)};\n`, true))) + if (userAttempted) results.push(await settle(executeAdminSql(`DROP USER IF EXISTS ${quoteMysqlValue(username)}@'%';\n`, true))) + const failure = results.find((result) => result.status === "rejected") + if (failure) { + evidence.lifecycle = "failed" + evidence.teardown = "failed" + evidence.diagnostic = { code: "teardown-failed" } + throw new Error(`Managed runtime service teardown failed: ${service.id}`) + } + evidence.lifecycle = "released" + evidence.teardown = "completed" + } + + try { + throwIfAborted(signal) + const preflight = await executeAdminSql([ + `SELECT EXISTS(SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME=${quoteMysqlValue(database)});`, + `SELECT EXISTS(SELECT 1 FROM mysql.user WHERE User=${quoteMysqlValue(username)} AND Host='%');`, + "", + ].join("\n")) + if (preflight.stdout.trim().split(/\s+/).join(" ") !== "0 0") throw new Error("External MySQL isolation namespace is unavailable or cannot be proven unused") + namespaceProven = true + + databaseAttempted = true + await executeAdminSql(`CREATE DATABASE ${quoteMysqlIdentifier(database)};\n`) + throwIfAborted(signal) + userAttempted = true + await executeAdminSql(`CREATE USER ${quoteMysqlValue(username)}@'%' IDENTIFIED BY ${quoteMysqlValue(password)};\n`) + throwIfAborted(signal) + await executeAdminSql(`GRANT ALL PRIVILEGES ON ${quoteMysqlIdentifier(database)}.* TO ${quoteMysqlValue(username)}@'%';\n`) + throwIfAborted(signal) + + readinessAttempted = true + await dependencies.execute(client, [...mysqlConnectionArgs(connection.host, connection.port, username), "--database", database], { + env: { ...process.env, MYSQL_PWD: password }, + signal, + timeout: 30_000, + stdin: "SELECT 1;\n", + }) + throwIfAborted(signal) + evidence.readiness = "ready" + evidence.lifecycle = "provisioned" + const values: Record = { host: connection.host, port: String(connection.port), username, password, database } + return { + env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])), + secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {}, + evidence, + async control(action) { + const result: RuntimeServiceControlResult = { serviceId: service.id, action, status: "unsupported", fidelity: "unsupported", reason: `The ${service.kind} provider does not support ${action}.` } + evidence.controls?.push(result) + return result + }, + release: cleanup, + } + } catch { + evidence.readiness = "failed" + evidence.lifecycle = "failed" + evidence.diagnostic = { code: signal?.aborted ? "interrupted" : readinessAttempted ? "readiness-failed" : "provision-failed" } + if (namespaceProven && (databaseAttempted || userAttempted)) await cleanup().catch(() => undefined) + throw new RuntimeServiceProvisionError(`Managed runtime service failed: ${service.id}`, evidenceList) + } +} + +function externalMysqlConnection(service: WorkspaceRecipeRuntimeService, environment: Record): { host: string; port: number; username: string; password: string } { + const configuration = service.configuration + const host = configuredEnvironmentValue(configuration?.hostEnv, environment, "hostEnv") + const username = configuredEnvironmentValue(configuration?.usernameEnv, environment, "usernameEnv") + const password = configuredEnvironmentValue(configuration?.passwordEnv, environment, "passwordEnv", true) + const portValue = configuration?.portEnv ? configuredEnvironmentValue(configuration.portEnv, environment, "portEnv") : "3306" + const port = Number(portValue) + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("External MySQL port environment value must be an integer between 1 and 65535") + return { host, port, username, password } +} + +function configuredEnvironmentValue(name: string | undefined, environment: Record, field: string, allowEmpty = false): string { + if (!name || !/^[A-Z_][A-Z0-9_]*$/.test(name)) throw new Error(`External MySQL ${field} must reference a runtime environment variable`) + const value = environment[name] + if (value === undefined || (!allowEmpty && value.trim() === "")) throw new Error(`External MySQL ${field} environment variable is unavailable`) + return value +} + +function mysqlConnectionArgs(host: string, port: number, username: string): string[] { + return ["--batch", "--skip-column-names", "--protocol=TCP", "--host", host, "--port", String(port), "--user", username] +} + +function validateGeneratedMysqlIdentifier(identifier: string): string { + if (!/^[a-z][a-z0-9_]{0,63}$/.test(identifier)) throw new Error("Generated MySQL isolation identifier is unsafe") + return identifier +} + +function quoteMysqlIdentifier(identifier: string): string { + return `\`${validateGeneratedMysqlIdentifier(identifier).replaceAll("`", "``")}\`` +} + +function quoteMysqlValue(value: string): string { + return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "''")}'` +} + +async function settle(promise: Promise): Promise> { + try { + return { status: "fulfilled", value: await promise } + } catch (reason) { + return { status: "rejected", reason } + } +} + async function provisionRedisDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise { return await provisionSimpleDockerService(service, dependencies, signal, evidenceList, { image: service.configuration?.image ?? SERVICE_IMAGES.redis, @@ -288,6 +443,7 @@ async function provisionSimpleDockerService( const values = spec.values(ports) return { env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])), + secretEnv: {}, evidence, async control(action, options) { return await controlDockerService(container, evidence, dependencies, action, options, spec.customControl ? async (candidate, candidateOptions) => await spec.customControl?.(container, candidate, candidateOptions) ?? false : undefined, async () => await dependencies.waitForReady("127.0.0.1", ports[0] as number, 30_000)) }, async release() { await releaseService(container, evidence, dependencies) }, @@ -477,3 +633,29 @@ function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { clearTimeout(timer); reject(new Error("Managed runtime service provisioning interrupted")) }, { once: true }) }) } + +function executeProcess(command: string, args: string[], options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeout: number; stdin?: string }): Promise<{ stdout: string }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: options.env, + signal: options.signal, + timeout: options.timeout, + stdio: ["pipe", "pipe", "pipe"], + }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)) + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)) + child.once("error", reject) + child.once("close", (code) => { + if (code === 0) { + resolve({ stdout: Buffer.concat(stdout).toString("utf8") }) + return + } + const error = new Error(`Command failed with exit code ${code ?? "unknown"}`) as Error & { stderr: string } + error.stderr = Buffer.concat(stderr).toString("utf8") + reject(error) + }) + child.stdin.end(options.stdin) + }) +} diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index 89458f486..139c6d349 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -928,13 +928,22 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche type: "object", additionalProperties: false, properties: { + provider: { enum: ["docker", "external"] }, engine: { enum: ["mysql", "mariadb"] }, rootAuthentication: { enum: ["generated-password", "empty-password"] }, foreignKeyTargetPolicy: { enum: ["unique-only", "indexed"] }, + hostEnv: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" }, + portEnv: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" }, + usernameEnv: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" }, + passwordEnv: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" }, image: { type: "string", minLength: 1 }, responseStatus: { type: "integer", minimum: 100, maximum: 599 }, responseBody: { type: "string", maxLength: 65536 }, }, + allOf: [{ + if: { properties: { provider: { const: "external" } }, required: ["provider"] }, + then: { required: ["hostEnv", "usernameEnv", "passwordEnv"] }, + }], }, outputs: { type: "object", diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index 10c1ec783..b56be2c0f 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -138,9 +138,14 @@ export interface WorkspaceRecipeRuntimeService { id: string kind: "mysql" | "redis" | "smtp" | "http" | (string & {}) configuration?: { + provider?: "docker" | "external" engine?: "mysql" | "mariadb" rootAuthentication?: "generated-password" | "empty-password" foreignKeyTargetPolicy?: "unique-only" | "indexed" + hostEnv?: string + portEnv?: string + usernameEnv?: string + passwordEnv?: string image?: string responseStatus?: number responseBody?: string diff --git a/tests/external-mysql-runtime-service.test.ts b/tests/external-mysql-runtime-service.test.ts new file mode 100644 index 000000000..aafbdc596 --- /dev/null +++ b/tests/external-mysql-runtime-service.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict" +import { provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" +import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" +import { validateWorkspaceRecipeJsonSchema, type WorkspaceRecipeRuntimeService } from "../packages/runtime-core/src/index.ts" + +const adminPassword = "admin-secret-'\\-value" +const externalService: WorkspaceRecipeRuntimeService = { + id: "external-db", + kind: "mysql", + configuration: { + provider: "external", + engine: "mysql", + hostEnv: "MYSQL_ADMIN_HOST", + portEnv: "MYSQL_ADMIN_PORT", + usernameEnv: "MYSQL_ADMIN_USER", + passwordEnv: "MYSQL_ADMIN_PASSWORD", + }, + outputs: { host: "DB_HOST", port: "DB_PORT", username: "DB_USER", password: "DB_PASSWORD", database: "DB_NAME" }, +} + +assert.equal(validateWorkspaceRecipeJsonSchema({ + schema: "wp-codebox/workspace-recipe/v1", + inputs: { services: [externalService] }, + workflow: { steps: [{ command: "wordpress.phpunit", args: ["database-type=mysql"] }] }, +}).valid, true) +assert.equal(validateWorkspaceRecipeJsonSchema({ + schema: "wp-codebox/workspace-recipe/v1", + inputs: { services: [{ ...externalService, configuration: { provider: "external" } }] }, + workflow: { steps: [{ command: "wordpress.phpunit" }] }, +}).valid, false, "external provider configuration requires host-side credential references") +assert.deepEqual(runtimeServicePlan([externalService]), [{ + id: "external-db", + kind: "mysql", + provider: "external", + version: "mysql-compatible:mysql", + bind: "configured", + port: "configured", + persistentVolume: false, + configuration: externalService.configuration, + outputs: externalService.outputs, +}]) +assert.deepEqual(await validateWorkspaceRecipeSemantics({ + schema: "wp-codebox/workspace-recipe/v1", + inputs: { services: [externalService] }, + workflow: { steps: [{ command: "wordpress.run-php", args: ["code=echo 1;"] }] }, +}, "recipe.json"), []) +const exposedAdminIssues = await validateWorkspaceRecipeSemantics({ + schema: "wp-codebox/workspace-recipe/v1", + inputs: { secretEnv: ["MYSQL_ADMIN_PASSWORD"], services: [externalService] }, + workflow: { steps: [{ command: "wordpress.run-php", args: ["code=echo 1;"] }] }, +}, "recipe.json") +assert.ok(exposedAdminIssues.some((issue) => issue.code === "runtime-service-admin-env-exposed"), "administrative credentials cannot be projected into the sandbox") + +interface FakeCall { + command: string + args: string[] + env?: NodeJS.ProcessEnv + signal?: AbortSignal + stdin?: string +} + +function fakeDependencies(handle?: (call: FakeCall, controller?: AbortController) => void | Promise, controller?: AbortController): { dependencies: RuntimeServiceDependencies; calls: FakeCall[] } { + const calls: FakeCall[] = [] + return { + calls, + dependencies: { + environment: { + MYSQL_ADMIN_HOST: "database.internal", + MYSQL_ADMIN_PORT: "3307", + MYSQL_ADMIN_USER: "provisioner", + MYSQL_ADMIN_PASSWORD: adminPassword, + }, + randomBytes: (size) => Buffer.alloc(size, 0xab), + async waitForReady() {}, + async execute(command, args, options) { + const call = { command, args, env: options.env, signal: options.signal, stdin: options.stdin } + calls.push(call) + await handle?.(call, controller) + if (options.stdin?.startsWith("SELECT EXISTS")) return { stdout: "0\n0\n" } + return { stdout: options.stdin === "SELECT 1;\n" ? "1\n" : "" } + }, + }, + } +} + +const successFake = fakeDependencies() +const provisioned = await provisionRuntimeServices([externalService], { dependencies: successFake.dependencies }) +assert.equal(provisioned.env.DB_HOST, "database.internal") +assert.equal(provisioned.env.DB_PORT, "3307") +assert.match(provisioned.env.DB_NAME ?? "", /^wp_codebox_[a-f0-9]{24}$/) +assert.match(provisioned.env.DB_USER ?? "", /^wpcb_[a-f0-9]{24}$/) +assert.equal(provisioned.secretEnv.DB_PASSWORD, provisioned.env.DB_PASSWORD) +assert.equal(provisioned.evidence[0]?.provider, "external") +assert.equal(provisioned.evidence[0]?.readiness, "ready") +const createDatabase = successFake.calls.find((call) => call.stdin?.startsWith("CREATE DATABASE")) +const createUser = successFake.calls.find((call) => call.stdin?.startsWith("CREATE USER")) +assert.match(createDatabase?.stdin ?? "", /^CREATE DATABASE `wp_codebox_[a-f0-9]{24}`;\n$/) +assert.match(createUser?.stdin ?? "", /^CREATE USER 'wpcb_[a-f0-9]{24}'@'%' IDENTIFIED BY '[A-Za-z0-9_-]+';\n$/) +assert.equal(successFake.calls.some((call) => call.args.some((arg) => arg.includes(adminPassword) || arg.includes(provisioned.env.DB_PASSWORD ?? ""))), false, "passwords never enter argv") +assert.equal(JSON.stringify(runtimeServicePlan([externalService])).includes(adminPassword), false) +assert.equal(JSON.stringify(provisioned.evidence).includes(adminPassword), false) +assert.equal(JSON.stringify(provisioned.evidence).includes(provisioned.env.DB_PASSWORD ?? ""), false) +await provisioned.release() +await provisioned.release() +assert.equal(successFake.calls.filter((call) => call.stdin?.startsWith("DROP DATABASE")).length, 1) +assert.equal(successFake.calls.filter((call) => call.stdin?.startsWith("DROP USER")).length, 1) +assert.equal(successFake.calls.filter((call) => call.stdin === "SELECT 1;\n").length, 1, "the isolated account is authenticated before workload execution") +assert.equal(provisioned.evidence[0]?.teardown, "completed") + +const insufficientFake = fakeDependencies((call) => { + if (call.stdin?.startsWith("CREATE DATABASE")) throw new Error(`permission denied ${adminPassword}`) +}) +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: insufficientFake.dependencies }), (error: unknown) => { + assert.ok(error instanceof RuntimeServiceProvisionError) + assert.equal(error.message.includes(adminPassword), false) + assert.equal(JSON.stringify(error.evidence).includes(adminPassword), false) + assert.equal(error.evidence[0]?.readiness, "failed") + return true +}) +assert.equal(insufficientFake.calls.some((call) => call.stdin === "SELECT 1;\n"), false, "insufficient privileges fail before workload credentials are used") +assert.equal(insufficientFake.calls.some((call) => call.stdin?.startsWith("DROP DATABASE")), true, "an uncertain partial create is rolled back") + +const partialFake = fakeDependencies((call) => { + if (call.stdin?.startsWith("CREATE USER")) throw new Error("create user denied") +}) +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: partialFake.dependencies }), RuntimeServiceProvisionError) +assert.equal(partialFake.calls.some((call) => call.stdin?.startsWith("DROP DATABASE")), true) +assert.equal(partialFake.calls.some((call) => call.stdin?.startsWith("DROP USER")), true, "partial user creation is treated as uncertain and rolled back") + +const cancellation = new AbortController() +const cancelledFake = fakeDependencies((call, controller) => { + if (call.stdin?.startsWith("CREATE DATABASE")) controller?.abort() +}, cancellation) +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: cancelledFake.dependencies, signal: cancellation.signal }), (error: unknown) => { + assert.ok(error instanceof RuntimeServiceProvisionError) + assert.equal(error.evidence[0]?.diagnostic?.code, "interrupted") + assert.equal(error.evidence[0]?.teardown, "completed") + return true +}) +const cancelledCleanup = cancelledFake.calls.find((call) => call.stdin?.startsWith("DROP DATABASE")) +assert.equal(cancelledCleanup?.signal, undefined, "cleanup uses an independent context after cancellation") + +const cleanupFailureFake = fakeDependencies((call) => { + if (call.stdin?.startsWith("DROP DATABASE")) throw new Error(`cleanup leaked ${adminPassword}`) +}) +const cleanupFailure = await provisionRuntimeServices([externalService], { dependencies: cleanupFailureFake.dependencies }) +await assert.rejects(cleanupFailure.release(), (error: unknown) => { + assert.ok(error instanceof Error) + assert.equal(error.message.includes(adminPassword), false) + return true +}) +assert.equal(cleanupFailureFake.calls.some((call) => call.stdin?.startsWith("DROP USER")), true, "user cleanup continues after database cleanup fails") +assert.equal(cleanupFailure.evidence[0]?.teardown, "failed") +assert.equal(cleanupFailure.evidence[0]?.diagnostic?.code, "teardown-failed") + +const unavailableNamespaceFake = fakeDependencies((call) => { + if (call.stdin?.startsWith("SELECT EXISTS")) return undefined +}) +unavailableNamespaceFake.dependencies.execute = async (command, args, options) => { + unavailableNamespaceFake.calls.push({ command, args, env: options.env, signal: options.signal, stdin: options.stdin }) + return { stdout: options.stdin?.startsWith("SELECT EXISTS") ? "1\n0\n" : "" } +} +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: unavailableNamespaceFake.dependencies }), RuntimeServiceProvisionError) +assert.equal(unavailableNamespaceFake.calls.some((call) => call.stdin?.startsWith("CREATE")), false, "a namespace that cannot be proven unused fails closed") + +const missingCredentials = fakeDependencies() +missingCredentials.dependencies.environment = {} +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: missingCredentials.dependencies }), (error: unknown) => error instanceof RuntimeServiceProvisionError && error.evidence[0]?.diagnostic?.code === "provision-failed") +assert.equal(missingCredentials.calls.length, 0, "missing host credential references fail before invoking a database client") + +const mariaService: WorkspaceRecipeRuntimeService = { ...externalService, configuration: { ...externalService.configuration, engine: "mariadb" } } +const mariaFake = fakeDependencies() +const maria = await provisionRuntimeServices([mariaService], { dependencies: mariaFake.dependencies }) +assert.equal(runtimeServicePlan([mariaService])[0]?.version, "mysql-compatible:mariadb") +assert.equal(mariaFake.calls.every((call) => call.command === "mariadb"), true) +await maria.release() + +console.log("external MySQL runtime service tests passed") From ddb6702613e02e6349622910be493c02bb0c9739 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 25 Jul 2026 00:20:30 +0000 Subject: [PATCH 2/3] fix: enforce external database security boundaries --- packages/cli/src/commands/recipe-run-types.ts | 1 + packages/cli/src/commands/recipe-run.ts | 25 +++- packages/cli/src/output.ts | 2 + packages/cli/src/recipe-evidence.ts | 24 ++- packages/cli/src/recipe-secret-env.ts | 9 ++ packages/cli/src/recipe-validation.ts | 35 +++++ packages/cli/src/runtime-services.ts | 139 +++++++++++++++--- packages/runtime-core/src/recipe-schema.ts | 3 +- .../runtime-core/src/runtime-contracts.ts | 1 + .../src/playground-cli-runner.ts | 18 ++- tests/external-mysql-runtime-service.test.ts | 95 ++++++++++-- ...layground-cli-runner-bootstrap-ini.test.ts | 7 +- 12 files changed, 297 insertions(+), 62 deletions(-) diff --git a/packages/cli/src/commands/recipe-run-types.ts b/packages/cli/src/commands/recipe-run-types.ts index 74a31dad0..dcb67fe8e 100644 --- a/packages/cli/src/commands/recipe-run-types.ts +++ b/packages/cli/src/commands/recipe-run-types.ts @@ -23,6 +23,7 @@ export interface RecipeRunOptions { timeoutMs: number adversarialReplayPath?: string policy?: RuntimePolicy + externalServiceWritesApproved: boolean json: boolean summary: boolean dryRun: boolean diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index 5b20e7eb0..449f41c70 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -11,7 +11,7 @@ import { parsePreviewBind, parsePreviewHoldSeconds, parsePreviewLease, parsePrev import { dryRunRecipe, planWorkspaceRecipe, recipeDryRunSiteSeeds } from "../recipe-dry-run.js" import { appendRecipeRuntimeEvidence, collectAndFinalizeFailedRecipeArtifacts, collectRecipeRuntimeArtifacts, finalizeAgentSandboxEvidence, finalizeRecipeArtifactEvidence, recipeAgentResultFailure, recipeArtifactEvidenceFailure, recipeReplayStatusOutput, recipeVerifyStepFailure } from "../recipe-evidence.js" import { recipeExternalServiceBoundarySummaries } from "../recipe-external-services.js" -import { resolveRecipeSecretEnv } from "../recipe-secret-env.js" +import { mergeRecipeSecretEnvSummary, resolveRecipeSecretEnv } from "../recipe-secret-env.js" import type { PreparedRuntimeBackendPackage } from "../recipe-backend-package.js" import { cleanupRecipePreparedSources, recipeBlueprintWithBootActivePlugins, recipeExtraPlugins, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js" import { loadWorkspaceRecipe, recipePolicy, recipeWorkflowSteps, validateRecipeRuntimePolicy, validateWorkspaceRecipe, type RecipeWorkflowPhase } from "../recipe-validation.js" @@ -110,7 +110,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe await artifactPointer.update({ commandStatus: "queued" }) const issues = [ ...await validateWorkspaceRecipe(recipe, recipePath), - ...validateRecipeRuntimePolicy(recipe, options.policy), + ...validateRecipeRuntimePolicy(recipe, options.policy ?? recipePolicy(recipe)), ] if (issues.length > 0) { const failure = { @@ -140,6 +140,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe } const secretEnvResolution = resolveRecipeSecretEnv(recipe.inputs?.secretEnv ?? [], { field: "--secret-env name" }) const secretEnv = secretEnvResolution.values + let secretEnvSummary = secretEnvResolution.summary let effectivePolicy = Object.keys(secretEnv).length > 0 ? { ...policy, secrets: "connector-scoped" as const } : policy let workspaceMounts: PreparedWorkspaceMount[] = [] let extraPlugins: PreparedExtraPlugin[] = [] @@ -194,7 +195,13 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe managedServices = await phaseTracker.run("provision_runtime_services", { services: recipe.inputs?.services?.map(({ id, kind }) => ({ id, kind })) ?? [] }, async () => await provisionRuntimeServicesForRecipe( recipe.inputs?.services ?? [], async (provisioning) => await awaitRecipe("runtime-services.provision", provisioning), - { signal: interruption?.signal, onEvidence: (evidence) => { serviceEvidence = evidence } }, + { + signal: interruption?.signal, + policy, + externalServices: recipe.inputs?.externalServices ?? [], + externalServiceWritesApproved: options.externalServiceWritesApproved, + onEvidence: (evidence) => { serviceEvidence = evidence }, + }, )) serviceEvidence = managedServices.evidence Object.assign(runtimeEnv, managedServices.env) @@ -202,6 +209,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe delete runtimeEnv[name] secretEnv[name] = value } + secretEnvSummary = mergeRecipeSecretEnvSummary(secretEnvSummary, Object.keys(managedServices.secretEnv)) if (Object.keys(managedServices.secretEnv).length > 0) effectivePolicy = { ...policy, secrets: "connector-scoped" } const runtimeEnvironment = { kind: "wordpress" as const, @@ -358,7 +366,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe if (declaredArtifactFailure) { throw declaredArtifactFailure } - const recipeEvidence = await finalizeRecipeArtifactEvidence(artifacts, recipe, workspaceMounts, stagedFiles, effectivePolicy, secretEnvResolution.summary) + const recipeEvidence = await finalizeRecipeArtifactEvidence(artifacts, recipe, workspaceMounts, stagedFiles, effectivePolicy, secretEnvSummary) const agentEvidence = await finalizeAgentSandboxEvidence(artifacts, executions) Object.assign(recipeEvidence, agentEvidence) markRecipeArtifactsFinalized(interruption, true) @@ -379,7 +387,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe declaredArtifacts = await collectRecipeDeclaredArtifacts(recipe, runtime) await materializeTypedRecipeDeclaredArtifacts(artifacts, declaredArtifacts) await appendRecipeRuntimeEvidence(artifacts, recipeRuntimeEvidenceFiles(fixtureDatabases, distributionSetupArtifacts, distributionStartupProbes, probes, declaredArtifacts)) - evidence = await finalizeRecipeArtifactEvidence(artifacts, recipe, workspaceMounts, stagedFiles, effectivePolicy, secretEnvResolution.summary) + evidence = await finalizeRecipeArtifactEvidence(artifacts, recipe, workspaceMounts, stagedFiles, effectivePolicy, secretEnvSummary) const previewAgentEvidence = await finalizeAgentSandboxEvidence(artifacts, executions) Object.assign(evidence, previewAgentEvidence) } @@ -494,7 +502,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe workspaceMounts, stagedFiles, policy: effectivePolicy, - secretEnv: secretEnvResolution.summary, + secretEnv: secretEnvSummary, executions, interruption, })) @@ -719,7 +727,7 @@ async function validateRecipe(options: RecipeValidateOptions): Promise } function parseRecipeRunOptions(args: string[]): RecipeRunOptions { - const parsed = parseCommandOptions(args, new Set(["--json", "--summary", "--summary-only", "--dry-run", "--preview-hold-blocking", "--preview-lease", "--preview-lease-child"])) + const parsed = parseCommandOptions(args, new Set(["--json", "--summary", "--summary-only", "--dry-run", "--preview-hold-blocking", "--preview-lease", "--preview-lease-child", "--approve-external-service-writes"])) if (parsed.positionals.length > 0) { throw new Error(`Invalid argument: ${parsed.positionals[0]}`) } @@ -781,6 +789,7 @@ function parseRecipeRunOptions(args: string[]): RecipeRunOptions { previewHoldBlocking: parsed.options.get("--preview-hold-blocking") === true, previewLeaseRequested: parsed.options.get("--preview-lease") === true, previewLeaseChild: parsed.options.get("--preview-lease-child") === true, + externalServiceWritesApproved: parsed.options.get("--approve-external-service-writes") === true, timeoutMs: DEFAULT_RECIPE_RUN_TIMEOUT_MS, } for (const [name, value] of parsed.options) { diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index 61cb54179..14b03be2d 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -431,6 +431,8 @@ Options: wp-codebox/preview-lease/v1 envelope for public/local URL, expiry, alignment, and handoff metadata. --timeout Maximum live recipe-run duration before emitting a structured timeout failure. Defaults to 25m. --policy Runtime policy JSON or path to a JSON file. For recipe-run and recipe validate, this overrides the recipe-derived runtime policy and must include every command required by the recipe setup, probes, and workflow. + --approve-external-service-writes + Explicitly approve short-lived managed writes to declared external-service boundaries when policy.approvals is on-write. --dry-run Validate recipe-run and emit a resolved JSON plan without booting Playground or writing temp workspaces. --json Emit machine-readable JSON. diff --git a/packages/cli/src/recipe-evidence.ts b/packages/cli/src/recipe-evidence.ts index c9b59f3ab..8611b172e 100644 --- a/packages/cli/src/recipe-evidence.ts +++ b/packages/cli/src/recipe-evidence.ts @@ -1344,13 +1344,7 @@ async function buildRecipeRunAttestation(args: { resultRef: artifactVerificationRef, }), }, - secretEnvelope: { - schema: "wp-codebox/redacted-secret-envelope/v1", - provided: args.secretEnv.some((entry) => entry.status === "available"), - count: args.secretEnv.filter((entry) => entry.status === "available").length, - secrets: [...args.secretEnv].sort((a, b) => a.name.localeCompare(b.name)), - redaction: "names-only", - }, + secretEnvelope: recipeSecretEnvelope(args.secretEnv), externalServices: { schema: "wp-codebox/external-service-boundaries-attestation/v1", boundaries: recipeExternalServiceBoundarySummaries(args.recipe), @@ -1378,6 +1372,22 @@ async function buildRecipeRunAttestation(args: { } } +export function recipeSecretEnvelope(secretEnv: readonly RecipeSecretEnvSummaryEntry[]): { + schema: "wp-codebox/redacted-secret-envelope/v1" + provided: boolean + count: number + secrets: Array<{ name: string; status: RecipeSecretEnvSummaryEntry["status"]; source?: string }> + redaction: "names-only" +} { + return { + schema: "wp-codebox/redacted-secret-envelope/v1", + provided: secretEnv.some((entry) => entry.status === "available"), + count: secretEnv.filter((entry) => entry.status === "available").length, + secrets: [...secretEnv].sort((a, b) => a.name.localeCompare(b.name)), + redaction: "names-only", + } +} + function enforcedPolicyField(value: RuntimePolicy[keyof RuntimePolicy]): RunAttestationPolicyField { return { value, enforcement: "enforced" } } diff --git a/packages/cli/src/recipe-secret-env.ts b/packages/cli/src/recipe-secret-env.ts index 82a442c72..1e92bf54e 100644 --- a/packages/cli/src/recipe-secret-env.ts +++ b/packages/cli/src/recipe-secret-env.ts @@ -54,6 +54,15 @@ export function resolveRecipeSecretEnv(names: readonly string[], options: Resolv return { values, summary } } +export function mergeRecipeSecretEnvSummary(summary: readonly RecipeSecretEnvSummaryEntry[], generatedNames: readonly string[]): RecipeSecretEnvSummaryEntry[] { + const entries = new Map(summary.map((entry) => [entry.name, { ...entry }])) + for (const name of generatedNames) { + assertRuntimeEnvName(name, "managed runtime service secret env") + entries.set(name, { name, status: "available", source: "managed-runtime-service" }) + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) +} + export function defaultRecipeSecretEnvProviders(source: Record = process.env): RecipeSecretEnvProvider[] { return [ directProcessEnvSecretProvider, diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index 8e0a7adf8..713608da6 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -536,6 +536,26 @@ export function validateRecipeRuntimePolicy(recipe: WorkspaceRecipe, policy: Run } } + const externalMysqlServices = (recipe.inputs?.services ?? []).filter((service) => service.kind === "mysql" && service.configuration?.provider === "external") + if (externalMysqlServices.length > 0) { + if (policy.network === "deny") { + issues.push({ code: "runtime-policy-external-service-network-denied", path: "$.policy.network", message: "External MySQL runtime services require network access to their declared external-service hosts." }) + } + if (policy.approvals !== "on-write") { + issues.push({ code: "runtime-policy-external-service-approval-required", path: "$.policy.approvals", message: "External MySQL runtime services require approvals=on-write and explicit write approval at execution time." }) + } + if (typeof policy.network === "object") { + for (const service of externalMysqlServices) { + const boundary = recipe.inputs?.externalServices?.find((candidate) => candidate.id === service.configuration?.externalService) + for (const host of boundary?.allowedHosts ?? []) { + if (!policyHostListIncludes(policy.network.allowHosts, host)) { + issues.push({ code: "runtime-policy-external-service-host-denied", path: "$.policy.network.allowHosts", message: `Runtime network policy must allow external-service host: ${host}` }) + } + } + } + } + } + return issues } @@ -747,6 +767,12 @@ function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: if (!["mysql", "redis", "smtp", "http"].includes(service.kind)) addIssue("unsupported-runtime-service-kind", `${path}.kind`, `Unsupported managed runtime service kind: ${service.kind}`) if (service.configuration?.provider === "external") { if (service.kind !== "mysql") addIssue("unsupported-runtime-service-provider", `${path}.configuration.provider`, "The external provider supports only MySQL-compatible services.") + const boundary = recipe.inputs?.externalServices?.find((candidate) => candidate.id === service.configuration?.externalService) + if (!boundary) addIssue("missing-runtime-service-external-boundary", `${path}.configuration.externalService`, "External MySQL services must reference an inputs.externalServices boundary.") + else { + if ((boundary.allowedHosts ?? []).length === 0) addIssue("missing-runtime-service-host-allowlist", `${path}.configuration.externalService`, "External MySQL service boundaries must explicitly allow at least one host.") + if (boundary.writes !== "allowed-with-approval") addIssue("runtime-service-writes-not-approved", `${path}.configuration.externalService`, "External MySQL service boundaries must declare writes=allowed-with-approval.") + } for (const field of ["hostEnv", "portEnv", "usernameEnv", "passwordEnv"] as const) { const name = service.configuration[field] if (name && exposedEnvironment.has(name)) addIssue("runtime-service-admin-env-exposed", `${path}.configuration.${field}`, `External service administration environment must remain host-only: ${name}`) @@ -770,6 +796,15 @@ function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: } } +function policyHostListIncludes(policyHosts: readonly string[], boundaryHost: string): boolean { + const normalizedBoundary = boundaryHost.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "") + const boundaryHasPort = /:\d+$/.test(normalizedBoundary) + return policyHosts.some((host) => { + const normalized = host.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "") + return normalized === normalizedBoundary || (!boundaryHasPort && normalized.replace(/:\d+$/, "") === normalizedBoundary) + }) +} + function validateRecipeExternalServiceBoundaries(recipe: WorkspaceRecipe, addIssue: (code: string, path: string, message: string) => void): void { const seenIds = new Set() for (const [index, boundary] of (recipe.inputs?.externalServices ?? []).entries()) { diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index 58d437387..e44b5af75 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -1,10 +1,11 @@ import { spawn } from "node:child_process" import { randomBytes } from "node:crypto" import { createConnection } from "node:net" -import type { WorkspaceRecipeRuntimeService } from "@automattic/wp-codebox-core" +import type { RuntimePolicy, WorkspaceRecipeExternalServiceBoundary, WorkspaceRecipeRuntimeService } from "@automattic/wp-codebox-core" const MYSQL_IMAGES = { mysql: "mysql:8.4", mariadb: "mariadb:11.4" } as const const SERVICE_IMAGES = { redis: "redis:7.4-alpine", smtp: "axllent/mailpit:v1.27", http: "hashicorp/http-echo:1.0" } as const +const DEFAULT_PROCESS_OUTPUT_LIMIT_BYTES = 1024 * 1024 export type RuntimeServiceControlAction = "stop" | "start" | "pause" | "resume" | "restart" | "disconnect" | "reconnect" | "flush" | "read-only" | "read-write" | "latency" @@ -55,7 +56,7 @@ interface ManagedRuntimeService { } export interface RuntimeServiceDependencies { - execute(command: string, args: string[], options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeout: number; stdin?: string }): Promise<{ stdout: string }> + execute(command: string, args: string[], options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeout: number; stdin?: string; maxOutputBytes?: number }): Promise<{ stdout: string }> waitForReady(host: string, port: number, timeoutMs: number, signal?: AbortSignal): Promise randomBytes(size: number): Buffer environment?: Record @@ -65,11 +66,26 @@ export interface RuntimeServiceProvider { readonly name: string readonly kind: string version(service: WorkspaceRecipeRuntimeService): string - provision(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidence: RuntimeServiceEvidence[]): Promise + provision(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidence: RuntimeServiceEvidence[]): Promise +} + +interface RuntimeServiceProvisionContext { + signal?: AbortSignal + policy?: RuntimePolicy + externalServices: WorkspaceRecipeExternalServiceBoundary[] + externalServiceWritesApproved: boolean +} + +export interface ProvisionRuntimeServicesOptions { + signal?: AbortSignal + dependencies?: RuntimeServiceDependencies + policy?: RuntimePolicy + externalServices?: WorkspaceRecipeExternalServiceBoundary[] + externalServiceWritesApproved?: boolean } const defaultDependencies: RuntimeServiceDependencies = { - execute: executeProcess, + execute: executeRuntimeServiceProcess, waitForReady: waitForTcpProtocol, randomBytes, environment: process.env, @@ -83,13 +99,19 @@ export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): A }) } -export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: { signal?: AbortSignal; dependencies?: RuntimeServiceDependencies } = {}): Promise<{ env: Record; secretEnv: Record; evidence: RuntimeServiceEvidence[]; control(serviceId: string, action: RuntimeServiceControlAction, controlOptions?: Record): Promise; release(): Promise }> { +export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: ProvisionRuntimeServicesOptions = {}): Promise<{ env: Record; secretEnv: Record; evidence: RuntimeServiceEvidence[]; control(serviceId: string, action: RuntimeServiceControlAction, controlOptions?: Record): Promise; release(): Promise }> { const dependencies = options.dependencies ?? defaultDependencies const provisioned: ManagedRuntimeService[] = [] const evidence: RuntimeServiceEvidence[] = [] + const context: RuntimeServiceProvisionContext = { + signal: options.signal, + policy: options.policy, + externalServices: options.externalServices ?? [], + externalServiceWritesApproved: options.externalServiceWritesApproved ?? false, + } try { for (const service of services) { - const managed = await runtimeServiceProvider(service).provision(service, dependencies, options.signal, evidence) + const managed = await runtimeServiceProvider(service).provision(service, dependencies, context, evidence) provisioned.push(managed) } } catch (error) { @@ -125,13 +147,19 @@ export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeS export async function provisionRuntimeServicesForRecipe( services: WorkspaceRecipeRuntimeService[], guard: (promise: Promise) => Promise, - options: { signal?: AbortSignal; dependencies?: RuntimeServiceDependencies; onEvidence?: (evidence: RuntimeServiceEvidence[]) => void } = {}, + options: ProvisionRuntimeServicesOptions & { onEvidence?: (evidence: RuntimeServiceEvidence[]) => void } = {}, ): Promise>> { const controller = new AbortController() const abort = () => controller.abort() options.signal?.addEventListener("abort", abort, { once: true }) if (options.signal?.aborted) controller.abort() - const provisioning = provisionRuntimeServices(services, { signal: controller.signal, dependencies: options.dependencies }) + const provisioning = provisionRuntimeServices(services, { + signal: controller.signal, + dependencies: options.dependencies, + policy: options.policy, + externalServices: options.externalServices, + externalServiceWritesApproved: options.externalServiceWritesApproved, + }) try { return await guard(provisioning) } catch (error) { @@ -184,7 +212,8 @@ function runtimeServiceProvider(service: WorkspaceRecipeRuntimeService): Runtime throw new Error(`Unsupported managed runtime service kind: ${service.kind}`) } -async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise { +async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidenceList: RuntimeServiceEvidence[]): Promise { + const { signal } = context const engine = service.configuration?.engine ?? "mysql" const image = mysqlDockerImage(service) const evidence: RuntimeServiceEvidence = { id: service.id, kind: service.kind, provider: "docker", version: image, readiness: "pending", lifecycle: "provisioning" } @@ -240,7 +269,8 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic } } -async function provisionMysqlExternalService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise { +async function provisionMysqlExternalService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidenceList: RuntimeServiceEvidence[]): Promise { + const { signal } = context const engine = service.configuration?.engine ?? "mysql" const version = `mysql-compatible:${engine}` const evidence: RuntimeServiceEvidence = { id: service.id, kind: service.kind, provider: "external", version, readiness: "pending", lifecycle: "provisioning", controls: [] } @@ -249,6 +279,7 @@ async function provisionMysqlExternalService(service: WorkspaceRecipeRuntimeServ let connection: ReturnType try { connection = externalMysqlConnection(service, dependencies.environment ?? process.env) + assertExternalMysqlAuthorization(service, connection.host, connection.port, context) } catch { evidence.readiness = "failed" evidence.lifecycle = "failed" @@ -351,6 +382,32 @@ function externalMysqlConnection(service: WorkspaceRecipeRuntimeService, environ return { host, port, username, password } } +function assertExternalMysqlAuthorization(service: WorkspaceRecipeRuntimeService, host: string, port: number, context: RuntimeServiceProvisionContext): void { + const policy = context.policy + if (!policy || policy.network === "deny") throw new Error("External MySQL access is denied by runtime network policy") + if (typeof policy.network === "object" && !hostListAllows(policy.network.allowHosts, host, port)) { + throw new Error("External MySQL host is not allowed by runtime network policy") + } + + const boundaryId = service.configuration?.externalService + const boundary = context.externalServices.find((candidate) => candidate.id === boundaryId) + if (!boundary || boundary.allowedHosts?.length === 0 || !hostListAllows(boundary.allowedHosts ?? [], host, port)) { + throw new Error("External MySQL host is not explicitly allowlisted by its external-service boundary") + } + if (hostListAllows(boundary.blockedHosts ?? [], host, port)) throw new Error("External MySQL host is blocked by its external-service boundary") + if (boundary.writes !== "allowed-with-approval") throw new Error("External MySQL boundary does not permit managed writes") + if (policy.approvals !== "on-write" || !context.externalServiceWritesApproved) throw new Error("External MySQL managed writes were not explicitly approved") +} + +function hostListAllows(allowedHosts: readonly string[], host: string, port: number): boolean { + const targetHost = host.trim().toLowerCase() + const targetWithPort = `${targetHost}:${port}` + return allowedHosts.some((candidate) => { + const normalized = candidate.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "") + return normalized === targetHost || normalized === targetWithPort + }) +} + function configuredEnvironmentValue(name: string | undefined, environment: Record, field: string, allowEmpty = false): string { if (!name || !/^[A-Z_][A-Z0-9_]*$/.test(name)) throw new Error(`External MySQL ${field} must reference a runtime environment variable`) const value = environment[name] @@ -383,8 +440,8 @@ async function settle(promise: Promise): Promise> } } -async function provisionRedisDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise { - return await provisionSimpleDockerService(service, dependencies, signal, evidenceList, { +async function provisionRedisDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidenceList: RuntimeServiceEvidence[]): Promise { + return await provisionSimpleDockerService(service, dependencies, context.signal, evidenceList, { image: service.configuration?.image ?? SERVICE_IMAGES.redis, ports: [6379], runArgs: ["--save", "", "--appendonly", "no"], @@ -397,8 +454,8 @@ async function provisionRedisDockerService(service: WorkspaceRecipeRuntimeServic }) } -async function provisionSmtpDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise { - return await provisionSimpleDockerService(service, dependencies, signal, evidenceList, { +async function provisionSmtpDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidenceList: RuntimeServiceEvidence[]): Promise { + return await provisionSimpleDockerService(service, dependencies, context.signal, evidenceList, { image: service.configuration?.image ?? SERVICE_IMAGES.smtp, ports: [1025, 8025], runArgs: [], @@ -406,8 +463,8 @@ async function provisionSmtpDockerService(service: WorkspaceRecipeRuntimeService }) } -async function provisionHttpDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise { - return await provisionSimpleDockerService(service, dependencies, signal, evidenceList, { +async function provisionHttpDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidenceList: RuntimeServiceEvidence[]): Promise { + return await provisionSimpleDockerService(service, dependencies, context.signal, evidenceList, { image: service.configuration?.image ?? SERVICE_IMAGES.http, ports: [5678], runArgs: ["-listen=:5678", `-status-code=${service.configuration?.responseStatus ?? 200}`, `-text=${service.configuration?.responseBody ?? "ok"}`], @@ -634,8 +691,13 @@ function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { +export function executeRuntimeServiceProcess(command: string, args: string[], options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeout: number; stdin?: string; maxOutputBytes?: number }): Promise<{ stdout: string }> { return new Promise((resolve, reject) => { + const limit = options.maxOutputBytes ?? DEFAULT_PROCESS_OUTPUT_LIMIT_BYTES + if (!Number.isSafeInteger(limit) || limit < 1) { + reject(new Error("Runtime service process output limit must be a positive integer")) + return + } const child = spawn(command, args, { env: options.env, signal: options.signal, @@ -644,18 +706,49 @@ function executeProcess(command: string, args: string[], options: { env?: NodeJS }) const stdout: Buffer[] = [] const stderr: Buffer[] = [] - child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)) - child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)) - child.once("error", reject) + let stdoutBytes = 0 + let stderrBytes = 0 + let overflow = false + let settled = false + const collect = (chunks: Buffer[], chunk: Buffer, currentBytes: number): number => { + const remaining = Math.max(0, limit - currentBytes) + if (remaining > 0) chunks.push(chunk.subarray(0, remaining)) + if (chunk.length > remaining && !overflow) { + overflow = true + child.kill("SIGKILL") + } + return currentBytes + Math.min(chunk.length, remaining) + } + child.stdout.on("data", (chunk: Buffer) => { stdoutBytes = collect(stdout, chunk, stdoutBytes) }) + child.stderr.on("data", (chunk: Buffer) => { stderrBytes = collect(stderr, chunk, stderrBytes) }) + child.once("error", (error) => { + if (settled) return + settled = true + reject(error) + }) child.once("close", (code) => { + if (settled) return + settled = true + const boundedStdout = Buffer.concat(stdout).toString("utf8") + const boundedStderr = Buffer.concat(stderr).toString("utf8") + if (overflow) { + const error = new Error(`Runtime service process output exceeded ${limit} bytes`) as Error & { code: string; stdout: string; stderr: string } + error.code = "runtime-service-output-overflow" + error.stdout = boundedStdout + error.stderr = boundedStderr + reject(error) + return + } if (code === 0) { - resolve({ stdout: Buffer.concat(stdout).toString("utf8") }) + resolve({ stdout: boundedStdout }) return } - const error = new Error(`Command failed with exit code ${code ?? "unknown"}`) as Error & { stderr: string } - error.stderr = Buffer.concat(stderr).toString("utf8") + const error = new Error(`Command failed with exit code ${code ?? "unknown"}`) as Error & { stdout: string; stderr: string } + error.stdout = boundedStdout + error.stderr = boundedStderr reject(error) }) + child.stdin.on("error", () => undefined) child.stdin.end(options.stdin) }) } diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index 139c6d349..6ca53da04 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -929,6 +929,7 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche additionalProperties: false, properties: { provider: { enum: ["docker", "external"] }, + externalService: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_.-]*$" }, engine: { enum: ["mysql", "mariadb"] }, rootAuthentication: { enum: ["generated-password", "empty-password"] }, foreignKeyTargetPolicy: { enum: ["unique-only", "indexed"] }, @@ -942,7 +943,7 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche }, allOf: [{ if: { properties: { provider: { const: "external" } }, required: ["provider"] }, - then: { required: ["hostEnv", "usernameEnv", "passwordEnv"] }, + then: { required: ["externalService", "hostEnv", "usernameEnv", "passwordEnv"] }, }], }, outputs: { diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index b56be2c0f..199811697 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -139,6 +139,7 @@ export interface WorkspaceRecipeRuntimeService { kind: "mysql" | "redis" | "smtp" | "http" | (string & {}) configuration?: { provider?: "docker" | "external" + externalService?: string engine?: "mysql" | "mariadb" rootAuthentication?: "generated-password" | "empty-password" foreignKeyTargetPolicy?: "unique-only" | "indexed" diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 735c7e55a..baedb658a 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -451,25 +451,25 @@ function runtimeAutoPrependPhp(spec: RuntimeCreateSpec): string { } function runtimeAutoPrependPhpBody(spec: RuntimeCreateSpec): string { - const runtimeEnv = spec.environment.databaseSetup === "external" ? phpEnvAssignments(spec.runtimeEnv ?? {}) : "" + const runtimeEnv = spec.environment.databaseSetup === "external" ? phpEnvAssignments(connectorRuntimeEnv(spec)) : "" return `${runtimeEnv}${distributionBootstrapPhp(spec)}` } function externalDatabaseWpConfig(spec: RuntimeCreateSpec): string | undefined { if (spec.environment.databaseSetup !== "external") return undefined - const host = spec.runtimeEnv?.DB_HOST + const connectorEnv = connectorRuntimeEnv(spec) + const host = connectorEnv.DB_HOST if (!host) return undefined - const port = spec.runtimeEnv?.DB_PORT + const port = connectorEnv.DB_PORT const values = { - DB_NAME: spec.runtimeEnv?.DB_NAME ?? "runtime", - DB_USER: spec.runtimeEnv?.DB_USER ?? "root", - DB_PASSWORD: spec.runtimeEnv?.DB_PASSWORD ?? "", + DB_NAME: connectorEnv.DB_NAME ?? "runtime", + DB_USER: connectorEnv.DB_USER ?? "root", DB_HOST: port ? `${host}:${port}` : host, } return ` { + return { ...(spec.runtimeEnv ?? {}), ...(spec.secretEnv ?? {}) } +} + function distributionBootstrapPhp(spec: RuntimeCreateSpec): string { const distribution = recipeDistribution(spec) if (!distribution) { diff --git a/tests/external-mysql-runtime-service.test.ts b/tests/external-mysql-runtime-service.test.ts index aafbdc596..47d8e2049 100644 --- a/tests/external-mysql-runtime-service.test.ts +++ b/tests/external-mysql-runtime-service.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict" -import { provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" -import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" -import { validateWorkspaceRecipeJsonSchema, type WorkspaceRecipeRuntimeService } from "../packages/runtime-core/src/index.ts" +import { mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { executeRuntimeServiceProcess, provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" +import { validateRecipeRuntimePolicy, validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" +import { startPlaygroundCliServer, type PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.ts" +import { validateWorkspaceRecipeJsonSchema, type RuntimeCreateSpec, type WorkspaceRecipeRuntimeService } from "../packages/runtime-core/src/index.ts" const adminPassword = "admin-secret-'\\-value" const externalService: WorkspaceRecipeRuntimeService = { @@ -9,6 +13,7 @@ const externalService: WorkspaceRecipeRuntimeService = { kind: "mysql", configuration: { provider: "external", + externalService: "database-service", engine: "mysql", hostEnv: "MYSQL_ADMIN_HOST", portEnv: "MYSQL_ADMIN_PORT", @@ -17,10 +22,13 @@ const externalService: WorkspaceRecipeRuntimeService = { }, outputs: { host: "DB_HOST", port: "DB_PORT", username: "DB_USER", password: "DB_PASSWORD", database: "DB_NAME" }, } +const externalServices = [{ id: "database-service", environment: "external" as const, allowedHosts: ["database.internal:3307"], writes: "allowed-with-approval" as const }] +const policy = { network: { allowHosts: ["database.internal:3307"] }, filesystem: "sandbox" as const, commands: ["wordpress.phpunit"], secrets: "connector-scoped" as const, approvals: "on-write" as const } +const authorization = { policy, externalServices, externalServiceWritesApproved: true } assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", - inputs: { services: [externalService] }, + inputs: { externalServices, services: [externalService] }, workflow: { steps: [{ command: "wordpress.phpunit", args: ["database-type=mysql"] }] }, }).valid, true) assert.equal(validateWorkspaceRecipeJsonSchema({ @@ -41,15 +49,18 @@ assert.deepEqual(runtimeServicePlan([externalService]), [{ }]) assert.deepEqual(await validateWorkspaceRecipeSemantics({ schema: "wp-codebox/workspace-recipe/v1", - inputs: { services: [externalService] }, + inputs: { externalServices, services: [externalService] }, workflow: { steps: [{ command: "wordpress.run-php", args: ["code=echo 1;"] }] }, }, "recipe.json"), []) const exposedAdminIssues = await validateWorkspaceRecipeSemantics({ schema: "wp-codebox/workspace-recipe/v1", - inputs: { secretEnv: ["MYSQL_ADMIN_PASSWORD"], services: [externalService] }, + inputs: { secretEnv: ["MYSQL_ADMIN_PASSWORD"], externalServices, services: [externalService] }, workflow: { steps: [{ command: "wordpress.run-php", args: ["code=echo 1;"] }] }, }, "recipe.json") assert.ok(exposedAdminIssues.some((issue) => issue.code === "runtime-service-admin-env-exposed"), "administrative credentials cannot be projected into the sandbox") +const policyRecipe = { schema: "wp-codebox/workspace-recipe/v1" as const, inputs: { externalServices, services: [externalService] }, workflow: { steps: [{ command: "wordpress.phpunit", args: ["plugin-slug=example", "database-type=mysql"] }] } } +assert.ok(validateRecipeRuntimePolicy(policyRecipe, { ...policy, network: "deny" }).some((issue) => issue.code === "runtime-policy-external-service-network-denied")) +assert.ok(validateRecipeRuntimePolicy(policyRecipe, { ...policy, approvals: "never" }).some((issue) => issue.code === "runtime-policy-external-service-approval-required")) interface FakeCall { command: string @@ -83,8 +94,24 @@ function fakeDependencies(handle?: (call: FakeCall, controller?: AbortController } } +const deniedPolicyFake = fakeDependencies() +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: deniedPolicyFake.dependencies, ...authorization, policy: { ...policy, network: "deny" } }), RuntimeServiceProvisionError) +assert.equal(deniedPolicyFake.calls.length, 0, "denied network policy fails before connecting") + +const unauthorizedHostFake = fakeDependencies() +await assert.rejects(provisionRuntimeServices([externalService], { + dependencies: unauthorizedHostFake.dependencies, + ...authorization, + externalServices: [{ ...externalServices[0]!, allowedHosts: ["other.internal"] }], +}), RuntimeServiceProvisionError) +assert.equal(unauthorizedHostFake.calls.length, 0, "a host absent from the external-service allowlist fails before connecting") + +const approvalRefusalFake = fakeDependencies() +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: approvalRefusalFake.dependencies, ...authorization, externalServiceWritesApproved: false }), RuntimeServiceProvisionError) +assert.equal(approvalRefusalFake.calls.length, 0, "write approval refusal fails before connecting") + const successFake = fakeDependencies() -const provisioned = await provisionRuntimeServices([externalService], { dependencies: successFake.dependencies }) +const provisioned = await provisionRuntimeServices([externalService], { dependencies: successFake.dependencies, ...authorization }) assert.equal(provisioned.env.DB_HOST, "database.internal") assert.equal(provisioned.env.DB_PORT, "3307") assert.match(provisioned.env.DB_NAME ?? "", /^wp_codebox_[a-f0-9]{24}$/) @@ -100,6 +127,38 @@ assert.equal(successFake.calls.some((call) => call.args.some((arg) => arg.includ assert.equal(JSON.stringify(runtimeServicePlan([externalService])).includes(adminPassword), false) assert.equal(JSON.stringify(provisioned.evidence).includes(adminPassword), false) assert.equal(JSON.stringify(provisioned.evidence).includes(provisioned.env.DB_PASSWORD ?? ""), false) +const bootstrapRoot = await mkdtemp(join(tmpdir(), "wp-codebox-external-mysql-bootstrap-")) +const wordpressRoot = await mkdtemp(join(tmpdir(), "wp-codebox-external-mysql-wordpress-")) +try { + const bootstrapCalls: Parameters[0][] = [] + const cliModule: PlaygroundCliModule = { async runCLI(options) { + bootstrapCalls.push(options) + return { serverUrl: "http://127.0.0.1:65535", playground: { async run() { return { text: "" } } }, async [Symbol.asyncDispose]() {} } + } } + const { DB_PASSWORD: _password, ...runtimeEnv } = provisioned.env + const runtimeSpec: RuntimeCreateSpec = { + backend: "wordpress-playground", + environment: { version: "mounted", wordpressInstallMode: "do-not-attempt-installing", databaseSetup: "external", assets: { wordpressDirectory: wordpressRoot }, blueprint: {} }, + policy, + runtimeEnv, + secretEnv: provisioned.secretEnv, + artifactsDirectory: bootstrapRoot, + } + const server = await startPlaygroundCliServer(runtimeSpec, [], { cliModule }) + await server[Symbol.asyncDispose]() + const mounts = bootstrapCalls[0]?.["mount-before-install"] ?? [] + const autoPrependPath = mounts.find((mount) => mount.vfsPath === "/internal/shared/wp-codebox-auto-prepend.php")?.hostPath + const wpConfigPath = mounts.find((mount) => mount.vfsPath === "/wordpress/wp-config.php")?.hostPath + assert.ok(autoPrependPath && wpConfigPath) + const autoPrepend = await readFile(autoPrependPath, "utf8") + const wpConfig = await readFile(wpConfigPath, "utf8") + assert.match(autoPrepend, new RegExp(`putenv\\("DB_PASSWORD=${provisioned.env.DB_PASSWORD}`), "generated password reaches the connector bootstrap environment") + assert.match(wpConfig, /getenv\('DB_PASSWORD'\)/) + assert.equal(wpConfig.includes(provisioned.env.DB_PASSWORD ?? ""), false, "generated password is not serialized into wp-config") +} finally { + await rm(bootstrapRoot, { recursive: true, force: true }) + await rm(wordpressRoot, { recursive: true, force: true }) +} await provisioned.release() await provisioned.release() assert.equal(successFake.calls.filter((call) => call.stdin?.startsWith("DROP DATABASE")).length, 1) @@ -110,7 +169,7 @@ assert.equal(provisioned.evidence[0]?.teardown, "completed") const insufficientFake = fakeDependencies((call) => { if (call.stdin?.startsWith("CREATE DATABASE")) throw new Error(`permission denied ${adminPassword}`) }) -await assert.rejects(provisionRuntimeServices([externalService], { dependencies: insufficientFake.dependencies }), (error: unknown) => { +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: insufficientFake.dependencies, ...authorization }), (error: unknown) => { assert.ok(error instanceof RuntimeServiceProvisionError) assert.equal(error.message.includes(adminPassword), false) assert.equal(JSON.stringify(error.evidence).includes(adminPassword), false) @@ -123,7 +182,7 @@ assert.equal(insufficientFake.calls.some((call) => call.stdin?.startsWith("DROP const partialFake = fakeDependencies((call) => { if (call.stdin?.startsWith("CREATE USER")) throw new Error("create user denied") }) -await assert.rejects(provisionRuntimeServices([externalService], { dependencies: partialFake.dependencies }), RuntimeServiceProvisionError) +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: partialFake.dependencies, ...authorization }), RuntimeServiceProvisionError) assert.equal(partialFake.calls.some((call) => call.stdin?.startsWith("DROP DATABASE")), true) assert.equal(partialFake.calls.some((call) => call.stdin?.startsWith("DROP USER")), true, "partial user creation is treated as uncertain and rolled back") @@ -131,7 +190,7 @@ const cancellation = new AbortController() const cancelledFake = fakeDependencies((call, controller) => { if (call.stdin?.startsWith("CREATE DATABASE")) controller?.abort() }, cancellation) -await assert.rejects(provisionRuntimeServices([externalService], { dependencies: cancelledFake.dependencies, signal: cancellation.signal }), (error: unknown) => { +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: cancelledFake.dependencies, signal: cancellation.signal, ...authorization }), (error: unknown) => { assert.ok(error instanceof RuntimeServiceProvisionError) assert.equal(error.evidence[0]?.diagnostic?.code, "interrupted") assert.equal(error.evidence[0]?.teardown, "completed") @@ -143,7 +202,7 @@ assert.equal(cancelledCleanup?.signal, undefined, "cleanup uses an independent c const cleanupFailureFake = fakeDependencies((call) => { if (call.stdin?.startsWith("DROP DATABASE")) throw new Error(`cleanup leaked ${adminPassword}`) }) -const cleanupFailure = await provisionRuntimeServices([externalService], { dependencies: cleanupFailureFake.dependencies }) +const cleanupFailure = await provisionRuntimeServices([externalService], { dependencies: cleanupFailureFake.dependencies, ...authorization }) await assert.rejects(cleanupFailure.release(), (error: unknown) => { assert.ok(error instanceof Error) assert.equal(error.message.includes(adminPassword), false) @@ -160,19 +219,27 @@ unavailableNamespaceFake.dependencies.execute = async (command, args, options) = unavailableNamespaceFake.calls.push({ command, args, env: options.env, signal: options.signal, stdin: options.stdin }) return { stdout: options.stdin?.startsWith("SELECT EXISTS") ? "1\n0\n" : "" } } -await assert.rejects(provisionRuntimeServices([externalService], { dependencies: unavailableNamespaceFake.dependencies }), RuntimeServiceProvisionError) +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: unavailableNamespaceFake.dependencies, ...authorization }), RuntimeServiceProvisionError) assert.equal(unavailableNamespaceFake.calls.some((call) => call.stdin?.startsWith("CREATE")), false, "a namespace that cannot be proven unused fails closed") const missingCredentials = fakeDependencies() missingCredentials.dependencies.environment = {} -await assert.rejects(provisionRuntimeServices([externalService], { dependencies: missingCredentials.dependencies }), (error: unknown) => error instanceof RuntimeServiceProvisionError && error.evidence[0]?.diagnostic?.code === "provision-failed") +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: missingCredentials.dependencies, ...authorization }), (error: unknown) => error instanceof RuntimeServiceProvisionError && error.evidence[0]?.diagnostic?.code === "provision-failed") assert.equal(missingCredentials.calls.length, 0, "missing host credential references fail before invoking a database client") const mariaService: WorkspaceRecipeRuntimeService = { ...externalService, configuration: { ...externalService.configuration, engine: "mariadb" } } const mariaFake = fakeDependencies() -const maria = await provisionRuntimeServices([mariaService], { dependencies: mariaFake.dependencies }) +const maria = await provisionRuntimeServices([mariaService], { dependencies: mariaFake.dependencies, ...authorization }) assert.equal(runtimeServicePlan([mariaService])[0]?.version, "mysql-compatible:mariadb") assert.equal(mariaFake.calls.every((call) => call.command === "mariadb"), true) await maria.release() +await assert.rejects(executeRuntimeServiceProcess(process.execPath, ["-e", "process.stdout.write('x'.repeat(4096))"], { timeout: 10_000, maxOutputBytes: 128 }), (error: unknown) => { + const bounded = error as Error & { code?: string; stdout?: string; stderr?: string } + assert.equal(bounded.code, "runtime-service-output-overflow") + assert.ok(Buffer.byteLength(bounded.stdout ?? "") <= 128) + assert.ok(Buffer.byteLength(bounded.stderr ?? "") <= 128) + return true +}) + console.log("external MySQL runtime service tests passed") diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index 918491deb..0bc3c7ec1 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -56,7 +56,8 @@ try { }, }, }, - runtimeEnv: { TC_MYSQL_PORT: "33060", DB_HOST: "127.0.0.1", DB_PORT: "33061", DB_USER: "runtime", DB_PASSWORD: "secret", DB_NAME: "runtime" }, + runtimeEnv: { TC_MYSQL_PORT: "33060", DB_HOST: "127.0.0.1", DB_PORT: "33061", DB_USER: "runtime", DB_NAME: "runtime" }, + secretEnv: { DB_PASSWORD: "secret" }, artifactsDirectory, } @@ -92,6 +93,7 @@ try { const sharedAutoPrepend = await readFile(sharedAutoPrependPath as string, "utf8") assert.match(sharedAutoPrepend, /require_once '\/internal\/shared\/auto_prepend_file\.php'/) assert.match(sharedAutoPrepend, /putenv\("TC_MYSQL_PORT=33060"\);/) + assert.match(sharedAutoPrepend, /putenv\("DB_PASSWORD=secret"\);/) const requestWorkerPath = calls[0]["mount-before-install"]?.[3]?.hostPath assert.equal(typeof requestWorkerPath, "string") const requestWorker = await readFile(requestWorkerPath as string, "utf8") @@ -102,7 +104,8 @@ try { assert.equal(typeof externalWpConfigPath, "string") const externalWpConfig = await readFile(externalWpConfigPath as string, "utf8") assert.match(externalWpConfig, /define\('DB_HOST', "127\.0\.0\.1:33061"\)/) - assert.match(externalWpConfig, /define\('DB_PASSWORD', "secret"\)/) + assert.match(externalWpConfig, /define\('DB_PASSWORD', \(string\) getenv\('DB_PASSWORD'\)\)/) + assert.doesNotMatch(externalWpConfig, /secret/) calls.length = 0 const defaultRuntimeIniSpec: RuntimeCreateSpec = { From 28a9d4c7b7c7869b54328189715df80274fab6c8 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 25 Jul 2026 00:20:30 +0000 Subject: [PATCH 3/3] test: cover generated runtime secret evidence --- tests/recipe-secret-env.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/recipe-secret-env.test.ts b/tests/recipe-secret-env.test.ts index 6d76739f5..f52d5d5c8 100644 --- a/tests/recipe-secret-env.test.ts +++ b/tests/recipe-secret-env.test.ts @@ -3,10 +3,12 @@ import assert from "node:assert/strict" import { SECRET_ENV_PROJECTIONS_ENV, defaultRecipeSecretEnvProviders, + mergeRecipeSecretEnvSummary, parseSecretEnvProjections, resolveRecipeSecretEnv, type RecipeSecretEnvProvider, } from "../packages/cli/src/recipe-secret-env.js" +import { recipeSecretEnvelope } from "../packages/cli/src/recipe-evidence.js" import { planWorkspaceRecipe } from "../packages/cli/src/recipe-dry-run.js" const source = { @@ -27,6 +29,20 @@ assert.deepEqual(resolved.summary, [ { name: "MISSING_SECRET", status: "missing" }, ]) assert.doesNotMatch(JSON.stringify(resolved.summary), /direct-value-123|projected-value-456/) +const generatedSummary = mergeRecipeSecretEnvSummary(resolved.summary, ["DB_PASSWORD"]) +assert.deepEqual(recipeSecretEnvelope(generatedSummary), { + schema: "wp-codebox/redacted-secret-envelope/v1", + provided: true, + count: 3, + secrets: [ + { name: "DB_PASSWORD", status: "available", source: "managed-runtime-service" }, + { name: "DIRECT_SECRET", status: "available", source: "process-env" }, + { name: "MISSING_SECRET", status: "missing" }, + { name: "PROJECTED_SECRET", status: "available", source: "env-projection" }, + ], + redaction: "names-only", +}) +assert.doesNotMatch(JSON.stringify(recipeSecretEnvelope(generatedSummary)), /direct-value-123|projected-value-456/) const customProvider: RecipeSecretEnvProvider = (name) => name === "DERIVED_SECRET" ? { value: `derived:${name.toLowerCase()}`, source: "test-provider" }