Skill: 0xclanky/captcha-relay (version 2.1.0)

--- name: captcha-relay description: "Human-in-the-loop CAPTCHA solving with two modes: screenshot (default, zero infrastructure) and token relay (requires network access). Screenshot mode captures the page with a grid overlay, sends it to the human, and injects clicks based on their reply. Token relay mode detects CAPTCHA type + sitekey, serves the real widget on a relay page for native solving, and injects the token via CDP." defaultMode: screenshot --- # CAPTCHA Relay v2 Solve CAPTCHAs by relaying them to a human. Two modes available. ## Modes ### Screenshot Mode (default) — No infrastructure needed Grid overlay screenshot → send image to human via Telegram → human replies with cell numbers → inject clicks. - **Zero setup** beyond the skill itself. No Tailscale, no tunnels, no relay server. - Works for **any** CAPTCHA type (reCAPTCHA, hCaptcha, sliders, text, etc.) - Uses `sharp` for image processing + CDP for screenshots and click injection. ```bash node index.js # screenshot mode (default) node index.js --mode screenshot # explicit node index.js --screenshot # legacy alias ``` ```js const { solveCaptchaScreenshot } = require('./index'); const capture = await solveCaptchaScreenshot({ cdpPort: 18800 }); // capture.imagePath — annotated screenshot to send to human // capture.prompt — text prompt for the human ``` ### Token Relay Mode — Requires network access Detects CAPTCHA type + sitekey → serves real widget on relay page → human solves natively → token injected via CDP. - Requires **Tailscale** or a **tunnel** (localtunnel/cloudflared) so the human's device can reach the relay server. - Produces a proper CAPTCHA token — more reliable for reCAPTCHA v2, hCaptcha, Turnstile. - Best when you have Tailscale already set up. ```bash node index.js --mode relay # with localtunnel node index.js --mode relay --no-tunnel # with Tailscale/LAN ``` ```js const { solveCaptcha } = require('./index'); const result = await solveCaptcha({ cdpPort: 18800, useTunnel: false }); // result.relayUrl — URL to send to human // result.token — solved CAPTCHA token ``` ## When to Use Each | Scenario | Mode | |----------|------| | Quick & easy, no setup | `screenshot` | | Any CAPTCHA type (sliders, text, etc.) | `screenshot` | | Known CAPTCHA with sitekey (reCAPTCHA, hCaptcha, Turnstile) | `relay` | | Tailscale already configured | `relay` | | No network access to host | `screenshot` | ## CLI Flags | Flag | Default | Description | |------|---------|-------------| | `--mode screenshot\|relay` | `screenshot` | Select solving mode | | `--screenshot` | — | Alias for `--mode screenshot` | | `--no-inject` | inject | Return token without injecting into browser | | `--no-tunnel` | tunnel | Skip tunnel, use local/Tailscale IP (relay mode) | | `--timeout N` | 120 | Timeout in seconds | | `--cdp-port N` | 18800 | Chrome DevTools Protocol port | ## Agent Workflow ### Screenshot mode (simplest) 1. Call `solveCaptchaScreenshot({ cdpPort })` 2. Send `capture.imagePath` to human via `message` tool with `capture.prompt` 3. Human replies with cell numbers (e.g. "1,3,5,7") 4. Call `injectGridClicks(cdpPort, capture, selectedCells)` to click those cells ### Relay mode 1. Call `solveCaptcha({ useTunnel: false })` (Tailscale) or `solveCaptcha()` (tunnel) 2. Send `result.relayUrl` to human via `message` tool 3. Wait — resolves when human completes the CAPTCHA 4. Token is auto-injected; continue automation ## Requirements - Chrome/Chromium with `--remote-debugging-port=18800` - Node.js 18+ and `npm install` (deps: ws, sharp) - **Relay mode only:** Tailscale or internet for tunnel

path: ARCHITECTURE.md
content: # CAPTCHA Relay v2 — Architecture

## Overview

Token-based CAPTCHA relay: extract sitekey from automated browser, serve a relay page with the real CAPTCHA widget, human solves on phone, token relayed back and injected via CDP.

## Flow

```
Agent Browser → CAPTCHA detected
       ↓
  Extract type + sitekey (CDP page target)
       ↓
  Start relay HTTP server (random port)
       ↓
  Start tunnel (localtunnel / cloudflared / Tailscale)
       ↓
  Send URL to human (Telegram inline button)
       ↓
  Human opens on phone → solves CAPTCHA widget
       ↓
  Token POSTed to /token → written to /tmp/captcha-relay-token.txt
       ↓
  Inject token into browser page (CDP page target)
       ↓
  Cleanup (stop server + tunnel)
```

## File Structure

```
skills/captcha-relay/
├── SKILL.md              # Skill manifest
├── ARCHITECTURE.md       # This file
├── TAILSCALE.md          # Tailscale setup guide
├── package.json
├── index.js              # Main orchestrator (CLI + module)
├── lib/
│   ├── cdp.js            # CDP client (WebSocket, page targets)
│   ├── detect.js         # CAPTCHA detection + sitekey extraction
│   ├── server.js         # HTTP relay server
│   ├── tunnel.js         # Tunnel management (localtunnel, cloudflared)
│   ├── inject.js         # Token injection back into browser
│   └── templates/        # HTML relay pages
│       ├── recaptcha-v2.html
│       ├── hcaptcha.html
│       └── turnstile.html
└── fallback/
    └── screenshot.js     # Screenshot grid fallback (v1 style)
```

## Key Modules

### detect.js
Connects to CDP via page target (not browser-level WS URL). Evaluates JS to find CAPTCHA widgets by class/data-sitekey attributes and iframe src parsing.

### server.js
Minimal HTTP server (no Express). Serves templated CAPTCHA page on GET /, receives token on POST /token. Writes token to `/tmp/captcha-relay-token.txt` and resolves a Promise.

### tunnel.js
Tries localtunnel (via `npx localtunnel`) first, falls back to cloudflared, then local IP. Returns `{ url, process, isLocal, method }`.

### inject.js
Connects to CDP page target and injects token via:
- Setting textarea value (`#g-recaptcha-response`, etc.)
- Dispatching input events
- Calling registered callbacks (data-callback attr, `___grecaptcha_cfg` internal, etc.)

### Templates
Each HTML template loads the provider's JS SDK, renders the widget with the extracted sitekey, and POSTs the token back to `/token` on solve. Dark theme, mobile-optimized.

## Domain Matching

| Provider | Client Domain Check | Token Relay Status |
|----------|--------------------|--------------------|
| reCAPTCHA v2 | Yes — but **works anyway** for many sites | ✅ Tested working |
| hCaptcha | No client check | ✅ Should work reliably |
| Turnstile | Yes | ⚠️ Untested |

**Lesson learned**: reCAPTCHA v2 token relay works better than expected. Tested with Google's demo — the widget renders on any domain and the token is accepted.

## Lessons Learned (v2 Development)

### Tunneling
- **cloudflared**: Too heavy for constrained machines (ARM, low-RAM). Binary is large and resource-hungry.
- **localhost.run**: Free tier has broken TLS certificates. Unusable.
- **localtunnel**: Works well via `npx localtunnel`. Free, no install. Downside: shows a splash/interstitial page on first visit ("Click to Continue"). Minor UX annoyance.
- **Tailscale**: Recommended for production. No tunnel needed — devices on the same Tailnet can reach the relay server directly via Tailscale IP. No splash pages, always-on. See TAILSCALE.md.

### CDP Connection
- Must use **page-level** WebSocket URL from `/json/list`, not the browser-level URL from `/json/version`. The browser-level connection can't evaluate JS in page context.

### Token Injection
- Setting textarea value alone isn't enough — must also trigger the callback. Walking `___grecaptcha_cfg.clients` tree to find the callback function works for reCAPTCHA v2.

## Dependencies

- `ws` — WebSocket client for CDP
- `sharp` — image processing (screenshot fallback mode)
- `localtunnel` — tunnel (via npx, not installed)
- `cloudflared` — system binary (optional fallback)


path: README.md
content: # captcha-relay

Human-in-the-loop CAPTCHA solving. No third-party solving services. No API keys. Just you and your phone.

## Two Modes

### 📸 Screenshot Mode (default) — Zero infrastructure

Captures the CAPTCHA as a screenshot with a numbered grid overlay, sends it to you (via Telegram, etc.), you reply with cell numbers, and clicks are injected into the browser.

**Works for any CAPTCHA type.** No network setup needed.

```bash
node index.js                   # screenshot mode is the default
node index.js --mode screenshot # explicit
```

### 🔑 Token Relay Mode — Native solving

Detects the CAPTCHA type and sitekey, serves the real CAPTCHA widget on a relay page, you solve it natively on your phone, and the token is injected back via CDP.

