Skill: georges91560/virtual-desktop-pro (version 4.0.1)

--- name: virtual-desktop-pro version: 4.0.0 homepage: https://github.com/openclaw-skills/virtual-desktop description: > Persistent authenticated browser for OpenClaw via kasmweb/chrome Docker sidecar. Principal logs in once via noVNC — sessions saved permanently in Docker volume. Agent navigates any website, clicks, fills forms, extracts data, uploads files, takes screenshots, solves CAPTCHAs autonomously, and analyses pages with Claude Vision. Use when the task requires a real authenticated browser, not a static fetch. metadata: openclaw: emoji: "🖥️" security_level: L3 always: false required_paths: read: - /workspace/TOOLS.md - /workspace/.learnings/LEARNINGS.md - /workspace/.learnings/ERRORS.md write: - /workspace/AUDIT.md - /workspace/screenshots/ - /workspace/logs/browser/ - /workspace/.learnings/ERRORS.md - /workspace/.learnings/LEARNINGS.md - /workspace/tasks/lessons.md - /workspace/memory/YYYY-MM-DD.md network_behavior: makes_requests: true request_targets: - "http://browser:9222 (Chrome CDP — internal Docker network only)" - "any URL the principal authorizes (Chrome accesses it via the sidecar)" - "https://api.capsolver.com (CAPTCHA solving — requires CAPSOLVER_API_KEY)" - "wss://connect.browserbase.com (stealth proxy — requires BROWSERBASE_API_KEY)" - "https://api.anthropic.com (Claude Vision — requires ANTHROPIC_API_KEY)" - "https://registry.npmjs.org (Playwright install only)" uses_agent_telegram: true telegram_note: > Uses existing agent Telegram channel. Agent notifies principal when CAPTCHA requires manual resolution or session has expired. requires: bins: - docker - python3 env: - VNC_PW - BROWSER_CDP_URL env_optional: - CAPSOLVER_API_KEY - BROWSERBASE_API_KEY - ANTHROPIC_API_KEY - VPS_IP - PROXY_URL - TELEGRAM_BOT_TOKEN --- # Virtual Desktop — Authenticated Browser Layer ## What this skill does Gives the agent a persistent authenticated browser (kasmweb/chrome) running as a Docker sidecar. Principal logs in once via noVNC. Sessions saved permanently. | Capability | What it means | |---|---| | **ANALYZE** | Read any page, extract structured data, monitor changes over time | | **PLAN** | Map the UI, identify selectors, prepare multi-step action sequences | | **EXECUTE** | Click, type, fill forms, submit, upload, download, navigate any flow | | **SELF-CORRECT** | Screenshot error state, identify root cause, retry with alternate approach | | **IMPROVE** | Write UI patterns and selector maps to `.learnings/` after every session | Use cases: Google Workspace · social platforms · admin dashboards · e-commerce · forms · market research · data extraction · any platform with or without an API --- ## Workspace Structure ``` /workspace/ ├── screenshots/ ← visual proof of every action (auto-created) ├── logs/browser/ ← full tracebacks (auto-created) ├── tasks/lessons.md ← immediate task capture during mission ├── AUDIT.md ← append-only action log ├── memory/YYYY-MM-DD.md ← daily session summary └── .learnings/ ├── ERRORS.md ← errors, broken selectors, ref maps └── LEARNINGS.md ← patterns, timing, navigation per platform ``` ## When to Use Use this skill when the task requires a **real authenticated browser**: - Pages requiring login (Google, social networks, dashboards, admin panels) - JS-rendered pages where static fetch returns nothing useful - Multi-step flows: forms, checkouts, confirmations, file uploads - Platforms without an API - Screenshots or visual evidence of a page state - CAPTCHA-protected pages **Prefer a lighter path first** — if a simple HTTP request or existing OpenClaw tool can answer the question, use that instead. This skill uses more tokens and resources than plain fetch. --- ## Architecture This skill runs a persistent **kasmweb/chrome** Docker sidecar alongside OpenClaw. Principal logs in once via noVNC (port 6901). Sessions saved permanently in a Docker volume. Three execution paths — load only what the task needs: | Path | When to use | File | |---|---|---| | **OpenClaw native browser** | Simple navigate/click/extract — fastest, fewest tokens | Built-in | | **browser_control.py** | AUDIT logging, workflows, CAPTCHA, Vision | `browser_control.py` | | **noVNC (manual)** | Initial login, 2FA, session renewal | Port 6901 | **Load only the smallest path needed.** Simple navigation → OpenClaw native. Complex multi-step with logging → browser_control.py. --- ## Setup — Run Once ```bash OPENCLAW_DIR="${OPENCLAW_DIR:-$(pwd)}" cd "$OPENCLAW_DIR" CONTAINER="${OPENCLAW_CONTAINER:-$(docker ps --format '{{.Names}}' | grep openclaw | head -1)}" # 1. Add kasmweb/chrome to docker-compose.yml python3 -c " import yaml, os VNC_PW = os.environ.get('VNC_PW') or __import__('secrets').token_urlsafe(18) with open('docker-compose.yml') as f: data = yaml.safe_load(f) data.setdefault('services', {})['browser'] = { 'image': 'kasmweb/chrome:1.15.0', 'container_name': 'browser', 'restart': 'unless-stopped', 'shm_size': '1gb', 'ports': ['6901:6901', '9222:9222'], 'environment': [ 'VNC_PW=' + VNC_PW, 'RESOLUTION=1920x1080', 'CHROME_ARGS=--remote-debugging-port=9222 --remote-debugging-address=0.0.0.0 --no-sandbox --disable-blink-features=AutomationControlled --disable-infobars' ], 'volumes': ['browser-profile:/home/kasm-user/chrome-profile'], 'networks': list(data.get('networks', {'default': None}).keys()) } data.setdefault('volumes', {})['browser-profile'] = None with open('docker-compose.yml', 'w') as f: yaml.dump(data, f, default_flow_style=False, allow_unicode=True) print('docker-compose.yml updated') " # 2. Update .env # VNC_PW — generate a strong random password if not already set if ! grep -q "VNC_PW" .env 2>/dev/null; then VNC_GENERATED=$(python3 -c "import secrets,string; print(''.join(secrets.choice(string.ascii_letters+string.digits) for _ in range(24)))") echo "VNC_PW=${VNC_GENERATED}" >> .env echo "✅ VNC_PW generated — save this: ${VNC_GENERATED}" fi grep -q "BROWSER_CDP_URL" .env || echo "BROWSER_CDP_URL=http://browser:9222" >> .env grep -q "CAPSOLVER_API_KEY" .env || echo "CAPSOLVER_API_KEY=" >> .env grep -q "BROWSERBASE_API_KEY" .env || echo "BROWSERBASE_API_KEY=" >> .env # 3. Update openclaw.json — hot reload, no restart needed python3 -c " import json, os f = 'data/.openclaw/openclaw.json' with open(f) as fp: cfg = json.load(fp) cfg.setdefault('browser', {}).update({'enabled': True, 'headless': False, 'noSandbox': True, 'defaultProfile': 'chrome-sidecar'}) profiles = cfg['browser'].setdefault('profiles', {}) profiles['chrome-sidecar'] = {'cdpUrl': 'http://browser:9222', 'color': '#4285F4'} bb_key = os.environ.get('BROWSERBASE_API_KEY', '') if bb_key: profiles['browserbase'] = {'cdpUrl': f'wss://connect.browserbase.com?[REDACTED_SECRET]', 'color': '#F97316'} with open(f, 'w') as fp: json.dump(cfg, fp, indent=2) print('openclaw.json updated — hot reload active') " # 4. Start browser container only — OpenClaw keeps running docker compose up -d --no-deps browser sleep 12 # 5. Install Python dependencies docker exec "$CONTAINER" pip install requests playwright --break-system-packages -q docker exec "$CONTAINER" node /app/node_modules/playwright-core/cli.js install chromium echo "✅ Python dependencies installed" # 6. Download CapSolver extension (optional — only if key present) CAPSOLVER_KEY=$(grep CAPSOLVER_API_KEY .env | cut -d= -f2) if [ -n "$CAPSOLVER_KEY" ]; then docker exec "$CONTAINER" bash -c " apt-get install -y unzip curl -qq curl -sL https://github.com/capsolver/capsolver-browser-extension/releases/latest/download/chrome.zip \ -o /tmp/capsolver.zip unzip -q /tmp/capsolver.zip -d /data/.openclaw/capsolver-extension sed -i \"s/apiKey: \\\"\\\"/apiKey: \\\"$CAPSOLVER_KEY\\\"/\" \ /data/.openclaw/capsolver-extension/assets/config.js 2>/dev/null " echo "✅ CapSolver extension configured" fi # 7. Create workspace directories and deploy browser_control.py docker exec "$CONTAINER" bash -c " mkdir -p /data/.openclaw/workspace/skills/virtual-desktop mkdir -p /workspace/screenshots /workspace/logs/browser /workspace/.learnings /workspace/memory touch /workspace/AUDIT.md /workspace/.learnings/ERRORS.md /workspace/.learnings/LEARNINGS.md " docker cp {baseDir}/browser_control.py \ "$CONTAINER":/data/.openclaw/workspace/skills/virtual-desktop/browser_control.py echo "✅ browser_control.py deployed" # 8. Verify docker ps | grep -E "openclaw|browser" curl -s http://localhost:9222/json > /dev/null && echo "✅ Chrome CDP active" || echo "⏳ Chrome starting" docker exec "$CONTAINER" \ python3 /data/.openclaw/workspace/skills/virtual-desktop/browser_control.py status # 9. Notify principal VPS_IP=$(curl -s ifconfig.me 2>/dev/null || echo "YOUR_VPS_IP") echo "Virtual Desktop ready — https://${VPS_IP}:6901" echo "Log in to your platforms via noVNC then reply DONE." ``` --- ## Initial Login — Once Per Platform ``` https://YOUR_VPS_IP:6901 login: kasm_user password: your VNC_PW ``` Open Chrome via noVNC and log in to every platform you want the agent to access. Sessions saved in Docker volume `browser-profile` — survive restarts — valid indefinitely. **Step by step — do this once after setup:** ``` 1. Open https://YOUR_VPS_IP:6901 in your browser 2. Enter password: your VNC_PW value from .env 3. Chrome Desktop opens inside the browser 4. Log in to Google (accounts.google.com) → Email + password + 2FA if required → "Trust this device" → YES → This unlocks: Gmail, Drive, Calendar, Docs, Sheets, Google AI Studio, YouTube, all Google services 5. Log in to every other platform you want Wesley to access: → Twitter/X → twitter.com → LinkedIn → linkedin.com → Reddit → reddit.com → Hostinger panel → hpanel.hostinger.com → Any other site → log in normally 6. After each login: Chrome saves the session automatically in the Docker volume browser-profile 7. Reply DONE to Wesley on Telegram → Wesley confirms sessions are active → He will never ask for your credentials again ``` **What happens after:** ``` Wesley opens any platform → already logged in ✅ No credentials needed → ever again Session expires (rare) → Wesley notifies Telegram → You open noVNC → log in again → reply DONE → Takes 2 minutes ``` **Important — 2FA:** ``` Google 2FA → confirm once via noVNC Chrome remembers the device No 2FA required again on this browser Other platforms → same principle confirm once → trusted device → done ``` --- ## Quick Reference | Reference | Content | |---|---| | OpenClaw native browser commands | See below — `openclaw browser` | | browser_control.py commands | See below — `$BC` | | CAPTCHA strategy | See `CAPTCHA` section | | Residential proxy | See `Proxy` section | | Claude Vision | See `Vision` section | | Selectors, timing, auth flows | `LEARNINGS.md` (auto-built by agent) | | Broken selectors, error recovery | `ERRORS.md` (auto-built by agent) | --- ## OpenClaw Native Browser — Fastest Path ```bash # Navigation openclaw browser open <url> openclaw browser snapshot [--interactive] openclaw browser back | forward | reload | close # Interaction openclaw browser click <ref> openclaw browser type <ref> "text" openclaw browser select <ref> "value" openclaw browser hover <ref> openclaw browser scroll [--direction down|up|right|left] # Files openclaw browser upload /tmp/file.pdf openclaw browser download <ref> file.pdf # Cookies & storage openclaw browser cookies | cookies set k v --url "https://example.com" | cookies clear openclaw browser storage local get | set k v | clear # Configuration openclaw browser set geo 48.8566 2.3522 --origin "https://example.com" openclaw browser set timezone Europe/Paris openclaw browser set locale fr-FR openclaw browser set device "iPhone 14" openclaw browser set media dark openclaw browser set headers --headers-json '{"X-Custom":"val"}' # Debug openclaw browser console --level error openclaw browser requests --filter api openclaw browser trace start | stop openclaw browser status # Stealth (if site blocks VPS) openclaw browser --browser-profile browserbase open <url> ``` --- ## browser_control.py — With AUDIT Logging + CAPTCHA + Vision ```bash BC="python3 /data/.openclaw/workspace/skills/virtual-desktop/browser_control.py" $BC screenshot <url> [label] $BC navigate <url> [selector] $BC click <url> <selector> $BC click_xy <url> <x> <y> $BC fill <url> <selector> <value> $BC select <url> <selector> <value> $BC hover <url> <selector> $BC scroll <url> <direction> [pixels] $BC keyboard <url> <selector> <key> $BC extract <url> <selector> [output_file] $BC wait_for <url> <selector> [timeout_ms] $BC upload <url> <file_selector> <file_path> $BC analyze <url_or_image> [question] ← Claude Vision $BC captcha <url> ← Autonomous CAPTCHA $BC workflow <json_steps_file> ← Multi-step workflow $BC status ``` --- ## Workflow JSON Format ```json [ { "action": "goto", "target": "https://TARGET_URL" }, { "action": "captcha" }, { "action": "analyze", "value": "Identify the key elements on this page" }, { "action": "wait_for", "target": ".loaded", "timeout_ms": 5000 }, { "action": "fill", "target": "#field", "value": "text" }, { "action": "click", "target": "#btn" }, { "action": "click_xy", "x": 960, "y": 540 }, { "action": "scroll", "direction": "down" }, { "action": "hover", "target": "#menu" }, { "action": "select", "target": "#list", "value": "option" }, { "action": "keyboard", "target": "#input", "value": "Enter" }, { "action": "extract", "target": ".data", "value": "/workspace/tasks/out.json" }, { "action": "screenshot" }, { "action": "wait", "value": "2" } ] ``` --- ## CAPTCHA — Autonomous Strategy ``` 1. Auto-detection on every page load → reCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile 2. CapSolver API (if CAPSOLVER_API_KEY set) → Extracts sitekey → API → token → injects → continues → ~$0.001 per CAPTCHA 3. Cloudflare Turnstile → CapSolver Chrome extension handles in background → waits 60s 4. Fallback — if CapSolver fails or key not set → Screenshot → Telegram → principal opens noVNC → solves → agent continues ``` --- ## Proxy — If Site Blocks the VPS ```bash # Browserbase — CAPTCHA + stealth + residential proxy built-in # Free tier: 1 concurrent session, 1h/month — browserbase.com # Add BROWSERBASE_API_KEY to .env openclaw browser --browser-profile browserbase open <url> # Custom proxy # Add PROXY_URL=[REDACTED_SECRET] to .env # browser_control.py reads it automatically via get_browser() ``` --- ## Claude Vision — Analyse Images and Pages ```bash # Web page → auto screenshot + analysis $BC analyze https://example.com "What does this page sell?" # AI-generated image $BC analyze https://site.com/image.png "Describe the visual elements" # Existing screenshot $BC analyze /workspace/screenshots/capture.png "Is there a form here?" # Inside a workflow { "action": "analyze", "value": "Identify all form fields" } ``` --- ## Execution Protocol ``` BEFORE EVERY ACTION: 1. Log to AUDIT.md: "BEFORE [action] on [url]" 2. Detect CAPTCHA → resolve automatically if present 3. Execute action 4. Screenshot as proof 5. Log to AUDIT.md: "OK/FAILED [action]" 6. Telegram report if real-world consequences NEVER: → Access platforms not authorized by the principal → Execute payments or destructive actions without explicit approval → Fail silently — always log → Retry more than 3 times without alerting the principal ``` --- ## Browser Traps Avoid these common mistakes: - **Guessing selectors from source** → use `snapshot --interactive` or `codegen` to discover stable refs - **Using force: true before understanding why** → investigate the overlay/disabled state first - **Driving a full browser when HTTP would work** → more cost, more flake, less signal - **Sharing one session across parallel tasks that mutate state** → failures become order-dependent - **Waiting on networkidle for chatty SPAs** → analytics, polling, or sockets keep the page "busy" even when the UI is ready - **Retrying the same selector 10 times** → log to ERRORS.md and alert the principal instead - **Accessing high-stakes flows (payments, production data) without explicit confirmation** → require approval first --- ## Error Recovery ``` CAPTCHA → CapSolver auto → fallback noVNC CLOUDFLARE → switch to --browser-profile browserbase SESSION EXPIRED → Telegram → principal opens noVNC → reconnects ELEMENT MISSING → use analyze to understand the new layout → log to .learnings/ERRORS.md with ref map TIMEOUT → check /workspace/logs/browser/YYYY-MM-DD.log ``` --- ## Files Written | File | When | Content | |---|---|---| | `/workspace/AUDIT.md` | Every action | Before + after log, append-only | | `/workspace/screenshots/YYYY-MM-DD_*.png` | Every action | Visual proof | | `/workspace/screenshots/*_analysis.txt` | After analyze | Vision result | | `/workspace/logs/browser/YYYY-MM-DD.log` | On exception | Full traceback | | `/workspace/.learnings/ERRORS.md` | On failure | Errors + broken selectors | | `/workspace/.learnings/LEARNINGS.md` | On discovery | Patterns + timing per platform | | `/workspace/tasks/lessons.md` | During mission | Immediate task capture | | `/workspace/memory/YYYY-MM-DD.md` | Daily | Session summary | **This skill does NOT:** - Create files outside the paths listed above - Persist sessions or credentials beyond the Docker volume - Make undeclared network requests beyond the target sites and optional services above - Access platforms not explicitly authorized by the principal --- ## Self-Improvement Write immediately after every session — do not batch: ``` # ERRORS.md — on failure ## [YYYY-MM-DD] [Platform] — [Title] **Priority**: low|medium|high **Status**: pending|resolved **What happened**: ... **Root cause**: ... **Fix**: ... **Ref map**: {"old_ref":"new_ref"} # LEARNINGS.md — on discovery ## [YYYY-MM-DD] [Platform] — [Pattern] **Category**: navigation|interaction|timing|auth_flow|captcha|vision **Discovery**: ... **Usage**: ... ``` --- ## Security This skill opens port 6901 (noVNC) and stores authenticated browser sessions permanently. ``` REQUIRED before running: 1. Set a strong VNC_PW in .env — never use the default 2. Firewall port 6901 to your IP only: Hostinger → Panel → VPS → Firewall → restrict 6901 to your IP Or use SSH tunnel: ssh -L 6901:localhost:6901 user@YOUR_VPS_IP 3. Only log in to accounts you trust the agent to access 4. Optional keys (CapSolver, Browserbase, Anthropic) send data to those services — only add them if you trust and accept their costs ``` --- ## External Endpoints | Endpoint | Data sent | Purpose | |---|---|---| | Any URL the principal authorizes | Browser requests, cookies, form data | Automation | | `http://browser:9222` | CDP protocol — internal only | Browser control | | `https://api.capsolver.com` | CAPTCHA sitekey + page URL | CAPTCHA solving (optional) | | `wss://connect.browserbase.com` | Browser session | Stealth proxy (optional) | | `https://api.anthropic.com` | Screenshot base64 | Claude Vision (optional) | | `https://registry.npmjs.org` | Package metadata | Playwright install only | No other data is sent externally.

