From 4344d2f9d1435d57cf9d702123e74c899921b01d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 15 Aug 2026 01:16:36 -0400 Subject: [PATCH] feat: add managed SMTP sink inspection --- docs/smtp-sink-runtime-service.md | 17 ++ packages/cli/src/adversarial-recipe.ts | 20 +- packages/cli/src/commands/recipe-run.ts | 4 + packages/cli/src/recipe-validation.ts | 23 +- packages/cli/src/runtime-services.ts | 217 +++++++++++++++++- .../cli/src/smtp-sink-recipe-operations.ts | 43 ++++ .../adversarial-recipe-orchestration.test.ts | 5 + tests/runtime-services.test.ts | 57 ++++- 8 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 docs/smtp-sink-runtime-service.md create mode 100644 packages/cli/src/smtp-sink-recipe-operations.ts diff --git a/docs/smtp-sink-runtime-service.md b/docs/smtp-sink-runtime-service.md new file mode 100644 index 000000000..dbb210d67 --- /dev/null +++ b/docs/smtp-sink-runtime-service.md @@ -0,0 +1,17 @@ +# SMTP Sink Runtime Service + +SMTP runtime services expose a provider-neutral host-side sink contract. Recipes use `host/smtp.inspect` and `host/smtp.reset`; neither operation is available to the sandbox runtime or requires a network policy grant. + +```json +{ "command": "host/smtp.inspect", "args": ["service=mail", "limit=20", "recipient=person@example.test", "recipient-label=account", "subject-marker=Reset", "link-marker=/reset/"] } +``` + +`limit` is required to be between 1 and 100 when supplied. Inspection scans at most 100 captured messages and returns a bounded `wp-codebox/smtp-sink-inspection/v1` envelope containing the count, returned count, truncation status, per-run opaque message/recipient/link labels, marker matches, and link scheme/host class/path depth. Recipient labels must be short safe identifiers without secret-like terms. It never emits addresses, message bodies, subjects, URLs, tokens, loopback ports, provider machine details, or reusable content fingerprints. Service IDs, recipient filters, and marker inputs are represented only by per-operation opaque labels and lengths in execution evidence. + +```json +{ "command": "host/smtp.reset", "args": ["service=mail"] } +``` + +Reset is deterministic and records a normalized `wp-codebox/smtp-sink-reset/v1` operation in managed service evidence. Checkpointed adversarial cases reset every declared SMTP sink after restoring their runtime checkpoint, because host-side sinks are outside a runtime checkpoint. + +The current Docker SMTP provider maps this generic contract to its private inspection API. Provider API paths and payload shapes are not part of the recipe contract. diff --git a/packages/cli/src/adversarial-recipe.ts b/packages/cli/src/adversarial-recipe.ts index 5176bae4b..ef69c52f6 100644 --- a/packages/cli/src/adversarial-recipe.ts +++ b/packages/cli/src/adversarial-recipe.ts @@ -63,6 +63,7 @@ interface RunRecipeAdversarialCampaignsOptions { signal?: AbortSignal executions: RecipeExecutionResult[] provenance?: Record + managedServices?: { resetSmtpSink(serviceId: string): Promise } } export async function runRecipeAdversarialCampaigns(options: RunRecipeAdversarialCampaignsOptions): Promise { @@ -100,8 +101,17 @@ export async function runRecipeAdversarialCampaigns(options: RunRecipeAdversaria const campaignExecutions: RecipeExecutionResult[] = [] const campaignOptions = { ...options, executions: campaignExecutions } const execute = async (plan: AdversarialCasePlan, signal: AbortSignal) => { - if (checkpointName) await options.runtime.restoreCheckpoint!(checkpointName) - return executeRecipeAdversarialCase(declaration, templates, plan, signal, campaignOptions) + const smtpSinkResets: unknown[] = [] + if (checkpointName) { + await options.runtime.restoreCheckpoint!(checkpointName) + // Runtime checkpoints do not include host-side sinks. Resetting declared + // SMTP sinks keeps every checkpointed case independently replayable. + for (const service of options.recipe.inputs?.services?.filter((candidate) => candidate.kind === "smtp") ?? []) { + const reset = await options.managedServices?.resetSmtpSink(service.id) + if (reset) smtpSinkResets.push(reset) + } + } + return executeRecipeAdversarialCase(declaration, templates, plan, signal, campaignOptions, smtpSinkResets) } const result = replay ? await runRecipeAdversarialReplay(campaign, declaration, templates, replay, execute, options.signal) @@ -208,6 +218,7 @@ async function executeRecipeAdversarialCase( plan: AdversarialCasePlan, signal: AbortSignal, options: RunRecipeAdversarialCampaignsOptions, + smtpSinkResets: unknown[] = [], ): Promise { if (signal.aborted) return { status: "error", diagnostics: [{ code: "campaign-case-interrupted", message: "Case was interrupted before execution." }] } const phases = materializeCasePhases(plan, templates) @@ -222,7 +233,7 @@ async function executeRecipeAdversarialCase( phases, metadata: { adversarialCase: true }, }], - metadata: { adversarialCampaignId: declaration.id, faultSchedule: declaration.faultSchedule }, + metadata: { adversarialCampaignId: declaration.id, faultSchedule: declaration.faultSchedule, ...(smtpSinkResets.length > 0 ? { smtpSinkResets } : {}) }, } const execution = await executeRecipeWorkflowStep(options.runtime, { phase: "adversarial:action", @@ -243,6 +254,7 @@ async function executeRecipeAdversarialCase( options.executions.push(execution) const signals = [ `status:${status}`, + ...(smtpSinkResets.length > 0 ? [`smtp-sink-reset:${smtpSinkResets.length}`] : []), ...diagnostics.map((diagnostic) => `diagnostic:${diagnostic.code}`), ...diagnostics.map((diagnostic) => `diagnostic-message:${createHash("sha256").update(stableAdversarialDiagnosticMessage(diagnostic.message, plan.caseId)).digest("hex").slice(0, 16)}`), ...(typeof fuzzCase?.skipReason === "string" ? [`skip:${fuzzCase.skipReason}`] : []), @@ -253,7 +265,7 @@ async function executeRecipeAdversarialCase( diagnostics, artifacts: artifactRefs, stateDigest: createHash("sha256").update(JSON.stringify({ campaignId: declaration.id, status, signals, matrix: plan.matrix })).digest("hex"), - metadata: { fuzzSuite: parsed, resetPolicy: declaration.resetPolicy ?? { mode: "none" }, faultSchedule: declaration.faultSchedule }, + metadata: { fuzzSuite: parsed, resetPolicy: declaration.resetPolicy ?? { mode: "none" }, faultSchedule: declaration.faultSchedule, ...(smtpSinkResets.length > 0 ? { smtpSinkResets } : {}) }, }) as AdversarialExecutionObservation } diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index 1316dd308..af380cf80 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -30,6 +30,7 @@ import { markPreviewLeaseAvailable, markPreviewLeaseFailed, markPreviewLeaseRele import { importRecipeSiteSeeds } from "./recipe-site-seeds.js" import { applyRecipeRuntimeSetup, cleanupInputMountBaselines, prepareRecipeRuntimeSetup, recipeRunDependencyOverlay, recipeRunExtraPlugin, recipeRunStagedFile, rewriteInputMountPathArgs } from "./recipe-runtime-setup.js" import { provisionRuntimeServices, provisionRuntimeServicesForRecipe, runtimeServiceEvidenceFromError, type RuntimeServiceEvidence } from "../runtime-services.js" +import { executeSmtpSinkRecipeOperation, isSmtpSinkRecipeOperation } from "../smtp-sink-recipe-operations.js" import { distributionStartupProbeFailure, executeRecipeCollectWorkloadResult, executeRecipeWorkflowStep, recipeAdvisoryFailure, recipeBrowserEvidence, recipeStepFailure, recipeWorkflowArgsEvidence, recipeWorkflowStepIsAdvisory, runDistributionSetupArtifacts, runDistributionStartupProbes, runRecipeProbes, withRecipeExecutionPhase } from "./recipe-run-workflow-evidence.js" import { recipeAdversarialCampaignFailure, runRecipeAdversarialCampaigns, writeRecipeAdversarialEvidence, type RecipeAdversarialCampaignOutput } from "../adversarial-recipe.js" import { classifyRuntimeMemoryFailure, replayWithHostNodeHeap } from "../host-node-heap.js" @@ -322,6 +323,8 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe try { const execution = await awaitRecipe(operation, async () => workflowStep.step.command === "wordpress.collect-workload-result" ? withRecipeExecutionPhase(executeRecipeCollectWorkloadResult(workflowStep.step, executions, new Date().toISOString()), workflowStep.phase, workflowStep.index, workflowStep.step.command, recipeWorkflowArgsEvidence(workflowStep.step.args, workflowStep.step.args), workflowStep.step.metadata) + : isSmtpSinkRecipeOperation(workflowStep.step.command) + ? (() => executeSmtpSinkRecipeOperation(workflowStep.step, managedServices!).then(({ execution, evidenceArgs }) => withRecipeExecutionPhase(execution, workflowStep.phase, workflowStep.index, workflowStep.step.command, recipeWorkflowArgsEvidence(evidenceArgs, evidenceArgs), workflowStep.step.metadata)))() : executeRecipeWorkflowStep(runtime!, workflowStep, recipeDirectory, sandboxWorkspace, configuredArtifactsDirectory, options, inputMountPathMap, (progress) => { continuationProgress = progress }), workflowStep.step.timeoutMs) executions.push({ ...execution, ...(recipeWorkflowStepIsAdvisory(workflowStep.step) ? { recipeAdvisory: true } : {}) }) interruption?.throwIfInterrupted() @@ -349,6 +352,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe inputMountPathMap, signal: interruption?.signal, executions, + managedServices, provenance: recipeRunProvenance(recipe, recipePath) as unknown as Record, })) interruption?.throwIfInterrupted() diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index 51f632ea5..8cffd8b64 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -6,6 +6,7 @@ import { commandValidationDescriptorFor, effectivePolicyCommandsFor, type Comman import { composerPackageVendorPath, evaluateRecipeSourcePolicy, isComposerPackageName, pluginTarget, recipeExtraPluginSlug, recipeExtraPluginSource, recipeExtraPluginSourceRoot, recipeExtraPluginSourceSubpath, recipeExtraPlugins, recipeSource, resolveRecipeExtraPluginFile } from "./recipe-sources.js" import { loadConfiguredRuntimeOverlayDescriptors, registeredRuntimeOverlayDescriptors, runtimeOverlayDescriptor, runtimeOverlayTarget } from "./runtime-overlay-registry.js" import { assertHostNodeHeapRequirement } from "./host-node-heap.js" +import { isSmtpSinkRecipeOperation } from "./smtp-sink-recipe-operations.js" import { cliRuntimeBackendRecipePolicy, listCliRecipeCommandIds, listCliRuntimeBackendKinds } from "./runtime-backends.js" import { evaluateZipSourcePolicy } from "./source-policy.js" @@ -1098,7 +1099,7 @@ export function recipePolicy(recipe: WorkspaceRecipe, recipeDirectory?: string): return [] }) const commands = [ - ...effectivePolicyCommandsFor(recipeDeclaredWorkflowSteps(recipe).map(({ step }) => step.command), cliRecipeCommandDefinitions), + ...effectivePolicyCommandsFor(recipeDeclaredWorkflowSteps(recipe).map(({ step }) => step.command).filter((command) => !isSmtpSinkRecipeOperation(command)), cliRecipeCommandDefinitions), ...effectivePolicyCommandsFor(boundedRuntimePlanCommands(recipe), cliRecipeCommandDefinitions), ...effectivePolicyCommandsFor(pluginRuntimeCommands, cliRecipeCommandDefinitions), ...effectivePolicyCommandsFor(distributionStartupProbeCommands, cliRecipeCommandDefinitions), @@ -1557,6 +1558,26 @@ export function hasExplicitSiteSeedSelectors(scope: NonNullable void, recipeDirectory: string): Promise { validateRecipeStepDescriptorArgs(step, path, addIssue) + if (isSmtpSinkRecipeOperation(step.command)) { + const allowed = step.command === "host/smtp.inspect" ? new Set(["service", "limit", "recipient", "recipient-label", "subject-marker", "link-marker"]) : new Set(["service"]) + const seen = new Set() + for (const argument of step.args ?? []) { + const separator = argument.indexOf("=") + const name = separator < 1 ? "" : argument.slice(0, separator) + if (!allowed.has(name)) addIssue("unknown-smtp-operation-arg", `${path}.args`, `${step.command} does not accept ${name || "unnamed"} arguments.`) + else if (seen.has(name)) addIssue("duplicate-smtp-operation-arg", `${path}.args`, `${step.command} accepts each argument at most once.`) + else seen.add(name) + } + if (!recipeStepArgValue(step.args ?? [], "service")?.trim()) addIssue("missing-smtp-service", `${path}.args`, `${step.command} requires service=.`) + if (step.command === "host/smtp.inspect") { + const limit = recipeStepArgValue(step.args ?? [], "limit") + if (limit && (!/^\d+$/.test(limit) || Number(limit) < 1 || Number(limit) > 100)) addIssue("invalid-smtp-limit", `${path}.args`, "host/smtp.inspect limit must be an integer from 1 through 100.") + const label = recipeStepArgValue(step.args ?? [], "recipient-label") + if (label && (!/^[a-z][a-z0-9_-]{0,63}$/.test(label) || /token|secret|password|credential|apikey|api_key|private|bearer/i.test(label))) addIssue("unsafe-smtp-recipient-label", `${path}.args`, "host/smtp.inspect recipient-label must be a short safe identifier without secret-like terms.") + } + return + } + if (step.command === "wordpress.run-php" || step.command === "wordpress.phpunit" || step.command === "wordpress.core-phpunit") { const code = recipeStepArgValue(step.args ?? [], "code") const codeFile = recipeStepArgValue(step.args ?? [], "code-file") diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index ac4157deb..6eb27a332 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -11,6 +11,9 @@ const MYSQL_IMAGES = { mysql: "mysql:8.4", mariadb: "mariadb:11.4" } as const const SERVICE_IMAGES = { redis: "redis:7.4-alpine", smtp: "axllent/mailpit:v1.27", http: "hashicorp/http-echo:1.0" } as const const DEFAULT_PROCESS_OUTPUT_LIMIT_BYTES = 1024 * 1024 const MAX_NATIVE_RUNTIME_SERVICES = 2 +const SMTP_SINK_RESPONSE_MAX_BYTES = 128 * 1024 +const SMTP_SINK_MESSAGE_TEXT_MAX_BYTES = 64 * 1024 +const SMTP_SINK_MAX_LINKS = 20 export type RuntimeServiceControlAction = "stop" | "start" | "pause" | "resume" | "restart" | "disconnect" | "reconnect" | "flush" | "read-only" | "read-write" | "latency" @@ -36,10 +39,36 @@ export interface RuntimeServiceEvidence { cause?: { code: string; message: string } } controls?: RuntimeServiceControlResult[] + operations?: RuntimeServiceOperationEvidence[] memory?: { budgetMiB: number; observedRssMiB?: number } storage?: "tmpfs" | "disk" } +export interface SmtpSinkInspectOptions { + limit?: number + recipient?: string + recipientLabel?: string + subjectMarker?: string + linkMarker?: string +} + +export interface SmtpSinkInspection { + schema: "wp-codebox/smtp-sink-inspection/v1" + serviceId: string + count: number + returned: number + truncated: boolean + messages: Array<{ id: string; recipientLabels: string[]; subjectMarkerMatched?: boolean; linkMarkerMatched?: boolean; links: Array<{ id: string; scheme: string; hostClass: "loopback" | "external"; pathDepth: number }> }> +} + +export interface RuntimeServiceOperationEvidence { + schema: "wp-codebox/runtime-service-operation/v1" + operation: "smtp.inspect" | "smtp.reset" + status: "completed" | "failed" + result?: SmtpSinkInspection | { schema: "wp-codebox/smtp-sink-reset/v1"; serviceId: string; reset: true } + reason?: string +} + export class RuntimeServiceProvisionError extends Error { constructor(message: string, readonly evidence: RuntimeServiceEvidence[], options?: ErrorOptions) { super(message, options) @@ -65,6 +94,9 @@ interface ManagedRuntimeService { evidence: RuntimeServiceEvidence release(): Promise control(action: RuntimeServiceControlAction, options?: Record): Promise + inspectSmtpSink?(options: SmtpSinkInspectOptions): Promise + resetSmtpSink?(): Promise<{ schema: "wp-codebox/smtp-sink-reset/v1"; serviceId: string; reset: true }> + providerData?: unknown } export interface RuntimeServiceDependencies { @@ -77,6 +109,7 @@ export interface RuntimeServiceDependencies { removeNativeRoot?: (root: string) => Promise signalNativeProcess?: (child: ChildProcess, signal: NodeJS.Signals) => boolean verifyNativeFilesystem?: (root: string, datadir: string) => Promise + request?: (url: string, options: { method: "GET" | "DELETE"; signal?: AbortSignal }) => Promise<{ status: number; body?: string }> } export interface RuntimeServiceProvider { @@ -92,6 +125,7 @@ interface RuntimeServiceProvisionContext { policy?: RuntimePolicy externalServices: WorkspaceRecipeExternalServiceBoundary[] externalServiceWritesApproved: boolean + nextSmtpServiceOrdinal(): number } export interface ProvisionRuntimeServicesOptions { @@ -119,16 +153,18 @@ export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): A }) } -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 }> { +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; inspectSmtpSink(serviceId: string, inspectOptions?: SmtpSinkInspectOptions): Promise; resetSmtpSink(serviceId: string): Promise<{ schema: "wp-codebox/smtp-sink-reset/v1"; serviceId: string; reset: true }>; release(): Promise }> { const dependencies = options.dependencies ?? defaultDependencies const provisioned: ManagedRuntimeService[] = [] const evidence: RuntimeServiceEvidence[] = [] let environment: ReturnType + let smtpServiceOrdinal = 0 const context: RuntimeServiceProvisionContext = { signal: options.signal, policy: options.policy, externalServices: options.externalServices ?? [], externalServiceWritesApproved: options.externalServiceWritesApproved ?? false, + nextSmtpServiceOrdinal: () => ++smtpServiceOrdinal, } try { if (services.filter((service) => service.configuration?.provider === "native").length > MAX_NATIVE_RUNTIME_SERVICES) throw new Error(`Managed runtime services exceed the native service budget of ${MAX_NATIVE_RUNTIME_SERVICES}`) @@ -157,6 +193,16 @@ export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeS if (!service) throw new Error(`Managed runtime service does not exist: ${serviceId}`) return await service.control(action, controlOptions) }, + async inspectSmtpSink(serviceId, inspectOptions = {}) { + const service = provisioned.find((candidate) => candidate.evidence.id === serviceId) + if (!service?.inspectSmtpSink) throw new Error("Managed SMTP sink is unavailable") + return await recordServiceOperation(service.evidence, "smtp.inspect", async () => await service.inspectSmtpSink!(inspectOptions)) + }, + async resetSmtpSink(serviceId) { + const service = provisioned.find((candidate) => candidate.evidence.id === serviceId) + if (!service?.resetSmtpSink) throw new Error("Managed SMTP sink is unavailable") + return await recordServiceOperation(service.evidence, "smtp.reset", async () => await service.resetSmtpSink!()) + }, async release() { try { await releaseServices(provisioned) @@ -1328,14 +1374,180 @@ async function provisionRedisDockerService(service: WorkspaceRecipeRuntimeServic } async function provisionSmtpDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidenceList: RuntimeServiceEvidence[]): Promise { - return await provisionSimpleDockerService(service, dependencies, context.signal, evidenceList, { + const managed = await provisionSimpleDockerService(service, dependencies, context.signal, evidenceList, { image: service.configuration?.image ?? SERVICE_IMAGES.smtp, ports: [1025, 8025], runArgs: [], values: (ports) => ({ host: "127.0.0.1", port: String(ports[0]), httpPort: String(ports[1]), url: `smtp://127.0.0.1:${ports[0]}` }), }) + const httpPort = (managed.providerData as { ports?: number[] } | undefined)?.ports?.[1] + if (typeof httpPort !== "number" || !Number.isInteger(httpPort)) throw new Error("SMTP sink inspection port is unavailable") + const inspectionPort = httpPort + const labels = opaqueSmtpLabels(context.nextSmtpServiceOrdinal()) + try { + await waitForSmtpInspectionReady(dependencies, inspectionPort, context.signal) + } catch (error) { + await managed.release().catch(() => undefined) + managed.evidence.readiness = "failed" + managed.evidence.lifecycle = "failed" + managed.evidence.diagnostic = { code: "readiness-failed" } + throw error + } + return { + ...managed, + async inspectSmtpSink(options) { + const limit = smtpSinkLimit(options.limit) + // Mailpit's HTTP API is contained in this provider adapter. Link-check is + // intentionally not used: it makes outbound requests for message URLs. + const payload = await smtpSinkRequest(dependencies, `http://127.0.0.1:${inspectionPort}/api/v1/messages?limit=100`, "GET", context.signal) + return await normalizeMailpitMessages(labels, payload, options, limit, async (id) => await smtpSinkRequest(dependencies, `http://127.0.0.1:${inspectionPort}/api/v1/message/${encodeURIComponent(id)}`, "GET", context.signal)) + }, + async resetSmtpSink() { + await smtpSinkRequest(dependencies, `http://127.0.0.1:${inspectionPort}/api/v1/messages`, "DELETE", context.signal) + return { schema: "wp-codebox/smtp-sink-reset/v1", serviceId: labels.service, reset: true } + }, + async control(action, options) { + const result = await managed.control(action, options) + if (result.status === "applied" && ["start", "resume", "restart", "reconnect"].includes(action)) { + try { + await waitForSmtpInspectionReady(dependencies, inspectionPort, context.signal) + } catch { + result.status = "failed" + result.reason = "SMTP sink inspection API did not recover" + } + } + return result + }, + } +} + +async function recordServiceOperation(evidence: RuntimeServiceEvidence, operation: RuntimeServiceOperationEvidence["operation"], run: () => Promise): Promise { + try { + const result = await run() + ;(evidence.operations ??= []).push({ schema: "wp-codebox/runtime-service-operation/v1", operation, status: "completed", result }) + return result + } catch (error) { + ;(evidence.operations ??= []).push({ schema: "wp-codebox/runtime-service-operation/v1", operation, status: "failed", reason: "provider-operation-failed" }) + throw error + } +} + +function smtpSinkLimit(value: number | undefined): number { + const limit = value ?? 20 + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("SMTP sink inspection limit must be an integer from 1 through 100") + return limit +} + +async function smtpSinkRequest(dependencies: RuntimeServiceDependencies, url: string, method: "GET" | "DELETE", signal?: AbortSignal): Promise { + if (dependencies.request) { + const response = await dependencies.request(url, { method, signal }) + if (response.status < 200 || response.status >= 300) throw new Error("SMTP sink provider request failed") + return parseSmtpSinkResponse(response.body) + } + const response = await fetch(url, { method, signal }) + if (!response.ok) throw new Error("SMTP sink provider request failed") + return parseSmtpSinkResponse(await response.text()) +} + +function parseSmtpSinkResponse(body: string | undefined): unknown { + if (body && Buffer.byteLength(body) > SMTP_SINK_RESPONSE_MAX_BYTES) throw new Error("SMTP sink provider response exceeded the bounded response limit") + if (!body?.trim()) return undefined + try { return JSON.parse(body) } catch { return body } +} + +async function waitForSmtpInspectionReady(dependencies: RuntimeServiceDependencies, port: number, signal?: AbortSignal): Promise { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + throwIfAborted(signal) + try { + const response = await smtpSinkRequest(dependencies, `http://127.0.0.1:${port}/api/v1/messages?limit=1`, "GET", signal) + if (objectRecord(response)) return + } catch (error) { + if (signal?.aborted) throw error + } + await abortableDelay(100, signal) + } + throw new Error("SMTP sink inspection API readiness timed out") +} + +function opaqueSmtpLabels(serviceOrdinal: number): { service: string; message(value: string): string; recipient(value: string): string; link(value: string): string } { + const labels = new Map() + const label = (kind: string, value: string): string => { + const key = `${kind}:${value}` + let ordinal = labels.get(key) + if (ordinal === undefined) { ordinal = labels.size + 1; labels.set(key, ordinal) } + return `${kind}-${ordinal}` + } + return { service: `service-${serviceOrdinal}`, message: (value) => label("message", value), recipient: (value) => label("recipient", value), link: (value) => label("link", value) } +} + +async function normalizeMailpitMessages(labels: ReturnType, payload: unknown, options: SmtpSinkInspectOptions, limit: number, linkCheck: (id: string) => Promise): Promise { + const record = objectRecord(payload) + const source = Array.isArray(record?.messages) ? record.messages : [] + const filtered = source.filter((message) => { + const item = objectRecord(message) + const recipients = mailpitRecipients(item) + const subject = stringField(item, "Subject") + return (!options.recipient || recipients.includes(options.recipient)) && (!options.subjectMarker || subject.includes(options.subjectMarker)) + }) + const messages = await Promise.all(filtered.slice(0, limit).map(async (message) => { + const summary = objectRecord(message) + const id = stringField(summary, "ID") + const links = id ? await linkCheck(id) : undefined + return normalizeMailpitMessage(labels, summary, links, options) + })) + const total = numberField(record, "total") ?? source.length + return { schema: "wp-codebox/smtp-sink-inspection/v1", serviceId: labels.service, count: filtered.length, returned: messages.length, truncated: filtered.length > messages.length || total > source.length, messages } +} + +function normalizeMailpitMessage(labels: ReturnType, message: Record | undefined, detail: unknown, options: SmtpSinkInspectOptions): SmtpSinkInspection["messages"][number] { + const recipients = mailpitRecipients(message) + const subject = stringField(message, "Subject") + const extractedLinks = extractMailpitMessageLinks(detail) + const links = extractedLinks.map((link) => normalizeSmtpLink(labels, link)) + return { + id: labels.message(stringField(message, "ID")), + recipientLabels: [...new Set(recipients.map((recipient) => options.recipient && recipient === options.recipient && options.recipientLabel ? options.recipientLabel : labels.recipient(recipient)))].sort(), + ...(options.subjectMarker ? { subjectMarkerMatched: subject.includes(options.subjectMarker) } : {}), + ...(options.linkMarker ? { linkMarkerMatched: extractedLinks.some((link) => link.includes(options.linkMarker!)) } : {}), + links, + } +} + +function mailpitRecipients(message: Record | undefined): string[] { + const recipients = Array.isArray(message?.To) ? message.To : [] + return recipients.map(objectRecord).map((recipient) => stringField(recipient, "Address")).filter((value): value is string => Boolean(value)) +} + +function extractMailpitMessageLinks(payload: unknown): string[] { + const message = objectRecord(payload) + const html = stringField(message, "HTML").slice(0, SMTP_SINK_MESSAGE_TEXT_MAX_BYTES) + const text = stringField(message, "Text").slice(0, SMTP_SINK_MESSAGE_TEXT_MAX_BYTES) + const links = new Set() + const collect = (value: string): void => { + for (const match of value.matchAll(/https?:\/\/[^\s"'<>]+/gi)) { + if (links.size >= SMTP_SINK_MAX_LINKS) return + links.add(match[0]) + } + } + collect(html) + collect(text) + return [...links] +} + +function normalizeSmtpLink(labels: ReturnType, link: string): { id: string; scheme: string; hostClass: "loopback" | "external"; pathDepth: number } { + try { + const parsed = new URL(link) + return { id: labels.link(link), scheme: parsed.protocol.replace(/:$/, ""), hostClass: /^(localhost|127\.0\.0\.1|::1)$/i.test(parsed.hostname) ? "loopback" : "external", pathDepth: parsed.pathname.split("/").filter(Boolean).length } + } catch { + return { id: labels.link(link), scheme: "unknown", hostClass: "external", pathDepth: 0 } + } } +function objectRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined } +function stringField(value: Record | undefined, key: string): string { return typeof value?.[key] === "string" ? value[key] : "" } +function numberField(value: Record | undefined, key: string): number | undefined { return typeof value?.[key] === "number" && Number.isFinite(value[key]) ? value[key] : undefined } + async function provisionHttpDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidenceList: RuntimeServiceEvidence[]): Promise { return await provisionSimpleDockerService(service, dependencies, context.signal, evidenceList, { image: service.configuration?.image ?? SERVICE_IMAGES.http, @@ -1376,6 +1588,7 @@ async function provisionSimpleDockerService( secretEnv: {}, secretEnvTargets: {}, evidence, + providerData: { ports }, 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/cli/src/smtp-sink-recipe-operations.ts b/packages/cli/src/smtp-sink-recipe-operations.ts new file mode 100644 index 000000000..0a6e04343 --- /dev/null +++ b/packages/cli/src/smtp-sink-recipe-operations.ts @@ -0,0 +1,43 @@ +import { commandArgValue, type ExecutionResult, type WorkspaceRecipeStep } from "@automattic/wp-codebox-core" +import type { SmtpSinkInspectOptions } from "./runtime-services.js" + +export function isSmtpSinkRecipeOperation(command: string): boolean { + return command === "host/smtp.inspect" || command === "host/smtp.reset" +} + +export function smtpSinkEvidenceValue(value: string): string { + return `[redacted:${Buffer.byteLength(value)}]` +} + +export async function executeSmtpSinkRecipeOperation( + step: WorkspaceRecipeStep, + services: { inspectSmtpSink(serviceId: string, options?: SmtpSinkInspectOptions): Promise; resetSmtpSink(serviceId: string): Promise }, +): Promise<{ execution: ExecutionResult; evidenceArgs: string[] }> { + const serviceId = commandArgValue(step.args ?? [], "service") + if (!serviceId) throw new Error(`${step.command} requires service=`) + if (step.command === "host/smtp.reset") { + const result = await services.resetSmtpSink(serviceId) + const evidenceArgs = [`service=${smtpSinkEvidenceValue(serviceId)}`] + return { execution: smtpExecution(step.command, evidenceArgs, result), evidenceArgs } + } + const limitText = commandArgValue(step.args ?? [], "limit") + const limit = limitText === undefined ? undefined : Number(limitText) + if (limitText !== undefined && (!Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 100)) throw new Error("host/smtp.inspect limit must be an integer from 1 through 100") + const options = { + limit, + recipient: commandArgValue(step.args ?? [], "recipient"), + recipientLabel: commandArgValue(step.args ?? [], "recipient-label"), + subjectMarker: commandArgValue(step.args ?? [], "subject-marker"), + linkMarker: commandArgValue(step.args ?? [], "link-marker"), + } + const result = await services.inspectSmtpSink(serviceId, options) + // Query inputs are represented by fixed-size hashes and lengths in replay evidence. + const evidenceArgs = [`service=${smtpSinkEvidenceValue(serviceId)}`, ...(limit === undefined ? [] : [`limit=${limit}`]), ...(options.recipient ? [`recipient=${smtpSinkEvidenceValue(options.recipient)}`] : []), ...(options.recipientLabel ? [`recipient-label=${options.recipientLabel}`] : []), ...(options.subjectMarker ? [`subject-marker=${smtpSinkEvidenceValue(options.subjectMarker)}`] : []), ...(options.linkMarker ? [`link-marker=${smtpSinkEvidenceValue(options.linkMarker)}`] : [])] + return { execution: smtpExecution(step.command, evidenceArgs, result), evidenceArgs } +} + +function smtpExecution(command: string, args: string[], json: unknown): ExecutionResult { + const now = new Date().toISOString() + const stdout = JSON.stringify(json) + return { id: `smtp-sink-${Date.now()}`, command, args, exitCode: 0, stdout, stderr: "", startedAt: now, finishedAt: now, result: { schema: "wp-codebox/runtime-command-result/v1", status: "ok", stdout, stderr: "", json } } +} diff --git a/tests/adversarial-recipe-orchestration.test.ts b/tests/adversarial-recipe-orchestration.test.ts index eec8d4bf0..0cce675a6 100644 --- a/tests/adversarial-recipe-orchestration.test.ts +++ b/tests/adversarial-recipe-orchestration.test.ts @@ -10,6 +10,7 @@ import type { RecipeExecutionResult } from "../packages/cli/src/commands/recipe- const recipe: WorkspaceRecipe = { schema: "wp-codebox/workspace-recipe/v1", + inputs: { services: [{ id: "mail", kind: "smtp", outputs: { host: "SMTP_HOST" } }] }, workflow: { steps: [{ command: "inspect-mounted-inputs" }] }, adversarialCampaigns: [{ schema: "wp-codebox/adversarial-recipe-campaign/v1", @@ -41,6 +42,7 @@ assert(recipePolicy(recipe).commands.includes("wordpress.run-php"), "template co const executions: ExecutionSpec[] = [] const checkpointOperations: string[] = [] +const smtpResets: string[] = [] const runtime = { info: async () => ({ id: "neutral", backend: "neutral", environment: { kind: "wordpress", name: "Neutral" }, createdAt: "2026-01-01T00:00:00.000Z", status: "created" }), execute: async (spec: ExecutionSpec) => { @@ -63,6 +65,7 @@ const executeCampaign = async () => runRecipeAdversarialCampaigns({ recipeDirectory: "/portable", runtime, executions: [], + managedServices: { resetSmtpSink: async (serviceId) => { smtpResets.push(serviceId); return { schema: "wp-codebox/smtp-sink-reset/v1", serviceId: "service-1", reset: true } } }, provenance: { runtime: "neutral" }, }) @@ -76,6 +79,8 @@ assert.equal(first[0]?.capabilities.optional[0]?.available, false, "optional fid assert(executions.length > 0, "generated cases must execute through runtime commands") assert(checkpointOperations.includes("create:baseline") && checkpointOperations.includes("restore:baseline"), "campaign cases must use the existing checkpoint reset path") assert(checkpointOperations.filter((operation) => operation === "restore:baseline").length >= (first[0]?.result.summary.generated ?? 0) + (second[0]?.result.summary.generated ?? 0), "every campaign and minimization execution must restore the declared baseline before running") +assert.equal(smtpResets.length > 0, true, "checkpointed cases reset host-side SMTP sinks") +assert.equal(first[0]?.result.corpus.some((entry) => entry.signals.includes("smtp-sink-reset:1")), true, "replay corpus retains normalized SMTP reset evidence") const unsupportedRecipe = structuredClone(recipe) unsupportedRecipe.adversarialCampaigns![0]!.requiredCapabilities = ["missing-adapter"] diff --git a/tests/runtime-services.test.ts b/tests/runtime-services.test.ts index a3fd1120b..a4d87588c 100644 --- a/tests/runtime-services.test.ts +++ b/tests/runtime-services.test.ts @@ -7,7 +7,8 @@ import { join } from "node:path" import { runRecipeBuildCommand } from "../packages/cli/src/commands/recipe-build.ts" import { executeRuntimeServiceProcess, parseLoopbackPort, provisionRuntimeServices, provisionRuntimeServicesForRecipe, RuntimeServiceProvisionError, runtimeServiceEvidenceFromError, runtimeServicePlan, waitForMysqlProtocol, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" import { planWorkspaceRecipe } from "../packages/cli/src/recipe-dry-run.ts" -import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" +import { executeSmtpSinkRecipeOperation } from "../packages/cli/src/smtp-sink-recipe-operations.ts" +import { recipePolicy, validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe-builders.ts" import { validateWorkspaceRecipeJsonSchema, type WorkspaceRecipe, type WorkspaceRecipeRuntimeService } from "../packages/runtime-core/src/index.ts" @@ -100,6 +101,11 @@ try { } const recipe: WorkspaceRecipe = { schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [service] }, workflow: { steps: [{ command: "wordpress.run-php", args: ["code=echo 'ok';"] }] } } assert.deepEqual(await validateWorkspaceRecipeSemantics(recipe, "recipe.json"), []) +assert.equal(recipePolicy({ ...recipe, workflow: { steps: [{ command: "host/unknown", args: [] }] } }).commands.includes("host/unknown"), true, "unknown host commands remain policy-checked") +assert.deepEqual( + (await validateWorkspaceRecipeSemantics({ ...recipe, workflow: { steps: [{ command: "host/smtp.inspect", args: ["service=mail", "unexpected=value", "recipient-label=api_key"] }] } }, "recipe.json")).map((issue) => issue.code).sort(), + ["unknown-smtp-operation-arg", "unsafe-smtp-recipient-label"], +) const dryRun = await planWorkspaceRecipe(recipe, process.cwd(), { recipePath: "recipe.json" }, { defaultWordPressVersion: "latest", resolveExecutionSpec: async (step) => ({ command: step.command, args: step.args ?? [] }), @@ -279,14 +285,35 @@ assert.deepEqual(indexedForeignKeyRun?.args.slice(-2), ["mysql:8.4", "--restrict await indexedForeignKey.release() const auxiliaryCalls: string[][] = [] +let smtpMessages: unknown[] = [ + { ID: "first", To: [{ Address: "person@example.test" }], Subject: "Reset your password" }, + { ID: "second", To: [{ Address: "person@example.test" }], Subject: "Reset your password" }, +] +let smtpUnavailable = false +let smtpApiReadinessFailures = 1 +const smtpLinks: Record = { + first: ["http://localhost:41001/reset/token-secret"], + second: ["https://example.test/reset/token-secret"], +} +const smtpProviderRequests: string[] = [] const auxiliaryDependencies: RuntimeServiceDependencies = { ...dependencies, async execute(command, args, options) { auxiliaryCalls.push(args) return await dependencies.execute(command, args, options) }, + async request(url, options) { + smtpProviderRequests.push(url) + if (smtpUnavailable) throw new Error("sink unavailable") + if (options.method === "DELETE") { smtpMessages = []; return { status: 200, body: "ok" } } + if (smtpApiReadinessFailures-- > 0) return { status: 503, body: "starting" } + const detailId = url.match(/\/message\/([^/]+)$/)?.[1] + if (detailId) return { status: 200, body: JSON.stringify({ ID: detailId, HTML: (smtpLinks[detailId] ?? []).map((URL) => `link`).join(""), Text: (smtpLinks[detailId] ?? []).join("\n") }) } + return { status: 200, body: JSON.stringify({ total: smtpMessages.length, messages: smtpMessages }) } + }, } const auxiliary = await provisionRuntimeServices(auxiliaryServices, { dependencies: auxiliaryDependencies }) +assert.equal(smtpApiReadinessFailures < 0, true, "SMTP inspection API readiness retries independently after SMTP is reachable") assert.equal(auxiliary.env.REDIS_URL, "redis://127.0.0.1:41001") assert.equal(auxiliary.env.FIXTURE_URL, "http://127.0.0.1:41001") assert.equal((await auxiliary.control("cache", "pause")).status, "applied") @@ -300,6 +327,34 @@ assert.ok(auxiliaryCalls.some((args) => args.includes("redis:7.4-alpine"))) assert.ok(auxiliaryCalls.some((args) => args.includes("axllent/mailpit:v1.27"))) assert.ok(auxiliaryCalls.some((args) => args.includes("hashicorp/http-echo:1.0"))) assert.equal(auxiliary.evidence.find((item) => item.id === "cache")?.controls?.length, 5) +const inspectedMail = await auxiliary.inspectSmtpSink("mail", { limit: 1, recipient: "person@example.test", recipientLabel: "account", subjectMarker: "Reset", linkMarker: "/reset/" }) +assert.equal(inspectedMail.count, 2) +assert.equal(inspectedMail.returned, 1) +assert.equal(inspectedMail.truncated, true) +assert.deepEqual(inspectedMail.messages[0]?.recipientLabels, ["account"]) +assert.equal(inspectedMail.messages[0]?.subjectMarkerMatched, true) +assert.equal(inspectedMail.messages[0]?.linkMarkerMatched, true) +assert.equal(JSON.stringify(inspectedMail).includes("person@example.test"), false) +assert.equal(JSON.stringify(inspectedMail).includes("token-secret"), false) +assert.equal(JSON.stringify(inspectedMail).includes("41001"), false) +assert.equal(smtpProviderRequests.every((url) => /^http:\/\/127\.0\.0\.1:\d+\/api\/v1\/(messages(?:\?[^/]*)?|message\/[^/]+)$/.test(url)), true, "SMTP inspection uses only loopback Mailpit list/detail/reset/readiness endpoints") +assert.equal(smtpProviderRequests.some((url) => url.includes("link-check") || url.includes("token-secret") || url.includes("example.test")), false, "message URLs never become provider requests") +smtpUnavailable = true +await assert.rejects(auxiliary.inspectSmtpSink("mail"), /sink unavailable/) +smtpUnavailable = false +assert.equal((await auxiliary.control("mail", "restart")).status, "applied", "recovery verifies the inspection API after SMTP restart") +assert.deepEqual(await auxiliary.resetSmtpSink("mail"), { schema: "wp-codebox/smtp-sink-reset/v1", serviceId: "service-1", reset: true }) +const emptyMail = await auxiliary.inspectSmtpSink("mail") +assert.deepEqual(emptyMail.messages, []) +assert.equal(emptyMail.count, 0) +smtpMessages = [{ ID: "single", To: [{ Address: "other@example.test" }], Subject: "Welcome", Links: [] }] +assert.equal((await auxiliary.inspectSmtpSink("mail", { limit: 1 })).returned, 1) +const recipeInspection = await executeSmtpSinkRecipeOperation({ command: "host/smtp.inspect", args: ["service=mail", "recipient=other@example.test", "recipient-label=other", "subject-marker=Welcome secret", "link-marker=/token-secret"] }, auxiliary) +assert.equal(recipeInspection.execution.stdout.includes("other@example.test"), false) +assert.equal(recipeInspection.evidenceArgs.some((argument) => argument.includes("other@example.test")), false) +assert.equal(recipeInspection.evidenceArgs.some((argument) => argument.includes("Welcome secret") || argument.includes("token-secret") || argument === "service=mail"), false) +assert.equal(auxiliary.evidence.find((item) => item.id === "mail")?.operations?.length, 6) +await assert.rejects(auxiliary.inspectSmtpSink("mail", { limit: 101 }), /1 through 100/) await auxiliary.release() const mariaDbCalls: Array<{ args: string[]; env?: NodeJS.ProcessEnv }> = []