**Requires network access** (Tailscale or tunnel) so your phone can reach the relay server.

```bash
node index.js --mode relay              # with localtunnel
node index.js --mode relay --no-tunnel  # with Tailscale/LAN
```

## Quick Start

### Screenshot Mode (easiest)

```bash
cd captcha-relay
npm install
# Launch Chrome with CDP:
chromium --remote-debugging-port=18800

# In another terminal:
node index.js
# → Outputs annotated screenshot path as JSON
# → Send image to human, get cell numbers back, inject clicks
```

### Token Relay Mode

**With Tailscale (recommended):**

```bash
# Install Tailscale on both your server and phone
# See TAILSCALE.md for details

node index.js --mode relay --no-tunnel
# → Outputs relay URL as JSON
# → Open URL on phone, solve CAPTCHA, token auto-injected
```

**With localtunnel:**

```bash
node index.js --mode relay
# → Creates public tunnel URL automatically
```

## Module API

```js
const { solveCaptcha, solveCaptchaScreenshot, injectGridClicks } = require('captcha-relay');

// Screenshot mode
const capture = await solveCaptchaScreenshot({ cdpPort: 18800 });
// capture.imagePath, capture.prompt, capture.rows, capture.cols

// Token relay mode
const result = await solveCaptcha({ cdpPort: 18800, useTunnel: false });
// result.relayUrl, result.token, result.solved
```

## CLI Options

| Flag | Description | Default |
|------|-------------|---------|
| `--mode screenshot\|relay` | Solving mode | `screenshot` |
| `--screenshot` | Alias for `--mode screenshot` | — |
| `--cdp-port N` | Chrome DevTools Protocol port | `18800` |
| `--timeout N` | Timeout in seconds | `120` |
| `--no-tunnel` | Skip tunnel, use local/Tailscale IP | off |
| `--no-inject` | Return token without injecting | off |

## How It Works

```
Screenshot Mode:                    Token Relay Mode:
                                    
Browser hits CAPTCHA                Browser hits CAPTCHA
       ↓                                   ↓
Screenshot + grid overlay           Detect type + sitekey via CDP
       ↓                                   ↓
Send image to human                 Start relay server (real widget)
       ↓                                   ↓
Human replies: "1,3,5"             Generate URL → send to phone
       ↓                                   ↓
Inject clicks at grid cells         Human solves CAPTCHA natively
       ↓                                   ↓
Automation continues ✓              Token injected via CDP
                                           ↓
                                    Automation continues ✓
```

## Supported CAPTCHAs

| Type | Screenshot | Token Relay |
|------|-----------|-------------|
| reCAPTCHA v2 | ✅ | ✅ Tested |
| hCaptcha | ✅ | ✅ Supported |
| Cloudflare Turnstile | ✅ | ✅ Supported |
| Sliders, text, custom | ✅ | ❌ |

## Network Setup (Relay Mode Only)

See [TAILSCALE.md](TAILSCALE.md) for detailed Tailscale setup.

| Method | How | Trade-offs |
|--------|-----|------------|
| **Tailscale** | `--no-tunnel` | Always-on, encrypted, no splash pages |
| **localtunnel** | Default | Works anywhere, has splash page |
| **LAN** | `--no-tunnel` | Same WiFi only |

## Architecture

See [ARCHITECTURE.md](ARCHITECTURE.md) for the full breakdown.

## License

MIT


path: TAILSCALE.md
content: # Tailscale Setup for CAPTCHA Relay

Tailscale creates a private mesh VPN (Tailnet) between your devices. With it, the relay server is directly accessible from your phone — no tunneling, no splash pages, no TLS issues.

## Why Tailscale?

| Approach | Pros | Cons |
|----------|------|------|
| localtunnel | Free, no setup | Splash page, unreliable, public URL |
| cloudflared | Fast | Heavy binary, resource-hungry |
| **Tailscale** | Always-on, private, fast, no splash | Requires install on both devices |

## Setup

### 1. Server (Ubuntu Linux)

```bash
# Install
curl -fsSL https://tailscale.com/install.sh | sh

# Start and authenticate
sudo tailscale up

# Note your Tailscale IP
tailscale ip -4
# e.g., 100.x.y.z
```

The machine gets a stable `100.x.y.z` Tailscale IP on your private Tailnet.

### 2. Phone (iOS/Android)

1. Install Tailscale from App Store / Play Store
2. Sign in with the same account (Google, Microsoft, GitHub, etc.)
3. Enable the VPN toggle
4. Both devices are now on the same Tailnet

### 3. Integration with captcha-relay

Run with `--no-tunnel` and use the Tailscale IP:

```bash
# On the server
TAILSCALE_IP=$(tailscale ip -4)
node index.js --no-tunnel
# The relay URL will be http://100.x.y.z:PORT
```

Or in module mode:

```js
const result = await solveCaptcha({
  useTunnel: false,  // no tunnel needed
});
// result.relayUrl will be http://<local-ip>:PORT
// With Tailscale, <local-ip> should resolve to the Tailscale IP
```

**Note**: The `getLocalIp()` function in tunnel.js returns the first non-internal IPv4 address. If Tailscale is active, you may want to explicitly use the `tailscale ip -4` output. A future enhancement could auto-detect the Tailscale interface (`tailscale0`).

### 4. MagicDNS (Optional)

Tailscale assigns human-readable names to devices:

```
http://clankys-house:PORT
```

Enable MagicDNS in the Tailscale admin console for this to work.

## Free Tier

Tailscale's free "Personal" plan includes:
- **Up to 3 users**
- **Up to 100 devices**
- All core features (MagicDNS, HTTPS, ACLs)
- No bandwidth limits
- No time limits

This is more than enough for personal use.

## Security

- Traffic is encrypted end-to-end (WireGuard)
- The relay server is only accessible to devices on your Tailnet
- No public URLs — unlike localtunnel/cloudflared, nothing is exposed to the internet
- This is actually more secure than the tunnel approaches

## Pros/Cons Summary

**Pros:**
- Always-on — no need to start/stop tunnels per CAPTCHA
- No splash pages or interstitials
- Private and encrypted
- Reliable — no dependency on third-party tunnel services
- Fast — direct WireGuard connection
- Free for personal use

**Cons:**
- One-time setup required on each device
- Adds a background service/VPN on the phone
- Need same auth account on both devices
- If Tailscale service is down (rare), no connectivity

## Recommendation

For development/testing: use localtunnel (zero setup).
For production/daily use: set up Tailscale once and forget about tunneling forever.


path: browser-relay-cli.js
content: #!/usr/bin/env node
/**
 * Browser Relay CLI — start a relay server for a headless Chrome tab
 * Usage: node browser-relay-cli.js [--port 8787] [--cdp-port 18800] [--target-id <id>]
 */
const { createBrowserRelay } = require('./lib/browser-relay');

const args = process.argv.slice(2);
function getArg(name, def) {
  const i = args.indexOf(name);
  return i >= 0 && args[i + 1] ? args[i + 1] : def;
}

(async () => {
  try {
    const relay = await createBrowserRelay({
      cdpPort: parseInt(getArg('--cdp-port', '18800')),
      targetId: getArg('--target-id', undefined),
      port: parseInt(getArg('--port', '8787')),
      timeout: parseInt(getArg('--timeout', '300')) * 1000,
    });
    console.log(`\n🖥️  Browser Relay running!`);
    console.log(`   Local:     http://localhost:${relay.port}`);
    console.log(`   Tailscale: http://100.117.26.44:${relay.port}\n`);
  } catch (e) {
    console.error('Failed to start:', e.message);
    process.exit(1);
  }
})();


path: fallback/screenshot.js
content: /**
 * Screenshot-based CAPTCHA relay (fallback for domain-locked CAPTCHAs)
 * Takes screenshot, overlays numbered grid, sends to human via Telegram
 */
const { CdpSession, getCdpWsUrl } = require('../lib/cdp');
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');

/**
 * Detect grid dimensions from CAPTCHA iframe
 */
const GRID_DETECT_SCRIPT = `
(() => {
  // Try reCAPTCHA bframe
  const bframe = document.querySelector('iframe[src*="recaptcha/api2/bframe"]');
  if (bframe) {
    try {
      const doc = bframe.contentDocument;
      const tds = doc.querySelectorAll('td[role="button"]');
      const rows = doc.querySelectorAll('tr').length;
      const cols = tds.length / rows;
      const prompt = doc.querySelector('.rc-imageselect-desc-no-canonical')?.textContent ||
                     doc.querySelector('.rc-imageselect-desc')?.textContent || '';
      const table = doc.querySelector('table.rc-imageselect-table');
      const rect = table ? table.getBoundingClientRect() : null;
      const frameRect = bframe.getBoundingClientRect();
      return {
        found: true, rows, cols, prompt,
        gridClip: rect ? {
          x: Math.round(frameRect.x + rect.x),
          y: Math.round(frameRect.y + rect.y),
          w: Math.round(rect.width),
          h: Math.round(rect.height)
        } : null
      };
    } catch(e) {
      return { found: true, rows: 3, cols: 3, prompt: '', gridClip: null, error: e.message };
    }
  }
  return { found: false };
})()
`;

