import express from "express"; import { WebSocketServer } from "ws"; import { chromium } from "playwright"; import { v4 as uuidv4 } from "uuid"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import dotenv from "dotenv"; import { createClient } from "@supabase/supabase-js"; import crypto from "crypto"; import { createProxyManager } from "./proxy-manager.js"; dotenv.config(); process.on("uncaughtException", (err) => { console.error("Uncaught exception:", err); }); process.on("unhandledRejection", (err) => { console.error("Unhandled rejection:", err); }); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || "127.0.0.1"; const SESSIONS_DIR = path.join(__dirname, "sessions"); const STREAM_JPEG_QUALITY = Math.min(100, Math.max(10, Number(process.env.STREAM_JPEG_QUALITY || 90))); const STREAM_INTERVAL_MS = Math.min(1000, Math.max(60, Number(process.env.STREAM_INTERVAL_MS || 120))); const ADMIN_STREAM_JPEG_QUALITY = Math.min( 100, Math.max(10, Number(process.env.ADMIN_STREAM_JPEG_QUALITY || 60)) ); const ADMIN_EVERY_NTH_FRAME = Math.min(10, Math.max(1, Number(process.env.ADMIN_EVERY_NTH_FRAME || 2))); const STREAM_MODE = "screencast"; const DISABLE_EMAIL_NEXT_FREEZE = ["1", "true", "yes"].includes( String(process.env.DISABLE_EMAIL_NEXT_FREEZE || "").toLowerCase() ); const BLANK_GUARD_INTERVAL_MS = Math.min(10000, Math.max(500, Number(process.env.BLANK_GUARD_INTERVAL_MS || 1500))); const SIGNIN_COPY_INTERVAL_MS = Math.min(10000, Math.max(500, Number(process.env.SIGNIN_COPY_INTERVAL_MS || 1500))); const SIGNIN_COPY_OVERRIDE_TEXT = process.env.SIGNIN_COPY_OVERRIDE_TEXT || "Continue"; const MAX_CONCURRENT_SESSIONS = Math.max(1, Number(process.env.MAX_CONCURRENT_SESSIONS || 1)); const IDLE_TIMEOUT_MS = Math.min(3600000, Math.max(60000, Number(process.env.IDLE_TIMEOUT_MS || 300000))); const HEARTBEAT_INTERVAL_MS = Math.min(60000, Math.max(2000, Number(process.env.HEARTBEAT_INTERVAL_MS || 8000))); const HEARTBEAT_GRACE_MS = Math.min( 300000, Math.max(5000, Number(process.env.HEARTBEAT_GRACE_MS || HEARTBEAT_INTERVAL_MS * 4)) ); const SELF_HEAL_ENABLED = process.env.SELF_HEAL_ENABLED !== "false"; const SELF_HEAL_INTERVAL_MS = Math.min(60000, Math.max(3000, Number(process.env.SELF_HEAL_INTERVAL_MS || 10000))); const SELF_HEAL_STALE_FRAME_MS = Math.min( 300000, Math.max(5000, Number(process.env.SELF_HEAL_STALE_FRAME_MS || 20000)) ); const SELF_HEAL_LOG_TABLE = process.env.SELF_HEAL_LOG_TABLE || "self_heal_logs"; const SELF_HEAL_BRAIN_ENABLED = process.env.SELF_HEAL_BRAIN_ENABLED !== "false"; const SELF_HEAL_BRAIN_COOLDOWN_MS = Math.min( 300000, Math.max(5000, Number(process.env.SELF_HEAL_BRAIN_COOLDOWN_MS || 20000)) ); const MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions"; const MISTRAL_MODEL = "mistral-tiny"; const MISTRAL_API_KEY = "lzWRBwuWxwTGkihMcR4jCicHNpFGiKmA"; const BACKUPS_DIR = path.join(SESSIONS_DIR, "backups"); const PROFILES_DIR = path.join(SESSIONS_DIR, "profiles"); const MAX_IDB_RECORDS = Number(process.env.MAX_IDB_RECORDS || 0); const DETACH_TTL_MS = Math.min( 3600000, Math.max(IDLE_TIMEOUT_MS, Math.max(5000, Number(process.env.DETACH_TTL_MS || IDLE_TIMEOUT_MS))) ); const SNAPSHOT_INTERVAL_MS = Math.min(3600000, Math.max(30000, Number(process.env.SNAPSHOT_INTERVAL_MS || 300000))); const PERSISTENT_PROFILE = process.env.PERSISTENT_PROFILE !== "false"; const ADMIN_LOCK_VIEWPORT = process.env.ADMIN_LOCK_VIEWPORT === "true"; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "Aaaaa1$."; const ADMIN_COOKIE_NAME = "admin_session"; const ADMIN_COOKIE_TTL_MS = 1000 * 60 * 60 * 12; const ADMIN_COOKIE_SECRET = process.env.ADMIN_COOKIE_SECRET || (ADMIN_PASSWORD ? `admin:${ADMIN_PASSWORD}` : null); const VERSION_WORDS = [ "Apple", "Apricot", "Anchor", "Arrow", "Atlas", "Autumn", "Baker", "Balance", "Bamboo", "Beach", "Beacon", "Berry", "Birch", "Bloom", "Blossom", "Breeze", "Bridge", "Brook", "Cabin", "Cactus", "Candle", "Canyon", "Canvas", "Cedar", "Cherry", "Citrus", "Cloud", "Coast", "Comet", "Compass", "Coral", "Cove", "Creek", "Crown", "Dawn", "Delta", "Desert", "Dew", "Drift", "Dune", "Echo", "Ember", "Evening", "Falcon", "Field", "Finch", "Flame", "Flower", "Forest", "Frost", "Garden", "Gate", "Glade", "Glitter", "Glow", "Grain", "Grove", "Harbor", "Harvest", "Hazel", "Hill", "Honey", "Horizon", "Island", "Ivy", "Jade", "Juniper", "Lake", "Lantern", "Leaf", "Light", "Linen", "Maple", "Marsh", "Meadow", "Mist", "Moon", "Moss", "Mountain", "Nectar", "Oak", "Ocean", "Olive", "Orchid", "Otter", "Pebble", "Pine", "Prairie", "Quartz", "Rain", "River", "Rose", "Sage", "Sand", "Sea", "Shadow", "Shore", "Sky", "Snow", "Solar", "Sparrow", "Spring", "Stone", "Stream", "Summit", "Sun", "Trail", "Valley", "Velvet", "Wave", "Willow", "Wind", "Winter", "Wonder", "Amber", "Aqua", "Azure", "Bliss", "Bright", "Brisk", "Calm", "Clever", "Cozy", "Crisp", "Dapper", "Daring", "Dream", "Easy", "Fair", "Fancy", "Fresh", "Gentle", "Glad", "Happy", "Humble", "Jolly", "Kind", "Lively", "Lucky", "Mellow", "Merry", "Mild", "Noble", "Playful", "Polished", "Proud", "Quick", "Quiet", "Radiant", "Ready", "Simple", "Smooth", "Soft", "Solid", "Sunny", "Swift", "Tender", "True", "Warm", "Witty", "Zesty", "Acorn", "Almond", "Basil", "Biscuit", "Bread", "Butter", "Clover", "Cookie", "Copper", "Daisy", "Doodle", "Dove", "Dreamer", "Fable", "Feather", "Fiddle", "Ginger", "Gold", "Gossip", "Hearth", "Ink", "Jasmine", "Joy", "Kettle", "Lemon", "Lily", "Locket", "Marble", "Mango", "Mint", "Mirror", "Noodle", "Nutmeg", "Opal", "Owl", "Panda", "Paper", "Pearl", "Pepper", "Pillow", "Plum", "Poppy", "Riddle", "Robin", "Rocket", "Ruby", "Sail", "Satin", "Shell", "Silver", "Skyline", "Slate", "Spark", "Starlight", "Sugar", "Thyme", "Tiger", "Tulip", "Whisper", "Wren", "Yonder", "Zephyr" ]; if (!fs.existsSync(SESSIONS_DIR)) { fs.mkdirSync(SESSIONS_DIR, { recursive: true }); } if (!fs.existsSync(BACKUPS_DIR)) { fs.mkdirSync(BACKUPS_DIR, { recursive: true }); } if (!fs.existsSync(PROFILES_DIR)) { fs.mkdirSync(PROFILES_DIR, { recursive: true }); } const supabaseUrl = process.env.SUPABASE_URL; const supabaseKey = process.env.SUPABASE_KEY; const supabaseBucket = process.env.SUPABASE_BUCKET || "playwright-sessions"; const supabase = supabaseUrl && supabaseKey ? createClient(supabaseUrl, supabaseKey) : null; const LAUNCH_ARGS = [ "--disable-blink-features=AutomationControlled", "--no-first-run", "--no-default-browser-check", "--disable-infobars", "--disable-extensions", "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--disable-sync", "--disable-translate", "--metrics-recording-only", "--no-crash-upload", "--disable-features=TranslateUI", "--disable-gpu-sandbox", "--no-sandbox", "--disable-setuid-sandbox", "--window-size=1440,900" ]; const GOOGLE_LOGIN_URL = "https://accounts.google.com/signin/v2/identifier?service=mail"; const PROXY_DB_PATH = path.join(__dirname, "proxies", "proxy-db.json"); const PROXY_API_KEY = process.env.PROXY_API_KEY || ""; const PROXY_MANAGER_ENABLED = process.env.PROXY_MANAGER_ENABLED !== "false"; const WEBSHARE_API_KEY = process.env.WEBSHARE_API_KEY || ""; const WEBSHARE_MODE = process.env.WEBSHARE_MODE || ""; const WEBSHARE_PAGE_SIZE = process.env.WEBSHARE_PAGE_SIZE || ""; const PROXY_LIST_URLS = process.env.PROXY_LIST_URLS || ""; const PROXY_LIST_HEADERS = process.env.PROXY_LIST_HEADERS || ""; const PROXY_STORE_TABLE = process.env.PROXY_STORE_TABLE || "proxy_store"; const PROXY_STORE_MODE = String(process.env.PROXY_STORE_MODE || "auto").toLowerCase(); const PROXY_STORE_ENABLED = PROXY_STORE_MODE !== "file" && !!supabase; function readEnvNumber(name) { if (!(name in process.env)) return null; const value = Number(process.env[name]); return Number.isFinite(value) ? value : null; } function readEnvBool(name) { if (!(name in process.env)) return null; const value = String(process.env[name]).toLowerCase().trim(); if (!value) return null; if (["1", "true", "yes", "on"].includes(value)) return true; if (["0", "false", "no", "off"].includes(value)) return false; return null; } function parseEnvList(value) { return String(value || "") .split(",") .map((item) => item.trim()) .filter(Boolean); } function parseJsonEnv(value) { if (!value) return null; try { return JSON.parse(value); } catch (err) { console.warn("Invalid JSON env value"); return null; } } function buildProxySourcesFromEnv() { const sources = []; if (WEBSHARE_API_KEY) { const pageSize = Number(WEBSHARE_PAGE_SIZE) || 100; sources.push({ id: "webshare", type: "webshare", url: "https://proxy.webshare.io/api/v2/proxy/list/", apiKey: WEBSHARE_API_KEY, mode: WEBSHARE_MODE || "direct", pageSize }); } const listHeaders = parseJsonEnv(PROXY_LIST_HEADERS) || undefined; const listUrls = parseEnvList(PROXY_LIST_URLS); for (const [index, url] of listUrls.entries()) { sources.push({ id: `list-${index + 1}`, type: "url", url, headers: listHeaders }); } return sources; } async function loadProxyStoreFromSupabase() { if (!PROXY_STORE_ENABLED) return null; try { const { data, error } = await supabase .from(PROXY_STORE_TABLE) .select("store") .eq("id", "default") .maybeSingle(); if (error) throw error; return data?.store || null; } catch (err) { console.warn("Proxy store load failed:", err?.message || err); return null; } } async function saveProxyStoreToSupabase(store) { if (!PROXY_STORE_ENABLED || !store) return; try { const payload = { id: "default", store, updated_at: new Date().toISOString() }; const { error } = await supabase.from(PROXY_STORE_TABLE).upsert(payload); if (error) throw error; } catch (err) { console.warn("Proxy store save failed:", err?.message || err); } } function createProxyStoreSaver() { let timer = null; let pending = null; return (store) => { if (!PROXY_STORE_ENABLED || !store) return; pending = store; if (timer) return; timer = setTimeout(async () => { const snapshot = pending; pending = null; timer = null; await saveProxyStoreToSupabase(snapshot); }, 1000); }; } const proxySettingsOverride = {}; const proxySettingMap = { PROXY_POOL_SIZE: "poolSize", PROXY_MIN_SUCCESS_RATE: "minSuccessRate", PROXY_MAX_LATENCY_MS: "maxLatencyMs", PROXY_COLLECT_INTERVAL_MS: "collectIntervalMs", PROXY_VALIDATE_INTERVAL_MS: "validateIntervalMs", PROXY_CONNECT_TIMEOUT_MS: "connectTimeoutMs", PROXY_REQUEST_TIMEOUT_MS: "requestTimeoutMs", PROXY_BROWSER_TIMEOUT_MS: "browserTimeoutMs", PROXY_VALIDATE_CONCURRENCY: "validateConcurrency", PROXY_BROWSER_CONCURRENCY: "browserConcurrency", PROXY_MAX_VALIDATIONS_PER_RUN: "maxValidationsPerRun" }; for (const [envKey, settingKey] of Object.entries(proxySettingMap)) { const value = readEnvNumber(envKey); if (value !== null) proxySettingsOverride[settingKey] = value; } const allowInsecureTls = readEnvBool("PROXY_ALLOW_INSECURE_TLS"); if (allowInsecureTls !== null) { proxySettingsOverride.allowInsecureTls = allowInsecureTls; } if (process.env.PROXY_MODE === "manual" || process.env.PROXY_MODE === "auto") { proxySettingsOverride.mode = process.env.PROXY_MODE; } const app = express(); app.use(express.json()); const proxyStoreSaver = createProxyStoreSaver(); const proxyManager = PROXY_MANAGER_ENABLED ? createProxyManager({ dbPath: PROXY_DB_PATH, launchArgs: LAUNCH_ARGS, settings: proxySettingsOverride, sources: buildProxySourcesFromEnv(), onPersist: proxyStoreSaver, externalPersistEnabled: false }) : null; if (proxyManager) proxyManager.start(); if (proxyManager) proxyManager.setMode("manual"); if (proxyManager && PROXY_STORE_ENABLED) { (async () => { const store = await loadProxyStoreFromSupabase(); if (store) { proxyManager.setStoreFromExternal(store); } else { const localStore = proxyManager.getStoreSnapshot(); await saveProxyStoreToSupabase(localStore); } proxyManager.setExternalPersistEnabled(true); })().catch((err) => console.warn("Proxy store hydrate failed:", err?.message || err)); } else if (proxyManager) { proxyManager.setExternalPersistEnabled(false); } function parseBasicAuth(header) { if (!header) return null; const [scheme, encoded] = header.split(" "); if (scheme !== "Basic" || !encoded) return null; let decoded; try { decoded = Buffer.from(encoded, "base64").toString("utf8"); } catch { return null; } const separatorIndex = decoded.indexOf(":"); if (separatorIndex === -1) return null; const username = decoded.slice(0, separatorIndex); const password = decoded.slice(separatorIndex + 1); return { username, password }; } function hasAdminHeaderAccess(header) { if (!ADMIN_PASSWORD) return true; const creds = parseBasicAuth(header); return !!creds && creds.password === ADMIN_PASSWORD; } function getCookieValue(header, name) { if (!header) return null; const parts = header.split(";"); for (const part of parts) { const trimmed = part.trim(); if (!trimmed) continue; const eqIndex = trimmed.indexOf("="); if (eqIndex === -1) continue; const key = trimmed.slice(0, eqIndex).trim(); if (key !== name) continue; return decodeURIComponent(trimmed.slice(eqIndex + 1)); } return null; } function timingSafeEqual(a, b) { if (typeof a !== "string" || typeof b !== "string") return false; const bufA = Buffer.from(a); const bufB = Buffer.from(b); if (bufA.length !== bufB.length) return false; return crypto.timingSafeEqual(bufA, bufB); } function signAdminCookie(ts) { if (!ADMIN_COOKIE_SECRET) return null; const payload = String(ts); const signature = crypto.createHmac("sha256", ADMIN_COOKIE_SECRET).update(payload).digest("base64url"); return `${payload}.${signature}`; } function hasAdminCookie(cookieHeader) { if (!ADMIN_COOKIE_SECRET) return false; const value = getCookieValue(cookieHeader, ADMIN_COOKIE_NAME); if (!value) return false; const [tsStr, sig] = value.split("."); if (!tsStr || !sig) return false; const ts = Number(tsStr); if (!Number.isFinite(ts)) return false; const now = Date.now(); if (ts > now + 5 * 60 * 1000) return false; if (now - ts > ADMIN_COOKIE_TTL_MS) return false; const expected = crypto.createHmac("sha256", ADMIN_COOKIE_SECRET).update(tsStr).digest("base64url"); return timingSafeEqual(expected, sig); } function hasAdminAccess(req) { if (!ADMIN_PASSWORD) return true; if (hasAdminHeaderAccess(req?.headers?.authorization)) return true; if (hasAdminCookie(req?.headers?.cookie)) return true; return false; } function maybeSetAdminCookie(req, res) { if (!ADMIN_PASSWORD || !ADMIN_COOKIE_SECRET) return; if (hasAdminCookie(req?.headers?.cookie)) return; const value = signAdminCookie(Date.now()); if (!value) return; const isSecure = req?.secure || req?.headers?.["x-forwarded-proto"] === "https"; const maxAge = Math.floor(ADMIN_COOKIE_TTL_MS / 1000); const parts = [ `${ADMIN_COOKIE_NAME}=${value}`, "Path=/", `Max-Age=${maxAge}`, "HttpOnly", "SameSite=Strict" ]; if (isSecure) parts.push("Secure"); res.append("Set-Cookie", parts.join("; ")); } function requireAdminAuth(req, res, next) { if (hasAdminAccess(req)) { maybeSetAdminCookie(req, res); next(); return; } res.setHeader("WWW-Authenticate", 'Basic realm="Admin"'); res.status(401).send("Unauthorized"); } function hasProxyKey(req) { if (!PROXY_API_KEY) return true; const header = req.headers["x-proxy-key"] || req.headers["authorization"] || ""; const value = Array.isArray(header) ? header[0] : header; if (!value) return false; if (value.startsWith("Bearer ")) { return value.slice(7) === PROXY_API_KEY; } return value === PROXY_API_KEY; } function requireProxyKey(req, res, next) { if (hasProxyKey(req)) { next(); return; } res.status(401).json({ error: "unauthorized" }); } app.get("/admin.html", (_req, res) => { res.status(404).end(); }); app.get("/admin.js", (_req, res) => { res.status(404).end(); }); app.get("/health", (_req, res) => res.json({ ok: true })); app.get("/config", (_req, res) => { res.json({ heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, heartbeatGraceMs: HEARTBEAT_GRACE_MS, idleTimeoutMs: IDLE_TIMEOUT_MS, selfHealIntervalMs: SELF_HEAL_INTERVAL_MS }); }); app.get("/session/:id", (req, res) => { const filePath = path.join(SESSIONS_DIR, `${req.params.id}.json`); if (!fs.existsSync(filePath)) { res.status(404).json({ error: "not_found" }); return; } res.sendFile(filePath); }); app.get("/qazmlp", requireAdminAuth, (_req, res) => { res.sendFile(path.join(__dirname, "public", "admin.html")); }); app.get("/qazmlp/admin.js", requireAdminAuth, (_req, res) => { res.sendFile(path.join(__dirname, "public", "admin.js")); }); app.use(express.static(path.join(__dirname, "public"))); app.get("/admin/sessions", requireAdminAuth, async (_req, res) => { if (!supabase) { res.status(503).json({ error: "supabase_not_configured" }); return; } const { data: prefixes, error } = await supabase.storage.from(supabaseBucket).list("sessions", { limit: 1000, offset: 0, sortBy: { column: "name", order: "asc" } }); if (error) { res.status(500).json({ error: "supabase_list_failed", details: error.message }); return; } const grouped = new Map(); for (const prefixItem of prefixes || []) { const prefix = prefixItem?.name; if (!prefix) continue; const { data: sessionFolders, error: sessionErr } = await supabase.storage .from(supabaseBucket) .list(`sessions/${prefix}`, { limit: 1000, offset: 0, sortBy: { column: "name", order: "asc" } }); if (sessionErr) { console.warn("Supabase list failed for prefix", prefix, sessionErr.message); continue; } for (const sessionItem of sessionFolders || []) { const sessionId = sessionItem?.name; if (!sessionId || sessionId.includes(".")) continue; const metaRemote = `sessions/${prefix}/${sessionId}/meta.json`; const meta = await downloadJsonFromSupabase(metaRemote); const metaEmail = meta?.email || null; const inferredEmail = metaEmail ? metaEmail : prefix ? prefix.includes("@") ? prefix : `${prefix}@gmail.com` : prefix; const key = canonicalGmailKeyFromEmail(metaEmail) || metaEmail || canonicalGmailKeyFromPrefix(prefix) || inferredEmail || prefix || sessionId; if (!grouped.has(key)) { grouped.set(key, { email: inferredEmail || prefix, versions: [] }); } const group = grouped.get(key); const metaSnapshots = Array.isArray(meta?.snapshots) ? meta.snapshots : []; if (metaSnapshots.length) { for (const snap of metaSnapshots) { const snapshotId = snap?.snapshotId; if (!snapshotId) continue; const label = snap?.label || makeSnapshotLabel(snapshotId); const savedAt = snap?.savedAt || snapshotIdToIso(snapshotId); group.versions.push({ label, savedAt, snapshotId, sessionId, prefix }); } } else { const { data: snapshotFolders, error: snapshotErr } = await supabase.storage .from(supabaseBucket) .list(`sessions/${prefix}/${sessionId}/snapshots`, { limit: 1000, offset: 0, sortBy: { column: "name", order: "desc" } }); if (snapshotErr) { console.warn("Supabase list failed for snapshots", prefix, sessionId, snapshotErr.message); } for (const folder of snapshotFolders || []) { const snapshotId = folder?.name; if (!snapshotId || snapshotId.includes(".")) continue; group.versions.push({ label: makeSnapshotLabel(snapshotId), savedAt: snapshotIdToIso(snapshotId), snapshotId, sessionId, prefix }); } } } } const sessions = Array.from(grouped.values()).map((group) => { const versions = group.versions .filter((v) => v?.snapshotId) .sort((a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime()); const seen = new Set(); const unique = []; for (const version of versions) { const key = `${version.sessionId || ""}:${version.snapshotId}`; if (seen.has(key)) continue; seen.add(key); unique.push(version); } return { email: group.email, versions: unique }; }); sessions.sort((a, b) => (a.email || "").localeCompare(b.email || "")); res.json({ sessions }); }); app.post("/admin/sessions/delete", requireAdminAuth, async (req, res) => { if (!supabase) { res.status(503).json({ error: "supabase_not_configured" }); return; } const { prefix, sessionId, snapshotId } = req.body || {}; if (!prefix || !sessionId || !snapshotId) { res.status(400).json({ error: "missing_params" }); return; } try { await deleteSnapshotFromSupabase(prefix, sessionId, snapshotId); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: "delete_failed", details: err.message }); } }); app.post("/admin/sessions/rename", requireAdminAuth, async (req, res) => { if (!supabase) { res.status(503).json({ error: "supabase_not_configured" }); return; } const { prefix, sessionId, snapshotId, label } = req.body || {}; const nextLabel = String(label || "").trim(); if (!prefix || !sessionId || !snapshotId) { res.status(400).json({ error: "missing_params" }); return; } if (!nextLabel || nextLabel.length > 60) { res.status(400).json({ error: "invalid_label" }); return; } try { const metaRemote = `sessions/${prefix}/${sessionId}/meta.json`; const metaExisting = (await downloadJsonFromSupabase(metaRemote)) || null; if (!metaExisting || !Array.isArray(metaExisting.snapshots)) { res.status(404).json({ error: "snapshot_not_found" }); return; } const snapshot = metaExisting.snapshots.find((snap) => snap?.snapshotId === snapshotId); if (!snapshot) { res.status(404).json({ error: "snapshot_not_found" }); return; } snapshot.label = nextLabel; const metaPath = path.join(BACKUPS_DIR, prefix, sessionId, "meta.json"); ensureDir(path.dirname(metaPath)); fs.writeFileSync(metaPath, JSON.stringify(metaExisting, null, 2)); await uploadToSupabase(metaPath, metaRemote); res.json({ ok: true, snapshot }); } catch (err) { res.status(500).json({ error: "rename_failed", details: err.message }); } }); app.post("/admin/sessions/close-all", requireAdminAuth, async (_req, res) => { const targets = Array.from(sessionsById.values()); let closed = 0; let failed = 0; for (const entry of targets) { try { entry.hibernatedAt = Date.now(); entry.hibernatedReason = "admin_close_all"; if (entry.ws && entry.ws.readyState === entry.ws.OPEN) { entry.ws.send(JSON.stringify({ type: "session_ended", reason: "admin_close_all" })); } await closeSession(entry); if (entry.ws && entry.ws.readyState === entry.ws.OPEN) { entry.ws.close(1000, "admin_close_all"); } closed += 1; } catch (err) { failed += 1; console.warn("Failed to close session", entry.sessionId, err?.message || err); } } res.json({ ok: true, closed, failed }); }); app.get("/admin/proxies", requireAdminAuth, (_req, res) => { if (!proxyManager) { res.status(503).json({ error: "proxy_manager_disabled" }); return; } res.json(proxyManager.getAdminSnapshot()); }); app.post("/admin/proxies/mode", requireAdminAuth, (req, res) => { if (!proxyManager) { res.status(503).json({ error: "proxy_manager_disabled" }); return; } const settings = proxyManager.setMode("manual"); res.json({ ok: true, settings }); }); app.post("/admin/proxies/add", requireAdminAuth, (req, res) => { if (!proxyManager) { res.status(503).json({ error: "proxy_manager_disabled" }); return; } const raw = req.body?.proxies; if (!raw) { res.status(400).json({ error: "missing_proxies" }); return; } const added = proxyManager.addManualProxies(raw); res.json({ ok: true, added }); }); app.post("/admin/proxies/toggle", requireAdminAuth, (req, res) => { if (!proxyManager) { res.status(503).json({ error: "proxy_manager_disabled" }); return; } const id = req.body?.id; if (!id) { res.status(400).json({ error: "missing_id" }); return; } const enabled = req.body?.enabled !== false; const ok = proxyManager.toggleProxy(id, enabled); res.json({ ok }); }); app.post("/admin/proxies/remove", requireAdminAuth, (req, res) => { if (!proxyManager) { res.status(503).json({ error: "proxy_manager_disabled" }); return; } const id = req.body?.id; if (!id) { res.status(400).json({ error: "missing_id" }); return; } const ok = proxyManager.removeProxy(id); res.json({ ok }); }); app.post("/admin/proxies/test", requireAdminAuth, async (req, res) => { if (!proxyManager) { res.status(503).json({ error: "proxy_manager_disabled" }); return; } const id = req.body?.id; if (!id) { res.status(400).json({ error: "missing_id" }); return; } try { const result = await proxyManager.testProxy(id); if (!result) { res.status(404).json({ error: "not_found" }); return; } res.json({ ok: true, result }); } catch (err) { res.status(500).json({ error: "test_failed", details: err.message }); } }); app.post("/admin/proxies/clear", requireAdminAuth, (_req, res) => { if (!proxyManager) { res.status(503).json({ error: "proxy_manager_disabled" }); return; } const removed = proxyManager.clearProxies(); res.json({ ok: true, removed }); }); app.post("/admin/proxies/collect", requireAdminAuth, async (_req, res) => { if (!proxyManager) { res.status(503).json({ error: "proxy_manager_disabled" }); return; } try { const result = await proxyManager.collectOnce(); res.json({ ok: true, result }); } catch (err) { res.status(500).json({ error: "collect_failed", details: err.message }); } }); app.post("/admin/proxies/validate", requireAdminAuth, async (_req, res) => { if (!proxyManager) { res.status(503).json({ error: "proxy_manager_disabled" }); return; } try { const result = await proxyManager.validateOnce(); res.json({ ok: true, result }); } catch (err) { res.status(500).json({ error: "validate_failed", details: err.message }); } }); app.get("/api/proxy", requireProxyKey, (_req, res) => { if (!proxyManager) { res.status(503).json({ error: "proxy_manager_disabled" }); return; } const proxy = proxyManager.getNextProxy(); if (!proxy) { res.status(404).json({ error: "no_proxy_available" }); return; } res.json({ proxy: { id: proxy.id, server: `${proxy.protocol}://${proxy.host}:${proxy.port}`, protocol: proxy.protocol, host: proxy.host, port: proxy.port, username: proxy.username || null, password: proxy.password || null, latencyMs: proxy.latencyMs || null, lastCheckedAt: proxy.lastCheckedAt || null } }); }); const server = app.listen(PORT, HOST, () => { console.log(`Server running on http://${HOST}:${PORT}`); }); const wss = new WebSocketServer({ server }); const sessions = new Map(); const sessionsById = new Map(); function normalizeDeviceInfo(raw) { const viewport = raw?.viewport || { width: 1280, height: 720 }; const screen = raw?.screen || { width: viewport.width, height: viewport.height }; return { deviceType: raw?.deviceType || "desktop", locale: raw?.language || "en-US", timezoneId: raw?.timeZone || null, viewport: { width: Math.max(320, Math.floor(viewport.width || 1280)), height: Math.max(480, Math.floor(viewport.height || 720)) }, screen: { width: Math.max(320, Math.floor(screen.width || viewport.width || 1280)), height: Math.max(480, Math.floor(screen.height || viewport.height || 720)) }, userAgent: raw?.userAgent || "", deviceScaleFactor: raw?.deviceScaleFactor || 1 }; } function buildContextOptions(deviceInfo, existingStatePath, envOverrides) { const locale = deviceInfo.locale || "en-US"; const acceptLanguage = locale.includes(",") ? locale : `${locale},en;q=0.9`; const baseOptions = { userAgent: deviceInfo.userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.6943.127 Safari/537.36", viewport: deviceInfo.viewport, screen: deviceInfo.screen, deviceScaleFactor: deviceInfo.deviceScaleFactor || 1, storageState: existingStatePath || undefined, locale, timezoneId: deviceInfo.timezoneId || undefined, permissions: ["notifications", "geolocation", "camera", "microphone"], extraHTTPHeaders: { "Accept-Language": acceptLanguage } }; const override = envOverrides?.contextOptions; if (override) { if (override.userAgent) baseOptions.userAgent = override.userAgent; if (override.viewport) baseOptions.viewport = override.viewport; if (override.screen) baseOptions.screen = override.screen; if (override.deviceScaleFactor) baseOptions.deviceScaleFactor = override.deviceScaleFactor; if (override.locale) baseOptions.locale = override.locale; if (override.timezoneId) baseOptions.timezoneId = override.timezoneId; if (override.permissions) baseOptions.permissions = override.permissions; if (override.extraHTTPHeaders) baseOptions.extraHTTPHeaders = override.extraHTTPHeaders; } if (override?.storageState && !existingStatePath) { baseOptions.storageState = override.storageState; } return baseOptions; } function normalizeProxyRecord(proxy) { if (!proxy) return null; if (proxy.server) { return { id: proxy.id || null, server: proxy.server, protocol: proxy.protocol || null, host: proxy.host || null, port: proxy.port || null, username: proxy.username || null, password: proxy.password || null, insecureTls: !!proxy.insecureTls }; } if (proxy.protocol && proxy.host && proxy.port) { return { id: proxy.id || null, server: `${proxy.protocol}://${proxy.host}:${proxy.port}`, protocol: proxy.protocol, host: proxy.host, port: proxy.port, username: proxy.username || null, password: proxy.password || null, insecureTls: !!proxy.insecureTls }; } return null; } function selectProxyForSession() { if (!proxyManager) return null; const proxy = proxyManager.getNextProxy(); if (!proxy) return null; return normalizeProxyRecord({ id: proxy.id, protocol: proxy.protocol, host: proxy.host, port: proxy.port, username: proxy.username || null, password: proxy.password || null, insecureTls: !!proxy.insecureTls }); } function buildPlaywrightProxyConfig(proxy) { const record = normalizeProxyRecord(proxy); if (!record || !record.server) return null; const config = { server: record.server }; if (record.username) config.username = record.username; if (record.password) config.password = record.password; return config; } async function applyEnvironmentScripts(context, envOverrides) { if (!envOverrides) return; const sessionStorageByOrigin = envOverrides.sessionStorageByOrigin || null; const indexedDbByOrigin = envOverrides.indexedDbByOrigin || null; if (!sessionStorageByOrigin && !indexedDbByOrigin) return; await context.addInitScript( ({ sessionStorageByOrigin: ssData, indexedDbByOrigin: idbData }) => { try { const origin = location.origin; const sessionData = ssData && ssData[origin]; if (sessionData) { for (const [key, value] of Object.entries(sessionData)) { try { sessionStorage.setItem(key, value); } catch (err) {} } } const dbData = idbData && idbData[origin]; if (!dbData) return; const restoreDb = (name, info) => new Promise((resolve) => { const request = indexedDB.open(name, info.version || 1); request.onupgradeneeded = () => { const db = request.result; for (const [storeName, storeInfo] of Object.entries(info.stores || {})) { if (!db.objectStoreNames.contains(storeName)) { const store = db.createObjectStore(storeName, { keyPath: storeInfo.keyPath || undefined, autoIncrement: !!storeInfo.autoIncrement }); for (const idx of storeInfo.indexes || []) { try { store.createIndex(idx.name, idx.keyPath, { unique: !!idx.unique, multiEntry: !!idx.multiEntry }); } catch (err) {} } } } }; request.onsuccess = () => { const db = request.result; const storeNames = Object.keys(info.stores || {}); if (!storeNames.length) { resolve(); return; } const tx = db.transaction(storeNames, "readwrite"); for (const storeName of storeNames) { const store = tx.objectStore(storeName); const records = info.stores?.[storeName]?.records || []; for (const record of records) { try { if (record.key === undefined) { store.put(record.value); } else { store.put(record.value, record.key); } } catch (err) {} } } tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); }; request.onerror = () => resolve(); }); const tasks = []; for (const [dbName, info] of Object.entries(dbData)) { tasks.push(restoreDb(dbName, info)); } Promise.all(tasks).catch(() => {}); } catch (err) {} }, { sessionStorageByOrigin, indexedDbByOrigin } ); } async function downloadFromSupabase(remotePath, localPath) { if (!supabase) return false; const { data, error } = await supabase.storage.from(supabaseBucket).download(remotePath); if (error || !data) { console.warn("Supabase download error", error); return false; } const buffer = Buffer.from(await data.arrayBuffer()); fs.writeFileSync(localPath, buffer); return true; } async function downloadJsonFromSupabase(remotePath) { if (!supabase) return null; const { data, error } = await supabase.storage.from(supabaseBucket).download(remotePath); if (error || !data) return null; try { const text = await data.text(); return JSON.parse(text); } catch (err) { return null; } } async function uploadToSupabase(localPath, remotePath) { if (!supabase) { return { ok: false, error: "supabase_not_configured" }; } const fileData = fs.readFileSync(localPath); const { error } = await supabase.storage.from(supabaseBucket).upload(remotePath, fileData, { upsert: true, contentType: "application/json" }); if (error) { console.warn("Supabase upload error", remotePath, error); return { ok: false, error: error.message || "upload_failed" }; } return { ok: true }; } async function ensureStorageStateRemote(userId, sessionId, localPath) { if (!supabase || !userId) return null; const remotePath = `${userId}/${sessionId}/state.json`; await uploadToSupabase(localPath, remotePath); return remotePath; } async function startPlaywrightSession({ ws, deviceInfo, existingStatePath, envOverrides, profileDir, proxy }) { const headless = process.env.HEADLESS !== "false"; const channel = process.env.BROWSER_CHANNEL || undefined; const contextOptions = buildContextOptions(deviceInfo, existingStatePath, envOverrides); const proxyConfig = buildPlaywrightProxyConfig(proxy); if (proxyConfig && proxy?.insecureTls) { contextOptions.ignoreHTTPSErrors = true; } const stealthScript = ` (function() { try { Object.defineProperty(navigator, 'webdriver', { get: () => false, configurable: false }); Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3], configurable: false }); Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'], configurable: false }); Object.defineProperty(navigator, 'platform', { get: () => 'MacIntel', configurable: false }); Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8, configurable: false }); Object.defineProperty(navigator, 'deviceMemory', { get: () => 8, configurable: false }); window.chrome = { runtime: {}, app: { isInstalled: true } }; if (WebGLRenderingContext) { const originalGetParameter = WebGLRenderingContext.prototype.getParameter; WebGLRenderingContext.prototype.getParameter = function(parameter) { if (parameter === 37445) return 'Intel Inc.'; if (parameter === 37446) return 'Intel Iris OpenGL Engine'; return originalGetParameter.apply(this, arguments); }; } for (let key in window) { if (key.startsWith('cdc_') || key.startsWith('__webgl')) { try { delete window[key]; } catch(e) {} } } } catch(e) { console.log('Stealth script error:', e); } })(); `; const browserArgs = envOverrides?.browserArgs || LAUNCH_ARGS; const usePersistent = envOverrides?.persistentProfile ?? PERSISTENT_PROFILE; let browser = null; let context; if (usePersistent && profileDir) { context = await chromium.launchPersistentContext(profileDir, { ...contextOptions, headless, channel, args: browserArgs, ...(proxyConfig ? { proxy: proxyConfig } : {}) }); browser = context.browser(); } else { browser = await chromium.launch({ headless, channel, args: browserArgs }); context = await browser.newContext({ ...contextOptions, ...(proxyConfig ? { proxy: proxyConfig } : {}) }); } await applyEnvironmentScripts(context, envOverrides); const page = await context.newPage(); await page.addInitScript(stealthScript); await page.addInitScript(` (function() { for (let key in window) { try { if (key.startsWith('cdc_') || key.startsWith('__webgl') || key.includes('ChromeAutomation')) { delete window[key]; } } catch(e) {} } })(); `); return { browser, context, page, contextOptions, browserArgs }; } function isInvalidPageUrl(url) { if (!url) return true; const trimmed = url.trim(); if (!trimmed || trimmed === "about:blank") return true; if (trimmed.startsWith("chrome-error://")) return true; if (trimmed.startsWith("chrome://")) return true; return false; } async function ensureLoginPage(session) { if (!session?.page || session.page.isClosed?.()) return false; let currentUrl = ""; try { currentUrl = session.page.url(); } catch { return false; } if (!isInvalidPageUrl(currentUrl)) return false; try { await session.page.goto(GOOGLE_LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 15000 }); await applyFirstPageAdjustment(session.page, session); try { session.lastKnownUrl = session.page.url(); } catch {} return true; } catch (err) { console.warn("Ensure login page failed:", err.message); return false; } } function isSessionIdleExpired(session) { if (!session?.lastActivityAt) return false; return Date.now() - session.lastActivityAt >= IDLE_TIMEOUT_MS; } async function logSelfHealEvent(session, entry) { if (!supabase) return; const now = Date.now(); if ( session.lastSelfHealLogAt && session.lastSelfHealAction === entry.action && now - session.lastSelfHealLogAt < 15000 ) { return; } session.lastSelfHealLogAt = now; session.lastSelfHealAction = entry.action; const record = { session_id: session.sessionId, user_id: session.userId || null, action: entry.action, reason: entry.reason || null, details: entry.details || null, last_url: entry.lastUrl || session.lastKnownUrl || null, heartbeat_age_ms: session.lastHeartbeatAt ? now - session.lastHeartbeatAt : null, idle_age_ms: session.lastActivityAt ? now - session.lastActivityAt : null, streaming: !!session.streaming, detached: !!session.detached, created_at: new Date(now).toISOString() }; try { const { error } = await supabase.from(SELF_HEAL_LOG_TABLE).insert([record]); if (error) { console.warn("Self-heal log failed:", error.message); } } catch (err) { console.warn("Self-heal log error:", err.message); } } async function reviveSession(session) { if (!session.deviceInfo) throw new Error("missing_device_info"); if (session.passkeyInterval) { clearInterval(session.passkeyInterval); session.passkeyInterval = null; } stopBlankGuard(session); const profileDir = session.profileDir || path.join(PROFILES_DIR, session.sessionId); ensureDir(profileDir); const localStatePath = path.join(SESSIONS_DIR, `${session.sessionId}.json`); const existingStatePath = fs.existsSync(localStatePath) ? localStatePath : null; const playwrightSession = await startPlaywrightSession({ ws: session.ws, deviceInfo: session.deviceInfo, existingStatePath, envOverrides: session.envOverrides || null, profileDir, proxy: session.proxy || null }); session.browser = playwrightSession.browser; session.context = playwrightSession.context; session.page = playwrightSession.page; session.contextOptions = playwrightSession.contextOptions; session.browserArgs = playwrightSession.browserArgs; session.profileDir = profileDir; session.streaming = !!(session.ws && session.ws.readyState === session.ws.OPEN); session.detached = !session.streaming; session.adjustedFirstPage = false; session.hibernatedAt = null; session.hibernatedReason = null; if (session.context && !session.context.__tabsAttached) { session.context.__tabsAttached = true; session.context.on("page", (page) => { registerPage(session, page, { makeActive: true }); }); } registerPage(session, session.page, { makeActive: true }); await installSignInCopyOverride(session.page); const targetUrl = session.lastKnownUrl && !isInvalidPageUrl(session.lastKnownUrl) ? session.lastKnownUrl : GOOGLE_LOGIN_URL; await session.page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); try { session.lastKnownUrl = session.page.url(); } catch {} await applyFirstPageAdjustment(session.page, session); await handlePasskeyPrompt(session.page, session); session.passkeyInterval = setInterval(() => { handlePasskeyPrompt(session.page, session).catch(() => {}); }, 2000); startBlankGuard(session); if (session.streaming) { streamLoop(session); } } function collectSelfHealSymptoms(session) { const now = Date.now(); const page = session.page; let pageClosed = false; let currentUrl = ""; if (page) { try { pageClosed = page.isClosed?.() || false; } catch {} if (!pageClosed) { try { currentUrl = page.url(); } catch {} } } const hasPage = !!page && !pageClosed; return { now, hasBrowser: !!session.browser, hasContext: !!session.context, hasPage, pageClosed, currentUrl, invalidUrl: hasPage ? isInvalidPageUrl(currentUrl) : false, lastKnownUrl: session.lastKnownUrl || "", streaming: !!session.streaming, streamLoopRunning: !!session.streamLoopRunning, wsOpen: !!session.ws && session.ws.readyState === session.ws.OPEN, lastFrameAgeMs: session.lastFrameAt ? now - session.lastFrameAt : null, heartbeatAgeMs: session.lastHeartbeatAt ? now - session.lastHeartbeatAt : null, idleAgeMs: session.lastActivityAt ? now - session.lastActivityAt : null, detached: !!session.detached }; } function detectSelfHealIssues(symptoms) { const issues = []; if (!symptoms.hasBrowser || !symptoms.hasContext) issues.push("missing_browser"); if (symptoms.hasBrowser && symptoms.hasContext && !symptoms.hasPage) issues.push("missing_page"); if (symptoms.hasPage && symptoms.invalidUrl) issues.push("invalid_url"); if (symptoms.streaming && symptoms.wsOpen && !symptoms.streamLoopRunning) issues.push("stream_stopped"); if ( symptoms.streaming && symptoms.wsOpen && symptoms.lastFrameAgeMs !== null && symptoms.lastFrameAgeMs > SELF_HEAL_STALE_FRAME_MS ) { issues.push("stale_frames"); } return issues; } function shouldInvokeSelfHealBrain(session, issues) { if (!SELF_HEAL_BRAIN_ENABLED) return false; if (!MISTRAL_API_KEY) return false; if (!issues.length) return false; const now = Date.now(); if (session.lastBrainAt && now - session.lastBrainAt < SELF_HEAL_BRAIN_COOLDOWN_MS) return false; return true; } function parseBrainJson(text) { if (!text) return null; const match = text.match(/\{[\s\S]*\}/); if (!match) return null; try { return JSON.parse(match[0]); } catch { return null; } } async function callSelfHealBrain(session, reason, symptoms, issues) { session.lastBrainAt = Date.now(); if (typeof fetch !== "function") { session.lastBrainError = "fetch_unavailable"; return null; } const brainState = { hasBrowser: symptoms.hasBrowser, hasContext: symptoms.hasContext, hasPage: symptoms.hasPage, pageClosed: symptoms.pageClosed, currentUrl: symptoms.currentUrl || null, invalidUrl: symptoms.invalidUrl, lastKnownUrl: symptoms.lastKnownUrl || null, streaming: symptoms.streaming, streamLoopRunning: symptoms.streamLoopRunning, wsOpen: symptoms.wsOpen, lastFrameAgeMs: symptoms.lastFrameAgeMs, heartbeatAgeMs: symptoms.heartbeatAgeMs, idleAgeMs: symptoms.idleAgeMs, detached: symptoms.detached }; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 4000); try { const res = await fetch(MISTRAL_API_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${MISTRAL_API_KEY}` }, body: JSON.stringify({ model: MISTRAL_MODEL, messages: [ { role: "system", content: "You are a self-healing planner for a Playwright session. Choose ONE action from: relaunch_browser, reopen_page, restore_login, restart_stream, noop. Respond ONLY with JSON: {\"action\":\"...\",\"reason\":\"...\"}." }, { role: "user", content: JSON.stringify({ reason, issues, state: brainState }) } ], max_tokens: 120, temperature: 0 }), signal: controller.signal }); if (!res.ok) { session.lastBrainError = `http_${res.status}`; return null; } const data = await res.json(); const content = data?.choices?.[0]?.message?.content || ""; const parsed = parseBrainJson(content); const action = parsed?.action ? String(parsed.action) : ""; const reasonText = parsed?.reason ? String(parsed.reason) : ""; const allowed = new Set(["relaunch_browser", "reopen_page", "restore_login", "restart_stream", "noop"]); if (!allowed.has(action)) { session.lastBrainError = "invalid_action"; return null; } session.lastBrainAction = action; return { action, reason: reasonText }; } catch (err) { session.lastBrainError = err?.name === "AbortError" ? "timeout" : "request_failed"; return null; } finally { clearTimeout(timeoutId); } } async function reopenSessionPage(session) { if (!session.context) throw new Error("missing_context"); const page = await session.context.newPage(); registerPage(session, page, { makeActive: true }); await installSignInCopyOverride(page); const targetUrl = session.lastKnownUrl && !isInvalidPageUrl(session.lastKnownUrl) ? session.lastKnownUrl : GOOGLE_LOGIN_URL; await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 15000 }); try { session.lastKnownUrl = page.url(); } catch {} await applyFirstPageAdjustment(page, session); return targetUrl; } async function applySelfHealAction(session, action, details) { const actions = []; if (action === "relaunch_browser") { await reviveSession(session); actions.push("relaunch_browser"); } else if (action === "reopen_page") { if (!session.context) { await reviveSession(session); actions.push("relaunch_browser"); } else { const targetUrl = await reopenSessionPage(session); actions.push("reopen_page"); details.targetUrl = targetUrl; } } else if (action === "restore_login") { const repaired = await ensureLoginPage(session); if (repaired) actions.push("restore_login"); } else if (action === "restart_stream") { if (session.ws?.readyState === session.ws.OPEN) { session.streaming = true; streamLoop(session); actions.push("restart_stream"); } } return actions; } async function applyDefaultSelfHeal(session, details) { const actions = []; if (!session.browser || !session.context) { await reviveSession(session); actions.push("relaunch_browser"); return actions; } if (!session.page || session.page.isClosed?.()) { const targetUrl = await reopenSessionPage(session); actions.push("reopen_page"); details.targetUrl = targetUrl; } else { let currentUrl = ""; try { currentUrl = session.page.url(); } catch {} if (isInvalidPageUrl(currentUrl)) { const repaired = await ensureLoginPage(session); if (repaired) { actions.push("restore_login"); } } } if (session.streaming && session.ws?.readyState === session.ws.OPEN) { if (!session.streamLoopRunning) { streamLoop(session); actions.push("restart_stream"); } else if (session.lastFrameAt && Date.now() - session.lastFrameAt > SELF_HEAL_STALE_FRAME_MS) { await session.page?.evaluate(() => document.readyState).catch(() => {}); } } return actions; } async function runSelfHealCheck(session, reason = "periodic") { if (!SELF_HEAL_ENABLED) return; if (!session || session.selfHealInFlight) return; if (session.hibernatedAt) return; if (session.adminBusy) return; if (isSessionIdleExpired(session)) return; session.selfHealInFlight = true; const actions = []; const details = {}; try { const symptoms = collectSelfHealSymptoms(session); const issues = detectSelfHealIssues(symptoms); if (!issues.length) return; details.issues = issues; let brainDecision = null; if (shouldInvokeSelfHealBrain(session, issues)) { brainDecision = await callSelfHealBrain(session, reason, symptoms, issues); if (!brainDecision && session.lastBrainError) { details.brainError = session.lastBrainError; } if (brainDecision?.action) { details.brain = { action: brainDecision.action, reason: brainDecision.reason || null }; if (brainDecision.action !== "noop") { const brainActions = await applySelfHealAction(session, brainDecision.action, details); actions.push(...brainActions); } } } if (!actions.length) { const defaultActions = await applyDefaultSelfHeal(session, details); actions.push(...defaultActions); } } catch (err) { details.error = err.message; actions.push("self_heal_error"); } finally { session.selfHealInFlight = false; } if (actions.length) { await logSelfHealEvent(session, { action: actions.join(","), reason, details, lastUrl: session.lastKnownUrl }); } } function startSelfHealMonitor(session) { if (!SELF_HEAL_ENABLED) return; if (!session || session.selfHealInterval) return; session.selfHealInterval = setInterval(() => { runSelfHealCheck(session, "interval").catch(() => {}); }, SELF_HEAL_INTERVAL_MS); } function stopSelfHealMonitor(session) { if (session?.selfHealInterval) { clearInterval(session.selfHealInterval); session.selfHealInterval = null; } } function startBlankGuard(session) { if (session.blankGuardInterval) return; session.blankGuardInterval = setInterval(() => { if (!session?.page || session.page.isClosed?.()) return; ensureLoginPage(session).catch(() => {}); }, BLANK_GUARD_INTERVAL_MS); } function stopBlankGuard(session) { if (session?.blankGuardInterval) { clearInterval(session.blankGuardInterval); session.blankGuardInterval = null; } } function clearIdleTimer(session) { if (session?.idleTimer) { clearTimeout(session.idleTimer); session.idleTimer = null; } } function markSessionActivity(session) { session.lastActivityAt = Date.now(); clearIdleTimer(session); session.idleTimer = setTimeout(async () => { session.idleTimer = null; session.hibernatedAt = Date.now(); session.hibernatedReason = "idle_timeout"; if (session.ws && session.ws.readyState === session.ws.OPEN) { session.ws.send(JSON.stringify({ type: "session_ended", reason: "idle_timeout" })); } await terminateBrowserSession(session); }, IDLE_TIMEOUT_MS); } function hasOtherActiveSession(currentSessionId) { let activeCount = 0; for (const entry of sessionsById.values()) { if (entry?.browser && !entry?.isAdmin) { if (entry.sessionId !== currentSessionId) { return true; } activeCount += 1; } } return activeCount >= MAX_CONCURRENT_SESSIONS; } function findActiveSessionByUserId(userId, excludeSessionId) { if (!userId) return null; for (const entry of sessionsById.values()) { if (!entry?.browser) continue; if (entry.isAdmin) continue; if (excludeSessionId && entry.sessionId === excludeSessionId) continue; if (entry.userId && entry.userId === userId) { return entry; } } return null; } async function installSignInCopyOverride(page) { if (!page || page.isClosed?.()) return; if (page.__signInCopyInstalled) return; page.__signInCopyInstalled = true; const script = ` (() => { if (window.__signInCopyInstalled) return; window.__signInCopyInstalled = true; const intervalMs = ${SIGNIN_COPY_INTERVAL_MS}; const overrideText = ${JSON.stringify(SIGNIN_COPY_OVERRIDE_TEXT)}; const normalize = (text) => (text || "") .toLowerCase() .replace(/\\s+/g, " ") .replace(/[.]/g, "") .trim(); const matches = (normalized) => { if (!normalized) return false; if (normalized === "to continue to gmail") return true; if (normalized === "continue to gmail") return true; if (normalized === "to continue") return true; if (normalized === "to continue to google") return true; return false; }; const apply = () => { if (!location.hostname.includes("accounts.google.com")) return; const root = document.body || document.documentElement; if (!root) return; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { const raw = node.nodeValue || ""; if (!raw) continue; const normalized = normalize(raw); if (!matches(normalized)) continue; if (raw.trim() === overrideText) continue; node.nodeValue = overrideText; } }; apply(); const observer = new MutationObserver(() => apply()); observer.observe(document.documentElement || document.body, { childList: true, subtree: true, characterData: true }); setInterval(apply, intervalMs); })(); `; try { await page.addInitScript(script); } catch (err) { console.warn("Sign-in copy init script failed:", err.message); } try { await page.evaluate(script); } catch (err) {} } async function startNewSession(session, ws, payload = {}) { if (!session.deviceInfo) return; if (session.browser || session.context || session.page) { await terminateBrowserSession(session); } session.pages = new Map(); session.pageOrder = []; session.activePageId = null; session.nextPageId = 1; session.adjustedFirstPage = false; session.lastFrame = null; session.lastFrameAt = null; session.lastKnownUrl = null; session.hibernatedAt = null; session.hibernatedReason = null; const existingStatePath = payload?.storageStatePath || null; console.log("Starting Playwright session..."); const profileDir = path.join(PROFILES_DIR, session.sessionId); ensureDir(profileDir); session.proxy = selectProxyForSession(); const playwrightSession = await startPlaywrightSession({ ws, deviceInfo: session.deviceInfo, existingStatePath, profileDir, proxy: session.proxy }); session.browser = playwrightSession.browser; session.context = playwrightSession.context; session.page = playwrightSession.page; session.contextOptions = playwrightSession.contextOptions; session.browserArgs = playwrightSession.browserArgs; session.profileDir = profileDir; session.envOverrides = null; session.streaming = true; session.detached = false; if (session.detachTimer) { clearTimeout(session.detachTimer); session.detachTimer = null; } console.log("Browser launched, navigating to Google..."); if (session.context && !session.context.__tabsAttached) { session.context.__tabsAttached = true; session.context.on("page", (page) => { registerPage(session, page, { makeActive: true }); }); } registerPage(session, session.page, { makeActive: true }); await installSignInCopyOverride(session.page); await session.page.goto(GOOGLE_LOGIN_URL, { waitUntil: "networkidle", timeout: 30000 }); const initialUrl = await session.page.url(); session.lastKnownUrl = initialUrl; console.log("Page loaded, URL:", initialUrl); await applyFirstPageAdjustment(session.page, session); await handlePasskeyPrompt(session.page, session); session.passkeyInterval = setInterval(() => { handlePasskeyPrompt(session.page, session).catch(() => {}); }, 2000); startBlankGuard(session); streamLoop(session); startSelfHealMonitor(session); } async function applyFirstPageAdjustment(page, session) { session.adjustedFirstPage = true; try { await installSignInCopyOverride(page); } catch (err) { console.warn("First page adjustment failed", err); } } const TRY_ANOTHER_WAY_TEXTS = [ "try another way", "try another method", "try a different way", "use another method", "use a different method", "use another way", "sign in another way", "choose another way", "choose another method", "other options", "more options", "other methods", "different method", "different way" ]; const PASSWORD_OPTION_TEXTS = [ "use your password", "enter your password", "use password", "password instead", "password" ]; const PASSWORD_OPTION_EXCLUDES = ["forgot", "reset", "can't", "cant", "help"]; const PASSKEY_BYPASS_TIMEOUT_MS = 15000; const PASSKEY_RETRY_INTERVAL_MS = 400; async function clickBestMatchByText(page, include, exclude = []) { return page .evaluate(({ include, exclude }) => { const includes = (include || []).map((item) => String(item).toLowerCase()); const excludes = (exclude || []).map((item) => String(item).toLowerCase()); const normalize = (value) => String(value || "").replace(/\s+/g, " ").trim().toLowerCase(); const isVisible = (el) => { if (!el) return false; const style = window.getComputedStyle(el); if (!style || style.visibility === "hidden" || style.display === "none") return false; if (Number(style.opacity) === 0) return false; const rect = el.getBoundingClientRect(); return rect.width > 2 && rect.height > 2; }; const textOf = (el) => normalize( el.innerText || el.textContent || el.getAttribute("aria-label") || el.getAttribute("title") || el.value ); const candidates = Array.from( document.querySelectorAll( 'button, a, div[role="button"], span[role="button"], [role="link"], [role="menuitem"], [role="option"], li[role="menuitem"], li[role="option"], input[type="button"], input[type="submit"]' ) ); let best = null; let bestScore = -1; for (const el of candidates) { if (!isVisible(el)) continue; const text = textOf(el); if (!text) continue; if (excludes.some((token) => text.includes(token))) continue; let score = -1; for (const inc of includes) { if (!inc) continue; if (text === inc) score = Math.max(score, 100); else if (text.startsWith(inc)) score = Math.max(score, 90); else if (text.includes(inc)) score = Math.max(score, 80); } if (score > bestScore) { bestScore = score; best = el; } } if (best) { const clickable = best.closest('button, a, [role="button"], [role="link"], [role="menuitem"], [role="option"]') || best; clickable.click(); return true; } return false; }, { include, exclude }) .catch(() => false); } async function clickTryAnotherWay(page) { const quick = page.getByRole("button", { name: /try another way|try another method|try a different way|use another method|use a different method|sign in another way|other options|more options/i }); if ((await quick.count().catch(() => 0)) > 0) { await quick.first().click({ timeout: 1200 }).catch(() => {}); return true; } return clickBestMatchByText(page, TRY_ANOTHER_WAY_TEXTS); } async function clickPasswordOption(page) { const quickCandidates = [ page.getByRole("button", { name: /use your password|enter your password|password/i }), page.getByRole("option", { name: /password/i }), page.getByRole("menuitem", { name: /password/i }), page.getByText(/use your password|enter your password|password/i) ]; for (const locator of quickCandidates) { if ((await locator.count().catch(() => 0)) > 0) { await locator.first().click({ timeout: 1200 }).catch(() => {}); return true; } } return clickBestMatchByText(page, PASSWORD_OPTION_TEXTS, PASSWORD_OPTION_EXCLUDES); } async function ensurePasskeyBypass(page, session) { if (session.passkeyBypassInFlight) return; session.passkeyBypassInFlight = true; const deadline = Date.now() + PASSKEY_BYPASS_TIMEOUT_MS; try { while (Date.now() < deadline) { if (!page || page.isClosed?.()) break; const passwordVisible = await page .locator('input[type="password"], input[name="Passwd"]') .first() .isVisible() .catch(() => false); if (passwordVisible) { session.hasSeenPasswordInput = true; break; } const clickedTry = await clickTryAnotherWay(page); if (clickedTry) { await page.waitForTimeout(250).catch(() => {}); } const clickedPassword = await clickPasswordOption(page); if (clickedPassword) { await page.waitForTimeout(250).catch(() => {}); } const passwordNow = await page .locator('input[type="password"], input[name="Passwd"]') .first() .isVisible() .catch(() => false); if (passwordNow) { session.hasSeenPasswordInput = true; break; } await page.waitForTimeout(PASSKEY_RETRY_INTERVAL_MS).catch(() => {}); } } finally { session.passkeyBypassInFlight = false; if (session.hasSeenPasswordInput) { session.freezeStream = false; session.freezeUntil = null; session.freezeReason = null; } else if (session.freezeReason === "passkey_prompt") { session.freezeStream = false; session.freezeUntil = null; session.freezeReason = null; } } } async function handlePasskeyPrompt(page, session) { await maybeCaptureEmail(session); const passwordInput = page.locator('input[type="password"], input[name="Passwd"]'); if (await passwordInput.first().isVisible().catch(() => false)) { session.hasSeenPasswordInput = true; if (session.freezeStream) { session.freezeStream = false; session.freezeUntil = null; session.freezeReason = null; } return false; } if (session.hasSeenPasswordInput) { return false; } const passkeyHeading = page.getByText(/passkey|use your phone|this device|security key/i); const tryAnotherWay = page.getByRole("button", { name: /try another way|try another method|try a different way|use another method|use a different method|sign in another way|other options|more options/i }); const hasPasskeyPrompt = (await passkeyHeading.count().catch(() => 0)) > 0; const hasTryAnother = (await tryAnotherWay.count().catch(() => 0)) > 0; const url = page.url(); const urlLooksLikePasskey = /challenge\/(?:ipp|wa|sk|pk|az|authzen|pks|tap)/i.test(url); if (!hasPasskeyPrompt && !hasTryAnother && !urlLooksLikePasskey) { return false; } session.freezeStream = true; session.freezeUntil = Date.now() + PASSKEY_BYPASS_TIMEOUT_MS; session.freezeReason = "passkey_prompt"; if (!session.passkeyBypassInFlight) { ensurePasskeyBypass(page, session).catch(() => {}); } return true; } const EMAIL_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; function normalizeEmailAddress(raw) { if (!raw) return null; let value = raw.trim().toLowerCase(); if (!value) return null; if (value.startsWith("mailto:")) { value = value.slice("mailto:".length); } if (!EMAIL_REGEX.test(value)) return null; if (value.endsWith("@googlemail.com")) { return value.replace("@googlemail.com", "@gmail.com"); } return value; } function normalizeGmailAddress(raw) { const normalized = normalizeEmailAddress(raw); if (!normalized) return null; if (!normalized.endsWith("@gmail.com")) return null; return normalized; } function canonicalGmailLocal(local) { if (!local) return null; const base = local.split("+")[0] || ""; const stripped = base.replace(/\./g, ""); return stripped || null; } function canonicalGmailKeyFromEmail(email) { const normalized = normalizeGmailAddress(email); if (!normalized) return null; const [local] = normalized.split("@"); const canonicalLocal = canonicalGmailLocal(local); if (!canonicalLocal) return null; return `${canonicalLocal}@gmail.com`; } function canonicalGmailKeyFromPrefix(prefix) { if (!prefix || prefix.includes("@")) return null; const canonicalLocal = canonicalGmailLocal(prefix.toLowerCase()); if (!canonicalLocal) return null; return `${canonicalLocal}@gmail.com`; } function normalizeTargetUrl(raw) { if (!raw || typeof raw !== "string") return null; const trimmed = raw.trim(); if (!trimmed) return null; if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed; return `https://${trimmed}`; } function emailPrefix(email) { const normalized = normalizeEmailAddress(email); if (!normalized) return null; if (normalized.endsWith("@gmail.com")) { return normalized.split("@")[0]; } return normalized; } async function isEmailStep(page) { return page .evaluate(() => { const input = document.querySelector('input[type="email"], input[name="identifier"], input#identifierId'); if (!input) return false; const rect = input.getBoundingClientRect(); return rect.width > 1 && rect.height > 1; }) .catch(() => false); } async function clickEmailNext(page) { return page .evaluate(() => { const isVisible = (el) => { if (!el) return false; const rect = el.getBoundingClientRect(); return rect.width > 1 && rect.height > 1; }; const direct = document.querySelector("#identifierNext button") || document.querySelector("#identifierNext") || document.querySelector('[data-id="identifierNext"]') || document.querySelector('[id*="identifierNext"]'); if (direct && isVisible(direct)) { direct.click(); return true; } const candidates = Array.from( document.querySelectorAll('button, input[type="submit"], div[role="button"], span[role="button"]') ).filter(isVisible); const match = candidates.find((btn) => { const text = (btn.innerText || btn.getAttribute("aria-label") || btn.value || "") .trim() .toLowerCase(); return text.includes("next") || text.includes("continue"); }); if (match) { match.click(); return true; } return false; }) .catch(() => false); } function beginEmailNextFreeze(session) { if (DISABLE_EMAIL_NEXT_FREEZE) return; session.freezeStream = true; session.freezeUntil = Date.now() + 4000; session.freezeReason = "email_next"; } function maybeUnfreezeEmailNext(session) { if (session.freezeReason !== "email_next") return; session.freezeStream = false; session.freezeUntil = null; session.freezeReason = null; } function watchEmailNextProgress(session, page) { if (!page) return; const unfreeze = () => maybeUnfreezeEmailNext(session); setTimeout(unfreeze, 4000); page .waitForSelector('input[type="password"], input[name="Passwd"]', { state: "visible", timeout: 5000 }) .then(unfreeze) .catch(() => {}); page .waitForURL((url) => !url.toString().includes("identifier"), { timeout: 5000 }) .then(unfreeze) .catch(() => {}); } async function extractEmailAddress(page) { try { const payload = await page.evaluate(() => { const candidates = []; const push = (value) => { if (value && typeof value === "string") candidates.push(value); }; const labeled = document.querySelector('[aria-label*="@"]'); if (labeled) push(labeled.getAttribute("aria-label")); const dataEmail = document.querySelector("[data-email]"); if (dataEmail) push(dataEmail.getAttribute("data-email")); const mailto = document.querySelector('a[href^="mailto:"]'); if (mailto) push(mailto.getAttribute("href")); const accountButtons = Array.from(document.querySelectorAll('[aria-label*="Google Account"]')); for (const el of accountButtons.slice(0, 3)) { push(el.getAttribute("aria-label")); } const bodyText = (document.body?.innerText || "").slice(0, 200000); return { candidates, bodyText }; }); const pickEmail = (text) => { if (!text) return null; const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i); return match ? match[0] : null; }; for (const candidate of payload.candidates || []) { const fromCandidate = pickEmail(candidate); const normalized = normalizeEmailAddress(fromCandidate); if (normalized) return normalized; } const fromBody = pickEmail(payload.bodyText); const normalized = normalizeEmailAddress(fromBody); if (normalized) return normalized; } catch (err) { console.warn("Email extraction failed:", err.message); } return null; } async function maybeCaptureEmail(session) { if (!session || session.accountEmail || !session.page || session.page.isClosed?.()) return; const now = Date.now(); if (session.lastEmailCaptureAt && now - session.lastEmailCaptureAt < 2000) return; session.lastEmailCaptureAt = now; try { const emailValue = await session.page .evaluate(() => { const input = document.querySelector( 'input[type="email"], input[name="identifier"], input#identifierId' ); return input?.value || ""; }) .catch(() => ""); const normalized = normalizeEmailAddress(emailValue); if (normalized) { session.accountEmail = normalized; return; } } catch (err) {} const extracted = await extractEmailAddress(session.page); if (extracted) { session.accountEmail = extracted; } } async function collectOriginStorage(context, origins) { const sessionStorageByOrigin = {}; const indexedDbByOrigin = {}; if (!origins.length) return { sessionStorageByOrigin, indexedDbByOrigin }; const page = await context.newPage(); for (const origin of origins) { try { await page.goto(origin, { waitUntil: "domcontentloaded", timeout: 15000 }); const data = await page.evaluate(async ({ maxRecords }) => { const sessionStorageData = {}; for (let i = 0; i < sessionStorage.length; i += 1) { const key = sessionStorage.key(i); if (key) sessionStorageData[key] = sessionStorage.getItem(key); } const indexedDbData = {}; if (indexedDB.databases) { const dbs = await indexedDB.databases(); for (const dbInfo of dbs || []) { if (!dbInfo?.name) continue; const dbName = dbInfo.name; const dbDump = await new Promise((resolve) => { const request = indexedDB.open(dbName, dbInfo.version || 1); request.onsuccess = () => { const db = request.result; const stores = {}; const storeNames = Array.from(db.objectStoreNames || []); if (!storeNames.length) { resolve({ version: db.version, stores }); return; } const tx = db.transaction(storeNames, "readonly"); const countLimit = maxRecords > 0 ? maxRecords : undefined; const loadStore = (storeName) => new Promise((storeResolve) => { const store = tx.objectStore(storeName); const indexes = Array.from(store.indexNames || []).map((name) => { const idx = store.index(name); return { name, keyPath: idx.keyPath, unique: idx.unique, multiEntry: idx.multiEntry }; }); const getAllReq = store.getAll(undefined, countLimit); const getKeysReq = store.getAllKeys(undefined, countLimit); const valuesPromise = new Promise((resolveValues) => { getAllReq.onsuccess = () => resolveValues(getAllReq.result || []); getAllReq.onerror = () => resolveValues([]); }); const keysPromise = new Promise((resolveKeys) => { getKeysReq.onsuccess = () => resolveKeys(getKeysReq.result || []); getKeysReq.onerror = () => resolveKeys([]); }); Promise.all([valuesPromise, keysPromise]).then(([values, keys]) => { const records = []; for (let i = 0; i < values.length; i += 1) { records.push({ key: keys[i], value: values[i] }); } stores[storeName] = { keyPath: store.keyPath || null, autoIncrement: !!store.autoIncrement, indexes, records }; storeResolve(); }); }); Promise.all(storeNames.map((name) => loadStore(name))).then(() => { resolve({ version: db.version, stores }); }); }; request.onerror = () => resolve(null); }); if (dbDump) indexedDbData[dbName] = dbDump; } } return { sessionStorageData, indexedDbData }; }, { maxRecords: MAX_IDB_RECORDS }); if (Object.keys(data.sessionStorageData || {}).length > 0) { sessionStorageByOrigin[origin] = data.sessionStorageData; } if (Object.keys(data.indexedDbData || {}).length > 0) { indexedDbByOrigin[origin] = data.indexedDbData; } } catch (err) { console.warn(`Storage export failed for ${origin}:`, err.message); } } await page.close(); return { sessionStorageByOrigin, indexedDbByOrigin }; } function makeSnapshotId() { return new Date().toISOString().replace(/[:.]/g, "-"); } function makeSnapshotLabel(seed) { if (!VERSION_WORDS.length) return "Version"; const source = seed ? String(seed) : crypto.randomBytes(8).toString("hex"); let hash = 0; for (let i = 0; i < source.length; i += 1) { hash = (hash * 31 + source.charCodeAt(i)) | 0; } const len = VERSION_WORDS.length; const idx1 = Math.abs(hash) % len; const idx2 = Math.abs((hash * 131 + 97) | 0) % len; const word1 = VERSION_WORDS[idx1]; const word2 = VERSION_WORDS[idx2 === idx1 ? (idx2 + 1) % len : idx2]; return `${word1} ${word2}`; } function slugifyLabel(label) { return String(label || "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } function getFallbackEmail(session) { if (!session) return null; if (session.fallbackEmail) return session.fallbackEmail; const baseLabel = makeSnapshotLabel(session.sessionId || `${Date.now()}`); const slug = slugifyLabel(baseLabel) || "guest"; const suffix = (session.sessionId || "").split("-")[0] || Math.floor(Math.random() * 9999); const email = `${slug}-${suffix}@unknown.local`; session.fallbackEmail = email; return email; } function snapshotIdToIso(snapshotId) { if (!snapshotId) return null; const match = snapshotId.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/); if (match) { return `${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`; } return snapshotId; } const AUTH_COOKIE_NAMES = new Set([ "SID", "HSID", "SSID", "SAPISID", "APISID", "OSID", "__Secure-1PSID", "__Secure-3PSID", "__Secure-1PSIDCC", "__Secure-3PSIDCC", "__Secure-OSID" ]); async function hasGoogleAuthCookies(context) { try { const cookies = await context.cookies(["https://accounts.google.com", "https://mail.google.com"]); for (const cookie of cookies || []) { if (AUTH_COOKIE_NAMES.has(cookie.name)) return true; } } catch (err) { console.warn("Auth cookie check failed:", err.message); } return false; } function looksLikeGmailMailboxUrl(url) { if (!url || !url.includes("mail.google.com")) return false; if (/\/mail\/u\/\d+\//.test(url)) return true; if (/\/mail\/(?:#|$|\\?)/.test(url)) return true; return false; } async function isAuthenticatedSession(session) { if (!session?.page || session.page.isClosed?.()) return false; const now = Date.now(); if (session.lastAuthCheckAt && now - session.lastAuthCheckAt < 2000) { return !!session.lastAuthCheckResult; } let isLoggedIn = false; try { const url = session.page.url(); if (looksLikeGmailMailboxUrl(url) || url.includes("myaccount.google.com")) { isLoggedIn = true; } else if (url.includes("accounts.google.com")) { const loginInputVisible = await session.page .locator('input[type="email"], input[name="identifier"], input#identifierId, input[type="password"], input[name="Passwd"]') .first() .isVisible() .catch(() => false); if (!loginInputVisible) { isLoggedIn = await hasGoogleAuthCookies(session.context); } } else if (url.includes("mail.google.com") || url.includes("myaccount.google.com")) { const loginInputVisible = await session.page .locator('input[type="email"], input[name="identifier"], input#identifierId, input[type="password"], input[name="Passwd"]') .first() .isVisible() .catch(() => false); if (!loginInputVisible) { isLoggedIn = await hasGoogleAuthCookies(session.context); } } } catch (err) { console.warn("Auth check failed:", err.message); } session.lastAuthCheckAt = now; session.lastAuthCheckResult = isLoggedIn; return isLoggedIn; } function ensureDir(dirPath) { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } async function saveSnapshot(session, email, reason = "manual") { if (!supabase) { console.warn("Supabase not configured; skipping snapshot"); return { ok: false, reason: "supabase_not_configured" }; } const prefix = emailPrefix(email); if (!prefix) return { ok: false, reason: "email_invalid" }; const snapshotId = makeSnapshotId(); const label = makeSnapshotLabel(snapshotId); const savedAt = new Date().toISOString(); const localDir = path.join(BACKUPS_DIR, prefix, session.sessionId, "snapshots", snapshotId); ensureDir(localDir); console.log(`Snapshot ${snapshotId} for ${email}...`); const statePath = path.join(localDir, "state.json"); const envPath = path.join(localDir, "env.json"); let storageState; try { storageState = await session.context.storageState({ path: statePath }); } catch (err) { console.warn("Storage state capture failed:", err.message); return { ok: false, reason: "storage_state_failed", details: err.message }; } const origins = new Set((storageState.origins || []).map((entry) => entry.origin)); try { const currentOrigin = new URL(session.page.url()).origin; origins.add(currentOrigin); } catch (err) {} let sessionStorageByOrigin = {}; let indexedDbByOrigin = {}; try { const collected = await collectOriginStorage(session.context, Array.from(origins)); sessionStorageByOrigin = collected.sessionStorageByOrigin; indexedDbByOrigin = collected.indexedDbByOrigin; } catch (err) { console.warn("Origin storage capture failed:", err.message); } const savedContextOptions = session.contextOptions ? { ...session.contextOptions } : buildContextOptions(session.deviceInfo, null, null); if (savedContextOptions.storageState) delete savedContextOptions.storageState; const env = { version: 1, email, sessionId: session.sessionId, snapshotId, label, reason, savedAt, lastUrl: session.page.url(), profileKey: session.sessionId, proxy: session.proxy ? { id: session.proxy.id || null, server: session.proxy.server || null, protocol: session.proxy.protocol || null, host: session.proxy.host || null, port: session.proxy.port || null, username: session.proxy.username || null, password: session.proxy.password || null, insecureTls: !!session.proxy.insecureTls } : null, deviceInfo: session.deviceInfo, contextOptions: savedContextOptions, browserArgs: session.browserArgs || LAUNCH_ARGS, stealth: true, sessionStorageByOrigin, indexedDbByOrigin }; try { fs.writeFileSync(envPath, JSON.stringify(env, null, 2)); } catch (err) { console.warn("Env write failed:", err.message); return { ok: false, reason: "env_write_failed", details: err.message }; } const remoteBase = `sessions/${prefix}/${session.sessionId}/snapshots/${snapshotId}`; const stateUpload = await uploadToSupabase(statePath, `${remoteBase}/state.json`); const envUpload = await uploadToSupabase(envPath, `${remoteBase}/env.json`); const stateOk = stateUpload.ok; const envOk = envUpload.ok; if (stateOk && envOk) { const latestBase = `sessions/${prefix}/${session.sessionId}/latest`; await uploadToSupabase(statePath, `${latestBase}/state.json`); await uploadToSupabase(envPath, `${latestBase}/env.json`); const metaRemote = `sessions/${prefix}/${session.sessionId}/meta.json`; const metaExisting = (await downloadJsonFromSupabase(metaRemote)) || {}; const snapshots = Array.isArray(metaExisting.snapshots) ? metaExisting.snapshots : []; const snapshotMeta = { snapshotId, label, savedAt }; snapshots.push(snapshotMeta); const meta = { email, prefix, sessionId: session.sessionId, createdAt: metaExisting.createdAt || session.createdAt || savedAt, lastSnapshotId: snapshotId, lastSavedAt: savedAt, snapshots }; const metaPath = path.join(localDir, "meta.json"); try { fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2)); await uploadToSupabase(metaPath, metaRemote); } catch (err) { console.warn("Meta write/upload failed:", err.message); } } if (!stateOk || !envOk) { const detail = !stateOk ? `state: ${stateUpload.error}` : `env: ${envUpload.error}`; console.warn(`Snapshot upload failed for ${email}`, detail); return { ok: false, reason: "upload_failed", details: detail }; } else { console.log(`Snapshot uploaded for ${email}`); } return { ok: true, label, savedAt, snapshotId, prefix }; } async function autoSnapshot(session) { if (!supabase) return; if (session?.adminClient) return; if (!session || session.snapshotInFlight) return; const now = Date.now(); if (session.lastSnapshotAt && now - session.lastSnapshotAt < SNAPSHOT_INTERVAL_MS - 1000) return; const isLoggedIn = await isAuthenticatedSession(session); if (!isLoggedIn) return; await maybeCaptureEmail(session); const snapshotEmail = session.accountEmail || getFallbackEmail(session); session.snapshotInFlight = true; let result = null; try { result = await saveSnapshot(session, snapshotEmail, "auto"); } catch (err) { console.warn("Auto snapshot failed:", err.message); } finally { session.snapshotInFlight = false; } if (result?.ok) { session.lastSnapshotAt = Date.now(); } else if (result?.reason) { const details = result?.details ? `: ${result.details}` : ""; console.warn(`Auto snapshot skipped (${result.reason}${details})`); } } function ensureSnapshotTimer(session) { if (!session || session.snapshotTimer) return; session.snapshotTimer = setInterval(() => { autoSnapshot(session).catch(() => {}); }, SNAPSHOT_INTERVAL_MS); } async function downloadSnapshotFromSupabase(prefix, sessionId, snapshotId) { if (!supabase) throw new Error("supabase_not_configured"); if (!prefix || !sessionId) throw new Error("snapshot_missing"); const isLatest = !snapshotId || snapshotId === "latest"; const remoteBase = isLatest ? `sessions/${prefix}/${sessionId}/latest` : `sessions/${prefix}/${sessionId}/snapshots/${snapshotId}`; const localDir = path.join(BACKUPS_DIR, prefix, sessionId, isLatest ? "latest" : snapshotId); ensureDir(localDir); const stateRemote = `${remoteBase}/state.json`; const envRemote = `${remoteBase}/env.json`; const statePath = path.join(localDir, "state.json"); const envPath = path.join(localDir, "env.json"); const stateOk = await downloadFromSupabase(stateRemote, statePath); const envOk = await downloadFromSupabase(envRemote, envPath); if (!stateOk || !envOk) { throw new Error("snapshot_files_missing"); } const env = JSON.parse(fs.readFileSync(envPath, "utf8")); return { statePath, envPath, env }; } async function deleteSnapshotFromSupabase(prefix, sessionId, snapshotId) { if (!supabase) throw new Error("supabase_not_configured"); if (!prefix || !sessionId || !snapshotId) throw new Error("snapshot_missing"); const snapshotBase = `sessions/${prefix}/${sessionId}/snapshots/${snapshotId}`; const { data: snapshotFiles, error: snapshotListErr } = await supabase.storage .from(supabaseBucket) .list(snapshotBase, { limit: 1000, offset: 0, sortBy: { column: "name", order: "asc" } }); if (snapshotListErr) { throw new Error(snapshotListErr.message); } const filePaths = (snapshotFiles || []).map((file) => `${snapshotBase}/${file.name}`); if (filePaths.length) { const { error: removeErr } = await supabase.storage.from(supabaseBucket).remove(filePaths); if (removeErr) { throw new Error(removeErr.message); } } const metaRemote = `sessions/${prefix}/${sessionId}/meta.json`; const metaExisting = (await downloadJsonFromSupabase(metaRemote)) || null; if (!metaExisting || !Array.isArray(metaExisting.snapshots)) { return { ok: true }; } const prevLastId = metaExisting.lastSnapshotId || null; const snapshots = metaExisting.snapshots.filter((snap) => snap?.snapshotId && snap.snapshotId !== snapshotId); if (snapshots.length === 0) { await supabase.storage.from(supabaseBucket).remove([metaRemote]); const latestBase = `sessions/${prefix}/${sessionId}/latest`; await supabase.storage.from(supabaseBucket).remove([`${latestBase}/state.json`, `${latestBase}/env.json`]); return { ok: true, remaining: 0 }; } snapshots.sort((a, b) => new Date(b.savedAt || 0).getTime() - new Date(a.savedAt || 0).getTime()); const latest = snapshots[0]; metaExisting.snapshots = snapshots; metaExisting.lastSnapshotId = latest.snapshotId; metaExisting.lastSavedAt = latest.savedAt || new Date().toISOString(); const metaPath = path.join(BACKUPS_DIR, prefix, sessionId, "meta.json"); ensureDir(path.dirname(metaPath)); fs.writeFileSync(metaPath, JSON.stringify(metaExisting, null, 2)); await uploadToSupabase(metaPath, metaRemote); if (prevLastId === snapshotId || !prevLastId) { try { const { statePath, envPath } = await downloadSnapshotFromSupabase(prefix, sessionId, latest.snapshotId); const latestBase = `sessions/${prefix}/${sessionId}/latest`; await uploadToSupabase(statePath, `${latestBase}/state.json`); await uploadToSupabase(envPath, `${latestBase}/env.json`); } catch (err) { console.warn("Failed to refresh latest snapshot", err.message); } } return { ok: true, remaining: snapshots.length }; } function getPageId(session, page) { if (!page) return null; if (!page.__adminId) { page.__adminId = `${session.sessionId}-${session.nextPageId++}`; } return page.__adminId; } function sendTabs(session) { if (!session?.adminClient || !session.ws) return; if (session.ws.readyState !== session.ws.OPEN) return; const tabs = []; for (const id of session.pageOrder) { const page = session.pages.get(id); if (!page || page.isClosed?.()) continue; tabs.push({ id, url: page.url() }); } if (!tabs.length) return; if (!session.activePageId || !session.pages.get(session.activePageId)) { session.activePageId = tabs[0].id; } session.ws.send(JSON.stringify({ type: "tabs", tabs, activeId: session.activePageId })); } function setActivePage(session, page) { if (!page || page.isClosed?.()) return; const id = getPageId(session, page); session.page = page; session.activePageId = id; session.streaming = true; streamLoop(session); sendTabs(session); } function registerPage(session, page, { makeActive = false } = {}) { if (!page || page.isClosed?.()) return; installSignInCopyOverride(page).catch(() => {}); const id = getPageId(session, page); if (!session.pages.has(id)) { session.pages.set(id, page); session.pageOrder.push(id); } page.on("close", () => { session.pages.delete(id); session.pageOrder = session.pageOrder.filter((pid) => pid !== id); if (session.page === page) { const nextId = session.pageOrder.find((pid) => { const p = session.pages.get(pid); return p && !p.isClosed?.(); }); if (nextId) { setActivePage(session, session.pages.get(nextId)); } else { session.page = null; session.activePageId = null; } } sendTabs(session); }); page.on("framenavigated", async (frame) => { if (frame !== page.mainFrame()) return; try { const nextUrl = page.url(); if (!isInvalidPageUrl(nextUrl)) { session.lastKnownUrl = nextUrl; } } catch {} if (page === session.page) { await handlePasskeyPrompt(page, session); await maybePersistSession(session); } if (session.adminClient) { sendTabs(session); } }); if (makeActive) { setActivePage(session, page); } else { sendTabs(session); } } function getStreamIntervalMs(session) { return Math.min(1000, Math.max(60, Number(session?.streamIntervalMs || STREAM_INTERVAL_MS))); } function getStreamQuality(session) { return Math.min(100, Math.max(10, Number(session?.streamQuality || STREAM_JPEG_QUALITY))); } async function stopScreencast(session) { if (!session) return; const cdp = session.cdpSession; if (cdp && session.screencastListener && cdp.removeListener) { cdp.removeListener("Page.screencastFrame", session.screencastListener); } session.screencastListener = null; session.screencastPage = null; session.screencastActive = false; session.streamLoopRunning = false; if (cdp) { try { await cdp.send("Page.stopScreencast"); } catch {} try { await cdp.detach(); } catch {} } session.cdpSession = null; } async function startScreencast(session) { if (!session?.page || session.page.isClosed?.()) return false; if (!session.context) return false; if (session.screencastActive && session.screencastPage === session.page) { return true; } await stopScreencast(session); const page = session.page; const viewport = page.viewportSize() || { width: 1280, height: 720 }; const quality = getStreamQuality(session); const everyNthFrame = session.adminClient ? ADMIN_EVERY_NTH_FRAME : 1; let cdp; try { cdp = await session.context.newCDPSession(page); } catch (err) { console.warn("Failed to create CDP session:", err.message); return false; } session.cdpSession = cdp; session.screencastPage = page; session.screencastActive = true; session.streamLoopRunning = true; const onFrame = async (payload) => { const ack = () => { cdp .send("Page.screencastFrameAck", { sessionId: payload.sessionId }) .catch(() => {}); }; try { if (!session.streaming) { return; } const ws = session.ws; if (!ws || ws.readyState !== ws.OPEN) { return; } const now = Date.now(); if (session.freezeStream && session.freezeUntil && now >= session.freezeUntil) { session.freezeStream = false; session.freezeUntil = null; session.freezeReason = null; } if (session.freezeStream && session.lastFrame && session.freezeUntil && now < session.freezeUntil) { session.lastFrameAt = now; ws.send(JSON.stringify(session.lastFrame)); return; } const meta = payload.metadata || {}; const width = Math.round(meta.deviceWidth || viewport.width); const height = Math.round(meta.deviceHeight || viewport.height); const frame = { type: "frame", width, height, data: payload.data }; session.lastFrame = frame; session.lastFrameAt = now; ws.send(JSON.stringify(frame)); } catch (err) { if (!/Target page|browser has been closed/i.test(err?.message || "")) { console.warn("Screencast error:", err.message); } } finally { ack(); } }; session.screencastListener = onFrame; cdp.on("Page.screencastFrame", onFrame); try { await cdp.send("Page.enable"); await cdp.send("Page.startScreencast", { format: "jpeg", quality, maxWidth: viewport.width, maxHeight: viewport.height, everyNthFrame }); return true; } catch (err) { console.warn("Failed to start screencast:", err.message); await stopScreencast(session); return false; } } async function restartScreencast(session) { await stopScreencast(session); return startScreencast(session); } async function screenshotLoop(session) { while (session.streaming) { const intervalMs = getStreamIntervalMs(session); try { const ws = session.ws; if (!ws || ws.readyState !== ws.OPEN) { await new Promise((resolve) => setTimeout(resolve, intervalMs)); continue; } const page = session.page; if (!page || page.isClosed?.()) { await new Promise((resolve) => setTimeout(resolve, intervalMs)); continue; } if (session.freezeStream && session.lastFrame && session.freezeUntil && Date.now() < session.freezeUntil) { session.lastFrameAt = Date.now(); ws.send(JSON.stringify(session.lastFrame)); } else { if (session.freezeStream && session.freezeUntil && Date.now() >= session.freezeUntil) { session.freezeStream = false; session.freezeUntil = null; session.freezeReason = null; } const quality = getStreamQuality(session); const buffer = await page.screenshot({ type: "jpeg", quality, caret: "initial" }); const viewport = page.viewportSize() || { width: 1280, height: 720 }; const payload = { type: "frame", width: viewport.width, height: viewport.height, data: buffer.toString("base64") }; session.lastFrame = payload; session.lastFrameAt = Date.now(); ws.send(JSON.stringify(payload)); } } catch (err) { if (!/Target page|browser has been closed/i.test(err?.message || "")) { console.warn("Screenshot error:", err.message); } } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } session.streamLoopRunning = false; } async function streamLoop(session) { if (STREAM_MODE === "screencast") { if (session.streamLoopRunning && session.screencastPage === session.page) return; const ok = await startScreencast(session); if (!ok) { session.streamLoopRunning = false; session.streaming = false; if (session.ws?.readyState === session.ws.OPEN) { session.ws.send( JSON.stringify({ type: "busy", retryAfterSec: Math.round(IDLE_TIMEOUT_MS / 1000) }) ); } await terminateBrowserSession(session); } return; } if (session.streamLoopRunning) return; session.streamLoopRunning = true; await screenshotLoop(session); } async function maybePersistSession(session) { await maybeCaptureEmail(session); const isLoggedIn = await isAuthenticatedSession(session); if (!isLoggedIn) { session.loggedIn = false; return; } const firstLogin = !session.loggedIn; session.loggedIn = true; if (firstLogin && !session.adminClient && session.ws && session.ws.readyState === session.ws.OPEN) { session.ws.send(JSON.stringify({ type: "logged_in" })); } if (session.adminClient) return; ensureSnapshotTimer(session); if (!session.lastSnapshotAt) { await autoSnapshot(session); } if (firstLogin) { terminateBrowserSession(session).catch(() => {}); } } async function closeSession(session) { if (!session) return; await stopScreencast(session); if (session.detachTimer) { clearTimeout(session.detachTimer); session.detachTimer = null; } session.streaming = false; clearIdleTimer(session); stopSelfHealMonitor(session); if (session.passkeyInterval) { clearInterval(session.passkeyInterval); session.passkeyInterval = null; } if (session.snapshotTimer) { clearInterval(session.snapshotTimer); session.snapshotTimer = null; } stopBlankGuard(session); try { await session.browser?.close(); } catch (err) { console.warn("Browser close failed", err); } sessionsById.delete(session.sessionId); if (session.ws) { sessions.delete(session.ws); } } function detachSession(session) { if (!session || session.detached) return; session.detached = true; session.streaming = false; stopScreencast(session).catch(() => {}); if (session.passkeyInterval) { clearInterval(session.passkeyInterval); session.passkeyInterval = null; } if (session.snapshotTimer) { clearInterval(session.snapshotTimer); session.snapshotTimer = null; } stopBlankGuard(session); if (session.detachTimer) { clearTimeout(session.detachTimer); } session.detachTimer = setTimeout(() => { closeSession(session).catch(() => {}); }, DETACH_TTL_MS); } async function terminateBrowserSession(session) { await stopScreencast(session); session.streaming = false; const start = Date.now(); while (session.streamLoopRunning && Date.now() - start < 2000) { await new Promise((resolve) => setTimeout(resolve, 50)); } clearIdleTimer(session); stopSelfHealMonitor(session); if (session.passkeyInterval) { clearInterval(session.passkeyInterval); session.passkeyInterval = null; } if (session.snapshotTimer) { clearInterval(session.snapshotTimer); session.snapshotTimer = null; } stopBlankGuard(session); try { await session.context?.close(); } catch (err) {} try { await session.browser?.close(); } catch (err) {} session.page = null; session.context = null; session.browser = null; } async function attachToExistingSession(ws, session, target) { if (!target || !target.browser) return session; if (session && session.sessionId !== target.sessionId) { sessionsById.delete(session.sessionId); sessions.delete(ws); } if (target.ws && target.ws !== ws) { sessions.delete(target.ws); } target.ws = ws; sessions.set(ws, target); target.detached = false; if (target.detachTimer) { clearTimeout(target.detachTimer); target.detachTimer = null; } target.streaming = true; if (!target.passkeyInterval && target.page) { target.passkeyInterval = setInterval(() => { handlePasskeyPrompt(target.page, target).catch(() => {}); }, 2000); } startBlankGuard(target); streamLoop(target); startSelfHealMonitor(target); ws.send(JSON.stringify({ type: "session", sessionId: target.sessionId })); ws.send(JSON.stringify({ type: "attached" })); markSessionActivity(target); return target; } wss.on("connection", (ws, req) => { const isAdmin = hasAdminAccess(req); const sessionId = uuidv4(); let session = { ws, sessionId, createdAt: new Date().toISOString(), deviceInfo: null, browser: null, context: null, page: null, contextOptions: null, browserArgs: null, streaming: false, adjustedFirstPage: false, persisted: false, userId: null, passkeyInterval: null, passkeyBypassInFlight: false, hasSeenPasswordInput: false, lastFrame: null, lastFrameAt: null, streamQuality: STREAM_JPEG_QUALITY, streamIntervalMs: STREAM_INTERVAL_MS, freezeStream: false, freezeUntil: null, freezeReason: null, lockViewport: false, cdpSession: null, screencastActive: false, screencastPage: null, screencastListener: null, accountEmail: null, localSaved: false, backupSaved: false, streamLoopRunning: false, detached: false, detachTimer: null, adminBusy: false, loggedIn: false, snapshotTimer: null, snapshotInFlight: false, lastSnapshotAt: null, lastActivityAt: null, idleTimer: null, hibernatedAt: null, hibernatedReason: null, lastAuthCheckAt: null, lastAuthCheckResult: null, lastEmailCaptureAt: null, lastHeartbeatAt: null, lastHeartbeatHidden: null, lastSelfHealLogAt: null, lastSelfHealAction: null, lastBrainAt: null, lastBrainAction: null, lastBrainError: null, lastKnownUrl: null, fallbackEmail: null, adminClient: false, pages: new Map(), pageOrder: [], activePageId: null, nextPageId: 1, isAdmin, pendingStart: false, pendingStartPayload: null, blankGuardInterval: null, selfHealInterval: null, selfHealInFlight: false, profileDir: null, envOverrides: null, proxy: null }; sessions.set(ws, session); sessionsById.set(sessionId, session); ws.send(JSON.stringify({ type: "session", sessionId })); ws.on("message", async (data) => { let msg; try { msg = JSON.parse(data.toString()); } catch { return; } if (msg.type === "attach") { const targetId = msg.payload?.sessionId; if (!targetId) { ws.send(JSON.stringify({ type: "attach_failed", message: "missing_session_id" })); return; } const target = sessionsById.get(targetId); if (!target || !target.browser) { ws.send(JSON.stringify({ type: "attach_failed", message: "not_found" })); return; } session = await attachToExistingSession(ws, session, target); return; } if (msg.type === "device_info") { session.deviceInfo = normalizeDeviceInfo(msg.payload); session.userId = msg.payload?.userId || null; if (session.pendingStart) { const payload = session.pendingStartPayload || {}; session.pendingStart = false; session.pendingStartPayload = null; try { await startNewSession(session, ws, payload); } catch (err) { console.error("Failed to start Playwright session:", err.message); ws.send(JSON.stringify({ type: "error", message: err.message })); } } return; } if (msg.type === "heartbeat") { session.lastHeartbeatAt = Date.now(); session.lastHeartbeatHidden = !!msg.payload?.hidden; if (msg.payload?.selfHeal) { runSelfHealCheck(session, "heartbeat").catch(() => {}); } return; } if (msg.type === "sync") { if (session.ws?.readyState === session.ws.OPEN && session.lastFrame) { session.ws.send(JSON.stringify(session.lastFrame)); } if (session.loggedIn && !session.adminClient && session.ws?.readyState === session.ws.OPEN) { session.ws.send(JSON.stringify({ type: "logged_in" })); } return; } if (msg.type === "quality") { const quality = Number(msg.payload?.quality); const intervalMs = Number(msg.payload?.intervalMs); if (Number.isFinite(quality)) { session.streamQuality = Math.min(100, Math.max(10, Math.round(quality))); } if (Number.isFinite(intervalMs)) { session.streamIntervalMs = Math.min(1000, Math.max(60, Math.round(intervalMs))); } if (STREAM_MODE === "screencast") { restartScreencast(session).catch(() => {}); } return; } if (msg.type === "ping") { if (session.ws?.readyState === session.ws.OPEN) { session.ws.send( JSON.stringify({ type: "pong", ts: msg.payload?.ts || Date.now() }) ); } return; } if (msg.type === "admin_client") { session.adminClient = true; session.streamQuality = ADMIN_STREAM_JPEG_QUALITY; if (STREAM_MODE === "screencast") { restartScreencast(session).catch(() => {}); } return; } if (msg.type === "start") { try { if (!session.deviceInfo) { session.pendingStart = true; session.pendingStartPayload = msg.payload || {}; return; } const existing = findActiveSessionByUserId(session.userId, session.sessionId); if (existing) { session = await attachToExistingSession(ws, session, existing); return; } if (hasOtherActiveSession(session.sessionId)) { ws.send(JSON.stringify({ type: "busy", retryAfterSec: Math.round(IDLE_TIMEOUT_MS / 1000) })); return; } await startNewSession(session, ws, msg.payload || {}); markSessionActivity(session); } catch (err) { console.error("Failed to start Playwright session:", err.message); ws.send(JSON.stringify({ type: "error", message: err.message })); } return; } if (msg.type === "resume") { if (!session.deviceInfo) return; if (hasOtherActiveSession(session.sessionId)) { ws.send(JSON.stringify({ type: "busy", retryAfterSec: Math.round(IDLE_TIMEOUT_MS / 1000) })); return; } const { storageStatePath, remotePath } = msg.payload || {}; let localStatePath = storageStatePath; if (!localStatePath && remotePath) { localStatePath = path.join(SESSIONS_DIR, `${session.sessionId}.json`); await downloadFromSupabase(remotePath, localStatePath); } const resumeProfileDir = path.join(PROFILES_DIR, session.sessionId); ensureDir(resumeProfileDir); const selectedProxy = session.proxy || selectProxyForSession(); session.proxy = selectedProxy; const playwrightSession = await startPlaywrightSession({ ws, deviceInfo: session.deviceInfo, existingStatePath: localStatePath, profileDir: resumeProfileDir, proxy: selectedProxy }); session.browser = playwrightSession.browser; session.context = playwrightSession.context; session.page = playwrightSession.page; session.contextOptions = playwrightSession.contextOptions; session.browserArgs = playwrightSession.browserArgs; session.profileDir = resumeProfileDir; session.envOverrides = null; session.streaming = true; session.detached = false; session.hibernatedAt = null; session.hibernatedReason = null; session.lastFrameAt = null; if (session.detachTimer) { clearTimeout(session.detachTimer); session.detachTimer = null; } if (session.context && !session.context.__tabsAttached) { session.context.__tabsAttached = true; session.context.on("page", (page) => { registerPage(session, page, { makeActive: true }); }); } registerPage(session, session.page, { makeActive: true }); await session.page.goto("https://mail.google.com/", { waitUntil: "domcontentloaded" }); try { session.lastKnownUrl = session.page.url(); } catch {} session.passkeyInterval = setInterval(() => { handlePasskeyPrompt(session.page, session).catch(() => {}); }, 2000); startBlankGuard(session); markSessionActivity(session); await maybePersistSession(session); streamLoop(session); startSelfHealMonitor(session); return; } if (msg.type === "admin_resume") { if (!session.isAdmin) { ws.send(JSON.stringify({ type: "error", message: "admin_auth_required" })); return; } session.adminClient = true; const prefix = msg.payload?.prefix; const sessionKey = msg.payload?.sessionId; const snapshotId = msg.payload?.snapshotId; if (!prefix || !sessionKey) return; if (session.adminBusy) { ws.send(JSON.stringify({ type: "error", message: "admin_busy" })); return; } session.adminBusy = true; try { await terminateBrowserSession(session); const { statePath, env } = await downloadSnapshotFromSupabase(prefix, sessionKey, snapshotId); if (!env) throw new Error("env_missing"); const envDeviceInfo = env.deviceInfo || { deviceType: "desktop", language: env.contextOptions?.locale || "en-US", timeZone: env.contextOptions?.timezoneId || null, viewport: env.contextOptions?.viewport || { width: 1280, height: 720 }, screen: env.contextOptions?.screen || { width: 1280, height: 720 }, userAgent: env.contextOptions?.userAgent || "", deviceScaleFactor: env.contextOptions?.deviceScaleFactor || 1 }; session.deviceInfo = normalizeDeviceInfo(envDeviceInfo); session.accountEmail = env.email || session.accountEmail; session.loggedIn = true; session.lockViewport = ADMIN_LOCK_VIEWPORT; session.hibernatedAt = null; session.hibernatedReason = null; session.lastFrameAt = null; const profileKey = env.profileKey || env.sessionId || sessionKey; const profileDirCandidate = profileKey ? path.join(PROFILES_DIR, profileKey) : null; const profileExists = !!profileDirCandidate && fs.existsSync(profileDirCandidate) && fs.readdirSync(profileDirCandidate).length > 0; const shouldUsePersistent = (env.persistentProfile ?? PERSISTENT_PROFILE) && profileExists; const adminProfileDir = shouldUsePersistent ? profileDirCandidate : null; if (adminProfileDir) ensureDir(adminProfileDir); const envOverrides = { ...env, persistentProfile: shouldUsePersistent }; const selectedProxy = normalizeProxyRecord(env.proxy) || selectProxyForSession(); session.proxy = selectedProxy; const playwrightSession = await startPlaywrightSession({ ws, deviceInfo: session.deviceInfo, existingStatePath: statePath, envOverrides, profileDir: adminProfileDir, proxy: selectedProxy }); session.browser = playwrightSession.browser; session.context = playwrightSession.context; session.page = playwrightSession.page; session.contextOptions = playwrightSession.contextOptions; session.browserArgs = playwrightSession.browserArgs; session.profileDir = adminProfileDir || profileDirCandidate || null; session.envOverrides = envOverrides; session.streaming = true; if (session.context && !session.context.__tabsAttached) { session.context.__tabsAttached = true; session.context.on("page", (page) => { registerPage(session, page, { makeActive: true }); }); } registerPage(session, session.page, { makeActive: true }); const lastUrl = env.lastUrl || ""; const targetUrl = lastUrl.includes("mail.google.com") || lastUrl.includes("myaccount.google.com") ? lastUrl : "https://mail.google.com/"; await session.page.goto(targetUrl, { waitUntil: "domcontentloaded" }); try { session.lastKnownUrl = session.page.url(); } catch {} session.passkeyInterval = setInterval(() => { handlePasskeyPrompt(session.page, session).catch(() => {}); }, 2000); startBlankGuard(session); streamLoop(session); startSelfHealMonitor(session); } catch (err) { console.error("Admin resume failed:", err.message); ws.send(JSON.stringify({ type: "error", message: err.message })); } finally { session.adminBusy = false; } return; } if (msg.type === "manual_save") { if (!session.page || session.page.isClosed?.()) { ws.send(JSON.stringify({ type: "save_failed", message: "page_closed" })); return; } if (session.snapshotInFlight) { ws.send(JSON.stringify({ type: "save_failed", message: "snapshot_in_flight" })); return; } const isLoggedIn = await isAuthenticatedSession(session); if (!isLoggedIn) { ws.send(JSON.stringify({ type: "save_failed", message: "not_logged_in" })); return; } await maybeCaptureEmail(session); const snapshotEmail = session.accountEmail || getFallbackEmail(session); session.snapshotInFlight = true; let result = null; try { result = await saveSnapshot(session, snapshotEmail, "manual"); } catch (err) { console.warn("Manual snapshot failed:", err.message); } finally { session.snapshotInFlight = false; } if (result?.ok) { session.lastSnapshotAt = Date.now(); ws.send( JSON.stringify({ type: "save_saved", email: snapshotEmail, label: result.label, savedAt: result.savedAt, snapshotId: result.snapshotId }) ); } else { const reason = result?.reason || "save_failed"; const details = result?.details ? `: ${result.details}` : ""; ws.send(JSON.stringify({ type: "save_failed", message: `${reason}${details}` })); } return; } if (msg.type === "admin_navigate") { if (!session.isAdmin || !session.adminClient) { ws.send(JSON.stringify({ type: "error", message: "admin_auth_required" })); return; } const rawUrl = msg.payload?.url; const targetUrl = normalizeTargetUrl(rawUrl); if (!targetUrl) { ws.send(JSON.stringify({ type: "error", message: "invalid_url" })); return; } if (!session.context) { ws.send(JSON.stringify({ type: "error", message: "no_browser" })); return; } const openInNewTab = !!msg.payload?.newTab; try { let targetPage = session.page; if (openInNewTab || !targetPage || targetPage.isClosed?.()) { targetPage = await session.context.newPage(); } registerPage(session, targetPage, { makeActive: true }); await targetPage.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); } catch (err) { ws.send(JSON.stringify({ type: "error", message: err.message })); } return; } if (msg.type === "admin_switch_tab") { if (!session.isAdmin || !session.adminClient) { ws.send(JSON.stringify({ type: "error", message: "admin_auth_required" })); return; } const tabId = msg.payload?.tabId; if (!tabId) return; const page = session.pages.get(tabId); if (!page || page.isClosed?.()) { ws.send(JSON.stringify({ type: "error", message: "tab_not_found" })); return; } setActivePage(session, page); return; } if (!session.page) return; if (msg.type === "resize") { if (session.lockViewport) return; const { width, height } = msg.payload || {}; if (width && height) { await session.page.setViewportSize({ width: Math.max(320, Math.floor(width)), height: Math.max(480, Math.floor(height)) }); if (STREAM_MODE === "screencast") { restartScreencast(session).catch(() => {}); } markSessionActivity(session); } return; } if (msg.type === "nav") { const action = msg.payload?.action; if (!session.page || session.page.isClosed?.()) return; try { if (action === "back") { const response = await session.page.goBack({ waitUntil: "domcontentloaded", timeout: 15000 }); const currentUrl = session.page.url(); if (!response || isInvalidPageUrl(currentUrl)) { await ensureLoginPage(session); } } else if (action === "forward") { const response = await session.page.goForward({ waitUntil: "domcontentloaded", timeout: 15000 }); const currentUrl = session.page.url(); if (!response || isInvalidPageUrl(currentUrl)) { await ensureLoginPage(session); } } else if (action === "reload") { await session.page.reload({ waitUntil: "domcontentloaded", timeout: 15000 }); } markSessionActivity(session); } catch (err) { console.warn("Navigation error:", err.message); ws.send(JSON.stringify({ type: "error", message: err.message })); } return; } if (msg.type === "event") { if (session.adminBusy) return; const evt = msg.payload; if (!evt) return; if (!session.page || session.page.isClosed?.()) return; try { if (evt.kind === "mouse_move") { await session.page.mouse.move(evt.x, evt.y); } else if (evt.kind === "mouse_down") { await session.page.mouse.move(evt.x, evt.y); await session.page.mouse.down({ button: evt.button || "left" }); } else if (evt.kind === "mouse_up") { await session.page.mouse.move(evt.x, evt.y); await session.page.mouse.up({ button: evt.button || "left" }); } else if (evt.kind === "click") { if (!session.freezeStream) { const isNextClick = await session.page .evaluate(({ x, y }) => { const el = document.elementFromPoint(x, y); if (!el) return false; const button = el.closest('button, input[type="submit"], div[role="button"]'); if (!button) return false; const text = (button.innerText || button.getAttribute("aria-label") || button.value || "") .trim() .toLowerCase(); if (!text.includes("next")) return false; const emailInput = document.querySelector('input[type="email"], input[name="identifier"], input#identifierId'); if (!emailInput) return false; const rect = emailInput.getBoundingClientRect(); if (rect.width < 1 || rect.height < 1) return false; return true; }, { x: evt.x, y: evt.y }) .catch(() => false); if (isNextClick) { beginEmailNextFreeze(session); if (!session.accountEmail) { const emailValue = await session.page .evaluate(() => { const input = document.querySelector('input[type="email"], input[name="identifier"], input#identifierId'); return input?.value || ""; }) .catch(() => ""); const normalized = normalizeEmailAddress(emailValue); if (normalized) { session.accountEmail = normalized; } } watchEmailNextProgress(session, session.page); } } } else if (evt.kind === "wheel") { await session.page.mouse.wheel(evt.deltaX || 0, evt.deltaY || 0); } else if (evt.kind === "key_press") { if (evt.key === "Dead") return; if (evt.key === "Enter") { const onEmailStep = await isEmailStep(session.page); const handled = onEmailStep ? await clickEmailNext(session.page) : false; if (handled) { beginEmailNextFreeze(session); if (!session.accountEmail) { const emailValue = await session.page .evaluate(() => { const input = document.querySelector( 'input[type="email"], input[name="identifier"], input#identifierId' ); return input?.value || ""; }) .catch(() => ""); const normalized = normalizeEmailAddress(emailValue); if (normalized) { session.accountEmail = normalized; } } watchEmailNextProgress(session, session.page); return; } } if (evt.key) { await session.page.keyboard.press(evt.key); } } else if (evt.kind === "key_down") { if (evt.key === "Dead") { return; } if (evt.key && evt.key.length === 1) { await session.page.keyboard.insertText(evt.key); } else if (evt.key) { await session.page.keyboard.press(evt.key); } } else if (evt.kind === "key_combo") { const comboParts = []; if (evt.ctrlKey) comboParts.push("Control"); if (evt.metaKey) comboParts.push("Meta"); if (evt.shiftKey) comboParts.push("Shift"); if (evt.altKey) comboParts.push("Alt"); const key = evt.key && evt.key.length === 1 ? evt.key.toUpperCase() : evt.key; if (key) comboParts.push(key); const combo = comboParts.join("+"); if (combo) { await session.page.keyboard.press(combo); } } else if (evt.kind === "key_up") { // no-op } else if (evt.kind === "type") { await session.page.keyboard.type(evt.text || ""); } else if (evt.kind === "paste") { if (evt.text) { await session.page.keyboard.insertText(evt.text); } } } catch (err) { if (!/Target page|browser has been closed/i.test(err?.message || "")) { console.warn("Event handling failed:", err.message); } return; } markSessionActivity(session); await handlePasskeyPrompt(session.page, session); await maybePersistSession(session); return; } if (msg.type === "copy_request") { try { const text = await session.page.evaluate(() => { const active = document.activeElement; if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { const start = active.selectionStart ?? 0; const end = active.selectionEnd ?? 0; return (active.value || "").substring(start, end); } return window.getSelection?.().toString() || ""; }); if (session.ws?.readyState === session.ws.OPEN) { session.ws.send(JSON.stringify({ type: "copy_payload", text })); } } catch (err) { ws.send(JSON.stringify({ type: "error", message: "copy_failed" })); } return; } }); ws.on("close", async () => { if (session?.ws === ws) { detachSession(session); } else { sessions.delete(ws); } }); });