Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/smtp-sink-runtime-service.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 16 additions & 4 deletions packages/cli/src/adversarial-recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ interface RunRecipeAdversarialCampaignsOptions {
signal?: AbortSignal
executions: RecipeExecutionResult[]
provenance?: Record<string, unknown>
managedServices?: { resetSmtpSink(serviceId: string): Promise<unknown> }
}

export async function runRecipeAdversarialCampaigns(options: RunRecipeAdversarialCampaignsOptions): Promise<RecipeAdversarialCampaignOutput[]> {
Expand Down Expand Up @@ -111,9 +112,18 @@ 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)
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)
}
}
try {
return await executeRecipeAdversarialCase(declaration, templates, plan, signal, campaignOptions)
return await executeRecipeAdversarialCase(declaration, templates, plan, signal, campaignOptions, smtpSinkResets)
} finally {
if (plan.actions.some((action) => action.clock?.length)) {
const cleanup = wordpressServerClockCleanupAction()
Expand Down Expand Up @@ -230,6 +240,7 @@ async function executeRecipeAdversarialCase(
plan: AdversarialCasePlan,
signal: AbortSignal,
options: RunRecipeAdversarialCampaignsOptions,
smtpSinkResets: unknown[] = [],
): Promise<AdversarialExecutionObservation> {
if (signal.aborted) return { status: "error", diagnostics: [{ code: "campaign-case-interrupted", message: "Case was interrupted before execution." }] }
const phases = materializeCasePhases(plan, templates)
Expand All @@ -244,7 +255,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",
Expand All @@ -265,6 +276,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}`] : []),
Expand All @@ -275,7 +287,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
}

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/recipe-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<string, unknown>,
}))
interruption?.throwIfInterrupted()
Expand Down
23 changes: 22 additions & 1 deletion packages/cli/src/recipe-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1557,6 +1558,26 @@ export function hasExplicitSiteSeedSelectors(scope: NonNullable<WorkspaceRecipeS
async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"][number], path: string, addIssue: (code: string, path: string, message: string) => void, recipeDirectory: string): Promise<void> {
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<string>()
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=<smtp-service-id>.`)
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")
Expand Down
Loading
Loading