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)} /> + {!form.skip_vlm && ( +
+

+ 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. +

+
+ )} update("skip_keyboard", value)} @@ -658,9 +677,10 @@ function LocalLoginHandoff({ "Axcess is confirming that the browser returned to the approved application.", }, scanning: { - title: "Scanning with your temporary session", - detail: - "The browser context is being reused in memory. Closing it will stop the scan.", + title: "Scanning in the background", + detail: status.data?.browser_backgrounded + ? "The signed-in Chromium window has been moved out of the way while Axcess reuses its in-memory session. You can keep working, but quitting Chromium will stop the scan." + : "Axcess is reusing the signed-in browser session in the background. You can keep working, but closing Chromium will stop the scan.", }, authentication_required: { title: "Sign-in could not be confirmed", diff --git a/src/audit/web/frontend/src/routes/NewScan.tsx b/src/audit/web/frontend/src/routes/NewScan.tsx index d482dbb..e3f3e56 100644 --- a/src/audit/web/frontend/src/routes/NewScan.tsx +++ b/src/audit/web/frontend/src/routes/NewScan.tsx @@ -27,10 +27,10 @@ export default function NewScanRoute() { const loginSelected = searchParams.get("mode") === "login"; const [form, setForm] = useState({ url: searchParams.get("url") ?? "", - max_pages: 50, + max_pages: 2500, max_depth: 10, rps: 2.0, - workers: 4, + workers: 8, include_subdomain: false, whole_host: false, ignore_robots: false, @@ -400,10 +400,17 @@ export default function NewScanRoute() { label="Workers" value={form.workers} min={1} - max={16} + max={32} onChange={(v) => update("workers", v)} /> +

+ 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. +

@@ -554,7 +561,7 @@ export default function NewScanRoute() { hint={ form.static_only ? "Unavailable in static-only mode because Axcess is not rendering pages." - : "Opens Chromium on the computer running Axcess and shows each page while it is rendered and tested. Closing it stops browser-based checks." + : "Leave this off to render invisibly in the background while you use other apps. Turn it on only when you want to watch page navigation; closing it stops browser-based checks." } /> + {(!form.skip_vlm || + !form.skip_semantic || + (!form.skip_visual && + localAnalysisCapability.data?.vision.available)) && ( +
+

+ Local AI—no automatic model downloads +

+

+ No model download will start with this scan. Axcess + uses only models already installed in local Ollama; + unavailable model options stay disabled. Page and + image evidence remains on this computer, but Ollama + may load several GB into unified memory and make + other apps feel slower while analysis is running. +

+
+ )}
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()