diff --git a/README.md b/README.md index 723da7ca..12976af7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This plugin includes the following skills (see `skills/` for details): |-------|-------------| | [browser](skills/browser/SKILL.md) | Automate web browser interactions via CLI commands — supports remote Browserbase sessions with anti-bot stealth, CAPTCHA solving, and residential proxies | | [browserbase-cli](skills/browserbase-cli/SKILL.md) | Use the official `bb` CLI for Browserbase Functions and platform API workflows including sessions, projects, contexts, extensions, fetch, and dashboard | +| [browser-tunnel](skills/browser-tunnel/SKILL.md) | Open a Browserbase cloud browser that can reach your `localhost:` via an auth-gated cloudflared tunnel — no ngrok, no public exposure | | [functions](skills/functions/SKILL.md) | Deploy serverless browser automation to Browserbase cloud using the `bb` CLI | | [site-debugger](skills/site-debugger/SKILL.md) | Diagnose and fix failing browser automations — analyzes bot detection, selectors, timing, auth, and captchas, then generates a tested site playbook | | [browser-trace](skills/browser-trace/SKILL.md) | Capture a full DevTools-protocol trace (CDP firehose, screenshots, DOM dumps) alongside any browser automation, then bisect the stream into per-page searchable buckets | diff --git a/skills/browser-tunnel/SKILL.md b/skills/browser-tunnel/SKILL.md new file mode 100644 index 00000000..db848439 --- /dev/null +++ b/skills/browser-tunnel/SKILL.md @@ -0,0 +1,242 @@ +--- +name: browser-tunnel +description: Open a Browserbase cloud browser that can reach the user's localhost via an auth-gated cloudflared tunnel. Use when the user wants to run a cloud browser against a local dev server (e.g. localhost:3000), test a local app on a remote browser, or get a shareable Browserbase session link for a local-only URL. Solves the "BB sessions can't see my localhost" gap without exposing the dev server to the public internet via ngrok. +--- + +# Browser Tunnel — cloud browser → localhost + +Run a **Browserbase cloud session** that can hit a `localhost` URL on this machine. The cloud browser sees a public `*.trycloudflare.com` URL that is gated by a random per-session secret, so only this BB session can use the tunnel. Random scrapers get `401 Unauthorized`. + +**Use when the user says things like:** +- "test my localhost:3000 app on a cloud browser" +- "I want a Browserbase session that can hit my dev server" +- "give me a shareable BB replay of my local app" +- "test this on BB but the URL is localhost" + +**Don't use when:** +- The target URL is already public — use the `browser` skill directly +- The user wants to use their local Chrome — use `cookie-sync` + local mode + +## How It Works + +``` +BB cloud browser ──HTTPS──► xyz.trycloudflare.com ──HTTP──► local auth proxy (127.0.0.1:auto) + │ + │ check: secret (cookie / ?__tunnel / X-Tunnel-Auth) + ▼ + user's localhost: +``` + +1. `cloudflared` exposes an ephemeral `*.trycloudflare.com` URL pointed at a local auth proxy +2. The auth proxy gates every request on a `` secret, accepted as the `bb_tunnel_auth` cookie, a `?__tunnel=` query param, or an `X-Tunnel-Auth` header. On the first authed request it plants the cookie, so subresources authenticate automatically +3. The launcher creates a Browserbase session and prints an `authUrl` (`https://...?__tunnel=`) plus the raw `tunnelUrl` + `secret` +4. You drive the BB session — easiest with the `browse` CLI pointed at `authUrl` (the first navigation sets the cookie; everything after rides it, no header injection needed) +5. On exit, the launcher releases the BB session, kills cloudflared, closes the proxy + +## Prerequisites + +```bash +# One-time install of cloudflared +brew install cloudflared # macOS +# or: see https://github.com/cloudflare/cloudflared/releases + +# Env var +export BROWSERBASE_API_KEY="..." # from browserbase.com/settings +``` + +Your API key is scoped to a single project, so no project ID is needed. + +Node.js 18+ required (uses built-in `fetch`). + +## Step 1 — Launch the tunnel + session + +Run the launcher in the **background**. It prints a single-line JSON config to stdout, then `---READY---`, then stays alive until killed. + +```bash +nohup node .claude/skills/browser-tunnel/scripts/launch.mjs --port 3000 \ + > /tmp/bb-localhost.log 2>&1 & +echo $! > /tmp/bb-localhost.pid + +# Wait until the sentinel appears (usually 3-6s) +until grep -q "^---READY---$" /tmp/bb-localhost.log 2>/dev/null; do sleep 0.5; done + +# Read the JSON config (the line starting with `{`) +CONFIG_JSON=$(grep -m1 '^{' /tmp/bb-localhost.log) +echo "$CONFIG_JSON" | jq . +``` + +The JSON has these fields: + +| Field | What it is | +|---|---| +| `authUrl` | `https://*.trycloudflare.com/?__tunnel=` — the URL to open. The query param authenticates the first request; the proxy then sets a cookie that covers the rest. **Use this with the `browse` CLI.** | +| `tunnelUrl` | The bare `https://*.trycloudflare.com` URL (no secret) — use when injecting the secret as a header instead | +| `secret` | UUID — the tunnel secret. Carried via the `?__tunnel` query param / `bb_tunnel_auth` cookie, or as `X-Tunnel-Auth` | +| `headerName` | `X-Tunnel-Auth` (header name, for CDP injection) | +| `sessionId` | Browserbase session ID | +| `connectUrl` | `wss://...` — for `chromium.connectOverCDP()` | +| `dashboardUrl` | `https://www.browserbase.com/sessions/` — share with the user | + +Always show the user the `dashboardUrl` so they can watch live. + +### Launcher options + +``` +--port (required) local port to expose +--host (default: 127.0.0.1) local host +--env prod|dev (default: prod) which BB environment +``` + +## Step 2 — Drive the BB session + +The secret can travel two ways. Pick based on your driver: + +- **`authUrl` (query param → cookie)** — open `https://host/?__tunnel=`. The proxy validates the query param on the first request and plants an `HttpOnly` cookie, so the browser then carries the secret on every subsequent request (page *and* subresources) automatically. This is what makes the **`browse` CLI** a clean one-liner. (Don't try `https://user:pass@host` — Chrome strips URL credentials on CDP navigation, so they never arrive.) +- **`X-Tunnel-Auth` header via CDP** — for programmatic drivers (Stagehand/Playwright), inject the header with `Network.setExtraHTTPHeaders`. Don't use a framework helper like `page.setExtraHTTPHeaders()`: it only covers top-level navigations, so subresources will 401. + +### Option A — `browse` CLI (recommended) + +Attach the `browse` CLI to the session the launcher already created (via its `connectUrl`) and open the `authUrl`. No header injection — the query param authenticates the first request and the cookie covers everything after. + +```bash +AUTH_URL=$(echo "$CONFIG_JSON" | jq -r .authUrl) +CONNECT_URL=$(echo "$CONFIG_JSON" | jq -r .connectUrl) + +# cloudflared's edge takes a few seconds to register — wait for a 200 first +until [ "$(curl -s -m 5 -o /dev/null -w '%{http_code}' "$AUTH_URL")" = "200" ]; do sleep 2; done + +# --cdp pins this named session to the BB browser; follow-ups just use --session +browse open --cdp "$CONNECT_URL" --session bb "$AUTH_URL" +browse snapshot --session bb +browse screenshot --session bb --path /tmp/local-on-bb.png +``` + +> Use a fresh `--session` name (not the implicit `default`) to avoid "already running in cdp mode" if you have other browse sessions open. + +### Option B — Stagehand + +Use when you need programmatic control with Stagehand's AI actions. Inject the header via CDP before any `page.goto()`, then navigate to the bare `tunnelUrl` (or just `page.goto(authUrl)` and skip the header entirely). + +```javascript +const stagehand = new Stagehand({ + env: "BROWSERBASE", + browserbaseSessionID: sessionId, // reuse the session created by the launcher +}); +await stagehand.init(); +const page = stagehand.page; + +// Inject auth header on every request via CDP +const client = await page.context().newCDPSession(page); +await client.send("Network.setExtraHTTPHeaders", { + headers: { "X-Tunnel-Auth": secret }, +}); + +await page.goto(tunnelUrl); +await stagehand.act({ action: "click the login button" }); +``` + +### Option C — Playwright + +Same CDP approach as Stagehand — inject the header before navigating, then go to the bare `tunnelUrl`. + +```javascript +import { chromium } from "playwright-core"; + +const { connectUrl, tunnelUrl, secret } = JSON.parse(configJson); + +const browser = await chromium.connectOverCDP(connectUrl); +const context = browser.contexts()[0]; +const page = context.pages()[0] || (await context.newPage()); + +const client = await context.newCDPSession(page); +await client.send("Network.enable"); +await client.send("Network.setExtraHTTPHeaders", { + headers: { "X-Tunnel-Auth": secret }, +}); + +await page.goto(tunnelUrl + "/login", { waitUntil: "domcontentloaded" }); +console.log("Title:", await page.title()); +await page.screenshot({ path: "/tmp/login.png", fullPage: true }); + +await browser.close(); +``` + +> Either driver can also use `authUrl` directly (`page.goto(authUrl)`) and skip the CDP header — the `X-Tunnel-Auth` route is just the alternative if you'd rather not put creds in the URL. + +## Step 3 — Clean up + +```bash +# SIGINT the launcher — it ends the BB session, kills cloudflared, closes the proxy +kill -SIGINT $(cat /tmp/bb-localhost.pid) +rm -f /tmp/bb-localhost.pid /tmp/bb-localhost.log +``` + +Verify the BB session is released: + +```bash +curl -s "https://api.browserbase.com/v1/sessions/$SESSION_ID" \ + -H "x-bb-api-key: $BROWSERBASE_API_KEY" | jq '.status' # → "COMPLETED" +``` + +## Security Model + +What you can tell a security-minded user: + +- The `*.trycloudflare.com` URL exists during the session, **but** every request requires the `` secret (cookie, `?__tunnel` query param, or `X-Tunnel-Auth` header) — anyone without it gets 401 +- The secret lives in exactly two places: the launcher process on the user's machine, and the BB session (the `authUrl` it navigates to / the cookie the proxy plants, or the header injected via CDP). It is never logged and never persisted. The cookie is `HttpOnly`, so page JS can't read it +- The local proxy strips whichever credential authed the request (the `?__tunnel` query param, `bb_tunnel_auth` cookie, `X-Tunnel-Auth`, or `Authorization`) before forwarding upstream, so the dev server never sees it +- The secret rides inside the TLS tunnel to Cloudflare's edge; it is never sent in cleartext +- The proxy listens only on `127.0.0.1`, never on a public interface +- Tunnel dies when the launcher exits or the BB session ends +- Cloudflare-the-company terminates TLS at their edge, so trust includes them. For stricter guarantees (no public URL existing at all), the long-term answer is a native `bb tunnel` with a VPC-internal relay. This skill is the v0. + +## End-to-End Example + +A complete "test my localhost on a cloud browser, screenshot, share the replay" flow: + +```bash +# 1. Launch +nohup node .claude/skills/browser-tunnel/scripts/launch.mjs --port 3000 \ + > /tmp/bb-localhost.log 2>&1 & +echo $! > /tmp/bb-localhost.pid +until grep -q "^---READY---$" /tmp/bb-localhost.log 2>/dev/null; do sleep 0.5; done + +CONFIG_JSON=$(grep -m1 '^{' /tmp/bb-localhost.log) +AUTH_URL=$(echo "$CONFIG_JSON" | jq -r .authUrl) +CONNECT_URL=$(echo "$CONFIG_JSON" | jq -r .connectUrl) +SESSION_ID=$(echo "$CONFIG_JSON" | jq -r .sessionId) +DASHBOARD_URL=$(echo "$CONFIG_JSON" | jq -r .dashboardUrl) + +echo "Watch live: $DASHBOARD_URL" + +# 2. Drive with the browse CLI — wait for the edge, attach to the launcher's +# session, open the auth URL (cookie covers subresources), screenshot. +until [ "$(curl -s -m 5 -o /dev/null -w '%{http_code}' "$AUTH_URL")" = "200" ]; do sleep 2; done +browse open --cdp "$CONNECT_URL" --session bb "$AUTH_URL" +browse screenshot --session bb --path /tmp/local-on-bb.png + +# 3. Clean up +kill -SIGINT $(cat /tmp/bb-localhost.pid) +rm -f /tmp/bb-localhost.pid /tmp/bb-localhost.log + +echo "Replay: $DASHBOARD_URL" +``` + +## Common Pitfalls + +| Symptom | Fix | +|---|---| +| `cloudflared not found` | `brew install cloudflared` | +| 401 on every request from BB | Open `authUrl` (not the bare `tunnelUrl`) so the `?__tunnel` query param sets the auth cookie — or, on a CDP driver, inject `X-Tunnel-Auth` via `Network.setExtraHTTPHeaders` | +| Root HTML loads but JS/CSS 401 | You used a framework helper like `page.setExtraHTTPHeaders()` (top-level navs only) instead of `authUrl` or CDP `Network.setExtraHTTPHeaders` | +| Tunnel URL takes 5-10s to be reachable from BB | Normal — cloudflared edge needs to register. Retry once on 502 | +| Local dev server isn't reached | `curl http://localhost:` first to confirm the dev server is actually up | +| `BROWSERBASE_API_KEY not set` | `export BROWSERBASE_API_KEY=...` (the key is project-scoped — no project ID needed) | +| WebSockets don't work | The proxy supports HTTP upgrade — make sure your client uses `wss://` (cloudflared quick tunnels are HTTPS-only) | +| Launcher hangs at "starting quick tunnel" | Network or DNS issue reaching `trycloudflare.com`. `cloudflared tunnel --url http://example.com` to test cloudflared standalone | + +## When NOT to Use + +- **The URL is already public** (Vercel preview, staging, prod) — use the `browser` skill directly +- **You need video recording from a local-Chrome session** — that's a different product gap; this skill replaces local with cloud, it doesn't mirror local +- **Bank/healthcare strict-security customer that disallows any public URL** — even auth-gated. They need a native `bb tunnel` with VPC-internal relay, which doesn't exist yet diff --git a/skills/browser-tunnel/scripts/launch.mjs b/skills/browser-tunnel/scripts/launch.mjs new file mode 100755 index 00000000..d3525c6f --- /dev/null +++ b/skills/browser-tunnel/scripts/launch.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node +/** + * browser-tunnel launcher + * + * Creates a Browserbase cloud session that can reach a localhost port via an + * auth-gated cloudflared quick tunnel. Outputs connection details as JSON, + * stays alive until SIGINT, then cleans up. + * + * Usage: + * node launch.mjs --port 3000 + * node launch.mjs --port 5173 --host 127.0.0.1 --env dev + * + * Required env: BROWSERBASE_API_KEY (scoped to a single project; no project ID needed) + * + * Output (stdout, single JSON line then "---READY---" sentinel): + * { + * "tunnelUrl": "https://random.trycloudflare.com", + * "authUrl": "https://random.trycloudflare.com/?__tunnel=uuid", + * "secret": "uuid", + * "headerName": "X-Tunnel-Auth", + * "sessionId": "...", + * "connectUrl": "wss://connect.browserbase.com/...", + * "dashboardUrl":"https://www.browserbase.com/sessions/..." + * } + * ---READY--- + * + * The process keeps running. Send SIGINT (Ctrl-C) to stop everything. + */ + +import http from "node:http"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; + +// ─── Args ──────────────────────────────────────────────────────────────────── +function parseArgs(argv) { + const out = { port: null, host: "127.0.0.1", env: "prod" }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--port") out.port = Number(argv[++i]); + else if (a === "--host") out.host = argv[++i]; + else if (a === "--env") out.env = argv[++i]; + else if (a === "--help" || a === "-h") { + console.error("Usage: launch.mjs --port [--host 127.0.0.1] [--env prod|dev]"); + process.exit(0); + } + } + return out; +} + +const { port, host, env } = parseArgs(process.argv); +if (!port || Number.isNaN(port)) { + console.error("ERROR: --port is required"); + process.exit(2); +} + +const BB_API_KEY = process.env.BROWSERBASE_API_KEY; +if (!BB_API_KEY) { + console.error("ERROR: BROWSERBASE_API_KEY must be set"); + process.exit(2); +} + +const BB_API_BASE = + env === "dev" ? "https://api.dev.browserbase.com" : "https://api.browserbase.com"; +const BB_DASH_BASE = + env === "dev" ? "https://www.dev.browserbase.com" : "https://www.browserbase.com"; + +// Each API key is scoped to exactly one project, so we never deal with project +// IDs: the API derives the project from the key on session create, and the +// REQUEST_RELEASE endpoint identifies the session by ID alone. +const SECRET = randomUUID(); +const HEADER = "X-Tunnel-Auth"; +const COOKIE = "bb_tunnel_auth"; +const QUERY = "__tunnel"; + +// A request is authed if it carries the secret as any of: +// (a) the X-Tunnel-Auth header — used by Playwright/Stagehand via CDP +// (b) the bb_tunnel_auth cookie — set by us on the first authed response, then +// replayed by the browser on every same-origin request (incl. subresources) +// (c) the ?__tunnel= query param — the entry point for browser drivers +// (e.g. `browse open `): the first navigation carries it, we set +// the cookie, and everything after rides the cookie +// (d) HTTP Basic auth whose password == SECRET — handy for curl debugging +// Returns how it authed so we can strip exactly the credential we consumed. +// NOTE: credentials embedded in the URL (https://user:pass@host) are NOT used — +// Chrome strips them on CDP navigation, so they never reach us. Hence the cookie. +function authVia(req) { + if (req.headers[HEADER.toLowerCase()] === SECRET) return "header"; + + const cookie = req.headers["cookie"]; + if (cookie) { + const m = cookie.match(new RegExp(`(?:^|;\\s*)${COOKIE}=([^;]+)`)); + if (m && m[1] === SECRET) return "cookie"; + } + + try { + if (new URL(req.url, "http://x").searchParams.get(QUERY) === SECRET) return "query"; + } catch {} + + const auth = req.headers["authorization"]; + if (auth && auth.startsWith("Basic ")) { + try { + const decoded = Buffer.from(auth.slice(6), "base64").toString("utf8"); + if (decoded.slice(decoded.indexOf(":") + 1) === SECRET) return "basic"; + } catch {} + } + return null; +} + +// Strip whichever credential(s) we consumed and our query param, so the dev +// server never sees the tunnel secret. Returns the rewritten upstream path. +function sanitize(req, via) { + const headers = { ...req.headers }; + delete headers[HEADER.toLowerCase()]; + if (via === "basic") delete headers["authorization"]; + if (headers["cookie"]) { + const kept = headers["cookie"] + .split(/;\s*/) + .filter((c) => !c.startsWith(`${COOKIE}=`)) + .join("; "); + if (kept) headers["cookie"] = kept; + else delete headers["cookie"]; + } + headers.host = `${host}:${port}`; + + let path = req.url; + try { + const u = new URL(req.url, "http://x"); + if (u.searchParams.has(QUERY)) { + u.searchParams.delete(QUERY); + path = u.pathname + u.search + u.hash; + } + } catch {} + return { headers, path }; +} + +// Append our auth cookie to an upstream response's headers (preserving any +// Set-Cookie the app already sent), so the browser carries the secret onward. +function withAuthCookie(upHeaders) { + const out = { ...upHeaders }; + const existing = out["set-cookie"] || []; + out["set-cookie"] = [ + ...(Array.isArray(existing) ? existing : [existing]), + `${COOKIE}=${SECRET}; Path=/; HttpOnly; Secure; SameSite=Lax`, + ]; + return out; +} + +// ─── Auth-gated local HTTP proxy ───────────────────────────────────────────── +// Sits between cloudflared edge and the user's localhost:. +// Requires the secret (cookie, ?__tunnel query, X-Tunnel-Auth header, or Basic +// password). Forwards request+body, streams response, and plants the auth cookie. +const proxy = http.createServer((req, res) => { + const via = authVia(req); + if (!via) { + res.writeHead(401, { "content-type": "text/plain" }); + res.end("unauthorized: missing or invalid tunnel auth\n"); + return; + } + const { headers, path } = sanitize(req, via); + + const upstream = http.request( + { host, port, method: req.method, path, headers }, + (upRes) => { + res.writeHead(upRes.statusCode || 502, withAuthCookie(upRes.headers)); + upRes.pipe(res); + } + ); + upstream.on("error", (err) => { + res.writeHead(502, { "content-type": "text/plain" }); + res.end(`upstream error: ${err.message}\n`); + }); + req.pipe(upstream); +}); + +// WebSocket upgrade passthrough (auth-gated) +proxy.on("upgrade", (req, clientSocket, head) => { + const via = authVia(req); + if (!via) { + clientSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + clientSocket.destroy(); + return; + } + const { headers, path } = sanitize(req, via); + + const upstream = http.request({ host, port, method: req.method, path, headers }); + upstream.on("upgrade", (upRes, upstreamSocket, upHead) => { + clientSocket.write( + `HTTP/1.1 101 Switching Protocols\r\n` + + Object.entries(upRes.headers) + .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`) + .join("\r\n") + + "\r\n\r\n" + ); + // Forward any buffered bytes that arrived with the handshake, in both + // directions, before wiring up the bidirectional pipe. `head` is the + // client's trailing buffer (already consumed from clientSocket, so pipe + // won't re-emit it); `upHead` is the upstream's. + if (upHead?.length) clientSocket.write(upHead); + if (head?.length) upstreamSocket.write(head); + upstreamSocket.pipe(clientSocket).pipe(upstreamSocket); + }); + upstream.on("error", () => clientSocket.destroy()); + upstream.end(); +}); + +// listen on a random free port (0) +await new Promise((resolve, reject) => { + proxy.listen(0, "127.0.0.1", resolve); + proxy.on("error", reject); +}); +const proxyPort = proxy.address().port; +console.error(`[proxy] auth-gated proxy listening on 127.0.0.1:${proxyPort} -> ${host}:${port}`); + +// ─── cloudflared quick tunnel ──────────────────────────────────────────────── +console.error(`[cloudflared] starting quick tunnel...`); +const cf = spawn( + "cloudflared", + ["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${proxyPort}`], + { stdio: ["ignore", "pipe", "pipe"] } +); + +const tunnelUrl = await new Promise((resolve, reject) => { + let buf = ""; + const onChunk = (chunk) => { + buf += chunk.toString(); + const m = buf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/); + if (m) { + cleanup(); + resolve(m[0]); + } + }; + const onExit = (code) => + reject(new Error(`cloudflared exited (code ${code}) before URL was found`)); + const timer = setTimeout(() => { + cleanup(); + reject(new Error("timed out waiting for cloudflared URL")); + }, 30_000); + function cleanup() { + clearTimeout(timer); + cf.stdout.off("data", onChunk); + cf.stderr.off("data", onChunk); + cf.off("exit", onExit); + } + cf.stdout.on("data", onChunk); + cf.stderr.on("data", onChunk); + cf.on("exit", onExit); +}); +console.error(`[cloudflared] tunnel URL: ${tunnelUrl}`); + +// ─── Cleanup on exit ───────────────────────────────────────────────────────── +// Defined and wired up *before* session creation so that a cloudflared crash or +// a Ctrl-C during that window still tears everything down. `session` may still +// be null at that point — the release call is guarded accordingly. +let session = null; +let shuttingDown = false; +async function shutdown(signal, code = 0) { + if (shuttingDown) return; + shuttingDown = true; + console.error(`\n[shutdown] received ${signal}, cleaning up...`); + + // Local cleanup first — these are instant and must happen even if the BB API + // is slow or unreachable, so nothing lingers after Ctrl-C. + cf.kill("SIGINT"); + proxy.close(); + + // Hard safety net: never let a hanging release request keep us alive. + const hardExit = setTimeout(() => process.exit(code), 3000); + hardExit.unref?.(); + + // Release the BB session if we got far enough to create one. Time-boxed so a + // slow API can't block the (already-done) local cleanup. + if (session?.id) { + try { + await fetch(`${BB_API_BASE}/v1/sessions/${session.id}`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-bb-api-key": BB_API_KEY }, + body: JSON.stringify({ status: "REQUEST_RELEASE" }), + signal: AbortSignal.timeout(2500), + }); + console.error("[shutdown] BB session released"); + } catch (e) { + console.error("[shutdown] release error:", e.message); + } + } + clearTimeout(hardExit); + process.exit(code); +} +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); +// A cloudflared exit means the tunnel is dead — tear everything down. Registered +// here (not after session creation) so an exit during the create window, when +// the tunnel-URL promise's own exit listener has already been removed, still +// triggers cleanup instead of silently emitting a dead tunnel URL. +cf.on("exit", (code) => { + console.error(`[cloudflared] exited with code ${code}`); + if (!shuttingDown) shutdown("cloudflared-exit"); +}); + +// ─── Create Browserbase session ────────────────────────────────────────────── +// No projectId sent — the API key is scoped to one project, so the API derives +// it from the key. +console.error(`[bb] creating session on ${BB_API_BASE}...`); +const bbRes = await fetch(`${BB_API_BASE}/v1/sessions`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-bb-api-key": BB_API_KEY }, + body: JSON.stringify({}), +}); +if (!bbRes.ok) { + const text = await bbRes.text(); + console.error(`[bb] failed to create session: ${bbRes.status} ${text}`); + await shutdown("create-failed", 1); +} +session = await bbRes.json(); +console.error(`[bb] session: ${session.id}`); + +// ─── Emit connection JSON on stdout ───────────────────────────────────────── +// authUrl carries the secret as a query param. The first navigation authenticates +// on it; the proxy then plants the bb_tunnel_auth cookie, so every subsequent +// request (subresources, XHR/fetch) authenticates via the cookie — no header +// injection, and it survives Chrome stripping URL credentials. +const authUrl = `${tunnelUrl}/?${QUERY}=${SECRET}`; +const output = { + tunnelUrl, + authUrl, + secret: SECRET, + headerName: HEADER, + sessionId: session.id, + connectUrl: session.connectUrl, + dashboardUrl: `${BB_DASH_BASE}/sessions/${session.id}`, + localPort: port, + proxyPort, +}; +process.stdout.write(JSON.stringify(output) + "\n"); +process.stdout.write("---READY---\n"); + +// The process now stays alive on the open proxy/cloudflared handles until a +// signal (or a cloudflared exit) triggers shutdown(), wired up above.