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
26 changes: 25 additions & 1 deletion packages/runtime-playground/src/playground-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ class PlaygroundRuntime implements Runtime {
this.activeExecutionAbortControllers.add(abortController)
this.activeExecutionSignal = abortController.signal
try {
const output = await executePlaygroundCommand(this, spec, this.hostTools)
const output = await timeoutPlaygroundCommand(executePlaygroundCommand(this, spec, this.hostTools), spec, abortController)
const envelope = typeof output === "string" ? undefined : output
const result: ExecutionResult = {
id: commandId,
Expand Down Expand Up @@ -1352,3 +1352,27 @@ function abortable<T>(operation: Promise<T>, signal: AbortSignal | undefined): P
}),
])
}

function timeoutPlaygroundCommand<T>(operation: Promise<T>, spec: ExecutionSpec, abortController: AbortController): Promise<T> {
const timeoutMs = spec.timeoutMs
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return operation
}

operation.catch(() => undefined)
let timeout: ReturnType<typeof setTimeout> | undefined
return Promise.race([
operation,
new Promise<T>((_resolve, reject) => {
timeout = setTimeout(() => {
abortController.abort()
reject(new Error(`Runtime command ${spec.command} exceeded timeoutMs=${Math.round(timeoutMs)}`))
}, Math.round(timeoutMs))
timeout.unref()
}),
]).finally(() => {
if (timeout) {
clearTimeout(timeout)
}
})
}
13 changes: 11 additions & 2 deletions packages/wordpress-plugin/assets/browser-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -1319,9 +1319,18 @@ try {
};

