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
2 changes: 2 additions & 0 deletions docs/sandbox-session-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/runtime-cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ) ) {
Expand Down
10 changes: 7 additions & 3 deletions scripts/php-browser-contained-site-contract-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
53 changes: 53 additions & 0 deletions tests/browser-runtime-generic-invoker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
materializer_transport: Record<string, unknown>
}>(`
define('ABSPATH', ${phpStringLiteral(repoRoot)});
class WP_Error {
Expand Down Expand Up @@ -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' => '<h1>Canonical</h1>' ) ),
),
'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,
Expand All @@ -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 );
`)

Expand All @@ -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: "<h1>Canonical</h1>" }] },
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")
14 changes: 14 additions & 0 deletions tests/cloudflare-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` }]
Expand Down
Loading