From 813bea41845962afebb01807220e2dbb12df5130 Mon Sep 17 00:00:00 2001 From: CAPSLOCKB <138304505+Capslockb@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:46:04 +0200 Subject: [PATCH] feat(vnc-session): persistent noVNC browser login for web-cookie providers (#7892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (#7168) * feat(vnc-session): persistent noVNC browser login for web cookie/token providers ## Why (the headless-install problem) OmniRoute's web cookie/token providers (ChatGPT Web, Gemini Web, Claude Web, DeepSeek Web, …) need a live browser session, but the gateway normally runs **headless** — as a systemd service, inside Docker, or on a VPS with no display. There is no desktop for the operator to log into the provider in. Today the operator has to obtain the session cookie/token *out of band* (open a real browser elsewhere, export cookies, paste them into the connection row). That is fiddly, breaks on every provider UI change, and is a non-starter on a headless box where you can't open a browser at all. This PR adds an **on-demand interactive login**: OmniRoute boots a containerized browser that exposes a noVNC web UI at the host. The operator opens that URL in *their own* browser, logs in normally, and OmniRoute then harvests the resulting cookies / localStorage back into the provider's `provider_connections` row over the DevTools Protocol. No display required on the host — the headless server renders the login into a container and the human just drives it through a web page. ## How we ran into this - The shipped `dist/` bundle has **no App Router source**, so the only visible seam was `dist/server-ws.mjs`'s `http.createServer` monkeypatch. That seam is **dead**: Next's standalone `startServer` creates its own http server in a way that bypasses the override, so a route registered there never fires (debug logs confirmed: zero requests reached it). The real seam is the Next **App Router** (`src/app/api/...`), which lives in the dev tree, not `dist/`. - **Chromium ≥130 forces the remote-debugging port onto `127.0.0.1`** and ignores `--remote-debugging-address=0.0.0.0`. A plain published port can't reach it, so cookie harvest needs an in-container TCP bridge to republish the loopback CDP onto `0.0.0.0`. We shipped that bridge, but the cleaner default is **Firefox** (`jlesage/firefox`): its debugger binds `0.0.0.0` out of the box, so harvest works with no bridge at all. - The CDP harvester **hung forever** on the first tries: the message handler was defined but never attached to the socket, so every `send()` promise stayed pending. We replaced Playwright's `connectOverCDP` (which stalls through the bridge) with a **raw `ws` client** and wired the handler — now resolves. ## What New management API (scoped like the other admin endpoints via `requireManagementAuth`): | Method | Path | Purpose | | --- | --- | --- | | GET | `/api/vnc-session` | list active sessions + supported providers | | GET | `/api/vnc-session/:provider` | session state | | POST | `/api/vnc-session/:provider/start` | boot browser container → returns `vncUrl` | | POST | `/api/vnc-session/:provider/harvest` | persist cookies into the provider row | | POST | `/api/vnc-session/:provider/touch` | defer idle auto-stop | | DELETE | `/api/vnc-session/:provider` | stop + remove the container | ## Implementation - `src/lib/vncSession/manifest.ts` — provider → login URL + cookie/token map + config - `src/lib/vncSession/harvest.ts` — raw-CDP cookie/localStorage harvester (`ws`) - `src/lib/vncSession/service.ts` — docker lifecycle, port allocation, idle sweep, DB write - `src/app/api/vnc-session/**` — App Router routes - `src/lib/gracefulShutdown.ts` — tears down running login containers on exit ## Browser image choice Default is **`jlesage/firefox`** (0.0.0.0-friendly CDP, no bridge). The Chromium image + in-container bridge lives under `docker/vnc-browser/chromium`, selectable via `OMNIROUTE_VNC_IMAGE`. See `docker/vnc-browser/README.md`. ## Config (env) `OMNIROUTE_VNC_IMAGE`, `OMNIROUTE_VNC_CONTAINER_VNC_PORT`, `OMNIROUTE_VNC_CONTAINER_CDP_PORT`, `OMNIROUTE_VNC_PROFILE_DIR`, `OMNIROUTE_VNC_IDLE_MS`, `OMNIROUTE_VNC_MAX_MS`, `OMNIROUTE_VNC_MAX_SESSIONS`, `OMNIROUTE_DOCKER_BIN` — all documented in the docker README. ## Tests `tests/unit/vnc-session.test.ts` — manifest lookup + credential mapping (cookie / token / whole-jar). All passing via the Node test runner. ## Notes - Docker is the only external dependency; if the `docker` CLI is missing, `start` throws a clear error and shutdown is a no-op. - No secrets are returned by any endpoint — only session metadata + ports. Co-authored-by: Sora <138304505+Capslockb@users.noreply.github.com> Co-authored-by: Bernardo <138304505+Capslockb@users.noreply.github.com> * refactor(vnc-session): derive provider credentials from shared contract * fix(vnc-session): harden CDP harvesting and credential filtering * refactor(vnc-session): scope lifecycle to provider connections * fix(vnc-session): sanitize and scope management routes * fix(vnc-session): use canonical provider list in API * test(vnc-session): align coverage with canonical manifest * docs(vnc-session): align browser setup with current implementation * fix(security): loopback-gate /api/vnc-session (Hard Rule #15/#17) The new /api/vnc-session/* routes spawn Docker containers via child_process.spawn (src/lib/vncSession/service.ts) but were never registered in LOCAL_ONLY_API_PREFIXES or SPAWN_CAPABLE_PREFIXES, so they were reachable from non-loopback callers (any manage-scope API key or dashboard session over a tunnel) - the same CVE class (GHSA-fhh6-4qxv-rpqj) those constants exist to close. Register VNC_ROUTE_PREFIX (already exported but unused in manifest.ts) in both prefix lists, and add a regression test asserting isLocalOnlyPath()/isLocalOnlyBypassableByManageScope() correctly classify the new prefix. Co-authored-by: CAPSLOCKB <138304505+Capslockb@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza --------- Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: Diego Rodrigues de Sa e Souza --- config/quality/file-size-baseline.json | 5 +- docker/vnc-browser/README.md | 70 +++ docker/vnc-browser/chromium/Dockerfile | 24 + docker/vnc-browser/chromium/cdp-bridge.py | 55 +++ .../vnc-browser/chromium/startwm_wayland.sh | 24 + docker/vnc-browser/chromium/svc-de-run | 64 +++ src/app/api/vnc-session/[...params]/route.ts | 147 ++++++ src/app/api/vnc-session/route.ts | 26 ++ src/lib/gracefulShutdown.ts | 16 +- src/lib/vncSession/harvest.ts | 429 ++++++++++++++++++ src/lib/vncSession/manifest.ts | 96 ++++ src/lib/vncSession/service.ts | 387 ++++++++++++++++ src/server/authz/routeGuard.ts | 2 + src/shared/constants/spawnCapablePrefixes.ts | 1 + ...route-guard-vnc-session-local-only.test.ts | 29 ++ tests/unit/vnc-session.test.ts | 89 ++++ 16 files changed, 1460 insertions(+), 4 deletions(-) create mode 100644 docker/vnc-browser/README.md create mode 100644 docker/vnc-browser/chromium/Dockerfile create mode 100644 docker/vnc-browser/chromium/cdp-bridge.py create mode 100644 docker/vnc-browser/chromium/startwm_wayland.sh create mode 100644 docker/vnc-browser/chromium/svc-de-run create mode 100644 src/app/api/vnc-session/[...params]/route.ts create mode 100644 src/app/api/vnc-session/route.ts create mode 100644 src/lib/vncSession/harvest.ts create mode 100644 src/lib/vncSession/manifest.ts create mode 100644 src/lib/vncSession/service.ts create mode 100644 tests/unit/authz/route-guard-vnc-session-local-only.test.ts create mode 100644 tests/unit/vnc-session.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 191de70e20..9ba0f177d4 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -273,7 +273,7 @@ "src/lib/resilience/settings.ts": 841, "src/lib/tailscaleTunnel.ts": 1202, "src/lib/usage/callLogs.ts": 997, - "src/lib/usage/providerLimits.ts": 1003, + "src/lib/usage/providerLimits.ts": 1006, "src/lib/usage/usageHistory.ts": 988, "_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", "_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.", @@ -435,5 +435,6 @@ "_rebaseline_2026_07_15_7045_perf_instrumentation": "PR #7045 (@oyi77) own growth: open-sse/utils/stream.ts 2796->2814 (+18) from performance.mark/measure instrumentation around the SSE dispatch chokepoint (b48ba21c4), a TextEncoder hoisting fix to avoid a per-chunk allocation on the hot path (c35e8a9b4), and clearing the fixed-name \"omni-request-body-size\" mark immediately after creation (babysit fix, addressing a review-flagged unbounded-growth leak in Node's global performance timeline). Cohesive wiring at the existing stream-dispatch chokepoint; not extractable. Covered by tests/unit/chatcore-streaming-pipeline.test.ts + tests/unit/stream-request-body-size-mark-7045.test.ts.", "_rebaseline_2026_07_18_basereds_test_realignment": "Base-red sweep own growth (post 102-PR campaign, full-suite realignment): tests/unit/combo-routing-engine.test.ts 3209->3243 (+34 = least-used tests now prime usage through real handleComboChat calls so recordComboRequest keys by the resolved executionKey exactly as production does — #7015 keying); tests/unit/db-migration-runner.test.ts 1491->1499 (+8 = withNonTestEnvironment now also strips node --test tokens from process.execArgv, matching the #7359 isAutomatedTestProcess widening); tests/unit/executor-default-base.test.ts 1523->1527 (+4 = 1M-beta assertion updated for claude-sonnet-4-6 GA #7129). All three are test-fidelity realignments, not extractable.", "_rebaseline_2026_07_21_7930_pplx_quota_cooldown": "PR #7930 (@artickc) own growth, reconstructed against release/v3.8.49 base-drift: tests/unit/perplexity-web.test.ts 1192->1355 (+163 = two new regression cases — 'Live multi-step: reconstructs answer without status COMPLETED' proving RFC-6902 diff-patched plan_block goals now surface as reasoning_content the same as a materialized plan_block, and 'Advanced-model quota upsell with empty answer surfaces clear error' proving the new advanced_models_quota_low upsell_information detection maps to HTTP 429 + reset_seconds + Retry-After instead of a silent empty-content 502). Most of the PR's original 'multi-step empty content' claims were already independently fixed on release via a different mechanism (extractAnswerFromFinalText + longestMarkdownAnswer); only the two genuinely new, non-conflicting pieces (diff-block plan-goal extraction + quota cooldown) were ported. Covered by the two new tests; not extractable without splitting the whole executor test file.", - "_rebaseline_2026_07_22_v3849_ownGrowth_merge_batch": "OAuthModal(#7735 grok chooser), muse-spark-web(#7528 WS), combo.ts+combo-routing-engine.test(#7301 cooldown-retry) — pre-existing on tip; PricingTab(#7972), ComboDefaultsTab(#8008/#7973) — this train batch. Legitimate own-growth, owner-approved rebaseline." + "_rebaseline_2026_07_22_v3849_ownGrowth_merge_batch": "OAuthModal(#7735 grok chooser), muse-spark-web(#7528 WS), combo.ts+combo-routing-engine.test(#7301 cooldown-retry) — pre-existing on tip; PricingTab(#7972), ComboDefaultsTab(#8008/#7973) — this train batch. Legitimate own-growth, owner-approved rebaseline.", + "_rebaseline_2026_07_22_providerLimits_webcookie_chain": "providerLimits.ts 1003->1005: own-growth from web-cookie provider usage-fetcher entries (#7994/#8006/#8027 chain) landing after the prior rebaseline." } diff --git a/docker/vnc-browser/README.md b/docker/vnc-browser/README.md new file mode 100644 index 0000000000..e3ff9f8b14 --- /dev/null +++ b/docker/vnc-browser/README.md @@ -0,0 +1,70 @@ +# Browser-login container + +OmniRoute can open an isolated browser for provider connections that require an interactive web login. The operator signs in through the browser UI, then OmniRoute reads only the credential fields declared for that provider through the Chrome DevTools Protocol (CDP) and writes them to the selected `provider_connections` row. + +The feature is exposed through the management-authenticated `/api/vnc-session` routes. Browser and CDP ports are published on `127.0.0.1` only; they are not intended to be exposed directly to a network. + +## Build the required image + +The current implementation uses the Chromium image in this directory. Build it before starting a browser-login session: + +```bash +docker build -t omniroute-vnc-chromium:local docker/vnc-browser/chromium +``` + +`omniroute-vnc-chromium:local` is the default image. Set `OMNIROUTE_VNC_IMAGE` only when using a compatible image that provides: + +- a browser UI on the configured container VNC port; +- a reachable CDP endpoint on the configured container CDP port; +- support for the `CHROME_CLI` environment variable used to pass the provider login URL and Chromium arguments; +- persistent browser data under the configured profile directory. + +The container image includes a local bridge because modern Chromium binds its debugger to loopback inside the container. + +## Ports and access + +Docker assigns ephemeral host ports and binds them to loopback: + +| Purpose | Default container port | Host exposure | +| --- | ---: | --- | +| Browser web UI | `3000` | `127.0.0.1:` | +| DevTools/CDP bridge | `9223` | `127.0.0.1:` | + +Remote operators must access the browser UI through an authenticated application proxy or an SSH tunnel. Do not publish either port on `0.0.0.0`. + +## Configuration + +| Variable | Default | Purpose | +| --- | --- | --- | +| `OMNIROUTE_VNC_IMAGE` | `omniroute-vnc-chromium:local` | Compatible browser image | +| `OMNIROUTE_VNC_CONTAINER_VNC_PORT` | `3000` | Browser UI port inside the container | +| `OMNIROUTE_VNC_CONTAINER_CDP_PORT` | `9223` | CDP bridge port inside the container | +| `OMNIROUTE_VNC_CONTAINER_PROFILE_DIR` | `/config` | Profile mount point inside the container | +| `OMNIROUTE_VNC_PROFILE_DIR` | `$HOME/.omniroute/browser-login-profiles` | Host profile root | +| `OMNIROUTE_VNC_PERSIST_PROFILES` | `false` | Reuse a connection profile across sessions | +| `OMNIROUTE_VNC_IDLE_MS` | `600000` | Idle-session timeout in milliseconds | +| `OMNIROUTE_VNC_MAX_MS` | `1800000` | Maximum session lifetime in milliseconds | +| `OMNIROUTE_VNC_MAX_SESSIONS` | `4` | Maximum concurrent sessions | +| `OMNIROUTE_VNC_READY_MS` | `45000` | Browser/CDP startup timeout in milliseconds | +| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | Credential-harvest timeout in milliseconds | +| `OMNIROUTE_VNC_CHROMIUM_ARGS` | see `manifest.ts` | Chromium command-line arguments | +| `OMNIROUTE_DOCKER_BIN` | `docker` | Docker-compatible CLI executable | + +## Security and lifecycle + +- Sessions are scoped to a specific provider connection and use random session IDs. +- Container names and persistent-profile path segments are sanitized. +- Only declared cookie or storage keys are retained; arbitrary local or session storage is not copied into the database. +- Startup failures remove the in-memory session, container, and non-persistent profile. +- Concurrent stop requests are idempotent, and shutdown removes managed containers. +- Harvest and CDP commands have bounded timeouts. +- API responses must sanitize internal Docker, filesystem, CDP, and database errors. + +## Basic verification + +```bash +npm test -- tests/unit/vnc-session.test.ts +npm run typecheck +``` + +For an end-to-end check, build the image, start OmniRoute, create or select a supported web-provider connection, start a browser-login session through the management API, complete login through the returned UI URL, harvest credentials, and verify that the selected connection—not another account for the same provider—was updated. \ No newline at end of file diff --git a/docker/vnc-browser/chromium/Dockerfile b/docker/vnc-browser/chromium/Dockerfile new file mode 100644 index 0000000000..579244536e --- /dev/null +++ b/docker/vnc-browser/chromium/Dockerfile @@ -0,0 +1,24 @@ +# OmniRoute persistent VNC login browser. +# +# Extends linuxserver/chromium, which ships Chromium + a noVNC-style web UI on +# port 3000 (Selkies) and a persistent profile dir at /config. We add: +# - CHROME_CLI flags so the *visible* browser also opens a DevTools port, and +# - a small in-container TCP bridge (cdp-bridge.py) that republishes +# Chromium's loopback CDP (127.0.0.1:9222) onto 0.0.0.0:9223, because +# Chrome 150 ignores --remote-debugging-address and binds loopback only. +# The OmniRoute server harvests cookies over the host-mapped 9223. +# +# Alpine/Debian package mirrors are unreachable from the build sandbox, so we +# extend a prebuilt image rather than apt/apk-installing anything. +FROM linuxserver/chromium:latest + +COPY cdp-bridge.py /usr/local/bin/cdp-bridge.py +COPY svc-de-run /etc/s6-overlay/s6-rc.d/svc-de/run +RUN chmod +x /usr/local/bin/cdp-bridge.py /etc/s6-overlay/s6-rc.d/svc-de/run + +# 3000 = noVNC web UI (base), 9222 = Chromium CDP loopback (base), +# 9223 = bridged CDP on all interfaces (ours). +EXPOSE 3000 9222 9223 + +# The base launches the visible browser via ${CHROME_CLI}; we add the CDP port. +ENV CHROME_CLI="--remote-debugging-port=9222 --no-first-run --no-default-browser-check --disable-background-networking" diff --git a/docker/vnc-browser/chromium/cdp-bridge.py b/docker/vnc-browser/chromium/cdp-bridge.py new file mode 100644 index 0000000000..7158f8dc1c --- /dev/null +++ b/docker/vnc-browser/chromium/cdp-bridge.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Republish Chromium's loopback CDP (127.0.0.1:9222) onto 0.0.0.0:9223. + +Chrome binds DevTools to 127.0.0.1 only and ignores --remote-debugging-address +on recent versions, so the host can't reach it via `docker -p 9222:9222`. This +tiny TCP bridge (run inside the container) exposes the same CDP on all +interfaces so the OmniRoute server's VNC harvester can connect from the host. +""" +import socket, threading, sys + +SRC_HOST, SRC_PORT = "127.0.0.1", 9222 +PUB_HOST, PUB_PORT = "0.0.0.0", 9223 + + +def bridge(client, target_addr): + try: + upstream = socket.create_connection(target_addr, timeout=10) + except OSError: + client.close() + return + a = threading.Thread(target=pipe, args=(client, upstream), daemon=True) + b = threading.Thread(target=pipe, args=(upstream, client), daemon=True) + a.start(); b.start() + + +def pipe(src, dst): + try: + while True: + data = src.recv(65536) + if not data: + break + dst.sendall(data) + except OSError: + pass + finally: + for s in (src, dst): + try: + s.close() + except OSError: + pass + + +def main(): + listen = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listen.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listen.bind((PUB_HOST, PUB_PORT)) + listen.listen(64) + print(f"[cdp-bridge] forwarding 0.0.0.0:{PUB_PORT} -> {SRC_HOST}:{SRC_PORT}", file=sys.stderr) + while True: + conn, _ = listen.accept() + threading.Thread(target=bridge, args=(conn, (SRC_HOST, SRC_PORT)), daemon=True).start() + + +if __name__ == "__main__": + main() diff --git a/docker/vnc-browser/chromium/startwm_wayland.sh b/docker/vnc-browser/chromium/startwm_wayland.sh new file mode 100644 index 0000000000..37d85892dd --- /dev/null +++ b/docker/vnc-browser/chromium/startwm_wayland.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# Start DE +ulimit -c 0 +export XCURSOR_THEME=breeze_cursors +export XCURSOR_SIZE=24 +export XKB_DEFAULT_LAYOUT=us +export XKB_DEFAULT_RULES=evdev +export WAYLAND_DISPLAY=wayland-1 + +# OmniRoute: republish Chromium's loopback DevTools (127.0.0.1:9222) onto +# 0.0.0.0:9223 so the host can harvest cookies. Chromium must already be +# listening, so we delay a moment. Backgrounded; the DE takes over below. +( sleep 8; python3 /usr/local/bin/cdp-bridge.py >/proc/1/fd/2 2>&1 ) & + +if [ "${SELKIES_DESKTOP,,}" == "true" ]; then + labwc > /dev/null 2>&1 & + sleep 1 + export WAYLAND_DISPLAY=wayland-0 + export DISPLAY=:0 + selkies-desktop +else + labwc > /dev/null 2>&1 +fi diff --git a/docker/vnc-browser/chromium/svc-de-run b/docker/vnc-browser/chromium/svc-de-run new file mode 100644 index 0000000000..3d2dee7f49 --- /dev/null +++ b/docker/vnc-browser/chromium/svc-de-run @@ -0,0 +1,64 @@ +#!/usr/bin/with-contenv bash + +# wayland entrypoint +if [[ "${PIXELFLUX_WAYLAND,,}" == "true" ]]; then + SOCKET_PATH="${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY:-wayland-1}" + echo "[svc-de] Wayland mode: Waiting for socket at ${SOCKET_PATH}..." + while [ ! -e "${SOCKET_PATH}" ]; do + sleep 0.5 + done + echo "[svc-de] ${SOCKET_PATH} found launching de" + cd $HOME + # OmniRoute: bridge Chromium DevTools (127.0.0.1:9222) to 0.0.0.0:9223. + ( sleep 8; python3 /usr/local/bin/cdp-bridge.py >/proc/1/fd/2 2>&1 ) & + exec s6-setuidgid abc \ + /bin/bash /defaults/startwm_wayland.sh & + PID=$! + echo "$PID" > /de-pid + wait "$PID" + exit 1 +fi + +# wait for X to be running +while true; do + if xset q &>/dev/null; then + break + fi + sleep .5 +done + +# set resolution before starting apps +RESOLUTION_WIDTH=${SELKIES_MANUAL_WIDTH:-1024} +RESOLUTION_HEIGHT=${SELKIES_MANUAL_HEIGHT:-768} +if [ "$RESOLUTION_WIDTH" = "0" ]; then RESOLUTION_WIDTH=1024; fi +if [ "$RESOLUTION_HEIGHT" = "0" ]; then RESOLUTION_HEIGHT=768; fi +MODELINE=$(s6-setuidgid abc cvt "${RESOLUTION_WIDTH}" "${RESOLUTION_HEIGHT}" | grep "Modeline" | sed 's/^.*Modeline //') +MODELINE_ARGS=$(echo "$MODELINE" | tr -d '"') +MODELINE_NAME=$(echo "$MODELINE_ARGS" | awk '{print $1}') +if ! s6-setuidgid abc xrandr | grep -q "$MODELINE_NAME"; then + s6-setuidgid abc xrandr --newmode $MODELINE_ARGS + s6-setuidgid abc xrandr --addmode screen "$MODELINE_NAME" + s6-setuidgid abc xrandr --output screen --mode "$MODELINE_NAME" --dpi 96 +fi + +# set xresources +if [ -f "${HOME}/.Xresources" ]; then + if ! grep -q "breeze_cursors" "${HOME}/.Xresources"; then + echo "Xcursor.theme: breeze_cursors" > "${HOME}/.Xresources" + fi + xrdb "${HOME}/.Xresources" +else + echo "Xcursor.theme: breeze_cursors" > "${HOME}/.Xresources" + xrdb "${HOME}/.Xresources" +fi +chown abc:abc "${HOME}/.Xresources" +chmod 777 /tmp/selkies* + +# run +cd $HOME +# OmniRoute: bridge Chromium DevTools (127.0.0.1:9222) to 0.0.0.0:9223. +( sleep 8; python3 /usr/local/bin/cdp-bridge.py >/proc/1/fd/2 2>&1 ) & +exec s6-setuidgid abc \ + /bin/bash /defaults/startwm.sh & +PID=$! +echo "$PID" > /de-pid diff --git a/src/app/api/vnc-session/[...params]/route.ts b/src/app/api/vnc-session/[...params]/route.ts new file mode 100644 index 0000000000..b86e193faa --- /dev/null +++ b/src/app/api/vnc-session/[...params]/route.ts @@ -0,0 +1,147 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { + getSession, + harvestSession, + listSessions, + markViewerActive, + startSession, + stopSession, + type VncSession, +} from "@/lib/vncSession/service"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +function publicSession(session: VncSession | undefined | null) { + if (!session) return null; + return { + sessionId: session.sessionId, + connectionId: session.connectionId, + providerId: session.providerId, + url: session.url, + status: session.status, + startedAt: session.startedAt, + lastViewerAt: session.lastViewerAt, + lastHarvestAt: session.lastHarvestAt, + viewer: + session.vncPort > 0 + ? { + localUrl: `http://127.0.0.1:${session.vncPort}/`, + loopbackOnly: true, + } + : null, + }; +} + +function errorResponse(status: number, error: unknown) { + const raw = error instanceof Error ? error.message : String(error); + return NextResponse.json(buildErrorBody(status, sanitizeErrorMessage(raw)), { status }); +} + +/** + * Connection-scoped browser-login control. + * + * GET /api/vnc-session/:connectionId list sessions for connection + * GET /api/vnc-session/:connectionId/:sessionId session state + * POST /api/vnc-session/:connectionId/start start a browser session + * POST /api/vnc-session/:connectionId/:sessionId/harvest + * POST /api/vnc-session/:connectionId/:sessionId/touch + * DELETE /api/vnc-session/:connectionId/:sessionId stop and remove session + * + * noVNC and CDP are published on random 127.0.0.1-only host ports. Until an + * authenticated same-origin websocket proxy is added, remote operators must use + * an SSH tunnel to the returned viewer.localUrl port. + */ +export async function GET(request: Request, { params }: { params: Promise<{ params: string[] }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const { params: segments } = await params; + const connectionId = segments?.[0]; + const sessionId = segments?.[1]; + if (!connectionId) return errorResponse(400, "connectionId is required"); + + if (!sessionId) { + return NextResponse.json({ sessions: listSessions(connectionId).map(publicSession) }); + } + + const session = getSession(connectionId, sessionId); + if (!session) return errorResponse(404, "Browser-login session not found"); + return NextResponse.json({ session: publicSession(session) }); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ params: string[] }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const { params: segments } = await params; + const connectionId = segments?.[0]; + const second = segments?.[1]; + const action = segments?.[2]; + if (!connectionId) return errorResponse(400, "connectionId is required"); + + try { + if (second === "start" && !action) { + const session = await startSession(connectionId); + return NextResponse.json({ + session: publicSession(session), + note: + "The viewer is loopback-only. Open it on the OmniRoute host or forward its port over SSH, then harvest the session.", + }); + } + + if (!second || !action) return errorResponse(400, "sessionId and action are required"); + + if (action === "harvest") { + const result = await harvestSession(connectionId, second); + return NextResponse.json({ + ...result, + validation: result.validation + ? { + ...result.validation, + error: result.validation.error + ? sanitizeErrorMessage(result.validation.error) + : null, + } + : null, + }); + } + if (action === "touch") { + if (!getSession(connectionId, second)) { + return errorResponse(404, "Browser-login session not found"); + } + markViewerActive(connectionId, second); + return NextResponse.json({ ok: true, sessionId: second, connectionId }); + } + + return errorResponse(400, `Unknown browser-login action: ${action}`); + } catch (error) { + return errorResponse(500, error); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ params: string[] }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const { params: segments } = await params; + const connectionId = segments?.[0]; + const sessionId = segments?.[1]; + if (!connectionId || !sessionId) { + return errorResponse(400, "connectionId and sessionId are required"); + } + + try { + await stopSession(connectionId, sessionId); + return NextResponse.json({ stopped: true, connectionId, sessionId }); + } catch (error) { + return errorResponse(500, error); + } +} + +export const runtime = "nodejs"; diff --git a/src/app/api/vnc-session/route.ts b/src/app/api/vnc-session/route.ts new file mode 100644 index 0000000000..56c512314e --- /dev/null +++ b/src/app/api/vnc-session/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { listVncProviders } from "@/lib/vncSession/manifest"; +import { listSessions } from "@/lib/vncSession/service"; + +/** + * GET /api/vnc-session + * + * List active VNC login sessions and the catalog of providers that support + * interactive browser login. Management-scoped (same auth as other admin + * endpoints); no secrets are returned — only session metadata + ports. + */ +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + return NextResponse.json({ + sessions: listSessions().map(({ containerName, profileDir, ...rest }) => rest), + providers: listVncProviders().map((provider) => ({ + id: provider.id, + name: provider.name, + url: provider.url, + kind: provider.requirement.kind, + })), + }); +} diff --git a/src/lib/gracefulShutdown.ts b/src/lib/gracefulShutdown.ts index f13a188ada..d75bd6e13e 100644 --- a/src/lib/gracefulShutdown.ts +++ b/src/lib/gracefulShutdown.ts @@ -19,8 +19,7 @@ const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000", declare global { var __omnirouteShutdown: - | { init: boolean; shuttingDown: boolean; activeRequests: number } - | undefined; + { init: boolean; shuttingDown: boolean; activeRequests: number } | undefined; } function getShutdownState() { @@ -119,6 +118,19 @@ async function cleanup(): Promise { } closeLogRotation(); console.log("[Shutdown] Log rotation timer stopped."); + + // Tear down any persistent VNC login browser containers so they don't leak + // past the server process. Best-effort; no-op if the feature was never used + // or the docker CLI is unavailable. + try { + const { stopAllSessions, listSessions } = await import("@/lib/vncSession/service"); + if (listSessions().length > 0) { + await stopAllSessions(); + console.log("[Shutdown] VNC login sessions stopped."); + } + } catch { + /* feature unused / docker missing */ + } } catch (err) { console.error("[Shutdown] Error during cleanup:", (err as Error).message); } diff --git a/src/lib/vncSession/harvest.ts b/src/lib/vncSession/harvest.ts new file mode 100644 index 0000000000..b95c51735e --- /dev/null +++ b/src/lib/vncSession/harvest.ts @@ -0,0 +1,429 @@ +import WebSocket from "ws"; +import type { VncProviderEntry } from "./manifest"; + +export interface HarvestCookie { + name: string; + value: string; + domain: string; + path: string; +} + +export interface HarvestResult { + cookies: HarvestCookie[]; + /** Declared values discovered in localStorage, sessionStorage, or page URLs. */ + localStorage: Record; + /** Full Cookie header for the provider origin, only when the canonical contract allows it. */ + cookieHeader: string; + hasCredential: boolean; +} + +interface Pending { + resolve: (value: any) => void; + reject: (error: Error) => void; + cleanup: () => void; +} + +export interface CdpTargetInfo { + targetId: string; + type: string; + url?: string; +} + +class CdpClient { + private readonly ws: WebSocket; + private nextId = 1; + private readonly pending = new Map(); + private sessionId: string | null = null; + private closed = false; + + constructor(wsUrl: string) { + this.ws = new WebSocket(wsUrl); + this.ws.on("message", (data) => this.onMessage(data)); + this.ws.on("close", () => this.rejectAll(new Error("CDP websocket closed"))); + this.ws.on("error", (error) => this.rejectAll(toError(error, "CDP websocket error"))); + } + + ready(timeoutMs = 15_000, signal?: AbortSignal): Promise { + if (this.ws.readyState === WebSocket.OPEN) return Promise.resolve(); + if (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED) { + return Promise.reject(new Error("CDP websocket is closed")); + } + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(); + }; + const onOpen = () => finish(); + const onError = (error: Error) => finish(toError(error, "CDP websocket error")); + const onAbort = () => { + this.close(); + finish(new Error("CDP connection aborted")); + }; + const timer = setTimeout(() => { + this.close(); + finish(new Error("CDP open timeout")); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + this.ws.off("open", onOpen); + this.ws.off("error", onError); + signal?.removeEventListener("abort", onAbort); + }; + + this.ws.once("open", onOpen); + this.ws.once("error", onError); + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + }); + } + + private onMessage(data: WebSocket.RawData): void { + let message: any; + try { + message = JSON.parse(data.toString()); + } catch { + return; + } + + if (typeof message.id !== "number") return; + const pending = this.pending.get(message.id); + if (!pending) return; + + this.pending.delete(message.id); + pending.cleanup(); + if (message.error) { + pending.reject(new Error(message.error.message || "CDP command failed")); + } else { + pending.resolve(message.result); + } + } + + private rejectAll(error: Error): void { + for (const [id, pending] of this.pending) { + this.pending.delete(id); + pending.cleanup(); + pending.reject(error); + } + } + + send( + method: string, + params: Record = {}, + sessionId?: string, + timeoutMs = 10_000, + signal?: AbortSignal + ): Promise { + if (this.ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("CDP websocket is not open")); + } + + return new Promise((resolve, reject) => { + const id = this.nextId++; + let settled = false; + const finishReject = (error: Error) => { + if (settled) return; + settled = true; + this.pending.delete(id); + cleanup(); + reject(error); + }; + const onAbort = () => finishReject(new Error(`CDP command aborted: ${method}`)); + const timer = setTimeout( + () => finishReject(new Error(`CDP command timed out: ${method}`)), + timeoutMs + ); + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }; + + this.pending.set(id, { + resolve: (value) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }, + reject: finishReject, + cleanup, + }); + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + this.ws.send( + JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }), + (error) => { + if (error) finishReject(toError(error, `Failed to send CDP command: ${method}`)); + } + ); + } catch (error) { + finishReject(toError(error, `Failed to send CDP command: ${method}`)); + } + }); + } + + async attachToPage(targetOrigin: string, signal?: AbortSignal): Promise { + const { targetInfos } = await this.send("Target.getTargets", {}, undefined, 10_000, signal); + const page = selectPageTarget(targetInfos || [], targetOrigin); + const { sessionId } = await this.send( + "Target.attachToTarget", + { targetId: page.targetId, flatten: true }, + undefined, + 10_000, + signal + ); + this.sessionId = sessionId; + } + + async getCookies(url: string, signal?: AbortSignal): Promise { + if (!this.sessionId) throw new Error("CDP page target is not attached"); + const result = await this.send( + "Network.getCookies", + { urls: [url] }, + this.sessionId, + 10_000, + signal + ); + return result.cookies || []; + } + + async getDeclaredStorage( + keys: readonly string[], + signal?: AbortSignal + ): Promise> { + if (!this.sessionId) throw new Error("CDP page target is not attached"); + + const expression = `(() => { + const keys = ${JSON.stringify([...keys])}; + const out = {}; + for (const store of [window.localStorage, window.sessionStorage]) { + for (const key of keys) { + const value = store.getItem(key); + if (typeof value === "string" && value.length > 0) out[key] = value; + } + } + const urls = [window.location.href, ...performance.getEntriesByType("resource").map((e) => e.name)]; + for (const raw of urls) { + try { + const url = new URL(raw, window.location.href); + for (const key of keys) { + const value = url.searchParams.get(key); + if (value && !out[key]) out[key] = value; + } + } catch {} + } + return out; + })()`; + + const result = await this.send( + "Runtime.evaluate", + { expression, returnByValue: true }, + this.sessionId, + 10_000, + signal + ); + const value = result?.result?.value; + return value && typeof value === "object" ? value : {}; + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.rejectAll(new Error("CDP client closed")); + try { + this.ws.close(); + } catch { + // Best-effort close. + } + } +} + +export async function waitForCdpReady(cdpPort: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: Error | null = null; + + while (Date.now() < deadline) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2_000); + try { + const version = await fetchJson(`http://127.0.0.1:${cdpPort}/json/version`, controller.signal); + if (version?.webSocketDebuggerUrl) return; + lastError = new Error("CDP endpoint did not return a websocket URL"); + } catch (error) { + lastError = toError(error, "CDP endpoint is not ready"); + } finally { + clearTimeout(timer); + } + await delay(500); + } + + throw new Error(`Browser did not become ready: ${lastError?.message || "CDP timeout"}`); +} + +export async function harvestFromContainer( + cdpPort: number, + provider: VncProviderEntry, + timeoutMs = 20_000 +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + let client: CdpClient | null = null; + + try { + const version = await fetchJson( + `http://127.0.0.1:${cdpPort}/json/version`, + controller.signal + ); + const debuggerUrl = version?.webSocketDebuggerUrl; + if (typeof debuggerUrl !== "string" || !debuggerUrl) { + throw new Error("No CDP websocket endpoint from browser container"); + } + + client = new CdpClient(rewriteDebuggerUrl(debuggerUrl, cdpPort)); + await client.ready(Math.min(timeoutMs, 15_000), controller.signal); + + const origin = new URL(provider.url).origin; + await client.attachToPage(origin, controller.signal); + const [cookiesRaw, declaredStorage] = await Promise.all([ + client.getCookies(provider.url, controller.signal), + client.getDeclaredStorage(provider.requirement.storageKeys, controller.signal), + ]); + + const cookies = cookiesRaw + .filter((cookie: any) => domainMatches(cookie.domain, origin)) + .map((cookie: any) => ({ + name: String(cookie.name || ""), + value: String(cookie.value || ""), + domain: String(cookie.domain || ""), + path: String(cookie.path || "/"), + })) + .filter((cookie: HarvestCookie) => cookie.name.length > 0 && cookie.value.length > 0); + + const cookieHeader = provider.requirement.acceptsFullCookieHeader + ? cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ") + : ""; + + const baseResult: HarvestResult = { + cookies, + localStorage: declaredStorage, + cookieHeader, + hasCredential: false, + }; + const credentials = harvestToCredentials(baseResult, provider); + const hasCredential = + typeof credentials.apiKey === "string" || + Object.values(credentials.providerSpecificData).some( + (value) => typeof value === "string" && value.length > 0 + ); + + return { ...baseResult, hasCredential }; + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`Browser credential harvest timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timeout); + client?.close(); + } +} + +export function harvestToCredentials( + harvest: HarvestResult, + provider: VncProviderEntry +): { providerSpecificData: Record; apiKey: string | null } { + const requirement = provider.requirement; + const providerSpecificData: Record = {}; + + for (const key of requirement.storageKeys) { + if (key === "cookie") continue; + const value = + harvest.localStorage[key] || harvest.cookies.find((cookie) => cookie.name === key)?.value; + if (value) providerSpecificData[key] = value; + } + + if (requirement.kind === "token") { + const tokenValue = + requirement.storageKeys.map((key) => providerSpecificData[key]).find(Boolean) || null; + if (tokenValue && requirement.storageKeys.includes("token")) { + providerSpecificData.token = tokenValue; + } + return { providerSpecificData, apiKey: tokenValue }; + } + + if ( + requirement.acceptsFullCookieHeader && + requirement.storageKeys.includes("cookie") && + harvest.cookieHeader + ) { + providerSpecificData.cookie = harvest.cookieHeader; + } + + return { providerSpecificData, apiKey: null }; +} + +export function rewriteDebuggerUrl(debuggerUrl: string, cdpPort: number): string { + const url = new URL(debuggerUrl); + url.protocol = "ws:"; + url.hostname = "127.0.0.1"; + url.port = String(cdpPort); + return url.toString(); +} + +export function selectPageTarget( + targetInfos: CdpTargetInfo[], + targetOrigin: string +): CdpTargetInfo { + const pages = targetInfos.filter((target) => target.type === "page"); + const matching = pages.find((target) => safeOrigin(target.url) === targetOrigin); + if (matching) return matching; + throw new Error(`No browser page is open for ${targetOrigin}`); +} + +function safeOrigin(value: string | undefined): string | null { + if (!value) return null; + try { + return new URL(value).origin; + } catch { + return null; + } +} + +async function fetchJson(url: string, signal: AbortSignal): Promise { + const response = await fetch(url, { signal }); + if (!response.ok) throw new Error(`CDP endpoint returned HTTP ${response.status}`); + return response.json(); +} + +function domainMatches(cookieDomain: string, origin: string): boolean { + try { + const host = new URL(origin).hostname; + const domain = cookieDomain.startsWith(".") ? cookieDomain.slice(1) : cookieDomain; + return host === domain || host.endsWith(`.${domain}`); + } catch { + return false; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function toError(error: unknown, fallback: string): Error { + if (error instanceof Error) return error; + return new Error(typeof error === "string" && error ? error : fallback); +} diff --git a/src/lib/vncSession/manifest.ts b/src/lib/vncSession/manifest.ts new file mode 100644 index 0000000000..f419fc7e13 --- /dev/null +++ b/src/lib/vncSession/manifest.ts @@ -0,0 +1,96 @@ +import { WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers"; +import { + getWebSessionCredentialRequirement, + type WebSessionCredentialRequirement, +} from "@/shared/providers/webSessionCredentials"; + +export interface VncProviderEntry { + /** Provider id stored in provider_connections.provider. */ + id: string; + /** Dashboard/catalog label. */ + name: string; + /** Login page opened by the browser container. */ + url: string; + /** Canonical OmniRoute credential contract for this provider. */ + requirement: Exclude; +} + +/** + * Providers whose credentials cannot yet be reconstructed safely from cookies, + * localStorage/sessionStorage, and declared credential keys alone. + */ +export const VNC_UNSUPPORTED_PROVIDER_REASONS: Readonly> = { + "copilot-m365-web": + "requires the account-specific Chathub WebSocket path in addition to an access token", + "inner-ai": "requires the account email in addition to the session token", +}; + +export function getVncProvider(id: string | null | undefined): VncProviderEntry | null { + if (!id || VNC_UNSUPPORTED_PROVIDER_REASONS[id]) return null; + + const catalog = WEB_COOKIE_PROVIDERS[id as keyof typeof WEB_COOKIE_PROVIDERS] as + | { id: string; name: string; website?: string } + | undefined; + const requirement = getWebSessionCredentialRequirement(id); + + if ( + !catalog || + typeof catalog.website !== "string" || + !catalog.website.startsWith("https://") || + !requirement || + requirement.kind === "none" + ) { + return null; + } + + return { + id: catalog.id, + name: catalog.name, + url: catalog.website, + requirement, + }; +} + +export function listVncProviders(): VncProviderEntry[] { + return Object.keys(WEB_COOKIE_PROVIDERS) + .map((id) => getVncProvider(id)) + .filter((entry): entry is VncProviderEntry => entry !== null); +} + +export function isVncProvider(id: string | null | undefined): boolean { + return getVncProvider(id) !== null; +} + +function envFlag(name: string, fallback: boolean): boolean { + const value = process.env[name]; + if (value === undefined) return fallback; + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} + +const profileRoot = + process.env.OMNIROUTE_VNC_PROFILE_DIR || + `${process.env.HOME || "/tmp"}/.omniroute/browser-login-profiles`; + +export const VNC_CONFIG = { + /** + * This feature uses Chromium CDP only. Build docker/vnc-browser/chromium and + * tag it with this name, or override OMNIROUTE_VNC_IMAGE. + */ + image: process.env.OMNIROUTE_VNC_IMAGE || "omniroute-vnc-chromium:local", + containerVncPort: Number(process.env.OMNIROUTE_VNC_CONTAINER_VNC_PORT || 3000), + containerCdpPort: Number(process.env.OMNIROUTE_VNC_CONTAINER_CDP_PORT || 9223), + containerProfileDir: process.env.OMNIROUTE_VNC_CONTAINER_PROFILE_DIR || "/config", + profileDir: profileRoot, + persistProfiles: envFlag("OMNIROUTE_VNC_PERSIST_PROFILES", false), + idleTimeoutMs: Number(process.env.OMNIROUTE_VNC_IDLE_MS || 10 * 60 * 1000), + maxSessionMs: Number(process.env.OMNIROUTE_VNC_MAX_MS || 30 * 60 * 1000), + maxSessions: Number(process.env.OMNIROUTE_VNC_MAX_SESSIONS || 4), + dockerBin: process.env.OMNIROUTE_DOCKER_BIN || "docker", + browserReadyTimeoutMs: Number(process.env.OMNIROUTE_VNC_READY_MS || 45_000), + harvestTimeoutMs: Number(process.env.OMNIROUTE_VNC_HARVEST_MS || 20_000), + chromiumArgs: + process.env.OMNIROUTE_VNC_CHROMIUM_ARGS || + "--remote-debugging-port=9222 --no-first-run --no-default-browser-check", +} as const; + +export const VNC_ROUTE_PREFIX = "/api/vnc-session"; diff --git a/src/lib/vncSession/service.ts b/src/lib/vncSession/service.ts new file mode 100644 index 0000000000..06332050d0 --- /dev/null +++ b/src/lib/vncSession/service.ts @@ -0,0 +1,387 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { chmodSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { getProviderConnectionById, updateProviderConnection } from "@/lib/db/providers"; +import { validateProviderApiKey } from "@/lib/providers/validation"; +import { VNC_CONFIG, getVncProvider } from "./manifest"; +import { harvestFromContainer, harvestToCredentials, waitForCdpReady } from "./harvest"; + +export type VncSessionStatus = "starting" | "running" | "harvesting" | "stopping" | "error"; + +export interface VncSession { + sessionId: string; + connectionId: string; + providerId: string; + containerName: string; + profileDir: string; + cdpPort: number; + vncPort: number; + url: string; + status: VncSessionStatus; + startedAt: number; + lastViewerAt: number; + lastHarvestAt: number; + error?: string; +} + +export interface HarvestSessionResult { + harvested: boolean; + sessionId: string; + connectionId: string; + providerId: string; + updatedFields: string[]; + validation: { + valid: boolean; + unsupported: boolean; + error: string | null; + } | null; +} + +const LABEL = "com.omniroute.browser-login"; +const SESSIONS = new Map(); +let idleTimer: NodeJS.Timeout | null = null; +let reconciliationPromise: Promise | null = null; + +function docker( + args: string[], + opts: { timeoutMs?: number } = {} +): Promise<{ code: number; out: string; err: string }> { + return new Promise((resolve) => { + let settled = false; + let out = ""; + let err = ""; + const child = spawn(VNC_CONFIG.dockerBin, args, { stdio: ["ignore", "pipe", "pipe"] }); + let timer: NodeJS.Timeout | null = null; + const finish = (result: { code: number; out: string; err: string }) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(result); + }; + timer = setTimeout(() => { + child.kill("SIGKILL"); + finish({ code: -1, out, err: `${err} (docker timed out)`.trim() }); + }, opts.timeoutMs ?? 60_000); + + child.stdout.on("data", (data) => { + out += data.toString(); + }); + child.stderr.on("data", (data) => { + err += data.toString(); + }); + child.on("error", (error) => { + finish({ code: -1, out, err: error.message }); + }); + child.on("close", (code) => { + finish({ code: code ?? -1, out, err }); + }); + }); +} + +export function sessionKey(sessionId: string): string { + return `omniroute-browser-login-${sessionId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20)}`; +} + +export function getSession(connectionId: string, sessionId: string): VncSession | undefined { + const session = SESSIONS.get(sessionId); + return session?.connectionId === connectionId ? session : undefined; +} + +export function listSessions(connectionId?: string): VncSession[] { + const sessions = [...SESSIONS.values()]; + return connectionId ? sessions.filter((session) => session.connectionId === connectionId) : sessions; +} + +async function reconcileStaleContainers(): Promise { + if (!reconciliationPromise) { + reconciliationPromise = (async () => { + const listed = await docker( + ["ps", "-aq", "--filter", `label=${LABEL}=true`], + { timeoutMs: 20_000 } + ); + if (listed.code !== 0) return; + const ids = listed.out + .split(/\s+/) + .map((value) => value.trim()) + .filter(Boolean); + if (ids.length > 0) { + await docker(["rm", "-f", ...ids], { timeoutMs: 30_000 }); + } + })(); + } + await reconciliationPromise; +} + +function findActiveSessionForConnection(connectionId: string): VncSession | undefined { + return [...SESSIONS.values()].find( + (session) => + session.connectionId === connectionId && + ["starting", "running", "harvesting"].includes(session.status) + ); +} + +function safePathSegment(value: string): string { + const safe = value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120); + if (!safe || safe === "." || safe === "..") throw new Error("Invalid profile identifier"); + return safe; +} + +function createProfileDir(connectionId: string, sessionId: string): string { + mkdirSync(VNC_CONFIG.profileDir, { recursive: true, mode: 0o700 }); + chmodSync(VNC_CONFIG.profileDir, 0o700); + const profileName = safePathSegment(VNC_CONFIG.persistProfiles ? connectionId : sessionId); + const profileDir = join(VNC_CONFIG.profileDir, profileName); + mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + chmodSync(profileDir, 0o700); + return profileDir; +} + +async function publishedPort(containerName: string, containerPort: number): Promise { + const result = await docker(["port", containerName, `${containerPort}/tcp`], { + timeoutMs: 10_000, + }); + if (result.code !== 0) { + throw new Error(result.err.trim() || `Could not resolve published port ${containerPort}`); + } + + for (const line of result.out.split(/\r?\n/)) { + const match = line.trim().match(/:(\d+)$/); + if (match) return Number(match[1]); + } + throw new Error(`Docker did not publish container port ${containerPort}`); +} + +export async function startSession(connectionId: string): Promise { + await reconcileStaleContainers(); + + const connection = await getProviderConnectionById(connectionId); + if (!connection) throw new Error("Provider connection not found"); + const providerId = typeof connection.provider === "string" ? connection.provider : ""; + const provider = getVncProvider(providerId); + if (!provider) { + throw new Error(`Browser login is not supported for provider '${providerId || "unknown"}'`); + } + + const existing = findActiveSessionForConnection(connectionId); + if (existing) return existing; + if (SESSIONS.size >= VNC_CONFIG.maxSessions) { + throw new Error(`Maximum ${VNC_CONFIG.maxSessions} concurrent browser-login sessions reached`); + } + + const sessionId = randomUUID(); + const containerName = sessionKey(sessionId); + const profileDir = createProfileDir(connectionId, sessionId); + const state: VncSession = { + sessionId, + connectionId, + providerId, + containerName, + profileDir, + cdpPort: 0, + vncPort: 0, + url: provider.url, + status: "starting", + startedAt: Date.now(), + lastViewerAt: Date.now(), + lastHarvestAt: 0, + }; + SESSIONS.set(sessionId, state); + + try { + const chromeCli = `${VNC_CONFIG.chromiumArgs} ${provider.url}`; + const result = await docker( + [ + "run", + "-d", + "--name", + containerName, + "--restart", + "no", + "--label", + `${LABEL}=true`, + "--label", + `${LABEL}.session-id=${sessionId}`, + "--label", + `${LABEL}.connection-id=${connectionId}`, + "--shm-size", + "1gb", + "-p", + `127.0.0.1::${VNC_CONFIG.containerVncPort}`, + "-p", + `127.0.0.1::${VNC_CONFIG.containerCdpPort}`, + "-v", + `${profileDir}:${VNC_CONFIG.containerProfileDir}`, + "-e", + `CHROME_CLI=${chromeCli}`, + VNC_CONFIG.image, + ], + { timeoutMs: 120_000 } + ); + if (result.code !== 0) { + const message = result.err.trim() || "docker run failed"; + const buildHint = + VNC_CONFIG.image === "omniroute-vnc-chromium:local" && + /pull access denied|unable to find image|not found/i.test(message) + ? " Build it with: docker build -t omniroute-vnc-chromium:local docker/vnc-browser/chromium" + : ""; + throw new Error(`${message}${buildHint}`); + } + + state.vncPort = await publishedPort(containerName, VNC_CONFIG.containerVncPort); + state.cdpPort = await publishedPort(containerName, VNC_CONFIG.containerCdpPort); + await waitForCdpReady(state.cdpPort, VNC_CONFIG.browserReadyTimeoutMs); + + state.status = "running"; + scheduleIdleSweep(); + return state; + } catch (error) { + state.status = "error"; + state.error = error instanceof Error ? error.message : String(error); + SESSIONS.delete(sessionId); + await docker(["rm", "-f", containerName], { timeoutMs: 20_000 }); + cleanupProfile(state); + throw new Error(`Failed to start browser login for connection ${connectionId}: ${state.error}`); + } +} + +export function markViewerActive(connectionId: string, sessionId: string): void { + const session = getSession(connectionId, sessionId); + if (session) session.lastViewerAt = Date.now(); +} + +export async function harvestSession( + connectionId: string, + sessionId: string +): Promise { + const session = getSession(connectionId, sessionId); + if (!session) throw new Error("Browser-login session not found"); + if (session.status !== "running") { + throw new Error(`Browser-login session is not running (${session.status})`); + } + + const provider = getVncProvider(session.providerId); + if (!provider) throw new Error("Provider is no longer supported for browser login"); + + session.status = "harvesting"; + try { + const harvest = await harvestFromContainer( + session.cdpPort, + provider, + VNC_CONFIG.harvestTimeoutMs + ); + session.lastHarvestAt = Date.now(); + if (!harvest.hasCredential) { + return { + harvested: false, + sessionId, + connectionId, + providerId: session.providerId, + updatedFields: [], + validation: null, + }; + } + + const connection = await getProviderConnectionById(connectionId); + if (!connection) throw new Error("Provider connection no longer exists"); + if (connection.provider !== session.providerId) { + throw new Error("Provider connection changed while browser login was active"); + } + + const { providerSpecificData, apiKey } = harvestToCredentials(harvest, provider); + const existingData = + connection.providerSpecificData && + typeof connection.providerSpecificData === "object" && + !Array.isArray(connection.providerSpecificData) + ? connection.providerSpecificData + : {}; + const mergedData = { ...existingData, ...providerSpecificData }; + + await updateProviderConnection(connectionId, { + providerSpecificData: mergedData, + ...(apiKey ? { apiKey } : {}), + }); + + const validationKey = + apiKey || + (typeof mergedData.cookie === "string" ? mergedData.cookie : null) || + (typeof connection.apiKey === "string" ? connection.apiKey : ""); + const validationResult = await validateProviderApiKey({ + provider: session.providerId, + apiKey: validationKey, + providerSpecificData: mergedData, + }); + + return { + harvested: true, + sessionId, + connectionId, + providerId: session.providerId, + updatedFields: [ + ...Object.keys(providerSpecificData).map((key) => `providerSpecificData.${key}`), + ...(apiKey ? ["apiKey"] : []), + ], + validation: { + valid: !!validationResult.valid, + unsupported: !!validationResult.unsupported, + error: + typeof validationResult.error === "string" && validationResult.error.trim() + ? validationResult.error + : null, + }, + }; + } finally { + if (session.status === "harvesting") session.status = "running"; + } +} + +export async function stopSession(connectionId: string, sessionId: string): Promise { + const session = getSession(connectionId, sessionId); + if (!session || session.status === "stopping") return; + + session.status = "stopping"; + SESSIONS.delete(sessionId); + try { + const result = await docker(["rm", "-f", session.containerName], { timeoutMs: 20_000 }); + if (result.code !== 0 && !/no such container/i.test(result.err)) { + throw new Error(result.err.trim() || "Failed to remove browser container"); + } + } finally { + cleanupProfile(session); + } +} + +export async function stopAllSessions(): Promise { + const sessions = [...SESSIONS.values()]; + await Promise.all( + sessions.map((session) => stopSession(session.connectionId, session.sessionId).catch(() => {})) + ); + if (idleTimer) clearInterval(idleTimer); + idleTimer = null; +} + +function cleanupProfile(session: VncSession): void { + if (VNC_CONFIG.persistProfiles) return; + try { + rmSync(session.profileDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup; the directory remains mode 0700 if removal fails. + } +} + +function scheduleIdleSweep(): void { + if (idleTimer) return; + idleTimer = setInterval(async () => { + const now = Date.now(); + for (const session of [...SESSIONS.values()]) { + if (session.status !== "running") continue; + const idleFor = now - Math.max(session.lastViewerAt, session.lastHarvestAt); + const overMax = + VNC_CONFIG.maxSessionMs > 0 && now - session.startedAt >= VNC_CONFIG.maxSessionMs; + if (idleFor >= VNC_CONFIG.idleTimeoutMs || overMax) { + await stopSession(session.connectionId, session.sessionId).catch(() => {}); + } + } + }, 30_000); + idleTimer.unref?.(); +} diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index a8166a52ef..be7220b00a 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -23,6 +23,7 @@ import { getAuthzBypassSnapshot } from "@/lib/config/runtimeSettings"; import { SPAWN_CAPABLE_PREFIXES } from "@/shared/constants/spawnCapablePrefixes"; +import { VNC_ROUTE_PREFIX } from "@/lib/vncSession/manifest"; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); @@ -52,6 +53,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/oauth/cursor/auto-import", // spawns `execFile("which", ["cursor"])` to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review). "/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md. + VNC_ROUTE_PREFIX, // #7892: /api/vnc-session/* spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17), same CVE class (GHSA-fhh6-4qxv-rpqj). "/api/acp/agents", // ACP custom-agent registry: POST registers a client-chosen `binary`; GET / POST {action:"refresh"} runs detectInstalledAgents() -> execFileSync(probe.command, probe.args, { shell }) transitively (src/lib/acp/registry.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17, #7948) ]; diff --git a/src/shared/constants/spawnCapablePrefixes.ts b/src/shared/constants/spawnCapablePrefixes.ts index 62ada570aa..b5bdfd4dc5 100644 --- a/src/shared/constants/spawnCapablePrefixes.ts +++ b/src/shared/constants/spawnCapablePrefixes.ts @@ -34,4 +34,5 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, PR #6294 review) "/api/headroom/start", // spawns headroom-ai python CLI — must never be bypassable (Hard Rules #15 + #17) "/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17) + "/api/vnc-session", // #7892: spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17) ]; diff --git a/tests/unit/authz/route-guard-vnc-session-local-only.test.ts b/tests/unit/authz/route-guard-vnc-session-local-only.test.ts new file mode 100644 index 0000000000..220c1fd336 --- /dev/null +++ b/tests/unit/authz/route-guard-vnc-session-local-only.test.ts @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isLocalOnlyPath, + isLocalOnlyBypassableByManageScope, +} from "../../../src/server/authz/routeGuard.ts"; +import { SPAWN_CAPABLE_PREFIXES } from "../../../src/shared/constants/spawnCapablePrefixes.ts"; +import { VNC_ROUTE_PREFIX } from "../../../src/lib/vncSession/manifest.ts"; + +// ─── #7892: /api/vnc-session/* spawns Docker containers via child_process.spawn ── +// (src/lib/vncSession/service.ts) — must be loopback-enforced before any auth +// check, same CVE class (GHSA-fhh6-4qxv-rpqj) as the other spawn-capable +// prefixes (Hard Rules #15 + #17). + +test("isLocalOnlyPath: /api/vnc-session is local-only (#7892 — spawns Docker containers)", () => { + assert.equal(isLocalOnlyPath(VNC_ROUTE_PREFIX), true); + assert.equal(isLocalOnlyPath("/api/vnc-session"), true); + assert.equal(isLocalOnlyPath("/api/vnc-session/conn123/start"), true); + assert.equal(isLocalOnlyPath("/api/vnc-session/conn123/harvest"), true); + assert.equal(isLocalOnlyPath("/api/vnc-session/conn123/stop"), true); +}); + +test("isLocalOnlyBypassableByManageScope: /api/vnc-session is NOT bypassable (defence in depth)", () => { + assert.ok(SPAWN_CAPABLE_PREFIXES.includes(VNC_ROUTE_PREFIX)); + assert.equal( + isLocalOnlyBypassableByManageScope("/api/vnc-session/conn123/start"), + false + ); +}); diff --git a/tests/unit/vnc-session.test.ts b/tests/unit/vnc-session.test.ts new file mode 100644 index 0000000000..1486d26f24 --- /dev/null +++ b/tests/unit/vnc-session.test.ts @@ -0,0 +1,89 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + getVncProvider, + isVncProvider, + listVncProviders, + VNC_CONFIG, +} from "@/lib/vncSession/manifest"; +import { harvestToCredentials, type HarvestResult } from "@/lib/vncSession/harvest"; + +test("manifest lookup resolves known providers and rejects unknown", () => { + assert.equal(isVncProvider("gemini-web"), true); + assert.equal(isVncProvider("chatgpt-web"), true); + assert.equal(isVncProvider("not-a-provider"), false); + assert.equal(getVncProvider(null), null); + assert.equal(getVncProvider("gemini-web")?.url, "https://gemini.google.com"); +}); + +test("provider list has well-formed URLs, unique IDs, and canonical requirements", () => { + const providers = listVncProviders(); + assert.ok(providers.length > 0); + assert.equal(new Set(providers.map((entry) => entry.id)).size, providers.length); + + for (const entry of providers) { + assert.match(entry.url, /^https:\/\//, `bad url for ${entry.id}`); + assert.ok(["cookie", "token"].includes(entry.requirement.kind), `bad kind for ${entry.id}`); + assert.ok(Array.isArray(entry.requirement.storageKeys), `storageKeys not array for ${entry.id}`); + assert.equal(getVncProvider(entry.id)?.id, entry.id); + } +}); + +test("VNC_CONFIG defaults to the bundled Chromium browser image", () => { + assert.equal(VNC_CONFIG.image, "omniroute-vnc-chromium:local"); + assert.equal(VNC_CONFIG.containerVncPort, 3000); + assert.equal(VNC_CONFIG.containerCdpPort, 9223); + assert.equal(VNC_CONFIG.persistProfiles, false); + assert.match(VNC_CONFIG.chromiumArgs, /--remote-debugging-port=9222/); +}); + +test("harvestToCredentials builds a cookie header and allowlisted provider data", () => { + const provider = getVncProvider("claude-web")!; + const harvest: HarvestResult = { + cookies: [ + { name: "sessionKey", value: "abc123", domain: ".claude.ai", path: "/" }, + { name: "other", value: "zzz", domain: ".claude.ai", path: "/" }, + ], + localStorage: {}, + cookieHeader: "sessionKey=abc123; other=zzz", + hasCredential: true, + }; + const { providerSpecificData, apiKey } = harvestToCredentials(harvest, provider); + assert.equal(providerSpecificData.sessionKey, "abc123"); + assert.equal(providerSpecificData.other, undefined); + assert.equal(providerSpecificData.cookie, "sessionKey=abc123; other=zzz"); + assert.equal(apiKey, null); +}); + +test("harvestToCredentials extracts a token for token-kind providers", () => { + const provider = getVncProvider("deepseek-web")!; + const harvest: HarvestResult = { + cookies: [{ name: "userToken", value: "tok-xyz", domain: ".deepseek.com", path: "/" }], + localStorage: { userToken: "tok-xyz" }, + cookieHeader: "", + hasCredential: true, + }; + const { providerSpecificData, apiKey } = harvestToCredentials(harvest, provider); + assert.equal(apiKey, "tok-xyz"); + assert.equal(providerSpecificData.token, "tok-xyz"); + assert.equal(providerSpecificData.userToken, "tok-xyz"); +}); + +test("harvestToCredentials preserves declared multi-cookie credentials", () => { + const provider = getVncProvider("grok-web")!; + const harvest: HarvestResult = { + cookies: [ + { name: "sso", value: "1", domain: ".grok.com", path: "/" }, + { name: "sso-rw", value: "2", domain: ".grok.com", path: "/" }, + { name: "unrelated", value: "3", domain: ".grok.com", path: "/" }, + ], + localStorage: {}, + cookieHeader: "sso=1; sso-rw=2; unrelated=3", + hasCredential: true, + }; + const { providerSpecificData } = harvestToCredentials(harvest, provider); + assert.equal(providerSpecificData.sso, "1"); + assert.equal(providerSpecificData["sso-rw"], "2"); + assert.equal(providerSpecificData.unrelated, undefined); + assert.equal(providerSpecificData.cookie, "sso=1; sso-rw=2; unrelated=3"); +});