path: browser_control.py
content: #!/usr/bin/env python3
"""
Virtual Desktop — Browser Control v3
CDP to authenticated kasmweb/chrome + Playwright + CapSolver + Claude Vision
Usage: python3 browser_control.py [action] [args...]

Actions:
  screenshot  <url> [label]
  navigate    <url> [selector]
  click       <url> <selector>
  click_xy    <url> <x> <y>
  fill        <url> <selector> <value>
  select      <url> <selector> <value>
  hover       <url> <selector>
  scroll      <url> <direction> [pixels]
  keyboard    <url> <selector> <key>
  extract     <url> <selector> [output_file]
  wait_for    <url> <selector> [timeout_ms]
  upload      <url> <file_selector> <file_path>
  analyze     <image_path_or_url> [question]
  captcha     <url>
  workflow    <json_steps_file>
  status
"""
import sys, os, json, time, traceback, base64, requests
from datetime import datetime
from playwright.sync_api import sync_playwright

# ── Workspace paths ──
WORKSPACE   = os.environ.get("WORKSPACE", "/workspace")
SCREENSHOTS = f"{WORKSPACE}/screenshots"
LOGS        = f"{WORKSPACE}/logs/browser"
AUDIT       = f"{WORKSPACE}/AUDIT.md"
ERRORS      = f"{WORKSPACE}/.learnings/ERRORS.md"
LEARNINGS   = f"{WORKSPACE}/.learnings/LEARNINGS.md"