async function captureAndAnnotate(cdpPort = 18800) {
  const wsUrl = await getCdpWsUrl(cdpPort);
  const session = new CdpSession(wsUrl);
  await session.connect();

  try {
    // Detect grid
    const gridInfo = await session.send('Runtime.evaluate', {
      expression: GRID_DETECT_SCRIPT,
      returnByValue: true,
    });
    const grid = gridInfo.result.value;

    // Take screenshot
    const screenshotResult = await session.send('Page.captureScreenshot', {
      format: 'png',
      clip: grid.gridClip ? {
        x: grid.gridClip.x,
        y: grid.gridClip.y,
        width: grid.gridClip.w,
        height: grid.gridClip.h,
        scale: 1,
      } : undefined,
    });

    const imgBuffer = Buffer.from(screenshotResult.data, 'base64');
    const metadata = await sharp(imgBuffer).metadata();
    const { width, height } = metadata;
    const rows = grid.rows || 3;
    const cols = grid.cols || 3;

    // Create SVG overlay with numbered cells
    const cellW = Math.floor(width / cols);
    const cellH = Math.floor(height / rows);
    const fontSize = Math.min(cellW, cellH) * 0.3;

    let svgParts = [`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">`];
    let cellNum = 1;
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        const cx = c * cellW + cellW / 2;
        const cy = r * cellH + cellH / 2;
        // Number circle
        svgParts.push(`<circle cx="${cx}" cy="${cy}" r="${fontSize * 0.7}" fill="rgba(0,0,0,0.6)" stroke="white" stroke-width="2"/>`);
        svgParts.push(`<text x="${cx}" y="${cy + fontSize * 0.35}" text-anchor="middle" fill="white" font-size="${fontSize}" font-family="Arial" font-weight="bold">${cellNum}</text>`);
        // Grid lines
        if (c > 0) svgParts.push(`<line x1="${c * cellW}" y1="0" x2="${c * cellW}" y2="${height}" stroke="rgba(255,255,255,0.3)" stroke-width="1"/>`);
        if (r > 0) svgParts.push(`<line x1="0" y1="${r * cellH}" x2="${width}" y2="${r * cellH}" stroke="rgba(255,255,255,0.3)" stroke-width="1"/>`);
        cellNum++;
      }
    }
    svgParts.push('</svg>');

    const annotated = await sharp(imgBuffer)
      .composite([{ input: Buffer.from(svgParts.join('')), top: 0, left: 0 }])
      .png()
      .toBuffer();

    const outPath = path.join('/tmp', `captcha-grid-${Date.now()}.png`);
    fs.writeFileSync(outPath, annotated);

    return {
      imagePath: outPath,
      imageBuffer: annotated,
      prompt: grid.prompt || 'Select the correct images',
      rows,
      cols,
      totalCells: rows * cols,
    };
  } finally {
    session.close();
  }
}

/**
 * Inject cell clicks into reCAPTCHA grid
 */
async function injectGridClicks(cells, cdpPort = 18800) {
  const wsUrl = await getCdpWsUrl(cdpPort);
  const session = new CdpSession(wsUrl);
  await session.connect();

  try {
    const script = `
      (() => {
        const cells = ${JSON.stringify(cells)};
        const bframe = document.querySelector('iframe[src*="recaptcha/api2/bframe"]');
        if (!bframe) return 'no-bframe';
        const doc = bframe.contentDocument;
        const tds = doc.querySelectorAll('td[role="button"]');
        cells.forEach(c => { if (tds[c - 1]) tds[c - 1].click(); });
        // Click verify after a short delay
        setTimeout(() => {
          const verify = doc.querySelector('#recaptcha-verify-button');
          if (verify) verify.click();
        }, 500);
        return 'clicked-' + cells.join(',');
      })()
    `;
    const result = await session.send('Runtime.evaluate', {
      expression: script,
      returnByValue: true,
    });
    return result.result.value;
  } finally {
    session.close();
  }
}

module.exports = { captureAndAnnotate, injectGridClicks };


path: index.js
content: #!/usr/bin/env node
/**
 * CAPTCHA Relay v2 — Human-in-the-loop CAPTCHA solving
 *
 * Two modes:
 *   screenshot (default) — Grid overlay screenshot, human replies with cell numbers, clicks injected.
 *                           No network infrastructure needed.
 *   relay                 — Detects CAPTCHA type + sitekey, serves real widget on relay page,
 *                           human solves natively, token injected via CDP.
 *                           Requires Tailscale or tunnel for network access.
 *
 * Usage:
 *   node index.js                            # screenshot mode (default)
 *   node index.js --mode screenshot          # explicit screenshot mode
 *   node index.js --mode relay               # token relay mode
 *   node index.js --screenshot               # alias for --mode screenshot
 *   node index.js --mode relay --no-tunnel   # relay via Tailscale/LAN
 *
 * As module:
 *   const { solveCaptcha, solveCaptchaScreenshot } = require('./index');
 */

const { detectCaptcha } = require('./lib/detect');
const { createRelayServer } = require('./lib/server');
const { startTunnel, stopTunnel, getLocalIp, getTailscaleIp } = require('./lib/tunnel');
const { injectToken } = require('./lib/inject');
const { captureAndAnnotate, injectGridClicks } = require('./fallback/screenshot');

async function solveCaptcha(opts = {}) {
  const {
    cdpPort = 18800,
    timeout = 120000,
    inject = true,
    useTunnel = true,
    // Allow manual override
    type: overrideType,
    sitekey: overrideSitekey,
    pageUrl: overridePageUrl,
  } = opts;

  const log = (msg) => process.stderr.write(`[captcha-relay] ${msg}\n`);

  // Step 1: Detect CAPTCHA
  log('Detecting CAPTCHA...');
  let detection;
  if (overrideType && overrideSitekey) {
    detection = { type: overrideType, sitekey: overrideSitekey, pageUrl: overridePageUrl };
  } else {
    detection = await detectCaptcha(cdpPort);
  }

  if (!detection.type || !detection.sitekey) {
    throw new Error('No CAPTCHA detected on page. Detection result: ' + JSON.stringify(detection));
  }

  log(`Found ${detection.type} with sitekey ${detection.sitekey.substring(0, 20)}...`);

  // Step 2: Start relay server
  log('Starting relay server...');
  const relay = await createRelayServer({
    type: detection.type,
    sitekey: detection.sitekey,
    pageUrl: detection.pageUrl,
    timeout,
  });

  log(`Relay server on port ${relay.port}`);

  // Step 3: Get public URL
  let tunnel = null;
  let url;
  if (useTunnel) {
    log('Starting tunnel...');
    tunnel = await startTunnel(relay.port);
    url = tunnel.url;
    log(`Public URL: ${url}`);
  } else {
    const tsIp = getTailscaleIp();
    const ip = tsIp || getLocalIp();
    url = `http://${ip}:${relay.port}`;
    log(`${tsIp ? 'Tailscale' : 'Local'} URL: ${url}`);
  }

  // Step 4: Output URL for notification (caller sends to Telegram)
  const result = {
    type: detection.type,
    sitekey: detection.sitekey,
    pageUrl: detection.pageUrl,
    relayUrl: url,
    port: relay.port,
    isLocal: tunnel ? tunnel.isLocal : true,
  };

  // Output URL immediately so caller can send notification
  console.log(JSON.stringify({ event: 'ready', ...result }));

  // Step 5: Wait for token
  log('Waiting for human to solve CAPTCHA...');
  const token = await relay.waitForToken();

  if (!token) {
    stopTunnel(tunnel);
    throw new Error('CAPTCHA solving timed out');
  }

  log(`Token received (${token.length} chars)`);

  // Step 6: Inject token
  if (inject) {
    log('Injecting token...');
    const injectResult = await injectToken({
      type: detection.type,
      token,
      cdpPort,
    });
    log(`Injection result: ${injectResult}`);
    result.injected = true;
    result.injectionMethod = injectResult;
  }

  result.token = token;
  result.solved = true;

  // Cleanup
  stopTunnel(tunnel);

  console.log(JSON.stringify({ event: 'solved', ...result }));
  return result;
}

/**
 * Screenshot-based fallback for when token relay can't work
 */
