diff --git a/.agents/skills/cloakbrowser-manager-development/SKILL.md b/.agents/skills/cloakbrowser-manager-development/SKILL.md new file mode 100644 index 00000000..2af2a24b --- /dev/null +++ b/.agents/skills/cloakbrowser-manager-development/SKILL.md @@ -0,0 +1,148 @@ +--- +name: cloakbrowser-manager-development +description: Use when continuing, reviewing, testing, deploying, or documenting the CloakBrowser Manager fork, especially its mobile VNC workspace, profile organization, scoped access, harness bridge, redacted profile health, VCVM deployment, or release evidence. +metadata: + category: integration-documentation + triggers: + - CloakBrowser Manager + - mobile VNC + - VCVM + - Codex Computer Use + - profile organization + - profile health + - BrowserScan + - proxychecker + - scoped browser access +--- + +# Continue CloakBrowser Manager Development + +Use this repository-local workflow to continue the fork without losing its security boundaries, mobile constraints, or evidence history. + +## Non-negotiable boundaries + +- Work in the CloakBrowser Manager fork only: `https://github.com/Martin-Hausleitner/CloakBrowser-Manager`. +- Treat `https://github.com/CloakHQ/CloakBrowser-Manager` as read-only upstream unless the user explicitly changes that scope. +- Never push CloakBrowser work, reports, or generated files to `fintaro-ai/Fintaro-Agent`. +- Keep the Manager and browser runtime on the VCVM. Use the Mac as a client, validation host, and secure tunnel endpoint. +- Never expose administrator tokens, user passwords, proxy credentials, agent keys, cookies, browser content, raw launch arguments, or host paths in logs, reports, screenshots, commits, or chat. +- Preserve unrelated dirty files. Stage explicit paths and review the staged diff before every commit. +- A preferred harness is metadata, not an execution authority. Host-scoped browser actions must continue to require the verified `codex-computer-use` bridge and fail closed otherwise. +- Profile health is an observation, not an undetectability guarantee. Persist only masked IPs, numeric scores, source states and whitelisted warning/blocker codes; never provider HTML, raw responses or exception text. +- Keep the optional proxychecker on a trusted VCVM-local boundary. Manager `/health` must not depend on it, and an unavailable proxychecker must degrade to an explicit source state rather than block profile launch. +- **External agents steer via API/CLI/skill, not UI clicks.** Profile create/update/launch/stop/open-links must remain callable with a scoped `cbm_agent_…` bearer key. See [references/agent-control.md](references/agent-control.md). + +## Start every continuation + +1. Read the following sources in order: + - `README.md`, especially **Fork development status**. + - `docs/GOAL-ACCEPTANCE-MATRIX-2026-07-22.md`. + - The newest relevant plan in `docs/superpowers/plans/`. + - The relevant audit or benchmark document linked by the matrix. +2. Inspect `git status`, the active branch, both remotes, and recent commits. Do not discard or rewrite unknown changes. +3. Check that `fork` points to Martin Hausleitner's repository and `origin` points to CloakHQ upstream. +4. Select the first incomplete acceptance-matrix row whose prerequisites are already proven. +5. State the exact claim, verification needed, and stop condition before editing. + +If the repository's `bd` issue tool is unavailable, record that limitation in the handoff; do not invent issue state. + +## Implement one vertical slice at a time + +1. Add a failing test for the requested behavior or security boundary. +2. Make the smallest implementation that passes it; reuse existing types, access checks, redaction helpers, and mobile components. +3. Run focused backend or frontend tests immediately. +4. Run the relevant integrated build/gate before widening scope. +5. Update the acceptance matrix only from fresh evidence. +6. Commit the slice with explicit staging. Do not mix reports, generated build metadata, benchmark artifacts, or unrelated changes. + +## Product rules to preserve + +### Mobile UI and VNC + +- Keep the live browser as the dominant surface. +- Use progressive disclosure: persistent controls stay limited to essential session, fullscreen, tools, chat, and send actions. +- Controls may look compact, but actionable hit areas remain at least 44 by 44 CSS pixels. +- Text inputs remain at least 16 px on iOS to prevent focus zoom. +- Derive keyboard-open layout from `visualViewport`; keep both VNC and composer visible and non-overlapping. +- Keep Fit, Width, Height, Phone fit, viewer zoom, editable framebuffer viewport, fullscreen session switcher, and grid behavior reachable. +- Do not add benchmark controls or diagnostic clutter to the mobile workspace. + +### Profiles and access + +- Keep `sandbox_id` as the authorization key. `project_id` and `folder_path` are organization metadata only. +- Preserve deterministic ordering: pinned, project, folder, name, newest creation time, then id. +- Redact proxy, fingerprint, filesystem, and launch-argument details from access summaries. +- Preserve indistinguishable `404` behavior for missing and unauthorized profiles. +- Treat viewer, interaction, operation, and automation permissions as distinct capabilities. + +### Harnesses and automation + +- Keep Codex Computer Use as the execution boundary for browser-visible host actions. +- Saved preferences for Antigravity, Claude Code, OpenCode, or Browser Use may be displayed and propagated as metadata, but must not bypass bridge verification. +- Keep quick actions typed and capability-checked. Reject unknown commands. +- Server-side task history is persistence and conversation state; it is not proof that an agent executed a browser task. +- External agents authenticate with opaque `cbm_agent_…` keys and sandbox grants (`view` / `interact` / `operate` / `automate`). `operate` covers profile create/update/delete/launch/stop inside granted sandboxes; `automate` is separate for CDP. Prefer `scripts/cbm_agent_ctl.py` and [references/agent-control.md](references/agent-control.md) over UI workflows. + +### Profile health and external observations + +- Schedule the automatic probe only after the first successful launch and never await it in the launch response. +- Keep manual reruns behind `operate`; keep stored reads behind `view`; preserve indistinguishable `404` behavior outside scope. +- Mask IPv4 and IPv6 before persistence. Never store or return the raw outbound address. +- Treat saved-versus-runtime fingerprint consistency, proxy risk/authenticity and BrowserScan authenticity as separate measurements. Never substitute one score for another. +- BrowserScan challenge, consent, timeout or unsupported markup returns an unavailable score with a whitelisted blocker. Do not click consent or CAPTCHA controls. +- Do not interpret labels such as `WebDriver` as a detection by themselves; require an explicit negative result. +- Keep health disclosure off the persistent mobile workspace. A compact desktop summary with progressive detail is sufficient. + +## Current verified checkpoint + +Snapshot from 23 July 2026 (late night) on `integrate-pr-47-27-26`: + +- Agent/extension control plane: `GET/POST /api/extension/catalog`, `POST /api/extension/sessions/open`, `GET/PUT /api/extension/defaults`, templates, open-links (`mode=cdp|vnc|shell`), CDP live `/session/{id}/live`, and live-metrics. +- Reuse `GET /api/profiles/{id}/open-links` for VNC/CDP fullscreen URLs — do not invent parallel link builders in UI/extension. +- CDP live root cause + fix: Cloak/Chromium screencast stalls after one frame on static pages; `/session/{id}/live` injects a canvas/rAF compositor pulse (2s keepalive) and keeps real `Page.startScreencast` (screenshot-poll is stall fallback only). VCVM tunnel proof: **blank ~12 fps cast**, **example.com ~21 fps**, Live Dev **~22 fps**. +- Backend suites for session links / extension sessions / defaults / session_views are green locally; VCVM redeployed after this fix. +- Prior 23 July evening checkpoints still hold for proxy inventory, Browser-Use shell, and profile health (fingerprint scores not re-probed in this stream-only slice). +- Safari Remote Automation remains an external blocker. +- Note: Mac tunnel for Manager must forward to VCVM `MANAGER_PORT` (currently **18115**); a stale forward to 18116 breaks client checks. + +Refresh these numbers and claims after any relevant change; this is a dated handoff, not permanent proof. + +## Evidence rules + +Use the release checklist in [references/release-checklist.md](references/release-checklist.md). + +Classify every claim as one of: + +- **Proven**: fresh test or live evidence directly covers the full claim. +- **Implemented; scoped verification passed**: code and focused tests pass, but the required VCVM/device proof is not fresh. +- **Partial**: only part of the requirement is implemented or measured. +- **Blocked externally**: product code is ready, but an external prerequisite such as Safari Remote Automation, Tailnet policy, or physical-device access is unavailable. +- **Missing**: no acceptable implementation or evidence exists. + +Never convert Chromium emulation into a Safari claim, a visual canvas-change proxy into encoded FPS, a WebSocket handshake into touch-to-pixel latency, or a persisted benchmark report into live diagnostics. + +## Documentation and handoff + +Before ending a development session: + +1. Update the README status, completed work, current work, open work, roadmap, timeline, and fresh verification counts. +2. Update the goal matrix row-by-row with exact evidence and remaining proof. +3. Mark completed plan checkboxes and retain unresolved tasks. +4. Record changed files, test results, deployment state, browser/screenshots, latency limits, and external blockers. +5. Commit and push only to the fork branch, then verify the remote branch SHA matches local `HEAD`. +6. Do not provide a URL until an independent browser check has loaded it, exercised the relevant flow, and captured a screenshot. + +## Stop conditions + +Stop and report a blocker only when credentials or external authorization are required, the requested action is destructive, or three safe recovery approaches have failed. Otherwise continue through edit, test, browser validation, documentation, commit, push, and remote verification. + +## Required completion report + +Return: + +1. `Summary` +2. `Changes Made` +3. `Validation Results` +4. `Deployment And Browser Evidence` +5. `Open Gaps` +6. `Fork Branch And Commit` diff --git a/.agents/skills/cloakbrowser-manager-development/references/agent-control.md b/.agents/skills/cloakbrowser-manager-development/references/agent-control.md new file mode 100644 index 00000000..93894a94 --- /dev/null +++ b/.agents/skills/cloakbrowser-manager-development/references/agent-control.md @@ -0,0 +1,105 @@ +# External agent control plane + +Use this when Codex, Antigravity, Claude Code, OpenCode, Browser Use, a Chrome +extension, or any harness must steer CloakBrowser Manager **without UI clicks**. + +The UI is for human visibility. The control plane is HTTP + CLI + this skill. + +## Auth + +1. Admin creates a Paperclip-compatible agent once (dashboard or API): + +```bash +curl -sS -X POST "$CBM_BASE_URL/api/access/agents" \ + -H "Authorization: Bearer $CBM_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "display_name": "codex-ops", + "paperclip_agent_id": "codex-ops", + "grants": [ + {"sandbox_id": "agents", "permission": "operate"}, + {"sandbox_id": "agents", "permission": "automate"} + ] + }' +``` + +2. Store the returned `api_key` (`cbm_agent_…`) once. Only the hash is persisted. + +3. Every agent call uses: + +```text +Authorization: Bearer cbm_agent_… +``` + +Permissions: + +| Grant | Can do | +| --- | --- | +| `view` | List/get profiles in sandbox, open VNC read-only, read health/extensions/open-links | +| `interact` | VNC input + clipboard | +| `operate` | Create/update/delete/organize profiles in that sandbox; launch/stop; health rerun | +| `automate` | CDP REST + CDP WebSocket (does **not** imply launch — also grant `operate`) | + +`sandbox_id` is the authorization key. `project_id` / `folder_path` are organization only. + +## CLI + +```bash +export CBM_BASE_URL=http://127.0.0.1:18117 +export CBM_AGENT_KEY=cbm_agent_… + +scripts/cbm_agent_ctl.py whoami +scripts/cbm_agent_ctl.py profiles create --name demo --sandbox agents --harness codex --geoip +scripts/cbm_agent_ctl.py profiles launch +scripts/cbm_agent_ctl.py profiles open-links --mode cdp --field cdp_fullscreen_url +scripts/cbm_agent_ctl.py profiles open-links --mode vnc --field vnc_fullscreen_url +scripts/cbm_agent_ctl.py profiles status +scripts/cbm_agent_ctl.py profiles stop +``` + +Also: `sandboxes`, `catalog`, `profiles health`, `profiles extensions`, `open-session`. + +## Core HTTP surface + +| Action | Method + path | Min grant | +| --- | --- | --- | +| Identity | `GET /api/access/me` | any | +| Sandboxes | `GET /api/access/sandboxes` | view (filtered) | +| List/create profiles | `GET/POST /api/profiles` | view / operate | +| Get/update/delete | `GET/PUT/DELETE /api/profiles/{id}` | view / operate / operate | +| Launch/stop/status | `POST …/launch`, `POST …/stop`, `GET …/status` | operate / operate / view | +| Health | `GET …/health`, `POST …/health/run` | view / operate | +| Extensions | `GET …/extensions` | view | +| Open links | `GET …/open-links?prefer=local\|cloud&mode=cdp\|vnc\|shell` | view | +| One-click open | `POST /api/extension/sessions/open` | view (+ operate if launching) | +| Catalog | `GET /api/extension/catalog` | any authenticated | +| CDP | `GET/WS /api/profiles/{id}/cdp` | automate | +| VNC | `WS /api/profiles/{id}/vnc` | view (input needs interact) | + +Admin-only (not agent): access users/agents/grants management, proxy inventory ingest, live diagnostics. + +Agent lifecycle (admin): + +| Action | Method + path | +| --- | --- | +| Create | `POST /api/access/agents` (returns one-time `api_key`) | +| Update / deactivate | `PUT /api/access/agents/{id}` (`active=false` revokes WS) | +| Rotate key | `POST /api/access/agents/{id}/rotate-key` | +| Delete / revoke | `DELETE /api/access/agents/{id}` (drops key+grants, revokes WS immediately) | + +## VNC vs CDP open (handoff) + +- **CDP / Browser-Use snappy live:** top-level or `links.cdp_fullscreen_url` / `links.live_url` → `/session/{id}/live` +- **VNC fullscreen:** top-level or `links.vnc_fullscreen_url` → `/?profile={id}&view=vnc&fullscreen=1` +- **Raw sockets:** `links.websocket_url` (VNC WS), `links.cdp_url` / `local.cdp_ws_url` (CDP) +- Prefer `mode=cdp` when the agent has `automate`; otherwise `mode=vnc` +- `POST /api/extension/sessions/open` returns the same top-level flat URLs as `GET /api/profiles/{id}/open-links` + +Never print agent keys, proxy credentials, cookies, or host paths in logs/chat. + +## Boundaries + +- Host-scoped browser actions still require the verified `codex-computer-use` bridge. +- Preferred harness labels are metadata; they do not bypass bridge verification. +- Profile health is observation, not an undetectability guarantee. +- Manager and browsers stay on the VCVM; Mac is client/tunnel only. diff --git a/.agents/skills/cloakbrowser-manager-development/references/release-checklist.md b/.agents/skills/cloakbrowser-manager-development/references/release-checklist.md new file mode 100644 index 00000000..cc814427 --- /dev/null +++ b/.agents/skills/cloakbrowser-manager-development/references/release-checklist.md @@ -0,0 +1,117 @@ +--- +description: Release, VCVM, browser, security, evidence, commit, and push checks for the CloakBrowser Manager fork. +metadata: + tags: [cloakbrowser, vcvm, mobile-vnc, release, verification] + source: internal +--- + +# CloakBrowser Manager release checklist + +Use the smallest focused checks while developing, then run every applicable release layer before claiming the branch is ready. + +## 1. Source and repository safety + +- Confirm the active worktree, branch, `fork`, and `origin` URLs. +- Inspect all dirty and untracked paths; preserve unrelated work. +- Search the staged diff for credentials, bearer tokens, passwords, cookies, proxy URLs, host paths, and raw browser content. +- Run `git diff --check` and inspect `git diff --cached --stat` plus `git diff --cached`. + +## 2. Backend + +Focused examples: + +```bash +.venv/bin/pytest backend/tests/test_models.py backend/tests/test_database.py backend/tests/test_api.py backend/tests/test_access_control.py -q +``` + +Full suite: + +```bash +.venv/bin/pytest -q +``` + +When changing release scripts, compile them with the oldest supported Python runtime as well as the local interpreter. A passing Python 3.14 check does not prove Python 3.11 compatibility. + +## 3. Frontend + +Focused organization, access, mobile, API, and harness checks: + +```bash +cd frontend +npm test -- --run \ + src/App.test.tsx \ + src/components/ProfileList.test.tsx \ + src/components/mobile/MobileSplitScreen.test.tsx \ + src/components/AccessDashboard.test.tsx \ + src/lib/profileOrganization.test.ts \ + src/lib/api.test.ts \ + src/lib/taskHarness.test.ts +npm run build +``` + +Full suite: + +```bash +cd frontend +npm test +npm run build +``` + +## 4. Release scripts and static gates + +Run the repository tests for scripts and deployment surfaces before a live deployment: + +```bash +.venv/bin/pytest scripts -q +python3 scripts/release_acceptance_gate.py --help +python3 scripts/mobile_ui_gate.py --help +python3 scripts/mobile_webkit_gate.py --help +``` + +Use the actual gate options documented by each script for the target environment. Do not weaken a failed or blocked prerequisite. + +## 5. VCVM deployment + +- Deploy only after local unit, integration, build, and static gates pass. +- Confirm FastAPI, React, SQLite, CloakBrowser, KasmVNC/noVNC, and profile storage all run on the VCVM. +- Keep the Manager on loopback or private Tailnet HTTPS; never publish it unauthenticated. +- Verify authenticated profile listing, launch, VNC connection, framebuffer resize/apply, stop, and scoped denial behavior. +- Restore temporary viewport or profile changes made by the gate. + +## 6. Browser and mobile evidence + +At minimum, cover: + +- compact portrait iPhone viewport; +- short iPhone viewport with the software keyboard represented by `visualViewport`; +- large iPhone portrait and landscape; +- touch tablet; +- fullscreen browser preview; +- session grid and switching; +- Fit, Width, Height, Phone fit, viewer zoom, and persisted framebuffer size; +- chat collapse/expand, composer, Copy, Paste, and Capture capability states; +- administrator and scoped operator views; +- no horizontal overflow and no VNC/composer overlap. + +Capture screenshots only after the corresponding assertion passes. Record browser engine and emulation/device limits explicitly. + +## 7. Streaming evidence + +Report separately: + +- VCVM-local HTTP latency; +- VCVM-local VNC WebSocket open and first RFB frame; +- Mac-to-VCVM route and whether Tailscale is direct or DERP-relayed; +- browser-observed first non-black frame; +- visual canvas-change rate, if used. + +Do not call those values encoded FPS or touch-to-pixel latency unless the measurement actually covers those definitions. + +## 8. Documentation, commit, and push + +- Update `README.md` and `docs/GOAL-ACCEPTANCE-MATRIX-2026-07-22.md` with fresh evidence. +- Keep historical evidence labeled by date/revision. +- Commit explicit paths with a conventional message. +- Push only to `fork`. +- Verify `git ls-remote fork ` equals local `git rev-parse HEAD`. +- Report any generated or unrelated paths intentionally left uncommitted. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..51ed0099 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +.git +.gitignore +.gitattributes + +.env +.env.* +*.env +*.token +*token* +.env.vcvm + +.venv +backend/.venv +venv +node_modules +frontend/node_modules + +frontend/dist +dist +build +coverage +htmlcov + +.pytest_cache +.ruff_cache +.mypy_cache +.cache +__pycache__ +*.pyc + +backend/.data +artifacts +benchmarks +docker-compose.guacamole-benchmark.yml +scripts/guacamole_benchmark_config.json +scripts/run_guacamole_benchmark.sh + +.DS_Store +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..9808b8a5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +## [2026-07-24] - Browser-Use, proxies, agent control & live cast + +Fork integration release on `integrate-pr-47-27-26` (merged to `main`). Summarizes the Browser-Use shell, proxy inventory, agent/extension control plane, open-links live path, and P1 API fixes. + +### Added + +- **Browser-Use compact UI** — narrow sidebar, pin, templates / Generate New, mobile settings aligned with normal desktop settings; bulk organize removed from the primary flow. +- **Proxy inventory** — VCVM proxychecker integration, proxy-on-start, geo-aligned auto profiles, and honest risk/auth scoring (no invented authenticity). +- **Comet extension defaults catalog** — harvested defaults shipped in-image / via `EXTENSION_CATALOG_DIR`. +- **Agent control plane** — `cbm_agent` API keys, CLI `cbm_agent_ctl.py`, and operate grants. +- **Extension APIs** — catalog / defaults / templates / sessions / open, plus Chrome extension `extensions/cloak-profile-sync/`. +- **Open-links VNC + CDP** — fullscreen `/session` live path; cast hardening (~12–21 fps) with compositor pulse and screenshot fallback. +- **Live Dev views** — fingerprint / BrowserScan health surfaces and live CDP metrics (FPS/RTT). +- **Steel-browser-inspired open-link patterns** for session launch URLs. +- **Ultragoal vision E2E** — 29/29 on VCVM. + +### Fixed + +- **P1** — flat session open-links (top-level CDP/VNC URLs compatible with open-links clients). +- **P1** — agent `DELETE` revokes immediately. +- **P1** — proxy score honesty (structured proxychecker reasons without inventing authenticity). +- Live CDP screencast stall after first frame (page-target attach, compositor pulse, captureScreenshot fallback). +- BrowserScan label false positives in profile health checks. +- Extension catalog empty on VCVM deploy (config / catalog dir in image). + +### Changed + +- Profile organization and access context (projects, folders, pins) retained in API/schema; UI focus shifted to compact Browser-Use shell. +- Redacted admin live launch / VNC diagnostics for operators without leaking ports, paths, or secrets. + diff --git a/Dockerfile b/Dockerfile index bee234a6..6b0b65c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,9 @@ RUN pip install --no-cache-dir -r /app/backend/requirements.txt # Backend code COPY backend/ /app/backend/ +# Extension catalog metadata (Comet-derived selectable defaults) +COPY config/ /app/config/ + # Frontend build from stage 1 COPY --from=frontend-builder /build/dist /app/frontend/dist diff --git a/README.md b/README.md index 67b2eaf7..0f42b205 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,73 @@ Open [http://localhost:8080](http://localhost:8080) in your browser. Create a pr > **Early alpha** — this project is under active development. Expect bugs. If you find one, please [open an issue](https://github.com/CloakHQ/CloakBrowser-Manager/issues). +## Fork development status + +Status date: **23 July 2026** (late night). This section describes the active development branch `integrate-pr-47-27-26` in Martin Hausleitner's fork. It is intentionally stricter than the upstream feature list: a feature is not called complete merely because its component tests pass. + +### Repository boundaries + +| Role | Exact repository | Write policy | +| --- | --- | --- | +| Active product fork | [Martin-Hausleitner/CloakBrowser-Manager](https://github.com/Martin-Hausleitner/CloakBrowser-Manager) | Development branch and documentation are pushed here. | +| Read-only upstream | [CloakHQ/CloakBrowser-Manager](https://github.com/CloakHQ/CloakBrowser-Manager) | Used for upstream comparison and future rebases; no push from this workflow. | +| Browser engine | [CloakHQ/CloakBrowser](https://github.com/CloakHQ/CloakBrowser) | External runtime dependency; not modified by Manager changes. | +| Explicitly out of scope | [fintaro-ai/Fintaro-Agent](https://github.com/fintaro-ai/Fintaro-Agent) | Never receive CloakBrowser code, reports, commits, or generated artifacts. | + +The repo-local continuation workflow for the next developer is [`.agents/skills/cloakbrowser-manager-development/SKILL.md`](.agents/skills/cloakbrowser-manager-development/SKILL.md). The authoritative row-by-row status is [docs/GOAL-ACCEPTANCE-MATRIX-2026-07-22.md](docs/GOAL-ACCEPTANCE-MATRIX-2026-07-22.md). + +### What is complete + +- **Selected upstream changes are integrated** — PR #47 constrains the browser window to the VNC framebuffer, PR #27 makes the documented local backend start work, and PR #26 adds a per-profile search engine. +- **Read-only extension inventory and agent CLI** — safe extension manifest parser, trust/error state calculation (`valid`, `untrusted_manifest`, `missing_manifest`, `invalid_path`), GET `/api/profiles/{profile_id}/extensions` endpoint, and operator CLI `scripts/inspect_extensions.py`. +- **Compact mobile VNC workspace** — browser-first portrait/landscape layout, fullscreen preview, session grid/switcher, Fit/Width/Height/Phone modes, live viewer zoom, persisted framebuffer viewport changes, collapsible chat, typed Capture/Copy/Paste actions, and `visualViewport` keyboard adaptation. +- **Scoped access foundation** — administrator bootstrap, people and agent identities, access groups, sandbox-scoped grants, separate view/interact/operate/automate capabilities, revocation, audit metadata, VNC input filtering, and scoped REST/CDP/VNC enforcement. +- **Profile organization and preferred harness metadata** — persistent projects, nested folders, pins, color accents, deterministic ordering, compact desktop/mobile presentation, and saved preferences for Codex, Antigravity, Claude Code, OpenCode, and Browser Use. +- **Browser-Use-style desktop shell** — compact sidebar, fixed project choices, harness selector, task home, nicer pin affordances, and a mobile-parity settings overview without weakening the Codex Computer Use execution boundary. +- **VCVM proxy inventory** — admin ingest of `host:port:user:pass` lines, redacted overview UI, Proxy-Checker checks through the trusted VCVM-local boundary, and auto geo-aligned anti-stealth profile creation (`geoip`, timezone/locale defaults) without hand-tuning stealth basics. +- **Redacted profile health foundation** — asynchronous first-launch checks, masked outbound IP, saved-versus-runtime fingerprint consistency, conservative BrowserScan authenticity, optional VCVM-local proxychecker enrichment, access-controlled reruns, and a compact desktop disclosure that does not add mobile clutter. +- **Fail-closed execution boundary** — a saved harness preference never grants execution. Browser-visible host actions still require a capability-verified `codex-computer-use` bridge; server task history remains persistence only. +- **Streaming and competitor research** — reproducible redacted benchmark tooling, a VCVM/Tailscale latency audit, a Safari/WebKit gate that reports missing prerequisites honestly, and an official-source competitor feature matrix. + +### Current verified state + +| Area | State | Fresh or historical evidence | +| --- | --- | --- | +| Profile schema, migration, API, organization, access, health, extension inventory & proxy pool | Implemented; full local suite passed | **371/371 backend tests passed** on 23 July 2026 (proxy inventory + auto profile + prior suites). | +| Desktop/mobile organization, Browser-Use shell, access dashboard, harness boundary and compact health UI | Implemented; full local suite passed | **133/133 frontend tests passed** and the production build passed on 23 July 2026. | +| Release, mobile, streaming and deployment scripts | Full local script suite passed | **26/26 script tests passed**, including an explicit Python 3.11 compilation regression check. | +| Compact mobile workspace and scoped live browser control | Proven on the current automated VCVM Chromium surface | The authenticated release run passed **316 checks** across five viewports plus the access dashboard and captured **31 screenshots**. | +| Browser-path profile health | Proven on a live no-proxy VCVM profile | First-launch scheduling, manual rerun, refresh persistence, masked outbound IP, **100/100 fingerprint consistency**, **100/100 BrowserScan authenticity**, and a redacted desktop panel passed. The Manager container also reached the separately bound VCVM-local proxychecker health endpoint. | +| Admin-only live diagnostics | Implemented; full local suite passed | `GET /api/admin/live-diagnostics` returns launch/VNC counters with measured-or-unavailable metrics, strips ports/paths/URLs/proxy/secrets, rejects non-admin callers with HTTP 403, and leaves the mobile workspace unchanged. | +| Credentialed proxychecker enrichment | Live-proven on VCVM | Restored VCVM-local proxychecker; Manager launched a disposable credentialed-proxy profile; health sources reported `proxychecker: measured` with risk/authenticity scores, masked outbound IP, and no credentials in the API payload. Local Basic-auth forward proxy is intentionally low-authenticity (`warning`). | +| Proxy inventory + auto geo-aligned profiles | Live-proven on VCVM | Admin ingest of **11** inventory entries (credentials never returned), Proxy-Checker check produced redacted scores, auto profile created under `proxied/auto` with `geoip` + locale/timezone defaults; Browser-Use/Proxies UI verified via headless Chromium screenshots. | +| CDP `/session/{id}/live` screencast | Live-proven on VCVM tunnel | Root cause: Chromium emits one frame on static pages. Fix: canvas/rAF compositor pulse + immediate `screencastFrameAck` + 2s pulse keepalive. After redeploy: **about:blank avg ~12 / min ~8 fps** (cast), **example.com avg ~21 / min ~14 fps**, RTT p50 **~139–175 ms**, Live Dev **CDP 22 fps**. Before: **~5.7–7 fps** screenshot-poll. | +| Physical iPhone Safari and private Tailnet HTTPS | Not yet proven | Chromium emulation is not relabeled as Safari evidence; Safari Remote Automation and a physical-device run remain external prerequisites. | +| Direct Tailnet latency | Not achieved in the last measurement | The recorded Mac route used `DERP(nue)`; direct-path proof must be rerun rather than inferred. | + +### What is being worked on next + +| Priority | Feature | Required completion evidence | +| --- | --- | --- | +| P0 | Callable browser backends UI (Browser Use / Browser Harness / Unbrowse / Stagehand) + Proxies / Profiles / Accounts&2FA tabs | Local: harness enum + home settings panel + Accounts derivation tests green. VCVM browser proof of the three tabs and harness cards still required. Execution remains Codex Computer Use only. | +| P0 | Finish the release handoff | Push only the fork branch, verify its SHA and GitHub files, then retain the green release report and screenshot paths as local evidence. | +| P1 | Direct Tailnet route and real iPhone Safari acceptance | Private HTTPS, physical keyboard behavior, touch interaction, direct-versus-DERP route evidence, and honest latency definitions. | +| P1 | Profile organization refinements | Search/filter and safe bulk movement implemented; refresh-stable live E2E after redeploy remains. | +| P2 | Operational polish | Extension templates, health history, recordings/metrics, reusable browser templates, and bulk/API/CLI flows after the security and release gates are stable. | + +### Development timeline + +- **23 July 2026 (late night)** — CDP live stream hardened: canvas/rAF compositor dirty-pulse + ack-first cast + pulse keepalive so `Page.startScreencast` sustains frames on static Cloak pages; screenshot-poll remains stall fallback only. VCVM redeploy proof: blank **~12 fps cast**, example.com **~21 fps**, Live Dev synced via `/live-metrics`. +- **23 July 2026 (night)** — Browser Use home gained first-class preference cards for Browser Harness, Unbrowse, and Stagehand (metadata only); sidebar tabs for Proxies, Profiles, and Accounts & 2FA (redacted session/auth badges + planned Bitwarden/Keypad sync). Focused harness/account tests passed; host execution boundary unchanged. +- **23 July 2026 (late)** — Browser-Use desktop shell, redacted proxy inventory, Proxy-Checker overview, and auto geo-aligned profile creation landed and redeployed to VCVM; 371 backend / 133 frontend tests and production build passed; inventory ingest + checker + auto-profile proven live without leaking credentials into API payloads. +- **23 July 2026** — admin-only live diagnostics landed; bulk profile organization API/UI added; VCVM proxychecker service restored and credentialed `/check` proven against a local Basic-auth proxy; live-diagnostics import fix prepared for redeploy: +- **23 July 2026** — admin-only live diagnostics landed: launch/VNC counters, honest unavailable metrics, redaction tests, and `GET /api/admin/live-diagnostics` without mobile UI clutter. +- **20 July 2026** — mobile VNC workspace, iOS-safe paste, initial E2E gates, scoped Paperclip access, protected mobile login, and private Tailnet fail-closed helper landed. +- **21 July 2026** — mobile controls were progressively simplified; short-iPhone, keyboard, access-dashboard, Safari/WebKit, Codex Computer Use, live viewport, VCVM, streaming, and policy gates were added or hardened. +- **22 July 2026** — compact mobile/access-group work was consolidated; profile project/folder/pin/color/harness metadata, deterministic desktop/mobile organization, and redacted access context were implemented and scoped-tested. A redacted profile-health service, persistence, access-controlled API, desktop disclosure and optional VCVM-local proxychecker boundary were implemented and deployed. PR #47 (VNC window bounds), PR #27 (local backend startup), PR #26 (search engine selection), and a read-only extension inventory with agent CLI (`scripts/inspect_extensions.py`) were integrated and verified across 351 backend tests, 132 frontend tests, 26 script tests, and production build. + +Historical benchmark numbers below remain useful baselines, but they are not a substitute for the fresh release checkpoint above. + ## Why Not Just Use a VPN? A VPN only changes your IP. Incognito only clears cookies. Chrome profiles share the same hardware fingerprint underneath. Platforms use 50+ signals to link your accounts — canvas, WebGL, audio, GPU, fonts, screen size, timezone. @@ -61,7 +128,7 @@ Each CloakBrowser profile generates a completely different device identity. To t - **One-click launch/stop** — each profile runs as an isolated CloakBrowser instance - **Session persistence** — cookies, localStorage, and cache survive browser restarts - **In-browser viewing** — interact with launched browsers via noVNC, directly in the web GUI -- **Mobile task workspace** — browser-first live-VNC split with collapsed chat, central tools, fullscreen shortcuts, editable viewport, visual zoom and an injected Codex Computer Use host bridge +- **Mobile task workspace** — aspect-fitted live VNC with collapsed chat, visually compact icon controls backed by touch-safe hit areas, Visual Viewport keyboard adaptation, one compact tool sheet, typed Quick actions, fullscreen session switching, Fit/Width/Height/Phone modes, live pane/zoom controls, restart-applied profile viewports and a capability-checked Codex Computer Use host contract - **Playwright/Puppeteer API** — connect to any running profile programmatically via CDP, while still watching it live in the browser - **Optional scoped access** — protect the web UI with a bootstrap token, then give people and Paperclip agents only the browser sandboxes they need - **Powered by CloakBrowser** — 32 source-level C++ patches, passes Cloudflare Turnstile, 0.9 reCAPTCHA v3 score @@ -97,9 +164,19 @@ The runner writes JSON plus Markdown reports and separates `measured` candidates The [redacted 20-run report](docs/streaming-benchmark-latest.md) remains the warm r49 product-path baseline: the Manager health endpoint reached a median first byte at **1.438 ms** (p95 **4.005 ms**), and the real KasmVNC/noVNC WebSocket upgrade reached a median **3.457 ms** handshake (p95 **8.931 ms**). That historical report listed Selkies as `not_installed` and Sunshine/Moonlight plus Guacamole as `architecture_only`. -The new pinned r51 Selkies lane is now independently reproducible. In the fresh five-run loopback check, its HTTP shell returned **5/5** successful samples with **2.672 ms median total time** (p95 **3.515 ms**), and `/websockets` returned **5/5** valid upgrades with **2.190 ms median handshake** (p95 **3.722 ms**). This proves transport readiness only: it is not a first-frame, FPS, touch-to-pixel, authenticated product-flow, or mobile-Safari comparison. KasmVNC/noVNC remains the production recommendation because it is still the only candidate integrated and verified through the complete profile, policy, VNC-canvas and mobile-UI path. Full context and limits are in [docs/REMOTE-STREAMING-BENCHMARK.md](docs/REMOTE-STREAMING-BENCHMARK.md). +The fresh r55 comparison provisioned Selkies and Apache Guacamole beside the current product path. Twenty-run shell checks found a **0.756 ms** median Selkies WebSocket upgrade and **1.749 ms** median Guacamole HTTP total time. A separate five-run mobile browser observation measured the first non-black frame at **180 ms median** for the integrated KasmVNC/noVNC product path, **351 ms** for Guacamole and **5,272 ms** for Selkies after four reloads stalled near 5.3 seconds (its first run was 296 ms). These are directional local observations, not interchangeable transport metrics: only KasmVNC/noVNC includes the complete profile, policy, VNC-canvas and mobile-UI chain, and no FPS or WAN touch-to-pixel value is claimed. Full context and limits are in [docs/REMOTE-STREAMING-BENCHMARK.md](docs/REMOTE-STREAMING-BENCHMARK.md). + +### Current VCVM-only acceptance run + +The current deployment runs FastAPI, React, SQLite, CloakBrowser, Xvnc/KasmVNC and the profile data volume on the VCVM. The Mac is only a browser/test client through an SSH tunnel; the Manager itself stays bound to VCVM loopback. Thirty warm VCVM-local requests measured **0.514 ms median / 1.179 ms p95** for `/health` and **2.148 ms / 4.147 ms** for the authenticated profile API. Twenty real VNC proxy connections measured **6.689 ms / 9.989 ms** to WebSocket open and **19.558 ms / 26.837 ms** to the first RFB frame. + +The current Mac-to-VCVM route is degraded by Tailscale relay: `tailscale ping` used `DERP(nue)` at **49–64 ms** and did not establish a direct path. Through the authenticated SSH/Tailscale path, 30 health requests measured **61.8 ms median / 125.6 ms p95**; 15 VNC connections measured **195.2 ms / 266.8 ms** to WebSocket open and **204.6 ms / 328.6 ms** to the first RFB frame. A five-second synthetic moving-page observation produced **6.96 visible canvas changes/s** on VCVM loopback and **4.18/s** through the Mac relay path. This is a visual-update proxy, not an encoded-video FPS or touch-to-pixel claim. The practical latency fix is a direct Tailnet path (shared IPv4 reachability or IPv6 on the Mac side), not more mobile controls. + +The current mobile implementation is browser-first and compact: chat starts collapsed, the normal live pane fits the actual stream aspect instead of reserving empty letterbox space, benchmark controls are absent, and only Full, Tools, Chat and Send remain persistent. Icons and labels are visually small while their interaction areas remain touch-safe; text inputs stay at 16 px so iOS does not focus-zoom them. The root follows `visualViewport.height` while the software keyboard is open, removes nonessential chrome and keeps the VNC pane above the composer instead of letting either slide behind the keyboard. Opening a detail editor temporarily compresses the live pane so width, height and Apply stay reachable on short portrait screens. Fullscreen exposes a compact session switcher, distinct Fit, Width and Height modes, Phone fit, visual zoom and an editable viewport panel. Viewport Apply persists the new size and restarts a running VCVM browser so the changed framebuffer is actually used; pane and noVNC zoom remain immediate viewer-only controls. + +Tools uses progressive disclosure for Quick actions, View, Sessions, Admin and account controls. Quick actions are not URL bookmarks; Capture, Copy and Paste are typed, host-scoped commands enabled only when a verified `codex-computer-use` bridge reports the matching capability. Unknown command kinds are dropped at the boundary, while a missing, generic or mislabeled harness fails closed. The browser gate injects a deterministic test bridge for this contract; it does not claim that the Codex CLI/IDE on the VCVM supplies a production Computer Use browser runtime. Server-backed task history is persistence only and does not execute an agent by itself. -The current r51 mobile implementation is browser-first: chat starts collapsed, browser tools are centralized, benchmark controls are not shown in the UI, and the task composer accepts only a host bridge explicitly identifying itself as `codex-computer-use`. A missing, generic or mislabeled harness fails closed; task submission is not simulated and no direct vendor API key is required in the UI. The authenticated r51 gate passed **276/276 checks** across five mobile viewports plus the access dashboard and produced **23 screenshots**. A separate Codex Computer Use run logged in as a scoped viewer, reached a real connected VNC canvas, confirmed exactly four persistent actions, exercised the task bridge and found no horizontal overflow at `390 x 844`. The same run then used the admin dashboard to combine `operate` with independent CDP `automate` access for a Paperclip agent. The VCVM/Neko Tailnet check remains transport evidence only: Codex Computer Use completed the protected login and observed `/ws`, but WebRTC ICE failed, so no honest FPS value was recorded. Full details and limits are in [docs/MOBILE-STREAMING-AUTH-LATENCY-AUDIT-2026-07-21.md](docs/MOBILE-STREAMING-AUTH-LATENCY-AUDIT-2026-07-21.md). +The last published pre-organization baseline passed **261 backend tests**, **111 frontend tests** and **22 release/mobile gate tests**, plus the frontend production build and VCVM deployment-surface gate. Its VCVM-backed Chromium run passed **318/318 checks** with **31 screenshots** across iPhone 14, iPhone SE, iPhone Pro Max, iPhone landscape, touch tablet and the access dashboard. It explicitly emulated the open software keyboard, checked VNC/composer non-overlap, exercised the fullscreen session grid, applied Phone fit and restored the live profile to its original **665×1114** framebuffer. A separate group-only operator login saw exactly one assigned live profile, exposed no administration and received HTTP 403 for the group-management API. The Safari/WebKit gate records an explicit `blocked` result while Safari Remote Automation remains disabled; Chromium evidence is not relabeled as Safari evidence. The complete UI/UX rationale and ten next input improvements are in [docs/MOBILE-UI-UX-HARNESS-AUDIT-2026-07-21.md](docs/MOBILE-UI-UX-HARNESS-AUDIT-2026-07-21.md); the current official-source competitor matrix is in [docs/COMPETITOR-UI-FEATURE-MATRIX-2026-07-22.md](docs/COMPETITOR-UI-FEATURE-MATRIX-2026-07-22.md), and streaming, auth and Tailnet limits remain documented in [docs/MOBILE-STREAMING-AUTH-LATENCY-AUDIT-2026-07-21.md](docs/MOBILE-STREAMING-AUTH-LATENCY-AUDIT-2026-07-21.md). ## Development @@ -220,11 +297,12 @@ environment: `AUTH_TOKEN` remains the emergency administrator credential and signing secret. Do not give it to normal users or Paperclip agents. With `ACCESS_CONTROL_ENABLED=1`, sign in with that token and open **Browser access controls** in the dashboard to: 1. Assign every profile an `Access sandbox` (for example `research` or `finance`). -2. Create named people with a password and per-sandbox grants. +2. Create named people with a password and either direct per-sandbox grants or membership in reusable access groups. 3. Create a Paperclip agent identity, choose its inherited browser-control tier, independently enable CDP automation when needed, and copy its generated bearer key once into the agent's secret store. -4. Rotate or deactivate a person or agent immediately when access changes. +4. Create groups of people, assign sandbox grants once, and review each person's effective union of direct and group access. +5. Rotate or deactivate a person or agent immediately when access changes. -The access-control implementation and backend test matrix cover profile discovery, VNC, clipboard, launch/stop, CDP HTTP and CDP WebSockets. The r51 dashboard can persist two complementary grants on one sandbox, such as `operate + automate`, and its effective-access disclosure lists the profiles and resulting capabilities before a key is used. The authenticated r51 acceptance run proved that such an agent saw only its `beta` sandbox, reached its CDP endpoint with HTTP 200, passed lifecycle policy to the already-running response, and received HTTP 404 for `alpha`. Denied REST and WebSocket policy decisions are recorded as metadata-only audit events without credentials or browser content. The full suite currently passes **223 backend tests** and **75 frontend tests**. A profile outside a caller's scope still returns the same `404` response as a missing profile. The dashboard is a convenience layer; it is not the security boundary. +The access-control implementation and backend test matrix cover profile discovery, VNC, clipboard, launch/stop, profile-health reads/reruns, CDP HTTP and CDP WebSockets. Direct grants and active group grants are combined server-side into one effective policy; agents remain direct identities and are not group members. The dashboard can persist two complementary grants on one sandbox, such as `operate + automate`, and its effective-access disclosure lists the profiles and resulting capabilities before a key is used. The authenticated acceptance runs proved both a scoped agent and a group-only person saw only their assigned sandbox; the person received HTTP 403 for group administration, while the agent reached scoped CDP and received HTTP 404 for a profile outside its scope. Active VNC/CDP leases are revoked as soon as a user, group membership, group grant, agent, key or direct grant changes. Viewer-only RFB filtering is stateful, restricts negotiated encodings and discards pointer, keyboard and clipboard input before KasmVNC. Denied REST and WebSocket policy decisions are recorded as metadata-only audit events without credentials or browser content. The current local full suites pass **343 backend tests** and **132 frontend tests**, plus the production build; live VCVM evidence is tracked separately above. A profile outside a caller's scope still returns the same `404` response as a missing profile. The dashboard is a convenience layer; it is not the security boundary. | Grant | What it allows | | --- | --- | diff --git a/backend/access_control.py b/backend/access_control.py index c92281dd..2d2bf5e9 100644 --- a/backend/access_control.py +++ b/backend/access_control.py @@ -44,6 +44,7 @@ class AccessIdentity: display_name: str role: str grants: tuple[dict[str, str], ...] = () + group_ids: tuple[str, ...] = () @property def is_admin(self) -> bool: @@ -56,6 +57,8 @@ def public(self) -> dict[str, Any]: "display_name": self.display_name, "role": self.role, "grants": [dict(grant) for grant in self.grants], + "group_ids": list(self.group_ids), + "effective_grants": [dict(grant) for grant in self.grants], } @@ -213,7 +216,8 @@ def resolve_identity(scope: Scope, auth_token: str | None) -> AccessIdentity | N id=user["id"], display_name=user["username"], role=user["role"], - grants=tuple(user.get("grants", [])), + grants=tuple(user.get("effective_grants", user.get("grants", []))), + group_ids=tuple(user.get("group_ids", [])), ) if bearer and bearer.startswith("cbm_agent_"): diff --git a/backend/browser_manager.py b/backend/browser_manager.py index 2ec7509b..ebbfa749 100644 --- a/backend/browser_manager.py +++ b/backend/browser_manager.py @@ -15,8 +15,10 @@ from cloakbrowser import launch_persistent_context_async if __package__: + from . import live_diagnostics from .vnc_manager import VNCManager else: # Support importing browser_manager as a top-level module. + import live_diagnostics from vnc_manager import VNCManager logger = logging.getLogger("cloakbrowser.manager.browser") @@ -69,15 +71,20 @@ def _validate_proxy(url: str) -> None: """Validate that a normalized proxy URL has scheme, host, and port.""" from urllib.parse import urlparse - parsed = urlparse(url) + try: + parsed = urlparse(url) + hostname = parsed.hostname + port = parsed.port + except ValueError: + raise ValueError("Invalid proxy URL") from None if parsed.scheme not in ("http", "https", "socks5"): raise ValueError( f"Invalid proxy scheme '{parsed.scheme}'. Must be http, https, or socks5." ) - if not parsed.hostname: - raise ValueError(f"Proxy URL missing hostname: {url}") - if not parsed.port: - raise ValueError(f"Proxy URL missing port: {url}") + if not hostname: + raise ValueError("Proxy URL missing hostname") + if not port: + raise ValueError("Proxy URL missing port") def _init_profile_defaults(user_data_dir: Path, search_engine: str | None = None) -> None: @@ -192,6 +199,7 @@ def __init__(self): self._launching: set[str] = set() # profile IDs currently being launched self.vnc = VNCManager() self._lock = asyncio.Lock() + self._process_env_lock = asyncio.Lock() self._next_cdp_port = BASE_CDP_PORT self._auto_launch_task: asyncio.Task | None = None @@ -203,6 +211,7 @@ async def launch(self, profile: dict[str, Any]) -> RunningProfile: if profile_id in self.running or profile_id in self._launching: raise RuntimeError(f"Profile {profile_id} is already running") self._launching.add(profile_id) + live_diagnostics.live_diagnostics.mark_launch_started(profile_id) display, ws_port = await self.vnc.allocate() @@ -211,6 +220,7 @@ async def launch(self, profile: dict[str, Any]) -> RunningProfile: except ValueError: async with self._lock: self._launching.discard(profile_id) + live_diagnostics.live_diagnostics.mark_launch_failed(profile_id) await self.vnc.stop_vnc(display) raise @@ -239,30 +249,47 @@ async def launch(self, profile: dict[str, Any]) -> RunningProfile: # Normalize proxy format (host:port:user:pass → http://user:pass@host:port) raw_proxy = profile.get("proxy") or None + if isinstance(raw_proxy, str) and not raw_proxy.strip(): + raw_proxy = None proxy = _normalize_proxy(raw_proxy) if raw_proxy else None + if raw_proxy and not proxy: + raise ValueError("Profile proxy is configured but could not be applied") if proxy: _validate_proxy(proxy) - - # Launch CloakBrowser on that display - # DISPLAY is passed via env kwarg to avoid process-wide os.environ mutation - context = await launch_persistent_context_async( - user_data_dir=profile["user_data_dir"], - headless=bool(profile.get("headless", False)), - proxy=proxy, - args=extra_args, - timezone=profile.get("timezone") or None, - locale=profile.get("locale") or None, - humanize=bool(profile.get("humanize", False)), - human_preset=profile.get("human_preset", "default"), - geoip=bool(profile.get("geoip", False)), - color_scheme=profile.get("color_scheme") or None, - user_agent=profile.get("user_agent") or None, - viewport={ - "width": profile.get("screen_width", 1920), - "height": profile.get("screen_height", 1080) - 133, - }, - env={**os.environ, "DISPLAY": f":{display}"}, - ) + # Also pin Chromium proxy flags so Playwright env gaps cannot + # silently launch without the assigned proxy. + extra_args.append(f"--proxy-server={proxy}") + + # Some CloakBrowser builds do not forward Playwright's env kwarg to + # Chromium, so set DISPLAY around the launch and restore it after. + display_value = f":{display}" + async with self._process_env_lock: + previous_display = os.environ.get("DISPLAY") + os.environ["DISPLAY"] = display_value + try: + context = await launch_persistent_context_async( + user_data_dir=profile["user_data_dir"], + headless=bool(profile.get("headless", False)), + proxy=proxy, + args=extra_args, + timezone=profile.get("timezone") or None, + locale=profile.get("locale") or None, + humanize=bool(profile.get("humanize", False)), + human_preset=profile.get("human_preset", "default"), + geoip=bool(profile.get("geoip", False)), + color_scheme=profile.get("color_scheme") or None, + user_agent=profile.get("user_agent") or None, + viewport={ + "width": profile.get("screen_width", 1920), + "height": profile.get("screen_height", 1080) - 133, + }, + env={**os.environ, "DISPLAY": display_value}, + ) + finally: + if previous_display is None: + os.environ.pop("DISPLAY", None) + else: + os.environ["DISPLAY"] = previous_display await self._fit_window_to_vnc( context, @@ -309,6 +336,7 @@ async def launch(self, profile: dict[str, Any]) -> RunningProfile: async with self._lock: self.running[profile_id] = running self._launching.discard(profile_id) + live_diagnostics.live_diagnostics.mark_launch_succeeded(profile_id) logger.info( "Launched profile %s on display :%d (ws_port=%d, cdp_port=%d)", @@ -320,6 +348,7 @@ async def launch(self, profile: dict[str, Any]) -> RunningProfile: except BaseException: async with self._lock: self._launching.discard(profile_id) + live_diagnostics.live_diagnostics.mark_launch_failed(profile_id) await self.vnc.stop_vnc(display) raise @@ -330,6 +359,7 @@ async def _on_browser_closed(self, profile_id: str): if running: logger.info("Browser closed for profile %s, cleaning up", profile_id) + live_diagnostics.live_diagnostics.mark_stopped(profile_id) await self.vnc.stop_vnc(running.display) async def stop(self, profile_id: str): @@ -342,6 +372,7 @@ async def stop(self, profile_id: str): return logger.info("Stopping profile %s", profile_id) + live_diagnostics.live_diagnostics.mark_stopped(profile_id) try: await running.context.close() @@ -435,8 +466,8 @@ async def auto_launch_all(self): logger.info("Auto-launched profile %s (%s)", profile["name"], profile["id"]) except Exception as exc: logger.error( - "Auto-launch failed for profile %s (%s): %s", - profile["name"], profile["id"], exc, + "Auto-launch failed for profile %s (%s) (%s)", + profile["name"], profile["id"], type(exc).__name__, ) logger.info("Auto-launch complete: %d running", len(self.running)) diff --git a/backend/database.py b/backend/database.py index 0cb26d7e..6728cb97 100644 --- a/backend/database.py +++ b/backend/database.py @@ -15,6 +15,7 @@ ENV_DATA_DIR = "CLOAKBROWSER_MANAGER_DATA_DIR" DOCKER_DATA_DIR = Path("/data") LOCAL_DATA_DIR = Path(__file__).resolve().parent / ".data" +PROFILE_HEALTH_SOURCE_STATES = {"missing", "measured", "derived", "unavailable", "skipped"} def _is_usable_data_dir(path: Path) -> bool: @@ -65,6 +66,11 @@ def init_db(): id TEXT PRIMARY KEY, name TEXT NOT NULL, sandbox_id TEXT NOT NULL DEFAULT 'default', + project_id TEXT NOT NULL DEFAULT 'default', + folder_path TEXT NOT NULL DEFAULT '', + pinned BOOLEAN NOT NULL DEFAULT 0, + accent_color TEXT, + harness TEXT NOT NULL DEFAULT 'codex', fingerprint_seed INTEGER NOT NULL, proxy TEXT, timezone TEXT, @@ -97,6 +103,25 @@ def init_db(): PRIMARY KEY (profile_id, tag) ); + CREATE TABLE IF NOT EXISTS profile_health ( + profile_id TEXT PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, + state TEXT NOT NULL DEFAULT 'unavailable' + CHECK (state IN ('pending', 'running', 'passed', 'warning', 'failed', 'unavailable')), + checked_at TEXT, + proxy_configured BOOLEAN NOT NULL DEFAULT 0, + proxy_reachable BOOLEAN, + outbound_ip_masked TEXT, + proxy_latency_ms REAL, + proxy_risk_score INTEGER, + proxy_authenticity_score INTEGER, + fingerprint_consistency_score INTEGER, + browser_scan_score INTEGER, + warnings_json TEXT NOT NULL DEFAULT '[]', + blockers_json TEXT NOT NULL DEFAULT '[]', + error_code TEXT, + sources_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE IF NOT EXISTS access_users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, @@ -126,6 +151,30 @@ def init_db(): PRIMARY KEY (principal_type, principal_id, sandbox_id, permission) ); + CREATE TABLE IF NOT EXISTS access_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + description TEXT, + active BOOLEAN NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS access_group_members ( + group_id TEXT NOT NULL REFERENCES access_groups(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES access_users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (group_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS access_group_grants ( + group_id TEXT NOT NULL REFERENCES access_groups(id) ON DELETE CASCADE, + sandbox_id TEXT NOT NULL, + permission TEXT NOT NULL CHECK (permission IN ('view', 'interact', 'operate', 'automate')), + created_at TEXT NOT NULL, + PRIMARY KEY (group_id, sandbox_id, permission) + ); + CREATE TABLE IF NOT EXISTS access_audit_events ( id TEXT PRIMARY KEY, actor_type TEXT NOT NULL, @@ -136,6 +185,81 @@ def init_db(): outcome TEXT NOT NULL, created_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS task_sessions ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + sandbox_id TEXT NOT NULL, + title TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + created_by_kind TEXT NOT NULL, + created_by_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}' + ); + + CREATE INDEX IF NOT EXISTS idx_task_sessions_profile + ON task_sessions(profile_id, created_at DESC); + + CREATE INDEX IF NOT EXISTS idx_task_sessions_sandbox + ON task_sessions(sandbox_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS task_messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES task_sessions(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system', 'tool')), + content TEXT NOT NULL, + created_by_kind TEXT NOT NULL, + created_by_id TEXT, + created_at TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}' + ); + + CREATE INDEX IF NOT EXISTS idx_task_messages_session + ON task_messages(session_id, created_at ASC); + + CREATE TABLE IF NOT EXISTS task_events ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES task_sessions(id) ON DELETE CASCADE, + type TEXT NOT NULL, + payload TEXT NOT NULL DEFAULT '{}', + created_by_kind TEXT NOT NULL, + created_by_id TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_task_events_session + ON task_events(session_id, created_at ASC); + + CREATE TABLE IF NOT EXISTS proxy_inventory ( + id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL UNIQUE, + proxy_url TEXT NOT NULL, + host_masked TEXT NOT NULL, + port INTEGER, + username_masked TEXT, + has_credentials BOOLEAN NOT NULL DEFAULT 0, + label TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT 1, + check_state TEXT NOT NULL DEFAULT 'missing' + CHECK (check_state IN ('missing', 'passed', 'warning', 'failed', 'unavailable')), + reachable BOOLEAN, + latency_ms REAL, + risk_score INTEGER, + authenticity_score INTEGER, + country_code TEXT, + timezone_hint TEXT, + locale_hint TEXT, + warnings_json TEXT NOT NULL DEFAULT '[]', + blockers_json TEXT NOT NULL DEFAULT '[]', + last_checked_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_proxy_inventory_active + ON proxy_inventory(active, updated_at DESC); """) conn.commit() @@ -159,6 +283,21 @@ def init_db(): if "sandbox_id" not in cols: conn.execute("ALTER TABLE profiles ADD COLUMN sandbox_id TEXT NOT NULL DEFAULT 'default'") conn.commit() + if "project_id" not in cols: + conn.execute("ALTER TABLE profiles ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'") + conn.commit() + if "folder_path" not in cols: + conn.execute("ALTER TABLE profiles ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''") + conn.commit() + if "pinned" not in cols: + conn.execute("ALTER TABLE profiles ADD COLUMN pinned BOOLEAN NOT NULL DEFAULT 0") + conn.commit() + if "accent_color" not in cols: + conn.execute("ALTER TABLE profiles ADD COLUMN accent_color TEXT") + conn.commit() + if "harness" not in cols: + conn.execute("ALTER TABLE profiles ADD COLUMN harness TEXT NOT NULL DEFAULT 'codex'") + conn.commit() def _now() -> str: @@ -179,14 +318,21 @@ def create_profile( with get_db() as conn: conn.execute( """INSERT INTO profiles ( - id, name, sandbox_id, fingerprint_seed, proxy, timezone, locale, platform, + id, name, sandbox_id, project_id, folder_path, pinned, accent_color, harness, + fingerprint_seed, proxy, timezone, locale, platform, user_agent, screen_width, screen_height, gpu_vendor, gpu_renderer, hardware_concurrency, humanize, human_preset, headless, geoip, clipboard_sync, auto_launch, color_scheme, search_engine, launch_args, notes, user_data_dir, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( - profile_id, name, fields.get("sandbox_id", "default"), seed, + profile_id, name, fields.get("sandbox_id", "default"), + fields.get("project_id", "default"), + fields.get("folder_path", ""), + fields.get("pinned", False), + fields.get("accent_color"), + fields.get("harness", "codex"), + seed, fields.get("proxy"), fields.get("timezone"), fields.get("locale"), @@ -237,7 +383,10 @@ def get_profile(profile_id: str) -> dict[str, Any] | None: def list_profiles() -> list[dict[str, Any]]: with get_db() as conn: - rows = conn.execute("SELECT * FROM profiles ORDER BY created_at DESC").fetchall() + rows = conn.execute( + """SELECT * FROM profiles + ORDER BY pinned DESC, project_id ASC, folder_path ASC, name ASC, created_at DESC, id ASC""" + ).fetchall() profiles = [] for row in rows: profile = dict(row) @@ -266,7 +415,8 @@ def update_profile(profile_id: str, **fields: Any) -> dict[str, Any] | None: fields["launch_args"] = json.dumps(fields["launch_args"] or []) for col in ( - "name", "sandbox_id", "fingerprint_seed", "proxy", "timezone", "locale", "platform", + "name", "sandbox_id", "project_id", "folder_path", "pinned", "accent_color", "harness", + "fingerprint_seed", "proxy", "timezone", "locale", "platform", "user_agent", "screen_width", "screen_height", "gpu_vendor", "gpu_renderer", "hardware_concurrency", "humanize", "human_preset", "headless", "geoip", "clipboard_sync", "auto_launch", "color_scheme", "search_engine", "launch_args", "notes", @@ -277,13 +427,19 @@ def update_profile(profile_id: str, **fields: Any) -> dict[str, Any] | None: if update_cols: update_cols.append("updated_at = ?") - update_vals.append(_now()) + now = _now() + update_vals.append(now) update_vals.append(profile_id) with get_db() as conn: conn.execute( f"UPDATE profiles SET {', '.join(update_cols)} WHERE id = ?", update_vals, ) + if "sandbox_id" in fields: + conn.execute( + "UPDATE task_sessions SET sandbox_id = ?, updated_at = ? WHERE profile_id = ?", + (fields["sandbox_id"], now, profile_id), + ) conn.commit() if tags is not None: @@ -299,6 +455,34 @@ def update_profile(profile_id: str, **fields: Any) -> dict[str, Any] | None: return get_profile(profile_id) +def bulk_organize_profiles( + profile_ids: list[str], + *, + project_id: str | None = None, + folder_path: str | None = None, + pinned: bool | None = None, +) -> list[dict[str, Any]]: + """Apply organization fields to many profiles using the normal update path.""" + if not profile_ids: + return [] + fields: dict[str, Any] = {} + if project_id is not None: + fields["project_id"] = project_id + if folder_path is not None: + fields["folder_path"] = folder_path + if pinned is not None: + fields["pinned"] = pinned + if not fields: + raise ValueError("No organization fields provided") + + updated: list[dict[str, Any]] = [] + for profile_id in profile_ids: + row = update_profile(profile_id, **fields) + if row is not None: + updated.append(row) + return updated + + def delete_profile(profile_id: str) -> bool: with get_db() as conn: cursor = conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,)) @@ -306,6 +490,318 @@ def delete_profile(profile_id: str) -> bool: return cursor.rowcount > 0 +def _json_object(value: str | None) -> dict[str, Any]: + if not value: + return {} + try: + decoded = json.loads(value) + except json.JSONDecodeError: + return {} + return decoded if isinstance(decoded, dict) else {} + + +def _bounded_string_list(value: object, *, max_length: int = 64) -> list[str]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str) and len(item) <= max_length] + + +def _json_string_list(value: str | None) -> list[str]: + if not value: + return [] + try: + decoded = json.loads(value) + except json.JSONDecodeError: + return [] + return _bounded_string_list(decoded) + + +def _bounded_string_map(value: object, *, max_length: int = 64) -> dict[str, str]: + if not isinstance(value, dict): + return {} + return { + key: item + for key, item in value.items() + if isinstance(key, str) + and isinstance(item, str) + and len(key) <= max_length + and len(item) <= max_length + } + + +def _profile_health_from_row(row: sqlite3.Row) -> dict[str, Any]: + health = dict(row) + health["proxy_configured"] = bool(health.get("proxy_configured")) + if health.get("proxy_reachable") is not None: + health["proxy_reachable"] = bool(health["proxy_reachable"]) + health["warnings"] = _json_string_list(health.pop("warnings_json", None)) + health["blockers"] = _json_string_list(health.pop("blockers_json", None)) + sources = _bounded_string_map(_json_object(health.pop("sources_json", None))) + health["sources"] = { + key: value for key, value in sources.items() if value in PROFILE_HEALTH_SOURCE_STATES + } + return health + + +def get_profile_health(profile_id: str) -> dict[str, Any] | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM profile_health WHERE profile_id = ?", + (profile_id,), + ).fetchone() + return _profile_health_from_row(row) if row else None + + +def upsert_profile_health( + profile_id: str, + *, + state: str, + checked_at: str | None = None, + proxy_configured: bool = False, + proxy_reachable: bool | None = None, + outbound_ip_masked: str | None = None, + proxy_latency_ms: float | None = None, + proxy_risk_score: int | None = None, + proxy_authenticity_score: int | None = None, + fingerprint_consistency_score: int | None = None, + browser_scan_score: int | None = None, + warnings: list[str] | None = None, + blockers: list[str] | None = None, + error_code: str | None = None, + sources: dict[str, str] | None = None, +) -> dict[str, Any]: + normalized_warnings = _bounded_string_list(warnings or []) + normalized_blockers = _bounded_string_list(blockers or []) + normalized_sources = { + key: value + for key, value in _bounded_string_map(sources or {}).items() + if value in PROFILE_HEALTH_SOURCE_STATES + } + safe_error_code = error_code if isinstance(error_code, str) and len(error_code) <= 64 else None + safe_masked_ip = ( + outbound_ip_masked + if isinstance(outbound_ip_masked, str) and len(outbound_ip_masked) <= 64 + else None + ) + + with get_db() as conn: + conn.execute( + """INSERT INTO profile_health ( + profile_id, state, checked_at, proxy_configured, proxy_reachable, + outbound_ip_masked, proxy_latency_ms, proxy_risk_score, + proxy_authenticity_score, fingerprint_consistency_score, + browser_scan_score, warnings_json, blockers_json, error_code, sources_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(profile_id) DO UPDATE SET + state = excluded.state, + checked_at = excluded.checked_at, + proxy_configured = excluded.proxy_configured, + proxy_reachable = excluded.proxy_reachable, + outbound_ip_masked = excluded.outbound_ip_masked, + proxy_latency_ms = excluded.proxy_latency_ms, + proxy_risk_score = excluded.proxy_risk_score, + proxy_authenticity_score = excluded.proxy_authenticity_score, + fingerprint_consistency_score = excluded.fingerprint_consistency_score, + browser_scan_score = excluded.browser_scan_score, + warnings_json = excluded.warnings_json, + blockers_json = excluded.blockers_json, + error_code = excluded.error_code, + sources_json = excluded.sources_json""", + ( + profile_id, + state, + checked_at, + proxy_configured, + proxy_reachable, + safe_masked_ip, + proxy_latency_ms, + proxy_risk_score, + proxy_authenticity_score, + fingerprint_consistency_score, + browser_scan_score, + json.dumps(normalized_warnings, separators=(",", ":")), + json.dumps(normalized_blockers, separators=(",", ":")), + safe_error_code, + json.dumps(normalized_sources, separators=(",", ":"), sort_keys=True), + ), + ) + conn.commit() + + health = get_profile_health(profile_id) + if health is None: # pragma: no cover - the successful upsert always creates the row + raise RuntimeError("profile health upsert did not create a row") + return health + + +# ── Task session persistence ──────────────────────────────────────────────── + + +def _task_session_from_row(row: sqlite3.Row) -> dict[str, Any]: + session = dict(row) + session["metadata"] = _json_object(session.get("metadata")) + return session + + +def _task_message_from_row(row: sqlite3.Row) -> dict[str, Any]: + message = dict(row) + message["metadata"] = _json_object(message.get("metadata")) + return message + + +def _task_event_from_row(row: sqlite3.Row) -> dict[str, Any]: + event = dict(row) + event["payload"] = _json_object(event.get("payload")) + return event + + +def create_task_session( + profile_id: str, + sandbox_id: str, + created_by_kind: str, + created_by_id: str | None = None, + title: str | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + session_id = str(uuid.uuid4()) + now = _now() + with get_db() as conn: + conn.execute( + """INSERT INTO task_sessions + (id, profile_id, sandbox_id, title, status, created_by_kind, created_by_id, + created_at, updated_at, metadata) + VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)""", + ( + session_id, + profile_id, + sandbox_id, + title, + created_by_kind, + created_by_id, + now, + now, + json.dumps(metadata or {}, separators=(",", ":")), + ), + ) + conn.commit() + return get_task_session(session_id) # type: ignore[return-value] + + +def get_task_session(session_id: str) -> dict[str, Any] | None: + with get_db() as conn: + row = conn.execute("SELECT * FROM task_sessions WHERE id = ?", (session_id,)).fetchone() + return _task_session_from_row(row) if row else None + + +def list_task_sessions(profile_id: str, limit: int = 100) -> list[dict[str, Any]]: + safe_limit = max(1, min(limit, 200)) + with get_db() as conn: + rows = conn.execute( + """SELECT * FROM task_sessions + WHERE profile_id = ? + ORDER BY created_at DESC + LIMIT ?""", + (profile_id, safe_limit), + ).fetchall() + return [_task_session_from_row(row) for row in rows] + + +def append_task_message( + session_id: str, + role: str, + content: str, + created_by_kind: str, + created_by_id: str | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + message_id = str(uuid.uuid4()) + now = _now() + with get_db() as conn: + conn.execute( + """INSERT INTO task_messages + (id, session_id, role, content, created_by_kind, created_by_id, created_at, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + message_id, + session_id, + role, + content, + created_by_kind, + created_by_id, + now, + json.dumps(metadata or {}, separators=(",", ":")), + ), + ) + conn.execute("UPDATE task_sessions SET updated_at = ? WHERE id = ?", (now, session_id)) + conn.commit() + return get_task_message(message_id) # type: ignore[return-value] + + +def get_task_message(message_id: str) -> dict[str, Any] | None: + with get_db() as conn: + row = conn.execute("SELECT * FROM task_messages WHERE id = ?", (message_id,)).fetchone() + return _task_message_from_row(row) if row else None + + +def list_task_messages(session_id: str, limit: int = 100) -> list[dict[str, Any]]: + safe_limit = max(1, min(limit, 200)) + with get_db() as conn: + rows = conn.execute( + """SELECT * FROM task_messages + WHERE session_id = ? + ORDER BY created_at ASC + LIMIT ?""", + (session_id, safe_limit), + ).fetchall() + return [_task_message_from_row(row) for row in rows] + + +def record_task_event( + session_id: str, + event_type: str, + created_by_kind: str, + created_by_id: str | None = None, + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + event_id = str(uuid.uuid4()) + now = _now() + with get_db() as conn: + conn.execute( + """INSERT INTO task_events + (id, session_id, type, payload, created_by_kind, created_by_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + ( + event_id, + session_id, + event_type, + json.dumps(payload or {}, separators=(",", ":")), + created_by_kind, + created_by_id, + now, + ), + ) + conn.commit() + return get_task_event(event_id) # type: ignore[return-value] + + +def get_task_event(event_id: str) -> dict[str, Any] | None: + with get_db() as conn: + row = conn.execute("SELECT * FROM task_events WHERE id = ?", (event_id,)).fetchone() + return _task_event_from_row(row) if row else None + + +def list_task_events(session_id: str, limit: int = 100) -> list[dict[str, Any]]: + safe_limit = max(1, min(limit, 200)) + with get_db() as conn: + rows = conn.execute( + """SELECT * FROM task_events + WHERE session_id = ? + ORDER BY created_at ASC + LIMIT ?""", + (session_id, safe_limit), + ).fetchall() + return [_task_event_from_row(row) for row in rows] + + # ── Access control persistence ─────────────────────────────────────────────── @@ -319,6 +815,67 @@ def _access_grants(conn: sqlite3.Connection, principal_type: str, principal_id: return [dict(row) for row in rows] +def _access_group_grants(conn: sqlite3.Connection, group_id: str) -> list[dict[str, Any]]: + rows = conn.execute( + """SELECT sandbox_id, permission FROM access_group_grants + WHERE group_id = ? + ORDER BY sandbox_id, permission""", + (group_id,), + ).fetchall() + return [dict(row) for row in rows] + + +def _access_group_member_ids(conn: sqlite3.Connection, group_id: str) -> list[str]: + rows = conn.execute( + """SELECT user_id FROM access_group_members + WHERE group_id = ? + ORDER BY user_id""", + (group_id,), + ).fetchall() + return [str(row["user_id"]) for row in rows] + + +def _access_user_group_ids(conn: sqlite3.Connection, user_id: str, *, active_only: bool = False) -> list[str]: + where = "m.user_id = ?" + if active_only: + where += " AND g.active = 1" + rows = conn.execute( + f"""SELECT g.id FROM access_groups g + JOIN access_group_members m ON m.group_id = g.id + WHERE {where} + ORDER BY g.name COLLATE NOCASE""", + (user_id,), + ).fetchall() + return [str(row["id"]) for row in rows] + + +def _dedupe_grants(grants: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[tuple[str, str]] = set() + deduped: list[dict[str, Any]] = [] + for grant in grants: + key = (str(grant["sandbox_id"]), str(grant["permission"])) + if key in seen: + continue + seen.add(key) + deduped.append({"sandbox_id": key[0], "permission": key[1]}) + return sorted(deduped, key=lambda item: (item["sandbox_id"], item["permission"])) + + +def _access_user_effective_grants(conn: sqlite3.Connection, user_id: str) -> list[dict[str, Any]]: + grants = _access_grants(conn, "user", user_id) + rows = conn.execute( + """SELECT gg.sandbox_id, gg.permission + FROM access_group_grants gg + JOIN access_groups g ON g.id = gg.group_id + JOIN access_group_members m ON m.group_id = g.id + WHERE m.user_id = ? AND g.active = 1 + ORDER BY gg.sandbox_id, gg.permission""", + (user_id,), + ).fetchall() + grants.extend(dict(row) for row in rows) + return _dedupe_grants(grants) + + def set_access_grants(principal_type: str, principal_id: str, grants: list[dict[str, Any]]) -> None: if principal_type not in {"user", "agent"}: raise ValueError("Unsupported access principal type") @@ -338,7 +895,33 @@ def set_access_grants(principal_type: str, principal_id: str, grants: list[dict[ conn.commit() -def create_access_user(username: str, password_hash: str, role: str = "viewer", grants: list[dict[str, Any]] | None = None) -> dict[str, Any]: +def _set_access_user_group_memberships(conn: sqlite3.Connection, user_id: str, group_ids: list[str]) -> None: + now = _now() + conn.execute("DELETE FROM access_group_members WHERE user_id = ?", (user_id,)) + for group_id in group_ids: + conn.execute( + """INSERT INTO access_group_members (group_id, user_id, created_at) + VALUES (?, ?, ?)""", + (group_id, user_id, now), + ) + + +def set_access_user_groups(user_id: str, group_ids: list[str]) -> dict[str, Any] | None: + if not get_access_user(user_id): + return None + with get_db() as conn: + _set_access_user_group_memberships(conn, user_id, group_ids) + conn.commit() + return get_access_user(user_id) + + +def create_access_user( + username: str, + password_hash: str, + role: str = "viewer", + grants: list[dict[str, Any]] | None = None, + group_ids: list[str] | None = None, +) -> dict[str, Any]: user_id = str(uuid.uuid4()) now = _now() with get_db() as conn: @@ -350,6 +933,8 @@ def create_access_user(username: str, password_hash: str, role: str = "viewer", ) conn.commit() set_access_grants("user", user_id, grants or []) + if group_ids is not None: + set_access_user_groups(user_id, group_ids) return get_access_user(user_id) # type: ignore[return-value] @@ -360,6 +945,8 @@ def get_access_user(user_id: str) -> dict[str, Any] | None: return None user = dict(row) user["grants"] = _access_grants(conn, "user", user_id) + user["group_ids"] = _access_user_group_ids(conn, user_id) + user["effective_grants"] = _access_user_effective_grants(conn, user_id) return user @@ -372,6 +959,8 @@ def get_access_user_by_username(username: str) -> dict[str, Any] | None: return None user = dict(row) user["grants"] = _access_grants(conn, "user", user["id"]) + user["group_ids"] = _access_user_group_ids(conn, user["id"]) + user["effective_grants"] = _access_user_effective_grants(conn, user["id"]) return user @@ -382,6 +971,8 @@ def list_access_users() -> list[dict[str, Any]]: for row in rows: user = dict(row) user["grants"] = _access_grants(conn, "user", user["id"]) + user["group_ids"] = _access_user_group_ids(conn, user["id"]) + user["effective_grants"] = _access_user_effective_grants(conn, user["id"]) users.append(user) return users @@ -391,6 +982,7 @@ def update_access_user(user_id: str, **fields: Any) -> dict[str, Any] | None: if not existing: return None grants = fields.pop("grants", None) + group_ids = fields.pop("group_ids", None) update_cols: list[str] = [] values: list[Any] = [] for col in ("password_hash", "role", "active"): @@ -406,9 +998,104 @@ def update_access_user(user_id: str, **fields: Any) -> dict[str, Any] | None: conn.commit() if grants is not None: set_access_grants("user", user_id, grants) + if group_ids is not None: + set_access_user_groups(user_id, group_ids) return get_access_user(user_id) +def _set_access_group_members(conn: sqlite3.Connection, group_id: str, member_user_ids: list[str]) -> None: + now = _now() + conn.execute("DELETE FROM access_group_members WHERE group_id = ?", (group_id,)) + for user_id in member_user_ids: + conn.execute( + """INSERT INTO access_group_members (group_id, user_id, created_at) + VALUES (?, ?, ?)""", + (group_id, user_id, now), + ) + + +def _set_access_group_grants(conn: sqlite3.Connection, group_id: str, grants: list[dict[str, Any]]) -> None: + now = _now() + conn.execute("DELETE FROM access_group_grants WHERE group_id = ?", (group_id,)) + for grant in grants: + conn.execute( + """INSERT INTO access_group_grants (group_id, sandbox_id, permission, created_at) + VALUES (?, ?, ?, ?)""", + (group_id, grant["sandbox_id"], grant["permission"], now), + ) + + +def create_access_group( + name: str, + description: str | None = None, + active: bool = True, + member_user_ids: list[str] | None = None, + grants: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + group_id = str(uuid.uuid4()) + now = _now() + with get_db() as conn: + conn.execute( + """INSERT INTO access_groups + (id, name, description, active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (group_id, name, description, active, now, now), + ) + _set_access_group_members(conn, group_id, member_user_ids or []) + _set_access_group_grants(conn, group_id, grants or []) + conn.commit() + return get_access_group(group_id) # type: ignore[return-value] + + +def get_access_group(group_id: str) -> dict[str, Any] | None: + with get_db() as conn: + row = conn.execute("SELECT * FROM access_groups WHERE id = ?", (group_id,)).fetchone() + if not row: + return None + group = dict(row) + group["member_user_ids"] = _access_group_member_ids(conn, group_id) + group["grants"] = _access_group_grants(conn, group_id) + return group + + +def list_access_groups() -> list[dict[str, Any]]: + with get_db() as conn: + rows = conn.execute("SELECT * FROM access_groups ORDER BY name COLLATE NOCASE").fetchall() + groups: list[dict[str, Any]] = [] + for row in rows: + group = dict(row) + group["member_user_ids"] = _access_group_member_ids(conn, group["id"]) + group["grants"] = _access_group_grants(conn, group["id"]) + groups.append(group) + return groups + + +def update_access_group(group_id: str, **fields: Any) -> dict[str, Any] | None: + existing = get_access_group(group_id) + if not existing: + return None + member_user_ids = fields.pop("member_user_ids", None) + grants = fields.pop("grants", None) + update_cols: list[str] = [] + values: list[Any] = [] + for col in ("name", "description", "active"): + if col in fields: + update_cols.append(f"{col} = ?") + values.append(fields[col]) + with get_db() as conn: + if update_cols: + update_cols.append("updated_at = ?") + values.append(_now()) + values.append(group_id) + conn.execute(f"UPDATE access_groups SET {', '.join(update_cols)} WHERE id = ?", values) + if member_user_ids is not None: + _set_access_group_members(conn, group_id, member_user_ids) + if grants is not None: + _set_access_group_grants(conn, group_id, grants) + conn.commit() + return get_access_group(group_id) + + def create_access_agent( display_name: str, key_hash: str, @@ -485,6 +1172,21 @@ def update_access_agent(agent_id: str, **fields: Any) -> dict[str, Any] | None: return get_access_agent(agent_id) +def delete_access_agent(agent_id: str) -> bool: + """Permanently revoke an agent key and remove its sandbox grants.""" + existing = get_access_agent(agent_id) + if not existing: + return False + with get_db() as conn: + conn.execute( + "DELETE FROM access_grants WHERE principal_type = ? AND principal_id = ?", + ("agent", agent_id), + ) + conn.execute("DELETE FROM access_agents WHERE id = ?", (agent_id,)) + conn.commit() + return True + + def record_access_audit_event( actor_type: str, actor_id: str | None, @@ -501,3 +1203,145 @@ def record_access_audit_event( (str(uuid.uuid4()), actor_type, actor_id, action, sandbox_id, profile_id, outcome, _now()), ) conn.commit() + + +def _proxy_inventory_row(row: sqlite3.Row | None) -> dict[str, Any] | None: + if row is None: + return None + data = dict(row) + for key in ("warnings_json", "blockers_json"): + raw = data.pop(key, "[]") + try: + parsed = json.loads(raw or "[]") + except (TypeError, json.JSONDecodeError): + parsed = [] + data[key.replace("_json", "")] = parsed if isinstance(parsed, list) else [] + data["has_credentials"] = bool(data.get("has_credentials")) + data["active"] = bool(data.get("active")) + # Never return the secret URL from row helpers used by API list paths. + data.pop("proxy_url", None) + return data + + +def upsert_proxy_inventory_entry(proxy_url: str, *, redacted: dict[str, Any]) -> dict[str, Any]: + """Insert or reactivate a proxy inventory row. Secrets stay server-side only.""" + from backend.proxy_inventory import proxy_fingerprint + + fingerprint = proxy_fingerprint(proxy_url) + now = _now() + entry_id = str(uuid.uuid4()) + with get_db() as conn: + existing = conn.execute( + "SELECT id FROM proxy_inventory WHERE fingerprint = ?", + (fingerprint,), + ).fetchone() + if existing: + conn.execute( + """UPDATE proxy_inventory SET + proxy_url = ?, host_masked = ?, port = ?, username_masked = ?, + has_credentials = ?, label = ?, active = 1, updated_at = ? + WHERE fingerprint = ?""", + ( + proxy_url, + redacted.get("host_masked") or "unknown", + redacted.get("port"), + redacted.get("username_masked"), + 1 if redacted.get("has_credentials") else 0, + redacted.get("label") or "proxy", + now, + fingerprint, + ), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM proxy_inventory WHERE fingerprint = ?", + (fingerprint,), + ).fetchone() + return _proxy_inventory_row(row) or {} + + conn.execute( + """INSERT INTO proxy_inventory ( + id, fingerprint, proxy_url, host_masked, port, username_masked, + has_credentials, label, active, check_state, warnings_json, blockers_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'missing', '[]', '[]', ?, ?)""", + ( + entry_id, + fingerprint, + proxy_url, + redacted.get("host_masked") or "unknown", + redacted.get("port"), + redacted.get("username_masked"), + 1 if redacted.get("has_credentials") else 0, + redacted.get("label") or "proxy", + now, + now, + ), + ) + conn.commit() + row = conn.execute("SELECT * FROM proxy_inventory WHERE id = ?", (entry_id,)).fetchone() + return _proxy_inventory_row(row) or {} + + +def list_proxy_inventory(*, include_inactive: bool = False) -> list[dict[str, Any]]: + with get_db() as conn: + if include_inactive: + rows = conn.execute( + "SELECT * FROM proxy_inventory ORDER BY updated_at DESC, label ASC" + ).fetchall() + else: + rows = conn.execute( + """SELECT * FROM proxy_inventory + WHERE active = 1 + ORDER BY updated_at DESC, label ASC""" + ).fetchall() + return [item for item in (_proxy_inventory_row(row) for row in rows) if item] + + +def get_proxy_inventory_entry(entry_id: str, *, include_secret: bool = False) -> dict[str, Any] | None: + with get_db() as conn: + row = conn.execute("SELECT * FROM proxy_inventory WHERE id = ?", (entry_id,)).fetchone() + if row is None: + return None + if include_secret: + data = dict(row) + for key in ("warnings_json", "blockers_json"): + raw = data.pop(key, "[]") + try: + parsed = json.loads(raw or "[]") + except (TypeError, json.JSONDecodeError): + parsed = [] + data[key.replace("_json", "")] = parsed if isinstance(parsed, list) else [] + data["has_credentials"] = bool(data.get("has_credentials")) + data["active"] = bool(data.get("active")) + return data + return _proxy_inventory_row(row) + + +def update_proxy_inventory_check(entry_id: str, summary: dict[str, Any]) -> dict[str, Any] | None: + now = _now() + with get_db() as conn: + conn.execute( + """UPDATE proxy_inventory SET + check_state = ?, reachable = ?, latency_ms = ?, risk_score = ?, + authenticity_score = ?, country_code = ?, timezone_hint = ?, locale_hint = ?, + warnings_json = ?, blockers_json = ?, last_checked_at = ?, updated_at = ? + WHERE id = ?""", + ( + summary.get("check_state") or "unavailable", + summary.get("reachable"), + summary.get("latency_ms"), + summary.get("risk_score"), + summary.get("authenticity_score"), + summary.get("country_code"), + summary.get("timezone_hint"), + summary.get("locale_hint"), + json.dumps(summary.get("warnings") or []), + json.dumps(summary.get("blockers") or []), + now, + now, + entry_id, + ), + ) + conn.commit() + return get_proxy_inventory_entry(entry_id) diff --git a/backend/extension_catalog.py b/backend/extension_catalog.py new file mode 100644 index 00000000..194318eb --- /dev/null +++ b/backend/extension_catalog.py @@ -0,0 +1,183 @@ +"""Comet-derived extension catalog and selectable defaults. + +Catalog metadata is config-driven. Resolved on-disk paths come from +``EXTENSION_CATALOG_DIR`` (synced copies) so VCVM never depends on a Mac +Comet profile path. Credentials and host filesystem secrets stay out of API +payloads — only extension ids, names, and relative path hints are returned. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from backend.database import DATA_DIR + +_REPO_CATALOG = Path(__file__).resolve().parent.parent / "config" / "extension-catalog.json" +_DEFAULTS_FILENAME = "extension-defaults.json" + + +def catalog_dir() -> Path: + configured = (os.environ.get("EXTENSION_CATALOG_DIR") or "").strip() + if configured: + return Path(configured).expanduser() + return DATA_DIR / "extension-catalog" + + +def defaults_path() -> Path: + return DATA_DIR / _DEFAULTS_FILENAME + + +def load_catalog_config() -> dict[str, Any]: + path = Path(os.environ.get("EXTENSION_CATALOG_CONFIG") or _REPO_CATALOG) + if not path.exists(): + return {"source": "comet", "source_label": "Comet", "extensions": []} + with open(path, encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + return {"source": "comet", "source_label": "Comet", "extensions": []} + extensions = payload.get("extensions") + if not isinstance(extensions, list): + payload["extensions"] = [] + return payload + + +def _resolve_extension_path(ext_id: str) -> str | None: + root = catalog_dir() + if not root.exists(): + return None + direct = root / ext_id + if (direct / "manifest.json").exists(): + return str(direct.resolve()) + if direct.is_dir(): + versions = sorted( + (child for child in direct.iterdir() if child.is_dir()), + key=lambda child: child.name, + ) + for version in reversed(versions): + if (version / "manifest.json").exists(): + return str(version.resolve()) + return None + + +def list_catalog_extensions(*, include_paths: bool = True) -> list[dict[str, Any]]: + config = load_catalog_config() + rows: list[dict[str, Any]] = [] + for raw in config.get("extensions") or []: + if not isinstance(raw, dict): + continue + ext_id = str(raw.get("id") or "").strip() + if not ext_id: + continue + path = _resolve_extension_path(ext_id) if include_paths else None + rows.append( + { + "id": ext_id, + "name": str(raw.get("name") or ext_id), + "description": str(raw.get("description") or ""), + "default_selected": bool(raw.get("default_selected")), + "tags": [str(tag) for tag in (raw.get("tags") or []) if isinstance(tag, str)], + "available": bool(path), + "path": path, + "icon_url": raw.get("icon_url") if isinstance(raw.get("icon_url"), str) else None, + } + ) + return rows + + +def _default_selected_ids(catalog: list[dict[str, Any]]) -> list[str]: + return [str(item["id"]) for item in catalog if item.get("default_selected")] + + +def load_selected_ids() -> list[str]: + catalog = list_catalog_extensions(include_paths=False) + known = {item["id"] for item in catalog} + path = defaults_path() + if not path.exists(): + return _default_selected_ids(catalog) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return _default_selected_ids(catalog) + selected = payload.get("selected_ids") if isinstance(payload, dict) else None + if not isinstance(selected, list): + return _default_selected_ids(catalog) + return [str(item) for item in selected if str(item) in known] + + +def save_selected_ids(selected_ids: list[str]) -> list[str]: + catalog = list_catalog_extensions(include_paths=False) + known = {item["id"] for item in catalog} + cleaned = [ext_id for ext_id in selected_ids if ext_id in known] + # Preserve order, drop duplicates + cleaned = list(dict.fromkeys(cleaned)) + path = defaults_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"selected_ids": cleaned, "updated_from": "api"}, indent=2) + "\n", + encoding="utf-8", + ) + return cleaned + + +def defaults_payload() -> dict[str, Any]: + catalog = list_catalog_extensions(include_paths=True) + selected = set(load_selected_ids()) + extensions = [] + for item in catalog: + tags = list(item.get("tags") or []) + entry = { + "id": item["id"], + "name": item["name"], + "description": item["description"] or None, + "category": tags[0] if tags else None, + "tags": tags, + "recommended": bool(item["default_selected"]), + "default_selected": bool(item["default_selected"]), + "selectable": True, + "selected": item["id"] in selected, + "available": bool(item["available"]), + "icon_url": item["icon_url"], + "store_url": None, + } + if item.get("path"): + entry["path"] = item["path"] + extensions.append(entry) + config = load_catalog_config() + selected_ids = [item["id"] for item in extensions if item["selected"]] + return { + "source": str(config.get("source") or "comet"), + "source_label": str(config.get("source_label") or "Comet"), + "catalog_dir_configured": bool( + (os.environ.get("EXTENSION_CATALOG_DIR") or "").strip() or catalog_dir().exists() + ), + "selected_ids": selected_ids, + "extensions": extensions, + "items": extensions, # alias for Chrome sync extension client + "count": len(extensions), + } + + +def selected_load_extension_arg() -> str | None: + """Build a single ``--load-extension=a,b`` arg from selected available paths.""" + selected = set(load_selected_ids()) + paths: list[str] = [] + for item in list_catalog_extensions(include_paths=True): + if item["id"] in selected and item.get("path"): + paths.append(str(item["path"])) + if not paths: + return None + return "--load-extension=" + ",".join(paths) + + +def merge_launch_args_with_defaults(launch_args: list[str] | None) -> list[str]: + """Ensure selected catalog extensions are present in launch_args (API parity).""" + args = [str(arg) for arg in (launch_args or [])] + default_arg = selected_load_extension_arg() + if not default_arg: + return args + # Replace any existing --load-extension=… with the merged selected set. + without = [arg for arg in args if not arg.startswith("--load-extension=")] + return without + [default_arg] diff --git a/backend/extensions.py b/backend/extensions.py new file mode 100644 index 00000000..f8ca089a --- /dev/null +++ b/backend/extensions.py @@ -0,0 +1,152 @@ +"""Extension manifest parsing and read-only inventory inspection.""" + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any, Literal + + +TrustState = Literal["valid", "untrusted_manifest", "missing_manifest", "invalid_path"] + + +@dataclass +class ExtensionManifestInfo: + id: str + path: str + name: str + version: str + manifest_version: int + description: str + permissions: list[str] + trust_state: TrustState + error: str | None + + +def parse_extension_manifest(ext_path_str: str) -> ExtensionManifestInfo: + """Safely inspect a Chrome/Browser extension directory and parse manifest.json.""" + path = Path(ext_path_str).resolve() + if not path.exists() or not path.is_dir(): + return ExtensionManifestInfo( + id=path.name, + path=str(path), + name=path.name, + version="0.0.0", + manifest_version=0, + description="", + permissions=[], + trust_state="invalid_path", + error="Extension path does not exist or is not a directory", + ) + + manifest_file = path / "manifest.json" + if not manifest_file.exists(): + return ExtensionManifestInfo( + id=path.name, + path=str(path), + name=path.name, + version="0.0.0", + manifest_version=0, + description="", + permissions=[], + trust_state="missing_manifest", + error="manifest.json missing in extension directory", + ) + + try: + with open(manifest_file, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as exc: + return ExtensionManifestInfo( + id=path.name, + path=str(path), + name=path.name, + version="0.0.0", + manifest_version=0, + description="", + permissions=[], + trust_state="untrusted_manifest", + error=f"Invalid manifest JSON: {type(exc).__name__}", + ) + + if not isinstance(data, dict): + return ExtensionManifestInfo( + id=path.name, + path=str(path), + name=path.name, + version="0.0.0", + manifest_version=0, + description="", + permissions=[], + trust_state="untrusted_manifest", + error="manifest.json top-level JSON must be an object", + ) + + name = str(data.get("name", path.name)) + version = str(data.get("version", "1.0.0")) + manifest_version_raw = data.get("manifest_version", 2) + try: + manifest_version = int(manifest_version_raw) + except (ValueError, TypeError): + manifest_version = 2 + + description = str(data.get("description", "")) + permissions_raw = data.get("permissions", []) + permissions = ( + [str(p) for p in permissions_raw if isinstance(p, (str, int))] + if isinstance(permissions_raw, list) + else [] + ) + + return ExtensionManifestInfo( + id=path.name, + path=str(path), + name=name, + version=version, + manifest_version=manifest_version, + description=description, + permissions=permissions, + trust_state="valid", + error=None, + ) + + +def extract_load_extension_paths(launch_args: list[str]) -> list[str]: + """Extract unique directory paths from --load-extension launch arguments.""" + paths: list[str] = [] + for arg in launch_args: + if arg.startswith("--load-extension="): + raw = arg.split("=", 1)[1] + for p in raw.split(","): + p_trimmed = p.strip() + if p_trimmed and p_trimmed not in paths: + paths.append(p_trimmed) + return paths + + +def inspect_profile_extensions(profile: dict[str, Any]) -> list[dict[str, Any]]: + """Inspect all extensions configured via --load-extension for a profile.""" + launch_args = profile.get("launch_args") or [] + if isinstance(launch_args, str): + try: + launch_args = json.loads(launch_args) + except Exception: + launch_args = [] + + ext_paths = extract_load_extension_paths(launch_args) + results = [] + for p in ext_paths: + info = parse_extension_manifest(p) + results.append( + { + "id": info.id, + "path": info.path, + "name": info.name, + "version": info.version, + "manifest_version": info.manifest_version, + "description": info.description, + "permissions": info.permissions, + "trust_state": info.trust_state, + "error": info.error, + } + ) + return results diff --git a/backend/live_diagnostics.py b/backend/live_diagnostics.py new file mode 100644 index 00000000..0cea7ea3 --- /dev/null +++ b/backend/live_diagnostics.py @@ -0,0 +1,407 @@ +"""Admin-only live launch and VNC diagnostics. + +Process-local counters and timings for running profiles. Values are intentionally +redacted: no display numbers, ports, paths, URLs, proxy data, or browser content. +Unmeasured timings stay explicitly unavailable rather than being invented. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Literal + +MetricAvailability = Literal["measured", "unavailable"] +ProfileRuntimeStatus = Literal["running", "launching", "stopped"] + +_FORBIDDEN_RESPONSE_KEYS = frozenset( + { + "display", + "ws_port", + "vnc_ws_port", + "cdp_port", + "cdp_url", + "vnc_url", + "url", + "path", + "command", + "proxy", + "token", + "password", + "cookie", + "user_data_dir", + "launch_args", + "stdout", + "stderr", + "log", + } +) + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(ts: float | None) -> str | None: + if ts is None: + return None + return datetime.fromtimestamp(ts, timezone.utc).isoformat().replace("+00:00", "Z") + + +def metric(value: float | int | None) -> dict[str, Any]: + """Return a measured or explicitly unavailable metric payload.""" + if value is None: + return {"availability": "unavailable", "value": None} + if isinstance(value, bool): + return {"availability": "unavailable", "value": None} + if isinstance(value, float): + if value != value or value < 0: # NaN or negative + return {"availability": "unavailable", "value": None} + return {"availability": "measured", "value": round(value, 3)} + if isinstance(value, int) and value < 0: + return {"availability": "unavailable", "value": None} + return {"availability": "measured", "value": value} + + +@dataclass +class _VncSession: + connected_mono: float + connected_wall: float + open_ms: float | None = None + first_framebuffer_ms: float | None = None + saw_framebuffer: bool = False + + +@dataclass +class _ProfileDiagnostics: + profile_id: str + status: ProfileRuntimeStatus = "stopped" + launch_started_mono: float | None = None + launched_at_wall: float | None = None + launch_duration_ms: float | None = None + stopped_at_wall: float | None = None + total_vnc_connections: int = 0 + last_vnc_connected_at_wall: float | None = None + last_vnc_disconnected_at_wall: float | None = None + last_vnc_websocket_open_ms: float | None = None + last_vnc_first_framebuffer_ms: float | None = None + active_sessions: dict[int, _VncSession] = field(default_factory=dict) + + +class LiveDiagnosticsRegistry: + """Thread-safe in-memory registry for admin live diagnostics.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._profiles: dict[str, _ProfileDiagnostics] = {} + self._total_launches = 0 + self._total_vnc_connections = 0 + self._next_session_id = 1 + + def reset(self) -> None: + with self._lock: + self._profiles.clear() + self._total_launches = 0 + self._total_vnc_connections = 0 + self._next_session_id = 1 + + def _get(self, profile_id: str) -> _ProfileDiagnostics: + profile = self._profiles.get(profile_id) + if profile is None: + profile = _ProfileDiagnostics(profile_id=profile_id) + self._profiles[profile_id] = profile + return profile + + def mark_launch_started(self, profile_id: str) -> None: + with self._lock: + profile = self._get(profile_id) + profile.status = "launching" + profile.launch_started_mono = time.monotonic() + profile.stopped_at_wall = None + profile.launch_duration_ms = None + profile.launched_at_wall = None + + def mark_launch_succeeded(self, profile_id: str) -> None: + with self._lock: + profile = self._get(profile_id) + now_mono = time.monotonic() + started = profile.launch_started_mono + profile.status = "running" + profile.launched_at_wall = time.time() + profile.launch_duration_ms = ( + round((now_mono - started) * 1000.0, 3) if started is not None else None + ) + self._total_launches += 1 + + def mark_launch_failed(self, profile_id: str) -> None: + with self._lock: + profile = self._profiles.get(profile_id) + if profile is None: + return + profile.status = "stopped" + profile.stopped_at_wall = time.time() + if not profile.active_sessions and profile.total_vnc_connections == 0: + # Keep failed launch rows only when they already have history. + if profile.launched_at_wall is None and profile.launch_duration_ms is None: + self._profiles.pop(profile_id, None) + + def mark_stopped(self, profile_id: str) -> None: + with self._lock: + profile = self._profiles.get(profile_id) + if profile is None: + return + profile.status = "stopped" + profile.stopped_at_wall = time.time() + profile.active_sessions.clear() + + def begin_vnc_session(self, profile_id: str) -> int: + with self._lock: + profile = self._get(profile_id) + session_id = self._next_session_id + self._next_session_id += 1 + now_mono = time.monotonic() + now_wall = time.time() + profile.active_sessions[session_id] = _VncSession( + connected_mono=now_mono, + connected_wall=now_wall, + ) + profile.total_vnc_connections += 1 + profile.last_vnc_connected_at_wall = now_wall + self._total_vnc_connections += 1 + return session_id + + def mark_vnc_websocket_open(self, profile_id: str, session_id: int) -> None: + with self._lock: + profile = self._profiles.get(profile_id) + if profile is None: + return + session = profile.active_sessions.get(session_id) + if session is None: + return + open_ms = round((time.monotonic() - session.connected_mono) * 1000.0, 3) + session.open_ms = open_ms + profile.last_vnc_websocket_open_ms = open_ms + + def mark_vnc_first_framebuffer(self, profile_id: str, session_id: int) -> None: + with self._lock: + profile = self._profiles.get(profile_id) + if profile is None: + return + session = profile.active_sessions.get(session_id) + if session is None or session.saw_framebuffer: + return + session.saw_framebuffer = True + first_ms = round((time.monotonic() - session.connected_mono) * 1000.0, 3) + session.first_framebuffer_ms = first_ms + profile.last_vnc_first_framebuffer_ms = first_ms + + def end_vnc_session(self, profile_id: str, session_id: int) -> None: + with self._lock: + profile = self._profiles.get(profile_id) + if profile is None: + return + session = profile.active_sessions.pop(session_id, None) + profile.last_vnc_disconnected_at_wall = time.time() + if session is not None: + if session.open_ms is not None: + profile.last_vnc_websocket_open_ms = session.open_ms + if session.first_framebuffer_ms is not None: + profile.last_vnc_first_framebuffer_ms = session.first_framebuffer_ms + + def snapshot(self, *, running_profile_ids: set[str] | None = None) -> dict[str, Any]: + """Return a redacted administrator diagnostics payload.""" + with self._lock: + running_ids = set(running_profile_ids or ()) + active_vnc = sum(len(p.active_sessions) for p in self._profiles.values()) + profiles_out: list[dict[str, Any]] = [] + + # Prefer currently running profiles, then retained history. + ordered_ids = sorted(running_ids) + sorted( + pid for pid in self._profiles if pid not in running_ids + ) + seen: set[str] = set() + for profile_id in ordered_ids: + if profile_id in seen: + continue + seen.add(profile_id) + profile = self._profiles.get(profile_id) + if profile is None: + if profile_id not in running_ids: + continue + profile = _ProfileDiagnostics(profile_id=profile_id, status="running") + + status: ProfileRuntimeStatus + if profile_id in running_ids: + status = "running" if profile.status != "launching" else "launching" + else: + status = "stopped" if profile.status != "launching" else "stopped" + + profiles_out.append( + { + "profile_id": profile_id, + "status": status, + "launched_at": _iso(profile.launched_at_wall), + "stopped_at": ( + _iso(profile.stopped_at_wall) if status == "stopped" else None + ), + "metrics": { + "launch_duration_ms": metric(profile.launch_duration_ms), + "active_vnc_connections": metric(len(profile.active_sessions)), + "total_vnc_connections": metric(profile.total_vnc_connections), + "vnc_websocket_open_ms": metric(profile.last_vnc_websocket_open_ms), + "vnc_first_framebuffer_ms": metric( + profile.last_vnc_first_framebuffer_ms + ), + }, + "timestamps": { + "last_vnc_connected_at": _iso(profile.last_vnc_connected_at_wall), + "last_vnc_disconnected_at": _iso( + profile.last_vnc_disconnected_at_wall + ), + }, + } + ) + + payload = { + "generated_at": _utc_now().isoformat().replace("+00:00", "Z"), + "running_profiles": len(running_ids), + "active_vnc_connections": active_vnc, + "total_launches": self._total_launches, + "total_vnc_connections": self._total_vnc_connections, + "profiles": profiles_out, + } + return redact_diagnostics_payload(payload) + + +def redact_diagnostics_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Drop forbidden keys recursively and keep unavailable metrics explicit.""" + + def _walk(value: Any) -> Any: + if isinstance(value, dict): + cleaned: dict[str, Any] = {} + for key, item in value.items(): + key_l = str(key).lower() + if key_l in _FORBIDDEN_RESPONSE_KEYS or any( + bad in key_l for bad in ("password", "token", "cookie", "secret", "proxy") + ): + continue + cleaned[key] = _walk(item) + return cleaned + if isinstance(value, list): + return [_walk(item) for item in value] + return value + + return _walk(payload) + + +live_diagnostics = LiveDiagnosticsRegistry() + + +class FirstFramebufferDetector: + """Best-effort detector for the first RFB FramebufferUpdate (type 0). + + Used only for live diagnostics. Parse failures leave the metric unavailable + instead of guessing a frame time from arbitrary WebSocket bytes. + """ + + def __init__(self) -> None: + self._phase = "protocol_version" + self._buffer = bytearray() + self.seen = False + + def observe(self, data: bytes) -> bool: + if self.seen or not data: + return self.seen + if len(self._buffer) + len(data) > 8 * 1024 * 1024: + self._buffer.clear() + self._phase = "abandoned" + return False + self._buffer.extend(data) + try: + while self._buffer and not self.seen: + if self._phase == "abandoned": + self._buffer.clear() + return False + if self._phase == "protocol_version": + if len(self._buffer) < 12: + return False + version = bytes(self._buffer[:12]) + if not version.startswith(b"RFB 003.") or version[11:12] != b"\n": + self._phase = "abandoned" + self._buffer.clear() + return False + del self._buffer[:12] + self._phase = "security" + continue + if self._phase == "security": + # SecurityTypes (1 + N) or SecurityResult (4) then ServerInit. + # Accept either None-security shortcut and advance on ServerInit + # by scanning for a plausible desktop-name length once we have + # at least the fixed 24-byte ServerInit prefix later. + if not self._buffer: + return False + # RFB 3.8: number-of-security-types + types, client selects, then + # 4-byte SecurityResult. We cannot fully validate without the + # client stream, so wait until we see a SecurityResult of 0 and + # then treat the remainder as ServerInit. + if self._buffer[0] == 0 and len(self._buffer) >= 5: + # failure reason path — abandon timing rather than guess + self._phase = "abandoned" + self._buffer.clear() + return False + if len(self._buffer) >= 1 and self._buffer[0] > 0: + count = self._buffer[0] + need = 1 + count + if len(self._buffer) < need: + return False + del self._buffer[:need] + self._phase = "security_result" + continue + # RFB <= 3.3 style 4-byte security type + if len(self._buffer) >= 4 and self._buffer[0] == 0 and self._buffer[1] == 0: + del self._buffer[:4] + self._phase = "server_init" + continue + return False + if self._phase == "security_result": + if len(self._buffer) < 4: + return False + result = int.from_bytes(self._buffer[:4], "big") + del self._buffer[:4] + if result != 0: + self._phase = "abandoned" + self._buffer.clear() + return False + self._phase = "server_init" + continue + if self._phase == "server_init": + if len(self._buffer) < 24: + return False + name_len = int.from_bytes(self._buffer[20:24], "big") + total = 24 + name_len + if name_len < 0 or name_len > 1_000_000: + self._phase = "abandoned" + self._buffer.clear() + return False + if len(self._buffer) < total: + return False + del self._buffer[:total] + self._phase = "normal" + continue + if self._phase == "normal": + message_type = self._buffer[0] + if message_type == 0: + self.seen = True + self._buffer.clear() + return True + # Skip unknown/non-framebuffer traffic without claiming a frame. + self._buffer.clear() + return False + return False + except Exception: + self._phase = "abandoned" + self._buffer.clear() + return False + return self.seen diff --git a/backend/main.py b/backend/main.py index 120da818..8462f2e1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -16,14 +16,15 @@ import shutil import time from contextlib import asynccontextmanager +from dataclasses import dataclass from http.cookies import SimpleCookie from pathlib import Path from typing import Any from urllib.parse import urlparse import httpx -from fastapi import FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse, JSONResponse +from fastapi import FastAPI, HTTPException, Query, Request, Response, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles import starlette.requests from starlette.types import ASGIApp, Receive, Scope, Send @@ -31,49 +32,129 @@ if __package__: from . import access_control as access from . import database as db + from . import extensions + from . import live_diagnostics from .browser_manager import BrowserManager + from .profile_health import ProfileHealthProbe from .models import ( ClipboardRequest, AccessAgentCreate, AccessAgentCreatedResponse, AccessAgentResponse, AccessAgentUpdate, + AccessGroupCreate, + AccessGroupResponse, + AccessGroupUpdate, AccessIdentityResponse, AccessUserCreate, AccessUserResponse, AccessUserUpdate, + ExtensionCatalogResponse, + ExtensionDefaultsResponse, + ExtensionDefaultsUpdate, + ExtensionTemplatesResponse, + ExtensionTemplateItem, + ExtensionInventoryResponse, + ExtensionItem, + ExtensionOpenSessionRequest, + ExtensionOpenSessionResponse, + ExtensionProfileSummary, LaunchResponse, + LiveMetricsResponse, + LiveMetricsSample, LoginRequest, ProfileCreate, + ProfileHealthResponse, + ProfileOpenLinksResponse, ProfileResponse, ProfileStatusResponse, + ProfileBulkOrganize, + ProfileTemplateCreate, + ProfileTemplateSummary, ProfileUpdate, + ProxyAutoProfileCreate, + ProxyInventoryIngest, + ProxyInventoryIngestResponse, + ProxyInventoryItem, + SessionOpenLinks, StatusResponse, TagResponse, + TaskCommandRequest, + TaskEventResponse, + TaskMessageCreate, + TaskMessageResponse, + TaskSessionCreate, + TaskSessionResponse, ) + from . import proxy_inventory + from . import session_links + from . import session_views + from . import extension_catalog + from . import profile_templates + from . import stream_metrics else: # Support `uvicorn main:app` from the backend directory. import access_control as access import database as db + import extensions + import live_diagnostics from browser_manager import BrowserManager + from profile_health import ProfileHealthProbe from models import ( ClipboardRequest, AccessAgentCreate, AccessAgentCreatedResponse, AccessAgentResponse, AccessAgentUpdate, + AccessGroupCreate, + AccessGroupResponse, + AccessGroupUpdate, AccessIdentityResponse, AccessUserCreate, AccessUserResponse, AccessUserUpdate, + ExtensionCatalogResponse, + ExtensionDefaultsResponse, + ExtensionDefaultsUpdate, + ExtensionTemplatesResponse, + ExtensionTemplateItem, + ExtensionInventoryResponse, + ExtensionItem, + ExtensionOpenSessionRequest, + ExtensionOpenSessionResponse, + ExtensionProfileSummary, LaunchResponse, + LiveMetricsResponse, + LiveMetricsSample, LoginRequest, ProfileCreate, + ProfileHealthResponse, + ProfileOpenLinksResponse, ProfileResponse, ProfileStatusResponse, + ProfileBulkOrganize, + ProfileTemplateCreate, + ProfileTemplateSummary, ProfileUpdate, + ProxyAutoProfileCreate, + ProxyInventoryIngest, + ProxyInventoryIngestResponse, + ProxyInventoryItem, + SessionOpenLinks, StatusResponse, TagResponse, + TaskCommandRequest, + TaskEventResponse, + TaskMessageCreate, + TaskMessageResponse, + TaskSessionCreate, + TaskSessionResponse, ) + import proxy_inventory + import session_links + import session_views + import extension_catalog + import profile_templates + import stream_metrics logger = logging.getLogger("cloakbrowser.manager") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") @@ -102,6 +183,76 @@ _LOGIN_FAILURE_TTL_SECONDS = 10 * 60.0 _LOGIN_FAILURE_MAX_KEYS = 1024 _login_failures: dict[tuple[str, str], tuple[int, float, float]] = {} +_TASK_METADATA_MAX_BYTES = 8_192 +_TASK_METADATA_MAX_DEPTH = 4 +_TASK_METADATA_MAX_LIST_ITEMS = 20 +_TASK_SENSITIVE_KEY_PARTS = ( + "authorization", + "cookie", + "credential", + "password", + "secret", + "token", + "api_key", + "apikey", +) + + +@dataclass(eq=False) +class _WebSocketAccessLease: + """Process-local handle used to revoke an already-authorized WebSocket.""" + + identity_kind: str + identity_id: str + profile_id: str + revoked: asyncio.Event + + +_active_websocket_access_leases: set[_WebSocketAccessLease] = set() + + +def _register_websocket_access( + identity: access.AccessIdentity, profile_id: str +) -> _WebSocketAccessLease | None: + """Track mutable principals without adding authorization work per frame.""" + if ( + not ACCESS_CONTROL_ENABLED + or identity.kind not in {"user", "agent"} + or not identity.id + ): + return None + lease = _WebSocketAccessLease( + identity_kind=identity.kind, + identity_id=identity.id, + profile_id=profile_id, + revoked=asyncio.Event(), + ) + _active_websocket_access_leases.add(lease) + return lease + + +def _unregister_websocket_access(lease: _WebSocketAccessLease | None) -> None: + if lease is not None: + _active_websocket_access_leases.discard(lease) + + +def _revoke_websocket_access( + *, + identity_kind: str | None = None, + identity_id: str | None = None, + profile_id: str | None = None, +) -> None: + """Signal matching proxy tasks after a principal or sandbox policy change.""" + if identity_kind is None and identity_id is None and profile_id is None: + return + for lease in tuple(_active_websocket_access_leases): + if identity_kind is not None and lease.identity_kind != identity_kind: + continue + if identity_id is not None and lease.identity_id != identity_id: + continue + if profile_id is not None and lease.profile_id != profile_id: + continue + lease.revoked.set() _BENCHMARK_REPORT_ENV = "BENCHMARK_REPORT_PATH" _DEFAULT_BENCHMARK_REPORT_PATH = Path("/data/benchmark-report.json") @@ -617,6 +768,20 @@ async def _reject_unauthenticated(scope: Scope, receive: Receive, send: Send) -> # Singleton browser manager browser_mgr = BrowserManager() +_profile_health_allowed_hosts = { + host.strip() + for host in os.environ.get("PROXYCHECKER_ALLOWED_HOSTS", "").split(",") + if host.strip() +} +try: + profile_health_probe = ProfileHealthProbe( + proxychecker_url=os.environ.get("PROXYCHECKER_URL", ""), + allowed_proxychecker_hosts=_profile_health_allowed_hosts, + ) +except ValueError: + logger.error("Ignoring invalid PROXYCHECKER_URL; profile health enrichment is disabled") + profile_health_probe = ProfileHealthProbe() +_profile_health_tasks: dict[str, asyncio.Task[None]] = {} # Frontend build directory (React production build) FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" @@ -666,6 +831,250 @@ def _build_server_cut_text(text: str) -> bytes: return struct.pack(">BxxxI", 3, len(text_bytes)) + text_bytes +def _filter_vnc_server_message( + data: bytes, *, can_interact: bool +) -> bytes | None: + """Translate a frame-aligned Kasm clipboard message for operators.""" + if not data: + return data + + if data[0] == 180: + if not can_interact: + return None + text = _parse_kasmvnc_clipboard(data) + return _build_server_cut_text(text) if text is not None else None + + return data + + +_RFB_SERVER_MAX_BUFFER = 64 * 1024 * 1024 + + +class _RfbServerProtocolError(ValueError): + """Raised when a viewer stream cannot be parsed without leaking data.""" + + +class _RfbServerStreamFilter: + """Stateful server→client RFB filter for view-only connections. + + RFB is a byte stream, so WebSocket messages may split or coalesce RFB + messages. The filter buffers until it can identify complete top-level + messages and framebuffer rectangles. Clipboard messages are removed at + RFB boundaries; unknown message types or encodings fail closed instead of + forwarding bytes that might contain clipboard content. + """ + + def __init__(self, *, can_interact: bool, handshake_complete: bool = False): + self.can_interact = can_interact + self._phase = "normal" if handshake_complete else "protocol_version" + self._protocol_minor = 8 + self._pixel_size = 4 + self._buffer = bytearray() + self.last_dropped_clipboard = False + self.last_saw_framebuffer = False + + @staticmethod + def _bounded_length(length: int, label: str) -> int: + if length < 0 or length > _RFB_SERVER_MAX_BUFFER: + raise _RfbServerProtocolError(f"Invalid {label} length") + return length + + def _handshake_message_length(self) -> int | None: + data = self._buffer + if self._phase == "protocol_version": + if len(data) < 12: + return None + version = bytes(data[:12]) + if not version.startswith(b"RFB 003.") or version[11:12] != b"\n": + raise _RfbServerProtocolError("Invalid RFB protocol version") + try: + self._protocol_minor = int(version[8:11]) + except ValueError as exc: + raise _RfbServerProtocolError("Invalid RFB protocol version") from exc + self._phase = "security_types" + return 12 + + if self._phase == "security_types": + if self._protocol_minor <= 3: + if len(data) < 4: + return None + security_type = struct.unpack_from(">I", data, 0)[0] + if security_type != _RFB_SECURITY_TYPE_NONE: + raise _RfbServerProtocolError("Unsupported RFB security type") + self._phase = "server_init" + return 4 + + if not data: + return None + count = data[0] + if count == 0: + if len(data) < 5: + return None + reason_length = self._bounded_length( + struct.unpack_from(">I", data, 1)[0], "RFB failure reason" + ) + total = 5 + reason_length + if len(data) < total: + return None + self._phase = "failed" + return total + total = 1 + count + if len(data) < total: + return None + if _RFB_SECURITY_TYPE_NONE not in data[1:total]: + raise _RfbServerProtocolError("Unsupported RFB security types") + self._phase = "security_result" + return total + + if self._phase == "security_result": + if len(data) < 4: + return None + result = struct.unpack_from(">I", data, 0)[0] + self._phase = "server_init" if result == 0 else "failed" + return 4 + + if self._phase == "server_init": + if len(data) < 24: + return None + name_length = self._bounded_length( + struct.unpack_from(">I", data, 20)[0], "RFB desktop name" + ) + total = 24 + name_length + if len(data) < total: + return None + bits_per_pixel = data[4] + self._pixel_size = 1 if bits_per_pixel == 8 else 4 + self._phase = "normal" + return total + + if self._phase == "failed": + raise _RfbServerProtocolError("RFB negotiation failed") + return None + + def _rectangle_payload_length( + self, + data: bytearray, + offset: int, + width: int, + height: int, + encoding: int, + ) -> int | None: + if encoding == 0: # Raw + return self._bounded_length( + width * height * self._pixel_size, "Raw rectangle" + ) + if encoding == 1: # CopyRect + return 4 + if encoding == 16: # ZRLE + if len(data) < offset + 4: + return None + compressed = self._bounded_length( + struct.unpack_from(">I", data, offset)[0], "compressed rectangle" + ) + return 4 + compressed + if encoding == -239: # Cursor + return self._bounded_length( + width * height * self._pixel_size + ((width + 7) // 8) * height, + "cursor rectangle", + ) + if encoding == -224: # LastRect + return 0 + raise _RfbServerProtocolError(f"Unsupported RFB encoding {encoding}") + + def _framebuffer_update_length(self) -> int | None: + data = self._buffer + if len(data) < 4: + return None + rectangles = struct.unpack_from(">H", data, 2)[0] + cursor = 4 + for _ in range(rectangles): + if len(data) < cursor + 12: + return None + width, height = struct.unpack_from(">HH", data, cursor + 4) + encoding = struct.unpack_from(">i", data, cursor + 8)[0] + cursor += 12 + payload_length = self._rectangle_payload_length( + data, cursor, width, height, encoding + ) + if payload_length is None or len(data) < cursor + payload_length: + return None + cursor += payload_length + if encoding == -224: # LastRect terminates the update early. + break + return cursor + + def _normal_message_length(self) -> int | None: + data = self._buffer + if not data: + return None + message_type = data[0] + if message_type == 0: + return self._framebuffer_update_length() + if message_type == 1: # SetColourMapEntries + if len(data) < 6: + return None + colors = struct.unpack_from(">H", data, 4)[0] + return 6 + colors * 6 + if message_type in {2, 150}: # Bell / EndOfContinuousUpdates + return 1 + if message_type == 3: # ServerCutText, including extended clipboard + if len(data) < 8: + return None + signed_length = struct.unpack_from(">i", data, 4)[0] + return 8 + self._bounded_length(abs(signed_length), "ServerCutText") + if message_type == 248: # ServerFence + if len(data) < 9: + return None + return 9 + data[8] + if message_type == 250: # XVP + return 4 + raise _RfbServerProtocolError( + f"Unsupported RFB server message {message_type}" + ) + + def filter(self, data: bytes) -> bytes: + self.last_dropped_clipboard = False + self.last_saw_framebuffer = False + if self.can_interact: + filtered = _filter_vnc_server_message(data, can_interact=True) + return filtered or b"" + + if len(self._buffer) + len(data) > _RFB_SERVER_MAX_BUFFER: + raise _RfbServerProtocolError("RFB server buffer limit exceeded") + self._buffer.extend(data) + result = bytearray() + + while self._buffer: + if self._phase != "normal": + message_length = self._handshake_message_length() + if message_length is None: + break + result.extend(self._buffer[:message_length]) + del self._buffer[:message_length] + continue + + # KasmVNC BinaryClipboard is a WebSocket-framed extension without + # a stream-level total length. Drop the complete buffered extension + # and any coalesced tail rather than risk forwarding clipboard data. + if self._buffer[0] == 180: + self.last_dropped_clipboard = True + self._buffer.clear() + break + + message_length = self._normal_message_length() + if message_length is None or len(self._buffer) < message_length: + break + if self._buffer[0] == 3: + self.last_dropped_clipboard = True + else: + if self._buffer[0] == 0: + self.last_saw_framebuffer = True + result.extend(self._buffer[:message_length]) + del self._buffer[:message_length] + + return bytes(result) + + # --------------------------------------------------------------------------- # RFB client message filter — strip extension types KasmVNC doesn't support # --------------------------------------------------------------------------- @@ -712,6 +1121,18 @@ def _build_server_cut_text(text: str) -> bytes: *range(-256, -246), # compress levels 0-9 } +# View-only egress filtering needs deterministic server-message boundaries. +# Keep one compressed framebuffer encoding (ZRLE), simple fallbacks, and the +# two pseudo-encodings whose rectangle payloads are unambiguous. Interactive +# connections retain the broader KasmVNC-compatible allow-list above. +_VIEWER_ALLOWED_ENCODINGS: set[int] = { + 0, # Raw + 1, # CopyRect + 16, # ZRLE + -239, # Cursor + -224, # LastRect +} + def _rfb_msg_length(data: bytes, offset: int) -> int | None: """Return total length of the RFB message at offset, or None if unrecognized.""" @@ -735,15 +1156,22 @@ def _rfb_msg_length(data: bytes, offset: int) -> int | None: return None # truly unknown type -def _rewrite_set_encodings(data: bytes, offset: int, msg_len: int) -> bytes: +def _rewrite_set_encodings( + data: bytes, + offset: int, + msg_len: int, + *, + allowed_encodings: set[int] | None = None, +) -> bytes: """Keep only whitelisted encodings in a SetEncodings message.""" _log = logging.getLogger("cloakbrowser.manager") + allowed = _ALLOWED_ENCODINGS if allowed_encodings is None else allowed_encodings num_enc = struct.unpack_from(">H", data, offset + 2)[0] kept = [] stripped = [] for i in range(num_enc): enc = struct.unpack_from(">i", data, offset + 4 + i * 4)[0] # signed - if enc in _ALLOWED_ENCODINGS: + if enc in allowed: kept.append(enc) else: stripped.append(enc) @@ -835,7 +1263,14 @@ def _filter_rfb_viewer_messages(data: bytes) -> bytes: if msg_type == 0: # SetPixelFormat result.extend(data[offset:offset + msg_len]) elif msg_type == 2: # SetEncodings, with the same safe allow-list - result.extend(_rewrite_set_encodings(data, offset, msg_len)) + result.extend( + _rewrite_set_encodings( + data, + offset, + msg_len, + allowed_encodings=_VIEWER_ALLOWED_ENCODINGS, + ) + ) elif msg_type == 3: # FramebufferUpdateRequest result.extend(data[offset:offset + msg_len]) offset += msg_len @@ -910,6 +1345,94 @@ def filter(self, data: bytes) -> bytes: return bytes(result) +async def _run_profile_health_probe(profile: dict[str, object], running: Any) -> None: + """Run and persist one normalized probe without exposing provider errors.""" + profile_id = str(profile["id"]) + if db.get_profile(profile_id) is None: + return + db.upsert_profile_health( + profile_id, + state="running", + proxy_configured=bool(profile.get("proxy")), + warnings=[], + blockers=[], + sources={}, + ) + try: + result = await profile_health_probe.run(profile, running) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.error("Profile health probe failed for %s (%s)", profile_id, type(exc).__name__) + if db.get_profile(profile_id) is not None: + db.upsert_profile_health( + profile_id, + state="failed", + proxy_configured=bool(profile.get("proxy")), + warnings=[], + blockers=["profile_health_probe_failed"], + error_code="profile_health_probe_failed", + sources={}, + ) + return + + if db.get_profile(profile_id) is not None: + db.upsert_profile_health(profile_id, **result.as_record()) + + +def _schedule_profile_health( + profile: dict[str, object], + running: Any, + *, + force: bool, +) -> asyncio.Task[None] | None: + """Schedule at most one probe per profile and persist the pending state.""" + profile_id = str(profile["id"]) + existing_task = _profile_health_tasks.get(profile_id) + if existing_task is not None and not existing_task.done(): + return existing_task + if existing_task is not None: + _profile_health_tasks.pop(profile_id, None) + if not force and db.get_profile_health(profile_id) is not None: + return None + + db.upsert_profile_health( + profile_id, + state="pending", + proxy_configured=bool(profile.get("proxy")), + warnings=[], + blockers=[], + sources={}, + ) + task = asyncio.create_task(_run_profile_health_probe(profile, running)) + _profile_health_tasks[profile_id] = task + + def _forget(completed: asyncio.Task[None]) -> None: + if _profile_health_tasks.get(profile_id) is completed: + _profile_health_tasks.pop(profile_id, None) + + task.add_done_callback(_forget) + return task + + +async def _cancel_profile_health_task(profile_id: str) -> None: + task = _profile_health_tasks.pop(profile_id, None) + if task is None or task.done(): + return + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +async def _cancel_all_profile_health_tasks() -> None: + tasks = list(_profile_health_tasks.values()) + _profile_health_tasks.clear() + for task in tasks: + if not task.done(): + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + @asynccontextmanager async def lifespan(app: FastAPI): db.init_db() @@ -921,6 +1444,7 @@ async def lifespan(app: FastAPI): if browser_mgr._auto_launch_task and not browser_mgr._auto_launch_task.done(): browser_mgr._auto_launch_task.cancel() await asyncio.gather(browser_mgr._auto_launch_task, return_exceptions=True) + await _cancel_all_profile_health_tasks() await browser_mgr.cleanup_all() @@ -942,6 +1466,25 @@ def _require_admin(scope: Scope) -> access.AccessIdentity: return identity +def _require_sandbox_permission( + scope: Scope, sandbox_id: str, permission: access.Permission +) -> access.AccessIdentity: + """Authorize a sandbox-scoped action for agents/operators (not UI-only).""" + identity = _require_identity(scope) + sid = str(sandbox_id or "default") + if access.has_permission(identity, sid, permission): + return identity + db.record_access_audit_event( + identity.kind, + identity.id, + f"sandbox.permission.{permission}", + "denied", + sid, + None, + ) + raise HTTPException(status_code=403, detail="Sandbox permission required") + + def _require_profile_permission( scope: Scope, profile_id: str, permission: access.Permission ) -> tuple[dict[str, object], access.AccessIdentity]: @@ -964,6 +1507,107 @@ def _require_profile_permission( return profile, identity +def _can_read_task_sessions(identity: access.AccessIdentity, profile: dict[str, object]) -> bool: + sandbox_id = str(profile.get("sandbox_id") or "default") + return identity.is_admin or access.has_permission(identity, sandbox_id, "view") + + +def _can_write_task_sessions(identity: access.AccessIdentity, profile: dict[str, object]) -> bool: + sandbox_id = str(profile.get("sandbox_id") or "default") + return ( + identity.is_admin + or access.has_permission(identity, sandbox_id, "interact") + or access.has_permission(identity, sandbox_id, "automate") + ) + + +def _require_task_profile( + scope: Scope, profile_id: str, permission: access.Permission = "interact" +) -> tuple[dict[str, object], access.AccessIdentity]: + profile = db.get_profile(profile_id) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + identity = _require_identity(scope) + allowed = ( + _can_write_task_sessions(identity, profile) + if permission == "interact" + else _can_read_task_sessions(identity, profile) + ) + if not allowed: + db.record_access_audit_event( + identity.kind, + identity.id, + f"task_session.permission.{permission}", + "denied", + str(profile.get("sandbox_id") or "default"), + profile_id, + ) + raise HTTPException(status_code=404, detail="Profile not found") + return profile, identity + + +def _require_task_session( + scope: Scope, session_id: str, permission: access.Permission = "view" +) -> tuple[dict[str, object], dict[str, object], access.AccessIdentity]: + session = db.get_task_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Task session not found") + profile = db.get_profile(str(session["profile_id"])) + identity = _require_identity(scope) + allowed = False + if profile: + allowed = ( + _can_write_task_sessions(identity, profile) + if permission == "interact" + else _can_read_task_sessions(identity, profile) + ) + if not profile or not allowed: + if profile: + db.record_access_audit_event( + identity.kind, + identity.id, + f"task_session.permission.{permission}", + "denied", + str(profile.get("sandbox_id") or "default"), + str(profile.get("id") or session["profile_id"]), + ) + raise HTTPException(status_code=404, detail="Task session not found") + return session, profile, identity + + +def _sanitize_task_value(value: object, depth: int = 0) -> object: + if depth >= _TASK_METADATA_MAX_DEPTH: + return "[truncated]" + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, list): + return [ + _sanitize_task_value(item, depth + 1) + for item in value[:_TASK_METADATA_MAX_LIST_ITEMS] + ] + if isinstance(value, dict): + sanitized: dict[str, object] = {} + for raw_key, raw_value in value.items(): + key = str(raw_key)[:120] + key_lower = key.lower() + if any(part in key_lower for part in _TASK_SENSITIVE_KEY_PARTS): + sanitized[key] = "[redacted]" + else: + sanitized[key] = _sanitize_task_value(raw_value, depth + 1) + return sanitized + return str(value)[:500] + + +def _sanitize_task_metadata(metadata: dict[str, object] | None) -> dict[str, object]: + sanitized = _sanitize_task_value(metadata or {}) + if not isinstance(sanitized, dict): + return {} + encoded = json.dumps(sanitized, sort_keys=True, separators=(",", ":")).encode("utf-8") + if len(encoded) <= _TASK_METADATA_MAX_BYTES: + return sanitized + return {"truncated": True} + + async def _require_websocket_profile_permission( websocket: WebSocket, profile_id: str, permission: access.Permission ) -> tuple[dict[str, object], access.AccessIdentity] | None: @@ -1012,6 +1656,60 @@ def _profile_response(profile: dict[str, object], identity: access.AccessIdentit return ProfileResponse(**response) +def _request_local_base(request: Request) -> str: + """Client-facing base URL (SSH tunnel Host or reverse-proxy Host).""" + forwarded_proto = request.headers.get("x-forwarded-proto") + forwarded_host = request.headers.get("x-forwarded-host") + return session_links.request_base_url( + scheme=request.url.scheme, + host=request.headers.get("host") or request.url.netloc, + forwarded_proto=forwarded_proto, + forwarded_host=forwarded_host, + ) + + +def _session_open_links( + request: Request, + profile: dict[str, object], + identity: access.AccessIdentity, + *, + prefer: str = "local", + mode: str = "cdp", +) -> SessionOpenLinks: + include_cdp = (not ACCESS_CONTROL_ENABLED) or identity.is_admin or access.can_access_profile( + identity, profile, "automate" + ) + payload = session_links.build_session_open_links( + str(profile["id"]), + local_base=_request_local_base(request), + include_cdp=include_cdp, + prefer=prefer, + mode=mode, + ) + return SessionOpenLinks(**payload) + + +def _profile_open_links_response( + request: Request, + profile: dict[str, object], + identity: access.AccessIdentity, + *, + prefer: str = "local", + mode: str = "cdp", +) -> ProfileOpenLinksResponse: + include_cdp = (not ACCESS_CONTROL_ENABLED) or identity.is_admin or access.can_access_profile( + identity, profile, "automate" + ) + payload = session_links.build_session_open_links( + str(profile["id"]), + local_base=_request_local_base(request), + include_cdp=include_cdp, + prefer=prefer, + mode=mode, + ) + return ProfileOpenLinksResponse(**payload) + + def _access_user_response(user: dict[str, object]) -> AccessUserResponse: return AccessUserResponse( id=str(user["id"]), @@ -1019,7 +1717,21 @@ def _access_user_response(user: dict[str, object]) -> AccessUserResponse: role=str(user["role"]), active=bool(user["active"]), created_at=str(user["created_at"]), + group_ids=[str(group_id) for group_id in user.get("group_ids", [])], grants=user.get("grants", []), + effective_grants=user.get("effective_grants", user.get("grants", [])), + ) + + +def _access_group_response(group: dict[str, object]) -> AccessGroupResponse: + return AccessGroupResponse( + id=str(group["id"]), + name=str(group["name"]), + description=group.get("description") or None, + active=bool(group["active"]), + created_at=str(group["created_at"]), + member_user_ids=[str(user_id) for user_id in group.get("member_user_ids", [])], + grants=group.get("grants", []), ) @@ -1034,6 +1746,51 @@ def _access_agent_response(agent: dict[str, object]) -> AccessAgentResponse: ) +def _normalize_access_grants(grants: list[object]) -> list[dict[str, object]]: + normalized: list[dict[str, object]] = [] + seen: set[tuple[object, object]] = set() + for grant in grants: + value = grant.model_dump() if hasattr(grant, "model_dump") else grant + if isinstance(value, dict): + key = (value.get("sandbox_id"), value.get("permission")) + if key in seen: + continue + seen.add(key) + normalized.append(value) + return normalized + + +def _validate_access_group_member_ids(member_user_ids: list[str]) -> list[str]: + seen: set[str] = set() + valid_ids: list[str] = [] + for user_id in member_user_ids: + if user_id in seen: + continue + if not db.get_access_user(user_id): + raise HTTPException(status_code=404, detail=f"User not found: {user_id}") + seen.add(user_id) + valid_ids.append(user_id) + return valid_ids + + +def _validate_access_group_ids(group_ids: list[str]) -> list[str]: + seen: set[str] = set() + valid_ids: list[str] = [] + for group_id in group_ids: + if group_id in seen: + continue + if not db.get_access_group(group_id): + raise HTTPException(status_code=404, detail=f"Group not found: {group_id}") + seen.add(group_id) + valid_ids.append(group_id) + return valid_ids + + +def _revoke_user_websocket_access(user_ids: list[str]) -> None: + for user_id in set(user_ids): + _revoke_websocket_access(identity_kind="user", identity_id=user_id) + + # ── Authentication ──────────────────────────────────────────────────────────── @@ -1130,7 +1887,8 @@ async def auth_login(body: LoginRequest, request: Request, response: Response): id=str(user["id"]), display_name=str(user["username"]), role=str(user["role"]), - grants=tuple(user.get("grants", [])), + grants=tuple(user.get("effective_grants", user.get("grants", []))), + group_ids=tuple(user.get("group_ids", [])), ).public(), } @@ -1164,18 +1922,24 @@ async def list_access_users(request: Request): @app.post("/api/access/users", response_model=AccessUserResponse, status_code=201) async def create_access_user(body: AccessUserCreate, request: Request): actor = _require_admin(request.scope) + group_ids = _validate_access_group_ids(body.group_ids) try: user = db.create_access_user( body.username, access.hash_password(body.password), body.role, [grant.model_dump() for grant in body.grants], + group_ids, ) except Exception as exc: if "UNIQUE constraint failed" in str(exc): raise HTTPException(status_code=409, detail="Username already exists") from exc raise db.record_access_audit_event(actor.kind, actor.id, "access_user.create", "allowed") + if group_ids: + db.record_access_audit_event( + actor.kind, actor.id, "access_user.groups.update", "allowed" + ) return _access_user_response(user) @@ -1194,13 +1958,83 @@ async def update_access_user(user_id: str, body: AccessUserUpdate, request: Requ grant.model_dump() if hasattr(grant, "model_dump") else grant for grant in data["grants"] ] + if "group_ids" in data and data["group_ids"] is not None: + data["group_ids"] = _validate_access_group_ids(data["group_ids"]) user = db.update_access_user(user_id, **data) if not user: raise HTTPException(status_code=404, detail="User not found") + if {"password_hash", "role", "active", "grants", "group_ids"}.intersection(data): + _revoke_websocket_access(identity_kind="user", identity_id=user_id) + if "group_ids" in data: + db.record_access_audit_event( + actor.kind, actor.id, "access_user.groups.update", "allowed" + ) db.record_access_audit_event(actor.kind, actor.id, "access_user.update", "allowed") return _access_user_response(user) +@app.get("/api/access/groups", response_model=list[AccessGroupResponse]) +async def list_access_groups(request: Request): + _require_admin(request.scope) + return [_access_group_response(group) for group in db.list_access_groups()] + + +@app.post("/api/access/groups", response_model=AccessGroupResponse, status_code=201) +async def create_access_group(body: AccessGroupCreate, request: Request): + actor = _require_admin(request.scope) + member_user_ids = _validate_access_group_member_ids(body.member_user_ids) + try: + group = db.create_access_group( + body.name, + body.description, + body.active, + member_user_ids, + _normalize_access_grants(body.grants), + ) + except Exception as exc: + if "UNIQUE constraint failed" in str(exc): + raise HTTPException(status_code=409, detail="Group name already exists") from exc + raise + _revoke_user_websocket_access(member_user_ids) + db.record_access_audit_event(actor.kind, actor.id, "access_group.create", "allowed") + return _access_group_response(group) + + +@app.put("/api/access/groups/{group_id}", response_model=AccessGroupResponse) +async def update_access_group(group_id: str, body: AccessGroupUpdate, request: Request): + actor = _require_admin(request.scope) + existing = db.get_access_group(group_id) + if not existing: + raise HTTPException(status_code=404, detail="Group not found") + data = body.model_dump(exclude_unset=True) + prior_member_ids = [str(user_id) for user_id in existing.get("member_user_ids", [])] + if "member_user_ids" in data and data["member_user_ids"] is not None: + data["member_user_ids"] = _validate_access_group_member_ids(data["member_user_ids"]) + if "grants" in data and data["grants"] is not None: + data["grants"] = _normalize_access_grants(data["grants"]) + try: + group = db.update_access_group(group_id, **data) + except Exception as exc: + if "UNIQUE constraint failed" in str(exc): + raise HTTPException(status_code=409, detail="Group name already exists") from exc + raise + if not group: + raise HTTPException(status_code=404, detail="Group not found") + current_member_ids = [str(user_id) for user_id in group.get("member_user_ids", [])] + if {"active", "member_user_ids", "grants"}.intersection(data): + _revoke_user_websocket_access(prior_member_ids + current_member_ids) + if "member_user_ids" in data: + db.record_access_audit_event( + actor.kind, actor.id, "access_group.members.update", "allowed" + ) + if "grants" in data: + db.record_access_audit_event( + actor.kind, actor.id, "access_group.grants.update", "allowed" + ) + db.record_access_audit_event(actor.kind, actor.id, "access_group.update", "allowed") + return _access_group_response(group) + + @app.get("/api/access/agents", response_model=list[AccessAgentResponse]) async def list_access_agents(request: Request): _require_admin(request.scope) @@ -1233,10 +2067,24 @@ async def update_access_agent(agent_id: str, body: AccessAgentUpdate, request: R agent = db.update_access_agent(agent_id, **data) if not agent: raise HTTPException(status_code=404, detail="Agent not found") + if {"active", "grants"}.intersection(data): + _revoke_websocket_access(identity_kind="agent", identity_id=agent_id) db.record_access_audit_event(actor.kind, actor.id, "access_agent.update", "allowed") return _access_agent_response(agent) +@app.delete("/api/access/agents/{agent_id}", status_code=204) +async def delete_access_agent(agent_id: str, request: Request): + """Revoke an agent immediately: drop key/grants and close live WS leases.""" + actor = _require_admin(request.scope) + deleted = db.delete_access_agent(agent_id) + if not deleted: + raise HTTPException(status_code=404, detail="Agent not found") + _revoke_websocket_access(identity_kind="agent", identity_id=agent_id) + db.record_access_audit_event(actor.kind, actor.id, "access_agent.delete", "allowed") + return Response(status_code=204) + + @app.post("/api/access/agents/{agent_id}/rotate-key", response_model=AccessAgentCreatedResponse) async def rotate_access_agent_key(agent_id: str, request: Request): actor = _require_admin(request.scope) @@ -1244,23 +2092,428 @@ async def rotate_access_agent_key(agent_id: str, request: Request): agent = db.update_access_agent(agent_id, key_hash=access.hash_agent_key(key)) if not agent: raise HTTPException(status_code=404, detail="Agent not found") + _revoke_websocket_access(identity_kind="agent", identity_id=agent_id) db.record_access_audit_event(actor.kind, actor.id, "access_agent.rotate_key", "allowed") return AccessAgentCreatedResponse(**_access_agent_response(agent).model_dump(), api_key=key) @app.get("/api/access/sandboxes") async def list_access_sandboxes(request: Request): - _require_admin(request.scope) - counts: dict[str, int] = {} + """List sandboxes visible to the caller (admin: all; agent/user: granted only).""" + identity = _require_identity(request.scope) + summaries: dict[str, dict[str, Any]] = {} for profile in db.list_profiles(): + if not access.can_access_profile(identity, profile, "view"): + continue sandbox_id = str(profile.get("sandbox_id") or "default") - counts[sandbox_id] = counts.get(sandbox_id, 0) + 1 - return [{"sandbox_id": key, "profile_count": counts[key]} for key in sorted(counts)] + summary = summaries.setdefault( + sandbox_id, + { + "profile_count": 0, + "project_ids": set(), + "folder_paths": set(), + "profile_names": set(), + }, + ) + summary["profile_count"] += 1 + summary["project_ids"].add(str(profile.get("project_id") or "default")) + folder_path = str(profile.get("folder_path") or "") + if folder_path: + summary["folder_paths"].add(folder_path) + summary["profile_names"].add(str(profile.get("name") or "Unnamed profile")) + + # Include empty granted sandboxes so agents can create the first profile there. + if not identity.is_admin: + for grant in identity.grants: + sid = str(grant.get("sandbox_id") or "") + if sid and sid not in summaries and access.has_permission(identity, sid, "view"): + summaries[sid] = { + "profile_count": 0, + "project_ids": set(), + "folder_paths": set(), + "profile_names": set(), + } + + return [ + { + "sandbox_id": sandbox_id, + "profile_count": summaries[sandbox_id]["profile_count"], + "project_ids": sorted(summaries[sandbox_id]["project_ids"]), + "folder_paths": sorted(summaries[sandbox_id]["folder_paths"]), + "profile_names": sorted(summaries[sandbox_id]["profile_names"]), + } + for sandbox_id in sorted(summaries) + ] + + +# ── Task sessions ─────────────────────────────────────────────────────────── + + +@app.post("/api/task-sessions", response_model=TaskSessionResponse, status_code=201) +async def create_task_session(body: TaskSessionCreate, request: Request): + profile, identity = _require_task_profile(request.scope, body.profile_id, "interact") + session = db.create_task_session( + str(profile["id"]), + str(profile.get("sandbox_id") or "default"), + identity.kind, + identity.id, + body.title, + _sanitize_task_metadata(body.metadata), + ) + db.record_task_event( + str(session["id"]), + "task_session.created", + identity.kind, + identity.id, + {"profile_id": str(profile["id"]), "sandbox_id": str(profile.get("sandbox_id") or "default")}, + ) + db.record_access_audit_event( + identity.kind, + identity.id, + "task_session.create", + "allowed", + str(profile.get("sandbox_id") or "default"), + str(profile["id"]), + ) + return TaskSessionResponse(**session) + + +@app.get("/api/task-sessions", response_model=list[TaskSessionResponse]) +async def list_task_sessions( + request: Request, + profile_id: str = Query(..., min_length=1, max_length=120), + limit: int = Query(100, ge=1, le=200), +): + profile, _identity = _require_task_profile(request.scope, profile_id, "view") + return [ + TaskSessionResponse(**session) + for session in db.list_task_sessions(str(profile["id"]), limit=limit) + ] + + +@app.get("/api/task-sessions/{session_id}", response_model=TaskSessionResponse) +async def get_task_session(session_id: str, request: Request): + session, _profile, _identity = _require_task_session(request.scope, session_id, "view") + return TaskSessionResponse(**session) + + +def _append_task_user_message( + scope: Scope, + session_id: str, + text: str, + profile_id: str | None, + commands: list[object], + metadata: dict[str, object], +) -> TaskMessageResponse: + session, profile, identity = _require_task_session(scope, session_id, "interact") + if profile_id and profile_id != str(session["profile_id"]): + requested_profile = db.get_profile(profile_id) + if requested_profile: + db.record_access_audit_event( + identity.kind, + identity.id, + "task_session.profile_mismatch", + "denied", + str(requested_profile.get("sandbox_id") or "default"), + profile_id, + ) + raise HTTPException(status_code=404, detail="Task session not found") + + command_payload = [ + command.model_dump() if hasattr(command, "model_dump") else command + for command in commands + ] + stored_metadata_input = dict(metadata) + if command_payload: + stored_metadata_input["commands"] = command_payload + stored_metadata = _sanitize_task_metadata(stored_metadata_input) + message = db.append_task_message( + str(session["id"]), + "user", + text, + identity.kind, + identity.id, + stored_metadata, + ) + host_command_count = sum( + 1 for command in command_payload + if isinstance(command, dict) and command.get("scope") == "host" + ) + db.record_task_event( + str(session["id"]), + "task_message.appended", + identity.kind, + identity.id, + { + "message_id": str(message["id"]), + "role": "user", + "command_count": len(command_payload), + "host_command_count": host_command_count, + "server_executed": False, + }, + ) + db.record_access_audit_event( + identity.kind, + identity.id, + "task_message.append", + "allowed", + str(profile.get("sandbox_id") or "default"), + str(profile["id"]), + ) + return TaskMessageResponse(**message) + + +@app.post( + "/api/task-sessions/{session_id}/messages", + response_model=TaskMessageResponse, + status_code=201, +) +async def append_task_message(session_id: str, body: TaskMessageCreate, request: Request): + return _append_task_user_message( + request.scope, + session_id, + body.text, + body.profile_id, + body.commands, + body.metadata, + ) + + +@app.post( + "/api/task-sessions/{session_id}/commands", + response_model=TaskMessageResponse, + status_code=201, +) +async def append_task_command(session_id: str, body: TaskCommandRequest, request: Request): + return _append_task_user_message( + request.scope, + session_id, + body.content, + body.profile_id, + body.commands, + body.metadata, + ) + + +@app.get("/api/task-sessions/{session_id}/messages", response_model=list[TaskMessageResponse]) +async def list_task_messages( + session_id: str, + request: Request, + limit: int = Query(100, ge=1, le=200), +): + session, _profile, _identity = _require_task_session(request.scope, session_id, "view") + return [ + TaskMessageResponse(**message) + for message in db.list_task_messages(str(session["id"]), limit=limit) + ] + + +@app.get("/api/task-sessions/{session_id}/events", response_model=list[TaskEventResponse]) +async def list_task_events( + session_id: str, + request: Request, + limit: int = Query(100, ge=1, le=200), +): + session, _profile, _identity = _require_task_session(request.scope, session_id, "view") + return [ + TaskEventResponse(**event) + for event in db.list_task_events(str(session["id"]), limit=limit) + ] # ── Profile CRUD ────────────────────────────────────────────────────────────── +def _proxy_inventory_item(row: dict[str, object]) -> ProxyInventoryItem: + return ProxyInventoryItem( + id=str(row["id"]), + label=str(row.get("label") or "proxy"), + host_masked=str(row.get("host_masked") or "unknown"), + port=row.get("port") if isinstance(row.get("port"), int) else None, + username_masked=row.get("username_masked") if isinstance(row.get("username_masked"), str) else None, + has_credentials=bool(row.get("has_credentials")), + active=bool(row.get("active", True)), + check_state=str(row.get("check_state") or "missing"), # type: ignore[arg-type] + reachable=row.get("reachable") if isinstance(row.get("reachable"), bool) else None, + latency_ms=row.get("latency_ms") if isinstance(row.get("latency_ms"), (int, float)) else None, + risk_score=row.get("risk_score") if isinstance(row.get("risk_score"), int) else None, + authenticity_score=( + row.get("authenticity_score") if isinstance(row.get("authenticity_score"), int) else None + ), + country_code=row.get("country_code") if isinstance(row.get("country_code"), str) else None, + timezone_hint=row.get("timezone_hint") if isinstance(row.get("timezone_hint"), str) else None, + locale_hint=row.get("locale_hint") if isinstance(row.get("locale_hint"), str) else None, + warnings=[str(item) for item in (row.get("warnings") or [])], + blockers=[str(item) for item in (row.get("blockers") or [])], + last_checked_at=row.get("last_checked_at") if isinstance(row.get("last_checked_at"), str) else None, + created_at=str(row["created_at"]), + updated_at=str(row["updated_at"]), + ) + + +async def _proxychecker_check(proxy_url: str) -> dict[str, object]: + """Call VCVM-local proxychecker and reduce to redacted summary fields.""" + base = getattr(profile_health_probe, "proxychecker_url", "") or "" + if not base: + return { + "reachable": None, + "latency_ms": None, + "risk_score": None, + "authenticity_score": None, + "country_code": None, + "timezone_hint": None, + "locale_hint": None, + "warnings": [], + "blockers": ["proxychecker_unavailable"], + "check_state": "unavailable", + } + try: + async with httpx.AsyncClient(timeout=getattr(profile_health_probe, "component_timeout_s", 20.0)) as client: + response = await client.post( + f"{base.rstrip('/')}/check", + json={ + "target": getattr(profile_health_probe, "ip_echo_url", "https://api.ipify.org"), + "proxies": [proxy_url], + "limit": 1, + "scoring_profile": "default", + }, + ) + response.raise_for_status() + return proxy_inventory.summarize_check_payload(response.json()) + except Exception: + return { + "reachable": None, + "latency_ms": None, + "risk_score": None, + "authenticity_score": None, + "country_code": None, + "timezone_hint": None, + "locale_hint": None, + "warnings": [], + "blockers": ["proxychecker_unavailable"], + "check_state": "unavailable", + } + + +@app.get("/api/proxies", response_model=list[ProxyInventoryItem]) +async def list_proxies(request: Request): + identity = _require_admin(request.scope) + del identity # admin gate only + return [_proxy_inventory_item(row) for row in db.list_proxy_inventory()] + + +@app.post("/api/proxies/ingest", response_model=ProxyInventoryIngestResponse, status_code=201) +async def ingest_proxies(req: ProxyInventoryIngest, request: Request): + identity = _require_admin(request.scope) + created = 0 + updated = 0 + rejected = 0 + items: list[ProxyInventoryItem] = [] + + for raw in req.lines: + try: + proxy_url = proxy_inventory.parse_proxy_line(raw) + except proxy_inventory.ProxyParseError: + rejected += 1 + continue + fingerprint = proxy_inventory.proxy_fingerprint(proxy_url) + with db.get_db() as conn: + existing = conn.execute( + "SELECT id FROM proxy_inventory WHERE fingerprint = ?", + (fingerprint,), + ).fetchone() + redacted = proxy_inventory.redact_proxy_url(proxy_url) + row = db.upsert_proxy_inventory_entry(proxy_url, redacted=redacted) + if existing: + updated += 1 + else: + created += 1 + items.append(_proxy_inventory_item(row)) + + db.record_access_audit_event( + identity.kind, + identity.id, + "proxy.ingest", + "allowed", + None, + None, + ) + return ProxyInventoryIngestResponse( + created=created, + updated=updated, + rejected=rejected, + items=items, + ) + + +@app.post("/api/proxies/{proxy_id}/check", response_model=ProxyInventoryItem) +async def check_proxy(proxy_id: str, request: Request): + identity = _require_admin(request.scope) + secret_row = db.get_proxy_inventory_entry(proxy_id, include_secret=True) + if secret_row is None: + raise HTTPException(status_code=404, detail="Proxy not found") + proxy_url = str(secret_row.get("proxy_url") or "") + summary = await _proxychecker_check(proxy_url) + updated = db.update_proxy_inventory_check(proxy_id, summary) + if updated is None: + raise HTTPException(status_code=404, detail="Proxy not found") + db.record_access_audit_event( + identity.kind, + identity.id, + "proxy.check", + "allowed", + None, + None, + ) + return _proxy_inventory_item(updated) + + +@app.post("/api/proxies/{proxy_id}/profiles", response_model=ProfileResponse, status_code=201) +async def create_profile_from_proxy( + proxy_id: str, + req: ProxyAutoProfileCreate, + request: Request, +): + """Create a proxy-aligned anti-stealth profile without manual fingerprint tuning.""" + identity = _require_admin(request.scope) + secret_row = db.get_proxy_inventory_entry(proxy_id, include_secret=True) + if secret_row is None: + raise HTTPException(status_code=404, detail="Proxy not found") + proxy_url = str(secret_row.get("proxy_url") or "") + country = secret_row.get("country_code") if isinstance(secret_row.get("country_code"), str) else None + if country is None: + # Soft enrichment before profile create; degrade if checker is down. + summary = await _proxychecker_check(proxy_url) + db.update_proxy_inventory_check(proxy_id, summary) + country = summary.get("country_code") if isinstance(summary.get("country_code"), str) else None + + defaults = proxy_inventory.build_auto_profile_defaults( + proxy_url=proxy_url, + country_code=country, + name=req.name, + project_id=req.project_id, + harness=req.harness, + sandbox_id=req.sandbox_id, + ) + tags = defaults.pop("tags", []) + profile = db.create_profile(**defaults, tags=tags) + db.record_access_audit_event( + identity.kind, + identity.id, + "profile.create_from_proxy", + "allowed", + str(profile.get("sandbox_id") or "default"), + str(profile.get("id") or ""), + ) + if req.launch: + try: + running = await browser_mgr.launch(profile) + _schedule_profile_health(profile, running, force=False) + except Exception as exc: + raise HTTPException(status_code=500, detail="Failed to launch profile") from exc + return _profile_response(profile, identity) + + @app.get("/api/profiles", response_model=list[ProfileResponse]) async def list_profiles(request: Request): identity = _require_identity(request.scope) @@ -1273,8 +2526,10 @@ async def list_profiles(request: Request): @app.post("/api/profiles", response_model=ProfileResponse, status_code=201) async def create_profile(req: ProfileCreate, request: Request): - identity = _require_admin(request.scope) + """Create a profile in a sandbox the caller can operate (agent/CLI control plane).""" data = req.model_dump() + sandbox_id = str(data.get("sandbox_id") or "default") + identity = _require_sandbox_permission(request.scope, sandbox_id, "operate") tags = data.pop("tags", None) if tags: data["tags"] = [t.model_dump() if hasattr(t, "model_dump") else t for t in tags] @@ -1293,35 +2548,89 @@ async def get_profile(profile_id: str, request: Request): return _profile_response(profile, identity) + +@app.post("/api/profiles/bulk-organize", response_model=list[ProfileResponse]) +async def bulk_organize_profiles(req: ProfileBulkOrganize, request: Request): + """Move or pin profiles the caller can operate (sandbox-scoped; not UI-only).""" + identity = _require_identity(request.scope) + if req.project_id is None and req.folder_path is None and req.pinned is None: + raise HTTPException(status_code=400, detail="No organization fields provided") + + # Authorize every requested profile for operate before mutating any of them. + authorized_ids: list[str] = [] + for profile_id in req.profile_ids: + profile = db.get_profile(profile_id) + if not profile or not access.can_access_profile(identity, profile, "operate"): + db.record_access_audit_event( + identity.kind, + identity.id, + "profile.permission.operate", + "denied", + str((profile or {}).get("sandbox_id") or "default"), + profile_id, + ) + raise HTTPException(status_code=404, detail="Profile not found") + authorized_ids.append(profile_id) + + try: + updated = db.bulk_organize_profiles( + authorized_ids, + project_id=req.project_id, + folder_path=req.folder_path, + pinned=req.pinned, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if not updated: + raise HTTPException(status_code=404, detail="Profile not found") + + for row in updated: + db.record_access_audit_event( + identity.kind, + identity.id, + "profile.bulk_organize", + "allowed", + str(row.get("sandbox_id") or "default"), + str(row.get("id") or ""), + ) + return [_profile_response(row, identity) for row in updated] + + @app.put("/api/profiles/{profile_id}", response_model=ProfileResponse) async def update_profile(profile_id: str, req: ProfileUpdate, request: Request): - identity = _require_admin(request.scope) + """Update profile details for callers with operate on the profile sandbox.""" + profile, identity = _require_profile_permission(request.scope, profile_id, "operate") # Only pass fields that were explicitly set data = req.model_dump(exclude_unset=True) tags = data.pop("tags", None) if tags is not None: data["tags"] = [t.model_dump() if hasattr(t, "model_dump") else t for t in tags] - profile = db.update_profile(profile_id, **data) - if not profile: + if "sandbox_id" in data: + target_sandbox = str(data.get("sandbox_id") or "default") + current_sandbox = str(profile.get("sandbox_id") or "default") + if target_sandbox != current_sandbox: + _require_sandbox_permission(request.scope, target_sandbox, "operate") + updated = db.update_profile(profile_id, **data) + if not updated: raise HTTPException(status_code=404, detail="Profile not found") + if "sandbox_id" in data: + _revoke_websocket_access(profile_id=profile_id) db.record_access_audit_event( - identity.kind, identity.id, "profile.update", "allowed", str(profile.get("sandbox_id") or "default"), profile_id + identity.kind, identity.id, "profile.update", "allowed", str(updated.get("sandbox_id") or "default"), profile_id ) - return _profile_response(profile, identity) + return _profile_response(updated, identity) @app.delete("/api/profiles/{profile_id}") async def delete_profile(profile_id: str, request: Request): - identity = _require_admin(request.scope) + profile, identity = _require_profile_permission(request.scope, profile_id, "operate") # Stop browser if running if profile_id in browser_mgr.running: await browser_mgr.stop(profile_id) + await _cancel_profile_health_task(profile_id) - profile = db.get_profile(profile_id) - if not profile: - raise HTTPException(status_code=404, detail="Profile not found") - - user_data_dir = Path(profile["user_data_dir"]) + user_data_dir = Path(str(profile["user_data_dir"])) # DB first — if this fails, filesystem is untouched db.delete_profile(profile_id) @@ -1347,15 +2656,24 @@ async def launch_profile(profile_id: str, request: Request): try: running = await browser_mgr.launch(profile) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) + except ValueError: + # Validation errors can originate in URL-parsing or browser libraries + # and may contain embedded proxy credentials. Keep the API boundary + # intentionally generic while preserving the useful 400 classification. + raise HTTPException(status_code=400, detail="Invalid browser profile configuration") except Exception as exc: - logger.error("Failed to launch profile %s: %s", profile_id, exc) + # Third-party launch errors may echo the configured proxy URL. Log the + # exception class for diagnosis without persisting embedded credentials. + logger.error("Failed to launch profile %s (%s)", profile_id, type(exc).__name__) raise HTTPException(status_code=500, detail="Failed to launch browser") db.record_access_audit_event( identity.kind, identity.id, "profile.launch", "allowed", str(profile.get("sandbox_id") or "default"), profile_id ) + try: + _schedule_profile_health(profile, running, force=False) + except Exception as exc: + logger.error("Could not schedule profile health for %s (%s)", profile_id, type(exc).__name__) return LaunchResponse( profile_id=profile_id, status="running", @@ -1366,6 +2684,60 @@ async def launch_profile(profile_id: str, request: Request): if access.can_access_profile(identity, profile, "automate") else None ), + links=_session_open_links(request, profile, identity), + ) + + +@app.get("/api/profiles/{profile_id}/health", response_model=ProfileHealthResponse) +async def get_profile_health(profile_id: str, request: Request): + profile, _identity = _require_profile_permission(request.scope, profile_id, "view") + stored = db.get_profile_health(profile_id) + if stored is None: + return ProfileHealthResponse( + profile_id=profile_id, + proxy_configured=bool(profile.get("proxy")), + ) + return ProfileHealthResponse(**stored) + + +@app.get("/api/profiles/{profile_id}/extensions", response_model=ExtensionInventoryResponse) +async def get_profile_extensions(profile_id: str, request: Request): + profile, _identity = _require_profile_permission(request.scope, profile_id, "view") + ext_list = extensions.inspect_profile_extensions(profile) + return ExtensionInventoryResponse(profile_id=profile_id, extensions=ext_list) + + + +@app.post( + "/api/profiles/{profile_id}/health/run", + response_model=ProfileHealthResponse, + status_code=202, +) +async def run_profile_health(profile_id: str, request: Request): + profile, identity = _require_profile_permission(request.scope, profile_id, "operate") + running = browser_mgr.running.get(profile_id) + if running is None: + raise HTTPException(status_code=409, detail="Profile is not running") + + _schedule_profile_health(profile, running, force=True) + db.record_access_audit_event( + identity.kind, + identity.id, + "profile.health.run", + "allowed", + str(profile.get("sandbox_id") or "default"), + profile_id, + ) + stored = db.get_profile_health(profile_id) + return ProfileHealthResponse( + **( + stored + or { + "profile_id": profile_id, + "state": "pending", + "proxy_configured": bool(profile.get("proxy")), + } + ) ) @@ -1392,7 +2764,316 @@ async def get_profile_status(profile_id: str, request: Request): status["vnc_ws_port"] = None if not access.can_access_profile(identity, profile, "automate"): status["cdp_url"] = None - return ProfileStatusResponse(**status) + links = _session_open_links(request, profile, identity) + return ProfileStatusResponse(**status, links=links) + + +# ── Extension bootstrap (Chrome extension / agent one-click) ───────────────── + + +# ── Extension bootstrap (Chrome extension / agent one-click) ───────────────── + + +def _extension_catalog_payload(request: Request) -> ExtensionCatalogResponse: + identity = _require_identity(request.scope) + profiles: list[ExtensionProfileSummary] = [] + for profile in db.list_profiles(): + if not access.can_access_profile(identity, profile, "view"): + continue + status = browser_mgr.get_status(str(profile["id"])) + summary = session_links.extension_profile_summary( + profile, + status=str(status.get("status") or "stopped"), + running=str(status.get("status") or "") == "running", + ) + profiles.append(ExtensionProfileSummary(**summary)) + + proxies: list[ProxyInventoryItem] = [] + if identity.is_admin: + proxies = [_proxy_inventory_item(row) for row in db.list_proxy_inventory()] + + local_base = _request_local_base(request) + cloud_base = session_links.cloud_base_url() + return ExtensionCatalogResponse( + bases={"local": local_base, "cloud": cloud_base}, + endpoints=session_links.catalog_endpoint_map(), + profiles=profiles, + proxies=proxies, + capabilities={ + "can_list_proxies": identity.is_admin, + "can_ingest_proxies": identity.is_admin, + "can_open_sessions": True, + "cloud_base_configured": bool(cloud_base), + "can_manage_extension_defaults": identity.is_admin, + "can_list_templates": True, + "cdp_live": True, + "live_metrics": True, + }, + ) + + +@app.get("/api/extension/catalog", response_model=ExtensionCatalogResponse) +async def get_extension_catalog(request: Request): + """List redacted profiles + proxies and stable endpoints for an extension.""" + return _extension_catalog_payload(request) + + +@app.post("/api/extension/catalog", response_model=ExtensionCatalogResponse) +async def post_extension_catalog(request: Request): + """POST alias for catalog refresh (agent-friendly; same as GET).""" + return _extension_catalog_payload(request) + + +@app.get("/api/extension/defaults", response_model=ExtensionDefaultsResponse) +async def get_extension_defaults(request: Request): + """Selectable Comet-derived default extensions for new/template profiles.""" + _require_identity(request.scope) + payload = extension_catalog.defaults_payload() + return ExtensionDefaultsResponse(**payload) + + +@app.put("/api/extension/defaults", response_model=ExtensionDefaultsResponse) +async def put_extension_defaults(req: ExtensionDefaultsUpdate, request: Request): + """Persist which catalog extensions install by default (admin/API parity).""" + identity = _require_identity(request.scope) + if ACCESS_CONTROL_ENABLED and not identity.is_admin: + raise HTTPException(status_code=403, detail="Administrator required") + extension_catalog.save_selected_ids(list(req.selected_ids or [])) + db.record_access_audit_event( + identity.kind, + identity.id, + "extension.defaults.update", + "allowed", + "default", + None, + ) + return ExtensionDefaultsResponse(**extension_catalog.defaults_payload()) + + +@app.get("/api/extension/templates", response_model=ExtensionTemplatesResponse) +async def get_extension_templates(request: Request): + """Lightweight template list for Chrome sync / agents.""" + _require_identity(request.scope) + items = [] + for template in profile_templates.list_templates(): + items.append( + ExtensionTemplateItem( + id=str(template["id"]), + name=str(template["name"]), + project_id=str(template.get("project_id") or "default"), + folder_path=str(template.get("folder_path") or ""), + harness=template.get("harness") or "browser-use", + geoip=bool(template.get("geoip")) if "geoip" in template else None, + screen_width=template.get("screen_width"), + screen_height=template.get("screen_height"), + create_path="/api/profile-templates/{template_id}/profiles", + from_proxy_path="/api/proxies/{proxy_id}/profiles", + ) + ) + # Also expose the thin session_links templates for compatibility. + for template in session_links.profile_templates(): + if any(item.id == template["id"] for item in items): + continue + items.append(ExtensionTemplateItem(**template)) + return ExtensionTemplatesResponse(templates=items) + + +@app.get("/api/profile-templates", response_model=list[ProfileTemplateSummary]) +async def list_profile_templates(request: Request): + """Full template cards including system prompts (agent + UI parity).""" + _require_identity(request.scope) + return [ + ProfileTemplateSummary( + id=str(item["id"]), + name=str(item["name"]), + summary=str(item.get("summary") or ""), + system_prompt=str(item.get("system_prompt") or ""), + harness=item.get("harness") or "browser-use", + project_id=str(item.get("project_id") or "default"), + folder_path=str(item.get("folder_path") or ""), + platform=item.get("platform") or "windows", + apply_default_extensions=bool(item.get("apply_default_extensions", True)), + quick_options=list(item.get("quick_options") or []), + ) + for item in profile_templates.list_templates() + ] + + +@app.post( + "/api/profile-templates/{template_id}/profiles", + response_model=ProfileResponse, + status_code=201, +) +async def create_profile_from_template( + template_id: str, + request: Request, + req: ProfileTemplateCreate | None = None, +): + """Materialize a prefabricated profile (includes selected default extensions).""" + identity = _require_identity(request.scope) + if ACCESS_CONTROL_ENABLED and not identity.is_admin: + raise HTTPException(status_code=403, detail="Administrator required") + body = req or ProfileTemplateCreate(template_id=template_id) + try: + fields = profile_templates.build_profile_fields( + template_id, + overrides={ + "name": body.name, + "project_id": body.project_id, + "harness": body.harness, + "proxy": body.proxy, + }, + apply_extensions=body.apply_default_extensions, + ) + except KeyError: + raise HTTPException(status_code=404, detail="Template not found") from None + # Strip agent-only metadata before DB write + fields.pop("system_prompt", None) + fields.pop("template_id", None) + profile = db.create_profile(**fields) + db.record_access_audit_event( + identity.kind, + identity.id, + "profile.create_from_template", + "allowed", + str(profile.get("sandbox_id") or "default"), + str(profile["id"]), + ) + if body.launch: + try: + await browser_mgr.launch(profile) + except Exception: + logger.error("Template profile launch failed for %s", profile["id"]) + return _profile_response(profile, identity) + + +@app.get("/session/{profile_id}/live") +async def cdp_live_session(profile_id: str, request: Request): + """Browser-Use-style CDP screencast fullscreen page (snappy live URL).""" + from fastapi.responses import HTMLResponse + + profile, identity = _require_profile_permission(request.scope, profile_id, "view") + if profile_id not in browser_mgr.running: + raise HTTPException(status_code=409, detail="Profile is not running") + include_cdp = (not ACCESS_CONTROL_ENABLED) or identity.is_admin or access.can_access_profile( + identity, profile, "automate" + ) + if not include_cdp: + raise HTTPException(status_code=403, detail="CDP live view requires automate permission") + local_base = _request_local_base(request) + ws_base = f"{session_links.ws_scheme_for(local_base)}://{local_base.split('://', 1)[-1]}" + cdp_ws = f"{ws_base}/api/profiles/{profile_id}/cdp" + cdp_list = f"{local_base.rstrip('/')}/api/profiles/{profile_id}/cdp/json/list" + metrics = f"{local_base.rstrip('/')}/api/profiles/{profile_id}/live-metrics" + interactive = (not ACCESS_CONTROL_ENABLED) or identity.is_admin or access.can_access_profile( + identity, profile, "interact" + ) + html = session_views.render_cdp_live_html( + profile_id=profile_id, + profile_name=str(profile.get("name") or profile_id), + cdp_ws_url=cdp_ws, + cdp_list_url=cdp_list, + metrics_url=metrics, + interactive=interactive, + ) + return HTMLResponse(html) + + +@app.get("/api/profiles/{profile_id}/open-links", response_model=ProfileOpenLinksResponse) +async def get_profile_open_links( + profile_id: str, + request: Request, + prefer: str = Query(default="local"), + mode: str = Query(default="cdp"), +): + """Steel-style local/cloud open URLs for a profile (extension/agent compatible).""" + profile, identity = _require_profile_permission(request.scope, profile_id, "view") + prefer_key = prefer if prefer in {"local", "cloud"} else "local" + mode_key = mode if mode in {"cdp", "vnc", "shell"} else "cdp" + return _profile_open_links_response( + request, profile, identity, prefer=prefer_key, mode=mode_key + ) + + +@app.post("/api/extension/sessions/open", response_model=ExtensionOpenSessionResponse) +async def extension_open_session(req: ExtensionOpenSessionRequest, request: Request): + """Launch (optional) and return local/cloud open links for one-click use.""" + profile, identity = _require_profile_permission(request.scope, req.profile_id, "view") + already_running = req.profile_id in browser_mgr.running + launched = False + running = browser_mgr.running.get(req.profile_id) + + if req.launch and not already_running: + _require_profile_permission(request.scope, req.profile_id, "operate") + try: + running = await browser_mgr.launch(profile) + launched = True + except ValueError: + raise HTTPException(status_code=400, detail="Invalid browser profile configuration") + except Exception: + logger.error("Extension open failed for profile %s", req.profile_id) + raise HTTPException(status_code=500, detail="Failed to launch browser") + db.record_access_audit_event( + identity.kind, + identity.id, + "profile.launch", + "allowed", + str(profile.get("sandbox_id") or "default"), + req.profile_id, + ) + try: + _schedule_profile_health(profile, running, force=False) + except Exception as exc: + logger.error( + "Could not schedule profile health for %s (%s)", + req.profile_id, + type(exc).__name__, + ) + + links = _session_open_links( + request, profile, identity, prefer=req.prefer, mode=req.mode + ) + include_cdp = (not ACCESS_CONTROL_ENABLED) or identity.is_admin or access.can_access_profile( + identity, profile, "automate" + ) + status = "running" if (already_running or launched or running) else "stopped" + return ExtensionOpenSessionResponse( + profile_id=req.profile_id, + status=status, + launched=launched, + already_running=already_running, + prefer=links.prefer, + mode=links.mode, + open_url=links.open_url, + links=links, + session_viewer_url=links.session_viewer_url, + vnc_fullscreen_url=links.vnc_fullscreen_url, + cdp_fullscreen_url=links.cdp_fullscreen_url, + live_url=links.live_url, + cdp_url=(f"/api/profiles/{req.profile_id}/cdp" if include_cdp and status == "running" else None), + vnc_ws_port=( + None + if ACCESS_CONTROL_ENABLED and not identity.is_admin + else (running.ws_port if running else None) + ), + display=(f":{running.display}" if running and (identity.is_admin or not ACCESS_CONTROL_ENABLED) else None), + ) + + +@app.get("/api/profiles/{profile_id}/live-metrics", response_model=LiveMetricsResponse) +async def get_live_metrics(profile_id: str, request: Request): + """Poll CDP/VNC livestream metrics (fps, rtt, connection state).""" + _require_profile_permission(request.scope, profile_id, "view") + return LiveMetricsResponse(**stream_metrics.stream_metrics.snapshot(profile_id)) + + +@app.post("/api/profiles/{profile_id}/live-metrics", response_model=LiveMetricsResponse) +async def post_live_metrics(profile_id: str, body: LiveMetricsSample, request: Request): + """Client/agent heartbeat for livestream quality metrics.""" + _require_profile_permission(request.scope, profile_id, "view") + return LiveMetricsResponse( + **stream_metrics.stream_metrics.record(profile_id, body.model_dump()) + ) # ── System Status ───────────────────────────────────────────────────────────── @@ -1417,6 +3098,20 @@ async def get_system_status(request: Request): ) +@app.get("/api/admin/live-diagnostics") +async def get_live_diagnostics(request: Request) -> dict[str, Any]: + """Return administrator-only live launch and VNC counters. + + Unmeasured timings are explicit ``unavailable`` values. The payload never + includes ports, display numbers, filesystem paths, URLs, proxy data, or + browser content. This endpoint does not start benchmarks or mutate runtime. + """ + _require_admin(request.scope) + return live_diagnostics.live_diagnostics.snapshot( + running_profile_ids=set(browser_mgr.running.keys()) + ) + + @app.get("/api/benchmarks/latest") async def get_latest_benchmark_report(request: Request) -> dict[str, Any]: """Serve a redacted, administrator-only benchmark summary. @@ -1549,10 +3244,18 @@ async def vnc_proxy(websocket: WebSocket, profile_id: str): # the server must not respond with a subprotocol the client didn't request. requested = websocket.scope.get("subprotocols", []) subprotocol = "binary" if "binary" in requested else None - await websocket.accept(subprotocol=subprotocol) - import websockets + access_lease = _register_websocket_access(identity, profile_id) + diagnostics_session_id: int | None = None + try: + await websocket.accept(subprotocol=subprotocol) + except BaseException: + _unregister_websocket_access(access_lease) + raise + + diagnostics_session_id = live_diagnostics.live_diagnostics.begin_vnc_session(profile_id) + framebuffer_detector = live_diagnostics.FirstFramebufferDetector() vnc_url = f"ws://127.0.0.1:{running.ws_port}/websockify" try: @@ -1565,6 +3268,9 @@ async def vnc_proxy(websocket: WebSocket, profile_id: str): ping_timeout=None, compression=None, # KasmVNC can't handle permessage-deflate ) as vnc_ws: + live_diagnostics.live_diagnostics.mark_vnc_websocket_open( + profile_id, diagnostics_session_id + ) logger.info( "VNC proxy: connected to KasmVNC for %s (subprotocol=%s)", profile_id, vnc_ws.subprotocol, @@ -1628,27 +3334,38 @@ async def client_to_vnc(): async def vnc_to_client(): count = 0 + dropped = 0 + rfb_filter = _RfbServerStreamFilter(can_interact=can_interact) try: async for msg in vnc_ws: count += 1 - if isinstance(msg, bytes) and len(msg) > 0: - msg_type = msg[0] - if msg_type == 180: - # KasmVNC BinaryClipboard → convert to standard - # ServerCutText (type 3) so noVNC can handle it - text = _parse_kasmvnc_clipboard(msg) - if text: - logger.info("VNC proxy [v->c]: clipboard %d chars", len(text)) - await websocket.send_bytes(_build_server_cut_text(text)) - else: - logger.info("VNC proxy [v->c]: dropped type 180 (no text/plain)") + if isinstance(msg, bytes): + filtered = rfb_filter.filter(msg) + saw_framebuffer = False + if diagnostics_session_id is not None and not framebuffer_detector.seen: + saw_framebuffer = framebuffer_detector.observe(msg) + if rfb_filter.last_saw_framebuffer: + saw_framebuffer = True + framebuffer_detector.seen = True + if saw_framebuffer: + live_diagnostics.live_diagnostics.mark_vnc_first_framebuffer( + profile_id, diagnostics_session_id + ) + if rfb_filter.last_dropped_clipboard: + dropped += 1 + logger.info( + "VNC proxy [v->c]: dropped clipboard message for %s", + identity.kind, + ) + if not filtered: continue - await websocket.send_bytes(msg) - elif isinstance(msg, bytes): - await websocket.send_bytes(msg) + await websocket.send_bytes(filtered) else: + if not can_interact: + dropped += 1 + continue await websocket.send_text(msg) - logger.info("VNC proxy [v->c]: KasmVNC stream ended after %d msgs (close_code=%s)", count, vnc_ws.close_code) + logger.info("VNC proxy [v->c]: KasmVNC stream ended after %d msgs (%d dropped, close_code=%s)", count, dropped, vnc_ws.close_code) except WebSocketDisconnect as exc: logger.info("VNC proxy [v->c]: client disconnect code=%s after %d msgs", exc.code, count) except Exception as exc: @@ -1656,11 +3373,19 @@ async def vnc_to_client(): c2v = asyncio.create_task(client_to_vnc(), name="c2v") v2c = asyncio.create_task(vnc_to_client(), name="v2c") + proxy_tasks = [c2v, v2c] + revocation_task = None + if access_lease is not None: + revocation_task = asyncio.create_task( + access_lease.revoked.wait(), name="access-revocation" + ) + proxy_tasks.append(revocation_task) done, pending = await asyncio.wait( - [c2v, v2c], + proxy_tasks, return_when=asyncio.FIRST_COMPLETED, ) + access_revoked = revocation_task is not None and revocation_task in done finished = [t.get_name() for t in done] still_running = [t.get_name() for t in pending] @@ -1684,10 +3409,20 @@ async def vnc_to_client(): for task in pending: task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + if access_revoked: + logger.info("VNC proxy: access revoked for %s", profile_id) + await websocket.close(code=4403, reason="Access revoked") except Exception as exc: logger.error("VNC proxy connect error for %s: %s: %s", profile_id, type(exc).__name__, exc) finally: + if diagnostics_session_id is not None: + live_diagnostics.live_diagnostics.end_vnc_session( + profile_id, diagnostics_session_id + ) + _unregister_websocket_access(access_lease) try: await websocket.close() except Exception as exc: @@ -1772,7 +3507,10 @@ async def cdp_json_list(profile_id: str, request: Request): async def _proxy_cdp_websocket( - websocket: WebSocket, target_url: str, label: str, + websocket: WebSocket, + target_url: str, + label: str, + access_lease: _WebSocketAccessLease | None = None, ) -> None: """Bidirectional WebSocket proxy between a FastAPI client and a CDP target. @@ -1815,11 +3553,24 @@ async def cdp_to_client(): c2d = asyncio.create_task(client_to_cdp(), name="c2d") d2c = asyncio.create_task(cdp_to_client(), name="d2c") + proxy_tasks = [c2d, d2c] + revocation_task = None + if access_lease is not None: + revocation_task = asyncio.create_task( + access_lease.revoked.wait(), name="access-revocation" + ) + proxy_tasks.append(revocation_task) done, pending = await asyncio.wait( - [c2d, d2c], return_when=asyncio.FIRST_COMPLETED + proxy_tasks, return_when=asyncio.FIRST_COMPLETED ) + access_revoked = revocation_task is not None and revocation_task in done for task in pending: task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + if access_revoked: + logger.info("%s: access revoked", label) + await websocket.close(code=4403, reason="Access revoked") logger.info("%s: disconnected", label) except Exception as exc: @@ -1840,27 +3591,34 @@ async def cdp_proxy(websocket: WebSocket, profile_id: str): access_result = await _require_websocket_profile_permission(websocket, profile_id, "automate") if not access_result: return + _profile, identity = access_result running = browser_mgr.running.get(profile_id) if not running: await websocket.close(code=4004, reason="Profile not running") return - await websocket.accept() - - # Get browser-level CDP WebSocket URL from Chrome + access_lease = _register_websocket_access(identity, profile_id) try: - async with httpx.AsyncClient() as client: - resp = await client.get( - f"http://127.0.0.1:{running.cdp_port}/json/version", timeout=5 - ) - ws_url = resp.json()["webSocketDebuggerUrl"] - except Exception as exc: - logger.error("CDP proxy: failed to get WS URL for %s: %s", profile_id, exc) - await websocket.close(code=4005, reason="CDP not available") - return + await websocket.accept() - await _proxy_cdp_websocket(websocket, ws_url, f"CDP proxy [{profile_id}]") + # Get browser-level CDP WebSocket URL from Chrome + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"http://127.0.0.1:{running.cdp_port}/json/version", timeout=5 + ) + ws_url = resp.json()["webSocketDebuggerUrl"] + except Exception as exc: + logger.error("CDP proxy: failed to get WS URL for %s: %s", profile_id, exc) + await websocket.close(code=4005, reason="CDP not available") + return + + await _proxy_cdp_websocket( + websocket, ws_url, f"CDP proxy [{profile_id}]", access_lease + ) + finally: + _unregister_websocket_access(access_lease) @app.websocket("/api/profiles/{profile_id}/cdp/devtools/{path:path}") @@ -1872,16 +3630,22 @@ async def cdp_page_proxy(websocket: WebSocket, profile_id: str, path: str): access_result = await _require_websocket_profile_permission(websocket, profile_id, "automate") if not access_result: return + _profile, identity = access_result running = browser_mgr.running.get(profile_id) if not running: await websocket.close(code=4004, reason="Profile not running") return - await websocket.accept() - - target_url = f"ws://127.0.0.1:{running.cdp_port}/devtools/{path}" - await _proxy_cdp_websocket(websocket, target_url, f"CDP page proxy [{profile_id}]") + access_lease = _register_websocket_access(identity, profile_id) + try: + await websocket.accept() + target_url = f"ws://127.0.0.1:{running.cdp_port}/devtools/{path}" + await _proxy_cdp_websocket( + websocket, target_url, f"CDP page proxy [{profile_id}]", access_lease + ) + finally: + _unregister_websocket_access(access_lease) # ── Static Frontend ─────────────────────────────────────────────────────────── diff --git a/backend/models.py b/backend/models.py index 6d50393c..38a61e93 100644 --- a/backend/models.py +++ b/backend/models.py @@ -2,10 +2,43 @@ from __future__ import annotations +import json +import re from typing import Literal from pydantic import BaseModel, Field, field_validator, model_validator +SLUG_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]*$" +FOLDER_SEGMENT_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._ -]*$" +ACCENT_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}$" +Harness = Literal[ + "codex", + "antigravity", + "claude-code", + "opencode", + "browser-use", + "browser-harness", + "unbrowse", + "stagehand", +] +ProfileHealthState = Literal["pending", "running", "passed", "warning", "failed", "unavailable"] +ProfileHealthSourceState = Literal["missing", "measured", "derived", "unavailable", "skipped"] + + +def _validate_folder_path(value: str) -> str: + if value == "": + return value + if len(value) > 240: + raise ValueError("folder_path must be at most 240 characters") + if value.startswith("/") or value.endswith("/"): + raise ValueError("folder_path must not start or end with '/'") + segments = value.split("/") + if any(segment in {"", ".", ".."} for segment in segments): + raise ValueError("folder_path must contain friendly path segments") + if any(re.fullmatch(FOLDER_SEGMENT_PATTERN, segment) is None for segment in segments): + raise ValueError("folder_path must contain friendly path segments") + return value + class ProfileCreate(BaseModel): name: str @@ -13,8 +46,13 @@ class ProfileCreate(BaseModel): default="default", min_length=1, max_length=80, - pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*$", + pattern=SLUG_PATTERN, ) + project_id: str = Field(default="default", min_length=1, max_length=80, pattern=SLUG_PATTERN) + folder_path: str = Field(default="", max_length=240) + pinned: bool = False + accent_color: str | None = Field(default=None, pattern=ACCENT_COLOR_PATTERN) + harness: Harness = "codex" fingerprint_seed: int | None = None # random if not set proxy: str | None = None # "http://user:pass@host:port" or null timezone: str | None = None # "America/New_York" @@ -38,6 +76,11 @@ class ProfileCreate(BaseModel): notes: str | None = None tags: list[TagCreate] | None = None + @field_validator("folder_path") + @classmethod + def validate_folder_path(cls, value: str) -> str: + return _validate_folder_path(value) + class ProfileUpdate(BaseModel): name: str | None = None @@ -45,8 +88,13 @@ class ProfileUpdate(BaseModel): default=None, min_length=1, max_length=80, - pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*$", + pattern=SLUG_PATTERN, ) + project_id: str | None = Field(default=None, min_length=1, max_length=80, pattern=SLUG_PATTERN) + folder_path: str | None = Field(default=None, max_length=240) + pinned: bool | None = None + accent_color: str | None = Field(default=None, pattern=ACCENT_COLOR_PATTERN) + harness: Harness | None = None fingerprint_seed: int | None = None proxy: str | None = Field(default=None) timezone: str | None = Field(default=None) @@ -70,6 +118,47 @@ class ProfileUpdate(BaseModel): notes: str | None = Field(default=None) tags: list[TagCreate] | None = None + @field_validator("folder_path") + @classmethod + def validate_folder_path(cls, value: str | None) -> str | None: + if value is None: + return value + return _validate_folder_path(value) + + +class ProfileBulkOrganize(BaseModel): + """Safe bulk organization update. Authorization remains sandbox-scoped per profile.""" + + profile_ids: list[str] = Field(min_length=1, max_length=100) + project_id: str | None = Field(default=None, min_length=1, max_length=80, pattern=SLUG_PATTERN) + folder_path: str | None = Field(default=None, max_length=240) + pinned: bool | None = None + + @field_validator("folder_path") + @classmethod + def validate_folder_path(cls, value: str | None) -> str | None: + if value is None: + return value + return _validate_folder_path(value) + + @field_validator("profile_ids") + @classmethod + def validate_profile_ids(cls, value: list[str]) -> list[str]: + cleaned: list[str] = [] + seen: set[str] = set() + for item in value: + pid = str(item or "").strip() + if not pid or len(pid) > 120: + raise ValueError("profile_ids must contain non-empty ids up to 120 characters") + if pid in seen: + continue + seen.add(pid) + cleaned.append(pid) + if not cleaned: + raise ValueError("profile_ids must not be empty") + return cleaned + + class TagCreate(BaseModel): tag: str @@ -85,6 +174,11 @@ class ProfileResponse(BaseModel): id: str name: str sandbox_id: str = "default" + project_id: str = Field(default="default", min_length=1, max_length=80, pattern=SLUG_PATTERN) + folder_path: str = Field(default="", max_length=240) + pinned: bool = False + accent_color: str | None = Field(default=None, pattern=ACCENT_COLOR_PATTERN) + harness: Harness = "codex" fingerprint_seed: int proxy: str | None = None timezone: str | None = None @@ -103,6 +197,11 @@ class ProfileResponse(BaseModel): clipboard_sync: bool = True auto_launch: bool = False + @field_validator("folder_path") + @classmethod + def validate_folder_path(cls, value: str) -> str: + return _validate_folder_path(value) + @field_validator("clipboard_sync", mode="before") @classmethod def coerce_clipboard_sync(cls, v: object) -> bool: @@ -121,12 +220,60 @@ def coerce_clipboard_sync(cls, v: object) -> bool: cdp_url: str | None = None +class SessionLinkSet(BaseModel): + """Absolute open links for one origin (local tunnel or cloud/public).""" + + session_viewer_url: str + vnc_fullscreen_url: str | None = None + cdp_fullscreen_url: str | None = None + live_url: str | None = None + vnc_ws_url: str + debug_url: str | None = None + debugger_url: str | None = None + cdp_http_url: str | None = None + cdp_ws_url: str | None = None + live_metrics_url: str | None = None + launch_path: str + stop_path: str + status_path: str + live_metrics_path: str | None = None + + +class SessionOpenLinks(BaseModel): + """Steel-style local vs cloud open URLs for a profile session.""" + + profile_id: str + prefer: Literal["local", "cloud"] = "local" + mode: Literal["cdp", "vnc", "shell"] = "cdp" + open_url: str + local: SessionLinkSet + cloud: SessionLinkSet | None = None + bases: dict[str, str | None] = Field(default_factory=dict) + # Flat preferred-origin fields (same shape as ProfileOpenLinksResponse). + session_viewer_url: str | None = None + vnc_fullscreen_url: str | None = None + cdp_fullscreen_url: str | None = None + live_url: str | None = None + debug_url: str | None = None + debugger_url: str | None = None + websocket_url: str | None = None + cdp_url: str | None = None + live_metrics_url: str | None = None + local_url: str | None = None + cloud_url: str | None = None + local_vnc_fullscreen_url: str | None = None + local_cdp_fullscreen_url: str | None = None + cloud_vnc_fullscreen_url: str | None = None + cloud_cdp_fullscreen_url: str | None = None + + class LaunchResponse(BaseModel): profile_id: str status: str = "running" vnc_ws_port: int display: str cdp_url: str | None = None + links: SessionOpenLinks | None = None class StatusResponse(BaseModel): @@ -140,12 +287,139 @@ class ProfileStatusResponse(BaseModel): vnc_ws_port: int | None = None display: str | None = None cdp_url: str | None = None + links: SessionOpenLinks | None = None + + +class ProfileHealthResponse(BaseModel): + profile_id: str = Field(min_length=1, max_length=120) + state: ProfileHealthState = "unavailable" + checked_at: str | None = None + proxy_configured: bool = False + proxy_reachable: bool | None = None + outbound_ip_masked: str | None = Field(default=None, max_length=64) + proxy_latency_ms: float | None = Field(default=None, ge=0) + proxy_risk_score: int | None = Field(default=None, ge=0, le=100) + proxy_authenticity_score: int | None = Field(default=None, ge=0, le=100) + fingerprint_consistency_score: int | None = Field(default=None, ge=0, le=100) + browser_scan_score: int | None = Field(default=None, ge=0, le=100) + warnings: list[str] = Field(default_factory=list) + blockers: list[str] = Field(default_factory=list) + error_code: str | None = Field(default=None, max_length=64) + sources: dict[str, ProfileHealthSourceState] = Field(default_factory=dict) class ClipboardRequest(BaseModel): text: str = Field(max_length=1_048_576) # 1MB max +class TaskSessionCreate(BaseModel): + profile_id: str = Field(min_length=1, max_length=120) + title: str | None = Field(default=None, max_length=120) + metadata: dict[str, object] = Field(default_factory=dict) + + +class TaskSessionResponse(BaseModel): + id: str + profile_id: str + sandbox_id: str + title: str | None = None + status: Literal["active", "archived"] = "active" + created_by_kind: str + created_by_id: str | None = None + created_at: str + updated_at: str + metadata: dict[str, object] = Field(default_factory=dict) + + +TaskCommandKind = Literal[ + "navigate", + "click", + "double_click", + "scroll", + "type_text", + "keypress", + "drag", + "move", + "wait", + "copy", + "paste", + "screenshot", + "viewport", + "fullscreen", + "focus_remote", + "focus_chat", +] + + +class TaskCommand(BaseModel): + id: str = Field(min_length=1, max_length=120) + label: str = Field(min_length=1, max_length=120) + kind: TaskCommandKind + scope: Literal["ui", "host"] + args: dict[str, str | int | float | bool | None] = Field(default_factory=dict) + + @field_validator("args") + @classmethod + def validate_args(cls, value: dict[str, object]) -> dict[str, object]: + if len(value) > 20: + raise ValueError("Command args may contain at most 20 keys") + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + if len(encoded) > 2_048: + raise ValueError("Command args are too large") + return value + + +class TaskMessageCreate(BaseModel): + text: str = Field(min_length=1, max_length=8_000) + profile_id: str | None = Field(default=None, min_length=1, max_length=120) + commands: list[TaskCommand] = Field(default_factory=list) + metadata: dict[str, object] = Field(default_factory=dict) + + @field_validator("commands") + @classmethod + def validate_commands(cls, value: list[TaskCommand]) -> list[TaskCommand]: + return _validate_task_commands(value) + + +class TaskCommandRequest(BaseModel): + content: str = Field(min_length=1, max_length=8_000) + profile_id: str | None = Field(default=None, min_length=1, max_length=120) + commands: list[TaskCommand] = Field(default_factory=list) + metadata: dict[str, object] = Field(default_factory=dict) + + @field_validator("commands") + @classmethod + def validate_commands(cls, value: list[TaskCommand]) -> list[TaskCommand]: + return _validate_task_commands(value) + + +def _validate_task_commands(value: list[TaskCommand]) -> list[TaskCommand]: + if len(value) > 20: + raise ValueError("A task message may contain at most 20 commands") + return value + + +class TaskMessageResponse(BaseModel): + id: str + session_id: str + role: Literal["user", "assistant", "system", "tool"] + content: str + created_by_kind: str + created_by_id: str | None = None + created_at: str + metadata: dict[str, object] = Field(default_factory=dict) + + +class TaskEventResponse(BaseModel): + id: str + session_id: str + type: str + created_by_kind: str + created_by_id: str | None = None + created_at: str + payload: dict[str, object] = Field(default_factory=dict) + + class LoginRequest(BaseModel): """Bootstrap-token or named-user login request. @@ -187,6 +461,7 @@ class AccessUserCreate(BaseModel): password: str = Field(min_length=12, max_length=4096) role: AccessRole = "viewer" grants: list[AccessGrant] = Field(default_factory=list) + group_ids: list[str] = Field(default_factory=list) class AccessUserUpdate(BaseModel): @@ -194,6 +469,7 @@ class AccessUserUpdate(BaseModel): role: AccessRole | None = None active: bool | None = None grants: list[AccessGrant] | None = None + group_ids: list[str] | None = None class AccessUserResponse(BaseModel): @@ -202,6 +478,57 @@ class AccessUserResponse(BaseModel): role: AccessRole active: bool created_at: str + group_ids: list[str] = Field(default_factory=list) + grants: list[AccessGrant] = Field(default_factory=list) + effective_grants: list[AccessGrant] = Field(default_factory=list) + + +class AccessGroupCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + description: str | None = Field(default=None, max_length=500) + active: bool = True + member_user_ids: list[str] = Field(default_factory=list) + grants: list[AccessGrant] = Field(default_factory=list) + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + if not value.strip(): + raise ValueError("Group name cannot be blank") + return value + + +class AccessGroupUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + description: str | None = Field(default=None, max_length=500) + active: bool | None = None + member_user_ids: list[str] | None = None + grants: list[AccessGrant] | None = None + + @field_validator("name") + @classmethod + def validate_name(cls, value: str | None) -> str | None: + if value is None: + raise ValueError("Group name cannot be null") + if not value.strip(): + raise ValueError("Group name cannot be blank") + return value + + @field_validator("active") + @classmethod + def validate_active(cls, value: bool | None) -> bool | None: + if value is None: + raise ValueError("Group active state cannot be null") + return value + + +class AccessGroupResponse(BaseModel): + id: str + name: str + description: str | None = None + active: bool + created_at: str + member_user_ids: list[str] = Field(default_factory=list) grants: list[AccessGrant] = Field(default_factory=list) @@ -237,3 +564,250 @@ class AccessIdentityResponse(BaseModel): display_name: str role: str grants: list[AccessGrant] = Field(default_factory=list) + group_ids: list[str] = Field(default_factory=list) + effective_grants: list[AccessGrant] = Field(default_factory=list) + + +class ExtensionItem(BaseModel): + id: str + path: str + name: str + version: str + manifest_version: int + description: str + permissions: list[str] = Field(default_factory=list) + trust_state: Literal["valid", "untrusted_manifest", "missing_manifest", "invalid_path"] + error: str | None = None + + +class ExtensionInventoryResponse(BaseModel): + profile_id: str + extensions: list[ExtensionItem] = Field(default_factory=list) + + +ProxyCheckState = Literal["missing", "passed", "warning", "failed", "unavailable"] + + +class ProxyInventoryIngest(BaseModel): + """Bulk ingest of ``host:port:user:pass`` lines. Secrets never leave the server.""" + + lines: list[str] = Field(min_length=1, max_length=500) + + +class ProxyInventoryItem(BaseModel): + id: str + label: str + host_masked: str + port: int | None = None + username_masked: str | None = None + has_credentials: bool = False + active: bool = True + check_state: ProxyCheckState = "missing" + reachable: bool | None = None + latency_ms: float | None = Field(default=None, ge=0) + risk_score: int | None = Field(default=None, ge=0, le=100) + authenticity_score: int | None = Field(default=None, ge=0, le=100) + country_code: str | None = Field(default=None, max_length=2) + timezone_hint: str | None = None + locale_hint: str | None = None + warnings: list[str] = Field(default_factory=list) + blockers: list[str] = Field(default_factory=list) + last_checked_at: str | None = None + created_at: str + updated_at: str + + +class ProxyInventoryIngestResponse(BaseModel): + created: int = 0 + updated: int = 0 + rejected: int = 0 + items: list[ProxyInventoryItem] = Field(default_factory=list) + + +class ProxyAutoProfileCreate(BaseModel): + """Create a geo-aligned stealth profile from an inventory proxy.""" + + name: str | None = Field(default=None, max_length=120) + project_id: str = Field(default="proxied", min_length=1, max_length=80, pattern=SLUG_PATTERN) + sandbox_id: str = Field(default="default", min_length=1, max_length=80, pattern=SLUG_PATTERN) + harness: Harness = "browser-use" + launch: bool = False + + +class ExtensionProfileSummary(BaseModel): + id: str + name: str + project_id: str = "default" + folder_path: str = "" + sandbox_id: str = "default" + harness: Harness = "codex" + pinned: bool = False + timezone: str | None = None + locale: str | None = None + proxy_configured: bool = False + status: str = "stopped" + running: bool = False + + +class ExtensionCatalogResponse(BaseModel): + """One-shot bootstrap payload for a Chrome extension or agent.""" + + bases: dict[str, str | None] = Field(default_factory=dict) + endpoints: dict[str, str] = Field(default_factory=dict) + profiles: list[ExtensionProfileSummary] = Field(default_factory=list) + proxies: list[ProxyInventoryItem] = Field(default_factory=list) + capabilities: dict[str, bool] = Field(default_factory=dict) + + +class ExtensionOpenSessionRequest(BaseModel): + profile_id: str = Field(min_length=1, max_length=120) + launch: bool = True + prefer: Literal["local", "cloud"] = "local" + mode: Literal["cdp", "vnc", "shell"] = "cdp" + + +class ExtensionOpenSessionResponse(BaseModel): + profile_id: str + status: str + launched: bool = False + already_running: bool = False + prefer: Literal["local", "cloud"] = "local" + mode: Literal["cdp", "vnc", "shell"] = "cdp" + open_url: str + links: SessionOpenLinks + # Top-level flat URLs matching GET /api/profiles/{id}/open-links. + session_viewer_url: str | None = None + vnc_fullscreen_url: str | None = None + cdp_fullscreen_url: str | None = None + live_url: str | None = None + # Relative CDP proxy path for automation (not the absolute open-links cdp_url). + cdp_url: str | None = None + vnc_ws_port: int | None = None + display: str | None = None + + +class ProfileOpenLinksResponse(BaseModel): + """Flat + nested open links for Chrome extension / agent one-click actions.""" + + profile_id: str + prefer: Literal["local", "cloud"] = "local" + mode: Literal["cdp", "vnc", "shell"] = "cdp" + open_url: str + local: SessionLinkSet + cloud: SessionLinkSet | None = None + bases: dict[str, str | None] = Field(default_factory=dict) + session_viewer_url: str + vnc_fullscreen_url: str + cdp_fullscreen_url: str | None = None + live_url: str | None = None + debug_url: str | None = None + debugger_url: str | None = None + websocket_url: str + cdp_url: str | None = None + live_metrics_url: str | None = None + local_url: str + cloud_url: str | None = None + local_vnc_fullscreen_url: str | None = None + local_cdp_fullscreen_url: str | None = None + cloud_vnc_fullscreen_url: str | None = None + cloud_cdp_fullscreen_url: str | None = None + + +class DefaultExtensionItem(BaseModel): + """Selectable Comet-derived catalog entry for new/template profiles.""" + + id: str + name: str + description: str | None = None + category: str | None = None + tags: list[str] = Field(default_factory=list) + recommended: bool = False + default_selected: bool = False + selectable: bool = True + selected: bool = False + available: bool = False + path: str | None = None + icon_url: str | None = None + store_url: str | None = None + + +class ExtensionDefaultsResponse(BaseModel): + source: str = "comet" + source_label: str = "Comet" + catalog_dir_configured: bool = False + selected_ids: list[str] = Field(default_factory=list) + extensions: list[DefaultExtensionItem] = Field(default_factory=list) + items: list[DefaultExtensionItem] = Field(default_factory=list) + count: int = 0 + + +class ExtensionDefaultsUpdate(BaseModel): + selected_ids: list[str] = Field(default_factory=list, max_length=64) + + +class ProfileTemplateSummary(BaseModel): + id: str + name: str + summary: str = "" + system_prompt: str = "" + harness: Harness = "browser-use" + project_id: str = "default" + folder_path: str = "" + platform: Literal["windows", "macos", "linux"] = "windows" + apply_default_extensions: bool = True + quick_options: list[str] = Field(default_factory=list) + + +class ProfileTemplateCreate(BaseModel): + template_id: str = Field(min_length=1, max_length=80) + name: str | None = Field(default=None, max_length=120) + project_id: str | None = None + harness: Harness | None = None + proxy: str | None = None + apply_default_extensions: bool | None = None + launch: bool = False + + +class ExtensionTemplateItem(BaseModel): + id: str + name: str + project_id: str = "default" + folder_path: str = "" + harness: Harness = "browser-use" + geoip: bool | None = None + screen_width: int | None = None + screen_height: int | None = None + create_path: str = "/api/profiles" + from_proxy_path: str | None = None + + +class ExtensionTemplatesResponse(BaseModel): + templates: list[ExtensionTemplateItem] = Field(default_factory=list) + create_profile_path: str = "/api/profiles" + create_from_proxy_path: str = "/api/proxies/{proxy_id}/profiles" + + +class LiveMetricsSample(BaseModel): + transport: Literal["cdp", "vnc"] = "cdp" + connection_state: Literal["connecting", "connected", "reconnecting", "failed", "idle"] = ( + "connected" + ) + fps: float | None = Field(default=None, ge=0) + rtt_ms: float | None = Field(default=None, ge=0) + frames_received: int | None = Field(default=None, ge=0) + reconnect_count: int | None = Field(default=None, ge=0) + dropped_frames: int | None = Field(default=None, ge=0) + + +class LiveMetricsResponse(BaseModel): + profile_id: str + transport: Literal["cdp", "vnc"] | None = None + connection_state: Literal["connecting", "connected", "reconnecting", "failed", "idle"] = "idle" + fps: float | None = None + rtt_ms: float | None = None + frames_received: int | None = None + reconnect_count: int | None = None + dropped_frames: int | None = None + updated_at: str | None = None + transports: dict[str, dict[str, object]] = Field(default_factory=dict) + diff --git a/backend/profile_health.py b/backend/profile_health.py new file mode 100644 index 00000000..3f9cf238 --- /dev/null +++ b/backend/profile_health.py @@ -0,0 +1,681 @@ +"""Redacted profile-health normalization and classification helpers.""" + +from __future__ import annotations + +import asyncio +import datetime +import ipaddress +import json +import math +import re +import time +from dataclasses import dataclass +from typing import Any, Callable, Collection, Mapping +from urllib.parse import urlsplit + +import httpx + +_MAX_EXTERNAL_TEXT = 200_000 +_BROWSERSCAN_SCORE_RE = re.compile( + r"\bauthenticity(?:\s+score)?\s*[:\-]?\s*(100|[0-9]{1,2})\s*%", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class NormalizedProxyCheckerResult: + reachable: bool | None + latency_ms: float | None + risk_score: int | None + authenticity_score: int | None + warnings: tuple[str, ...] = () + blockers: tuple[str, ...] = () + authenticity_source: str | None = None # "measured" | "derived" | None + + +@dataclass(frozen=True) +class FingerprintConsistencyResult: + score: int | None + warnings: tuple[str, ...] = () + blockers: tuple[str, ...] = () + + +@dataclass(frozen=True) +class BrowserScanClassification: + score: int | None + warnings: tuple[str, ...] = () + blockers: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ProfileHealthResult: + state: str + checked_at: str + proxy_configured: bool + proxy_reachable: bool | None + outbound_ip_masked: str | None + proxy_latency_ms: float | None + proxy_risk_score: int | None + proxy_authenticity_score: int | None + fingerprint_consistency_score: int | None + browser_scan_score: int | None + warnings: tuple[str, ...] + blockers: tuple[str, ...] + error_code: str | None + sources: dict[str, str] + + def as_record(self) -> dict[str, object]: + return { + "state": self.state, + "checked_at": self.checked_at, + "proxy_configured": self.proxy_configured, + "proxy_reachable": self.proxy_reachable, + "outbound_ip_masked": self.outbound_ip_masked, + "proxy_latency_ms": self.proxy_latency_ms, + "proxy_risk_score": self.proxy_risk_score, + "proxy_authenticity_score": self.proxy_authenticity_score, + "fingerprint_consistency_score": self.fingerprint_consistency_score, + "browser_scan_score": self.browser_scan_score, + "warnings": list(self.warnings), + "blockers": list(self.blockers), + "error_code": self.error_code, + "sources": dict(self.sources), + } + + +def mask_ip_address(value: str) -> str | None: + """Return a coarse display-only IP mask or None for invalid input.""" + try: + address = ipaddress.ip_address(value.strip()) + except (AttributeError, ValueError): + return None + + if address.version == 4: + octets = str(address).split(".") + return ".".join((*octets[:3], "x")) + + hextets = [f"{int(part, 16):x}" for part in address.exploded.split(":")[:3]] + return f"{':'.join(hextets)}:…" + + +def _clamp_score(value: object) -> int | None: + if value is None or isinstance(value, bool): + return None + try: + numeric = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(numeric): + return None + return max(0, min(100, int(round(numeric)))) + + +def derive_authenticity_score(risk_score: object) -> int | None: + """Convert a risk score to a clearly derived inverse score.""" + normalized = _clamp_score(risk_score) + return None if normalized is None else 100 - normalized + + +def is_trusted_proxychecker_url( + url: str, + *, + allowed_hosts: Collection[str] | None = None, +) -> bool: + """Allow only credential-free local or explicitly configured service URLs.""" + try: + parsed = urlsplit(url) + host = (parsed.hostname or "").lower().rstrip(".") + _ = parsed.port + except (TypeError, ValueError): + return False + + if parsed.scheme not in {"http", "https"} or not host: + return False + if parsed.username is not None or parsed.password is not None: + return False + if parsed.query or parsed.fragment: + return False + + allowed = {item.lower().rstrip(".") for item in (allowed_hosts or ())} + if host in {"localhost", "host.docker.internal"} or host in allowed: + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _reason_haystack(reasons: object) -> str: + """Flatten string or structured proxychecker reasons into searchable text.""" + if not isinstance(reasons, list): + return "" + parts: list[str] = [] + for item in reasons: + if isinstance(item, str): + parts.append(item) + continue + if isinstance(item, Mapping): + for key in ("code", "message", "rule_id", "feature"): + value = item.get(key) + if isinstance(value, str) and value.strip(): + parts.append(value) + return " ".join(parts).lower() + + +def _safe_proxy_warnings(reasons: object, risk_score: int | None) -> tuple[str, ...]: + haystack = _reason_haystack(reasons) + warnings: list[str] = [] + for category, terms in ( + ("tor_detected", ("tor exit", "tor network", "tor_detected", "active_tor", "is_tor")), + ("vpn_detected", ("vpn", "active_vpn", "is_vpn")), + ( + "datacenter_network", + ("datacenter", "data center", "hosting", "is_datacenter", "is_hosting"), + ), + ( + "proxy_detected", + ("open proxy", "anonymous proxy", "proxy signal", "is_proxy", "proxy_detected"), + ), + ): + if any(term in haystack for term in terms): + warnings.append(category) + if risk_score is not None and risk_score >= 70: + warnings.append("proxy_risk_high") + return tuple(warnings) + + +def normalize_proxychecker_response(payload: object) -> NormalizedProxyCheckerResult: + """Reduce a proxychecker payload to safe, bounded product fields.""" + if not isinstance(payload, Mapping): + return NormalizedProxyCheckerResult(None, None, None, None, blockers=("proxychecker_invalid_response",)) + + results = payload.get("results") + scoring = payload.get("scoring") + if ( + not isinstance(results, list) + or not results + or not isinstance(results[0], Mapping) + or not isinstance(scoring, Mapping) + ): + return NormalizedProxyCheckerResult(None, None, None, None, blockers=("proxychecker_invalid_response",)) + + primary = results[0] + reachable_value = primary.get("ok") + reachable = reachable_value if isinstance(reachable_value, bool) else None + + latency_value = primary.get("latency_ms") + latency_ms: float | None = None + if not isinstance(latency_value, bool): + try: + candidate = float(latency_value) + except (TypeError, ValueError): + candidate = -1 + if math.isfinite(candidate) and candidate >= 0: + latency_ms = round(candidate, 3) + + risk_score = _clamp_score(scoring.get("risk_score")) + # Prefer an explicit authenticity field when the checker provides one; otherwise + # derive the inverse of risk. Never invent a 0 when risk itself is missing. + explicit_authenticity = _clamp_score( + scoring.get("authenticity_score", scoring.get("authenticity")) + ) + if explicit_authenticity is not None: + authenticity_score = explicit_authenticity + authenticity_source = "measured" + else: + authenticity_score = derive_authenticity_score(risk_score) + authenticity_source = "derived" if authenticity_score is not None else None + return NormalizedProxyCheckerResult( + reachable=reachable, + latency_ms=latency_ms, + risk_score=risk_score, + authenticity_score=authenticity_score, + warnings=_safe_proxy_warnings(scoring.get("reasons"), risk_score), + authenticity_source=authenticity_source, + ) + + +def _platform_family(value: object) -> str | None: + if not isinstance(value, str): + return None + lowered = value.lower() + if "win" in lowered: + return "windows" + if "mac" in lowered or "darwin" in lowered: + return "macos" + if "linux" in lowered: + return "linux" + return None + + +def _user_agent_family(value: object) -> str | None: + if not isinstance(value, str): + return None + lowered = value.lower() + if "edg/" in lowered or "edgios/" in lowered: + return "edge" + if "chrome/" in lowered or "crios/" in lowered: + return "chrome" + if "firefox/" in lowered or "fxios/" in lowered: + return "firefox" + if "safari/" in lowered: + return "safari" + return None + + +def _normalized_locale(value: object) -> str | None: + if not isinstance(value, str) or not value.strip(): + return None + return value.strip().replace("_", "-").lower() + + +def score_fingerprint_consistency( + config: Mapping[str, Any], + runtime: Mapping[str, Any], +) -> FingerprintConsistencyResult: + """Score saved configuration against runtime signals without returning values.""" + comparisons: list[tuple[str, bool | None]] = [] + + expected_platform = _platform_family(config.get("platform")) + if expected_platform is not None: + actual_platform = _platform_family(runtime.get("platform")) + comparisons.append(("platform", None if actual_platform is None else actual_platform == expected_platform)) + + expected_width = config.get("screen_width") + expected_height = config.get("screen_height") + if isinstance(expected_width, int) and isinstance(expected_height, int): + actual_width = runtime.get("screen_width") + actual_height = runtime.get("screen_height") + if not isinstance(actual_width, int) or not isinstance(actual_height, int): + comparisons.append(("screen", None)) + else: + comparisons.append(("screen", (actual_width, actual_height) == (expected_width, expected_height))) + + expected_timezone = config.get("timezone") + if isinstance(expected_timezone, str) and expected_timezone: + # When geoip alignment is on, timezone may be rewritten from egress IP. + # Treat mismatch as soft warning rather than hard score damage. + actual_timezone = runtime.get("timezone") + if bool(config.get("geoip")): + if isinstance(actual_timezone, str) and actual_timezone != expected_timezone: + # still count as match for scoring; warning retained via separate path below + comparisons.append(("timezone", True)) + else: + comparisons.append( + ( + "timezone", + None if not isinstance(actual_timezone, str) else actual_timezone == expected_timezone, + ) + ) + else: + comparisons.append( + ("timezone", None if not isinstance(actual_timezone, str) else actual_timezone == expected_timezone) + ) + + expected_locale = _normalized_locale(config.get("locale")) + if expected_locale is not None: + actual_locale = _normalized_locale(runtime.get("language")) + if bool(config.get("geoip")) and actual_locale is not None and actual_locale != expected_locale: + # Soft-match language family (de-AT vs de-DE) under geoip. + expected_family = expected_locale.split("-", 1)[0] + actual_family = actual_locale.split("-", 1)[0] + comparisons.append(("locale", expected_family == actual_family)) + else: + comparisons.append(("locale", None if actual_locale is None else actual_locale == expected_locale)) + + expected_hardware = config.get("hardware_concurrency") + if isinstance(expected_hardware, int): + actual_hardware = runtime.get("hardware_concurrency") + comparisons.append( + ("hardware_concurrency", None if not isinstance(actual_hardware, int) else actual_hardware == expected_hardware) + ) + + expected_ua = _user_agent_family(config.get("user_agent")) + if expected_ua is not None: + actual_ua = _user_agent_family(runtime.get("user_agent")) + comparisons.append(("user_agent_family", None if actual_ua is None else actual_ua == expected_ua)) + + if not comparisons: + return FingerprintConsistencyResult(None, blockers=("fingerprint_signals_missing",)) + + matches = sum(outcome is True for _, outcome in comparisons) + score = int(round((matches / len(comparisons)) * 100)) + warnings = tuple(f"{name}_mismatch" for name, outcome in comparisons if outcome is False) + blockers = tuple(f"{name}_missing" for name, outcome in comparisons if outcome is None) + return FingerprintConsistencyResult(score, warnings=warnings, blockers=blockers) + + +def classify_browserscan_text(text: object) -> BrowserScanClassification: + """Conservatively reduce visible BrowserScan text without retaining it.""" + if not isinstance(text, str): + return BrowserScanClassification(None, blockers=("browser_scan_score_missing",)) + + bounded = text[:_MAX_EXTERNAL_TEXT] + lowered = bounded.lower() + if any(term in lowered for term in ("verify you are human", "captcha", "cloudflare challenge")): + return BrowserScanClassification(None, blockers=("browser_scan_challenge",)) + if any(term in lowered for term in ("cookie consent", "accept all cookies", "manage consent")): + return BrowserScanClassification(None, blockers=("browser_scan_consent",)) + + match = _BROWSERSCAN_SCORE_RE.search(bounded) + if match is None: + return BrowserScanClassification(None, blockers=("browser_scan_score_missing",)) + + warnings: list[str] = [] + for category, terms in ( + ( + "automation_detected", + ( + "automation detected", + "webdriver detected", + "webdriver: true", + "webdriver true", + ), + ), + ("network_mismatch", ("network mismatch", "webrtc mismatch", "dns mismatch", "timezone mismatch")), + ("fingerprint_warning", ("canvas fingerprint warning", "webgl warning", "audio fingerprint warning")), + ("profile_warning", ("incognito warning", "profile warning")), + ): + if any(term in lowered for term in terms): + warnings.append(category) + + return BrowserScanClassification(int(match.group(1)), warnings=tuple(warnings)) + + +def _utc_now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def _unique(values: list[str]) -> tuple[str, ...]: + return tuple(dict.fromkeys(values)) + + +class ProfileHealthProbe: + """Measure one running profile while reducing all outputs to safe fields.""" + + def __init__( + self, + *, + proxychecker_url: str = "", + allowed_proxychecker_hosts: Collection[str] | None = None, + ip_echo_url: str = "https://api.ipify.org?format=json", + browserscan_url: str = "https://www.browserscan.net/", + component_timeout_s: float = 12.0, + http_client_factory: Callable[..., Any] = httpx.AsyncClient, + monotonic: Callable[[], float] = time.monotonic, + now: Callable[[], str] = _utc_now, + ): + normalized_url = proxychecker_url.strip().rstrip("/") + if normalized_url and not is_trusted_proxychecker_url( + normalized_url, + allowed_hosts=allowed_proxychecker_hosts, + ): + raise ValueError("PROXYCHECKER_URL must be a trusted local endpoint") + self.proxychecker_url = normalized_url + self.ip_echo_url = ip_echo_url + self.browserscan_url = browserscan_url + self.component_timeout_s = max(0.1, float(component_timeout_s)) + self.http_client_factory = http_client_factory + self.monotonic = monotonic + self.now = now + + async def _wait(self, awaitable: Any) -> Any: + return await asyncio.wait_for(awaitable, timeout=self.component_timeout_s) + + async def _close_page(self, page: Any) -> None: + try: + await self._wait(page.close()) + except Exception: + return + + async def _observe_network(self, page: Any) -> dict[str, object]: + started = self.monotonic() + try: + await self._wait( + page.goto( + self.ip_echo_url, + wait_until="domcontentloaded", + timeout=int(self.component_timeout_s * 1000), + ) + ) + body = await self._wait(page.text_content("body")) + except (TimeoutError, asyncio.TimeoutError): + self.monotonic() + return { + "reachable": False, + "masked_ip": None, + "latency_ms": None, + "blocker": "network_timeout", + "error_code": "network_timeout", + "source": "unavailable", + } + except Exception: + self.monotonic() + return { + "reachable": False, + "masked_ip": None, + "latency_ms": None, + "blocker": "network_unavailable", + "error_code": "network_unavailable", + "source": "unavailable", + } + + elapsed_ms = round(max(0.0, self.monotonic() - started) * 1000, 3) + try: + payload = json.loads(body) if isinstance(body, str) and len(body) <= 4096 else None + except json.JSONDecodeError: + payload = None + raw_ip = payload.get("ip") if isinstance(payload, dict) else None + masked_ip = mask_ip_address(raw_ip) if isinstance(raw_ip, str) else None + if masked_ip is None: + return { + "reachable": True, + "masked_ip": None, + "latency_ms": elapsed_ms, + "blocker": "network_invalid_response", + "error_code": "network_invalid_response", + "source": "unavailable", + } + return { + "reachable": True, + "masked_ip": masked_ip, + "latency_ms": elapsed_ms, + "blocker": None, + "error_code": None, + "source": "measured", + } + + async def _observe_fingerprint( + self, + profile: Mapping[str, Any], + page: Any, + ) -> FingerprintConsistencyResult: + script = """() => ({ + platform: navigator.platform || null, + screen_width: Number.isFinite(screen.width) ? screen.width : null, + screen_height: Number.isFinite(screen.height) ? screen.height : null, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null, + language: navigator.language || null, + hardware_concurrency: navigator.hardwareConcurrency || null, + user_agent: navigator.userAgent || null, + })""" + try: + runtime = await self._wait(page.evaluate(script)) + except Exception: + return FingerprintConsistencyResult(None, blockers=("fingerprint_runtime_unavailable",)) + if not isinstance(runtime, Mapping): + return FingerprintConsistencyResult(None, blockers=("fingerprint_runtime_unavailable",)) + return score_fingerprint_consistency(profile, runtime) + + async def _observe_browserscan(self, context: Any) -> BrowserScanClassification: + page = None + try: + page = await self._wait(context.new_page()) + await self._wait( + page.goto( + self.browserscan_url, + wait_until="domcontentloaded", + timeout=int(self.component_timeout_s * 1000), + ) + ) + text = await self._wait(page.text_content("body")) + return classify_browserscan_text(text) + except (TimeoutError, asyncio.TimeoutError): + return BrowserScanClassification(None, blockers=("browser_scan_timeout",)) + except Exception: + return BrowserScanClassification(None, blockers=("browser_scan_unavailable",)) + finally: + if page is not None: + await self._close_page(page) + + async def _observe_proxychecker(self, proxy: str) -> NormalizedProxyCheckerResult: + if not self.proxychecker_url: + return NormalizedProxyCheckerResult(None, None, None, None) + try: + async with self.http_client_factory(timeout=self.component_timeout_s) as client: + response = await self._wait( + client.post( + f"{self.proxychecker_url}/check", + json={ + "target": self.ip_echo_url, + "proxies": [proxy], + "limit": 1, + "scoring_profile": "default", + }, + ) + ) + response.raise_for_status() + return normalize_proxychecker_response(response.json()) + except (TimeoutError, asyncio.TimeoutError, httpx.HTTPError): + return NormalizedProxyCheckerResult( + None, + None, + None, + None, + blockers=("proxychecker_unavailable",), + ) + except Exception: + return NormalizedProxyCheckerResult( + None, + None, + None, + None, + blockers=("proxychecker_unavailable",), + ) + + async def run(self, profile: Mapping[str, Any], running: Any) -> ProfileHealthResult: + proxy = profile.get("proxy") + proxy_configured = isinstance(proxy, str) and bool(proxy) + sources: dict[str, str] = {} + warnings: list[str] = [] + blockers: list[str] = [] + error_code: str | None = None + + proxy_reachable: bool | None = None + outbound_ip_masked: str | None = None + proxy_latency_ms: float | None = None + fingerprint_score: int | None = None + browser_scan_score: int | None = None + risk_score: int | None = None + authenticity_score: int | None = None + + context = getattr(running, "context", None) + network_page = None + if context is None: + blockers.append("browser_context_unavailable") + error_code = "browser_context_unavailable" + sources["browser_network"] = "unavailable" + sources["fingerprint_consistency"] = "unavailable" + else: + try: + network_page = await self._wait(context.new_page()) + except Exception: + blockers.append("browser_context_unavailable") + error_code = "browser_context_unavailable" + sources["browser_network"] = "unavailable" + sources["fingerprint_consistency"] = "unavailable" + else: + try: + network = await self._observe_network(network_page) + proxy_reachable = network["reachable"] if isinstance(network["reachable"], bool) else None + outbound_ip_masked = ( + network["masked_ip"] if isinstance(network["masked_ip"], str) else None + ) + if proxy_configured and isinstance(network["latency_ms"], float): + proxy_latency_ms = network["latency_ms"] + sources["browser_network"] = str(network["source"]) + if isinstance(network["blocker"], str): + blockers.append(network["blocker"]) + if isinstance(network["error_code"], str): + error_code = network["error_code"] + + fingerprint = await self._observe_fingerprint(profile, network_page) + fingerprint_score = fingerprint.score + warnings.extend(fingerprint.warnings) + blockers.extend(fingerprint.blockers) + sources["fingerprint_consistency"] = ( + "measured" if fingerprint.score is not None else "unavailable" + ) + finally: + await self._close_page(network_page) + + if context is None: + browserscan = BrowserScanClassification(None, blockers=("browser_scan_unavailable",)) + else: + browserscan = await self._observe_browserscan(context) + browser_scan_score = browserscan.score + warnings.extend(browserscan.warnings) + blockers.extend(browserscan.blockers) + sources["browser_scan"] = "measured" if browserscan.score is not None else "unavailable" + + if self.proxychecker_url and proxy_configured: + checker = await self._observe_proxychecker(proxy) + risk_score = checker.risk_score + authenticity_score = checker.authenticity_score + if checker.reachable is not None: + proxy_reachable = checker.reachable + if checker.latency_ms is not None: + proxy_latency_ms = checker.latency_ms + warnings.extend(checker.warnings) + blockers.extend(checker.blockers) + sources["proxychecker"] = "unavailable" if checker.blockers else "measured" + if authenticity_score is not None: + sources["proxy_authenticity"] = checker.authenticity_source or "derived" + else: + sources["proxychecker"] = "skipped" + + meaningful = any( + value is not None + for value in ( + proxy_reachable, + outbound_ip_masked, + fingerprint_score, + browser_scan_score, + risk_score, + ) + ) + normalized_warnings = _unique(warnings) + normalized_blockers = _unique(blockers) + if not meaningful: + state = "unavailable" + elif normalized_warnings or normalized_blockers or proxy_reachable is False: + state = "warning" + else: + state = "passed" + + return ProfileHealthResult( + state=state, + checked_at=self.now(), + proxy_configured=proxy_configured, + proxy_reachable=proxy_reachable, + outbound_ip_masked=outbound_ip_masked, + proxy_latency_ms=proxy_latency_ms, + proxy_risk_score=risk_score, + proxy_authenticity_score=authenticity_score, + fingerprint_consistency_score=fingerprint_score, + browser_scan_score=browser_scan_score, + warnings=normalized_warnings, + blockers=normalized_blockers, + error_code=error_code, + sources=sources, + ) diff --git a/backend/profile_templates.py b/backend/profile_templates.py new file mode 100644 index 00000000..1698e844 --- /dev/null +++ b/backend/profile_templates.py @@ -0,0 +1,256 @@ +"""Standard / prefabricated profile templates with system prompts. + +Templates are first-class API resources so agents are not UI-bound. +""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from backend import extension_catalog + +_TEMPLATES: list[dict[str, Any]] = [ + { + "id": "generate-new", + "name": "Generate New", + "summary": "Fresh fingerprint with geo defaults and selected Comet extensions", + "system_prompt": ( + "You are operating a fresh CloakBrowser profile. Prefer precise browser " + "actions, verify pages visually, and never invent proxy or cookie secrets." + ), + "harness": "browser-use", + "project_id": "default", + "folder_path": "templates", + "platform": "windows", + "screen_width": 1920, + "screen_height": 1080, + "hardware_concurrency": 8, + "gpu_vendor": "Google Inc. (NVIDIA)", + "gpu_renderer": ( + "ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 (0x00002786) " + "Direct3D11 vs_5_0 ps_5_0, D3D11)" + ), + "geoip": True, + "humanize": True, + "human_preset": "default", + "clipboard_sync": True, + "color_scheme": "dark", + "search_engine": "duckduckgo", + "timezone": "Europe/Berlin", + "locale": "de-DE", + "apply_default_extensions": True, + "quick_options": ["harness", "platform", "geoip", "humanize", "proxy"], + }, + { + "id": "codex-operator", + "name": "Codex Operator", + "summary": "Codex Computer Use–oriented desktop profile", + "system_prompt": ( + "Prefer Codex Computer Use for host-scoped browser actions. Capture, " + "copy, and paste only when the verified bridge reports capability." + ), + "harness": "codex", + "project_id": "default", + "folder_path": "templates/codex", + "platform": "windows", + "screen_width": 1920, + "screen_height": 1080, + "hardware_concurrency": 8, + "gpu_vendor": "Google Inc. (NVIDIA)", + "gpu_renderer": ( + "ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 (0x00002786) " + "Direct3D11 vs_5_0 ps_5_0, D3D11)" + ), + "geoip": True, + "humanize": True, + "human_preset": "careful", + "clipboard_sync": True, + "color_scheme": "dark", + "search_engine": "duckduckgo", + "timezone": "Europe/Berlin", + "locale": "de-DE", + "apply_default_extensions": True, + "quick_options": ["harness", "geoip", "humanize"], + }, + { + "id": "mobile-demo", + "name": "Mobile Demo", + "summary": "Portrait framebuffer for VCVM mobile workspace demos", + "system_prompt": ( + "You are on a mobile-oriented CloakBrowser surface. Keep interactions " + "compact, respect touch-safe targets, and verify the live VNC/CDP feed." + ), + "harness": "browser-use", + "project_id": "mobile", + "folder_path": "templates", + "platform": "macos", + "screen_width": 390, + "screen_height": 844, + "hardware_concurrency": 6, + "gpu_vendor": "Google Inc. (Apple)", + "gpu_renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)", + "geoip": True, + "humanize": True, + "human_preset": "default", + "clipboard_sync": True, + "color_scheme": "dark", + "search_engine": "duckduckgo", + "timezone": "Europe/Vienna", + "locale": "de-AT", + "apply_default_extensions": True, + "quick_options": ["harness", "platform", "geoip"], + }, + { + "id": "proxied-auto", + "name": "Proxied Auto", + "summary": "Geo-aligned anti-stealth baseline for inventory proxies", + "system_prompt": ( + "This profile must always launch with its assigned proxy. Confirm " + "outbound IP via health / Proxy-Checker before trusting geo claims." + ), + "harness": "browser-use", + "project_id": "proxied", + "folder_path": "auto", + "platform": "windows", + "screen_width": 1920, + "screen_height": 1080, + "hardware_concurrency": 8, + "gpu_vendor": "Google Inc. (NVIDIA)", + "gpu_renderer": ( + "ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 (0x00002786) " + "Direct3D11 vs_5_0 ps_5_0, D3D11)" + ), + "geoip": True, + "humanize": True, + "human_preset": "default", + "clipboard_sync": True, + "color_scheme": "dark", + "search_engine": "duckduckgo", + "timezone": "Europe/Berlin", + "locale": "de-DE", + "apply_default_extensions": True, + "quick_options": ["harness", "proxy", "geoip", "humanize"], + }, + { + "id": "stagehand-cdp", + "name": "Stagehand CDP", + "summary": "Production CDP primitives preference (metadata)", + "system_prompt": ( + "Prefer Act/Observe/Extract style steps. Use CDP-direct live view when " + "latency matters; fall back to VNC for full desktop takeover." + ), + "harness": "stagehand", + "project_id": "research", + "folder_path": "templates/stagehand", + "platform": "windows", + "screen_width": 1920, + "screen_height": 1080, + "hardware_concurrency": 8, + "gpu_vendor": "Google Inc. (NVIDIA)", + "gpu_renderer": ( + "ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 (0x00002786) " + "Direct3D11 vs_5_0 ps_5_0, D3D11)" + ), + "geoip": True, + "humanize": False, + "human_preset": "default", + "clipboard_sync": True, + "color_scheme": "dark", + "search_engine": "duckduckgo", + "timezone": "Europe/Berlin", + "locale": "de-DE", + "apply_default_extensions": True, + "quick_options": ["harness", "geoip"], + }, +] + + +def list_templates() -> list[dict[str, Any]]: + return [dict(item) for item in _TEMPLATES] + + +def get_template(template_id: str) -> dict[str, Any] | None: + for item in _TEMPLATES: + if item["id"] == template_id: + return dict(item) + return None + + +def generate_profile_name(template_id: str, *, project_id: str | None = None) -> str: + template = get_template(template_id) or {"name": "Profile"} + label = str(template.get("name") or "Profile") + digest = hashlib.sha1(f"{template_id}:{project_id or ''}".encode("utf-8")).hexdigest()[:4] + # Include a short random-ish suffix from time-independent digest + counter material + suffix = digest.upper() + project = (project_id or str(template.get("project_id") or "default")).strip() or "default" + return f"{label} · {project}-{suffix}" + + +def build_profile_fields( + template_id: str, + *, + overrides: dict[str, Any] | None = None, + apply_extensions: bool | None = None, +) -> dict[str, Any]: + template = get_template(template_id) + if template is None: + raise KeyError(template_id) + overrides = dict(overrides or {}) + name = overrides.pop("name", None) or generate_profile_name( + template_id, project_id=overrides.get("project_id") or template.get("project_id") + ) + fields = { + "name": name, + "sandbox_id": overrides.get("sandbox_id") or "default", + "project_id": overrides.get("project_id") or template["project_id"], + "folder_path": overrides.get("folder_path") if "folder_path" in overrides else template["folder_path"], + "pinned": bool(overrides.get("pinned", False)), + "accent_color": overrides.get("accent_color", "#6366f1"), + "harness": overrides.get("harness") or template["harness"], + "platform": overrides.get("platform") or template["platform"], + "screen_width": int(overrides.get("screen_width") or template["screen_width"]), + "screen_height": int(overrides.get("screen_height") or template["screen_height"]), + "hardware_concurrency": overrides.get( + "hardware_concurrency", template.get("hardware_concurrency") + ), + "gpu_vendor": overrides.get("gpu_vendor", template.get("gpu_vendor")), + "gpu_renderer": overrides.get("gpu_renderer", template.get("gpu_renderer")), + "geoip": bool(overrides.get("geoip", template.get("geoip", False))), + "humanize": bool(overrides.get("humanize", template.get("humanize", False))), + "human_preset": overrides.get("human_preset") or template.get("human_preset") or "default", + "clipboard_sync": bool(overrides.get("clipboard_sync", template.get("clipboard_sync", True))), + "color_scheme": overrides.get("color_scheme", template.get("color_scheme")), + "search_engine": overrides.get("search_engine", template.get("search_engine")), + "timezone": overrides.get("timezone", template.get("timezone")), + "locale": overrides.get("locale", template.get("locale")), + "proxy": overrides.get("proxy"), + "notes": overrides.get( + "notes", + f"Template `{template_id}`.\n\nSystem prompt:\n{template.get('system_prompt')}", + ), + "tags": overrides.get( + "tags", + [ + {"tag": "template", "color": "#6366f1"}, + {"tag": template_id, "color": "#22c55e"}, + ], + ), + "launch_args": list(overrides.get("launch_args") or []), + } + should_apply = ( + template.get("apply_default_extensions", True) + if apply_extensions is None + else bool(apply_extensions) + ) + if should_apply: + fields["launch_args"] = extension_catalog.merge_launch_args_with_defaults( + fields["launch_args"] + ) + seed_material = f"{template_id}:{fields['project_id']}:{fields['platform']}:{fields['timezone']}" + fields["fingerprint_seed"] = ( + int(hashlib.sha256(seed_material.encode("utf-8")).hexdigest()[:8], 16) % 2_147_483_647 + ) + fields["system_prompt"] = template.get("system_prompt") + fields["template_id"] = template_id + return fields diff --git a/backend/proxy_inventory.py b/backend/proxy_inventory.py new file mode 100644 index 00000000..a886321d --- /dev/null +++ b/backend/proxy_inventory.py @@ -0,0 +1,275 @@ +"""Proxy pool inventory helpers: parse, redact, and geo-aligned profile defaults. + +Credentials stay in SQLite only. API responses never include passwords or full +proxy URLs. Optional proxychecker enrichment is best-effort and fail-soft. +""" + +from __future__ import annotations + +import hashlib +import math +import re +from typing import Any, Mapping +from urllib.parse import quote, urlparse + +from backend.browser_manager import _normalize_proxy, _validate_proxy +from backend.profile_health import ( + NormalizedProxyCheckerResult, + derive_authenticity_score, + normalize_proxychecker_response, +) + +_HOST_PORT_USER_PASS = re.compile( + r"^(?P[^:\s]+):(?P\d{2,5}):(?P[^:\s]+):(?P.+)$" +) + +# Conservative country → (timezone, locale) map for auto anti-stealth defaults. +_COUNTRY_DEFAULTS: dict[str, tuple[str, str]] = { + "AT": ("Europe/Vienna", "de-AT"), + "DE": ("Europe/Berlin", "de-DE"), + "CH": ("Europe/Zurich", "de-CH"), + "NL": ("Europe/Amsterdam", "nl-NL"), + "BE": ("Europe/Brussels", "nl-BE"), + "FR": ("Europe/Paris", "fr-FR"), + "GB": ("Europe/London", "en-GB"), + "IE": ("Europe/Dublin", "en-IE"), + "US": ("America/New_York", "en-US"), + "CA": ("America/Toronto", "en-CA"), + "PL": ("Europe/Warsaw", "pl-PL"), + "CZ": ("Europe/Prague", "cs-CZ"), + "ES": ("Europe/Madrid", "es-ES"), + "IT": ("Europe/Rome", "it-IT"), + "SE": ("Europe/Stockholm", "sv-SE"), + "NO": ("Europe/Oslo", "nb-NO"), + "DK": ("Europe/Copenhagen", "da-DK"), + "FI": ("Europe/Helsinki", "fi-FI"), +} + +_DEFAULT_TZ_LOCALE = ("Europe/Berlin", "de-DE") + + +class ProxyParseError(ValueError): + """Raised when a proxy inventory line cannot be parsed safely.""" + + +def parse_proxy_line(raw: str) -> str: + """Parse ``host:port:user:pass`` (or URL) into a normalized proxy URL.""" + text = (raw or "").strip() + if not text or text.startswith("#"): + raise ProxyParseError("empty proxy line") + + match = _HOST_PORT_USER_PASS.match(text) + if match: + host = match.group("host") + port = match.group("port") + user = match.group("user") + password = match.group("password") + if not host or host.startswith("/") or " " in host: + raise ProxyParseError("invalid proxy host") + try: + port_i = int(port) + except ValueError as exc: + raise ProxyParseError("invalid proxy port") from exc + if port_i < 1 or port_i > 65535: + raise ProxyParseError("invalid proxy port") + normalized = ( + f"http://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port_i}" + ) + else: + normalized = _normalize_proxy(text) + + try: + _validate_proxy(normalized) + except ValueError as exc: + raise ProxyParseError("invalid proxy URL") from exc + return normalized + + +def proxy_fingerprint(proxy_url: str) -> str: + """Stable non-reversible id for dedupe without storing a second secret.""" + return hashlib.sha256(proxy_url.encode("utf-8")).hexdigest() + + +def redact_proxy_url(proxy_url: str) -> dict[str, Any]: + """Return overview fields without credentials.""" + parsed = urlparse(proxy_url) + host = parsed.hostname or "" + port = parsed.port + username = parsed.username or "" + host_masked = _mask_host(host) + user_masked = _mask_username(username) + return { + "host_masked": host_masked, + "port": port, + "username_masked": user_masked, + "has_credentials": bool(username), + "label": f"{host_masked}:{port}" if port else host_masked, + } + + +def _mask_host(host: str) -> str: + if not host: + return "unknown" + if ":" in host and "." not in host: + # IPv6 — keep first hextet only + return host.split(":", 1)[0] + ":****" + parts = host.split(".") + if len(parts) == 4 and all(p.isdigit() for p in parts): + return f"{parts[0]}.{parts[1]}.x.x" + if len(parts) >= 2: + return f"*.{parts[-2]}.{parts[-1]}" + return "***" + + +def _mask_username(username: str) -> str | None: + if not username: + return None + if len(username) <= 2: + return "*" * len(username) + return f"{username[0]}***{username[-1]}" + + +def country_defaults(country_code: str | None) -> tuple[str, str]: + if not country_code: + return _DEFAULT_TZ_LOCALE + code = country_code.strip().upper() + return _COUNTRY_DEFAULTS.get(code, _DEFAULT_TZ_LOCALE) + + +def extract_country_code(payload: object) -> str | None: + """Best-effort country extraction from a proxychecker payload.""" + if not isinstance(payload, Mapping): + return None + candidates: list[object] = [] + results = payload.get("results") + if isinstance(results, list) and results and isinstance(results[0], Mapping): + primary = results[0] + candidates.extend( + [ + primary.get("country"), + primary.get("country_code"), + primary.get("countryCode"), + ] + ) + for key in ("enrichment", "geo", "ip_api", "features", "providers"): + nested = primary.get(key) + if isinstance(nested, Mapping): + candidates.extend( + [ + nested.get("country"), + nested.get("country_code"), + nested.get("countryCode"), + nested.get("country_code2"), + ] + ) + for nested_value in nested.values(): + if isinstance(nested_value, Mapping): + candidates.extend( + [ + nested_value.get("country"), + nested_value.get("country_code"), + nested_value.get("countryCode"), + ] + ) + features = payload.get("features") + if isinstance(features, Mapping): + candidates.extend( + [ + features.get("country"), + features.get("country_code"), + features.get("countryCode"), + ] + ) + for value in candidates: + if isinstance(value, str) and re.fullmatch(r"[A-Za-z]{2}", value.strip()): + return value.strip().upper() + return None + + +def build_auto_profile_defaults( + *, + proxy_url: str, + country_code: str | None = None, + name: str | None = None, + project_id: str = "proxied", + harness: str = "browser-use", + sandbox_id: str = "default", +) -> dict[str, Any]: + """Profile fields for automatic proxy-aligned anti-stealth creation.""" + timezone, locale = country_defaults(country_code) + redacted = redact_proxy_url(proxy_url) + label = redacted["label"] or "proxy" + seed_material = f"{proxy_fingerprint(proxy_url)}:{timezone}:{locale}" + seed = int(hashlib.sha256(seed_material.encode("utf-8")).hexdigest()[:8], 16) % 2_147_483_647 + return { + "name": name or f"Auto {label}", + "sandbox_id": sandbox_id, + "project_id": project_id, + "folder_path": "auto", + "pinned": False, + "accent_color": "#10b981", + "harness": harness, + "fingerprint_seed": seed, + "proxy": proxy_url, + "timezone": timezone, + "locale": locale, + "platform": "windows", + "screen_width": 1920, + "screen_height": 1080, + "hardware_concurrency": 8, + "gpu_vendor": "Google Inc. (NVIDIA)", + "gpu_renderer": ( + "ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 (0x00002786) " + "Direct3D11 vs_5_0 ps_5_0, D3D11)" + ), + "geoip": True, + "humanize": True, + "human_preset": "default", + "clipboard_sync": True, + "auto_launch": False, + "color_scheme": "dark", + "search_engine": "duckduckgo", + "notes": "Auto-created from proxy inventory with geo-aligned stealth defaults.", + "tags": [ + {"tag": "auto", "color": "#10b981"}, + {"tag": "proxied", "color": "#6366f1"}, + ], + } + + +def summarize_check_payload(payload: object) -> dict[str, Any]: + """Reduce a proxychecker response to redacted inventory fields.""" + normalized: NormalizedProxyCheckerResult = normalize_proxychecker_response(payload) + country = extract_country_code(payload) + timezone, locale = country_defaults(country) + latency = normalized.latency_ms + if latency is not None and (not math.isfinite(latency) or latency < 0): + latency = None + return { + "reachable": normalized.reachable, + "latency_ms": latency, + "risk_score": normalized.risk_score, + "authenticity_score": ( + normalized.authenticity_score + if normalized.authenticity_score is not None + else derive_authenticity_score(normalized.risk_score) + ), + "country_code": country, + "timezone_hint": timezone, + "locale_hint": locale, + "warnings": list(normalized.warnings), + "blockers": list(normalized.blockers), + "check_state": _check_state(normalized), + } + + +def _check_state(result: NormalizedProxyCheckerResult) -> str: + if result.blockers: + return "unavailable" + if result.reachable is True: + if result.risk_score is not None and result.risk_score >= 70: + return "warning" + return "passed" + if result.reachable is False: + return "failed" + return "unavailable" diff --git a/backend/session_links.py b/backend/session_links.py new file mode 100644 index 00000000..8c03cbf3 --- /dev/null +++ b/backend/session_links.py @@ -0,0 +1,248 @@ +"""Build clean local/cloud open-session URLs for extensions and agents. + +URL shaping is adapted from the steel-dev/steel-browser ``getBaseUrl`` / +``getUrl`` helpers (Apache-2.0). CloakBrowser keeps its own path layout +(VNC/CDP under ``/api/profiles/{id}/...``) and never embeds secrets. + +Performance priority: CDP live (``/session/{id}/live``) is the snappy +Browser-Use-style path; VNC fullscreen remains available as a fallback. +""" + +from __future__ import annotations + +import os +from typing import Any, Mapping +from urllib.parse import urlparse + +try: + from .session_views import cdp_fullscreen_path, vnc_fullscreen_path +except ImportError: # uvicorn main:app from backend/ + from session_views import cdp_fullscreen_path, vnc_fullscreen_path + + +def cloud_base_url() -> str | None: + """Return a configured absolute public/cloud base URL, or None.""" + raw = (os.environ.get("CLOUD_BASE_URL") or os.environ.get("PUBLIC_BASE_URL") or "").strip() + if not raw: + return None + parsed = urlparse(raw if "://" in raw else f"https://{raw}") + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return None + return f"{parsed.scheme}://{parsed.netloc}".rstrip("/") + + +def request_base_url( + *, + scheme: str, + host: str, + forwarded_proto: str | None = None, + forwarded_host: str | None = None, +) -> str: + """Derive the client-facing local base from the inbound request.""" + proto = (forwarded_proto or scheme or "http").split(",")[0].strip().lower() + if proto not in {"http", "https"}: + proto = "http" + netloc = (forwarded_host or host or "").split(",")[0].strip() + if not netloc: + netloc = "127.0.0.1" + return f"{proto}://{netloc}".rstrip("/") + + +def ws_scheme_for(http_base: str) -> str: + parsed = urlparse(http_base) + return "wss" if parsed.scheme == "https" else "ws" + + +def join_url(base: str, path: str) -> str: + """Join base + path without double slashes (steel getUrl style).""" + root = base.rstrip("/") + formatted = path if path.startswith("/") else f"/{path}" + return f"{root}{formatted}" + + +def build_link_set( + base: str, + profile_id: str, + *, + include_cdp: bool, +) -> dict[str, str | None]: + """Absolute open links for one origin (local tunnel or cloud).""" + http_base = base.rstrip("/") + ws_base = f"{ws_scheme_for(http_base)}://{urlparse(http_base).netloc}" + viewer = join_url(http_base, f"/?profile={profile_id}") + vnc_fs = join_url(http_base, vnc_fullscreen_path(profile_id)) + cdp_fs = join_url(http_base, cdp_fullscreen_path(profile_id)) if include_cdp else None + vnc_ws = join_url(ws_base, f"/api/profiles/{profile_id}/vnc") + status = join_url(http_base, f"/api/profiles/{profile_id}/status") + metrics = join_url(http_base, f"/api/profiles/{profile_id}/live-metrics") + cdp_http = join_url(http_base, f"/api/profiles/{profile_id}/cdp") if include_cdp else None + cdp_ws = join_url(ws_base, f"/api/profiles/{profile_id}/cdp") if include_cdp else None + debugger = ( + join_url(http_base, f"/api/profiles/{profile_id}/cdp/json/version") if include_cdp else None + ) + return { + "session_viewer_url": viewer, + "vnc_fullscreen_url": vnc_fs, + "cdp_fullscreen_url": cdp_fs, + "live_url": cdp_fs, # Browser-Use naming for snappy CDP screencast + "vnc_ws_url": vnc_ws, + "debug_url": status, + "debugger_url": debugger, + "cdp_http_url": cdp_http, + "cdp_ws_url": cdp_ws, + "live_metrics_url": metrics, + "launch_path": f"/api/profiles/{profile_id}/launch", + "stop_path": f"/api/profiles/{profile_id}/stop", + "status_path": f"/api/profiles/{profile_id}/status", + "live_metrics_path": f"/api/profiles/{profile_id}/live-metrics", + } + + +def _preferred_open_url(link_set: dict[str, str | None], mode: str) -> str: + if mode == "cdp" and link_set.get("cdp_fullscreen_url"): + return str(link_set["cdp_fullscreen_url"]) + if mode == "vnc": + return str(link_set["vnc_fullscreen_url"]) + return str(link_set["session_viewer_url"]) + + +def build_session_open_links( + profile_id: str, + *, + local_base: str, + cloud_base: str | None = None, + include_cdp: bool = False, + prefer: str = "local", + mode: str = "cdp", +) -> dict[str, Any]: + """Steel-style session details: local + optional cloud open URLs.""" + resolved_cloud = cloud_base or cloud_base_url() + prefer_key = prefer if prefer in {"local", "cloud"} else "local" + if prefer_key == "cloud" and not resolved_cloud: + prefer_key = "local" + mode_key = mode if mode in {"cdp", "vnc", "shell"} else "cdp" + if mode_key == "cdp" and not include_cdp: + mode_key = "vnc" + + local = build_link_set(local_base, profile_id, include_cdp=include_cdp) + cloud = ( + build_link_set(resolved_cloud, profile_id, include_cdp=include_cdp) + if resolved_cloud + else None + ) + preferred = cloud if prefer_key == "cloud" and cloud else local + open_url = _preferred_open_url(preferred, mode_key) + return { + "profile_id": profile_id, + "prefer": prefer_key, + "mode": mode_key, + "open_url": open_url, + "local": local, + "cloud": cloud, + "bases": { + "local": local_base.rstrip("/"), + "cloud": resolved_cloud, + }, + # Flat compatibility fields for Profile Sync / agents. + "session_viewer_url": preferred["session_viewer_url"], + "vnc_fullscreen_url": preferred["vnc_fullscreen_url"], + "cdp_fullscreen_url": preferred["cdp_fullscreen_url"], + "live_url": preferred["live_url"], + "debug_url": preferred["debug_url"], + "debugger_url": preferred["debugger_url"], + "websocket_url": preferred["vnc_ws_url"], + "cdp_url": preferred["cdp_http_url"], + "live_metrics_url": preferred["live_metrics_url"], + "local_url": local["session_viewer_url"], + "cloud_url": cloud["session_viewer_url"] if cloud else None, + "local_vnc_fullscreen_url": local["vnc_fullscreen_url"], + "local_cdp_fullscreen_url": local["cdp_fullscreen_url"], + "cloud_vnc_fullscreen_url": cloud["vnc_fullscreen_url"] if cloud else None, + "cloud_cdp_fullscreen_url": cloud["cdp_fullscreen_url"] if cloud else None, + } + + +def catalog_endpoint_map() -> dict[str, str]: + """Stable relative paths a Chrome extension or agent can call.""" + return { + "catalog": "/api/extension/catalog", + "defaults": "/api/extension/defaults", + "templates": "/api/extension/templates", + "open_session": "/api/extension/sessions/open", + "open_links": "/api/profiles/{profile_id}/open-links", + "live_metrics": "/api/profiles/{profile_id}/live-metrics", + "cdp_live": "/session/{profile_id}/live", + "vnc_fullscreen": "/?profile={profile_id}&view=vnc&fullscreen=1", + "list_profiles": "/api/profiles", + "create_profile": "/api/profiles", + "list_proxies": "/api/proxies", + "ingest_proxies": "/api/proxies/ingest", + "create_profile_from_proxy": "/api/proxies/{proxy_id}/profiles", + "launch_profile": "/api/profiles/{profile_id}/launch", + "stop_profile": "/api/profiles/{profile_id}/stop", + "profile_status": "/api/profiles/{profile_id}/status", + "profile_health": "/api/profiles/{profile_id}/health", + "profile_extensions": "/api/profiles/{profile_id}/extensions", + "cdp": "/api/profiles/{profile_id}/cdp", + "vnc": "/api/profiles/{profile_id}/vnc", + "auth_status": "/api/auth/status", + "auth_login": "/api/auth/login", + } + + +def profile_templates() -> list[dict[str, Any]]: + """Discoverable create-flow templates for extension/agent UIs.""" + return [ + { + "id": "browser-use-proxied", + "name": "Browser Use · proxied", + "project_id": "proxied", + "folder_path": "auto", + "harness": "browser-use", + "geoip": True, + "create_path": "/api/profiles", + "from_proxy_path": "/api/proxies/{proxy_id}/profiles", + }, + { + "id": "research-default", + "name": "Research · default", + "project_id": "research", + "folder_path": "", + "harness": "browser-use", + "geoip": False, + "create_path": "/api/profiles", + }, + { + "id": "mobile-viewport", + "name": "Mobile · viewport", + "project_id": "mobile", + "folder_path": "", + "harness": "browser-use", + "screen_width": 390, + "screen_height": 844, + "create_path": "/api/profiles", + }, + ] + + +def extension_profile_summary( + profile: Mapping[str, Any], + *, + status: str, + running: bool, +) -> dict[str, Any]: + """Minimal redacted profile card for extension UIs.""" + return { + "id": str(profile.get("id") or ""), + "name": str(profile.get("name") or ""), + "project_id": str(profile.get("project_id") or "default"), + "folder_path": str(profile.get("folder_path") or ""), + "sandbox_id": str(profile.get("sandbox_id") or "default"), + "harness": str(profile.get("harness") or "codex"), + "pinned": bool(profile.get("pinned")), + "timezone": profile.get("timezone") if isinstance(profile.get("timezone"), str) else None, + "locale": profile.get("locale") if isinstance(profile.get("locale"), str) else None, + "proxy_configured": bool(profile.get("proxy")), + "status": status, + "running": running, + } diff --git a/backend/session_views.py b/backend/session_views.py new file mode 100644 index 00000000..45d1a26e --- /dev/null +++ b/backend/session_views.py @@ -0,0 +1,536 @@ +"""Minimal fullscreen session HTML pages (VNC deep-link + CDP screencast). + +The CDP live viewer is oriented after steel-browser's cast/debug streamer and +Browser-Use ``liveUrl``: one absolute URL that opens a snappy fullscreen feed +via CDP ``Page.startScreencast`` rather than RFB/VNC. + +Cloak/Chromium only emits screencast frames when the compositor dirties. A +tiny page-side CSS/rAF pulse keeps the feed alive on static pages so the +viewer sustains high FPS instead of stalling after the first frame and falling +back to slow ``Page.captureScreenshot`` polling. +""" + +from __future__ import annotations + +import html +import json + + +def vnc_fullscreen_path(profile_id: str) -> str: + """SPA deep-link that opens the existing noVNC surface fullscreen.""" + return f"/?profile={profile_id}&view=vnc&fullscreen=1" + + +def cdp_fullscreen_path(profile_id: str) -> str: + """Standalone CDP screencast fullscreen page (Browser-Use-style live URL).""" + return f"/session/{profile_id}/live" + + +def render_cdp_live_html( + *, + profile_id: str, + profile_name: str, + cdp_ws_url: str, + metrics_url: str, + interactive: bool, + cdp_list_url: str | None = None, +) -> str: + """Self-contained low-latency CDP screencast page. No secrets embedded.""" + safe_name = html.escape(profile_name or profile_id) + safe_id = html.escape(profile_id) + config = json.dumps( + { + "profileId": profile_id, + "cdpWsUrl": cdp_ws_url, + "cdpListUrl": cdp_list_url, + "metricsUrl": metrics_url, + "interactive": bool(interactive), + "screencast": { + "format": "jpeg", + "quality": 35, + "maxWidth": 1280, + "maxHeight": 720, + "everyNthFrame": 1, + }, + } + ) + return f""" + + + + + Live · {safe_name} + + + +
+ {safe_name} + connecting… + + + + +
+
+ + + +""" diff --git a/backend/stream_metrics.py b/backend/stream_metrics.py new file mode 100644 index 00000000..d1a52ee4 --- /dev/null +++ b/backend/stream_metrics.py @@ -0,0 +1,132 @@ +"""Client-reported live stream metrics (CDP/VNC) for UI and agents. + +Samples are process-local, redacted, and overwritten per profile/transport. +Clients POST measured FPS/RTT/connection state; consumers poll GET. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Literal + +Transport = Literal["cdp", "vnc"] +ConnectionState = Literal["connecting", "connected", "reconnecting", "failed", "idle"] + + +def _iso(ts: float | None) -> str | None: + if ts is None: + return None + return datetime.fromtimestamp(ts, timezone.utc).isoformat().replace("+00:00", "Z") + + +@dataclass +class _Sample: + transport: Transport + connection_state: ConnectionState = "idle" + fps: float | None = None + rtt_ms: float | None = None + frames_received: int | None = None + reconnect_count: int | None = None + dropped_frames: int | None = None + updated_at: float = field(default_factory=time.time) + + +class StreamMetricsRegistry: + def __init__(self) -> None: + self._lock = threading.RLock() + self._by_profile: dict[str, dict[str, _Sample]] = {} + + def reset(self) -> None: + with self._lock: + self._by_profile.clear() + + def record(self, profile_id: str, payload: dict[str, Any]) -> dict[str, Any]: + transport = str(payload.get("transport") or "cdp") + if transport not in {"cdp", "vnc"}: + transport = "cdp" + state = str(payload.get("connection_state") or "connected") + if state not in {"connecting", "connected", "reconnecting", "failed", "idle"}: + state = "connected" + + def _num(key: str) -> float | None: + value = payload.get(key) + if value is None: + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + if number != number or number < 0: + return None + return number + + def _int(key: str) -> int | None: + value = _num(key) + if value is None: + return None + return int(value) + + sample = _Sample( + transport=transport, # type: ignore[arg-type] + connection_state=state, # type: ignore[arg-type] + fps=_num("fps"), + rtt_ms=_num("rtt_ms"), + frames_received=_int("frames_received"), + reconnect_count=_int("reconnect_count"), + dropped_frames=_int("dropped_frames"), + updated_at=time.time(), + ) + with self._lock: + bucket = self._by_profile.setdefault(profile_id, {}) + bucket[transport] = sample + return self.snapshot(profile_id) + + def snapshot(self, profile_id: str) -> dict[str, Any]: + with self._lock: + bucket = self._by_profile.get(profile_id) or {} + # Prefer CDP (snappy path) when both present. + preferred = bucket.get("cdp") or bucket.get("vnc") + transports = { + name: { + "transport": sample.transport, + "connection_state": sample.connection_state, + "fps": sample.fps, + "rtt_ms": sample.rtt_ms, + "frames_received": sample.frames_received, + "reconnect_count": sample.reconnect_count, + "dropped_frames": sample.dropped_frames, + "updated_at": _iso(sample.updated_at), + } + for name, sample in bucket.items() + } + if preferred is None: + return { + "profile_id": profile_id, + "transport": None, + "connection_state": "idle", + "fps": None, + "rtt_ms": None, + "frames_received": None, + "reconnect_count": None, + "dropped_frames": None, + "updated_at": None, + "transports": transports, + } + return { + "profile_id": profile_id, + "transport": preferred.transport, + "connection_state": preferred.connection_state, + "fps": preferred.fps, + "rtt_ms": preferred.rtt_ms, + "frames_received": preferred.frames_received, + "reconnect_count": preferred.reconnect_count, + "dropped_frames": preferred.dropped_frames, + "updated_at": _iso(preferred.updated_at), + "transports": transports, + } + + +stream_metrics = StreamMetricsRegistry() diff --git a/backend/tests/test_access_control.py b/backend/tests/test_access_control.py index 67ee3921..1925756e 100644 --- a/backend/tests/test_access_control.py +++ b/backend/tests/test_access_control.py @@ -2,17 +2,92 @@ from __future__ import annotations +import asyncio +import json import struct from types import SimpleNamespace from unittest.mock import AsyncMock +import anyio import pytest from starlette.testclient import TestClient from starlette.websockets import WebSocketDisconnect from backend import access_control as access from backend import database as db -from backend.main import _RfbClientStreamFilter, _filter_rfb_viewer_messages +from backend.main import ( + _RfbClientStreamFilter, + _build_server_cut_text, + _filter_rfb_viewer_messages, +) + + +class _BlockingWebSocketUpstream: + """Minimal fake upstream that stays open until the proxy cancels it.""" + + def __init__( + self, + subprotocol: str | None = None, + messages: tuple[bytes | str, ...] = (), + ): + self.subprotocol = subprotocol + self.close_code = None + self.sent: list[bytes | str] = [] + self.messages = list(messages) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + self.close_code = 1000 + return False + + async def send(self, message: bytes | str): + self.sent.append(message) + + def __aiter__(self): + return self + + async def __anext__(self): + if self.messages: + return self.messages.pop(0) + await asyncio.Event().wait() + raise StopAsyncIteration + + +def _receive_websocket_message(session, timeout: float = 2.0): + async def receive_with_timeout(): + with anyio.fail_after(timeout): + return await session._send_rx.receive() + + return session.portal.call(receive_with_timeout) + + +def _kasm_server_clipboard(text: str) -> bytes: + mime = b"text/plain" + payload = text.encode("utf-8") + return ( + bytes([180, 0]) + + b"\0" * 4 + + bytes([len(mime)]) + + mime + + struct.pack(">I", len(payload)) + + payload + ) + + +def _rfb_server_handshake() -> bytes: + name = b"Viewer test desktop" + pixel_format = struct.pack( + ">BBBBHHHBBBxxx", 32, 24, 0, 1, 255, 255, 255, 16, 8, 0 + ) + server_init = ( + struct.pack(">HH", 1024, 768) + + pixel_format + + struct.pack(">I", len(name)) + + name + ) + return b"RFB 003.008\n" + b"\x01\x01" + b"\0\0\0\0" + server_init @pytest.fixture() @@ -39,6 +114,43 @@ def create_scoped_profiles(): return alpha, beta +def test_admin_access_sandbox_summary_includes_only_redacted_organization_context( + client_access: TestClient, +): + db.create_profile( + "Checkout QA", + sandbox_id="research", + project_id="commerce", + folder_path="checkout/us", + proxy="http://secret-proxy-user:secret-password@example.invalid:8080", + fingerprint_seed=12345, + launch_args=["--private-diagnostic-flag"], + ) + db.create_profile( + "Payments QA", + sandbox_id="research", + project_id="commerce", + folder_path="payments", + ) + + response = client_access.get("/api/access/sandboxes", headers=bootstrap_headers()) + + assert response.status_code == 200 + assert response.json() == [ + { + "sandbox_id": "research", + "profile_count": 2, + "project_ids": ["commerce"], + "folder_paths": ["checkout/us", "payments"], + "profile_names": ["Checkout QA", "Payments QA"], + } + ] + serialized = json.dumps(response.json()) + assert "secret-password" not in serialized + assert "private-diagnostic" not in serialized + assert "fingerprint_seed" not in serialized + + def test_password_hashing_is_salted_and_verifiable(): first = access.hash_password("very-long-test-password") second = access.hash_password("very-long-test-password") @@ -67,13 +179,20 @@ def test_viewer_rfb_filter_allows_only_display_negotiation(): def test_viewer_rfb_filter_keeps_safe_encoding_negotiation(): # Viewers still need the normal noVNC display-negotiation sequence. This # catches a regression where a view grant would connect but remain black. - set_encodings = struct.pack(">BBHii", 2, 0, 2, 7, -239) + advertised = [7, 16, 21, -260, 1, 0, -239, -224, -308, -307, -258] + set_encodings = struct.pack(">BBH", 2, 0, len(advertised)) + b"".join( + struct.pack(">i", encoding) for encoding in advertised + ) + viewer_encodings = [16, 1, 0, -239, -224] + expected_encodings = struct.pack(">BBH", 2, 0, len(viewer_encodings)) + b"".join( + struct.pack(">i", encoding) for encoding in viewer_encodings + ) frame_request = bytes([3]) + b"\0" * 9 key_event = bytes([4]) + b"\0" * 7 filtered = _filter_rfb_viewer_messages(set_encodings + frame_request + key_event) - assert filtered == set_encodings + frame_request + assert filtered == expected_encodings + frame_request def test_viewer_rfb_stream_filter_drops_appended_input_in_early_frames(): @@ -127,7 +246,8 @@ def test_viewer_rfb_stream_filter_preserves_handshake_and_display_negotiation(): version = b"RFB 003.008\n" security_none = b"\x01" client_init = b"\x01" - set_encodings = struct.pack(">BBHii", 2, 0, 2, 7, -239) + set_encodings = struct.pack(">BBHiii", 2, 0, 3, 7, 16, -239) + viewer_encodings = struct.pack(">BBHii", 2, 0, 2, 16, -239) frame_request = bytes([3]) + b"\0" * 9 key_event = bytes([4]) + b"\0" * 7 @@ -136,7 +256,7 @@ def test_viewer_rfb_stream_filter_preserves_handshake_and_display_negotiation(): assert rfb_filter.filter(security_none) == security_none assert rfb_filter.filter(client_init) == client_init assert rfb_filter.filter(set_encodings + frame_request + key_event) == ( - set_encodings + frame_request + viewer_encodings + frame_request ) @@ -196,7 +316,393 @@ def test_scoped_viewer_only_sees_granted_sandbox_and_no_sensitive_config(client_ assert ("profile.permission.interact", "alpha", alpha["id"], "denied") in denied_events -def test_operator_can_operate_only_its_scoped_profile(client_access: TestClient): +def test_admin_can_create_list_and_update_access_group(client_access: TestClient): + user = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "group-member", + "password": "group-member-password-123", + "grants": [], + }, + ).json() + + created = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Alpha viewers", + "description": "Alpha sandbox access", + "member_user_ids": [user["id"]], + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ) + assert created.status_code == 201 + group = created.json() + assert group["name"] == "Alpha viewers" + assert group["member_user_ids"] == [user["id"]] + assert group["grants"] == [{"sandbox_id": "alpha", "permission": "view"}] + + listed = client_access.get("/api/access/groups", headers=bootstrap_headers()) + assert listed.status_code == 200 + assert [item["id"] for item in listed.json()] == [group["id"]] + + updated = client_access.put( + f"/api/access/groups/{group['id']}", + headers=bootstrap_headers(), + json={ + "name": "Beta operators", + "description": None, + "active": False, + "member_user_ids": [], + "grants": [{"sandbox_id": "beta", "permission": "operate"}], + }, + ) + assert updated.status_code == 200 + assert updated.json()["name"] == "Beta operators" + assert updated.json()["description"] is None + assert updated.json()["active"] is False + assert updated.json()["member_user_ids"] == [] + assert updated.json()["grants"] == [{"sandbox_id": "beta", "permission": "operate"}] + + +def test_group_only_access_allows_granted_sandbox_and_hides_other(client_access: TestClient): + alpha, beta = create_scoped_profiles() + user = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "group-only", + "password": "group-only-password-123", + "grants": [], + }, + ).json() + client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Alpha group only", + "member_user_ids": [user["id"]], + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ) + + client_access.cookies.clear() + login = client_access.post( + "/api/auth/login", + json={"username": "group-only", "password": "group-only-password-123"}, + ) + assert login.status_code == 200 + assert login.json()["identity"]["grants"] == [{"sandbox_id": "alpha", "permission": "view"}] + listed = client_access.get("/api/profiles") + assert listed.status_code == 200 + assert [profile["id"] for profile in listed.json()] == [alpha["id"]] + assert client_access.get(f"/api/profiles/{alpha['id']}").status_code == 200 + assert client_access.get(f"/api/profiles/{beta['id']}").status_code == 404 + + +@pytest.mark.parametrize("payload", [{"name": None}, {"active": None}]) +def test_group_update_rejects_null_required_fields( + client_access: TestClient, + payload: dict[str, object], +): + group = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={"name": "Required group fields"}, + ).json() + + response = client_access.put( + f"/api/access/groups/{group['id']}", + headers=bootstrap_headers(), + json=payload, + ) + + assert response.status_code == 422 + + +def test_group_create_deduplicates_duplicate_grants(client_access: TestClient): + response = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Deduplicated grants", + "grants": [ + {"sandbox_id": "alpha", "permission": "operate"}, + {"sandbox_id": "alpha", "permission": "operate"}, + ], + }, + ) + + assert response.status_code == 201 + assert response.json()["grants"] == [ + {"sandbox_id": "alpha", "permission": "operate"} + ] + + +def test_user_create_payload_group_ids_updates_effective_access(client_access: TestClient): + alpha, beta = create_scoped_profiles() + group = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Frontend create group", + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ).json() + + created = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "frontend-create-member", + "password": "frontend-create-member-password-123", + "grants": [], + "group_ids": [group["id"]], + }, + ) + assert created.status_code == 201 + assert created.json()["group_ids"] == [group["id"]] + assert created.json()["grants"] == [] + assert created.json()["effective_grants"] == [ + {"sandbox_id": "alpha", "permission": "view"} + ] + + listed_group = client_access.get("/api/access/groups", headers=bootstrap_headers()).json()[0] + assert listed_group["member_user_ids"] == [created.json()["id"]] + + client_access.cookies.clear() + login = client_access.post( + "/api/auth/login", + json={ + "username": "frontend-create-member", + "password": "frontend-create-member-password-123", + }, + ) + assert login.status_code == 200 + assert login.json()["identity"]["group_ids"] == [group["id"]] + assert client_access.get(f"/api/profiles/{alpha['id']}").status_code == 200 + assert client_access.get(f"/api/profiles/{beta['id']}").status_code == 404 + + +def test_user_update_payload_group_ids_replaces_effective_access_and_audits( + client_access: TestClient, +): + alpha, beta = create_scoped_profiles() + alpha_group = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Frontend update alpha", + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ).json() + beta_group = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Frontend update beta", + "grants": [{"sandbox_id": "beta", "permission": "view"}], + }, + ).json() + user = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "frontend-update-member", + "password": "frontend-update-member-password-123", + "grants": [], + "group_ids": [alpha_group["id"]], + }, + ).json() + + client_access.cookies.clear() + assert client_access.post( + "/api/auth/login", + json={ + "username": "frontend-update-member", + "password": "frontend-update-member-password-123", + }, + ).status_code == 200 + assert client_access.get(f"/api/profiles/{alpha['id']}").status_code == 200 + assert client_access.get(f"/api/profiles/{beta['id']}").status_code == 404 + + updated = client_access.put( + f"/api/access/users/{user['id']}", + headers=bootstrap_headers(), + json={"group_ids": [beta_group["id"]]}, + ) + assert updated.status_code == 200 + assert updated.json()["group_ids"] == [beta_group["id"]] + assert updated.json()["effective_grants"] == [ + {"sandbox_id": "beta", "permission": "view"} + ] + assert client_access.get(f"/api/profiles/{alpha['id']}").status_code == 404 + assert client_access.get(f"/api/profiles/{beta['id']}").status_code == 200 + + with db.get_db() as conn: + group_update_count = conn.execute( + """SELECT COUNT(*) AS count FROM access_audit_events + WHERE action = 'access_user.groups.update' AND outcome = 'allowed'""" + ).fetchone()["count"] + assert group_update_count == 2 + + +def test_inactive_group_removes_user_access(client_access: TestClient): + alpha, _beta = create_scoped_profiles() + user = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "inactive-group-user", + "password": "inactive-group-user-password-123", + "grants": [], + }, + ).json() + group = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Temporarily active", + "member_user_ids": [user["id"]], + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ).json() + + client_access.cookies.clear() + assert client_access.post( + "/api/auth/login", + json={"username": "inactive-group-user", "password": "inactive-group-user-password-123"}, + ).status_code == 200 + assert client_access.get(f"/api/profiles/{alpha['id']}").status_code == 200 + + updated = client_access.put( + f"/api/access/groups/{group['id']}", + headers=bootstrap_headers(), + json={"active": False}, + ) + assert updated.status_code == 200 + assert client_access.get(f"/api/profiles/{alpha['id']}").status_code == 404 + + +def test_direct_and_multiple_group_grants_are_deduplicated_union(client_access: TestClient): + alpha, beta = create_scoped_profiles() + user = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "grant-union", + "password": "grant-union-password-123", + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ).json() + first_group = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Alpha interactors", + "member_user_ids": [user["id"]], + "grants": [ + {"sandbox_id": "alpha", "permission": "view"}, + {"sandbox_id": "alpha", "permission": "interact"}, + ], + }, + ).json() + second_group = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Beta automators", + "member_user_ids": [user["id"]], + "grants": [{"sandbox_id": "beta", "permission": "automate"}], + }, + ).json() + + users = client_access.get("/api/access/users", headers=bootstrap_headers()).json() + listed_user = next(item for item in users if item["id"] == user["id"]) + assert listed_user["group_ids"] == [first_group["id"], second_group["id"]] + assert listed_user["grants"] == [{"sandbox_id": "alpha", "permission": "view"}] + assert listed_user["effective_grants"] == [ + {"sandbox_id": "alpha", "permission": "interact"}, + {"sandbox_id": "alpha", "permission": "view"}, + {"sandbox_id": "beta", "permission": "automate"}, + ] + + client_access.cookies.clear() + assert client_access.post( + "/api/auth/login", json={"username": "grant-union", "password": "grant-union-password-123"} + ).status_code == 200 + assert client_access.post( + "/api/task-sessions", + json={"profile_id": alpha["id"], "title": "group interact"}, + ).status_code == 201 + assert client_access.get(f"/api/profiles/{beta['id']}/cdp").status_code in {404, 409} + + +def test_access_group_management_is_admin_only_and_validates_members(client_access: TestClient): + user = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "group-non-admin", + "password": "group-non-admin-password-123", + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ).json() + client_access.cookies.clear() + assert client_access.post( + "/api/auth/login", + json={"username": "group-non-admin", "password": "group-non-admin-password-123"}, + ).status_code == 200 + assert client_access.get("/api/access/groups").status_code == 403 + assert client_access.post( + "/api/access/groups", + json={"name": "Forbidden group", "member_user_ids": [user["id"]], "grants": []}, + ).status_code == 403 + + blank_name = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={"name": " ", "member_user_ids": [], "grants": []}, + ) + assert blank_name.status_code == 422 + + missing_group = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "missing-group-member", + "password": "missing-group-member-password-123", + "group_ids": ["missing-group-id"], + }, + ) + assert missing_group.status_code == 404 + assert missing_group.json()["detail"] == "Group not found: missing-group-id" + + missing_member = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Bad members", + "member_user_ids": ["missing-user-id"], + "grants": [], + }, + ) + assert missing_member.status_code == 404 + assert missing_member.json()["detail"] == "User not found: missing-user-id" + + +def test_operator_can_operate_only_its_scoped_profile( + client_access: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend import main + + monkeypatch.setattr( + main.browser_mgr, + "launch", + AsyncMock(side_effect=RuntimeError("launch unavailable in policy test")), + ) alpha, beta = create_scoped_profiles() created = client_access.post( "/api/access/users", @@ -214,14 +720,158 @@ def test_operator_can_operate_only_its_scoped_profile(client_access: TestClient) assert client_access.post( "/api/auth/login", json={"username": "operator", "password": "operator-password-123"} ).status_code == 200 - # No running browser is expected in this test. The error proves the policy - # allowed the request to reach the lifecycle handler. + # The controlled lifecycle error proves policy allowed the request to reach + # the handler without depending on whether CloakBrowser is installed in the + # test image. allowed = client_access.post(f"/api/profiles/{alpha['id']}/launch") assert allowed.status_code in {409, 500} denied = client_access.post(f"/api/profiles/{beta['id']}/launch") assert denied.status_code == 404 +def test_interact_user_can_create_task_session_with_redacted_metadata(client_access: TestClient): + alpha, _beta = create_scoped_profiles() + client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "interact-task", + "password": "interact-task-password-123", + "grants": [{"sandbox_id": "alpha", "permission": "interact"}], + }, + ) + client_access.cookies.clear() + assert client_access.post( + "/api/auth/login", + json={"username": "interact-task", "password": "interact-task-password-123"}, + ).status_code == 200 + + created = client_access.post( + "/api/task-sessions", + json={ + "profile_id": alpha["id"], + "title": "Scoped harness task", + "metadata": { + "source": "vcvm-e2e", + "authorization": "Bearer should-not-persist", + "nested": {"password": "should-not-persist"}, + }, + }, + ) + + assert created.status_code == 201 + assert created.json()["metadata"] == { + "source": "vcvm-e2e", + "authorization": "[redacted]", + "nested": {"password": "[redacted]"}, + } + + +def test_interact_user_cannot_create_task_session_outside_granted_sandbox( + client_access: TestClient, +): + _alpha, beta = create_scoped_profiles() + client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "interact-task-denied", + "password": "interact-task-denied-password-123", + "grants": [{"sandbox_id": "alpha", "permission": "interact"}], + }, + ) + client_access.cookies.clear() + assert client_access.post( + "/api/auth/login", + json={ + "username": "interact-task-denied", + "password": "interact-task-denied-password-123", + }, + ).status_code == 200 + + denied = client_access.post( + "/api/task-sessions", + json={"profile_id": beta["id"], "title": "Out of scope"}, + ) + + assert denied.status_code == 404 + assert denied.json()["detail"] == "Profile not found" + + +def test_interact_user_cannot_operate_or_automate_profile(client_access: TestClient): + alpha, _beta = create_scoped_profiles() + client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "interact-no-lifecycle", + "password": "interact-no-lifecycle-password-123", + "grants": [{"sandbox_id": "alpha", "permission": "interact"}], + }, + ) + client_access.cookies.clear() + assert client_access.post( + "/api/auth/login", + json={ + "username": "interact-no-lifecycle", + "password": "interact-no-lifecycle-password-123", + }, + ).status_code == 200 + + launch_denied = client_access.post(f"/api/profiles/{alpha['id']}/launch") + cdp_denied = client_access.get(f"/api/profiles/{alpha['id']}/cdp") + + assert launch_denied.status_code == 404 + assert cdp_denied.status_code == 404 + + +def test_paperclip_agent_command_metadata_is_redacted(client_access: TestClient): + alpha, _beta = create_scoped_profiles() + created = client_access.post( + "/api/access/agents", + headers=bootstrap_headers(), + json={ + "display_name": "Paperclip task agent", + "paperclip_agent_id": "paperclip-agent-task", + "grants": [ + {"sandbox_id": "alpha", "permission": "interact"}, + {"sandbox_id": "alpha", "permission": "automate"}, + ], + }, + ) + agent_headers = {"Authorization": f"Bearer {created.json()['api_key']}"} + identity = client_access.get("/api/access/me", headers=agent_headers) + assert identity.status_code == 200, identity.text + assert identity.json()["kind"] == "agent" + visible = client_access.get("/api/profiles", headers=agent_headers) + assert visible.status_code == 200, visible.text + assert [profile["id"] for profile in visible.json()] == [alpha["id"]] + session = client_access.post( + "/api/task-sessions", + headers=agent_headers, + json={"profile_id": alpha["id"], "title": "Agent task"}, + ) + assert session.status_code == 201, session.text + + command = client_access.post( + f"/api/task-sessions/{session.json()['id']}/commands", + headers=agent_headers, + json={ + "content": "type into browser", + "metadata": { + "harness": "paperclip", + "cookie": "should-not-persist", + "api_key": "should-not-persist", + }, + }, + ) + + assert command.status_code == 201 + assert command.json()["metadata"]["harness"] == "paperclip" + assert command.json()["metadata"]["cookie"] == "[redacted]" + assert command.json()["metadata"]["api_key"] == "[redacted]" + + def test_admin_can_update_a_user_grants_from_the_access_dashboard_payload(client_access: TestClient): """Pydantic serializes nested grants before main.py receives the update. @@ -315,6 +965,295 @@ def test_paperclip_agent_key_is_scoped_and_rotation_revokes_old_key(client_acces assert client_access.get("/api/profiles", headers=agent_headers).status_code == 401 +def test_delete_access_agent_revokes_key_immediately(client_access: TestClient): + created = client_access.post( + "/api/access/agents", + headers=bootstrap_headers(), + json={ + "display_name": "Disposable Paperclip agent", + "paperclip_agent_id": "paperclip-agent-delete", + "grants": [{"sandbox_id": "beta", "permission": "view"}], + }, + ) + assert created.status_code == 201 + agent = created.json() + agent_headers = {"Authorization": f"Bearer {agent['api_key']}"} + assert client_access.get("/api/access/me", headers=agent_headers).status_code == 200 + + deleted = client_access.delete( + f"/api/access/agents/{agent['id']}", headers=bootstrap_headers() + ) + assert deleted.status_code == 204 + assert client_access.get("/api/access/me", headers=agent_headers).status_code == 401 + assert ( + client_access.get(f"/api/access/agents/{agent['id']}", headers=bootstrap_headers()).status_code + in {404, 405} + ) + missing = client_access.delete( + f"/api/access/agents/{agent['id']}", headers=bootstrap_headers() + ) + assert missing.status_code == 404 + + +def test_open_vnc_closes_immediately_after_agent_key_rotation( + client_access: TestClient, monkeypatch +): + from backend import main + + profile = db.create_profile("Rotating VNC", sandbox_id="alpha") + created = client_access.post( + "/api/access/agents", + headers=bootstrap_headers(), + json={ + "display_name": "Rotating VNC agent", + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ).json() + agent_headers = {"Authorization": f"Bearer {created['api_key']}"} + upstream = _BlockingWebSocketUpstream(subprotocol="binary") + monkeypatch.setattr("websockets.connect", lambda *_args, **_kwargs: upstream) + main.browser_mgr.running[profile["id"]] = SimpleNamespace( + ws_port=6102, cdp_port=5102, display=102 + ) + + try: + with client_access.websocket_connect( + f"/api/profiles/{profile['id']}/vnc", headers=agent_headers + ) as websocket: + rotated = client_access.post( + f"/api/access/agents/{created['id']}/rotate-key", + headers=bootstrap_headers(), + ) + assert rotated.status_code == 200 + message = _receive_websocket_message(websocket) + assert message == { + "type": "websocket.close", + "code": 4403, + "reason": "Access revoked", + } + finally: + main.browser_mgr.running.pop(profile["id"], None) + + +def test_open_viewer_vnc_filters_split_and_coalesced_server_clipboard( + client_access: TestClient, monkeypatch +): + from backend import main + + profile = db.create_profile("Filtered viewer VNC", sandbox_id="alpha") + created = client_access.post( + "/api/access/agents", + headers=bootstrap_headers(), + json={ + "display_name": "Filtered viewer agent", + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ).json() + agent_headers = {"Authorization": f"Bearer {created['api_key']}"} + handshake = _rfb_server_handshake() + framebuffer_update = b"\0\0\0\0" + clipboard = _build_server_cut_text("never-send-to-viewer") + bell = b"\x02" + upstream = _BlockingWebSocketUpstream( + subprotocol="binary", + messages=( + handshake, + framebuffer_update + clipboard[:5], + clipboard[5:] + bell, + ), + ) + monkeypatch.setattr("websockets.connect", lambda *_args, **_kwargs: upstream) + main.browser_mgr.running[profile["id"]] = SimpleNamespace( + ws_port=6105, cdp_port=5105, display=105 + ) + + try: + with client_access.websocket_connect( + f"/api/profiles/{profile['id']}/vnc", headers=agent_headers + ) as websocket: + assert _receive_websocket_message(websocket) == { + "type": "websocket.send", + "bytes": handshake, + } + assert _receive_websocket_message(websocket) == { + "type": "websocket.send", + "bytes": framebuffer_update, + } + assert _receive_websocket_message(websocket) == { + "type": "websocket.send", + "bytes": bell, + } + finally: + main.browser_mgr.running.pop(profile["id"], None) + + +@pytest.mark.parametrize( + "update_payload", + [ + pytest.param({"active": False}, id="principal-deactivated"), + pytest.param({"grants": []}, id="sandbox-grant-revoked"), + ], +) +def test_open_cdp_closes_immediately_after_agent_access_revoked( + client_access: TestClient, monkeypatch, update_payload +): + from backend import main + + profile = db.create_profile("Revocable CDP", sandbox_id="alpha") + created = client_access.post( + "/api/access/agents", + headers=bootstrap_headers(), + json={ + "display_name": "Revocable CDP agent", + "grants": [{"sandbox_id": "alpha", "permission": "automate"}], + }, + ).json() + agent_headers = {"Authorization": f"Bearer {created['api_key']}"} + upstream = _BlockingWebSocketUpstream() + monkeypatch.setattr("websockets.connect", lambda *_args, **_kwargs: upstream) + main.browser_mgr.running[profile["id"]] = SimpleNamespace( + ws_port=6103, cdp_port=5103, display=103 + ) + + try: + with client_access.websocket_connect( + f"/api/profiles/{profile['id']}/cdp/devtools/page/REVOCABLE", + headers=agent_headers, + ) as websocket: + updated = client_access.put( + f"/api/access/agents/{created['id']}", + headers=bootstrap_headers(), + json=update_payload, + ) + assert updated.status_code == 200 + message = _receive_websocket_message(websocket) + assert message == { + "type": "websocket.close", + "code": 4403, + "reason": "Access revoked", + } + finally: + main.browser_mgr.running.pop(profile["id"], None) + + +def test_open_vnc_closes_immediately_after_user_deactivation( + client_access: TestClient, monkeypatch +): + from backend import main + + profile = db.create_profile("Revocable user VNC", sandbox_id="alpha") + created = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "revocable-viewer", + "password": "revocable-viewer-password-123", + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ).json() + client_access.cookies.clear() + assert client_access.post( + "/api/auth/login", + json={ + "username": "revocable-viewer", + "password": "revocable-viewer-password-123", + }, + ).status_code == 200 + upstream = _BlockingWebSocketUpstream(subprotocol="binary") + monkeypatch.setattr("websockets.connect", lambda *_args, **_kwargs: upstream) + main.browser_mgr.running[profile["id"]] = SimpleNamespace( + ws_port=6104, cdp_port=5104, display=104 + ) + + try: + with client_access.websocket_connect( + f"/api/profiles/{profile['id']}/vnc" + ) as websocket: + updated = client_access.put( + f"/api/access/users/{created['id']}", + headers=bootstrap_headers(), + json={"active": False}, + ) + assert updated.status_code == 200 + message = _receive_websocket_message(websocket) + assert message == { + "type": "websocket.close", + "code": 4403, + "reason": "Access revoked", + } + finally: + main.browser_mgr.running.pop(profile["id"], None) + + +def test_open_vnc_closes_after_access_group_grant_revoked_and_audits_update( + client_access: TestClient, monkeypatch +): + from backend import main + + profile = db.create_profile("Group revocable VNC", sandbox_id="alpha") + user = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "group-revocable-viewer", + "password": "group-revocable-viewer-password-123", + "grants": [], + }, + ).json() + group = client_access.post( + "/api/access/groups", + headers=bootstrap_headers(), + json={ + "name": "Revocable group viewers", + "member_user_ids": [user["id"]], + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ).json() + client_access.cookies.clear() + assert client_access.post( + "/api/auth/login", + json={ + "username": "group-revocable-viewer", + "password": "group-revocable-viewer-password-123", + }, + ).status_code == 200 + upstream = _BlockingWebSocketUpstream(subprotocol="binary") + monkeypatch.setattr("websockets.connect", lambda *_args, **_kwargs: upstream) + main.browser_mgr.running[profile["id"]] = SimpleNamespace( + ws_port=6105, cdp_port=5105, display=105 + ) + + try: + with client_access.websocket_connect( + f"/api/profiles/{profile['id']}/vnc" + ) as websocket: + updated = client_access.put( + f"/api/access/groups/{group['id']}", + headers=bootstrap_headers(), + json={"grants": []}, + ) + assert updated.status_code == 200 + message = _receive_websocket_message(websocket) + assert message == { + "type": "websocket.close", + "code": 4403, + "reason": "Access revoked", + } + finally: + main.browser_mgr.running.pop(profile["id"], None) + + with db.get_db() as conn: + actions = { + row["action"] + for row in conn.execute( + "SELECT action FROM access_audit_events WHERE outcome = 'allowed'" + ).fetchall() + } + assert "access_group.create" in actions + assert "access_group.grants.update" in actions + assert "access_group.update" in actions + + def test_scoped_user_cannot_reach_out_of_scope_vnc_upstream(client_access: TestClient, monkeypatch): from backend import main diff --git a/backend/tests/test_agent_control_plane.py b/backend/tests/test_agent_control_plane.py new file mode 100644 index 00000000..54115463 --- /dev/null +++ b/backend/tests/test_agent_control_plane.py @@ -0,0 +1,193 @@ +"""Agent/CLI control plane: operate-scoped profile CRUD without UI or admin token.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from starlette.testclient import TestClient + +from backend import database as db + + +@pytest.fixture() +def client_access(tmp_db, monkeypatch: pytest.MonkeyPatch): + from backend import main + + monkeypatch.setattr(main, "AUTH_TOKEN", "bootstrap-test-secret") + monkeypatch.setattr(main, "ACCESS_CONTROL_ENABLED", True) + main._login_failures.clear() + monkeypatch.setattr(main.browser_mgr, "cleanup_stale", AsyncMock()) + monkeypatch.setattr(main.browser_mgr, "cleanup_all", AsyncMock()) + monkeypatch.setattr(main.browser_mgr.vnc, "cleanup_stale", AsyncMock()) + with TestClient(main.app) as client: + yield client + + +def bootstrap_headers() -> dict[str, str]: + return {"Authorization": "Bearer bootstrap-test-secret"} + + +def _create_agent(client: TestClient, *, grants: list[dict[str, str]], name: str = "Control agent") -> dict: + created = client.post( + "/api/access/agents", + headers=bootstrap_headers(), + json={ + "display_name": name, + "paperclip_agent_id": f"paperclip-{name.lower().replace(' ', '-')}", + "grants": grants, + }, + ) + assert created.status_code == 201, created.text + return created.json() + + +def test_operate_agent_can_create_update_launch_status_and_open_links( + client_access: TestClient, monkeypatch: pytest.MonkeyPatch +): + from backend import main + + agent = _create_agent( + client_access, + grants=[ + {"sandbox_id": "agents", "permission": "operate"}, + {"sandbox_id": "agents", "permission": "automate"}, + ], + ) + headers = {"Authorization": f"Bearer {agent['api_key']}"} + + created = client_access.post( + "/api/profiles", + headers=headers, + json={ + "name": "Agent-built profile", + "sandbox_id": "agents", + "harness": "codex", + "project_id": "automation", + "folder_path": "external/agents", + "timezone": "Europe/Vienna", + "locale": "de-AT", + "geoip": True, + }, + ) + assert created.status_code == 201, created.text + profile = created.json() + assert profile["name"] == "Agent-built profile" + assert profile["sandbox_id"] == "agents" + assert profile["harness"] == "codex" + # Secrets stay redacted for non-admin callers even after agent create. + assert profile["proxy"] is None + assert profile["user_data_dir"] == "" + + updated = client_access.put( + f"/api/profiles/{profile['id']}", + headers=headers, + json={"harness": "antigravity", "pinned": True, "notes": "set-by-agent"}, + ) + assert updated.status_code == 200, updated.text + assert updated.json()["harness"] == "antigravity" + assert updated.json()["pinned"] is True + assert updated.json()["notes"] is None # redacted on read + + forbidden_sandbox = client_access.post( + "/api/profiles", + headers=headers, + json={"name": "Outside", "sandbox_id": "finance"}, + ) + assert forbidden_sandbox.status_code == 403 + + async def fake_launch(profile_row): + from types import SimpleNamespace + + running = SimpleNamespace(ws_port=6100, cdp_port=9222, display=99) + main.browser_mgr.running[str(profile_row["id"])] = running + return running + + monkeypatch.setattr(main.browser_mgr, "launch", fake_launch) + monkeypatch.setattr(main, "_schedule_profile_health", lambda *args, **kwargs: None) + + launched = client_access.post(f"/api/profiles/{profile['id']}/launch", headers=headers) + assert launched.status_code == 200, launched.text + body = launched.json() + assert body["status"] == "running" + assert body["cdp_url"] == f"/api/profiles/{profile['id']}/cdp" + assert body["links"]["local"]["vnc_ws_url"].endswith(f"/api/profiles/{profile['id']}/vnc") + assert body["links"]["local"]["cdp_http_url"].endswith(f"/api/profiles/{profile['id']}/cdp") + + status = client_access.get(f"/api/profiles/{profile['id']}/status", headers=headers) + assert status.status_code == 200 + assert status.json()["status"] == "running" + assert status.json()["links"]["local"]["vnc_ws_url"].endswith( + f"/api/profiles/{profile['id']}/vnc" + ) + + open_links = client_access.get( + f"/api/profiles/{profile['id']}/open-links?prefer=local", + headers=headers, + ) + assert open_links.status_code == 200 + links = open_links.json() + assert links["cdp_url"].endswith(f"/api/profiles/{profile['id']}/cdp") + assert links["websocket_url"].endswith(f"/api/profiles/{profile['id']}/vnc") + assert links["vnc_fullscreen_url"].endswith("fullscreen=1") + assert "/session/" in links["cdp_fullscreen_url"] + + sandboxes = client_access.get("/api/access/sandboxes", headers=headers) + assert sandboxes.status_code == 200 + assert any(row["sandbox_id"] == "agents" for row in sandboxes.json()) + + stopped = client_access.post(f"/api/profiles/{profile['id']}/stop", headers=headers) + assert stopped.status_code == 200 + deleted = client_access.delete(f"/api/profiles/{profile['id']}", headers=headers) + assert deleted.status_code == 200 + + +def test_view_agent_cannot_create_or_launch_profiles(client_access: TestClient): + db.create_profile("Existing", sandbox_id="alpha") + agent = _create_agent( + client_access, + name="Viewer agent", + grants=[{"sandbox_id": "alpha", "permission": "view"}], + ) + headers = {"Authorization": f"Bearer {agent['api_key']}"} + + assert ( + client_access.post( + "/api/profiles", + headers=headers, + json={"name": "Nope", "sandbox_id": "alpha"}, + ).status_code + == 403 + ) + listed = client_access.get("/api/profiles", headers=headers) + assert listed.status_code == 200 + assert len(listed.json()) == 1 + profile_id = listed.json()[0]["id"] + assert client_access.post(f"/api/profiles/{profile_id}/launch", headers=headers).status_code == 404 + + +def test_operate_agent_bulk_organize_is_sandbox_scoped(client_access: TestClient): + alpha = db.create_profile("Alpha", sandbox_id="alpha", project_id="old") + beta = db.create_profile("Beta", sandbox_id="beta", project_id="old") + agent = _create_agent( + client_access, + name="Organize agent", + grants=[{"sandbox_id": "alpha", "permission": "operate"}], + ) + headers = {"Authorization": f"Bearer {agent['api_key']}"} + + denied = client_access.post( + "/api/profiles/bulk-organize", + headers=headers, + json={"profile_ids": [alpha["id"], beta["id"]], "project_id": "new"}, + ) + assert denied.status_code == 404 + + moved = client_access.post( + "/api/profiles/bulk-organize", + headers=headers, + json={"profile_ids": [alpha["id"]], "project_id": "commerce", "folder_path": "ops"}, + ) + assert moved.status_code == 200, moved.text + assert moved.json()[0]["project_id"] == "commerce" + assert moved.json()[0]["folder_path"] == "ops" diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 9b2bca33..80758aaa 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -26,6 +26,11 @@ def test_create_profile(app_client: TestClient): assert resp.status_code == 201 data = resp.json() assert data["name"] == "Test" + assert data["project_id"] == "default" + assert data["folder_path"] == "" + assert data["pinned"] is False + assert data["accent_color"] is None + assert data["harness"] == "codex" assert data["status"] == "stopped" assert "id" in data assert len(data["id"]) == 36 # UUID @@ -41,12 +46,22 @@ def test_create_profile_with_all_fields(app_client: TestClient): "screen_height": 1440, "humanize": True, "human_preset": "careful", + "project_id": "project-alpha", + "folder_path": "research/phase-1", + "pinned": True, + "accent_color": "#1A2B3C", + "harness": "opencode", "tags": [{"tag": "work", "color": "#ff0000"}], }) assert resp.status_code == 201 data = resp.json() assert data["fingerprint_seed"] == 42 assert data["platform"] == "macos" + assert data["project_id"] == "project-alpha" + assert data["folder_path"] == "research/phase-1" + assert data["pinned"] is True + assert data["accent_color"] == "#1A2B3C" + assert data["harness"] == "opencode" assert len(data["tags"]) == 1 @@ -71,9 +86,46 @@ def test_get_profile_not_found(app_client: TestClient): def test_update_profile(app_client: TestClient): create = app_client.post("/api/profiles", json={"name": "Original"}) pid = create.json()["id"] - resp = app_client.put(f"/api/profiles/{pid}", json={"name": "Renamed"}) + resp = app_client.put( + f"/api/profiles/{pid}", + json={ + "name": "Renamed", + "project_id": "project-2", + "folder_path": "ops/on-call", + "pinned": True, + "accent_color": "#ABCDEF", + "harness": "browser-use", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "Renamed" + assert data["project_id"] == "project-2" + assert data["folder_path"] == "ops/on-call" + assert data["pinned"] is True + assert data["accent_color"] == "#ABCDEF" + assert data["harness"] == "browser-use" + + +def test_list_profiles_orders_and_returns_organization_fields(app_client: TestClient): + app_client.post("/api/profiles", json={"name": "Zulu", "project_id": "beta"}) + app_client.post( + "/api/profiles", + json={"name": "Bravo", "project_id": "alpha", "folder_path": "a", "pinned": True}, + ) + app_client.post( + "/api/profiles", + json={"name": "Alpha", "project_id": "alpha", "folder_path": "a", "pinned": True}, + ) + + resp = app_client.get("/api/profiles") + assert resp.status_code == 200 - assert resp.json()["name"] == "Renamed" + data = resp.json() + assert [p["name"] for p in data] == ["Alpha", "Bravo", "Zulu"] + assert data[0]["project_id"] == "alpha" + assert data[0]["folder_path"] == "a" + assert data[0]["pinned"] is True def test_update_profile_not_found(app_client: TestClient): @@ -153,20 +205,32 @@ def test_launch_invalid_proxy_400(app_client: TestClient): """ValueError from browser_mgr.launch should map to 400.""" create = app_client.post("/api/profiles", json={"name": "BadProxy"}) pid = create.json()["id"] - main.browser_mgr.launch = AsyncMock(side_effect=ValueError("Invalid proxy scheme 'ftp'")) + leaked_proxy = "http://proxy-user:top-secret@:8080" + main.browser_mgr.launch = AsyncMock( + side_effect=ValueError(f"Proxy URL missing hostname: {leaked_proxy}") + ) resp = app_client.post(f"/api/profiles/{pid}/launch") assert resp.status_code == 400 - assert "ftp" in resp.json()["detail"] + assert resp.json()["detail"] == "Invalid browser profile configuration" + assert "proxy-user" not in resp.text + assert "top-secret" not in resp.text + assert leaked_proxy not in resp.text -def test_launch_failure_500(app_client: TestClient): +def test_launch_failure_500(app_client: TestClient, caplog): """Generic exception from browser_mgr.launch should map to 500.""" create = app_client.post("/api/profiles", json={"name": "Crash"}) pid = create.json()["id"] - main.browser_mgr.launch = AsyncMock(side_effect=RuntimeError("Xvnc failed")) + leaked_proxy = "http://proxy-user:top-secret@proxy.test:8080" + main.browser_mgr.launch = AsyncMock( + side_effect=RuntimeError(f"Xvnc failed while using {leaked_proxy}") + ) resp = app_client.post(f"/api/profiles/{pid}/launch") assert resp.status_code == 500 assert resp.json()["detail"] == "Failed to launch browser" + assert "proxy-user" not in caplog.text + assert "top-secret" not in caplog.text + assert leaked_proxy not in caplog.text def test_stop_not_running(app_client: TestClient): diff --git a/backend/tests/test_browser_manager.py b/backend/tests/test_browser_manager.py index 651545e6..8124d623 100644 --- a/backend/tests/test_browser_manager.py +++ b/backend/tests/test_browser_manager.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import json from pathlib import Path @@ -80,13 +81,53 @@ def test_validate_bad_scheme(): def test_validate_no_hostname(): - with pytest.raises(ValueError, match="missing hostname"): - _validate_proxy("http://:8080") + proxy = "http://proxy-user:top-secret@:8080" + with pytest.raises(ValueError, match="missing hostname") as exc_info: + _validate_proxy(proxy) + assert "proxy-user" not in str(exc_info.value) + assert "top-secret" not in str(exc_info.value) + assert proxy not in str(exc_info.value) def test_validate_no_port(): - with pytest.raises(ValueError, match="missing port"): - _validate_proxy("http://host") + proxy = "http://proxy-user:top-secret@host" + with pytest.raises(ValueError, match="missing port") as exc_info: + _validate_proxy(proxy) + assert "proxy-user" not in str(exc_info.value) + assert "top-secret" not in str(exc_info.value) + assert proxy not in str(exc_info.value) + + +def test_validate_malformed_port_redacts_credentials(): + proxy = "http://proxy-user:top-secret@host:not-a-port" + with pytest.raises(ValueError, match="Invalid proxy URL") as exc_info: + _validate_proxy(proxy) + assert "proxy-user" not in str(exc_info.value) + assert "top-secret" not in str(exc_info.value) + assert proxy not in str(exc_info.value) + + +def test_auto_launch_failure_redacts_exception_details(monkeypatch, caplog): + from backend import database as db + + profile = { + "id": "auto-launch-redaction", + "name": "Redaction test", + "auto_launch": True, + } + leaked_proxy = "http://proxy-user:top-secret@proxy.test:8080" + mgr = BrowserManager() + mgr.launch = AsyncMock( + side_effect=RuntimeError(f"browser failed while using {leaked_proxy}") + ) + monkeypatch.setattr(db, "list_profiles", lambda: [profile]) + + asyncio.run(mgr.auto_launch_all()) + + assert "RuntimeError" in caplog.text + assert "proxy-user" not in caplog.text + assert "top-secret" not in caplog.text + assert leaked_proxy not in caplog.text # ── _build_fingerprint_args ────────────────────────────────────────────────── diff --git a/backend/tests/test_bulk_organize_api.py b/backend/tests/test_bulk_organize_api.py new file mode 100644 index 00000000..e694b92e --- /dev/null +++ b/backend/tests/test_bulk_organize_api.py @@ -0,0 +1,84 @@ +"""API tests for POST /api/profiles/bulk-organize.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from starlette.testclient import TestClient + +from backend import database as db + + +@pytest.fixture() +def client_access(tmp_db, monkeypatch: pytest.MonkeyPatch): + from backend import main + + monkeypatch.setattr(main, "AUTH_TOKEN", "bootstrap-test-secret") + monkeypatch.setattr(main, "ACCESS_CONTROL_ENABLED", True) + main._login_failures.clear() + monkeypatch.setattr(main.browser_mgr, "cleanup_stale", AsyncMock()) + monkeypatch.setattr(main.browser_mgr, "cleanup_all", AsyncMock()) + monkeypatch.setattr(main.browser_mgr.vnc, "cleanup_stale", AsyncMock()) + with TestClient(main.app) as client: + yield client + + +def bootstrap_headers() -> dict[str, str]: + return {"Authorization": "Bearer bootstrap-test-secret"} + + +def test_bulk_organize_moves_profiles_and_keeps_sandbox_authz(client_access: TestClient): + alpha = db.create_profile("Alpha", sandbox_id="alpha", project_id="old", folder_path="a") + beta = db.create_profile("Beta", sandbox_id="alpha", project_id="old", folder_path="b") + other = db.create_profile("Other", sandbox_id="beta", project_id="keep") + + # Viewer cannot organize + viewer = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "bulk-viewer", + "password": "viewer-password-123", + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ) + assert viewer.status_code == 201 + client_access.cookies.clear() + assert client_access.post( + "/api/auth/login", + json={"username": "bulk-viewer", "password": "viewer-password-123"}, + ).status_code == 200 + denied = client_access.post( + "/api/profiles/bulk-organize", + json={"profile_ids": [alpha["id"], beta["id"]], "project_id": "commerce", "folder_path": "buyers/us"}, + ) + assert denied.status_code in {403, 404} + + # Admin can organize within scope + moved = client_access.post( + "/api/profiles/bulk-organize", + headers=bootstrap_headers(), + json={ + "profile_ids": [alpha["id"], beta["id"], other["id"]], + "project_id": "commerce", + "folder_path": "buyers/us", + "pinned": True, + }, + ) + assert moved.status_code == 200, moved.text + payload = moved.json() + assert {row["id"] for row in payload} == {alpha["id"], beta["id"], other["id"]} + assert all(row["project_id"] == "commerce" for row in payload) + assert all(row["folder_path"] == "buyers/us" for row in payload) + assert all(row["pinned"] is True for row in payload) + + +def test_bulk_organize_requires_at_least_one_field(client_access: TestClient): + profile = db.create_profile("Solo") + response = client_access.post( + "/api/profiles/bulk-organize", + headers=bootstrap_headers(), + json={"profile_ids": [profile["id"]]}, + ) + assert response.status_code == 400 diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index 3f00cc13..8d9d8bfc 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -2,6 +2,8 @@ from __future__ import annotations +import datetime +import sqlite3 import time from pathlib import Path @@ -37,6 +39,7 @@ def test_init_db_creates_tables(tmp_db: Path): names = {r["name"] for r in tables} assert "profiles" in names assert "profile_tags" in names + assert "profile_health" in names def test_init_db_idempotent(tmp_db: Path): @@ -49,6 +52,92 @@ def test_init_db_idempotent(tmp_db: Path): assert len(tables) >= 2 +def test_init_db_adds_profile_health_to_existing_database(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + db_file = tmp_path / "profiles.db" + monkeypatch.setattr(db, "DB_PATH", db_file) + monkeypatch.setattr(db, "DATA_DIR", tmp_path) + db_file.parent.mkdir(parents=True, exist_ok=True) + + with sqlite3.connect(str(db_file)) as conn: + conn.execute( + """CREATE TABLE profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + fingerprint_seed INTEGER NOT NULL, + user_data_dir TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""" + ) + conn.commit() + + db.init_db() + + with db.get_db() as conn: + table = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='profile_health'" + ).fetchone() + assert table is not None + + +def test_init_db_migrates_profile_organization_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + db_file = tmp_path / "profiles.db" + monkeypatch.setattr(db, "DB_PATH", db_file) + monkeypatch.setattr(db, "DATA_DIR", tmp_path) + db_file.parent.mkdir(parents=True, exist_ok=True) + now = datetime.datetime.now(datetime.timezone.utc).isoformat() + + with sqlite3.connect(str(db_file)) as conn: + conn.execute( + """CREATE TABLE profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + sandbox_id TEXT NOT NULL DEFAULT 'default', + fingerprint_seed INTEGER NOT NULL, + proxy TEXT, + timezone TEXT, + locale TEXT, + platform TEXT DEFAULT 'windows', + user_agent TEXT, + screen_width INTEGER DEFAULT 1920, + screen_height INTEGER DEFAULT 1080, + gpu_vendor TEXT, + gpu_renderer TEXT, + hardware_concurrency INTEGER, + humanize BOOLEAN DEFAULT 0, + human_preset TEXT DEFAULT 'default', + headless BOOLEAN DEFAULT 0, + geoip BOOLEAN DEFAULT 0, + clipboard_sync BOOLEAN DEFAULT 1, + auto_launch BOOLEAN DEFAULT 0, + color_scheme TEXT, + search_engine TEXT, + launch_args TEXT DEFAULT '[]', + notes TEXT, + user_data_dir TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )""" + ) + conn.execute( + """INSERT INTO profiles ( + id, name, sandbox_id, fingerprint_seed, user_data_dir, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)""", + ("old-profile", "Old", "secure-sandbox", 12345, str(tmp_path / "profiles" / "old-profile"), now, now), + ) + conn.commit() + + db.init_db() + + migrated = db.get_profile("old-profile") + assert migrated["sandbox_id"] == "secure-sandbox" + assert migrated["project_id"] == "default" + assert migrated["folder_path"] == "" + assert migrated["pinned"] == 0 + assert migrated["accent_color"] is None + assert migrated["harness"] == "codex" + + # ── create_profile ─────────────────────────────────────────────────────────── @@ -114,6 +203,11 @@ def test_create_profile_with_tags(tmp_db: Path): def test_create_profile_defaults(tmp_db: Path): p = db.create_profile("Defaults") + assert p["project_id"] == "default" + assert p["folder_path"] == "" + assert p["pinned"] == 0 + assert p["accent_color"] is None + assert p["harness"] == "codex" assert p["platform"] == "windows" assert p["screen_width"] == 1920 assert p["screen_height"] == 1080 @@ -129,6 +223,25 @@ def test_create_profile_with_launch_args(tmp_db: Path): assert p["launch_args"] == ["--load-extension=/tmp/ext", "--disable-features=Foo"] +def test_create_profile_with_organization_fields(tmp_db: Path): + p = db.create_profile( + "Organized", + sandbox_id="secure-alpha", + project_id="project-alpha", + folder_path="research/phase-1", + pinned=True, + accent_color="#1A2B3C", + harness="claude-code", + ) + + assert p["sandbox_id"] == "secure-alpha" + assert p["project_id"] == "project-alpha" + assert p["folder_path"] == "research/phase-1" + assert p["pinned"] == 1 + assert p["accent_color"] == "#1A2B3C" + assert p["harness"] == "claude-code" + + def test_get_profile_launch_args_roundtrip(tmp_db: Path): p = db.create_profile("Args", launch_args=["--flag1", "--flag2"]) fetched = db.get_profile(p["id"]) @@ -191,7 +304,52 @@ def test_list_profiles_ordered(tmp_db: Path): db.create_profile("Second") profiles = db.list_profiles() assert len(profiles) == 2 - assert profiles[0]["name"] == "Second" # newest first + assert [profile["name"] for profile in profiles] == ["First", "Second"] + + +def test_list_profiles_orders_by_pin_project_folder_name(tmp_db: Path): + db.create_profile("Zulu", project_id="beta", folder_path="") + db.create_profile("Alpha", project_id="alpha", folder_path="z") + db.create_profile("Bravo", project_id="alpha", folder_path="a", pinned=True) + db.create_profile("Alpha", project_id="alpha", folder_path="a", pinned=True) + db.create_profile("Charlie", project_id="alpha", folder_path="a") + + profiles = db.list_profiles() + + assert [p["name"] for p in profiles] == ["Alpha", "Bravo", "Charlie", "Alpha", "Zulu"] + + +def test_list_profiles_orders_identical_visible_keys_by_id(tmp_db: Path): + created_at = datetime.datetime.now(datetime.timezone.utc).isoformat() + base_values = { + "name": "Same", + "sandbox_id": "same-sandbox", + "project_id": "same-project", + "folder_path": "same-folder", + "pinned": 1, + "fingerprint_seed": 12345, + "user_data_dir": str(tmp_db / "profiles" / "placeholder"), + "created_at": created_at, + "updated_at": created_at, + } + with db.get_db() as conn: + for profile_id in ("b-profile", "a-profile"): + values = dict(base_values, id=profile_id, user_data_dir=str(tmp_db / "profiles" / profile_id)) + conn.execute( + """INSERT INTO profiles ( + id, name, sandbox_id, project_id, folder_path, pinned, + fingerprint_seed, user_data_dir, created_at, updated_at + ) VALUES ( + :id, :name, :sandbox_id, :project_id, :folder_path, :pinned, + :fingerprint_seed, :user_data_dir, :created_at, :updated_at + )""", + values, + ) + conn.commit() + + profiles = db.list_profiles() + + assert [profile["id"] for profile in profiles] == ["a-profile", "b-profile"] def test_list_profiles_includes_tags(tmp_db: Path): @@ -200,6 +358,165 @@ def test_list_profiles_includes_tags(tmp_db: Path): assert len(profiles[0]["tags"]) == 1 +# ── profile health ────────────────────────────────────────────────────────── + + +def test_profile_health_upsert_roundtrip_and_replace(tmp_db: Path): + profile = db.create_profile("Health") + + first = db.upsert_profile_health( + profile["id"], + state="warning", + checked_at="2026-07-22T12:00:00+00:00", + proxy_configured=True, + proxy_reachable=True, + outbound_ip_masked="203.0.113.x", + proxy_latency_ms=42.5, + proxy_risk_score=12, + proxy_authenticity_score=88, + fingerprint_consistency_score=90, + browser_scan_score=None, + warnings=["fingerprint_mismatch"], + blockers=["browser_scan_consent"], + error_code=None, + sources={ + "browser_network": "measured", + "proxy_authenticity": "derived", + "browser_scan": "unavailable", + }, + ) + + assert first["profile_id"] == profile["id"] + assert first["proxy_configured"] is True + assert first["proxy_reachable"] is True + assert first["warnings"] == ["fingerprint_mismatch"] + assert first["blockers"] == ["browser_scan_consent"] + assert first["sources"]["proxy_authenticity"] == "derived" + + replaced = db.upsert_profile_health( + profile["id"], + state="passed", + checked_at="2026-07-22T12:05:00+00:00", + proxy_configured=False, + proxy_reachable=True, + outbound_ip_masked="2001:db8:abcd:…", + proxy_latency_ms=18.0, + proxy_risk_score=None, + proxy_authenticity_score=None, + fingerprint_consistency_score=100, + browser_scan_score=99, + warnings=[], + blockers=[], + error_code=None, + sources={"browser_network": "measured", "browser_scan": "measured"}, + ) + + with db.get_db() as conn: + count = conn.execute( + "SELECT COUNT(*) AS count FROM profile_health WHERE profile_id = ?", + (profile["id"],), + ).fetchone()["count"] + + assert count == 1 + assert replaced["state"] == "passed" + assert replaced["proxy_configured"] is False + assert replaced["warnings"] == [] + assert db.get_profile_health(profile["id"]) == replaced + + +def test_profile_health_defensively_normalizes_corrupt_json(tmp_db: Path): + profile = db.create_profile("Corrupt health") + with db.get_db() as conn: + conn.execute( + """INSERT INTO profile_health ( + profile_id, state, proxy_configured, warnings_json, blockers_json, sources_json + ) VALUES (?, 'unavailable', 0, ?, ?, ?)""", + (profile["id"], '{"not":"a-list"}', '["ok", 42, null]', '["not-an-object"]'), + ) + conn.commit() + + health = db.get_profile_health(profile["id"]) + + assert health["warnings"] == [] + assert health["blockers"] == ["ok"] + assert health["sources"] == {} + + +def test_profile_health_missing_returns_none(tmp_db: Path): + profile = db.create_profile("No health") + + assert db.get_profile_health(profile["id"]) is None + + +def test_profile_health_is_deleted_with_profile(tmp_db: Path): + profile = db.create_profile("Cascade health") + db.upsert_profile_health( + profile["id"], + state="unavailable", + proxy_configured=False, + warnings=[], + blockers=[], + sources={}, + ) + + assert db.delete_profile(profile["id"]) is True + assert db.get_profile_health(profile["id"]) is None + + +# ── task sessions ──────────────────────────────────────────────────────────── + + +def test_task_session_message_and_event_roundtrip(tmp_db: Path): + profile = db.create_profile("Task Browser", sandbox_id="tasks") + session = db.create_task_session( + profile["id"], + profile["sandbox_id"], + "user", + "user-1", + "Research", + {"source": "test"}, + ) + + message = db.append_task_message( + session["id"], + "user", + "Open the dashboard", + "user", + "user-1", + {"intent": "navigate"}, + ) + event = db.record_task_event( + session["id"], + "task_command.appended", + "user", + "user-1", + {"message_id": message["id"]}, + ) + + assert db.get_task_session(session["id"])["metadata"] == {"source": "test"} + assert db.list_task_sessions(profile["id"])[0]["id"] == session["id"] + assert db.list_task_messages(session["id"]) == [message] + assert db.list_task_events(session["id"]) == [event] + + +def test_task_sessions_are_deleted_with_profile(tmp_db: Path): + profile = db.create_profile("Task Browser") + session = db.create_task_session( + profile["id"], + profile["sandbox_id"], + "bootstrap", + None, + ) + db.append_task_message(session["id"], "user", "hello", "bootstrap") + db.record_task_event(session["id"], "task_session.created", "bootstrap") + + assert db.delete_profile(profile["id"]) is True + + assert db.get_task_session(session["id"]) is None + assert db.list_task_messages(session["id"]) == [] + assert db.list_task_events(session["id"]) == [] + + # ── update_profile ─────────────────────────────────────────────────────────── @@ -209,6 +526,23 @@ def test_update_profile_partial(sample_profile: dict): assert updated["fingerprint_seed"] == 12345 # unchanged +def test_update_profile_organization_fields(sample_profile: dict): + updated = db.update_profile( + sample_profile["id"], + project_id="project-2", + folder_path="ops/on-call", + pinned=True, + accent_color="#ABCDEF", + harness="antigravity", + ) + + assert updated["project_id"] == "project-2" + assert updated["folder_path"] == "ops/on-call" + assert updated["pinned"] == 1 + assert updated["accent_color"] == "#ABCDEF" + assert updated["harness"] == "antigravity" + + def test_update_profile_tags_replace(tmp_db: Path): p = db.create_profile("Tagged", tags=[{"tag": "old"}]) updated = db.update_profile(p["id"], tags=[{"tag": "new", "color": "#fff"}]) diff --git a/backend/tests/test_extension_defaults_api.py b/backend/tests/test_extension_defaults_api.py new file mode 100644 index 00000000..c9cc7707 --- /dev/null +++ b/backend/tests/test_extension_defaults_api.py @@ -0,0 +1,86 @@ +"""Focused tests for Comet extension defaults and profile templates.""" + +from __future__ import annotations + +import json + +import pytest + + +@pytest.fixture() +def admin_client(app_client, monkeypatch: pytest.MonkeyPatch): + from backend import main + + monkeypatch.setattr(main, "AUTH_TOKEN", "test-admin-token-0123456789abcdef") + monkeypatch.setattr(main, "ACCESS_CONTROL_ENABLED", True) + app_client.cookies.set("auth_token", "test-admin-token-0123456789abcdef") + return app_client + + +def test_extension_defaults_lists_comet_catalog(admin_client, tmp_path, monkeypatch): + from backend import extension_catalog + from backend import main + + monkeypatch.setattr(extension_catalog, "DATA_DIR", tmp_path) + monkeypatch.setattr(extension_catalog, "defaults_path", lambda: tmp_path / "extension-defaults.json") + monkeypatch.setattr(extension_catalog, "catalog_dir", lambda: tmp_path / "missing-catalog") + + response = admin_client.get("/api/extension/defaults") + assert response.status_code == 200, response.text + payload = response.json() + assert payload["source"] == "comet" + assert payload["count"] >= 3 + assert payload["extensions"] + assert payload["items"] + assert all("password" not in json.dumps(item).lower() for item in payload["extensions"]) + assert any(item["id"] == "ddkjiahejlhfcafbddmgiahcphecmpfh" for item in payload["extensions"]) + assert "defaults" in main.session_links.catalog_endpoint_map() + + +def test_extension_defaults_put_persists_selection(admin_client, tmp_path, monkeypatch): + from backend import extension_catalog + + monkeypatch.setattr(extension_catalog, "DATA_DIR", tmp_path) + monkeypatch.setattr(extension_catalog, "defaults_path", lambda: tmp_path / "extension-defaults.json") + monkeypatch.setattr(extension_catalog, "catalog_dir", lambda: tmp_path / "missing-catalog") + + listed = admin_client.get("/api/extension/defaults").json() + chosen = [listed["extensions"][0]["id"], listed["extensions"][1]["id"]] + response = admin_client.put("/api/extension/defaults", json={"selected_ids": chosen}) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["selected_ids"] == chosen + assert {item["id"] for item in payload["extensions"] if item["selected"]} == set(chosen) + assert json.loads((tmp_path / "extension-defaults.json").read_text())["selected_ids"] == chosen + + +def test_profile_templates_create_applies_defaults(admin_client, tmp_path, monkeypatch): + from backend import extension_catalog + from backend import profile_templates + + catalog_root = tmp_path / "extension-catalog" / "ddkjiahejlhfcafbddmgiahcphecmpfh" / "1.0_0" + catalog_root.mkdir(parents=True) + (catalog_root / "manifest.json").write_text( + json.dumps({"name": "uBlock Origin Lite", "version": "1.0", "manifest_version": 3}), + encoding="utf-8", + ) + monkeypatch.setattr(extension_catalog, "DATA_DIR", tmp_path) + monkeypatch.setattr(extension_catalog, "defaults_path", lambda: tmp_path / "extension-defaults.json") + monkeypatch.setattr(extension_catalog, "catalog_dir", lambda: tmp_path / "extension-catalog") + extension_catalog.save_selected_ids(["ddkjiahejlhfcafbddmgiahcphecmpfh"]) + + listed = admin_client.get("/api/profile-templates") + assert listed.status_code == 200 + assert any(item["id"] == "generate-new" for item in listed.json()) + + created = admin_client.post( + "/api/profile-templates/generate-new/profiles", + json={"template_id": "generate-new", "name": "Demo Generated"}, + ) + assert created.status_code == 201, created.text + profile = created.json() + assert profile["name"] == "Demo Generated" + assert any(arg.startswith("--load-extension=") for arg in profile.get("launch_args") or []) + fields = profile_templates.build_profile_fields("codex-operator") + assert fields["harness"] == "codex" + assert "System prompt" in (fields.get("notes") or "") diff --git a/backend/tests/test_extension_sessions_api.py b/backend/tests/test_extension_sessions_api.py new file mode 100644 index 00000000..4544539f --- /dev/null +++ b/backend/tests/test_extension_sessions_api.py @@ -0,0 +1,170 @@ +"""API tests for Chrome-extension catalog and one-click session open links.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +@pytest.fixture() +def admin_client(app_client, monkeypatch: pytest.MonkeyPatch): + from backend import main + + monkeypatch.setattr(main, "AUTH_TOKEN", "test-admin-token-0123456789abcdef") + monkeypatch.setattr(main, "ACCESS_CONTROL_ENABLED", True) + app_client.cookies.set("auth_token", "test-admin-token-0123456789abcdef") + return app_client + + +def test_extension_catalog_lists_profiles_and_endpoints(admin_client, sample_profile, monkeypatch): + monkeypatch.setenv("CLOUD_BASE_URL", "https://cloud.example") + response = admin_client.get( + "/api/extension/catalog", + headers={"Host": "127.0.0.1:18117"}, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["bases"]["local"].startswith("http://127.0.0.1:18117") + assert payload["bases"]["cloud"] == "https://cloud.example" + assert payload["endpoints"]["open_session"] == "/api/extension/sessions/open" + assert payload["endpoints"]["defaults"] == "/api/extension/defaults" + assert payload["endpoints"]["templates"] == "/api/extension/templates" + assert any(item["id"] == sample_profile["id"] for item in payload["profiles"]) + assert payload["capabilities"]["can_list_proxies"] is True + body = response.text + assert "password" not in body.lower() or "has_credentials" in body + + posted = admin_client.post("/api/extension/catalog", headers={"Host": "127.0.0.1:18117"}) + assert posted.status_code == 200 + assert posted.json()["endpoints"]["catalog"] == "/api/extension/catalog" + + +def test_extension_defaults_and_templates_discoverable(admin_client): + defaults = admin_client.get("/api/extension/defaults") + assert defaults.status_code == 200, defaults.text + payload = defaults.json() + assert "extensions" in payload + assert "items" in payload + assert isinstance(payload["extensions"], list) + if payload["extensions"]: + assert "icon_url" in payload["extensions"][0] + assert "id" in payload["extensions"][0] + assert "name" in payload["extensions"][0] + + templates = admin_client.get("/api/extension/templates") + assert templates.status_code == 200, templates.text + tpayload = templates.json() + assert "templates" in tpayload + assert tpayload["create_profile_path"] == "/api/profiles" + + +def test_extension_open_session_launches_and_returns_links(admin_client, sample_profile, monkeypatch): + from backend import main + + running = MagicMock() + running.ws_port = 6101 + running.display = 101 + monkeypatch.setattr(main.browser_mgr, "launch", AsyncMock(return_value=running)) + monkeypatch.setattr(main.browser_mgr, "running", {}) + monkeypatch.setattr(main, "_schedule_profile_health", MagicMock()) + monkeypatch.setenv("CLOUD_BASE_URL", "https://cloud.example") + + response = admin_client.post( + "/api/extension/sessions/open", + headers={"Host": "127.0.0.1:18117"}, + json={ + "profile_id": sample_profile["id"], + "launch": True, + "prefer": "local", + "mode": "cdp", + }, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["launched"] is True + assert payload["status"] == "running" + assert payload["mode"] == "cdp" + assert payload["open_url"] == f"http://127.0.0.1:18117/session/{sample_profile['id']}/live" + assert payload["cdp_fullscreen_url"] == payload["open_url"] + assert payload["live_url"] == payload["open_url"] + assert payload["vnc_fullscreen_url"].endswith("&view=vnc&fullscreen=1") + assert payload["session_viewer_url"].endswith(f"?profile={sample_profile['id']}") + assert payload["links"]["cdp_fullscreen_url"] == payload["cdp_fullscreen_url"] + assert payload["links"]["vnc_fullscreen_url"] == payload["vnc_fullscreen_url"] + assert payload["links"]["local"]["vnc_ws_url"].endswith(f"/api/profiles/{sample_profile['id']}/vnc") + assert payload["links"]["local"]["vnc_fullscreen_url"].endswith("&view=vnc&fullscreen=1") + assert payload["links"]["cloud"]["session_viewer_url"].startswith("https://cloud.example/?profile=") + assert payload["cdp_url"] == f"/api/profiles/{sample_profile['id']}/cdp" + assert "secret" not in response.text + + +def test_launch_response_includes_open_links(admin_client, sample_profile, monkeypatch): + from backend import main + + running = MagicMock() + running.ws_port = 6102 + running.display = 102 + monkeypatch.setattr(main.browser_mgr, "launch", AsyncMock(return_value=running)) + monkeypatch.setattr(main, "_schedule_profile_health", MagicMock()) + + response = admin_client.post( + f"/api/profiles/{sample_profile['id']}/launch", + headers={"Host": "127.0.0.1:18117"}, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert "/session/" in payload["links"]["open_url"] or "?profile=" in payload["links"]["open_url"] + assert payload["links"]["local"]["status_path"] == f"/api/profiles/{sample_profile['id']}/status" + assert payload["links"]["local"]["live_metrics_path"] == ( + f"/api/profiles/{sample_profile['id']}/live-metrics" + ) + + +def test_status_includes_open_links_without_launch(admin_client, sample_profile): + response = admin_client.get( + f"/api/profiles/{sample_profile['id']}/status", + headers={"Host": "127.0.0.1:18117"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["links"]["local"]["session_viewer_url"].endswith(f"?profile={sample_profile['id']}") + + +def test_profile_open_links_flat_compatibility(admin_client, sample_profile, monkeypatch): + monkeypatch.setenv("CLOUD_BASE_URL", "https://cloud.example") + response = admin_client.get( + f"/api/profiles/{sample_profile['id']}/open-links?prefer=cloud&mode=cdp", + headers={"Host": "127.0.0.1:18117"}, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["cloud_url"] == f"https://cloud.example/?profile={sample_profile['id']}" + assert payload["local_url"] == f"http://127.0.0.1:18117/?profile={sample_profile['id']}" + assert payload["live_url"] == f"https://cloud.example/session/{sample_profile['id']}/live" + assert payload["vnc_fullscreen_url"].endswith("&view=vnc&fullscreen=1") + assert payload["open_url"] == payload["live_url"] + assert "secret" not in response.text + + +def test_live_metrics_roundtrip(admin_client, sample_profile): + posted = admin_client.post( + f"/api/profiles/{sample_profile['id']}/live-metrics", + json={ + "transport": "cdp", + "connection_state": "connected", + "fps": 28.5, + "rtt_ms": 42.0, + "reconnect_count": 1, + "dropped_frames": 3, + }, + ) + assert posted.status_code == 200, posted.text + body = posted.json() + assert body["transport"] == "cdp" + assert body["fps"] == 28.5 + assert body["rtt_ms"] == 42.0 + + got = admin_client.get(f"/api/profiles/{sample_profile['id']}/live-metrics") + assert got.status_code == 200 + assert got.json()["connection_state"] == "connected" diff --git a/backend/tests/test_extensions.py b/backend/tests/test_extensions.py new file mode 100644 index 00000000..8ebf2cd0 --- /dev/null +++ b/backend/tests/test_extensions.py @@ -0,0 +1,93 @@ +"""Tests for backend/extensions.py manifest inspection and parsing.""" + +import json +from pathlib import Path +import pytest + +from backend.extensions import ( + extract_load_extension_paths, + inspect_profile_extensions, + parse_extension_manifest, +) + + +def test_extract_load_extension_paths(): + args = [ + "--no-sandbox", + "--load-extension=/tmp/ext1,/tmp/ext2", + "--disable-gpu", + "--load-extension=/tmp/ext3", + ] + paths = extract_load_extension_paths(args) + assert paths == ["/tmp/ext1", "/tmp/ext2", "/tmp/ext3"] + + +def test_parse_extension_manifest_invalid_path(tmp_path: Path): + non_existent = tmp_path / "does_not_exist" + info = parse_extension_manifest(str(non_existent)) + assert info.trust_state == "invalid_path" + assert info.error is not None + + +def test_parse_extension_manifest_missing_manifest(tmp_path: Path): + ext_dir = tmp_path / "ext" + ext_dir.mkdir() + info = parse_extension_manifest(str(ext_dir)) + assert info.trust_state == "missing_manifest" + assert info.error is not None + + +def test_parse_extension_manifest_invalid_json(tmp_path: Path): + ext_dir = tmp_path / "ext" + ext_dir.mkdir() + manifest = ext_dir / "manifest.json" + manifest.write_text("{ invalid json ", encoding="utf-8") + + info = parse_extension_manifest(str(ext_dir)) + assert info.trust_state == "untrusted_manifest" + assert "Invalid manifest JSON" in (info.error or "") + + +def test_parse_extension_manifest_valid(tmp_path: Path): + ext_dir = tmp_path / "my_extension" + ext_dir.mkdir() + manifest_data = { + "name": "Test Extension", + "version": "1.2.3", + "manifest_version": 3, + "description": "A sample extension for testing.", + "permissions": ["storage", "activeTab"], + } + manifest = ext_dir / "manifest.json" + manifest.write_text(json.dumps(manifest_data), encoding="utf-8") + + info = parse_extension_manifest(str(ext_dir)) + assert info.trust_state == "valid" + assert info.name == "Test Extension" + assert info.version == "1.2.3" + assert info.manifest_version == 3 + assert info.description == "A sample extension for testing." + assert info.permissions == ["storage", "activeTab"] + assert info.error is None + + +def test_inspect_profile_extensions(tmp_path: Path): + ext_dir = tmp_path / "my_extension" + ext_dir.mkdir() + manifest_data = { + "name": "Test Extension", + "version": "1.0.0", + "manifest_version": 2, + "permissions": ["cookies"], + } + (ext_dir / "manifest.json").write_text(json.dumps(manifest_data), encoding="utf-8") + + profile = { + "id": "p-123", + "launch_args": [f"--load-extension={ext_dir}"], + } + + results = inspect_profile_extensions(profile) + assert len(results) == 1 + assert results[0]["name"] == "Test Extension" + assert results[0]["trust_state"] == "valid" diff --git a/backend/tests/test_extensions_api.py b/backend/tests/test_extensions_api.py new file mode 100644 index 00000000..94212fdb --- /dev/null +++ b/backend/tests/test_extensions_api.py @@ -0,0 +1,61 @@ +"""Tests for GET /api/profiles/{profile_id}/extensions endpoint.""" + +import json +from pathlib import Path +import pytest +from fastapi.testclient import TestClient + +from backend.main import app +from backend import database as db + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(db, "DATA_DIR", tmp_path) + monkeypatch.setattr(db, "DB_PATH", tmp_path / "test_manager.db") + db.init_db() + with TestClient(app) as c: + yield c + + +def test_get_profile_extensions_not_found(client: TestClient): + resp = client.get("/api/profiles/non-existent-id/extensions") + assert resp.status_code == 404 + + +def test_get_profile_extensions_empty(client: TestClient): + p = db.create_profile("NoExt") + resp = client.get(f"/api/profiles/{p['id']}/extensions") + assert resp.status_code == 200 + data = resp.json() + assert data["profile_id"] == p["id"] + assert data["extensions"] == [] + + +def test_get_profile_extensions_valid(client: TestClient, tmp_path: Path): + ext_dir = tmp_path / "sample_ext" + ext_dir.mkdir() + manifest_data = { + "name": "Sample Extension", + "version": "2.0.0", + "manifest_version": 3, + "description": "Sample extension", + "permissions": ["notifications"], + } + (ext_dir / "manifest.json").write_text(json.dumps(manifest_data), encoding="utf-8") + + p = db.create_profile( + "WithExt", + launch_args=[f"--load-extension={ext_dir}"], + ) + + resp = client.get(f"/api/profiles/{p['id']}/extensions") + assert resp.status_code == 200 + data = resp.json() + assert data["profile_id"] == p["id"] + assert len(data["extensions"]) == 1 + ext = data["extensions"][0] + assert ext["name"] == "Sample Extension" + assert ext["version"] == "2.0.0" + assert ext["trust_state"] == "valid" + assert ext["permissions"] == ["notifications"] diff --git a/backend/tests/test_live_diagnostics.py b/backend/tests/test_live_diagnostics.py new file mode 100644 index 00000000..da42d53c --- /dev/null +++ b/backend/tests/test_live_diagnostics.py @@ -0,0 +1,102 @@ +"""Unit tests for admin-only live diagnostics registry and redaction.""" + +from __future__ import annotations + +import struct + +from backend.live_diagnostics import ( + FirstFramebufferDetector, + LiveDiagnosticsRegistry, + metric, + redact_diagnostics_payload, +) + + +def test_metric_unavailable_for_missing_values(): + assert metric(None) == {"availability": "unavailable", "value": None} + assert metric(float("nan")) == {"availability": "unavailable", "value": None} + assert metric(-1) == {"availability": "unavailable", "value": None} + + +def test_metric_measured_for_non_negative_values(): + assert metric(0) == {"availability": "measured", "value": 0} + assert metric(12.3456) == {"availability": "measured", "value": 12.346} + + +def test_redaction_strips_forbidden_keys(): + payload = { + "profile_id": "abc", + "display": ":3", + "ws_port": 5901, + "cdp_url": "http://127.0.0.1:9222", + "proxy": "http://user:pass@host:8080", + "nested": {"token": "secret", "ok": True, "password_hint": "nope"}, + "metrics": {"launch_duration_ms": {"availability": "unavailable", "value": None}}, + } + cleaned = redact_diagnostics_payload(payload) + assert cleaned["profile_id"] == "abc" + assert "display" not in cleaned + assert "ws_port" not in cleaned + assert "cdp_url" not in cleaned + assert "proxy" not in cleaned + assert "token" not in cleaned["nested"] + assert "password_hint" not in cleaned["nested"] + assert cleaned["nested"]["ok"] is True + assert cleaned["metrics"]["launch_duration_ms"]["availability"] == "unavailable" + + +def test_registry_records_launch_and_vnc_counters(): + registry = LiveDiagnosticsRegistry() + registry.mark_launch_started("p1") + registry.mark_launch_succeeded("p1") + session = registry.begin_vnc_session("p1") + registry.mark_vnc_websocket_open("p1", session) + registry.mark_vnc_first_framebuffer("p1", session) + + snap = registry.snapshot(running_profile_ids={"p1"}) + assert snap["running_profiles"] == 1 + assert snap["total_launches"] == 1 + assert snap["total_vnc_connections"] == 1 + assert snap["active_vnc_connections"] == 1 + profile = snap["profiles"][0] + assert profile["profile_id"] == "p1" + assert profile["status"] == "running" + assert profile["metrics"]["launch_duration_ms"]["availability"] == "measured" + assert profile["metrics"]["vnc_websocket_open_ms"]["availability"] == "measured" + assert profile["metrics"]["vnc_first_framebuffer_ms"]["availability"] == "measured" + assert profile["launched_at"] is not None + assert "display" not in profile + assert "ws_port" not in profile + + registry.end_vnc_session("p1", session) + registry.mark_stopped("p1") + stopped = registry.snapshot(running_profile_ids=set()) + assert stopped["active_vnc_connections"] == 0 + assert stopped["profiles"][0]["status"] == "stopped" + assert stopped["profiles"][0]["metrics"]["active_vnc_connections"]["value"] == 0 + + +def test_unmeasured_vnc_timings_stay_unavailable(): + registry = LiveDiagnosticsRegistry() + registry.mark_launch_started("p2") + registry.mark_launch_succeeded("p2") + snap = registry.snapshot(running_profile_ids={"p2"}) + metrics = snap["profiles"][0]["metrics"] + assert metrics["vnc_websocket_open_ms"] == {"availability": "unavailable", "value": None} + assert metrics["vnc_first_framebuffer_ms"] == { + "availability": "unavailable", + "value": None, + } + + +def test_first_framebuffer_detector_on_simple_stream(): + detector = FirstFramebufferDetector() + name = b"desk" + pixel_format = struct.pack(">BBBBHHHBBBxxx", 32, 24, 0, 1, 255, 255, 255, 16, 8, 0) + server_init = struct.pack(">HH", 100, 200) + pixel_format + struct.pack(">I", len(name)) + name + assert detector.observe(b"RFB 003.008\n") is False + assert detector.observe(b"\x01\x01") is False + assert detector.observe(b"\x00\x00\x00\x00") is False + assert detector.observe(server_init) is False + assert detector.observe(b"\x00\x00\x00") is True + assert detector.seen is True diff --git a/backend/tests/test_live_diagnostics_api.py b/backend/tests/test_live_diagnostics_api.py new file mode 100644 index 00000000..ac1ef65a --- /dev/null +++ b/backend/tests/test_live_diagnostics_api.py @@ -0,0 +1,82 @@ +"""API tests for GET /api/admin/live-diagnostics.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from starlette.testclient import TestClient + +from backend import live_diagnostics + + +def _patch_lifespan(main, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(main.browser_mgr, "cleanup_stale", AsyncMock()) + monkeypatch.setattr(main.browser_mgr, "cleanup_all", AsyncMock()) + monkeypatch.setattr(main.browser_mgr.vnc, "cleanup_stale", AsyncMock()) + + +@pytest.fixture() +def client_access(tmp_db, monkeypatch: pytest.MonkeyPatch): + from backend import main + + monkeypatch.setattr(main, "AUTH_TOKEN", "bootstrap-test-secret") + monkeypatch.setattr(main, "ACCESS_CONTROL_ENABLED", True) + main._login_failures.clear() + _patch_lifespan(main, monkeypatch) + live_diagnostics.live_diagnostics.reset() + + with TestClient(main.app) as client: + yield client + + +def bootstrap_headers() -> dict[str, str]: + return {"Authorization": "Bearer bootstrap-test-secret"} + + +def test_live_diagnostics_requires_admin(client_access: TestClient): + created = client_access.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": "viewer-diag", + "password": "viewer-password-123", + "grants": [{"sandbox_id": "alpha", "permission": "view"}], + }, + ) + assert created.status_code == 201, created.text + + client_access.cookies.clear() + login = client_access.post( + "/api/auth/login", + json={"username": "viewer-diag", "password": "viewer-password-123"}, + ) + assert login.status_code == 200, login.text + assert login.json()["identity"]["kind"] == "user" + + denied = client_access.get("/api/admin/live-diagnostics") + assert denied.status_code == 403 + + +def test_live_diagnostics_admin_snapshot_is_redacted(client_access: TestClient): + live_diagnostics.live_diagnostics.mark_launch_started("prof-1") + live_diagnostics.live_diagnostics.mark_launch_succeeded("prof-1") + session = live_diagnostics.live_diagnostics.begin_vnc_session("prof-1") + live_diagnostics.live_diagnostics.mark_vnc_websocket_open("prof-1", session) + + response = client_access.get( + "/api/admin/live-diagnostics", + headers=bootstrap_headers(), + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["total_launches"] == 1 + assert payload["total_vnc_connections"] == 1 + assert "profiles" in payload + blob = response.text.lower() + for forbidden in ("ws_port", "cdp_url", "password", "token=", "/tmp/"): + assert forbidden not in blob + profile = next(p for p in payload["profiles"] if p["profile_id"] == "prof-1") + assert profile["metrics"]["vnc_first_framebuffer_ms"]["availability"] == "unavailable" + assert profile["metrics"]["vnc_websocket_open_ms"]["availability"] == "measured" + assert profile["metrics"]["launch_duration_ms"]["availability"] == "measured" diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 0235b54a..2623264b 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -7,6 +7,7 @@ ClipboardRequest, LaunchResponse, ProfileCreate, + ProfileHealthResponse, ProfileResponse, ProfileStatusResponse, ProfileUpdate, @@ -16,12 +17,64 @@ ) +# ── ProfileHealthResponse ─────────────────────────────────────────────────── + + +def test_profile_health_response_defaults_are_explicitly_unavailable(): + health = ProfileHealthResponse(profile_id="profile-1") + + assert health.state == "unavailable" + assert health.checked_at is None + assert health.proxy_configured is False + assert health.proxy_reachable is None + assert health.outbound_ip_masked is None + assert health.proxy_latency_ms is None + assert health.proxy_risk_score is None + assert health.proxy_authenticity_score is None + assert health.fingerprint_consistency_score is None + assert health.browser_scan_score is None + assert health.warnings == [] + assert health.blockers == [] + assert health.error_code is None + assert health.sources == {} + + +@pytest.mark.parametrize( + "state", + ["pending", "running", "passed", "warning", "failed", "unavailable"], +) +def test_profile_health_response_accepts_documented_states(state: str): + health = ProfileHealthResponse(profile_id="profile-1", state=state) + + assert health.state == state + + +def test_profile_health_response_rejects_unknown_state(): + with pytest.raises(ValidationError): + ProfileHealthResponse(profile_id="profile-1", state="healthy") + + +def test_profile_health_response_rejects_out_of_range_scores(): + with pytest.raises(ValidationError): + ProfileHealthResponse(profile_id="profile-1", proxy_risk_score=101) + + +def test_profile_health_response_rejects_unknown_source_state(): + with pytest.raises(ValidationError): + ProfileHealthResponse(profile_id="profile-1", sources={"browser_scan": "trusted"}) + + # ── ProfileCreate ──────────────────────────────────────────────────────────── def test_profile_create_minimal(): p = ProfileCreate(name="Test") assert p.name == "Test" + assert p.project_id == "default" + assert p.folder_path == "" + assert p.pinned is False + assert p.accent_color is None + assert p.harness == "codex" assert p.fingerprint_seed is None assert p.platform == "windows" assert p.screen_width == 1920 @@ -72,6 +125,23 @@ def test_profile_create_with_launch_args(): assert p.launch_args == ["--load-extension=/tmp/ext"] +def test_profile_create_organization_fields(): + p = ProfileCreate( + name="Organized", + project_id="client.alpha_1", + folder_path="research/phase-1", + pinned=True, + accent_color="#1A2B3C", + harness="opencode", + ) + + assert p.project_id == "client.alpha_1" + assert p.folder_path == "research/phase-1" + assert p.pinned is True + assert p.accent_color == "#1A2B3C" + assert p.harness == "opencode" + + def test_profile_update_launch_args(): p = ProfileUpdate(launch_args=["--flag"]) dumped = p.model_dump(exclude_unset=True) @@ -97,6 +167,41 @@ def test_profile_create_invalid_search_engine(): ProfileCreate(name="Bad", search_engine="yahoo") +@pytest.mark.parametrize("project_id", ["", "-bad", "bad space", "a" * 81]) +def test_profile_create_invalid_project_id(project_id: str): + with pytest.raises(ValidationError): + ProfileCreate(name="Bad", project_id=project_id) + + +@pytest.mark.parametrize( + "folder_path", + ["/leading", "trailing/", "two//segments", ".", "..", "safe/../unsafe", "a" * 241], +) +def test_profile_create_invalid_folder_path(folder_path: str): + with pytest.raises(ValidationError): + ProfileCreate(name="Bad", folder_path=folder_path) + + +@pytest.mark.parametrize("accent_color", ["#abc", "112233", "#GG0011", "#11223344"]) +def test_profile_create_invalid_accent_color(accent_color: str): + with pytest.raises(ValidationError): + ProfileCreate(name="Bad", accent_color=accent_color) + + +def test_profile_create_invalid_harness(): + with pytest.raises(ValidationError): + ProfileCreate(name="Bad", harness="selenium") + + +@pytest.mark.parametrize( + "harness", + ["browser-use", "browser-harness", "unbrowse", "stagehand", "codex"], +) +def test_profile_create_accepts_callable_browser_harnesses(harness: str): + profile = ProfileCreate(name="Harness", harness=harness) + assert profile.harness == harness + + # ── ProfileUpdate ──────────────────────────────────────────────────────────── @@ -112,6 +217,25 @@ def test_profile_update_exclude_unset(): assert dumped == {"name": "New Name"} +def test_profile_update_organization_fields_exclude_unset(): + p = ProfileUpdate( + project_id="project-2", + folder_path="ops/on-call", + pinned=True, + accent_color="#ABCDEF", + harness="browser-use", + ) + dumped = p.model_dump(exclude_unset=True) + + assert dumped == { + "project_id": "project-2", + "folder_path": "ops/on-call", + "pinned": True, + "accent_color": "#ABCDEF", + "harness": "browser-use", + } + + def test_profile_update_invalid_platform(): with pytest.raises(ValidationError): ProfileUpdate(platform="android") diff --git a/backend/tests/test_profile_health.py b/backend/tests/test_profile_health.py new file mode 100644 index 00000000..85e215fb --- /dev/null +++ b/backend/tests/test_profile_health.py @@ -0,0 +1,439 @@ +"""Unit tests for redacted profile-health normalization and classification.""" + +from __future__ import annotations + +import pytest +from unittest.mock import AsyncMock + +from backend.profile_health import ( + ProfileHealthProbe, + classify_browserscan_text, + derive_authenticity_score, + is_trusted_proxychecker_url, + mask_ip_address, + normalize_proxychecker_response, + score_fingerprint_consistency, +) + + +class _FakeResponse: + def __init__(self, payload: object): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> object: + return self._payload + + +class _FakeHttpClient: + def __init__(self, payload: object): + self.payload = payload + self.posts: list[tuple[str, dict[str, object]]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def post(self, url: str, *, json: dict[str, object]): + self.posts.append((url, json)) + return _FakeResponse(self.payload) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("203.0.113.42", "203.0.113.x"), + ("2001:db8:abcd:12::1", "2001:db8:abcd:…"), + ("not-an-ip", None), + ("", None), + ], +) +def test_mask_ip_address_never_returns_complete_input(value: str, expected: str | None): + assert mask_ip_address(value) == expected + if expected is not None: + assert expected != value + + +@pytest.mark.parametrize( + ("risk", "expected"), + [(0, 100), (12.6, 87), (100, 0), (250, 0), (-10, 100), (None, None), (True, None)], +) +def test_derive_authenticity_score_is_clamped_and_explicitly_derived(risk: object, expected: int | None): + assert derive_authenticity_score(risk) == expected + + +@pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1:8899", + "http://[::1]:8899", + "http://localhost:8899", + "http://host.docker.internal:8899", + "https://proxychecker.internal:8899", + ], +) +def test_proxychecker_url_accepts_only_local_or_explicitly_allowed_hosts(url: str): + assert is_trusted_proxychecker_url(url, allowed_hosts={"proxychecker.internal"}) is True + + +@pytest.mark.parametrize( + "url", + [ + "https://example.com/check", + "http://user:password@127.0.0.1:8899", + "ftp://127.0.0.1/check", + "//127.0.0.1:8899", + "not-a-url", + "", + ], +) +def test_proxychecker_url_rejects_public_credentials_and_invalid_schemes(url: str): + assert is_trusted_proxychecker_url(url) is False + + +def test_normalize_proxychecker_response_keeps_only_safe_fields(): + raw = { + "results": [ + { + "proxy": "http://secret-user:secret-pass@proxy.example:8080", + "ok": True, + "latency_ms": 41.25, + "ip": "203.0.113.42", + "error": "provider said a secret thing", + } + ], + "scoring": { + "risk_score": 23.4, + "verdict_final": "DEGRADED", + "reasons": ["VPN provider signal", "unknown provider prose containing secret-value"], + }, + } + + result = normalize_proxychecker_response(raw) + + assert result.reachable is True + assert result.latency_ms == 41.25 + assert result.risk_score == 23 + assert result.authenticity_score == 77 + assert result.authenticity_source == "derived" + assert result.warnings == ("vpn_detected",) + assert result.blockers == () + assert "secret" not in repr(result) + assert "203.0.113.42" not in repr(result) + assert "proxy.example" not in repr(result) + + +def test_normalize_proxychecker_structured_reasons_and_explicit_authenticity(): + raw = { + "results": [{"ok": True, "latency_ms": 120}], + "scoring": { + "risk_score": 80, + "authenticity_score": 55, + "verdict_final": "block", + "reasons": [ + { + "code": "IPQS_PROXY_DETECTED", + "message": "IP is a known proxy server (IPQS).", + "feature": "ipqs_is_proxy", + }, + { + "code": "IPAPI_IS_DATACENTER", + "message": "IP is in a datacenter (ipapi.is).", + "feature": "ipapi_is_datacenter", + }, + ], + }, + } + + result = normalize_proxychecker_response(raw) + + assert result.risk_score == 80 + assert result.authenticity_score == 55 + assert result.authenticity_source == "measured" + assert result.warnings == ("datacenter_network", "proxy_detected", "proxy_risk_high") + + +def test_normalize_proxychecker_response_marks_malformed_payload_unavailable(): + result = normalize_proxychecker_response({"results": "not-a-list", "scoring": []}) + + assert result.reachable is None + assert result.risk_score is None + assert result.authenticity_score is None + assert result.blockers == ("proxychecker_invalid_response",) + + +def test_score_fingerprint_consistency_reports_matches_without_raw_values(): + config = { + "platform": "macos", + "screen_width": 390, + "screen_height": 844, + "timezone": "Europe/Vienna", + "locale": "de-AT", + "hardware_concurrency": 8, + "user_agent": "Mozilla/5.0 Safari/605.1.15", + } + runtime = { + "platform": "MacIntel", + "screen_width": 390, + "screen_height": 844, + "timezone": "Europe/Vienna", + "language": "de-AT", + "hardware_concurrency": 8, + "user_agent": "Mozilla/5.0 Safari/605.1.15", + } + + result = score_fingerprint_consistency(config, runtime) + + assert result.score == 100 + assert result.warnings == () + assert result.blockers == () + assert "Safari/605" not in repr(result) + assert "Europe/Vienna" not in repr(result) + + +def test_score_fingerprint_consistency_separates_mismatch_and_missing_signals(): + config = { + "platform": "windows", + "screen_width": 390, + "screen_height": 844, + "timezone": "Europe/Vienna", + "locale": "de-AT", + "hardware_concurrency": 8, + "user_agent": "Mozilla/5.0 Chrome/126.0.0.0", + } + runtime = { + "platform": "Linux x86_64", + "screen_width": 1920, + "screen_height": 1080, + "timezone": "UTC", + "language": "en-US", + "hardware_concurrency": None, + "user_agent": "Mozilla/5.0 Firefox/128.0", + } + + result = score_fingerprint_consistency(config, runtime) + + assert result.score == 0 + assert result.warnings == ( + "platform_mismatch", + "screen_mismatch", + "timezone_mismatch", + "locale_mismatch", + "user_agent_family_mismatch", + ) + assert result.blockers == ("hardware_concurrency_missing",) + + +def test_classify_browserscan_text_extracts_only_score_and_whitelisted_warnings(): + text = """ + BrowserScan report + Authenticity 94% + WebRTC network mismatch detected + Canvas fingerprint warning + account-id: private-12345 + """ + + result = classify_browserscan_text(text) + + assert result.score == 94 + assert result.warnings == ("network_mismatch", "fingerprint_warning") + assert result.blockers == () + assert "private-12345" not in repr(result) + + +def test_classify_browserscan_text_does_not_treat_field_labels_as_detection(): + result = classify_browserscan_text( + "BrowserScan report Authenticity 100% WebDriver WebRTC Canvas WebGL Audio" + ) + + assert result.score == 100 + assert result.warnings == () + assert result.blockers == () + + +def test_classify_browserscan_text_requires_an_explicit_automation_failure(): + result = classify_browserscan_text( + "BrowserScan report Authenticity 72% WebDriver detected" + ) + + assert result.score == 72 + assert result.warnings == ("automation_detected",) + assert result.blockers == () + + +@pytest.mark.parametrize( + ("text", "blocker"), + [ + ("Verify you are human. Authenticity 99%", "browser_scan_challenge"), + ("Cookie consent: Accept all cookies. Authenticity 99%", "browser_scan_consent"), + ("BrowserScan dashboard loaded without a score", "browser_scan_score_missing"), + ], +) +def test_classify_browserscan_text_fails_closed(text: str, blocker: str): + result = classify_browserscan_text(text) + + assert result.score is None + assert result.warnings == () + assert result.blockers == (blocker,) + + +@pytest.mark.asyncio +async def test_profile_health_probe_measures_existing_context_and_closes_temporary_pages(): + network_page = AsyncMock() + network_page.text_content = AsyncMock(return_value='{"ip":"203.0.113.42"}') + network_page.evaluate = AsyncMock( + return_value={ + "platform": "MacIntel", + "screen_width": 390, + "screen_height": 844, + "timezone": "Europe/Vienna", + "language": "de-AT", + "hardware_concurrency": 8, + "user_agent": "Mozilla/5.0 Safari/605.1.15", + } + ) + browserscan_page = AsyncMock() + browserscan_page.text_content = AsyncMock(return_value="Authenticity 98%") + context = AsyncMock() + context.new_page = AsyncMock(side_effect=[network_page, browserscan_page]) + running = type("Running", (), {"context": context})() + client = _FakeHttpClient( + { + "results": [{"ok": True, "latency_ms": 22.5, "ip": "203.0.113.42"}], + "scoring": {"risk_score": 5, "verdict_final": "GOOD", "reasons": []}, + } + ) + ticks = iter([10.0, 10.125]) + probe = ProfileHealthProbe( + proxychecker_url="http://127.0.0.1:8899", + http_client_factory=lambda **_kwargs: client, + monotonic=lambda: next(ticks), + now=lambda: "2026-07-22T12:00:00+00:00", + ) + profile = { + "id": "profile-1", + "proxy": "http://secret-user:secret-password@proxy.example:8080", + "platform": "macos", + "screen_width": 390, + "screen_height": 844, + "timezone": "Europe/Vienna", + "locale": "de-AT", + "hardware_concurrency": 8, + "user_agent": "Mozilla/5.0 Safari/605.1.15", + } + + result = await probe.run(profile, running) + + assert result.state == "passed" + assert result.checked_at == "2026-07-22T12:00:00+00:00" + assert result.proxy_configured is True + assert result.proxy_reachable is True + assert result.outbound_ip_masked == "203.0.113.x" + assert result.proxy_latency_ms == 22.5 + assert result.proxy_risk_score == 5 + assert result.proxy_authenticity_score == 95 + assert result.fingerprint_consistency_score == 100 + assert result.browser_scan_score == 98 + assert result.sources == { + "browser_network": "measured", + "browser_scan": "measured", + "fingerprint_consistency": "measured", + "proxy_authenticity": "derived", + "proxychecker": "measured", + } + assert result.warnings == () + assert result.blockers == () + assert "secret-password" not in repr(result) + assert "proxy.example" not in repr(result) + network_page.close.assert_awaited_once() + browserscan_page.close.assert_awaited_once() + assert not any(call[0] == "click" for call in browserscan_page.method_calls) + assert client.posts[0][0] == "http://127.0.0.1:8899/check" + + +@pytest.mark.asyncio +async def test_profile_health_probe_keeps_partial_measurement_when_external_sources_are_unavailable(): + network_page = AsyncMock() + network_page.goto = AsyncMock(side_effect=TimeoutError("private upstream detail")) + network_page.evaluate = AsyncMock( + return_value={ + "platform": "Win32", + "screen_width": 390, + "screen_height": 844, + "timezone": "UTC", + "language": "en-US", + "hardware_concurrency": 4, + "user_agent": "Mozilla/5.0 Chrome/126.0.0.0", + } + ) + browserscan_page = AsyncMock() + browserscan_page.goto = AsyncMock(side_effect=ConnectionError("private provider detail")) + context = AsyncMock() + context.new_page = AsyncMock(side_effect=[network_page, browserscan_page]) + running = type("Running", (), {"context": context})() + ticks = iter([20.0, 20.1]) + probe = ProfileHealthProbe( + monotonic=lambda: next(ticks), + now=lambda: "2026-07-22T12:05:00+00:00", + ) + profile = { + "id": "profile-2", + "proxy": None, + "platform": "windows", + "screen_width": 390, + "screen_height": 844, + "timezone": "UTC", + "locale": "en-US", + "hardware_concurrency": 4, + "user_agent": "Mozilla/5.0 Chrome/126.0.0.0", + } + + result = await probe.run(profile, running) + + assert result.state == "warning" + assert result.proxy_configured is False + assert result.proxy_reachable is False + assert result.fingerprint_consistency_score == 100 + assert result.browser_scan_score is None + assert result.error_code == "network_timeout" + assert result.sources == { + "browser_network": "unavailable", + "browser_scan": "unavailable", + "fingerprint_consistency": "measured", + "proxychecker": "skipped", + } + assert result.blockers == ("network_timeout", "browser_scan_unavailable") + assert "private" not in repr(result) + network_page.close.assert_awaited_once() + browserscan_page.close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_profile_health_probe_returns_unavailable_when_no_component_can_be_measured(): + context = AsyncMock() + context.new_page = AsyncMock(side_effect=RuntimeError("private context detail")) + running = type("Running", (), {"context": context})() + probe = ProfileHealthProbe(now=lambda: "2026-07-22T12:10:00+00:00") + + result = await probe.run({"id": "profile-3", "proxy": None}, running) + + assert result.state == "unavailable" + assert result.error_code == "browser_context_unavailable" + assert result.blockers == ( + "browser_context_unavailable", + "browser_scan_unavailable", + ) + assert result.sources["browser_network"] == "unavailable" + assert result.sources["fingerprint_consistency"] == "unavailable" + assert result.sources["browser_scan"] == "unavailable" + assert result.sources["proxychecker"] == "skipped" + assert "private context detail" not in repr(result) + + +def test_profile_health_probe_rejects_untrusted_proxychecker_url(): + with pytest.raises(ValueError, match="trusted local endpoint"): + ProfileHealthProbe(proxychecker_url="https://example.com/check") diff --git a/backend/tests/test_profile_health_api.py b/backend/tests/test_profile_health_api.py new file mode 100644 index 00000000..b0fdc5b5 --- /dev/null +++ b/backend/tests/test_profile_health_api.py @@ -0,0 +1,263 @@ +"""API and scheduler tests for stored, scoped profile health.""" + +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from starlette.testclient import TestClient + +from backend import database as db +from backend.profile_health import ProfileHealthResult + + +@pytest.fixture() +def health_client(tmp_db, monkeypatch): + from backend import main + + monkeypatch.setattr(main, "AUTH_TOKEN", "bootstrap-health-secret") + monkeypatch.setattr(main, "ACCESS_CONTROL_ENABLED", True) + main._login_failures.clear() + main.browser_mgr.running.clear() + main._profile_health_tasks.clear() + monkeypatch.setattr(main.browser_mgr, "cleanup_stale", AsyncMock()) + monkeypatch.setattr(main.browser_mgr, "cleanup_all", AsyncMock()) + monkeypatch.setattr(main.browser_mgr.vnc, "cleanup_stale", AsyncMock()) + with TestClient(main.app) as client: + yield client + main.browser_mgr.running.clear() + main._profile_health_tasks.clear() + + +def _bootstrap_headers() -> dict[str, str]: + return {"Authorization": "Bearer bootstrap-health-secret"} + + +def _create_user( + client: TestClient, + username: str, + sandbox_id: str, + permission: str, +) -> str: + password = f"{username}-password-123" + response = client.post( + "/api/access/users", + headers=_bootstrap_headers(), + json={ + "username": username, + "password": password, + "grants": [{"sandbox_id": sandbox_id, "permission": permission}], + }, + ) + assert response.status_code == 201 + return password + + +def _login(client: TestClient, username: str, password: str) -> None: + client.cookies.clear() + response = client.post( + "/api/auth/login", + json={"username": username, "password": password}, + ) + assert response.status_code == 200 + + +def test_profile_health_read_defaults_to_explicit_unavailable(app_client: TestClient): + profile = db.create_profile("No health yet") + + response = app_client.get(f"/api/profiles/{profile['id']}/health") + + assert response.status_code == 200 + assert response.json() == { + "profile_id": profile["id"], + "state": "unavailable", + "checked_at": None, + "proxy_configured": False, + "proxy_reachable": None, + "outbound_ip_masked": None, + "proxy_latency_ms": None, + "proxy_risk_score": None, + "proxy_authenticity_score": None, + "fingerprint_consistency_score": None, + "browser_scan_score": None, + "warnings": [], + "blockers": [], + "error_code": None, + "sources": {}, + } + + +def test_profile_health_manual_run_requires_running_profile(app_client: TestClient): + profile = db.create_profile("Stopped") + + response = app_client.post(f"/api/profiles/{profile['id']}/health/run") + + assert response.status_code == 409 + assert response.json()["detail"] == "Profile is not running" + + +def test_profile_health_is_scoped_and_run_requires_operate( + health_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend import main + + alpha = db.create_profile( + "Alpha", + sandbox_id="alpha", + proxy="http://secret-user:secret-password@proxy.example:8080", + ) + beta = db.create_profile("Beta", sandbox_id="beta") + db.upsert_profile_health( + alpha["id"], + state="warning", + checked_at="2026-07-22T12:00:00+00:00", + proxy_configured=True, + proxy_reachable=True, + outbound_ip_masked="203.0.113.x", + proxy_latency_ms=31.0, + fingerprint_consistency_score=80, + warnings=["timezone_mismatch"], + blockers=[], + sources={"browser_network": "measured", "fingerprint_consistency": "measured"}, + ) + + viewer_password = _create_user(health_client, "health-viewer", "alpha", "view") + _login(health_client, "health-viewer", viewer_password) + visible = health_client.get(f"/api/profiles/{alpha['id']}/health") + assert visible.status_code == 200 + assert visible.json()["outbound_ip_masked"] == "203.0.113.x" + assert health_client.get(f"/api/profiles/{beta['id']}/health").status_code == 404 + assert health_client.get("/api/profiles/missing/health").status_code == 404 + assert health_client.post(f"/api/profiles/{alpha['id']}/health/run").status_code == 404 + serialized = json.dumps(visible.json()) + assert "secret-password" not in serialized + assert "proxy.example" not in serialized + + operator_password = _create_user(health_client, "health-operator", "alpha", "operate") + _login(health_client, "health-operator", operator_password) + running = SimpleNamespace(context=AsyncMock()) + main.browser_mgr.running[alpha["id"]] = running + + scheduled = MagicMock() + + def _fake_schedule(profile, active, *, force): + scheduled(profile, active, force=force) + db.upsert_profile_health( + profile["id"], + state="pending", + proxy_configured=True, + warnings=[], + blockers=[], + sources={}, + ) + return None + + monkeypatch.setattr(main, "_schedule_profile_health", _fake_schedule) + accepted = health_client.post(f"/api/profiles/{alpha['id']}/health/run") + + assert accepted.status_code == 202 + assert accepted.json()["state"] == "pending" + scheduled.assert_called_once_with(alpha, running, force=True) + assert health_client.post(f"/api/profiles/{beta['id']}/health/run").status_code == 404 + + +@pytest.mark.asyncio +async def test_profile_health_scheduler_reuses_inflight_task_and_suppresses_repeat( + tmp_db, + monkeypatch: pytest.MonkeyPatch, +): + from backend import main + + main._profile_health_tasks.clear() + profile = db.create_profile("Scheduled", proxy="http://proxy.example:8080") + running = SimpleNamespace(context=AsyncMock()) + started = asyncio.Event() + release = asyncio.Event() + + async def _run(_profile, _running): + started.set() + await release.wait() + return ProfileHealthResult( + state="passed", + checked_at="2026-07-22T12:15:00+00:00", + proxy_configured=True, + proxy_reachable=True, + outbound_ip_masked="203.0.113.x", + proxy_latency_ms=20.0, + proxy_risk_score=5, + proxy_authenticity_score=95, + fingerprint_consistency_score=100, + browser_scan_score=99, + warnings=(), + blockers=(), + error_code=None, + sources={"browser_network": "measured"}, + ) + + run_mock = AsyncMock(side_effect=_run) + monkeypatch.setattr(main.profile_health_probe, "run", run_mock) + + first = main._schedule_profile_health(profile, running, force=False) + assert first is not None + await started.wait() + second = main._schedule_profile_health(profile, running, force=True) + assert second is first + assert db.get_profile_health(profile["id"])["state"] in {"pending", "running"} + + release.set() + await first + + stored = db.get_profile_health(profile["id"]) + assert stored["state"] == "passed" + assert stored["outbound_ip_masked"] == "203.0.113.x" + assert run_mock.await_count == 1 + assert main._schedule_profile_health(profile, running, force=False) is None + + +@pytest.mark.asyncio +async def test_profile_health_probe_failure_is_stored_without_raw_exception( + tmp_db, + monkeypatch: pytest.MonkeyPatch, +): + from backend import main + + main._profile_health_tasks.clear() + profile = db.create_profile("Failing health") + running = SimpleNamespace(context=AsyncMock()) + monkeypatch.setattr( + main.profile_health_probe, + "run", + AsyncMock(side_effect=RuntimeError("secret proxy credential in exception")), + ) + + task = main._schedule_profile_health(profile, running, force=False) + assert task is not None + await task + + stored = db.get_profile_health(profile["id"]) + assert stored["state"] == "failed" + assert stored["error_code"] == "profile_health_probe_failed" + assert stored["blockers"] == ["profile_health_probe_failed"] + assert "secret" not in json.dumps(stored) + + +def test_successful_launch_schedules_health_without_waiting_for_probe( + app_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend import main + + profile = db.create_profile("Launch health") + running = SimpleNamespace(ws_port=6100, display=100, context=AsyncMock()) + monkeypatch.setattr(main.browser_mgr, "launch", AsyncMock(return_value=running)) + schedule = MagicMock() + monkeypatch.setattr(main, "_schedule_profile_health", schedule) + + response = app_client.post(f"/api/profiles/{profile['id']}/launch") + + assert response.status_code == 200 + schedule.assert_called_once_with(profile, running, force=False) diff --git a/backend/tests/test_proxy_inventory.py b/backend/tests/test_proxy_inventory.py new file mode 100644 index 00000000..1f9db7ed --- /dev/null +++ b/backend/tests/test_proxy_inventory.py @@ -0,0 +1,88 @@ +"""Tests for proxy inventory parse/redact/geo defaults.""" + +from __future__ import annotations + +import pytest + +from backend.proxy_inventory import ( + ProxyParseError, + build_auto_profile_defaults, + extract_country_code, + parse_proxy_line, + proxy_fingerprint, + redact_proxy_url, + summarize_check_payload, +) + + +def test_parse_host_port_user_pass_line(): + url = parse_proxy_line("10.0.0.5:8080:alice:s3cret") + assert url == "http://alice:s3cret@10.0.0.5:8080" + assert "s3cret" in url # stored form may keep password; API must redact + + +def test_parse_rejects_empty_and_invalid(): + with pytest.raises(ProxyParseError): + parse_proxy_line("") + with pytest.raises(ProxyParseError): + parse_proxy_line("# comment") + with pytest.raises(ProxyParseError): + parse_proxy_line("not-a-proxy") + + +def test_redact_masks_host_and_username_without_password(): + redacted = redact_proxy_url("http://alice:top-secret@84.55.0.94:5432") + assert redacted["host_masked"] == "84.55.x.x" + assert redacted["port"] == 5432 + assert redacted["username_masked"] == "a***e" + assert redacted["has_credentials"] is True + blob = str(redacted) + assert "top-secret" not in blob + assert "alice" not in blob + assert "84.55.0.94" not in blob + + +def test_fingerprint_is_stable_and_non_reversible(): + a = proxy_fingerprint("http://u:p@10.0.0.1:1") + b = proxy_fingerprint("http://u:p@10.0.0.1:1") + c = proxy_fingerprint("http://u:p@10.0.0.1:2") + assert a == b + assert a != c + assert "10.0.0.1" not in a + + +def test_auto_profile_defaults_enable_geoip_and_align_locale(): + defaults = build_auto_profile_defaults( + proxy_url="http://u:p@10.0.0.1:8080", + country_code="AT", + harness="browser-use", + ) + assert defaults["geoip"] is True + assert defaults["timezone"] == "Europe/Vienna" + assert defaults["locale"] == "de-AT" + assert defaults["harness"] == "browser-use" + assert defaults["proxy"] == "http://u:p@10.0.0.1:8080" + assert defaults["folder_path"] == "auto" + assert defaults["project_id"] == "proxied" + + +def test_extract_country_and_summarize_check_payload(): + payload = { + "results": [ + { + "ok": True, + "latency_ms": 42.5, + "enrichment": {"country_code": "de"}, + } + ], + "scoring": {"risk_score": 12, "reasons": []}, + } + assert extract_country_code(payload) == "DE" + summary = summarize_check_payload(payload) + assert summary["reachable"] is True + assert summary["latency_ms"] == 42.5 + assert summary["risk_score"] == 12 + assert summary["country_code"] == "DE" + assert summary["timezone_hint"] == "Europe/Berlin" + assert summary["check_state"] == "passed" + assert "password" not in str(summary) diff --git a/backend/tests/test_proxy_inventory_api.py b/backend/tests/test_proxy_inventory_api.py new file mode 100644 index 00000000..ba88c144 --- /dev/null +++ b/backend/tests/test_proxy_inventory_api.py @@ -0,0 +1,95 @@ +"""API tests for proxy inventory ingest, check, and auto profile creation.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + + +@pytest.fixture() +def admin_client(app_client, monkeypatch: pytest.MonkeyPatch): + """Authenticate as bootstrap admin for access-controlled routes.""" + monkeypatch.setenv("AUTH_TOKEN", "test-admin-token-0123456789abcdef") + # Re-import-sensitive flags are already set at import; patch module values. + from backend import main + + monkeypatch.setattr(main, "AUTH_TOKEN", "test-admin-token-0123456789abcdef") + monkeypatch.setattr(main, "ACCESS_CONTROL_ENABLED", True) + app_client.cookies.set("auth_token", "test-admin-token-0123456789abcdef") + return app_client + + +def test_ingest_and_list_proxies_redacted(admin_client, monkeypatch: pytest.MonkeyPatch): + from backend import main + + monkeypatch.setattr(main, "AUTH_TOKEN", "test-admin-token-0123456789abcdef") + monkeypatch.setattr(main, "ACCESS_CONTROL_ENABLED", True) + + response = admin_client.post( + "/api/proxies/ingest", + json={"lines": ["10.0.0.8:8080:alice:top-secret", "bad-line", "10.0.0.9:8080:bob:other-secret"]}, + ) + assert response.status_code == 201, response.text + payload = response.json() + assert payload["created"] == 2 + assert payload["rejected"] == 1 + body = response.text + assert "top-secret" not in body + assert "other-secret" not in body + assert "10.0.0.8" not in body + + listed = admin_client.get("/api/proxies") + assert listed.status_code == 200 + items = listed.json() + assert len(items) == 2 + assert all(item["host_masked"].endswith(".x.x") for item in items) + assert "proxy_url" not in items[0] + + +def test_create_profile_from_proxy_auto_aligns(admin_client, monkeypatch: pytest.MonkeyPatch): + from backend import main + + monkeypatch.setattr(main, "AUTH_TOKEN", "test-admin-token-0123456789abcdef") + monkeypatch.setattr(main, "ACCESS_CONTROL_ENABLED", True) + monkeypatch.setattr( + main, + "_proxychecker_check", + AsyncMock( + return_value={ + "reachable": True, + "latency_ms": 20.0, + "risk_score": 5, + "authenticity_score": 95, + "country_code": "AT", + "timezone_hint": "Europe/Vienna", + "locale_hint": "de-AT", + "warnings": [], + "blockers": [], + "check_state": "passed", + } + ), + ) + + ingest = admin_client.post( + "/api/proxies/ingest", + json={"lines": ["10.1.2.3:9050:userx:passx"]}, + ) + assert ingest.status_code == 201 + proxy_id = ingest.json()["items"][0]["id"] + + created = admin_client.post( + f"/api/proxies/{proxy_id}/profiles", + json={"harness": "browser-use", "project_id": "proxied", "name": "Auto AT"}, + ) + assert created.status_code == 201, created.text + profile = created.json() + assert profile["name"] == "Auto AT" + assert profile["project_id"] == "proxied" + assert profile["folder_path"] == "auto" + assert profile["harness"] == "browser-use" + assert profile["timezone"] == "Europe/Vienna" + assert profile["locale"] == "de-AT" + assert profile["geoip"] is True + # Admin may see proxy; ensure password is not echoed in JSON if redacted later. + assert "passx" not in created.text or profile.get("proxy") is not None diff --git a/backend/tests/test_proxy_inventory_db.py b/backend/tests/test_proxy_inventory_db.py new file mode 100644 index 00000000..10454c92 --- /dev/null +++ b/backend/tests/test_proxy_inventory_db.py @@ -0,0 +1,54 @@ +"""Database tests for proxy inventory persistence and redaction.""" + +from __future__ import annotations + +from backend import database as db +from backend.proxy_inventory import parse_proxy_line, redact_proxy_url + + +def test_init_db_creates_proxy_inventory(tmp_db): + with db.get_db() as conn: + names = { + row["name"] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + } + assert "proxy_inventory" in names + + +def test_upsert_list_and_check_never_return_secret(tmp_db): + proxy_url = parse_proxy_line("10.20.30.40:8080:alice:super-secret") + redacted = redact_proxy_url(proxy_url) + row = db.upsert_proxy_inventory_entry(proxy_url, redacted=redacted) + assert "proxy_url" not in row + assert "super-secret" not in str(row) + assert row["host_masked"] == "10.20.x.x" + assert row["username_masked"] == "a***e" + + listed = db.list_proxy_inventory() + assert len(listed) == 1 + assert "proxy_url" not in listed[0] + assert "super-secret" not in str(listed) + + updated = db.update_proxy_inventory_check( + row["id"], + { + "check_state": "passed", + "reachable": True, + "latency_ms": 12.5, + "risk_score": 8, + "authenticity_score": 92, + "country_code": "DE", + "timezone_hint": "Europe/Berlin", + "locale_hint": "de-DE", + "warnings": [], + "blockers": [], + }, + ) + assert updated is not None + assert updated["check_state"] == "passed" + assert updated["country_code"] == "DE" + assert "proxy_url" not in updated + + secret = db.get_proxy_inventory_entry(row["id"], include_secret=True) + assert secret is not None + assert secret["proxy_url"] == proxy_url diff --git a/backend/tests/test_rfb.py b/backend/tests/test_rfb.py index 010b04ba..2d8243d0 100644 --- a/backend/tests/test_rfb.py +++ b/backend/tests/test_rfb.py @@ -5,6 +5,7 @@ import struct from backend.main import ( + _RfbServerStreamFilter, _build_server_cut_text, _filter_rfb_client_messages, _parse_kasmvnc_clipboard, @@ -67,6 +68,39 @@ def _make_kasmvnc_clipboard(mime: str, data: str) -> bytes: return bytes(buf) +def _make_server_init( + *, width: int = 768, height: int = 1024, name: bytes = b"Test desktop" +) -> bytes: + pixel_format = struct.pack( + ">BBBBHHHBBBxxx", + 32, # bits per pixel + 24, # depth + 0, # little endian + 1, # true colour + 255, + 255, + 255, + 16, + 8, + 0, + ) + return ( + struct.pack(">HH", width, height) + + pixel_format + + struct.pack(">I", len(name)) + + name + ) + + +def _make_server_handshake() -> bytes: + return ( + b"RFB 003.008\n" + + b"\x01\x01" # one advertised security type: None + + b"\0\0\0\0" # successful SecurityResult + + _make_server_init() + ) + + # ── _parse_kasmvnc_clipboard ──────────────────────────────────────────────── @@ -138,6 +172,101 @@ def test_build_cut_text_latin1_fallback(): assert text_bytes.startswith(b"hello ") +def test_viewer_drops_standard_and_kasm_server_clipboard(): + standard = _build_server_cut_text("viewer-secret") + kasm = _make_kasmvnc_clipboard("text/plain", "viewer-secret") + standard_filter = _RfbServerStreamFilter( + can_interact=False, handshake_complete=True + ) + kasm_filter = _RfbServerStreamFilter( + can_interact=False, handshake_complete=True + ) + + assert standard_filter.filter(standard) == b"" + assert standard_filter.last_dropped_clipboard is True + assert kasm_filter.filter(kasm) == b"" + assert kasm_filter.last_dropped_clipboard is True + + +def test_interactor_receives_standardized_kasm_server_clipboard(): + kasm = _make_kasmvnc_clipboard("text/plain", "operator-copy") + rfb_filter = _RfbServerStreamFilter(can_interact=True) + + assert rfb_filter.filter(kasm) == _build_server_cut_text("operator-copy") + + +def test_viewer_preserves_fragmented_server_handshake(): + handshake = _make_server_handshake() + rfb_filter = _RfbServerStreamFilter(can_interact=False) + chunks = ( + handshake[:5], + handshake[5:13], + handshake[13:18], + handshake[18:31], + handshake[31:], + ) + + assert b"".join(rfb_filter.filter(chunk) for chunk in chunks) == handshake + + +def test_viewer_does_not_mistake_server_init_for_clipboard(): + # A 768px-wide ServerInit starts with byte 3, the standard ServerCutText + # message type. Handshake state disambiguates it from normal messages. + handshake = _make_server_handshake() + rfb_filter = _RfbServerStreamFilter(can_interact=False) + + assert rfb_filter.filter(handshake) == handshake + + +def test_viewer_filters_clipboard_coalesced_between_display_messages(): + framebuffer_update = b"\0\0\0\0" # zero-rectangle update + clipboard = _build_server_cut_text("viewer-secret") + bell = b"\x02" + rfb_filter = _RfbServerStreamFilter( + can_interact=False, handshake_complete=True + ) + + assert rfb_filter.filter(framebuffer_update + clipboard + bell) == ( + framebuffer_update + bell + ) + assert rfb_filter.last_dropped_clipboard is True + + +def test_viewer_filters_clipboard_split_across_websocket_chunks(): + framebuffer_update = b"\0\0\0\0" # zero-rectangle update + clipboard = _build_server_cut_text("viewer-secret") + bell = b"\x02" + rfb_filter = _RfbServerStreamFilter( + can_interact=False, handshake_complete=True + ) + + assert rfb_filter.filter(framebuffer_update + clipboard[:5]) == framebuffer_update + assert rfb_filter.last_dropped_clipboard is False + assert rfb_filter.filter(clipboard[5:] + bell) == bell + assert rfb_filter.last_dropped_clipboard is True + + +def test_viewer_preserves_fragmented_zrle_framebuffer_around_clipboard(): + compressed = b"test-zrle-payload" + framebuffer_update = ( + struct.pack(">BBH", 0, 0, 1) + + struct.pack(">HHHHi", 0, 0, 64, 32, 16) + + struct.pack(">I", len(compressed)) + + compressed + ) + clipboard = _build_server_cut_text("viewer-secret") + bell = b"\x02" + rfb_filter = _RfbServerStreamFilter( + can_interact=False, handshake_complete=True + ) + + assert rfb_filter.filter(framebuffer_update[:19]) == b"" + assert rfb_filter.filter(framebuffer_update[19:] + clipboard + bell) == ( + framebuffer_update + bell + ) + assert rfb_filter.last_dropped_clipboard is True + + # ── _rfb_msg_length ───────────────────────────────────────────────────────── diff --git a/backend/tests/test_session_links.py b/backend/tests/test_session_links.py new file mode 100644 index 00000000..442eff79 --- /dev/null +++ b/backend/tests/test_session_links.py @@ -0,0 +1,91 @@ +"""Unit tests for steel-adapted local/cloud session open links.""" + +from __future__ import annotations + +from backend import session_links + + +def test_join_url_avoids_double_slash(): + assert session_links.join_url("http://127.0.0.1:18117/", "/api/x") == "http://127.0.0.1:18117/api/x" + assert session_links.join_url("http://127.0.0.1:18117", "api/x") == "http://127.0.0.1:18117/api/x" + + +def test_request_base_url_prefers_forwarded_headers(): + base = session_links.request_base_url( + scheme="http", + host="127.0.0.1:18117", + forwarded_proto="https", + forwarded_host="manager.example.ts.net", + ) + assert base == "https://manager.example.ts.net" + + +def test_build_session_open_links_local_and_cloud(monkeypatch): + monkeypatch.setenv("CLOUD_BASE_URL", "https://cloak.example.ts.net") + payload = session_links.build_session_open_links( + "prof-1", + local_base="http://127.0.0.1:18117", + include_cdp=True, + prefer="cloud", + mode="cdp", + ) + assert payload["prefer"] == "cloud" + assert payload["mode"] == "cdp" + assert payload["open_url"] == "https://cloak.example.ts.net/session/prof-1/live" + assert payload["local"]["vnc_ws_url"] == "ws://127.0.0.1:18117/api/profiles/prof-1/vnc" + assert payload["local"]["vnc_fullscreen_url"].endswith("&view=vnc&fullscreen=1") + assert payload["cloud"]["cdp_http_url"] == "https://cloak.example.ts.net/api/profiles/prof-1/cdp" + assert payload["cloud"]["cdp_ws_url"] == "wss://cloak.example.ts.net/api/profiles/prof-1/cdp" + assert payload["cloud"]["live_url"] == "https://cloak.example.ts.net/session/prof-1/live" + assert payload["local"]["debugger_url"].endswith("/cdp/json/version") + + +def test_build_session_open_links_falls_back_when_cloud_missing(monkeypatch): + monkeypatch.delenv("CLOUD_BASE_URL", raising=False) + monkeypatch.delenv("PUBLIC_BASE_URL", raising=False) + payload = session_links.build_session_open_links( + "prof-2", + local_base="http://127.0.0.1:18117", + include_cdp=False, + prefer="cloud", + mode="cdp", + ) + assert payload["prefer"] == "local" + assert payload["cloud"] is None + assert payload["local"]["cdp_http_url"] is None + # Without CDP permission, mode falls back to VNC fullscreen. + assert payload["mode"] == "vnc" + assert payload["open_url"] == "http://127.0.0.1:18117/?profile=prof-2&view=vnc&fullscreen=1" + + +def test_catalog_endpoint_map_stable(): + endpoints = session_links.catalog_endpoint_map() + assert endpoints["catalog"] == "/api/extension/catalog" + assert endpoints["open_session"] == "/api/extension/sessions/open" + assert endpoints["defaults"] == "/api/extension/defaults" + assert endpoints["templates"] == "/api/extension/templates" + assert endpoints["list_proxies"] == "/api/proxies" + assert endpoints["live_metrics"] == "/api/profiles/{profile_id}/live-metrics" + + +def test_extension_profile_summary_redacts_proxy_secret(): + summary = session_links.extension_profile_summary( + { + "id": "p1", + "name": "Alpha", + "proxy": "http://user:secret@10.0.0.1:8080", + "project_id": "proxied", + "folder_path": "auto", + "sandbox_id": "default", + "harness": "browser-use", + "pinned": True, + "timezone": "Europe/Vienna", + "locale": "de-AT", + }, + status="running", + running=True, + ) + assert summary["proxy_configured"] is True + assert "secret" not in str(summary) + assert "10.0.0.1" not in str(summary) + assert summary["running"] is True diff --git a/backend/tests/test_session_views.py b/backend/tests/test_session_views.py new file mode 100644 index 00000000..d82e0466 --- /dev/null +++ b/backend/tests/test_session_views.py @@ -0,0 +1,42 @@ +"""Tests for CDP live HTML viewer (screencast keep-alive).""" + +from __future__ import annotations + +from backend import session_views + + +def test_cdp_live_html_uses_screencast_with_compositor_pulse(): + html = session_views.render_cdp_live_html( + profile_id="prof-1", + profile_name="Demo", + cdp_ws_url="ws://127.0.0.1:18117/api/profiles/prof-1/cdp", + metrics_url="http://127.0.0.1:18117/api/profiles/prof-1/live-metrics", + interactive=True, + cdp_list_url="http://127.0.0.1:18117/api/profiles/prof-1/cdp/json/list", + ) + assert "Page.startScreencast" in html + assert "Page.screencastFrameAck" in html + assert "injectCompositorPulse" in html + assert "__cbm_live_pulse" in html + assert "createElement('canvas')" in html + assert "createImageBitmap" in html + assert "live · CDP cast" in html + assert "cdpListUrl" in html + assert "Page.frameNavigated" in html + assert "setInterval(() => { injectCompositorPulse()" in html + # Screenshot remains emergency fallback only. + assert "Page.captureScreenshot" in html + assert "startScreenshotFallback" in html + assert session_views.cdp_fullscreen_path("prof-1") == "/session/prof-1/live" + + +def test_cdp_live_html_escapes_profile_name(): + html = session_views.render_cdp_live_html( + profile_id="p", + profile_name='', + cdp_ws_url="ws://example/cdp", + metrics_url="http://example/metrics", + interactive=False, + ) + assert "" not in html + assert "<script>alert(1)</script>" in html diff --git a/backend/tests/test_task_sessions_api.py b/backend/tests/test_task_sessions_api.py new file mode 100644 index 00000000..9a40865c --- /dev/null +++ b/backend/tests/test_task_sessions_api.py @@ -0,0 +1,269 @@ +"""API tests for sandbox-scoped browser task sessions.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from starlette.testclient import TestClient + +from backend import database as db + + +@pytest.fixture() +def client_access(tmp_db, monkeypatch): + from backend import main + + monkeypatch.setattr(main, "AUTH_TOKEN", "bootstrap-test-secret") + monkeypatch.setattr(main, "ACCESS_CONTROL_ENABLED", True) + main._login_failures.clear() + monkeypatch.setattr(main.browser_mgr, "cleanup_stale", AsyncMock()) + monkeypatch.setattr(main.browser_mgr, "cleanup_all", AsyncMock()) + monkeypatch.setattr(main.browser_mgr.vnc, "cleanup_stale", AsyncMock()) + with TestClient(main.app) as client: + yield client + + +def bootstrap_headers() -> dict[str, str]: + return {"Authorization": "Bearer bootstrap-test-secret"} + + +def create_user( + client: TestClient, + username: str, + sandbox_id: str, + permission: str, +) -> str: + password = f"{username}-password-123" + response = client.post( + "/api/access/users", + headers=bootstrap_headers(), + json={ + "username": username, + "password": password, + "grants": [{"sandbox_id": sandbox_id, "permission": permission}], + }, + ) + assert response.status_code == 201 + return password + + +def login(client: TestClient, username: str, password: str) -> None: + client.cookies.clear() + response = client.post( + "/api/auth/login", + json={"username": username, "password": password}, + ) + assert response.status_code == 200 + + +def test_task_session_messages_persist_commands_and_never_fake_assistant( + client_access: TestClient, +): + profile = db.create_profile("Alpha browser", sandbox_id="alpha") + password = create_user(client_access, "alpha-operator", "alpha", "interact") + login(client_access, "alpha-operator", password) + + created = client_access.post( + "/api/task-sessions", + json={ + "profile_id": profile["id"], + "metadata": {"source": "mobile", "api_token": "secret-token"}, + }, + ) + assert created.status_code == 201 + session = created.json() + assert session["profile_id"] == profile["id"] + assert session["sandbox_id"] == "alpha" + assert session["metadata"]["api_token"] == "[redacted]" + + posted = client_access.post( + f"/api/task-sessions/{session['id']}/messages", + json={ + "text": "Copy the visible result", + "profile_id": profile["id"], + "commands": [ + { + "id": "copy-visible", + "label": "Copy visible result", + "kind": "copy", + "scope": "host", + "args": {"format": "text"}, + } + ], + "metadata": {"password": "do-not-store", "client": "mobile"}, + }, + ) + assert posted.status_code == 201 + message = posted.json() + assert message["role"] == "user" + assert message["content"] == "Copy the visible result" + assert message["metadata"]["password"] == "[redacted]" + assert message["metadata"]["commands"][0]["kind"] == "copy" + assert message["metadata"]["commands"][0]["scope"] == "host" + + history = client_access.get(f"/api/task-sessions/{session['id']}/messages") + assert history.status_code == 200 + assert history.json() == [message] + assert all(item["role"] != "assistant" for item in history.json()) + + events = client_access.get(f"/api/task-sessions/{session['id']}/events") + assert events.status_code == 200 + appended = [event for event in events.json() if event["type"] == "task_message.appended"][0] + assert appended["payload"]["command_count"] == 1 + assert appended["payload"]["host_command_count"] == 1 + assert appended["payload"]["server_executed"] is False + + +def test_view_grant_can_read_history_but_cannot_create_or_send(client_access: TestClient): + profile = db.create_profile("Alpha browser", sandbox_id="alpha") + session = db.create_task_session(profile["id"], "alpha", "bootstrap") + db.append_task_message(session["id"], "user", "stored command", "bootstrap") + password = create_user(client_access, "alpha-viewer", "alpha", "view") + login(client_access, "alpha-viewer", password) + + listed = client_access.get(f"/api/task-sessions?profile_id={profile['id']}") + assert listed.status_code == 200 + assert [item["id"] for item in listed.json()] == [session["id"]] + + messages = client_access.get(f"/api/task-sessions/{session['id']}/messages") + assert messages.status_code == 200 + assert messages.json()[0]["content"] == "stored command" + + denied_create = client_access.post( + "/api/task-sessions", + json={"profile_id": profile["id"]}, + ) + assert denied_create.status_code == 404 + assert denied_create.json()["detail"] == "Profile not found" + + denied_send = client_access.post( + f"/api/task-sessions/{session['id']}/messages", + json={"text": "should not append"}, + ) + assert denied_send.status_code == 404 + assert denied_send.json()["detail"] == "Task session not found" + + +def test_cross_sandbox_task_sessions_are_indistinguishable_404( + client_access: TestClient, +): + alpha = db.create_profile("Alpha browser", sandbox_id="alpha") + beta = db.create_profile("Beta browser", sandbox_id="beta") + beta_session = db.create_task_session(beta["id"], "beta", "bootstrap") + password = create_user(client_access, "alpha-user", "alpha", "interact") + login(client_access, "alpha-user", password) + + assert client_access.get(f"/api/task-sessions?profile_id={alpha['id']}").status_code == 200 + denied_profile = client_access.get(f"/api/task-sessions?profile_id={beta['id']}") + assert denied_profile.status_code == 404 + assert denied_profile.json()["detail"] == "Profile not found" + + denied_session = client_access.get(f"/api/task-sessions/{beta_session['id']}/messages") + assert denied_session.status_code == 404 + assert denied_session.json()["detail"] == "Task session not found" + + with db.get_db() as conn: + denied_events = { + (row["action"], row["sandbox_id"], row["profile_id"], row["outcome"]) + for row in conn.execute( + """SELECT action, sandbox_id, profile_id, outcome + FROM access_audit_events WHERE outcome = 'denied'""" + ).fetchall() + } + assert ("task_session.permission.view", "beta", beta["id"], "denied") in denied_events + + +def test_task_message_rejects_missing_profile_mismatch_and_unknown_commands( + client_access: TestClient, +): + alpha = db.create_profile("Alpha browser", sandbox_id="alpha") + beta = db.create_profile("Beta browser", sandbox_id="beta") + password = create_user(client_access, "alpha-operator", "alpha", "interact") + login(client_access, "alpha-operator", password) + + missing = client_access.post("/api/task-sessions", json={"profile_id": "missing"}) + assert missing.status_code == 404 + + session = client_access.post( + "/api/task-sessions", + json={"profile_id": alpha["id"]}, + ).json() + + mismatch = client_access.post( + f"/api/task-sessions/{session['id']}/messages", + json={"text": "wrong browser", "profile_id": beta["id"]}, + ) + assert mismatch.status_code == 404 + assert mismatch.json()["detail"] == "Task session not found" + + invalid = client_access.post( + f"/api/task-sessions/{session['id']}/messages", + json={ + "text": "bad command", + "commands": [ + { + "id": "bad", + "label": "Bad", + "kind": "shell", + "scope": "host", + } + ], + }, + ) + assert invalid.status_code == 422 + + +def test_legacy_commands_route_rejects_more_than_twenty_commands(client_access: TestClient): + alpha = db.create_profile("Alpha browser", sandbox_id="alpha") + password = create_user(client_access, "alpha-operator", "alpha", "interact") + login(client_access, "alpha-operator", password) + session = client_access.post( + "/api/task-sessions", + json={"profile_id": alpha["id"]}, + ).json() + + too_many = client_access.post( + f"/api/task-sessions/{session['id']}/commands", + json={ + "content": "run pinned actions", + "commands": [ + { + "id": f"cmd-{index}", + "label": f"Command {index}", + "kind": "screenshot", + "scope": "ui", + } + for index in range(21) + ], + }, + ) + + assert too_many.status_code == 422 + + +def test_task_session_history_follows_profile_sandbox_move(client_access: TestClient): + profile = db.create_profile("Movable browser", sandbox_id="alpha") + session = db.create_task_session(profile["id"], "alpha", "bootstrap") + db.append_task_message(session["id"], "user", "move-safe history", "bootstrap") + + updated = client_access.put( + f"/api/profiles/{profile['id']}", + headers=bootstrap_headers(), + json={"sandbox_id": "beta"}, + ) + assert updated.status_code == 200 + assert updated.json()["sandbox_id"] == "beta" + assert db.get_task_session(session["id"])["sandbox_id"] == "beta" + + old_password = create_user(client_access, "old-alpha-viewer", "alpha", "view") + login(client_access, "old-alpha-viewer", old_password) + old_view = client_access.get(f"/api/task-sessions/{session['id']}/messages") + assert old_view.status_code == 404 + assert old_view.json()["detail"] == "Task session not found" + + new_password = create_user(client_access, "new-beta-viewer", "beta", "view") + login(client_access, "new-beta-viewer", new_password) + new_view = client_access.get(f"/api/task-sessions/{session['id']}/messages") + assert new_view.status_code == 200 + assert new_view.json()[0]["content"] == "move-safe history" diff --git a/backend/tests/test_vnc_manager.py b/backend/tests/test_vnc_manager.py index 99eb3a1a..2c22fb9f 100644 --- a/backend/tests/test_vnc_manager.py +++ b/backend/tests/test_vnc_manager.py @@ -2,6 +2,8 @@ from __future__ import annotations +from unittest.mock import MagicMock, mock_open + import pytest from backend.vnc_manager import VNCInstance, VNCManager @@ -89,6 +91,27 @@ async def test_active_displays_after_allocate(vnc: VNCManager): assert sorted(vnc.active_displays) == [100, 101] +@pytest.mark.asyncio +async def test_start_vnc_keeps_reverse_proxy_transport_loopback_only( + vnc: VNCManager, + monkeypatch: pytest.MonkeyPatch, +): + await vnc.allocate() + process = MagicMock() + process.poll.return_value = None + popen = MagicMock(return_value=process) + monkeypatch.setattr("backend.vnc_manager.subprocess.Popen", popen) + monkeypatch.setattr("backend.vnc_manager.open", mock_open(), raising=False) + + await vnc.start_vnc(100, 6100, width=1024, height=576) + + command = popen.call_args.args[0] + assert command[command.index("-interface") + 1] == "127.0.0.1" + assert command[command.index("-publicIP") + 1] == "127.0.0.1" + assert command[command.index("-websocketPort") + 1] == "6100" + assert command[command.index("-rfbport") + 1] == "-1" + + # ── BrowserManager.get_status ──────────────────────────────────────────────── diff --git a/backend/vnc_manager.py b/backend/vnc_manager.py index d43e5483..fc5ada83 100644 --- a/backend/vnc_manager.py +++ b/backend/vnc_manager.py @@ -6,7 +6,7 @@ import logging import shutil import subprocess -from dataclasses import dataclass, field +from dataclasses import dataclass logger = logging.getLogger("cloakbrowser.manager.vnc") @@ -60,6 +60,10 @@ async def start_vnc( "-SecurityTypes", "None", "-DisableBasicAuth", "-interface", "127.0.0.1", # internal only, proxied by FastAPI + # KasmVNC otherwise blocks startup while probing public STUN + # servers. This Manager deliberately keeps KasmVNC loopback-only + # behind the authenticated FastAPI reverse proxy. + "-publicIP", "127.0.0.1", "-AlwaysShared", "-httpd", httpd_dir, ] diff --git a/config/extension-catalog.json b/config/extension-catalog.json new file mode 100644 index 00000000..46645138 --- /dev/null +++ b/config/extension-catalog.json @@ -0,0 +1,76 @@ +{ + "source": "comet", + "source_label": "Comet (MeinComet) Default profile", + "extensions": [ + { + "id": "ddkjiahejlhfcafbddmgiahcphecmpfh", + "name": "uBlock Origin Lite", + "description": "Content blocker (MV3)", + "default_selected": true, + "tags": ["privacy", "adblock"] + }, + { + "id": "fihnjjcciajhdojfnbdddfaoknhalnja", + "name": "I don't care about cookies", + "description": "Auto-dismiss cookie banners", + "default_selected": true, + "tags": ["ux"] + }, + { + "id": "mlomiejdfkolichcflejclcbmpeaniij", + "name": "Ghostery AdBlocker for Privacy", + "description": "Privacy / tracker blocking", + "default_selected": false, + "tags": ["privacy", "adblock"] + }, + { + "id": "gppongmhjkpfnbhagpmjfkannfbllamg", + "name": "Wappalyzer - Technology profiler", + "description": "Identify web technologies", + "default_selected": true, + "tags": ["devtools"] + }, + { + "id": "nffaoalbilbmmfgbnbgppjihopabppdk", + "name": "Video Speed Controller", + "description": "HTML5 media speed shortcuts", + "default_selected": false, + "tags": ["media"] + }, + { + "id": "mnjggcdmjocbbbhaepdhchncahnbgone", + "name": "SponsorBlock for YouTube", + "description": "Skip sponsorships", + "default_selected": false, + "tags": ["media"] + }, + { + "id": "neebplgakaahbhdphmkckjjcegoiijjo", + "name": "Keepa - Amazon Price Tracker", + "description": "Amazon price history", + "default_selected": false, + "tags": ["commerce"] + }, + { + "id": "cnjifjpddelmedmihgijeibhnjfabmlf", + "name": "Obsidian Web Clipper", + "description": "Save pages to Obsidian", + "default_selected": false, + "tags": ["notes"] + }, + { + "id": "hjdoplcnndgiblooccencgcggcoihigg", + "name": "Terms of Service; Didn’t Read", + "description": "ToS rating overlays", + "default_selected": false, + "tags": ["privacy"] + }, + { + "id": "icallnadddjmdinamnolclfjanhfoafe", + "name": "FastForward", + "description": "Skip redirect/timer walls", + "default_selected": false, + "tags": ["ux"] + } + ] +} diff --git a/docker-compose.vcvm.yml b/docker-compose.vcvm.yml new file mode 100644 index 00000000..70ef3677 --- /dev/null +++ b/docker-compose.vcvm.yml @@ -0,0 +1,46 @@ +name: cloakbrowser-manager-vcvm + +services: + manager: + container_name: cloakbrowser-manager-vcvm + image: cloakbrowser-manager:vcvm + platform: linux/amd64 + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + network_mode: bridge + ports: + - "127.0.0.1:${MANAGER_PORT:-18115}:8080" + environment: + AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required for VCVM deployments} + ACCESS_CONTROL_ENABLED: "1" + PROXYCHECKER_URL: ${PROXYCHECKER_URL:-} + PROXYCHECKER_ALLOWED_HOSTS: ${PROXYCHECKER_ALLOWED_HOSTS:-host.docker.internal} + EXTENSION_CATALOG_DIR: ${EXTENSION_CATALOG_DIR:-/data/extension-catalog} + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - cloakbrowser-manager-vcvm-data:/data + shm_size: "${VCVM_SHM_SIZE:-2gb}" + cpus: "${VCVM_CPUS:-16.0}" + mem_limit: "${VCVM_MEMORY_LIMIT:-32g}" + pids_limit: 4096 + ulimits: + nofile: + soft: 65535 + hard: 65535 + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=5)" + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s + +volumes: + cloakbrowser-manager-vcvm-data: + name: cloakbrowser-manager-vcvm-data diff --git a/docs/AGENT-SESSION-API.md b/docs/AGENT-SESSION-API.md new file mode 100644 index 00000000..f8c56431 --- /dev/null +++ b/docs/AGENT-SESSION-API.md @@ -0,0 +1,54 @@ +# Agent / CLI session control plane + +Bearer auth (bootstrap token or `cbm_agent_…` API key): + +```bash +export BASE=http://127.0.0.1:18117 +export TOKEN="$(cat ~/.config/cloakbrowser/vcvm-auth-token)" +AUTH=(-H "Authorization: Bearer $TOKEN" -H "Accept: application/json") +``` + +## Catalog and defaults + +```bash +curl -fsS "${AUTH[@]}" "$BASE/api/extension/catalog" +curl -fsS -X POST "${AUTH[@]}" "$BASE/api/extension/catalog" +curl -fsS "${AUTH[@]}" "$BASE/api/extension/defaults" # Comet selectable defaults (+ optional icon_url) +curl -fsS "${AUTH[@]}" "$BASE/api/extension/templates" +curl -fsS "${AUTH[@]}" "$BASE/api/profile-templates" +``` + +## Open session (VNC + CDP links) + +```bash +# Prefer snappy CDP live URL (default mode=cdp) +curl -fsS -X POST "${AUTH[@]}" -H "Content-Type: application/json" \ + -d '{"profile_id":"PROFILE_ID","launch":true,"prefer":"local","mode":"cdp"}' \ + "$BASE/api/extension/sessions/open" + +# Or only resolve links +curl -fsS "${AUTH[@]}" "$BASE/api/profiles/PROFILE_ID/open-links?prefer=local&mode=cdp" +curl -fsS "${AUTH[@]}" "$BASE/api/profiles/PROFILE_ID/open-links?prefer=local&mode=vnc" +``` + +Returned link fields (local and optional cloud via `CLOUD_BASE_URL`): + +| Field | Purpose | +| --- | --- | +| `live_url` / `cdp_fullscreen_url` | Ultra-low-latency CDP screencast (`/session/{id}/live`) | +| `vnc_fullscreen_url` | Manager VNC fullscreen deep-link | +| `session_viewer_url` | Standard Manager shell | +| `cdp_http_url` / `cdp_ws_url` | Playwright/agent-browser CDP attach | +| `vnc_ws_url` | Authenticated VNC websocket | +| `live_metrics_url` | Poll/post FPS/RTT/connection state | + +## Live metrics + +```bash +curl -fsS -X POST "${AUTH[@]}" -H "Content-Type: application/json" \ + -d '{"transport":"cdp","connection_state":"connected","fps":30,"rtt_ms":18}' \ + "$BASE/api/profiles/PROFILE_ID/live-metrics" +curl -fsS "${AUTH[@]}" "$BASE/api/profiles/PROFILE_ID/live-metrics" +``` + +Do not put proxy passwords, agent keys, or auth tokens in docs, commits, or chat. diff --git a/docs/COMPETITOR-UI-FEATURE-MATRIX-2026-07-22.md b/docs/COMPETITOR-UI-FEATURE-MATRIX-2026-07-22.md new file mode 100644 index 00000000..1283ef35 --- /dev/null +++ b/docs/COMPETITOR-UI-FEATURE-MATRIX-2026-07-22.md @@ -0,0 +1,101 @@ +# Wettbewerbs-UI- und Feature-Matrix + +Stand: 22. Juli 2026 + +Diese Matrix bewertet offizielle Produktdokumentation und den aktuellen CloakBrowser-Manager-Fork. Sie kopiert keine fremden Marken, Screenshots oder Texte. Sie trennt belegte Fakten von Design-Inferenzen fuer die naechsten Produktentscheidungen. + +## Kurzentscheidung + +Der aktuelle Fork hat bereits eine starke Basis fuer den mobilen Live-Browser: `MobileSplitScreen`, `ProfileViewer`, serverseitige Task-Sessions, VNC/noVNC, Clipboard, Viewport-Steuerung, Tags, Proxy, Fingerprint-Felder, Launch-Args und Benchmark-Reports sind im Code vorhanden. Die naechsten Verbesserungen sollten nicht mehr Steuerleisten hinzufuegen, sondern vorhandene Flaechen besser organisieren: zuerst Browser/Composer/Fullscreen weiter verdichten, dann Profilorganisation, danach Proxy-/Fingerprint-Health und erst spaeter Live-Dev-Metriken sowie Extension-Sichtbarkeit. + +## Rangierte Referenzprodukte + +| Rang | Produkt | Belegte Staerke | Relevanz fuer CloakBrowser | +|---:|---|---|---| +| 1 | Browser Use Cloud | Chat-UI mit Live-Browser-Preview, Streaming-Messages, Follow-ups, Recording und serverseitigem API-Key-Vertrag; `liveUrl` kommt direkt bei Session-Erstellung zurueck. Quellen: [Chat UI](https://docs.browser-use.com/cloud/tutorials/chat-ui), [Live preview](https://docs.browser-use.com/cloud/browser/live-preview), [Profiles](https://docs.browser-use.com/cloud/guides/authentication) | Primaere UX-Referenz fuer Browser oben, Composer unten, Follow-up-Sessions und keine Client-Secrets. Keine Marken- oder Cloud-UI kopieren. | +| 2 | Browserbase + Stagehand | Interaktive Live View fuer Watch/Click/Type/Scroll, Embedding auch fuer Mobile, Session Inspector mit Netzwerk, Console, Performance und Recording; Stagehand kann Browserbase oder lokal nutzen und Browserbase bietet Stealth, Proxy und persistente Kontexte. Quellen: [Session live view](https://docs.browserbase.com/platform/browser/observability/session-live-view), [Using browser session](https://docs.browserbase.com/platform/browser/getting-started/using-browser-session), [Observability](https://docs.browserbase.com/platform/browser/observability/observability), [Stagehand browser config](https://docs.stagehand.dev/v2/configuration/browser) | Gute Referenz fuer Human-in-the-loop, Dev-Observability und Harness-Abstraktion. Die mobile Keyboard-Luecke ist explizit relevant: Browserbase dokumentiert, dass Mobile-Keyboards nicht nativ unterstuetzt sind und separat in Actions gemappt werden muessen. | +| 3 | Octo Browser | Profile lassen sich ueber Tags organisieren; Startseiten/Bookmarks und Proxy-Formate sind in Profileinstellungen vorgesehen; Produktseite nennt Gruppen/Tags, Fingerprint-Finetuning, Teamrechte, API-Automation und Proxy-Import/Export/Transfer. Quellen: [Profile settings](https://docs.octobrowser.net/en/profiles/browser-profile-settings/), [Functions](https://octobrowser.net/functions/) | Beste Referenz fuer kompakte Profil-Liste mit Tags, Farben und Team-/API-Denke. Fuer CloakBrowser besser als kleine Profil-Chips, nicht als grosse Kartenwand. | +| 4 | GoLogin | Fingerprint-Parameter werden pro Profil konfiguriert; WebRTC kann auf Proxy-IP ausgerichtet oder deaktiviert werden; jedes Profil trennt Fingerprint, Cookies und Proxy. Quellen: [Fingerprint settings](https://support.gologin.com/en/articles/14810056-profile-fingerprint-settings), [FAQ profiles](https://support.gologin.com/en/articles/14839273-faq-profiles) | Referenz fuer Proxy/Fingerprint-Konsistenz pro Profil und klare Health-Warnungen statt tiefer Expertenformulare im mobilen Hauptflow. | +| 5 | Multilogin | Fingerprint-Review umfasst WebRTC, Timezone, Geolocation, Screen, Fonts, Media, Canvas, WebGL, AudioContext und Port-Scan-Schutz; die Doku warnt vor zufaelligen, inkonsistenten Aenderungen. Quelle: [Fingerprint section](https://multilogin.com/help/en_US/profile-settings-fingerprint-section), [Fingerprint check](https://multilogin.com/help/en_US/how-to-check-browser-fingerprint) | Referenz fuer Health-Score: nicht beliebig viele Slider, sondern Konsistenzpruefung, erklaerte Warnungen und gezielte Korrekturen. | +| 6 | AdsPower | Profilanlage umfasst Proxy-Check, Gruppen, Tags, Fingerprint, Startseiten und automatische Extension-Kategorien; Proxy-Tags koennen farbig, in Bulk und fuer Random-Proxy-Auswahl genutzt werden. Quellen: [Create profile](https://help.adspower.com/docs/creating_browser_profiles), [Proxy tags](https://help.adspower.com/docs/Proxy-tag-Random-proxies) | Referenz fuer Profilorganisation, Proxy-Status und Extension-Sichtbarkeit. Konfiguration sollte bei CloakBrowser CLI-/Agent-getrieben bleiben; UI zeigt nur Status, Herkunft und Risiko. | +| 7 | MoreLogin | Bulk-Labels mit Farben, Gruppen, Bulk-Proxy-Detection, Bulk-Proxy-Modify und Bulk-Fingerprint-Parameter sind dokumentiert. Quelle: [Bulk operations](https://support.morelogin.com/en/articles/10204251-bulk-operations-for-profiles) | Referenz fuer spaetere Multi-Profile-Operationen. Jetzt nur die Datenstruktur so vorbereiten, dass Bulk spaeter nicht die mobile UI ueberlaedt. | +| 8 | Kameleo | Profil-Lifecycle per API: Fingerprint suchen, Profil erstellen, starten/stoppen, Proxy hinzufuegen, duplizieren, importieren/exportieren, loeschen; Best Practices: Proxy-Geo mit Locale/Timezone abgleichen, realistische Browser/Locale/Screen-Kombinationen nutzen. Quellen: [Manage profiles](https://developer.kameleo.io/tutorials/managing-profiles/), [Fingerprints](https://developer.kameleo.io/concepts/fingerprints/) | Beste Referenz fuer Agent-/CLI-first Profilbau und Anti-Stealth-Konsistenzlogik. | + +## Feature-Matrix + +| Bereich | Belegte Wettbewerbsfunktion | Aktueller Repo-Stand | Design-Inferenz fuer CloakBrowser | +|---|---|---|---| +| Kompakte Navigation | Browser Use zeigt Session-Flow mit Live-Preview, Streaming und Follow-ups; Browserbase bietet Live View als eingebettete Kontrollflaeche. Quellen: [Browser Use Chat UI](https://docs.browser-use.com/cloud/tutorials/chat-ui), [Browserbase Live View](https://docs.browserbase.com/platform/browser/observability/session-live-view) | `MobileSplitScreen.tsx` hat Full, Tools, Chat, Send, Grid, Viewport, Fullscreen-Fit, Phone fit und Harness-Actions; der aktuelle VCVM-Gate dokumentiert 318/318 Mobile-Checks mit 31 Screenshots. | **Jetzt:** vier dauerhafte Aktionen behalten, Tools/Chat weiter gegenseitig exklusiv halten, Viewport/Zoom im Fullscreen als Mini-Inspector statt grosses Sheet anzeigen. | +| Projekte, Ordner, Tags, Farben, Pins | Octo dokumentiert Tags pro Profil; AdsPower dokumentiert Gruppen und Tags; MoreLogin dokumentiert Labels mit Farben und Gruppen. Quellen: [Octo Profile settings](https://docs.octobrowser.net/en/profiles/browser-profile-settings/), [AdsPower Create profile](https://help.adspower.com/docs/creating_browser_profiles), [MoreLogin Bulk operations](https://support.morelogin.com/en/articles/10204251-bulk-operations-for-profiles) | `Profile` enthaelt `sandbox_id`, `tags: { tag, color }[]`, `notes`, `color_scheme`; echte Folder/Project/Pin-Felder sind nicht belegt. | **Next:** vorhandene Tags/Farben sichtbar machen, `sandbox_id` als Projekt-/Workspace-Gruppierung verwenden, Pins als UI-Prferenz pro Profil einfuehren. Ordner erst als einklappbare Gruppierung, nicht als neues Sicherheitsmodell. | +| Proxy- und Fingerprint-Health | GoLogin, Multilogin und Kameleo betonen konsistente Fingerprint-/Proxy-/Timezone-/Locale-Signale; MoreLogin und AdsPower zeigen Proxy-Detection bzw. Proxy-Check. Quellen: [GoLogin fingerprint](https://support.gologin.com/en/articles/14810056-profile-fingerprint-settings), [Multilogin fingerprint check](https://multilogin.com/help/en_US/how-to-check-browser-fingerprint), [Kameleo fingerprints](https://developer.kameleo.io/concepts/fingerprints/), [MoreLogin Bulk detection](https://support.morelogin.com/en/articles/10204251-bulk-operations-for-profiles) | Backend normalisiert und validiert Proxy-Strings; Profile haben `proxy`, `timezone`, `locale`, `platform`, `user_agent`, `screen_width`, `screen_height`, GPU und Hardware-Felder. Automatischer Health-Score ist nicht als Produktflaeche belegt. | **Then:** Health als Ampel/Score pro Profil: Proxy erreichbar, IP/Geo/Timezone/Locale plausibel, WebRTC-Leak-Risiko, Screen/UA/Platform stimmig. Keine sensiblen Proxy-Secrets in UI, Logs oder Reports. | +| Live-Session- und Dev-Metriken | Browserbase Session Inspector zeigt Live-Browser-Zustand, Netzwerk, Console, Performance, Resource Usage, Recording/Replay; Observability listet CDP-Events, Netzwerklogs, Token usage und Execution time fuer Stagehand. Quellen: [Using browser session](https://docs.browserbase.com/platform/browser/getting-started/using-browser-session), [Observability](https://docs.browserbase.com/platform/browser/observability/observability) | `BenchmarkReport`-Typen und `REMOTE-STREAMING-BENCHMARK.md` existieren; mobile UI soll Benchmarks laut Audit nicht dauerhaft anzeigen. | **Later:** Dev-Panel nur fuer Admin/Debug: VNC open, erstes Frame, Canvas-Change/s, reconnects, WebSocket status, optional CDP/network summaries. Nicht in die Standard-Mobile-UI legen. | +| Extensions | AdsPower dokumentiert automatische Extension-Kategorien pro Profil; Browserbase Observability fuehrt eine Extension-ID als Session-Property. Quellen: [AdsPower Create profile](https://help.adspower.com/docs/creating_browser_profiles), [Browserbase Observability](https://docs.browserbase.com/platform/browser/observability/observability) | `launch_args` ist im Profilmodell vorhanden und Tests decken `--load-extension=/tmp/ext` ab; eine sichere Extension-Inventaransicht mit Icon/Quelle ist nicht belegt. | **Later:** CLI installiert und verwaltet Extensions; UI zeigt read-only Inventar: Name, ID, Version, Quelle, Hash/Path, aktiv/inaktiv, Risiko. Keine manuelle Extension-Installation in Mobile. | +| Mobile/Fullscreen | Browserbase Live View kann in Apps eingebettet werden und nennt Mobile Viewports, aber Mobile-Keyboards muessen separat gemappt werden. Browser Use Live Preview empfiehlt responsive iframe-sizing statt fixer Groesse. Quellen: [Browserbase Session live view](https://docs.browserbase.com/platform/browser/observability/session-live-view), [Browser Use Live preview](https://docs.browser-use.com/cloud/browser/live-preview) | `MobileSplitScreen.tsx` nutzt `visualViewport`, Fullscreen-Fit-Modi, Viewport-Presets und denselben Viewer-Knoten; `ProfileViewer.tsx` kapselt noVNC, reconnect, Clipboard und Scale. | **Jetzt:** Fullscreen als echte Arbeitsflaeche behandeln: Viewport, Zoom, Fit-Modus, Paste und Chat-Collapse bleiben erreichbar, aber als sehr kleine Overlays. Softwaretastatur darf den Composer nicht verdecken. | + +## Umsetzung fuer diesen Repo-Fork + +### Now: kompakter Browser, Composer und Fullscreen-Sessions + +Belegter Stand: `MobileSplitScreen.tsx` und `ProfileViewer.tsx` besitzen bereits kompakte Controls, Fullscreen-Fit, Phone fit, Viewport-Editor, Grid, Clipboard/Paste und Harness-Actions. Der aktuelle VCVM-Gate dokumentiert 318/318 bestandene Checks mit 31 Screenshots ueber fuenf Viewports und das Access-Dashboard. + +Umsetzung: + +- Dauerhaft sichtbar bleiben nur Browser, Composer und maximal vier Dock-Aktionen. +- Tools werden ein Sheet mit Sektionen, nicht mehrere parallele Panels. +- Fullscreen bekommt eine Mini-Leiste: Fit, Zoom, Viewport, Paste, Exit. +- Tastaturzustand nutzt weiter `visualViewport`; alle nicht notwendigen Dock-Buttons verschwinden waehrend Text-Eingabe. +- Quick-Actions bleiben typed actions, keine URL-Bookmarks. + +### Next: Profilorganisation + +Belegter Stand: `sandbox_id`, farbige `tags`, `notes` und Access-Grants existieren. Wettbewerber nutzen Tags, Labels, Gruppen und Farben zur Profilnavigation. + +Umsetzung: + +- Links/oben im Session-Sheet: Projektgruppe aus `sandbox_id`. +- Innerhalb einer Gruppe: kompakte Profilzeile mit Status, Farbe, Tags, Aufloesung, Health-Platzhalter. +- Pin nur als lokale UI-Prioritaet: gepinnte Profile stehen oben; Sicherheitsrechte bleiben unveraendert ueber Grants. +- Ordner als reine Gruppierung in der UI einfuehren, spaeter persistieren. + +### Then: Proxy- und Fingerprint-Health + +Belegter Stand: Proxy/Fingerprint-Felder sind im Modell vorhanden; Wettbewerber pruefen Proxy-Konnektivitaet und warnen vor inkonsistenten Signalen. + +Umsetzung: + +- Serverjob `profile_health`: Proxy-Reachability, IP-Land, Timezone/Locale-Abgleich, WebRTC-Leak-Test, Screen/UA/Platform-Konsistenz. +- UI zeigt nur Score, letzte Pruefung und wichtigste Warnung. +- Detailansicht erklaert "belegt" versus "Inferenz": zum Beispiel Proxy unreachable ist belegt, Risk wegen Timezone/IP-Mismatch ist Inferenz. +- Kein automatisches Rotieren ohne expliziten Agent-/CLI-Auftrag. + +### Later: Live-Dev-Metriken und Extension-Sichtbarkeit + +Belegter Stand: Benchmark-Runner und Reports existieren; `launch_args` kann Extensions starten. Wettbewerber zeigen Session-/Dev-Observability und Extension-/Proxy-Status. + +Umsetzung: + +- Admin-only Dev-Panel: aktuelle VNC/WebSocket-Verbindung, reconnect count, first-frame, Canvas-Change/s, CDP-Events, Network/Console-Ausschnitte. +- Extension-Inventar aus CLI/Browser-Inspection generieren, nicht per Mobile-Formular pflegen. +- Read-only Extension-Karte: Icon, Name, Version, ID, Quelle, Installationspfad/Hash, Profilzuordnung. +- Metriken bleiben Debug-Information, nicht Teil des normalen Mobile-Flows. + +## Fakt oder Inferenz + +| Aussage | Status | +|---|---| +| Browser Use dokumentiert Chat-UI, Live-Preview, Follow-ups, Recording und serverseitige SDK-Nutzung. | Verifizierter Fakt aus offizieller Doku. | +| Browserbase dokumentiert interaktive Live View, Mobile-Embedding, Session Inspector und Performance-/Netzwerk-/Console-Observability. | Verifizierter Fakt aus offizieller Doku. | +| Octo, AdsPower, MoreLogin nutzen Tags/Labels/Gruppen/Farben fuer Profil- oder Proxy-Organisation. | Verifizierter Fakt aus offizieller Doku. | +| GoLogin, Multilogin und Kameleo stellen Proxy/Fingerprint/Locale/Timezone-Konsistenz als wichtig dar. | Verifizierter Fakt aus offizieller Doku. | +| CloakBrowser sollte keine grossen neuen Mobile-Panels bekommen, sondern vorhandene Tools verdichten. | Design-Inferenz aus Ziel, Repo-Stand und Referenzen. | +| `sandbox_id` kann kurzfristig als Projekt-/Gruppenachse dienen. | Design-Inferenz; technisch naheliegend, aber noch kein vollstaendiges Projektmodell. | +| Fingerprint-Health sollte als Score statt als Expertenformular erscheinen. | Design-Inferenz aus Wettbewerbermustern und Mobile-UX-Ziel. | +| Extension-Verwaltung sollte CLI-/Agent-first bleiben und im UI nur sichtbar sein. | Design-Inferenz aus Nutzerziel und vorhandenem `launch_args`-Modell. | + +## Nicht uebernehmen + +- Keine fremden Logos, Farben, Screenshots, Texte oder Markenbegriffe in der UI. +- Keine echten Bookmarks als Hauptnavigation. Wenn URLs/Startseiten spaeter gebraucht werden, bleiben sie Profilkonfiguration oder typed harness action. +- Keine Live-Benchmarks in der normalen Mobile-Oberflaeche. +- Keine Proxy-, API-, Browser- oder Admin-Secrets in Chat, Screenshots, Logs oder Markdown-Reports. +- Keine beliebigen Fingerprint-Slider fuer Mobile; Health und gezielte Korrektur sind wichtiger als Formularbreite. diff --git a/docs/GOAL-ACCEPTANCE-MATRIX-2026-07-22.md b/docs/GOAL-ACCEPTANCE-MATRIX-2026-07-22.md new file mode 100644 index 00000000..9f39cdc6 --- /dev/null +++ b/docs/GOAL-ACCEPTANCE-MATRIX-2026-07-22.md @@ -0,0 +1,57 @@ +# Goal acceptance matrix + +Stand: 23. Juli 2026 + +This document keeps the full mobile browser-manager objective intact and separates proven behavior from partial or missing work. A green narrow test is not used as evidence for a broader requirement. Future developers must start with the repo-local [continuation skill](../.agents/skills/cloakbrowser-manager-development/SKILL.md) and keep this matrix synchronized with fresh evidence. + +## Mobile design checkpoint + +- Platform: iPhone and iPad Safari, with Chromium device emulation as the automated regression surface. +- Framework: React 19 web UI served by FastAPI; the remote browser runs on the VCVM. +- Audience: private enterprise administrators, scoped operators and automation agents. +- Offline behavior: a live VNC session cannot work offline. The UI must instead keep explicit connection, reconnect and recovery states. +- Interaction rules: visually compact controls may use 24-32 px icons, but every actionable hit area remains at least 44 px; only one detail panel is open at a time; the live browser stays the dominant surface. + +The inherent network and streaming dependency makes this a high-risk mobile surface. Release evidence therefore requires real VCVM streaming, keyboard-resize geometry, access enforcement and measured remote-path latency rather than component tests alone. + +## Requirement status + +| Requirement | Status | Current evidence | Evidence still required | +|---|---|---|---| +| Fullscreen keeps View, Viewport, Phone fit and Sessions reachable | Proven | `MobileSplitScreen.tsx`, component tests and the 318-check VCVM mobile report | Physical iPhone Safari remains separate release evidence | +| Phone fit persists the real VCVM framebuffer and restores it after the gate | Proven | `App.applyProfileViewport`, live gate apply/restore checks | Runtime mobile identity consistency is still required | +| Compact browser-first mobile UI and keyboard-safe composer | Proven on automated VCVM surface | Five viewport runs, 31 screenshots, independent visual PASS | Physical iPhone keyboard and private HTTPS proof | +| Existing profiles, colored tags and scoped access groups | Proven | Profile/tag persistence, groups CRUD/effective grants, authenticated E2E | None for the current MVP | +| Persistent profile pinning, projects and collapsible folders | Proven on automated VCVM surface; bulk move implemented locally | Additive SQLite migration, deterministic desktop/mobile organization, search/filter, and admin bulk-organize API/UI with tests | Refresh-stable live VCVM E2E after redeploy | +| Harness choice for Codex, Antigravity, Claude Code, OpenCode and Browser Use clients | Live-proven on VCVM desktop shell | Preference persists, Browser-Use home exposes project + harness selectors, execution remains fail-closed behind the verified Codex Computer Use bridge, and full local suites pass | Fresh live VCVM round-trip for each non-Codex label remains metadata-only | +| Proxy reachability, outbound IP, fingerprint consistency and BrowserScan score | Live-proven on VCVM | No-proxy path earlier; credentialed disposable profile after redeploy produced `proxychecker: measured`, risk/authenticity scores, masked IP, redacted API payload | Optional higher-quality residential proxy sample remains nice-to-have | +| Proxy inventory, overview UI, checker and auto geo-aligned profiles | Live-proven on VCVM | Admin ingest of 11 entries, redacted overview, Proxy-Checker check scores, auto profile under `proxied/auto` with geoip/timezone/locale defaults; credentials never returned | Batch-check remaining inventory entries; optional country enrichment when providers return codes | +| Read-only extension inventory managed from CLI | Implemented; full local suites passed | Safe manifest parser, trust/error state calculation, GET `/api/profiles/{profile_id}/extensions` endpoint, CLI tool `scripts/inspect_extensions.py`, and unit test suites (9 new tests passed; 351 total backend tests) | None for MVP extension inventory | +| External agent control plane (API/CLI/skill; not UI-only) | Live-proven on VCVM | Operate-scoped create/update/delete/launch/stop; `scripts/cbm_agent_ctl.py`; skill `references/agent-control.md`; agent-key E2E create→launch→open-links(CDP/VNC)→health→stop→delete without UI; finance sandbox create denied 403 | Commit/push of harness SHA still pending if requested | +| Live latency and developer diagnostics | Implemented; full local suites passed | Admin-only `GET /api/admin/live-diagnostics` with launch/VNC counters, measured-or-unavailable timings, redaction tests and non-admin 403 | Fresh VCVM live connection counters after deploy; still no encoded FPS or touch-to-pixel claim | +| Competitor research | Proven | `COMPETITOR-UI-FEATURE-MATRIX-2026-07-22.md` | Keep facts and design inferences separated | +| End-to-end mobile release | Proven on automated VCVM Chromium surface | Current authenticated gate passed 316 checks across five viewports plus access dashboard, produced 31 screenshots, and the redacted release acceptance gate passed | Safari Remote Automation, physical iPhone and private Tailnet HTTPS remain external/device proof | + +## Fresh verification snapshot + +- Complete backend suite: **371 passed**. +- Complete frontend suite: **133 passed**. +- Complete script suite: **26 passed**, including Python 3.11 release-gate compilation. +- Frontend production build: **passed**. +- Redacted release acceptance gate: **passed** (prior mobile pack); Browser-Use/Proxies VCVM UI re-verified with headless Chromium screenshots on 23 July evening. +- VCVM deployment: **healthy and protected**, with the optional local proxychecker reachable from the Manager container. +- Proxy inventory: **11** entries ingested on VCVM; credentials masked in API/UI; one live Proxy-Checker sample recorded as redacted scores. +- Authenticated VCVM Chromium gate: **316 checks passed**, **31 screenshots**, five mobile/tablet viewports plus access dashboard. +- Live profile health: first-launch persistence, manual rerun, masked outbound IP, 100/100 fingerprint consistency and 100/100 BrowserScan passed. +- Safari/WebKit gate: **blocked externally** because Safari Remote Automation is disabled; no WebKit or physical-iPhone claim is made. + +## Delivery order and stop conditions + +1. **Proven on automated VCVM surface:** additive profile organization, compact grouped UI and current mobile release ladder. +2. **Implemented; one live input still required:** redacted profile health and BrowserScan are live; credentialed proxychecker scoring still needs an authorized configured proxy. +3. **Proven locally:** read-only extension inventory plus admin-only live diagnostics. +4. **Live-proven:** credentialed proxychecker enrichment via Manager health path. +5. **Next:** release handoff evidence pack. +6. **External/device checkpoint:** Safari Remote Automation, physical iPhone and private Tailnet HTTPS/direct-route evidence. + +The goal is accepted only when every row above is proven or an external-only proof is explicitly completed. A blocked Safari or physical-device row cannot be relabeled as a pass. diff --git a/docs/MOBILE-E2E-VALIDATION.md b/docs/MOBILE-E2E-VALIDATION.md index 0dcea34b..4b34ce49 100644 --- a/docs/MOBILE-E2E-VALIDATION.md +++ b/docs/MOBILE-E2E-VALIDATION.md @@ -2,9 +2,13 @@ Stand: 21. Juli 2026 -## Aktueller r51-Nachweis +## Aktueller VCVM-Nachweis -Der aktuelle authentifizierte Release-Gate umfasst fünf Mobile-Viewports plus Access-Dashboard und bestand **276/276 Checks** mit **23 Screenshots**. Der Composer akzeptiert ausschließlich eine Host-Bridge mit `provider: codex-computer-use`; fehlende oder generische Harnesses bleiben deaktiviert. Ein separater Codex-Computer-Use-Lauf bediente den echten verbundenen VNC-Canvas, den scoped Viewer-Login und die kombinierte `operate + automate`-Vergabe im Access-Dashboard. Details, Grenzen und die aktuellen Performancewerte stehen im [Mobile-/Auth-/Latenz-Audit](MOBILE-STREAMING-AUTH-LATENCY-AUDIT-2026-07-21.md). +Der aktuelle authentifizierte r63-Release-Gate lief gegen den loopback-gebundenen Manager auf der VCVM und umfasst fünf Mobile-Viewports plus Access-Dashboard. Er bestand **307/307 Checks** mit **25 Screenshots**: 66 auf iPhone 14 Portrait, 59 auf iPhone SE, 61 auf iPhone Pro Max, je 58 auf iPhone Landscape und Touch-Tablet sowie 5 im Access-Dashboard. Geprüft wurden der echte verbundene VNC-Canvas, RFB-/CDP-Eingabe, Clipboard, Grid, Vollbild mit getrennten Fit-/Width-/Height-Modi, editierbare Viewports, kompakte 36-px-Controls und fehlender horizontaler Overflow. Der iPhone-14-Gate emuliert zusätzlich die geöffnete Softwaretastatur und verlangt, dass Visual Viewport, VNC-Pane, einzeiliger Composer und Send-Aktion sichtbar bleiben und nicht überlappen. Der iPhone-SE-Gate sichert weiterhin Seitenverhältnis, vollständigen Canvas und die kompakte Browser-/Composer-Anordnung ab. + +Der manuelle VCVM-Lauf änderte den Browser-Framebuffer live von **1024 × 576** auf **390 × 844**, wartete den vollständigen Browser-/VNC-Neustart ab und stellte ihn anschließend wieder auf **1024 × 576** zurück. Xvnc lief danach erneut mit `-geometry 1024x576`; die mobile Oberfläche blieb auf 390 × 844 ohne horizontalen Overflow. Ein separates kurzlebiges Viewer-Konto sah genau dieses eine Profil und einen echten Live-Canvas, aber keine Administration; die Deaktivierung trennte seine bereits aktive VNC-Verbindung sofort. Connected- und Revoked-Screenshot wurden visuell kontrolliert, alle angelegten E2E-Viewer blieben inaktiv. + +Der Composer akzeptiert ausschließlich eine Host-Bridge mit `provider: codex-computer-use`; fehlende oder generische Harnesses bleiben deaktiviert. Der r63-Gate injiziert diesen Host deterministisch und beweist damit den UI-Vertrag, nicht einen produktiven Codex Computer Use Browser im VCVM-CLI/IDE. Servergespeicherter Chatverlauf ist Persistenz und führt selbst keine Agent-Aufgabe aus. Details, Grenzen und die aktuellen Performancewerte stehen im [Mobile-/Auth-/Latenz-Audit](MOBILE-STREAMING-AUTH-LATENCY-AUDIT-2026-07-21.md); die kompakte Informationsarchitektur und universellen Quick actions sind im [Mobile UI/UX and universal browser-action audit](MOBILE-UI-UX-HARNESS-AUDIT-2026-07-21.md) dokumentiert. Die folgenden Abschnitte bewahren ältere, enger abgegrenzte Läufe als Regressionsevidenz. @@ -24,7 +28,7 @@ Die Dauerleisten wurden entfernt: Workspace-Aktionen, Pane-/Zoom-Regler und selt Der Vollbildmodus startet mit drei klaren 44-px-Aktionen (View, Viewport, Exit). Der Viewport-Dialog bietet im Vollbild Phone-fit und Presets, editierbare Breite/Höhe sowie `Apply`, ohne den VNC-Viewer verlassen zu müssen. Der Gate prüft dazu genau einen verbundenen Canvas, RFB-Remote-Eingabe mit CDP-Bestätigung, Ratio und Canvas-Zoom, Grid, den iOS-Paste-Fallback, Vollbild-Viewport-Persistenz, Fokus-Rückgabe, fehlenden horizontalen Overflow und alle sichtbaren Touch-Ziele. Es entstanden 22 lokale PNG-Artefakte; repräsentative iPhone-SE-, Vollbild- und Vollbild-Viewport-Screenshots wurden visuell kontrolliert. -Die nachfolgenden Abschnitte bewahren weitere ältere, enger abgegrenzte Läufe als Vergleichs- und Fehlerhistorie. Sie ersetzen nicht den r51-Nachweis oben. +Die nachfolgenden Abschnitte bewahren weitere ältere, enger abgegrenzte Läufe als Vergleichs- und Fehlerhistorie. Sie ersetzen nicht den r63-Nachweis oben. ## Historischer Kompatibilitäts-Wiederholungslauf @@ -111,20 +115,20 @@ Der erste Wert je Zeile startete aus einem gestoppten Profil und ist deshalb nur - Mobile Workspace statt Desktop-UI, einschließlich Landscape und 768-px-Touch-Tablet. - Root-Geometrie entspricht dem jeweiligen Visual Viewport. +- Bei emulierter offener Softwaretastatur entspricht die Root-Höhe dem verkleinerten Visual Viewport; Browser, Composer und Send-Aktion bleiben sichtbar und überlappen sich nicht. - Kein horizontaler Body-Overflow. - Vertikaler beziehungsweise horizontaler Split liegt ohne Lücke oder Überlappung aneinander. - Browserfläche, Task-Verlauf und Composer sind vorhanden. -- Attach-, Run-Settings-, Demo-Modell- und Run-Task-Control sind programmatisch erreichbar. -- Alle sichtbaren Buttons, Selects, Textareas und normalen Inputs sind mindestens 44 × 44 px groß. -- Lokale Chat-Nachricht und ausdrücklich gekennzeichnete Demo-Antwort funktionieren. -- Run Settings öffnen; das Demo-Modell lässt sich ändern. +- Alle sichtbaren geprüften Buttons, Selects, Textareas und normalen Inputs sind mindestens 36 × 36 px groß; Input-Text bleibt 16 px groß. +- Full, Tools, Chat und Send sind die einzigen persistenten Primäraktionen; Tools und Chat schließen sich gegenseitig. +- Der capability-geprüfte Hostvertrag akzeptiert eine deterministische Testnachricht; fehlende oder falsch bezeichnete Hosts bleiben deaktiviert. - Grid öffnet und rendert den aktuellen Profilzustand. - Der echte VNC-Status erreicht `Connected`. - Vor, während und nach CSS-Vollbild existiert exakt ein Canvas. - Der noVNC-Canvas erhält bei Pointer-/Touch-Interaktion Fokus; `Ctrl+L`, URL-Zeichen und Enter werden als deterministische Keyboard-Events durch RFB gesendet. Der CDP-Proxy bestätigt anschließend die harmlose eindeutige HTTP(S)-Probe, und der VNC-Screenshot zeigt die geladene Zielseite. -- Vollbild deckt den Viewport ab, setzt den Hintergrund `inert` und fokussiert die Schließen-Aktion. +- Vollbild deckt den Viewport ab, setzt den Hintergrund `inert`, fokussiert die Schließen-Aktion und unterscheidet Fit, Width und Height sichtbar und geometrisch. - Escape schließt Vollbild, gibt den Fokus zurück und erzeugt keinen Overflow. -- Dreizehn PNG-Artefakte besitzen die erwartete Viewportgröße und eine nichttriviale Dateigröße. +- 25 PNG-Artefakte besitzen die erwartete Viewportgröße und eine nichttriviale Dateigröße; repräsentative Workspace-, Keyboard-, Fullscreen-, Access- und Viewer-Bilder wurden visuell kontrolliert. ## Historische Code-Gates diff --git a/docs/MOBILE-STREAMING-AUTH-LATENCY-AUDIT-2026-07-21.md b/docs/MOBILE-STREAMING-AUTH-LATENCY-AUDIT-2026-07-21.md index 2e466a5a..ac965cc2 100644 --- a/docs/MOBILE-STREAMING-AUTH-LATENCY-AUDIT-2026-07-21.md +++ b/docs/MOBILE-STREAMING-AUTH-LATENCY-AUDIT-2026-07-21.md @@ -2,50 +2,53 @@ Stand: 21. Juli 2026 -Dieser Audit bewertet den final geprüften r51-Stand des CloakBrowser Managers und ordnet die älteren r49-/r50-Basen ein. Er trennt nachgewiesene Funktion, lokale Messung, Tailnet-Transport und noch offene Produktbehauptung. Browser-Use diente nur als Interaktionsreferenz; fremde Marken-, Cloud- oder UI-Assets wurden nicht übernommen. +Dieser Audit bewertet den final geprüften r63-Stand des CloakBrowser Managers und ordnet die älteren r49-/r50-Basen ein. Er trennt nachgewiesene Funktion, lokale Messung, Tailnet-Transport und noch offene Produktbehauptung. Browser-Use diente nur als Interaktionsreferenz; fremde Marken-, Cloud- oder UI-Assets wurden nicht übernommen. ## Ergebnis in einem Satz -Für das mobile Web-MVP bleibt **KasmVNC 1.3.3 + noVNC 1.4.x** die am besten nachgewiesene Basis: Der authentifizierte r51-Gate bestand fünf mobile Viewpoints plus Access-Dashboard mit **276/276 Checks** und **23 Screenshots**, während die ältere r49-Auth-Suite vier Authentifizierungswege bis zu einem echten verbundenen Canvas belegte. Der warme lokale KasmVNC-WebSocket-Upgrade lag in 20/20 Läufen bei **3,457 ms Median / 8,931 ms p95**. Ein neuer, reproduzierbarer Selkies-Lauf erreichte **2,190 ms Median / 3,722 ms p95** für den isolierten WebSocket-Handshake, aber noch keinen gleichwertigen Produkt-, Frame- oder Eingabe-E2E. Ein zusätzlicher Codex-Computer-Use-Lauf bediente die finale iPhone-Ansicht real und bestätigte Canvas, vier persistente Aktionen, Rechte-Dashboard und den strikt verifizierten Hostvertrag. Das beweist weiterhin keine physische iPhone-, WAN- oder Touch-to-Pixel-Latenz. +Für das mobile Web-MVP bleibt **KasmVNC 1.3.3 + noVNC 1.4.x** die am besten nachgewiesene Basis: Der authentifizierte r63-Gate bestand fünf mobile Viewpoints plus Access-Dashboard mit **307/307 Checks** und **25 Screenshots**, während die ältere r49-Auth-Suite vier Authentifizierungswege bis zu einem echten verbundenen Canvas belegte. Im frischen browserbeobachteten Fünferlauf erreichte der vollständige lokale KasmVNC/noVNC-Produktpfad den ersten nichtschwarzen Frame nach **180 ms Median**, Guacamole nach **351 ms** und Selkies nach **5.272 ms**; der Selkies-Erstlauf lag bei 296 ms, vier Reloads jedoch nahe 5,3 s. Diese Werte sind wegen der unterschiedlichen Integration nur richtungsweisend. Der r63-Lauf bestätigte außerdem die offene Softwaretastatur, kompakte 36-px-Controls, Fullscreen-Fit-Modi und einen echten viewer-only VNC-Login mit sofortigem Rechteentzug. Das beweist weiterhin keine physische iPhone-, WAN-, FPS- oder Touch-to-Pixel-Latenz. -## r51-Endstand: UI-Architektur und Codex Computer Use +## r63-Endstand: UI-Architektur und Codex-Hostvertrag -Der belegte r51-Stand ist eine Umstrukturierung und vollständige lokale Abnahme der mobilen Oberfläche, nicht ein neuer Performance-Sieg: +Der belegte r63-Stand ist eine Umstrukturierung und vollständige VCVM-Abnahme der mobilen Oberfläche, nicht ein allgemeiner Performance-Sieg: - **Browser-first:** Der Live-Browser ist die primäre Fläche. Chat startet bei laufendem Browser collapsed, damit VNC nicht von Steuerleisten verdrängt wird. -- **Zentrale Tools:** Browser-Werkzeuge, Viewport, Zoom, Fullscreen und Grid sind in einem zentralen Bedienbereich zusammengeführt. Benchmarks werden nicht in der mobilen UI angezeigt. +- **Zentrale Tools:** Browser-Werkzeuge, Viewport, Zoom, Fullscreen, Sessions und Kontoaktionen sind in einem einzigen Tool-Sheet zusammengeführt. Benchmarks werden nicht in der mobilen UI angezeigt. +- **Quick actions statt Bookmarks:** Capture, Copy und Paste sind typisierte Hostaktionen und keine gespeicherten URLs. Sie werden nur aktiviert, wenn der verifizierte Host die passende Capability meldet. - **Shortcuts:** Chat-Collapse und Fullscreen-Preview sind als schnelle Bedienwege vorgesehen, ohne Touch-only Bedienung zu erzwingen. +- **Kompakte Controls:** Primäraktionen, Login, Access-Dashboard und Paste-Aktionen verwenden 36 px statt 44 px; Inputs behalten 16 px Text, damit iOS beim Fokus nicht automatisch zoomt. +- **Softwaretastatur:** Die Oberfläche folgt `visualViewport.height`, blendet während der Tastatur nicht benötigte Controls aus und hält Live-Browser, Composer und Send-Aktion ohne Überlappung im sichtbaren Bereich. +- **Fullscreen-View:** Fit, Width und Height sind eigenständige Modi; Zoom und ein kompakter Profil-Viewport-Editor bleiben im Fullscreen erreichbar. - **Grid:** Grid bleibt ein kompakter Session-/Profilumschalter und soll nicht still mehrere Streams starten, weil das iPhone-FPS und Akku verfälschen würde. - **Codex-only Hostvertrag:** Der Composer akzeptiert nur eine injizierte Bridge, deren Capabilities explizit `provider: codex-computer-use` melden. Fehlende, generische oder nur umbenannte Harnesses bleiben deaktiviert; auch `send()` kann die Prüfung nicht umgehen. -- **Chat-Anbindung:** Der freigegebene UI- und Testpfad verwendet Codex Computer Use, hält Browser-Credentials außerhalb des Chats und simuliert keinen lokalen Erfolg. Der r51-Stand behauptet noch keine produktive externe Agent-Task-API ohne einen realen Host-Bridge-Prozess. +- **Chat-Anbindung:** Der freigegebene UI-Vertrag hält Browser-Credentials außerhalb des Chats und simuliert keinen lokalen Erfolg. Der Browser-Gate injiziert dafür deterministisch einen als `codex-computer-use` ausgewiesenen Testhost. Codex CLI/IDE auf der VCVM stellt hier keinen produktiven Computer-Use-Browser bereit; gespeicherter Server-Chatverlauf ist keine Agent-Ausführung. - **Rechte-Dashboard:** Browsersteuerung (`view`/`interact`/`operate`) und CDP-Automation sind getrennte Controls. Dadurch ist `operate + automate` auf derselben Sandbox möglich; eine einklappbare Vorschau zeigt die tatsächlich erreichbaren Profile und effektiven Fähigkeiten. -Der finale r51-Mobile-Gate ist abgeschlossen: fünf Viewports plus Access-Dashboard, **276/276 Checks**, **23 Screenshots**, keine Fehler. Er prüft Touch-Ziele, Fullscreen, Grid, Viewport/Zoom, Chat-Collapse, Clipboard/Paste, eine echte VNC-Verbindung und die authentifizierte Access-Oberfläche. Ein separater Codex-Computer-Use-Lauf bestätigte den finalen Release-Container zusätzlich über echte UI-Interaktionen und vergab einem Paperclip-Agenten kombiniert `operate + automate`. +Der finale r63-Mobile-Gate ist abgeschlossen: fünf Viewports plus Access-Dashboard, **307/307 Checks**, **25 Screenshots**, keine Fehler. Er prüft kompakte Controls, den emulierten offenen Mobile-Keyboard-Zustand, Fullscreen Fit/Width/Height, Grid, Viewport/Zoom, Chat-Collapse, den einzeiligen Composer, Kontoaktionen nur im Tool-Sheet, Clipboard/Paste, eine echte VNC-Verbindung und die authentifizierte Access-Oberfläche. Ein separater echter Viewer-Login bestätigte genau ein freigegebenes Profil, einen verbundenen Live-Canvas, fehlende Admin-Aktionen und sofortige Trennung nach Deaktivierung. ## Was jetzt nachgewiesen ist | Bereich | Nachweis | Ergebnis | |---|---|---| -| iPhone 14 Portrait | 390 × 844, echter VNC-Canvas, Split, Grid, Fullscreen, Viewport-Editor und Codex-Computer-Use-Composer | 55/55 | -| iPhone SE Portrait | 375 × 667, kurzer Viewport mit kompaktem Live-Anteil und vollständig erreichbarem Composer | 54/54 | -| iPhone Pro Max Portrait | 430 × 932, inklusive sichtbarem Inline-Viewport-Editor | 56/56 | -| iPhone 14 Landscape | 844 × 390, horizontaler Split und Fullscreen | 53/53 | -| Touch-Tablet | 768 × 1024, coarse pointer, Grid und Fullscreen | 53/53 | +| iPhone 14 Portrait | 390 × 844, echter VNC-Canvas, Split, Softwaretastatur, Grid, Fullscreen, Viewport-Editor und Hostvertrag | 66/66 | +| iPhone SE Portrait | 375 × 667, kurzer Viewport mit kompaktem Live-Anteil und vollständig erreichbarem Composer | 59/59 | +| iPhone Pro Max Portrait | 430 × 932, inklusive sichtbarem Inline-Viewport-Editor | 61/61 | +| iPhone 14 Landscape | 844 × 390, horizontaler Split und Fullscreen | 58/58 | +| Touch-Tablet | 768 × 1024, coarse pointer, Grid und Fullscreen | 58/58 | | Access-Dashboard | authentifizierter Adminpfad, kompakte Grants und mobile Overflow-Prüfung | 5/5 | -| Vision-Artefakte | Empty, Workspace, Grid, Fullscreen, Fullscreen-Viewport und Access-Dashboard | 23 Screenshots | +| Vision-Artefakte | Initial, Workspace, Keyboard, Grid, Fullscreen, Fullscreen-Viewport und Access-Dashboard | 25 Screenshots | | Legacy-Token | Login, Profilwahl und verbundener Canvas bei 390 × 844 | bestanden | | Bootstrap-Token | Admin-Login, Profilwahl, verbundener Canvas und Viewport-Administration | bestanden | | Operator-Konto | Nur zugewiesene Sandbox; Stream und sichtbare Remote-Werkzeuge; keine Profil-Viewport-Administration | bestanden | -| Viewer-Konto | Nur zugewiesene Sandbox; Stream und Fullscreen; Paste/Remote-Werkzeuge ausgeblendet, Start und Clipboard per API verweigert | bestanden | +| Viewer-Konto | Nur zugewiesene Sandbox; genau ein echter 1024 × 576 Canvas; keine Admin-Aktionen; sofortige VNC-Trennung nach Deaktivierung | bestanden | -Der Gate prüfte unter anderem einen echten Canvas, `Connected`, keinen horizontalen Overflow, mindestens 44 × 44 CSS-Pixel große sichtbare Controls, Live-Pane und visuellen noVNC-Zoom, Pointer-/Touch-Zielkoordinaten über eine CDP-beobachtete Probe, manuellen iOS-Paste-Fallback, Fullscreen-Fokus, `inert`-Hintergrund, Escape-Rückkehr, Grid, Chat-Verlauf und den Codex-Computer-Use-Composer. Eine echte WAN-Touch-to-Pixel-Messung wurde in diesem lokalen Artefakt nicht aufgezeichnet. +Der Gate prüfte unter anderem einen echten Canvas, `Connected`, keinen horizontalen Overflow, mindestens 36 × 36 CSS-Pixel große sichtbare Controls, Live-Pane und visuellen noVNC-Zoom, Pointer-/Touch-Zielkoordinaten über eine CDP-beobachtete Probe, manuellen iOS-Paste-Fallback, den geöffneten Softwaretastatur-Zustand, Fullscreen-Fokus, `inert`-Hintergrund, Escape-Rückkehr, Grid, Chat-Verlauf und den Codex-Hostvertrag. Eine echte WAN-Touch-to-Pixel-Messung wurde in diesem lokalen Artefakt nicht aufgezeichnet. Lokale Belege: -- `artifacts/mobile-ui-gate-r51-codex-access-acceptance-r2/report.json` -- `artifacts/ui-redesign-r51/codex-computer-use-viewer-iphone14.png` -- `artifacts/ui-redesign-r51/codex-computer-use-access-dashboard.png` -- `artifacts/selkies-benchmark/local-r4-independent/streaming-benchmark-report.json` +- `artifacts/vcvm-r63-compact-keyboard-final/mobile-ui-gate/report.json` +- `artifacts/selkies-benchmark/r55-local/streaming-benchmark-report.json` +- `artifacts/guacamole-benchmark/r55-local/streaming-benchmark-report.json` - `artifacts/streaming-login-audit-r49/auth-api-summary.json` - `artifacts/streaming-login-audit-r49/auth-ui-summary.json` - `artifacts/streaming-benchmark-r49/streaming-benchmark-report.json` @@ -60,9 +63,9 @@ Der r50-Nachtrag behebt denselben Payload-Fehler auch für `PUT /api/access/agen Der r51-Nachtrag behebt außerdem zwei Mehrfachgrant-Fehler: Das Dashboard löscht beim Setzen der Steuerstufe nicht mehr ein vorhandenes `automate`, und die App prüft nicht mehr nur den ersten Grant einer Sandbox. Die gemeinsame Frontend-Policy spiegelt jetzt die serverseitige Vererbung. Abgelehnte REST- und WebSocket-Policyentscheidungen werden als redigierte Audit-Metadaten protokolliert. -### 2. Login-Controls waren auf Mobilgeräten zu klein +### 2. Login-Controls und Softwaretastatur waren auf Mobilgeräten zu groß beziehungsweise instabil -Account-Eingaben, Token-Eingabe, Hauptaktion und Moduswechsel lagen teilweise unter dem 44-Pixel-Touchziel. Alle vier Control-Typen sind jetzt mindestens 44 Pixel hoch. Der reale 390-×-844-Lauf maß für jedes sichtbare Login-Control exakt 44 Pixel ohne horizontalen Overflow. +Account-Eingaben, Token-Eingabe, Hauptaktion und Moduswechsel waren für die gewünschte kompakte Oberfläche zu hoch. Alle vier Control-Typen verwenden jetzt eine 36-Pixel-Höhe; Eingabetext bleibt bewusst 16 Pixel groß, um iOS-Fokuszoom zu vermeiden. Im Workspace folgt die Root-Höhe dem Visual Viewport. Der reale 390-×-844-Gate emulierte die geöffnete Tastatur und bestätigte, dass VNC, Composer und Send-Aktion sichtbar, getrennt und ohne horizontalen Overflow bleiben. ### 3. Operator und Viewer sahen eine scheinbar editierbare Remote-Viewport-Aktion @@ -76,10 +79,10 @@ Pane-Ratio und visueller Zoom sind lokale Live-Funktionen und bleiben für alle | Visueller noVNC-Zoom | verändert sichtbare Canvas-Geometrie sofort, ohne CSS-Transform | live, 75–150 % | | View-Reset | setzt Pane und Zoom geräteabhängig zurück | live | | Fullscreen View/Zoom | bleibt im Fullscreen erreichbar | live | -| Profil-Viewport Breite/Höhe | speichert die Xvnc-/Browserauflösung | wirkt beim nächsten Profilstart | -| Phone-fit-Preset | übernimmt den aktuellen Visual Viewport als Profilwert | speichert für den nächsten Start | +| Profil-Viewport Breite/Höhe | speichert die Xvnc-/Browserauflösung | laufendes Profil wird kontrolliert neu gestartet | +| Phone-fit-Preset | übernimmt den aktuellen Visual Viewport als Profilwert | laufendes Profil wird kontrolliert neu gestartet | -Die Oberfläche sagt bei einem laufenden Profil ausdrücklich: **„Saves for the next launch; visual zoom changes now“**. Ein echter Live-Resize des laufenden Xvnc-Framebuffers ist nicht implementiert und wird nicht als erledigt behauptet. +Pane und Zoom reagieren ohne Server-Neustart. Eine neue echte Xvnc-/Browserauflösung kann KasmVNC in diesem Produktpfad nicht sicher in place übernehmen; `Apply` speichert deshalb den Wert und startet nur das ausgewählte laufende Profil kontrolliert neu. Die Oberfläche zeigt währenddessen **„Restarting live browser…“** und meldet Stop-/Relaunch-Fehler, statt eine nur gespeicherte Größe als live angewendet darzustellen. ## Frische Latenzmessung @@ -92,6 +95,22 @@ Der r49-Runner verwendete eine bereits laufende, loopback-gebundene 1024-×-576- Diese Zahlen messen TCP/HTTP beziehungsweise den WebSocket-Upgrade. Sie messen **nicht** Browserstart, ersten Bildinhalt, Frame-Rate, Berührung bis Pixeländerung, Tailnet/WAN oder Mobilfunk. +### Aktueller VCVM-only-Produktpfad + +Der aktuelle Manager, das React-UI, SQLite, CloakBrowser, Xvnc/KasmVNC und das Profilvolume liefen gemeinsam auf der VCVM; der Mac war ausschließlich Client über einen authentifizierten SSH-/Tailscale-Tunnel. Die VCVM veröffentlichte weder den Manager noch rohe VNC-Ports außerhalb von Loopback. + +| Messpunkt | Läufe | Median | p95 | Maximum | +|---|---:|---:|---:|---:| +| VCVM `/health` total | 30 | 0,514 ms | 1,179 ms | 13,268 ms | +| VCVM authentifiziertes Profil-API total | 30 | 2,148 ms | 4,147 ms | 10,878 ms | +| VCVM VNC-WebSocket open | 20 | 6,689 ms | 9,989 ms | 70,799 ms | +| VCVM erstes RFB-Frame | 20 | 19,558 ms | 26,837 ms | 91,409 ms | +| Mac-Tunnel `/health` total | 30 | 61,8 ms | 125,6 ms | 150,7 ms | +| Mac-Tunnel VNC-WebSocket open | 15 | 195,2 ms | 266,8 ms | 502,5 ms | +| Mac-Tunnel erstes RFB-Frame | 15 | 204,6 ms | 328,6 ms | 555,1 ms | + +Tailscale nutzte `DERP(nue)` mit 49–64 ms beobachtetem Ping und stellte keine direkte Verbindung her. `netcheck` zeigte auf dem Mac öffentliches IPv4 ohne IPv6, auf der VCVM dagegen öffentliches IPv6 ohne gefundenes IPv4; damit fehlt aktuell ein gemeinsamer direkter Adresspfad. Eine fünfsekündige synthetische Bewegungsseite ergab auf dem verbundenen Canvas **6,96 sichtbare Änderungen/s** VCVM-lokal und **4,18/s** über den Mac-Relay-Pfad. Das ist ein reproduzierbarer visueller Update-Proxy, aber weder Codec-FPS noch Touch-to-Pixel-Latenz. + ### r50-VCVM/Neko-Tailnet-Beleg Zusätzlich wurde ein bereits laufender Neko/Chrome-Stack auf der VCVM über Tailscale geprüft. Diese Messung bewertet den Tailnet-Transport und den geschützten Login, nicht die CloakBrowser-noVNC-UI. @@ -108,14 +127,14 @@ Zusätzlich wurde ein bereits laufender Neko/Chrome-Stack auf der VCVM über Tai Codex Computer Use führte den geschützten Login erfolgreich aus und sah `/ws`. WebRTC ICE blieb jedoch `checking` und wechselte danach zu `failed`; das Video blieb bei `readyState 0`. Deshalb gibt es aus diesem Lauf **keinen ehrlichen FPS-Wert**. Die wichtigste Performance-Empfehlung ist nicht weiteres UI-Tuning auf Basis erfundener FPS, sondern zuerst direkte Tailscale-Konnektivität und UDP/ICE zu reparieren und danach Frame- sowie Touch-to-Pixel-Messung erneut auszuführen. -### Einordnung der Streaming-Stapel nach dem r51-Nachtest +### Einordnung der Streaming-Stapel nach dem r63-Nachtest -| Stack | r49-Status | Entscheidung | +| Stack | frischer lokaler Stand | Entscheidung | |---|---|---| -| KasmVNC 1.3.3 + noVNC 1.4.x | vollständiger aktueller Produktpfad, 249 Mobile-Checks, vier Loginpfade | beibehalten | -| Selkies | reproduzierbare HTTP-/WebSocket-Bereitschaft, 5/5 + 5/5; kein gleichwertiger Produkt-E2E | weiter messen, noch nicht migrieren | +| KasmVNC 1.3.3 + noVNC 1.4.x | vollständiger aktueller Produktpfad, 307 Mobile-/Access-Checks; erster nichtschwarzer Frame Median 180 ms | beibehalten | +| Selkies | 20/20 HTTP und WebSocket; erster nichtschwarzer Frame Median 5.272 ms nach langsamen Reloads; kein gleichwertiger Produkt-E2E | Reload-/Sessionproblem untersuchen, noch nicht migrieren | | Sunshine/Moonlight | `architecture_only`, keine aktuelle Messung | nicht als Latenzvergleich werten | -| Apache Guacamole | `architecture_only`, keine aktuelle Messung | nicht als Latenzvergleich werten | +| Apache Guacamole 1.6 | 20/20 HTTP; erster nichtschwarzer Frame Median 351 ms; zusätzliche Gateway- und fehlende Policy-/CDP-Integration | kein Web-MVP-Core | Frühere isolierte KasmVNC-1.4- und Selkies-POCs sowie der neue Selkies-Readiness-Lauf sind in `docs/REMOTE-STREAMING-BENCHMARK.md` methodisch getrennt dokumentiert. Einen technologieübergreifenden „Latenz-Sieger“ zu behaupten wäre weiterhin falsch. KasmVNC ist die aktuelle Produktempfehlung, weil nur dieser Pfad vollständig integriert und aktuell end-to-end geprüft ist. @@ -123,7 +142,7 @@ Frühere isolierte KasmVNC-1.4- und Selkies-POCs sowie der neue Selkies-Readines 1. **1024 × 576 als verifizierten Standard behalten.** Genau diese laufende Profilauflösung bestand die frischen Mobile-, Auth- und warmen Loopback-Gates; der r49-Lauf enthält keinen neuen 1440-×-900-Vergleich. 2. **Pane und noVNC-Zoom für spontane Anpassungen nutzen.** Sie reagieren live und halten den Remote-Browserprozess stabil. -3. **390 × 844 oder Phone-fit bewusst pro Profil speichern**, wenn die Zielseite ihre echte Mobile-Responsive-Variante liefern soll; anschließend Profil neu starten. +3. **390 × 844 oder Phone-fit bewusst pro Profil anwenden**, wenn die Zielseite ihre echte Mobile-Responsive-Variante liefern soll; die UI startet ein laufendes Profil dafür kontrolliert neu und verbindet den Canvas anschließend wieder. 4. **1440 × 900 nicht aus dem r49-Lauf bewerten.** Bei einer konkreten Desktop-Layout-Anforderung separat auf demselben Gerät und Transport messen. 5. **Browserfenster an den VNC-Framebuffer anpassen.** Der integrierte PR-47-Pfad hält Chrome innerhalb des sichtbaren Desktops. 6. **Mobile User-Agent nicht global erzwingen.** Viewport, Plattform/Fingerprint und gewünschte Website-Variante müssen pro Profil zusammenpassen. @@ -135,7 +154,7 @@ Frühere isolierte KasmVNC-1.4- und Selkies-POCs sowie der neue Selkies-Readines | Rang | Implementierung | Warum sie den größten Nutzen bringt | Abnahmekriterium | |---:|---|---|---| | 1 | **iOS-IME-Bridge über fokussierbares Hidden Textarea** | öffnet die native Tastatur zuverlässig und unterstützt Komposition, Autokorrektur und Sonderzeichen statt nur einzelner Key-Events | `beforeinput`/composition bis RFB oder CDP; Umlaute, Emoji und CJK auf iPhone geprüft | -| 2 | **Kompakte Keyboard-Zubehörleiste** | Esc, Tab, Enter, Pfeile, Cmd/Ctrl, Alt und Backspace fehlen auf der iPhone-Tastatur oder sind umständlich | einhändig erreichbar, 44-Pixel-Ziele, Sticky nur bei aktivem Input | +| 2 | **Kompakte Keyboard-Zubehörleiste** | Esc, Tab, Enter, Pfeile, Cmd/Ctrl, Alt und Backspace fehlen auf der iPhone-Tastatur oder sind umständlich | einhändig erreichbar, kompakte 36-Pixel-Ziele, Sticky nur bei aktivem Input | | 3 | **Trackpad-/Direkt-Touch-Umschalter** | Desktop-Seiten brauchen präzisen Cursor, mobile Seiten direkte Taps | sichtbarer Modus, Cursor-Speed, Tap/Drag/Right-click und Haptik geprüft | | 4 | **Explizite Gesten-Zustandsmaschine** | verhindert Konflikte zwischen Remote-Scroll, Pinch-Zoom, Pane-Resize und Browsernavigation | ein Finger = Pointer, zwei Finger = Scroll, Pinch = lokaler Zoom; keine Ghost-Clicks | | 5 | **Paste Sheet 2.0 mit Queue und Zielanzeige** | baut auf dem vorhandenen manuellen iOS-Paste-Fallback auf und macht Mehrzeiler/Passwörter kontrollierbar | Preview, Maskierung, Länge, Ziel-Sandbox, Erfolg/Fehler und kein stilles Doppel-Senden | @@ -150,12 +169,12 @@ Frühere isolierte KasmVNC-1.4- und Selkies-POCs sowie der neue Selkies-Readines - Kein physischer iPhone-/Mobile-Safari-E2E. Chromium-Device-Emulation und macOS-WebKit ersetzen ihn nicht. - Safari Remote Automation war lokal nicht freigegeben; das vorbereitete WebKit-Gate meldete diesen Zustand korrekt als blockiert. - Tailscale Serve ist im Tailnet nicht aktiviert. Es existiert daher noch keine geprüfte private HTTPS-iPhone-URL. -- Der r50-VCVM/Neko-Lauf belegt Tailnet-HTTP und geschützten Login über Codex Computer Use, aber wegen fehlgeschlagenem WebRTC-ICE keine Framerate. +- Der r50-VCVM/Neko-Lauf belegt Tailnet-HTTP und geschützten Login über Codex Computer Use, aber wegen fehlgeschlagenem WebRTC-ICE keine Neko-Framerate. Für KasmVNC existiert nur der oben klar begrenzte Canvas-Änderungsproxy. - Kein echter Mobilfunk- und kein Touch-to-Pixel-p50/p95-Bericht. - Der Grid-View ist ein schneller Session-/Profilumschalter, kein gleichzeitiges Multi-Canvas-Monitoring. -- Der Chat-Composer akzeptiert in r51 ausschließlich den verifizierten Codex-Computer-Use-Providervertrag, benötigt aber weiterhin einen realen Host-Bridge-Prozess und ist keine eigenständige Vendor-API. -- Selkies ist reproduzierbar provisioniert, aber noch kein gleichwertiges authentifiziertes Produktdeployment; Sunshine/Moonlight und Guacamole bleiben Architekturpfade. +- Der Chat-Composer akzeptiert in r63 ausschließlich den verifizierten Codex-Computer-Use-Providervertrag. Der aktuelle Gate beweist den synthetisch injizierten Vertrag, nicht einen produktiven Codex Computer Use Browser auf der VCVM; dafür wird weiterhin ein realer Host-Bridge-Prozess benötigt. +- Selkies und Guacamole sind reproduzierbar provisioniert und browserbeobachtet, aber weiterhin keine gleichwertigen authentifizierten Produktdeployments; Sunshine/Moonlight bleibt ein Architekturpfad. ## Freigabeempfehlung -Der r51-Stand ist als **lokales, rollenbasiertes Mobile-Web-MVP** freigabefähig. Für eine externe oder iPhone-spezifische Freigabe fehlen noch Tailscale Serve/HTTPS, ein physisches Safari-Gerät sowie echte Touch-to-Pixel- und Reconnect-Messungen. Die höchste nächste Produktpriorität ist die iOS-IME-Bridge, gefolgt von Keyboard-Zubehörleiste und echter Eingabelatenztelemetrie. +Der aktuelle Stand ist als **VCVM-gehostetes, rollenbasiertes Mobile-Web-MVP** freigabefähig. Für eine direkte iPhone-Freigabe fehlen noch Tailscale Serve/HTTPS, ein physisches Safari-Gerät sowie echte Touch-to-Pixel- und Reconnect-Messungen. Die höchste nächste Produktpriorität ist die direkte Tailnet-Verbindung, danach iOS-IME-Bridge, Keyboard-Zubehörleiste und echte Eingabelatenztelemetrie. Die UI/UX-Details stehen im [Mobile UI/UX and universal browser-action audit](MOBILE-UI-UX-HARNESS-AUDIT-2026-07-21.md). diff --git a/docs/MOBILE-UI-UX-HARNESS-AUDIT-2026-07-21.md b/docs/MOBILE-UI-UX-HARNESS-AUDIT-2026-07-21.md new file mode 100644 index 00000000..6ff1effb --- /dev/null +++ b/docs/MOBILE-UI-UX-HARNESS-AUDIT-2026-07-21.md @@ -0,0 +1,110 @@ +# Mobile UI/UX and universal browser-action audit + +Status: 21 July 2026 · r63 deployed on the VCVM and end-to-end verified + +## Decision + +The mobile workspace uses one dominant live browser, one compact command dock and one composer. Everything else is disclosed on demand. It deliberately does not copy Browser Use branding or proprietary UI assets. The interaction model is informed by Browser Use's documented live preview, streaming messages and follow-up-task flow, Apple's fullscreen and gesture guidance, WCAG 2.2 touch/focus requirements, and OpenAI's screen/mouse/keyboard computer-use loop. + +The default mobile surface contains exactly four persistent actions: **Full**, **Tools**, **Chat** and **Send**. Chat and Tools are mutually exclusive. Chat starts collapsed while a browser is running. Account controls, viewport settings, session switching, profile administration, clipboard tools and typed browser actions live behind Tools. Benchmarks and quality reports stay outside the product UI. + +Primary references: + +- [Browser Use Chat UI tutorial](https://docs.browser-use.com/cloud/tutorials/chat-ui), [live preview](https://docs.browser-use.com/cloud/browser/live-preview) and [follow-up tasks](https://docs.browser-use.com/cloud/agent/follow-up-tasks) +- [OpenAI: Computer-Using Agent](https://openai.com/index/computer-using-agent/) +- [Apple: Going full screen](https://developer.apple.com/design/human-interface-guidelines/going-full-screen) and [Gestures](https://developer.apple.com/design/human-interface-guidelines/gestures) +- [WCAG 2.2](https://www.w3.org/TR/WCAG22/) and [What's New in WCAG 2.2](https://www.w3.org/WAI/standards-guidelines/wcag/new-in-22/) + +## Minimal information architecture + +### 1. Live browser + +- The VNC canvas is the primary surface and consumes all unused height. +- Portrait defaults to a browser-first split; the collapsed workspace allocates up to 82% of the visual height to the live pane. +- Landscape gives the live browser the main horizontal pane and keeps the control pane narrow. +- Fullscreen keeps the same live canvas rather than opening a second stream. +- Fullscreen exposes distinct Fit, Width and Height modes, visual zoom and a compact width/height editor without leaving the live canvas. +- Visual zoom and pane ratio update immediately. A real profile width/height change is saved and a running browser is deliberately restarted so the framebuffer really changes. +- While an iOS-style software keyboard is open, the root follows `visualViewport.height`, layout transitions are disabled and the browser remains above the composer. Nonessential dock controls disappear until the keyboard closes. + +### 2. Command dock + +- **Full** opens the distraction-free live viewer. +- **Tools** opens the only settings/action sheet. +- **Chat** opens or collapses the task history. +- **Send** remains beside the text field in both collapsed and expanded chat states. +- Primary buttons, login controls, access administration and paste actions use a compact 36 by 36 CSS-pixel floor with separation between adjacent actions. This is intentionally smaller than Apple's 44-point recommendation but remains above WCAG 2.2's 24-CSS-pixel minimum target size. +- Text fields retain a 16 px font size to prevent automatic iOS focus zoom even though their surrounding control is compact. + +### 3. Progressive disclosure + +Tools contains: + +- Launch/Stop when the signed-in role may operate the profile. +- Three capability-gated Quick actions: Capture, Copy and Paste. +- View, Sessions and role-appropriate Admin disclosures. +- ProfileViewer actions such as CDP, clipboard and the manual iOS paste fallback. +- Signed-in identity and Log out at the bottom of the sheet, never as a permanent footer. + +The Sessions grid is an honest session selector with name, state, platform and resolution. It does not fake live thumbnails or silently start multiple streams. + +## Quick actions are not bookmarks + +Quick actions are reusable, typed browser commands. They do not store destination URLs and they do not become another navigation bar. The current action vocabulary is: + +`navigate`, `click`, `double_click`, `scroll`, `type_text`, `keypress`, `drag`, `move`, `wait`, `copy`, `paste`, `screenshot`, `viewport`, `fullscreen`, `focus_remote`, `focus_chat`. + +Each command carries an ID, label, action kind, `ui` or `host` scope and optional structured arguments. A host reports the exact actions it supports. The UI enables a Quick action only when that capability is present; unknown action kinds are discarded at the bridge boundary. + +This makes the action schema reusable by a harness adapter without binding the UI to a vendor API. For this product build, execution is intentionally stricter: the injected host must identify itself as `codex-computer-use`. Missing, generic or mislabeled bridges fail closed and keep the composer disabled. There is no local fake-success fallback and no browser credential is passed through the chat UI. The current VCVM/CLI environment does not itself provide a production Codex Computer Use browser runtime; the automated gate injects a deterministic host solely to verify this boundary. Server-backed task history persists messages but does not execute an agent by itself. + +Current trust boundary: the provider identity is an implementation contract, not cryptographic attestation. A stronger deployment should add a signed or session-bound host handshake before broad external exposure. + +## End-to-end evidence + +The authenticated r63 gate ran the production container on the VCVM, selected a real browser profile, connected one live VNC canvas and exercised five device layouts plus the access dashboard. The Mac participated only as the browser/test client through the authenticated SSH tunnel. + +| Surface | Result | +|---|---:| +| iPhone 14 portrait, 390 × 844 | 66 checks passed | +| iPhone SE portrait, 375 × 667 | 59 checks passed | +| iPhone Pro Max portrait, 430 × 932 | 61 checks passed | +| iPhone 14 landscape, 844 × 390 | 58 checks passed | +| Touch tablet portrait, 768 × 1024 | 58 checks passed | +| Authenticated access dashboard | 5 checks passed | +| Total | **307/307 checks, 25 screenshots** | + +The gate verifies, among other things: + +- exactly Full, Tools, Chat and Send as the compact primary controls; +- no benchmark navigation or harness picker; +- no horizontal document overflow; +- compact 36-pixel controls and coarse-pointer behavior; +- an emulated open software keyboard whose Visual Viewport, VNC pane, one-row composer and reachable Send action neither overlap nor leave the visible screen; +- Tools/Chat mutual exclusion and keyboard shortcuts that do not steal input focus; +- a one-row composer with a visible Send control in collapsed and expanded chat; +- account/logout controls absent from the compact surface and present only inside Tools; +- live zoom, pane ratio, viewport persistence, distinct fullscreen Fit/Width/Height behavior, fullscreen focus/inert behavior and one-canvas preservation; +- real VNC connection, pointer hit-testing, clipboard round-trip and manual iOS paste; +- honest session cards and the authenticated access dashboard. + +Fresh verification also passed 106 frontend tests, 250 backend tests, 18 release/mobile/benchmark script tests, the production frontend build and the VCVM deployment-surface gate. A separate disposable viewer-only login saw exactly one assigned profile, rendered one live 1024 × 576 canvas, exposed no profile/access administration and disconnected immediately after the principal was deactivated. Its connected and revoked states were both captured and visually inspected. + +The release preview remains loopback-only on the VCVM. The current Mac-to-VCVM Tailscale path is relayed rather than direct, and Tailscale Serve is disabled by tailnet policy; physical iPhone Safari and private Tailnet HTTPS therefore remain separate release gates. + +## Ten highest-value next input and interaction improvements + +1. **iOS IME bridge.** Use a controlled hidden input for composition events so predictive text, accented characters, emoji and non-Latin keyboards reach the remote browser reliably. +2. **Keyboard accessory strip.** Provide Esc, Tab, Enter, arrows, Backspace and modifier keys in one disclosure that does not cover the canvas. +3. **Direct-touch / trackpad mode.** Make the input model explicit instead of overloading the same gesture with remote click and local scrolling. +4. **Tap-to-control lock.** Require an intentional control mode before remote taps, preventing accidental clicks while the user scrolls the surrounding page. +5. **Paste sheet with preview and queue.** Separate task input from remote-browser paste, show destination state, and report success/failure without recording clipboard contents. +6. **User-defined Quick-action pins.** Let a profile pin typed, capability-checked commands such as Focus chat, Screenshot or Viewport preset; never turn pins into raw script or URL bookmarks. +7. **Risk confirmations.** Require an explicit confirmation for destructive or sensitive host actions, consistent with computer-use safety guidance. +8. **Reconnect input replay protection.** Drop stale pointer/key events across a stream reconnect and visibly restore control state. +9. **Server-backed task sessions.** Persist conversation IDs, task messages, follow-ups and cancellation state so chat history survives refresh without pretending a local component is an agent runtime. +10. **Touch-to-pixel telemetry and physical-device gate.** Measure touch dispatch to observed frame change on a real iPhone over Tailnet HTTPS, alongside reconnect, keyboard and fullscreen tests. + +## Release boundary + +The r63 UI is ready as a compact VCVM-hosted mobile-web MVP. It is not yet evidence of a production Codex host bootstrap, physical iPhone Safari compatibility, public-network safety, WAN frame rate or touch-to-pixel latency. Those claims must stay blocked until a real host bridge, private HTTPS and physical-device measurements are present. diff --git a/docs/PAPERCLIP-BROWSER-ACCESS-CONTROL-PROPOSAL.md b/docs/PAPERCLIP-BROWSER-ACCESS-CONTROL-PROPOSAL.md index cd33c237..bff00fc9 100644 --- a/docs/PAPERCLIP-BROWSER-ACCESS-CONTROL-PROPOSAL.md +++ b/docs/PAPERCLIP-BROWSER-ACCESS-CONTROL-PROPOSAL.md @@ -1,6 +1,6 @@ # Paperclip-gestützte Browser-Zugriffskontrolle -Stand: 2026-07-21 · Status: r51 lokal implementiert und Ende-zu-Ende geprüft; bereit für Fork-Review +Stand: 2026-07-21 · Status: r63 auf der VCVM implementiert und Ende-zu-Ende geprüft; bereit für Fork-Review ## Kurzentscheidung @@ -18,24 +18,26 @@ Die erste, lokale Policy-Schicht ist in diesem Fork umgesetzt. Sie bleibt absich - Menschen erhalten lokale Passwort-Logins mit scrypt-Hash und kurzlebigem, signiertem `HttpOnly`-Cookie. - Paperclip-Agenten erhalten einen individuellen, hochentropischen Bearer-Key; nur dessen Hash wird persistiert. Der Klartext erscheint ausschließlich bei Anlage oder Rotation. - Die Policy wird serverseitig vor Profilauflistung, Einzelprofil, VNC, Clipboard, Lifecycle, CDP-REST und CDP-WebSocket geprüft. Nicht freigegebene Profile erscheinen als `404`. -- VNC mit `view` bleibt tatsächlich lesbar: nur RFB-Displayverhandlung und Frame-Requests werden weitergereicht; Tastatur, Maus und Clipboard-Eingaben werden serverseitig verworfen. +- VNC mit `view` bleibt tatsächlich lesbar: Die stateful RFB-Filterung erlaubt nur Displayverhandlung, eine kleine sichere Encoding-Menge und Frame-Requests; Tastatur, Maus und Clipboard-Eingaben werden serverseitig verworfen. +- Bereits aktive VNC- und CDP-WebSockets erhalten eine widerrufbare Lease. Deaktivierung, Grant-/Rollenänderung, Passwortwechsel oder Agent-Key-Rotation trennt bestehende Verbindungen sofort statt erst beim nächsten Request. - Das Dashboard verwaltet Profile-Sandboxes, Personen, Paperclip-Agenten, Grants, Deaktivierung und Key-Rotation. Browsersteuerung und CDP-Automation sind getrennt, damit zum Beispiel `operate + automate` auf derselben Sandbox möglich ist. Eine kompakte Vorschau zeigt die tatsächlich sichtbaren Profile und effektiven Fähigkeiten. Nichtadministratoren erhalten weder Profil-Administration noch Zugang zur Access-Verwaltung. - Erlaubte Lifecycle-/CDP-Aktionen und abgelehnte REST-/WebSocket-Policyentscheidungen erzeugen Audit-Metadaten. Credentials, Clipboard, CDP-Nutzdaten und Browserinhalte werden nicht in den Audit-Events gespeichert. Die Live-Abnahme lief isoliert gegen eine frische lokale Datenbank: zwei Sandboxes (`research`, `finance`), ein `view`-Nutzer und ein `automate`-Agent. Der Nutzer sah nur `research`; direkte `finance`-, Lifecycle- und Admin-Anfragen wurden mit `404` beziehungsweise `403` abgewiesen. Der Agent sah per eigenem Bearer-Key nur `research`; sein alter Key lieferte nach Rotation `401`. -## Frische r51-Browser-E2E-Abnahme (21. Juli 2026) +## Frische r63-Browser-E2E-Abnahme (21. Juli 2026) Die Policy wurde zusätzlich in einem nur auf `127.0.0.1` gebundenen Container mit isolierten Testprofilen und lokalen Wegwerf-Credentials geprüft. Nutzerbereitgestellte Zugangsdaten wurden nicht verwendet oder persistiert. -- Der authentifizierte Mobile-Gate prüfte fünf Viewports plus Access-Dashboard und bestand **276/276 Checks** mit **23 Screenshots**. -- Codex Computer Use meldete sich als `view`-Nutzer an, sah ausschließlich das laufende `beta`-Profil, erreichte einen echten verbundenen VNC-Canvas und fand weder Access- noch Launch-/Stop-Aktionen. Die iPhone-14-Ansicht hatte bei 390 px keinen horizontalen Overflow. -- Codex Computer Use meldete sich danach als Wegwerf-Admin an und vergab dem Paperclip-Testagenten im echten Dashboard kombiniert `operate + automate`. Die effektive Vorschau zeigte genau das `beta`-Profil mit `Operate + CDP automation`. +- Der authentifizierte Mobile-Gate prüfte fünf Viewports plus Access-Dashboard und bestand **307/307 Checks** mit **25 Screenshots**. Konto/Logout blieb außerhalb des Tool-Sheets verborgen; der Composer blieb in allen Viewports einzeilig und vollständig sichtbar. Der Keyboard-Gate bestätigte zusätzlich Visual-Viewport-Anpassung ohne Überlappung von VNC, Composer oder Send-Aktion. +- Ein kurzlebiger echter `view`-Nutzer meldete sich am laufenden VCVM-Produkt an, sah ausschließlich das zugewiesene Profil, erreichte genau einen verbundenen 1024 × 576 VNC-Canvas und fand weder Profil- noch Access-Administration. Der Screenshot zeigte echten Browserinhalt statt einer schwarzen oder statischen Fläche. +- Die Deaktivierung dieses Nutzers trennte den bereits verbundenen VNC-WebSocket sofort. Das Wegwerfkonto blieb danach inaktiv; es wurden keine Nutzerzugangsdaten in Reports oder Screenshots geschrieben. +- Ein früherer isolierter Adminpfad vergab dem Paperclip-Testagenten im echten Dashboard kombiniert `operate + automate`. Die effektive Vorschau zeigte genau das freigegebene Profil mit `Operate + CDP automation`. - Derselbe Agent sah per rotiertem Wegwerf-Key genau ein `beta`-Profil. Ein Lifecycle-Aufruf erreichte die erlaubte „bereits laufend“-Antwort, CDP lieferte HTTP 200 und ein direkter `alpha`-Aufruf HTTP 404. - Abgelehnte REST- und VNC-WebSocket-Entscheidungen werden als `profile.permission.` mit Sandbox-/Profilkennung und `denied` protokolliert, ohne Secrets oder Browserinhalt. -- Die vollständige Backend-Suite lief mit **223 bestanden**; die Frontend-Suite mit **75 bestanden**, gefolgt von einem erfolgreichen Produktions-Build. +- Die vollständige Backend-Suite lief mit **250 bestanden**; die Frontend-Suite mit **106 bestanden**, ergänzt um **18** Release-/Mobile-/Benchmark-Skripttests und einen erfolgreichen Produktions-Build. -Diese Abnahme belegt die lokale Produktoberfläche und die serverseitige Entscheidung gemeinsam. Sie ersetzt keine externe Production-Abnahme, veröffentlicht keine Test- oder Produktions-Credentials und enthält keine Browserinhalte. +Diese Abnahme belegt die VCVM-Produktoberfläche und die serverseitige Entscheidung gemeinsam. Der deterministische Mobile-Gate prüft nur den injizierten `codex-computer-use`-Hostvertrag; er behauptet keinen produktiven Codex Computer Use Browser auf der VCVM. Die Abnahme ersetzt keine externe Production-Abnahme und veröffentlicht keine Test- oder Produktions-Credentials. Lokale Vision-Artefakte mit Browserinhalt bleiben unversioniert. ## Zielbild diff --git a/docs/REMOTE-STREAMING-BENCHMARK.md b/docs/REMOTE-STREAMING-BENCHMARK.md index 5c505b07..fb1ca6fe 100644 --- a/docs/REMOTE-STREAMING-BENCHMARK.md +++ b/docs/REMOTE-STREAMING-BENCHMARK.md @@ -23,9 +23,9 @@ Für den aktuellen CloakBrowser Manager bleibt **KasmVNC 1.3.3 + noVNC 1.4.x bei | KasmVNC 1.3.3 + noVNC 1.4.x | Vollständiger echter Browserstream und Mobile-Vollbild lokal geprüft | E2E-verifiziert | Beibehalten | | KasmVNC 1.4 | Vollständiger isolierter Browser-, Mobile- und RFB/CDP-E2E-Lauf gegen dieselbe App-Konfiguration bestanden; kein Performancegewinn gegenüber 1.3.3 | E2E-verifiziert, lokale Stichprobe | Nicht migrieren | | noVNC 1.7 | Build scheitert ohne Migration an ESM-/Export-Änderungen und entfernter `showDotCursor`-API | Kompatibilitätsprüfung | Nicht direkt aktualisieren | -| Selkies | Echter CloakBrowser auf X11, JPEG- und H.264-Frame im Browser sowie Remote-Eingabe lokal nachgewiesen; keine Produktintegration und kein brauchbares mobiles Raw-UI | Isolierter Browser-E2E-POC | Nicht als Drop-in ersetzen | +| Selkies | Echter Browserstream und Eingabe lokal nachgewiesen; r55 zusätzlich 20 HTTP-/WebSocket- und fünf mobile First-frame-Beobachtungen; keine Produktintegration und instabile Reload-Zeit | Isolierter Browser-E2E-POC | Nicht als Drop-in ersetzen | | Sunshine + Moonlight | Architektur und Clientmodell geprüft; kein passender allgemeiner Web-Embed-Pfad | Architekturprüfung | Kein Web-MVP-Core | -| Apache Guacamole | Browser-/Touch-Unterstützung und Gateway-Architektur geprüft; bestehender KasmVNC-Server hat bewusst keinen Raw-VNC-TCP-Port | Architektur-/Integrationsprüfung | Kein Web-MVP-Core | +| Apache Guacamole | Version 1.6 lokal provisioniert; 20 HTTP-Läufe und fünf mobile First-frame-Beobachtungen bestanden, aber zusätzliche Gateway-, Policy- und CDP-Integration fehlt | Isolierter Gateway-E2E | Kein Web-MVP-Core | | Browser-Use Chat UI | Interaktions- und Informationsarchitektur geprüft | UI-Referenz | Als UX-Referenz, nicht als Streamingstack | ## Upstream-Snapshot @@ -105,6 +105,26 @@ Der unabhängig wiederholte Lauf vom 21. Juli 2026 ergab: Damit ist die lokale Transport-Provisionierung reproduzierbar. Der Lauf misst jedoch weder Profilstart noch ersten nichtschwarzen Frame, Bildrate, Remote-Eingabe, Rechteintegration, Tailnet noch Mobile Safari. Er ist daher kein Beweis, dass Selkies den vollständigen KasmVNC/noVNC-Produktpfad überholt. Die Produktionsentscheidung bleibt unverändert, bis Selkies dieselben authentifizierten Profil-, Policy-, Canvas-, Mobile- und Eingabe-Gates besteht. +### r55 Selkies-/Guacamole-/Kasm-Browserbeobachtung + +Der r55-Nachtest trennte erneut rohe Shell-/Handshake-Zeiten von sichtbarer Browserbereitschaft. Selkies und Guacamole liefen jeweils isoliert auf Loopback; KasmVNC/noVNC lief als vollständiger Manager-Pfad mit Profilwahl und Mobile-UI. Die Shell-Messungen verwendeten 20 Läufe: + +| Kandidat | Erfolg | Median | p95 | Aussagegrenze | +|---|---:|---:|---:|---| +| Selkies HTTP shell | 20/20 | total **0,708 ms** | **1,621 ms** | HTTP, kein Frame | +| Selkies WebSocket | 20/20 | handshake **0,756 ms** | **1,882 ms** | Upgrade, kein Frame | +| Guacamole 1.6 HTTP shell | 20/20 | total **1,749 ms** | **5,443 ms** | Gateway-HTML, kein Frame | + +Anschließend öffnete Codex Computer Use jedes lokale Mobile-UI fünfmal und markierte den ersten nichtschwarzen sichtbaren Frame. Diese Beobachtung ist näher an der wahrgenommenen Bereitschaft, bleibt aber wegen der unterschiedlichen Produktintegration ein Richtungswert: + +| Stack | fünf Beobachtungen | Median | Einordnung | +|---|---|---:|---| +| KasmVNC/noVNC im Manager | 289 / 230 / 180 / 161 / 157 ms | **180 ms** | vollständiger Profil-, Policy-, VNC- und Mobile-UI-Pfad | +| Apache Guacamole 1.6 | 917 / 363 / 351 / 348 / 350 ms | **351 ms** | rohes Gateway-UI; kein Cloak-Policy-/CDP-Lifecycle | +| Selkies Chromium | 296 / 5.249 / 5.272 / 5.296 / 5.323 ms | **5.272 ms** | schneller Erstlauf, danach reproduzierbare Reload-Verzögerung | + +Der Selkies-WebSocket-Handshake ist damit sehr schnell, aber der sichtbare Reload-Pfad in diesem Setup nicht. Guacamole zeigte einen brauchbaren ersten Frame, bringt jedoch eine zusätzliche Gateway-Schicht und keine vorhandene Manager-Integration mit. KasmVNC/noVNC bleibt die Empfehlung, weil es im gemessenen Gesamtpfad zugleich am schnellsten und vollständig integriert war. Es wurde weiterhin kein FPS-, WAN- oder Touch-to-Pixel-Wert erfunden. + ### r50 VCVM/Neko über Tailscale Am 21. Juli 2026 wurde zusätzlich ein bereits laufender Neko/Chrome-Stack auf der VCVM über Tailscale geprüft. Dieser Lauf ist ein Transport- und Login-Beleg, kein fairer Ersatzbenchmark gegen den CloakBrowser-KasmVNC-Pfad. @@ -121,6 +141,17 @@ Am 21. Juli 2026 wurde zusätzlich ein bereits laufender Neko/Chrome-Stack auf d Codex Computer Use absolvierte den geschützten Login und beobachtete `/ws`. WebRTC ICE blieb aber bei `checking` und wechselte danach zu `failed`; das Video blieb bei `readyState 0`. Deshalb wurde **kein FPS-Wert** berichtet. Die nächste sinnvolle Performance-Arbeit ist direkte Tailnet-Konnektivität beziehungsweise UDP/ICE zu reparieren und erst danach Frame- und Touch-to-Pixel-Messungen zu wiederholen. +### Aktueller CloakBrowser-Manager auf der VCVM + +Der vollständige KasmVNC/noVNC-Produktpfad wurde anschließend selbst auf der VCVM betrieben und getrennt VCVM-lokal sowie vom Mac über den authentifizierten SSH-/Tailscale-Tunnel gemessen. + +| Pfad | Health median/p95 | VNC open median/p95 | erstes RFB-Frame median/p95 | +|---|---:|---:|---:| +| VCVM Loopback | 0,514 / 1,179 ms | 6,689 / 9,989 ms | 19,558 / 26,837 ms | +| Mac über SSH + Tailscale DERP(nue) | 61,8 / 125,6 ms | 195,2 / 266,8 ms | 204,6 / 328,6 ms | + +Eine synthetische Bewegungsseite erzeugte **6,96 sichtbare Canvas-Änderungen/s** VCVM-lokal und **4,18/s** über den Relay-Pfad. Diese Beobachtung misst veränderte gerenderte Canvas-Zustände, nicht Encoder-FPS, nicht jedes RFB-Update und keine Touch-to-Pixel-Antwort. Sie zeigt dennoch, dass der Relay-Pfad die sichtbare Aktualisierung weiter reduziert. `tailscale ping` stellte keine Direktverbindung her; der Mac hatte öffentliches IPv4 ohne IPv6, die VCVM öffentliches IPv6 ohne gefundenes IPv4. Die höchste Performance-Priorität ist daher ein gemeinsamer direkter Tailnet-Adresspfad. + ## KasmVNC 1.3.3 gegen 1.4.0: isolierter A/B-Lauf Am 20. Juli 2026 wurden beide KasmVNC-Versionen in getrennten lokalen Containern mit identischem 1024-×-576-Profil, deaktiviertem Clipboard-Sync, derselben aktuellen Frontend-Ausgabe und demselben aktuellen `browser_manager.py` geprüft. Die App war jeweils nur auf `127.0.0.1` gebunden; der normale Produktionscontainer blieb unverändert. Für KasmVNC 1.4 war das aktuelle Backend notwendig: Das zuvor gebaute Image enthielt noch den bereits behobenen Fehler, `search_engine` als nicht unterstütztes Chromium-Launch-Argument weiterzugeben. @@ -199,9 +230,9 @@ python3 scripts/streaming_benchmark_runner.py \ --latest-markdown docs/streaming-benchmark-latest.md ``` -Der Runner unterscheidet absichtlich zwischen `measured`, `not_installed` und `architecture_only`. Eine fehlende lokale Installation oder ein reiner Architekturpfad erhält keine erfundenen Zeiten; ein erreichbarer HTTP-/WebSocket-/Command-Kandidat erhält dagegen rohe Messungen und Median-Min-Max-P95-Zusammenfassungen. Der Report ist als Headless-/Offline-Diagnostik gedacht; r51 zeigt Benchmarks nicht in der mobilen UI. Die öffentliche Projektion enthält bewusst keine lokalen Pfade, Endpunkte, Commands, Header oder Prozessausgaben. +Der Runner unterscheidet absichtlich zwischen `measured`, `not_installed` und `architecture_only`. Eine fehlende lokale Installation oder ein reiner Architekturpfad erhält keine erfundenen Zeiten; ein erreichbarer HTTP-/WebSocket-/Command-Kandidat erhält dagegen rohe Messungen und Median-Min-Max-P95-Zusammenfassungen. Der Report ist als Headless-/Offline-Diagnostik gedacht; r63 zeigt Benchmarks nicht in der mobilen UI. Die öffentliche Projektion enthält bewusst keine lokalen Pfade, Endpunkte, Commands, Header oder Prozessausgaben. -Der Browser-/UI-Gate-Runner liegt unter `scripts/mobile_ui_gate.py`. Er prüft fünf Viewports (iPhone 14, iPhone SE, iPhone Pro Max, iPhone 14 Landscape und Touch-Tablet), Touch-Ziele, Overflow, Split-Geometrie, den verifizierten Codex-Computer-Use-Composer, Grid, Fullscreen-Fokus und – mit einer Profil-ID – einen echten VNC-Canvas. Der authentifizierte r51-Lauf bestand **276/276 Checks** und erzeugte **23 Screenshots**, einschließlich Access-Dashboard. Mit Profil-ID öffnet der Runner außerdem den manuellen iOS-Paste-Fallback, prüft dessen Touch-Ziele und bestätigt den kontrollierten Clipboard-Bridge-Rundlauf, ohne Clipboard-Text im Report zu speichern. Optional tippt `--remote-probe-url` eine harmlose, eindeutige URL per Keyboard-Events durch noVNC/RFB und verifiziert die Zielseite danach über den CDP-Proxy. Seine Screenshotprüfung validiert Abmessungen und Mindestdateigröße; die abschließende semantische Sichtprüfung bleibt bewusst ein separater menschlicher oder Vision-Agent-Gate. +Der Browser-/UI-Gate-Runner liegt unter `scripts/mobile_ui_gate.py`. Er prüft fünf Viewports (iPhone 14, iPhone SE, iPhone Pro Max, iPhone 14 Landscape und Touch-Tablet), kompakte 36-px-Controls, Visual-Viewport-Keyboard-Geometrie, Overflow, Split-Geometrie, den verifizierten Codex-Computer-Use-Hostvertrag, Grid, die Fullscreen-Modi Fit/Width/Height und – mit einer Profil-ID – einen echten VNC-Canvas. Der authentifizierte r63-Lauf bestand **307/307 Checks** und erzeugte **25 Screenshots**, einschließlich geöffnetem Keyboard und Access-Dashboard. Zusätzlich prüft er den einzeiligen sichtbaren Composer und dass Kontoaktionen nur hinter Tools liegen. Mit Profil-ID öffnet der Runner außerdem den manuellen iOS-Paste-Fallback, prüft dessen Touch-Ziele und bestätigt den kontrollierten Clipboard-Bridge-Rundlauf, ohne Clipboard-Text im Report zu speichern. Optional tippt `--remote-probe-url` eine harmlose, eindeutige URL per Keyboard-Events durch noVNC/RFB und verifiziert die Zielseite danach über den CDP-Proxy. Seine Screenshotprüfung validiert Abmessungen und Mindestdateigröße; die abschließende semantische Sichtprüfung bleibt bewusst ein separater menschlicher oder Vision-Agent-Gate. Der Hostvertrag wird im Gate deterministisch injiziert und ist kein Beleg für einen produktiven Codex Computer Use Browser auf der VCVM. ## Gepinnte Referenzstände @@ -218,7 +249,7 @@ Die folgenden `HEAD`-Stände wurden am 20. Juli 2026 direkt aus den öffentliche ## Offene Nachweise - Selkies: echter Mobile-Safari-/WebKit-Lauf auf einem physischen iPhone, Authentifizierung und Tailscale-HTTPS sowie ein bewusstes mobiles Client-Layout statt des nachgewiesenen Letterbox-POCs. -- Selkies: WebRTC-Modus unter derselben echten Browser- und Eingabeprobe; der reproduzierbare r51-Lauf misst ausschließlich HTTP-/WebSocket-Bereitschaft. +- Selkies: WebRTC-Modus unter derselben echten Browser- und Eingabeprobe; der r55-Lauf ergänzt WebSocket- und First-frame-Beobachtungen, aber noch keinen gleichwertigen WebRTC-/Policy-Produktpfad. - Physisches iPhone: Safari über freigegebenes Tailscale Serve/HTTPS. Der konkrete Aktivierungsversuch wurde vom Tailnet mit `Serve is not enabled on your tailnet` abgelehnt; ein Administrator muss Serve freigeben, bevor eine ehrliche iPhone-URL und der Safari-E2E-Test möglich sind. - Mehrfachmessung der kompletten Interaktionskette: fünf Start-zu-erster-nichtleerer-Frame- und Eingabeantwort-Läufe je Version, nicht nur API-Launches. diff --git a/docs/VCVM-DEPLOYMENT.md b/docs/VCVM-DEPLOYMENT.md new file mode 100644 index 00000000..dfad88ee --- /dev/null +++ b/docs/VCVM-DEPLOYMENT.md @@ -0,0 +1,120 @@ +# VCVM Deployment + +This deployment path runs the whole CloakBrowser Manager product on the VCVM: +FastAPI, the built React UI, SQLite data, KasmVNC and launched browser profiles +stay inside one Docker service with one persistent Docker volume. + +The Mac or iPhone is only a presentation and input client. Do not run a second +Manager, browser profile, database or VNC server on the Mac for this deployment. + +## Safety model + +- Host: `vcvm` +- Remote path: `/home/coder/cloakbrowser-manager` +- Docker project and container: `cloakbrowser-manager-vcvm` +- Data volume: `cloakbrowser-manager-vcvm-data` +- Manager bind: `127.0.0.1:${MANAGER_PORT:-18115}` on the VCVM only +- Required auth: `AUTH_TOKEN` +- Required policy layer: `ACCESS_CONTROL_ENABLED=1` +- Optional private iPhone access: Tailscale Serve HTTPS after the app proves + `auth_required=true` and `access_control_enabled=true` + +The compose file does not publish any raw VNC port. Browser viewing remains +behind the authenticated Manager proxy. + +Profile health can optionally enrich its browser-path observation with the +existing VCVM proxychecker. This dependency is not part of `/health`, and a +stopped or unavailable proxychecker does not make the Manager unhealthy. + +The current acceptance deployment uses custom loopback port `18116` to avoid a +legacy preview on `18115`; the documented default remains `18115`. Use the same +port consistently for deploy, smoke checks and an SSH tunnel. + +## Deploy + +Create a long bootstrap token in a local secret file with mode `600`. The token +is sent to the VCVM over SSH and written to +`/home/coder/cloakbrowser-manager/.env.vcvm` with mode `600`. + +```bash +mkdir -p ~/.config/cloakbrowser +openssl rand -base64 48 > ~/.config/cloakbrowser/vcvm-auth-token +chmod 600 ~/.config/cloakbrowser/vcvm-auth-token +./scripts/deploy_vcvm.sh --auth-token-file ~/.config/cloakbrowser/vcvm-auth-token +``` + +The script syncs the current checkout to the VCVM, builds the Docker image on +the VCVM, starts the stack and checks: + +1. `/health` answers locally on the VCVM. +2. `/api/auth/status` reports required auth. +3. `/api/auth/status` reports access control enabled. + +### Optional VCVM-local proxychecker + +First ensure the already-authorized proxychecker listens only on the VCVM's +Docker bridge host address, not on a public or Tailnet-wide socket. The Manager +container reaches that boundary through the explicit `host.docker.internal` +host-gateway mapping. + +Then deploy with the fixed local service URL: + +```bash +PROXYCHECKER_URL=http://host.docker.internal:18899 \ + ./scripts/deploy_vcvm.sh --auth-token-file ~/.config/cloakbrowser/vcvm-auth-token +``` + +The deploy script defaults to `http://host.docker.internal:18899` when +`PROXYCHECKER_URL` is unset, rejects credentials/public hosts/arbitrary paths, +and writes only the validated URL plus the single `host.docker.internal` +allow-list entry into the mode-600 VCVM env file. Export +`PROXYCHECKER_URL=` (empty) to disable enrichment explicitly; browser +reachability, fingerprint consistency and conservative BrowserScan +classification continue independently. + +## Private Tailscale HTTPS + +If Tailscale Serve is enabled for the tailnet and a private HTTPS port is free: + +```bash +./scripts/deploy_vcvm.sh --auth-token-file ~/.config/cloakbrowser/vcvm-auth-token --serve-private +``` + +Use a different private HTTPS port if `443` is already configured: + +```bash +TAILSCALE_HTTPS_PORT=8443 ./scripts/deploy_vcvm.sh --auth-token-file ~/.config/cloakbrowser/vcvm-auth-token --serve-private +``` + +The script refuses to replace an existing Serve entry on the selected HTTPS +port. It also refuses to publish unless the protected Manager is already running +with scoped access control. + +## Validation + +Run the local deployment-surface checks before changing the VCVM: + +```bash +python3 scripts/test_vcvm_deployment.py +``` + +Run a remote smoke after deploy: + +```bash +ssh vcvm 'curl -fsS http://127.0.0.1:18115/health' +ssh vcvm 'curl -fsS http://127.0.0.1:18115/api/auth/status' +``` + +The second command must report `auth_required: true` and +`access_control_enabled: true`; do not publish a URL if it does not. + +For Mac-only validation of a loopback Manager, keep the runtime on the VCVM and +forward only the UI connection: + +```bash +ssh -N -L 18118:127.0.0.1:18116 vcvm +``` + +Then open `http://127.0.0.1:18118/`. This URL is a client tunnel, not a local +Manager. For iPhone access, use private Tailscale Serve HTTPS once the tailnet +administrator has enabled Serve; never replace it with a raw Tailnet HTTP bind. diff --git a/docs/drafts/PAPERCLIP-BROWSER-ACCESS-GITHUB-DISCUSSION.md b/docs/drafts/PAPERCLIP-BROWSER-ACCESS-GITHUB-DISCUSSION.md index 93c6764a..4b21607b 100644 --- a/docs/drafts/PAPERCLIP-BROWSER-ACCESS-GITHUB-DISCUSSION.md +++ b/docs/drafts/PAPERCLIP-BROWSER-ACCESS-GITHUB-DISCUSSION.md @@ -14,6 +14,8 @@ This is Paperclip-inspired control-plane behavior, not a dependency on a Papercl - Local people use signed sessions; agents use individual opaque credentials whose hashes alone are persisted. A generated agent key is shown only once. - The backend enforces scopes for profile discovery, VNC, clipboard, CDP REST, and CDP WebSockets. - `view`, `interact`, `operate`, and `automate` are distinct. CDP is never implied by a view grant. +- Existing VNC/CDP WebSockets carry revocable leases, so deactivation, grant changes and credential rotation terminate active access immediately. +- Viewer-only RFB filtering is stateful, limits negotiated encodings and drops keyboard, pointer and clipboard input before the upstream server. - The compact admin dashboard manages people, agent identities, sandbox grants, deactivation, and agent-key rotation. Its control tier and independent CDP toggle support combinations such as `operate + automate`, and an effective-access disclosure lists the concrete profiles and resulting capabilities. - The bootstrap-admin path and `ACCESS_CONTROL_ENABLED=1` make migration explicit and reversible. - Denied REST and WebSocket policy decisions are recorded as metadata-only audit events; browser content, clipboard data and credentials are never audit payloads. @@ -27,9 +29,9 @@ VNC and CDP are direct API/WebSocket surfaces. A frontend-only filter would stil - A viewer saw only its assigned sandbox; a direct request for another profile and a lifecycle request both returned the same not-found response. - A Paperclip-style agent credential with `operate + automate` saw only its assigned sandbox, passed lifecycle authorization, reached scoped CDP, and received the same not-found response for a profile in another sandbox. - Rotating an agent key invalidated the old key immediately. -- A viewer-only noVNC connection displayed live frames. A real click sent through that viewer canvas did not reach a controlled remote button; keyboard, pointer, and clipboard input are filtered server-side after a validated RFB handshake. +- A disposable viewer-only login saw exactly one assigned profile and one live 1024 × 576 noVNC canvas, with no profile or access administration. Its captured frame contained real browser content. Deactivating the principal terminated the already-connected VNC session immediately; the test account remained inactive afterward. - The public container check is a metadata-free `/health` endpoint. Runtime/profile counts in `/api/status` require authentication. -- The authenticated mobile acceptance gate passed 276/276 checks across five viewports plus the access dashboard, with 23 screenshots. A separate Codex Computer Use run exercised both the scoped viewer and admin grant-editing flows at 390 px without horizontal overflow. +- The authenticated mobile acceptance gate passed 307/307 checks across five viewports plus the access dashboard, with 25 screenshots. It also verified compact 36 px controls, the open software-keyboard layout, account controls behind Tools, and a one-row composer. The deterministic UI gate injects the `codex-computer-use` host contract for boundary testing; it is not evidence of a production Codex Computer Use browser runtime on the VCVM. No production deployment, upstream merge, public URL, live credential, or browser content is included in this draft. diff --git a/docs/superpowers/plans/2026-07-22-profile-health-proxy-browserscan.md b/docs/superpowers/plans/2026-07-22-profile-health-proxy-browserscan.md new file mode 100644 index 00000000..d372baf8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-profile-health-proxy-browserscan.md @@ -0,0 +1,290 @@ +# Profile Health, Proxy And BrowserScan Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a redacted, access-controlled profile-health result that measures browser-path reachability, saved-config/runtime fingerprint consistency, optional VCVM proxychecker risk, and conservative BrowserScan authenticity after the first successful launch. + +**Architecture:** Keep measurement logic in a dependency-injected `backend/profile_health.py` service, persistence in the existing SQLite module, authorization and task scheduling in FastAPI, and read-only presentation in existing profile surfaces. Launch remains asynchronous with respect to the probe. External sources are optional, fail closed, and are reduced to safe normalized fields before persistence. + +**Tech Stack:** Python 3.11+, FastAPI, Pydantic, SQLite, Playwright async API, httpx, React 19, TypeScript, Vitest/Testing Library, Tailwind CSS. + +**Design source:** `docs/superpowers/specs/2026-07-22-profile-health-proxy-browserscan-design.md` + +**Execution record (22 July 2026):** The implementation landed in commits `e040b76` and `9e14ac8`, and the first documentation/evidence push was verified on GitHub at `1663f36`. Tasks 1-7 are complete. The fresh full suites passed 343 backend, 132 frontend and 26 script tests plus the production build. The protected VCVM deployment on loopback port `18116` passed automatic first-launch health, manual rerun, refresh persistence, masked outbound IP, 100/100 fingerprint consistency and 100/100 BrowserScan authenticity. The authenticated mobile release passed 316 checks and 31 screenshots under the ignored local artifact directory `artifacts/mobile-ui-gate-health-20260722-rerun/`, and the redacted acceptance report passed. The Manager container reached the VCVM-local proxychecker health endpoint, but a real configured-proxy `/check` and live viewer-versus-operator health rerun remain explicit follow-up evidence. Safari Remote Automation is disabled, so WebKit and physical-device proof remain external blockers. Local, `git ls-remote` and GitHub API commit SHAs matched, and GitHub bytes for README, continuation skill, acceptance matrix, plan and implementation were read back successfully. + +--- + +### Task 1: Add normalized health models and persistence + +**Files:** +- Modify: `backend/models.py` +- Modify: `backend/database.py` +- Modify: `backend/tests/test_models.py` +- Modify: `backend/tests/test_database.py` + +- [x] **Step 1: Write failing model and migration tests** + +Add tests proving the response defaults to explicit unavailable/null values, accepts only the six documented states, creates the additive table on an existing database, upserts one row per profile, and deletes the row with the profile. + +- [x] **Step 2: Run focused tests and verify RED** + +Run: `.venv/bin/pytest backend/tests/test_models.py backend/tests/test_database.py -q` + +Expected: FAIL because the health models and persistence helpers do not exist. + +- [x] **Step 3: Implement the schema and normalized repository helpers** + +Add `ProfileHealthResponse`, component source metadata, state literals, safe warning/blocker lists, table creation, `get_profile_health`, `upsert_profile_health`, and profile-delete cleanup. Keep JSON parsing defensive and return normalized empty containers on corrupt legacy content. + +- [x] **Step 4: Verify GREEN** + +Run: `.venv/bin/pytest backend/tests/test_models.py backend/tests/test_database.py -q` + +Expected: all focused tests pass. + +### Task 2: Implement pure redaction, consistency and classifier functions + +**Files:** +- Create: `backend/profile_health.py` +- Create: `backend/tests/test_profile_health.py` + +- [x] **Step 1: Write failing pure-function tests** + +Cover: + +- IPv4/IPv6 masking without retaining the raw value; +- risk-score clamping and authenticity derivation; +- trusted/blocked proxychecker URLs; +- proxychecker response normalization and safe reason categories; +- matching, mismatching and missing fingerprint signals; +- BrowserScan explicit score extraction; +- challenge, consent, timeout and unsupported markup returning a blocker with `score=None`; +- raw external text never surviving normalization. + +- [x] **Step 2: Run the new test file and verify RED** + +Run: `.venv/bin/pytest backend/tests/test_profile_health.py -q` + +Expected: FAIL because `profile_health.py` does not exist. + +- [x] **Step 3: Implement the smallest pure functions** + +Use bounded regular expressions, enum-like constants, length limits, `ipaddress`, `urllib.parse`, and typed dictionaries/dataclasses. Do not introduce a new dependency. + +- [x] **Step 4: Verify GREEN and scan for accidental raw-data fields** + +Run: `.venv/bin/pytest backend/tests/test_profile_health.py -q` + +Run: `rg -n "raw_(ip|proxy|html|text|response)|page_content|exception_message" backend/profile_health.py backend/models.py` + +Expected: tests pass; no persisted or response field is designed for raw external data. + +### Task 3: Implement dependency-injected runtime observations + +**Files:** +- Modify: `backend/profile_health.py` +- Modify: `backend/tests/test_profile_health.py` +- Reference: `backend/browser_manager.py` + +- [x] **Step 1: Write failing async observation tests with fakes** + +Prove that the service opens temporary pages in the existing context, measures browser-path network reachability, evaluates runtime signals, classifies BrowserScan without clicking anything, closes every page in `finally`, normalizes proxychecker responses, and produces a result when individual optional sources are unavailable. + +- [x] **Step 2: Verify RED** + +Run: `.venv/bin/pytest backend/tests/test_profile_health.py -q` + +Expected: FAIL on missing orchestration methods. + +- [x] **Step 3: Implement bounded adapters and orchestrator** + +Implement `ProfileHealthProbe` with injectable clock, HTTP client factory, endpoint URLs, and browser page factory/context. Add per-component timeouts, a fixed public target, and no retries. Aggregate state from component results without manufacturing missing scores. + +- [x] **Step 4: Verify GREEN** + +Run: `.venv/bin/pytest backend/tests/test_profile_health.py -q` + +Expected: all probe tests pass without external network access. + +### Task 4: Add access-controlled APIs and first-launch scheduling + +**Files:** +- Modify: `backend/main.py` +- Modify: `backend/browser_manager.py` only if a minimal launch result hook is needed +- Modify: `backend/tests/test_api.py` +- Modify: `backend/tests/test_access_control.py` +- Create or Modify: `backend/tests/test_profile_health_api.py` + +- [x] **Step 1: Write failing API and authorization tests** + +Cover default read, stored read, viewer/operator/admin permissions, missing/unauthorized 404 equivalence, stopped-profile conflict, duplicate in-flight reuse, redaction, and HTTP 202 scheduling. + +- [x] **Step 2: Write failing launch-scheduling tests** + +Prove first successful launch schedules once, a stored result suppresses automatic rerun, duplicate launch does not duplicate the probe, and a probe failure never changes a successful launch response. + +- [x] **Step 3: Run focused backend tests and verify RED** + +Run: `.venv/bin/pytest backend/tests/test_profile_health_api.py backend/tests/test_api.py backend/tests/test_access_control.py -q` + +Expected: FAIL because the routes and scheduler are absent. + +- [x] **Step 4: Implement scoped routes and in-flight registry** + +Reuse existing scoped-profile helpers, store `asyncio.Task` objects by profile id, attach a done callback that removes the exact completed task, and persist only normalized results. Keep launch response timing independent of the probe. + +- [x] **Step 5: Verify GREEN** + +Run: `.venv/bin/pytest backend/tests/test_profile_health_api.py backend/tests/test_api.py backend/tests/test_access_control.py -q` + +Expected: all focused API and access tests pass. + +### Task 5: Add a compact read-only health presentation + +**Files:** +- Modify: `frontend/src/lib/api.ts` +- Modify: `frontend/src/lib/api.test.ts` +- Create: `frontend/src/components/ProfileHealthSummary.tsx` +- Create: `frontend/src/components/ProfileHealthSummary.test.tsx` +- Modify: `frontend/src/components/ProfileList.tsx` +- Modify: `frontend/src/components/ProfileList.test.tsx` +- Modify: `frontend/src/App.tsx` +- Modify: `frontend/src/App.test.tsx` +- Test: `frontend/src/components/mobile/MobileSplitScreen.test.tsx` + +- [x] **Step 1: Write failing API and rendering tests** + +Assert type-safe fetch/run methods, compact status display, explicit measured/derived/unavailable/skipped labels, operator-only rerun, viewer read access, and absence of raw proxy/IP/error strings. + +- [x] **Step 2: Lock the mobile non-clutter rule with a failing or regression test** + +Assert the live mobile workspace has no persistent `Run scan`, `Benchmark`, or `Proxy check` control. + +- [x] **Step 3: Run focused frontend tests and verify RED** + +Run: `cd frontend && npm test -- --run src/lib/api.test.ts src/components/ProfileHealthSummary.test.tsx src/components/ProfileList.test.tsx src/App.test.tsx src/components/mobile/MobileSplitScreen.test.tsx` + +Expected: new API/component tests fail before implementation; the non-clutter regression remains green. + +- [x] **Step 4: Implement compact read-only UI** + +Use existing tokens and progressive disclosure. Show one small list status and a details panel in the desktop/profile settings surface. Do not add settings, target URLs, proxy inputs, or external page content. + +- [x] **Step 5: Verify GREEN and build** + +Run: `cd frontend && npm test -- --run src/lib/api.test.ts src/components/ProfileHealthSummary.test.tsx src/components/ProfileList.test.tsx src/App.test.tsx src/components/mobile/MobileSplitScreen.test.tsx && npm run build` + +Expected: tests and production build pass. + +### Task 6: Configure and validate the VCVM-local proxychecker boundary + +**Files:** +- Modify: `docker-compose.vcvm.yml` +- Modify: `.env.example` if present +- Modify: `scripts/deploy_vcvm.sh` +- Modify or Create: relevant deployment-surface tests under `scripts/tests/` +- Modify: `docs/VCVM-DEPLOYMENT.md` or the current deployment document + +- [x] **Step 1: Add failing deployment-surface tests** + +Assert the Manager accepts an optional proxychecker URL, the Docker service can reach only the explicitly configured host-local endpoint, the Manager remains bound to loopback, and no credential is rendered into public documentation. + +- [x] **Step 2: Implement optional configuration** + +Add the minimal host gateway/configuration needed for VCVM deployment. Do not default a public or untrusted URL and do not make the Manager healthcheck depend on proxychecker availability. + +- [x] **Step 3: Verify deployment tests** + +Run the focused script/deployment tests identified by `rg -n "deploy_vcvm|docker-compose.vcvm" scripts/tests`. + +Expected: all focused tests pass. + +### Task 7: Run full local release verification + +**Files:** +- Verify all changed files above. + +- [x] **Step 1: Run the complete backend suite** + +Run: `.venv/bin/pytest backend/tests -q` + +Expected: all backend tests pass. + +- [x] **Step 2: Run the complete frontend suite and production build** + +Run: `cd frontend && npm test -- --run && npm run build` + +Expected: all frontend tests and build pass. + +- [x] **Step 3: Run the complete script/release suite** + +Run the repository's canonical script test command documented by the continuation skill/release checklist. + +Expected: all script and release checks pass, including Python 3.11 compilation. + +- [x] **Step 4: Run static diff and secret checks** + +Run: `git diff --check` + +Run the repository's secret/redaction gate against explicitly changed paths. + +Expected: no whitespace, secret, raw proxy, token, or credential findings. + +### Task 8: Deploy and prove the live VCVM path + +**Files:** +- Update: `docs/GOAL-ACCEPTANCE-MATRIX-2026-07-22.md` +- Update: `README.md` +- Update: `.agents/skills/cloakbrowser-manager-development/SKILL.md` only if the proven workflow adds a durable instruction +- Update: this plan's execution record + +- [x] **Step 1: Verify/start the existing proxychecker on VCVM loopback** + +Confirm the correct process owns the selected port and that `/health` returns the expected schema. Do not print `.env`, proxy URLs, or credentials. + +- [x] **Step 2: Deploy Manager on VCVM with the trusted local endpoint** + +Use the existing secret-file deployment path and keep the public bind unchanged. + +- [ ] **Step 3: Run authenticated browser E2E** + +Partial: launch, automatic health, manual rerun, refresh persistence and the admin UI passed live. The credentialed proxychecker `/check` and live viewer-versus-operator rerun are intentionally still open. + +Launch a test profile, prove launch is not blocked by the probe, wait for the stored result, verify masking and source states, rerun manually, refresh, and confirm persistence. Exercise viewer versus operator authorization. + +- [x] **Step 4: Validate the mobile UI and capture safe screenshots** + +Use the existing browser gate across required viewports. Confirm no new mobile clutter, no keyboard regression, no raw IP/proxy/error, and no hidden Full View controls. + +- [x] **Step 5: Update documentation from fresh evidence only** + +Change the matrix status to `Proven` only if VCVM evidence covers the full slice. Record exact test counts, deployment port, screenshot artifact location, and remaining physical Safari/Tailnet blockers without embedding secrets or local-only URLs. + +### Task 9: Commit, push to the fork and verify remote bytes + +**Files:** +- Stage only files intentionally changed by this plan. + +- [x] **Step 1: Review status and explicit staged diff** + +Run: `git status --short` + +Run: `git diff --cached --stat && git diff --cached` + +Expected: no generated `tsbuildinfo`, unrelated benchmark/Fintaro artifacts, credentials, or user-owned files are staged. + +- [x] **Step 2: Commit the vertical slice** + +Use a focused message such as `feat(health): add redacted profile runtime checks`. + +- [x] **Step 3: Push only the active fork branch** + +Push `integrate-pr-47-27-26` to the `fork` remote. Do not push to upstream or Fintaro. + +- [x] **Step 4: Verify the remote SHA and GitHub files** + +Confirm `fork/integrate-pr-47-27-26` equals local `HEAD`, and read back the committed README, continuation skill, design, plan, and implementation files from the fork GitHub URLs. + +- [x] **Step 5: Record execution evidence** + +Mark completed checkboxes, add commit SHA and fresh test/deployment evidence to this plan, and leave all unproven device/network requirements explicit. diff --git a/docs/superpowers/plans/2026-07-22-profile-organization-harness-ui.md b/docs/superpowers/plans/2026-07-22-profile-organization-harness-ui.md new file mode 100644 index 00000000..45ed7971 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-profile-organization-harness-ui.md @@ -0,0 +1,239 @@ +# Profile Organization And Harness UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish the already-started profile organization feature so projects, nested folders, pins, colors and preferred harnesses behave consistently in desktop and mobile UI without weakening the Codex Computer Use execution boundary. + +**Architecture:** Keep authorization scoped to `sandbox_id`; treat `project_id` and `folder_path` as organization metadata only. Move label and ordering rules into one frontend helper, reuse it in desktop and mobile surfaces, and keep execution provider selection separate from the saved preferred-harness label. The verified `codex-computer-use` bridge remains the only host allowed to run typed browser actions. + +**Tech Stack:** React 19, TypeScript, Vitest/Testing Library, FastAPI/Pydantic, SQLite, Tailwind CSS. + +**Execution record (22 July 2026):** Tasks 1-5 were completed in commits `2cf7e57`, `ce3ead7`, `e93331b`, and `be0465f`. The affected backend slice passed 160 tests, the affected frontend slice passed 92 tests, and the production build passed. A subsequent fresh full local run passed 286 backend tests and 126 frontend tests plus the production build. The live VCVM release rerun remains tracked in the acceptance matrix; it is not part of this completed implementation slice. + +--- + +### Task 1: Centralize profile organization labels and ordering + +**Files:** +- Create: `frontend/src/lib/profileOrganization.ts` +- Create: `frontend/src/lib/profileOrganization.test.ts` +- Modify: `frontend/src/components/ProfileList.tsx` +- Test: `frontend/src/components/ProfileList.test.tsx` + +- [x] **Step 1: Write the failing helper tests** + +```ts +import { describe, expect, it } from "vitest"; +import { compareOrganizedProfiles, profileOrganizationLabel } from "./profileOrganization"; + +describe("profile organization", () => { + it("formats project and nested folder without changing the access sandbox", () => { + expect(profileOrganizationLabel({ project_id: "commerce", folder_path: "buyers/us" })) + .toBe("commerce / buyers/us"); + }); + + it("sorts pinned profiles before project, folder, name, timestamp and id", () => { + const rows = [ + { id: "z", name: "Zulu", pinned: false, project_id: "beta", folder_path: "", created_at: "2026-07-22T00:00:00Z" }, + { id: "a", name: "Alpha", pinned: true, project_id: "alpha", folder_path: "qa", created_at: "2026-07-22T00:00:00Z" }, + ]; + expect(rows.sort(compareOrganizedProfiles).map((row) => row.id)).toEqual(["a", "z"]); + }); +}); +``` + +- [x] **Step 2: Run the helper test and verify RED** + +Run: `cd frontend && npm test -- --run src/lib/profileOrganization.test.ts` + +Expected: FAIL because `profileOrganization.ts` does not exist. + +- [x] **Step 3: Implement the shared helper** + +```ts +type OrganizedProfile = { + id: string; + name: string; + pinned: boolean; + project_id: string; + folder_path: string; + created_at: string; +}; + +export function profileOrganizationLabel(profile: Pick) { + const project = profile.project_id || "default"; + return profile.folder_path ? `${project} / ${profile.folder_path}` : project; +} + +export function compareOrganizedProfiles(a: OrganizedProfile, b: OrganizedProfile) { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; + return a.project_id.localeCompare(b.project_id) + || a.folder_path.localeCompare(b.folder_path) + || a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + || b.created_at.localeCompare(a.created_at) + || a.id.localeCompare(b.id); +} +``` + +- [x] **Step 4: Reuse the helper and make group headers unambiguous** + +Import the helper in `ProfileList.tsx`, remove the local duplicate implementations, sort with `compareOrganizedProfiles`, and label each collapsible header as `Profile group: ` so tests and assistive technology can distinguish it from a profile row. + +- [x] **Step 5: Verify the focused desktop tests are GREEN** + +Run: `cd frontend && npm test -- --run src/lib/profileOrganization.test.ts src/components/ProfileList.test.tsx` + +Expected: all tests pass. + +### Task 2: Complete mobile profile and preferred-harness behavior + +**Files:** +- Modify: `frontend/src/components/mobile/MobileSplitScreen.tsx` +- Modify: `frontend/src/components/mobile/MobileSplitScreen.test.tsx` +- Modify: `frontend/src/styles/globals.css` +- Test: `frontend/src/components/mobile/MobileSplitScreen.test.tsx` + +- [x] **Step 1: Preserve the existing RED evidence** + +Run: `cd frontend && npm test -- --run src/components/mobile/MobileSplitScreen.test.tsx` + +Expected current result: six failing tests covering organization labels, pinned ordering, accents and preferred-harness metadata. + +- [x] **Step 2: Add deterministic display helpers in the component** + +```ts +const harnessNames: Record = { + codex: "Codex", + antigravity: "Antigravity", + "claude-code": "Claude Code", + opencode: "OpenCode", + "browser-use": "Browser Use", +}; + +const selectedHarnessName = selected ? harnessNames[selected.harness] : "Codex"; +const selectedHarnessStatus = codexHostReady + ? `${selectedHarnessName} · via Codex Computer Use` + : `${selectedHarnessName} · saved only`; +``` + +Use `profileOrganizationLabel` and `compareOrganizedProfiles` for the selector, tool grid and fullscreen sessions. Render pin state and `--profile-accent` without exposing access or proxy secrets. + +- [x] **Step 3: Keep execution and preference metadata separate** + +Extend the task request metadata with: + +```ts +metadata: { + runner: "codex-computer-use", + selected_runner: "codex-computer-use", + selected_profile_id: selected.id, + selected_profile_name: selected.name, + preferred_harness: selected.harness, + execution_provider: "codex-computer-use", + execution_bridge: "codex-computer-use", + execution: "host", + browser_visible: true, +} +``` + +Do not allow `preferred_harness` to bypass `isCodexVerifiedHost()` or enable host-scoped commands on the server-history fallback. + +- [x] **Step 4: Verify the mobile tests are GREEN** + +Run: `cd frontend && npm test -- --run src/components/mobile/MobileSplitScreen.test.tsx src/lib/taskHarness.test.ts` + +Expected: all mobile and harness boundary tests pass. + +### Task 3: Align profile form validation with the backend contract + +**Files:** +- Modify: `frontend/src/components/ProfileForm.tsx` +- Modify: `frontend/src/App.test.tsx` + +- [x] **Step 1: Add failing form validation assertions** + +Extend the existing organization roundtrip test to assert that `Project` has `pattern="[A-Za-z0-9][A-Za-z0-9._-]*"` and `maxLength=80`, and `Folder` has `maxLength=240`, no leading/trailing slash guidance and a browser validation failure for `/unsafe`. + +- [x] **Step 2: Run the form test and verify RED** + +Run: `cd frontend && npm test -- --run src/App.test.tsx` + +Expected: FAIL because the organization inputs do not yet expose the backend constraints. + +- [x] **Step 3: Add matching client constraints** + +```tsx + + +``` + +Keep server-side Pydantic validation authoritative and retain generic API error handling as a fallback. + +- [x] **Step 4: Verify form, API and production build** + +Run: `cd frontend && npm test -- --run src/App.test.tsx src/lib/api.test.ts && npm run build` + +Expected: tests and build pass. + +### Task 4: Add project/folder context to access grants without changing authorization semantics + +**Files:** +- Modify: `backend/main.py` +- Modify: `backend/tests/test_access_control.py` +- Modify: `frontend/src/lib/api.ts` +- Modify: `frontend/src/components/AccessDashboard.tsx` +- Modify: `frontend/src/components/AccessDashboard.test.tsx` + +- [x] **Step 1: Write a failing API test for access scope summaries** + +Create profiles in one sandbox across two projects/folders and assert `/api/access/sandboxes` returns redacted organization summaries such as `project_ids`, `folder_paths` and profile names/counts without proxy, fingerprint or user-data fields. + +- [x] **Step 2: Run the backend test and verify RED** + +Run: `.venv/bin/pytest backend/tests/test_access_control.py -q` + +Expected: FAIL because the sandbox response currently contains only its identifier and counts. + +- [x] **Step 3: Return a redacted organization summary** + +Build the summary from profiles already visible to the administrator, deduplicate/sort project and folder strings, and never include proxy credentials, launch arguments, user-data paths or fingerprint seeds. + +- [x] **Step 4: Write a failing dashboard test** + +Assert each grant row and effective-access preview names the associated project/folder context while grants continue to use `sandbox_id` as their stored and enforced key. + +- [x] **Step 5: Render the organization context and verify GREEN** + +Run: `cd frontend && npm test -- --run src/components/AccessDashboard.test.tsx && npm run build` + +Expected: tests and build pass with no horizontal mobile overflow. + +### Task 5: Integrated verification and commit + +**Files:** +- Verify all files changed above. + +- [x] **Step 1: Run backend regression tests** + +Run: `.venv/bin/pytest backend/tests/test_models.py backend/tests/test_database.py backend/tests/test_api.py backend/tests/test_access_control.py -q` + +Expected: all tests pass. + +- [x] **Step 2: Run frontend regression tests and build** + +Run: `cd frontend && npm test -- --run src/App.test.tsx src/components/ProfileList.test.tsx src/components/mobile/MobileSplitScreen.test.tsx src/components/AccessDashboard.test.tsx src/lib/profileOrganization.test.ts src/lib/api.test.ts src/lib/taskHarness.test.ts && npm run build` + +Expected: all tests pass and Vite production build succeeds. + +- [x] **Step 3: Check the diff** + +Run: `git diff --check` + +Expected: no whitespace errors. + +- [x] **Step 4: Commit the completed slice** + +```bash +git add backend/models.py backend/database.py backend/main.py backend/tests/test_models.py backend/tests/test_database.py backend/tests/test_api.py backend/tests/test_access_control.py frontend/src/App.tsx frontend/src/App.test.tsx frontend/src/lib/api.ts frontend/src/lib/api.test.ts frontend/src/lib/profileOrganization.ts frontend/src/lib/profileOrganization.test.ts frontend/src/components/ProfileForm.tsx frontend/src/components/ProfileList.tsx frontend/src/components/ProfileList.test.tsx frontend/src/components/mobile/MobileSplitScreen.tsx frontend/src/components/mobile/MobileSplitScreen.test.tsx frontend/src/components/AccessDashboard.tsx frontend/src/components/AccessDashboard.test.tsx frontend/src/styles/globals.css docs/superpowers/plans/2026-07-22-profile-organization-harness-ui.md +git commit -m "feat: organize profiles and surface harness preferences" +``` diff --git a/docs/superpowers/specs/2026-07-22-profile-health-proxy-browserscan-design.md b/docs/superpowers/specs/2026-07-22-profile-health-proxy-browserscan-design.md new file mode 100644 index 00000000..7724f528 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-profile-health-proxy-browserscan-design.md @@ -0,0 +1,254 @@ +# Profile Health, Proxy And BrowserScan Design + +Status: approved objective translated into an implementation contract on 22 July 2026. + +## Purpose + +Give each browser profile a small, honest, read-only health summary after its first successful launch. The summary must answer three separate questions without leaking secrets or pretending that one signal proves another: + +1. Did the launched browser reach the public network through its configured runtime path? +2. Are the browser's observable runtime properties consistent with the saved profile configuration? +3. Did an external public diagnostic surface expose a safe, machine-readable authenticity result, or was that measurement unavailable? + +This is a product-health feature, not a promise of undetectability. It must never label a profile “safe”, “anonymous”, “human”, or “undetectable”. It must preserve the existing authorization boundary and keep the compact mobile workspace free of diagnostic controls. + +## Product behavior + +### Automatic first-run probe + +- Schedule one probe after the first successful browser launch for a profile that has no stored health result. +- Do not make browser launch wait for the probe. +- Do not start duplicate probes for the same profile. +- A failed or unavailable probe is still a stored result and may be rerun manually by an authorized operator. +- Later launches do not rerun automatically merely because the prior result was warning, failed, or unavailable. + +### Manual rerun + +- A profile viewer may read the latest health summary. +- An operator or administrator may request a rerun for a running profile. +- A rerun for a stopped profile returns a conflict response rather than launching it implicitly. +- Missing and unauthorized profiles remain indistinguishable through the existing scoped-profile helper. + +### Human presentation + +- Profile lists may show one compact status dot or short label. +- Profile details may show the latest component results, timestamp, safe warning categories, and source availability. +- The mobile live-browser workspace must not gain a persistent benchmark, scan, or rerun button. +- Raw IP addresses, proxy URLs, credentials, page text, launch arguments, browser storage, cookies, and host paths are never rendered. + +## Data model + +Create an additive `profile_health` SQLite table with one current row per profile. + +| Column | Type | Meaning | +| --- | --- | --- | +| `profile_id` | text primary key | Existing profile identifier; cascade cleanup is performed by repository code. | +| `state` | text | `pending`, `running`, `passed`, `warning`, `failed`, or `unavailable`. | +| `checked_at` | text nullable | UTC ISO timestamp of the completed attempt. | +| `proxy_configured` | integer | Whether the profile has a proxy configured; no proxy value is stored. | +| `proxy_reachable` | integer nullable | Browser-path or trusted proxychecker reachability result. | +| `outbound_ip_masked` | text nullable | Coarse masked display value only, such as `203.0.113.x` or `2001:db8:abcd:…`. | +| `proxy_latency_ms` | real nullable | Measured end-to-end request latency when available. | +| `proxy_risk_score` | integer nullable | Normalized trusted-proxychecker risk score, 0-100. | +| `proxy_authenticity_score` | integer nullable | Explicitly derived as `100 - risk_score`; label the derivation in the API. | +| `fingerprint_consistency_score` | integer nullable | Deterministic config-versus-runtime consistency score, 0-100. | +| `browser_scan_score` | integer nullable | Visible external authenticity percentage when safely classified. | +| `warnings_json` | text | JSON array of whitelisted warning category strings. | +| `blockers_json` | text | JSON array of whitelisted availability/blocker category strings. | +| `error_code` | text nullable | Whitelisted stable error code, never a raw exception message. | +| `sources_json` | text | JSON object describing each component as measured, unavailable, skipped, or derived. | + +The table stores only the latest result for the MVP. Historical observations belong to a later metrics feature and must not be inferred from this row. + +## API contract + +### Read latest health + +`GET /api/profiles/{profile_id}/health` + +- Required capability: `view`. +- Returns a stable response even before any probe: `state: "unavailable"`, `checked_at: null`, and all unmeasured components `null`. +- Uses explicit source states so the UI can distinguish missing, skipped, blocked, derived, and measured values. + +### Rerun health + +`POST /api/profiles/{profile_id}/health/run` + +- Required capability: `operate`. +- Requires the profile to be running. +- Returns HTTP 202 with `state: "pending"` or `state: "running"`. +- Rejects a duplicate in-flight probe with the existing in-flight state rather than creating another task. + +### Redaction rules + +The response may contain: + +- masked outbound IP; +- integer scores; +- safe latency values; +- whitelisted categories; +- stable timestamps and source states. + +The response must not contain: + +- a raw proxy URL, username, password, complete outbound IP, browser page content, raw provider response, filesystem path, fingerprint seed, user-agent string, launch argument, cookie, token, or exception message. + +## Probe orchestration + +Create `backend/profile_health.py` as a focused, dependency-injected service. It receives the stored profile configuration, the active `RunningProfile`, and optional adapters. It does not import the FastAPI app. + +### State machine + +```text +no row -> pending -> running -> passed | warning | failed | unavailable + \-> existing in-flight result is reused +``` + +- `passed`: every requested and available component measured without a warning. +- `warning`: at least one measured component reported a consistency or risk warning, while the probe completed. +- `failed`: the browser runtime or required internal measurement failed after starting. +- `unavailable`: no meaningful component could be measured because an external source or prerequisite was unavailable. +- Component availability is more important than the aggregate label; the UI must show it. + +### Browser-path network observation + +- Open a temporary page inside the already-running Playwright browser context. +- Request a fixed HTTPS IP-echo endpoint through the actual browser runtime. +- Measure elapsed monotonic time around the navigation/request. +- Parse only the IP field, mask it immediately, discard the raw value, and close the temporary page. +- Network failure produces a stable `network_unreachable` or `network_timeout` code; raw exception text is not persisted. + +### Fingerprint consistency observation + +Evaluate a small, deterministic set of saved-config versus runtime properties: + +- platform family; +- configured screen width and height; +- timezone; +- primary locale/language; +- hardware concurrency when configured; +- user-agent family only, not the full raw string. + +Each available signal contributes equal weight. Missing runtime signals are blockers, not silent matches. The score is named `fingerprint_consistency_score`; it is not a detection-resistance score. + +### BrowserScan observation + +- Open BrowserScan in a separate temporary page within the active profile context. +- Never click consent prompts, CAPTCHAs, challenges, login elements, or anti-bot interstitials. +- Classify the visible page conservatively from a bounded text snapshot in memory. +- Extract only an explicit visible `authenticity ... N%` value and whitelisted category matches. +- Persist no DOM, screenshot, raw text, IP, identifier, or provider payload. +- If a challenge, consent wall, unsupported markup, timeout, or missing score prevents classification, record an explicit blocker and leave the score `null`. +- A missing BrowserScan score must never be replaced by the internal consistency score. + +## Existing VCVM proxychecker adapter + +The proxychecker service is optional enrichment. The Manager must remain useful when it is unavailable. + +### Configuration + +- Read `PROXYCHECKER_URL` from the environment. +- Empty means disabled. +- Accept only loopback, `host.docker.internal`, or an explicitly allow-listed private hostname supplied through deployment configuration. +- Use short connect/read timeouts and no retries in the foreground adapter. +- Never log the request body because it can contain a proxy credential. + +### Request and normalization + +- Send the configured proxy only to the trusted VCVM-local service. +- Use a fixed public HTTPS target. +- Consume only normalized reachability, latency, risk score, final verdict, and safe reason categories. +- Clamp numeric scores to 0-100. +- Derive `proxy_authenticity_score = 100 - proxy_risk_score` and mark its source as `derived`. +- Discard raw IPs, proxy strings, provider payloads, errors, routes, headers, and unrecognized reason text. + +### Failure behavior + +- Disabled service: component source `skipped`. +- Connection refused or timeout: source `unavailable`, blocker `proxychecker_unavailable`. +- Malformed response: source `unavailable`, blocker `proxychecker_invalid_response`. +- Browser-path reachability can still be measured independently. + +## Security and access boundaries + +- Reuse the existing profile capability helpers in `backend/main.py`. +- Do not expose health-run control to viewers. +- Do not weaken the `codex-computer-use` execution boundary; this server probe is a narrowly typed internal health operation, not a generic agent command. +- Do not accept a target URL from the client. +- Do not follow arbitrary provider links or render provider-returned HTML. +- Treat all external text as untrusted and reduce it to whitelisted categories. +- Keep all persisted strings length-bounded. +- Never include health details in public `/health`; that endpoint stays a minimal service liveness check. + +## Performance constraints + +- Launch must return before the automatic probe finishes. +- At most one probe runs per profile. +- Temporary diagnostic pages are closed in `finally` blocks. +- Default component timeouts are short and individually bounded. +- External calls are not retried in a tight loop. +- Health reads are one SQLite lookup and must not contact external services. + +## Testing strategy + +### Pure unit tests + +- IPv4 and IPv6 masking. +- Score clamping and derivation. +- BrowserScan score extraction, challenge/consent fail-closed handling, and warning-category whitelisting. +- Fingerprint consistency scoring with matches, mismatches, and missing signals. +- Proxychecker URL trust validation and response normalization. + +### Persistence tests + +- Additive table creation on existing databases. +- Default unavailable DTO when no row exists. +- Upsert replaces the current result without duplicating rows. +- Deleting a profile removes its health row. +- JSON fields tolerate only normalized lists/objects. + +### API and authorization tests + +- Viewers may read. +- Viewers may not run. +- Operators and administrators may run a live profile. +- Stopped profile returns conflict. +- Unauthorized and missing profiles both return 404. +- No response contains raw proxy, complete IP, raw error, user-agent, fingerprint seed, or launch arguments. + +### Launch integration tests + +- First successful launch schedules exactly one probe. +- A stored result suppresses automatic rerun. +- A probe failure does not fail browser launch. +- Duplicate manual requests reuse the in-flight task. + +### Frontend tests + +- Compact status renders on profile surfaces. +- Details distinguish measured, derived, unavailable, and skipped values. +- Rerun is absent for viewers and available to operators/admins in the appropriate detail surface. +- Mobile live view receives no persistent scan or benchmark control. +- Raw proxy/IP/error data cannot be found in rendered output. + +### VCVM acceptance + +- Start or verify the existing proxychecker on VCVM loopback without exposing it publicly. +- Deploy Manager on VCVM with an explicit trusted service URL. +- Launch one no-proxy profile and one authorized proxy profile when safe credentials are already configured. +- Verify asynchronous launch, stored health, masked IP, component availability, manual rerun, and refresh persistence. +- Capture browser screenshots containing no credentials or full IP addresses. +- Run the complete backend, frontend, build, script, security, deployment, and authenticated mobile gates before promoting the status to proven. + +## Explicit non-goals for this slice + +- No claim that BrowserScan or any score guarantees stealth. +- No CAPTCHA, WAF, consent, login, or Terms-of-Service bypass. +- No proxy purchase, discovery, rotation, or credential editing UI. +- No historical time-series database. +- No mobile benchmark dashboard. +- No extension installation or generic harness execution; those remain separate P0 slices. + +## Completion definition + +The slice is complete only when the implementation, tests, API redaction, access enforcement, asynchronous first-launch behavior, VCVM deployment, browser screenshots, README, continuation skill, acceptance matrix, fork commit, push, and remote SHA verification all agree. Until VCVM evidence exists, documentation must say “implemented; scoped verification passed”, not “proven”. diff --git a/extensions/cloak-profile-sync/README.md b/extensions/cloak-profile-sync/README.md new file mode 100644 index 00000000..31f6dc1a --- /dev/null +++ b/extensions/cloak-profile-sync/README.md @@ -0,0 +1,81 @@ +# CloakBrowser Profile Sync (Chrome Extension) + +Compact MV3 extension that lists CloakBrowser Manager profiles and proxies, then opens the same profile in **Cloud/VCVM** or **Local Mac CloakBrowser**. + +## Load unpacked + +1. Ensure the Manager tunnel is up (`http://127.0.0.1:18117/`). +2. Chrome → `chrome://extensions` → enable **Developer mode** → **Load unpacked**. +3. Select this directory: + + `extensions/cloak-profile-sync/` + +4. Open the extension popup → paste the Manager bearer token + (from `~/.config/cloakbrowser/vcvm-auth-token`) or username/password. +5. For **Open Local**, start the bridge in a terminal: + +```bash +python3 extensions/cloak-profile-sync/host/local_bridge.py +``` + +Requires `cloakbrowser` installed (`pip install cloakbrowser && cloakbrowser install`). + +## Open Cloud vs Open Local + +| Button | What it does | Proxy-on-start | +|--------|----------------|----------------| +| **Open Cloud** | Calls `POST /api/extension/sessions/open` (`prefer: cloud`, `launch: true`) when available; otherwise `POST /api/profiles/{id}/launch` + opens `/?profile={id}` in Manager. | Manager launch always applies the profile proxy. | +| **Open Local** | POSTs to the localhost bridge (`http://127.0.0.1:18765/launch`). Bridge fetches the profile from Manager and launches CloakBrowser with fingerprint + **proxy when configured**. | Enforced in bridge (`proxy_applied` in response). | + +Cloud = VCVM/Manager VNC workspace (via tunnel or `CLOUD_BASE_URL`). +Local = CloakBrowser binary on this Mac under `~/.cloakbrowser/profile-sync/profiles//`. + +## Data sources (aligned to Manager) + +Preferred (when deployed on VCVM): + +- `GET /api/extension/catalog` — redacted profiles + proxy inventory + endpoint map +- `POST /api/extension/sessions/open` — launch + steel-style link set (`session_viewer_url`, `debug_url`, …) + +Fallback (current VCVM as of this writing): + +- `GET /api/profiles`, `GET /api/proxies` +- `POST /api/profiles/{id}/launch` +- Deep-link `/?profile={id}` (Manager frontend) + +Also used: + +- `GET /api/profiles/{id}/extensions` — installed extensions (Details) +- `GET /api/profiles/{id}/health` — authenticity / scan / fingerprint scores when measured +- `GET /api/extension/defaults` — **extension point** for selectable Comet default extensions (ignored until Manager ships it) + +## UI notes + +- Dark compact Browser-Use aesthetic (`#0a0a0a` / indigo accent). +- Proxy passwords are never rendered; inventory fields are already masked; legacy `profile.proxy` is masked client-side. +- Profile **Details** loads extension chips (name · version · trust) and health scores when the API returns them. +- Default-extension selection is shown only if Manager exposes `/api/extension/defaults`. + +## Auth storage + +- Bearer token → `chrome.storage.local` (practical for tunnel workflows). +- Password → `chrome.storage.session` only (cleared when Chrome quits). +- Prefer token; password cookie auth is unreliable cross-origin from extensions. + +## Verify without Chrome + +```bash +python3 extensions/cloak-profile-sync/scripts/verify_against_manager.py +``` + +## Manager API gaps / extension points + +| Gap | Extension behavior today | +|-----|---------------------------| +| `/api/extension/catalog` + `/sessions/open` not yet on VCVM | Falls back to classic profile/proxy/launch APIs | +| `/api/extension/defaults` (Comet selectable defaults) | Optional banner when present | +| Extension `icon_url` on inventory items | Rendered when Manager adds the field | +| Explicit “Mac local launch” URL in open-links | Remains the localhost bridge (`18765`) | +| Profile create/templates from Manager create-flow | Extension lists whatever catalog/profiles returns; adapt when template list endpoints appear | + +Do not commit raw proxy passwords or auth tokens. diff --git a/extensions/cloak-profile-sync/background/service-worker.js b/extensions/cloak-profile-sync/background/service-worker.js new file mode 100644 index 00000000..03848235 --- /dev/null +++ b/extensions/cloak-profile-sync/background/service-worker.js @@ -0,0 +1,32 @@ +/** + * Background service worker — message hub for open-cloud / open-local. + */ + +import { loadSettings } from "../lib/api.js"; +import { openInCloud, openLocal } from "../lib/open-links.js"; + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + handle(message) + .then((result) => sendResponse({ ok: true, result })) + .catch((err) => + sendResponse({ + ok: false, + error: err.message || String(err), + code: err.code || null, + }), + ); + return true; +}); + +async function handle(message) { + if (!message || !message.type) throw new Error("Missing message type"); + const settings = await loadSettings(); + + if (message.type === "OPEN_CLOUD") { + return openInCloud(settings, message.profile); + } + if (message.type === "OPEN_LOCAL") { + return openLocal(settings, message.profile); + } + throw new Error(`Unknown message type: ${message.type}`); +} diff --git a/extensions/cloak-profile-sync/host/local_bridge.py b/extensions/cloak-profile-sync/host/local_bridge.py new file mode 100755 index 00000000..16452415 --- /dev/null +++ b/extensions/cloak-profile-sync/host/local_bridge.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Local CloakBrowser launch bridge for the Profile Sync Chrome extension. + +Listens on 127.0.0.1 only. Fetches a Manager profile with the caller's token +and launches cloakbrowser with proxy + fingerprint alignment (proxy-on-start). + +Usage: + python3 extensions/cloak-profile-sync/host/local_bridge.py + python3 extensions/cloak-profile-sync/host/local_bridge.py --port 18765 +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import threading +import traceback +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +DEFAULT_PORT = 18765 +DEFAULT_DATA_ROOT = Path.home() / ".cloakbrowser" / "profile-sync" / "profiles" + +_lock = threading.Lock() +_running: dict[str, dict[str, Any]] = {} + + +def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Access-Control-Allow-Origin", "*") + handler.send_header("Access-Control-Allow-Headers", "Content-Type") + handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + handler.end_headers() + handler.wfile.write(body) + + +def _read_json(handler: BaseHTTPRequestHandler) -> dict[str, Any]: + length = int(handler.headers.get("Content-Length") or 0) + raw = handler.rfile.read(length) if length else b"{}" + data = json.loads(raw.decode("utf-8") or "{}") + if not isinstance(data, dict): + raise ValueError("JSON body must be an object") + return data + + +def _manager_request( + base: str, + path: str, + *, + token: str | None = None, + username: str | None = None, + password: str | None = None, + method: str = "GET", + body: dict[str, Any] | None = None, +) -> Any: + url = base.rstrip("/") + path + headers = {"Accept": "application/json", "Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + data = None if body is None else json.dumps(body).encode("utf-8") + req = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(req, timeout=60) as resp: + raw = resp.read().decode("utf-8") + return json.loads(raw) if raw else {} + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Manager HTTP {exc.code}: {detail[:200]}") from exc + except URLError as exc: + raise RuntimeError(f"Manager unreachable: {exc.reason}") from exc + + +def _ensure_auth(base: str, payload: dict[str, Any]) -> tuple[str | None, str | None, str | None]: + token = (payload.get("token") or "").strip() or None + username = (payload.get("username") or "").strip() or None + password = payload.get("password") or None + if token: + return token, None, None + if username and password: + # Cookie auth is awkward from urllib; prefer token. Attempt login for + # deployments that accept repeated username/password on each call is N/A. + # Ask Manager login — if it only sets cookies, require token instead. + raise RuntimeError( + "local_bridge requires a bearer token (password login cookies are not portable). " + "Paste the Manager auth token into the extension." + ) + # Fall back to local token file used by VCVM tunnel workflows. + token_path = Path.home() / ".config" / "cloakbrowser" / "vcvm-auth-token" + if token_path.is_file(): + file_token = token_path.read_text(encoding="utf-8").strip() + if file_token: + return file_token, None, None + raise RuntimeError("No auth token provided for Manager fetch") + + +def _normalize_proxy(raw: str) -> str: + if raw.startswith(("http://", "https://", "socks5://")): + return raw + parts = raw.split(":") + if len(parts) == 4: + host, port, user, passwd = parts + return f"http://{user}:{passwd}@{host}:{port}" + if len(parts) == 2: + return f"http://{raw}" + return raw + + +def _build_fingerprint_args(profile: dict[str, Any]) -> list[str]: + """Mirror Manager browser_manager fingerprint args (no VNC swiftshader).""" + args: list[str] = ["--disable-infobars"] + seed = profile.get("fingerprint_seed") + if seed is not None: + args.append(f"--fingerprint={seed}") + platform = profile.get("platform") + if platform: + args.append(f"--fingerprint-platform={platform}") + vendor = profile.get("gpu_vendor") + if vendor: + args.append(f"--fingerprint-gpu-vendor={vendor}") + renderer = profile.get("gpu_renderer") + if renderer: + args.append(f"--fingerprint-gpu-renderer={renderer}") + hw = profile.get("hardware_concurrency") + if hw is not None: + args.append(f"--fingerprint-hardware-concurrency={hw}") + sw = profile.get("screen_width") + if sw: + args.append(f"--fingerprint-screen-width={sw}") + sh = profile.get("screen_height") + if sh: + args.append(f"--fingerprint-screen-height={sh}") + # Preserve --load-extension paths from Manager profile when present. + for arg in profile.get("launch_args") or []: + if isinstance(arg, str) and ( + arg.startswith("--load-extension=") + or arg.startswith("--disable-extensions-except=") + or arg.startswith("--fingerprint") + ): + args.append(arg) + return args + + +def _launch_profile(profile: dict[str, Any], *, require_proxy_if_configured: bool) -> dict[str, Any]: + try: + from cloakbrowser import launch_persistent_context + except ImportError as exc: + raise RuntimeError( + "cloakbrowser package not installed. pip install cloakbrowser && cloakbrowser install" + ) from exc + + profile_id = str(profile["id"]) + with _lock: + existing = _running.get(profile_id) + if existing and existing.get("context") is not None: + return { + "ok": True, + "already_running": True, + "profile_id": profile_id, + "user_data_dir": existing.get("user_data_dir"), + "proxy_applied": bool(existing.get("proxy_applied")), + } + + raw_proxy = profile.get("proxy") or None + proxy = _normalize_proxy(raw_proxy) if raw_proxy else None + if require_proxy_if_configured and raw_proxy and not proxy: + raise RuntimeError("Profile has proxy configured but it could not be normalized") + if require_proxy_if_configured and profile.get("proxy") in ("", None): + # not configured — fine + pass + + user_data_dir = DEFAULT_DATA_ROOT / profile_id + user_data_dir.mkdir(parents=True, exist_ok=True) + + extra_args = _build_fingerprint_args(profile) + viewport = { + "width": int(profile.get("screen_width") or 1920), + "height": max(600, int(profile.get("screen_height") or 1080) - 80), + } + + # Proxy-on-start: always pass proxy when the Manager profile has one. + context = launch_persistent_context( + user_data_dir=str(user_data_dir), + headless=False, + proxy=proxy, + args=extra_args, + timezone=profile.get("timezone") or None, + locale=profile.get("locale") or None, + humanize=bool(profile.get("humanize", False)), + human_preset=profile.get("human_preset") or "default", + geoip=bool(profile.get("geoip", False)), + color_scheme=profile.get("color_scheme") or None, + user_agent=profile.get("user_agent") or None, + viewport=viewport, + ) + + def _on_close() -> None: + with _lock: + _running.pop(profile_id, None) + + try: + context.on("close", lambda _=None: _on_close()) + except Exception: + pass + + with _lock: + _running[profile_id] = { + "context": context, + "user_data_dir": str(user_data_dir), + "proxy_applied": bool(proxy), + } + + # Open a blank page so the window is visible immediately. + try: + page = context.pages[0] if context.pages else context.new_page() + if page.url in ("", "about:blank"): + page.goto("about:blank") + except Exception: + pass + + return { + "ok": True, + "already_running": False, + "profile_id": profile_id, + "user_data_dir": str(user_data_dir), + "proxy_applied": bool(proxy), + "name": profile.get("name"), + } + + +class Handler(BaseHTTPRequestHandler): + server_version = "CloakProfileSyncBridge/0.1" + + def log_message(self, fmt: str, *args: Any) -> None: + sys.stderr.write("[bridge] " + (fmt % args) + "\n") + + def do_OPTIONS(self) -> None: # noqa: N802 + _json_response(self, 204, {}) + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path == "/health": + info: dict[str, Any] = {"ok": True, "service": "cloak-profile-sync-bridge"} + try: + import cloakbrowser + + info["cloakbrowser"] = getattr(cloakbrowser, "__version__", "installed") + except Exception: + info["cloakbrowser"] = None + with _lock: + info["running_profiles"] = list(_running.keys()) + _json_response(self, 200, info) + return + _json_response(self, 404, {"ok": False, "detail": "Not found"}) + + def do_POST(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != "/launch": + _json_response(self, 404, {"ok": False, "detail": "Not found"}) + return + try: + payload = _read_json(self) + profile_id = str(payload.get("profile_id") or "").strip() + if not profile_id: + raise ValueError("profile_id is required") + manager_base = str(payload.get("manager_base") or "http://127.0.0.1:18117").rstrip("/") + token, username, password = _ensure_auth(manager_base, payload) + profile = _manager_request( + manager_base, + f"/api/profiles/{profile_id}", + token=token, + username=username, + password=password, + ) + result = _launch_profile( + profile, + require_proxy_if_configured=bool(payload.get("require_proxy_if_configured", True)), + ) + # Never echo proxy credentials back to the extension. + _json_response(self, 200, result) + except Exception as exc: + traceback.print_exc() + _json_response(self, 400, {"ok": False, "detail": str(exc)}) + + +def main() -> int: + parser = argparse.ArgumentParser(description="CloakBrowser Profile Sync local bridge") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + args = parser.parse_args() + + if args.host not in {"127.0.0.1", "localhost", "::1"}: + print("Refusing to bind non-loopback host", file=sys.stderr) + return 2 + + httpd = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"CloakBrowser local bridge on http://{args.host}:{args.port}", flush=True) + print(f"Profile data root: {DEFAULT_DATA_ROOT}", flush=True) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nStopping bridge", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/extensions/cloak-profile-sync/icons/icon128.png b/extensions/cloak-profile-sync/icons/icon128.png new file mode 100644 index 00000000..5f33cec7 Binary files /dev/null and b/extensions/cloak-profile-sync/icons/icon128.png differ diff --git a/extensions/cloak-profile-sync/icons/icon16.png b/extensions/cloak-profile-sync/icons/icon16.png new file mode 100644 index 00000000..d83a848b Binary files /dev/null and b/extensions/cloak-profile-sync/icons/icon16.png differ diff --git a/extensions/cloak-profile-sync/icons/icon48.png b/extensions/cloak-profile-sync/icons/icon48.png new file mode 100644 index 00000000..0eec0938 Binary files /dev/null and b/extensions/cloak-profile-sync/icons/icon48.png differ diff --git a/extensions/cloak-profile-sync/lib/api.js b/extensions/cloak-profile-sync/lib/api.js new file mode 100644 index 00000000..d9c6dafb --- /dev/null +++ b/extensions/cloak-profile-sync/lib/api.js @@ -0,0 +1,336 @@ +/** + * Manager API client for the CloakBrowser Profile Sync extension. + * + * Prefers /api/extension/* when deployed; falls back to /api/profiles + /api/proxies. + */ + +const DEFAULT_BASE = "http://127.0.0.1:18117"; +const DEFAULT_BRIDGE = "http://127.0.0.1:18765"; + +export { DEFAULT_BASE, DEFAULT_BRIDGE }; + +export async function loadSettings() { + const stored = await chrome.storage.local.get({ + managerBase: DEFAULT_BASE, + bridgeBase: DEFAULT_BRIDGE, + authMode: "token", // token | password + token: "", + username: "", + persistToken: true, + }); + const session = await chrome.storage.session.get({ password: "" }); + return { ...stored, password: session.password || "" }; +} + +export async function saveSettings(partial) { + const next = { ...partial }; + if ("password" in next) { + await chrome.storage.session.set({ password: next.password || "" }); + delete next.password; + } + if (Object.keys(next).length) { + await chrome.storage.local.set(next); + } +} + +export async function clearSecrets() { + await chrome.storage.local.remove(["token"]); + await chrome.storage.session.remove(["password"]); +} + +function authHeaders(settings) { + const headers = { "Content-Type": "application/json", Accept: "application/json" }; + if (settings.token) { + headers.Authorization = `Bearer ${settings.token}`; + } + return headers; +} + +async function ensureOriginPermission(baseUrl) { + let origin; + try { + origin = `${new URL(baseUrl).origin}/*`; + } catch { + throw new Error("Invalid Manager URL"); + } + const alwaysAllowed = + origin.startsWith("http://127.0.0.1/") || origin.startsWith("http://localhost/"); + if (alwaysAllowed) return; + const granted = await chrome.permissions.contains({ origins: [origin] }); + if (!granted) { + const ok = await chrome.permissions.request({ origins: [origin] }); + if (!ok) throw new Error(`Permission denied for ${origin}`); + } +} + +function detailMessage(body, fallback) { + if (!body) return fallback; + if (typeof body.detail === "string") return body.detail; + if (Array.isArray(body.detail)) { + return body.detail.map((d) => d.msg || JSON.stringify(d)).join("; "); + } + return body.error || fallback; +} + +export class ManagerApi { + constructor(settings) { + this.settings = settings; + this.base = String(settings.managerBase || DEFAULT_BASE).replace(/\/$/, ""); + } + + async request(path, options = {}) { + await ensureOriginPermission(this.base); + const url = `${this.base}${path}`; + const init = { + ...options, + headers: { + ...authHeaders(this.settings), + ...(options.headers || {}), + }, + credentials: this.settings.authMode === "password" ? "include" : "omit", + }; + + if ( + this.settings.authMode === "password" && + this.settings.username && + this.settings.password && + !this._loggedIn + ) { + await this.loginWithPassword(); + } + + let res = await fetch(url, init); + if (res.status === 401 && this.settings.authMode === "password") { + await this.loginWithPassword(); + res = await fetch(url, init); + } + if (!res.ok) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + const err = new Error(detailMessage(body, res.statusText || `HTTP ${res.status}`)); + err.status = res.status; + throw err; + } + if (res.status === 204) return null; + return res.json(); + } + + async loginWithPassword() { + await ensureOriginPermission(this.base); + const res = await fetch(`${this.base}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + credentials: "include", + body: JSON.stringify({ + username: this.settings.username, + password: this.settings.password, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + const err = new Error(detailMessage(body, "Login failed")); + err.status = res.status; + throw err; + } + this._loggedIn = true; + return res.json(); + } + + authStatus() { + return this.request("/api/auth/status"); + } + + /** + * Preferred one-shot bootstrap. Returns null when Manager build lacks the route. + */ + async getCatalog() { + try { + return await this.request("/api/extension/catalog"); + } catch (err) { + if (err.status === 404 || err.status === 405) return null; + throw err; + } + } + + listProfiles() { + return this.request("/api/profiles"); + } + + getProfile(id) { + return this.request(`/api/profiles/${encodeURIComponent(id)}`); + } + + listProxies() { + return this.request("/api/proxies"); + } + + getStatus() { + return this.request("/api/status"); + } + + getProfileHealth(id) { + return this.request(`/api/profiles/${encodeURIComponent(id)}/health`); + } + + getProfileExtensions(id) { + return this.request(`/api/profiles/${encodeURIComponent(id)}/extensions`); + } + + /** + * Future Manager catalog of selectable default (Comet) extensions. + * Returns null until the API exists. + */ + async getDefaultExtensions() { + try { + return await this.request("/api/extension/defaults"); + } catch (err) { + if (err.status === 404 || err.status === 405) return null; + throw err; + } + } + + launchProfile(id) { + return this.request(`/api/profiles/${encodeURIComponent(id)}/launch`, { + method: "POST", + }); + } + + /** + * One-click open: launch (optional) + steel-style local/cloud link set. + * Returns null when route is not deployed yet. + */ + async openSession({ profileId, launch = true, prefer = "cloud" }) { + try { + return await this.request("/api/extension/sessions/open", { + method: "POST", + body: JSON.stringify({ + profile_id: profileId, + launch, + prefer, + }), + }); + } catch (err) { + if (err.status === 404 || err.status === 405) return null; + throw err; + } + } + + /** + * Load profiles + proxies for the popup. + * Catalog is preferred (redacted). Falls back to classic list endpoints. + */ + async loadWorkspace() { + const catalog = await this.getCatalog(); + if (catalog) { + let defaults = null; + try { + defaults = await this.getDefaultExtensions(); + } catch { + defaults = null; + } + return { + source: "catalog", + profiles: (catalog.profiles || []).map(normalizeCatalogProfile), + proxies: catalog.proxies || [], + bases: catalog.bases || {}, + endpoints: catalog.endpoints || {}, + capabilities: catalog.capabilities || {}, + defaultExtensions: defaults?.extensions || defaults?.items || null, + }; + } + + const [profiles, proxies, status] = await Promise.all([ + this.listProfiles(), + this.listProxies().catch(() => []), + this.getStatus().catch(() => null), + ]); + let defaults = null; + try { + defaults = await this.getDefaultExtensions(); + } catch { + defaults = null; + } + return { + source: "legacy", + profiles: (profiles || []).map(normalizeLegacyProfile), + proxies: proxies || [], + bases: { local: this.base, cloud: null }, + endpoints: {}, + capabilities: { + can_list_proxies: true, + can_open_sessions: true, + cloud_base_configured: false, + }, + status, + defaultExtensions: defaults?.extensions || defaults?.items || null, + }; + } +} + +function normalizeCatalogProfile(p) { + return { + id: p.id, + name: p.name, + project_id: p.project_id || "default", + folder_path: p.folder_path || "", + sandbox_id: p.sandbox_id || "default", + harness: p.harness || "browser-use", + pinned: Boolean(p.pinned), + timezone: p.timezone || null, + locale: p.locale || null, + proxy_configured: Boolean(p.proxy_configured), + // Never trust raw proxy from other payloads in UI — catalog is redacted. + proxy: null, + tags: Array.isArray(p.tags) ? p.tags : [], + status: p.status || (p.running ? "running" : "stopped"), + location: p.running || p.status === "running" ? "cloud" : "stopped", + }; +} + +function normalizeLegacyProfile(p) { + return { + id: p.id, + name: p.name, + project_id: p.project_id || "default", + folder_path: p.folder_path || "", + sandbox_id: p.sandbox_id || "default", + harness: p.harness || "browser-use", + pinned: Boolean(p.pinned), + timezone: p.timezone || null, + locale: p.locale || null, + proxy_configured: Boolean(p.proxy), + // Keep raw only for internal matching; UI must use maskProxy(). + proxy: p.proxy || null, + tags: Array.isArray(p.tags) ? p.tags : [], + status: p.status || "stopped", + location: p.status === "running" ? "cloud" : "stopped", + humanize: p.humanize, + fingerprint_seed: p.fingerprint_seed, + }; +} + +export async function bridgeHealth(bridgeBase = DEFAULT_BRIDGE) { + const base = String(bridgeBase || DEFAULT_BRIDGE).replace(/\/$/, ""); + try { + const res = await fetch(`${base}/health`, { method: "GET" }); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; + return await res.json(); + } catch (err) { + return { ok: false, error: err.message || "bridge unreachable" }; + } +} + +export async function bridgeLaunch(bridgeBase, payload) { + const base = String(bridgeBase || DEFAULT_BRIDGE).replace(/\/$/, ""); + const res = await fetch(`${base}/launch`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(payload), + }); + const body = await res.json().catch(() => ({ detail: res.statusText })); + if (!res.ok) { + const err = new Error(detailMessage(body, `Bridge HTTP ${res.status}`)); + err.status = res.status; + throw err; + } + return body; +} diff --git a/extensions/cloak-profile-sync/lib/mask.js b/extensions/cloak-profile-sync/lib/mask.js new file mode 100644 index 00000000..1be23392 --- /dev/null +++ b/extensions/cloak-profile-sync/lib/mask.js @@ -0,0 +1,146 @@ +/** + * Redact proxy credentials and IP addresses for UI display. + * Never render raw passwords from Manager profile.proxy fields. + */ + +export function maskIpv4Host(host) { + const parts = String(host || "").split("."); + if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p))) { + return host || "unknown"; + } + return `${parts[0]}.${parts[1]}.x.x`; +} + +export function maskUsername(username) { + const value = String(username || ""); + if (!value) return null; + if (value.length <= 2) return `${value[0] || "*"}***`; + return `${value[0]}***${value[value.length - 1]}`; +} + +/** + * Mask a proxy URL or host:port:user:pass string for display. + * Returns null when unset. + */ +export function maskProxy(raw) { + if (!raw) return null; + const text = String(raw).trim(); + if (!text) return null; + + try { + if (text.includes("://")) { + const url = new URL(text); + const host = maskIpv4Host(url.hostname); + const user = maskUsername(url.username); + const auth = user ? `${user}@` : ""; + const port = url.port ? `:${url.port}` : ""; + return `${url.protocol}//${auth}${host}${port}`; + } + } catch { + // fall through + } + + const parts = text.split(":"); + if (parts.length === 4) { + const [host, port, user] = parts; + return `http://${maskUsername(user)}@${maskIpv4Host(host)}:${port}`; + } + if (parts.length === 2) { + return `http://${maskIpv4Host(parts[0])}:${parts[1]}`; + } + return "configured"; +} + +export function formatProxyLine(profile, proxies = []) { + if (!profile?.proxy_configured && !profile?.proxy) { + return { text: "no proxy", tone: "muted" }; + } + + const maskedFromProfile = maskProxy(profile.proxy); + const inventoryHit = findInventoryMatch(profile, proxies); + if (inventoryHit) { + const label = `${inventoryHit.host_masked}${inventoryHit.port ? `:${inventoryHit.port}` : ""}`; + const auth = inventoryHit.username_masked ? ` · ${inventoryHit.username_masked}` : ""; + const score = + inventoryHit.authenticity_score != null + ? ` · auth ${inventoryHit.authenticity_score}` + : ""; + const state = inventoryHit.check_state && inventoryHit.check_state !== "missing" + ? ` · ${inventoryHit.check_state}` + : ""; + return { + text: `${label}${auth}${score}${state}`, + tone: toneForCheck(inventoryHit.check_state), + }; + } + + return { + text: maskedFromProfile || "proxy on", + tone: "neutral", + }; +} + +function findInventoryMatch(profile, proxies) { + if (!proxies?.length) return null; + if (!profile.proxy) { + // Catalog-only: cannot match host; return first active warning/passed with same project later. + return null; + } + try { + const raw = profile.proxy.includes("://") ? profile.proxy : `http://${profile.proxy}`; + const url = new URL(raw); + const prefix = url.hostname.split(".").slice(0, 2).join("."); + return ( + proxies.find( + (item) => + item.port === Number(url.port || 0) && + String(item.host_masked || "").startsWith(prefix), + ) || null + ); + } catch { + return null; + } +} + +function toneForCheck(state) { + if (state === "passed") return "ok"; + if (state === "warning") return "warn"; + if (state === "failed") return "bad"; + return "neutral"; +} + +/** Compact health score line from ProfileHealthResponse. */ +export function formatHealthLine(health) { + if (!health) return null; + const parts = []; + if (health.proxy_authenticity_score != null) { + parts.push(`auth ${health.proxy_authenticity_score}`); + } + if (health.browser_scan_score != null) { + parts.push(`scan ${health.browser_scan_score}`); + } + if (health.fingerprint_consistency_score != null) { + parts.push(`fp ${health.fingerprint_consistency_score}`); + } + if (!parts.length) { + return health.state && health.state !== "unavailable" + ? { text: health.state, tone: "neutral" } + : null; + } + const worst = Math.min( + ...[ + health.proxy_authenticity_score, + health.browser_scan_score, + health.fingerprint_consistency_score, + ].filter((n) => typeof n === "number"), + ); + const tone = worst >= 95 ? "ok" : worst >= 80 ? "neutral" : worst >= 50 ? "warn" : "bad"; + return { text: parts.join(" · "), tone }; +} + +/** Chrome Web Store / edge link when id looks like a 32-char extension id. */ +export function extensionStoreUrl(extId) { + const id = String(extId || ""); + if (!/^[a-p]{32}$/.test(id)) return null; + return `https://chromewebstore.google.com/detail/${id}`; +} diff --git a/extensions/cloak-profile-sync/lib/open-links.js b/extensions/cloak-profile-sync/lib/open-links.js new file mode 100644 index 00000000..dfd5b168 --- /dev/null +++ b/extensions/cloak-profile-sync/lib/open-links.js @@ -0,0 +1,106 @@ +/** + * Open Cloud = Manager/VCVM session (proxy-on-start via Manager launch). + * Open Local = Mac CloakBrowser via localhost bridge (proxy-on-start enforced). + * + * Aligns with Manager /api/extension/sessions/open when deployed + * (steel-browser sessionViewerUrl / debugUrl style link sets). + */ + +import { + ManagerApi, + bridgeHealth, + bridgeLaunch, + DEFAULT_BRIDGE, +} from "./api.js"; + +/** + * Open the profile in Manager cloud/VCVM (VNC workspace). + * Always requests launch so proxy-on-start is applied when a proxy is configured. + */ +export async function openInCloud(settings, profile) { + const api = new ManagerApi(settings); + + const session = await api.openSession({ + profileId: profile.id, + launch: true, + prefer: "cloud", + }); + + if (session) { + const url = + session.open_url || + session.links?.cloud?.session_viewer_url || + session.links?.local?.session_viewer_url; + if (!url) throw new Error("Manager open-session returned no viewer URL"); + await chrome.tabs.create({ url }); + return { + mode: "cloud", + via: "extension-sessions-open", + url, + status: session.status, + launched: session.launched, + proxy_on_start: Boolean(profile.proxy_configured || profile.proxy), + }; + } + + // Fallback: classic launch + deep-link (?profile=) + let status = profile.status; + if (status !== "running") { + try { + await api.launchProfile(profile.id); + status = "running"; + } catch (err) { + if (err.status !== 409) throw err; + status = "running"; + } + } + + const base = String(settings.managerBase || "").replace(/\/$/, ""); + const url = `${base}/?profile=${encodeURIComponent(profile.id)}`; + await chrome.tabs.create({ url }); + return { + mode: "cloud", + via: "launch+deeplink", + url, + status, + proxy_on_start: Boolean(profile.proxy_configured || profile.proxy), + }; +} + +/** + * Launch the same profile settings in local CloakBrowser on this Mac. + * Proxy credentials stay on the bridge (fetched from Manager); UI never shows them. + */ +export async function openLocal(settings, profile) { + const bridgeBase = settings.bridgeBase || DEFAULT_BRIDGE; + const health = await bridgeHealth(bridgeBase); + if (!health.ok) { + const err = new Error( + "Local bridge offline. Run: python3 extensions/cloak-profile-sync/host/local_bridge.py", + ); + err.code = "BRIDGE_OFFLINE"; + err.health = health; + throw err; + } + + if (!settings.token && settings.authMode !== "password") { + throw new Error("Auth token required for Open Local (bridge fetches the profile securely)."); + } + + const result = await bridgeLaunch(bridgeBase, { + profile_id: profile.id, + manager_base: settings.managerBase, + token: settings.token || undefined, + username: settings.authMode === "password" ? settings.username : undefined, + password: settings.authMode === "password" ? settings.password : undefined, + // Bridge MUST apply proxy when profile has one (proxy-on-start). + require_proxy_if_configured: true, + }); + + return { + mode: "local", + via: "local-bridge", + result, + proxy_on_start: Boolean(result.proxy_applied), + }; +} diff --git a/extensions/cloak-profile-sync/manifest.json b/extensions/cloak-profile-sync/manifest.json new file mode 100644 index 00000000..f1f0ce58 --- /dev/null +++ b/extensions/cloak-profile-sync/manifest.json @@ -0,0 +1,33 @@ +{ + "manifest_version": 3, + "name": "CloakBrowser Profile Sync", + "version": "0.1.0", + "description": "View CloakBrowser Manager profiles and proxies; open the same profile in cloud/VCVM or locally on Mac.", + "action": { + "default_title": "CloakBrowser Profile Sync", + "default_popup": "popup/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "background": { + "service_worker": "background/service-worker.js", + "type": "module" + }, + "permissions": ["storage", "tabs"], + "host_permissions": [ + "http://127.0.0.1/*", + "http://localhost/*" + ], + "optional_host_permissions": [ + "http://*/*", + "https://*/*" + ] +} diff --git a/extensions/cloak-profile-sync/package.json b/extensions/cloak-profile-sync/package.json new file mode 100644 index 00000000..95dbc616 --- /dev/null +++ b/extensions/cloak-profile-sync/package.json @@ -0,0 +1,6 @@ +{ + "name": "cloak-profile-sync", + "private": true, + "type": "module", + "description": "Chrome extension sources — load unpacked via manifest.json, not npm." +} diff --git a/extensions/cloak-profile-sync/popup/popup.css b/extensions/cloak-profile-sync/popup/popup.css new file mode 100644 index 00000000..f8116b4c --- /dev/null +++ b/extensions/cloak-profile-sync/popup/popup.css @@ -0,0 +1,354 @@ +:root { + --bg: #0a0a0a; + --surface-1: #111111; + --surface-2: #1a1a1a; + --surface-3: #222222; + --border: #2a2a2a; + --text: #e5e5e5; + --muted: #737373; + --accent: #6366f1; + --accent-hover: #818cf8; + --ok: #34d399; + --warn: #fbbf24; + --bad: #f87171; + font-family: "Avenir Next", "SF Pro Text", "Helvetica Neue", system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + width: 380px; + max-height: 560px; + background: var(--bg); + color: var(--text); + font-size: 12px; +} + +.hidden { + display: none !important; +} + +.top { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + background: var(--surface-1); +} + +.brand { + font-size: 14px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.sub { + color: var(--muted); + font-size: 10px; + margin-top: 1px; +} + +.top-actions { + display: flex; + gap: 4px; +} + +.ghost { + background: transparent; + border: 1px solid var(--border); + color: var(--text); + border-radius: 6px; + width: 28px; + height: 28px; + cursor: pointer; +} + +.ghost:hover { + border-color: #3a3a3a; + background: var(--surface-2); +} + +.panel { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + background: var(--surface-1); + display: flex; + flex-direction: column; + gap: 6px; +} + +.label { + color: var(--muted); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.input { + width: 100%; + height: 30px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + padding: 0 8px; + font-size: 12px; +} + +.input:focus { + outline: none; + border-color: var(--accent); +} + +.row { + display: flex; + align-items: center; + gap: 10px; +} + +.row.gap { + margin-top: 4px; +} + +.radio { + color: var(--muted); + display: flex; + align-items: center; + gap: 4px; +} + +.btn { + border: 1px solid var(--border); + background: var(--surface-3); + color: var(--text); + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.btn:hover { + background: #2a2a2a; +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn.primary { + background: var(--accent); + border-color: transparent; + color: #fff; +} + +.btn.primary:hover { + background: var(--accent-hover); +} + +.btn.tiny { + padding: 4px 8px; + font-size: 10px; +} + +.hint { + color: var(--muted); + margin: 2px 0 0; + line-height: 1.35; +} + +.hint code { + font-size: 10px; + color: #a3a3a3; +} + +.tabs { + display: flex; + border-bottom: 1px solid var(--border); + background: var(--surface-1); +} + +.tab { + flex: 1; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + color: var(--muted); + padding: 8px; + cursor: pointer; + font-size: 11px; + font-weight: 600; +} + +.tab.active { + color: var(--text); + border-bottom-color: var(--accent); +} + +.main { + max-height: 400px; + overflow: auto; + padding: 8px; +} + +.error { + background: rgba(248, 113, 113, 0.12); + color: var(--bad); + border: 1px solid rgba(248, 113, 113, 0.25); + border-radius: 8px; + padding: 8px; + margin-bottom: 8px; + line-height: 1.35; +} + +.card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 10px; + margin-bottom: 8px; +} + +.card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.name { + font-weight: 600; + font-size: 12px; +} + +.meta { + color: var(--muted); + font-size: 10px; + margin-top: 2px; + line-height: 1.4; +} + +.meta .ok { + color: var(--ok); +} +.meta .warn { + color: var(--warn); +} +.meta .bad { + color: var(--bad); +} +.meta .muted { + color: var(--muted); +} +.meta .neutral { + color: #a3a3a3; +} + +.badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} + +.badge { + font-size: 9px; + padding: 2px 6px; + border-radius: 999px; + background: var(--surface-3); + color: #d4d4d4; + border: 1px solid var(--border); +} + +.badge.running { + background: rgba(52, 211, 153, 0.12); + color: var(--ok); + border-color: rgba(52, 211, 153, 0.3); +} + +.badge.stopped { + color: var(--muted); +} + +.badge.proxy { + color: #a5b4fc; +} + +.actions { + display: flex; + gap: 6px; + margin-top: 8px; +} + +.ext-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} + +.ext-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 2px 6px; + border-radius: 6px; + background: var(--surface-2); + border: 1px solid var(--border); + color: #d4d4d4; + font-size: 10px; + text-decoration: none; +} + +.ext-chip:hover { + border-color: #3a3a3a; +} + +.ext-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ok); + flex-shrink: 0; +} + +.ext-dot.bad { + background: var(--bad); +} + +.ext-dot.warn { + background: var(--warn); +} + +.empty { + color: var(--muted); + text-align: center; + padding: 28px 12px; +} + +.foot { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 6px 12px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 10px; + background: var(--surface-1); +} + +.defaults { + margin-bottom: 8px; + padding: 6px 8px; + border-radius: 8px; + border: 1px dashed var(--border); + color: var(--muted); + font-size: 10px; +} diff --git a/extensions/cloak-profile-sync/popup/popup.html b/extensions/cloak-profile-sync/popup/popup.html new file mode 100644 index 00000000..11ebcbb4 --- /dev/null +++ b/extensions/cloak-profile-sync/popup/popup.html @@ -0,0 +1,68 @@ + + + + + + CloakBrowser Profile Sync + + + +
+
+
CloakBrowser
+
Profile Sync
+
+
+ + +
+
+ + + + + +
+ +
+ +
+ +
+ Bridge: … + +
+ + + + diff --git a/extensions/cloak-profile-sync/popup/popup.js b/extensions/cloak-profile-sync/popup/popup.js new file mode 100644 index 00000000..9ad96443 --- /dev/null +++ b/extensions/cloak-profile-sync/popup/popup.js @@ -0,0 +1,365 @@ +import { + ManagerApi, + loadSettings, + saveSettings, + clearSecrets, + bridgeHealth, + DEFAULT_BASE, + DEFAULT_BRIDGE, +} from "../lib/api.js"; +import { formatProxyLine, formatHealthLine, extensionStoreUrl } from "../lib/mask.js"; + +const els = { + statusLine: document.getElementById("statusLine"), + authPanel: document.getElementById("authPanel"), + managerBase: document.getElementById("managerBase"), + bridgeBase: document.getElementById("bridgeBase"), + token: document.getElementById("token"), + username: document.getElementById("username"), + password: document.getElementById("password"), + tokenFields: document.getElementById("tokenFields"), + passwordFields: document.getElementById("passwordFields"), + saveAuthBtn: document.getElementById("saveAuthBtn"), + clearAuthBtn: document.getElementById("clearAuthBtn"), + settingsBtn: document.getElementById("settingsBtn"), + refreshBtn: document.getElementById("refreshBtn"), + errorBox: document.getElementById("errorBox"), + profilesView: document.getElementById("profilesView"), + proxiesView: document.getElementById("proxiesView"), + bridgeLine: document.getElementById("bridgeLine"), + sourceLine: document.getElementById("sourceLine"), + tabs: document.getElementById("tabs"), +}; + +let settings = null; +let workspace = null; +const detailCache = new Map(); // profileId -> { extensions, health } + +init(); + +async function init() { + settings = await loadSettings(); + fillSettingsForm(settings); + wireUi(); + await refreshBridge(); + if (settings.token || (settings.username && settings.password)) { + await loadWorkspace(); + } else { + showAuth(true); + showError("Connect to Manager with a token or username/password."); + } +} + +function wireUi() { + els.settingsBtn.addEventListener("click", () => { + showAuth(els.authPanel.classList.contains("hidden")); + }); + els.refreshBtn.addEventListener("click", () => loadWorkspace()); + els.saveAuthBtn.addEventListener("click", saveAuth); + els.clearAuthBtn.addEventListener("click", async () => { + await clearSecrets(); + settings = await loadSettings(); + fillSettingsForm(settings); + showError("Secrets cleared."); + }); + + document.querySelectorAll('input[name="authMode"]').forEach((input) => { + input.addEventListener("change", syncAuthModeUi); + }); + + els.tabs.addEventListener("click", (event) => { + const btn = event.target.closest(".tab"); + if (!btn) return; + document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === btn)); + const tab = btn.dataset.tab; + els.profilesView.classList.toggle("hidden", tab !== "profiles"); + els.proxiesView.classList.toggle("hidden", tab !== "proxies"); + }); +} + +function fillSettingsForm(s) { + els.managerBase.value = s.managerBase || DEFAULT_BASE; + els.bridgeBase.value = s.bridgeBase || DEFAULT_BRIDGE; + els.token.value = s.token || ""; + els.username.value = s.username || ""; + els.password.value = s.password || ""; + const mode = s.authMode || "token"; + document.querySelectorAll('input[name="authMode"]').forEach((input) => { + input.checked = input.value === mode; + }); + syncAuthModeUi(); +} + +function syncAuthModeUi() { + const mode = document.querySelector('input[name="authMode"]:checked')?.value || "token"; + els.tokenFields.classList.toggle("hidden", mode !== "token"); + els.passwordFields.classList.toggle("hidden", mode !== "password"); +} + +function showAuth(open) { + els.authPanel.classList.toggle("hidden", !open); +} + +function showError(msg) { + if (!msg) { + els.errorBox.classList.add("hidden"); + els.errorBox.textContent = ""; + return; + } + els.errorBox.textContent = msg; + els.errorBox.classList.remove("hidden"); +} + +async function saveAuth() { + const mode = document.querySelector('input[name="authMode"]:checked')?.value || "token"; + await saveSettings({ + managerBase: els.managerBase.value.trim() || DEFAULT_BASE, + bridgeBase: els.bridgeBase.value.trim() || DEFAULT_BRIDGE, + authMode: mode, + token: els.token.value.trim(), + username: els.username.value.trim(), + password: els.password.value, + }); + settings = await loadSettings(); + showAuth(false); + await refreshBridge(); + await loadWorkspace(); +} + +async function refreshBridge() { + const health = await bridgeHealth(settings.bridgeBase || DEFAULT_BRIDGE); + els.bridgeLine.textContent = health.ok + ? `Bridge: ok${health.cloakbrowser ? ` · ${health.cloakbrowser}` : ""}` + : "Bridge: offline"; +} + +async function loadWorkspace() { + showError(""); + els.statusLine.textContent = "Loading…"; + try { + const api = new ManagerApi(settings); + workspace = await api.loadWorkspace(); + els.sourceLine.textContent = workspace.source === "catalog" ? "API: catalog" : "API: legacy"; + const running = workspace.profiles.filter((p) => p.status === "running").length; + els.statusLine.textContent = `${workspace.profiles.length} profiles · ${running} running`; + renderProfiles(); + renderProxies(); + await refreshBridge(); + } catch (err) { + showError(err.message || String(err)); + els.statusLine.textContent = "Disconnected"; + showAuth(true); + } +} + +function renderProfiles() { + const root = els.profilesView; + root.innerHTML = ""; + + if (workspace?.defaultExtensions?.length) { + const box = document.createElement("div"); + box.className = "defaults"; + box.textContent = `Default extensions available: ${workspace.defaultExtensions + .map((e) => e.name || e.id) + .slice(0, 6) + .join(", ")}${workspace.defaultExtensions.length > 6 ? "…" : ""}`; + root.appendChild(box); + } + + const profiles = workspace?.profiles || []; + if (!profiles.length) { + root.innerHTML += `
No profiles visible for this identity.
`; + return; + } + + for (const profile of profiles) { + root.appendChild(renderProfileCard(profile)); + } +} + +function renderProfileCard(profile) { + const card = document.createElement("article"); + card.className = "card"; + card.dataset.id = profile.id; + + const proxy = formatProxyLine(profile, workspace.proxies || []); + const tags = (profile.tags || []) + .map((t) => `${escapeHtml(typeof t === "string" ? t : t.tag)}`) + .join(""); + + card.innerHTML = ` +
+
+
${escapeHtml(profile.name)}
+
+ ${escapeHtml(profile.project_id || "default")} + ${profile.folder_path ? ` · ${escapeHtml(profile.folder_path)}` : ""} + · ${escapeHtml(proxy.text)} +
+
+
+
+ ${escapeHtml(profile.status)} + ${profile.status === "running" ? "cloud/VCVM" : "mac-ready"} + ${profile.proxy_configured || profile.proxy ? `proxy-on-start` : ""} + ${tags} +
+
+
+
+ + + +
+ `; + + card.querySelector('[data-action="cloud"]').addEventListener("click", () => openCloud(profile, card)); + card.querySelector('[data-action="local"]').addEventListener("click", () => openLocal(profile, card)); + card.querySelector('[data-action="details"]').addEventListener("click", () => loadDetails(profile, card)); + + return card; +} + +async function openCloud(profile, card) { + setBusy(card, true); + showError(""); + try { + const res = await chrome.runtime.sendMessage({ type: "OPEN_CLOUD", profile }); + if (!res?.ok) throw new Error(res?.error || "Open Cloud failed"); + flash(card, `Cloud · ${res.result?.via || "ok"}`); + } catch (err) { + showError(err.message || String(err)); + } finally { + setBusy(card, false); + } +} + +async function openLocal(profile, card) { + setBusy(card, true); + showError(""); + try { + const res = await chrome.runtime.sendMessage({ type: "OPEN_LOCAL", profile }); + if (!res?.ok) throw new Error(res?.error || "Open Local failed"); + const proxyNote = res.result?.proxy_on_start ? " · proxy applied" : ""; + flash(card, `Local launched${proxyNote}`); + await refreshBridge(); + } catch (err) { + showError(err.message || String(err)); + } finally { + setBusy(card, false); + } +} + +async function loadDetails(profile, card) { + const extRoot = card.querySelector('[data-role="extensions"]'); + const healthRoot = card.querySelector('[data-role="health"]'); + extRoot.textContent = "Loading extensions…"; + healthRoot.textContent = ""; + + try { + const api = new ManagerApi(settings); + const cached = detailCache.get(profile.id); + const [extensionsResp, health] = await Promise.all([ + cached?.extensions + ? Promise.resolve(cached.extensions) + : api.getProfileExtensions(profile.id), + cached?.health ? Promise.resolve(cached.health) : api.getProfileHealth(profile.id).catch(() => null), + ]); + detailCache.set(profile.id, { extensions: extensionsResp, health }); + + const healthLine = formatHealthLine(health); + healthRoot.innerHTML = healthLine + ? `Health: ${escapeHtml(healthLine.text)}` + : "Health: unavailable"; + + const items = extensionsResp?.extensions || []; + if (!items.length) { + extRoot.innerHTML = `No profile extensions installed`; + return; + } + extRoot.innerHTML = ""; + for (const ext of items) { + const store = extensionStoreUrl(ext.id); + const chip = document.createElement(store ? "a" : "span"); + chip.className = "ext-chip"; + if (store) { + chip.href = store; + chip.target = "_blank"; + chip.rel = "noreferrer"; + chip.title = ext.description || ext.name; + } + const dot = document.createElement("span"); + dot.className = `ext-dot${ext.trust_state === "valid" ? "" : ext.trust_state === "untrusted_manifest" ? " warn" : " bad"}`; + chip.appendChild(dot); + const label = document.createElement("span"); + label.textContent = `${ext.name || ext.id} · ${ext.version || "?"}`; + chip.appendChild(label); + // Future: ext.icon_url from Manager default-extension catalog + if (ext.icon_url) { + const img = document.createElement("img"); + img.src = ext.icon_url; + img.alt = ""; + img.width = 12; + img.height = 12; + chip.prepend(img); + } + extRoot.appendChild(chip); + } + } catch (err) { + extRoot.innerHTML = `${escapeHtml(err.message || String(err))}`; + } +} + +function renderProxies() { + const root = els.proxiesView; + root.innerHTML = ""; + const proxies = workspace?.proxies || []; + if (!proxies.length) { + root.innerHTML = `
No proxy inventory (admin-only or empty).
`; + return; + } + for (const proxy of proxies) { + const card = document.createElement("article"); + card.className = "card"; + const auth = + proxy.authenticity_score != null ? ` · authenticity ${proxy.authenticity_score}` : ""; + const risk = proxy.risk_score != null ? ` · risk ${proxy.risk_score}` : ""; + const user = proxy.username_masked ? ` · ${proxy.username_masked}` : ""; + card.innerHTML = ` +
${escapeHtml(proxy.label || `${proxy.host_masked}:${proxy.port || "?"}`)}
+
+ ${escapeHtml(proxy.host_masked)}${proxy.port ? `:${proxy.port}` : ""}${escapeHtml(user)} + · ${escapeHtml(proxy.check_state || "missing")} + ${escapeHtml(auth)}${escapeHtml(risk)} + ${proxy.country_code ? ` · ${escapeHtml(proxy.country_code)}` : ""} +
+ `; + root.appendChild(card); + } +} + +function toneClass(state) { + if (state === "passed") return "ok"; + if (state === "warning") return "warn"; + if (state === "failed") return "bad"; + return "muted"; +} + +function setBusy(card, busy) { + card.querySelectorAll("button").forEach((btn) => { + btn.disabled = busy; + }); +} + +function flash(card, text) { + const meta = card.querySelector(".health-line") || card.querySelector(".meta"); + if (meta) meta.textContent = text; +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/extensions/cloak-profile-sync/scripts/verify_against_manager.py b/extensions/cloak-profile-sync/scripts/verify_against_manager.py new file mode 100755 index 00000000..ae8c2256 --- /dev/null +++ b/extensions/cloak-profile-sync/scripts/verify_against_manager.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""E2E-ish verification of Manager APIs used by cloak-profile-sync. + +Never prints proxy passwords, auth tokens, or raw credentials. +""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + +BASE = os.environ.get("CLOAK_MANAGER_BASE", "http://127.0.0.1:18117").rstrip("/") +TOKEN_PATH = Path.home() / ".config" / "cloakbrowser" / "vcvm-auth-token" + + +def load_token() -> str: + env = os.environ.get("CLOAK_MANAGER_TOKEN", "").strip() + if env: + return env + if TOKEN_PATH.is_file(): + return TOKEN_PATH.read_text(encoding="utf-8").strip() + raise SystemExit(f"No token in CLOAK_MANAGER_TOKEN or {TOKEN_PATH}") + + +def request(path: str, token: str, method: str = "GET", body: dict | None = None) -> tuple[int, object]: + data = None if body is None else json.dumps(body).encode("utf-8") + req = urllib.request.Request( + BASE + path, + data=data, + method=method, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode("utf-8") + return resp.status, (json.loads(raw) if raw else None) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = {"detail": raw[:200]} + return exc.code, parsed + + +def mask_proxy(value: object) -> str: + if not value: + return "none" + text = str(value) + if "@" in text: + return "http://***:***@*** (redacted)" + return "configured (redacted)" + + +def main() -> int: + token = load_token() + print(f"Manager base: {BASE}") + results: list[tuple[str, str]] = [] + + code, health = request("/health", token) + results.append(("/health", "ok" if code == 200 else f"fail {code}")) + + code, catalog = request("/api/extension/catalog", token) + catalog_ok = code == 200 + results.append( + ( + "/api/extension/catalog", + f"ok profiles={len(catalog.get('profiles', []))}" if catalog_ok and isinstance(catalog, dict) + else f"fallback ({code})", + ) + ) + + code, profiles = request("/api/profiles", token) + if code != 200 or not isinstance(profiles, list): + print("FATAL: /api/profiles failed", code, profiles) + return 1 + results.append(("/api/profiles", f"ok count={len(profiles)}")) + + code, proxies = request("/api/proxies", token) + results.append( + ( + "/api/proxies", + f"ok count={len(proxies)}" if code == 200 and isinstance(proxies, list) else f"fail {code}", + ) + ) + + if not profiles: + print("No profiles to probe further.") + else: + sample = profiles[0] + pid = sample["id"] + print(f"Sample profile: {sample.get('name')} ({pid[:8]}…)") + print(f" status={sample.get('status')} proxy={mask_proxy(sample.get('proxy'))}") + + code, exts = request(f"/api/profiles/{pid}/extensions", token) + n = len(exts.get("extensions", [])) if isinstance(exts, dict) else "?" + results.append((f"/api/profiles/{{id}}/extensions", f"{code} count={n}")) + + code, healthp = request(f"/api/profiles/{pid}/health", token) + if isinstance(healthp, dict): + results.append( + ( + "/api/profiles/{id}/health", + f"{code} state={healthp.get('state')} auth={healthp.get('proxy_authenticity_score')}", + ) + ) + else: + results.append(("/api/profiles/{id}/health", f"fail {code}")) + + code, opened = request( + "/api/extension/sessions/open", + token, + method="POST", + body={"profile_id": pid, "launch": False, "prefer": "cloud"}, + ) + if code == 200 and isinstance(opened, dict): + url = opened.get("open_url") or "" + results.append( + ( + "/api/extension/sessions/open", + f"ok status={opened.get('status')} open_url_host={url.split('/')[2] if '//' in url else 'n/a'}", + ) + ) + else: + results.append(("/api/extension/sessions/open", f"fallback ({code})")) + + code, defaults = request("/api/extension/defaults", token) + results.append( + ( + "/api/extension/defaults", + "ok" if code == 200 else f"extension-point ({code})", + ) + ) + + # Local bridge optional + try: + req = urllib.request.Request("http://127.0.0.1:18765/health") + with urllib.request.urlopen(req, timeout=2) as resp: + payload = json.loads(resp.read().decode("utf-8")) + results.append(("/local-bridge/health", f"ok cloakbrowser={payload.get('cloakbrowser')}")) + except Exception: + results.append(("/local-bridge/health", "offline (start host/local_bridge.py for Open Local)")) + + print("\nChecks:") + for name, status in results: + print(f" {name}: {status}") + + print("\nLoad unpacked: extensions/cloak-profile-sync/") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 00000000..b21862c6 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { applyProfileViewport, toggleProfilePin } from "./App"; +import { ProfileForm } from "./components/ProfileForm"; +import type { Profile } from "./lib/api"; + +const stoppedProfile: Profile = { + id: "profile-1", + name: "Checkout QA", + sandbox_id: "default", + project_id: "commerce", + folder_path: "checkout", + pinned: false, + accent_color: null, + harness: "codex", + fingerprint_seed: 12345, + proxy: null, + timezone: null, + locale: null, + platform: "macos", + user_agent: null, + screen_width: 390, + screen_height: 844, + gpu_vendor: null, + gpu_renderer: null, + hardware_concurrency: null, + humanize: false, + human_preset: "default", + headless: false, + geoip: false, + clipboard_sync: true, + auto_launch: false, + color_scheme: null, + search_engine: null, + launch_args: [], + notes: null, + user_data_dir: "/tmp/profile-1", + created_at: "2026-07-20T00:00:00Z", + updated_at: "2026-07-20T00:00:00Z", + tags: [], + status: "stopped", + vnc_ws_port: null, + cdp_url: null, +}; + +const runningProfile: Profile = { + ...stoppedProfile, + id: "profile-2", + name: "Live Checkout QA", + status: "running", + vnc_ws_port: 5901, +}; + +describe("applyProfileViewport", () => { + it("saves stopped profile viewport without restarting", async () => { + const update = vi.fn().mockResolvedValue({ ...stoppedProfile, screen_width: 768, screen_height: 1024 }); + const stop = vi.fn(); + const launch = vi.fn(); + + const result = await applyProfileViewport({ + profile: stoppedProfile, + width: 768, + height: 1024, + canManageProfiles: true, + canOperateProfile: true, + update, + stop, + launch, + }); + + expect(result).toBe(true); + expect(update).toHaveBeenCalledWith(stoppedProfile.id, { screen_width: 768, screen_height: 1024 }); + expect(stop).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + }); + + it("restarts a running profile from the updated profile after saving viewport", async () => { + const updatedProfile = { ...runningProfile, id: "profile-2-updated", screen_width: 1024, screen_height: 576 }; + const update = vi.fn().mockResolvedValue(updatedProfile); + const stop = vi.fn().mockResolvedValue(true); + const launch = vi.fn().mockResolvedValue({ ws_url: "ws://127.0.0.1:5901" }); + + const result = await applyProfileViewport({ + profile: runningProfile, + width: 1024, + height: 576, + canManageProfiles: true, + canOperateProfile: true, + update, + stop, + launch, + }); + + expect(result).toBe(true); + expect(update).toHaveBeenCalledWith(runningProfile.id, { screen_width: 1024, screen_height: 576 }); + expect(stop).toHaveBeenCalledWith(runningProfile.id); + expect(launch).toHaveBeenCalledWith(updatedProfile.id); + }); + + it("aborts running profile relaunch when stop does not succeed", async () => { + const update = vi.fn().mockResolvedValue({ ...runningProfile, screen_width: 1024, screen_height: 576 }); + const stop = vi.fn().mockResolvedValue(false); + const launch = vi.fn(); + + const result = await applyProfileViewport({ + profile: runningProfile, + width: 1024, + height: 576, + canManageProfiles: true, + canOperateProfile: true, + update, + stop, + launch, + }); + + expect(result).toBe(false); + expect(stop).toHaveBeenCalledWith(runningProfile.id); + expect(launch).not.toHaveBeenCalled(); + }); + + it("does not save a running profile viewport without operate permission", async () => { + const update = vi.fn(); + const stop = vi.fn(); + const launch = vi.fn(); + + const result = await applyProfileViewport({ + profile: runningProfile, + width: 1024, + height: 576, + canManageProfiles: true, + canOperateProfile: false, + update, + stop, + launch, + }); + + expect(result).toBe(false); + expect(update).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + }); + + it("returns false when a running profile relaunch does not succeed", async () => { + const update = vi.fn().mockResolvedValue({ ...runningProfile, screen_width: 1024, screen_height: 576 }); + const stop = vi.fn().mockResolvedValue(true); + const launch = vi.fn().mockResolvedValue(undefined); + + const result = await applyProfileViewport({ + profile: runningProfile, + width: 1024, + height: 576, + canManageProfiles: true, + canOperateProfile: true, + update, + stop, + launch, + }); + + expect(result).toBe(false); + expect(stop).toHaveBeenCalledWith(runningProfile.id); + expect(launch).toHaveBeenCalledWith(runningProfile.id); + }); +}); + +describe("toggleProfilePin", () => { + it("persists the inverse pinned state through profile update for admins", async () => { + const update = vi.fn().mockResolvedValue({ ...stoppedProfile, pinned: true }); + + const result = await toggleProfilePin({ + profile: stoppedProfile, + canManageProfiles: true, + update, + }); + + expect(result).toBe(true); + expect(update).toHaveBeenCalledWith(stoppedProfile.id, { pinned: true }); + }); + + it("does not update pin state without profile management access", async () => { + const update = vi.fn(); + + const result = await toggleProfilePin({ + profile: stoppedProfile, + canManageProfiles: false, + update, + }); + + expect(result).toBe(false); + expect(update).not.toHaveBeenCalled(); + }); +}); + +describe("ProfileForm profile organization", () => { + it("roundtrips organization fields while keeping sandbox as a separate access boundary", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + const projectInput = screen.getByLabelText("Project") as HTMLInputElement; + const folderInput = screen.getByLabelText("Folder") as HTMLInputElement; + expect(projectInput.pattern).toBe("[A-Za-z0-9][A-Za-z0-9._-]*"); + expect(projectInput.maxLength).toBe(80); + expect(projectInput.required).toBe(true); + expect(folderInput.maxLength).toBe(240); + fireEvent.change(folderInput, { target: { value: "/unsafe" } }); + expect(folderInput.checkValidity()).toBe(false); + expect(screen.getByText(/no leading or trailing slash/i)).toBeTruthy(); + + fireEvent.change(screen.getByLabelText("Profile Name"), { target: { value: "Client QA" } }); + fireEvent.change(screen.getByLabelText("Project"), { target: { value: "marketplace" } }); + fireEvent.change(screen.getByLabelText("Folder"), { target: { value: "buyers/us" } }); + fireEvent.click(screen.getByLabelText("Pinned")); + fireEvent.change(screen.getByLabelText("Accent color"), { target: { value: "#22c55e" } }); + fireEvent.change(screen.getByLabelText("Preferred harness"), { target: { value: "opencode" } }); + fireEvent.change(screen.getByLabelText("Access sandbox"), { target: { value: "research-team" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + name: "Client QA", + project_id: "marketplace", + folder_path: "buyers/us", + pinned: true, + accent_color: "#22c55e", + harness: "opencode", + sandbox_id: "research-team", + })); + expect(screen.getByText("Organization")).toBeTruthy(); + expect(screen.getByText("Access sandbox")).toBeTruthy(); + expect(screen.getByText(/access per sandbox/i)).toBeTruthy(); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 445a14a7..0e99ddcb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from "react"; -import { ArrowLeft, Lock, PanelLeftClose, PanelLeft, ShieldCheck } from "lucide-react"; +import { ArrowLeft, Lock, PanelLeftClose, PanelLeft, ShieldCheck, Globe2, LayoutGrid, Plus, Users, KeyRound } from "lucide-react"; import { useProfiles } from "./hooks/useProfiles"; import { api, @@ -8,21 +8,82 @@ import { type AccessPermission, type Profile, type ProfileCreateData, + type ProfileHarness, } from "./lib/api"; import { hasAccessPermission } from "./lib/accessPermissions"; import { ProfileList } from "./components/ProfileList"; import { ProfileForm } from "./components/ProfileForm"; +import { CreateProfileFlow } from "./components/CreateProfileFlow"; import { ProfileViewer } from "./components/ProfileViewer"; import { LaunchButton } from "./components/LaunchButton"; import { StatusIndicator } from "./components/StatusIndicator"; import { LoginPage } from "./components/LoginPage"; import { MobileSplitScreen } from "./components/mobile/MobileSplitScreen"; import { AccessDashboard } from "./components/AccessDashboard"; +import { ProfileHealthSummary } from "./components/ProfileHealthSummary"; +import { BrowserUseHome } from "./components/BrowserUseHome"; +import { ProxyOverview } from "./components/ProxyOverview"; +import { ProfilesWorkspace } from "./components/ProfilesWorkspace"; +import { AccountsOverview } from "./components/AccountsOverview"; +import { LiveDevPanel } from "./components/LiveDevPanel"; +import { SessionStreamButtons } from "./components/SessionStreamButtons"; type AuthState = "checking" | "required" | "ok" | "error"; -type View = "empty" | "create" | "edit" | "view" | "access"; +type View = "home" | "empty" | "create" | "edit" | "view" | "access" | "proxies" | "profiles" | "accounts"; const MOBILE_WORKSPACE_QUERY = "(max-width: 767px), (pointer: coarse) and (max-width: 1024px)"; type MobileConnectionStatus = "connecting" | "connected" | "reconnecting" | "failed"; +const FIXED_PROJECTS = ["default", "proxied", "mobile", "research"] as const; + +interface ApplyProfileViewportOptions { + profile: Profile | null; + width: number; + height: number; + canManageProfiles: boolean; + canOperateProfile: boolean; + update: (id: string, data: Partial) => Promise; + stop: (id: string) => Promise; + launch: (id: string) => Promise; +} + +export async function applyProfileViewport({ + profile, + width, + height, + canManageProfiles, + canOperateProfile, + update, + stop, + launch, +}: ApplyProfileViewportOptions) { + if (!profile || !canManageProfiles) return false; + if (profile.status === "running" && !canOperateProfile) return false; + + const updatedProfile = await update(profile.id, { screen_width: width, screen_height: height }); + if (!updatedProfile) return false; + if (profile.status !== "running") return true; + + const stopped = await stop(profile.id); + if (!stopped) return false; + + const result = await launch(updatedProfile.id); + return !!result; +} + +interface ToggleProfilePinOptions { + profile: Profile | null; + canManageProfiles: boolean; + update: (id: string, data: Partial) => Promise; +} + +export async function toggleProfilePin({ + profile, + canManageProfiles, + update, +}: ToggleProfilePinOptions) { + if (!profile || !canManageProfiles) return false; + const updated = await update(profile.id, { pinned: !profile.pinned }); + return Boolean(updated); +} export default function App() { const [authState, setAuthState] = useState("checking"); @@ -107,25 +168,74 @@ interface AppContentProps { } function AppContent({ authRequired, accessControlEnabled, identity, onLogout }: AppContentProps) { - const { profiles, loading, error, create, update, remove, launch, stop } = useProfiles(); + const { profiles, loading, error, refresh, create, update, remove, launch, stop } = useProfiles(); const [selectedId, setSelectedId] = useState(null); - const [view, setView] = useState("empty"); + const [view, setView] = useState("home"); const [sidebarOpen, setSidebarOpen] = useState(true); const [mobileFullscreenOpen, setMobileFullscreenOpen] = useState(false); const [mobileBrowserZoom, setMobileBrowserZoom] = useState(100); const [mobileRemoteToolsOpen, setMobileRemoteToolsOpen] = useState(false); const [mobileConnectionStatus, setMobileConnectionStatus] = useState("connecting"); + const [projectId, setProjectId] = useState("default"); + const [harness, setHarness] = useState("browser-use"); + const [taskDraft, setTaskDraft] = useState(""); const isMobile = useIsMobile(); const selected = profiles.find((p) => p.id === selectedId) ?? null; const canManageProfiles = isAdministrator(identity); const canOperateSelected = Boolean(selected && canAccess(identity, selected, "operate")); const canInteractSelected = Boolean(selected && canAccess(identity, selected, "interact")); + const projects = Array.from( + new Set([ + ...FIXED_PROJECTS, + ...profiles.map((profile) => profile.project_id || "default"), + ]), + ); + + useEffect(() => { + if (!isMobile || loading || profiles.length === 0) return; + + const selectedStillExists = selectedId + ? profiles.some((profile) => profile.id === selectedId) + : false; + if (selectedStillExists) return; + + const nextProfile = profiles.find((profile) => profile.status === "running") ?? profiles[0]; + if (!nextProfile) return; + + setSelectedId(nextProfile.id); + setView("view"); + }, [isMobile, loading, profiles, selectedId]); + + // Deep-link from the Cloak Profile Sync extension (?profile=). + useEffect(() => { + if (loading || profiles.length === 0) return; + const params = new URLSearchParams(window.location.search); + const profileParam = params.get("profile"); + if (!profileParam) return; + const match = profiles.find((profile) => profile.id === profileParam); + if (!match) return; + setSelectedId(match.id); + if (match.project_id) setProjectId(match.project_id); + if (match.harness) setHarness(match.harness); + const viewParam = params.get("view"); + const wantFullscreen = params.get("fullscreen") === "1"; + if (match.status === "running") { + setView("view"); + if (wantFullscreen || viewParam === "vnc") { + setMobileFullscreenOpen(true); + } + } else { + setView("home"); + } + }, [loading, profiles]); const handleSelect = useCallback((id: string) => { setSelectedId(id); const profile = profiles.find((p) => p.id === id); - setView(isMobile ? "view" : profile?.status === "running" ? "view" : canManageProfiles ? "edit" : "empty"); + if (profile?.project_id) setProjectId(profile.project_id); + if (profile?.harness) setHarness(profile.harness); + setView(isMobile ? "view" : profile?.status === "running" ? "view" : canManageProfiles ? "edit" : "home"); }, [canManageProfiles, isMobile, profiles]); const handleNew = useCallback(() => { @@ -136,12 +246,16 @@ function AppContent({ authRequired, accessControlEnabled, identity, onLogout }: const handleCreate = useCallback(async (data: ProfileCreateData) => { if (!canManageProfiles) return; - const profile = await create(data); + const profile = await create({ + ...data, + project_id: data.project_id || projectId, + harness: data.harness || harness, + }); if (profile) { setSelectedId(profile.id); setView("edit"); } - }, [canManageProfiles, create]); + }, [canManageProfiles, create, harness, projectId]); const handleUpdate = useCallback(async (data: ProfileCreateData) => { if (!selectedId || !canManageProfiles) return; @@ -152,7 +266,7 @@ function AppContent({ authRequired, accessControlEnabled, identity, onLogout }: if (!selectedId || !canManageProfiles) return; await remove(selectedId); setSelectedId(null); - setView("empty"); + setView("home"); }, [canManageProfiles, selectedId, remove]); const handleLaunch = useCallback(async () => { @@ -164,21 +278,38 @@ function AppContent({ authRequired, accessControlEnabled, identity, onLogout }: const handleStop = useCallback(async () => { if (!selectedId || !canAccess(identity, profiles.find((profile) => profile.id === selectedId) ?? null, "operate")) return; await stop(selectedId); - setView("edit"); - }, [identity, profiles, selectedId, stop]); + setView(canManageProfiles ? "edit" : "home"); + }, [canManageProfiles, identity, profiles, selectedId, stop]); const handleVncDisconnect = useCallback(() => { - setView(canManageProfiles ? "edit" : "empty"); - }, [canManageProfiles]); + setView(isMobile ? "view" : canManageProfiles ? "edit" : "home"); + }, [canManageProfiles, isMobile]); const handleViewportApply = useCallback(async (width: number, height: number) => { - if (!selectedId || !canManageProfiles) return false; - const profile = await update(selectedId, { screen_width: width, screen_height: height }); - return !!profile; - }, [canManageProfiles, selectedId, update]); + return applyProfileViewport({ + profile: selected, + width, + height, + canManageProfiles, + canOperateProfile: canOperateSelected, + update, + stop, + launch, + }); + }, [canManageProfiles, canOperateSelected, launch, selected, stop, update]); + + + const handleTogglePin = useCallback(async (id: string) => { + const profile = profiles.find((candidate) => candidate.id === id) ?? null; + await toggleProfilePin({ + profile, + canManageProfiles, + update, + }); + }, [canManageProfiles, profiles, update]); if (view === "access" && canManageProfiles && accessControlEnabled) { - return setView(selected ? "view" : "empty")} />; + return setView(selected ? "view" : "home")} />; } if (loading) { @@ -288,16 +419,78 @@ function AppContent({ authRequired, accessControlEnabled, identity, onLogout }: return (
- {/* Sidebar */} + {/* Compact Browser-Use style sidebar */} {sidebarOpen && ( -
- +
+
+
Browser Use
+
+ + {canManageProfiles ? ( + + ) : null} + + + {canManageProfiles ? ( + + ) : null} +
+
+
+ +
)} @@ -313,16 +506,24 @@ function AppContent({ authRequired, accessControlEnabled, identity, onLogout }: > {sidebarOpen ? : } - {selected && ( + {selected && view !== "home" && view !== "proxies" && view !== "profiles" && view !== "accounts" && (
{selected.name} {selected.platform} +
)}
- {selected && ( + {selected && selected.status === "running" ? ( + + ) : null} + {selected && view !== "home" && view !== "proxies" && view !== "profiles" && view !== "accounts" && ( canOperateSelected && ( )} + {selected && view === "view" ? ( + + ) : null} + {/* Content */}
+ {view === "home" && ( + setView("proxies")} + onOpenProfiles={() => setView("profiles")} + onOpenAccounts={() => setView("accounts")} + onCreateProjectProfile={handleNew} + onOpenSettings={(profileId) => { + if (!profileId) { + handleNew(); + return; + } + setSelectedId(profileId); + setView(canManageProfiles ? "edit" : "home"); + }} + onLaunchSelected={async () => { + if (!selected) return; + if (selected.status === "running") { + setView("view"); + return; + } + await handleLaunch(); + }} + /> + )} + + {view === "proxies" && canManageProfiles && ( + { + await refresh(); + setSelectedId(profile.id); + setProjectId(profile.project_id || "proxied"); + setHarness(profile.harness); + setView("edit"); + }} + /> + )} + + {view === "profiles" && ( + { + setSelectedId(profileId); + const profile = profiles.find((item) => item.id === profileId); + if (profile?.project_id) setProjectId(profile.project_id); + if (profile?.harness) setHarness(profile.harness); + }} + onEdit={(profileId) => { + setSelectedId(profileId); + setView(canManageProfiles ? "edit" : "profiles"); + }} + /> + )} + + {view === "accounts" && ( + { + setSelectedId(profileId); + const profile = profiles.find((item) => item.id === profileId); + if (profile?.project_id) setProjectId(profile.project_id); + if (profile?.harness) setHarness(profile.harness); + setView(canManageProfiles ? "edit" : "accounts"); + }} + /> + )} + {view === "empty" && (
@@ -376,10 +666,16 @@ function AppContent({ authRequired, accessControlEnabled, identity, onLogout }: )} {view === "create" && canManageProfiles && ( - setView("home")} onSave={handleCreate} - onCancel={() => setView("empty")} + onCreated={async (profileId) => { + await refresh(); + setSelectedId(profileId); + setView("edit"); + }} /> )} @@ -390,7 +686,7 @@ function AppContent({ authRequired, accessControlEnabled, identity, onLogout }: onDelete={handleDelete} onCancel={() => { setSelectedId(null); - setView("empty"); + setView("home"); }} /> )} diff --git a/frontend/src/components/AccessDashboard.test.tsx b/frontend/src/components/AccessDashboard.test.tsx index 549a6ef5..b058765e 100644 --- a/frontend/src/components/AccessDashboard.test.tsx +++ b/frontend/src/components/AccessDashboard.test.tsx @@ -1,16 +1,19 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccessDashboard } from "./AccessDashboard"; const apiMock = vi.hoisted(() => ({ listAccessUsers: vi.fn(), listAccessAgents: vi.fn(), + listAccessGroups: vi.fn(), listAccessSandboxes: vi.fn(), listProfiles: vi.fn(), createAccessUser: vi.fn(), updateAccessUser: vi.fn(), createAccessAgent: vi.fn(), updateAccessAgent: vi.fn(), + createAccessGroup: vi.fn(), + updateAccessGroup: vi.fn(), rotateAccessAgentKey: vi.fn(), })); @@ -20,12 +23,15 @@ describe("AccessDashboard", () => { beforeEach(() => { apiMock.listAccessUsers.mockReset(); apiMock.listAccessAgents.mockReset(); + apiMock.listAccessGroups.mockReset(); apiMock.listAccessSandboxes.mockReset(); apiMock.listProfiles.mockReset(); apiMock.createAccessUser.mockReset(); apiMock.updateAccessUser.mockReset(); apiMock.createAccessAgent.mockReset(); apiMock.updateAccessAgent.mockReset(); + apiMock.createAccessGroup.mockReset(); + apiMock.updateAccessGroup.mockReset(); apiMock.rotateAccessAgentKey.mockReset(); apiMock.listAccessUsers.mockResolvedValue([ @@ -36,28 +42,80 @@ describe("AccessDashboard", () => { active: true, created_at: "2026-07-20T00:00:00Z", grants: [{ sandbox_id: "research", permission: "view" }], + group_ids: ["group-1"], + effective_grants: [ + { sandbox_id: "research", permission: "view" }, + { sandbox_id: "private", permission: "operate" }, + ], }, ]); apiMock.listAccessAgents.mockResolvedValue([]); + apiMock.listAccessGroups.mockResolvedValue([ + { + id: "group-1", + name: "Research team", + description: null, + active: true, + created_at: "2026-07-20T00:00:00Z", + member_user_ids: ["user-1"], + grants: [{ sandbox_id: "private", permission: "operate" }], + }, + ]); apiMock.listAccessSandboxes.mockResolvedValue([ - { sandbox_id: "research", profile_count: 2 }, + { + sandbox_id: "research", + profile_count: 2, + project_ids: ["commerce"], + folder_paths: ["checkout", "payments"], + profile_names: ["Research browser", "Research reports"], + }, + { + sandbox_id: "private", + profile_count: 1, + project_ids: ["security"], + folder_paths: ["audit"], + profile_names: ["Private browser"], + }, ]); apiMock.listProfiles.mockResolvedValue([ { id: "profile-1", name: "Research browser", sandbox_id: "research", + project_id: "commerce", + folder_path: "checkout", status: "running", }, { id: "profile-2", name: "Private browser", sandbox_id: "private", + project_id: "security", + folder_path: "audit", status: "stopped", }, ]); }); + it("defaults to identities and hides add/edit forms until explicit actions", async () => { + render(); + + expect(await screen.findByText("alice")).toBeTruthy(); + expect(screen.getByRole("tab", { name: "Identities" }).getAttribute("aria-selected")).toBe("true"); + expect(screen.queryByLabelText("Username")).toBeNull(); + expect(screen.queryByLabelText("Display name")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Add person" })); + expect(screen.getByLabelText("Username")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByLabelText("Username")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Edit alice" })); + expect(screen.getByLabelText("Username")).toBeTruthy(); + expect((screen.getByLabelText("Username") as HTMLInputElement).disabled).toBe(true); + }); + it("loads existing scopes and creates a Paperclip key with an explicit grant", async () => { apiMock.createAccessAgent.mockResolvedValue({ id: "agent-1", @@ -72,16 +130,17 @@ describe("AccessDashboard", () => { render(); expect(await screen.findByText("alice")).toBeTruthy(); - expect(screen.getByText(/research: View/)).toBeTruthy(); + expect(screen.getByText(/Direct: research: View/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Create agent key" })); fireEvent.change(screen.getByLabelText("Display name"), { target: { value: "Research helper" }, }); - fireEvent.change(screen.getAllByLabelText("Browser control for research")[1], { + fireEvent.change(screen.getByLabelText("Browser control for research"), { target: { value: "operate" }, }); - fireEvent.click(screen.getAllByLabelText("CDP automation for research")[1]); - fireEvent.click(screen.getByRole("button", { name: "Create agent key" })); + fireEvent.click(screen.getByLabelText("CDP automation for research")); + fireEvent.click(screen.getByRole("button", { name: "Create key" })); await waitFor(() => expect(apiMock.createAccessAgent).toHaveBeenCalledWith({ display_name: "Research helper", @@ -95,6 +154,73 @@ describe("AccessDashboard", () => { .toBe("test-agent-key-only"); }); + it("creates and updates groups with membership and sandbox grants", async () => { + apiMock.createAccessGroup.mockResolvedValue({ + id: "group-2", + name: "Ops", + description: "Operators", + active: true, + created_at: "2026-07-21T00:00:00Z", + member_user_ids: ["user-1"], + grants: [{ sandbox_id: "research", permission: "operate" }], + }); + + render(); + + expect(await screen.findByText("alice")).toBeTruthy(); + fireEvent.click(screen.getByRole("tab", { name: "Groups" })); + expect(screen.getByText("Research team")).toBeTruthy(); + expect(screen.queryByText("Shared private research browsers")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Add group" })); + expect((screen.getByLabelText("Group name") as HTMLInputElement).maxLength).toBe(120); + expect((screen.getByLabelText("Short description") as HTMLTextAreaElement).maxLength).toBe(500); + fireEvent.change(screen.getByLabelText("Group name"), { target: { value: "Ops" } }); + fireEvent.change(screen.getByLabelText("Short description"), { target: { value: "Operators" } }); + fireEvent.click(screen.getByLabelText("Group member alice")); + fireEvent.change(screen.getByLabelText("Browser control for research"), { + target: { value: "operate" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create group" })); + + await waitFor(() => expect(apiMock.createAccessGroup).toHaveBeenCalledWith({ + name: "Ops", + description: "Operators", + active: true, + member_user_ids: ["user-1"], + grants: [{ sandbox_id: "research", permission: "operate" }], + })); + + fireEvent.click(screen.getByRole("button", { name: "Edit group Research team" })); + expect((screen.getByLabelText("Short description") as HTMLTextAreaElement).value).toBe(""); + fireEvent.click(screen.getByLabelText("Group active")); + fireEvent.click(screen.getByRole("button", { name: "Save group" })); + + await waitFor(() => expect(apiMock.updateAccessGroup).toHaveBeenCalledWith("group-1", { + name: "Research team", + description: null, + active: false, + member_user_ids: ["user-1"], + grants: [{ sandbox_id: "private", permission: "operate" }], + })); + }); + + it("updates user group assignments in person payloads", async () => { + render(); + + expect(await screen.findByText("alice")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Edit alice" })); + fireEvent.click(screen.getByLabelText("User group Research team")); + fireEvent.click(screen.getByRole("button", { name: "Save person" })); + + await waitFor(() => expect(apiMock.updateAccessUser).toHaveBeenCalledWith("user-1", { + role: "viewer", + active: true, + grants: [{ sandbox_id: "research", permission: "view" }], + group_ids: [], + })); + }); + it("keeps mobile access controls at touch-target size", async () => { apiMock.listAccessAgents.mockResolvedValue([ { @@ -114,21 +240,24 @@ describe("AccessDashboard", () => { for (const buttonName of ["Refresh", "Close", "Add person", "Create agent key"]) { expect(screen.getByRole("button", { name: buttonName }).className).toContain("min-h-11"); } + expect(screen.getByRole("button", { name: "Edit alice" }).className).toContain("h-11"); + expect(screen.getByRole("button", { name: "Rotate key for Research helper" }).className).toContain("h-11"); + expect(screen.getByRole("button", { name: "Edit Research helper" }).className).toContain("h-11"); - for (const permissionSelect of screen.getAllByLabelText("Browser control for research")) { - expect(permissionSelect.className).toContain("h-11"); - } - for (const automationToggle of screen.getAllByLabelText("CDP automation for research")) { - expect(automationToggle.closest("label")?.className).toContain("min-h-11"); - } + fireEvent.click(screen.getByRole("button", { name: "Add person" })); expect(screen.getByLabelText("Username").className).toContain("min-h-11"); expect(screen.getByLabelText("Password").className).toContain("min-h-11"); expect(screen.getByLabelText("Role").className).toContain("h-11"); - expect(screen.getByLabelText("Display name").className).toContain("min-h-11"); - expect(screen.getByLabelText("Paperclip agent ID (optional)").className).toContain("min-h-11"); - expect(screen.getByRole("button", { name: "Edit alice" }).className).toBe("mobile-icon-button"); - expect(screen.getByRole("button", { name: "Rotate key for Research helper" }).className).toBe("mobile-icon-button"); - expect(screen.getByRole("button", { name: "Edit Research helper" }).className).toBe("mobile-icon-button"); + expect(screen.getByLabelText("Browser control for research").className).toContain("h-11"); + expect(screen.getByLabelText("CDP automation for research").closest("label")?.className).toContain("min-h-11"); + expect(screen.getByLabelText("User group Research team").closest("label")?.className).toContain("min-h-11"); + + fireEvent.click(screen.getByRole("tab", { name: "Groups" })); + fireEvent.click(screen.getByRole("button", { name: "Add group" })); + expect(screen.getByLabelText("Group name").className).toContain("min-h-11"); + expect(screen.getByLabelText("Short description").className).toContain("min-h-11"); + expect(screen.getByLabelText("Group active").closest("label")?.className).toContain("min-h-11"); + expect(screen.getByLabelText("Group member alice").closest("label")?.className).toContain("min-h-11"); }); it("allows long sandbox identifiers to shrink instead of widening a mobile form", async () => { @@ -138,27 +267,43 @@ describe("AccessDashboard", () => { render(); + expect(await screen.findByText("alice")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Add person" })); + const longSandbox = "paperclip-automation-sandbox-with-a-long-name"; - const permission = await screen.findAllByLabelText(`Browser control for ${longSandbox}`); - const grantFieldset = permission[0].closest("fieldset"); - const peopleSection = screen.getByRole("heading", { name: "People" }).closest("section"); - const dashboardGrid = peopleSection?.parentElement; + const permission = screen.getByLabelText(`Browser control for ${longSandbox}`); + const grantFieldset = permission.closest("fieldset"); + const identitiesPanel = screen.getByRole("tabpanel", { name: "Identities" }); expect(grantFieldset?.className).toContain("min-w-0"); - expect(permission[0].parentElement?.className).toContain("min-w-0"); - expect(permission[0].className).toContain("shrink-0"); - expect(peopleSection?.className).toContain("min-w-0"); - expect(dashboardGrid?.className).toContain("grid-cols-1"); + expect(permission.parentElement?.className).toContain("min-w-0"); + expect(permission.className).toContain("shrink-0"); + expect(identitiesPanel.className).toContain("min-w-0"); }); - it("shows the effective profile scope and keeps unrelated sandboxes hidden", async () => { + it("uses inherited effective grants for people access preview", async () => { render(); expect(await screen.findByText("alice")).toBeTruthy(); - expect(screen.getByText("Effective access · 1 browser")).toBeTruthy(); + expect(screen.getByText("Effective access · 2 browsers")).toBeTruthy(); const preview = screen.getByLabelText("Effective browser access for alice"); expect(preview.textContent).toContain("Research browser"); - expect(preview.textContent).toContain("View"); - expect(preview.textContent).not.toContain("Private browser"); + expect(preview.textContent).toContain("Private browser"); + expect(preview.textContent).toContain("commerce / checkout"); + expect(preview.textContent).toContain("security / audit"); + expect(preview.textContent).toContain("Operate"); + expect(within(screen.getByLabelText("Groups for alice")).getByText("Research team")).toBeTruthy(); + }); + + it("shows redacted project and folder context beside sandbox grants", async () => { + render(); + + expect(await screen.findByText("alice")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Add person" })); + + const researchGrant = screen.getByLabelText("Browser control for research").closest("div"); + expect(researchGrant?.textContent).toContain("Projects: commerce"); + expect(researchGrant?.textContent).toContain("Folders: checkout, payments"); + expect(researchGrant?.textContent).not.toContain("proxy"); }); }); diff --git a/frontend/src/components/AccessDashboard.tsx b/frontend/src/components/AccessDashboard.tsx index 284856c7..ad508c73 100644 --- a/frontend/src/components/AccessDashboard.tsx +++ b/frontend/src/components/AccessDashboard.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; import { + Bot, Copy, KeyRound, Pencil, @@ -7,12 +8,14 @@ import { RefreshCw, ShieldCheck, UserRound, + UsersRound, X, } from "lucide-react"; import { api, type AccessAgent, type AccessGrant, + type AccessGroup, type AccessPermission, type AccessRole, type AccessSandbox, @@ -20,6 +23,7 @@ import { type Profile, } from "../lib/api"; import { hasAccessPermission } from "../lib/accessPermissions"; +import { profileOrganizationLabel } from "../lib/profileOrganization"; interface AccessDashboardProps { onClose: () => void; @@ -31,6 +35,7 @@ interface UserDraft { role: AccessRole; active: boolean; grants: AccessGrant[]; + group_ids: string[]; } interface AgentDraft { @@ -40,12 +45,23 @@ interface AgentDraft { grants: AccessGrant[]; } +interface GroupDraft { + name: string; + description: string; + active: boolean; + member_user_ids: string[]; + grants: AccessGrant[]; +} + +type AccessTab = "identities" | "groups"; + const emptyUserDraft = (): UserDraft => ({ username: "", password: "", role: "viewer", active: true, grants: [], + group_ids: [], }); const emptyAgentDraft = (): AgentDraft => ({ @@ -55,6 +71,14 @@ const emptyAgentDraft = (): AgentDraft => ({ grants: [], }); +const emptyGroupDraft = (): GroupDraft => ({ + name: "", + description: "", + active: true, + member_user_ids: [], + grants: [], +}); + const permissionLabel: Record = { view: "View", interact: "Interact", @@ -71,9 +95,10 @@ const permissionOrder: Record = { automate: 3, }; -const actionButtonClass = "btn-secondary inline-flex min-h-11 items-center gap-1.5"; -const primaryActionButtonClass = "btn-primary inline-flex min-h-11 items-center gap-1.5"; -const compactActionButtonClass = "min-h-11 rounded-md px-3 text-xs text-gray-500 underline focus:outline-none focus:ring-2 focus:ring-accent/50"; +const actionButtonClass = "btn-secondary inline-flex min-h-11 items-center gap-1"; +const primaryActionButtonClass = "btn-primary inline-flex min-h-11 items-center gap-1"; +const compactActionButtonClass = "min-h-11 rounded-md px-2 text-[11px] text-gray-500 underline focus:outline-none focus:ring-2 focus:ring-accent/50"; +const iconButtonClass = "inline-flex h-11 w-11 shrink-0 items-center justify-center rounded border border-border bg-surface-2 text-gray-300 transition-colors hover:bg-surface-3 focus:outline-none focus:ring-2 focus:ring-accent/50"; function summarizeGrants(grants: AccessGrant[]) { if (!grants.length) return "No sandbox access"; @@ -123,6 +148,11 @@ function updateAutomationGrant(grants: AccessGrant[], sandboxId: string, enabled : preserved); } +function toggleId(ids: string[], id: string, checked: boolean) { + if (checked) return ids.includes(id) ? ids : [...ids, id]; + return ids.filter((currentId) => currentId !== id); +} + function effectiveCapabilityLabel(grants: AccessGrant[], sandboxId: string) { const labels: string[] = []; if (hasAccessPermission(grants, sandboxId, "operate")) labels.push("Operate"); @@ -132,6 +162,21 @@ function effectiveCapabilityLabel(grants: AccessGrant[], sandboxId: string) { return labels.join(" + "); } +function groupNamesForUser(user: AccessUser, groups: AccessGroup[]) { + const groupIds = user.group_ids ?? []; + return groups + .filter((group) => groupIds.includes(group.id)) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((group) => group.name); +} + +function memberNames(group: AccessGroup, users: AccessUser[]) { + return users + .filter((user) => group.member_user_ids.includes(user.id)) + .sort((left, right) => left.username.localeCompare(right.username)) + .map((user) => user.username); +} + function EffectiveAccessPreview({ label, profiles, @@ -159,7 +204,9 @@ function EffectiveAccessPreview({
  • {profile.name} - {profile.sandbox_id} + + {profile.sandbox_id} · {profileOrganizationLabel(profile)} + {administrator ? "Administrator" : effectiveCapabilityLabel(grants, profile.sandbox_id)} @@ -188,7 +235,13 @@ function GrantEditor({ const known = new Map(sandboxes.map((sandbox) => [sandbox.sandbox_id, sandbox])); for (const grant of grants) { if (!known.has(grant.sandbox_id)) { - known.set(grant.sandbox_id, { sandbox_id: grant.sandbox_id, profile_count: 0 }); + known.set(grant.sandbox_id, { + sandbox_id: grant.sandbox_id, + profile_count: 0, + project_ids: [], + folder_paths: [], + profile_names: [], + }); } } return [...known.values()].sort((left, right) => left.sandbox_id.localeCompare(right.sandbox_id)); @@ -211,12 +264,21 @@ function GrantEditor({ )); return (
    - - {sandbox.sandbox_id} - ({sandbox.profile_count} browser{sandbox.profile_count === 1 ? "" : "s"}) - +
    +

    + {sandbox.sandbox_id} + ({sandbox.profile_count} browser{sandbox.profile_count === 1 ? "" : "s"}) +

    + {(sandbox.project_ids?.length || sandbox.folder_paths?.length) ? ( +

    + {(sandbox.project_ids ?? []).length ? `Projects: ${sandbox.project_ids.join(", ")}` : ""} + {(sandbox.project_ids ?? []).length && (sandbox.folder_paths ?? []).length ? " · " : ""} + {(sandbox.folder_paths ?? []).length ? `Folders: ${sandbox.folder_paths.join(", ")}` : ""} +

    + ) : null} +
    onChange(toggleId(selectedUserIds, user.id, event.target.checked))} + /> + {user.username} + + ))} +
    + ) : ( +

    Add people before assigning group membership.

    + )} + + ); +} + export function AccessDashboard({ onClose }: AccessDashboardProps) { + const [activeTab, setActiveTab] = useState("identities"); const [users, setUsers] = useState([]); const [agents, setAgents] = useState([]); + const [groups, setGroups] = useState([]); const [sandboxes, setSandboxes] = useState([]); const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); @@ -267,22 +364,29 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) { const [saving, setSaving] = useState(false); const [editingUserId, setEditingUserId] = useState(null); const [editingAgentId, setEditingAgentId] = useState(null); + const [editingGroupId, setEditingGroupId] = useState(null); + const [showUserForm, setShowUserForm] = useState(false); + const [showAgentForm, setShowAgentForm] = useState(false); + const [showGroupForm, setShowGroupForm] = useState(false); const [userDraft, setUserDraft] = useState(emptyUserDraft); const [agentDraft, setAgentDraft] = useState(emptyAgentDraft); + const [groupDraft, setGroupDraft] = useState(emptyGroupDraft); const [revealedKey, setRevealedKey] = useState<{ name: string; key: string } | null>(null); const [copied, setCopied] = useState(false); const refresh = useCallback(async () => { setLoading(true); try { - const [nextUsers, nextAgents, nextSandboxes, nextProfiles] = await Promise.all([ + const [nextUsers, nextAgents, nextGroups, nextSandboxes, nextProfiles] = await Promise.all([ api.listAccessUsers(), api.listAccessAgents(), + api.listAccessGroups(), api.listAccessSandboxes(), api.listProfiles(), ]); setUsers(nextUsers); setAgents(nextAgents); + setGroups(nextGroups); setSandboxes(nextSandboxes); setProfiles(nextProfiles); setError(null); @@ -306,11 +410,13 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) { role: AccessRole; active: boolean; grants: AccessGrant[]; + group_ids: string[]; password?: string; } = { role: userDraft.role, active: userDraft.active, grants: userDraft.grants, + group_ids: userDraft.group_ids, }; if (userDraft.password) data.password = userDraft.password; await api.updateAccessUser(editingUserId, data); @@ -320,9 +426,11 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) { password: userDraft.password, role: userDraft.role, grants: userDraft.grants, + group_ids: userDraft.group_ids, }); } setEditingUserId(null); + setShowUserForm(false); setUserDraft(emptyUserDraft()); await refresh(); } catch (err) { @@ -353,6 +461,7 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) { setCopied(false); } setEditingAgentId(null); + setShowAgentForm(false); setAgentDraft(emptyAgentDraft()); await refresh(); } catch (err) { @@ -362,6 +471,33 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) { } }; + const saveGroup = async (event: FormEvent) => { + event.preventDefault(); + setSaving(true); + try { + const data = { + name: groupDraft.name.trim(), + description: groupDraft.description.trim() || null, + active: groupDraft.active, + member_user_ids: groupDraft.member_user_ids, + grants: groupDraft.grants, + }; + if (editingGroupId) { + await api.updateAccessGroup(editingGroupId, data); + } else { + await api.createAccessGroup(data); + } + setEditingGroupId(null); + setShowGroupForm(false); + setGroupDraft(emptyGroupDraft()); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not save group"); + } finally { + setSaving(false); + } + }; + const rotateAgentKey = async (agent: AccessAgent) => { setSaving(true); try { @@ -388,8 +524,8 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) { }; return ( -
    -
    +
    +
    @@ -397,7 +533,7 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) {

    Browser access controls

    - Give people and Paperclip agents only the browser sandboxes they need. The server enforces these permissions for the dashboard, VNC and CDP. + Assign direct and group-based sandbox permissions for people and Paperclip agents.

    @@ -413,6 +549,21 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) {
    +
    + {(["identities", "groups"] as const).map((tab) => ( + + ))} +
    + {error && (
    {error} @@ -420,7 +571,7 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) { )} {revealedKey && ( -
    +

    Copy the new key for {revealedKey.name} now

    @@ -432,7 +583,7 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) {
    - +
    )} -
    -
    -
    - -
    + {activeTab === "identities" ? ( +
    +
    +
    +

    People

    -

    Named dashboard sign-ins with individual sandbox access.

    +
    -
    - {loading ?

    Loading people…

    : null} +
    + {loading ?

    Loading people...

    : null} {!loading && users.length === 0 ?

    No named users yet.

    : null} - {users.map((user) => ( -
    -
    -
    -
    - {user.username} - {user.role} - {!user.active && disabled} + {users.map((user) => { + const names = groupNamesForUser(user, groups); + return ( +
    +
    +
    +
    + {user.username} + {user.role} + + {user.active ? "active" : "disabled"} + +
    + {names.length ? ( +
    + {names.map((name) => ( + {name} + ))} +
    + ) : null} +

    Direct: {user.role === "admin" ? "All sandboxes (administrator)" : summarizeGrants(user.grants)}

    +
    -

    {user.role === "admin" ? "All sandboxes (administrator)" : summarizeGrants(user.grants)}

    - +
    - -
    -
    - ))} + + ); + })}
    -
    -
    -

    {editingUserId ? "Edit person" : "Add person"}

    - {editingUserId && ( + {showUserForm ? ( + +
    +

    {editingUserId ? "Edit person" : "Add person"}

    - )} -
    -
    - - -
    -
    - - {editingUserId ? ( -
    +
    + + +
    +
    + + {editingUserId ? ( + + ) : null} +
    + {groups.length ? ( +
    + Groups +
    + {groups.map((group) => ( + + ))} +
    +
    ) : null} -
    - setUserDraft((current) => ({ ...current, grants }))} /> - - -
    - -
    -
    - -
    + setUserDraft((current) => ({ ...current, grants }))} /> + + + ) : null} + +
    +
    +

    Paperclip agents

    -

    Opaque keys that can only access their assigned sandboxes.

    +
    -
    - {loading ?

    Loading agents…

    : null} +
    + {loading ?

    Loading agents...

    : null} {!loading && agents.length === 0 ?

    No Paperclip agents yet.

    : null} {agents.map((agent) => (
    -
    +
    {agent.display_name} - {!agent.active && disabled} + + {agent.active ? "active" : "disabled"} +
    {agent.paperclip_agent_id &&

    {agent.paperclip_agent_id}

    }

    {summarizeGrants(agent.grants)}

    @@ -566,12 +775,12 @@ export function AccessDashboard({ onClose }: AccessDashboardProps) { />
    -
    -
    -
    -

    {editingAgentId ? "Edit Paperclip agent" : "Add Paperclip agent"}

    - {editingAgentId && ( + {showAgentForm ? ( + +
    +

    {editingAgentId ? "Edit Paperclip agent" : "Add Paperclip agent"}

    - )} -
    - - - {editingAgentId ? ( -
    + - ) : null} - setAgentDraft((current) => ({ ...current, grants }))} /> - + + ) : null} +
    + ) : ( +
    +
    +
    + +

    Groups

    +
    + - +
    + +
    + {loading ?

    Loading groups...

    : null} + {!loading && groups.length === 0 ?

    No access groups yet.

    : null} + {groups.map((group) => { + const names = memberNames(group, users); + return ( +
    +
    +
    +
    + {group.name} + + {group.active ? "active" : "disabled"} + + + {group.member_user_ids.length} member{group.member_user_ids.length === 1 ? "" : "s"} + +
    + {group.description ?

    {group.description}

    : null} +

    Members: {names.length ? names.join(", ") : "None"}

    +

    {summarizeGrants(group.grants)}

    +
    + +
    +
    + ); + })} +
    + + {showGroupForm ? ( +
    +
    +

    {editingGroupId ? "Edit group" : "Add group"}

    + +
    + +