Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions src/audit/protected/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions src/audit/web/frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ export interface LocalLoginScanState {
scan_id: number;
status: LocalLoginScanStatus;
error: string | null;
browser_backgrounded?: boolean;
message?: string;
}

Expand Down
30 changes: 25 additions & 5 deletions src/audit/web/frontend/src/components/LocalLoginScan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)}
/>
<LoginNumberField
Expand Down Expand Up @@ -479,6 +479,25 @@ function LocalLoginForm({ showSteps }: { showSteps: boolean }) {
"Checking whether the configured local vision model is ready…"
}
/>
{!form.skip_vlm && (
<div
className="rounded-xs border border-umich-maize/70 bg-umich-maize/10 p-3 text-sm text-fg"
role="status"
aria-live="polite"
>
<p className="font-semibold">
Local AI—no automatic model downloads
</p>
<p className="mt-1 text-xs text-fg-muted">
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.
</p>
</div>
)}
<Checkbox
checked={form.skip_keyboard}
onChange={(value) => update("skip_keyboard", value)}
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 33 additions & 4 deletions src/audit/web/frontend/src/routes/NewScan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ export default function NewScanRoute() {
const loginSelected = searchParams.get("mode") === "login";
const [form, setForm] = useState<NewScanPayload>({
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,
Expand Down Expand Up @@ -400,10 +400,17 @@ export default function NewScanRoute() {
label="Workers"
value={form.workers}
min={1}
max={16}
max={32}
onChange={(v) => update("workers", v)}
/>
</div>
<p className="text-xs text-fg-muted">
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.
</p>

<fieldset className="rounded-xs border border-border p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-fg-subtle">
Expand Down Expand Up @@ -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."
}
/>
<Checkbox
Expand Down Expand Up @@ -635,6 +642,28 @@ export default function NewScanRoute() {
: "Adds deterministic motion checks. Vision review will remain unavailable until the configured local vision model is installed."
}
/>
{(!form.skip_vlm ||
!form.skip_semantic ||
(!form.skip_visual &&
localAnalysisCapability.data?.vision.available)) && (
<div
className="rounded-xs border border-umich-maize/70 bg-umich-maize/10 p-3 text-sm text-fg"
role="status"
aria-live="polite"
>
<p className="font-semibold">
Local AI—no automatic model downloads
</p>
<p className="mt-1 text-xs text-fg-muted">
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.
</p>
</div>
)}
</div>
</fieldset>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/audit/web/frontend/src/routes/ScanDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 12 additions & 4 deletions src/audit/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand All @@ -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"},
)
Expand Down Expand Up @@ -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")),
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions tests/ui/test_accessibility_axe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading