diff --git a/README.md b/README.md index d088858..6f4e40b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ For WordPress, this means a control plane such as Studio, Data Machine, or WordP - `@chubes4/sandbox-runtime-core`: backend-agnostic runtime interfaces and shared types. - `@chubes4/sandbox-runtime-playground`: first backend adapter shaped around WordPress Playground. - `@chubes4/sandbox-runtime-cli`: `sandbox-runtime` command for external consumers. +- `packages/wordpress-plugin`: WordPress ability surface for parent sites that launch sandboxed agent tasks. ## CLI @@ -68,6 +69,18 @@ Use `--wp trunk`, `--wp nightly`, or a numeric WordPress version when a mounted The fixture plugin is documented in [`examples/simple-plugin/README.md`](examples/simple-plugin/README.md). +## WordPress Ability Surface + +The WordPress plugin in `packages/wordpress-plugin` registers: + +- `sandbox-runtime/run-agent-task` + +This is the parent-site control-plane surface for frontend/chat integrations. A chat agent can be granted this ability without receiving raw shell or parent-site filesystem access. The ability launches `sandbox-runtime agent-sandbox-run`, which boots a disposable WordPress Playground runtime, mounts the configured agent stack, executes the task, and returns artifact metadata. + +Component paths come from ability input, the `sandbox_runtime_component_paths` option, or the `sandbox_runtime_component_paths` filter. Data Machine Code is the mounted coding-tools component for file-editing agent sandboxes; it provides the workspace/file/GitHub tools inside the sandbox, while Sandbox Runtime owns the parent-site control plane and sandbox lifecycle. + +Apply-back is intentionally separate: sandbox task execution returns artifacts and proposed outputs, while applying changes to the real site should use a distinct reviewed permission path. + ## v0 Runtime Policy `RuntimePolicy` is a portable declaration that every backend receives with `RuntimeCreateSpec`. The core package exposes `validateRuntimePolicy()`, `assertRuntimePolicy()`, and `assertRuntimeCommandAllowed()` so backends and control planes can validate the v0 policy shape before work starts. diff --git a/package.json b/package.json index 7ab3071..f91314f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "tsc -b packages/runtime-core packages/runtime-playground packages/cli", "policy-validation-smoke": "tsx scripts/policy-validation-smoke.ts", "sandbox-runtime": "node packages/cli/dist/index.js", - "check": "npm run build && npm run policy-validation-smoke && npm run sandbox-runtime -- run --mount ./examples/simple-plugin:/wordpress/wp-content/plugins/simple-plugin --command wordpress.run-php --arg code-file=./examples/simple-plugin/probe.php --artifacts ./artifacts --json" + "wordpress-plugin-smoke": "php tests/smoke-wordpress-plugin.php", + "check": "npm run build && npm run policy-validation-smoke && npm run wordpress-plugin-smoke && npm run sandbox-runtime -- run --mount ./examples/simple-plugin:/wordpress/wp-content/plugins/simple-plugin --command wordpress.run-php --arg code-file=./examples/simple-plugin/probe.php --artifacts ./artifacts --json" }, "workspaces": [ "packages/*" diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3000e8d..c0e951c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -37,6 +37,12 @@ interface AgentRuntimeProbeOptions { json: boolean } +interface AgentSandboxRunOptions extends AgentRuntimeProbeOptions { + task: string + code?: string + codeFile?: string +} + const defaultPolicy: RuntimePolicy = { network: "deny", filesystem: "readwrite-mounts", @@ -70,6 +76,23 @@ async function main(args: string[]): Promise { return output.success ? 0 : 1 } + if (command === "agent-sandbox-run") { + const options = parseAgentSandboxRunOptions(args) + const runOptions = await agentSandboxRunOptions(options) + const execute = () => run(runOptions) + + if (!options.json) { + const output = await execute() + printHumanOutput(output) + return output.success ? 0 : 1 + } + + const { result, logs } = await captureStdout(execute) + const output = logs.length > 0 ? { ...result, logs } : result + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`) + return output.success ? 0 : 1 + } + if (command !== "run") { console.error(`Unknown command: ${command}`) printHelp() @@ -93,12 +116,7 @@ async function main(args: string[]): Promise { function agentRuntimeProbeRunOptions(options: AgentRuntimeProbeOptions): RunOptions { return { - mounts: [ - { source: resolve(options.agentsApiPath), target: "/wordpress/wp-content/plugins/agents-api", mode: "readwrite" }, - { source: resolve(options.dataMachinePath), target: "/wordpress/wp-content/plugins/data-machine", mode: "readwrite" }, - { source: resolve(options.dataMachineCodePath), target: "/wordpress/wp-content/plugins/data-machine-code", mode: "readwrite" }, - { source: resolve(options.openaiProviderPath), target: "/wordpress/wp-content/plugins/ai-provider-for-openai", mode: "readwrite" }, - ], + mounts: agentRuntimeMounts(options), command: "wordpress.run-php", args: [`code=${agentRuntimeProbeCode()}`], wpVersion: options.wpVersion ?? "trunk", @@ -107,7 +125,27 @@ function agentRuntimeProbeRunOptions(options: AgentRuntimeProbeOptions): RunOpti } } -function parseAgentRuntimeProbeOptions(args: string[]): AgentRuntimeProbeOptions { +async function agentSandboxRunOptions(options: AgentSandboxRunOptions): Promise { + return { + mounts: agentRuntimeMounts(options), + command: "wordpress.run-php", + args: [`code=${agentSandboxRunCode(options.task, await resolveSandboxTaskCode(options))}`], + wpVersion: options.wpVersion ?? "trunk", + artifactsDirectory: options.artifactsDirectory, + json: options.json, + } +} + +function agentRuntimeMounts(options: AgentRuntimeProbeOptions): RunOptions["mounts"] { + return [ + { source: resolve(options.agentsApiPath), target: "/wordpress/wp-content/plugins/agents-api", mode: "readwrite" }, + { source: resolve(options.dataMachinePath), target: "/wordpress/wp-content/plugins/data-machine", mode: "readwrite" }, + { source: resolve(options.dataMachineCodePath), target: "/wordpress/wp-content/plugins/data-machine-code", mode: "readwrite" }, + { source: resolve(options.openaiProviderPath), target: "/wordpress/wp-content/plugins/ai-provider-for-openai", mode: "readwrite" }, + ] +} + +function parseAgentRuntimeProbeOptions(args: string[], extraOptions: string[] = []): AgentRuntimeProbeOptions { const options: Partial = { json: false } for (let index = 0; index < args.length; index++) { @@ -145,6 +183,9 @@ function parseAgentRuntimeProbeOptions(args: string[]): AgentRuntimeProbeOptions options.artifactsDirectory = value break default: + if (extraOptions.includes(name)) { + break + } throw new Error(`Unknown option: ${name}`) } } @@ -163,6 +204,38 @@ function parseAgentRuntimeProbeOptions(args: string[]): AgentRuntimeProbeOptions return options as AgentRuntimeProbeOptions } +function parseAgentSandboxRunOptions(args: string[]): AgentSandboxRunOptions { + const options = parseAgentRuntimeProbeOptions(args, ["--task", "--code", "--code-file"]) as Partial + + for (let index = 0; index < args.length; index++) { + const arg = args[index] + const [name, inlineValue] = arg.split("=", 2) + const value = inlineValue ?? args[index + 1] + + switch (name) { + case "--task": + options.task = value + break + case "--code": + options.code = value + break + case "--code-file": + options.codeFile = value + break + } + } + + if (!options.task) { + throw new Error("Missing required option: --task") + } + + if (options.code && options.codeFile) { + throw new Error("Use either --code or --code-file, not both") + } + + return options as AgentSandboxRunOptions +} + async function run(options: RunOptions): Promise { let runtime: Awaited> | undefined let execution: ExecutionResult | undefined @@ -352,6 +425,7 @@ function printHelp(): void { console.log(`Usage: sandbox-runtime run --mount : --command [options] sandbox-runtime agent-runtime-probe --agents-api --data-machine --data-machine-code --openai-provider [options] + sandbox-runtime agent-sandbox-run --agents-api --data-machine --data-machine-code --openai-provider --task [options] Options: --mount Mount a host path into the runtime. Repeatable. @@ -368,10 +442,88 @@ Agent runtime probe options: --data-machine-code Local Data Machine Code plugin checkout. --openai-provider Local AI Provider for OpenAI plugin checkout. +Agent sandbox run options: + --task Task description recorded in the sandbox run. + --code Optional PHP body to run after the agent stack boots. + --code-file Optional PHP file to run after the agent stack boots. + Example: sandbox-runtime run --mount ./examples/simple-plugin:/wordpress/wp-content/plugins/simple-plugin --command wordpress.run-php --arg code-file=./examples/simple-plugin/probe.php --artifacts ./artifacts --json`) } +async function resolveSandboxTaskCode(options: AgentSandboxRunOptions): Promise { + if (options.code) { + return options.code + } + + if (options.codeFile) { + return readFile(resolve(options.codeFile), "utf8") + } + + return `echo json_encode(array('task_received' => true), JSON_PRETTY_PRINT);` +} + +function agentSandboxRunCode(task: string, code: string): string { + return ` is_plugin_active($plugin), + 'error' => is_wp_error($result) ? $result->get_error_message() : null, + ); +} + +do_action('plugins_loaded'); +do_action('init'); +do_action('wp_abilities_api_categories_init'); +do_action('wp_abilities_api_init'); + +$sandbox_task = ${JSON.stringify(task)}; +$sandbox_stack = array( + 'plugins' => $activation_results, + 'signals' => array( + 'agents_api_loaded' => defined('AGENTS_API_LOADED'), + 'agents_registry_class' => class_exists('WP_Agents_Registry'), + 'data_machine_version' => defined('DATAMACHINE_VERSION') ? DATAMACHINE_VERSION : null, + 'data_machine_permission_helper' => class_exists('DataMachine\\Abilities\\PermissionHelper'), + 'data_machine_code_version' => defined('DATAMACHINE_CODE_VERSION') ? DATAMACHINE_CODE_VERSION : null, + 'data_machine_code_workspace' => class_exists('DataMachineCode\\Workspace\\Workspace'), + 'openai_provider_plugin_loaded' => function_exists('WordPress\\OpenAiAiProvider\\register_provider'), + ), +); + +ob_start(); +${phpBody(code)} +$sandbox_output = ob_get_clean(); + +echo json_encode( + array( + 'command' => 'agent-sandbox.run', + 'task' => $sandbox_task, + 'wp_loaded' => function_exists('wp_insert_post'), + 'stack' => $sandbox_stack, + 'output' => $sandbox_output, + ), + JSON_PRETTY_PRINT +); +` +} + +function phpBody(code: string): string { + return code.trimStart().replace(/^<\?php\s*/, "") +} + function agentRuntimeProbeCode(): string { return `register(); + self::$registered = true; + } + + private function register(): void { + $register_callback = function (): void { + wp_register_ability( + 'sandbox-runtime/run-agent-task', + array( + 'label' => 'Run Agent Sandbox Task', + 'description' => 'Run a bounded task inside an isolated Sandbox Runtime WordPress agent sandbox and return artifacts.', + 'category' => 'sandbox-runtime', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'task' ), + 'properties' => array( + 'task' => array( + 'type' => 'string', + 'description' => 'Task description to run inside the isolated sandbox.', + ), + 'code' => array( + 'type' => 'string', + 'description' => 'Optional PHP code body to execute after the sandbox agent stack boots.', + ), + 'code_file' => array( + 'type' => 'string', + 'description' => 'Optional PHP file to execute after the sandbox agent stack boots.', + ), + 'wp' => array( + 'type' => 'string', + 'description' => 'WordPress version passed to Playground. Defaults to trunk.', + ), + 'artifacts_path' => array( + 'type' => 'string', + 'description' => 'Directory where Sandbox Runtime should write artifact bundles.', + ), + 'sandbox_runtime_bin' => array( + 'type' => 'string', + 'description' => 'Sandbox Runtime CLI binary or path. JS dist files are run through node.', + ), + 'agents_api_path' => array( 'type' => 'string' ), + 'data_machine_path' => array( 'type' => 'string' ), + 'data_machine_code_path' => array( 'type' => 'string' ), + 'openai_provider_path' => array( 'type' => 'string' ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'schema' => array( 'type' => 'string' ), + 'task' => array( 'type' => 'string' ), + 'wp' => array( 'type' => 'string' ), + 'paths' => array( 'type' => 'object' ), + 'artifacts' => array( 'type' => 'string' ), + 'exit_code' => array( 'type' => 'integer' ), + 'run' => array( 'type' => 'object' ), + ), + ), + 'execute_callback' => array( self::class, 'run_agent_task' ), + 'permission_callback' => array( self::class, 'can_run_agent_task' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + }; + + if ( function_exists( 'doing_action' ) && doing_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + return; + } + + add_action( 'wp_abilities_api_init', $register_callback ); + } + + /** @param array $input Ability input. @return array|WP_Error */ + public static function run_agent_task( array $input ): array|WP_Error { + return ( new Sandbox_Runtime_Agent_Sandbox_Runner() )->run( $input ); + } + + public static function can_run_agent_task(): bool { + $allowed = current_user_can( 'manage_options' ); + + return (bool) apply_filters( 'sandbox_runtime_can_run_agent_task', $allowed ); + } +} diff --git a/packages/wordpress-plugin/src/class-sandbox-runtime-agent-sandbox-runner.php b/packages/wordpress-plugin/src/class-sandbox-runtime-agent-sandbox-runner.php new file mode 100644 index 0000000..65f6e94 --- /dev/null +++ b/packages/wordpress-plugin/src/class-sandbox-runtime-agent-sandbox-runner.php @@ -0,0 +1,258 @@ + */ + private array $callbacks; + + /** + * @param array $callbacks Test seams for pure-PHP smoke coverage. + */ + public function __construct( array $callbacks = array() ) { + $this->callbacks = $callbacks; + } + + /** + * Run a task inside an isolated Sandbox Runtime agent sandbox. + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function run( array $input ): array|WP_Error { + if ( ! $this->shell_available() ) { + return new WP_Error( 'sandbox_runtime_shell_unavailable', 'Shell execution is not available for Sandbox Runtime.', array( 'status' => 500 ) ); + } + + $task = trim( (string) ( $input['task'] ?? '' ) ); + if ( '' === $task ) { + return new WP_Error( 'sandbox_runtime_task_missing', 'task is required.', array( 'status' => 400 ) ); + } + + $paths = $this->resolve_component_paths( $input ); + if ( is_wp_error( $paths ) ) { + return $paths; + } + + $code = trim( (string) ( $input['code'] ?? '' ) ); + $code_file = $this->clean_path( (string) ( $input['code_file'] ?? '' ) ); + if ( '' !== $code && '' !== $code_file ) { + return new WP_Error( 'sandbox_runtime_code_conflict', 'Use either code or code_file, not both.', array( 'status' => 400 ) ); + } + + $artifacts = $this->clean_path( (string) ( $input['artifacts_path'] ?? $this->default_artifacts_path() ) ); + $wp_version = trim( (string) ( $input['wp'] ?? 'trunk' ) ); + if ( '' === $wp_version ) { + $wp_version = 'trunk'; + } + + $bin = trim( (string) ( $input['sandbox_runtime_bin'] ?? $this->default_bin() ) ); + if ( '' === $bin || ! preg_match( '#^[A-Za-z0-9_./:@+-]+$#', $bin ) ) { + return new WP_Error( 'sandbox_runtime_bin_invalid', 'sandbox_runtime_bin must be a command name or path without shell metacharacters.', array( 'status' => 400 ) ); + } + + $command = sprintf( + '%s agent-sandbox-run --agents-api %s --data-machine %s --data-machine-code %s --openai-provider %s --task %s --wp %s --artifacts %s --json', + $this->command_prefix( $bin ), + escapeshellarg( $paths['agents_api'] ), + escapeshellarg( $paths['data_machine'] ), + escapeshellarg( $paths['data_machine_code'] ), + escapeshellarg( $paths['openai_provider'] ), + escapeshellarg( $task ), + escapeshellarg( $wp_version ), + escapeshellarg( $artifacts ) + ); + + if ( '' !== $code ) { + $command .= ' --code ' . escapeshellarg( $code ); + } + + if ( '' !== $code_file ) { + $command .= ' --code-file ' . escapeshellarg( $code_file ); + } + + $result = $this->run_command( $command ); + $exit_code = (int) ( $result['exit_code'] ?? 1 ); + $output = (string) ( $result['output'] ?? '' ); + $decoded = $this->decode_json_output( $output ); + + if ( is_wp_error( $decoded ) ) { + return new WP_Error( + 'sandbox_runtime_json_invalid', + 'Sandbox Runtime did not return valid JSON: ' . $decoded->get_error_message(), + array( + 'status' => 500, + 'exit_code' => $exit_code, + 'output' => $this->bound_output( $output ), + ) + ); + } + + if ( 0 !== $exit_code ) { + return new WP_Error( + 'sandbox_runtime_run_failed', + 'Sandbox Runtime agent sandbox run failed.', + array( + 'status' => 500, + 'exit_code' => $exit_code, + 'output' => $this->bound_output( $output ), + 'run' => $decoded, + ) + ); + } + + return array( + 'success' => true, + 'schema' => self::SCHEMA, + 'task' => $task, + 'wp' => $wp_version, + 'paths' => $paths, + 'artifacts' => $artifacts, + 'exit_code' => $exit_code, + 'run' => $decoded, + ); + } + + /** + * @param array $input Ability input. + * @return array{agents_api:string,data_machine:string,data_machine_code:string,openai_provider:string}|WP_Error + */ + private function resolve_component_paths( array $input ): array|WP_Error { + $configured = $this->configured_paths(); + $paths = array( + 'agents_api' => $this->clean_path( (string) ( $input['agents_api_path'] ?? $configured['agents_api'] ?? '' ) ), + 'data_machine' => $this->clean_path( (string) ( $input['data_machine_path'] ?? $configured['data_machine'] ?? '' ) ), + 'data_machine_code' => $this->clean_path( (string) ( $input['data_machine_code_path'] ?? $configured['data_machine_code'] ?? '' ) ), + 'openai_provider' => $this->clean_path( (string) ( $input['openai_provider_path'] ?? $configured['openai_provider'] ?? '' ) ), + ); + + foreach ( $paths as $key => $path ) { + if ( '' === $path || ! is_dir( $path ) ) { + return new WP_Error( 'sandbox_runtime_component_path_missing', sprintf( 'Sandbox Runtime component path %s is missing or not a directory.', $key ), array( 'status' => 400 ) ); + } + } + + return $paths; + } + + /** @return array */ + private function configured_paths(): array { + $paths = array(); + if ( function_exists( 'get_option' ) ) { + $option = get_option( 'sandbox_runtime_component_paths', array() ); + if ( is_array( $option ) ) { + $paths = $option; + } + } + + if ( function_exists( 'apply_filters' ) ) { + $paths = apply_filters( 'sandbox_runtime_component_paths', $paths ); + } + + return is_array( $paths ) ? $paths : array(); + } + + private function shell_available(): bool { + if ( isset( $this->callbacks['shell_available'] ) ) { + return (bool) ( $this->callbacks['shell_available'] )(); + } + + return function_exists( 'exec' ) && function_exists( 'shell_exec' ); + } + + private function default_artifacts_path(): string { + $base = function_exists( 'wp_upload_dir' ) ? wp_upload_dir() : array( 'basedir' => sys_get_temp_dir() ); + $root = is_array( $base ) && ! empty( $base['basedir'] ) ? (string) $base['basedir'] : sys_get_temp_dir(); + + return rtrim( $root, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'sandbox-runtime' . DIRECTORY_SEPARATOR . $this->generate_run_id(); + } + + private function default_bin(): string { + $bin = 'sandbox-runtime'; + if ( function_exists( 'get_option' ) ) { + $bin = (string) get_option( 'sandbox_runtime_bin', $bin ); + } + + if ( function_exists( 'apply_filters' ) ) { + $bin = (string) apply_filters( 'sandbox_runtime_bin', $bin ); + } + + return $bin; + } + + private function clean_path( string $path ): string { + return rtrim( trim( $path ), DIRECTORY_SEPARATOR ); + } + + private function command_prefix( string $bin ): string { + if ( str_ends_with( $bin, '.js' ) && is_file( $bin ) ) { + return 'node ' . escapeshellarg( $bin ); + } + + return escapeshellarg( $bin ); + } + + private function generate_run_id(): string { + if ( function_exists( 'wp_generate_uuid4' ) ) { + return wp_generate_uuid4(); + } + + return bin2hex( random_bytes( 16 ) ); + } + + /** @return array|WP_Error */ + private function decode_json_output( string $output ): array|WP_Error { + $trimmed = trim( $output ); + if ( '' === $trimmed ) { + return new WP_Error( 'empty_output', 'Empty output.' ); + } + + $decoded = json_decode( $trimmed, true ); + if ( is_array( $decoded ) ) { + return $decoded; + } + + $offset = strrpos( $trimmed, "\n{" ); + if ( false !== $offset ) { + $decoded = json_decode( substr( $trimmed, $offset + 1 ), true ); + if ( is_array( $decoded ) ) { + return $decoded; + } + } + + return new WP_Error( 'json_decode_failed', json_last_error_msg() ); + } + + /** @return array{exit_code:int,output:string} */ + private function run_command( string $command ): array { + if ( isset( $this->callbacks['command_runner'] ) ) { + return ( $this->callbacks['command_runner'] )( $command ); + } + + $output = array(); + $exit = 0; + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Required host-side Sandbox Runtime execution primitive. + exec( $command . ' 2>&1', $output, $exit ); + + return array( + 'exit_code' => $exit, + 'output' => implode( "\n", $output ), + ); + } + + private function bound_output( string $output ): string { + if ( strlen( $output ) <= 4000 ) { + return $output; + } + + return substr( $output, 0, 4000 ); + } +} diff --git a/tests/smoke-wordpress-plugin.php b/tests/smoke-wordpress-plugin.php new file mode 100644 index 0000000..3bd723f --- /dev/null +++ b/tests/smoke-wordpress-plugin.php @@ -0,0 +1,128 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } + } +} + +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; } +} + +$GLOBALS['sandbox_runtime_registered_abilities'] = array(); +$GLOBALS['sandbox_runtime_filters'] = array(); + +function wp_register_ability( string $name, array $definition ): void { + $GLOBALS['sandbox_runtime_registered_abilities'][ $name ] = $definition; +} + +function doing_action( string $hook ): bool { return 'wp_abilities_api_init' === $hook; } +function add_action( string $hook, callable $callback, int $priority = 10 ): void {} +function current_user_can( string $capability ): bool { return 'manage_options' === $capability; } +function apply_filters( string $hook, mixed $value ): mixed { return $GLOBALS['sandbox_runtime_filters'][ $hook ] ?? $value; } +function get_option( string $name, mixed $default = null ): mixed { return $default; } + +require __DIR__ . '/../packages/wordpress-plugin/src/class-sandbox-runtime-agent-sandbox-runner.php'; +require __DIR__ . '/../packages/wordpress-plugin/src/class-sandbox-runtime-abilities.php'; + +$root = sys_get_temp_dir() . '/sandbox-runtime-wordpress-plugin-' . getmypid(); +foreach ( array( 'agents-api', 'data-machine', 'data-machine-code', 'ai-provider-for-openai', 'artifacts' ) as $dir ) { + mkdir( $root . '/' . $dir, 0777, true ); +} +file_put_contents( $root . '/sandbox-runtime.js', "#!/usr/bin/env node\n" ); + +$failures = array(); +$total = 0; +$assert = function ( string $label, bool $condition ) use ( &$failures, &$total ): void { + ++$total; + if ( $condition ) { + echo " ok {$label}\n"; + return; + } + + $failures[] = $label; + echo " fail {$label}\n"; +}; + +echo "Sandbox Runtime WordPress plugin - smoke\n"; + +new Sandbox_Runtime_Abilities(); + +$ability = $GLOBALS['sandbox_runtime_registered_abilities']['sandbox-runtime/run-agent-task'] ?? null; +$assert( 'run-agent-task ability registered', is_array( $ability ) ); +$assert( 'ability is REST visible', true === ( $ability['meta']['show_in_rest'] ?? false ) ); +$assert( 'ability requires task only', array( 'task' ) === ( $ability['input_schema']['required'] ?? array() ) ); +$assert( 'permission defaults to manage_options', true === call_user_func( $ability['permission_callback'] ) ); + +$GLOBALS['sandbox_runtime_filters']['sandbox_runtime_component_paths'] = array( + 'agents_api' => $root . '/agents-api', + 'data_machine' => $root . '/data-machine', + 'data_machine_code' => $root . '/data-machine-code', + 'openai_provider' => $root . '/ai-provider-for-openai', +); +$GLOBALS['sandbox_runtime_filters']['sandbox_runtime_bin'] = $root . '/sandbox-runtime.js'; + +$captured_command = ''; +$runner = new Sandbox_Runtime_Agent_Sandbox_Runner( + array( + 'shell_available' => fn() => true, + 'command_runner' => function ( string $command ) use ( &$captured_command ): array { + $captured_command = $command; + return array( + 'exit_code' => 0, + 'output' => json_encode( + array( + 'success' => true, + 'runtime' => array( 'backend' => 'wordpress-playground' ), + ) + ), + ); + }, + ) +); + +$result = $runner->run( + array( + 'task' => 'Run a chat-requested sandbox task.', + 'artifacts_path' => $root . '/artifacts', + ) +); + +$assert( 'runner succeeds with filter-provided component paths', ! is_wp_error( $result ) && true === ( $result['success'] ?? false ) ); +$assert( 'runner schema is stable', ! is_wp_error( $result ) && 'sandbox-runtime/agent-task-run/v1' === ( $result['schema'] ?? '' ) ); +$assert( 'runner invokes agent-sandbox-run', str_contains( $captured_command, 'agent-sandbox-run' ) ); +$assert( 'runner uses node for JS CLI', str_contains( $captured_command, 'node ' ) ); +$assert( 'runner passes task', str_contains( $captured_command, '--task' ) ); + +$missing_task = $runner->run( array( 'artifacts_path' => $root . '/artifacts' ) ); +$assert( 'missing task fails closed', is_wp_error( $missing_task ) && 'sandbox_runtime_task_missing' === $missing_task->get_error_code() ); + +if ( ! empty( $failures ) ) { + echo "\nFAIL: " . count( $failures ) . " assertion(s) failed out of {$total}\n"; + foreach ( $failures as $failure ) { + echo " - {$failure}\n"; + } + exit( 1 ); +} + +echo "\nOK ({$total} assertions)\n"; +exit( 0 );