diff --git a/backend/app/main.py b/backend/app/main.py index 30d417a58..776fc1fbc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,7 +25,7 @@ from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, HTMLResponse from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from sqlalchemy.exc import OperationalError @@ -1119,6 +1119,20 @@ def health(response: Response): return {"status": "ok", "boot_id": _BOOT_ID} +@app.get( + "/api/browser-bootstrap", + response_class=HTMLResponse, + include_in_schema=False, +) +def browser_bootstrap(): + """Stable same-origin document for authenticated browser automation setup.""" + return HTMLResponse( + "" + "Möbius browser bootstrap", + headers={"Cache-Control": "no-store"}, + ) + + @app.get("/api/ready") def ready(response: Response): """Readiness probe: 200 only when chat persistence can actually serve. diff --git a/backend/scripts/agent-screenshot.sh b/backend/scripts/agent-screenshot.sh index 2e656175a..830825267 100755 --- a/backend/scripts/agent-screenshot.sh +++ b/backend/scripts/agent-screenshot.sh @@ -37,6 +37,198 @@ set -euo pipefail +# agent-browser includes launch/runtime configuration in its daemon identity. +# Never vary AGENT_BROWSER_* env between commands: doing so silently splits one +# logical capture across multiple browsers. Bound individual CLI waits with the +# process-level `timeout` utility instead, which preserves daemon identity and +# lets the caller inspect/close the same session after this helper returns. + +browser_eval_retry() { + local expression="$1" + local output="" + local attempt + for attempt in 1 2 3; do + if output="$(timeout 5s agent-browser eval "$expression" 2>/dev/null)"; then + printf '%s' "$output" + return 0 + fi + sleep 0.3 + done + return 1 +} + +browser_screenshot_retry() { + local output_path="$1" + local attempt + local byte_count + for attempt in 1 2 3; do + # Navigation/service-worker handoffs can replace the page after an earlier + # viewport command. Configure the page that will produce THIS screenshot, + # not merely the page that existed at helper startup. + if ! prepare_capture_viewport; then + continue + fi + if timeout 5s agent-browser screenshot "$output_path" >/dev/null 2>&1; then + byte_count="$(wc -c < "$output_path" 2>/dev/null || printf '0')" + if { [ "${CAPTURE_MIN_BYTES:-0}" -le 0 ] \ + || [ "${byte_count:-0}" -ge "${CAPTURE_MIN_BYTES}" ]; } \ + && python3 - "$output_path" "$VIEWPORT_WIDTH" "$VIEWPORT_HEIGHT" <<'PY' +import struct +import sys + +path, expected_w, expected_h = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) +try: + with open(path, "rb") as handle: + header = handle.read(24) + valid = header[:8] == b"\x89PNG\r\n\x1a\n" and header[12:16] == b"IHDR" + width, height = struct.unpack(">II", header[16:24]) if valid else (0, 0) +except (OSError, struct.error): + width, height = 0, 0 +raise SystemExit(0 if (width, height) == (expected_w, expected_h) else 1) +PY + then + return 0 + fi + # A shell screenshot smaller than the minimum is the observed solid- + # background compositor frame, not useful evidence. Yield two renderer + # frames before retrying; do not accept a successful CDP command as proof + # that the shell's paint reached the captured surface. + browser_eval_retry \ + "new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))" \ + >/dev/null || true + fi + sleep 0.5 + done + return 1 +} + +browser_open_origin_retry() { + local url="$1" + local mode="$2" + local current="" + local confirmed="" + local settled="" + local attempt + for attempt in 1 2 3 4 5; do + timeout 5s agent-browser open "$url" >/dev/null 2>&1 || true + sleep 0.2 + current="$( + timeout 5s agent-browser get url 2>/dev/null || true + )" + sleep 0.2 + confirmed="$( + timeout 5s agent-browser get url 2>/dev/null || true + )" + if [ "$current" != "$confirmed" ]; then + # In-shell deep links intentionally canonicalize to `/shell/` after the + # router consumes their intent. That redirect can land between these two + # reads. Require the canonical URL to remain stable for one more read + # instead of retrying the deep link forever; the final auth/readiness + # checks below still reject login or an unmounted target. + case "$mode:$confirmed" in + target:"${API_BASE_URL}"*) + case "$confirmed" in + "${API_BASE_URL}/api/browser-bootstrap"*) continue ;; + esac + sleep 0.2 + settled="$( + timeout 5s agent-browser get url 2>/dev/null || true + )" + [ "$confirmed" = "$settled" ] && return 0 + ;; + esac + continue + fi + case "$mode:$confirmed" in + bootstrap:"${API_BASE_URL}/api/browser-bootstrap"*) return 0 ;; + target:"${API_BASE_URL}"*) + case "$confirmed" in + "${API_BASE_URL}/api/browser-bootstrap"*) : ;; + *) return 0 ;; + esac + ;; + esac + done + return 1 +} + +browser_open_exact_retry() { + local url="$1" + local current="" + local confirmed="" + local attempt + for attempt in 1 2 3 4 5; do + timeout 5s agent-browser open "$url" >/dev/null 2>&1 || true + sleep 0.2 + current="$( + timeout 5s agent-browser get url 2>/dev/null || true + )" + sleep 0.2 + confirmed="$( + timeout 5s agent-browser get url 2>/dev/null || true + )" + [ "$current" = "$url" ] && [ "$confirmed" = "$url" ] && return 0 + done + return 1 +} + +browser_set_viewport_retry() { + local attempt + for attempt in 1 2 3 4 5; do + if timeout 5s agent-browser set viewport "$VIEWPORT_WIDTH" "$VIEWPORT_HEIGHT" \ + >/dev/null 2>&1; then + return 0 + fi + sleep 0.2 + done + return 1 +} + +clear_stale_browser_profile_lock() { + local lock_path="${AGENT_BROWSER_PROFILE:-}/SingletonLock" + local lock_target="" + local owner_host="" + local owner_pid="" + local current_host="" + local artifact="" + + [ -n "${AGENT_BROWSER_PROFILE:-}" ] || return 0 + [ -L "$lock_path" ] || return 0 + + lock_target="$(readlink "$lock_path" 2>/dev/null || true)" + owner_host="${lock_target%-*}" + owner_pid="${lock_target##*-}" + case "$owner_pid" in + ''|*[!0-9]*) + # An unfamiliar lock shape may belong to a newer Chromium contract. + # Preserve it rather than guessing that the automation profile is idle. + echo "agent-screenshot.sh: browser profile lock has an unfamiliar owner; leaving it untouched" >&2 + return 0 + ;; + esac + if [ -z "$owner_host" ] || [ "$owner_host" = "$lock_target" ]; then + echo "agent-screenshot.sh: browser profile lock has an unfamiliar owner; leaving it untouched" >&2 + return 0 + fi + + current_host="$(hostname)" + if [ "$owner_host" = "$current_host" ] && kill -0 "$owner_pid" 2>/dev/null; then + # Never disturb a browser that still owns this profile in this container. + return 0 + fi + + # Chromium records its singleton owner as -. The per-chat + # automation profile survives container restarts, while the previous + # container and its processes do not. Remove only Chromium's three singleton + # symlinks; authenticated browser state, cache, and the partner's real browser + # profile remain untouched. A same-host dead PID is the equivalent crash case. + for artifact in SingletonLock SingletonCookie SingletonSocket; do + if [ -L "${AGENT_BROWSER_PROFILE}/${artifact}" ]; then + rm -f "${AGENT_BROWSER_PROFILE}/${artifact}" + fi + done +} + CONTENT_ONLY=0 PRESERVE_CACHE=0 while [ "${1:-}" = "--content-only" ] || [ "${1:-}" = "--preserve-cache" ]; do @@ -135,30 +327,73 @@ PY fi read -r VIEWPORT_WIDTH VIEWPORT_HEIGHT <<<"$NORMALIZED_VIEWPORT" -# Origin must be loaded before localStorage.setItem (localStorage is -# per-origin and only writable once a same-origin document exists). -# Both the shell and the standalone /apps// page read the owner -# JWT from localStorage['token'] on the same origin. -agent-browser open "${API_BASE_URL}/" >/dev/null -# `set viewport` requires a live browser connection. Setting it before the -# first `open` happened to work only when a previous turn had leaked/reused a -# daemon; a correctly closed, cold session failed before it could launch. -agent-browser set viewport "$VIEWPORT_WIDTH" "$VIEWPORT_HEIGHT" >/dev/null -# Seed the token via stdin (eval --stdin), never argv: the JWT must not -# appear in /proc//cmdline. python reads it from the env (not argv) -# and JSON-encodes it so any character is a safe JS string literal. -AGENT_TOKEN="$AGENT_TOKEN" python3 -c 'import json,os; print("localStorage.setItem(\"token\", "+json.dumps(os.environ["AGENT_TOKEN"])+")")' | agent-browser eval --stdin >/dev/null - -# Content mode is a browser-session presentation flag, not onboarding or -# install completion state. Set it while the origin document is live and before -# the target mounts so React never opens its modal. It survives the about:blank -# detour below because sessionStorage is scoped to this tab + origin. -if [ "$CONTENT_ONLY" -eq 1 ]; then - agent-browser eval \ - "sessionStorage.setItem('mobius:visual-content-only', '1')" >/dev/null -else - agent-browser eval \ - "sessionStorage.removeItem('mobius:visual-content-only')" >/dev/null +clear_stale_browser_profile_lock + +# Start the browser, then wait narrowly for its command socket before applying +# viewport state. A cold launch can return before that socket is connectable. +timeout 5s agent-browser open "${API_BASE_URL}/api/browser-bootstrap" \ + >/dev/null 2>&1 || true +if ! browser_set_viewport_retry; then + echo "agent-screenshot.sh: browser did not become ready for viewport configuration" >&2 + exit 1 +fi + +# A retained test profile can start under an older service worker that handles +# `/api/browser-bootstrap` as an app navigation and immediately canonicalizes +# it to `/shell/`. Requiring the inert bootstrap before detaching that +# controller makes authentication fail even though the authenticated shell is +# already on screen. Unregister from whichever same-origin document the cold +# open produced, then leave that controlled document before requiring the +# bootstrap URL below. The later detach still protects the final target +# navigation from a controller installed between authentication and capture. +if [ "$PRESERVE_CACHE" -eq 0 ]; then + browser_eval_retry \ + "(async () => { try { const regs = await navigator.serviceWorker?.getRegistrations?.() || []; await Promise.all(regs.map((r) => r.unregister())); } catch {} return true })()" \ + >/dev/null || true + if ! browser_open_exact_retry "about:blank"; then + echo "agent-screenshot.sh: browser did not detach its stale bootstrap page" >&2 + exit 1 + fi +fi + +# Seed the token, ephemeral visual mode, and default service-worker reset in +# one same-origin evaluation. The dedicated bootstrap is inert HTML, so it +# cannot restore the last chat or disappear like Chromium's JSON viewer. +# The JWT travels via stdin, never argv or /proc//cmdline. +TOKEN_READY=0 +for attempt in 1 2 3; do + if ! browser_open_origin_retry \ + "${API_BASE_URL}/api/browser-bootstrap" bootstrap; then + continue + fi + if AGENT_TOKEN="$AGENT_TOKEN" CONTENT_ONLY="$CONTENT_ONLY" PRESERVE_CACHE="$PRESERVE_CACHE" \ + python3 -c ' +import json, os +token = json.dumps(os.environ["AGENT_TOKEN"]) +visual = ( + "sessionStorage.setItem(\"mobius:visual-content-only\", \"1\");" + if os.environ["CONTENT_ONLY"] == "1" + else "sessionStorage.removeItem(\"mobius:visual-content-only\");" +) +reset = ( + "try { const regs = await navigator.serviceWorker?.getRegistrations?.() || []; " + "await Promise.all(regs.map((r) => r.unregister())); } catch {}" + if os.environ["PRESERVE_CACHE"] == "0" + else "" +) +print( + "(async () => { localStorage.setItem(\"token\", " + token + "); " + + visual + reset + " return true })()" +) +' \ + | timeout 5s agent-browser eval --stdin >/dev/null 2>&1; then + TOKEN_READY=1 + break + fi +done +if [ "$TOKEN_READY" -ne 1 ]; then + echo "agent-screenshot.sh: browser origin did not remain ready for authentication" >&2 + exit 1 fi # A per-chat Chromium profile deliberately survives browser close, which is @@ -171,10 +406,10 @@ fi # and bounded. The owner's real browser/profile is never touched. TARGET_ROUTE="$ROUTE" if [ "$PRESERVE_CACHE" -eq 0 ]; then - agent-browser eval \ - "(async () => { try { const regs = await navigator.serviceWorker?.getRegistrations?.() || []; await Promise.all(regs.map((r) => r.unregister())); } catch {} return true })()" \ - >/dev/null - agent-browser open "about:blank" >/dev/null + if ! browser_open_exact_retry "about:blank"; then + echo "agent-screenshot.sh: browser did not detach before target navigation" >&2 + exit 1 + fi CAPTURE_NONCE="$(date +%s%N)" case "$TARGET_ROUTE" in *\?*) TARGET_ROUTE="${TARGET_ROUTE}&__mobius_capture=${CAPTURE_NONCE}" ;; @@ -183,28 +418,41 @@ if [ "$PRESERVE_CACHE" -eq 0 ]; then fi # Now navigate to the actual target route, authenticated. -agent-browser open "${API_BASE_URL}${TARGET_ROUTE}" >/dev/null +if ! browser_open_origin_retry "${API_BASE_URL}${TARGET_ROUTE}" target; then + echo "agent-screenshot.sh: browser did not reach the target route" >&2 + exit 1 +fi -# Give the target a bounded render window. The missing password field is only a -# settling signal — token presence mounts Shell before the server has accepted -# it — so the protected request below remains the authoritative auth check. -agent-browser wait --fn \ - "!document.querySelector('input[type=password]')" >/dev/null 2>&1 || \ - agent-browser wait 1500 >/dev/null +# The URL can canonicalize before the replacement document has committed. +# Applying device metrics during that gap reports success on the outgoing page, +# then the newly-created shell page falls back to Chromium's default viewport. +# Wait on browser paint ownership before configuring the final page. +if ! agent-browser wait --fn \ + "document.readyState === 'complete' && performance.getEntriesByName('first-contentful-paint').length > 0" \ + >/dev/null 2>&1; then + echo "agent-screenshot.sh: target document did not finish its initial paint" >&2 + exit 1 +fi + +# Let the navigation commit without asking the renderer to poll the transcript. +# A long, actively streaming chat can keep agent-browser's DOM wait inside one +# Runtime.evaluate call until its global timeout even though the browser is +# otherwise responsive. The authoritative checks below retry narrowly instead. +sleep 0.3 # Dismiss the PWA install banner if it surfaces — it covers the bottom # of the view and would distract from the actual page. -agent-browser find text "Not now" click >/dev/null 2>&1 || true -agent-browser wait 300 >/dev/null +timeout 2s agent-browser find text "Not now" click >/dev/null 2>&1 || true +sleep 0.3 # Token presence alone is not proof of authentication: App mounts Shell from # localStorage immediately, then a later protected request can reject the token, # clear it, and reload onto LoginForm. Verify the token with a protected request # at the FINAL capture boundary, after the settle/banner work above. The token is # read inside the page and never appears in argv or output. -AUTH_OK="$(agent-browser eval \ +AUTH_OK="$(browser_eval_retry \ "(async () => { const token = localStorage.getItem('token'); if (!token || document.querySelector('input[type=password]')) return false; try { const res = await fetch('/api/chats?agent-screenshot-auth=' + Date.now(), { cache: 'no-store', headers: { Authorization: 'Bearer ' + token } }); return res.status === 200 && !!localStorage.getItem('token') && !document.querySelector('input[type=password]'); } catch { return false; } })()" \ - 2>/dev/null || true)" + || true)" if [ "$AUTH_OK" != "true" ]; then echo "agent-screenshot.sh: authentication failed; the token was rejected or the login page remained visible" >&2 exit 1 @@ -215,9 +463,15 @@ fi # failure instead of misleading visual evidence. Standalone app PWAs have their # own entry shape, but still receive controller detachment + cache-busted # navigation. +SHELL_SETTLED_EXPR="" case "$ROUTE" in /apps/*) : ;; *) + # An authenticated shell frame always contains chrome/text and is far + # larger than the ~3 KiB one-colour PNG Chromium emits before its first + # useful compositor submission. Standalone app PWAs may intentionally be a + # solid canvas, so this evidence check is shell-only. + CAPTURE_MIN_BYTES=8192 if [ "$PRESERVE_CACHE" -eq 0 ]; then DIST_INDEX="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../frontend" && pwd)/dist/index.html" if [ ! -f "$DIST_INDEX" ]; then @@ -241,9 +495,9 @@ PY exit 1 } LOADED_SHELL_ENTRY_RAW="$( - agent-browser eval \ + browser_eval_retry \ "(() => { const src = document.querySelector('script[type=\"module\"][src*=\"/assets/index-\"]')?.src || ''; return src.split('/').pop() })()" \ - 2>/dev/null || true + || true )" LOADED_SHELL_ENTRY="$( printf '%s' "$LOADED_SHELL_ENTRY_RAW" | python3 -c \ @@ -255,16 +509,49 @@ PY exit 1 fi fi + + # Shell mode changes and chat-to-chat handoffs deliberately retain multiple + # fully laid-out surfaces. Shell owns which world is actually painted and + # publishes one stable visual-readiness contract; automation must not learn + # its private handoff classes or compositor attributes. Once the owner says + # settled, give style/layout two frames to commit. + SHELL_SETTLED_EXPR="document.querySelector('.shell[data-workspace-visual-state=\"settled\"]') !== null && performance.getEntriesByName('first-contentful-paint').length > 0" + if ! agent-browser wait --fn "$SHELL_SETTLED_EXPR" >/dev/null 2>&1; then + echo "agent-screenshot.sh: shell did not reach a settled visual state before capture" >&2 + exit 1 + fi + if ! browser_eval_retry \ + "new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))" \ + >/dev/null; then + echo "agent-screenshot.sh: shell did not commit its settled frame before capture" >&2 + exit 1 + fi ;; esac +# Device metrics attach to one CDP page. A canonical redirect, a service-worker +# handoff, or agent-browser's about:blank detach can replace that page after the +# startup viewport command. Reapply immediately before each capture attempt; +# browser_screenshot_retry validates the kept PNG's exact IHDR dimensions. +prepare_capture_viewport() { + # Do not insert a separate renderer round-trip here. A document replacement + # can land between configuration and that probe; the kept PNG's IHDR below is + # the exact, race-free proof of which viewport actually produced evidence. + browser_set_viewport_retry +} + +if ! prepare_capture_viewport; then + echo "agent-screenshot.sh: target page did not retain the requested viewport" >&2 + exit 1 +fi + # A fresh phone-width shell can restore with the modal navigation drawer open # or still exiting, which makes an otherwise-correct app screenshot capture the # scrim/drawer transition. Close only the mobile modal form; the desktop docked # sidebar is part of the partner's actual layout and stays untouched. -agent-browser eval \ +browser_eval_retry \ "(() => { const b = document.querySelector('button[aria-label=\"Toggle navigation\"][aria-expanded=\"true\"]'); if (window.innerWidth < 768 && b) b.click(); return true })()" \ - >/dev/null 2>&1 || true + >/dev/null || true if [ "$VIEWPORT_WIDTH" -lt 768 ]; then if ! agent-browser wait --fn \ "!document.querySelector('.drawer-overlay--blocking') && !document.querySelector('.drawer:not(.drawer--persistent).drawer--open')" \ @@ -292,7 +579,31 @@ case "$ROUTE" in ;; esac -agent-browser screenshot "${OUT}" >/dev/null +# Chromium can expose a complete DOM and a first-contentful-paint timing entry +# one compositor submission before CDP's first screenshot contains that paint. +# The symptom is a successful, solid-background PNG; the immediately following +# capture is correct. Prime the screenshot path into a disposable file, then +# wait two frames before keeping evidence. This is a renderer handshake, not a +# guessed sleep, and the temporary image never enters chat media. +WARMUP_OUT="$(mktemp "${TMPDIR:-/tmp}/mobius-screenshot-warmup.XXXXXX.png")" +trap 'rm -f "$WARMUP_OUT"' EXIT +if ! browser_screenshot_retry "$WARMUP_OUT"; then + echo "agent-screenshot.sh: page remained too busy to prime capture" >&2 + exit 1 +fi +if ! browser_eval_retry \ + "new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))" \ + >/dev/null; then + echo "agent-screenshot.sh: page did not commit after capture priming" >&2 + exit 1 +fi + +if ! browser_screenshot_retry "$OUT"; then + echo "agent-screenshot.sh: page remained too busy to capture after bounded retries" >&2 + exit 1 +fi +rm -f "$WARMUP_OUT" +trap - EXIT echo "${OUT}" # Also print the ready-to-paste chat embed. The partner sees ONLY embedded diff --git a/backend/tests/test_agent_screenshot_auth.py b/backend/tests/test_agent_screenshot_auth.py index f3b83fc81..f174276a8 100644 --- a/backend/tests/test_agent_screenshot_auth.py +++ b/backend/tests/test_agent_screenshot_auth.py @@ -3,6 +3,7 @@ from pathlib import Path import os import shutil +import socket import subprocess @@ -35,21 +36,62 @@ def _fixture_script(tmp_path: Path) -> Path: def _fake_browser(tmp_path: Path) -> tuple[Path, Path]: marker = tmp_path / "screenshot-called" + png_writer = tmp_path / "fake-png.py" + png_writer.write_text( + "import struct, sys\n" + "path, width, height, size = sys.argv[1], *map(int, sys.argv[2:])\n" + "header = (b'\\x89PNG\\r\\n\\x1a\\n' + struct.pack('>I', 13) + b'IHDR' " + "+ struct.pack('>II', width, height))\n" + "with open(path, 'wb') as handle:\n" + " handle.write(header + bytes(max(0, size - len(header))))\n", + encoding="utf-8", + ) browser = tmp_path / "agent-browser" browser.write_text( "#!/bin/sh\n" "printf '%s\\n' \"$*\" >> \"$FAKE_BROWSER_LOG\"\n" "case \"$1\" in\n" + " open)\n" + " printf '%s\\n' \"$2\" > \"$FAKE_BROWSER_URL_FILE\"\n" + " if [ -n \"${FAKE_CANONICAL_TARGET_URL:-}\" ]; then\n" + " case \"$2\" in\n" + " */chat/*) printf '%s\\n' \"$FAKE_CANONICAL_TARGET_URL\" > \"$FAKE_BROWSER_NEXT_URL_FILE\" ;;\n" + " esac\n" + " fi\n" + " ;;\n" + " get)\n" + " if [ \"$2\" = url ]; then\n" + " cat \"$FAKE_BROWSER_URL_FILE\"\n" + " if [ -s \"$FAKE_BROWSER_NEXT_URL_FILE\" ]; then\n" + " mv \"$FAKE_BROWSER_NEXT_URL_FILE\" \"$FAKE_BROWSER_URL_FILE\"\n" + " fi\n" + " fi\n" + " ;;\n" " eval)\n" - " if [ \"$2\" = \"--stdin\" ]; then cat >/dev/null; exit 0; fi\n" + " if [ \"$2\" = \"--stdin\" ]; then cat > \"$FAKE_BROWSER_STDIN_LOG\"; exit 0; fi\n" " case \"$2\" in\n" " *src.split*) printf '%s\\n' \"${FAKE_LOADED_ASSET:-none}\" ;;\n" " *serviceWorker*) printf '%s\\n' true ;;\n" " *) printf '%s\\n' \"${FAKE_AUTH_OK:-false}\" ;;\n" " esac\n" " ;;\n" + " set)\n" + " if [ \"${FAKE_VIEWPORT_FAIL_ONCE:-0}\" = 1 ] && [ ! -e \"$FAKE_VIEWPORT_MARKER\" ]; then\n" + " : > \"$FAKE_VIEWPORT_MARKER\"\n" + " exit 1\n" + " fi\n" + " ;;\n" " screenshot)\n" - " : > \"$2\"\n" + " if [ \"${FAKE_SCREENSHOT_FAIL_ONCE:-0}\" = 1 ] && [ ! -e \"$FAKE_SCREENSHOT_RETRY_MARKER\" ]; then\n" + " : > \"$FAKE_SCREENSHOT_RETRY_MARKER\"\n" + " exit 1\n" + " fi\n" + " if [ \"${FAKE_SCREENSHOT_TINY_ONCE:-0}\" = 1 ] && [ ! -e \"$FAKE_SCREENSHOT_TINY_MARKER\" ]; then\n" + " : > \"$FAKE_SCREENSHOT_TINY_MARKER\"\n" + " python3 \"$FAKE_PNG_WRITER\" \"$2\" \"$VIEWPORT_WIDTH\" \"$VIEWPORT_HEIGHT\" 100\n" + " else\n" + " python3 \"$FAKE_PNG_WRITER\" \"$2\" \"$VIEWPORT_WIDTH\" \"$VIEWPORT_HEIGHT\" 9000\n" + " fi\n" " : > \"$FAKE_SCREENSHOT_MARKER\"\n" " ;;\n" " *) exit 0 ;;\n" @@ -67,11 +109,24 @@ def _run_helper( content_only: bool = False, preserve_cache: bool = False, loaded_asset: str | None = None, + viewport_fail_once: bool = False, + screenshot_fail_once: bool = False, + canonical_target_url: str | None = None, + screenshot_tiny_once: bool = False, + profile_lock_target: str | None = None, + profile_lock_artifacts: tuple[str, ...] = ( + "SingletonLock", "SingletonCookie", "SingletonSocket", + ), ) -> tuple[subprocess.CompletedProcess, Path, Path, Path]: _, marker = _fake_browser(tmp_path) script = _fixture_script(tmp_path) output = tmp_path / "shot.png" browser_log = tmp_path / "browser.log" + browser_profile = tmp_path / "browser-profile" + browser_profile.mkdir() + if profile_lock_target is not None: + for artifact in profile_lock_artifacts: + (browser_profile / artifact).symlink_to(profile_lock_target) env = { **os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}", @@ -79,10 +134,22 @@ def _run_helper( "API_BASE_URL": "http://mobius.test", "VIEWPORT_WIDTH": str(viewport_width), "VIEWPORT_HEIGHT": str(viewport_height), + "AGENT_BROWSER_PROFILE": str(browser_profile), "FAKE_AUTH_OK": "true" if auth_ok else "false", "FAKE_LOADED_ASSET": loaded_asset or SHELL_ENTRY, "FAKE_BROWSER_LOG": str(browser_log), "FAKE_SCREENSHOT_MARKER": str(marker), + "FAKE_VIEWPORT_FAIL_ONCE": "1" if viewport_fail_once else "0", + "FAKE_VIEWPORT_MARKER": str(tmp_path / "viewport-ready"), + "FAKE_SCREENSHOT_FAIL_ONCE": "1" if screenshot_fail_once else "0", + "FAKE_SCREENSHOT_RETRY_MARKER": str(tmp_path / "screenshot-retried"), + "FAKE_SCREENSHOT_TINY_ONCE": "1" if screenshot_tiny_once else "0", + "FAKE_SCREENSHOT_TINY_MARKER": str(tmp_path / "screenshot-tiny"), + "FAKE_PNG_WRITER": str(tmp_path / "fake-png.py"), + "FAKE_BROWSER_URL_FILE": str(tmp_path / "browser-url"), + "FAKE_BROWSER_NEXT_URL_FILE": str(tmp_path / "browser-next-url"), + "FAKE_CANONICAL_TARGET_URL": canonical_target_url or "", + "FAKE_BROWSER_STDIN_LOG": str(tmp_path / "browser-stdin.log"), } args = ["bash", str(script)] if content_only: @@ -100,6 +167,59 @@ def _run_helper( return result, output, marker, browser_log +def test_stale_foreign_container_profile_lock_is_repaired_before_launch(tmp_path: Path): + profile = tmp_path / "browser-profile" + result, output, marker, _ = _run_helper( + tmp_path, + auth_ok=True, + profile_lock_target="previous-container-999999", + profile_lock_artifacts=("SingletonLock",), + ) + + assert result.returncode == 0, result.stderr + assert output.exists() + assert marker.exists() + assert not any( + (profile / artifact).is_symlink() + for artifact in ("SingletonLock", "SingletonCookie", "SingletonSocket") + ) + + +def test_live_local_profile_lock_is_preserved(tmp_path: Path): + profile = tmp_path / "browser-profile" + result, output, marker, _ = _run_helper( + tmp_path, + auth_ok=True, + profile_lock_target=f"{socket.gethostname()}-{os.getpid()}", + ) + + assert result.returncode == 0, result.stderr + assert output.exists() + assert marker.exists() + assert all( + (profile / artifact).is_symlink() + for artifact in ("SingletonLock", "SingletonCookie", "SingletonSocket") + ) + + +def test_unfamiliar_profile_lock_is_preserved(tmp_path: Path): + profile = tmp_path / "browser-profile" + result, output, marker, _ = _run_helper( + tmp_path, + auth_ok=True, + profile_lock_target="unexpected-owner-format", + ) + + assert result.returncode == 0, result.stderr + assert "unfamiliar owner" in result.stderr + assert output.exists() + assert marker.exists() + assert all( + (profile / artifact).is_symlink() + for artifact in ("SingletonLock", "SingletonCookie", "SingletonSocket") + ) + + def test_helper_refuses_to_capture_when_protected_request_rejects_token(tmp_path: Path): result, output, marker, browser_log = _run_helper(tmp_path, auth_ok=False) @@ -110,6 +230,16 @@ def test_helper_refuses_to_capture_when_protected_request_rejects_token(tmp_path assert not marker.exists() +def test_browser_bootstrap_is_inert_same_origin_html(client): + response = client.get("/api/browser-bootstrap") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + assert response.headers["cache-control"] == "no-store" + assert " target_index and command == "set viewport 412 915" + ] + assert len(target_viewports) == 3 + assert target_index < final_viewport_index < final_screenshot_index + + +def test_capture_retries_when_busy_renderer_rejects_first_screenshot(tmp_path: Path): + result, output, marker, browser_log = _run_helper( + tmp_path, + auth_ok=True, + screenshot_fail_once=True, + ) + + assert result.returncode == 0, result.stderr + assert output.exists() + assert marker.exists() + commands = browser_log.read_text(encoding="utf-8").splitlines() + assert sum(command.startswith("screenshot ") for command in commands) == 3 + + +def test_capture_primes_the_compositor_before_keeping_evidence(tmp_path: Path): + result, output, marker, browser_log = _run_helper(tmp_path, auth_ok=True) + + assert result.returncode == 0, result.stderr + assert output.exists() + assert marker.exists() + commands = browser_log.read_text(encoding="utf-8").splitlines() + screenshots = [ + command for command in commands if command.startswith("screenshot ") + ] + assert len(screenshots) == 2 + assert "/mobius-screenshot-warmup." in screenshots[0] + assert screenshots[1] == f"screenshot {output}" + warmup_index = commands.index(screenshots[0]) + post_warmup_frame = next( + i for i, command in enumerate(commands[warmup_index + 1:], warmup_index + 1) + if command.startswith("eval ") and "requestAnimationFrame" in command + ) + assert warmup_index < post_warmup_frame < commands.index(screenshots[1]) + + +def test_shell_capture_retries_a_solid_background_frame(tmp_path: Path): + result, output, marker, browser_log = _run_helper( + tmp_path, + auth_ok=True, + screenshot_tiny_once=True, + ) + + assert result.returncode == 0, result.stderr + assert output.stat().st_size == 9000 + assert marker.exists() + commands = browser_log.read_text(encoding="utf-8").splitlines() + screenshots = [ + command for command in commands if command.startswith("screenshot ") + ] + assert len(screenshots) == 3, "tiny warm-up, retry, then kept capture" def test_invalid_manual_viewport_fails_before_browser_launch(tmp_path: Path): @@ -283,6 +568,35 @@ def test_non_app_capture_skips_frame_readiness_wait(tmp_path: Path): ) +def test_shell_capture_waits_for_visual_ownership_to_settle(tmp_path: Path): + result, output, marker, browser_log = _run_helper( + tmp_path, auth_ok=True, route="/chat/example", + ) + + assert result.returncode == 0, result.stderr + assert output.exists() + assert marker.exists() + commands = browser_log.read_text(encoding="utf-8").splitlines() + settle_index = next( + i for i, command in enumerate(commands) + if command.startswith("wait --fn ") + and "data-workspace-visual-state" in command + and "first-contentful-paint" in command + ) + settle_command = commands[settle_index] + assert "shell__chat-view--staging" not in settle_command + assert "shell__chat-view--held" not in settle_command + assert "data-mode-motion" not in settle_command + frame_index = next( + i for i, command in enumerate(commands) + if command.startswith("eval ") and "requestAnimationFrame" in command + ) + screenshot_index = next( + i for i, command in enumerate(commands) if command.startswith("screenshot ") + ) + assert settle_index < frame_index < screenshot_index + + def test_content_only_mode_is_set_before_target_navigation(tmp_path: Path): result, output, marker, browser_log = _run_helper( tmp_path, @@ -295,11 +609,9 @@ def test_content_only_mode_is_set_before_target_navigation(tmp_path: Path): assert output.exists() assert marker.exists() commands = browser_log.read_text(encoding="utf-8").splitlines() - visual_mode_index = next( - i for i, command in enumerate(commands) - if command.startswith("eval ") - and "sessionStorage.setItem('mobius:visual-content-only', '1')" in command - ) + seed_index = commands.index("eval --stdin") + seed = (tmp_path / "browser-stdin.log").read_text(encoding="utf-8") + assert 'sessionStorage.setItem("mobius:visual-content-only", "1")' in seed target_index = next( i for i, command in enumerate(commands) if command.startswith("open http://mobius.test/app/42?__mobius_capture=") @@ -313,22 +625,21 @@ def test_content_only_mode_is_set_before_target_navigation(tmp_path: Path): i for i, command in enumerate(commands) if command.startswith("screenshot ") ) - assert visual_mode_index < target_index < readiness_index < screenshot_index + assert seed_index < target_index < readiness_index < screenshot_index def test_default_mode_clears_prior_visual_mode_before_navigation(tmp_path: Path): _, _, _, browser_log = _run_helper(tmp_path, auth_ok=True) commands = browser_log.read_text(encoding="utf-8").splitlines() - clear_index = next( - i for i, command in enumerate(commands) - if "sessionStorage.removeItem('mobius:visual-content-only')" in command - ) + seed_index = commands.index("eval --stdin") + seed = (tmp_path / "browser-stdin.log").read_text(encoding="utf-8") + assert 'sessionStorage.removeItem("mobius:visual-content-only")' in seed target_index = next( i for i, command in enumerate(commands) if command.startswith("open http://mobius.test/chat/example?__mobius_capture=") ) - assert clear_index < target_index + assert seed_index < target_index def test_content_mode_suppresses_modals_without_dom_surgery(): diff --git a/frontend/src/components/Shell/Shell.jsx b/frontend/src/components/Shell/Shell.jsx index 3b177ee09..820a684d9 100644 --- a/frontend/src/components/Shell/Shell.jsx +++ b/frontend/src/components/Shell/Shell.jsx @@ -103,10 +103,12 @@ import * as modeMachine from './modeMachine.js' import { undoKeyPressed, isEditableTarget } from './workspaceOnboarding.js' import PaneChatView from './PaneChatView.jsx' import { + BUILDER_CHAT_WORLD, STANDARD_CHAT_WORLD, deriveChatSurfaceLayers, deriveChatSurfaceOwners, } from './chatSurfaceModel.js' +import { deriveWorkspaceVisualState } from './visualReadiness.js' import { shouldFocusComposerAfterPanePointer, supportsDesktopPaneComposerFocus, @@ -1446,6 +1448,17 @@ export default function Shell() { const chatPaneLayers = useMemo(() => { return deriveChatSurfaceLayers(visibleChatPanes, presentedChatByPane) }, [presentedChatByPane, visibleChatPanes]) + // Shell is the only layer that knows which retained workspace world is + // actually painted. Publish one stable readiness contract for visual tools; + // they must not learn private handoff classes or compositor attributes. + const workspaceVisualState = deriveWorkspaceVisualState({ + modeTransition: modeState.transition, + chatPanesVisible, + chatPaneLayers, + paintedChatWorld: effectiveViewMode === 'single' + ? STANDARD_CHAT_WORLD + : BUILDER_CHAT_WORLD, + }) // ── Synchronous pinned iframe-cache derivation (design §2/§4) ───────────── // renderedAppIds = sortById(visibleAppIds ∪ boundedWarmLRU). Visible ids come @@ -3572,6 +3585,7 @@ export default function Shell() { // beat class, so this is the only external signal it is armed). idle otherwise. data-mode-phase={modeState.transition ? modeState.transition.phase : 'idle'} data-mode-epoch={modeState.transition ? modeState.transition.id : undefined} + data-workspace-visual-state={workspaceVisualState} // The ONE transient beat class comes from the descriptor (INV 1/4): exactly // one of entering/exiting is ever present, and the keyed animationend on // this root completes the beat (the controller's listener). No separate diff --git a/frontend/src/components/Shell/__tests__/visualReadiness.test.js b/frontend/src/components/Shell/__tests__/visualReadiness.test.js new file mode 100644 index 000000000..8a12c4709 --- /dev/null +++ b/frontend/src/components/Shell/__tests__/visualReadiness.test.js @@ -0,0 +1,48 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + WORKSPACE_VISUAL_SETTLED, + WORKSPACE_VISUAL_TRANSITIONING, + deriveWorkspaceVisualState, +} from '../visualReadiness.js' + +const active = world => ({ world, role: 'active' }) +const held = world => ({ world, role: 'held' }) +const staging = world => ({ world, role: 'staging' }) + +test('a workspace mode transition owns visual readiness', () => { + assert.equal(deriveWorkspaceVisualState({ + modeTransition: { id: 4, phase: 'entering' }, + chatPanesVisible: true, + chatPaneLayers: [active('builder')], + paintedChatWorld: 'builder', + }), WORKSPACE_VISUAL_TRANSITIONING) +}) + +test('a handoff in the painted chat world keeps the shell transitioning', () => { + assert.equal(deriveWorkspaceVisualState({ + modeTransition: null, + chatPanesVisible: true, + chatPaneLayers: [held('builder'), staging('builder'), active('standard')], + paintedChatWorld: 'builder', + }), WORKSPACE_VISUAL_TRANSITIONING) +}) + +test('a retained hidden-world handoff does not block the painted world', () => { + assert.equal(deriveWorkspaceVisualState({ + modeTransition: null, + chatPanesVisible: true, + chatPaneLayers: [active('builder'), held('standard'), staging('standard')], + paintedChatWorld: 'builder', + }), WORKSPACE_VISUAL_SETTLED) +}) + +test('a takeover with chat panes hidden is visually settled', () => { + assert.equal(deriveWorkspaceVisualState({ + modeTransition: null, + chatPanesVisible: false, + chatPaneLayers: [held('builder'), staging('builder')], + paintedChatWorld: 'builder', + }), WORKSPACE_VISUAL_SETTLED) +}) diff --git a/frontend/src/components/Shell/visualReadiness.js b/frontend/src/components/Shell/visualReadiness.js new file mode 100644 index 000000000..b5103f0ca --- /dev/null +++ b/frontend/src/components/Shell/visualReadiness.js @@ -0,0 +1,24 @@ +/* Workspace visual readiness keeps automation independent of private DOM classes. */ + +export const WORKSPACE_VISUAL_SETTLED = 'settled' +export const WORKSPACE_VISUAL_TRANSITIONING = 'transitioning' + +/** + * A capture is stable once the workspace mode is idle and the chat world that + * is actually painted has one active owner per surface. Retained hidden worlds + * may remain mid-handoff indefinitely; they are mounted for continuity, not + * visual ownership, and therefore never block capture readiness. + */ +export function deriveWorkspaceVisualState({ + modeTransition, + chatPanesVisible, + chatPaneLayers, + paintedChatWorld, +}) { + if (modeTransition) return WORKSPACE_VISUAL_TRANSITIONING + if (!chatPanesVisible) return WORKSPACE_VISUAL_SETTLED + const paintedHandoff = chatPaneLayers.some(layer => ( + layer.world === paintedChatWorld && layer.role !== 'active' + )) + return paintedHandoff ? WORKSPACE_VISUAL_TRANSITIONING : WORKSPACE_VISUAL_SETTLED +}