# ── Config ──
UA            = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
CDP_URL       = os.environ.get("BROWSER_CDP_URL", "http://browser:9222")
CAPSOLVER_KEY = os.environ.get("CAPSOLVER_API_KEY", "")
VPS_IP        = os.environ.get("VPS_IP", "YOUR_VPS_IP")

# ── Timestamp calculated per call, not at startup ──
def get_ts():
    return datetime.now().strftime("%Y-%m-%d_%H%M%S")

def get_date():
    return datetime.now().strftime("%Y-%m-%d")

# ── Logging ──

def audit(msg):
    os.makedirs(os.path.dirname(AUDIT), exist_ok=True)
    with open(AUDIT, "a") as f:
        f.write(f"\n[{get_ts()}] [virtual-desktop] {msg}\n")

def log_error(platform, title, what, cause, fix, prevention):
    os.makedirs(os.path.dirname(ERRORS), exist_ok=True)
    with open(ERRORS, "a") as f:
        f.write(
            f"\n## [{get_date()}] {platform} — {title}\n"
            f"**Logged**: {get_ts()}\n**Priority**: medium\n**Status**: pending\n"
            f"**Area**: browser_automation\n**What happened**: {what}\n"
            f"**Root cause**: {cause}\n**Fix applied**: {fix}\n**Prevention**: {prevention}\n"
        )