async function solveCaptchaScreenshot(opts = {}) {
  const { cdpPort = 18800 } = opts;
  const log = (msg) => process.stderr.write(`[captcha-relay] ${msg}\n`);

  log('Using screenshot fallback...');
  const capture = await captureAndAnnotate(cdpPort);

  console.log(JSON.stringify({
    event: 'screenshot-ready',
    imagePath: capture.imagePath,
    prompt: capture.prompt,
    rows: capture.rows,
    cols: capture.cols,
    totalCells: capture.totalCells,
  }));

  return capture;
}

// CLI mode
if (require.main === module) {
  const args = process.argv.slice(2);
  const getArg = (name, def) => {
    const i = args.indexOf(name);
    return i >= 0 ? args[i + 1] : def;
  };
  const hasFlag = (name) => args.includes(name);

  // Determine mode: --mode screenshot|relay, --screenshot as alias
  const explicitMode = getArg('--mode', null);
  const useScreenshot = hasFlag('--screenshot');
  const mode = explicitMode || (useScreenshot ? 'screenshot' : 'screenshot'); // default: screenshot

  const opts = {
    cdpPort: parseInt(getArg('--cdp-port', '18800')),
    timeout: parseInt(getArg('--timeout', '120')) * 1000,
    inject: !hasFlag('--no-inject'),
    useTunnel: !hasFlag('--no-tunnel'),
  };

  const run = mode === 'relay' ? solveCaptcha : solveCaptchaScreenshot;

  run(opts).catch(e => {
    console.error(JSON.stringify({ event: 'error', error: e.message }));
    process.exit(1);
  });
}

module.exports = { solveCaptcha, solveCaptchaScreenshot, injectGridClicks };


path: lib/browser-relay.js
content: /**
 * Browser Relay — streams a headless Chrome tab over WebSocket
 * so a human can see and interact with it remotely (via Tailscale/LAN).
 *
 * Uses CDP Page.startScreencast for frames + Input.dispatch* for interaction.
 */
const http = require('http');
const fs = require('fs');
const path = require('path');
const WebSocket = require('ws');
const { getPageTargets } = require('./cdp');

const TEMPLATE = path.join(__dirname, 'templates', 'browser-relay.html');

/** Minimal CDP connection that supports both commands and events */
class CdpConnection {
  constructor(wsUrl) {
    this.wsUrl = wsUrl;
    this.ws = null;
    this.msgId = 0;
    this.pending = new Map();
    this.onEvent = null; // callback for CDP events
  }

  connect() {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(this.wsUrl, { perMessageDeflate: false });
      this.ws.on('open', resolve);
      this.ws.on('message', (raw) => {
        const msg = JSON.parse(raw);
        if (msg.id && this.pending.has(msg.id)) {
          const { resolve, reject } = this.pending.get(msg.id);
          this.pending.delete(msg.id);
          if (msg.error) reject(new Error(msg.error.message));
          else resolve(msg.result);
        } else if (msg.method && this.onEvent) {
          this.onEvent(msg.method, msg.params);
        }
      });
      this.ws.on('error', reject);
    });
  }

  send(method, params = {}) {
    return new Promise((resolve, reject) => {
      const id = ++this.msgId;
      this.pending.set(id, { resolve, reject });
      this.ws.send(JSON.stringify({ id, method, params }));
      setTimeout(() => {
        if (this.pending.has(id)) {
          this.pending.delete(id);
          reject(new Error('CDP timeout'));
        }
      }, 15000);
    });
  }

  close() {
    if (this.ws) this.ws.close();
  }
}

async function createBrowserRelay({
  cdpPort = 18800,
  targetId,
  port = 0,
  host = '0.0.0.0',
  timeout = 300000,
  quality = 60,
  maxWidth = 1280,
  maxHeight = 900,
  everyNthFrame = 1,
} = {}) {
  // Find the target page
  if (!targetId) {
    const pages = await getPageTargets(cdpPort);
    if (!pages.length) throw new Error('No browser pages found');
    targetId = pages[0].id;
  }

  const wsUrl = `ws://127.0.0.1:${cdpPort}/devtools/page/${targetId}`;
  const cdp = new CdpConnection(wsUrl);
  await cdp.connect();
  console.log('[browser-relay] CDP connected to', targetId);

  // Get viewport size
  const layoutMetrics = await cdp.send('Page.getLayoutMetrics').catch(() => null);
  const viewportWidth = layoutMetrics?.cssVisualViewport?.clientWidth || 1280;
  const viewportHeight = layoutMetrics?.cssVisualViewport?.clientHeight || 800;
  console.log(`[browser-relay] viewport: ${viewportWidth}x${viewportHeight}`);

  const html = fs.readFileSync(TEMPLATE, 'utf-8')
    .replace('{{VIEWPORT_WIDTH}}', viewportWidth)
    .replace('{{VIEWPORT_HEIGHT}}', viewportHeight);

  // Track connected clients
  const clients = new Set();
  let frameCount = 0;

  // Handle CDP events (screencast frames)
  cdp.onEvent = (method, params) => {
    if (method === 'Page.screencastFrame') {
      frameCount++;
      const { data, sessionId, metadata } = params;
      // Ack the frame so Chrome sends the next one
      cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
      // Broadcast to all connected clients
      const payload = JSON.stringify({ type: 'frame', data, metadata });
      for (const ws of clients) {
        if (ws.readyState === WebSocket.OPEN) {
          ws.send(payload);
        }
      }
      if (frameCount % 50 === 1) {
        console.log(`[browser-relay] frame #${frameCount}, clients: ${clients.size}`);
      }
    }
  };

  // Enable Page domain and start screencast
  await cdp.send('Page.enable');
  await cdp.send('Page.startScreencast', {
    format: 'jpeg',
    quality,
    maxWidth,
    maxHeight,
    everyNthFrame,
  });
  console.log('[browser-relay] screencast started');

  // HTTP server
  const server = http.createServer((req, res) => {
    if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(html);
    } else {
      res.writeHead(404);
      res.end('Not found');
    }
  });

  // WebSocket server for client interaction
  const wss = new WebSocket.Server({ server });

  wss.on('connection', async (ws) => {
    clients.add(ws);
    console.log(`[browser-relay] client connected (${clients.size} total)`);
    // Restart screencast so frames flow to new clients
    try {
      await cdp.send('Page.stopScreencast').catch(() => {});
      await cdp.send('Page.startScreencast', {
        format: 'jpeg', quality, maxWidth, maxHeight, everyNthFrame,
      });
      console.log('[browser-relay] screencast restarted for new client');
    } catch (e) {
      console.error('[browser-relay] screencast restart failed:', e.message);
    }

    ws.on('message', async (raw) => {
      try {
        const msg = JSON.parse(raw);
        await handleInput(cdp, msg, viewportWidth, viewportHeight);
      } catch (e) {
        console.error('[browser-relay] input error:', e.message);
      }
    });

    ws.on('close', () => {
      clients.delete(ws);
      console.log(`[browser-relay] client disconnected (${clients.size} total)`);
    });
  });

  // Timeout
  let timer;
  const cleanup = () => {
    clearTimeout(timer);
    cdp.send('Page.stopScreencast').catch(() => {});
    cdp.close();
    wss.close();
    server.close();
    console.log('[browser-relay] shut down');
  };

  if (timeout > 0) {
    timer = setTimeout(cleanup, timeout);
  }

  return new Promise((resolve, reject) => {
    server.listen(port, host, () => {
      const actualPort = server.address().port;
      console.log(`[browser-relay] listening on ${host}:${actualPort}`);
      resolve({ port: actualPort, url: `http://${host}:${actualPort}`, cleanup, clients });
    });
    server.on('error', reject);
  });
}

