diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index e312d7eb..8be7d925 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -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, @@ -1352,3 +1352,27 @@ function abortable(operation: Promise, signal: AbortSignal | undefined): P }), ]) } + +function timeoutPlaygroundCommand(operation: Promise, spec: ExecutionSpec, abortController: AbortController): Promise { + const timeoutMs = spec.timeoutMs + if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return operation + } + + operation.catch(() => undefined) + let timeout: ReturnType | undefined + return Promise.race([ + operation, + new Promise((_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) + } + }) +} diff --git a/packages/wordpress-plugin/assets/browser-runtime.js b/packages/wordpress-plugin/assets/browser-runtime.js index 331b09ea..a6d9cee6 100644 --- a/packages/wordpress-plugin/assets/browser-runtime.js +++ b/packages/wordpress-plugin/assets/browser-runtime.js @@ -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', } ); diff --git a/packages/wordpress-plugin/src/class-wp-codebox-agent-runtime-invoker.php b/packages/wordpress-plugin/src/class-wp-codebox-agent-runtime-invoker.php index 3b2a80ee..2c8d41b3 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-agent-runtime-invoker.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-agent-runtime-invoker.php @@ -243,6 +243,16 @@ 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 ); @@ -250,7 +260,7 @@ function wp_codebox_browser_runtime_prepare_input( array $payload, array $invoca $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'] ?? '' ) : '' ) ), @@ -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 ) ); } @@ -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; } @@ -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; } diff --git a/packages/wordpress-plugin/src/class-wp-codebox-browser-provider-bridge.php b/packages/wordpress-plugin/src/class-wp-codebox-browser-provider-bridge.php index 15d15aed..ba1fe0df 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-browser-provider-bridge.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-browser-provider-bridge.php @@ -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 ); } @@ -387,6 +391,16 @@ private static function request_body( array $payload ): string { return ''; } + /** @param array $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 $headers Request headers. @return array */ private static function safe_headers( array $headers ): array { $safe = array(); @@ -408,6 +422,17 @@ private static function safe_headers( array $headers ): array { return $safe; } + /** @param array $headers Request headers. @return array */ + 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> $headers Header lists. @return array */ private static function flat_headers( array $headers ): array { $flat = array(); diff --git a/scripts/playground-command-timeout-smoke.ts b/scripts/playground-command-timeout-smoke.ts new file mode 100644 index 00000000..276d3ba3 --- /dev/null +++ b/scripts/playground-command-timeout-smoke.ts @@ -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(() => 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") diff --git a/scripts/smoke-manifest.ts b/scripts/smoke-manifest.ts index d66bea0e..35c6c3d3 100644 --- a/scripts/smoke-manifest.ts +++ b/scripts/smoke-manifest.ts @@ -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"),