diff --git a/docs/sandbox-session-contract.md b/docs/sandbox-session-contract.md index 0d49d322..c0bdd3dd 100644 --- a/docs/sandbox-session-contract.md +++ b/docs/sandbox-session-contract.md @@ -62,6 +62,8 @@ 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. +Materializer recipes preserve caller-owned `materializer.input` unchanged for direct ability execution. Product transport metadata such as `project_id` and `validation_policy` remains on the materializer envelope rather than being reconstructed into a source-specific artifact contract. + ## Browser Contained Site Handle Browser session, materializer, and task contracts include an additive durable diff --git a/packages/runtime-cloudflare/README.md b/packages/runtime-cloudflare/README.md index 69e63531..837aa40c 100644 --- a/packages/runtime-cloudflare/README.md +++ b/packages/runtime-cloudflare/README.md @@ -58,7 +58,7 @@ The D1 profile registers the configured site identity and creates or converges o Before canonical promotion, the operation checkpoints the exact prepared R2 pointer and expected coordinator version. Recovery skips SSI only when the coordinator's immutable commit receipt matches that complete pointer; a version match alone cannot claim another mutation's commit. The operation remains `publication-pending` after canonical commit until the corresponding immutable R2 publication receipt records promotion, supersession, or orphaning. Its terminal receipt identifies the artifact digest, SSI report, canonical pointer/version, publication identity, site URL, and canonical and terminal completion times. Profiles without a D1 operation repository retain the direct import transaction. -The import transaction boots a dedicated runtime with the pinned Static Site Importer v1.3.6 archive (`fb3679d679c03422fa77f5f33bc549fa869b0ce2bb16f7431705af4fc16b97db`) from R2, invokes its public `static-site-importer/import-website-artifact` ability as the operator-authorized administrator, and requires the canonical quality gate plus zero fallback, core HTML, freeform, and invalid blocks. SSI and its MU loader are runtime-owned and excluded from canonical mutable `wp-content`; generated themes, pages, options, and assets persist normally through MDI/R2. Failed or partial imports discard the PHP runtime without committing. Successful imports persist compact provenance, enqueue affected publication routes, and store at most 20 idempotency receipts in canonical options as a second mutation fence behind the D1 operation record. Exact replay converges without a new revision; reuse with different input returns a conflict. +The import transaction boots a dedicated runtime with the pinned Static Site Importer v1.3.6 archive (`fb3679d679c03422fa77f5f33bc549fa869b0ce2bb16f7431705af4fc16b97db`) from R2 and invokes its public `static-site-importer/import` ability as the operator-authorized administrator. Its exact canonical input uses `operation: "apply"`, a files source containing the artifact entrypoint and files, deterministic slug/name/site title, activation, overwrite, quality failure, and Cloudflare artifact provenance. The transaction requires the canonical quality gate plus zero fallback, core HTML, freeform, and invalid blocks. SSI and its MU loader are runtime-owned and excluded from canonical mutable `wp-content`; generated themes, pages, options, and assets persist normally through MDI/R2. Failed or partial imports discard the PHP runtime without committing. Successful imports persist compact provenance, enqueue affected publication routes, and store at most 20 idempotency receipts in canonical options as a second mutation fence behind the D1 operation record. Exact replay converges without a new revision; reuse with different input returns a conflict. SSI is extracted only for import requests, so normal browser, mutation, publication, and cron boots retain their existing memory and latency profile. The pinned normal plugin archive bundles Blocks Engine and supports website artifacts without the optional Figma zstd extension; compressed `.fig` import is outside this runtime contract. diff --git a/packages/runtime-cloudflare/assets/website-importer-artifact.json b/packages/runtime-cloudflare/assets/website-importer-artifact.json index d096dc79..cbd13bec 100644 --- a/packages/runtime-cloudflare/assets/website-importer-artifact.json +++ b/packages/runtime-cloudflare/assets/website-importer-artifact.json @@ -28,7 +28,7 @@ "version_constant": "STATIC_SITE_IMPORTER_VERSION" }, "abilities": { - "website-artifact-import": "static-site-importer/import-website-artifact" + "website-artifact-import": "static-site-importer/import" }, "limits": { "files": 10000, diff --git a/packages/runtime-cloudflare/components/website-importer.json b/packages/runtime-cloudflare/components/website-importer.json index 28164366..361fd9c1 100644 --- a/packages/runtime-cloudflare/components/website-importer.json +++ b/packages/runtime-cloudflare/components/website-importer.json @@ -23,7 +23,7 @@ "version_constant": "STATIC_SITE_IMPORTER_VERSION" }, "abilities": { - "website-artifact-import": "static-site-importer/import-website-artifact" + "website-artifact-import": "static-site-importer/import" }, "limits": { "files": 10000, diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index b44baf21..110a9283 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -176,7 +176,12 @@ if (!$ability) { return; } $ability_input = array( - 'artifact' => $artifact, + 'operation' => 'apply', + 'source' => array( + 'type' => 'files', + 'entrypoint' => (string) ($artifact['entrypoint'] ?? ''), + 'files' => isset($artifact['files']) && is_array($artifact['files']) ? $artifact['files'] : array(), + ), 'slug' => $input['slug'], 'name' => $input['name'], 'site_title' => $input['siteTitle'], 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 75151c3b..c0a6f607 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 @@ -195,6 +195,24 @@ function wp_codebox_browser_runtime_execute_ability( string $ability_name, array } function wp_codebox_browser_runtime_prepare_input( array $payload, array $invocation, string $session_id, array $ability_tools, array $allowed_tool_ids, array $sandbox_tool_ids ): array { + +$materializer = is_array( $payload['materializer'] ?? null ) ? $payload['materializer'] : array(); +$materializer_input = is_array( $materializer['input'] ?? null ) ? $materializer['input'] : null; +$is_direct_materializer_ability = null !== $materializer_input && 'ability' === (string) ( $invocation['type'] ?? 'ability' ); +$materializer_transport = array_filter( + array( + 'task' => isset( $materializer['task'] ) ? (string) $materializer['task'] : '', + 'project_id' => $materializer['project_id'] ?? null, + 'validation_policy' => is_array( $materializer['validation_policy'] ?? null ) ? $materializer['validation_policy'] : array(), + ), + static fn( mixed $value ): bool => null !== $value && '' !== $value && array() !== $value +); + +// A materializer owns its canonical ability input; product metadata remains transport data. +if ( $is_direct_materializer_ability ) { + return $materializer_input; +} + $agent = sanitize_key( (string) ( $payload['agent'] ?? '' ) ); if ( '' === $agent ) { $agent = 'wp-codebox-sandbox'; @@ -231,6 +249,7 @@ function wp_codebox_browser_runtime_prepare_input( array $payload, array $invoca 'caller_session_id' => $session_id, 'task_input' => $payload['task_input'] ?? array(), 'ability_tools' => $ability_tools, + 'materializer' => $materializer_transport, ), ); if ( ! empty( $allowed_tool_ids ) ) { diff --git a/scripts/php-browser-contained-site-contract-smoke.php b/scripts/php-browser-contained-site-contract-smoke.php index e511e3aa..78a182d6 100644 --- a/scripts/php-browser-contained-site-contract-smoke.php +++ b/scripts/php-browser-contained-site-contract-smoke.php @@ -176,8 +176,11 @@ function expect( bool $condition, string $message ): void { 'result_path' => '/tmp/recipe-smoke-result.json', 'invocation' => array( 'type' => 'ability', - 'name' => 'static-site-importer/import-website-artifact', - 'input' => array(), + 'name' => 'static-site-importer/import', + 'input' => array( + 'operation' => 'apply', + 'source' => array( 'type' => 'files', 'entrypoint' => 'website/index.html', 'files' => array() ), + ), ), ), array( @@ -196,7 +199,8 @@ function expect( bool $condition, string $message ): void { $recipe_steps = is_array( $recipe['runtime']['blueprint']['steps'] ?? null ) ? $recipe['runtime']['blueprint']['steps'] : array(); $recipe_run_php_steps = array_values( array_filter( $recipe_steps, static fn( array $step ): bool => 'runPHP' === ( $step['step'] ?? '' ) ) ); expect( count( $recipe_run_php_steps ) >= 1, 'Expected browser recipe Blueprint to include the runner runPHP step.' ); -expect( str_contains( (string) ( $recipe_run_php_steps[0]['code'] ?? '' ), 'static-site-importer/import-website-artifact' ), 'Expected browser recipe Blueprint runPHP step to execute the requested invocation.' ); +expect( str_contains( (string) ( $recipe_run_php_steps[0]['code'] ?? '' ), 'static-site-importer/import' ), 'Expected browser recipe Blueprint runPHP step to execute the canonical invocation.' ); +expect( ! str_contains( (string) ( $recipe_run_php_steps[0]['code'] ?? '' ), 'static-site-importer/import-website-artifact' ), 'Expected browser recipe Blueprint runPHP step to omit the legacy invocation.' ); $blueprint_method = new ReflectionMethod( WP_Codebox_Abilities::class, 'browser_blueprint_with_runtime' ); $local_package_blueprint = $blueprint_method->invoke( diff --git a/tests/browser-runtime-generic-invoker.test.ts b/tests/browser-runtime-generic-invoker.test.ts index 9e082eaf..ed45f214 100644 --- a/tests/browser-runtime-generic-invoker.test.ts +++ b/tests/browser-runtime-generic-invoker.test.ts @@ -9,6 +9,8 @@ const result = await runPhpJson<{ adapter_ability_names: { chat: string } has_principal: boolean agents_api_input: { has_principal: boolean; source: string; peer_agent_call: boolean; effective_agent_id: string } + materializer_input: Record + materializer_transport: Record }>(` define('ABSPATH', ${phpStringLiteral(repoRoot)}); class WP_Error { @@ -56,6 +58,36 @@ WP_Codebox_Agents_API_Adapter::register_runtime_profiles(); $agents_api_payload = array( 'agent' => 'agents-api-agent', 'message' => 'Run adapter runtime', 'task_input' => array() ); $agents_api_invocation = array( 'type' => 'ability', 'name' => 'agents/chat' ); $agents_api_input = wp_codebox_browser_runtime_prepare_input( $agents_api_payload, $agents_api_invocation, 'agents-api-session', array(), array(), array(), array() ); +$materializer_input = array( + 'operation' => 'apply', + 'source' => array( + 'type' => 'files', + 'entrypoint' => 'website/index.html', + 'files' => array( array( 'path' => 'website/index.html', 'content' => '

