diff --git a/package-lock.json b/package-lock.json index 3a9d15ea..3fbfba00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wp-codebox-workspace", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wp-codebox-workspace", - "version": "0.13.0", + "version": "0.14.0", "hasInstallScript": true, "license": "ISC", "workspaces": [ @@ -138,6 +138,7 @@ "once": "^1.4.0", "pako": "^1.0.11", "parseurl": "^1.3.3", + "patch-package": "^8.0.1", "path-expression-matcher": "^1.5.0", "path-to-regexp": "^0.1.13", "pify": "^4.0.1", @@ -207,7 +208,6 @@ "devDependencies": { "@cloudflare/workers-types": "^5.20260718.1", "@types/node": "^24.0.0", - "patch-package": "^8.0.1", "tsx": "^4.20.0", "wrangler": "^4.112.0" } @@ -866,9 +866,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -886,9 +883,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -906,9 +900,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -926,9 +917,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -946,9 +934,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -966,9 +951,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -986,9 +968,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1006,9 +985,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1026,9 +1002,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1052,9 +1025,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1078,9 +1048,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1104,9 +1071,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1130,9 +1094,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1156,9 +1117,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1182,9 +1140,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1208,9 +1163,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2389,7 +2341,6 @@ }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/@zip.js/zip.js": { @@ -2592,7 +2543,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -2695,7 +2645,6 @@ }, "node_modules/chalk": { "version": "4.1.2", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -2710,7 +2659,6 @@ }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -2721,7 +2669,6 @@ }, "node_modules/ci-info": { "version": "3.9.0", - "dev": true, "funding": [ { "type": "github", @@ -2831,7 +2778,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3245,7 +3191,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -3274,7 +3219,6 @@ }, "node_modules/find-yarn-workspace-root": { "version": "2.0.0", - "dev": true, "license": "Apache-2.0", "dependencies": { "micromatch": "^4.0.2" @@ -3425,7 +3369,6 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3588,7 +3531,6 @@ }, "node_modules/is-docker": { "version": "2.2.1", - "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -3611,7 +3553,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -3646,7 +3587,6 @@ }, "node_modules/is-wsl": { "version": "2.2.0", - "dev": true, "license": "MIT", "dependencies": { "is-docker": "^2.0.0" @@ -3663,7 +3603,6 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/isomorphic-git": { @@ -3697,7 +3636,6 @@ }, "node_modules/json-stable-stringify": { "version": "1.3.0", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -3733,7 +3671,6 @@ }, "node_modules/jsonify": { "version": "0.0.1", - "dev": true, "license": "Public Domain", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3784,7 +3721,6 @@ }, "node_modules/klaw-sync": { "version": "6.0.0", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.1.11" @@ -3887,7 +3823,6 @@ }, "node_modules/micromatch": { "version": "4.0.8", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -4019,7 +3954,6 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4068,7 +4002,6 @@ }, "node_modules/open": { "version": "7.4.2", - "dev": true, "license": "MIT", "dependencies": { "is-docker": "^2.0.0", @@ -4098,7 +4031,6 @@ }, "node_modules/patch-package": { "version": "8.0.1", - "dev": true, "license": "MIT", "dependencies": { "@yarnpkg/lockfile": "^1.1.0", @@ -4126,7 +4058,6 @@ }, "node_modules/patch-package/node_modules/fs-extra": { "version": "10.1.0", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -4154,7 +4085,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4173,7 +4103,6 @@ }, "node_modules/picomatch": { "version": "2.3.2", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -4535,7 +4464,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4546,7 +4474,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4671,7 +4598,6 @@ }, "node_modules/slash": { "version": "2.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4781,7 +4707,6 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -4961,7 +4886,6 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -5158,7 +5082,6 @@ }, "node_modules/yaml": { "version": "2.9.0", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -5236,7 +5159,7 @@ }, "packages/cli": { "name": "@automattic/wp-codebox-cli", - "version": "0.13.0", + "version": "0.14.0", "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", "@automattic/wp-codebox-playground": "file:../runtime-playground" @@ -5258,14 +5181,14 @@ }, "packages/runtime-core": { "name": "@automattic/wp-codebox-core", - "version": "0.13.0", + "version": "0.14.0", "dependencies": { "ajv": "^8.20.0" } }, "packages/runtime-playground": { "name": "@automattic/wp-codebox-playground", - "version": "0.13.0", + "version": "0.14.0", "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", "@php-wasm/node": "3.1.46", diff --git a/package.json b/package.json index 69715753..14cc48fc 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "packages/cli/dist", "packages/cli/package.json", "scripts/apply-development-patches.mjs", + "patches", "README.md", "LICENSE" ], @@ -284,7 +285,6 @@ "devDependencies": { "@cloudflare/workers-types": "^5.20260718.1", "@types/node": "^24.0.0", - "patch-package": "^8.0.1", "tsx": "^4.20.0", "wrangler": "^4.112.0" }, @@ -420,6 +420,7 @@ "on-finished": "^2.4.1", "once": "^1.4.0", "pako": "^1.0.11", + "patch-package": "^8.0.1", "parseurl": "^1.3.3", "path-expression-matcher": "^1.5.0", "path-to-regexp": "^0.1.13", diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index 449f41c7..694ca8dc 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -200,6 +200,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe policy, externalServices: recipe.inputs?.externalServices ?? [], externalServiceWritesApproved: options.externalServiceWritesApproved, + reservedEnvNames: [...Object.keys(runtimeEnv), ...(recipe.inputs?.secretEnv ?? [])], onEvidence: (evidence) => { serviceEvidence = evidence }, }, )) @@ -229,6 +230,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe policy: effectivePolicy, runtimeEnv, secretEnv, + secretEnvTargets: managedServices.secretEnvTargets, artifactsDirectory: configuredArtifactsDirectory, metadata: { ...runtimeMetadata(configuredArtifactsDirectory, plan.runtime.wp), diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index 713608da..acc2275b 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -753,18 +753,36 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe, function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: string, path: string, message: string) => void): void { const ids = new Set() + const services = recipe.inputs?.services ?? [] 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 outputOwners = new Map>() + for (const [serviceIndex, service] of services.entries()) { + for (const [output, name] of Object.entries(service.outputs)) { + const owners = outputOwners.get(name) ?? [] + owners.push({ serviceIndex, output }) + outputOwners.set(name, owners) + } + } + const connectorTargets = new Set() + for (const [index, service] of 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.kind === "mysql" && service.outputs.password) { + const target = "DB_PASSWORD" + if (exposedEnvironment.has(target)) addIssue("runtime-service-secret-target-collision", `${path}.outputs.password`, `Managed connector secret target is already injected by recipe environment: ${target}`) + const conflictingOutput = (outputOwners.get(target) ?? []).some((owner) => !(owner.serviceIndex === index && owner.output === "password" && service.outputs.password === target)) + if (conflictingOutput) addIssue("runtime-service-secret-target-collision", `${path}.outputs.password`, `Managed connector secret target collides with a managed output: ${target}`) + if (connectorTargets.has(target)) addIssue("ambiguous-runtime-service-secret-target", `${path}.outputs.password`, `Multiple managed connectors target the same runtime environment name: ${target}`) + connectorTargets.add(target) + } 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) diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index e44b5af7..c627ad4a 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -50,6 +50,7 @@ export function runtimeServiceEvidenceFromError(error: unknown): RuntimeServiceE interface ManagedRuntimeService { env: Record secretEnv: Record + secretEnvTargets: Record evidence: RuntimeServiceEvidence release(): Promise control(action: RuntimeServiceControlAction, options?: Record): Promise @@ -66,6 +67,7 @@ export interface RuntimeServiceProvider { readonly name: string readonly kind: string version(service: WorkspaceRecipeRuntimeService): string + secretEnvTargets(service: WorkspaceRecipeRuntimeService): Record provision(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidence: RuntimeServiceEvidence[]): Promise } @@ -82,6 +84,7 @@ export interface ProvisionRuntimeServicesOptions { policy?: RuntimePolicy externalServices?: WorkspaceRecipeExternalServiceBoundary[] externalServiceWritesApproved?: boolean + reservedEnvNames?: readonly string[] } const defaultDependencies: RuntimeServiceDependencies = { @@ -99,10 +102,11 @@ export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): A }) } -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 }> { +export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: ProvisionRuntimeServicesOptions = {}): Promise<{ env: Record; secretEnv: Record; secretEnvTargets: Record; evidence: RuntimeServiceEvidence[]; control(serviceId: string, action: RuntimeServiceControlAction, controlOptions?: Record): Promise; release(): Promise }> { const dependencies = options.dependencies ?? defaultDependencies const provisioned: ManagedRuntimeService[] = [] const evidence: RuntimeServiceEvidence[] = [] + let environment: ReturnType const context: RuntimeServiceProvisionContext = { signal: options.signal, policy: options.policy, @@ -110,10 +114,12 @@ export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeS externalServiceWritesApproved: options.externalServiceWritesApproved ?? false, } try { + validateDeclaredRuntimeServiceSecretTargets(services, options.reservedEnvNames ?? []) for (const service of services) { const managed = await runtimeServiceProvider(service).provision(service, dependencies, context, evidence) provisioned.push(managed) } + environment = aggregateRuntimeServiceEnvironment(provisioned, options.reservedEnvNames ?? []) } catch (error) { await releaseServices(provisioned).catch(() => undefined) if (error instanceof RuntimeServiceProvisionError) throw error @@ -126,8 +132,7 @@ export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeS const lease = provisioned.length > 0 ? setInterval(() => undefined, 1_000) : undefined return { - env: Object.assign({}, ...provisioned.map((service) => service.env)), - secretEnv: Object.assign({}, ...provisioned.map((service) => service.secretEnv)), + ...environment, evidence, async control(serviceId, action, controlOptions) { const service = provisioned.find((candidate) => candidate.evidence.id === serviceId) @@ -159,6 +164,7 @@ export async function provisionRuntimeServicesForRecipe( policy: options.policy, externalServices: options.externalServices, externalServiceWritesApproved: options.externalServiceWritesApproved, + reservedEnvNames: options.reservedEnvNames, }) try { return await guard(provisioning) @@ -185,6 +191,7 @@ const mysqlDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "mysql", version: mysqlDockerImage, + secretEnvTargets: mysqlRuntimeServiceSecretTargets, provision: provisionMysqlDockerService, } @@ -192,17 +199,22 @@ const mysqlExternalProvider: RuntimeServiceProvider = { name: "external", kind: "mysql", version: (service) => `mysql-compatible:${service.configuration?.engine ?? "mysql"}`, + secretEnvTargets: mysqlRuntimeServiceSecretTargets, 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 } +const redisDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "redis", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.redis, secretEnvTargets: () => ({}), provision: provisionRedisDockerService } +const smtpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "smtp", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.smtp, secretEnvTargets: () => ({}), provision: provisionSmtpDockerService } +const httpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "http", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.http, secretEnvTargets: () => ({}), provision: provisionHttpDockerService } function mysqlDockerImage(service: WorkspaceRecipeRuntimeService): string { return MYSQL_IMAGES[service.configuration?.engine ?? "mysql"] } +function mysqlRuntimeServiceSecretTargets(service: WorkspaceRecipeRuntimeService): Record { + return service.outputs.password ? { DB_PASSWORD: service.outputs.password } : {} +} + 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}`) @@ -243,8 +255,9 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic evidence.lifecycle = "provisioned" 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] ?? ""])), + env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])), secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {}, + secretEnvTargets: mysqlRuntimeServiceSecretTargets(service), evidence, async control(action, options) { return await controlDockerService(container, evidence, dependencies, action, options, async (customAction) => { if (customAction === "flush") { @@ -352,8 +365,9 @@ async function provisionMysqlExternalService(service: WorkspaceRecipeRuntimeServ 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] ?? ""])), + env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])), secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {}, + secretEnvTargets: mysqlRuntimeServiceSecretTargets(service), evidence, async control(action) { const result: RuntimeServiceControlResult = { serviceId: service.id, action, status: "unsupported", fidelity: "unsupported", reason: `The ${service.kind} provider does not support ${action}.` } @@ -419,6 +433,62 @@ function mysqlConnectionArgs(host: string, port: number, username: string): stri return ["--batch", "--skip-column-names", "--protocol=TCP", "--host", host, "--port", String(port), "--user", username] } +function runtimeServiceOutputEnvironment(service: WorkspaceRecipeRuntimeService, values: Record, secretOutputs: ReadonlySet = new Set()): Record { + return Object.fromEntries(Object.entries(service.outputs) + .filter(([output]) => !secretOutputs.has(output)) + .map(([output, name]) => [name, values[output] ?? ""])) +} + +function validateDeclaredRuntimeServiceSecretTargets(services: readonly WorkspaceRecipeRuntimeService[], reservedEnvNames: readonly string[]): void { + const reserved = new Set(reservedEnvNames) + const outputOwners = new Map>() + for (const [serviceIndex, service] of services.entries()) { + for (const [output, name] of Object.entries(service.outputs)) { + const owners = outputOwners.get(name) ?? [] + owners.push({ serviceIndex, output }) + outputOwners.set(name, owners) + } + } + const targets = new Map() + for (const [serviceIndex, service] of services.entries()) { + for (const [target, source] of Object.entries(runtimeServiceProvider(service).secretEnvTargets(service))) { + if (reserved.has(target)) throw new Error(`Managed runtime service secret target is reserved by injected environment: ${target}`) + const conflictingOutput = (outputOwners.get(target) ?? []).some((owner) => !(owner.serviceIndex === serviceIndex && owner.output === "password" && source === target)) + if (conflictingOutput) throw new Error(`Managed runtime service secret target collides with managed output: ${target}`) + if (targets.has(target)) throw new Error(`Managed runtime service secret target is ambiguous: ${target}`) + targets.set(target, source) + } + } +} + +function aggregateRuntimeServiceEnvironment(services: readonly ManagedRuntimeService[], reservedEnvNames: readonly string[]): { env: Record; secretEnv: Record; secretEnvTargets: Record } { + const env: Record = {} + const secretEnv: Record = {} + const secretEnvTargets: Record = {} + for (const service of services) { + mergeUniqueEnvironment(env, service.env, "runtime service environment") + mergeUniqueEnvironment(secretEnv, service.secretEnv, "runtime service secret environment") + for (const [target, source] of Object.entries(service.secretEnvTargets)) { + const existing = secretEnvTargets[target] + if (existing !== undefined && existing !== source) throw new Error(`Managed runtime service secret target is ambiguous: ${target}`) + secretEnvTargets[target] = source + } + } + for (const [target, source] of Object.entries(secretEnvTargets)) { + if (!(source in secretEnv)) throw new Error(`Managed runtime service secret target references an unavailable secret: ${target}`) + if (target in env) throw new Error(`Managed runtime service secret target collides with non-secret environment: ${target}`) + if (reservedEnvNames.includes(target)) throw new Error(`Managed runtime service secret target is reserved by injected environment: ${target}`) + } + return { env, secretEnv, secretEnvTargets } +} + +function mergeUniqueEnvironment(target: Record, source: Record, label: string): void { + for (const [name, value] of Object.entries(source)) { + if (name in target) throw new Error(`Duplicate ${label} name: ${name}`) + target[name] = value + } +} + 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 @@ -501,6 +571,7 @@ async function provisionSimpleDockerService( return { env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])), secretEnv: {}, + secretEnvTargets: {}, 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) }, diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index 19981169..46038027 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -31,6 +31,8 @@ export interface RuntimeCreateSpec { artifactsDirectory?: string runtimeEnv?: Record secretEnv?: Record + /** Maps an environment target to the secretEnv name that supplies its value. */ + secretEnvTargets?: Record metadata?: Record preview?: RuntimePreviewSpec onBrowserStartupProgress?: BrowserStartupProgressListener diff --git a/packages/runtime-core/src/runtime-env.ts b/packages/runtime-core/src/runtime-env.ts index 68e4df67..7fa1ec4a 100644 --- a/packages/runtime-core/src/runtime-env.ts +++ b/packages/runtime-core/src/runtime-env.ts @@ -63,6 +63,24 @@ export function resolveSecretEnvNames(names: readonly string[], options: Resolve return secretEnv } +export function resolveRuntimeSecretEnvTargets(secretEnv: Record, targets: Record = {}): Record { + const resolved: Record = {} + for (const [target, source] of Object.entries(targets)) { + assertRuntimeEnvName(target, "secret env target") + assertRuntimeEnvName(source, "secret env source") + if (!(source in secretEnv)) throw new Error(`Secret env target ${target} references unavailable source: ${source}`) + resolved[target] = secretEnv[source] as string + } + return resolved +} + +export function assertRuntimeSecretEnvTargetsAvailable(targets: Record = {}, ...environmentSources: Array>): void { + for (const target of Object.keys(targets)) { + assertRuntimeEnvName(target, "secret env target") + if (environmentSources.some((source) => target in source)) throw new Error(`Secret env target collides with injected environment: ${target}`) + } +} + export function registerRuntimeSecretRedactions(secretEnv: Record, registrar: RuntimeEnvRedactionRegistrar): void { for (const [name, value] of Object.entries(secretEnv)) { registrar.registerSecretName(name) diff --git a/packages/runtime-playground/src/php-bootstrap.ts b/packages/runtime-playground/src/php-bootstrap.ts index 1b89a630..aca86bf7 100644 --- a/packages/runtime-playground/src/php-bootstrap.ts +++ b/packages/runtime-playground/src/php-bootstrap.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises" import { argValue, normalizePhpCode, phpBody } from "./commands.js" import { phpCliStreamConstants, phpEnvAssignments, phpRuntimeRecipePluginPreloadFunction, phpWpConfigDefineAssignments } from "./php-snippets.js" -import { normalizeRuntimeEnvRecord, resolveCommandPath, type RuntimeCreateSpec } from "@automattic/wp-codebox-core" +import { assertRuntimeSecretEnvTargetsAvailable, normalizeRuntimeEnvRecord, resolveCommandPath, type RuntimeCreateSpec } from "@automattic/wp-codebox-core" interface PhpBootstrapBridge { url: string @@ -9,6 +9,7 @@ interface PhpBootstrapBridge { } export function bootstrapAbilityPhpCode(spec: RuntimeCreateSpec, code: string): string { + assertRuntimeSecretEnvTargetsAvailable(spec.secretEnvTargets, spec.runtimeEnv ?? {}) return ` | undefined { } function secretEnvPhp(spec: RuntimeCreateSpec): string { - return phpEnvAssignments(normalizeRuntimeEnvRecord(spec.secretEnv ?? {}, { field: "secretEnv" })) + const secretEnv = { ...(spec.secretEnv ?? {}) } + if (spec.environment.databaseSetup === "external") { + for (const source of Object.values(spec.secretEnvTargets ?? {})) delete secretEnv[source] + } + return phpEnvAssignments(normalizeRuntimeEnvRecord(secretEnv, { field: "secretEnv" })) } function runtimeEnvPhp(spec: RuntimeCreateSpec, args: string[] = []): string { + const executionEnvironment = runtimeEnvOverride(args) + return phpEnvAssignments(normalizeRuntimeEnvRecord({ ...(spec.runtimeEnv ?? {}), ...executionEnvironment }, { field: "runtimeEnv" })) +} + +function runtimeEnvOverride(args: string[]): Record { const override = argValue(args, "runtime-env-json") let executionEnvironment: Record = {} if (override) { @@ -284,5 +296,5 @@ function runtimeEnvPhp(spec: RuntimeCreateSpec, args: string[] = []): string { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("runtime-env-json must be a JSON object") executionEnvironment = parsed as Record } - return phpEnvAssignments(normalizeRuntimeEnvRecord({ ...(spec.runtimeEnv ?? {}), ...executionEnvironment }, { field: "runtimeEnv" })) + return executionEnvironment } diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index baedb658..77591566 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -2,7 +2,7 @@ import { playgroundBlueprint } from "./blueprint.js" import { PlaygroundCliExitError, type PlaygroundCliBufferedOutput } from "./playground-command-errors.js" import { PlaygroundPreviewPortUnavailableError, assertPreviewPortAvailable, errorHasCode, withPreviewProxy, type PlaygroundCliServer } from "./preview-server.js" import { startProgrammaticPlaygroundServer } from "./programmatic-playground-runner.js" -import { normalizeLiveProgressEvent, previewLease, type BrowserStartupProgressEvent, type BrowserStartupProgressPhase, type BrowserStartupProgressStatus, type MountSpec, type PreviewLease, type RuntimeCreateSpec, type RuntimePreviewLeaseProvider } from "@automattic/wp-codebox-core" +import { assertRuntimeSecretEnvTargetsAvailable, normalizeLiveProgressEvent, previewLease, resolveRuntimeSecretEnvTargets, type BrowserStartupProgressEvent, type BrowserStartupProgressPhase, type BrowserStartupProgressStatus, type MountSpec, type PreviewLease, type RuntimeCreateSpec, type RuntimePreviewLeaseProvider } from "@automattic/wp-codebox-core" import { randomBytes, randomInt } from "node:crypto" import { existsSync } from "node:fs" import { createServer as createHttpServer, type Server as HttpServer } from "node:http" @@ -34,6 +34,7 @@ export interface PlaygroundCliModule { skipSqliteSetup?: boolean "site-url"?: string phpIniEntries?: Record + phpEnv?: Record phpExtension?: string[] }): Promise } @@ -44,6 +45,7 @@ export interface PlaygroundCliStartupOptions { } export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: MountSpec[], options: PlaygroundCliStartupOptions = {}): Promise { + assertRuntimeSecretEnvTargetsAvailable(spec.secretEnvTargets, spec.runtimeEnv ?? {}, distributionEnv(recipeDistribution(spec)?.env)) const startedAt = Date.now() const emitProgress = (phase: BrowserStartupProgressPhase, status: BrowserStartupProgressStatus, label: string, detail?: Record) => { const event = { @@ -168,6 +170,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: skipSqliteSetup: spec.environment.databaseSetup === "external", ...(spec.environment.extensions?.length ? { phpExtension: spec.environment.extensions.map((extension) => extension.manifest) } : {}), phpIniEntries: pluginRuntimePhpIniEntries(spec), + phpEnv: connectorSecretEnvironment(spec), "site-url": spec.preview?.siteUrl, blueprint: playgroundCliBlueprint(spec), }) @@ -451,19 +454,18 @@ function runtimeAutoPrependPhp(spec: RuntimeCreateSpec): string { } function runtimeAutoPrependPhpBody(spec: RuntimeCreateSpec): string { - const runtimeEnv = spec.environment.databaseSetup === "external" ? phpEnvAssignments(connectorRuntimeEnv(spec)) : "" + const runtimeEnv = spec.environment.databaseSetup === "external" ? phpEnvAssignments(spec.runtimeEnv ?? {}) : "" return `${runtimeEnv}${distributionBootstrapPhp(spec)}` } function externalDatabaseWpConfig(spec: RuntimeCreateSpec): string | undefined { if (spec.environment.databaseSetup !== "external") return undefined - const connectorEnv = connectorRuntimeEnv(spec) - const host = connectorEnv.DB_HOST + const host = spec.runtimeEnv?.DB_HOST if (!host) return undefined - const port = connectorEnv.DB_PORT + const port = spec.runtimeEnv?.DB_PORT const values = { - DB_NAME: connectorEnv.DB_NAME ?? "runtime", - DB_USER: connectorEnv.DB_USER ?? "root", + DB_NAME: spec.runtimeEnv?.DB_NAME ?? "runtime", + DB_USER: spec.runtimeEnv?.DB_USER ?? "root", DB_HOST: port ? `${host}:${port}` : host, } return ` { - return { ...(spec.runtimeEnv ?? {}), ...(spec.secretEnv ?? {}) } +function connectorSecretEnvironment(spec: RuntimeCreateSpec): Record | undefined { + if (spec.environment.databaseSetup !== "external") return undefined + const resolved = resolveRuntimeSecretEnvTargets(spec.secretEnv ?? {}, spec.secretEnvTargets) + return Object.keys(resolved).length > 0 ? resolved : undefined } function distributionBootstrapPhp(spec: RuntimeCreateSpec): string { diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index 4f39848c..f129d302 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -3,7 +3,7 @@ import { AsyncLocalStorage } from "node:async_hooks" import { mkdir, readFile, realpath, unlink, writeFile } from "node:fs/promises" import type { IncomingMessage, ServerResponse } from "node:http" import { dirname, join, resolve } from "node:path" -import { HostToolRegistry, PREVIEW_LEASE_SCHEMA, RUNTIME_EPISODE_OBSERVATION_SCHEMA, RUNTIME_EPISODE_SNAPSHOT_SCHEMA, RuntimeActionExecutionError, assertRuntimeCommandAllowed, commandAgentRunResultJson, createCommandAgentRunResult, createHostToolRegistry, createRuntimeCommandResultEnvelope, parseCommandAgentRunRequest, previewLease, resolveArtifactPath, resolveCommandPath, runtimeCommandResultEnvelopeFromOutput, runtimeEpisodeDigest } from "@automattic/wp-codebox-core" +import { HostToolRegistry, PREVIEW_LEASE_SCHEMA, RUNTIME_EPISODE_OBSERVATION_SCHEMA, RUNTIME_EPISODE_SNAPSHOT_SCHEMA, RuntimeActionExecutionError, assertRuntimeCommandAllowed, assertRuntimeSecretEnvTargetsAvailable, commandAgentRunResultJson, createCommandAgentRunResult, createHostToolRegistry, createRuntimeCommandResultEnvelope, parseCommandAgentRunRequest, previewLease, resolveArtifactPath, resolveCommandPath, runtimeCommandResultEnvelopeFromOutput, runtimeEpisodeDigest } from "@automattic/wp-codebox-core" import { now, sha256 } from "@automattic/wp-codebox-core/internals" import { recipeCommandDefinitions } from "@automattic/wp-codebox-core/contracts" import { browserArtifactFileManifest, browserReviewSummary as browserArtifactReviewSummary, type BrowserArtifact, type BrowserArtifactFiles } from "./browser-artifacts.js" @@ -348,6 +348,7 @@ class PlaygroundRuntime implements Runtime { async execute(spec: ExecutionSpec): Promise { assertRuntimeCommandAllowed(spec.command, this.spec.policy) + assertRuntimeSecretEnvTargetsAvailable(this.spec.secretEnvTargets, spec.environment ?? {}) const startedAt = now() const commandId = id("command") diff --git a/packages/runtime-playground/src/preview-server.ts b/packages/runtime-playground/src/preview-server.ts index 1f0ba238..055d6f12 100644 --- a/packages/runtime-playground/src/preview-server.ts +++ b/packages/runtime-playground/src/preview-server.ts @@ -10,7 +10,7 @@ export interface PlaygroundServerRunResponse { export interface PlaygroundCliServer { playground: { - run(options: { code: string } | { scriptPath: string }): Promise + run(options: ({ code: string } | { scriptPath: string }) & { env?: Record }): Promise onMessage?(listener: (data: string) => Promise | string | void): Promise<(() => Promise | void) | void> | (() => Promise | void) | void readFileAsText?(path: string): string | Promise unlink?(path: string): Promise | void diff --git a/patches/@wp-playground+cli+3.1.46.patch b/patches/@wp-playground+cli+3.1.46.patch index 99ac8c2e..8b270431 100644 --- a/patches/@wp-playground+cli+3.1.46.patch +++ b/patches/@wp-playground+cli+3.1.46.patch @@ -1,8 +1,36 @@ +diff --git a/node_modules/@wp-playground/cli/run-cli-DKYOp4UU.js b/node_modules/@wp-playground/cli/run-cli-DKYOp4UU.js +index e22c476..ed7c114 100644 +--- a/node_modules/@wp-playground/cli/run-cli-DKYOp4UU.js ++++ b/node_modules/@wp-playground/cli/run-cli-DKYOp4UU.js +@@ -536,6 +536,7 @@ class Yt { + mountsBeforeWpInstall: this.args["mount-before-install"] || [], + mountsAfterWpInstall: this.args.mount || [], + processId: t.processId, ++ environmentVariables: this.args.phpEnv, + followSymlinks: this.args.followSymlinks === !0, + trace: this.args.experimentalTrace === !0, + extensions: Ve( +@@ -777,6 +778,7 @@ class er { + mountsBeforeWpInstall: this.args["mount-before-install"] || [], + mountsAfterWpInstall: this.args.mount || [], + processId: t.processId, ++ environmentVariables: this.args.phpEnv, + followSymlinks: this.args.followSymlinks === !0, + trace: this.args.experimentalTrace === !0, + extensions: Ve(this.args), diff --git a/node_modules/@wp-playground/cli/worker-thread-v1.js b/node_modules/@wp-playground/cli/worker-thread-v1.js -index 93b7099..65f76be 100644 +index 93b7099..a8ecae8 100644 --- a/node_modules/@wp-playground/cli/worker-thread-v1.js +++ b/node_modules/@wp-playground/cli/worker-thread-v1.js -@@ -157,6 +157,8 @@ async function C(r, e) { +@@ -125,6 +125,7 @@ function V(r, e) { + { + fileLockManager: e, + emscriptenOptions: { ++ ENV: r.environmentVariables, + processId: r.processId, + trace: r.trace ? x : void 0, + nativeInternalDirPath: r.nativeInternalDirPath +@@ -157,6 +158,8 @@ async function C(r, e) { } process.on("unhandledRejection", (r) => { S.error("Unhandled rejection:", r); @@ -12,10 +40,18 @@ index 93b7099..65f76be 100644 const i = new _(), [c, p] = y( new F(new f()), diff --git a/node_modules/@wp-playground/cli/worker-thread-v2.js b/node_modules/@wp-playground/cli/worker-thread-v2.js -index fb79a59..77ddb67 100644 +index fb79a59..5197c68 100644 --- a/node_modules/@wp-playground/cli/worker-thread-v2.js +++ b/node_modules/@wp-playground/cli/worker-thread-v2.js -@@ -168,6 +168,8 @@ async function j(r, e) { +@@ -136,6 +136,7 @@ function V(r, e) { + { + fileLockManager: e, + emscriptenOptions: { ++ ENV: r.environmentVariables, + processId: r.processId, + trace: r.trace ? C : void 0, + nativeInternalDirPath: r.nativeInternalDirPath +@@ -168,6 +169,8 @@ async function j(r, e) { } process.on("unhandledRejection", (r) => { E.error("Unhandled rejection:", r); diff --git a/scripts/package-release-artifact.ts b/scripts/package-release-artifact.ts index d19ab25e..2a625ed9 100644 --- a/scripts/package-release-artifact.ts +++ b/scripts/package-release-artifact.ts @@ -26,6 +26,7 @@ try { await copyIfPresent("README.md") await copyIfPresent("LICENSE") + await cp(resolve(repoRoot, "patches"), join(packageRoot, "patches"), { recursive: true }) await cp(resolve(repoRoot, "package.json"), join(packageRoot, "package.json")) await cp(resolve(repoRoot, "package-lock.json"), join(packageRoot, "package-lock.json")) @@ -41,6 +42,10 @@ try { cwd: packageRoot, maxBuffer: 1024 * 1024 * 20, }) + await execFileAsync(process.execPath, [resolve(repoRoot, "node_modules", "patch-package", "index.js")], { + cwd: packageRoot, + maxBuffer: 1024 * 1024 * 20, + }) await materializeWorkspacePackages(packageRoot) await bundleNodeRuntime(packageRoot, platformName, archName) diff --git a/tests/disposable-mysql-mysqli.integration.test.ts b/tests/disposable-mysql-mysqli.integration.test.ts index 58798677..453adc8c 100644 --- a/tests/disposable-mysql-mysqli.integration.test.ts +++ b/tests/disposable-mysql-mysqli.integration.test.ts @@ -83,6 +83,7 @@ final class BoundedMariaDbTest extends PHPUnit\\Framework\\TestCase { public function test_database_identity(): void { $index = (string) getenv('TC_DB_INDEX'); $this->assertMatchesRegularExpression('/^[12]$/', $index); + $this->assertNotSame('', (string) getenv('DB_PASSWORD')); $db = mysqli_init(); $this->assertTrue(mysqli_real_connect($db, '127.0.0.1', 'root', '', 'runtime', (int) getenv('TC_MYSQL_PORT'))); mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); @@ -111,7 +112,7 @@ require_once ABSPATH . 'wp-settings.php'; pluginSlug: "bounded-phpunit-fixture", phpVersion: "8.3", pluginSource: plugin, - services: [{ id: "phpunit-mariadb", kind: "mysql", configuration: { engine: "mariadb", rootAuthentication: "empty-password" }, outputs: { port: "TC_MYSQL_PORT" } }], + services: [{ id: "phpunit-mariadb", kind: "mysql", configuration: { engine: "mariadb", rootAuthentication: "empty-password" }, outputs: { port: "TC_MYSQL_PORT", password: "DB_PASSWORD" } }], mounts: [ { source: wpConfig, target: "/wordpress/wp-config.php", mode: "readonly" }, { source: join(harness, "vendor"), target: "/wp-codebox-vendor", mode: "readonly" }, diff --git a/tests/external-mysql-runtime-service.test.ts b/tests/external-mysql-runtime-service.test.ts index 47d8e204..83592159 100644 --- a/tests/external-mysql-runtime-service.test.ts +++ b/tests/external-mysql-runtime-service.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict" -import { mkdtemp, readFile, rm } from "node:fs/promises" +import { mkdtemp, readFile, readdir, 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" +import { bootstrapAbilityPhpCode, bootstrapPhpCode } from "../packages/runtime-playground/src/php-bootstrap.ts" +import { resolveRuntimeSecretEnvTargets, validateWorkspaceRecipeJsonSchema, type RuntimeCreateSpec, type WorkspaceRecipeRuntimeService } from "../packages/runtime-core/src/index.ts" const adminPassword = "admin-secret-'\\-value" const externalService: WorkspaceRecipeRuntimeService = { @@ -20,11 +21,13 @@ const externalService: WorkspaceRecipeRuntimeService = { usernameEnv: "MYSQL_ADMIN_USER", passwordEnv: "MYSQL_ADMIN_PASSWORD", }, - outputs: { host: "DB_HOST", port: "DB_PORT", username: "DB_USER", password: "DB_PASSWORD", database: "DB_NAME" }, + outputs: { host: "DB_HOST", port: "DB_PORT", username: "DB_USER", password: "MYSQL_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.deepEqual(resolveRuntimeSecretEnvTargets({ MYSQL_PASSWORD: "one", CACHE_PASSWORD: "two" }, { DB_PASSWORD: "MYSQL_PASSWORD", CACHE_AUTH: "CACHE_PASSWORD" }), { DB_PASSWORD: "one", CACHE_AUTH: "two" }) +assert.throws(() => resolveRuntimeSecretEnvTargets({ MYSQL_PASSWORD: "one" }, { DB_PASSWORD: "MISSING_PASSWORD" }), /unavailable source/) assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", @@ -58,6 +61,24 @@ const exposedAdminIssues = await validateWorkspaceRecipeSemantics({ 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") +for (const inputs of [ + { runtimeEnv: { DB_PASSWORD: "shadow" } }, + { secretEnv: ["DB_PASSWORD"] }, +] as const) { + const collisionIssues = await validateWorkspaceRecipeSemantics({ + schema: "wp-codebox/workspace-recipe/v1", + inputs: { ...inputs, externalServices, services: [externalService] }, + workflow: { steps: [{ command: "wordpress.run-php", args: ["code=echo 1;"] }] }, + }, "recipe.json") + assert.ok(collisionIssues.some((issue) => issue.code === "runtime-service-secret-target-collision")) +} +const distributionCollisionIssues = await validateWorkspaceRecipeSemantics({ + schema: "wp-codebox/workspace-recipe/v1", + distribution: { name: "fixture", wordpress: { root: "/wordpress" }, env: { DB_PASSWORD: "shadow" } }, + inputs: { externalServices, services: [externalService] }, + workflow: { steps: [{ command: "wordpress.run-php", args: ["code=echo 1;"] }] }, +}, "recipe.json") +assert.ok(distributionCollisionIssues.some((issue) => issue.code === "runtime-service-secret-target-collision")) 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")) @@ -110,41 +131,89 @@ 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 ambiguousTargetsFake = fakeDependencies() +const secondExternalService: WorkspaceRecipeRuntimeService = { ...externalService, id: "external-db-two", outputs: { password: "SECOND_MYSQL_PASSWORD" } } +await assert.rejects(provisionRuntimeServices([externalService, secondExternalService], { dependencies: ambiguousTargetsFake.dependencies, ...authorization }), RuntimeServiceProvisionError) +assert.equal(ambiguousTargetsFake.calls.length, 0, "multiple connector targets fail before provisioning") +const multipleConnectorIssues = await validateWorkspaceRecipeSemantics({ + schema: "wp-codebox/workspace-recipe/v1", + inputs: { externalServices, services: [externalService, secondExternalService] }, + workflow: { steps: [{ command: "wordpress.run-php", args: ["code=echo 1;"] }] }, +}, "recipe.json") +assert.ok(multipleConnectorIssues.some((issue) => issue.code === "ambiguous-runtime-service-secret-target")) + +const collidingTargetFake = fakeDependencies() +const collidingTargetService: WorkspaceRecipeRuntimeService = { ...externalService, outputs: { host: "DB_PASSWORD", password: "MYSQL_PASSWORD" } } +await assert.rejects(provisionRuntimeServices([collidingTargetService], { dependencies: collidingTargetFake.dependencies, ...authorization }), RuntimeServiceProvisionError) +assert.equal(collidingTargetFake.calls.length, 0, "managed output target collisions fail before provisioning") + +const reservedTargetFake = fakeDependencies() +await assert.rejects(provisionRuntimeServices([externalService], { dependencies: reservedTargetFake.dependencies, ...authorization, reservedEnvNames: ["DB_PASSWORD"] }), RuntimeServiceProvisionError) +assert.equal(reservedTargetFake.calls.length, 0, "injected target shadows fail before provisioning") + +const lateCollisionService: WorkspaceRecipeRuntimeService = structuredClone(externalService) +const lateCollisionFake = fakeDependencies((call) => { + if (call.stdin?.startsWith("SELECT EXISTS")) lateCollisionService.outputs.host = "DB_PASSWORD" +}) +await assert.rejects(provisionRuntimeServices([lateCollisionService], { dependencies: lateCollisionFake.dependencies, ...authorization }), RuntimeServiceProvisionError) +assert.equal(lateCollisionFake.calls.some((call) => call.stdin?.startsWith("DROP DATABASE")), true, "post-allocation target collisions roll back provisioned resources") + const successFake = fakeDependencies() 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}$/) assert.match(provisioned.env.DB_USER ?? "", /^wpcb_[a-f0-9]{24}$/) -assert.equal(provisioned.secretEnv.DB_PASSWORD, provisioned.env.DB_PASSWORD) +const generatedPassword = provisioned.secretEnv.MYSQL_PASSWORD ?? "" +assert.match(generatedPassword, /^[A-Za-z0-9_-]+$/) +assert.equal(provisioned.env.DB_PASSWORD, undefined, "password is absent from the non-secret output channel") +assert.deepEqual(provisioned.secretEnvTargets, { DB_PASSWORD: "MYSQL_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(successFake.calls.some((call) => call.args.some((arg) => arg.includes(adminPassword) || arg.includes(generatedPassword))), 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) +assert.equal(JSON.stringify(provisioned.evidence).includes(generatedPassword), 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 bootstrapRuns: Array<({ code: string } | { scriptPath: string }) & { env?: Record }> = [] 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]() {} } + return { serverUrl: "http://127.0.0.1:65535", playground: { async run(runOptions) { bootstrapRuns.push(runOptions); return { text: options.phpEnv?.DB_PASSWORD ?? "" } } }, async [Symbol.asyncDispose]() {} } } } - const { DB_PASSWORD: _password, ...runtimeEnv } = provisioned.env + const secondaryConnectorSecret = "secondary-connector-secret" 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, + runtimeEnv: provisioned.env, + secretEnv: { ...provisioned.secretEnv, CACHE_PASSWORD: secondaryConnectorSecret }, + secretEnvTargets: { ...provisioned.secretEnvTargets, CACHE_AUTH: "CACHE_PASSWORD" }, artifactsDirectory: bootstrapRoot, } + assert.throws(() => bootstrapPhpCode({ ...runtimeSpec, runtimeEnv: { ...runtimeSpec.runtimeEnv, DB_PASSWORD: "shadow" } }, "echo 1;", []), /collides with injected environment/) + assert.throws(() => bootstrapPhpCode(runtimeSpec, "echo 1;", [`runtime-env-json=${JSON.stringify({ DB_PASSWORD: "shadow" })}`]), /collides with injected environment/) + const generatedCommandPhp = bootstrapPhpCode(runtimeSpec, "echo getenv('DB_PASSWORD');", []) + const generatedAbilityPhp = bootstrapAbilityPhpCode(runtimeSpec, "echo getenv('DB_PASSWORD');") + assert.equal(generatedCommandPhp.includes(generatedPassword), false, "connector password is absent from generated command PHP") + assert.equal(generatedCommandPhp.includes(secondaryConnectorSecret), false, "all mapped connector secrets are absent from generated command PHP") + assert.equal(generatedAbilityPhp.includes(generatedPassword), false, "connector password is absent from generated ability PHP") + assert.equal(generatedAbilityPhp.includes(secondaryConnectorSecret), false, "all mapped connector secrets are absent from generated ability PHP") const server = await startPlaygroundCliServer(runtimeSpec, [], { cliModule }) + const connectorResponse = await server.playground.run({ code: " !("code" in run) || !run.code.includes(generatedPassword)), true, "captured PHP source never contains the connector password") + assert.equal(bootstrapCalls[0]?.phpEnv?.DB_PASSWORD, generatedPassword, "Playground startup receives the generated password through its in-memory PHP environment") + assert.equal(bootstrapCalls[0]?.phpEnv?.CACHE_AUTH, secondaryConnectorSecret, "multiple connector targets resolve through the same in-memory channel") 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 @@ -152,9 +221,12 @@ try { 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") + assert.equal(autoPrepend.includes(generatedPassword), false, "generated password is not serialized into auto-prepend PHP") + assert.equal(wpConfig.includes(generatedPassword), false, "generated password is not serialized into wp-config") + assert.equal(await directoryContains(bootstrapRoot, generatedPassword), false, "generated password is absent from persisted artifact files") + assert.equal(await directoryContains(bootstrapRoot, secondaryConnectorSecret), false, "secondary connector secret is absent from persisted artifact files") + assert.equal(await directoryContains(wordpressRoot, generatedPassword), false, "generated password is absent from persisted WordPress files") } finally { await rm(bootstrapRoot, { recursive: true, force: true }) await rm(wordpressRoot, { recursive: true, force: true }) @@ -243,3 +315,15 @@ await assert.rejects(executeRuntimeServiceProcess(process.execPath, ["-e", "proc }) console.log("external MySQL runtime service tests passed") + +async function directoryContains(root: string, needle: string): Promise { + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name) + if (entry.isDirectory()) { + if (await directoryContains(path, needle)) return true + } else if (entry.isFile() && (await readFile(path)).includes(Buffer.from(needle))) { + return true + } + } + return false +} diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index 0bc3c7ec..1a438d43 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -9,6 +9,7 @@ import type { RuntimeCreateSpec } from "../packages/runtime-core/src/index.js" const wordpressDevelopDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-wordpress-develop-")) const artifactsDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-artifacts-")) const calls: Parameters[0][] = [] +const runs: Array<({ code: string } | { scriptPath: string }) & { env?: Record }> = [] const cliModule: PlaygroundCliModule = { async runCLI(options) { @@ -16,8 +17,9 @@ const cliModule: PlaygroundCliModule = { return { serverUrl: "http://127.0.0.1:65535", playground: { - async run() { - return { text: "" } + async run(runOptions) { + runs.push(runOptions) + return { text: options.phpEnv?.DB_PASSWORD ?? "" } }, }, async [Symbol.asyncDispose]() {}, @@ -58,10 +60,18 @@ try { }, runtimeEnv: { TC_MYSQL_PORT: "33060", DB_HOST: "127.0.0.1", DB_PORT: "33061", DB_USER: "runtime", DB_NAME: "runtime" }, secretEnv: { DB_PASSWORD: "secret" }, + secretEnvTargets: { DB_PASSWORD: "DB_PASSWORD" }, artifactsDirectory, } + await assert.rejects(startPlaygroundCliServer({ + ...spec, + metadata: { recipe: { distribution: { name: "shadow", wordpress: { root: "/wordpress" }, env: { DB_PASSWORD: "shadow" } } } }, + }, [], { cliModule }), /collides with injected environment/) + assert.equal(calls.length, 0, "distribution target shadows fail before Playground startup") + const server = await startPlaygroundCliServer(spec, [], { cliModule }) + assert.equal((await server.playground.run({ code: " mount.vfsPath === "/internal/wp-codebox"), true, "passwordless external databases retain isolated request workers") + assert.equal(calls[0]?.["mount-before-install"]?.some((mount) => /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/.test(mount.vfsPath)), true) + calls.length = 0 const defaultRuntimeIniSpec: RuntimeCreateSpec = { ...spec, diff --git a/tests/release-package-coverage.test.ts b/tests/release-package-coverage.test.ts index 39791f69..67185a7e 100644 --- a/tests/release-package-coverage.test.ts +++ b/tests/release-package-coverage.test.ts @@ -116,6 +116,8 @@ try { const rootPackage = JSON.parse(await readFile(resolve(repositoryRoot, "package.json"), "utf8")) assert.equal(rootPackage.scripts.postinstall, "node scripts/apply-development-patches.mjs") assert.ok(rootPackage.files.includes("scripts/apply-development-patches.mjs")) +assert.ok(rootPackage.files.includes("patches")) +assert.equal(rootPackage.dependencies["patch-package"], "^8.0.1") const lifecycleRoot = await mkdtemp(join(tmpdir(), "wp-codebox-production-lifecycle-")) try { diff --git a/tests/runtime-services.test.ts b/tests/runtime-services.test.ts index 9b64ac95..a2e40bcc 100644 --- a/tests/runtime-services.test.ts +++ b/tests/runtime-services.test.ts @@ -88,7 +88,7 @@ const collisions: WorkspaceRecipe = { } assert.deepEqual( (await validateWorkspaceRecipeSemantics(collisions, "recipe.json")).map((issue) => issue.code), - ["duplicate-runtime-service-env", "duplicate-runtime-service-env", "duplicate-runtime-service-env"], + ["runtime-service-secret-target-collision", "duplicate-runtime-service-env", "duplicate-runtime-service-env", "duplicate-runtime-service-env"], ) const server = createServer((socket) => socket.end(Buffer.from([1, 0, 0, 0, 10]))) @@ -117,19 +117,22 @@ const dependencies: RuntimeServiceDependencies = { async waitForReady() {}, } const provisioned = await provisionRuntimeServices([service], { dependencies }) +const provisionedPassword = Buffer.alloc(24, 7).toString("base64url") assert.equal(provisioned.env.DB_PORT, "41001") -assert.equal(provisioned.env.DB_PASSWORD, Buffer.alloc(24, 7).toString("base64url")) +assert.equal(provisioned.env.DB_PASSWORD, undefined, "password is excluded from the non-secret output channel") +assert.equal(provisioned.secretEnv.DB_PASSWORD, provisionedPassword) +assert.deepEqual(provisioned.secretEnvTargets, { DB_PASSWORD: "DB_PASSWORD" }) const runCall = calls.find((call) => call.args[0] === "run") assert.ok(runCall?.args.includes("MYSQL_PASSWORD")) assert.ok(runCall?.args.includes("127.0.0.1::3306"), "Docker publishes MySQL on a loopback ephemeral port") assert.deepEqual(runCall?.args.slice(runCall.args.indexOf("--tmpfs"), runCall.args.indexOf("--tmpfs") + 2), ["--tmpfs", "/var/lib/mysql"]) assert.equal(runCall?.args.includes("--volume") || runCall?.args.includes("--mount"), false, "Docker uses no persistent volume") -assert.equal(runCall?.args.some((arg) => arg.includes(provisioned.env.DB_PASSWORD)), false, "credentials never enter Docker argv") +assert.equal(runCall?.args.some((arg) => arg.includes(provisionedPassword)), false, "credentials never enter Docker argv") const readinessCall = calls.find((call) => call.args[0] === "exec") assert.ok(readinessCall?.args.includes("mysql"), "MySQL readiness authenticates against the initialized database") -assert.equal(readinessCall?.args.some((arg) => arg.includes(provisioned.env.DB_PASSWORD)), false, "readiness credentials never enter Docker argv") -assert.equal(readinessCall?.env?.MYSQL_PWD, provisioned.env.DB_PASSWORD, "readiness credentials use the child environment") -assert.equal(JSON.stringify(provisioned.evidence).includes(provisioned.env.DB_PASSWORD), false, "credentials never enter evidence") +assert.equal(readinessCall?.args.some((arg) => arg.includes(provisionedPassword)), false, "readiness credentials never enter Docker argv") +assert.equal(readinessCall?.env?.MYSQL_PWD, provisionedPassword, "readiness credentials use the child environment") +assert.equal(JSON.stringify(provisioned.evidence).includes(provisionedPassword), false, "credentials never enter evidence") assert.equal(runCall?.env?.DOCKER_HOST, process.env.DOCKER_HOST, "Docker provider context is preserved") assert.equal(calls[0]?.args[0], "image", "the provider checks the image before starting the service") await provisioned.release()