diff --git a/crates/design-http/src/api.rs b/crates/design-http/src/api.rs index f4aaaf2a7..b7020e82c 100644 --- a/crates/design-http/src/api.rs +++ b/crates/design-http/src/api.rs @@ -15,7 +15,6 @@ use design_harness::{ encode_env_into_extras, harness_from_zip, harness_id, validate_bundle, HarnessBundle, }; use design_prompts::{load_prompt_set, prompt_set_digest, select_prompts_for_round}; -use design_sanitize::viewer_headers; use design_store::{ DesignStore, HarnessRow, RoundAward, RunStage, RunState, StageEvent, StoreError, StorePatch, }; @@ -99,6 +98,10 @@ pub fn design_router(state: Arc) -> Router { .route("/v1/annotate", post(post_annotate)) .route("/v1/admin/rounds/{id}/candidates", get(admin_candidates)) .route("/v1/admin/rounds/{id}/winners", post(admin_winners)) + .route( + "/v1/admin/rounds/current/requeue", + post(admin_requeue_current), + ) .route("/v1/rounds/{id}/leaderboard", get(leaderboard)) .with_state(state) } @@ -738,30 +741,32 @@ async fn get_pages(State(st): State>, Path(id): Path) -> R } } -async fn get_bundle_json(State(st): State>, Path(id): Path) -> Response { - match st.store.list_pages(&id).await { - Ok(pages) => { - let mut map = BTreeMap::new(); - for p in pages { - map.insert(p.path, p.sanitized_html); - } - Json(json!({"run_id": id, "pages": map})).into_response() - } - Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()), - } +/// `bundle.json` used to embed every page's produced HTML. The viewer is +/// screenshots-only now, so the route is retired with a pointer instead of +/// serving a silently hollow bundle. +async fn get_bundle_json() -> Response { + json_err( + StatusCode::GONE, + "gone", + "bundle.json no longer embeds produced HTML; use /v1/runs/{id}/pages for page metadata and /v1/view/{id}/index.png for the screenshot", + ) } +/// Screenshots-only viewer: produced HTML is never served. Only captured PNG +/// artifacts (`index.png`) are public; any non-PNG page request is 410 Gone. async fn view_page( State(st): State>, Path((id, page)): Path<(String, String)>, ) -> Response { - let path = if page.ends_with(".html") || page.ends_with(".png") { - page - } else { - format!("{page}.html") - }; - match st.store.get_page(&id, &path).await { - Ok(Some(body)) if path.ends_with(".png") => { + if !page.ends_with(".png") { + return json_err( + StatusCode::GONE, + "gone", + "produced HTML is never served; fetch the index.png screenshot instead", + ); + } + match st.store.get_page(&id, &page).await { + Ok(Some(body)) => { let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(body.trim()) else { return json_err(StatusCode::INTERNAL_SERVER_ERROR, "artifact", "bad png b64"); }; @@ -780,22 +785,6 @@ async fn view_page( ); (StatusCode::OK, headers, bytes).into_response() } - Ok(Some(html)) => { - let mut headers = HeaderMap::new(); - for (k, v) in viewer_headers(&st.frame_ancestors) { - if let (Ok(name), Ok(val)) = ( - header::HeaderName::try_from(k), - header::HeaderValue::try_from(v), - ) { - headers.insert(name, val); - } - } - headers.insert( - header::CONTENT_TYPE, - header::HeaderValue::from_static("text/html; charset=utf-8"), - ); - (StatusCode::OK, headers, html).into_response() - } Ok(None) => json_err(StatusCode::NOT_FOUND, "not_found", "page"), Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()), } @@ -949,6 +938,48 @@ struct WinnersBody { harness_ids: Vec, } +/// Manually schedule every active harness into the CURRENT open round. +/// +/// Operator escape hatch when a round opened with no/few runs (e.g. challenge +/// restart). Idempotent for the current round: `schedule_harness_for_round` +/// returns the existing run ids for a `(harness, round)` pair that already has +/// runs, so a repeated call creates nothing and consumes no quota. Harnesses +/// that fail scheduling (daily quota) are reported under `skipped`; one bad +/// harness never blocks the rest. +async fn admin_requeue_current(State(st): State>, headers: HeaderMap) -> Response { + if let Err(r) = check_admin(&st, &headers) { + return r; + } + let rid = round_id_at(now_secs()); + let harnesses = match st.store.list_active_harnesses(rid).await { + Ok(h) => h, + Err(e) => return json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()), + }; + let epoch = st.epoch.load(std::sync::atomic::Ordering::Relaxed); + let mut scheduled = Vec::new(); + let mut skipped = Vec::new(); + for harness in &harnesses { + match schedule_harness_for_round(st.store.as_ref(), harness, rid, st.netuid, epoch).await { + Ok(run_ids) => scheduled.push(json!({ + "harness_id": harness.id, + "miner_hotkey": harness.miner_hotkey, + "run_ids": run_ids, + })), + Err(e) => skipped.push(json!({ + "harness_id": harness.id, + "miner_hotkey": harness.miner_hotkey, + "reason": e, + })), + } + } + Json(json!({ + "round_id": rid, + "scheduled": scheduled, + "skipped": skipped, + })) + .into_response() +} + async fn admin_candidates( State(st): State>, headers: HeaderMap, @@ -1204,21 +1235,30 @@ mod tests { } #[tokio::test] - async fn view_page_serves_lockdown_headers_and_no_cookies() { + async fn view_page_serves_screenshots_only() { let (st, _g) = app_state(None); let run_id = "a".repeat(64); - // Store a script-laden page directly: even if sanitization were - // bypassed, the response headers must keep the payload inert. + // index.html exists in the store, but produced HTML is never served. + let png_bytes = [0x89, 0x50, 0x4E, 0x47]; st.store .put_artifacts( &run_id, - &[( - "index.html".to_owned(), - "miner".to_owned(), - "raw".to_owned(), - "00".repeat(32), - 42_u32, - )], + &[ + ( + "index.html".to_owned(), + "miner".to_owned(), + "raw".to_owned(), + "00".repeat(32), + 42_u32, + ), + ( + "index.png".to_owned(), + base64::engine::general_purpose::STANDARD.encode(png_bytes), + "raw".to_owned(), + "11".repeat(32), + 4_u32, + ), + ], ) .await .unwrap(); @@ -1232,35 +1272,153 @@ mod tests { .oneshot(Request::get(&url).body(Body::empty()).unwrap()) .await .unwrap(); - assert_eq!(res.status(), StatusCode::OK, "{url}"); - let h = res.headers().clone(); - let csp = h - .get(header::CONTENT_SECURITY_POLICY) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!(csp.starts_with("sandbox;"), "{csp}"); - assert!(!csp.contains("allow-scripts"), "{csp}"); - assert!(!csp.contains("allow-same-origin"), "{csp}"); - assert!(csp.contains("default-src 'none'"), "{csp}"); - // app_state pins 'none'; prod default allows the public site. - assert!(csp.contains("frame-ancestors 'none'"), "{csp}"); - assert_eq!( - h.get(header::X_CONTENT_TYPE_OPTIONS) - .and_then(|v| v.to_str().ok()), - Some("nosniff") - ); - assert_eq!( - h.get(header::REFERRER_POLICY).and_then(|v| v.to_str().ok()), - Some("no-referrer") - ); - assert_eq!( - h.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()), - Some("text/html; charset=utf-8") - ); - assert!(h.get(header::SET_COOKIE).is_none(), "{url} sets a cookie"); + assert_eq!(res.status(), StatusCode::GONE, "{url}"); + assert!(res.headers().get(header::SET_COOKIE).is_none()); let bytes = res.into_body().collect().await.unwrap().to_bytes(); - assert!(std::str::from_utf8(&bytes).unwrap().contains("miner")); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["error"], "gone", "{url}"); + // The stored HTML must not leak into the 410 body. + assert!(!String::from_utf8_lossy(&bytes).contains("miner"), "{url}"); } + // The PNG screenshot is served as image/png. + let res = app + .clone() + .oneshot( + Request::get(format!("/v1/view/{run_id}/index.png")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("image/png") + ); + assert_eq!( + res.headers() + .get(header::X_CONTENT_TYPE_OPTIONS) + .and_then(|v| v.to_str().ok()), + Some("nosniff") + ); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(bytes.as_ref(), &png_bytes); + // Unknown png → 404. + let (s, v) = call( + app, + Request::get(format!("/v1/view/{run_id}/missing.png")) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(s, StatusCode::NOT_FOUND, "{v}"); + } + + #[tokio::test] + async fn bundle_json_is_gone() { + let (st, _g) = app_state(None); + let run_id = "b".repeat(64); + st.store + .put_artifacts( + &run_id, + &[( + "index.html".to_owned(), + "miner".to_owned(), + "raw".to_owned(), + "00".repeat(32), + 7_u32, + )], + ) + .await + .unwrap(); + let app = design_router(Arc::clone(&st)); + let (s, v) = call( + app, + Request::get(format!("/v1/runs/{run_id}/bundle.json")) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(s, StatusCode::GONE, "{v}"); + assert_eq!(v["error"], "gone"); + // The stored HTML must not leak into the response body. + assert!(!v.to_string().contains("miner")); + } + + #[tokio::test] + async fn admin_requeue_schedules_current_round_once() { + let admin_token = "test-admin-token"; + let gating = Arc::new(MemoryGatingStore::new()); + let st = Arc::new(AppState { + store: Arc::new(MemoryDesignStore::new()), + epoch: std::sync::atomic::AtomicU64::new(0), + netuid: 541, + backend_mode: "memory", + annotator_token_hashes: vec![], + admin_token_hashes: vec![token_hash(admin_token)], + frame_ancestors: "'none'".into(), + retry_max: 2, + award_hook: None, + gating: Some(Arc::clone(&gating) as Arc), + metagraph: None, + }); + let app = design_router(Arc::clone(&st)); + + // Operator-protected like the other admin routes. + let (s, v) = call( + app.clone(), + Request::post("/v1/admin/rounds/current/requeue") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(s, StatusCode::UNAUTHORIZED, "{v}"); + + // Two active harnesses, each auto-scheduled into the NEXT round. + let (s, v) = post(app.clone(), submit_body(&hk(0xAA), "a")).await; + assert_eq!(s, StatusCode::ACCEPTED, "{v}"); + let (s, v) = post(app.clone(), submit_body(&hk(0xBB), "b")).await; + assert_eq!(s, StatusCode::ACCEPTED, "{v}"); + let current = round_id_at(now_secs()); + assert!(st.store.runs_for_round(current).await.unwrap().is_empty()); + + // First requeue schedules both harnesses into the current round. + let requeue = || { + Request::post("/v1/admin/rounds/current/requeue") + .header(header::AUTHORIZATION, format!("Bearer {admin_token}")) + .body(Body::empty()) + .unwrap() + }; + let (s, v) = call(app.clone(), requeue()).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["round_id"], current); + assert_eq!(v["scheduled"].as_array().unwrap().len(), 2, "{v}"); + assert!(v["skipped"].as_array().unwrap().is_empty(), "{v}"); + let runs = st.store.runs_for_round(current).await.unwrap(); + assert_eq!(runs.len(), 2 * design_challenge_task::prompts_per_round()); + assert!(runs.iter().all(|r| r.status == RunStage::Queued)); + + // Second call is a no-op: same run ids, no new runs, quota untouched. + let (s, v2) = call(app.clone(), requeue()).await; + assert_eq!(s, StatusCode::OK, "{v2}"); + assert_eq!( + v["scheduled"].as_array().unwrap(), + v2["scheduled"].as_array().unwrap(), + "idempotent requeue returns the same run ids" + ); + assert_eq!( + st.store.runs_for_round(current).await.unwrap().len(), + runs.len() + ); + let day = utc_day(now_secs()); + let used = st.store.quota_get(&hk(0xAA), &day).await.unwrap(); + assert_eq!( + usize::try_from(used).unwrap(), + 2 * design_challenge_task::prompts_per_round(), + "next-round + current-round schedule only" + ); } #[tokio::test] diff --git a/crates/site-api/src/handlers.rs b/crates/site-api/src/handlers.rs index 11d504525..a0185aec1 100644 --- a/crates/site-api/src/handlers.rs +++ b/crates/site-api/src/handlers.rs @@ -746,7 +746,12 @@ mod tests { "status": "scored", "final_score": {"score": 1000}, "prompt_title": "SaaS PR review", - "prompt": "Build a three-page marketing site for a SaaS PR review tool." + "prompt": "Build a three-page marketing site for a SaaS PR review tool.", + "pages": [ + {"path": "index.html", "bytes": 128, "raw_sha256": "aa"}, + {"path": "index.png", "bytes": 4096, "raw_sha256": "bb"} + ], + "screenshot_url": "/challenge/design/v1/view/run1/index.png" }))) .mount(design) .await; @@ -839,9 +844,11 @@ mod tests { "Build a three-page marketing site for a SaaS PR review tool." ); assert_eq!(v["items"][0]["promptTitle"], "SaaS PR review"); + // Screenshots-only viewer: no html `url` key, only `screenshotUrl`. + assert!(v["items"][0].get("url").is_none()); assert_eq!( - v["items"][0]["url"], - "/challenge/design/v1/view/run1/index.html" + v["items"][0]["screenshotUrl"], + "/challenge/design/v1/view/run1/index.png" ); let (s, v) = call(app.clone(), "/v1/site/arenas/design/duels").await; @@ -898,6 +905,68 @@ mod tests { assert_eq!(v["total"], 0); } + #[tokio::test] + async fn design_submissions_exclude_runs_without_screenshots() { + let (design, _prism, st) = setup().await; + // One scored run with a captured screenshot, one failed run that never + // produced pages (dead view link before the screenshots-only viewer). + Mock::given(method("GET")) + .and(path("/v1/dashboard")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "epoch": 3, + "leaderboard": {"current_round": 9, "ratings": [], "previous_ratings": []}, + "round": {"round_id": 9, "closes_at_secs": 1_700_000_100_u64, "seconds_remaining": 120}, + "recent_runs": [ + {"id": "ok1", "status": "scored", "round_id": 9, "harness_id": "h1", "prompt_id": "p1", "error_detail": null, "updated_at_ms": 1_700_000_000_000_u64}, + {"id": "bad1", "status": "failed", "round_id": 9, "harness_id": "h1", "prompt_id": "p1", "error_detail": "agent crashed", "updated_at_ms": 1_700_000_001_000_u64} + ] + }))) + .mount(&design) + .await; + Mock::given(method("GET")) + .and(path("/v1/harness/h1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "h1", + "miner_hotkey": "aa".repeat(32) + }))) + .mount(&design) + .await; + Mock::given(method("GET")) + .and(path("/v1/runs/ok1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "ok1", + "status": "scored", + "final_score": {"score": 1000}, + "pages": [{"path": "index.png", "bytes": 4096, "raw_sha256": "bb"}], + "screenshot_url": "/challenge/design/v1/view/ok1/index.png" + }))) + .mount(&design) + .await; + Mock::given(method("GET")) + .and(path("/v1/runs/bad1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "bad1", + "status": "failed", + "error_detail": "agent crashed", + "pages": [] + }))) + .mount(&design) + .await; + let app = site_router(st); + + let (s, v) = call(app, "/v1/site/arenas/design/submissions").await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["total"], 1, "{v}"); + let items = v["items"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{v}"); + assert_eq!(items[0]["id"], "ok1"); + assert!(items[0].get("url").is_none()); + assert_eq!( + items[0]["screenshotUrl"], + "/challenge/design/v1/view/ok1/index.png" + ); + } + #[tokio::test] async fn arenas_carry_trust_root_shares_and_weights_endpoint() { use std::sync::Arc; diff --git a/crates/site-api/src/map.rs b/crates/site-api/src/map.rs index e0b025527..3a28b1945 100644 --- a/crates/site-api/src/map.rs +++ b/crates/site-api/src/map.rs @@ -90,12 +90,6 @@ pub fn map_prism_status(status: &str) -> SubmissionStatus { } } -/// Design view URL on the public gateway proxy. -#[must_use] -pub fn design_view_url(run_id: &str) -> String { - format!("/challenge/design/v1/view/{run_id}/index.html") -} - /// Design full-page screenshot URL on the public gateway proxy. #[must_use] pub fn design_screenshot_url(run_id: &str) -> String { @@ -279,6 +273,22 @@ pub fn design_leaderboard( } /// Map one design `recent_run` (+ optional harness/run detail) to a submission. +/// Resolve the run's screenshot URL from run detail: the explicit +/// `screenshot_url` field first, else the pages list when it has `index.png`. +fn run_screenshot_url(run_id: &str, run_detail: Option<&Value>) -> Option { + run_detail + .and_then(|d| d.get("screenshot_url")) + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| { + let pages = run_detail?.get("pages")?.as_array()?; + pages + .iter() + .any(|p| p.get("path").and_then(Value::as_str) == Some("index.png")) + .then(|| design_screenshot_url(run_id)) + }) +} + #[must_use] pub fn design_submission( run: &Value, @@ -349,21 +359,10 @@ pub fn design_submission( "sanitizing" => Some("sanitizing pages".into()), _ => None, }); - let screenshot_url = run_detail - .and_then(|d| d.get("screenshot_url")) - .and_then(Value::as_str) - .map(str::to_owned) - .or_else(|| { - // Prefer explicit pages list from run detail when present. - let pages = run_detail - .and_then(|d| d.get("pages")) - .and_then(Value::as_array); - pages.and_then(|arr| { - arr.iter() - .any(|p| p.get("path").and_then(Value::as_str) == Some("index.png")) - .then(|| design_screenshot_url(id)) - }) - }); + // The public list shows only runs with a captured screenshot: the viewer + // is screenshots-only, so any run without `index.png` (failed, in flight, + // or html-only) has nothing viewable and would render a dead link. + let screenshot_url = run_screenshot_url(id, run_detail)?; Some(Submission { id: id.to_owned(), arena: ArenaSlug::Design, @@ -371,8 +370,8 @@ pub fn design_submission( prompt_id: format!("#{prompt_id}"), prompt_title, title, - url: design_view_url(id), - screenshot_url, + url: None, + screenshot_url: Some(screenshot_url), status, stage, status_detail, @@ -449,7 +448,7 @@ pub fn prism_submission(row: &Value) -> Option { prompt_id: format!("#epoch-{epoch}"), prompt_title: None, title: label.to_owned(), - url: format!("/challenge/prism/v1/submissions/{id}"), + url: Some(format!("/challenge/prism/v1/submissions/{id}")), screenshot_url: None, status, stage, @@ -837,11 +836,7 @@ mod tests { use serde_json::json; #[test] - fn design_view_url_shape() { - assert_eq!( - design_view_url("abc"), - "/challenge/design/v1/view/abc/index.html" - ); + fn design_screenshot_url_shape() { assert_eq!( design_screenshot_url("abc"), "/challenge/design/v1/view/abc/index.png" @@ -1056,11 +1051,58 @@ mod tests { "prompt_id": "p1", "updated_at_ms": 1_700_000_000_000_u64 }); - let sub = design_submission(&run, "aabbccdd", None, 1).unwrap(); + let detail = json!({ + "status": "agentic_review", + "screenshot_url": "/challenge/design/v1/view/r1/index.png" + }); + let sub = design_submission(&run, "aabbccdd", Some(&detail), 1).unwrap(); assert_eq!(sub.status, SubmissionStatus::Pending); assert_eq!(sub.stage, "agentic_review"); assert!(sub.status_detail.as_deref().unwrap().contains("agentic")); assert!(sub.bpb.is_none()); + // Screenshots-only viewer: no html url, only the screenshot link. + assert!(sub.url.is_none()); + assert_eq!( + sub.screenshot_url.as_deref(), + Some("/challenge/design/v1/view/r1/index.png") + ); + } + + #[test] + fn design_submission_requires_screenshot() { + let run = |id| { + json!({ + "id": id, + "status": "scored", + "prompt_id": "p1", + "updated_at_ms": 1_700_000_000_000_u64 + }) + }; + // No run detail at all → no screenshot evidence → excluded. + assert!(design_submission(&run("r1"), "aabbccdd", None, 1).is_none()); + // Detail with html pages but no index.png → excluded. + let no_png = json!({ + "status": "scored", + "pages": [ + {"path": "index.html", "bytes": 10, "raw_sha256": "aa"}, + {"path": "pricing.html", "bytes": 10, "raw_sha256": "bb"} + ] + }); + assert!(design_submission(&run("r2"), "aabbccdd", Some(&no_png), 1).is_none()); + // Detail with a screenshot page → included. + let with_png = json!({ + "status": "scored", + "pages": [ + {"path": "index.html", "bytes": 10, "raw_sha256": "aa"}, + {"path": "index.png", "bytes": 99, "raw_sha256": "cc"} + ] + }); + let sub = design_submission(&run("r3"), "aabbccdd", Some(&with_png), 1).unwrap(); + assert_eq!( + sub.screenshot_url.as_deref(), + Some("/challenge/design/v1/view/r3/index.png") + ); + assert!(sub.url.is_none()); } #[test] diff --git a/crates/site-types/src/types.rs b/crates/site-types/src/types.rs index ab6f2062b..e08b8fef4 100644 --- a/crates/site-types/src/types.rs +++ b/crates/site-types/src/types.rs @@ -187,8 +187,10 @@ pub struct Submission { pub prompt_title: Option, /// Title. pub title: String, - /// Preview or detail URL (gateway-relative for design view). - pub url: String, + /// Preview or detail URL. Absent for design: produced HTML is never + /// served, so design rows carry only `screenshot_url`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, /// Full-page PNG screenshot URL when master captured one (design arena). #[serde(skip_serializing_if = "Option::is_none")] pub screenshot_url: Option, diff --git a/docs/DESIGN_CHALLENGE.md b/docs/DESIGN_CHALLENGE.md index ece64dbd1..8240012d4 100644 --- a/docs/DESIGN_CHALLENGE.md +++ b/docs/DESIGN_CHALLENGE.md @@ -38,9 +38,9 @@ Miner --POST /v1/harness--> design-challenge (:8093) | +----------------+----------------+ | | - viewer (sanitized+CSP) agentic review → admin winners (1|2) - | - exact-E leaves → gateway /v1/weights/raw + viewer (index.png agentic review → admin winners (1|2) + screenshots only; | + HTML never served) exact-E leaves → gateway /v1/weights/raw ``` | Process | Host | Holds `design_sk`? | Holds OpenRouter key? | @@ -189,7 +189,10 @@ Floating tags (`:latest`) are **forbidden** for `design-runtime` / challenge ima ## 5. Sanitize rules -Ingestion via `design-sanitize` (ammonia + CSS filter). **Raw HTML is never served.** +Ingestion via `design-sanitize` (ammonia + CSS filter). **Produced HTML is +never served** — sanitized pages are orchestrator input only (screenshot +capture, anti-cheat review); the public viewer serves PNG screenshots +only (§6). ### Stripped / rejected @@ -208,7 +211,15 @@ missing required pages → automatic `Score(0)` at scoring gates. ## 6. Viewer headers and CSP -`GET /v1/view/{run_id}/{page}` serves **sanitized** HTML only, with: +`GET /v1/view/{run_id}/{page}` serves **PNG screenshots only** (`image/png`, +`private, no-store`, `nosniff`). **Produced HTML is never served**: requests +for `.html` pages (or bare page names) return `410 Gone` with a short JSON +error, and `GET /v1/runs/{id}/bundle.json` no longer embeds page HTML (same +`410 Gone` contract — use `/v1/runs/{id}/pages` for page metadata). Miner +output reaches browsers exclusively as the captured `index.png` screenshot. + +The full lockdown header set remains as the **gateway-enforced floor** on +every `/challenge/{id}/v1/view/*` response (defense in depth — below): ``` Content-Security-Policy: sandbox; default-src 'none'; img-src data: https:; style-src 'unsafe-inline' https:; font-src data: https:; base-uri 'none'; form-action 'none'; frame-ancestors @@ -221,13 +232,13 @@ Cache-Control: private, no-store ``` The `sandbox` directive is emitted **without** `allow-scripts` and without -`allow-same-origin`: the page runs in an **opaque origin with script execution -disabled**, so miner HTML can never read the serving origin's cookies, -storage, or DOM — even though joinbase.ai embeds it **same-origin** through -the `/gbase-api` proxy. Miner pages are static HTML/CSS (the sanitizer strips -`