diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml index 8ed8bbd7..567b14d8 100644 --- a/.github/workflows/agent-task-contracts.yml +++ b/.github/workflows/agent-task-contracts.yml @@ -26,6 +26,10 @@ on: - "packages/runtime-playground/src/phpunit-command-handlers.ts" - "tests/playground-phpunit-readonly-cache.integration.test.ts" - "tests/fixtures/phpunit-playground-harness/**" + - "packages/cli/src/bounded-recipe-plan.ts" + - "packages/runtime-core/src/bounded-runtime-plan.ts" + - "tests/bounded-*.test.ts" + - "tests/disposable-mysql-mysqli.integration.test.ts" push: paths: - ".github/scripts/run-agent-task/**" @@ -51,6 +55,10 @@ on: - "packages/runtime-playground/src/phpunit-command-handlers.ts" - "tests/playground-phpunit-readonly-cache.integration.test.ts" - "tests/fixtures/phpunit-playground-harness/**" + - "packages/cli/src/bounded-recipe-plan.ts" + - "packages/runtime-core/src/bounded-runtime-plan.ts" + - "tests/bounded-*.test.ts" + - "tests/disposable-mysql-mysqli.integration.test.ts" jobs: contracts: @@ -70,6 +78,10 @@ jobs: - run: npm ci - run: npm run build - run: npm run test:agent-task-contracts + - run: npm run test:bounded-runtime-plan + - run: npm run test:bounded-recipe-plan + - run: npm run test:bounded-recipe-plan-integration + - run: npm run test:disposable-mysql-mysqli-e2e - run: npm run test:runtime-sources-playground-integration - run: npm run test:playground-phpunit-readonly-cache-integration - run: npm run test:native-agent-task-playground-e2e diff --git a/docs/public-api-contract.md b/docs/public-api-contract.md index 170b45f7..e602c5c3 100644 --- a/docs/public-api-contract.md +++ b/docs/public-api-contract.md @@ -41,7 +41,9 @@ Use these package entrypoints from external integrations: concurrency. It accepts `wp-codebox/bounded-runtime-plan/v1`; every entry has `argv`, string-only environment overrides, a per-entry timeout, process identity, safe artifact namespace, and input index. Results use - `wp-codebox/bounded-runtime-plan-result/v1` in input order. The adapter owns + `wp-codebox/bounded-runtime-plan-result/v1` in input order with aggregate + counts, per-entry duration, and optional stdout, stderr, result, and artifact + refs. The adapter owns runtime-specific execution, while Codebox guarantees one materialize/start and one stop/dispose lifecycle. Set `failFast: true` to cancel not-yet-started entries, and use `retryBoundedRuntimePlan(plan, priorResult)` to select only @@ -66,6 +68,15 @@ Use these package entrypoints from external integrations: - `@automattic/wp-codebox-cli/recipe-secret-env`: recipe secret environment resolution helpers for CLI consumers that need dry-run summaries or runtime environment injection outside the command entrypoint. +- `@automattic/wp-codebox-cli/bounded-recipe-plan` and the matching workspace + `./cli/bounded-recipe-plan` entrypoint execute a bounded plan inside an already + prepared recipe runtime. Recipes use the generic + `wp-codebox.bounded-runtime-plan` helper with `plan-json` or `plan-file`; nested + commands remain subject to the recipe runtime policy, entry environment values + are applied as per-execution runtime environment overrides without appearing in + command result arguments, and outputs are persisted under + each safe artifact namespace. Running that recipe through `wp-codebox + recipe-run` preserves one outer runtime and disposable-service lifecycle. Browser sessions that load the WordPress plugin browser runtime also publish `window.wpCodeboxBrowser.v1`. The `v1` facade is the stable browser SDK diff --git a/package.json b/package.json index a43b07fd..714288fe 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,10 @@ "./cli/recipe-secret-env": { "types": "./packages/cli/dist/recipe-secret-env.d.ts", "import": "./packages/cli/dist/recipe-secret-env.js" + }, + "./cli/bounded-recipe-plan": { + "types": "./packages/cli/dist/bounded-recipe-plan.d.ts", + "import": "./packages/cli/dist/bounded-recipe-plan.js" } }, "bin": { @@ -127,6 +131,8 @@ "test:run-registry": "tsx tests/run-registry.test.ts", "test:run-plan-executor": "tsx tests/run-plan-executor.test.ts", "test:bounded-runtime-plan": "tsx tests/bounded-runtime-plan.test.ts", + "test:bounded-recipe-plan": "tsx tests/bounded-recipe-plan.test.ts", + "test:bounded-recipe-plan-integration": "tsx tests/bounded-recipe-plan.integration.test.ts", "test:fanout-execution": "tsx tests/fanout-execution.test.ts", "test:agent-fanout-executor": "tsx tests/agent-fanout-executor.test.ts", "test:fanout-aggregation-contract-parity": "tsx tests/fanout-aggregation-contract-parity.test.ts", diff --git a/packages/cli/package.json b/packages/cli/package.json index a1f7b949..87872e18 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,6 +16,10 @@ "./recipe-secret-env": { "types": "./dist/recipe-secret-env.d.ts", "import": "./dist/recipe-secret-env.js" + }, + "./bounded-recipe-plan": { + "types": "./dist/bounded-recipe-plan.d.ts", + "import": "./dist/bounded-recipe-plan.js" } }, "files": [ diff --git a/packages/cli/src/bounded-recipe-plan.ts b/packages/cli/src/bounded-recipe-plan.ts new file mode 100644 index 00000000..aa8f99ee --- /dev/null +++ b/packages/cli/src/bounded-recipe-plan.ts @@ -0,0 +1,78 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { commandArgValue, executeBoundedRuntimePlan, parseCommandJsonObject, type BoundedRuntimePlan, type BoundedRuntimePlanResult, type ExecutionResult, type Runtime } from "@automattic/wp-codebox-core" +import { recipeExecutionSpec } from "./agent-sandbox.js" + +export interface BoundedRecipePlanExecutionOptions { + artifactRoot: string + recipeDirectory: string +} + +export async function executeBoundedRecipePlan(runtime: Runtime, plan: BoundedRuntimePlan, options: BoundedRecipePlanExecutionOptions): Promise { + const aggregate = await executeBoundedRuntimePlan(plan, { + async materialize() { return { workspace: options.recipeDirectory, runtime } }, + async startServices() { return undefined }, + async execute({ entry, signal }) { + const command = entry.argv[0]?.trim() + if (!command) throw new Error(`Bounded recipe plan entry requires argv[0] command: ${entry.id}`) + const args = entry.argv.slice(1) + const directory = join(options.artifactRoot, entry.artifactNamespace) + const stdoutRef = `${entry.artifactNamespace}/stdout.txt` + const stderrRef = `${entry.artifactNamespace}/stderr.txt` + const resultRef = `${entry.artifactNamespace}/result.json` + await mkdir(directory, { recursive: true }) + let execution: ExecutionResult | undefined + let message = "" + try { + const spec = await recipeExecutionSpec({ command, args }, options.recipeDirectory) + execution = await runtime.execute({ ...spec, environment: entry.environment, processIdentity: entry.processIdentity, artifactNamespace: entry.artifactNamespace, timeoutMs: entry.timeoutMs, signal }) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + const stdout = redactOutput(execution?.stdout ?? "", entry.environment) + const stderr = redactOutput(execution?.stderr || message, entry.environment) + const exitCode = execution?.exitCode ?? 1 + const artifactRefs = runtimeArtifactRefs(execution) + await writeFile(join(options.artifactRoot, stdoutRef), stdout, "utf8") + await writeFile(join(options.artifactRoot, stderrRef), stderr, "utf8") + await writeFile(join(options.artifactRoot, resultRef), `${JSON.stringify({ + schema: "wp-codebox/bounded-runtime-plan-entry-result/v1", + id: entry.id, + inputIndex: entry.inputIndex, + processIdentity: entry.processIdentity, + artifactNamespace: entry.artifactNamespace, + command, + exitCode, + success: exitCode === 0, + stdoutRef, + stderrRef, + artifactRefs, + }, null, 2)}\n`, "utf8") + return { success: exitCode === 0, exitCode, message: stderr, stdoutRef, stderrRef, resultRef, artifactRefs } + }, + async stopServices() {}, + async dispose() {}, + }) + await mkdir(join(options.artifactRoot, "bounded-plan"), { recursive: true }) + await writeFile(join(options.artifactRoot, "bounded-plan/result.json"), `${JSON.stringify(aggregate, null, 2)}\n`, "utf8") + return aggregate +} + +export async function executeBoundedRecipePlanFromArgs(runtime: Runtime, args: string[], options: BoundedRecipePlanExecutionOptions): Promise { + const raw = commandArgValue(args, "plan-json") || commandArgValue(args, "request-json") + const file = commandArgValue(args, "plan-file") || commandArgValue(args, "request-file") + if (!raw && !file) throw new Error("wp-codebox.bounded-runtime-plan requires plan-json= or plan-file=") + const text = raw ?? await readFile(join(options.recipeDirectory, file!), "utf8") + return executeBoundedRecipePlan(runtime, parseCommandJsonObject(text, "wp-codebox.bounded-runtime-plan plan") as unknown as BoundedRuntimePlan, options) +} + +function redactOutput(output: string, environment: Record | undefined): string { + return Object.values(environment ?? {}).reduce((redacted, value) => value ? redacted.split(value).join("[redacted]") : redacted, output) +} + +function runtimeArtifactRefs(execution: ExecutionResult | undefined): string[] { + return (execution?.artifactRefs ?? []).flatMap((reference) => { + const value = reference.path ?? reference.id + return typeof value === "string" && value ? [value] : [] + }) +} diff --git a/packages/cli/src/commands/recipe-run-workflow-evidence.ts b/packages/cli/src/commands/recipe-run-workflow-evidence.ts index 0ac606ad..a4561967 100644 --- a/packages/cli/src/commands/recipe-run-workflow-evidence.ts +++ b/packages/cli/src/commands/recipe-run-workflow-evidence.ts @@ -7,6 +7,7 @@ import { createWordPressFuzzSuiteResetExecutor } from "@automattic/wp-codebox-pl import { correlateObservedHostsToExternalServiceBoundaries, recipeExternalServiceBoundarySummaries } from "../recipe-external-services.js" import { recipeExecutionSpec, sandboxWorkspaceContract } from "../agent-sandbox.js" import { executeAgentFanoutFromArgs } from "../agent-fanout.js" +import { executeBoundedRecipePlanFromArgs } from "../bounded-recipe-plan.js" import { recipeWorkflowSteps, type RecipeWorkflowPhase } from "../recipe-validation.js" import { artifactManifestFilesByPath } from "./recipe-run-benchmark-artifacts.js" import { assertResolvedInputMountPathArgs, rewriteInputMountPathArgs, rewriteInputMountPathJsonArgs, type InputMountPathMapping } from "../input-mount-paths.js" @@ -244,6 +245,25 @@ export async function executeRecipeWorkflowStep(runtime: Runtime, workflowStep: ...(workflowStep.fuzzStepIndex !== undefined ? { fuzzStepIndex: workflowStep.fuzzStepIndex } : {}), } } + if (step.command === "wp-codebox.bounded-runtime-plan") { + const startedAt = new Date().toISOString() + const result = await executeBoundedRecipePlanFromArgs(runtime, step.args ?? [], { + artifactRoot: artifactRoot || recipeDirectory, + recipeDirectory, + }) + const safeArgs = (step.args ?? []).map((argument) => argument.startsWith("plan-json=") || argument.startsWith("request-json=") ? `${argument.slice(0, argument.indexOf("=") + 1)}[redacted-plan]` : argument) + return phase({ + id: `bounded-runtime-plan-${workflowStep.index}`, + command: step.command, + args: safeArgs, + exitCode: result.success ? 0 : 1, + stdout: `${JSON.stringify(result, null, 2)}\n`, + stderr: "", + startedAt, + finishedAt: new Date().toISOString(), + artifactRefs: [{ kind: "bounded-runtime-plan-result", id: "bounded-runtime-plan-result", path: "bounded-plan/result.json" }], + }, step.command, recipeWorkflowArgsEvidence(safeArgs, safeArgs)) + } if (isRuntimeCheckpointRecipeCommand(step.command)) { return phase(await executeRuntimeCheckpointRecipeCommand(runtime, step.command, step.args ?? [])) } diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index 9aa7f910..74411ff4 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -960,6 +960,7 @@ export function recipePolicy(recipe: WorkspaceRecipe): RuntimePolicy { }) const commands = [ ...effectivePolicyCommandsFor(recipeWorkflowSteps(recipe).map(({ step }) => step.command), cliRecipeCommandDefinitions), + ...effectivePolicyCommandsFor(boundedRuntimePlanCommands(recipe), cliRecipeCommandDefinitions), ...effectivePolicyCommandsFor(pluginRuntimeCommands, cliRecipeCommandDefinitions), ...effectivePolicyCommandsFor(distributionStartupProbeCommands, cliRecipeCommandDefinitions), ...effectivePolicyCommandsFor((recipe.probes ?? []).map((probe) => probe.step.command), cliRecipeCommandDefinitions), @@ -995,6 +996,20 @@ export function recipePolicy(recipe: WorkspaceRecipe): RuntimePolicy { } } +function boundedRuntimePlanCommands(recipe: WorkspaceRecipe): string[] { + return recipeWorkflowSteps(recipe).flatMap(({ step }) => { + if (step.command !== "wp-codebox.bounded-runtime-plan") return [] + const raw = step.args?.find((argument) => argument.startsWith("plan-json="))?.slice("plan-json=".length) + if (!raw) return [] + try { + const plan = JSON.parse(raw) as { entries?: Array<{ argv?: unknown[] }> } + return (plan.entries ?? []).flatMap((entry) => typeof entry.argv?.[0] === "string" ? [entry.argv[0]] : []) + } catch { + return [] + } + }) +} + function validateFixtureDatabases(fixtureDatabases: WorkspaceRecipeFixtureDatabase[] | undefined, recipePath: string): void { if (fixtureDatabases && !Array.isArray(fixtureDatabases)) { throw new Error(`Recipe fixtureDatabases must be an array: ${recipePath}`) diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index db9062d9..90ed2758 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -172,6 +172,7 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic const { stdout } = await dependencies.execute("docker", ["port", container, "3306/tcp"], { signal, timeout: 10_000 }) const port = parseLoopbackPort(stdout) await dependencies.waitForReady("127.0.0.1", port, 30_000, signal) + await waitForMysqlDatabase(container, engine, password, dependencies, 30_000, signal) throwIfAborted(signal) evidence.readiness = "ready" evidence.lifecycle = "provisioned" @@ -186,6 +187,23 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic } } +async function waitForMysqlDatabase(container: string, engine: keyof typeof MYSQL_IMAGES, password: string, dependencies: RuntimeServiceDependencies, timeoutMs: number, signal?: AbortSignal): Promise { + const deadline = Date.now() + timeoutMs + const client = engine === "mariadb" ? "mariadb" : "mysql" + const args = ["exec", "--env", "MYSQL_PWD", container, client, "--protocol=TCP", "--host=127.0.0.1", "--user=runtime", "--database=runtime", "--execute=SELECT 1"] + while (Date.now() < deadline) { + throwIfAborted(signal) + try { + await dependencies.execute("docker", args, { env: { ...process.env, MYSQL_PWD: password }, signal, timeout: 5_000 }) + return + } catch (error) { + if (signal?.aborted) throw error + await abortableDelay(100, signal) + } + } + throw new Error(`MySQL database readiness timed out after ${timeoutMs}ms`) +} + async function ensureDockerImage(image: string, dependencies: RuntimeServiceDependencies, signal?: AbortSignal): Promise { try { await dependencies.execute("docker", ["image", "inspect", image], { signal, timeout: 10_000 }) diff --git a/packages/runtime-core/src/bounded-runtime-plan.ts b/packages/runtime-core/src/bounded-runtime-plan.ts index a0abc7f1..bca5bea0 100644 --- a/packages/runtime-core/src/bounded-runtime-plan.ts +++ b/packages/runtime-core/src/bounded-runtime-plan.ts @@ -30,7 +30,12 @@ export interface BoundedRuntimePlanEntryResult { artifactNamespace: string status: BoundedRuntimePlanEntryStatus success: boolean + durationMs: number exitCode?: number + stdoutRef?: string + stderrRef?: string + resultRef?: string + artifactRefs?: string[] error?: { code: "execution-failed" | "execution-threw" | "timed-out" | "fail-fast"; message: string } } @@ -38,6 +43,7 @@ export interface BoundedRuntimePlanResult { schema: typeof BOUNDED_RUNTIME_PLAN_RESULT_SCHEMA success: boolean concurrency: number + counts: { total: number; succeeded: number; failed: number; timedOut: number; cancelled: number } entries: BoundedRuntimePlanEntryResult[] } @@ -57,7 +63,7 @@ export interface BoundedRuntimePlanExecution { materialize(): Promise<{ workspace: TWorkspace; runtime: TRuntime }> startServices(context: { workspace: TWorkspace; runtime: TRuntime }): Promise - execute(context: BoundedRuntimePlanExecution): Promise<{ success: boolean; exitCode?: number; message?: string }> + execute(context: BoundedRuntimePlanExecution): Promise<{ success: boolean; exitCode?: number; message?: string; stdoutRef?: string; stderrRef?: string; resultRef?: string; artifactRefs?: string[] }> stopServices(context: { workspace: TWorkspace; runtime: TRuntime; services: TServices }): Promise dispose(context: { workspace: TWorkspace; runtime: TRuntime }): Promise } @@ -96,6 +102,7 @@ export async function executeBoundedRuntimePlan schema: BOUNDED_RUNTIME_PLAN_RESULT_SCHEMA, success: entries.every((entry) => entry.success), concurrency, + counts: boundedRuntimePlanCounts(entries), entries, } } finally { @@ -140,25 +147,27 @@ async function executeEntries(plan: BoundedRunt async function executeEntry(entry: BoundedRuntimePlanEntry, materialized: { workspace: TWorkspace; runtime: TRuntime }, services: TServices, adapter: BoundedRuntimePlanAdapter): Promise { const controller = new AbortController() + const startedAt = Date.now() let timer: ReturnType | undefined - let operation: Promise<{ success: boolean; exitCode?: number; message?: string }> | undefined + let operation: Promise<{ success: boolean; exitCode?: number; message?: string; stdoutRef?: string; stderrRef?: string; resultRef?: string; artifactRefs?: string[] }> | undefined try { operation = adapter.execute({ entry, ...materialized, services, signal: controller.signal }) const completed = entry.timeoutMs === undefined ? await operation : await Promise.race([ operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new BoundedRuntimePlanTimeoutError(entry.id)) }, entry.timeoutMs) }), ]) + const references = executionReferences(completed) return completed.success - ? baseResult(entry, "succeeded", true, completed.exitCode) - : { ...baseResult(entry, "failed", false, completed.exitCode), error: { code: "execution-failed", message: redactEntryMessage(completed.message || `Execution failed: ${entry.id}`, entry) } } + ? { ...baseResult(entry, "succeeded", true, Date.now() - startedAt, completed.exitCode), ...references } + : { ...baseResult(entry, "failed", false, Date.now() - startedAt, completed.exitCode), ...references, error: { code: "execution-failed", message: redactEntryMessage(completed.message || `Execution failed: ${entry.id}`, entry) } } } catch (error) { if (error instanceof BoundedRuntimePlanTimeoutError) { // Keep the worker slot and shared services alive until the adapter confirms // that the aborted process has actually terminated. await operation?.catch(() => undefined) - return { ...baseResult(entry, "timed_out", false), error: { code: "timed-out", message: `Execution timed out: ${entry.id}` } } + return { ...baseResult(entry, "timed_out", false, Date.now() - startedAt), error: { code: "timed-out", message: `Execution timed out: ${entry.id}` } } } - return { ...baseResult(entry, "failed", false), error: { code: "execution-threw", message: redactEntryMessage(error instanceof Error ? error.message : `Execution threw: ${entry.id}`, entry) } } + return { ...baseResult(entry, "failed", false, Date.now() - startedAt), error: { code: "execution-threw", message: redactEntryMessage(error instanceof Error ? error.message : `Execution threw: ${entry.id}`, entry) } } } finally { if (timer) clearTimeout(timer) } @@ -166,12 +175,31 @@ async function executeEntry(entry: BoundedRunti class BoundedRuntimePlanTimeoutError extends Error {} -function baseResult(entry: BoundedRuntimePlanEntry, status: BoundedRuntimePlanEntryStatus, success: boolean, exitCode?: number): BoundedRuntimePlanEntryResult { - return { id: entry.id, inputIndex: entry.inputIndex, processIdentity: entry.processIdentity, artifactNamespace: entry.artifactNamespace, status, success, ...(exitCode === undefined ? {} : { exitCode }) } +function baseResult(entry: BoundedRuntimePlanEntry, status: BoundedRuntimePlanEntryStatus, success: boolean, durationMs: number, exitCode?: number): BoundedRuntimePlanEntryResult { + return { id: entry.id, inputIndex: entry.inputIndex, processIdentity: entry.processIdentity, artifactNamespace: entry.artifactNamespace, status, success, durationMs, ...(exitCode === undefined ? {} : { exitCode }) } } function cancelledResult(entry: BoundedRuntimePlanEntry): BoundedRuntimePlanEntryResult { - return { ...baseResult(entry, "cancelled", false), error: { code: "fail-fast", message: `Execution was not started after an earlier failure: ${entry.id}` } } + return { ...baseResult(entry, "cancelled", false, 0), error: { code: "fail-fast", message: `Execution was not started after an earlier failure: ${entry.id}` } } +} + +function executionReferences(completed: { stdoutRef?: string; stderrRef?: string; resultRef?: string; artifactRefs?: string[] }): Pick { + return { + ...(completed.stdoutRef ? { stdoutRef: completed.stdoutRef } : {}), + ...(completed.stderrRef ? { stderrRef: completed.stderrRef } : {}), + ...(completed.resultRef ? { resultRef: completed.resultRef } : {}), + ...(completed.artifactRefs?.length ? { artifactRefs: completed.artifactRefs } : {}), + } +} + +function boundedRuntimePlanCounts(entries: BoundedRuntimePlanEntryResult[]): BoundedRuntimePlanResult["counts"] { + return { + total: entries.length, + succeeded: entries.filter((entry) => entry.status === "succeeded").length, + failed: entries.filter((entry) => entry.status === "failed").length, + timedOut: entries.filter((entry) => entry.status === "timed_out").length, + cancelled: entries.filter((entry) => entry.status === "cancelled").length, + } } function safeIdentifier(value: unknown): value is string { diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index c4d68245..759d54bd 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -1396,6 +1396,24 @@ export const commandRegistry = [ recipe: true, handler: { kind: "recipe-alias", command: "wp-codebox.agent-fanout" }, }, + { + id: "wp-codebox.bounded-runtime-plan", + description: "Recipe-only helper that executes a generic bounded command plan inside the current prepared runtime and service lifecycle.", + acceptedArgs: [ + { name: "plan-json", description: "Inline wp-codebox/bounded-runtime-plan/v1 envelope.", format: "JSON object" }, + { name: "plan-file", description: "Recipe-relative path to a wp-codebox/bounded-runtime-plan/v1 envelope.", format: "path" }, + ], + outputShape: "JSON wp-codebox/bounded-runtime-plan-result/v1 envelope plus stable per-entry stdout, stderr, and result refs.", + outputSchema: objectEnvelopeSchema("wp-codebox/bounded-runtime-plan-result/v1", { + success: { type: "boolean" }, + concurrency: { type: "number" }, + counts: { type: "object" }, + entries: { type: "array" }, + }), + policyRequirement: "Host-side recipe helper; every nested command remains subject to the prepared runtime policy.", + recipe: true, + handler: { kind: "recipe-alias", command: "wp-codebox.bounded-runtime-plan" }, + }, ] as const satisfies readonly CommandDefinition[] export type CommandId = typeof commandRegistry[number]["id"] diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index 93586226..3e018a64 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -718,9 +718,13 @@ export interface MountSpec { export interface ExecutionSpec { command: string args?: string[] + environment?: Record + processIdentity?: string + artifactNamespace?: string diagnostics?: RuntimeCommandDiagnosticsCaptureSpec cwd?: string timeoutMs?: number + signal?: AbortSignal } export type RuntimeEpisodeActionKind = "command" | "filesystem" | "http" | "browser" diff --git a/packages/runtime-playground/src/php-bootstrap.ts b/packages/runtime-playground/src/php-bootstrap.ts index a3da0ad3..48cb4a1f 100644 --- a/packages/runtime-playground/src/php-bootstrap.ts +++ b/packages/runtime-playground/src/php-bootstrap.ts @@ -33,7 +33,7 @@ ${failureDiagnosticFile ? phpFailureDiagnosticFilePhp(failureDiagnosticFile) : " ${phpCliStreamConstants()} ${pluginRuntimeBootstrapPhp(spec)} ${saveQueriesBootstrapPhp(args)} -${runtimeEnvPhp(spec)} +${runtimeEnvPhp(spec, args)} ${secretEnvPhp(spec)} ${componentManifestPhp(spec)} require_once '/wordpress/wp-load.php'; @@ -231,6 +231,13 @@ function secretEnvPhp(spec: RuntimeCreateSpec): string { return phpEnvAssignments(normalizeRuntimeEnvRecord(spec.secretEnv ?? {}, { field: "secretEnv" })) } -function runtimeEnvPhp(spec: RuntimeCreateSpec): string { - return phpEnvAssignments(normalizeRuntimeEnvRecord(spec.runtimeEnv ?? {}, { field: "runtimeEnv" })) +function runtimeEnvPhp(spec: RuntimeCreateSpec, args: string[] = []): string { + const override = argValue(args, "runtime-env-json") + let executionEnvironment: Record = {} + if (override) { + const parsed = JSON.parse(override) as unknown + 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" })) } diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 47b77fa1..1ce71626 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -3,7 +3,7 @@ import { PlaygroundCliExitError, type PlaygroundCliBufferedOutput } from "./play 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 { randomInt } from "node:crypto" +import { randomBytes, randomInt } from "node:crypto" import { existsSync } from "node:fs" import { createServer as createHttpServer, type Server as HttpServer } from "node:http" import { mkdir, readFile, rename, rm, stat, unlink, utimes, writeFile } from "node:fs/promises" @@ -11,7 +11,7 @@ import { basename, dirname, join } from "node:path" import { createServer as createNetServer } from "node:net" import * as PlaygroundStorage from "@wp-playground/storage" import { resolveWordPressRelease } from "@wp-playground/wordpress" -import { phpEnvAssignments, phpWpConfigDefineAssignments } from "./php-snippets.js" +import { phpEnvAssignments, phpLiteral, phpWpConfigDefineAssignments } from "./php-snippets.js" import { stageReadonlyPlaygroundMounts, type ReadonlyMountStaging } from "./mount-materialization.js" import { acquirePlaygroundArchiveReference, isCustomPlaygroundWordPressArchive, maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, withPlaygroundArchiveCacheLock, type PlaygroundArchiveReference, type PlaygroundCustomArchiveCacheMaintenance } from "./playground-wordpress-archive-cache.js" @@ -79,9 +79,16 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: const wordpressInstallMode = spec.environment.wordpressInstallMode ?? "install-from-existing-files" const bootstrapIniEntries = runtimeBootstrapPhpIniEntries(spec) const useProgrammaticRunner = shouldUseProgrammaticPlaygroundRunner(spec, options) + const requestWorkerEndpoint = useProgrammaticRunner ? undefined : { + route: `/wp-codebox-execute-${randomBytes(12).toString("hex")}.php`, + token: randomBytes(32).toString("base64url"), + payloadDirectory: join(spec.artifactsDirectory ?? "artifacts", "playground-internal-shared"), + } usesArchiveCache = !wordpressDirectory && !spec.environment.assets?.wordpressZip readonlyMountStaging = await stageReadonlyPlaygroundMounts(mounts) const stagedMounts = readonlyMountStaging.mounts + const preinstallMounts = stagedMounts.filter((mount) => mount.target === "/wordpress/wp-config.php") + const postinstallMounts = stagedMounts.filter((mount) => mount.target !== "/wordpress/wp-config.php") const wordpressStartupAsset = wordpressDirectory ? undefined : await resolvePlaygroundWordPressStartupAsset(spec.environment.version, spec.environment.assets?.wordpressZip) archiveReference = wordpressStartupAsset?.archiveReference if (usesArchiveCache) { @@ -124,7 +131,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: }, Boolean(spec.preview?.port)) : await startPlaygroundCliWithDynamicPortRetry(async (port) => { const { runCLI } = options.cliModule ?? (await import("@wp-playground/cli")) as unknown as PlaygroundCliModule const localAssetServer = wordpressStartupAsset?.localPath ? await serveLocalStartupAsset(wordpressStartupAsset.localPath) : undefined - const bootstrapSharedMount = await pluginRuntimeBootstrapSharedMount(spec) + const bootstrapSharedMounts = await pluginRuntimeBootstrapSharedMounts(spec, requestWorkerEndpoint) try { return await runCLI({ command: "server", @@ -134,14 +141,15 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: skipBrowser: true, workers: 6, mount: [ - ...stagedMounts.map((mount) => ({ + ...postinstallMounts.map((mount) => ({ hostPath: mount.source, vfsPath: mount.target, })), ], - ...(wordpressDirectory || bootstrapSharedMount ? { + ...(wordpressDirectory || bootstrapSharedMounts.length > 0 || preinstallMounts.length > 0 ? { "mount-before-install": [ - ...(bootstrapSharedMount ? [bootstrapSharedMount] : []), + ...bootstrapSharedMounts, + ...preinstallMounts.map((mount) => ({ hostPath: mount.source, vfsPath: mount.target })), ...(wordpressDirectory ? [{ hostPath: wordpressDirectory, vfsPath: "/wordpress" }] : []), ], } : {}), @@ -165,7 +173,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: fixedPreviewPort: spec.preview?.port ?? null, }) - const proxiedServer = await withPreviewLeaseProvider(await withPreviewProxy(server, spec.preview?.port ?? 0, spec.preview?.bind), spec) + const proxiedServer = await withPreviewLeaseProvider(await withPreviewProxy({ ...server, ...(requestWorkerEndpoint ? { requestWorkerEndpoint } : {}) }, spec.preview?.port ?? 0, spec.preview?.bind), spec) emitProgress("preview:ready", "complete", "Preview ready", { localUrl: proxiedServer.serverUrl, upstreamUrl: server.serverUrl, @@ -289,20 +297,65 @@ export function shouldUseProgrammaticPlaygroundRunner(spec: RuntimeCreateSpec, o && (Boolean(runtimeBootstrapPhpIniEntries(spec)) || Boolean(spec.environment.extensions?.length)) } -async function pluginRuntimeBootstrapSharedMount(spec: RuntimeCreateSpec): Promise<{ hostPath: string; vfsPath: string } | undefined> { +async function pluginRuntimeBootstrapSharedMounts(spec: RuntimeCreateSpec, requestWorkerEndpoint?: { route: string; token: string; payloadDirectory: string }): Promise> { const iniEntries = runtimeBootstrapPhpIniEntries(spec) - if (!iniEntries) { - return undefined + if (!iniEntries && !requestWorkerEndpoint) { + return [] } const directory = join(spec.artifactsDirectory ?? "artifacts", "playground-internal-shared") await mkdir(directory, { recursive: true }) - await mkdir(join(directory, "mu-plugins"), { recursive: true }) - await mkdir(join(directory, "preload"), { recursive: true }) - await writeFile(join(directory, "php.ini"), phpIniContent(iniEntries), "utf8") - await writeFile(join(directory, "auto_prepend_file.php"), runtimeAutoPrependPhp(spec), "utf8") + if (iniEntries) { + await writeFile(join(directory, "php.ini"), phpIniContent(iniEntries), "utf8") + await writeFile(join(directory, "wp-codebox-auto-prepend.php"), runtimeAutoPrependPhp(spec), "utf8") + } + if (requestWorkerEndpoint) await writeFile(join(directory, "request-worker.php"), requestWorkerPhp(requestWorkerEndpoint.token), "utf8") + const externalWpConfig = externalDatabaseWpConfig(spec) + if (externalWpConfig) await writeFile(join(directory, "wp-config.php"), externalWpConfig, "utf8") + + return [ + ...(iniEntries ? [ + { hostPath: join(directory, "php.ini"), vfsPath: "/internal/shared/php.ini" }, + { hostPath: join(directory, "wp-codebox-auto-prepend.php"), vfsPath: "/internal/shared/wp-codebox-auto-prepend.php" }, + ] : []), + ...(requestWorkerEndpoint ? [ + { hostPath: directory, vfsPath: "/internal/wp-codebox" }, + { hostPath: join(directory, "request-worker.php"), vfsPath: `/wordpress${requestWorkerEndpoint.route}` }, + ] : []), + ...(externalWpConfig ? [{ hostPath: join(directory, "wp-config.php"), vfsPath: "/wordpress/wp-config.php" }] : []), + ] +} - return { hostPath: directory, vfsPath: "/internal/shared" } +function requestWorkerPhp(token: string): string { + return ` $wp_codebox_value) { + if (!is_string($wp_codebox_name) || !is_string($wp_codebox_value)) { + http_response_code(400); + exit; + } + putenv($wp_codebox_name . '=' . $wp_codebox_value); + $_ENV[$wp_codebox_name] = $wp_codebox_value; + $_SERVER[$wp_codebox_name] = $wp_codebox_value; +} +eval('?>' . $wp_codebox_code); +` } function runtimeBootstrapPhpIniEntries(spec: RuntimeCreateSpec): Record | undefined { @@ -349,7 +402,7 @@ function pluginRuntimePhpEntries(spec: RuntimeCreateSpec, key: "iniEntries" | "b function phpIniContent(entries: Record): string { const lines = [ - "auto_prepend_file=/internal/shared/auto_prepend_file.php", + "auto_prepend_file=/internal/shared/wp-codebox-auto-prepend.php", // Runtime memory ceiling for all in-sandbox PHP, including artifact // collection. The collect_artifacts phase reads declared/typed artifacts and // runtime snapshot files into memory and base64-encodes them @@ -385,7 +438,7 @@ function phpIniContent(entries: Record): string { } function runtimeAutoPrependPhp(spec: RuntimeCreateSpec): string { - return ` private readonly activeExecutionAbortControllers = new Set() - private activeExecutionSignal?: AbortSignal + private readonly executionSignals = new AsyncLocalStorage() + private readonly requestWorkerExecutions = new AsyncLocalStorage>() + private requestWorkerReady?: Promise private reviewerAuthBootstrapRouteRegistered = false private readonly reviewerAuthBootstraps = new Map() @@ -356,10 +359,15 @@ class PlaygroundRuntime implements Runtime { timeoutMs: spec.timeoutMs ?? null, }) const abortController = new AbortController() + const abortFromCaller = () => abortController.abort() + spec.signal?.addEventListener("abort", abortFromCaller, { once: true }) + if (spec.signal?.aborted) abortController.abort() this.activeExecutionAbortControllers.add(abortController) - this.activeExecutionSignal = abortController.signal try { - const output = await timeoutPlaygroundCommand(executePlaygroundCommand(this, spec, this.hostTools), spec, abortController) + const executionSpec = executionSpecWithEnvironment(spec) + const output = await this.executionSignals.run(abortController.signal, async () => spec.processIdentity + ? await this.requestWorkerExecutions.run(spec.environment ?? {}, async () => await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController)) + : await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController)) const finishedAt = now() const envelope = typeof output === "string" ? runtimeCommandResultEnvelopeFromOutput({ @@ -416,10 +424,8 @@ class PlaygroundRuntime implements Runtime { }) throw error } finally { + spec.signal?.removeEventListener("abort", abortFromCaller) this.activeExecutionAbortControllers.delete(abortController) - if (this.activeExecutionSignal === abortController.signal) { - this.activeExecutionSignal = undefined - } } } @@ -852,7 +858,7 @@ class PlaygroundRuntime implements Runtime { const server = await this.bootPlayground() let result: Awaited> try { - result = await runBrowserProbeCommand({ abortSignal: this.activeExecutionSignal, artifactRoot: this.artifactRoot, runtimeSpec: this.spec, runPlaygroundCommand: (command, targetServer, options) => this.runPlaygroundCommand(command, targetServer, options), server, spec, onProgress: (event) => this.recordEvent("runtime.browser-command-progress", { ...event, specCommand: spec.command }), diagnosticProviders: [browserWordPressDiagnosticProvider()] }) + result = await runBrowserProbeCommand({ abortSignal: this.executionSignals.getStore(), artifactRoot: this.artifactRoot, runtimeSpec: this.spec, runPlaygroundCommand: (command, targetServer, options) => this.runPlaygroundCommand(command, targetServer, options), server, spec, onProgress: (event) => this.recordEvent("runtime.browser-command-progress", { ...event, specCommand: spec.command }), diagnosticProviders: [browserWordPressDiagnosticProvider()] }) } catch (error) { if (isBrowserCommandArtifactError(error)) { this.browserProbes.push(error.artifact) @@ -867,7 +873,7 @@ class PlaygroundRuntime implements Runtime { const server = await this.bootPlayground() let result: Awaited> try { - result = await runHtmlCaptureCommand({ abortSignal: this.activeExecutionSignal, artifactRoot: this.artifactRoot, runtimeSpec: this.spec, runPlaygroundCommand: (command, targetServer, options) => this.runPlaygroundCommand(command, targetServer, options), server, spec }) + result = await runHtmlCaptureCommand({ abortSignal: this.executionSignals.getStore(), artifactRoot: this.artifactRoot, runtimeSpec: this.spec, runPlaygroundCommand: (command, targetServer, options) => this.runPlaygroundCommand(command, targetServer, options), server, spec }) } catch (error) { if (isBrowserCommandArtifactError(error)) { this.browserProbes.push(error.artifact) @@ -1763,7 +1769,13 @@ class PlaygroundRuntime implements Runtime { private async runPlaygroundCommand(command: string, server: PlaygroundCliServer, options: { code: string } | { scriptPath: string }): Promise { try { - return await abortable(server.playground.run(options), this.activeExecutionSignal) + const requestWorkerEnvironment = this.requestWorkerExecutions.getStore() + if (requestWorkerEnvironment && "code" in options && server.requestWorkerEndpoint) { + await this.prepareRequestWorker(server) + const response = await this.executeRequestWorker(server, options.code, requestWorkerEnvironment, this.executionSignals.getStore()) + return { text: response.text, exitCode: response.ok ? 0 : 1, ...(!response.ok ? { errors: response.text } : {}) } + } + return await abortable(server.playground.run(options), this.executionSignals.getStore()) } catch (error) { const payload = "code" in options ? options.code : options.scriptPath throw new PlaygroundCommandCrashError(command, error, { @@ -1774,6 +1786,36 @@ class PlaygroundRuntime implements Runtime { } } + private async prepareRequestWorker(server: PlaygroundCliServer): Promise { + if (!server.requestWorkerEndpoint) return + this.requestWorkerReady ??= (async () => { + const response = await this.executeRequestWorker(server, ", signal?: AbortSignal): Promise<{ ok: boolean; status: number; text: string }> { + const endpoint = server.requestWorkerEndpoint + if (!endpoint) throw new Error("Playground request worker endpoint is unavailable.") + const payloadId = randomBytes(16).toString("hex") + const payloadPath = join(endpoint.payloadDirectory, `execution-${payloadId}.json`) + await writeFile(payloadPath, JSON.stringify({ code, environment }), "utf8") + try { + const response = await fetch(new URL(endpoint.route, server.serverUrl), { + method: "POST", + headers: { + "X-WP-Codebox-Execution-Token": endpoint.token, + "X-WP-Codebox-Execution-Payload": payloadId, + }, + signal, + }) + return { ok: response.ok, status: response.status, text: await response.text() } + } finally { + await unlink(payloadPath).catch(() => undefined) + } + } + async inspectMountedInputs(): Promise { const server = await this.bootPlayground() const response = await server.playground.run({ @@ -1887,6 +1929,14 @@ echo json_encode(array('command' => 'inspect-mounted-inputs', 'mounts' => $inspe } +function executionSpecWithEnvironment(spec: ExecutionSpec): ExecutionSpec { + if (!spec.environment || Object.keys(spec.environment).length === 0) return spec + return { + ...spec, + args: [...(spec.args ?? []).filter((argument) => !argument.startsWith("runtime-env-json=")), `runtime-env-json=${JSON.stringify(spec.environment)}`], + } +} + function normalizeCheckpointName(name: string): string { const normalized = name.trim() if (!/^[A-Za-z0-9._-]{1,120}$/.test(normalized)) { diff --git a/packages/runtime-playground/src/preview-server.ts b/packages/runtime-playground/src/preview-server.ts index 714a6b95..79e3b454 100644 --- a/packages/runtime-playground/src/preview-server.ts +++ b/packages/runtime-playground/src/preview-server.ts @@ -16,6 +16,7 @@ export interface PlaygroundCliServer { writeFile?(path: string, contents: string): Promise } serverUrl: string + requestWorkerEndpoint?: { route: string; token: string; payloadDirectory: string } previewLease?: PreviewLease previewRoutes?: PlaygroundPreviewRouteRegistry previewProxyDiagnostics?: PlaygroundPreviewProxyDiagnostics diff --git a/packages/runtime-playground/src/runtime-diagnostics.ts b/packages/runtime-playground/src/runtime-diagnostics.ts index 7846a50f..e90a71f5 100644 --- a/packages/runtime-playground/src/runtime-diagnostics.ts +++ b/packages/runtime-playground/src/runtime-diagnostics.ts @@ -3,8 +3,8 @@ import { captureArtifactFile, type MountSpec } from "@automattic/wp-codebox-core import type { PlaygroundCliServer } from "./preview-server.js" import { extractPhpunitFailureMessage } from "./playground-command-errors.js" -export async function persistPluginPhpunitResult(server: PlaygroundCliServer, vfsPath: string, artifactRoot: string): Promise { - await persistPhpunitResult(server, vfsPath, join(artifactRoot, "files", "phpunit", ".pg-test-result.txt")) +export async function persistPluginPhpunitResult(server: PlaygroundCliServer, vfsPath: string, artifactRoot: string, namespace?: string): Promise { + await persistPhpunitResult(server, vfsPath, join(artifactRoot, "files", "phpunit", ...(namespace ? [namespace] : []), ".pg-test-result.txt")) } export async function persistCorePhpunitResult(server: PlaygroundCliServer, vfsPath: string, artifactRoot: string): Promise { diff --git a/packages/runtime-playground/src/wordpress-command-runners.ts b/packages/runtime-playground/src/wordpress-command-runners.ts index 107be042..c9a9ff92 100644 --- a/packages/runtime-playground/src/wordpress-command-runners.ts +++ b/packages/runtime-playground/src/wordpress-command-runners.ts @@ -912,7 +912,9 @@ export async function runPhpunitCommand({ const bootstrapMode = argValue(args, "bootstrap-mode")?.trim() || "managed" const autoloadFile = argValue(args, "autoload-file")?.trim() || (bootstrapMode === "project" ? "" : "/wp-codebox-vendor/autoload.php") const autoloadFileRole = argValue(args, "autoload-file-role")?.trim() === "harness" ? "harness" : undefined - const resultFile = PLUGIN_PHPUNIT_RESULT_FILE + const processIdentity = boundedProcessIdentity(spec.processIdentity) + const resultFile = processIdentity ? `/tmp/wp-codebox-phpunit-result-${processIdentity}.txt` : PLUGIN_PHPUNIT_RESULT_FILE + const diagnosticHostFile = `/wordpress/wp-content/plugins/${pluginSlug}/.pg-test-result${processIdentity ? `-${processIdentity}` : ""}.txt` const code = explicitCode ? await phpCodeFromArgs(args, "wordpress.phpunit", false) : phpunitRunCode({ pluginSlug, cwd: argValue(args, "cwd")?.trim() || `/wordpress/wp-content/plugins/${pluginSlug}`, @@ -942,8 +944,8 @@ export async function runPhpunitCommand({ try { response = await runPlaygroundCommand("wordpress.phpunit", server, { code: bootstrapPhpCode(runtimeSpec, code, args, undefined, resultFile) }) } catch (error) { - await persistPluginPhpunitResult(server, resultFile, artifactRoot) - await persistVfsDiagnosticFileToHost(server, resultFile, `/wordpress/wp-content/plugins/${pluginSlug}/.pg-test-result.txt`, mounts) + await persistPluginPhpunitResult(server, resultFile, artifactRoot, processIdentity) + await persistVfsDiagnosticFileToHost(server, resultFile, diagnosticHostFile, mounts) const structured = await readPluginPhpunitDiagnostic(server, resultFile) if (structured) { throw attachPlaygroundDiagnostics(error, "wordpress.phpunit structured diagnostics", structured) @@ -951,8 +953,8 @@ export async function runPhpunitCommand({ throw error } - await persistPluginPhpunitResult(server, resultFile, artifactRoot) - await persistVfsDiagnosticFileToHost(server, resultFile, `/wordpress/wp-content/plugins/${pluginSlug}/.pg-test-result.txt`, mounts) + await persistPluginPhpunitResult(server, resultFile, artifactRoot, processIdentity) + await persistVfsDiagnosticFileToHost(server, resultFile, diagnosticHostFile, mounts) const structured = await readPluginPhpunitDiagnostic(server, resultFile) try { assertPlaygroundResponseOk("wordpress.phpunit", response) @@ -966,6 +968,12 @@ export async function runPhpunitCommand({ return response.text } +function boundedProcessIdentity(value: string | undefined): string { + if (!value) return "" + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) throw new Error(`Invalid PHPUnit process identity: ${value}`) + return value +} + export async function runCorePhpunitCommand({ artifactRoot, runPlaygroundCommand, diff --git a/tests/bounded-recipe-plan.integration.test.ts b/tests/bounded-recipe-plan.integration.test.ts new file mode 100644 index 00000000..d2d1bf09 --- /dev/null +++ b/tests/bounded-recipe-plan.integration.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" + +const execFileAsync = promisify(execFile) +const root = await mkdtemp(join(tmpdir(), "wp-codebox-bounded-recipe-integration-")) +const recipePath = join(root, "recipe.json") +const artifactsPath = join(root, "artifacts") +const plan = { + schema: "wp-codebox/bounded-runtime-plan/v1", + concurrency: 2, + entries: [ + { id: "one", argv: ["wordpress.run-php", "bootstrap=none", "code=usleep(20000); echo getenv('DB_INDEX');"], environment: { DB_INDEX: "db-one" }, timeoutMs: 30_000, processIdentity: "one", artifactNamespace: "entries/one", inputIndex: 0 }, + { id: "two", argv: ["wordpress.run-php", "bootstrap=none", "code=echo getenv('DB_INDEX');"], environment: { DB_INDEX: "db-two" }, timeoutMs: 30_000, processIdentity: "two", artifactNamespace: "entries/two", inputIndex: 1 }, + ], +} + +try { + await writeFile(recipePath, `${JSON.stringify({ + schema: "wp-codebox/workspace-recipe/v1", + runtime: { backend: "wordpress-playground", wp: "latest", blueprint: { steps: [] } }, + workflow: { steps: [{ command: "wp-codebox.bounded-runtime-plan", args: [`plan-json=${JSON.stringify(plan)}`] }] }, + })}\n`) + const command = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--artifacts", artifactsPath, "--json"], { + cwd: process.cwd(), + timeout: 300_000, + maxBuffer: 4 * 1024 * 1024, + }) + const output = JSON.parse(command.stdout) + assert.equal(output.success, true, command.stdout) + assert.doesNotMatch(command.stdout, /db-one|db-two/, "entry environment values must not appear in recipe-run output") + const aggregate = JSON.parse(await readFile(join(artifactsPath, "bounded-plan/result.json"), "utf8")) + assert.deepEqual(aggregate.counts, { total: 2, succeeded: 2, failed: 0, timedOut: 0, cancelled: 0 }) + assert.deepEqual(aggregate.entries.map((entry: { id: string }) => entry.id), ["one", "two"]) + assert.equal(await readFile(join(artifactsPath, "entries/one/stdout.txt"), "utf8"), "[redacted]") + assert.equal(await readFile(join(artifactsPath, "entries/two/stdout.txt"), "utf8"), "[redacted]") +} finally { + await rm(root, { recursive: true, force: true }) +} + +console.log("bounded recipe plan integration passed") diff --git a/tests/bounded-recipe-plan.test.ts b/tests/bounded-recipe-plan.test.ts new file mode 100644 index 00000000..7d5769f7 --- /dev/null +++ b/tests/bounded-recipe-plan.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict" +import { mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { executeBoundedRecipePlan } from "../packages/cli/src/bounded-recipe-plan.js" +import type { ExecutionSpec, Runtime } from "../packages/runtime-core/src/index.js" + +const root = await mkdtemp(join(tmpdir(), "wp-codebox-bounded-recipe-plan-")) +const executed: ExecutionSpec[] = [] +let active = 0 +let maximumActive = 0 +const runtime = { + async execute(spec: ExecutionSpec) { + executed.push(spec) + active++ + maximumActive = Math.max(maximumActive, active) + try { + await new Promise((resolve) => setTimeout(resolve, spec.args?.some((arg) => arg.includes("suite-one")) ? 15 : 1)) + const failed = spec.args?.some((arg) => arg.includes("suite-failed")) + return { + id: failed ? "failed" : "one", + command: spec.command, + args: spec.args ?? [], + exitCode: failed ? 1 : 0, + stdout: failed ? "" : "password=entry-secret\n", + stderr: failed ? "database entry-secret failed\n" : "", + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + artifactRefs: [{ kind: "test", id: "runtime-ref", path: "runtime/ref.json" }], + } + } finally { + active-- + } + }, +} as Runtime + +try { + const result = await executeBoundedRecipePlan(runtime, { + schema: "wp-codebox/bounded-runtime-plan/v1", + concurrency: 2, + entries: [ + { id: "one", argv: ["wordpress.run-php", "code=suite-one", "env-json={\"BASE\":\"base\"}"], environment: { DB_PASSWORD: "entry-secret" }, processIdentity: "one", artifactNamespace: "entries/one", inputIndex: 0 }, + { id: "failed", argv: ["wordpress.run-php", "code=suite-failed"], environment: { DB_PASSWORD: "entry-secret" }, processIdentity: "failed", artifactNamespace: "entries/failed", inputIndex: 1 }, + ], + }, { artifactRoot: root, recipeDirectory: root }) + + assert.equal(maximumActive, 2) + assert.deepEqual(result.entries.map((entry) => entry.id), ["one", "failed"]) + assert.deepEqual(result.counts, { total: 2, succeeded: 1, failed: 1, timedOut: 0, cancelled: 0 }) + assert.equal(result.entries[0]?.stdoutRef, "entries/one/stdout.txt") + assert.equal(result.entries[1]?.resultRef, "entries/failed/result.json") + assert.deepEqual(result.entries[0]?.artifactRefs, ["runtime/ref.json"]) + const firstExecution = executed.find((spec) => spec.args?.some((arg) => arg.includes("suite-one"))) + const firstEnvironmentArg = firstExecution?.args?.find((arg) => arg.startsWith("env-json=")) + assert.deepEqual(JSON.parse(firstEnvironmentArg?.slice("env-json=".length) ?? "{}"), { BASE: "base" }) + assert.deepEqual(firstExecution?.environment, { DB_PASSWORD: "entry-secret" }) + assert.equal(await readFile(join(root, "entries/one/stdout.txt"), "utf8"), "password=[redacted]\n") + assert.equal(await readFile(join(root, "entries/failed/stderr.txt"), "utf8"), "database [redacted] failed\n") + assert.equal(JSON.parse(await readFile(join(root, "bounded-plan/result.json"), "utf8")).schema, "wp-codebox/bounded-runtime-plan-result/v1") +} finally { + await rm(root, { recursive: true, force: true }) +} + +console.log("bounded recipe plan tests passed") diff --git a/tests/bounded-runtime-plan.test.ts b/tests/bounded-runtime-plan.test.ts index aefb2151..88599de7 100644 --- a/tests/bounded-runtime-plan.test.ts +++ b/tests/bounded-runtime-plan.test.ts @@ -40,6 +40,8 @@ assert.equal(result.success, false) assert.equal(maximumActive, 2, "execution never exceeds the declared concurrency") assert.deepEqual(result.entries.map((entry) => entry.id), ["first", "failed", "slow", "last"], "aggregate order is input order rather than completion order") assert.deepEqual(result.entries.map((entry) => entry.status), ["succeeded", "failed", "timed_out", "succeeded"]) +assert.deepEqual(result.counts, { total: 4, succeeded: 2, failed: 1, timedOut: 1, cancelled: 0 }) +assert.equal(result.entries.every((entry) => Number.isInteger(entry.durationMs) && entry.durationMs >= 0), true) assert.deepEqual(lifecycle, ["materialize", "start-services", "stop-services", "dispose"], "workspace, service, and runtime lifecycles are each owned once") assert.deepEqual(executed.sort(), ["failed", "first", "last", "slow"], "one failed entry does not isolate unrelated entries") diff --git a/tests/disposable-mysql-mysqli.integration.test.ts b/tests/disposable-mysql-mysqli.integration.test.ts index af793869..0a404e0a 100644 --- a/tests/disposable-mysql-mysqli.integration.test.ts +++ b/tests/disposable-mysql-mysqli.integration.test.ts @@ -1,12 +1,11 @@ import assert from "node:assert/strict" import { execFile } from "node:child_process" -import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { promisify } from "node:util" import { runRecipe } from "../packages/cli/src/commands/recipe-run.ts" -import { provisionRuntimeServices } from "../packages/cli/src/runtime-services.ts" -import { executeBoundedRuntimePlan } from "../packages/runtime-core/src/index.js" +import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe-builders.js" const execFileAsync = promisify(execFile) @@ -43,7 +42,7 @@ if (!await dockerAvailable()) { summary: false, dryRun: false, }) - assert.equal(result.success, true) + assert.equal(result.success, true, JSON.stringify(result)) assert.equal(result.executions.at(-1)?.stdout.trim(), "1") const mariaDbRecipePath = join(directory, "mariadb-recipe.json") @@ -65,44 +64,88 @@ if (!await dockerAvailable()) { summary: false, dryRun: false, }) - assert.equal(mariaDbResult.success, true) + assert.equal(mariaDbResult.success, true, JSON.stringify(mariaDbResult)) assert.equal(mariaDbResult.executions.at(-1)?.stdout.trim(), "1") - // The aggregate adapter models two PHPUnit invocations. They share the one - // MariaDB allocation but each receives its own database identity. - const allocation = await provisionRuntimeServices([{ - id: "phpunit-mariadb", - kind: "mysql", - configuration: { engine: "mariadb", rootAuthentication: "empty-password" }, - outputs: { host: "DB_HOST", port: "DB_PORT" }, - }]) - try { - const container = (await execFileAsync("docker", ["ps", "--filter", "name=wp-codebox-phpunit-mariadb", "--format", "{{.Names}}"], { timeout: 10_000 })).stdout.trim() - assert.equal(container.split("\n").filter(Boolean).length, 1, "two entries use one disposable MariaDB allocation") - const aggregate = await executeBoundedRuntimePlan({ + const harness = join(directory, "phpunit-harness") + const plugin = join(directory, "bounded-phpunit-fixture") + const wpConfig = join(directory, "wp-config.php") + const boundedRecipePath = join(directory, "bounded-phpunit-recipe.json") + const boundedArtifacts = join(directory, "bounded-phpunit-artifacts") + await cp("tests/fixtures/phpunit-playground-harness", harness, { recursive: true }) + await execFileAsync("composer", ["install", "--no-interaction", "--prefer-dist"], { cwd: harness, timeout: 300_000, maxBuffer: 2 * 1024 * 1024 }) + await mkdir(join(plugin, "tests"), { recursive: true }) + await writeFile(join(plugin, "bounded-phpunit-fixture.php"), "tests\n") + await writeFile(join(plugin, "tests", "bootstrap.php"), "assertMatchesRegularExpression('/^[12]$/', $index); + $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); + $database = 'bounded_phpunit_' . $index; + mysqli_query($db, 'CREATE DATABASE ' . $database); + mysqli_query($db, 'CREATE TABLE ' . $database . '.entry_identity (value VARCHAR(8) NOT NULL) ENGINE=InnoDB'); + mysqli_query($db, "INSERT INTO " . $database . ".entry_identity VALUES ('" . $index . "')"); + $row = mysqli_fetch_assoc(mysqli_query($db, 'SELECT value FROM ' . $database . '.entry_identity')); + $this->assertSame($index, $row['value']); + } +} +`) + await writeFile(wpConfig, ` entry.artifactNamespace), ["phpunit/one", "phpunit/two"]) - assert.deepEqual(aggregate.entries.map((entry) => entry.inputIndex), [0, 1]) - } finally { - await allocation.release() - } + })}`], + }] + await writeFile(boundedRecipePath, `${JSON.stringify(boundedRecipe)}\n`) + const boundedResult = await runRecipe({ recipePath: boundedRecipePath, artifactsDirectory: boundedArtifacts, previewHoldBlocking: false, previewLeaseRequested: false, previewLeaseChild: false, timeoutMs: 300_000, json: true, summary: false, dryRun: false }) + const boundedDiagnostics = await Promise.all(["one", "two"].flatMap((identity) => ["stdout.txt", "stderr.txt", "result.json"].map(async (file) => { + const path = join(boundedArtifacts, "phpunit", identity, file) + try { return `${path}:\n${await readFile(path, "utf8")}` } catch { return `${path}: unavailable` } + }))) + assert.equal(boundedResult.success, true, `${JSON.stringify(boundedResult)}\n${boundedDiagnostics.join("\n")}`) + const boundedExecution = boundedResult.executions.find((execution) => execution.command === "wp-codebox.bounded-runtime-plan") + const aggregate = JSON.parse(boundedExecution?.stdout ?? "{}") + assert.deepEqual(aggregate.counts, { total: 2, succeeded: 2, failed: 0, timedOut: 0, cancelled: 0 }) + assert.deepEqual(aggregate.entries.map((entry: { artifactNamespace: string }) => entry.artifactNamespace), ["phpunit/one", "phpunit/two"]) + assert.deepEqual(aggregate.entries.map((entry: { inputIndex: number }) => entry.inputIndex), [0, 1]) console.log("disposable MySQL and MariaDB mysqli E2E passed") } finally { await rm(directory, { recursive: true, force: true }) diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index 1fc97326..918491de 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict" -import { mkdtemp, readFile, rm, stat } from "node:fs/promises" +import { mkdtemp, readFile, rm } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" @@ -56,7 +56,7 @@ try { }, }, }, - runtimeEnv: { TC_MYSQL_PORT: "33060" }, + runtimeEnv: { TC_MYSQL_PORT: "33060", DB_HOST: "127.0.0.1", DB_PORT: "33061", DB_USER: "runtime", DB_PASSWORD: "secret", DB_NAME: "runtime" }, artifactsDirectory, } @@ -64,10 +64,14 @@ try { await server[Symbol.asyncDispose]() assert.equal(calls.length, 1) - assert.equal(calls[0]["mount-before-install"]?.length, 2) - assert.equal(calls[0]["mount-before-install"]?.[0]?.vfsPath, "/internal/shared") + assert.equal(calls[0]["mount-before-install"]?.length, 6) + assert.equal(calls[0]["mount-before-install"]?.[0]?.vfsPath, "/internal/shared/php.ini") + assert.equal(calls[0]["mount-before-install"]?.[1]?.vfsPath, "/internal/shared/wp-codebox-auto-prepend.php") + assert.equal(calls[0]["mount-before-install"]?.[2]?.vfsPath, "/internal/wp-codebox") + assert.match(calls[0]["mount-before-install"]?.[3]?.vfsPath ?? "", /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/) // A wordpress-develop checkout is the runtime root, not an ordinary post-startup mount. - assert.deepEqual(calls[0]["mount-before-install"]?.[1], { hostPath: wordpressDevelopDirectory, vfsPath: "/wordpress" }) + assert.equal(calls[0]["mount-before-install"]?.[4]?.vfsPath, "/wordpress/wp-config.php") + assert.deepEqual(calls[0]["mount-before-install"]?.[5], { hostPath: wordpressDevelopDirectory, vfsPath: "/wordpress" }) assert.deepEqual(calls[0].mount, []) assert.equal(calls[0].workers, 6) assert.equal(calls[0].wordpressInstallMode, "do-not-attempt-installing") @@ -75,16 +79,30 @@ try { assert.equal(shouldUseProgrammaticPlaygroundRunner(spec), false) assert.deepEqual(calls[0].phpIniEntries, { memory_limit: "512M" }) assert.deepEqual(calls[0].phpExtension, ["/tmp/sodium/manifest.json"]) - const sharedMount = calls[0]["mount-before-install"]?.[0]?.hostPath - assert.equal(typeof sharedMount, "string") - const sharedPhpIni = await readFile(join(sharedMount as string, "php.ini"), "utf8") + const sharedPhpIniPath = calls[0]["mount-before-install"]?.[0]?.hostPath + const sharedAutoPrependPath = calls[0]["mount-before-install"]?.[1]?.hostPath + assert.equal(typeof sharedPhpIniPath, "string") + assert.equal(typeof sharedAutoPrependPath, "string") + const sharedPhpIni = await readFile(sharedPhpIniPath as string, "utf8") assert.match(sharedPhpIni, /opcache\.file_cache = \/tmp\/opcache/) // The runtime default memory ceiling stays high enough for collect_artifacts to // base64 heavy snapshot/declared-artifact files without a hard PHP fatal. assert.match(sharedPhpIni, /memory_limit=512M/) - assert.match(await readFile(join(sharedMount as string, "auto_prepend_file.php"), "utf8"), /putenv\("TC_MYSQL_PORT=33060"\);/) - assert.equal((await stat(join(sharedMount as string, "mu-plugins"))).isDirectory(), true) - assert.equal((await stat(join(sharedMount as string, "preload"))).isDirectory(), true) + assert.match(sharedPhpIni, /auto_prepend_file=\/internal\/shared\/wp-codebox-auto-prepend\.php/) + 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"\);/) + const requestWorkerPath = calls[0]["mount-before-install"]?.[3]?.hostPath + assert.equal(typeof requestWorkerPath, "string") + const requestWorker = await readFile(requestWorkerPath as string, "utf8") + assert.match(requestWorker, /HTTP_X_WP_CODEBOX_EXECUTION_TOKEN/) + assert.match(requestWorker, /hash_equals/) + assert.match(requestWorker, /\$_ENV\[\$wp_codebox_name\] = \$wp_codebox_value/) + const externalWpConfigPath = calls[0]["mount-before-install"]?.[4]?.hostPath + 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"\)/) calls.length = 0 const defaultRuntimeIniSpec: RuntimeCreateSpec = { @@ -116,7 +134,9 @@ try { await downloadedWordPressServer[Symbol.asyncDispose]() assert.equal(calls.length, 1) - assert.equal(calls[0]["mount-before-install"], undefined) + assert.equal(calls[0]["mount-before-install"]?.length, 2) + assert.equal(calls[0]["mount-before-install"]?.[0]?.vfsPath, "/internal/wp-codebox") + assert.match(calls[0]["mount-before-install"]?.[1]?.vfsPath ?? "", /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/) assert.equal(calls[0].wordpressInstallMode, undefined) assert.equal(shouldUseProgrammaticPlaygroundRunner(downloadedWordPressSpec), false) @@ -139,9 +159,9 @@ try { await distributionOnlyServer[Symbol.asyncDispose]() assert.equal(calls.length, 1) - const distributionSharedMount = calls[0]["mount-before-install"]?.[0]?.hostPath - assert.equal(typeof distributionSharedMount, "string") - const distributionAutoPrepend = await readFile(join(distributionSharedMount as string, "auto_prepend_file.php"), "utf8") + const distributionAutoPrependPath = calls[0]["mount-before-install"]?.[1]?.hostPath + assert.equal(typeof distributionAutoPrependPath, "string") + const distributionAutoPrepend = await readFile(distributionAutoPrependPath as string, "utf8") assert.match(distributionAutoPrepend, /putenv\("WPCOM_BRANCH=feature\/example"\);/) assert.match(distributionAutoPrepend, /putenv\("FEATURE_ENABLED=true"\);/) assert.match(distributionAutoPrepend, /putenv\("EMPTY_VALUE="\);/) diff --git a/tests/playground-readonly-mounts.test.ts b/tests/playground-readonly-mounts.test.ts index a755360f..8d6243d1 100644 --- a/tests/playground-readonly-mounts.test.ts +++ b/tests/playground-readonly-mounts.test.ts @@ -11,10 +11,12 @@ import type { RuntimeCreateSpec } from "../packages/runtime-core/src/index.js" const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-")) const readonlySource = join(root, "readonly.bin") const readwriteSource = join(root, "readwrite.bin") +const wpConfigSource = join(root, "wp-config.php") const readonlyBytes = Buffer.from([0, 255, 1, 2, 3, 127, 128]) const readwriteBytes = Buffer.from([10, 20, 30]) await writeFile(readonlySource, readonlyBytes) await writeFile(readwriteSource, readwriteBytes) +await writeFile(wpConfigSource, " mount.vfsPath === "/readonly") const readwriteMount = options.mount.find((mount) => mount.vfsPath === "/readwrite") + const wpConfigMount = options["mount-before-install"]?.find((mount) => mount.vfsPath === "/wordpress/wp-config.php") assert.ok(readonlyMount) assert.ok(readwriteMount) + assert.ok(wpConfigMount) mountedReadonlyPath = readonlyMount.hostPath // This is the host path Playground's writable Node mount handler receives. await writeFile(readonlyMount.hostPath, Buffer.from("sandbox overwrite")) @@ -59,6 +63,7 @@ try { const server = await startPlaygroundCliServer(spec, [ { type: "file", source: readonlySource, target: "/readonly", mode: "readonly" }, { type: "file", source: readwriteSource, target: "/readwrite", mode: "readwrite" }, + { type: "file", source: wpConfigSource, target: "/wordpress/wp-config.php", mode: "readonly" }, ], { cliModule }) assert.equal(sha256(await readFile(readonlySource)), beforeReadonlyHash, "readonly source bytes must survive a sandbox overwrite") diff --git a/tests/public-api-contract.test.ts b/tests/public-api-contract.test.ts index a0ca485f..dde1589a 100644 --- a/tests/public-api-contract.test.ts +++ b/tests/public-api-contract.test.ts @@ -106,6 +106,7 @@ assert.deepEqual(exportKeys(rootPackage), [ "./playground/public", "./cli", "./cli/recipe-secret-env", + "./cli/bounded-recipe-plan", ]) assert.deepEqual(exportKeys(corePackage), [ @@ -193,6 +194,7 @@ assert.deepEqual(barrelExportModules(publicBarrel), [ "./recipe-schema.js", "./recipe-source-packages.js", "./run-plan.js", + "./bounded-runtime-plan.js", "./run-registry.js", "./runner-workspace-publication.js", "./runtime-boundary-contracts.js", @@ -273,6 +275,8 @@ for (const publicEntry of [ "@automattic/wp-codebox-cli", "./cli/recipe-secret-env", "@automattic/wp-codebox-cli/recipe-secret-env", + "./cli/bounded-recipe-plan", + "@automattic/wp-codebox-cli/bounded-recipe-plan", ]) { assert.match(docs, new RegExp(escapeRegExp(publicEntry)), `docs must mention ${publicEntry}`) } diff --git a/tests/runtime-services.test.ts b/tests/runtime-services.test.ts index 572fcf01..27552adc 100644 --- a/tests/runtime-services.test.ts +++ b/tests/runtime-services.test.ts @@ -109,6 +109,10 @@ assert.ok(runCall?.args.includes("127.0.0.1::3306"), "Docker publishes MySQL on 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") +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(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") @@ -155,7 +159,9 @@ const mariaDbDependencies: RuntimeServiceDependencies = { } const mariaDb = await provisionRuntimeServices([mariaDbService], { dependencies: mariaDbDependencies }) const mariaDbRun = mariaDbCalls.find((call) => call.args[0] === "run") +const mariaDbReadiness = mariaDbCalls.find((call) => call.args[0] === "exec") assert.ok(mariaDbRun?.args.includes("mariadb:11.4")) +assert.ok(mariaDbReadiness?.args.includes("mariadb"), "MariaDB readiness uses the image client") assert.ok(mariaDbRun?.args.includes("MARIADB_ALLOW_EMPTY_ROOT_PASSWORD")) assert.equal(mariaDbRun?.env?.MARIADB_ALLOW_EMPTY_ROOT_PASSWORD, "yes") assert.equal(mariaDbRun?.env?.MYSQL_ALLOW_EMPTY_PASSWORD, undefined)