diff --git a/package.json b/package.json index 9390b75..8d23f49 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "runtime-episode-smoke": "tsx scripts/runtime-episode-smoke.ts", "runtime-snapshot-restore-smoke": "tsx scripts/runtime-snapshot-restore-smoke.ts", "runtime-action-adapter-smoke": "tsx scripts/runtime-action-adapter-smoke.ts", + "rest-request-runtime-smoke": "tsx scripts/rest-request-runtime-smoke.ts", "runtime-reference-index-smoke": "tsx scripts/runtime-reference-index-smoke.ts", "core-phpunit-command-smoke": "tsx scripts/core-phpunit-command-smoke.ts", "theme-check-normalization-smoke": "tsx scripts/theme-check-normalization-smoke.ts", diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index 8c49caf..4b65984 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -64,6 +64,22 @@ export const commandRegistry = [ recipe: true, handler: { kind: "playground", method: "runAbility" }, }, + { + id: "wordpress.rest-request", + description: "Execute an in-process WordPress REST request with WP_REST_Request and rest_do_request().", + acceptedArgs: [ + { name: "method", description: "HTTP method for the REST request; defaults to GET.", format: "GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS" }, + { name: "path", description: "REST route path, with or without the /wp-json prefix.", required: true, format: "REST route path" }, + { name: "headers-json", description: "Optional request headers object.", format: "JSON object" }, + { name: "params-json", description: "Optional request parameters object.", format: "JSON object" }, + { name: "body", description: "Optional raw request body.", format: "string" }, + { name: "body-json", description: "Optional JSON request body string; takes precedence over body.", format: "JSON string" }, + ], + outputShape: "JSON object with command, method, path, route, status, headers, and REST response data.", + policyRequirement: "Runtime policy commands must include wordpress.rest-request.", + recipe: true, + handler: { kind: "playground", method: "runRestRequest" }, + }, { id: "wordpress.bench", description: "Run plugin benchmark workloads and emit a normalized benchmark results envelope.", diff --git a/packages/runtime-core/src/runtime-action-adapter.ts b/packages/runtime-core/src/runtime-action-adapter.ts index 7568c7c..50145b4 100644 --- a/packages/runtime-core/src/runtime-action-adapter.ts +++ b/packages/runtime-core/src/runtime-action-adapter.ts @@ -8,7 +8,7 @@ export const RUNTIME_ACTION_OBSERVATION_SCHEMA = "wp-codebox/runtime-action-obse export const SANDBOX_WORKSPACE_ROOT = "/workspace" -export type RuntimeAction = RuntimeWpCliAction | RuntimeFilesystemAction | RuntimeBrowserAction +export type RuntimeAction = RuntimeWpCliAction | RuntimeRestRequestAction | RuntimeFilesystemAction | RuntimeBrowserAction export interface RuntimeWpCliAction { type: "wp_cli" @@ -16,6 +16,17 @@ export interface RuntimeWpCliAction { timeout_ms?: number } +export interface RuntimeRestRequestAction { + type: "rest_request" + method?: string + path: string + headers?: Record + params?: Record + body?: string + body_json?: unknown + timeout_ms?: number +} + export interface RuntimeFilesystemAction { type: "filesystem" operation: "list" | "read" | "write" | "delete" @@ -74,6 +85,10 @@ export async function runRuntimeAction( return runRuntimeWpCliAction(episode, action) } + if (action.type === "rest_request") { + return runRuntimeRestRequestAction(episode, action) + } + if (action.type === "browser") { return runRuntimeBrowserAction(episode, action) } @@ -110,6 +125,61 @@ async function runRuntimeWpCliAction(episode: RuntimeEpisode, action: RuntimeWpC }) } +async function runRuntimeRestRequestAction(episode: RuntimeEpisode, action: RuntimeRestRequestAction): Promise { + const args = [`path=${action.path}`] + if (action.method) { + args.push(`method=${action.method}`) + } + if (action.headers) { + args.push(`headers-json=${JSON.stringify(action.headers)}`) + } + if (action.params) { + args.push(`params-json=${JSON.stringify(action.params)}`) + } + if (action.body_json !== undefined) { + args.push(`body-json=${JSON.stringify(action.body_json)}`) + } else if (action.body !== undefined) { + args.push(`body=${action.body}`) + } + + const step = await episode.step( + { + kind: "http", + command: "wordpress.rest-request", + args, + method: action.method ?? "GET", + path: action.path, + ...(action.timeout_ms !== undefined ? { timeoutMs: action.timeout_ms } : {}), + }, + { type: "command-result" }, + ) + + let stdout: unknown = step.execution.stdout + try { + stdout = JSON.parse(step.execution.stdout) + } catch { + // Keep raw stdout when a backend returns non-JSON diagnostics. + } + + return runtimeActionObservation({ + type: action.type, + action, + step, + data: { + method: action.method ?? "GET", + path: action.path, + mappedCommand: step.execution.command, + args: step.execution.args, + exitCode: step.execution.exitCode, + stdout, + stderr: step.execution.stderr, + executionId: step.execution.id, + stepId: step.id, + }, + artifactRefs: step.observation?.artifactRefs, + }) +} + async function runRuntimeFilesystemAction( episode: RuntimeEpisode, action: RuntimeFilesystemAction, diff --git a/packages/runtime-playground/src/command-router.ts b/packages/runtime-playground/src/command-router.ts index 9aba36e..9bece99 100644 --- a/packages/runtime-playground/src/command-router.ts +++ b/packages/runtime-playground/src/command-router.ts @@ -5,6 +5,7 @@ interface PlaygroundCommandRuntime { inspectMountedInputs(): Promise runPhp(spec: ExecutionSpec): Promise runWpCli(spec: ExecutionSpec): Promise + runRestRequest(spec: ExecutionSpec): Promise runAbility(spec: ExecutionSpec): Promise runBench(spec: ExecutionSpec): Promise runPhpunit(spec: ExecutionSpec): Promise @@ -19,6 +20,7 @@ const playgroundCommandHandlers = { "inspect-mounted-inputs": (runtime) => runtime.inspectMountedInputs(), "wordpress.run-php": (runtime, spec) => runtime.runPhp(spec), "wordpress.wp-cli": (runtime, spec) => runtime.runWpCli(spec), + "wordpress.rest-request": (runtime, spec) => runtime.runRestRequest(spec), "wordpress.ability": (runtime, spec) => runtime.runAbility(spec), "wordpress.bench": (runtime, spec) => runtime.runBench(spec), "wordpress.phpunit": (runtime, spec) => runtime.runPhpunit(spec), diff --git a/packages/runtime-playground/src/commands.ts b/packages/runtime-playground/src/commands.ts index 985e3be..6f5f847 100644 --- a/packages/runtime-playground/src/commands.ts +++ b/packages/runtime-playground/src/commands.ts @@ -3,4 +3,5 @@ export * from "./bench-command-handlers.js" export * from "./check-command-handlers.js" export * from "./command-args.js" export * from "./phpunit-command-handlers.js" +export * from "./rest-request-command-handlers.js" export * from "./wp-cli-command-handlers.js" diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index bcbbc23..ddea4fd 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -13,7 +13,7 @@ import { PlaygroundCommandCrashError, assertPlaygroundResponseOk, errorMessage, import { startPlaygroundCliServer } from "./playground-cli-runner.js" import type { PlaygroundCliServer } from "./preview-server.js" import { collectPlaygroundArtifacts } from "./runtime-artifact-helpers.js" -import { runAbilityCommand, runBenchCommand, runCorePhpunitCommand, runPhpCommand, runPhpunitCommand, runPluginCheckCommand, runThemeCheckCommand } from "./wordpress-command-runners.js" +import { runAbilityCommand, runBenchCommand, runCorePhpunitCommand, runPhpCommand, runPhpunitCommand, runPluginCheckCommand, runRestRequestCommand, runThemeCheckCommand } from "./wordpress-command-runners.js" import { PlaygroundSnapshotRestoreError, contentDigest, mountsFromSnapshot, runtimeSnapshotExportPhp, runtimeSnapshotPayload, runtimeSnapshotRestorePhp, runtimeSpecFromSnapshot, snapshotDigest, type RuntimeSnapshotArtifact } from "./runtime-snapshot.js" import { createRuntimeWpCliBridge, type RuntimeWpCliBridge } from "./runtime-wp-cli-bridge.js" import type { @@ -505,6 +505,16 @@ class PlaygroundRuntime implements Runtime { }) } + async runRestRequest(spec: ExecutionSpec): Promise { + const server = await this.bootPlayground() + return runRestRequestCommand({ + runPlaygroundCommand: (command, targetServer, options) => this.runPlaygroundCommand(command, targetServer, options), + runtimeSpec: this.spec, + server, + spec, + }) + } + async runBench(spec: ExecutionSpec): Promise { const server = await this.bootPlayground() return runBenchCommand({ diff --git a/packages/runtime-playground/src/rest-request-command-handlers.ts b/packages/runtime-playground/src/rest-request-command-handlers.ts new file mode 100644 index 0000000..55b6440 --- /dev/null +++ b/packages/runtime-playground/src/rest-request-command-handlers.ts @@ -0,0 +1,69 @@ +import { argValue, jsonObjectArg } from "./command-args.js" + +export interface RestRequestCommandInput { + method: string + path: string + headers: Record + params: Record + body: string +} + +export function restRequestInputFromArgs(args: string[]): RestRequestCommandInput { + const path = argValue(args, "path")?.trim() || argValue(args, "route")?.trim() + if (!path) { + throw new Error("wordpress.rest-request requires path=") + } + + const bodyJson = argValue(args, "body-json") + const body = bodyJson !== undefined ? bodyJson : (argValue(args, "body") ?? "") + + return { + method: (argValue(args, "method")?.trim() || "GET").toUpperCase(), + path, + headers: jsonObjectArg(args, "headers-json"), + params: jsonObjectArg(args, "params-json"), + body, + } +} + +export function restRequestPhpCode(input: RestRequestCommandInput): string { + return `define( 'REST_REQUEST', true ); +$wp_codebox_method = ${JSON.stringify(input.method)}; +$wp_codebox_path = ${JSON.stringify(input.path)}; +$wp_codebox_headers = json_decode( ${JSON.stringify(JSON.stringify(input.headers))}, true ); +$wp_codebox_params = json_decode( ${JSON.stringify(JSON.stringify(input.params))}, true ); +$wp_codebox_body = ${JSON.stringify(input.body)}; + +if ( ! class_exists( 'WP_REST_Request' ) || ! function_exists( 'rest_do_request' ) ) { + throw new RuntimeException( 'The WordPress REST API is not available in this runtime.' ); +} + +$wp_codebox_route = '/' . ltrim( preg_replace( '#^/wp-json#', '', $wp_codebox_path ), '/' ); +$wp_codebox_request = new WP_REST_Request( $wp_codebox_method, $wp_codebox_route ); + +foreach ( $wp_codebox_headers as $wp_codebox_name => $wp_codebox_value ) { + $wp_codebox_request->set_header( $wp_codebox_name, $wp_codebox_value ); +} + +foreach ( $wp_codebox_params as $wp_codebox_name => $wp_codebox_value ) { + $wp_codebox_request->set_param( $wp_codebox_name, $wp_codebox_value ); +} + +if ( $wp_codebox_body !== '' ) { + $wp_codebox_request->set_body( $wp_codebox_body ); +} + +$wp_codebox_response = rest_do_request( $wp_codebox_request ); +$wp_codebox_server = rest_get_server(); +$wp_codebox_data = $wp_codebox_server->response_to_data( $wp_codebox_response, false ); + +echo wp_json_encode( array( + 'command' => 'wordpress.rest-request', + 'method' => $wp_codebox_method, + 'path' => $wp_codebox_path, + 'route' => $wp_codebox_route, + 'status' => $wp_codebox_response->get_status(), + 'headers' => $wp_codebox_response->get_headers(), + 'data' => $wp_codebox_data, +), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );` +} diff --git a/packages/runtime-playground/src/wordpress-command-runners.ts b/packages/runtime-playground/src/wordpress-command-runners.ts index 3d2f166..b77891a 100644 --- a/packages/runtime-playground/src/wordpress-command-runners.ts +++ b/packages/runtime-playground/src/wordpress-command-runners.ts @@ -19,6 +19,8 @@ import { normalizeThemeCheckOutput, phpunitRunCode, positiveIntegerArg, + restRequestInputFromArgs, + restRequestPhpCode, themeCheckRunCode, } from "./commands.js" import { bootstrapAbilityPhpCode, bootstrapPhpCode, phpCodeFromArgs } from "./php-bootstrap.js" @@ -173,6 +175,23 @@ export async function runAbilityCommand({ return response.text } +export async function runRestRequestCommand({ + runPlaygroundCommand, + runtimeSpec, + server, + spec, +}: { + runPlaygroundCommand: RunPlaygroundCommand + runtimeSpec: RuntimeCreateSpec + server: PlaygroundCliServer + spec: ExecutionSpec +}): Promise { + const input = restRequestInputFromArgs(spec.args ?? []) + const response = await runPlaygroundCommand("wordpress.rest-request", server, { code: bootstrapPhpCode(runtimeSpec, restRequestPhpCode(input), []) }) + assertPlaygroundResponseOk("wordpress.rest-request", response) + return response.text +} + export async function runBenchCommand({ browserProbes, createRuntimeWpCliBridge, diff --git a/scripts/rest-request-runtime-smoke.ts b/scripts/rest-request-runtime-smoke.ts new file mode 100644 index 0000000..55a5f72 --- /dev/null +++ b/scripts/rest-request-runtime-smoke.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + RUNTIME_ACTION_OBSERVATION_SCHEMA, + createRuntimeEpisode, + runRuntimeAction, + validateRuntimeEpisodeTrace, +} from "@chubes4/wp-codebox-core" +import { createPlaygroundRuntimeBackend } from "@chubes4/wp-codebox-playground" + +const tempRoot = await mkdtemp(join(tmpdir(), "wp-codebox-rest-request-")) + +try { + const episode = await createRuntimeEpisode( + { + runtime: { + backend: "wordpress-playground", + environment: { kind: "wordpress", name: "rest-request-runtime-smoke", version: "7.0", blueprint: { steps: [] } }, + policy: { + network: "deny", + filesystem: "readwrite-mounts", + commands: ["wordpress.rest-request"], + secrets: "none", + approvals: "never", + }, + artifactsDirectory: join(tempRoot, "artifacts"), + metadata: { task: { kind: "rest-request-runtime-smoke" } }, + }, + resetObservations: [{ type: "runtime-info" }], + artifactSpec: { includeLogs: true, includeObservations: true }, + }, + createPlaygroundRuntimeBackend(), + ) + + try { + const direct = await episode.step( + { kind: "http", command: "wordpress.rest-request", method: "GET", path: "/wp/v2/types", args: ["method=GET", "path=/wp/v2/types"] }, + { type: "command-result" }, + ) + assert.equal(direct.execution.exitCode, 0) + const directBody = JSON.parse(direct.execution.stdout) as { command: string; status: number; data: Record } + assert.equal(directBody.command, "wordpress.rest-request") + assert.equal(directBody.status, 200) + assert.ok(directBody.data.post) + + const action = await runRuntimeAction(episode, { type: "rest_request", method: "GET", path: "/wp-json/wp/v2/types", params: { context: "view" } }) + assert.equal(action.schema, RUNTIME_ACTION_OBSERVATION_SCHEMA) + assert.equal(action.type, "rest_request") + assert.equal(action.step?.action.kind, "http") + assert.equal(action.step?.execution.command, "wordpress.rest-request") + assert.deepEqual(action.step?.execution.args, ["path=/wp-json/wp/v2/types", "method=GET", 'params-json={"context":"view"}']) + assert.equal((action.data.stdout as { status: number }).status, 200) + + const trace = await episode.trace() + assert.equal(trace.steps.length, 2) + assert.equal(trace.steps.every((step) => step.action.command === "wordpress.rest-request"), true) + assert.equal(validateRuntimeEpisodeTrace(trace).valid, true) + } finally { + await episode.close() + } + + console.log("REST request runtime smoke passed") +} finally { + await rm(tempRoot, { recursive: true, force: true }) +}