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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions packages/runtime-core/src/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
72 changes: 71 additions & 1 deletion packages/runtime-core/src/runtime-action-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@ 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"
command: string
timeout_ms?: number
}

export interface RuntimeRestRequestAction {
type: "rest_request"
method?: string
path: string
headers?: Record<string, unknown>
params?: Record<string, unknown>
body?: string
body_json?: unknown
timeout_ms?: number
}

export interface RuntimeFilesystemAction {
type: "filesystem"
operation: "list" | "read" | "write" | "delete"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -110,6 +125,61 @@ async function runRuntimeWpCliAction(episode: RuntimeEpisode, action: RuntimeWpC
})
}

async function runRuntimeRestRequestAction(episode: RuntimeEpisode, action: RuntimeRestRequestAction): Promise<RuntimeActionObservation> {
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,
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-playground/src/command-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface PlaygroundCommandRuntime {
inspectMountedInputs(): Promise<string>
runPhp(spec: ExecutionSpec): Promise<string>
runWpCli(spec: ExecutionSpec): Promise<string>
runRestRequest(spec: ExecutionSpec): Promise<string>
runAbility(spec: ExecutionSpec): Promise<string>
runBench(spec: ExecutionSpec): Promise<string>
runPhpunit(spec: ExecutionSpec): Promise<string>
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-playground/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
12 changes: 11 additions & 1 deletion packages/runtime-playground/src/playground-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -505,6 +505,16 @@ class PlaygroundRuntime implements Runtime {
})
}

async runRestRequest(spec: ExecutionSpec): Promise<string> {
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<string> {
const server = await this.bootPlayground()
return runBenchCommand({
Expand Down
69 changes: 69 additions & 0 deletions packages/runtime-playground/src/rest-request-command-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { argValue, jsonObjectArg } from "./command-args.js"

export interface RestRequestCommandInput {
method: string
path: string
headers: Record<string, unknown>
params: Record<string, unknown>
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=<rest-route>")
}

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 );`
}
19 changes: 19 additions & 0 deletions packages/runtime-playground/src/wordpress-command-runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
normalizeThemeCheckOutput,
phpunitRunCode,
positiveIntegerArg,
restRequestInputFromArgs,
restRequestPhpCode,
themeCheckRunCode,
} from "./commands.js"
import { bootstrapAbilityPhpCode, bootstrapPhpCode, phpCodeFromArgs } from "./php-bootstrap.js"
Expand Down Expand Up @@ -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<string> {
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,
Expand Down
67 changes: 67 additions & 0 deletions scripts/rest-request-runtime-smoke.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }
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 })
}