async function handleInput(cdp, msg, viewportWidth, viewportHeight) {
  console.log(`[browser-relay] input: ${msg.type}`, msg.x !== undefined ? `(${msg.x},${msg.y})` : '');
  switch (msg.type) {
    case 'mousedown':
    case 'mouseup':
    case 'mousemove':
      await cdp.send('Input.dispatchMouseEvent', {
        type: msg.type === 'mousemove' ? 'mouseMoved' :
              msg.type === 'mousedown' ? 'mousePressed' : 'mouseReleased',
        x: msg.x,
        y: msg.y,
        button: msg.button || 'left',
        clickCount: msg.clickCount || (msg.type === 'mousedown' ? 1 : 0),
      });
      break;

    case 'click':
      await cdp.send('Input.dispatchMouseEvent', {
        type: 'mousePressed', x: msg.x, y: msg.y, button: 'left', clickCount: 1,
      });
      await cdp.send('Input.dispatchMouseEvent', {
        type: 'mouseReleased', x: msg.x, y: msg.y, button: 'left', clickCount: 1,
      });
      break;

    case 'keydown':
    case 'keyup': {
      const cdpType = msg.type === 'keydown' ? 'keyDown' : 'keyUp';
      const params = {
        type: cdpType,
        key: msg.key,
        code: msg.code || '',
        windowsVirtualKeyCode: msg.keyCode || 0,
        nativeVirtualKeyCode: msg.keyCode || 0,
      };
      if (msg.text && msg.type === 'keydown') {
        params.text = msg.text;
      }
      await cdp.send('Input.dispatchKeyEvent', params);
      break;
    }

    case 'scroll':
      await cdp.send('Input.dispatchMouseEvent', {
        type: 'mouseWheel',
        x: msg.x || viewportWidth / 2,
        y: msg.y || viewportHeight / 2,
        deltaX: msg.deltaX || 0,
        deltaY: msg.deltaY || 0,
      });
      break;

    case 'touch':
      await cdp.send('Input.dispatchMouseEvent', {
        type: msg.action === 'start' ? 'mousePressed' :
              msg.action === 'end' ? 'mouseReleased' : 'mouseMoved',
        x: msg.x,
        y: msg.y,
        button: 'left',
        clickCount: msg.action === 'start' ? 1 : 0,
      });
      break;
  }
}

module.exports = { createBrowserRelay };


path: lib/cdp.js
content: /**
 * Minimal CDP (Chrome DevTools Protocol) client
 */
const WebSocket = require('ws');
const http = require('http');

let msgId = 0;

function getCdpWsUrl(port = 18800) {
  return new Promise((resolve, reject) => {
    http.get(`http://127.0.0.1:${port}/json/version`, (res) => {
      let data = '';
      res.on('data', c => data += c);
      res.on('end', () => {
        try {
          resolve(JSON.parse(data).webSocketDebuggerUrl);
        } catch (e) { reject(e); }
      });
    }).on('error', reject);
  });
}

function getPageTargets(port = 18800) {
  return new Promise((resolve, reject) => {
    http.get(`http://127.0.0.1:${port}/json/list`, (res) => {
      let data = '';
      res.on('data', c => data += c);
      res.on('end', () => {
        try {
          const targets = JSON.parse(data).filter(t => t.type === 'page');
          resolve(targets);
        } catch (e) { reject(e); }
      });
    }).on('error', reject);
  });
}

function cdpSend(wsUrl, method, params = {}) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(wsUrl, { perMessageDeflate: false });
    const id = ++msgId;
    ws.on('open', () => {
      ws.send(JSON.stringify({ id, method, params }));
    });
    ws.on('message', (data) => {
      const msg = JSON.parse(data);
      if (msg.id === id) {
        ws.close();
        if (msg.error) reject(new Error(msg.error.message));
        else resolve(msg.result);
      }
    });
    ws.on('error', reject);
    setTimeout(() => { ws.close(); reject(new Error('CDP timeout')); }, 10000);
  });
}

/**
 * Long-lived CDP session for multiple commands
 */
class CdpSession {
  constructor(wsUrl) {
    this.wsUrl = wsUrl;
    this.ws = null;
    this.pending = new Map();
  }

  connect() {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(this.wsUrl, { perMessageDeflate: false });
      this.ws.on('open', resolve);
      this.ws.on('message', (data) => {
        const msg = JSON.parse(data);
        if (msg.id && this.pending.has(msg.id)) {
          const { resolve, reject } = this.pending.get(msg.id);
          this.pending.delete(msg.id);
          if (msg.error) reject(new Error(msg.error.message));
          else resolve(msg.result);
        }
      });
      this.ws.on('error', reject);
    });
  }

  send(method, params = {}) {
    return new Promise((resolve, reject) => {
      const id = ++msgId;
      this.pending.set(id, { resolve, reject });
      this.ws.send(JSON.stringify({ id, method, params }));
      setTimeout(() => {
        if (this.pending.has(id)) {
          this.pending.delete(id);
          reject(new Error('CDP timeout'));
        }
      }, 15000);
    });
  }

  close() {
    if (this.ws) this.ws.close();
  }
}

module.exports = { getCdpWsUrl, getPageTargets, cdpSend, CdpSession };


path: lib/detect.js
content: /**
 * CAPTCHA detection and sitekey extraction via CDP
 */
const { cdpSend, getCdpWsUrl, getPageTargets } = require('./cdp');

const DETECTION_SCRIPT = `
(() => {
  const result = { type: null, sitekey: null, pageUrl: location.href };

  // reCAPTCHA v2
  const rc = document.querySelector('.g-recaptcha[data-sitekey]');
  if (rc) {
    result.type = 'recaptcha-v2';
    result.sitekey = rc.dataset.sitekey;
    return result;
  }

  // reCAPTCHA via iframe
  const rcIframe = document.querySelector('iframe[src*="recaptcha/api2/anchor"]');
  if (rcIframe) {
    const m = rcIframe.src.match(/[?&]k=([^&]+)/);
    if (m) {
      result.type = 'recaptcha-v2';
      result.sitekey = m[1];
      return result;
    }
  }

  // hCaptcha
  const hc = document.querySelector('.h-captcha[data-sitekey]');
  if (hc) {
    result.type = 'hcaptcha';
    result.sitekey = hc.dataset.sitekey;
    return result;
  }

  // hCaptcha via iframe
  const hcIframe = document.querySelector('iframe[src*="hcaptcha.com"]');
  if (hcIframe) {
    const m = hcIframe.src.match(/sitekey=([^&]+)/);
    if (m) {
      result.type = 'hcaptcha';
      result.sitekey = m[1];
      return result;
    }
  }

  // Turnstile
  const ts = document.querySelector('.cf-turnstile[data-sitekey]');
  if (ts) {
    result.type = 'turnstile';
    result.sitekey = ts.dataset.sitekey;
    return result;
  }

  // Turnstile via iframe
  const tsIframe = document.querySelector('iframe[src*="challenges.cloudflare.com"]');
  if (tsIframe) {
    const m = tsIframe.src.match(/[?&]k=([^&]+)/);
    if (m) {
      result.type = 'turnstile';
      result.sitekey = m[1];
      return result;
    }
  }

  // Generic data-sitekey
  const generic = document.querySelector('[data-sitekey]');
  if (generic) {
    result.sitekey = generic.dataset.sitekey;
    // Try to guess type from scripts loaded
    if (document.querySelector('script[src*="recaptcha"]')) result.type = 'recaptcha-v2';
    else if (document.querySelector('script[src*="hcaptcha"]')) result.type = 'hcaptcha';
    else if (document.querySelector('script[src*="turnstile"]')) result.type = 'turnstile';
    else result.type = 'unknown';
    return result;
  }

  return result;
})()
`;

async function detectCaptcha(cdpPort = 18800, targetId) {
  const targets = await getPageTargets(cdpPort);
  let target;
  if (targetId) {
    target = targets.find(t => t.id === targetId);
  }
  if (!target) {
    // Pick the first non-newtab page
    target = targets.find(t => !t.url.startsWith('chrome://')) || targets[0];
  }
  if (!target) throw new Error('No page targets found');

  const wsUrl = target.webSocketDebuggerUrl;
  const result = await cdpSend(wsUrl, 'Runtime.evaluate', {
    expression: DETECTION_SCRIPT,
    returnByValue: true,
  });
  return result.result.value;
}

module.exports = { detectCaptcha };


path: lib/inject.js
content: /**
 * Inject CAPTCHA token back into the automated browser via CDP
 */
const { CdpSession, getCdpWsUrl, getPageTargets } = require('./cdp');