def log_learning(platform, category, discovery, usage):
    os.makedirs(os.path.dirname(LEARNINGS), exist_ok=True)
    with open(LEARNINGS, "a") as f:
        f.write(
            f"\n## [{get_date()}] {platform} — {category}\n"
            f"**Category**: {category}\n**Discovery**: {discovery}\n**Usage**: {usage}\n"
        )

def write_log(msg):
    os.makedirs(LOGS, exist_ok=True)
    with open(f"{LOGS}/{get_date()}.log", "a") as f:
        f.write(f"\n[{get_ts()}] {msg}\n")

# ── Browser ──

def get_browser(p):
    """CDP to kasmweb/chrome — session already authenticated. Headless fallback."""
    try:
        browser = p.chromium.connect_over_cdp(CDP_URL)
        ctx = browser.contexts[0] if browser.contexts else browser.new_context(
            user_agent=UA,
            viewport={"width": 1920, "height": 1080},
            locale="fr-FR",
            timezone_id="Europe/Paris",
            extra_http_headers={"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8"}
        )
        return browser, ctx
    except Exception:
        browser = p.chromium.launch(
            headless=True,
            args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
        )
        return browser, browser.new_context(
            user_agent=UA,
            viewport={"width": 1920, "height": 1080},
            locale="fr-FR",
            timezone_id="Europe/Paris"
        )