Canonical

' ) ), + ), + 'slug' => 'canonical-site', + 'name' => 'Canonical Site', + 'site_title' => 'Canonical Site', + 'activate' => true, + 'overwrite' => true, + 'fail_on_quality' => true, + 'source_metadata' => array( 'generator' => 'wp-build' ), + 'validation_artifacts' => array( 'screenshots' => array( 'before.png', 'after.png' ) ), + 'validation_policy' => array( 'schema' => 'wp-build/visual-parity-validation-policy/v1', 'required' => true ), +); +$materializer_payload = array( + 'agent' => 'generic-agent', + 'message' => 'Apply the canonical materializer.', + 'task_input' => array(), + 'materializer' => array( + 'task' => 'static-site-importer/import', + 'input' => $materializer_input, + 'project_id' => 2220, + 'validation_policy' => array( 'schema' => 'wp-build/visual-parity-validation-policy/v1', 'required' => true ), + ), +); +$materializer_ability_input = wp_codebox_browser_runtime_prepare_input( $materializer_payload, array( 'type' => 'ability', 'name' => 'static-site-importer/import' ), 'materializer-session', array(), array(), array(), array() ); +$materializer_task_input = wp_codebox_browser_runtime_prepare_input( $materializer_payload, $invocation, 'materializer-session', array(), array(), array(), array() ); echo json_encode( array( 'has_agents_api_adapter' => $has_agents_api_adapter, @@ -70,6 +102,8 @@ echo json_encode( array( 'peer_agent_call' => (bool) ( $agents_api_input['client_context']['peer_agent_call'] ?? false ), 'effective_agent_id' => (string) ( $agents_api_input['principal']['effective_agent_id'] ?? '' ), ), + 'materializer_input' => $materializer_ability_input, + 'materializer_transport' => $materializer_task_input['client_context']['materializer'] ?? array(), ), JSON_UNESCAPED_SLASHES ); `) @@ -82,5 +116,24 @@ assert.deepEqual(result.generic_ability_names, []) assert.equal(result.adapter_ability_names.chat, "agents/chat") assert.equal(result.has_principal, false) assert.deepEqual(result.agents_api_input, { has_principal: true, source: "peer-agent", peer_agent_call: true, effective_agent_id: "agents-api-agent" }) +assert.deepEqual(result.materializer_input, { + operation: "apply", + source: { type: "files", entrypoint: "website/index.html", files: [{ path: "website/index.html", content: "

