Skip to content
Closed
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
78 changes: 78 additions & 0 deletions inc/Abilities/WordPressRuntimeAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace DataMachineCode\Abilities;

use DataMachine\Abilities\PermissionHelper;
use DataMachineCode\Runtime\SandboxRuntimeAgentSandboxRunner;
use DataMachineCode\Runtime\WordPressRuntimeInspector;

defined( 'ABSPATH' ) || exit;
Expand Down Expand Up @@ -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' ) ) {
Expand All @@ -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<string,mixed> $input Input parameters. @return array<string,mixed>|\WP_Error */
public static function runAgentSandbox( array $input ): array|\WP_Error {
return ( new SandboxRuntimeAgentSandboxRunner() )->run( $input );
}
}
215 changes: 215 additions & 0 deletions inc/Runtime/SandboxRuntimeAgentSandboxRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
<?php
/**
* Sandbox Runtime agent sandbox runner.
*
* @package DataMachineCode\Runtime
*/

namespace DataMachineCode\Runtime;

use DataMachineCode\Environment;

defined( 'ABSPATH' ) || exit;

final class SandboxRuntimeAgentSandboxRunner {

private const SCHEMA = 'data-machine-code/run-agent-sandbox/v1';

/** @var array<string, callable> */
private array $callbacks;

/**
* @param array<string, callable> $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<string,mixed> $input Sandbox run input.
* @return array<string,mixed>|\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<string,mixed>|\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 );
}
}
Loading
Loading