const runBrowserSessionRecipe = async ( client, session, taskPayload, options = {} ) => {
const recipe = browserSessionRecipe( session );
const payload = taskPayload === undefined ? ( session.task_payload ?? session.task_input ?? {} ) : taskPayload;
return runRecipe( client, recipe, payload, {
const recipe = browserSessionRecipe( session );
const executableRecipe = payload?.agent_bundles && Array.isArray( payload.agent_bundles ) && payload.agent_bundles.length
? {
...recipe,
inputs: {
...( recipe.inputs && typeof recipe.inputs === 'object' ? recipe.inputs : {} ),
agent_bundles: payload.agent_bundles,
},
}
: recipe;
return runRecipe( client, executableRecipe, payload, {
...options,
name: options.name || 'codebox-browser-session',
} );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,14 +243,24 @@ function wp_codebox_browser_runtime_agent_principal( string $agent, string $sess

function wp_codebox_browser_runtime_prepare_input( array $payload, array $invocation, string $session_id, array $runtime_tool_declarations, array $ability_tools, array $allowed_tool_ids, array $sandbox_tool_ids ): array {
$agent = sanitize_key( (string) ( $payload['agent'] ?? 'wp-codebox-sandbox' ) );
$message = (string) ( $payload['message'] ?? ( $payload['task_input']['goal'] ?? '' ) );
$artifact_environment = function_exists( 'wp_codebox_browser_artifact_environment' ) ? wp_codebox_browser_artifact_environment( $payload ) : array();
$artifact_contract_schema = (string) ( $artifact_environment['contract']['schema'] ?? '' );
if ( '' !== $artifact_contract_schema && '' !== (string) ( $artifact_environment['entrypoint'] ?? '' ) ) {
$artifact_entrypoint_path = rtrim( (string) ( $artifact_environment['base_path'] ?? '/wordpress/wp-content/uploads/wp-codebox/artifacts' ), '/' ) . '/' . ltrim( (string) $artifact_environment['entrypoint'], '/' );
$message .= "\n\nRequired artifact output:\n";
$message .= "- Write the final browser-runnable artifact entrypoint to {$artifact_entrypoint_path}.\n";
$message .= "- Use the filesystem_write tool for this file before finishing.\n";
$message .= "- The captured artifact schema is {$artifact_contract_schema}.";
}
$runtime_user_id = wp_codebox_browser_runtime_user_id( $payload );
if ( function_exists( 'wp_set_current_user' ) ) {
wp_set_current_user( $runtime_user_id );
}

$base_input = array(
'agent' => $agent,
'message' => (string) ( $payload['message'] ?? ( $payload['task_input']['goal'] ?? '' ) ),
'message' => $message,
'user_id' => $runtime_user_id,
'provider' => (string) ( $payload['provider'] ?? ( is_array( $payload['task_input'] ?? null ) ? ( $payload['task_input']['provider'] ?? '' ) : '' ) ),
'model' => (string) ( $payload['model'] ?? ( is_array( $payload['task_input'] ?? null ) ? ( $payload['task_input']['model'] ?? '' ) : '' ) ),
Expand Down Expand Up @@ -292,6 +302,22 @@ function wp_codebox_browser_runtime_import_agent_bundles( array $bundle_specs ):
return array();
}

if ( ! function_exists( 'wp_agent_import_runtime_bundles' ) ) {
$agents_api_importers = array();
if ( defined( 'AGENTS_API_PATH' ) ) {
$agents_api_importers[] = trailingslashit( AGENTS_API_PATH ) . 'src/Registry/register-agent-runtime-bundle-importer.php';
}
if ( defined( 'WP_PLUGIN_DIR' ) ) {
$agents_api_importers[] = trailingslashit( WP_PLUGIN_DIR ) . 'agents-api/src/Registry/register-agent-runtime-bundle-importer.php';
}
foreach ( $agents_api_importers as $agents_api_importer ) {
if ( is_readable( $agents_api_importer ) ) {
require_once $agents_api_importer;
break;
}
}
}

if ( function_exists( 'wp_agent_import_runtime_bundles' ) ) {
return wp_agent_import_runtime_bundles( $bundle_specs, array( 'owner_id' => get_current_user_id() ?: 1 ) );
}
Expand All @@ -303,7 +329,7 @@ function wp_codebox_browser_runtime_import_agent_bundles( array $bundle_specs ):
continue;
}
if ( ! isset( $spec['source'] ) && ! isset( $spec['bundle'] ) ) {
$imports[] = array( 'success' => false, 'index' => $index, 'error' => array( 'code' => 'agent_bundle_source_missing', 'message' => 'Agent bundle spec requires source or bundle.' ) );
$imports[] = array( 'success' => false, 'index' => $index, 'error' => array( 'code' => 'agent_bundle_source_missing', 'message' => 'Agent bundle spec requires source or bundle.', 'data' => array( 'spec_keys' => array_values( array_slice( array_map( 'strval', array_keys( $spec ) ), 0, 12 ) ), 'has_source' => isset( $spec['source'] ), 'has_bundle' => isset( $spec['bundle'] ), 'slug' => is_scalar( $spec['slug'] ?? null ) ? (string) $spec['slug'] : '' ) ) );
continue;
}

Expand All @@ -325,13 +351,43 @@ function wp_codebox_browser_runtime_import_agent_bundles( array $bundle_specs ):
}
$result = apply_filters( 'wp_agent_runtime_import_bundle', null, $spec, $input, $index );
if ( null === $result ) {
$result = new WP_Error( 'wp_codebox_agent_bundle_importer_unavailable', 'No browser runtime agent bundle importer handled this bundle spec.', array( 'index' => $index ) );
$result = new WP_Error( 'wp_codebox_agent_bundle_importer_unavailable', 'No browser runtime agent bundle importer handled this bundle spec.', array( 'index' => $index, 'diagnostics' => wp_codebox_browser_runtime_agent_bundle_importer_diagnostics() ) );
}
$imports[] = is_wp_error( $result )
? array( 'success' => false, 'index' => $index, 'source' => isset( $input['source'] ) ? $input['source'] : 'inline', 'error' => array( 'code' => $result->get_error_code(), 'message' => $result->get_error_message(), 'data' => $result->get_error_data() ) )
: array_merge( array( 'index' => $index, 'source' => isset( $input['source'] ) ? $input['source'] : 'inline' ), is_array( $result ) ? $result : array( 'result' => $result ) );
}

function wp_codebox_browser_runtime_agent_bundle_importer_diagnostics(): array {
global $wp_filter;
$callback_count = 0;
if ( isset( $wp_filter['wp_agent_runtime_import_bundle'] ) && is_object( $wp_filter['wp_agent_runtime_import_bundle'] ) && isset( $wp_filter['wp_agent_runtime_import_bundle']->callbacks ) && is_array( $wp_filter['wp_agent_runtime_import_bundle']->callbacks ) ) {
foreach ( $wp_filter['wp_agent_runtime_import_bundle']->callbacks as $callbacks ) {
$callback_count += is_array( $callbacks ) ? count( $callbacks ) : 0;
}
}

$candidates = array();
if ( defined( 'AGENTS_API_PATH' ) ) {
$candidates[] = trailingslashit( AGENTS_API_PATH ) . 'src/Registry/register-agent-runtime-bundle-importer.php';
}
if ( defined( 'WP_PLUGIN_DIR' ) ) {
$candidates[] = trailingslashit( WP_PLUGIN_DIR ) . 'agents-api/src/Registry/register-agent-runtime-bundle-importer.php';
}

return array(
'agents_api_path_defined' => defined( 'AGENTS_API_PATH' ),
'agents_api_path' => defined( 'AGENTS_API_PATH' ) ? (string) AGENTS_API_PATH : '',
'wp_plugin_dir' => defined( 'WP_PLUGIN_DIR' ) ? (string) WP_PLUGIN_DIR : '',
'wp_agent_import_runtime_bundles_exists' => function_exists( 'wp_agent_import_runtime_bundles' ),
'wp_agent_runtime_import_bundle_callback_count' => $callback_count,
'candidate_importers' => array_values( array_map( static fn( $path ) => array(
'path' => (string) $path,
'readable' => is_readable( $path ),
), $candidates ) ),
);
}

return $imports;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ private static function prepare_http_request( string $provider, array $policy, a
$method = strtoupper( (string) ( $payload['method'] ?? 'POST' ) );
$headers = self::safe_headers( is_array( $payload['headers'] ?? null ) ? $payload['headers'] : array() );
$body = self::request_body( $payload );
if ( self::request_body_is_json( $payload, $body ) ) {
$headers = self::without_header( $headers, 'content-type' );
$headers['Content-Type'] = 'application/json';
}

return self::authenticate_request( $provider, $policy, array( 'url' => $url, 'method' => $method, 'headers' => $headers, 'body' => $body ), $request, $input );
}
Expand Down Expand Up @@ -387,6 +391,16 @@ private static function request_body( array $payload ): string {
return '';
}

/** @param array<string,mixed> $payload Request payload. */
private static function request_body_is_json( array $payload, string $body ): bool {
if ( is_array( $payload['body'] ?? null ) || is_array( $payload['data'] ?? null ) ) {
return true;
}

$trimmed = trim( $body );
return '' !== $trimmed && ( str_starts_with( $trimmed, '{' ) || str_starts_with( $trimmed, '[' ) );
}

/** @param array<string,mixed> $headers Request headers. @return array<string,string> */
private static function safe_headers( array $headers ): array {
$safe = array();
Expand All @@ -408,6 +422,17 @@ private static function safe_headers( array $headers ): array {
return $safe;
}

/** @param array<string,string> $headers Request headers. @return array<string,string> */
private static function without_header( array $headers, string $name ): array {
foreach ( array_keys( $headers ) as $header_name ) {
if ( strtolower( (string) $header_name ) === strtolower( $name ) ) {
unset( $headers[ $header_name ] );
}
}

return $headers;
}

/** @param array<string,array<int,string>|string> $headers Header lists. @return array<string,string> */
private static function flat_headers( array $headers ): array {
$flat = array();
Expand Down
56 changes: 56 additions & 0 deletions scripts/playground-command-timeout-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import assert from "node:assert/strict"
import { createRuntime } from "../packages/runtime-core/src/index.js"
import { createPlaygroundRuntimeBackend, type PlaygroundCliModule } from "../packages/runtime-playground/src/index.js"

let runCalled = false

const fakeCliModule: PlaygroundCliModule = {
runCLI: async () => ({
serverUrl: "http://127.0.0.1:9400",
playground: {
run: async () => {
runCalled = true
return await new Promise<never>(() => undefined)
},
},
async [Symbol.asyncDispose]() {
return undefined
},
}),
}

const runtime = await createRuntime({
backend: "wordpress-playground",
environment: { kind: "wordpress", name: "timeout-smoke", version: "7.0", blueprint: { steps: [] } },
policy: {
network: "deny",
filesystem: "sandbox",
commands: ["wordpress.run-php"],
secrets: "none",
approvals: "never",
},
}, createPlaygroundRuntimeBackend({ cliModule: fakeCliModule }))

await assert.rejects(
() => runtime.execute({
command: "wordpress.run-php",
args: ["code=echo 'never';"],
timeoutMs: 25,
}),
(error) => {
assert.ok(error instanceof Error)
assert.match(error.message, /Runtime command wordpress\.run-php exceeded timeoutMs=25/)
return true
},
)

assert.equal(runCalled, true)

const observation = await runtime.observe({ type: "command-result" })
const commandResult = observation.data as { exitCode?: number; stderr?: string }
assert.equal(commandResult.exitCode, 1)
assert.match(commandResult.stderr ?? "", /timeoutMs=25/)

await runtime.destroy()

console.log("playground command timeout smoke passed")
1 change: 1 addition & 0 deletions scripts/smoke-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const smokeGroups = {
tsxSmoke("wordpress-state-contract-smoke"),
tsxSmoke("playground-command-errors-smoke"),
tsxSmoke("runtime-command-result-envelope-smoke"),
tsxSmoke("playground-command-timeout-smoke"),
tsxSmoke("replay-export-snapshot-scoping-smoke"),
tsxSmoke("runtime-overlay-validation-smoke"),
npmScript("test:runtime-php-snippets"),
Expand Down