Canonical

" }] }, + slug: "canonical-site", + name: "Canonical Site", + site_title: "Canonical Site", + activate: true, + overwrite: true, + fail_on_quality: true, + source_metadata: { generator: "wp-build" }, + validation_artifacts: { screenshots: ["before.png", "after.png"] }, + validation_policy: { schema: "wp-build/visual-parity-validation-policy/v1", required: true }, +}) +assert.equal("artifact_bundle" in result.materializer_input, false) +assert.deepEqual(result.materializer_transport, { + task: "static-site-importer/import", + project_id: 2220, + validation_policy: { schema: "wp-build/visual-parity-validation-policy/v1", required: true }, +}) console.log("browser runtime generic invoker ok") diff --git a/tests/cloudflare-runtime.test.ts b/tests/cloudflare-runtime.test.ts index 8cbe81fd..add9e950 100644 --- a/tests/cloudflare-runtime.test.ts +++ b/tests/cloudflare-runtime.test.ts @@ -83,6 +83,20 @@ test("Cloudflare static artifact imports require bounded content-addressed R2 in await assert.rejects(() => readStaticArtifactImport(new Request("https://worker.example/", { method: "POST", body: "x".repeat(16 * 1024 + 1) }), bucket as never), /request exceeds its byte budget/) }) +test("Cloudflare static artifact imports invoke SSI with the canonical apply input", async () => { + const worker = await readFile(new URL("../packages/runtime-cloudflare/src/worker.ts", import.meta.url), "utf8") + assert.doesNotMatch(worker, /static-site-importer\/import-website-artifact/) + assert.match(worker, /'operation' => 'apply'/) + assert.match(worker, /'source' => array\(\s*'type' => 'files',\s*'entrypoint' => \(string\) \(\$artifact\['entrypoint'\] \?\? ''\),\s*'files' => isset\(\$artifact\['files'\]\)/) + assert.match(worker, /'slug' => \$input\['slug'\]/) + assert.match(worker, /'name' => \$input\['name'\]/) + assert.match(worker, /'site_title' => \$input\['siteTitle'\]/) + assert.match(worker, /'activate' => true/) + assert.match(worker, /'overwrite' => true/) + assert.match(worker, /'fail_on_quality' => true/) + assert.match(worker, /'source_metadata' => array\('provider' => 'wp-codebox-cloudflare'/) +}) + test("Cloudflare upload manifests reject unbounded or non-canonical R2 files", () => { const sha256 = "a".repeat(64) const valid = [{ path: "2026/07/photo.png", size: 128, sha256, objectKey: `${R2_UPLOAD_OBJECT_PREFIX}/${sha256}` }]