# ── Screenshot with per-call timestamp ──
def snap(page, label):
    os.makedirs(SCREENSHOTS, exist_ok=True)
    path = f"{SCREENSHOTS}/{get_ts()}_{label}.png"
    try:
        page.screenshot(path=path, full_page=True)
    except Exception as e:
        write_log(f"snap error ({label}): {e}")
    return path

# ── CAPTCHA ──

def detect_captcha(page):
    selectors = [
        "iframe[src*='recaptcha']",
        "iframe[src*='hcaptcha']",
        ".cf-turnstile",
        "[data-sitekey]",
        "iframe[src*='challenges.cloudflare']"
    ]
    for sel in selectors:
        try:
            if page.query_selector(sel):
                return True
        except Exception:
            pass
    try:
        text = page.inner_text("body").lower()
        return any(w in text for w in [
            "i'm not a robot", "je ne suis pas un robot",
            "verify you are human", "captcha"
        ])
    except Exception:
        return False

def solve_captcha_api(page_url, sitekey, kind="recaptcha"):
    if not CAPSOLVER_KEY:
        return None
    task_type = "HCaptchaTaskProxyless" if kind == "hcaptcha" else "ReCaptchaV2TaskProxyless"
    try:
        r = requests.post(
            "https://api.capsolver.com/createTask",
            json={"clientKey": CAPSOLVER_KEY, "task": {
                "type": task_type,
                "websiteURL": page_url,
                "websiteKey": sitekey
            }},
            timeout=30
        )
        task_id = r.json().get("taskId")
        if not task_id:
            return None
        for _ in range(30):
            time.sleep(3)
            res = requests.post(
                "https://api.capsolver.com/getTaskResult",
                json={"clientKey": CAPSOLVER_KEY, "taskId": task_id},
                timeout=10
            ).json()
            if res.get("status") == "ready":
                return res["solution"]["gRecaptchaResponse"]
    except Exception as e:
        write_log(f"CapSolver error: {e}")
    return None

def auto_solve_captcha(page):
    audit("CAPTCHA detected — autonomous resolution starting")
    el = page.query_selector("[data-sitekey]")
    if el:
        sitekey = el.get_attribute("data-sitekey")
        kind = "hcaptcha" if page.query_selector("iframe[src*='hcaptcha']") else "recaptcha"
        [REDACTED_SECRET], sitekey, kind)
        if token:
            # Token passed via Playwright args — not interpolated in JS string
            page.evaluate("""(token) => {
                var el = document.querySelector('[name="g-recaptcha-response"],[name="h-captcha-response"]');
                if (el) el.value = token;
                try { ___grecaptcha_cfg.clients[0].aa.l.callback(token); } catch(e) {}
                try { hcaptcha.submit(); } catch(e) {}
            }""", token)
            time.sleep(2)
            audit("OK CAPTCHA solved via CapSolver")
            log_learning(page.url, "captcha", f"CAPTCHA {kind} solved", "CapSolver API token injection")
            return True

    if page.query_selector(".cf-turnstile, iframe[src*='challenges.cloudflare']"):
        audit("Cloudflare Turnstile detected — waiting for CapSolver extension 60s")
        time.sleep(60)
        if not detect_captcha(page):
            audit("OK Cloudflare Turnstile resolved")
            return True

    path = snap(page, "captcha_manual")
    audit(f"CAPTCHA not resolved automatically — screenshot: {path}")
    # IP read from .env
    print(f"⚠️ CAPTCHA manuel requis. Ouvre https://{VPS_IP}:6901 — Screenshot: {path}")
    return False

# ── Claude Vision ──

def cmd_analyze(src, question="Describe in detail what you see in this image."):
    audit(f"BEFORE analyze {src}")
    try:
        # If web page URL, take screenshot first
        if src.startswith("http") and not src.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
            with sync_playwright() as p:
                browser, ctx = get_browser(p)
                page = ctx.new_page()
                page.goto(src)
                page.wait_for_load_state("networkidle")
                src = snap(page, "analyze")
                audit(f"OK screenshot for analyze: {src}")
                browser.close()

        # Read image
        if src.startswith("http"):
            img_data = base64.b64encode(requests.get(src, timeout=15).content).decode()
            mt = "image/jpeg"
        else:
            with open(src, "rb") as f:
                img_data = base64.b64encode(f.read()).decode()
            mt = {
                "png": "image/png", "jpg": "image/jpeg",
                "jpeg": "image/jpeg", "webp": "image/webp", "gif": "image/gif"
            }.get(src.split(".")[-1].lower(), "image/png")

        key = os.environ.get("ANTHROPIC_API_KEY", "")
        if not key:
            print("❌ ANTHROPIC_API_KEY missing from .env")
            return

        # Verify API response before accessing content
        resp = requests.post(
            "https://api.anthropic.com/v1/messages",
            headers={
                "x-api-key": key,
                "anthropic-version": "2023-06-01",
                "content-type": "application/json"
            },
            json={
                "model": "claude-sonnet-4-20250514",
                "max_tokens": 2000,
                "messages": [{"role": "user", "content": [
                    {"type": "image", "source": {"type": "base64", "media_type": mt, "data": img_data}},
                    {"type": "text", "text": question}
                ]}]
            },
            timeout=30
        )
        data = resp.json()
        if "content" not in data:
            raise Exception(f"API error: {data.get('error', data)}")
        result = data["content"][0]["text"]

        out = f"{SCREENSHOTS}/{get_ts()}_analysis.txt"
        with open(out, "w") as f:
            f.write(f"# Analysis\n**Source**: {src}\n**Question**: {question}\n\n{result}\n")
        audit(f"OK analyze — saved: {out}")
        print(result)
        print(f"\n✅ Saved: {out}")

    except Exception as e:
        write_log(f"analyze error\n{traceback.format_exc()}")
        print(f"❌ {e}")

