diff --git a/Makefile b/Makefile
index 3a494e5..636b77e 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: help setup run serve protected-maintenance test test-unit test-integration test-ui quality-gate detection-evals lint lint-fix typecheck migrate migrate-rollback fetch-models fixture-site a11y-check clean frontend-install frontend-lint frontend-build frontend-dev alfa-install desktop-install desktop-setup desktop-run desktop-test desktop-backend desktop-browsers desktop-ocr desktop-package
+.PHONY: help setup run run-stable serve protected-maintenance test test-unit test-integration test-ui quality-gate detection-evals lint lint-fix typecheck migrate migrate-rollback fetch-models fixture-site a11y-check clean frontend-install frontend-lint frontend-build frontend-dev alfa-install desktop-install desktop-setup desktop-run desktop-test desktop-backend desktop-browsers desktop-ocr desktop-package
PY := uv run
DB := data/audit.db
@@ -44,6 +44,9 @@ fixture-site: ## Serve tests/fixtures/site on :8000 for crawler tests
run: ## Start the review UI on http://$(HOST):$(PORT) (local dev, auto-reload)
$(PY) uvicorn audit.web.server:app --host $(HOST) --port $(PORT) --reload
+run-stable: ## Start the local review UI without reloads (safe for long login scans)
+ $(PY) uvicorn audit.web.server:app --host $(HOST) --port $(PORT)
+
# SERVE_HOST defaults to 0.0.0.0 so other devices on your LAN / Tailscale
# net can reach it. Set AUDIT_ACCESS_TOKEN first — see docs/hosting.md.
SERVE_HOST ?= 0.0.0.0
diff --git a/src/audit/protected/session.py b/src/audit/protected/session.py
index 8827815..09621fe 100644
--- a/src/audit/protected/session.py
+++ b/src/audit/protected/session.py
@@ -377,9 +377,12 @@ async def start(self) -> Page:
headless=False,
args=[
"--incognito",
+ "--disable-background-timer-throttling",
"--disable-quic",
"--disable-background-networking",
+ "--disable-backgrounding-occluded-windows",
"--disable-component-update",
+ "--disable-renderer-backgrounding",
"--disable-sync",
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
"--disk-cache-size=1",
@@ -426,6 +429,71 @@ def verify_authenticated_target(self, url: str | None = None) -> ValidatedUrl:
self._state = ManualAuthState.AUTHENTICATED
return verified
+ async def minimize_for_background_scan(self) -> bool:
+ """Minimize the headed sign-in window before reusing its context.
+
+ Chromium cannot switch a live authenticated context from headed to
+ headless mode. Minimizing the existing window preserves the memory-only
+ session while allowing the auditor to keep using the computer. Some
+ macOS Chromium builds ignore the minimized state, so an off-screen
+ window is the verified fallback. CDP window management is best-effort
+ because a platform or Chromium build may not expose a native window.
+ """
+
+ if self._state is not ManualAuthState.AUTHENTICATED:
+ raise ManualAuthenticationError(
+ "Complete and verify manual sign-in before backgrounding the browser."
+ )
+ page = self._page
+ if page is None:
+ return False
+ cdp_session: Any | None = None
+ try:
+ cdp_session = await self.context.new_cdp_session(page)
+ window = await cdp_session.send("Browser.getWindowForTarget")
+ window_id = window.get("windowId")
+ if not isinstance(window_id, int):
+ return False
+ await cdp_session.send(
+ "Browser.setWindowBounds",
+ {
+ "windowId": window_id,
+ "bounds": {"windowState": "minimized"},
+ },
+ )
+ bounds = await cdp_session.send("Browser.getWindowBounds", {"windowId": window_id})
+ native_bounds = bounds.get("bounds", {})
+ if native_bounds.get("windowState") == "minimized":
+ return True
+
+ # macOS may acknowledge but ignore `windowState=minimized`.
+ # Move the persistent scan window out of the working area instead;
+ # Chromium clamps it to a small off-screen edge while keeping the
+ # renderer active for Playwright.
+ await cdp_session.send(
+ "Browser.setWindowBounds",
+ {
+ "windowId": window_id,
+ "bounds": {
+ "windowState": "normal",
+ "left": -10_000,
+ "top": -10_000,
+ "width": 1280,
+ "height": 800,
+ },
+ },
+ )
+ bounds = await cdp_session.send("Browser.getWindowBounds", {"windowId": window_id})
+ native_bounds = bounds.get("bounds", {})
+ left = native_bounds.get("left")
+ return isinstance(left, int) and left < 0
+ except Exception:
+ return False
+ finally:
+ if cdp_session is not None:
+ with contextlib.suppress(Exception):
+ await cdp_session.detach()
+
async def discard_manual_auth_page(self) -> None:
"""Close the original headed tab once its session has been verified.
diff --git a/src/audit/web/frontend/src/api/types.ts b/src/audit/web/frontend/src/api/types.ts
index 0676fc6..85df87f 100644
--- a/src/audit/web/frontend/src/api/types.ts
+++ b/src/audit/web/frontend/src/api/types.ts
@@ -338,6 +338,7 @@ export interface LocalLoginScanState {
scan_id: number;
status: LocalLoginScanStatus;
error: string | null;
+ browser_backgrounded?: boolean;
message?: string;
}
diff --git a/src/audit/web/frontend/src/components/LocalLoginScan.tsx b/src/audit/web/frontend/src/components/LocalLoginScan.tsx
index 418af64..b846f66 100644
--- a/src/audit/web/frontend/src/components/LocalLoginScan.tsx
+++ b/src/audit/web/frontend/src/components/LocalLoginScan.tsx
@@ -51,7 +51,7 @@ function LocalLoginForm({ showSteps }: { showSteps: boolean }) {
>
>({
seed_url: "",
- max_pages: 50,
+ max_pages: 2500,
max_depth: 10,
rps: 1,
whole_host: false,
@@ -301,7 +301,7 @@ function LocalLoginForm({ showSteps }: { showSteps: boolean }) {
label="Max pages"
value={form.max_pages}
min={1}
- max={500}
+ max={2500}
onChange={(value) => update("max_pages", value)}
/>
+ Local AI—no automatic model downloads +
++ No model download will start with this scan. Axcess uses + only the vision model already installed in loopback + Ollama; the option stays disabled when it is missing. + Protected image evidence never goes to a cloud model, + but Ollama will use additional unified memory during + analysis and may temporarily slow other apps. +
++ Workers control local crawl and analysis concurrency; an + M4 Pro can comfortably start at 8 and scale up to 32. + Requests/sec limits traffic sent to the target site, so + increase it only when the site owner has approved the + additional load. +
diff --git a/src/audit/web/frontend/src/routes/ScanDetail.tsx b/src/audit/web/frontend/src/routes/ScanDetail.tsx index f0340a2..02a9bbd 100644 --- a/src/audit/web/frontend/src/routes/ScanDetail.tsx +++ b/src/audit/web/frontend/src/routes/ScanDetail.tsx @@ -59,10 +59,10 @@ export default function ScanDetailRoute() { mutationFn: () => api.createScan({ url: data?.seed_url ?? "", - max_pages: 50, + max_pages: 2500, max_depth: 10, rps: 2, - workers: 4, + workers: 8, include_subdomain: false, whole_host: false, ignore_robots: false, diff --git a/src/audit/web/server.py b/src/audit/web/server.py index 77bbc3c..293d184 100644 --- a/src/audit/web/server.py +++ b/src/audit/web/server.py @@ -119,7 +119,7 @@ class LocalLoginScanRequest(BaseModel): seed_url: str = Field(min_length=12, max_length=2048) approved_auth_origins: tuple[str, ...] = Field(default=(), max_length=32) authorization_acknowledged: bool - max_pages: int = Field(default=100, ge=1, le=500) + max_pages: int = Field(default=2500, ge=1, le=2500) max_depth: int = Field(default=10, ge=1, le=20) rps: float = Field(default=1.0, ge=0.1, le=5.0) whole_host: bool = False @@ -178,6 +178,7 @@ class _LocalLoginRun: confirmation: asyncio.Event status: str = "opening_browser" error: str | None = None + browser_backgrounded: bool = False task: asyncio.Task[Any] | None = None @@ -1008,7 +1009,12 @@ async def api_local_login_scan_status(request: Request, scan_id: int) -> JSONRes run = local_login_runs.get(scan_id) if run is not None: return JSONResponse( - {"scan_id": scan_id, "status": run.status, "error": run.error}, + { + "scan_id": scan_id, + "status": run.status, + "error": run.error, + "browser_backgrounded": run.browser_backgrounded, + }, headers={"Cache-Control": "no-store"}, ) with get_conn() as conn: @@ -1030,6 +1036,7 @@ async def api_local_login_scan_status(request: Request, scan_id: int) -> JSONRes "Start a new login scan." ) ), + "browser_backgrounded": False, }, headers={"Cache-Control": "no-store"}, ) @@ -1110,10 +1117,10 @@ async def api_create_scan( form = { "url": url, - "max_pages": int(body.get("max_pages") or 100), + "max_pages": int(body.get("max_pages") or 2500), "max_depth": int(body.get("max_depth") or 10), "rps": float(body.get("rps") or 2.0), - "workers": int(body.get("workers") or 4), + "workers": int(body.get("workers") or 8), "include_subdomain": bool(body.get("include_subdomain")), "whole_host": bool(body.get("whole_host")), "ignore_robots": bool(body.get("ignore_robots")), @@ -2524,6 +2531,7 @@ async def _run_local_login_background( await run.confirmation.wait() run.status = "verifying_authentication" run.session.verify_authenticated_target() + run.browser_backgrounded = await run.session.minimize_for_background_scan() await run.session.discard_manual_auth_page() # The orchestrator normally constructs these around a fresh browser. diff --git a/tests/ui/test_accessibility_axe.py b/tests/ui/test_accessibility_axe.py index 9ab1a69..a85fd76 100644 --- a/tests/ui/test_accessibility_axe.py +++ b/tests/ui/test_accessibility_axe.py @@ -651,6 +651,13 @@ async def test_login_scan_is_visible_and_explains_login_before_crawl( await playwright_async.expect(use_vlm).to_be_disabled() await use_ocr.check() await playwright_async.expect(use_vlm).to_be_enabled() + await use_vlm.check() + await playwright_async.expect( + page.get_by_text("Local AI—no automatic model downloads", exact=True) + ).to_be_visible() + await playwright_async.expect( + page.get_by_text(re.compile(r"No model download will start with this scan")) + ).to_be_visible() await playwright_async.expect( page.get_by_role( "checkbox", name=re.compile(r"^Store protected image-analysis evidence") diff --git a/tests/unit/test_protected_session.py b/tests/unit/test_protected_session.py index 37c6f03..f5aa4c9 100644 --- a/tests/unit/test_protected_session.py +++ b/tests/unit/test_protected_session.py @@ -198,9 +198,43 @@ def on(self, event: str, handler: object) -> None: self.event_handlers[event] = handler +@dataclass +class _FakeCdpSession: + calls: list[tuple[str, object | None]] = field(default_factory=list) + detached: bool = False + window_state: str = "normal" + left: int = 0 + + async def send(self, method: str, params: object | None = None) -> dict[str, object]: + self.calls.append((method, params)) + if method == "Browser.getWindowForTarget": + return {"windowId": 7} + if method == "Browser.setWindowBounds" and isinstance(params, dict): + bounds = params.get("bounds") + if isinstance(bounds, dict) and bounds.get("left") == -10_000: + # Match Chromium on macOS: it keeps a narrow edge reachable. + self.window_state = "normal" + self.left = -1240 + # Deliberately ignore the first minimized request so the unit test + # exercises the macOS off-screen fallback. + return {} + if method == "Browser.getWindowBounds": + return { + "bounds": { + "windowState": self.window_state, + "left": self.left, + } + } + return {} + + async def detach(self) -> None: + self.detached = True + + @dataclass class _FakeContext: page: _FakePage + cdp_session: _FakeCdpSession = field(default_factory=_FakeCdpSession) route_calls: list[tuple[str, object]] = field(default_factory=list) web_socket_route_calls: list[tuple[str, object]] = field(default_factory=list) event_handlers: dict[str, object] = field(default_factory=dict) @@ -222,6 +256,10 @@ def on(self, event: str, handler: object) -> None: async def new_page(self) -> _FakePage: return self.page + async def new_cdp_session(self, page: _FakePage) -> _FakeCdpSession: + assert page is self.page + return self.cdp_session + async def close(self) -> None: self.closed = True @@ -298,6 +336,9 @@ async def test_manual_session_is_headed_ephemeral_and_tightens_after_target_veri "--force-webrtc-ip-handling-policy=disable_non_proxied_udp" in chromium.context_options["args"] ) + assert "--disable-background-timer-throttling" in chromium.context_options["args"] + assert "--disable-backgrounding-occluded-windows" in chromium.context_options["args"] + assert "--disable-renderer-backgrounding" in chromium.context_options["args"] assert len(context.init_scripts) == 1 assert "RTCPeerConnection" in context.init_scripts[0] assert chromium.user_data_dir is not None @@ -335,6 +376,31 @@ async def test_manual_session_is_headed_ephemeral_and_tightens_after_target_veri assert verified.url == "https://app.example.edu/dashboard" assert session.state is ManualAuthState.AUTHENTICATED + assert await session.minimize_for_background_scan() + assert context.cdp_session.calls == [ + ("Browser.getWindowForTarget", None), + ( + "Browser.setWindowBounds", + {"windowId": 7, "bounds": {"windowState": "minimized"}}, + ), + ("Browser.getWindowBounds", {"windowId": 7}), + ( + "Browser.setWindowBounds", + { + "windowId": 7, + "bounds": { + "windowState": "normal", + "left": -10_000, + "top": -10_000, + "width": 1280, + "height": 800, + }, + }, + ), + ("Browser.getWindowBounds", {"windowId": 7}), + ] + assert context.cdp_session.detached + # The authenticated context remains available for new crawl tabs, but # the original headed sign-in tab is discarded as soon as it is safe. await session.discard_manual_auth_page()