Skip to content

Commit 9ccc6d2

Browse files
committed
feat: add sandbox runtime agent probe
1 parent f1d953a commit 9ccc6d2

3 files changed

Lines changed: 424 additions & 0 deletions

File tree

inc/Abilities/WordPressRuntimeAbilities.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace DataMachineCode\Abilities;
99

1010
use DataMachine\Abilities\PermissionHelper;
11+
use DataMachineCode\Runtime\SandboxRuntimeAgentProbeRunner;
1112
use DataMachineCode\Runtime\WordPressRuntimeInspector;
1213

1314
defined( 'ABSPATH' ) || exit;
@@ -145,6 +146,65 @@ private function registerAbilities(): void {
145146
'meta' => array( 'show_in_rest' => true ),
146147
)
147148
);
149+
150+
wp_register_ability(
151+
'datamachine-code/sandbox-runtime-agent-probe',
152+
array(
153+
'label' => 'Probe Sandbox Runtime Agent Stack',
154+
'description' => 'Run a WordPress Playground sandbox through the Sandbox Runtime CLI and verify the Agents API, Data Machine, Data Machine Code, and OpenAI provider stack activates cleanly.',
155+
'category' => 'datamachine-code-runtime',
156+
'input_schema' => array(
157+
'type' => 'object',
158+
'required' => array( 'agents_api_path', 'data_machine_path', 'openai_provider_path' ),
159+
'properties' => array(
160+
'agents_api_path' => array(
161+
'type' => 'string',
162+
'description' => 'Local checkout path for the Agents API plugin.',
163+
),
164+
'data_machine_path' => array(
165+
'type' => 'string',
166+
'description' => 'Local checkout path for the Data Machine plugin.',
167+
),
168+
'data_machine_code_path' => array(
169+
'type' => 'string',
170+
'description' => 'Local checkout path for Data Machine Code. Defaults to the active plugin path.',
171+
),
172+
'openai_provider_path' => array(
173+
'type' => 'string',
174+
'description' => 'Local checkout path for the AI Provider for OpenAI plugin.',
175+
),
176+
'sandbox_runtime_bin' => array(
177+
'type' => 'string',
178+
'description' => 'Sandbox Runtime CLI binary or path. JS dist files are run through node. Defaults to sandbox-runtime.',
179+
),
180+
'wp' => array(
181+
'type' => 'string',
182+
'description' => 'WordPress version passed to Playground. Defaults to trunk.',
183+
),
184+
'artifacts_path' => array(
185+
'type' => 'string',
186+
'description' => 'Directory where Sandbox Runtime should write artifact bundles.',
187+
),
188+
),
189+
),
190+
'output_schema' => array(
191+
'type' => 'object',
192+
'properties' => array(
193+
'success' => array( 'type' => 'boolean' ),
194+
'schema' => array( 'type' => 'string' ),
195+
'command' => array( 'type' => 'string' ),
196+
'wp' => array( 'type' => 'string' ),
197+
'paths' => array( 'type' => 'object' ),
198+
'artifacts' => array( 'type' => 'string' ),
199+
'exit_code' => array( 'type' => 'integer' ),
200+
'probe' => array( 'type' => 'object' ),
201+
),
202+
),
203+
'execute_callback' => array( self::class, 'sandboxRuntimeAgentProbe' ),
204+
'permission_callback' => fn() => PermissionHelper::can_manage(),
205+
'meta' => array( 'show_in_rest' => true ),
206+
)
207+
);
148208
};
149209