# ── run() helper — audit before browser.close() ──

def run(url, fn, label):
    audit(f"BEFORE {label} {url}")
    try:
        with sync_playwright() as p:
            browser, ctx = get_browser(p)
            page = ctx.new_page()
            page.goto(url)
            page.wait_for_load_state("networkidle")
            if detect_captcha(page):
                auto_solve_captcha(page)
            result = fn(page)
            snap(page, label)
            audit(f"OK {label}")
            browser.close()
            return result
    except Exception as e:
        write_log(f"{label} error\n{traceback.format_exc()}")
        log_error("unknown", f"{label} failed", str(e), "see log", "screenshot taken", "check selector/url")
        print(f"❌ {e}")

# ── Commands ──

def cmd_screenshot(url, label="screenshot"):
    run(url, lambda p: None, label)
    print(f"✅ Screenshot: {SCREENSHOTS}/")

def cmd_navigate(url, selector=None):
    def fn(page):
        if selector:
            for el in page.query_selector_all(selector):
                print(el.inner_text())
        else:
            print(page.inner_text("body")[:5000])
    run(url, fn, "navigate")

def cmd_click(url, selector):
    def fn(page):
        page.click(selector)
        time.sleep(1.0)
        page.wait_for_load_state("networkidle")
        print(f"✅ Clicked: {selector}")
    run(url, fn, "click")

def cmd_click_xy(url, x, y):
    def fn(page):
        page.mouse.click(int(x), int(y))
        time.sleep(1.0)
        print(f"✅ Clicked: ({x},{y})")
    run(url, fn, f"click_xy_{x}_{y}")

def cmd_fill(url, selector, value):
    def fn(page):
        page.fill(selector, value)
        time.sleep(0.5)
        print(f"✅ Filled: {selector}")
    run(url, fn, "fill")

def cmd_select(url, selector, value):
    def fn(page):
        page.select_option(selector, value)
        time.sleep(0.5)
        print(f"✅ Selected: {value}")
    run(url, fn, "select")

def cmd_hover(url, selector):
    def fn(page):
        page.hover(selector)
        time.sleep(0.8)
        print(f"✅ Hovered: {selector}")
    run(url, fn, "hover")

def cmd_scroll(url, direction="down", pixels=500):
    m = {"down": (0, int(pixels)), "up": (0, -int(pixels)),
         "right": (int(pixels), 0), "left": (-int(pixels), 0)}
    dx, dy = m.get(direction, (0, int(pixels)))
    def fn(page):
        page.mouse.wheel(dx, dy)
        time.sleep(0.5)
        print(f"✅ Scrolled {direction} {pixels}px")
    run(url, fn, f"scroll_{direction}")

def cmd_keyboard(url, selector, key):
    def fn(page):
        page.click(selector)
        time.sleep(0.3)
        page.keyboard.press(key)
        time.sleep(0.5)
        print(f"✅ Key: {key}")
    run(url, fn, f"key_{key}")

def cmd_extract(url, selector, output_file=None):
    def fn(page):
        results = [
            {
                "text": el.inner_text().strip(),
                "href": el.get_attribute("href") or "",
                "src":  el.get_attribute("src")  or ""
            }
            for el in page.query_selector_all(selector)
        ]
        out = json.dumps(results, ensure_ascii=False, indent=2)
        if output_file:

            os.makedirs(os.path.dirname(output_file), exist_ok=True)
            with open(output_file, "w") as f:
                f.write(out)
            print(f"✅ Extracted {len(results)} items → {output_file}")
        else:
            print(out)
    run(url, fn, "extract")

def cmd_wait_for(url, selector, timeout_ms=10000):
    def fn(page):
        page.wait_for_selector(selector, timeout=int(timeout_ms))
        print(f"✅ Element appeared: {selector}")
    run(url, fn, "wait_for")

def cmd_upload(url, file_selector, file_path):
    def fn(page):
        page.set_input_files(file_selector, file_path)
        time.sleep(1.0)
        print(f"✅ Uploaded: {file_path}")
    run(url, fn, "upload")

def cmd_captcha(url):
    audit(f"BEFORE captcha {url}")
    try:
        with sync_playwright() as p:
            browser, ctx = get_browser(p)
            page = ctx.new_page()
            page.goto(url)
            page.wait_for_load_state("networkidle")
            if detect_captcha(page):
                solved = auto_solve_captcha(page)
                snap(page, "captcha_result")
                audit(f"{'OK' if solved else 'FAILED'} captcha")
                browser.close()
                print(f"{'✅ CAPTCHA solved' if solved else '⚠️ Manual resolution required'}")
            else:
                snap(page, "no_captcha")
                audit("OK captcha — none detected")
                browser.close()
                print("✅ No CAPTCHA detected")
    except Exception as e:
        write_log(f"captcha error\n{traceback.format_exc()}")
        print(f"❌ {e}")

