From b65c997b473d77d6da3730424bc253495b751df0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 15 Jun 2026 18:51:53 -0400 Subject: [PATCH 1/3] Add replay package materializer --- packages/cli/src/cli-entry.ts | 2 + packages/cli/src/command-router.ts | 1 + packages/cli/src/commands/replay-package.ts | 129 ++++++++++++++++++++ packages/cli/src/output.ts | 7 +- scripts/materialize-replay-package-smoke.ts | 67 ++++++++++ scripts/smoke-manifest.ts | 1 + tests/fixtures/runtime-snapshot-small.json | 51 ++++++++ 7 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/replay-package.ts create mode 100644 scripts/materialize-replay-package-smoke.ts create mode 100644 tests/fixtures/runtime-snapshot-small.json diff --git a/packages/cli/src/cli-entry.ts b/packages/cli/src/cli-entry.ts index fe98ecba..eb017ef3 100644 --- a/packages/cli/src/cli-entry.ts +++ b/packages/cli/src/cli-entry.ts @@ -6,6 +6,7 @@ import { runCommandsCommand, runRecipeSchemaCommand } from "./commands/discovery import { runCleanupCommand, runDoctorCommand } from "./commands/doctor.js" import { runRecipeBuildCommand } from "./commands/recipe-build.js" import { runRecipeRunCommand, runRecipeValidateCommand } from "./commands/recipe-run.js" +import { runMaterializeReplayPackageCommand } from "./commands/replay-package.js" import { runBootCommand, runRunCommand, runValidateBlueprintCommand } from "./commands/runtime.js" import { runRunsArtifactsCommand, runRunsStatusCommand } from "./commands/runs.js" import { runWorkspacePolicyCheckCommand } from "./commands/workspace-policy.js" @@ -16,6 +17,7 @@ export async function runCli(args: string[]): Promise { printHelp, boot: runBootCommand, validateBlueprint: runValidateBlueprintCommand, + materializeReplayPackage: runMaterializeReplayPackageCommand, recipeRun: runRecipeRunCommand, agentTaskRun: runAgentTaskRunCommand, recipeValidate: runRecipeValidateCommand, diff --git a/packages/cli/src/command-router.ts b/packages/cli/src/command-router.ts index 5e369532..8a1fb662 100644 --- a/packages/cli/src/command-router.ts +++ b/packages/cli/src/command-router.ts @@ -5,6 +5,7 @@ type CliCommandHandler = (args: string[]) => Promise const cliCommandRoutes = { boot: "boot", "validate-blueprint": "validateBlueprint", + "materialize-replay-package": "materializeReplayPackage", "recipe-run": "recipeRun", "agent-task-run": "agentTaskRun", recipe: { diff --git a/packages/cli/src/commands/replay-package.ts b/packages/cli/src/commands/replay-package.ts new file mode 100644 index 00000000..f80cde8e --- /dev/null +++ b/packages/cli/src/commands/replay-package.ts @@ -0,0 +1,129 @@ +import { readFile } from "node:fs/promises" +import { resolve } from "node:path" +import { writeReplayExportPackage, type ReplayExportPackage, type RuntimeSnapshotArtifact } from "@automattic/wp-codebox-playground" +import { captureStdout } from "../output.js" + +interface MaterializeReplayPackageOptions { + snapshotPath: string + outputDirectory: string + snapshotRef?: string + id?: string + createdAt?: string + landingPage?: string + json: boolean +} + +export async function runMaterializeReplayPackageCommand(args: string[]): Promise { + const options = parseMaterializeReplayPackageOptions(args) + const execute = () => materializeReplayPackage(options) + + if (!options.json) { + const output = await execute() + printMaterializeReplayPackageHumanOutput(output) + return 0 + } + + const { result, logs } = await captureStdout(execute) + const output = logs.length > 0 ? { ...result, logs } : result + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`) + return 0 +} + +async function materializeReplayPackage(options: MaterializeReplayPackageOptions): Promise { + const snapshotPath = resolve(options.snapshotPath) + const snapshot = JSON.parse(await readFile(snapshotPath, "utf8")) + if (!isRuntimeSnapshotArtifact(snapshot)) { + throw new Error(`Input is not a wp-codebox/wordpress-runtime-snapshot/v1 JSON file: ${options.snapshotPath}`) + } + + const startedAtMs = Date.now() + return writeReplayExportPackage(snapshot, { + directory: resolve(options.outputDirectory), + id: options.id, + createdAt: options.createdAt, + landingPage: options.landingPage, + materializeMs: Date.now() - startedAtMs, + source: { + inputSnapshotPath: snapshotPath, + inputSnapshotRef: options.snapshotRef ?? options.snapshotPath, + materializerCommand: "wp-codebox materialize-replay-package", + }, + }) +} + +function parseMaterializeReplayPackageOptions(args: string[]): MaterializeReplayPackageOptions { + const options: Partial = { json: false } + + for (let index = 0; index < args.length; index++) { + const arg = args[index] + + if (arg === "--json") { + options.json = true + continue + } + + const [name, inlineValue] = arg.split("=", 2) + const value = inlineValue ?? args[++index] + + if (!name.startsWith("--") || value === undefined) { + throw new Error(`Invalid argument: ${arg}`) + } + + switch (name) { + case "--snapshot": + options.snapshotPath = value + break + case "--output": + options.outputDirectory = value + break + case "--snapshot-ref": + options.snapshotRef = value + break + case "--id": + options.id = value + break + case "--created-at": + options.createdAt = value + break + case "--landing-page": + options.landingPage = value + break + default: + throw new Error(`Unknown option: ${name}`) + } + } + + if (!options.snapshotPath) { + throw new Error("Missing required option: --snapshot") + } + + if (!options.outputDirectory) { + throw new Error("Missing required option: --output") + } + + return options as MaterializeReplayPackageOptions +} + +function printMaterializeReplayPackageHumanOutput(output: ReplayExportPackage): void { + console.log("WP Codebox replay package") + console.log(`Directory: ${output.directory}`) + console.log(`Blueprint: ${output.artifacts.blueprint}`) + console.log(`Snapshot: ${output.artifacts.snapshot}`) + console.log(`Notes: ${output.artifacts.notes}`) + console.log(`Manifest: ${output.artifacts.manifest}`) +} + +function isRuntimeSnapshotArtifact(value: unknown): value is RuntimeSnapshotArtifact { + return isRecord(value) + && value.schema === "wp-codebox/wordpress-runtime-snapshot/v1" + && value.version === 1 + && isRecord(value.compatibility) + && value.compatibility.backend === "wordpress-playground" + && isRecord(value.database) + && Array.isArray(value.database.tables) + && Array.isArray(value.files) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index 50002805..ed5900e5 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -282,6 +282,7 @@ export function printHelp(): void { wp-codebox runs artifacts --registry --run-id [--json] wp-codebox agent-task-run --input-file [--json] [--preview-hold-seconds ] [--preview-public-url ] wp-codebox validate-blueprint --blueprint [options] + wp-codebox materialize-replay-package --snapshot --output [--snapshot-ref ] [--json] wp-codebox recipe-run --recipe [options] wp-codebox boot [--mount :] [options] wp-codebox run --mount : --command [options] @@ -289,7 +290,7 @@ export function printHelp(): void { Options: --recipe Workspace recipe JSON file for recipe-run or recipe validate. --options Recipe builder options JSON file for recipe build. - --output Optional output JSON path for recipe build; defaults to stdout. + --output Recipe build output JSON path, or materialize-replay-package output directory. --input-file Agent task input JSON for agent-task-run. --preview-hold-seconds Keep preview runtimes alive after agent-task-run/recipe-run. @@ -334,7 +335,9 @@ Options: --arg Command argument. Repeatable. Recipe commands include wordpress.run-php, wordpress.phpunit, wordpress.core-phpunit, wordpress.plugin-check, wordpress.wp-cli, wordpress.ability, wordpress.bench, and wordpress.browser-probe. --wp WordPress version for Playground. Defaults to latest; accepts trunk, nightly, or numeric versions. --blueprint - WordPress Playground blueprint JSON or path for boot or validate-blueprint. + WordPress Playground blueprint JSON or path for boot or validate-blueprint. + --snapshot Runtime snapshot JSON file for materialize-replay-package. + --snapshot-ref Optional external reference for the input snapshot source metadata. --artifacts Artifact root directory. --preview-hold-seconds Keep the live Playground preview available after a successful run. Accepts seconds or minutes, e.g. 30s or 15m; max 3600s. diff --git a/scripts/materialize-replay-package-smoke.ts b/scripts/materialize-replay-package-smoke.ts new file mode 100644 index 00000000..38fd7247 --- /dev/null +++ b/scripts/materialize-replay-package-smoke.ts @@ -0,0 +1,67 @@ +import { existsSync } from "node:fs" +import { mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { spawnSync } from "node:child_process" + +const root = await mkdtemp(join(tmpdir(), "wp-codebox-materialize-replay-package-")) +const outputDirectory = join(root, "package") +const snapshotPath = join(process.cwd(), "tests/fixtures/runtime-snapshot-small.json") + +try { + const materialize = spawnSync(process.execPath, [ + "packages/cli/dist/index.js", + "materialize-replay-package", + "--snapshot", + snapshotPath, + "--snapshot-ref", + "fixture:runtime-snapshot-small", + "--output", + outputDirectory, + "--json", + ], { encoding: "utf8" }) + + if (materialize.status !== 0) { + throw new Error(`materialize-replay-package failed: ${materialize.stderr || materialize.stdout}`) + } + + for (const relativePath of ["blueprint.after.json", "files/runtime-snapshot.json", "blueprint.after-notes.json", "manifest.json"]) { + if (!existsSync(join(outputDirectory, relativePath))) { + throw new Error(`Missing materialized file: ${relativePath}`) + } + } + + const validate = spawnSync(process.execPath, [ + "packages/cli/dist/index.js", + "validate-blueprint", + "--blueprint", + join(outputDirectory, "blueprint.after.json"), + "--json", + ], { encoding: "utf8" }) + + if (validate.status !== 0) { + throw new Error(`Generated blueprint did not validate: ${validate.stderr || validate.stdout}`) + } + + const notes = JSON.parse(await readFile(join(outputDirectory, "blueprint.after-notes.json"), "utf8")) + if (notes.source?.inputSnapshotPath !== snapshotPath) { + throw new Error("Replay package notes must record the input snapshot path") + } + + if (notes.source?.inputSnapshotRef !== "fixture:runtime-snapshot-small") { + throw new Error("Replay package notes must record the input snapshot ref") + } + + if (notes.source?.materializerCommand !== "wp-codebox materialize-replay-package") { + throw new Error("Replay package notes must record the materializer command") + } + + const manifest = JSON.parse(await readFile(join(outputDirectory, "manifest.json"), "utf8")) + if (manifest.replayableWordPressSite?.source?.inputSnapshotPath !== snapshotPath) { + throw new Error("Replay package manifest must record the input snapshot path") + } + + console.log("materialize-replay-package-smoke passed") +} finally { + await rm(root, { recursive: true, force: true }) +} diff --git a/scripts/smoke-manifest.ts b/scripts/smoke-manifest.ts index fede603c..4989b974 100644 --- a/scripts/smoke-manifest.ts +++ b/scripts/smoke-manifest.ts @@ -62,6 +62,7 @@ export const smokeGroups = { tsxSmoke("partial-artifact-discovery-smoke"), tsxSmoke("mounted-workspace-diff-smoke"), tsxSmoke("replay-export-blueprint-smoke"), + tsxSmoke("materialize-replay-package-smoke"), ], }, runtime: { diff --git a/tests/fixtures/runtime-snapshot-small.json b/tests/fixtures/runtime-snapshot-small.json new file mode 100644 index 00000000..7806e242 --- /dev/null +++ b/tests/fixtures/runtime-snapshot-small.json @@ -0,0 +1,51 @@ +{ + "schema": "wp-codebox/wordpress-runtime-snapshot/v1", + "version": 1, + "id": "snapshot-fixture-small", + "createdAt": "2026-06-15T00:00:00.000Z", + "compatibility": { + "backend": "wordpress-playground", + "wordpressVersion": "latest", + "phpVersion": "8.3.31" + }, + "metadata": { + "runtime": { + "id": "runtime-fixture-small", + "backend": "wordpress-playground", + "status": "destroyed", + "createdAt": "2026-06-15T00:00:00.000Z", + "environment": { + "kind": "wordpress", + "name": "runtime-fixture-small", + "version": "latest" + } + }, + "mounts": [], + "mountedInputs": [], + "activeTheme": "twentytwentyfour", + "activePlugins": [], + "wpContentPath": "/wordpress/wp-content" + }, + "database": { + "tables": [] + }, + "files": [ + { + "scope": "wp-content", + "path": "fixture.txt", + "bytes": 5, + "sha256": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "base64": "aGVsbG8=" + } + ], + "hashes": { + "database": { + "algorithm": "sha256", + "value": "database-fixture" + }, + "files": { + "algorithm": "sha256", + "value": "files-fixture" + } + } +} From 647d3f04449cb41305544540eef13d6370c8fa96 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 15 Jun 2026 19:37:58 -0400 Subject: [PATCH 2/3] Add replay package bundle guidance --- README.md | 16 +++- .../src/replayable-wordpress-site-bundle.ts | 91 ++++++++++++++++++- scripts/materialize-replay-package-smoke.ts | 27 +++++- 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 66dabf9d..e4b4e072 100644 --- a/README.md +++ b/README.md @@ -1090,7 +1090,21 @@ Recipes that import a generated site into a clean runtime can export replay evid } ``` -The step writes `files/replay-package/manifest.json`, `blueprint.after.json`, `blueprint.after-notes.json`, and `files/runtime-snapshot.json` under the runtime artifact root. Its stdout is a `wp-codebox/wordpress-replay-export/v1` envelope with `importMs`, `materializeMs`, `snapshotMs`, `exportMs`, `databaseTables`, `wpContentFiles`, `snapshotBytes`, and `blueprintBytes`. The exported `blueprint.after.json` keeps the runtime snapshot as a referenced package file instead of embedding the full snapshot as one large `runPHP` string. +The step writes `files/replay-package/manifest.json`, `blueprint.after.json`, `blueprint.zip`, `blueprint.after-notes.json`, and `files/runtime-snapshot.json` under the runtime artifact root. Its stdout is a `wp-codebox/wordpress-replay-export/v1` envelope with `importMs`, `materializeMs`, `snapshotMs`, `exportMs`, `databaseTables`, `wpContentFiles`, `snapshotBytes`, and `blueprintBytes`. The exported `blueprint.after.json` keeps the runtime snapshot as a referenced package file instead of embedding the full snapshot as one large `runPHP` string. + +Existing `wp-codebox/wordpress-runtime-snapshot/v1` files can be turned into the same replay package without a live Playground runtime or the original recipe-run context: + +```bash +wp-codebox materialize-replay-package \ + --snapshot ./files/runtime-snapshot.json \ + --snapshot-ref artifact:r15/files/runtime-snapshot.json \ + --output ./replay-package \ + --json +``` + +The materializer writes `blueprint.after.json`, `blueprint.zip`, `files/runtime-snapshot.json`, `blueprint.after-notes.json`, and `manifest.json`. The generated notes and manifest record source metadata, including the resolved input snapshot path, optional `--snapshot-ref`, and the `wp-codebox materialize-replay-package` command. + +Replay packages use a bundled resource for `files/runtime-snapshot.json` so the blueprint and snapshot travel together as one local artifact directory. `blueprint.after.json` is the local/package validation artifact. Public WordPress Playground viewer links must use `blueprint.zip`, because Playground resolves bundled resources from a bundle archive with a root `blueprint.json` entry; a plain JSON URL cannot resolve `files/runtime-snapshot.json`. The manifest therefore records `replayableWordPressSite.publicViewerArtifactPath: "blueprint.zip"`. Use URL resources only when the snapshot is already hosted at a stable, browser-fetchable URL; this avoids shipping the snapshot beside the blueprint, but replay then depends on network access, CORS, URL lifetime, and the full snapshot download at restore time. `metadata.json` points to the canonical changed-files, patch, test-results, review, and mount-diff artifact paths under `artifacts`. It also includes `provenance` derived from data WP Codebox already has: task input/context where available, WP Codebox runtime version, WordPress version, mounted component/mount metadata, and agent/provider/model fields passed to the sandbox runner. `files/diffs/.patch` remains available for per-mount detail; `files/patch.diff` is the combined review/apply-back patch surface. diff --git a/packages/runtime-playground/src/replayable-wordpress-site-bundle.ts b/packages/runtime-playground/src/replayable-wordpress-site-bundle.ts index 9a29be14..df0537bd 100644 --- a/packages/runtime-playground/src/replayable-wordpress-site-bundle.ts +++ b/packages/runtime-playground/src/replayable-wordpress-site-bundle.ts @@ -22,6 +22,8 @@ export interface ReplayableWordPressSiteBundleManifest extends ArtifactManifest version: 1 replayableWordPressSite: { blueprintPath: string + playgroundBundlePath?: string + publicViewerArtifactPath?: string snapshotPath: string limitationsPath: string replayStatus: "replayable-runtime-state" @@ -64,6 +66,7 @@ export interface ReplayExportPackage { artifacts: { manifest: "manifest.json" blueprint: "blueprint.after.json" + playgroundBundle: "blueprint.zip" snapshot: "files/runtime-snapshot.json" notes: "blueprint.after-notes.json" } @@ -78,6 +81,7 @@ export async function writeReplayExportPackage(snapshot: RuntimeSnapshotArtifact const snapshotPath = join(filesDirectory, "runtime-snapshot.json") const blueprintPath = join(directory, "blueprint.after.json") + const playgroundBundlePath = join(directory, "blueprint.zip") const notesPath = join(directory, "blueprint.after-notes.json") const manifestPath = join(directory, "manifest.json") const blueprint = buildReplayExportBlueprint(snapshot, options) @@ -93,8 +97,12 @@ export async function writeReplayExportPackage(snapshot: RuntimeSnapshotArtifact await writeJson(blueprintPath, blueprint) await writeJson(snapshotPath, snapshot) await writeJson(notesPath, notes) + await writePlaygroundBlueprintBundle(playgroundBundlePath, [ + { path: "blueprint.json", data: `${JSON.stringify(blueprint, null, 2)}\n` }, + { path: "files/runtime-snapshot.json", data: `${JSON.stringify(snapshot, null, 2)}\n` }, + ]) - const contentInputs = ["blueprint.after.json", "files/runtime-snapshot.json", "blueprint.after-notes.json"] + const contentInputs = ["blueprint.after.json", "blueprint.zip", "files/runtime-snapshot.json", "blueprint.after-notes.json"] const contentDigest = await calculateArtifactContentDigest(directory, contentInputs) const id = options.id ?? `replay-export-package-sha256-${contentDigest}` const manifest: ReplayableWordPressSiteBundleManifest = { @@ -111,11 +119,14 @@ export async function writeReplayExportPackage(snapshot: RuntimeSnapshotArtifact files: [ artifactManifestFile("manifest.json", "manifest", "application/json"), artifactManifestFile("blueprint.after.json", "blueprint-after", "application/json"), + artifactManifestFile("blueprint.zip", "playground-blueprint-bundle", "application/zip"), artifactManifestFile("files/runtime-snapshot.json", "runtime-snapshot", "application/json"), artifactManifestFile("blueprint.after-notes.json", "blueprint-after-notes", "application/json"), ], replayableWordPressSite: { blueprintPath: "blueprint.after.json", + playgroundBundlePath: "blueprint.zip", + publicViewerArtifactPath: "blueprint.zip", snapshotPath: "files/runtime-snapshot.json", limitationsPath: "blueprint.after-notes.json", replayStatus: "replayable-runtime-state", @@ -150,6 +161,7 @@ export async function writeReplayExportPackage(snapshot: RuntimeSnapshotArtifact artifacts: { manifest: "manifest.json", blueprint: "blueprint.after.json", + playgroundBundle: "blueprint.zip", snapshot: "files/runtime-snapshot.json", notes: "blueprint.after-notes.json", }, @@ -326,3 +338,80 @@ function playgroundBlueprintPhpVersion(version: string): string { async function writeJson(path: string, value: unknown): Promise { await writeFile(path, `${JSON.stringify(value, null, 2)}\n`) } + +async function writePlaygroundBlueprintBundle(path: string, entries: Array<{ path: string; data: string }>): Promise { + const records: Buffer[] = [] + const centralDirectoryRecords: Buffer[] = [] + let offset = 0 + + for (const entry of entries) { + const fileName = Buffer.from(entry.path, "utf8") + const data = Buffer.from(entry.data, "utf8") + const crc = crc32(data) + const localHeader = Buffer.alloc(30) + localHeader.writeUInt32LE(0x04034b50, 0) + localHeader.writeUInt16LE(20, 4) + localHeader.writeUInt16LE(0, 6) + localHeader.writeUInt16LE(0, 8) + localHeader.writeUInt16LE(0, 10) + localHeader.writeUInt16LE(0, 12) + localHeader.writeUInt32LE(crc, 14) + localHeader.writeUInt32LE(data.length, 18) + localHeader.writeUInt32LE(data.length, 22) + localHeader.writeUInt16LE(fileName.length, 26) + localHeader.writeUInt16LE(0, 28) + records.push(localHeader, fileName, data) + + const centralDirectoryRecord = Buffer.alloc(46) + centralDirectoryRecord.writeUInt32LE(0x02014b50, 0) + centralDirectoryRecord.writeUInt16LE(20, 4) + centralDirectoryRecord.writeUInt16LE(20, 6) + centralDirectoryRecord.writeUInt16LE(0, 8) + centralDirectoryRecord.writeUInt16LE(0, 10) + centralDirectoryRecord.writeUInt16LE(0, 12) + centralDirectoryRecord.writeUInt16LE(0, 14) + centralDirectoryRecord.writeUInt32LE(crc, 16) + centralDirectoryRecord.writeUInt32LE(data.length, 20) + centralDirectoryRecord.writeUInt32LE(data.length, 24) + centralDirectoryRecord.writeUInt16LE(fileName.length, 28) + centralDirectoryRecord.writeUInt16LE(0, 30) + centralDirectoryRecord.writeUInt16LE(0, 32) + centralDirectoryRecord.writeUInt16LE(0, 34) + centralDirectoryRecord.writeUInt16LE(0, 36) + centralDirectoryRecord.writeUInt32LE(0, 38) + centralDirectoryRecord.writeUInt32LE(offset, 42) + centralDirectoryRecords.push(centralDirectoryRecord, fileName) + + offset += localHeader.length + fileName.length + data.length + } + + const centralDirectoryOffset = offset + const centralDirectorySize = centralDirectoryRecords.reduce((size, record) => size + record.length, 0) + const endOfCentralDirectory = Buffer.alloc(22) + endOfCentralDirectory.writeUInt32LE(0x06054b50, 0) + endOfCentralDirectory.writeUInt16LE(0, 4) + endOfCentralDirectory.writeUInt16LE(0, 6) + endOfCentralDirectory.writeUInt16LE(entries.length, 8) + endOfCentralDirectory.writeUInt16LE(entries.length, 10) + endOfCentralDirectory.writeUInt32LE(centralDirectorySize, 12) + endOfCentralDirectory.writeUInt32LE(centralDirectoryOffset, 16) + endOfCentralDirectory.writeUInt16LE(0, 20) + + await writeFile(path, Buffer.concat([...records, ...centralDirectoryRecords, endOfCentralDirectory])) +} + +function crc32(data: Buffer): number { + let crc = 0xffffffff + for (const byte of data) { + crc = (crc >>> 8) ^ crc32Table[(crc ^ byte) & 0xff] + } + return (crc ^ 0xffffffff) >>> 0 +} + +const crc32Table = Array.from({ length: 256 }, (_, index) => { + let value = index + for (let bit = 0; bit < 8; bit += 1) { + value = (value & 1) === 1 ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1) + } + return value >>> 0 +}) diff --git a/scripts/materialize-replay-package-smoke.ts b/scripts/materialize-replay-package-smoke.ts index 38fd7247..916bac03 100644 --- a/scripts/materialize-replay-package-smoke.ts +++ b/scripts/materialize-replay-package-smoke.ts @@ -25,12 +25,33 @@ try { throw new Error(`materialize-replay-package failed: ${materialize.stderr || materialize.stdout}`) } - for (const relativePath of ["blueprint.after.json", "files/runtime-snapshot.json", "blueprint.after-notes.json", "manifest.json"]) { + for (const relativePath of ["blueprint.after.json", "blueprint.zip", "files/runtime-snapshot.json", "blueprint.after-notes.json", "manifest.json"]) { if (!existsSync(join(outputDirectory, relativePath))) { throw new Error(`Missing materialized file: ${relativePath}`) } } + const zipListing = spawnSync("unzip", ["-Z1", join(outputDirectory, "blueprint.zip")], { encoding: "utf8" }) + if (zipListing.status !== 0) { + throw new Error(`Generated blueprint.zip is not listable: ${zipListing.stderr || zipListing.stdout}`) + } + + const zipEntries = zipListing.stdout.trim().split(/\r?\n/).filter(Boolean).sort() + const expectedZipEntries = ["blueprint.json", "files/runtime-snapshot.json"] + if (JSON.stringify(zipEntries) !== JSON.stringify(expectedZipEntries)) { + throw new Error(`Generated blueprint.zip entries mismatch: expected ${expectedZipEntries.join(", ")}; got ${zipEntries.join(", ")}`) + } + + const zippedBlueprint = spawnSync("unzip", ["-p", join(outputDirectory, "blueprint.zip"), "blueprint.json"], { encoding: "utf8" }) + if (zippedBlueprint.status !== 0) { + throw new Error(`Generated blueprint.zip does not contain root blueprint.json: ${zippedBlueprint.stderr || zippedBlueprint.stdout}`) + } + + const zippedBlueprintJson = JSON.parse(zippedBlueprint.stdout) + if (zippedBlueprintJson.steps?.[0]?.data?.path !== "files/runtime-snapshot.json") { + throw new Error("Root blueprint.json must reference files/runtime-snapshot.json as a bundled resource") + } + const validate = spawnSync(process.execPath, [ "packages/cli/dist/index.js", "validate-blueprint", @@ -61,6 +82,10 @@ try { throw new Error("Replay package manifest must record the input snapshot path") } + if (manifest.replayableWordPressSite?.publicViewerArtifactPath !== "blueprint.zip") { + throw new Error("Replay package manifest must point public viewers at blueprint.zip") + } + console.log("materialize-replay-package-smoke passed") } finally { await rm(root, { recursive: true, force: true }) From d8de43def380cd7c4048a968d408bb33021bdf05 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 21 Jun 2026 09:03:06 -0400 Subject: [PATCH 3/3] Harden browser materialization runtime seams --- .../src/playground-runtime.ts | 26 +++- .../assets/browser-runtime.js | 13 +- .../src/class-wp-codebox-abilities.php | 35 +++++ ...it-wp-codebox-abilities-browser-runner.php | 54 ++++++- .../trait-wp-codebox-abilities-execution.php | 118 ++++++++++++++++ ...-wp-codebox-abilities-provider-adapter.php | 133 +++++++++++++++++- scripts/playground-command-timeout-smoke.ts | 56 ++++++++ scripts/smoke-manifest.ts | 1 + tests/smoke-contained-site-ability.php | 34 +++++ 9 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 scripts/playground-command-timeout-smoke.ts create mode 100644 tests/smoke-contained-site-ability.php diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index 39734015..8019da3b 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -272,7 +272,7 @@ class PlaygroundRuntime implements Runtime { command: spec.command, args: spec.args ?? [], exitCode: 0, - stdout: await executePlaygroundCommand(this, spec, this.hostTools), + stdout: await timeoutPlaygroundCommand(executePlaygroundCommand(this, spec, this.hostTools), spec, abortController), stderr: "", startedAt, finishedAt: now(), @@ -1214,3 +1214,27 @@ function abortable(operation: Promise, signal: AbortSignal | undefined): P }), ]) } + +function timeoutPlaygroundCommand(operation: Promise, spec: ExecutionSpec, abortController: AbortController): Promise { + const timeoutMs = spec.timeoutMs + if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return operation + } + + operation.catch(() => undefined) + let timeout: ReturnType | undefined + return Promise.race([ + operation, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + abortController.abort() + reject(new Error(`Runtime command ${spec.command} exceeded timeoutMs=${Math.round(timeoutMs)}`)) + }, Math.round(timeoutMs)) + timeout.unref() + }), + ]).finally(() => { + if (timeout) { + clearTimeout(timeout) + } + }) +} diff --git a/packages/wordpress-plugin/assets/browser-runtime.js b/packages/wordpress-plugin/assets/browser-runtime.js index 331b09ea..a6d9cee6 100644 --- a/packages/wordpress-plugin/assets/browser-runtime.js +++ b/packages/wordpress-plugin/assets/browser-runtime.js @@ -1319,9 +1319,18 @@ try { }; const runBrowserSessionRecipe = async ( client, session, taskPayload, options = {} ) => { - const recipe = browserSessionRecipe( session ); const payload = taskPayload === undefined ? ( session.task_payload ?? session.task_input ?? {} ) : taskPayload; - return runRecipe( client, recipe, payload, { + const recipe = browserSessionRecipe( session ); + const executableRecipe = payload?.agent_bundles && Array.isArray( payload.agent_bundles ) && payload.agent_bundles.length + ? { + ...recipe, + inputs: { + ...( recipe.inputs && typeof recipe.inputs === 'object' ? recipe.inputs : {} ), + agent_bundles: payload.agent_bundles, + }, + } + : recipe; + return runRecipe( client, executableRecipe, payload, { ...options, name: options.name || 'codebox-browser-session', } ); diff --git a/packages/wordpress-plugin/src/class-wp-codebox-abilities.php b/packages/wordpress-plugin/src/class-wp-codebox-abilities.php index aebabcdc..04a25e69 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-abilities.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-abilities.php @@ -478,6 +478,41 @@ private function register(): void { ) ); + wp_register_ability( + 'wp-codebox/open-or-create-browser-contained-site', + array( + 'label' => 'Open Or Create Browser Contained Site', + 'description' => 'Open a browser-contained site when a reusable prepared runtime is available, or create a fresh browser Playground session from the provided generic session input.', + 'category' => 'wp-codebox', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'contained_site' ), + 'properties' => $browser_session_properties + array( + 'contained_site' => array( + 'type' => 'object', + 'description' => 'Opaque caller-owned contained-site descriptor returned by a prior browser session or product target.', + ), + 'preview_lease' => array( + 'anyOf' => array( + array( 'type' => 'object' ), + array( 'type' => 'null' ), + ), + 'description' => 'Optional caller-owned preview lease metadata to preserve on the returned contained-site envelope.', + ), + 'fallback_create' => array( + 'type' => 'boolean', + 'description' => 'When true, create a fresh browser Playground session if the contained-site descriptor cannot be opened directly.', + 'default' => false, + ), + ), + ), + 'output_schema' => array( 'type' => 'object' ), + 'execute_callback' => array( self::class, 'open_or_create_browser_contained_site' ), + 'permission_callback' => array( self::class, 'can_create_browser_playground_session' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + wp_register_ability( 'wp-codebox/create-browser-materializer-contract', array( diff --git a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-browser-runner.php b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-browser-runner.php index 555a4fc5..27ed9ea7 100644 --- a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-browser-runner.php +++ b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-browser-runner.php @@ -950,11 +950,53 @@ function wp_codebox_browser_input_control_diagnostics( array $input ): array { ) ); } +function wp_codebox_browser_agent_bundle_importer_diagnostics(): array { +global $wp_filter; +$callback_count = 0; +if ( isset( $wp_filter[\'wp_agent_runtime_import_bundle\'] ) && is_object( $wp_filter[\'wp_agent_runtime_import_bundle\'] ) && isset( $wp_filter[\'wp_agent_runtime_import_bundle\']->callbacks ) && is_array( $wp_filter[\'wp_agent_runtime_import_bundle\']->callbacks ) ) { + foreach ( $wp_filter[\'wp_agent_runtime_import_bundle\']->callbacks as $callbacks ) { + $callback_count += is_array( $callbacks ) ? count( $callbacks ) : 0; + } +} + +$candidates = array(); +if ( defined( \'AGENTS_API_PATH\' ) ) { + $candidates[] = trailingslashit( AGENTS_API_PATH ) . \'src/Registry/register-agent-runtime-bundle-importer.php\'; +} +$candidates[] = trailingslashit( WP_PLUGIN_DIR ) . \'agents-api/src/Registry/register-agent-runtime-bundle-importer.php\'; + +return array( + \'agents_api_path_defined\' => defined( \'AGENTS_API_PATH\' ), + \'agents_api_path\' => defined( \'AGENTS_API_PATH\' ) ? (string) AGENTS_API_PATH : \'\', + \'wp_plugin_dir\' => defined( \'WP_PLUGIN_DIR\' ) ? (string) WP_PLUGIN_DIR : \'\', + \'wp_agent_import_runtime_bundles_exists\' => function_exists( \'wp_agent_import_runtime_bundles\' ), + \'wp_agent_runtime_import_bundle_callback_count\' => $callback_count, + \'candidate_importers\' => array_values( array_map( static fn( $path ) => array( + \'path\' => (string) $path, + \'readable\' => is_readable( $path ), + ), $candidates ) ), +); +} + function wp_codebox_browser_import_agent_bundles( array $bundle_specs ): array { if ( empty( $bundle_specs ) ) { return array(); } +if ( ! function_exists( \'wp_agent_import_runtime_bundles\' ) ) { + $agents_api_importers = array(); + if ( defined( \'AGENTS_API_PATH\' ) ) { + $agents_api_importers[] = trailingslashit( AGENTS_API_PATH ) . \'src/Registry/register-agent-runtime-bundle-importer.php\'; + } + $agents_api_importers[] = trailingslashit( WP_PLUGIN_DIR ) . \'agents-api/src/Registry/register-agent-runtime-bundle-importer.php\'; + foreach ( $agents_api_importers as $agents_api_importer ) { + if ( is_readable( $agents_api_importer ) ) { + require_once $agents_api_importer; + break; + } + } +} + if ( function_exists( \'wp_agent_import_runtime_bundles\' ) ) { return wp_agent_import_runtime_bundles( $bundle_specs, array( \'owner_id\' => get_current_user_id() ?: 1 ) ); } @@ -966,7 +1008,7 @@ function wp_codebox_browser_import_agent_bundles( array $bundle_specs ): array { continue; } if ( ! isset( $spec[\'source\'] ) && ! isset( $spec[\'bundle\'] ) ) { - $imports[] = array( \'success\' => false, \'index\' => $index, \'error\' => array( \'code\' => \'agent_bundle_source_missing\', \'message\' => \'Agent bundle spec requires source or bundle.\' ) ); + $imports[] = array( \'success\' => false, \'index\' => $index, \'error\' => array( \'code\' => \'agent_bundle_source_missing\', \'message\' => \'Agent bundle spec requires source or bundle.\', \'data\' => array( \'spec_keys\' => array_values( array_slice( array_map( \'strval\', array_keys( $spec ) ), 0, 12 ) ), \'has_source\' => isset( $spec[\'source\'] ), \'has_bundle\' => isset( $spec[\'bundle\'] ), \'slug\' => is_scalar( $spec[\'slug\'] ?? null ) ? (string) $spec[\'slug\'] : \'\' ) ) ); continue; } @@ -988,7 +1030,7 @@ function wp_codebox_browser_import_agent_bundles( array $bundle_specs ): array { } $result = apply_filters( \'wp_agent_runtime_import_bundle\', null, $spec, $input, $index ); if ( null === $result ) { - $result = new WP_Error( \'wp_codebox_agent_bundle_importer_unavailable\', \'No browser runtime agent bundle importer handled this bundle spec.\', array( \'index\' => $index ) ); + $result = new WP_Error( \'wp_codebox_agent_bundle_importer_unavailable\', \'No browser runtime agent bundle importer handled this bundle spec.\', array( \'index\' => $index, \'diagnostics\' => wp_codebox_browser_agent_bundle_importer_diagnostics() ) ); } $imports[] = is_wp_error( $result ) ? array( \'success\' => false, \'index\' => $index, \'source\' => isset( $input[\'source\'] ) ? $input[\'source\'] : \'inline\', \'error\' => array( \'code\' => $result->get_error_code(), \'message\' => $result->get_error_message(), \'data\' => $result->get_error_data() ) ) @@ -1144,6 +1186,14 @@ function wp_codebox_browser_runtime_tool_callback( array $request, array $payloa $agent = sanitize_key( (string) ( $payload[\'agent\'] ?? \'wp-codebox-sandbox\' ) ); $message = (string) ( $payload[\'message\'] ?? ( $payload[\'task_input\'][\'goal\'] ?? \'\' ) ); +$artifact_contract_schema = (string) ( $wp_codebox_browser_artifact_environment[\'contract\'][\'schema\'] ?? \'\' ); +if ( \'\' !== $artifact_contract_schema && \'\' !== (string) ( $wp_codebox_browser_artifact_environment[\'entrypoint\'] ?? \'\' ) ) { + $artifact_entrypoint_path = rtrim( (string) $wp_codebox_browser_artifact_environment[\'base_path\'], \'/\' ) . \'/\' . ltrim( (string) $wp_codebox_browser_artifact_environment[\'entrypoint\'], \'/\' ); + $message .= "\n\nRequired artifact output:\n"; + $message .= "- Write the final browser-runnable artifact entrypoint to {$artifact_entrypoint_path}.\n"; + $message .= "- Use the filesystem_write tool for this file before finishing.\n"; + $message .= "- The captured artifact schema is {$artifact_contract_schema}."; +} $session_id = (string) ( $payload[\'session_id\'] ?? ' . var_export( $session_id, true ) . ' ); $runtime_user_id = (int) ( $payload[\'user_id\'] ?? ( function_exists( \'get_current_user_id\' ) ? get_current_user_id() : 0 ) ); if ( $runtime_user_id <= 0 ) { diff --git a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-execution.php b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-execution.php index 4ae5cb74..12143f97 100644 --- a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-execution.php +++ b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-execution.php @@ -136,6 +136,124 @@ public static function create_browser_playground_session( array $input ): array| ); } +/** @param array $input Ability input. @return array|WP_Error */ +public static function open_or_create_browser_contained_site( array $input ): array|WP_Error { + $contained_site = is_array( $input['contained_site'] ?? null ) ? $input['contained_site'] : array(); + if ( empty( $contained_site ) ) { + return new WP_Error( 'wp_codebox_browser_contained_site_missing', 'A contained_site descriptor is required.', array( 'status' => 400 ) ); + } + + $fallback_create = true === ( $input['fallback_create'] ?? false ); + if ( ! $fallback_create || '' === trim( (string) ( $input['goal'] ?? '' ) ) ) { + return self::browser_contained_site_open_unavailable( $contained_site, $input, $fallback_create ? 'fallback-create-missing-goal' : 'fallback-create-not-requested' ); + } + + $session = self::create_browser_playground_session( $input ); + if ( is_wp_error( $session ) ) { + return $session; + } + + $session_envelope = is_array( $session['session'] ?? null ) ? $session['session'] : array(); + $playground = is_array( $session['playground'] ?? null ) ? $session['playground'] : array(); + $runtime = is_array( $session['runtime'] ?? null ) ? $session['runtime'] : array(); + $preview_lease = is_array( $input['preview_lease'] ?? null ) ? $input['preview_lease'] : ( is_array( $contained_site['preview_lease'] ?? null ) ? $contained_site['preview_lease'] : array() ); + $preview_boot = array_filter( + array( + 'schema' => 'wp-codebox/browser-preview-boot/v1', + 'session_id' => (string) ( $session_envelope['id'] ?? '' ), + 'client_module_url' => (string) ( $playground['client_module_url'] ?? '' ), + 'remote_url' => (string) ( $playground['remote_url'] ?? '' ), + 'cors_proxy_url' => (string) ( $playground['cors_proxy_url'] ?? '' ), + 'preview_url' => (string) ( $playground['preview_url'] ?? '' ), + 'blueprint' => is_array( $playground['blueprint'] ?? null ) ? $playground['blueprint'] : null, + 'blueprint_ref_dto' => is_array( $contained_site['blueprint_ref'] ?? null ) ? $contained_site['blueprint_ref'] : null, + 'prepared_runtime' => is_array( $playground['prepared_runtime'] ?? null ) ? $playground['prepared_runtime'] : ( is_array( $runtime['prepared_runtime'] ?? null ) ? $runtime['prepared_runtime'] : null ), + 'artifact_base_path' => (string) ( $playground['artifact_base_path'] ?? '' ), + 'artifact_base_url' => (string) ( $playground['artifact_base_url'] ?? '' ), + ), + static fn( mixed $value ): bool => null !== $value && '' !== $value && array() !== $value + ); + + $opened_site = array_filter( + array_merge( + $contained_site, + array( + 'schema' => (string) ( $contained_site['schema'] ?? 'wp-codebox/browser-contained-site/v1' ), + 'status' => true === ( $session['success'] ?? false ) ? 'created' : (string) ( $session['status'] ?? 'blocked' ), + 'session_id' => (string) ( $session_envelope['id'] ?? '' ), + 'preview_boot' => $preview_boot, + 'preview_lease' => $preview_lease, + 'runtime' => $runtime, + ) + ), + static fn( mixed $value ): bool => null !== $value && '' !== $value && array() !== $value + ); + + $open = array( + 'success' => true === ( $session['success'] ?? false ), + 'schema' => 'wp-codebox/browser-contained-site-open/v1', + 'status' => true === ( $session['success'] ?? false ) ? 'created' : (string) ( $session['status'] ?? 'blocked' ), + 'contained_site' => $opened_site, + 'preview_boot' => $preview_boot, + 'preview_lease' => $preview_lease, + 'resolution' => array( + 'schema' => 'wp-codebox/browser-contained-site-resolution/v1', + 'status' => true === ( $session['success'] ?? false ) ? 'created' : (string) ( $session['status'] ?? 'blocked' ), + 'outcome' => true === ( $session['success'] ?? false ) ? 'created' : 'blocked', + 'reused' => false, + 'fallback' => 'created-browser-playground-session', + ), + ); + + return array( + 'success' => $open['success'], + 'schema' => 'wp-codebox/browser-contained-site-open-or-create/v1', + 'status' => $open['status'], + 'action' => 'created', + 'open' => $open, + 'created' => $open, + 'contained_site' => $opened_site, + 'preview_boot' => $preview_boot, + 'preview_lease' => $preview_lease, + 'resolution' => $open['resolution'], + 'session' => $session_envelope, + ); +} + +/** @param array $contained_site Contained site descriptor. @param array $input Ability input. @return array */ +private static function browser_contained_site_open_unavailable( array $contained_site, array $input, string $reason ): array { + $preview_lease = is_array( $input['preview_lease'] ?? null ) ? $input['preview_lease'] : ( is_array( $contained_site['preview_lease'] ?? null ) ? $contained_site['preview_lease'] : array() ); + $resolution = array( + 'schema' => 'wp-codebox/browser-contained-site-resolution/v1', + 'status' => 'unavailable', + 'outcome' => 'unavailable', + 'reused' => false, + 'fallback' => $reason, + ); + $open = array_filter( + array( + 'success' => false, + 'schema' => 'wp-codebox/browser-contained-site-open/v1', + 'status' => 'unavailable', + 'contained_site' => $contained_site, + 'preview_lease' => $preview_lease, + 'resolution' => $resolution, + ), + static fn( mixed $value ): bool => null !== $value && '' !== $value && array() !== $value + ); + + return array( + 'success' => false, + 'schema' => 'wp-codebox/browser-contained-site-open-or-create/v1', + 'status' => 'unavailable', + 'action' => 'unavailable', + 'open' => $open, + 'contained_site' => $contained_site, + 'preview_lease' => $preview_lease, + 'resolution' => $resolution, + ); +} + /** @param array $input Ability input. @return array|WP_Error */ public static function create_browser_materializer_contract( array $input ): array|WP_Error { $session = self::create_browser_playground_session( $input ); diff --git a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-provider-adapter.php b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-provider-adapter.php index 8520fc29..7342f778 100644 --- a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-provider-adapter.php +++ b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-provider-adapter.php @@ -26,7 +26,8 @@ public static function execute_browser_provider_request( array $input ): array|W } $inheritance = $inheritance_payload['inheritance']; - $connector = self::browser_provider_request_connector( $input, $inheritance ); + $raw_connector = self::browser_provider_request_connector_raw( $input, $inheritance ); + $connector = self::redact_provider_metadata( $raw_connector ); if ( empty( $connector ) ) { return new WP_Error( 'wp_codebox_browser_provider_connector_required', 'Browser provider requests require a resolved connector scope.', array( 'status' => 403, 'schema' => 'wp-codebox/browser-provider-error/v1' ) ); } @@ -53,6 +54,9 @@ public static function execute_browser_provider_request( array $input ): array|W * @param array $input Original ability input. */ $response = apply_filters( 'wp_codebox_browser_provider_request', null, $adapter_request, $input ); + if ( null === $response ) { + $response = self::default_browser_provider_request( $adapter_request, $raw_connector ); + } if ( null === $response ) { return new WP_Error( 'wp_codebox_browser_provider_adapter_missing', 'No browser provider adapter handled this connector-scoped request.', array( 'status' => 501, 'schema' => 'wp-codebox/browser-provider-error/v1', 'operation' => $operation, 'provider' => $adapter_request['provider'], 'model' => $adapter_request['model'], 'connector' => $connector ) ); @@ -69,8 +73,133 @@ public static function execute_browser_provider_request( array $input ): array|W return self::normalize_browser_provider_response( $response, $adapter_request ); } + /** @param array $adapter_request Generic redacted provider request. @return array|WP_Error|null */ + private static function default_browser_provider_request( array $adapter_request, array $raw_connector ): array|WP_Error|null { + if ( 'http.request' !== (string) ( $adapter_request['operation'] ?? '' ) ) { + return null; + } + + $request = is_array( $adapter_request['request'] ?? null ) ? $adapter_request['request'] : array(); + $uri = trim( (string) ( $request['uri'] ?? '' ) ); + $scheme = '' !== $uri ? wp_parse_url( $uri, PHP_URL_SCHEME ) : ''; + if ( 'https' !== $scheme ) { + return new WP_Error( 'wp_codebox_browser_provider_uri_invalid', 'Browser provider HTTP requests must use HTTPS URLs.', array( 'status' => 400, 'schema' => 'wp-codebox/browser-provider-error/v1' ) ); + } + + $method = strtoupper( trim( (string) ( $request['method'] ?? 'POST' ) ) ); + $headers = is_array( $request['headers'] ?? null ) ? $request['headers'] : array(); + $headers = array_filter( + array_map( static fn( mixed $value ): string => is_scalar( $value ) ? (string) $value : '', $headers ), + static fn( string $value ): bool => '' !== $value + ); + $authorization_header = (string) ( $headers['Authorization'] ?? $headers['authorization'] ?? '' ); + if ( '' === $authorization_header || str_contains( strtolower( $authorization_header ), '[redacted]' ) ) { + $secret = self::browser_provider_connector_secret( $raw_connector, (string) ( $adapter_request['provider'] ?? '' ) ); + if ( '' === $secret ) { + return new WP_Error( 'wp_codebox_browser_provider_secret_unavailable', 'Browser provider request could not resolve connector credentials.', array( 'status' => 403, 'schema' => 'wp-codebox/browser-provider-error/v1' ) ); + } + + unset( $headers['authorization'] ); + $headers['Authorization'] = 'Bearer ' . $secret; + } + + $body = null; + if ( array_key_exists( 'body', $request ) ) { + if ( is_scalar( $request['body'] ) ) { + $body = (string) $request['body']; + $trimmed_body = trim( $body ); + if ( str_starts_with( $trimmed_body, '{' ) || str_starts_with( $trimmed_body, '[' ) ) { + foreach ( array_keys( $headers ) as $header_name ) { + if ( 'content-type' === strtolower( (string) $header_name ) ) { + unset( $headers[ $header_name ] ); + } + } + $headers['Content-Type'] = 'application/json'; + } + } elseif ( is_array( $request['body'] ) ) { + $encoded_body = wp_json_encode( $request['body'] ); + if ( false === $encoded_body ) { + return new WP_Error( 'wp_codebox_browser_provider_body_invalid', 'Browser provider HTTP request body could not be encoded as JSON.', array( 'status' => 400, 'schema' => 'wp-codebox/browser-provider-error/v1' ) ); + } + + $body = $encoded_body; + foreach ( array_keys( $headers ) as $header_name ) { + if ( 'content-type' === strtolower( (string) $header_name ) ) { + unset( $headers[ $header_name ] ); + } + } + $headers['Content-Type'] = 'application/json'; + } + } + + $response = wp_remote_request( $uri, array( + 'method' => in_array( $method, array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ? $method : 'POST', + 'headers' => $headers, + 'body' => $body, + 'timeout' => 120, + 'redirection' => 0, + ) ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( $response->get_error_code(), $response->get_error_message(), array( 'status' => 502, 'schema' => 'wp-codebox/browser-provider-error/v1' ) ); + } + + return array( + 'response' => array( + 'http' => array( + 'status' => (int) wp_remote_retrieve_response_code( $response ), + 'headers' => wp_remote_retrieve_headers( $response )->getAll(), + 'body' => (string) wp_remote_retrieve_body( $response ), + ), + ), + ); + } + + /** @param array $connector Redacted connector metadata. */ + private static function browser_provider_connector_secret( array $connector, string $provider = '' ): string { + $names = array(); + foreach ( is_array( $connector['secretEnv'] ?? null ) ? $connector['secretEnv'] : array() as $name ) { + $names[] = (string) $name; + } + $credentials = is_array( $connector['credentials'] ?? null ) ? $connector['credentials'] : array(); + foreach ( is_array( $credentials['secrets'] ?? null ) ? $credentials['secrets'] : array() as $secret ) { + if ( is_array( $secret ) && 'available' === (string) ( $secret['status'] ?? '' ) ) { + $names[] = (string) ( $secret['name'] ?? '' ); + } + } + $provider_key = strtoupper( preg_replace( '/[^A-Za-z0-9]+/', '_', trim( $provider ) ) ); + if ( '' !== $provider_key ) { + $names[] = $provider_key . '_API_KEY'; + } + + foreach ( array_values( array_unique( array_filter( $names ) ) ) as $name ) { + if ( 1 !== preg_match( '/^[A-Z_][A-Z0-9_]*$/', $name ) ) { + continue; + } + $value = getenv( $name ); + if ( is_string( $value ) && '' !== trim( $value ) ) { + return trim( $value ); + } + } + + $provider_key = strtolower( preg_replace( '/[^A-Za-z0-9]+/', '_', trim( $provider ) ) ); + if ( '' !== $provider_key && function_exists( 'get_option' ) ) { + $value = get_option( 'connectors_ai_' . $provider_key . '_api_key', '' ); + if ( is_string( $value ) && '' !== trim( $value ) ) { + return trim( $value ); + } + } + + return ''; + } + /** @param array $input Ability input. @param array{connectors:array>,settings:array>} $inheritance @return array */ private static function browser_provider_request_connector( array $input, array $inheritance ): array { + return self::redact_provider_metadata( self::browser_provider_request_connector_raw( $input, $inheritance ) ); + } + + /** @param array $input Ability input. @param array{connectors:array>,settings:array>} $inheritance @return array */ + private static function browser_provider_request_connector_raw( array $input, array $inheritance ): array { $requested_name = trim( (string) ( $input['connector'] ?? '' ) ); foreach ( $inheritance['connectors'] as $connector ) { $name = trim( (string) ( $connector['name'] ?? '' ) ); @@ -82,7 +211,7 @@ private static function browser_provider_request_connector( array $input, array continue; } - return self::redact_provider_metadata( $connector ); + return $connector; } return array(); diff --git a/scripts/playground-command-timeout-smoke.ts b/scripts/playground-command-timeout-smoke.ts new file mode 100644 index 00000000..276d3ba3 --- /dev/null +++ b/scripts/playground-command-timeout-smoke.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict" +import { createRuntime } from "../packages/runtime-core/src/index.js" +import { createPlaygroundRuntimeBackend, type PlaygroundCliModule } from "../packages/runtime-playground/src/index.js" + +let runCalled = false + +const fakeCliModule: PlaygroundCliModule = { + runCLI: async () => ({ + serverUrl: "http://127.0.0.1:9400", + playground: { + run: async () => { + runCalled = true + return await new Promise(() => undefined) + }, + }, + async [Symbol.asyncDispose]() { + return undefined + }, + }), +} + +const runtime = await createRuntime({ + backend: "wordpress-playground", + environment: { kind: "wordpress", name: "timeout-smoke", version: "7.0", blueprint: { steps: [] } }, + policy: { + network: "deny", + filesystem: "sandbox", + commands: ["wordpress.run-php"], + secrets: "none", + approvals: "never", + }, +}, createPlaygroundRuntimeBackend({ cliModule: fakeCliModule })) + +await assert.rejects( + () => runtime.execute({ + command: "wordpress.run-php", + args: ["code=echo 'never';"], + timeoutMs: 25, + }), + (error) => { + assert.ok(error instanceof Error) + assert.match(error.message, /Runtime command wordpress\.run-php exceeded timeoutMs=25/) + return true + }, +) + +assert.equal(runCalled, true) + +const observation = await runtime.observe({ type: "command-result" }) +const commandResult = observation.data as { exitCode?: number; stderr?: string } +assert.equal(commandResult.exitCode, 1) +assert.match(commandResult.stderr ?? "", /timeoutMs=25/) + +await runtime.destroy() + +console.log("playground command timeout smoke passed") diff --git a/scripts/smoke-manifest.ts b/scripts/smoke-manifest.ts index 4989b974..45d7b3bd 100644 --- a/scripts/smoke-manifest.ts +++ b/scripts/smoke-manifest.ts @@ -71,6 +71,7 @@ export const smokeGroups = { tsxSmoke("run-registry-smoke"), tsxSmoke("wordpress-state-contract-smoke"), tsxSmoke("playground-command-errors-smoke"), + tsxSmoke("playground-command-timeout-smoke"), tsxSmoke("replay-export-snapshot-scoping-smoke"), tsxSmoke("composer-backed-source-hydration-smoke"), tsxSmoke("recipe-run-composer-autoload-extra-plugin-smoke"), diff --git a/tests/smoke-contained-site-ability.php b/tests/smoke-contained-site-ability.php new file mode 100644 index 00000000..0fa6b219 --- /dev/null +++ b/tests/smoke-contained-site-ability.php @@ -0,0 +1,34 @@ + 'null' )" ), 'ability-preview-lease-accepts-null' ); +$assert( str_contains( $abilities, "'execute_callback' => array( self::class, 'open_or_create_browser_contained_site' )" ), 'ability-execute-callback-declared' ); +$assert( str_contains( $abilities, "'permission_callback' => array( self::class, 'can_create_browser_playground_session' )" ), 'ability-reuses-browser-session-permission' ); +$assert( str_contains( $execution, 'function open_or_create_browser_contained_site' ), 'ability-callback-implemented' ); +$assert( str_contains( $execution, 'self::create_browser_playground_session( $input )' ), 'ability-uses-browser-session-contract' ); +$assert( str_contains( $execution, "'schema' => 'wp-codebox/browser-contained-site-open-or-create/v1'" ), 'ability-returns-open-or-create-schema' ); +$assert( str_contains( $execution, "'schema' => 'wp-codebox/browser-contained-site-open/v1'" ), 'ability-returns-open-schema' ); +$assert( str_contains( $execution, "'preview_boot'" ) && str_contains( $execution, "'preview_lease'" ), 'ability-returns-preview-boot-and-lease' ); +$assert( str_contains( $execution, "? 'created'" ) && ! str_contains( $execution, "? 'materialized'" ), 'fallback-create-does-not-claim-materialized-site' ); +$assert( str_contains( $execution, 'browser_contained_site_open_unavailable' ), 'ability-has-open-unavailable-path' ); +$assert( str_contains( $execution, "'fallback-create-not-requested'" ) && str_contains( $execution, "'fallback-create-missing-goal'" ), 'ability-does-not-require-create-goal-for-open-miss' ); + +echo "Contained-site ability smoke passed.\n";