diff --git a/docs/sandbox-session-contract.md b/docs/sandbox-session-contract.md index 34346a941..0d49d3229 100644 --- a/docs/sandbox-session-contract.md +++ b/docs/sandbox-session-contract.md @@ -52,6 +52,16 @@ registers runtime-principal authorization. If the runner is copied into a normal host WordPress install, it fails with `wp_codebox_browser_runner_not_playground` instead of executing the requested sandbox invocation. +Callers that only need an editable contained-site preview can set +`preview_only: true`. WP Codebox still compiles caller-declared runtime plugins, +MU plugins, themes, bootstrap operations, and site blueprint artifacts and +returns the normal product DTO, preview boot descriptor, readiness, contained +site metadata, and hydratable prepared-runtime blueprint ref. This mode does not +resolve agent/provider inheritance and does not append a task runner, captures, +or staged task payload. Provider plugins are included only when the caller +declares them as ordinary `browser_plugins` or `runtime.plugins` dependencies. +Browser task and materializer contract creation remains agentic. + ## Browser Contained Site Handle Browser session, materializer, and task contracts include an additive durable diff --git a/package.json b/package.json index 129227ed9..f60a24764 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "test:php-artifact-import-idempotency": "php scripts/php-artifact-import-idempotency-smoke.php", "test:php-browser-runtime-local-package": "php scripts/php-browser-runtime-local-package-smoke.php", "test:php-browser-runtime-agent-substrate": "php scripts/php-browser-runtime-agent-substrate-smoke.php", + "test:php-browser-preview-only-session": "php -d zend.assertions=1 -d assert.exception=1 scripts/php-browser-preview-only-session-smoke.php", "test:mcp-client-configs": "tsx tests/mcp-client-configs.test.ts", "test:runtime-tool-policy": "tsx tests/runtime-tool-policy.test.ts", "test:primitive-contract-parity": "tsx tests/primitive-contract-parity.test.ts", diff --git a/packages/wordpress-plugin/src/class-wp-codebox-browser-ability-descriptors.php b/packages/wordpress-plugin/src/class-wp-codebox-browser-ability-descriptors.php index a678cd5ae..5886c023f 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-browser-ability-descriptors.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-browser-ability-descriptors.php @@ -80,7 +80,7 @@ public static function descriptors( array $context ): array { ), 'wp-codebox/create-browser-playground-session' => array( 'label' => 'Create Browser Sandbox Session', - 'description' => 'Prepare a WP Codebox browser sandbox session without requiring the host to run the WP Codebox CLI or Node.', + 'description' => 'Prepare a WP Codebox browser sandbox session without requiring the host to run the WP Codebox CLI or Node. Set preview_only to prepare an editable runtime without an agent task runner.', 'category' => 'wp-codebox', 'input_schema' => array( 'type' => 'object', diff --git a/packages/wordpress-plugin/src/class-wp-codebox-browser-contained-site-service.php b/packages/wordpress-plugin/src/class-wp-codebox-browser-contained-site-service.php index 38a5823b1..66b0f55c5 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-browser-contained-site-service.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-browser-contained-site-service.php @@ -390,10 +390,12 @@ public function destroy_browser_contained_site_session( array $input ): array|WP public function blocked_browser_playground_session( string $session_id, array $input, array $task_input, array $ready_to_code, array $browser_plugins, array $runtime, array $artifacts, array $playground, array $blueprint, array $site_blueprint_artifact ): array { $prepared_runtime = is_array( $runtime['prepared_runtime'] ?? null ) ? $runtime['prepared_runtime'] : array(); $contained_site = $this->browser_contained_site_envelope( $input, $session_id, $playground, $runtime, $prepared_runtime, 'blocked' ); + $preview_only = true === ( $input['preview_only'] ?? false ); - return array( + $session = array( 'success' => false, 'schema' => 'wp-codebox/browser-playground-session/v1', + 'preview_only' => $preview_only, 'execution' => 'browser-playground', 'execution_scope' => 'disposable-playground', 'permission_model' => 'runtime-principal', @@ -406,12 +408,12 @@ public function blocked_browser_playground_session( string $session_id, array $i 'session' => $this->browser_session_envelope( $session_id, 'blocked', $input ), 'task' => (string) $task_input['goal'], 'task_input' => $task_input, - 'agent' => (string) ( $input['agent'] ?? 'wp-codebox-sandbox' ), + 'agent' => $preview_only ? '' : (string) ( $input['agent'] ?? 'wp-codebox-sandbox' ), 'plugins' => $browser_plugins, 'runtime' => $runtime, 'contained_site' => $contained_site, 'site_blueprint_artifact' => $site_blueprint_artifact, - 'materialization' => array( + 'materialization' => $preview_only ? array() : array( 'schema' => 'wp-codebox/browser-materialization/v1', 'status' => 'blocked', 'captures' => array(), @@ -445,6 +447,11 @@ public function blocked_browser_playground_session( string $session_id, array $i 'expected_artifacts' => $task_input['expected_artifacts'], ), ); + if ( $preview_only ) { + unset( $session['agent'], $session['materialization'] ); + } + + return $session; } public function browser_session_response_for_input( array $session, array $input ): array|WP_Error { diff --git a/packages/wordpress-plugin/src/class-wp-codebox-browser-task-builder.php b/packages/wordpress-plugin/src/class-wp-codebox-browser-task-builder.php index d206e636c..047de9316 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-browser-task-builder.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-browser-task-builder.php @@ -290,6 +290,7 @@ public static function product_browser_session_dto( array $session ): array { 'dto_schema' => 'wp-codebox/browser-preview-boot-config/v1', 'source_schema' => (string) ( $session['schema'] ?? '' ), 'success' => (bool) ( $session['success'] ?? false ), + 'preview_only' => true === ( $session['preview_only'] ?? false ), 'status' => (string) ( $session['status'] ?? ( true === ( $session['success'] ?? false ) ? 'ready' : '' ) ), 'execution' => (string) ( $session['execution'] ?? '' ), 'execution_scope' => (string) ( $session['execution_scope'] ?? '' ), diff --git a/packages/wordpress-plugin/src/class-wp-codebox-browser-task-contract-service.php b/packages/wordpress-plugin/src/class-wp-codebox-browser-task-contract-service.php index 0d18179e8..9344780c7 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-browser-task-contract-service.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-browser-task-contract-service.php @@ -25,6 +25,7 @@ public function __call( string $name, array $args ): mixed { /** @param array $input Ability input. @return array|WP_Error */ public function create_browser_materializer_contract( array $input ): array|WP_Error { $return_raw = $this->include_raw_browser_contract( $input, 'materializer' ); + $input['preview_only'] = false; $input['include_internal_browser_session'] = true; $session = $this->create_browser_playground_session( $input ); if ( is_wp_error( $session ) ) { @@ -111,6 +112,7 @@ private function include_raw_browser_contract( array $input, string $contract ): /** @param array $input Ability input. @return array|WP_Error */ private function prepare_browser_task_contract( array $input ): array|WP_Error { + $input['preview_only'] = false; $input['include_internal_browser_session'] = true; $primary = $this->create_browser_playground_session( $input ); if ( is_wp_error( $primary ) ) { diff --git a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-execution.php b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-execution.php index 488fceaef..62edb4470 100644 --- a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-execution.php +++ b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-execution.php @@ -145,6 +145,7 @@ public static function request_host_delegation( array $input ): array|WP_Error { /** @param array $input Ability input. @return array|WP_Error */ public static function create_browser_playground_session( array $input ): array|WP_Error { + $preview_only = true === ( $input['preview_only'] ?? false ); $input = WP_Codebox_Browser_Task_Builder::local_browser_task_input( $input ); $task_input = self::normalize_task_input( $input ); if ( is_wp_error( $task_input ) ) { @@ -161,21 +162,29 @@ public static function create_browser_playground_session( array $input ): array| return $playground; } - $inheritance_payload = self::browser_inheritance_resolution_payload( $input ); - if ( is_wp_error( $inheritance_payload ) ) { - return $inheritance_payload; - } - $input = self::browser_input_with_inheritance( $input, $inheritance_payload['inheritance'] ); - if ( is_wp_error( $input ) ) { - return $input; + $inheritance_payload = array( 'inheritance' => array( 'connectors' => array(), 'settings' => array() ) ); + $dependency_plan = null; + if ( ! $preview_only ) { + $inheritance_payload = self::browser_inheritance_resolution_payload( $input ); + if ( is_wp_error( $inheritance_payload ) ) { + return $inheritance_payload; + } + $input = self::browser_input_with_inheritance( $input, $inheritance_payload['inheritance'] ); + if ( is_wp_error( $input ) ) { + return $input; + } + $dependency_plan = self::browser_runtime_dependency_plan( $input, $inheritance_payload['inheritance'] ); } - $dependency_plan = self::browser_runtime_dependency_plan( $input, $inheritance_payload['inheritance'] ); $browser_runner = is_array( $input['browser_runner'] ?? null ) ? $input['browser_runner'] : array(); $browser_plugins = self::browser_plugins( $input ); if ( is_wp_error( $browser_plugins ) ) { return $browser_plugins; } - $runtime = self::browser_runtime_dependencies( $input, $browser_plugins, $dependency_plan ); + $runtime_input = $input; + if ( $preview_only ) { + unset( $runtime_input['browser_runner'], $runtime_input['provider_plugin_paths'], $runtime_input['inherit'], $runtime_input['secret_env'], $runtime_input['runtime_requirements'] ); + } + $runtime = self::browser_runtime_dependencies( $runtime_input, $browser_plugins, $dependency_plan ); if ( is_wp_error( $runtime ) ) { return $runtime; } @@ -196,43 +205,50 @@ public static function create_browser_playground_session( array $input ): array| if ( is_wp_error( $artifacts ) ) { return $artifacts; } - $ready_to_code = self::browser_ready_to_code_signal( $input, $runtime ); + $ready_to_code = self::browser_ready_to_code_signal( $runtime_input, $runtime ); if ( false === ( $ready_to_code['emitted'] ?? false ) ) { $blocked_session = self::blocked_browser_playground_session( $session_id, $input, $task_input, $ready_to_code, $browser_plugins, $runtime, $artifacts, $playground, $blueprint, $site_blueprint_artifact ); return self::browser_session_response_for_input( $blocked_session, $input ); } - $task_payload = self::browser_task_payload( $input, $task_input, $session_id, $artifacts, $inheritance_payload['inheritance'], $dependency_plan ); - $recipe = self::browser_agent_recipe( $task_input, $session_id, $browser_runner, $blueprint, $playground, $task_payload ); - if ( is_wp_error( $recipe ) ) { - return $recipe; + $task_payload = array(); + $recipe = array(); + $materialization = array(); + $recipe_blueprint = self::browser_playground_blueprint( self::browser_selected_prepared_runtime_blueprint( $prepared_runtime, $blueprint ), $playground ); + if ( ! $preview_only ) { + $task_payload = self::browser_task_payload( $input, $task_input, $session_id, $artifacts, $inheritance_payload['inheritance'], $dependency_plan ); + $recipe = self::browser_agent_recipe( $task_input, $session_id, $browser_runner, $blueprint, $playground, $task_payload ); + if ( is_wp_error( $recipe ) ) { + return $recipe; + } + $recipe_blueprint = is_array( $recipe['runtime']['blueprint'] ?? null ) ? $recipe['runtime']['blueprint'] : $blueprint; + $materialization = self::browser_materialization_contract( $recipe ); } - $recipe_blueprint = is_array( $recipe['runtime']['blueprint'] ?? null ) ? $recipe['runtime']['blueprint'] : $blueprint; if ( is_array( $runtime['prepared_runtime'] ?? null ) ) { self::browser_prepared_runtime_cache_store( $runtime['prepared_runtime'], $recipe_blueprint ); } $blueprint = $recipe_blueprint; - $materialization = self::browser_materialization_contract( $recipe ); $session = array( 'success' => true, 'schema' => 'wp-codebox/browser-playground-session/v1', + 'preview_only' => $preview_only, 'execution' => 'browser-playground', 'execution_scope' => 'disposable-playground', 'permission_model' => 'runtime-principal', 'session' => self::browser_session_envelope( $session_id, 'ready', $input ), 'task' => (string) $task_input['goal'], 'task_input' => $task_input, - 'task_payload' => $task_payload, - 'agent' => (string) ( $input['agent'] ?? 'wp-codebox-sandbox' ), - 'provider' => self::browser_provider( $input, $inheritance_payload['inheritance'] ), - 'model' => self::browser_model( $input, $inheritance_payload['inheritance'] ), - 'inheritance' => $inheritance_payload['inheritance'], + 'task_payload' => $preview_only ? array() : $task_payload, + 'agent' => $preview_only ? '' : (string) ( $input['agent'] ?? 'wp-codebox-sandbox' ), + 'provider' => $preview_only ? '' : self::browser_provider( $input, $inheritance_payload['inheritance'] ), + 'model' => $preview_only ? '' : self::browser_model( $input, $inheritance_payload['inheritance'] ), + 'inheritance' => $preview_only ? array() : $inheritance_payload['inheritance'], 'plugins' => $browser_plugins, 'runtime' => $runtime, 'contained_site' => $contained_site, 'site_blueprint_artifact' => $site_blueprint_artifact, - 'materialization' => $materialization, + 'materialization' => $preview_only ? array() : $materialization, 'runtime_capabilities' => array_values( array_unique( array_filter( @@ -263,7 +279,7 @@ public static function create_browser_playground_session( array $input ): array| ), 'provenance' => $playground['provenance'], ), - 'recipe' => $recipe, + 'recipe' => $preview_only ? array() : $recipe, 'signals' => array( 'ready_to_code' => $ready_to_code, ), @@ -274,6 +290,9 @@ public static function create_browser_playground_session( array $input ): array| 'expected_artifacts' => $task_input['expected_artifacts'], ), ); + if ( $preview_only ) { + unset( $session['task_payload'], $session['agent'], $session['provider'], $session['model'], $session['inheritance'], $session['materialization'], $session['recipe'] ); + } return self::browser_session_response_for_input( $session, $input ); } diff --git a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-schemas.php b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-schemas.php index 3120d3e81..df85562d8 100644 --- a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-schemas.php +++ b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-schemas.php @@ -287,6 +287,7 @@ private static function browser_playground_session_schema(): array { 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean' ), + 'preview_only' => array( 'type' => 'boolean' ), 'schema' => array( 'type' => 'string' ), 'execution' => array( 'type' => 'string', @@ -419,6 +420,7 @@ private static function browser_product_dto_schema(): array { 'description' => 'Compact product-facing browser task/session DTO for durable product records and REST responses. It exposes stable ids, preview refs, artifact refs, status, and compact diagnostics while omitting raw playground, runtime, recipe, task payload, sandbox path, and implementation diagnostic contracts.', 'properties' => array( 'success' => array( 'type' => 'boolean' ), + 'preview_only' => array( 'type' => 'boolean' ), 'schema' => array( 'type' => 'string' ), 'dto_schema' => array( 'type' => 'string' ), 'source_schema' => array( 'type' => 'string' ), @@ -1019,7 +1021,7 @@ private static function component_contracts_schema( string $description = '' ): * @return array */ private static function browser_task_input_properties( array $task_input_schema, array $inherit_schema, array $session_input, bool $detailed = false ): array { - return self::task_input_alias_properties( $task_input_schema ) + array( + $properties = self::task_input_alias_properties( $task_input_schema ) + array( 'agent_workload' => self::agent_workload_schema(), 'workload' => self::agent_workload_schema(), 'agent' => self::string_property_schema( $detailed ? 'Codebox agent runtime id for the browser sandbox.' : '' ), @@ -1050,6 +1052,15 @@ private static function browser_task_input_properties( array $task_input_schema, 'description' => 'Optional debug fields for diagnostics. Public callers receive the stable product DTO envelope.', ), ); + if ( $detailed ) { + $properties['preview_only'] = array( + 'type' => 'boolean', + 'default' => false, + 'description' => 'When true, prepare an editable browser preview runtime without resolving agent/provider inheritance or appending the task runner recipe.', + ); + } + + return $properties; } /** @return array */ diff --git a/scripts/php-browser-preview-only-session-smoke.php b/scripts/php-browser-preview-only-session-smoke.php new file mode 100644 index 000000000..4d247a3fe --- /dev/null +++ b/scripts/php-browser-preview-only-session-smoke.php @@ -0,0 +1,185 @@ + $data */ + public function __construct( private string $code = '', private string $message = '', private array $data = array() ) {} + public function get_error_code(): string { return $this->code; } + public function get_error_message(): string { return $this->message; } + /** @return array */ + public function get_error_data(): array { return $this->data; } +} + +function is_wp_error( mixed $value ): bool { return $value instanceof WP_Error; } +function plugin_dir_path( string $file ): string { return rtrim( dirname( $file ), '/' ) . '/'; } +function plugin_dir_url( string $file ): string { unset( $file ); return 'https://example.test/wp-content/plugins/wp-codebox/'; } +function add_action( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): void { unset( $hook, $callback, $priority, $accepted_args ); } +function add_filter( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): void { unset( $hook, $callback, $priority, $accepted_args ); } +function apply_filters( string $hook, mixed $value, mixed ...$args ): mixed { + if ( 'wp_codebox_resolve_inheritance' === $hook ) { + return array( + 'connectors' => array( + array( + 'name' => 'preview-provider', + 'status' => 'resolved', + 'provider' => 'inherited-provider', + 'model' => 'inherited-model', + ), + ), + 'settings' => array(), + ); + } + unset( $args ); + return $value; +} +function sanitize_key( string $value ): string { return strtolower( preg_replace( '/[^a-zA-Z0-9_-]/', '', $value ) ?? '' ); } +function sanitize_text_field( string $value ): string { return trim( $value ); } +function wp_json_encode( mixed $value, int $flags = 0, int $depth = 512 ): string|false { return json_encode( $value, $flags, $depth ); } +function wp_parse_url( string $url, int $component = -1 ): mixed { return -1 === $component ? parse_url( $url ) : parse_url( $url, $component ); } +function wp_create_nonce( string|int $action = -1 ): string { unset( $action ); return 'preview-only-test'; } +function wp_normalize_path( string $path ): string { return str_replace( '\\', '/', $path ); } +function wp_generate_uuid4(): string { return '00000000-0000-4000-8000-000000001743'; } +function get_transient( string $key ): mixed { return $GLOBALS['wp_codebox_test_transients'][ $key ] ?? false; } +function set_transient( string $key, mixed $value, int $expiration = 0 ): bool { unset( $expiration ); $GLOBALS['wp_codebox_test_transients'][ $key ] = $value; return true; } + +function fail( string $message ): void { + fwrite( STDERR, $message . PHP_EOL ); + exit( 1 ); +} + +function expect( bool $condition, string $message ): void { + if ( ! $condition ) { + fail( $message ); + } +} + +/** @param array> $steps */ +function has_runner_step( array $steps ): bool { + foreach ( $steps as $step ) { + if ( 'runPHP' === ( $step['step'] ?? '' ) && str_contains( (string) ( $step['code'] ?? '' ), 'WP_CODEBOX_BROWSER_RUNNER_BODY_START' ) ) { + return true; + } + } + return false; +} + +require __DIR__ . '/../packages/wordpress-plugin/wp-codebox.php'; + +$task_input_schema_method = new ReflectionMethod( WP_Codebox_Abilities::class, 'task_input_schema' ); +$inherit_schema_method = new ReflectionMethod( WP_Codebox_Abilities::class, 'inherit_schema' ); +$session_input_method = new ReflectionMethod( WP_Codebox_Abilities::class, 'sandbox_session_input_schema' ); +$browser_input_method = new ReflectionMethod( WP_Codebox_Abilities::class, 'browser_task_input_properties' ); +$task_input_schema = $task_input_schema_method->invoke( null ); +$inherit_schema = $inherit_schema_method->invoke( null ); +$session_input = $session_input_method->invoke( null ); +$session_properties = $browser_input_method->invoke( null, $task_input_schema, $inherit_schema, $session_input, true ); +$task_contract_properties = $browser_input_method->invoke( null, $task_input_schema, $inherit_schema, $session_input, false ); + +expect( 'boolean' === ( $session_properties['preview_only']['type'] ?? '' ), 'Browser session input schema must explicitly declare preview_only as a boolean.' ); +expect( false === ( $session_properties['preview_only']['default'] ?? null ), 'Browser session input schema must default preview_only to false.' ); +expect( ! isset( $task_contract_properties['preview_only'] ), 'Browser task contract input schema must remain agentic.' ); + +$input = array( + 'goal' => 'Prepare an editable theme preview.', + 'sandbox_session_id' => 'preview-only-session', + 'inherit' => array( 'connectors' => array( 'preview-provider' ) ), + 'runtime_requirements' => array( 'requires_provider' => false ), + 'runtime' => array( + 'plugins' => array( + array( + 'slug' => 'caller-runtime-plugin', + 'url' => 'https://downloads.wordpress.org/plugin/caller-runtime-plugin.zip', + 'package' => 'browser', + 'activate' => true, + ), + ), + 'mu_plugins' => array( + array( + 'slug' => 'preview-bootstrap', + 'file' => 'preview-bootstrap.php', + 'content' => ' array( + array( + 'slug' => 'preview-theme', + 'files' => array( + array( 'path' => 'style.css', 'content' => '/* Theme Name: Preview Theme */' ), + array( 'path' => 'index.php', 'content' => ' array( + array( 'operation' => 'set_option', 'args' => array( 'name' => 'blogname', 'value' => 'Preview Only' ) ), + ), + 'prepared' => array( + 'enabled' => true, + 'cache' => true, + 'cache_key' => 'preview-only-runtime', + ), + ), + 'site_blueprint_artifact' => array( + 'schema' => 'wp-codebox/site-blueprint-artifact/v1', + 'id' => 'preview-site-blueprint', + 'blueprint' => array( + 'steps' => array( array( 'step' => 'setSiteOptions', 'options' => array( 'description' => 'Site artifact applied' ) ) ), + ), + ), + 'include_internal_browser_session' => true, +); + +$preview = WP_Codebox_Abilities::create_browser_playground_session( $input + array( 'preview_only' => true ) ); +if ( is_wp_error( $preview ) ) { + fail( 'preview failed: ' . $preview->get_error_code() . ': ' . $preview->get_error_message() ); +} +$hydrated = WP_Codebox_Abilities::hydrate_browser_blueprint_ref( array( 'ref' => $preview['product']['preview_boot']['blueprint_ref'] ?? '' ) ); +$agentic = WP_Codebox_Abilities::create_browser_playground_session( $input ); +$task = WP_Codebox_Abilities::create_browser_task_contract( $input + array( 'preview_only' => true, 'include_internal_browser_contract' => true ) ); + +foreach ( array( 'hydrated' => $hydrated, 'agentic' => $agentic, 'task' => $task ) as $name => $result ) { + if ( is_wp_error( $result ) ) { + fail( $name . ' failed: ' . $result->get_error_code() . ': ' . $result->get_error_message() ); + } +} + +$preview_steps = is_array( $preview['playground']['blueprint']['steps'] ?? null ) ? $preview['playground']['blueprint']['steps'] : array(); +$agentic_steps = is_array( $agentic['playground']['blueprint']['steps'] ?? null ) ? $agentic['playground']['blueprint']['steps'] : array(); +$task_steps = is_array( $task['primary']['playground']['blueprint']['steps'] ?? null ) ? $task['primary']['playground']['blueprint']['steps'] : array(); +$preview_kinds = array_column( $preview_steps, 'step' ); + +expect( true === ( $preview['preview_only'] ?? false ), 'Expected raw preview session to audit preview_only=true.' ); +expect( true === ( $preview['product']['preview_only'] ?? false ), 'Expected product DTO to audit preview_only=true.' ); +expect( ! isset( $preview['agent'], $preview['provider'], $preview['model'], $preview['inheritance'] ), 'Preview-only must omit agent/provider inheritance fields.' ); +expect( ! isset( $preview['task_payload'], $preview['recipe'], $preview['materialization'] ), 'Preview-only must omit task payload, recipe, and materialization contracts.' ); +expect( ! has_runner_step( $preview_steps ), 'Preview-only boot blueprint must not contain the generated runner runPHP step.' ); +expect( in_array( 'installPlugin', $preview_kinds, true ), 'Preview-only boot blueprint must install the caller runtime plugin.' ); +expect( in_array( 'setSiteOptions', $preview_kinds, true ), 'Preview-only boot blueprint must retain the site blueprint artifact.' ); +expect( in_array( 'runPHP', $preview_kinds, true ), 'Preview-only boot blueprint must retain caller bootstrap operations.' ); +expect( str_contains( wp_json_encode( $preview_steps ) ?: '', 'preview-bootstrap.php' ), 'Preview-only boot blueprint must install caller MU plugin content.' ); +expect( str_contains( wp_json_encode( $preview_steps ) ?: '', 'preview-theme' ), 'Preview-only boot blueprint must install caller theme files.' ); +expect( array( 'caller-runtime-plugin' ) === array_column( $preview['runtime']['plugins'] ?? array(), 'slug' ), 'Preview-only runtime must contain only the caller-declared plugin.' ); +expect( array( 'preview-bootstrap' ) === array_column( $preview['runtime']['mu_plugins'] ?? array(), 'slug' ), 'Preview-only runtime must retain caller MU plugins.' ); +expect( array( 'preview-theme' ) === array_column( $preview['runtime']['themes'] ?? array(), 'slug' ), 'Preview-only runtime must retain caller themes.' ); +expect( 'ready' === ( $preview['product']['runtime_readiness']['status'] ?? '' ), 'Preview-only product DTO must remain runtime-ready.' ); +expect( true === ( $preview['product']['preview_boot']['blueprint_ref_dto']['hydratable'] ?? false ), 'Preview-only product DTO must expose a hydratable blueprint ref.' ); + +expect( ! is_wp_error( $hydrated ), 'Preview-only prepared runtime blueprint ref must hydrate.' ); +expect( $preview['playground']['blueprint'] === ( $hydrated['blueprint'] ?? null ), 'Preview-only boot blueprint must be the cached executable runtime blueprint.' ); + +expect( false === ( $agentic['preview_only'] ?? true ), 'Default browser session must remain agentic.' ); +expect( 'inherited-provider' === ( $agentic['provider'] ?? '' ), 'Default browser session must retain provider inheritance.' ); +expect( 'inherited-model' === ( $agentic['model'] ?? '' ), 'Default browser session must retain model inheritance.' ); +expect( has_runner_step( $agentic_steps ), 'Default browser session must retain the generated runner step.' ); +expect( isset( $agentic['task_payload'], $agentic['recipe'], $agentic['materialization'] ), 'Default browser session must retain runner contracts.' ); + +expect( false === ( $task['primary']['preview_only'] ?? true ), 'Browser task contract must remain agentic when preview_only is supplied.' ); +expect( 'inherited-provider' === ( $task['primary']['provider'] ?? '' ), 'Browser task contract must retain provider inheritance.' ); +expect( has_runner_step( $task_steps ), 'Browser task contract primary session must retain the generated runner step.' ); + +fwrite( STDOUT, "PHP browser preview-only session smoke passed\n" );