const INJECTION_SCRIPTS = {
  'recaptcha-v2': (token) => `
    (() => {
      // Set the textarea value
      const ta = document.querySelector('#g-recaptcha-response') ||
                 document.querySelector('[name="g-recaptcha-response"]');
      if (ta) {
        ta.value = ${JSON.stringify(token)};
        ta.style.display = 'block'; // make visible temporarily for events
        ta.dispatchEvent(new Event('input', { bubbles: true }));
        ta.style.display = '';
      }

      // Try to call the registered callback
      try {
        // Method 1: data-callback attribute
        const widget = document.querySelector('.g-recaptcha');
        if (widget && widget.dataset.callback) {
          window[widget.dataset.callback](${JSON.stringify(token)});
          return 'callback-attr';
        }
      } catch(e) {}

      try {
        // Method 2: grecaptcha internal
        if (typeof ___grecaptcha_cfg !== 'undefined') {
          const clients = ___grecaptcha_cfg.clients;
          for (const key of Object.keys(clients)) {
            const client = clients[key];
            // Walk the object tree to find callback
            const walk = (obj, depth) => {
              if (depth > 4 || !obj) return;
              for (const k of Object.keys(obj)) {
                if (typeof obj[k] === 'function' && k === 'callback') {
                  obj[k](${JSON.stringify(token)});
                  return true;
                }
                if (typeof obj[k] === 'object') {
                  if (walk(obj[k], depth + 1)) return true;
                }
              }
            };
            walk(client, 0);
          }
          return 'internal-callback';
        }
      } catch(e) {}

      // Method 3: grecaptcha.execute style
      try {
        if (typeof grecaptcha !== 'undefined' && grecaptcha.getResponse) {
          return 'grecaptcha-available';
        }
      } catch(e) {}

      return 'textarea-only';
    })()
  `,

  'hcaptcha': (token) => `
    (() => {
      const ta = document.querySelector('[name="h-captcha-response"]') ||
                 document.querySelector('textarea[name="h-captcha-response"]');
      if (ta) ta.value = ${JSON.stringify(token)};

      // Set iframe response too
      const iframes = document.querySelectorAll('iframe[data-hcaptcha-response]');
      iframes.forEach(f => f.setAttribute('data-hcaptcha-response', ${JSON.stringify(token)}));

      // Call hcaptcha callback
      try {
        const widget = document.querySelector('.h-captcha');
        if (widget && widget.dataset.callback) {
          window[widget.dataset.callback](${JSON.stringify(token)});
          return 'callback';
        }
      } catch(e) {}

      // Try hcaptcha internal
      try {
        if (typeof hcaptcha !== 'undefined') {
          // Trigger via internal event system
          const evt = new CustomEvent('hcaptcha-success', { detail: { [REDACTED_SECRET])} } });
          window.dispatchEvent(evt);
          return 'event-dispatched';
        }
      } catch(e) {}

      return 'textarea-only';
    })()
  `,

  'turnstile': (token) => `
    (() => {
      const ta = document.querySelector('[name="cf-turnstile-response"]') ||
                 document.querySelector('input[name="cf-turnstile-response"]');
      if (ta) ta.value = ${JSON.stringify(token)};

      // Try turnstile callback
      try {
        const widget = document.querySelector('.cf-turnstile');
        if (widget && widget.dataset.callback) {
          window[widget.dataset.callback](${JSON.stringify(token)});
          return 'callback';
        }
      } catch(e) {}

      return 'textarea-only';
    })()
  `,
};

async function injectToken({ type, token, cdpPort = 18800 }) {
  // Use page target WS URL (not browser-level) for Runtime.evaluate
  const targets = await getPageTargets(cdpPort);
  const target = targets.find(t => !t.url.startsWith('chrome://')) || targets[0];
  if (!target) throw new Error('No page targets found for injection');
  const wsUrl = target.webSocketDebuggerUrl;
  const session = new CdpSession(wsUrl);
  await session.connect();

  try {
    const scriptFn = INJECTION_SCRIPTS[type];
    if (!scriptFn) throw new Error(`No injection script for type: ${type}`);

    const result = await session.send('Runtime.evaluate', {
      expression: scriptFn(token),
      returnByValue: true,
    });

    return result.result.value;
  } finally {
    session.close();
  }
}

module.exports = { injectToken };


path: lib/server.js
content: /**
 * Lightweight HTTP relay server for CAPTCHA solving
 */
const http = require('http');
const fs = require('fs');
const path = require('path');

function createRelayServer({ type, sitekey, pageUrl, timeout = 120000 }) {
  return new Promise((resolve, reject) => {
    let tokenResolve;
    const tokenPromise = new Promise(r => { tokenResolve = r; });
    let server;
    let timer;

    const templateFile = path.join(__dirname, 'templates', `${type}.html`);
    let template;
    try {
      template = fs.readFileSync(templateFile, 'utf-8');
    } catch {
      reject(new Error(`No template for CAPTCHA type: ${type}`));
      return;
    }

    const html = template
      .replace(/\{\{SITEKEY\}\}/g, sitekey)
      .replace(/\{\{PAGE_URL\}\}/g, pageUrl || '');

    server = http.createServer((req, res) => {
      if (req.method === 'GET' && req.url === '/') {
        res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' });
        res.end(html);
      } else if (req.method === 'POST' && req.url === '/token') {
        let body = '';
        req.on('data', c => body += c);
        req.on('end', () => {
          try {
            const { token } = JSON.parse(body);
            res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
            res.end(JSON.stringify({ ok: true }));
            // Write token to predictable file for external consumers
            const tokenFile = path.join(require('os').tmpdir(), 'captcha-relay-token.txt');
            fs.writeFileSync(tokenFile, token);
            tokenResolve(token);
          } catch {
            res.writeHead(400);
            res.end('Bad request');
          }
        });
      } else if (req.method === 'OPTIONS') {
        res.writeHead(200, {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type',
        });
        res.end();
      } else {
        res.writeHead(404);
        res.end('Not found');
      }
    });

    server.listen(0, '0.0.0.0', () => {
      const port = server.address().port;
      timer = setTimeout(() => {
        server.close();
        tokenResolve(null); // timeout
      }, timeout);

      resolve({
        port,
        waitForToken: async () => {
          const token = await tokenPromise;
          clearTimeout(timer);
          server.close();
          return token;
        },
        close: () => {
          clearTimeout(timer);
          server.close();
        },
      });
    });

    server.on('error', reject);
  });
}

module.exports = { createRelayServer };


path: lib/templates/browser-relay.html
content: <!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Browser Relay</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    background: #111;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 100vh;
    overflow: hidden;
    font-family: -apple-system, sans-serif;
    color: #fff;
  }
  #status {
    position: fixed;
    top: 8px;
    left: 50%;
    transform: translateX(-50%);
    padding: 4px 12px;
    border-radius: 12px;
    font-size: 12px;
    background: rgba(0,0,0,0.7);
    z-index: 100;
    transition: opacity 0.3s;
  }
  #status.connected { background: rgba(0,128,0,0.5); }
  #status.error { background: rgba(200,0,0,0.7); }
  #container {
    position: relative;
    display: inline-block;
    max-width: 100vw;
    max-height: calc(100vh - 40px);
  }
  #screen {
    display: block;
    max-width: 100vw;
    max-height: calc(100vh - 40px);
    cursor: default;
    /* Prevent iOS double-tap zoom */
    touch-action: manipulation;
  }
  /* Touch ripple */
  .ripple {
    position: absolute;
    width: 30px;
    height: 30px;
    border-radius: 50%;
    border: 2px solid rgba(0, 150, 255, 0.6);
    pointer-events: none;
    animation: ripple-out 0.4s ease-out forwards;
  }
  @keyframes ripple-out {
    0% { transform: scale(0.5); opacity: 1; }
    100% { transform: scale(2); opacity: 0; }
  }
  /* Keyboard */
  #keyboard-toggle {
    position: fixed;
    bottom: 12px;
    right: 12px;
    width: 44px;
    height: 44px;
    border-radius: 22px;
    background: rgba(255,255,255,0.15);
    border: 1px solid rgba(255,255,255,0.3);
    color: #fff;
    font-size: 20px;
    cursor: pointer;
    z-index: 100;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  #hidden-input {
    position: fixed;
    top: -100px;
    left: -100px;
    opacity: 0;
    width: 1px;
    height: 1px;
  }
</style>
</head>
<body>
  <div id="status">Connecting…</div>
  <div id="container">
    <img id="screen" alt="Browser view">
  </div>
  <button id="keyboard-toggle">⌨</button>
  <input id="hidden-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off">

<script>
const VIEWPORT_W = {{VIEWPORT_WIDTH}};
const VIEWPORT_H = {{VIEWPORT_HEIGHT}};

const screen = document.getElementById('screen');
const status = document.getElementById('status');
const container = document.getElementById('container');
const kbToggle = document.getElementById('keyboard-toggle');
const hiddenInput = document.getElementById('hidden-input');

let ws;
let reconnectTimer;

function connect() {
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
  ws = new WebSocket(`${proto}//${location.host}`);

  ws.onopen = () => {
    status.textContent = '🟢 Connected';
    status.className = 'connected';
    setTimeout(() => { status.style.opacity = '0.3'; }, 2000);
  };

  ws.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    if (msg.type === 'frame') {
      screen.src = 'data:image/jpeg;base64,' + msg.data;
    }
  };

  ws.onclose = () => {
    status.textContent = '🔴 Disconnected — reconnecting…';
    status.className = 'error';
    status.style.opacity = '1';
    reconnectTimer = setTimeout(connect, 2000);
  };

  ws.onerror = () => ws.close();
}

// Map screen coordinates to viewport coordinates
function mapCoords(clientX, clientY) {
  const rect = screen.getBoundingClientRect();
  const scaleX = VIEWPORT_W / rect.width;
  const scaleY = VIEWPORT_H / rect.height;
  return {
    x: Math.round((clientX - rect.left) * scaleX),
    y: Math.round((clientY - rect.top) * scaleY),
  };
}