def cmd_workflow(steps_file):
    """
    Workflow JSON multi-étapes avec CAPTCHA auto + vision.
    Actions: goto, click, click_xy, fill, select, hover, scroll,
             keyboard, wait_for, extract, screenshot, wait, captcha, analyze
    """
    audit(f"BEFORE workflow {steps_file}")
    with open(steps_file) as f:
        steps = json.load(f)
    log = {"date": get_ts(), "file": steps_file, "steps": [], "status": "started"}
    try:
        with sync_playwright() as p:
            browser, ctx = get_browser(p)
            page = ctx.new_page()
            for i, step in enumerate(steps):
                a = step.get("action", "")
                t = step.get("target", "")
                v = step.get("value", "")
                try:
                    if a == "goto":
                        page.goto(t)
                        page.wait_for_load_state("networkidle")
                    
                        if detect_captcha(page):
                            auto_solve_captcha(page)
                    elif a == "click":
                        page.click(t); time.sleep(0.8)
                    elif a == "click_xy":
                        page.mouse.click(int(step.get("x", 0)), int(step.get("y", 0)))
                        time.sleep(0.8)
                    elif a == "fill":
                        page.fill(t, v); time.sleep(0.5)
                    elif a == "select":
                        page.select_option(t, v); time.sleep(0.5)
                    elif a == "hover":
                        page.hover(t); time.sleep(0.5)
                    elif a == "scroll":
                        m = {"down": (0,500), "up": (0,-500), "right": (500,0), "left": (-500,0)}
                        dx, dy = m.get(step.get("direction", "down"), (0, 500))
                        page.mouse.wheel(dx, dy); time.sleep(0.5)
                    elif a == "keyboard":
                        page.click(t); time.sleep(0.3); page.keyboard.press(v)
                    elif a == "wait_for":
                        page.wait_for_selector(t, timeout=int(step.get("timeout_ms", 10000)))
                    elif a == "wait":
                        time.sleep(float(v) if v else 1.0)
                    elif a == "extract":
                        data = [{"text": el.inner_text().strip()} for el in page.query_selector_all(t)]
                        if v:
                
                            os.makedirs(os.path.dirname(v), exist_ok=True)
                            with open(v, "w") as ef:
                                json.dump(data, ef, ensure_ascii=False, indent=2)
                    elif a == "screenshot":
                        snap(page, f"step{i}")
                    elif a == "captcha":
                        if detect_captcha(page):
                            auto_solve_captcha(page)
                    elif a == "analyze":
                        path = snap(page, f"step{i}_analyze")
                        cmd_analyze(path, v or "Describe this page in detail.")
                    else:
                        print(f"⚠️ Unknown action: {a}")

                    log["steps"].append({"step": i, "action": a, "status": "ok"})
                    print(f"✅ Step {i}: {a} {t}")

                except Exception as e:
                    log["steps"].append({"step": i, "action": a, "status": "failed", "error": str(e)})
                    snap(page, f"step{i}_error")
                    print(f"❌ Step {i} ({a}): {e}")

            log["status"] = "completed"
            snap(page, "workflow_done")
            audit("OK workflow completed")
            browser.close()

    except Exception as e:
        log["status"] = "failed"
        write_log(f"workflow error\n{traceback.format_exc()}")

    finally:
        mem = f"{WORKSPACE}/memory/{get_date()}.md"
        os.makedirs(os.path.dirname(mem), exist_ok=True)
        with open(mem, "a") as f:
            f.write(f"\n## Workflow — {get_ts()}\n```json\n{json.dumps(log, indent=2)}\n```\n")
        audit(f"{log['status']} workflow {steps_file}")
        print(f"{'✅' if log['status'] == 'completed' else '❌'} Workflow {log['status']}")

def cmd_status():
    cdp_ok = False
    try:
        cdp_ok = requests.get(f"{CDP_URL}/json", timeout=3).status_code == 200
    except Exception:
        pass
    checks = {
        "playwright":      os.system("which playwright > /dev/null 2>&1") == 0,
        "chrome_cdp":      cdp_ok,
        "screenshots_dir": os.path.exists(SCREENSHOTS),
        "audit_file":      os.path.exists(AUDIT),
        "capsolver":       bool(CAPSOLVER_KEY),
        "browserbase":     bool(os.environ.get("BROWSERBASE_API_KEY", "")),
        "claude_vision":   bool(os.environ.get("ANTHROPIC_API_KEY", "")),
    }
    print("\n🖥️ Virtual Desktop v3 — Status")
    for k, v in checks.items():
        print(f"  {'✅' if v else '❌'} {k}")

# ── CLI Dispatcher ──
if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(0)
    a = sys.argv[1]
    args = sys.argv[2:]
    if   a == "screenshot": cmd_screenshot(args[0], args[1] if len(args) > 1 else "screenshot")
    elif a == "navigate":   cmd_navigate(args[0], args[1] if len(args) > 1 else None)
    elif a == "click":      cmd_click(args[0], args[1])
    elif a == "click_xy":   cmd_click_xy(args[0], args[1], args[2])
    elif a == "fill":       cmd_fill(args[0], args[1], args[2])
    elif a == "select":     cmd_select(args[0], args[1], args[2])
    elif a == "hover":      cmd_hover(args[0], args[1])
    elif a == "scroll":     cmd_scroll(args[0], args[1] if len(args) > 1 else "down", args[2] if len(args) > 2 else 500)
    elif a == "keyboard":   cmd_keyboard(args[0], args[1], args[2])
    elif a == "extract":    cmd_extract(args[0], args[1], args[2] if len(args) > 2 else None)
    elif a == "wait_for":   cmd_wait_for(args[0], args[1], args[2] if len(args) > 2 else 10000)
    elif a == "upload":     cmd_upload(args[0], args[1], args[2])
    elif a == "analyze":    cmd_analyze(args[0], args[1] if len(args) > 1 else "Describe what you see.")
    elif a == "captcha":    cmd_captcha(args[0])
    elif a == "workflow":   cmd_workflow(args[0])
    elif a == "status":     cmd_status()
    else:
        print(f"❌ Unknown action: {a}")
        print(__doc__)


path: README.md
content: # virtual-desktop

🖥️ Persistent authenticated browser for OpenClaw — anti-bot, vision-enabled, CAPTCHA-autonomous.

If a human can do it in Chrome, the agent can do it — 24h/24, without you.

---

## What it does

Runs a real **kasmweb/chrome** Docker sidecar alongside OpenClaw.
Principal logs in once via noVNC. Sessions saved permanently in a Docker volume.
Agent accesses any authenticated platform from that point on — no credentials needed.

## Three execution paths

| Path | Use for | Tokens |
|---|---|---|
| OpenClaw native browser | Simple navigate / click / extract | Fewest |
| browser_control.py | AUDIT logging, workflows, CAPTCHA, Vision | Medium |
| noVNC (manual) | Initial login, 2FA, session renewal | None |

Load only the path the task requires.