150210
if ( function_exists( 'doing_action' ) && doing_action( 'wp_abilities_api_init' ) ) {
@@ -169,4 +229,9 @@ public static function ls( array $input ): array|\WP_Error {
169229
public static function read( array $input ): array|\WP_Error {
170230
return ( new WordPressRuntimeInspector() )->read( $input );
171231
}
232+
233+
/** @param array<string,mixed> $input Input parameters. @return array<string,mixed>|\WP_Error */
234+
public static function sandboxRuntimeAgentProbe( array $input ): array|\WP_Error {
235+
return ( new SandboxRuntimeAgentProbeRunner() )->run( $input );
236+
}
172237
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<?php
2+
/**
3+
* Sandbox Runtime agent stack probe runner.
4+
*
5+
* @package DataMachineCode\Runtime
6+
*/
7+
8+
namespace DataMachineCode\Runtime;
9+
10+
use DataMachineCode\Environment;
11+
12+
defined( 'ABSPATH' ) || exit;
13+
14+
final class SandboxRuntimeAgentProbeRunner {
15+
16+
private const SCHEMA = 'data-machine-code/sandbox-runtime-agent-probe/v1';
17+
18+
/** @var array<string, callable> */
19+
private array $callbacks;
20+
21+
/**
22+
* @param array<string, callable> $callbacks Test seams for pure-PHP smoke coverage.
23+
*/
24+
public function __construct( array $callbacks = array() ) {
25+
$this->callbacks = $callbacks;
26+
}
27+
28+
/**
29+
* Run the Sandbox Runtime WordPress agent stack probe.
30+
*
31+
* @param array<string,mixed> $input Probe input.
32+
* @return array<string,mixed>|\WP_Error
33+
*/
34+
public function run( array $input ): array|\WP_Error {
35+
if ( ! $this->shell_available() ) {
36+
return new \WP_Error( 'datamachine_code_shell_unavailable', 'Shell execution is not available for Sandbox Runtime.', array( 'status' => 500 ) );
37+
}
38+
39+
$paths = array(
40+
'agents_api' => $this->clean_path( (string) ( $input['agents_api_path'] ?? '' ) ),
41+
'data_machine' => $this->clean_path( (string) ( $input['data_machine_path'] ?? '' ) ),
42+
'data_machine_code' => $this->clean_path( (string) ( $input['data_machine_code_path'] ?? ( defined( 'DATAMACHINE_CODE_PATH' ) ? DATAMACHINE_CODE_PATH : '' ) ) ),
43+
'openai_provider' => $this->clean_path( (string) ( $input['openai_provider_path'] ?? '' ) ),
44+
);
45+
46+
foreach ( $paths as $key => $path ) {
47+
if ( '' === $path || ! is_dir( $path ) ) {
48+
return new \WP_Error( 'datamachine_code_probe_path_missing', sprintf( 'Sandbox Runtime probe path %s is missing or not a directory.', $key ), array( 'status' => 400 ) );
49+
}
50+
}
51+
52+
$artifacts = $this->clean_path( (string) ( $input['artifacts_path'] ?? '' ) );
53+
if ( '' === $artifacts ) {
54+
$artifacts = rtrim( sys_get_temp_dir(), DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . 'datamachine-code-sandbox-runtime-' . $this->generate_run_id();
55+
}
56+
57+
$wp_version = trim( (string) ( $input['wp'] ?? 'trunk' ) );
58+
if ( '' === $wp_version ) {
59+
$wp_version = 'trunk';
60+
}
61+
62+
$bin = trim( (string) ( $input['sandbox_runtime_bin'] ?? 'sandbox-runtime' ) );
63+
if ( '' === $bin || ! preg_match( '#^[A-Za-z0-9_./:@+-]+$#', $bin ) ) {
64+
return new \WP_Error( 'datamachine_code_probe_bin_invalid', 'sandbox_runtime_bin must be a command name or path without shell metacharacters.', array( 'status' => 400 ) );
65+
}
66+
$command_prefix = $this->command_prefix( $bin );
67+
68+
$command = sprintf(
69+
'%s agent-runtime-probe --agents-api %s --data-machine %s --data-machine-code %s --openai-provider %s --wp %s --artifacts %s --json',
70+
$command_prefix,
71+
escapeshellarg( $paths['agents_api'] ),
72+
escapeshellarg( $paths['data_machine'] ),
73+
escapeshellarg( $paths['data_machine_code'] ),
74+
escapeshellarg( $paths['openai_provider'] ),
75+
escapeshellarg( $wp_version ),
76+
escapeshellarg( $artifacts )
77+
);
78+
79+
$result = $this->run_command( $command );
80+
$exit_code = (int) ( $result['exit_code'] ?? 1 );
81+
$output = (string) ( $result['output'] ?? '' );
82+
$decoded = $this->decode_json_output( $output );
83+
84+
if ( is_wp_error( $decoded ) ) {
85+
return new \WP_Error(
86+
'datamachine_code_probe_json_invalid',
87+
'Sandbox Runtime probe did not return valid JSON: ' . $decoded->get_error_message(),
88+
array(
89+
'status' => 500,
90+
'exit_code' => $exit_code,
91+
'output' => $this->bound_output( $output ),
92+
)
93+
);
94+
}
95+
96+
if ( 0 !== $exit_code ) {
97+
return new \WP_Error(
98+
'datamachine_code_probe_failed',
99+
'Sandbox Runtime probe failed.',
100+
array(
101+
'status' => 500,
102+
'exit_code' => $exit_code,
103+
'output' => $this->bound_output( $output ),
104+
'probe' => $decoded,
105+
)
106+
);
107+
}
108+
109+
return array(
110+
'success' => true,
111+
'schema' => self::SCHEMA,
112+
'command' => $command,
113+
'wp' => $wp_version,
114+
'paths' => $paths,
115+
'artifacts' => $artifacts,
116+
'exit_code' => $exit_code,
117+
'probe' => $decoded,
118+
);
119+
}
120+
121+
private function shell_available(): bool {
122+
if ( isset( $this->callbacks['shell_available'] ) ) {
123+
return (bool) ( $this->callbacks['shell_available'] )();
124+
}
125+
126+
return Environment::has_shell();
127+
}
128+
129+
private function clean_path( string $path ): string {
130+
return rtrim( trim( $path ), DIRECTORY_SEPARATOR );
131+
}
132+
133+
private function command_prefix( string $bin ): string {
134+
if ( str_ends_with( $bin, '.js' ) && is_file( $bin ) ) {
135+
return 'node ' . escapeshellarg( $bin );
136+
}
137+
138+
return escapeshellarg( $bin );
139+
}
140+
141+
private function generate_run_id(): string {
142+
if ( function_exists( 'wp_generate_uuid4' ) ) {
143+
return \wp_generate_uuid4();
144+
}
145+
146+
return bin2hex( random_bytes( 16 ) );
147+
}
148+
149+
/** @return array<string,mixed>|\WP_Error */
150+
private function decode_json_output( string $output ): array|\WP_Error {
151+
$trimmed = trim( $output );
152+
if ( '' === $trimmed ) {
153+
return new \WP_Error( 'empty_output', 'Empty output.' );
154+
}
155+
156+
$decoded = json_decode( $trimmed, true );
157+
if ( is_array( $decoded ) ) {
158+
return $decoded;
159+
}
160+
161+
$offset = strrpos( $trimmed, "\n{" );
162+
if ( false !== $offset ) {
163+
$decoded = json_decode( substr( $trimmed, $offset + 1 ), true );
164+
if ( is_array( $decoded ) ) {
165+
return $decoded;
166+
}
167+
}
168+
169+
return new \WP_Error( 'json_decode_failed', json_last_error_msg() );
170+
}
171+
172+
/** @return array{exit_code:int,output:string} */
173+
private function run_command( string $command ): array {
174+
if ( isset( $this->callbacks['command_runner'] ) ) {
175+
return ( $this->callbacks['command_runner'] )( $command );
176+
}
177+
178+
$output = array();
179+
$exit = 0;
180+
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Required host-side Sandbox Runtime execution primitive.
181+
exec( $command . ' 2>&1', $output, $exit );
182+
183+
return array(
184+
'exit_code' => $exit,
185+
'output' => implode( "\n", $output ),
186+
);
187+
}
188+
189+
private function bound_output( string $output ): string {
190+
if ( strlen( $output ) <= 4000 ) {
191+
return $output;
192+
}
193+
194+
return substr( $output, 0, 4000 );
195+
}
196+
}

0 commit comments

Comments
 (0)