function send(obj) {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(obj));
  }
}

// Mouse events (desktop)
screen.addEventListener('mousedown', (e) => {
  e.preventDefault();
  const { x, y } = mapCoords(e.clientX, e.clientY);
  send({ type: 'mousedown', x, y, button: 'left' });
});

screen.addEventListener('mouseup', (e) => {
  e.preventDefault();
  const { x, y } = mapCoords(e.clientX, e.clientY);
  send({ type: 'mouseup', x, y, button: 'left' });
});

screen.addEventListener('mousemove', (e) => {
  if (e.buttons === 0) return; // only when pressed
  const { x, y } = mapCoords(e.clientX, e.clientY);
  send({ type: 'mousemove', x, y });
});

// Touch events (mobile)
let touchStart = null;
screen.addEventListener('touchstart', (e) => {
  e.preventDefault();
  const t = e.touches[0];
  const { x, y } = mapCoords(t.clientX, t.clientY);
  touchStart = { x, y, time: Date.now(), clientX: t.clientX, clientY: t.clientY };
  send({ type: 'touch', action: 'start', x, y });
  // Visual ripple
  showRipple(t.clientX, t.clientY);
}, { passive: false });

screen.addEventListener('touchend', (e) => {
  e.preventDefault();
  if (touchStart) {
    send({ type: 'touch', action: 'end', x: touchStart.x, y: touchStart.y });
    touchStart = null;
  }
}, { passive: false });

screen.addEventListener('touchmove', (e) => {
  e.preventDefault();
  const t = e.touches[0];
  const { x, y } = mapCoords(t.clientX, t.clientY);
  // Detect scroll gesture
  if (touchStart) {
    const dy = t.clientY - touchStart.clientY;
    if (Math.abs(dy) > 10) {
      send({ type: 'scroll', x: touchStart.x, y: touchStart.y, deltaX: 0, deltaY: -dy * 2 });
      touchStart.clientY = t.clientY;
    }
  }
}, { passive: false });

// Wheel (desktop)
screen.addEventListener('wheel', (e) => {
  e.preventDefault();
  const { x, y } = mapCoords(e.clientX, e.clientY);
  send({ type: 'scroll', x, y, deltaX: e.deltaX, deltaY: e.deltaY });
}, { passive: false });

// Keyboard
document.addEventListener('keydown', (e) => {
  if (e.target === hiddenInput || e.target === document.body) {
    // Don't prevent Tab, but prevent most others from doing browser things
    if (!['F5', 'F12'].includes(e.key)) {
      e.preventDefault();
    }
    send({
      type: 'keydown',
      key: e.key,
      code: e.code,
      keyCode: e.keyCode,
      text: e.key.length === 1 ? e.key : undefined,
    });
  }
});

document.addEventListener('keyup', (e) => {
  if (e.target === hiddenInput || e.target === document.body) {
    send({
      type: 'keyup',
      key: e.key,
      code: e.code,
      keyCode: e.keyCode,
    });
  }
});

// Mobile keyboard toggle
kbToggle.addEventListener('click', () => {
  hiddenInput.focus();
});

hiddenInput.addEventListener('input', (e) => {
  const val = hiddenInput.value;
  if (val) {
    // Send each character
    for (const ch of val) {
      send({ type: 'keydown', key: ch, code: '', keyCode: ch.charCodeAt(0), text: ch });
      send({ type: 'keyup', key: ch, code: '', keyCode: ch.charCodeAt(0) });
    }
    hiddenInput.value = '';
  }
});

// Handle special keys from mobile keyboard
hiddenInput.addEventListener('keydown', (e) => {
  if (['Backspace', 'Enter', 'Tab'].includes(e.key)) {
    e.preventDefault();
    send({ type: 'keydown', key: e.key, code: e.code, keyCode: e.keyCode, text: e.key === 'Enter' ? '\r' : undefined });
    send({ type: 'keyup', key: e.key, code: e.code, keyCode: e.keyCode });
  }
});

function showRipple(cx, cy) {
  const r = document.createElement('div');
  r.className = 'ripple';
  const rect = container.getBoundingClientRect();
  r.style.left = (cx - rect.left - 15) + 'px';
  r.style.top = (cy - rect.top - 15) + 'px';
  container.appendChild(r);
  setTimeout(() => r.remove(), 400);
}

// Prevent context menu on long press
screen.addEventListener('contextmenu', e => e.preventDefault());

connect();
</script>
</body>
</html>


path: lib/templates/hcaptcha.html
content: <!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Solve CAPTCHA</title>
  <script src="https://js.hcaptcha.com/1/api.js" async defer></script>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, system-ui, sans-serif; background: #1a1a2e; color: #eee; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
    h2 { margin-bottom: 20px; font-size: 1.2em; }
    .status { margin-top: 20px; padding: 12px 24px; border-radius: 8px; font-size: 1em; }
    .status.waiting { background: #2d2d44; }
    .status.success { background: #1b4332; color: #95d5b2; }
    .status.error { background: #442d2d; color: #d59595; }
  </style>
</head>
<body>
  <h2>🔐 Solve the CAPTCHA</h2>
  <div class="h-captcha" data-sitekey="{{SITEKEY}}" data-callback="onSolved"></div>
  <div class="status waiting" id="status">Waiting for you to solve...</div>

  <script>
    function onSolved(token) {
      document.getElementById('status').className = 'status waiting';
      document.getElementById('status').textContent = 'Sending token...';
      fetch('/token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token })
      }).then(r => r.json()).then(d => {
        if (d.ok) {
          document.getElementById('status').className = 'status success';
          document.getElementById('status').textContent = '✅ Done! You can close this page.';
        }
      }).catch(() => {
        document.getElementById('status').className = 'status error';
        document.getElementById('status').textContent = '❌ Failed to send token. Try again.';
      });
    }
  </script>
</body>
</html>


path: lib/templates/recaptcha-v2.html
content: <!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Solve CAPTCHA</title>
  <script src="https://www.google.com/recaptcha/api.js" async defer></script>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, system-ui, sans-serif; background: #1a1a2e; color: #eee; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
    h2 { margin-bottom: 20px; font-size: 1.2em; }
    .status { margin-top: 20px; padding: 12px 24px; border-radius: 8px; font-size: 1em; }
    .status.waiting { background: #2d2d44; }
    .status.success { background: #1b4332; color: #95d5b2; }
    .status.error { background: #442d2d; color: #d59595; }
    .g-recaptcha { transform: scale(1.1); transform-origin: center; }
  </style>
</head>
<body>
  <h2>🔐 Solve the CAPTCHA</h2>
  <div class="g-recaptcha" data-sitekey="{{SITEKEY}}" data-callback="onSolved"></div>
  <div class="status waiting" id="status">Waiting for you to solve...</div>

  <script>
    function onSolved(token) {
      document.getElementById('status').className = 'status waiting';
      document.getElementById('status').textContent = 'Sending token...';
      fetch('/token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token })
      }).then(r => r.json()).then(d => {
        if (d.ok) {
          document.getElementById('status').className = 'status success';
          document.getElementById('status').textContent = '✅ Done! You can close this page.';
        }
      }).catch(() => {
        document.getElementById('status').className = 'status error';
        document.getElementById('status').textContent = '❌ Failed to send token. Try again.';
      });
    }
  </script>
</body>
</html>


path: lib/templates/turnstile.html
content: <!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Solve CAPTCHA</title>
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, system-ui, sans-serif; background: #1a1a2e; color: #eee; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
    h2 { margin-bottom: 20px; font-size: 1.2em; }
    .status { margin-top: 20px; padding: 12px 24px; border-radius: 8px; font-size: 1em; }
    .status.waiting { background: #2d2d44; }
    .status.success { background: #1b4332; color: #95d5b2; }
    .status.error { background: #442d2d; color: #d59595; }
  </style>
</head>
<body>
  <h2>🔐 Solve the CAPTCHA</h2>
  <div class="cf-turnstile" data-sitekey="{{SITEKEY}}" data-callback="onSolved"></div>
  <div class="status waiting" id="status">Waiting for you to solve...</div>

  <script>
    function onSolved(token) {
      document.getElementById('status').className = 'status waiting';
      document.getElementById('status').textContent = 'Sending token...';
      fetch('/token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token })
      }).then(r => r.json()).then(d => {
        if (d.ok) {
          document.getElementById('status').className = 'status success';
          document.getElementById('status').textContent = '✅ Done! You can close this page.';
        }
      }).catch(() => {
        document.getElementById('status').className = 'status error';
        document.getElementById('status').textContent = '❌ Failed to send token. Try again.';
      });
    }
  </script>
</body>
</html>


path: lib/tunnel.js
content: /**
 * Tunnel management for public URL access
 *
 * Strategies (in order):
 *   1. localtunnel (via npx) — free, no install, works well
 *   2. cloudflared — Cloudflare's tunnel, heavier binary
 *   3. Local IP fallback — LAN only
 *
 * NOTE: For production, Tailscale is recommended. See TAILSCALE.md.
 * With Tailscale, no tunnel is needed — the relay server is directly
 * accessible via Tailscale IP from any device on the same Tailnet.
 */
const { spawn } = require('child_process');
const os = require('os');

/**
 * Start a tunnel to expose localPort publicly.
 * @param {number} localPort
 * @param {object} opts
 * @param {string} opts.method - 'localtunnel' | 'cloudflared' | 'auto' (default: 'auto')
 */
function startTunnel(localPort, opts = {}) {
  const method = opts.method || 'auto';

  if (method === 'cloudflared') return startCloudflared(localPort);
  if (method === 'localtunnel') return startLocaltunnel(localPort);

  // Auto: try localtunnel first, fall back to cloudflared, then local IP
  return startLocaltunnel(localPort).catch(() =>
    startCloudflared(localPort)
  );
}

function startLocaltunnel(localPort) {
  return new Promise((resolve, reject) => {
    const proc = spawn('npx', ['localtunnel', '--port', String(localPort)], {
      stdio: ['ignore', 'pipe', 'pipe'],
    });

    let resolved = false;
    const timer = setTimeout(() => {
      if (!resolved) {
        resolved = true;
        proc.kill();
        reject(new Error('localtunnel timeout'));
      }
    }, 30000);

    function checkOutput(data) {
      const text = data.toString();
      const match = text.match(/(https:\/\/[a-z0-9-]+\.loca\.lt)/);
      if (match && !resolved) {
        resolved = true;
        clearTimeout(timer);
        resolve({ url: match[1], process: proc, isLocal: false, method: 'localtunnel' });
      }
    }

    proc.stdout.on('data', checkOutput);
    proc.stderr.on('data', checkOutput);

    proc.on('error', () => {
      if (!resolved) {
        resolved = true;
        clearTimeout(timer);
        reject(new Error('localtunnel not available'));
      }
    });

    proc.on('exit', (code) => {
      if (!resolved) {
        resolved = true;
        clearTimeout(timer);
        reject(new Error(`localtunnel exited with code ${code}`));
      }
    });
  });
}

function startCloudflared(localPort) {
  return new Promise((resolve, reject) => {
    const proc = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${localPort}`], {
      stdio: ['ignore', 'pipe', 'pipe'],
    });

    let resolved = false;
    const timer = setTimeout(() => {
      if (!resolved) {
        resolved = true;
        proc.kill();
        // Final fallback: local IP
        const localUrl = `http://${getLocalIp()}:${localPort}`;
        resolve({ url: localUrl, process: null, isLocal: true, method: 'local' });
      }
    }, 15000);

    function checkOutput(data) {
      const text = data.toString();
      const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
      if (match && !resolved) {
        resolved = true;
        clearTimeout(timer);
        resolve({ url: match[0], process: proc, isLocal: false, method: 'cloudflared' });
      }
    }

    proc.stdout.on('data', checkOutput);
    proc.stderr.on('data', checkOutput);

    proc.on('error', () => {
      if (!resolved) {
        resolved = true;
        clearTimeout(timer);
        const localUrl = `http://${getLocalIp()}:${localPort}`;
        resolve({ url: localUrl, process: null, isLocal: true, method: 'local' });
      }
    });
  });
}

function stopTunnel(tunnel) {
  if (tunnel && tunnel.process) {
    tunnel.process.kill();
  }
}

function getTailscaleIp() {
  try {
    const { execSync } = require('child_process');
    const ip = execSync('tailscale ip -4 2>/dev/null', { timeout: 3000 }).toString().trim();
    if (/^100\./.test(ip)) return ip;
  } catch {}
  return null;
}

function getLocalIp() {
  const nets = os.networkInterfaces();
  for (const name of Object.keys(nets)) {
    for (const net of nets[name]) {
      if (net.family === 'IPv4' && !net.internal) {
        return net.address;
      }
    }
  }
  return '127.0.0.1';
}

module.exports = { startTunnel, stopTunnel, getLocalIp, getTailscaleIp };


path: package-lock.json
content: {
  "name": "captcha-relay",
  "version": "2.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "captcha-relay",
      "version": "2.0.0",
      "dependencies": {
        "sharp": "^0.33.2",
        "ws": "^8.16.0"
      }
    },
    "node_modules/@emnapi/runtime": {
      "version": "1.8.1",
      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "tslib": "^2.4.0"
      }
    },
    "node_modules/@img/sharp-darwin-arm64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-darwin-arm64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-darwin-x64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-darwin-x64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-libvips-darwin-arm64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "darwin"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-darwin-x64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "darwin"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-arm": {
      "version": "1.0.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-arm64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-s390x": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "s390x"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-x64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-linux-arm": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-arm": "1.0.5"
      }
    },
    "node_modules/@img/sharp-linux-arm64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-arm64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-linux-s390x": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "s390x"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-s390x": "1.0.4"
      }
    },
    "node_modules/@img/sharp-linux-x64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-x64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-linuxmusl-arm64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-linuxmusl-x64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-wasm32": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "wasm32"
      ],
      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
      "optional": true,
      "dependencies": {
        "@emnapi/runtime": "^1.2.0"
      },
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-win32-ia32": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "ia32"
      ],
      "license": "Apache-2.0 AND LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-win32-x64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0 AND LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/color": {
      "version": "4.2.3",
      "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "color-convert": "^2.0.1",
        "color-string": "^1.9.0"
      },
      "engines": {
        "node": ">=12.5.0"
      }
    },
    "node_modules/color-convert": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "color-name": "~1.1.4"
      },
      "engines": {
        "node": ">=7.0.0"
      }
    },
    "node_modules/color-name": {
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/color-string": {
      "version": "1.9.1",
      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "color-name": "^1.0.0",
        "simple-swizzle": "^0.2.2"
      }
    },
    "node_modules/detect-libc": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/is-arrayish": {
      "version": "0.3.4",
      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/semver": {
      "version": "7.7.4",
      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "bin": {
        "semver": "bin/semver.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/sharp": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "hasInstallScript": true,
      "license": "Apache-2.0",
      "dependencies": {
        "color": "^4.2.3",
        "detect-libc": "^2.0.3",
        "semver": "^7.6.3"
      },
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-darwin-arm64": "0.33.5",
        "@img/sharp-darwin-x64": "0.33.5",
        "@img/sharp-libvips-darwin-arm64": "1.0.4",
        "@img/sharp-libvips-darwin-x64": "1.0.4",
        "@img/sharp-libvips-linux-arm": "1.0.5",
        "@img/sharp-libvips-linux-arm64": "1.0.4",
        "@img/sharp-libvips-linux-s390x": "1.0.4",
        "@img/sharp-libvips-linux-x64": "1.0.4",
        "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
        "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
        "@img/sharp-linux-arm": "0.33.5",
        "@img/sharp-linux-arm64": "0.33.5",
        "@img/sharp-linux-s390x": "0.33.5",
        "@img/sharp-linux-x64": "0.33.5",
        "@img/sharp-linuxmusl-arm64": "0.33.5",
        "@img/sharp-linuxmusl-x64": "0.33.5",
        "@img/sharp-wasm32": "0.33.5",
        "@img/sharp-win32-ia32": "0.33.5",
        "@img/sharp-win32-x64": "0.33.5"
      }
    },
    "node_modules/simple-swizzle": {
      "version": "0.2.4",
      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "is-arrayish": "^0.3.1"
      }
    },
    "node_modules/tslib": {
      "version": "2.8.1",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "0BSD",
      "optional": true
    },
    "node_modules/ws": {
      "version": "8.19.0",
      "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10.0.0"
      },
      "peerDependencies": {
        "bufferutil": "^4.0.1",
        "utf-8-validate": ">=5.0.2"
      },
      "peerDependenciesMeta": {
        "bufferutil": {
          "optional": true
        },
        "utf-8-validate": {
          "optional": true
        }
      }
    }
  }
}


path: package.json
content: {
  "name": "captcha-relay",
  "version": "2.1.0",
  "description": "Human-in-the-loop CAPTCHA solving — screenshot mode (zero infra) or token relay mode",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "start:relay": "node index.js --mode relay"
  },
  "captchaRelay": {
    "defaultMode": "screenshot",
    "modes": ["screenshot", "relay"]
  },
  "dependencies": {
    "ws": "^8.16.0",
    "sharp": "^0.33.2"
  }
}


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