## Key features

- **Persistent sessions** — log in once, access forever
- **Autonomous CAPTCHA** — CapSolver resolves reCAPTCHA / hCaptcha / Turnstile automatically
- **Residential proxy** — Browserbase for sites that block VPS IPs
- **Claude Vision** — analyze any page or image natively
- **Full logging** — every action in AUDIT.md, errors in ERRORS.md, patterns in LEARNINGS.md
- **Self-improving** — agent writes learnings immediately after every session

## What gets downloaded at setup

| What | Why |
|---|---|
| `kasmweb/chrome:1.15.0` (~2GB) | Real Chrome Desktop with noVNC — your authenticated sessions live here |
| `Playwright Chromium` (~100MB) | Headless fallback inside the OpenClaw container — used if Chrome CDP is unavailable |
| `requests + playwright` (Python) | Python dependencies for browser_control.py |
| `CapSolver extension` (optional) | Only if CAPSOLVER_API_KEY is set — autonomous CAPTCHA |

## Setup

Agent runs `virtual_desktop.setup` — downloads and installs everything, notifies the principal.
Principal connects once via noVNC. Done.

## Optional keys (add to .env)

```
CAPSOLVER_API_KEY=xxx      # Autonomous CAPTCHA (~$0.001/solve)
BROWSERBASE_API_KEY=xxx    # Residential proxy + stealth (free tier available)
ANTHROPIC_API_KEY=xxx      # Claude Vision (uses existing key if already set)
```

## Files included

| File | Role |
|---|---|
| `SKILL.md` | Full instructions for the agent |
| `browser_control.py` | Python helper — logging, CAPTCHA, Vision, workflows |
| `README.md` | This file |
| `CONFIGURATION.md` | noVNC access, .env variables, reset procedures |


path: CONFIGURATION.md
content: # Virtual Desktop — Configuration

## noVNC Access

After setup, open Chrome Desktop from any browser:

```
URL      : https://YOUR_VPS_IP:6901
Login    : kasm_user
Password : your VNC_PW (set in .env)
```

To find your VPS IP:
```bash
curl -s ifconfig.me
```

---

## Required .env Variables

```bash
VNC_PW=YourSecurePassword          # noVNC access password — use something strong
BROWSER_CDP_URL=http://browser:9222
```

## Optional .env Variables

```bash
CAPSOLVER_API_KEY=                 # Autonomous CAPTCHA solving (~$0.001/solve)
BROWSERBASE_API_KEY=               # Residential proxy + stealth
ANTHROPIC_API_KEY=                 # Claude Vision (reuse existing key if already set)
VPS_IP=                            # Your VPS public IP — used in Telegram notifications
PROXY_URL=                         # Custom proxy: [REDACTED_SECRET]
```

---

## Initial Login — Once Per Platform

1. Open `https://YOUR_VPS_IP:6901` in your browser
2. Log in with `kasm_user` / your `VNC_PW`
3. Chrome Desktop opens — log in to every platform you want the agent to access
4. Sessions are saved automatically in Docker volume `browser-profile`
5. They survive container restarts and remain valid indefinitely

---

## Session Expired

The agent detects session expiry and notifies you via Telegram with the noVNC link.
Open noVNC, log in again, reply DONE. The agent resumes immediately.

---

## Enable CapSolver (Autonomous CAPTCHA)

```bash
# 1. Create account at https://capsolver.com
# 2. Add to .env:
CAPSOLVER_API_KEY=your_key_here
```

Supported types: reCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile, AWS WAF

Cost: ~$0.001 per CAPTCHA solved.

---

## Enable Browserbase (Residential Proxy + Stealth)

```bash
# 1. Create account at https://browserbase.com
# 2. Free tier: 1 concurrent session, 1h/month
# 3. Add to .env:
BROWSERBASE_API_KEY=your_key_here
```

Use when a site blocks your VPS IP:
```bash
openclaw browser --browser-profile browserbase open https://blocked-site.com
```

---

## Reset Sessions

If you want to clear all saved sessions and start fresh:

```bash
CONTAINER=$(docker ps --format '{{.Names}}' | grep openclaw | head -1)
docker volume rm browser-profile
# Restart only the browser container — OpenClaw keeps running
docker compose up -d --no-deps browser
# Log in again via noVNC
```

---

## Change noVNC Password

```bash
# In your .env file:
VNC_PW=NewSecurePassword
# Restart only the browser container
docker compose up -d --no-deps browser
```

---

## Firewall — Restrict Port 6901

Port 6901 (noVNC) should be restricted to your IP only.

**Hostinger:** Panel → VPS → Firewall → Add rule → TCP 6901 → Your IP only

**SSH tunnel (alternative — no open port needed):**
```bash
ssh -L 6901:localhost:6901 user@YOUR_VPS_IP
# Then open http://localhost:6901
```

---

## Verify Everything is Running

```bash
CONTAINER=$(docker ps --format '{{.Names}}' | grep openclaw | head -1)
docker ps | grep -E "openclaw|browser"
curl -s http://localhost:9222/json | head -3
docker exec "$CONTAINER" \
  python3 /data/.openclaw/workspace/skills/virtual-desktop/browser_control.py status
```

Expected output:
```
✅ playwright
✅ chrome_cdp
✅ screenshots_dir
✅ audit_file
✅ capsolver       (if CAPSOLVER_API_KEY set)
✅ browserbase     (if BROWSERBASE_API_KEY set)
✅ claude_vision   (if ANTHROPIC_API_KEY set)
```

---

## Open Port 6901

If noVNC is not accessible from your browser, open port 6901 (TCP) in your VPS firewall.

**Hostinger:** Panel → VPS → Firewall → Add rule → TCP 6901 → restrict to your IP only

**SSH tunnel (recommended — no open port needed):**
```bash
ssh -L 6901:localhost:6901 user@YOUR_VPS_IP
# Then open http://localhost:6901
```


Security scan verdicts — ClawScan: malicious | static analysis: clean | VirusTotal: suspicious | SkillSpector:  ()