diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml
index 1150d96d54..7569708e7d 100644
--- a/.github/workflows/runner-full-stack-e2e.yml
+++ b/.github/workflows/runner-full-stack-e2e.yml
@@ -1104,14 +1104,44 @@ jobs:
retention-days: 30
if-no-files-found: error
- - name: Verify history source report and private screenshot evidence
+ - name: Qualify public preview raster sanitizer
+ if: always()
+ run: |
+ set -euo pipefail
+ if ! command -v convert >/dev/null 2>&1; then
+ sudo apt-get update -qq
+ sudo apt-get install --no-install-recommends -y imagemagick
+ fi
+ convert -version
+
+ - name: Prepare public history bundle with redacted layout previews
+ id: prepare_public_history
+ if: always()
+ run: |
+ set -euo pipefail
+ pnpm test:e2e:runner:history:prepare -- \
+ "$GITHUB_WORKSPACE/runner-e2e-merged-report/normalized" \
+ "$GITHUB_WORKSPACE/runner-e2e-public-report/normalized"
+
+ - name: Upload prepared public history source
+ if: always() && steps.prepare_public_history.outcome == 'success'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: runner-e2e-public-history-source-${{ github.run_id }}-${{ github.run_attempt }}
+ path: runner-e2e-public-report/
+ retention-days: 1
+ if-no-files-found: error
+
+ - name: Verify prepared history source and public layout previews
id: history_source_ready
if: always()
run: |
set -euo pipefail
- dashboard_root="runner-e2e-merged-report/normalized"
- private_screenshot="$(find "$dashboard_root" -type f -name '*.png' -print -quit 2>/dev/null || true)"
- if [ -f "$dashboard_root/index.html" ] && [ -n "$private_screenshot" ]; then
+ dashboard_root="runner-e2e-public-report/normalized"
+ public_preview="$(find "$dashboard_root/evidence" -type f -path '*/public-visuals/*.png' -print -quit 2>/dev/null || true)"
+ unexpected_png="$(find "$dashboard_root/evidence" -type f -name '*.png' ! -path '*/public-visuals/*.png' -print -quit 2>/dev/null || true)"
+ passed_count="$(jq '[.results[] | select(.status == "passed")] | length' "$dashboard_root/normalized-results.json" 2>/dev/null || echo invalid)"
+ if [ "${{ steps.prepare_public_history.outcome }}" = "success" ] && [ -f "$dashboard_root/index.html" ] && [ -z "$unexpected_png" ] && { [ "$passed_count" = "0" ] || [ -n "$public_preview" ]; }; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
@@ -1154,10 +1184,10 @@ jobs:
- run: pnpm install --frozen-lockfile
- - name: Download access-controlled normalized campaign
+ - name: Download prepared public campaign
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
- name: runner-e2e-report-${{ github.run_id }}-${{ github.run_attempt }}
+ name: runner-e2e-public-history-source-${{ github.run_id }}-${{ github.run_attempt }}
path: runner-e2e-merged-report
- name: Exchange GitHub OIDC identity for scoped AWS credentials
diff --git a/package.json b/package.json
index 0a7fb82bab..e8aed3cb59 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"test:e2e:runner:dashboard": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/dashboard-regenerate.ts",
"test:e2e:runner:models:update": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/openrouter-models-update.ts",
"test:e2e:runner:history:publish": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/history-publish.ts",
+ "test:e2e:runner:history:prepare": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/history-public-bundle.ts",
"test:e2e:runner:unit": "vitest run --config tests/runner-e2e/vitest.config.ts",
"test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json",
"test:e2e:runner:report": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/report.ts",
diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md
index ad62738f60..af3c18f381 100644
--- a/tests/runner-e2e/README.md
+++ b/tests/runner-e2e/README.md
@@ -204,16 +204,18 @@ usage is labeled `unavailable` or `unpriced`; it is never presented as zero
cost. The CI report job stages the same portable site at
`normalized/index.html` inside the access-controlled merged report artifact.
-Permanent public history has a narrower boundary. Before uploading to S3 or
-packaging the optional GitHub Pages artifact, the publisher removes raster and
-video evidence, archives, and the generated Playwright/blob/HTML report trees.
-It then regenerates the dashboard against only the remaining allowlisted,
-inert structured per-attempt evidence (`.json`, `.log`, `.md`, and `.txt`).
-Per-attempt XML is excluded because browsers can process XML/XSLT. The root
-`junit.xml` remains public because the report aggregator builds it from fixed
-markup and XML-escaped fields. Public dashboards therefore contain results and
-accounting but no attempt screenshots, videos, traces, or generated Playwright
-reports.
+Permanent public history has a narrower boundary. A trusted job without
+provider or AWS credentials copies successful declared PNG screenshots into a
+separate publication tree. It validates the PNG container, reduces each image
+to at most 160 pixels on either edge, applies a strong blur, limits the image to
+24 colors, and removes metadata. These layout previews preserve coarse UI
+state while making rendered task and provider text unreadable. The job then
+removes the full-resolution raster files, failure screenshots, video, archives,
+SVG, and generated Playwright/blob/HTML report trees. Only the derived
+`public-visuals/*.png` previews and allowlisted inert evidence (`.json`, `.log`,
+`.md`, and `.txt`) remain. Per-attempt XML is excluded because browsers can
+process XML/XSLT. The root `junit.xml` remains public because the report
+aggregator builds it from fixed markup and XML-escaped fields.
### Billing interpretation
@@ -380,11 +382,12 @@ bundle digest fails closed.
GitHub Pages remains the stable latest dashboard. Enable Pages with GitHub
Actions as its source and set `RUNNER_FULL_STACK_E2E_PUBLISH_PAGES=true`.
-The publisher prunes screenshots, video, archives, and generated report trees,
-then regenerates the public dashboard before either the CloudFront-backed S3
-history or optional Pages artifact is created. Public per-attempt evidence is
-limited to allowlisted inert structured text. Databases, Paperclip homes,
-workspaces, raw/unredacted logs, credentials, and visual evidence are never
+The trusted report job creates a separate public bundle before the AWS role is
+available. It publishes only blurred, low-resolution, metadata-free layout
+previews for successful declared screenshots and allowlisted inert structured
+text. The S3/CloudFront history and optional Pages mirror use this same bundle.
+Full-resolution and failure screenshots, video, archives, generated reports,
+databases, Paperclip homes, workspaces, raw logs, and credentials are never
published. Sanitized allowlisted `.log` copies may be public only after
exact-value/key-shape scanning and redaction.
diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md
index 518e9f1a47..1e5e2a1e85 100644
--- a/tests/runner-e2e/SECURITY.md
+++ b/tests/runner-e2e/SECURITY.md
@@ -179,8 +179,9 @@ latest pointers are mutable, and S3 versioning makes those updates recoverable.
## Public evidence boundary
CloudFront and GitHub Pages are public. Fixture identifiers, timing, token
-usage, costs, normalized results, and allowlisted inert structured per-attempt
-evidence are expected public data. Screenshots, video, archives, generated
+usage, costs, normalized results, allowlisted inert structured per-attempt
+evidence, and deliberately degraded layout previews are expected public data.
+Full-resolution and failure screenshots, video, archives, generated
Playwright/blob/HTML report trees, credentials, Paperclip homes, databases,
workspaces, master keys, raw/unredacted logs, and unallowlisted files are not.
Only allowlisted `.log` copies that passed exact-value/key-shape scanning and
@@ -189,20 +190,29 @@ redaction may cross the public boundary.
The packaged evidence uploaded as a 30-day GitHub Actions artifact has a
different, access-controlled boundary. Text is exact-value and key-shape
scanned and redacted. PNG and WebM are raw-byte scanned but cannot be inspected
-for credentials rendered as pixels, so they remain only in local evidence and
-the access-controlled artifact. SVG is rejected during packaging because it is
-active content.
+for credentials rendered as pixels, so full-resolution files remain only in
+local evidence and the access-controlled artifact. SVG is rejected during
+packaging because it is active content.
-Before permanent publication, the campaign publisher prunes raster/video
-files, archives, and generated report trees. It then regenerates the dashboard
-from the remaining allowlisted `.json`, `.log`, `.md`, and `.txt` evidence and
-accepts only that dashboard, normalized JSON/JUnit/summary, fixed
-branding assets, and the inert structured evidence paths. Per-attempt XML is
-excluded because browsers can process XML/XSLT; the only public XML is the
-root `junit.xml`, which the report aggregator constructs from fixed markup and
-XML-escaped fields. The same pruned tree feeds both S3/CloudFront history and
-the optional GitHub Pages artifact. A leak fails the cell and withholds the
-unsafe file.
+Before permanent publication, the trusted report job creates a separate tree.
+It accepts only declared screenshots from passing results. It validates a
+bounded, non-interlaced PNG container before invoking ImageMagick with strict
+memory, disk, thread, and time limits. It reduces each image to at most 160
+pixels on either edge, applies a strong blur, limits the palette, removes alpha
+and metadata, and validates the new PNG again. This is a layout preview, not
+diagnostic evidence. It then prunes full-resolution raster files, all failure
+images, video, archives, active SVG, and generated reports. This transformation
+runs before AWS credentials are available. The AWS job downloads only the
+prepared public tree.
+
+The remaining allowlist contains `public-visuals/*.png`, `.json`, `.log`, `.md`,
+and `.txt` evidence, plus the generated dashboard, normalized
+JSON/JUnit/summary, and fixed branding assets. Per-attempt XML is excluded
+because browsers can process XML/XSLT. The only public XML is the root
+`junit.xml`, which the report aggregator constructs from fixed markup and
+XML-escaped fields. The same prepared tree feeds S3/CloudFront history and the
+optional GitHub Pages artifact. Any malformed image, transformation error,
+unexpected file, or scan failure withholds publication.
Rotate the affected credential immediately if a secret-scanning failure or
unexpected public object is observed. Preserve the access-controlled Actions
diff --git a/tests/runner-e2e/dashboard.ts b/tests/runner-e2e/dashboard.ts
index 23f80352f7..5580888c80 100644
--- a/tests/runner-e2e/dashboard.ts
+++ b/tests/runner-e2e/dashboard.ts
@@ -889,7 +889,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
Full-stack acceptance campaign
${html(input.title)}
-
A browser-verified matrix of runner profiles, execution environments, and deterministic task contracts. Visual evidence is retained in the access-controlled workflow artifact; public history contains inert structured evidence only.
+
A browser-verified matrix of runner profiles, execution environments, and deterministic task contracts. Full-resolution visual evidence stays in the access-controlled workflow artifact. Public history includes blurred low-resolution layout previews and inert structured evidence.
@@ -897,7 +897,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {