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 "