diff --git a/inc/Abilities/WordPressRuntimeAbilities.php b/inc/Abilities/WordPressRuntimeAbilities.php index 1295a1c..185db4a 100644 --- a/inc/Abilities/WordPressRuntimeAbilities.php +++ b/inc/Abilities/WordPressRuntimeAbilities.php @@ -8,6 +8,7 @@ namespace DataMachineCode\Abilities; use DataMachine\Abilities\PermissionHelper; +use DataMachineCode\Runtime\SandboxRuntimeAgentSandboxRunner; use DataMachineCode\Runtime\WordPressRuntimeInspector; defined( 'ABSPATH' ) || exit; @@ -145,6 +146,78 @@ private function registerAbilities(): void { 'meta' => array( 'show_in_rest' => true ), ) ); + + wp_register_ability( + 'datamachine-code/run-agent-sandbox', + array( + 'label' => 'Run Agent Sandbox', + 'description' => 'Run a task inside an isolated WordPress Playground sandbox with Agents API, Data Machine, Data Machine Code, and the OpenAI provider mounted.', + 'category' => 'datamachine-code-runtime', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'task', 'agents_api_path', 'data_machine_path', 'openai_provider_path' ), + 'properties' => array( + 'task' => array( + 'type' => 'string', + 'description' => 'Task description to run inside the isolated sandbox.', + ), + 'agents_api_path' => array( + 'type' => 'string', + 'description' => 'Local checkout path for the Agents API plugin.', + ), + 'data_machine_path' => array( + 'type' => 'string', + 'description' => 'Local checkout path for the Data Machine plugin.', + ), + 'data_machine_code_path' => array( + 'type' => 'string', + 'description' => 'Local checkout path for Data Machine Code. Defaults to the active plugin path.', + ), + 'openai_provider_path' => array( + 'type' => 'string', + 'description' => 'Local checkout path for the AI Provider for OpenAI plugin.', + ), + 'sandbox_runtime_bin' => array( + 'type' => 'string', + 'description' => 'Sandbox Runtime CLI binary or path. JS dist files are run through node. Defaults to sandbox-runtime.', + ), + '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.', + ), + 'code' => array( + 'type' => 'string', + 'description' => 'Optional PHP code body to execute inside the sandbox after the agent stack boots.', + ), + 'code_file' => array( + 'type' => 'string', + 'description' => 'Optional PHP file to execute inside the sandbox after the agent stack boots.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'schema' => array( 'type' => 'string' ), + 'command' => 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, 'runAgentSandbox' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); }; if ( function_exists( 'doing_action' ) && doing_action( 'wp_abilities_api_init' ) ) { @@ -169,4 +242,9 @@ public static function ls( array $input ): array|\WP_Error { public static function read( array $input ): array|\WP_Error { return ( new WordPressRuntimeInspector() )->read( $input ); } + + /** @param array $input Input parameters. @return array|\WP_Error */ + public static function runAgentSandbox( array $input ): array|\WP_Error { + return ( new SandboxRuntimeAgentSandboxRunner() )->run( $input ); + } } diff --git a/inc/Runtime/SandboxRuntimeAgentSandboxRunner.php b/inc/Runtime/SandboxRuntimeAgentSandboxRunner.php new file mode 100644 index 0000000..e1873fe --- /dev/null +++ b/inc/Runtime/SandboxRuntimeAgentSandboxRunner.php @@ -0,0 +1,215 @@ + */ + 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 WordPress agent stack. + * + * @param array $input Sandbox run input. + * @return array|\WP_Error + */ + public function run( array $input ): array|\WP_Error { + if ( ! $this->shell_available() ) { + return new \WP_Error( 'datamachine_code_shell_unavailable', 'Shell execution is not available for Sandbox Runtime.', array( 'status' => 500 ) ); + } + + $paths = array( + 'agents_api' => $this->clean_path( (string) ( $input['agents_api_path'] ?? '' ) ), + 'data_machine' => $this->clean_path( (string) ( $input['data_machine_path'] ?? '' ) ), + 'data_machine_code' => $this->clean_path( (string) ( $input['data_machine_code_path'] ?? ( defined( 'DATAMACHINE_CODE_PATH' ) ? DATAMACHINE_CODE_PATH : '' ) ) ), + 'openai_provider' => $this->clean_path( (string) ( $input['openai_provider_path'] ?? '' ) ), + ); + + foreach ( $paths as $key => $path ) { + if ( '' === $path || ! is_dir( $path ) ) { + return new \WP_Error( 'datamachine_code_sandbox_path_missing', sprintf( 'Sandbox Runtime path %s is missing or not a directory.', $key ), array( 'status' => 400 ) ); + } + } + + $task = trim( (string) ( $input['task'] ?? '' ) ); + if ( '' === $task ) { + return new \WP_Error( 'datamachine_code_sandbox_task_missing', 'task is required for Sandbox Runtime agent sandbox runs.', array( 'status' => 400 ) ); + } + + $code = trim( (string) ( $input['code'] ?? '' ) ); + $code_file = $this->clean_path( (string) ( $input['code_file'] ?? '' ) ); + if ( '' !== $code && '' !== $code_file ) { + return new \WP_Error( 'datamachine_code_sandbox_code_conflict', 'Use either code or code_file, not both.', array( 'status' => 400 ) ); + } + + $artifacts = $this->clean_path( (string) ( $input['artifacts_path'] ?? '' ) ); + if ( '' === $artifacts ) { + $artifacts = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'datamachine-code-sandbox-runtime-' . $this->generate_run_id(); + } + + $wp_version = trim( (string) ( $input['wp'] ?? 'trunk' ) ); + if ( '' === $wp_version ) { + $wp_version = 'trunk'; + } + + $bin = trim( (string) ( $input['sandbox_runtime_bin'] ?? 'sandbox-runtime' ) ); + if ( '' === $bin || ! preg_match( '#^[A-Za-z0-9_./:@+-]+$#', $bin ) ) { + return new \WP_Error( 'datamachine_code_sandbox_bin_invalid', 'sandbox_runtime_bin must be a command name or path without shell metacharacters.', array( 'status' => 400 ) ); + } + $command_prefix = $this->command_prefix( $bin ); + + $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', + $command_prefix, + 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( + 'datamachine_code_sandbox_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( + 'datamachine_code_sandbox_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, + 'command' => $command, + 'task' => $task, + 'wp' => $wp_version, + 'paths' => $paths, + 'artifacts' => $artifacts, + 'exit_code' => $exit_code, + 'run' => $decoded, + ); + } + + private function shell_available(): bool { + if ( isset( $this->callbacks['shell_available'] ) ) { + return (bool) ( $this->callbacks['shell_available'] )(); + } + + return Environment::has_shell(); + } + + 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-sandbox-runtime-agent-sandbox-runner.php b/tests/smoke-sandbox-runtime-agent-sandbox-runner.php new file mode 100644 index 0000000..59f83ae --- /dev/null +++ b/tests/smoke-sandbox-runtime-agent-sandbox-runner.php @@ -0,0 +1,183 @@ +code = $code; + $this->message = $message; + $this->data = $data; + } + + public function get_error_code(): string { return $this->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; } + } + + require __DIR__ . '/../inc/Runtime/SandboxRuntimeAgentSandboxRunner.php'; + + use DataMachineCode\Runtime\SandboxRuntimeAgentSandboxRunner; + + $root = sys_get_temp_dir() . '/dmc-sandbox-runtime-agent-sandbox-' . 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 = 0; + $total = 0; + $assert = function ( bool $condition, string $message ) use ( &$failures, &$total ): void { + ++$total; + if ( $condition ) { + echo " ok {$message}\n"; + return; + } + + ++$failures; + echo " fail {$message}\n"; + }; + + echo "Sandbox Runtime agent sandbox runner - smoke\n\n"; + + $captured_command = ''; + $runner = new SandboxRuntimeAgentSandboxRunner( + array( + 'shell_available' => fn() => true, + 'command_runner' => function ( string $command ) use ( &$captured_command ): array { + $captured_command = $command; + return array( + 'exit_code' => 0, + 'output' => "Preparing runtime\n" . json_encode( + array( + 'success' => true, + 'runtime' => array( 'backend' => 'wordpress-playground' ), + 'execution' => array( 'command' => 'agent-sandbox-run' ), + 'artifacts' => array( 'manifest' => 'run.json' ), + ) + ), + ); + }, + ) + ); + + $result = $runner->run( + array( + 'task' => 'Run an isolated coding sandbox task.', + 'agents_api_path' => $root . '/agents-api', + 'data_machine_path' => $root . '/data-machine', + 'data_machine_code_path' => $root . '/data-machine-code', + 'openai_provider_path' => $root . '/ai-provider-for-openai', + 'artifacts_path' => $root . '/artifacts', + 'sandbox_runtime_bin' => 'sandbox-runtime', + 'wp' => 'trunk', + 'code' => 'echo "sandbox ok";', + ) + ); + + $assert( ! is_wp_error( $result ), 'happy path returns a result array' ); + $assert( true === ( $result['success'] ?? false ), 'success flag is included' ); + $assert( 'data-machine-code/run-agent-sandbox/v1' === ( $result['schema'] ?? '' ), 'schema is pinned' ); + $assert( 'Run an isolated coding sandbox task.' === ( $result['task'] ?? '' ), 'task is carried through' ); + $assert( 'trunk' === ( $result['wp'] ?? '' ), 'WordPress version is carried through' ); + $assert( $root . '/artifacts' === ( $result['artifacts'] ?? '' ), 'artifact path is carried through' ); + $assert( true === ( $result['run']['success'] ?? false ), 'CLI JSON output is decoded' ); + $assert( 'wordpress-playground' === ( $result['run']['runtime']['backend'] ?? '' ), 'sandbox runtime metadata is preserved' ); + $assert( str_contains( $captured_command, 'agent-sandbox-run' ), 'command invokes agent-sandbox-run' ); + $assert( str_contains( $captured_command, '--task' ), 'command includes task text' ); + $assert( str_contains( $captured_command, '--code' ), 'command includes optional task code' ); + $assert( str_contains( $captured_command, '--agents-api' ), 'command includes Agents API mount path' ); + $assert( str_contains( $captured_command, '--json' ), 'command requests JSON output' ); + + $runner->run( + array( + 'task' => 'Run with JS CLI.', + 'agents_api_path' => $root . '/agents-api', + 'data_machine_path' => $root . '/data-machine', + 'data_machine_code_path' => $root . '/data-machine-code', + 'openai_provider_path' => $root . '/ai-provider-for-openai', + 'sandbox_runtime_bin' => $root . '/sandbox-runtime.js', + ) + ); + $assert( str_contains( $captured_command, 'node ' ), 'JS CLI path is run through node' ); + + $missing = $runner->run( + array( + 'task' => 'Run missing path test.', + 'agents_api_path' => $root . '/missing', + 'data_machine_path' => $root . '/data-machine', + 'data_machine_code_path' => $root . '/data-machine-code', + 'openai_provider_path' => $root . '/ai-provider-for-openai', + ) + ); + $assert( is_wp_error( $missing ), 'missing plugin path fails closed' ); + $assert( is_wp_error( $missing ) && 'datamachine_code_sandbox_path_missing' === $missing->get_error_code(), 'missing path error code is explicit' ); + + $missing_task = $runner->run( + array( + 'agents_api_path' => $root . '/agents-api', + 'data_machine_path' => $root . '/data-machine', + 'data_machine_code_path' => $root . '/data-machine-code', + 'openai_provider_path' => $root . '/ai-provider-for-openai', + ) + ); + $assert( is_wp_error( $missing_task ), 'missing task fails closed' ); + $assert( is_wp_error( $missing_task ) && 'datamachine_code_sandbox_task_missing' === $missing_task->get_error_code(), 'missing task error code is explicit' ); + + $invalid_bin = $runner->run( + array( + 'task' => 'Run invalid binary test.', + 'agents_api_path' => $root . '/agents-api', + 'data_machine_path' => $root . '/data-machine', + 'data_machine_code_path' => $root . '/data-machine-code', + 'openai_provider_path' => $root . '/ai-provider-for-openai', + 'sandbox_runtime_bin' => 'sandbox-runtime; rm -rf /', + ) + ); + $assert( is_wp_error( $invalid_bin ), 'invalid binary fails closed' ); + $assert( is_wp_error( $invalid_bin ) && 'datamachine_code_sandbox_bin_invalid' === $invalid_bin->get_error_code(), 'invalid binary error code is explicit' ); + + $bad_json = ( new SandboxRuntimeAgentSandboxRunner( + array( + 'shell_available' => fn() => true, + 'command_runner' => fn() => array( 'exit_code' => 0, 'output' => 'not-json' ), + ) + ) )->run( + array( + 'task' => 'Run invalid JSON test.', + 'agents_api_path' => $root . '/agents-api', + 'data_machine_path' => $root . '/data-machine', + 'data_machine_code_path' => $root . '/data-machine-code', + 'openai_provider_path' => $root . '/ai-provider-for-openai', + ) + ); + $assert( is_wp_error( $bad_json ), 'invalid JSON fails closed' ); + $assert( is_wp_error( $bad_json ) && 'datamachine_code_sandbox_json_invalid' === $bad_json->get_error_code(), 'invalid JSON error code is explicit' ); + + if ( $failures > 0 ) { + echo "\nFAIL: {$failures}/{$total} assertion(s) failed\n"; + exit( 1 ); + } + + echo "\nOK ({$total} assertions)\n"; + exit( 0 ); +}