iframer-toolkit 0.10.6 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,75 +35,18 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
35
35
  mod
36
36
  ));
37
37
 
38
- // src/lib/session/persistence.ts
39
- var persistence_exports = {};
40
- __export(persistence_exports, {
41
- extractSession: () => extractSession,
42
- injectCookies: () => injectCookies,
43
- injectStorage: () => injectStorage
44
- });
45
- async function extractSession(context, page) {
46
- const cookies = await context.cookies();
47
- const { localStorage, sessionStorage } = await page.evaluate(() => {
48
- const ls = {};
49
- const ss = {};
50
- for (let i = 0; i < window.localStorage.length; i++) {
51
- const key = window.localStorage.key(i);
52
- ls[key] = window.localStorage.getItem(key);
53
- }
54
- for (let i = 0; i < window.sessionStorage.length; i++) {
55
- const key = window.sessionStorage.key(i);
56
- ss[key] = window.sessionStorage.getItem(key);
57
- }
58
- return { localStorage: ls, sessionStorage: ss };
59
- });
60
- const origin = new URL(page.url()).origin;
61
- return {
62
- cookies,
63
- localStorage: { [origin]: localStorage },
64
- sessionStorage: { [origin]: sessionStorage },
65
- extractedAt: (/* @__PURE__ */ new Date()).toISOString()
66
- };
67
- }
68
- async function injectCookies(context, sessionData) {
69
- if (sessionData?.cookies?.length > 0) {
70
- await context.addCookies(sessionData.cookies);
71
- }
72
- }
73
- async function injectStorage(page, sessionData) {
74
- if (!sessionData) return;
75
- const origin = new URL(page.url()).origin;
76
- const ls = sessionData.localStorage?.[origin];
77
- const ss = sessionData.sessionStorage?.[origin];
78
- if (ls && Object.keys(ls).length > 0) {
79
- await page.evaluate((data) => {
80
- for (const [key, value] of Object.entries(data)) {
81
- window.localStorage.setItem(key, value);
82
- }
83
- }, ls);
84
- }
85
- if (ss && Object.keys(ss).length > 0) {
86
- await page.evaluate((data) => {
87
- for (const [key, value] of Object.entries(data)) {
88
- window.sessionStorage.setItem(key, value);
89
- }
90
- }, ss);
91
- }
92
- }
93
- var init_persistence = __esm({
94
- "src/lib/session/persistence.ts"() {
95
- "use strict";
96
- }
97
- });
98
-
99
38
  // src/lib/paths.ts
100
39
  var paths_exports = {};
101
40
  __export(paths_exports, {
102
- getDataDir: () => getDataDir
41
+ getDataDir: () => getDataDir,
42
+ getScreenshotDir: () => getScreenshotDir
103
43
  });
104
44
  function getDataDir() {
105
45
  return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
106
46
  }
47
+ function getScreenshotDir() {
48
+ return import_path.default.join(getDataDir(), "screenshots");
49
+ }
107
50
  var import_path, import_os;
108
51
  var init_paths = __esm({
109
52
  "src/lib/paths.ts"() {
@@ -115,27 +58,21 @@ var init_paths = __esm({
115
58
 
116
59
  // index.ts
117
60
  var import_express = __toESM(require("express"));
118
- var import_path12 = __toESM(require("path"));
119
- var import_fs13 = __toESM(require("fs"));
120
- var import_url2 = require("url");
61
+ var import_path11 = __toESM(require("path"));
62
+ var import_fs11 = __toESM(require("fs"));
63
+ var import_url = require("url");
64
+ init_paths();
121
65
 
122
66
  // src/api/routes.ts
123
67
  var import_patchright3 = require("patchright");
124
68
 
125
69
  // src/lib/iframer.ts
126
- var import_path11 = __toESM(require("path"));
127
- var import_url = require("url");
128
-
129
- // src/lib/browser/session-manager.ts
130
- var import_child_process = require("child_process");
131
- var import_fs2 = __toESM(require("fs"));
70
+ init_paths();
132
71
 
133
72
  // src/lib/browser/launcher.ts
134
- var import_fs = __toESM(require("fs"));
135
73
  var import_patchright = require("patchright");
136
74
 
137
75
  // src/lib/browser/stealth.ts
138
- var contextStealthScripts = /* @__PURE__ */ new Map();
139
76
  var CHROME_VERSION = "136.0.7103.93";
140
77
  var USER_AGENT = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_VERSION} Safari/537.36`;
141
78
  var NATIVE_TOSTRING_HELPER = `
@@ -542,27 +479,207 @@ function createLogger(tag) {
542
479
  };
543
480
  }
544
481
 
545
- // src/lib/browser/launcher.ts
546
- var log = createLogger("launcher");
547
- var UBLOCK_PATH = "/extensions/uBlock0.chromium";
548
- function findChromeExecutable() {
549
- if (process.env.CHROME_EXECUTABLE) return process.env.CHROME_EXECUTABLE;
550
- if (import_fs.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
551
- return void 0;
482
+ // src/lib/browser/registry.ts
483
+ var import_fs = __toESM(require("fs"));
484
+ var import_path2 = __toESM(require("path"));
485
+ var import_child_process = require("child_process");
486
+ init_paths();
487
+ var log = createLogger("registry");
488
+ function browsersDir() {
489
+ const dir = import_path2.default.join(getDataDir(), "browsers");
490
+ import_fs.default.mkdirSync(dir, { recursive: true });
491
+ return dir;
492
+ }
493
+ function serverInfoPath() {
494
+ return import_path2.default.join(getDataDir(), "server.json");
495
+ }
496
+ function isPidAlive(pid) {
497
+ if (!Number.isInteger(pid) || pid <= 1) return false;
498
+ try {
499
+ process.kill(pid, 0);
500
+ return true;
501
+ } catch (e) {
502
+ return e.code === "EPERM";
503
+ }
504
+ }
505
+ function pidMatchesMarker(pid, marker) {
506
+ if (!isPidAlive(pid)) return false;
507
+ try {
508
+ const cmd = (0, import_child_process.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
509
+ return cmd.includes(marker);
510
+ } catch {
511
+ return false;
512
+ }
513
+ }
514
+ function findChromePidByMarker(marker) {
515
+ try {
516
+ const out = (0, import_child_process.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
517
+ const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
518
+ if (pids.length === 0) return null;
519
+ return Math.min(...pids);
520
+ } catch {
521
+ return null;
522
+ }
523
+ }
524
+ function registerBrowser(rec) {
525
+ try {
526
+ import_fs.default.writeFileSync(import_path2.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
527
+ } catch (err) {
528
+ log.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
529
+ }
530
+ }
531
+ function unregisterBrowser(chromePid) {
532
+ try {
533
+ import_fs.default.unlinkSync(import_path2.default.join(browsersDir(), `${chromePid}.json`));
534
+ } catch {
535
+ }
536
+ }
537
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
538
+ async function forceKillBrowser(rec) {
539
+ if (!isPidAlive(rec.chromePid)) return true;
540
+ if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
541
+ return true;
542
+ }
543
+ try {
544
+ process.kill(rec.chromePid, "SIGKILL");
545
+ } catch {
546
+ }
547
+ const deadline = Date.now() + 2e3;
548
+ while (Date.now() < deadline) {
549
+ if (!isPidAlive(rec.chromePid)) return true;
550
+ await sleep(100);
551
+ }
552
+ return !isPidAlive(rec.chromePid);
553
+ }
554
+ async function reapOrphanBrowsers() {
555
+ let reaped = 0;
556
+ let skipped = 0;
557
+ let files = [];
558
+ try {
559
+ files = import_fs.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
560
+ } catch {
561
+ return { reaped, skipped };
562
+ }
563
+ for (const file of files) {
564
+ const full = import_path2.default.join(browsersDir(), file);
565
+ let rec;
566
+ try {
567
+ rec = JSON.parse(import_fs.default.readFileSync(full, "utf8"));
568
+ } catch {
569
+ try {
570
+ import_fs.default.unlinkSync(full);
571
+ } catch {
572
+ }
573
+ continue;
574
+ }
575
+ if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
576
+ try {
577
+ import_fs.default.unlinkSync(full);
578
+ } catch {
579
+ }
580
+ continue;
581
+ }
582
+ if (isPidAlive(rec.ownerPid)) {
583
+ skipped++;
584
+ continue;
585
+ }
586
+ log.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
587
+ if (await forceKillBrowser(rec)) {
588
+ try {
589
+ import_fs.default.unlinkSync(full);
590
+ } catch {
591
+ }
592
+ reaped++;
593
+ } else {
594
+ log.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
595
+ }
596
+ }
597
+ return { reaped, skipped };
598
+ }
599
+ function writeServerInfo(info) {
600
+ import_fs.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
601
+ }
602
+ function readServerInfo() {
603
+ try {
604
+ const info = JSON.parse(import_fs.default.readFileSync(serverInfoPath(), "utf8"));
605
+ if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
606
+ return info;
607
+ } catch {
608
+ return null;
609
+ }
552
610
  }
611
+ function clearServerInfo(pid) {
612
+ const info = readServerInfo();
613
+ if (info && info.pid === pid) {
614
+ try {
615
+ import_fs.default.unlinkSync(serverInfoPath());
616
+ } catch {
617
+ }
618
+ }
619
+ }
620
+
621
+ // src/lib/browser/launcher.ts
622
+ var log2 = createLogger("launcher");
553
623
  var cachedBrowser = null;
554
624
  var headedBrowser = null;
625
+ var headedPid = null;
626
+ var headedIdleTimer = null;
627
+ var HEADED_IDLE_MS = 5 * 60 * 1e3;
628
+ function armHeadedIdleTimer() {
629
+ if (headedIdleTimer) clearTimeout(headedIdleTimer);
630
+ headedIdleTimer = setTimeout(() => {
631
+ if (!headedBrowser) return;
632
+ const pages = headedBrowser.contexts().reduce((n, c) => n + c.pages().length, 0);
633
+ if (pages > 0) {
634
+ armHeadedIdleTimer();
635
+ return;
636
+ }
637
+ log2.info("idle timeout for the pivot headed browser, closing");
638
+ void closeHeadedBrowser();
639
+ }, HEADED_IDLE_MS);
640
+ }
555
641
  async function getHeadedBrowser() {
556
- if (!headedBrowser?.isConnected()) headedBrowser = await import_patchright.chromium.launch({ headless: false, args: STEALTH_ARGS });
642
+ if (!headedBrowser?.isConnected()) {
643
+ const marker = `--iframer-key=pivot-headed-${process.pid}-${Date.now()}`;
644
+ headedBrowser = await import_patchright.chromium.launch({ headless: false, args: [...STEALTH_ARGS, marker] });
645
+ headedPid = findChromePidByMarker(marker);
646
+ if (headedPid) {
647
+ registerBrowser({
648
+ key: "pivot::headed",
649
+ chromePid: headedPid,
650
+ ownerPid: process.pid,
651
+ marker,
652
+ launchedAt: (/* @__PURE__ */ new Date()).toISOString()
653
+ });
654
+ } else {
655
+ log2.warn("could not resolve Chrome PID for the pivot headed browser \u2014 force-kill unavailable");
656
+ }
657
+ }
658
+ armHeadedIdleTimer();
557
659
  return headedBrowser;
558
660
  }
661
+ async function closeHeadedBrowser() {
662
+ if (headedIdleTimer) {
663
+ clearTimeout(headedIdleTimer);
664
+ headedIdleTimer = null;
665
+ }
666
+ if (headedBrowser) {
667
+ await headedBrowser.close().catch(() => {
668
+ });
669
+ headedBrowser = null;
670
+ }
671
+ if (headedPid !== null) {
672
+ unregisterBrowser(headedPid);
673
+ headedPid = null;
674
+ }
675
+ }
559
676
  async function getBrowser(_name = "chromium") {
560
677
  if (cachedBrowser) {
561
678
  if (cachedBrowser.isConnected()) return cachedBrowser;
562
679
  try {
563
680
  await cachedBrowser.close();
564
681
  } catch (e) {
565
- log.warn(`stale browser close failed: ${e}`);
682
+ log2.warn(`stale browser close failed: ${e}`);
566
683
  }
567
684
  cachedBrowser = null;
568
685
  }
@@ -573,352 +690,74 @@ async function getBrowser(_name = "chromium") {
573
690
  return cachedBrowser;
574
691
  }
575
692
  async function closeBrowser() {
576
- if (headedBrowser) {
577
- await headedBrowser.close().catch(() => {
578
- });
579
- headedBrowser = null;
580
- }
693
+ await closeHeadedBrowser();
581
694
  if (!cachedBrowser) return;
582
695
  try {
583
696
  await cachedBrowser.close();
584
697
  } catch (e) {
585
- log.warn(`closeBrowser failed: ${e}`);
698
+ log2.warn(`closeBrowser failed: ${e}`);
586
699
  }
587
700
  cachedBrowser = null;
588
701
  }
589
702
  async function getBrowserWithFallback(_preferred) {
590
703
  return { browser: await getBrowser(), name: "chromium" };
591
704
  }
592
- async function launchHeadful(displayNum) {
593
- const executablePath = findChromeExecutable();
594
- const hasExtensions = import_fs.default.existsSync(UBLOCK_PATH);
595
- const args = [
596
- "--no-sandbox",
597
- "--disable-setuid-sandbox",
598
- "--disable-dev-shm-usage",
599
- "--disable-blink-features=AutomationControlled",
600
- "--disable-features=IsolateOrigins,site-per-process",
601
- "--disable-infobars",
602
- "--window-size=1920,1080",
603
- "--force-device-scale-factor=1.25",
604
- "--use-gl=angle",
605
- "--use-angle=swiftshader"
705
+
706
+ // src/lib/auth/crypto.ts
707
+ var import_crypto = __toESM(require("crypto"));
708
+ var import_fs2 = __toESM(require("fs"));
709
+ var import_os2 = __toESM(require("os"));
710
+ var import_path3 = __toESM(require("path"));
711
+ init_paths();
712
+ var SALT = "iframer-session";
713
+ var INFO = "encryption";
714
+ var KEY_LENGTH = 32;
715
+ var IV_LENGTH = 12;
716
+ var TAG_LENGTH = 16;
717
+ function getLocalToken() {
718
+ if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
719
+ const candidates = [
720
+ import_path3.default.join(getDataDir(), "secret"),
721
+ import_path3.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
606
722
  ];
607
- if (hasExtensions) args.push(`--load-extension=${UBLOCK_PATH}`);
608
- const launchOpts = {
609
- headless: false,
610
- args,
611
- env: { ...process.env, DISPLAY: `:${displayNum}` }
612
- };
613
- if (executablePath) launchOpts.executablePath = executablePath;
614
- log.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
615
- return import_patchright.chromium.launch(launchOpts);
723
+ for (const file of candidates) {
724
+ try {
725
+ const existing = import_fs2.default.readFileSync(file, "utf8").trim();
726
+ if (existing) return existing;
727
+ } catch {
728
+ }
729
+ }
730
+ for (const file of candidates) {
731
+ try {
732
+ import_fs2.default.mkdirSync(import_path3.default.dirname(file), { recursive: true });
733
+ const secret = import_crypto.default.randomBytes(32).toString("hex");
734
+ import_fs2.default.writeFileSync(file, secret, { mode: 384 });
735
+ return secret;
736
+ } catch {
737
+ }
738
+ }
739
+ throw new Error(
740
+ `iframer: could not read or create a persistent encryption secret in any writable location (${candidates.join(", ")}). Set IFRAMER_SECRET to a stable value shared between the MCP server and CLI (openssl rand -hex 32).`
741
+ );
616
742
  }
617
-
618
- // src/lib/browser/fingerprint.ts
619
- var import_fingerprint_generator = require("fingerprint-generator");
620
-
621
- // src/lib/constants.ts
622
- var TIMING = {
623
- /** Mouse move settle delay */
624
- MOUSE_MOVE: [50, 200],
625
- /** Click down→up delay */
626
- CLICK_HOLD: [30, 90],
627
- /** Post-click settle */
628
- POST_CLICK: [100, 300],
629
- /** Per-character typing delay */
630
- CHAR_DELAY: [30, 150],
631
- /** Occasional word-boundary pause */
632
- WORD_PAUSE: [200, 500],
633
- /** Pre-action move range */
634
- IDLE_MOUSE_X: [100, 400],
635
- IDLE_MOUSE_Y: [100, 300],
636
- /** Pre-checkbox random move */
637
- PRE_CHECKBOX_X: [200, 600],
638
- PRE_CHECKBOX_Y: [150, 400],
639
- /** Pause after moving to checkbox area */
640
- PRE_CHECKBOX_WAIT: [300, 800],
641
- /** Wait after clicking reCAPTCHA checkbox */
642
- POST_CHECKBOX_WAIT: 2500,
643
- /** Wait after clicking verify button */
644
- POST_VERIFY_WAIT: 2e3,
645
- /** Wait between clicking captcha tiles */
646
- TILE_CLICK_DELAY: [200, 500],
647
- /** Time to wait for reCAPTCHA tile images to settle */
648
- TILE_SETTLE: 800,
649
- /** Delay before captcha auto-detection */
650
- CAPTCHA_DETECT_WAIT: 1500,
651
- /** Post-login navigation settle */
652
- POST_LOGIN_WAIT: 1500,
653
- /** Post-submit form delay */
654
- POST_SUBMIT_WAIT: 500,
655
- /** Post-submit form extended wait */
656
- POST_SUBMIT_EXTENDED: 2e3,
657
- /** Delay before navigating (human hesitation) */
658
- PRE_NAVIGATE: [300, 700],
659
- /** Per-digit input delay base */
660
- DIGIT_DELAY_BASE: 80,
661
- DIGIT_DELAY_RANGE: 120,
662
- /** Post-click in forms */
663
- POST_FORM_CLICK: 200,
664
- /** Post-TOTP entry wait */
665
- POST_TOTP_WAIT: 300,
666
- /** Post-cookies inject wait */
667
- POST_COOKIES_WAIT: 300,
668
- /** Scroll step delay */
669
- SCROLL_DELAY: 150,
670
- /** Stale state check interval */
671
- STALE_CHECK_INTERVAL: 2e3
672
- };
673
- var CAPTCHA_GRID = {
674
- /** Pixels from bframe top to grid start (reCAPTCHA) */
675
- RECAPTCHA_HEADER_HEIGHT: 112,
676
- /** Pixels from bframe top to grid start (hCaptcha) — slightly different */
677
- HCAPTCHA_HEADER_HEIGHT: 110,
678
- /** Default tile size when bframeBox is unavailable */
679
- DEFAULT_TILE_SIZE: 125,
680
- /** Grid padding (left+right total) */
681
- GRID_PADDING: 24,
682
- /** Grid left margin */
683
- GRID_MARGIN: 12,
684
- /** Verify button offset from bframe bottom */
685
- VERIFY_BTN_BOTTOM_OFFSET: 35,
686
- /** Verify button offset from bframe right */
687
- VERIFY_BTN_RIGHT_OFFSET: 60
688
- };
689
- var SCREEN_DEFAULTS = {
690
- WIDTH: 1920,
691
- HEIGHT: 1080,
692
- AVAIL_HEIGHT: 1040,
693
- DPR: 1.25
694
- };
695
- var THRESHOLDS = {
696
- /** Minimum character change to consider page "changed" */
697
- STALE_CHAR_CHANGE: 100,
698
- /** Minimum percentage change to consider page "changed" */
699
- STALE_PERCENT_CHANGE: 0.05,
700
- /** Minimum body text length to not be considered "empty" */
701
- MIN_BODY_TEXT: 200,
702
- /** Maximum response text length to capture */
703
- MAX_RESPONSE_TEXT: 1e5
704
- };
705
- var TIMEOUTS = {
706
- DEFAULT_STALE: 2e4,
707
- NAVIGATION: 6e4,
708
- SELECTOR_WAIT: 1e4,
709
- TOTP_INPUT: 5e3,
710
- API_REQUEST: 18e4,
711
- HEALTH_CHECK: 3e3,
712
- CHALLENGE_FRAME_WAIT: 5e3,
713
- // New-tab following: after a click, how long to wait for a just-opened tab's
714
- // 'page' event to arrive (only applies when no tab has registered yet), and
715
- // how long to let the followed tab reach domcontentloaded before continuing.
716
- TAB_FOLLOW_SETTLE: 400,
717
- TAB_LOAD: 15e3,
718
- // A new tab usually opens at about:blank then navigates to its real URL —
719
- // wait this long for that first real navigation before judging it blank.
720
- TAB_BLANK_RESOLVE: 3e3
721
- };
722
- var CHROME_MIN_VERSION = 130;
723
-
724
- // src/lib/browser/fingerprint.ts
725
- var generator = new import_fingerprint_generator.FingerprintGenerator({
726
- browsers: [{ name: "chrome", minVersion: CHROME_MIN_VERSION }],
727
- operatingSystems: ["windows"],
728
- devices: ["desktop"],
729
- locales: ["en-US"]
730
- });
731
- function generateWindowsFingerprint() {
732
- const fp = generator.getFingerprint();
733
- const { navigator: nav, screen } = fp.fingerprint;
734
- const dprOptions = [1.25, 1.5, 1.25, 1.5, 1];
735
- const dpr = dprOptions[Math.floor(Math.random() * dprOptions.length)];
736
- const w = screen.width || SCREEN_DEFAULTS.WIDTH;
737
- const h = screen.height || SCREEN_DEFAULTS.HEIGHT;
738
- return {
739
- userAgent: nav.userAgent,
740
- platform: "Win32",
741
- screenWidth: w,
742
- screenHeight: h,
743
- screenAvailHeight: h - 40,
744
- // Windows taskbar ~40px
745
- colorDepth: 24,
746
- deviceScaleFactor: dpr,
747
- hardwareConcurrency: nav.hardwareConcurrency || 8,
748
- deviceMemory: nav.deviceMemory || 8,
749
- languages: nav.languages || ["en-US", "en"],
750
- uaData: nav.userAgentData
751
- };
752
- }
753
-
754
- // src/lib/browser/session-manager.ts
755
- var log2 = createLogger("session");
756
- var BASE_DISPLAY = parseInt(process.env.VNC_BASE_DISPLAY || "99", 10);
757
- var MAX_SESSIONS = parseInt(process.env.VNC_MAX_SESSIONS || "20", 10);
758
- var SESSION_TIMEOUT = parseInt(process.env.VNC_SESSION_TIMEOUT_MS || "300000", 10);
759
- var sessions = /* @__PURE__ */ new Map();
760
- var usedDisplays = /* @__PURE__ */ new Set();
761
- function allocateDisplay() {
762
- for (let i = 0; i < MAX_SESSIONS; i++) {
763
- const num = BASE_DISPLAY + i;
764
- if (!usedDisplays.has(num)) {
765
- usedDisplays.add(num);
766
- return num;
767
- }
768
- }
769
- throw new Error("No available displays. Max concurrent sessions reached.");
770
- }
771
- function freeDisplay(num) {
772
- usedDisplays.delete(num);
773
- }
774
- function waitForSocket(displayNum, timeoutMs = 5e3) {
775
- const socketPath = `/tmp/.X11-unix/X${displayNum}`;
776
- return new Promise((resolve, reject) => {
777
- const start = Date.now();
778
- const check = () => {
779
- if (import_fs2.default.existsSync(socketPath)) return resolve();
780
- if (Date.now() - start > timeoutMs) return reject(new Error(`Xvfb socket not ready after ${timeoutMs}ms`));
781
- setTimeout(check, 100);
782
- };
783
- check();
784
- });
785
- }
786
- function killProcess(proc) {
787
- if (proc && !proc.killed) {
788
- try {
789
- proc.kill("SIGTERM");
790
- } catch {
791
- }
792
- }
793
- }
794
- async function startSession(userId) {
795
- if (sessions.has(userId)) {
796
- return sessions.get(userId);
797
- }
798
- const displayNum = allocateDisplay();
799
- const vncPort = 5900 + displayNum;
800
- const wsPort = 6080 + (displayNum - BASE_DISPLAY);
801
- const xvfb = (0, import_child_process.spawn)("Xvfb", [`:${displayNum}`, "-screen", "0", "1920x1080x24", "-ac"], {
802
- stdio: "ignore"
803
- });
804
- await waitForSocket(displayNum);
805
- const x11vnc = (0, import_child_process.spawn)(
806
- "x11vnc",
807
- ["-display", `:${displayNum}`, "-nopw", "-listen", "localhost", "-rfbport", String(vncPort), "-shared", "-forever"],
808
- { stdio: "ignore" }
809
- );
810
- const noVncPath = import_fs2.default.existsSync("/usr/share/novnc") ? "/usr/share/novnc" : "/usr/share/noVNC";
811
- const websockify = (0, import_child_process.spawn)("websockify", ["--web", noVncPath, String(wsPort), `localhost:${vncPort}`], {
812
- stdio: "ignore"
813
- });
814
- await new Promise((r) => setTimeout(r, 500));
815
- const browser = await launchHeadful(displayNum);
816
- const fingerprint = generateWindowsFingerprint();
817
- const ctxOpts = stealthContextOptions({}, userId, fingerprint);
818
- const context = await browser.newContext(ctxOpts);
819
- const stealthScript = buildStealthScript(fingerprint);
820
- contextStealthScripts.set(context, stealthScript);
821
- const page = await context.newPage();
822
- log2.debug(`fingerprint: ${fingerprint.userAgent.slice(0, 60)}... DPR=${fingerprint.deviceScaleFactor} screen=${fingerprint.screenWidth}x${fingerprint.screenHeight}`);
823
- const session = {
824
- displayNum,
825
- vncPort,
826
- wsPort,
827
- xvfb,
828
- x11vnc,
829
- websockify,
830
- browser,
831
- context,
832
- page,
833
- createdAt: /* @__PURE__ */ new Date(),
834
- timeoutTimer: null
835
- };
836
- session.timeoutTimer = setTimeout(() => stopSession(userId), SESSION_TIMEOUT);
837
- sessions.set(userId, session);
838
- return session;
839
- }
840
- function resetTimeout(userId) {
841
- const session = sessions.get(userId);
842
- if (session) {
843
- clearTimeout(session.timeoutTimer);
844
- session.timeoutTimer = setTimeout(() => stopSession(userId), SESSION_TIMEOUT);
845
- }
846
- }
847
- function getSession(userId) {
848
- return sessions.get(userId) || null;
849
- }
850
- async function stopSession(userId) {
851
- const session = sessions.get(userId);
852
- if (!session) return null;
853
- clearTimeout(session.timeoutTimer);
854
- let sessionData = null;
743
+ function getPairingTokens() {
744
+ const tokens = /* @__PURE__ */ new Set();
855
745
  try {
856
- const { extractSession: extractSession2 } = await Promise.resolve().then(() => (init_persistence(), persistence_exports));
857
- sessionData = await extractSession2(session.context, session.page);
746
+ tokens.add(getLocalToken());
858
747
  } catch {
859
748
  }
860
- contextStealthScripts.delete(session.context);
861
- try {
862
- await session.context.close();
863
- } catch {
864
- }
865
- try {
866
- await session.browser.close();
867
- } catch {
868
- }
869
- killProcess(session.websockify);
870
- killProcess(session.x11vnc);
871
- killProcess(session.xvfb);
872
- await new Promise((r) => setTimeout(r, 1e3));
873
- try {
874
- import_fs2.default.unlinkSync(`/tmp/.X11-unix/X${session.displayNum}`);
875
- } catch {
876
- }
877
- freeDisplay(session.displayNum);
878
- sessions.delete(userId);
879
- return sessionData;
880
- }
881
- async function cleanupAllSessions() {
882
- const userIds = [...sessions.keys()];
883
- await Promise.all(userIds.map((id) => stopSession(id)));
884
- }
885
-
886
- // src/lib/auth/crypto.ts
887
- var import_crypto = __toESM(require("crypto"));
888
- var import_fs3 = __toESM(require("fs"));
889
- var import_os2 = __toESM(require("os"));
890
- var import_path2 = __toESM(require("path"));
891
- init_paths();
892
- var SALT = "iframer-session";
893
- var INFO = "encryption";
894
- var KEY_LENGTH = 32;
895
- var IV_LENGTH = 12;
896
- var TAG_LENGTH = 16;
897
- function getLocalToken() {
898
- if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
899
749
  const candidates = [
900
- import_path2.default.join(getDataDir(), "secret"),
901
- import_path2.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
750
+ import_path3.default.join(getDataDir(), "secret"),
751
+ import_path3.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
902
752
  ];
903
753
  for (const file of candidates) {
904
754
  try {
905
- const existing = import_fs3.default.readFileSync(file, "utf8").trim();
906
- if (existing) return existing;
907
- } catch {
908
- }
909
- }
910
- for (const file of candidates) {
911
- try {
912
- import_fs3.default.mkdirSync(import_path2.default.dirname(file), { recursive: true });
913
- const secret = import_crypto.default.randomBytes(32).toString("hex");
914
- import_fs3.default.writeFileSync(file, secret, { mode: 384 });
915
- return secret;
755
+ tokens.add(import_fs2.default.readFileSync(file, "utf8").trim());
916
756
  } catch {
917
757
  }
918
758
  }
919
- throw new Error(
920
- `iframer: could not read or create a persistent encryption secret in any writable location (${candidates.join(", ")}). Set IFRAMER_SECRET to a stable value shared between the MCP server and CLI (openssl rand -hex 32).`
921
- );
759
+ tokens.delete("");
760
+ return [...tokens];
922
761
  }
923
762
  function deriveKey(token, purpose = INFO) {
924
763
  return new Promise((resolve, reject) => {
@@ -967,77 +806,59 @@ function generateTOTP(secret, period = 30, digits = 6) {
967
806
  return code.toString().padStart(digits, "0");
968
807
  }
969
808
 
970
- // src/lib/iframer.ts
971
- init_persistence();
972
-
973
- // src/lib/screenshot.ts
974
- var import_fs4 = __toESM(require("fs"));
975
- var import_path3 = __toESM(require("path"));
976
- var log3 = createLogger("screenshot");
977
- var MAX_AGE_MS = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_AGE_MS || String(24 * 60 * 60 * 1e3), 10);
978
- var MAX_FILES = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_FILES || "500", 10);
979
- var PRUNE_THROTTLE_MS = 5 * 60 * 1e3;
980
- var lastPruneAt = 0;
981
- function saveScreenshot(buffer, filename, screenshotDir, publicUrl) {
982
- import_fs4.default.mkdirSync(screenshotDir, { recursive: true });
983
- const filePath = import_path3.default.join(screenshotDir, filename);
984
- import_fs4.default.writeFileSync(filePath, buffer);
985
- maybePrune(screenshotDir);
986
- return `${publicUrl}/screenshots/${filename}`;
809
+ // src/lib/session/persistence.ts
810
+ async function extractSession(context, page) {
811
+ const cookies = await context.cookies();
812
+ const { localStorage, sessionStorage } = await page.evaluate(() => {
813
+ const ls = {};
814
+ const ss = {};
815
+ for (let i = 0; i < window.localStorage.length; i++) {
816
+ const key = window.localStorage.key(i);
817
+ ls[key] = window.localStorage.getItem(key);
818
+ }
819
+ for (let i = 0; i < window.sessionStorage.length; i++) {
820
+ const key = window.sessionStorage.key(i);
821
+ ss[key] = window.sessionStorage.getItem(key);
822
+ }
823
+ return { localStorage: ls, sessionStorage: ss };
824
+ });
825
+ const origin = new URL(page.url()).origin;
826
+ return {
827
+ cookies,
828
+ localStorage: { [origin]: localStorage },
829
+ sessionStorage: { [origin]: sessionStorage },
830
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString()
831
+ };
987
832
  }
988
- function maybePrune(dir) {
989
- const now = Date.now();
990
- if (now - lastPruneAt < PRUNE_THROTTLE_MS) return;
991
- lastPruneAt = now;
992
- pruneScreenshots(dir);
833
+ async function injectCookies(context, sessionData) {
834
+ if (sessionData?.cookies?.length > 0) {
835
+ await context.addCookies(sessionData.cookies);
836
+ }
993
837
  }
994
- function pruneScreenshots(dir, opts = {}) {
995
- const maxAgeMs = opts.maxAgeMs ?? MAX_AGE_MS;
996
- const maxFiles = opts.maxFiles ?? MAX_FILES;
997
- const now = opts.now ?? Date.now();
998
- try {
999
- const entries = import_fs4.default.readdirSync(dir).filter((f) => f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".png")).map((f) => {
1000
- const full = import_path3.default.join(dir, f);
1001
- try {
1002
- return { full, mtimeMs: import_fs4.default.statSync(full).mtimeMs };
1003
- } catch {
1004
- return null;
1005
- }
1006
- }).filter((e) => e !== null);
1007
- let removed = 0;
1008
- const survivors = [];
1009
- for (const e of entries) {
1010
- if (now - e.mtimeMs > maxAgeMs) {
1011
- try {
1012
- import_fs4.default.unlinkSync(e.full);
1013
- removed++;
1014
- } catch {
1015
- }
1016
- } else {
1017
- survivors.push(e);
838
+ async function injectStorage(page, sessionData) {
839
+ if (!sessionData) return;
840
+ const origin = new URL(page.url()).origin;
841
+ const ls = sessionData.localStorage?.[origin];
842
+ const ss = sessionData.sessionStorage?.[origin];
843
+ if (ls && Object.keys(ls).length > 0) {
844
+ await page.evaluate((data) => {
845
+ for (const [key, value] of Object.entries(data)) {
846
+ window.localStorage.setItem(key, value);
1018
847
  }
1019
- }
1020
- if (survivors.length > maxFiles) {
1021
- survivors.sort((a, b) => a.mtimeMs - b.mtimeMs);
1022
- for (const e of survivors.slice(0, survivors.length - maxFiles)) {
1023
- try {
1024
- import_fs4.default.unlinkSync(e.full);
1025
- removed++;
1026
- } catch {
1027
- }
848
+ }, ls);
849
+ }
850
+ if (ss && Object.keys(ss).length > 0) {
851
+ await page.evaluate((data) => {
852
+ for (const [key, value] of Object.entries(data)) {
853
+ window.sessionStorage.setItem(key, value);
1028
854
  }
1029
- }
1030
- if (removed > 0) log3.debug(`pruned ${removed} old screenshot(s) from ${dir}`);
1031
- return removed;
1032
- } catch (err) {
1033
- log3.warn(`screenshot prune failed: ${err instanceof Error ? err.message : String(err)}`);
1034
- return 0;
855
+ }, ss);
1035
856
  }
1036
857
  }
1037
858
 
1038
859
  // src/lib/session/sqlite-store.ts
1039
860
  var import_path4 = __toESM(require("path"));
1040
- var import_fs5 = __toESM(require("fs"));
861
+ var import_fs3 = __toESM(require("fs"));
1041
862
  var IS_BUN = typeof globalThis.Bun !== "undefined";
1042
863
  function createBunDb(dbPath) {
1043
864
  const { Database } = require("bun:sqlite");
@@ -1076,7 +897,7 @@ function createNodeDb(dbPath) {
1076
897
  var SqliteStore = class {
1077
898
  db;
1078
899
  constructor(dataDir) {
1079
- import_fs5.default.mkdirSync(dataDir, { recursive: true });
900
+ import_fs3.default.mkdirSync(dataDir, { recursive: true });
1080
901
  const dbPath = import_path4.default.join(dataDir, "iframer.db");
1081
902
  this.db = IS_BUN ? createBunDb(dbPath) : createNodeDb(dbPath);
1082
903
  this.db.run(`
@@ -1157,11 +978,11 @@ var import_patchright2 = require("patchright");
1157
978
  var import_crypto2 = require("crypto");
1158
979
 
1159
980
  // src/lib/browser/chrome-downloader.ts
1160
- var import_fs6 = __toESM(require("fs"));
981
+ var import_fs4 = __toESM(require("fs"));
1161
982
  var import_path5 = __toESM(require("path"));
1162
983
  var import_os3 = __toESM(require("os"));
1163
984
  var import_child_process2 = require("child_process");
1164
- var log4 = createLogger("chrome");
985
+ var log3 = createLogger("chrome");
1165
986
  var CHROME_VERSIONS_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
1166
987
  var DEFAULT_INSTALL_DIR = import_path5.default.join(import_os3.default.homedir(), ".iframer", "chrome");
1167
988
  function getPlatform() {
@@ -1175,24 +996,24 @@ function getPlatform() {
1175
996
  function getChromeExecutablePath(installDir) {
1176
997
  const platform = process.platform;
1177
998
  if (platform === "darwin") {
1178
- const entries = import_fs6.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
999
+ const entries = import_fs4.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1179
1000
  const dir = entries[0] || "chrome-mac-arm64";
1180
1001
  return import_path5.default.join(installDir, dir, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
1181
1002
  }
1182
1003
  if (platform === "linux") {
1183
- const entries = import_fs6.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1004
+ const entries = import_fs4.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1184
1005
  const dir = entries[0] || "chrome-linux64";
1185
1006
  return import_path5.default.join(installDir, dir, "chrome");
1186
1007
  }
1187
1008
  if (platform === "win32") {
1188
- const entries = import_fs6.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1009
+ const entries = import_fs4.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1189
1010
  const dir = entries[0] || "chrome-win64";
1190
1011
  return import_path5.default.join(installDir, dir, "chrome.exe");
1191
1012
  }
1192
1013
  throw new Error(`Unsupported platform: ${platform}`);
1193
1014
  }
1194
1015
  async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
1195
- log4.info("Downloading Chrome for Testing (first time only)...");
1016
+ log3.info("Downloading Chrome for Testing (first time only)...");
1196
1017
  const res = await fetch(CHROME_VERSIONS_URL);
1197
1018
  if (!res.ok) throw new Error(`Failed to fetch Chrome versions: ${res.status}`);
1198
1019
  const data = await res.json();
@@ -1203,35 +1024,35 @@ async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
1203
1024
  if (!download2) throw new Error(`No Chrome for Testing download for platform: ${platform}`);
1204
1025
  const url = download2.url;
1205
1026
  const version = channel.version;
1206
- log4.debug(`Version ${version} for ${platform}`);
1207
- log4.debug(`URL: ${url}`);
1208
- import_fs6.default.mkdirSync(installDir, { recursive: true });
1027
+ log3.debug(`Version ${version} for ${platform}`);
1028
+ log3.debug(`URL: ${url}`);
1029
+ import_fs4.default.mkdirSync(installDir, { recursive: true });
1209
1030
  const zipPath = import_path5.default.join(installDir, "chrome.zip");
1210
1031
  const dlRes = await fetch(url);
1211
1032
  if (!dlRes.ok) throw new Error(`Download failed: ${dlRes.status}`);
1212
1033
  const buf = Buffer.from(await dlRes.arrayBuffer());
1213
- import_fs6.default.writeFileSync(zipPath, buf);
1214
- log4.info(`Downloaded ${(buf.length / 1024 / 1024).toFixed(1)}MB`);
1034
+ import_fs4.default.writeFileSync(zipPath, buf);
1035
+ log3.info(`Downloaded ${(buf.length / 1024 / 1024).toFixed(1)}MB`);
1215
1036
  (0, import_child_process2.execSync)(`unzip -o -q "${zipPath}" -d "${installDir}"`, { stdio: "inherit" });
1216
- import_fs6.default.unlinkSync(zipPath);
1037
+ import_fs4.default.unlinkSync(zipPath);
1217
1038
  const execPath = getChromeExecutablePath(installDir);
1218
- if (!import_fs6.default.existsSync(execPath)) {
1039
+ if (!import_fs4.default.existsSync(execPath)) {
1219
1040
  throw new Error(`Chrome executable not found after extraction: ${execPath}`);
1220
1041
  }
1221
1042
  if (process.platform !== "win32") {
1222
- import_fs6.default.chmodSync(execPath, 493);
1043
+ import_fs4.default.chmodSync(execPath, 493);
1223
1044
  }
1224
- import_fs6.default.writeFileSync(import_path5.default.join(installDir, "version.json"), JSON.stringify({ version, platform, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1225
- log4.info(`Installed at: ${execPath}`);
1045
+ import_fs4.default.writeFileSync(import_path5.default.join(installDir, "version.json"), JSON.stringify({ version, platform, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1046
+ log3.info(`Installed at: ${execPath}`);
1226
1047
  return execPath;
1227
1048
  }
1228
1049
  function findChromeForTesting() {
1229
1050
  if (process.env.CHROME_EXECUTABLE) {
1230
- if (import_fs6.default.existsSync(process.env.CHROME_EXECUTABLE)) return process.env.CHROME_EXECUTABLE;
1051
+ if (import_fs4.default.existsSync(process.env.CHROME_EXECUTABLE)) return process.env.CHROME_EXECUTABLE;
1231
1052
  }
1232
1053
  try {
1233
1054
  const execPath = getChromeExecutablePath(DEFAULT_INSTALL_DIR);
1234
- if (import_fs6.default.existsSync(execPath)) return execPath;
1055
+ if (import_fs4.default.existsSync(execPath)) return execPath;
1235
1056
  } catch {
1236
1057
  }
1237
1058
  return null;
@@ -1247,22 +1068,13 @@ function findChrome() {
1247
1068
  "/usr/bin/google-chrome-stable",
1248
1069
  "/usr/bin/google-chrome",
1249
1070
  "/usr/bin/chromium-browser",
1250
- "/usr/bin/chromium",
1251
- // Playwright/Patchright installed browsers (Docker containers)
1252
- ...(() => {
1253
- try {
1254
- const dirs = import_fs6.default.readdirSync("/ms-playwright").filter((d) => d.startsWith("chromium-")).sort().reverse();
1255
- return dirs.map((d) => import_path5.default.join("/ms-playwright", d, "chrome-linux", "chrome"));
1256
- } catch {
1257
- return [];
1258
- }
1259
- })()
1071
+ "/usr/bin/chromium"
1260
1072
  ] : [
1261
1073
  "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
1262
1074
  "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
1263
1075
  ];
1264
1076
  for (const p of systemPaths) {
1265
- if (import_fs6.default.existsSync(p)) return p;
1077
+ if (import_fs4.default.existsSync(p)) return p;
1266
1078
  }
1267
1079
  return null;
1268
1080
  }
@@ -1272,10 +1084,10 @@ async function ensureChrome() {
1272
1084
  try {
1273
1085
  return await downloadChrome();
1274
1086
  } catch (err) {
1275
- log4.error(`Failed to download Chrome for Testing: ${err instanceof Error ? err.message : String(err)}`);
1087
+ log3.error(`Failed to download Chrome for Testing: ${err instanceof Error ? err.message : String(err)}`);
1276
1088
  const system = findChrome();
1277
1089
  if (system) {
1278
- log4.warn(`Falling back to system Chrome: ${system}`);
1090
+ log3.warn(`Falling back to system Chrome: ${system}`);
1279
1091
  return system;
1280
1092
  }
1281
1093
  throw new Error("No Chrome found. Download failed and no system Chrome available.");
@@ -1283,7 +1095,7 @@ async function ensureChrome() {
1283
1095
  }
1284
1096
 
1285
1097
  // src/lib/browser/cloak-browser.ts
1286
- var log5 = createLogger("cloak");
1098
+ var log4 = createLogger("cloak");
1287
1099
  var _available = null;
1288
1100
  function cloakEnabled() {
1289
1101
  return process.env.IFRAMER_USE_CLOAKBROWSER === "1" || process.env.IFRAMER_USE_CLOAKBROWSER === "true";
@@ -1302,179 +1114,42 @@ async function ensureBinary() {
1302
1114
  try {
1303
1115
  const info = cloak.binaryInfo();
1304
1116
  if (!info.installed) {
1305
- log5.info("Downloading CloakBrowser binary...");
1117
+ log4.info("Downloading CloakBrowser binary...");
1306
1118
  await cloak.ensureBinary();
1307
- log5.info("CloakBrowser ready");
1119
+ log4.info("CloakBrowser ready");
1308
1120
  }
1309
1121
  _available = true;
1310
1122
  return true;
1311
1123
  } catch (err) {
1312
- log5.warn(`CloakBrowser setup failed: ${err instanceof Error ? err.message : String(err)}`);
1124
+ log4.warn(`CloakBrowser setup failed: ${err instanceof Error ? err.message : String(err)}`);
1313
1125
  _available = false;
1314
1126
  return false;
1315
1127
  }
1316
1128
  }
1317
1129
  async function launchCloakBrowser(options) {
1318
1130
  const cloak = await tryImport();
1319
- if (!cloak) return null;
1320
- try {
1321
- const ok = await ensureBinary();
1322
- if (!ok) return null;
1323
- const browser = await cloak.launch({
1324
- headless: options.headless,
1325
- args: options.args
1326
- });
1327
- log5.info(`CloakBrowser launched (headless=${options.headless})`);
1328
- return browser;
1329
- } catch (err) {
1330
- log5.warn(`CloakBrowser launch failed: ${err instanceof Error ? err.message : String(err)}`);
1331
- return null;
1332
- }
1333
- }
1334
-
1335
- // src/lib/browser/registry.ts
1336
- var import_fs7 = __toESM(require("fs"));
1337
- var import_path6 = __toESM(require("path"));
1338
- var import_child_process3 = require("child_process");
1339
- init_paths();
1340
- var log6 = createLogger("registry");
1341
- function browsersDir() {
1342
- const dir = import_path6.default.join(getDataDir(), "browsers");
1343
- import_fs7.default.mkdirSync(dir, { recursive: true });
1344
- return dir;
1345
- }
1346
- function serverInfoPath() {
1347
- return import_path6.default.join(getDataDir(), "server.json");
1348
- }
1349
- function isPidAlive(pid) {
1350
- if (!Number.isInteger(pid) || pid <= 1) return false;
1351
- try {
1352
- process.kill(pid, 0);
1353
- return true;
1354
- } catch {
1355
- return false;
1356
- }
1357
- }
1358
- function pidMatchesMarker(pid, marker) {
1359
- if (!isPidAlive(pid)) return false;
1360
- try {
1361
- const cmd = (0, import_child_process3.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
1362
- return cmd.includes(marker);
1363
- } catch {
1364
- return false;
1365
- }
1366
- }
1367
- function findChromePidByMarker(marker) {
1368
- try {
1369
- const out = (0, import_child_process3.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
1370
- const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
1371
- if (pids.length === 0) return null;
1372
- return Math.min(...pids);
1373
- } catch {
1374
- return null;
1375
- }
1376
- }
1377
- function registerBrowser(rec) {
1378
- try {
1379
- import_fs7.default.writeFileSync(import_path6.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
1380
- } catch (err) {
1381
- log6.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
1382
- }
1383
- }
1384
- function unregisterBrowser(chromePid) {
1385
- try {
1386
- import_fs7.default.unlinkSync(import_path6.default.join(browsersDir(), `${chromePid}.json`));
1387
- } catch {
1388
- }
1389
- }
1390
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1391
- async function forceKillBrowser(rec) {
1392
- if (!isPidAlive(rec.chromePid)) return true;
1393
- if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
1394
- return true;
1395
- }
1396
- try {
1397
- process.kill(rec.chromePid, "SIGKILL");
1398
- } catch {
1399
- }
1400
- const deadline = Date.now() + 2e3;
1401
- while (Date.now() < deadline) {
1402
- if (!isPidAlive(rec.chromePid)) return true;
1403
- await sleep(100);
1404
- }
1405
- return !isPidAlive(rec.chromePid);
1406
- }
1407
- async function reapOrphanBrowsers() {
1408
- let reaped = 0;
1409
- let skipped = 0;
1410
- let files = [];
1411
- try {
1412
- files = import_fs7.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
1413
- } catch {
1414
- return { reaped, skipped };
1415
- }
1416
- for (const file of files) {
1417
- const full = import_path6.default.join(browsersDir(), file);
1418
- let rec;
1419
- try {
1420
- rec = JSON.parse(import_fs7.default.readFileSync(full, "utf8"));
1421
- } catch {
1422
- try {
1423
- import_fs7.default.unlinkSync(full);
1424
- } catch {
1425
- }
1426
- continue;
1427
- }
1428
- if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
1429
- try {
1430
- import_fs7.default.unlinkSync(full);
1431
- } catch {
1432
- }
1433
- continue;
1434
- }
1435
- if (isPidAlive(rec.ownerPid)) {
1436
- skipped++;
1437
- continue;
1438
- }
1439
- log6.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
1440
- if (await forceKillBrowser(rec)) {
1441
- try {
1442
- import_fs7.default.unlinkSync(full);
1443
- } catch {
1444
- }
1445
- reaped++;
1446
- } else {
1447
- log6.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
1448
- }
1449
- }
1450
- return { reaped, skipped };
1451
- }
1452
- function writeServerInfo(info) {
1453
- import_fs7.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
1454
- }
1455
- function readServerInfo() {
1456
- try {
1457
- const info = JSON.parse(import_fs7.default.readFileSync(serverInfoPath(), "utf8"));
1458
- if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
1459
- return info;
1460
- } catch {
1131
+ if (!cloak) return null;
1132
+ try {
1133
+ const ok = await ensureBinary();
1134
+ if (!ok) return null;
1135
+ const browser = await cloak.launch({
1136
+ headless: options.headless,
1137
+ args: options.args
1138
+ });
1139
+ log4.info(`CloakBrowser launched (headless=${options.headless})`);
1140
+ return browser;
1141
+ } catch (err) {
1142
+ log4.warn(`CloakBrowser launch failed: ${err instanceof Error ? err.message : String(err)}`);
1461
1143
  return null;
1462
1144
  }
1463
1145
  }
1464
- function clearServerInfo(pid) {
1465
- const info = readServerInfo();
1466
- if (info && info.pid === pid) {
1467
- try {
1468
- import_fs7.default.unlinkSync(serverInfoPath());
1469
- } catch {
1470
- }
1471
- }
1472
- }
1473
1146
 
1474
1147
  // src/lib/browser/daemon.ts
1475
- var log7 = createLogger("daemon");
1148
+ var log5 = createLogger("daemon");
1476
1149
  var DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1e3;
1477
1150
  var CLOSE_GRACE_MS = 5e3;
1151
+ var BUSY_MAX_MS = 30 * 60 * 1e3;
1152
+ var BLANK_IDLE_MS = 2 * 60 * 1e3;
1478
1153
  var DEFAULT_INSTANCE = "default";
1479
1154
  function keyOf(mode, instanceId) {
1480
1155
  return `${mode}::${instanceId}`;
@@ -1488,9 +1163,6 @@ var BrowserDaemon = class {
1488
1163
  this.idleTimeout = idleTimeout;
1489
1164
  }
1490
1165
  async ensure(mode, instanceId = DEFAULT_INSTANCE, sessionProfile = instanceId) {
1491
- if (mode === "docker-headful") {
1492
- throw new Error("Docker mode doesn't use the daemon. Use the Docker API.");
1493
- }
1494
1166
  const key = keyOf(mode, instanceId);
1495
1167
  let instance = this.instances.get(key);
1496
1168
  if (instance) {
@@ -1501,11 +1173,11 @@ var BrowserDaemon = class {
1501
1173
  try {
1502
1174
  await page2.evaluate("1");
1503
1175
  } catch {
1504
- log7.info(`Page for ${mode} is dead, creating fresh context`);
1176
+ log5.info(`Page for ${mode} is dead, creating fresh context`);
1505
1177
  try {
1506
1178
  await context2.close();
1507
1179
  } catch (err) {
1508
- log7.warn(`dead-page context close failed: ${err}`);
1180
+ log5.warn(`dead-page context close failed: ${err}`);
1509
1181
  }
1510
1182
  context2 = await instance.browser.newContext();
1511
1183
  page2 = await context2.newPage();
@@ -1518,18 +1190,18 @@ var BrowserDaemon = class {
1518
1190
  }
1519
1191
  } catch {
1520
1192
  }
1521
- log7.info(`Browser for ${key} disconnected (window closed?), relaunching...`);
1193
+ log5.info(`Browser for ${key} disconnected (window closed?), relaunching...`);
1522
1194
  await this.stopMode(mode, instanceId);
1523
1195
  }
1524
1196
  const marker = `--iframer-key=${key}-${(0, import_crypto2.randomUUID)()}`;
1525
1197
  let browser;
1526
1198
  const cloakBrowser = await launchCloakBrowser({ headless: mode === "headless", args: [marker] });
1527
1199
  if (cloakBrowser) {
1528
- log7.info(`CloakBrowser ${mode} ready`);
1200
+ log5.info(`CloakBrowser ${mode} ready`);
1529
1201
  browser = cloakBrowser;
1530
1202
  } else {
1531
1203
  const executablePath = await ensureChrome();
1532
- log7.info(`Falling back to Chrome for Testing in ${mode} mode: ${executablePath}`);
1204
+ log5.info(`Falling back to Chrome for Testing in ${mode} mode: ${executablePath}`);
1533
1205
  browser = await import_patchright2.chromium.launch({
1534
1206
  executablePath,
1535
1207
  headless: mode === "headless",
@@ -1552,7 +1224,7 @@ var BrowserDaemon = class {
1552
1224
  launchedAt: (/* @__PURE__ */ new Date()).toISOString()
1553
1225
  });
1554
1226
  } else {
1555
- log7.warn(`could not resolve Chrome PID for ${key} \u2014 force-kill unavailable for this instance`);
1227
+ log5.warn(`could not resolve Chrome PID for ${key} \u2014 force-kill unavailable for this instance`);
1556
1228
  }
1557
1229
  const context = await browser.newContext();
1558
1230
  const page = await context.newPage();
@@ -1566,11 +1238,12 @@ var BrowserDaemon = class {
1566
1238
  createdAt: /* @__PURE__ */ new Date(),
1567
1239
  chromePid,
1568
1240
  marker,
1569
- active: 0
1241
+ active: 0,
1242
+ busySince: null
1570
1243
  };
1571
1244
  this.instances.set(key, instance);
1572
1245
  this.resetIdleTimer(key);
1573
- log7.info(`Chrome ${key} ready (pid=${chromePid ?? "unknown"})`);
1246
+ log5.info(`Chrome ${key} ready (pid=${chromePid ?? "unknown"})`);
1574
1247
  return { browser, context, page };
1575
1248
  }
1576
1249
  /** Mark an instance busy for the duration of a pipeline run. While busy,
@@ -1578,14 +1251,19 @@ var BrowserDaemon = class {
1578
1251
  * Always pair with release() in a finally block. */
1579
1252
  acquire(mode, instanceId = DEFAULT_INSTANCE) {
1580
1253
  const instance = this.instances.get(keyOf(mode, instanceId));
1581
- if (instance) instance.active++;
1254
+ if (!instance) return;
1255
+ if (instance.active === 0) instance.busySince = Date.now();
1256
+ instance.active++;
1582
1257
  }
1583
1258
  release(mode, instanceId = DEFAULT_INSTANCE) {
1584
1259
  const key = keyOf(mode, instanceId);
1585
1260
  const instance = this.instances.get(key);
1586
1261
  if (!instance) return;
1587
1262
  instance.active = Math.max(0, instance.active - 1);
1588
- if (instance.active === 0) this.resetIdleTimer(key);
1263
+ if (instance.active === 0) {
1264
+ instance.busySince = null;
1265
+ this.resetIdleTimer(key);
1266
+ }
1589
1267
  }
1590
1268
  isRunning(mode, instanceId = DEFAULT_INSTANCE) {
1591
1269
  const instance = this.instances.get(keyOf(mode, instanceId));
@@ -1596,6 +1274,35 @@ var BrowserDaemon = class {
1596
1274
  return false;
1597
1275
  }
1598
1276
  }
1277
+ /** Close instances that never left about:blank and have been idle a while.
1278
+ * Those are accidents — a pipeline that failed before its first navigate, or
1279
+ * a mode picked for a call that never needed a browser — and in headful mode
1280
+ * each one is an empty window sitting on the user's desktop. Called on the
1281
+ * server's reap tick, so it also catches instances whose idle timer was lost
1282
+ * (e.g. the daemon was restarted under them). */
1283
+ sweepBlankInstances() {
1284
+ const stopped = [];
1285
+ const now = Date.now();
1286
+ for (const [key, inst] of this.instances.entries()) {
1287
+ if (inst.active > 0) continue;
1288
+ if (now - inst.createdAt.getTime() < BLANK_IDLE_MS) continue;
1289
+ let blank = false;
1290
+ try {
1291
+ const pages = inst.context.pages();
1292
+ blank = pages.length === 0 || pages.every((p) => {
1293
+ const u = p.url();
1294
+ return u === "about:blank" || u === "" || u === "chrome://newtab/";
1295
+ });
1296
+ } catch {
1297
+ continue;
1298
+ }
1299
+ if (!blank) continue;
1300
+ log5.info(`blank-orphan sweep: ${key} never left about:blank, stopping`);
1301
+ stopped.push(key);
1302
+ this.stopKey(key).catch((err) => log5.warn(`blank sweep stop failed for ${key}: ${err}`));
1303
+ }
1304
+ return stopped;
1305
+ }
1599
1306
  /** Distinct modes that currently have at least one live instance. */
1600
1307
  runningModes() {
1601
1308
  return [...new Set(this.liveInstances().map((i) => i.mode))];
@@ -1664,17 +1371,17 @@ var BrowserDaemon = class {
1664
1371
  const timer = this.idleTimers.get(key);
1665
1372
  if (timer) clearTimeout(timer);
1666
1373
  this.idleTimers.delete(key);
1667
- log7.info(`Stopping Chrome ${key} (pid=${instance.chromePid ?? "unknown"})...`);
1374
+ log5.info(`Stopping Chrome ${key} (pid=${instance.chromePid ?? "unknown"})...`);
1668
1375
  const politeClose = (async () => {
1669
1376
  try {
1670
1377
  await instance.context.close();
1671
1378
  } catch (err) {
1672
- log7.warn(`context.close failed for ${key}: ${err}`);
1379
+ log5.warn(`context.close failed for ${key}: ${err}`);
1673
1380
  }
1674
1381
  try {
1675
1382
  await instance.browser.close();
1676
1383
  } catch (err) {
1677
- log7.warn(`browser.close failed for ${key}: ${err}`);
1384
+ log5.warn(`browser.close failed for ${key}: ${err}`);
1678
1385
  }
1679
1386
  })();
1680
1387
  const closedInTime = await Promise.race([
@@ -1682,20 +1389,20 @@ var BrowserDaemon = class {
1682
1389
  sleep2(CLOSE_GRACE_MS).then(() => false)
1683
1390
  ]);
1684
1391
  if (!closedInTime) {
1685
- log7.warn(`polite close timed out after ${CLOSE_GRACE_MS}ms for ${key}, force-killing`);
1392
+ log5.warn(`polite close timed out after ${CLOSE_GRACE_MS}ms for ${key}, force-killing`);
1686
1393
  }
1687
1394
  if (instance.chromePid !== null) {
1688
1395
  const dead = await forceKillBrowser({ chromePid: instance.chromePid, marker: instance.marker });
1689
1396
  if (dead) {
1690
1397
  unregisterBrowser(instance.chromePid);
1691
1398
  } else {
1692
- log7.warn(`Chrome pid=${instance.chromePid} survived SIGKILL?! leaving registry record for reaper`);
1399
+ log5.warn(`Chrome pid=${instance.chromePid} survived SIGKILL?! leaving registry record for reaper`);
1693
1400
  }
1694
1401
  } else if (!closedInTime) {
1695
- log7.warn(`no PID recorded for ${key} and polite close hung \u2014 this Chrome may leak until the next reap`);
1402
+ log5.warn(`no PID recorded for ${key} and polite close hung \u2014 this Chrome may leak until the next reap`);
1696
1403
  }
1697
1404
  this.instances.delete(key);
1698
- log7.info(`Stopped Chrome ${key}`);
1405
+ log5.info(`Stopped Chrome ${key}`);
1699
1406
  }
1700
1407
  /**
1701
1408
  * Stop browsers. By default skips instances that are mid-pipeline (active>0)
@@ -1714,11 +1421,17 @@ var BrowserDaemon = class {
1714
1421
  setTimeout(() => {
1715
1422
  const instance = this.instances.get(key);
1716
1423
  if (instance && instance.active > 0) {
1717
- this.resetIdleTimer(key);
1718
- return;
1424
+ const busyMs = instance.busySince ? Date.now() - instance.busySince : 0;
1425
+ if (busyMs < BUSY_MAX_MS) {
1426
+ this.resetIdleTimer(key);
1427
+ return;
1428
+ }
1429
+ log5.warn(`${key} reported busy for ${Math.round(busyMs / 6e4)}m \u2014 treating active=${instance.active} as leaked, stopping`);
1430
+ instance.active = 0;
1431
+ instance.busySince = null;
1719
1432
  }
1720
- log7.info(`Idle timeout for ${key}, stopping...`);
1721
- this.stopKey(key).catch((err) => log7.warn(`idle stop failed for ${key}: ${err}`));
1433
+ log5.info(`Idle timeout for ${key}, stopping...`);
1434
+ this.stopKey(key).catch((err) => log5.warn(`idle stop failed for ${key}: ${err}`));
1722
1435
  }, this.idleTimeout)
1723
1436
  );
1724
1437
  }
@@ -1731,15 +1444,15 @@ var BrowserDaemon = class {
1731
1444
  };
1732
1445
 
1733
1446
  // src/lib/domain-modes.ts
1734
- var import_fs8 = __toESM(require("fs"));
1735
- var import_path7 = __toESM(require("path"));
1447
+ var import_fs5 = __toESM(require("fs"));
1448
+ var import_path6 = __toESM(require("path"));
1736
1449
  init_paths();
1737
- var log8 = createLogger("domain-modes");
1450
+ var log6 = createLogger("domain-modes");
1738
1451
  function defaultFile() {
1739
- return import_path7.default.join(getDataDir(), "domain-modes.json");
1452
+ return import_path6.default.join(getDataDir(), "domain-modes.json");
1740
1453
  }
1741
1454
  var TTL_DAYS = 14;
1742
- var ESCALATION_LADDER = ["headless", "docker-headful", "binary-headful"];
1455
+ var ESCALATION_LADDER = ["headless", "binary-headful"];
1743
1456
  var DomainModeStore = class {
1744
1457
  data = {};
1745
1458
  filePath;
@@ -1818,8 +1531,8 @@ var DomainModeStore = class {
1818
1531
  }
1819
1532
  load() {
1820
1533
  try {
1821
- if (import_fs8.default.existsSync(this.filePath)) {
1822
- this.data = JSON.parse(import_fs8.default.readFileSync(this.filePath, "utf-8"));
1534
+ if (import_fs5.default.existsSync(this.filePath)) {
1535
+ this.data = JSON.parse(import_fs5.default.readFileSync(this.filePath, "utf-8"));
1823
1536
  }
1824
1537
  } catch {
1825
1538
  this.data = {};
@@ -1827,10 +1540,10 @@ var DomainModeStore = class {
1827
1540
  }
1828
1541
  save() {
1829
1542
  try {
1830
- import_fs8.default.mkdirSync(import_path7.default.dirname(this.filePath), { recursive: true });
1831
- import_fs8.default.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
1543
+ import_fs5.default.mkdirSync(import_path6.default.dirname(this.filePath), { recursive: true });
1544
+ import_fs5.default.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
1832
1545
  } catch (err) {
1833
- log8.error("Failed to save:", err);
1546
+ log6.error("Failed to save:", err);
1834
1547
  }
1835
1548
  }
1836
1549
  };
@@ -1946,7 +1659,7 @@ function failedStepResult(step, error, durationMs, stepIndex = -1) {
1946
1659
  }
1947
1660
 
1948
1661
  // src/lib/clipboard.ts
1949
- var import_child_process4 = require("child_process");
1662
+ var import_child_process3 = require("child_process");
1950
1663
  function platformTools(mode) {
1951
1664
  if (process.platform === "darwin") return [[mode === "read" ? "pbpaste" : "pbcopy"]];
1952
1665
  if (mode === "read") return [["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "-b", "-o"]];
@@ -1956,7 +1669,7 @@ function run(cmd, input) {
1956
1669
  return new Promise((resolve) => {
1957
1670
  let child;
1958
1671
  try {
1959
- child = (0, import_child_process4.spawn)(cmd[0], cmd.slice(1));
1672
+ child = (0, import_child_process3.spawn)(cmd[0], cmd.slice(1));
1960
1673
  } catch {
1961
1674
  resolve({ ok: false, out: "", err: `spawn ${cmd[0]} failed` });
1962
1675
  return;
@@ -1984,6 +1697,112 @@ async function clipboardRead() {
1984
1697
  throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}). ${lastErr}`);
1985
1698
  }
1986
1699
 
1700
+ // src/lib/constants.ts
1701
+ var TIMING = {
1702
+ /** Mouse move settle delay */
1703
+ MOUSE_MOVE: [50, 200],
1704
+ /** Click down→up delay */
1705
+ CLICK_HOLD: [30, 90],
1706
+ /** Post-click settle */
1707
+ POST_CLICK: [100, 300],
1708
+ /** Per-character typing delay */
1709
+ CHAR_DELAY: [30, 150],
1710
+ /** Occasional word-boundary pause */
1711
+ WORD_PAUSE: [200, 500],
1712
+ /** Pre-action move range */
1713
+ IDLE_MOUSE_X: [100, 400],
1714
+ IDLE_MOUSE_Y: [100, 300],
1715
+ /** Pre-checkbox random move */
1716
+ PRE_CHECKBOX_X: [200, 600],
1717
+ PRE_CHECKBOX_Y: [150, 400],
1718
+ /** Pause after moving to checkbox area */
1719
+ PRE_CHECKBOX_WAIT: [300, 800],
1720
+ /** Wait after clicking reCAPTCHA checkbox */
1721
+ POST_CHECKBOX_WAIT: 2500,
1722
+ /** Wait after clicking verify button */
1723
+ POST_VERIFY_WAIT: 2e3,
1724
+ /** Wait between clicking captcha tiles */
1725
+ TILE_CLICK_DELAY: [200, 500],
1726
+ /** Time to wait for reCAPTCHA tile images to settle */
1727
+ TILE_SETTLE: 800,
1728
+ /** Delay before captcha auto-detection */
1729
+ CAPTCHA_DETECT_WAIT: 1500,
1730
+ /** Post-login navigation settle */
1731
+ POST_LOGIN_WAIT: 1500,
1732
+ /** Post-submit form delay */
1733
+ POST_SUBMIT_WAIT: 500,
1734
+ /** Post-submit form extended wait */
1735
+ POST_SUBMIT_EXTENDED: 2e3,
1736
+ /** Delay before navigating (human hesitation) */
1737
+ PRE_NAVIGATE: [300, 700],
1738
+ /** Per-digit input delay base */
1739
+ DIGIT_DELAY_BASE: 80,
1740
+ DIGIT_DELAY_RANGE: 120,
1741
+ /** Post-click in forms */
1742
+ POST_FORM_CLICK: 200,
1743
+ /** Post-TOTP entry wait */
1744
+ POST_TOTP_WAIT: 300,
1745
+ /** Post-cookies inject wait */
1746
+ POST_COOKIES_WAIT: 300,
1747
+ /** Scroll step delay */
1748
+ SCROLL_DELAY: 150,
1749
+ /** Stale state check interval */
1750
+ STALE_CHECK_INTERVAL: 2e3,
1751
+ /** captureApi: grace period after the last step for a submit's XHR to fire */
1752
+ CAPTURE_SETTLE: 300,
1753
+ /** captureApi: max wait for in-flight requests to answer before stopping */
1754
+ CAPTURE_PENDING_MAX: 3e3,
1755
+ /** Post-failure probe ("does the selector exist right now?") budget */
1756
+ FAILURE_PROBE: 3e3
1757
+ };
1758
+ var CAPTCHA_GRID = {
1759
+ /** Pixels from bframe top to grid start (reCAPTCHA) */
1760
+ RECAPTCHA_HEADER_HEIGHT: 112,
1761
+ /** Pixels from bframe top to grid start (hCaptcha) — slightly different */
1762
+ HCAPTCHA_HEADER_HEIGHT: 110,
1763
+ /** Default tile size when bframeBox is unavailable */
1764
+ DEFAULT_TILE_SIZE: 125,
1765
+ /** Grid padding (left+right total) */
1766
+ GRID_PADDING: 24,
1767
+ /** Grid left margin */
1768
+ GRID_MARGIN: 12,
1769
+ /** Verify button offset from bframe bottom */
1770
+ VERIFY_BTN_BOTTOM_OFFSET: 35,
1771
+ /** Verify button offset from bframe right */
1772
+ VERIFY_BTN_RIGHT_OFFSET: 60
1773
+ };
1774
+ var THRESHOLDS = {
1775
+ /** Minimum character change to consider page "changed" */
1776
+ STALE_CHAR_CHANGE: 100,
1777
+ /** Minimum percentage change to consider page "changed" */
1778
+ STALE_PERCENT_CHANGE: 0.05,
1779
+ /** Minimum body text length to not be considered "empty" */
1780
+ MIN_BODY_TEXT: 200,
1781
+ /** Maximum response text length to capture */
1782
+ MAX_RESPONSE_TEXT: 1e5
1783
+ };
1784
+ var TIMEOUTS = {
1785
+ DEFAULT_STALE: 2e4,
1786
+ NAVIGATION: 6e4,
1787
+ SELECTOR_WAIT: 1e4,
1788
+ // Actionability wait for click. Under the 20s stale timer on purpose, so
1789
+ // Playwright's own reason (not visible, covered by another element…) reaches
1790
+ // the agent instead of a generic "no state change".
1791
+ CLICK: 8e3,
1792
+ TOTP_INPUT: 5e3,
1793
+ API_REQUEST: 18e4,
1794
+ HEALTH_CHECK: 3e3,
1795
+ CHALLENGE_FRAME_WAIT: 5e3,
1796
+ // New-tab following: after a click, how long to wait for a just-opened tab's
1797
+ // 'page' event to arrive (only applies when no tab has registered yet), and
1798
+ // how long to let the followed tab reach domcontentloaded before continuing.
1799
+ TAB_FOLLOW_SETTLE: 400,
1800
+ TAB_LOAD: 15e3,
1801
+ // A new tab usually opens at about:blank then navigates to its real URL —
1802
+ // wait this long for that first real navigation before judging it blank.
1803
+ TAB_BLANK_RESOLVE: 3e3
1804
+ };
1805
+
1987
1806
  // src/lib/browser/humanize.ts
1988
1807
  function rand(min, max) {
1989
1808
  return Math.random() * (max - min) + min;
@@ -2021,8 +1840,8 @@ async function humanMove(page, toX, toY) {
2021
1840
  const lastPos = mousePositions.get(page);
2022
1841
  const fromX = lastPos?.x ?? randRange(TIMING.IDLE_MOUSE_X);
2023
1842
  const fromY = lastPos?.y ?? randRange(TIMING.IDLE_MOUSE_Y);
2024
- const path13 = generatePath(fromX, fromY, toX, toY);
2025
- for (const point of path13) {
1843
+ const path12 = generatePath(fromX, fromY, toX, toY);
1844
+ for (const point of path12) {
2026
1845
  await mouse.move(point.x, point.y);
2027
1846
  await sleep3(rand(2, 12));
2028
1847
  }
@@ -2074,6 +1893,24 @@ async function humanType(page, selector, text, opts = {}) {
2074
1893
  await sleep3(randRange(TIMING.POST_CLICK));
2075
1894
  }
2076
1895
  await assertFocused(page, selector, !opts.skipClick);
1896
+ if (opts.clear) {
1897
+ const hadText = await page.evaluate(() => {
1898
+ const el = document.activeElement;
1899
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
1900
+ el.select();
1901
+ return el.value.length > 0;
1902
+ }
1903
+ if (el?.isContentEditable) {
1904
+ window.getSelection()?.selectAllChildren(el);
1905
+ return (el.textContent || "").length > 0;
1906
+ }
1907
+ return false;
1908
+ });
1909
+ if (hadText) {
1910
+ await page.keyboard.press("Backspace");
1911
+ await sleep3(randRange(TIMING.CHAR_DELAY));
1912
+ }
1913
+ }
2077
1914
  const factor = SPEED_FACTOR[opts.speed || "normal"] ?? 1;
2078
1915
  for (const char of text) {
2079
1916
  await page.keyboard.type(char);
@@ -2226,10 +2063,36 @@ function sleep3(ms) {
2226
2063
  return new Promise((r) => setTimeout(r, ms));
2227
2064
  }
2228
2065
 
2229
- // src/lib/actions/handlers/navigation.ts
2230
- init_persistence();
2066
+ // src/lib/budget.ts
2067
+ var BudgetExceededError = class extends Error {
2068
+ constructor(label, ms) {
2069
+ super(`${label} did not answer within ${ms}ms`);
2070
+ this.name = "BudgetExceededError";
2071
+ }
2072
+ };
2073
+ function withBudget(p, ms, label = "page call") {
2074
+ p.catch(() => void 0);
2075
+ let timer;
2076
+ return Promise.race([
2077
+ p,
2078
+ new Promise((_, reject) => {
2079
+ timer = setTimeout(() => reject(new BudgetExceededError(label, ms)), ms);
2080
+ timer.unref?.();
2081
+ })
2082
+ ]).finally(() => {
2083
+ if (timer) clearTimeout(timer);
2084
+ });
2085
+ }
2231
2086
 
2232
2087
  // src/lib/actions/resolve-selector.ts
2088
+ var PLAYWRIGHT_ONLY = /^\s*(text|xpath|role|id|data-testid|internal:[\w-]+)\s*=|:(has-text|text|text-is|text-matches|visible|nth-match|right-of|left-of|above|below|near)\b|\s>>\s/;
2089
+ function assertCssSelector(selector) {
2090
+ if (PLAYWRIGHT_ONLY.test(selector)) {
2091
+ throw new Error(
2092
+ `Unsupported selector "${selector}": Playwright text/role selectors (text=, :has-text(), :text-is(), >>) aren't supported. Locate the element by its text with a \`find\` step (e.g. { type: "find", text: "Texas" } or { role: "option", name: "Texas" }) and act on the @e ref it returns, in the same execute call.`
2093
+ );
2094
+ }
2095
+ }
2233
2096
  function resolveSelector(selector, ctx) {
2234
2097
  if (selector.startsWith("@a:")) {
2235
2098
  const name = selector.slice(3);
@@ -2250,42 +2113,194 @@ function resolveSelector(selector, ctx) {
2250
2113
  }
2251
2114
  return ref.selector;
2252
2115
  }
2116
+ assertCssSelector(selector);
2253
2117
  return selector;
2254
2118
  }
2255
2119
  function anchorNameOf(selector) {
2256
2120
  return typeof selector === "string" && selector.startsWith("@a:") ? selector.slice(3) : null;
2257
2121
  }
2258
2122
 
2123
+ // src/lib/reachability.ts
2124
+ var HONEYPOT_REASONS = /* @__PURE__ */ new Set(["zero-size", "hidden", "transparent", "offscreen", "clipped"]);
2125
+ var HONEYPOT_NAME_RE = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
2126
+ function isHoneypotReason(reason) {
2127
+ return !!reason && HONEYPOT_REASONS.has(reason);
2128
+ }
2129
+ function looksLikeBaitField(attrs) {
2130
+ const name = attrs.name || "";
2131
+ const id = attrs.id || "";
2132
+ if (HONEYPOT_NAME_RE.test(name) || HONEYPOT_NAME_RE.test(id)) return true;
2133
+ const textual = !attrs.type || /^(text|email|url|tel|search|number)$/i.test(attrs.type);
2134
+ if (textual && attrs.tabindex === "-1") return true;
2135
+ return false;
2136
+ }
2137
+ function honeypotRefusalMessage(selector, reason, name) {
2138
+ const what = name ? `"${name}" (${selector})` : selector;
2139
+ return `fill: refusing to fill ${what} \u2014 no human can reach this field (${reason}). It is almost certainly a honeypot: filling it marks the submission as a bot and the form fails with a generic error. Skip it. If you are certain it is a real field, retry with force: true.`;
2140
+ }
2141
+ async function probeField(page, selector) {
2142
+ const r = await page.evaluate((sel) => {
2143
+ const el = document.querySelector(sel);
2144
+ if (!el) return { found: false };
2145
+ const attrs = {
2146
+ name: el.getAttribute("name"),
2147
+ id: el.id || null,
2148
+ tabindex: el.getAttribute("tabindex"),
2149
+ type: el.getAttribute("type")
2150
+ };
2151
+ const tag = el.tagName.toLowerCase();
2152
+ {
2153
+ const r0 = el.getBoundingClientRect();
2154
+ const cx0 = r0.left + r0.width / 2, cy0 = r0.top + r0.height / 2;
2155
+ const dx = cx0 < 0 || cx0 >= window.innerWidth ? cx0 - window.innerWidth / 2 : 0;
2156
+ const dy = cy0 < 0 || cy0 >= window.innerHeight ? cy0 - window.innerHeight / 2 : 0;
2157
+ if (dx || dy) try {
2158
+ window.scrollBy(dx, dy);
2159
+ } catch {
2160
+ }
2161
+ }
2162
+ const rect = el.getBoundingClientRect();
2163
+ let reason = null;
2164
+ if (rect.width === 0 || rect.height === 0) reason = "zero-size";
2165
+ const style = getComputedStyle(el);
2166
+ if (!reason && (style.display === "none" || style.visibility === "hidden")) reason = "hidden";
2167
+ if (!reason) {
2168
+ let opacity = 1;
2169
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
2170
+ const o = parseFloat(getComputedStyle(n).opacity);
2171
+ const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
2172
+ if (!isNaN(o) && !animating) opacity *= o;
2173
+ }
2174
+ if (opacity < 0.05) reason = "transparent";
2175
+ }
2176
+ if (!reason) {
2177
+ const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
2178
+ const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
2179
+ const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
2180
+ const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
2181
+ if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) reason = "offscreen";
2182
+ }
2183
+ if (!reason) {
2184
+ const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
2185
+ const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
2186
+ let clipped = false;
2187
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
2188
+ const s = getComputedStyle(a);
2189
+ const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
2190
+ if (!clipsX && !clipsY) continue;
2191
+ const ar = a.getBoundingClientRect();
2192
+ const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
2193
+ const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
2194
+ if (outside || tiny) {
2195
+ clipped = true;
2196
+ break;
2197
+ }
2198
+ }
2199
+ if (inViewport) {
2200
+ const hit = document.elementFromPoint(cx, cy);
2201
+ if (!(hit && (hit === el || el.contains(hit)))) reason = clipped ? "clipped" : "covered";
2202
+ } else if (clipped) {
2203
+ reason = "clipped";
2204
+ }
2205
+ }
2206
+ return { found: true, reason, attrs, tag };
2207
+ }, selector);
2208
+ if (!r.found) return { found: false, hiddenReason: null, suspiciousName: false };
2209
+ return {
2210
+ found: true,
2211
+ hiddenReason: r.reason ?? null,
2212
+ suspiciousName: looksLikeBaitField(r.attrs),
2213
+ tag: r.tag,
2214
+ name: r.attrs.name ?? void 0
2215
+ };
2216
+ }
2217
+
2259
2218
  // src/lib/actions/handlers/navigation.ts
2260
- var log9 = createLogger("actions");
2261
- async function navigate(page, step, ctx) {
2262
- await page.goto(step.url, {
2263
- waitUntil: step.waitUntil || "domcontentloaded",
2264
- timeout: TIMEOUTS.NAVIGATION
2265
- });
2266
- const stealthScript = contextStealthScripts.get(page.context()) ?? STEALTH_SCRIPT;
2219
+ var log7 = createLogger("actions");
2220
+ var NAV_COMMIT_SUSPECT_MS = 12e3;
2221
+ async function navigate(page, step, ctx, monitor) {
2222
+ const waitUntil = step.waitUntil || "domcontentloaded";
2223
+ const urlBefore = page.url();
2224
+ const onDialog = (d) => {
2225
+ (d.type() === "beforeunload" ? d.accept() : d.dismiss()).catch(() => void 0);
2226
+ };
2227
+ page.on("dialog", onDialog);
2228
+ try {
2229
+ const nav = page.goto(step.url, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
2230
+ nav.catch(() => void 0);
2231
+ let suspectTimer;
2232
+ const outcome = await Promise.race([
2233
+ nav.then(() => "done"),
2234
+ new Promise((r) => {
2235
+ suspectTimer = setTimeout(() => r("slow"), NAV_COMMIT_SUSPECT_MS);
2236
+ })
2237
+ ]).finally(() => clearTimeout(suspectTimer));
2238
+ if (outcome === "slow") {
2239
+ if (page.url() !== urlBefore) {
2240
+ await nav;
2241
+ } else {
2242
+ log7.warn(`navigate: no commit after ${NAV_COMMIT_SUSPECT_MS}ms, falling back to location.assign`);
2243
+ monitor?.reportActivity();
2244
+ await withBudget(page.evaluate((u) => {
2245
+ location.assign(u);
2246
+ }, step.url), 3e3, "location.assign").catch(() => void 0);
2247
+ try {
2248
+ await page.waitForURL((u) => u.toString() !== urlBefore, { timeout: 15e3, waitUntil });
2249
+ } catch {
2250
+ throw new Error(
2251
+ `navigate: the page never left ${urlBefore} for ${step.url} (tried a normal navigation, then location.assign). The site is blocking the route change. Use a click on the site's own link to get there.`
2252
+ );
2253
+ }
2254
+ }
2255
+ }
2256
+ } finally {
2257
+ page.off("dialog", onDialog);
2258
+ }
2267
2259
  try {
2268
- await page.evaluate(stealthScript);
2260
+ await page.evaluate(STEALTH_SCRIPT);
2269
2261
  } catch (err) {
2270
- log9.warn(`stealth injection failed: ${err}`);
2262
+ log7.warn(`stealth injection failed: ${err}`);
2271
2263
  }
2272
2264
  if (ctx.sessionData) {
2273
2265
  try {
2274
2266
  await injectStorage(page, ctx.sessionData);
2275
2267
  } catch (err) {
2276
- log9.warn(`storage injection after navigate failed: ${err}`);
2268
+ log7.warn(`storage injection after navigate failed: ${err}`);
2277
2269
  }
2278
2270
  }
2279
2271
  }
2280
2272
  async function click(page, step, ctx) {
2281
- await page.click(resolveSelector(step.selector, ctx));
2273
+ await page.click(resolveSelector(step.selector, ctx), { timeout: TIMEOUTS.CLICK });
2274
+ }
2275
+ async function isCombobox(page, selector) {
2276
+ return withBudget(
2277
+ page.evaluate((sel) => {
2278
+ const el = document.querySelector(sel);
2279
+ if (!el) return false;
2280
+ const role = el.getAttribute("role");
2281
+ return role === "combobox" || el.hasAttribute("aria-autocomplete") || el.getAttribute("aria-haspopup") === "listbox" || el.hasAttribute("list") || !!el.closest('[role="combobox"]');
2282
+ }, selector),
2283
+ 3e3
2284
+ ).catch(() => false);
2282
2285
  }
2283
2286
  async function fill(page, step, ctx) {
2284
2287
  const selector = resolveSelector(step.selector, ctx);
2285
2288
  const value = step.value;
2289
+ if (!step.force) {
2290
+ let probe = null;
2291
+ try {
2292
+ probe = await probeField(page, selector);
2293
+ } catch {
2294
+ probe = null;
2295
+ }
2296
+ if (probe?.found && isHoneypotReason(probe.hiddenReason)) {
2297
+ throw new Error(honeypotRefusalMessage(step.selector, probe.hiddenReason, probe.name));
2298
+ }
2299
+ }
2300
+ const combobox = await isCombobox(page, selector);
2286
2301
  await page.fill(selector, value);
2287
2302
  const stuck = await page.evaluate(
2288
- ({ sel, val }) => {
2303
+ ({ sel, val, keepFocus }) => {
2289
2304
  const el = document.querySelector(sel);
2290
2305
  if (!el) return false;
2291
2306
  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
@@ -2301,13 +2316,15 @@ async function fill(page, step, ctx) {
2301
2316
  }
2302
2317
  el.dispatchEvent(new Event("input", { bubbles: true }));
2303
2318
  el.dispatchEvent(new Event("change", { bubbles: true }));
2304
- try {
2305
- el.blur();
2306
- } catch {
2319
+ if (!keepFocus) {
2320
+ try {
2321
+ el.blur();
2322
+ } catch {
2323
+ }
2307
2324
  }
2308
2325
  return el.value === val;
2309
2326
  },
2310
- { sel: selector, val: value }
2327
+ { sel: selector, val: value, keepFocus: combobox }
2311
2328
  );
2312
2329
  if (!stuck) {
2313
2330
  const loc = page.locator(selector);
@@ -2315,10 +2332,12 @@ async function fill(page, step, ctx) {
2315
2332
  await loc.click();
2316
2333
  await loc.fill("");
2317
2334
  await loc.pressSequentially(value, { delay: 15 });
2318
- await page.evaluate((sel) => {
2319
- const el = document.querySelector(sel);
2320
- el?.blur?.();
2321
- }, selector);
2335
+ if (!combobox) {
2336
+ await page.evaluate((sel) => {
2337
+ const el = document.querySelector(sel);
2338
+ el?.blur?.();
2339
+ }, selector);
2340
+ }
2322
2341
  } catch {
2323
2342
  }
2324
2343
  }
@@ -2342,7 +2361,7 @@ async function rightClick(page, step, ctx) {
2342
2361
  }
2343
2362
  }
2344
2363
  async function humanTypeStep(page, step, ctx) {
2345
- await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed });
2364
+ await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed, clear: step.clear });
2346
2365
  }
2347
2366
  async function selectOption(page, step, ctx) {
2348
2367
  const selector = resolveSelector(step.selector, ctx);
@@ -2385,7 +2404,29 @@ async function scroll(page, step, ctx) {
2385
2404
  { dy: step.deltaY ?? 0, sel: selector }
2386
2405
  );
2387
2406
  }
2388
- async function keyboard(page, step) {
2407
+ async function selectAllInFocused(page) {
2408
+ await page.evaluate(() => {
2409
+ const el = document.activeElement;
2410
+ if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
2411
+ el.select();
2412
+ } else if (el && el.isContentEditable) {
2413
+ const sel = window.getSelection();
2414
+ sel?.selectAllChildren(el);
2415
+ } else {
2416
+ window.getSelection()?.selectAllChildren(document.body);
2417
+ }
2418
+ });
2419
+ }
2420
+ async function keyboard(page, step, ctx) {
2421
+ if ((step.meta || step.ctrl) && step.key.toLowerCase() === "a" && !step.shift && !step.alt) {
2422
+ await selectAllInFocused(page);
2423
+ return;
2424
+ }
2425
+ if ((step.meta || step.ctrl) && ctx.driver === "extension") {
2426
+ throw new Error(
2427
+ `keyboard: ${step.meta ? "Cmd" : "Ctrl"}+${step.key} can't be sent in extension mode (the relay drops Cmd/Ctrl, so it would arrive as a plain "${step.key}"). Nothing was pressed. Instead: select-all \u2192 keyboard {key:"a", meta:true} (handled in-page); replace a field's text \u2192 fill, or human-type with clear:true; paste \u2192 the paste step.`
2428
+ );
2429
+ }
2389
2430
  const mods = [
2390
2431
  step.meta ? "Meta" : null,
2391
2432
  step.ctrl ? "Control" : null,
@@ -2425,7 +2466,7 @@ async function download(page, step) {
2425
2466
  const status = resp.status();
2426
2467
  if (!resp.ok()) throw new Error(`download: HTTP ${status} for ${step.url}`);
2427
2468
  const buf = await resp.body();
2428
- const fs14 = await import("fs");
2469
+ const fs12 = await import("fs");
2429
2470
  const pathMod = await import("path");
2430
2471
  const { getDataDir: getDataDir2 } = await Promise.resolve().then(() => (init_paths(), paths_exports));
2431
2472
  let outPath;
@@ -2439,8 +2480,8 @@ async function download(page, step) {
2439
2480
  }
2440
2481
  outPath = pathMod.join(getDataDir2(), "downloads", step.path || name);
2441
2482
  }
2442
- fs14.mkdirSync(pathMod.dirname(outPath), { recursive: true });
2443
- fs14.writeFileSync(outPath, buf);
2483
+ fs12.mkdirSync(pathMod.dirname(outPath), { recursive: true });
2484
+ fs12.writeFileSync(outPath, buf);
2444
2485
  return { path: outPath, size: buf.length, status };
2445
2486
  }
2446
2487
  async function typeCode(page, step, ctx) {
@@ -2486,12 +2527,12 @@ async function find(page, step, ctx) {
2486
2527
  const elInfo = await element.evaluate((el) => {
2487
2528
  const tag = el.tagName.toLowerCase();
2488
2529
  const text = (el.textContent?.trim() || "").slice(0, 60);
2489
- const path13 = [];
2530
+ const path12 = [];
2490
2531
  let current = el;
2491
2532
  while (current && current !== document.body && current !== document.documentElement) {
2492
2533
  let seg = current.tagName.toLowerCase();
2493
2534
  if (current.id && /^[a-zA-Z][\w-]*$/.test(current.id)) {
2494
- path13.unshift(`#${current.id}`);
2535
+ path12.unshift(`#${current.id}`);
2495
2536
  break;
2496
2537
  }
2497
2538
  const parent = current.parentElement;
@@ -2503,10 +2544,10 @@ async function find(page, step, ctx) {
2503
2544
  seg += `:nth-of-type(${idx})`;
2504
2545
  }
2505
2546
  }
2506
- path13.unshift(seg);
2547
+ path12.unshift(seg);
2507
2548
  current = parent;
2508
2549
  }
2509
- return { tag, text, selector: path13.join(" > ") };
2550
+ return { tag, text, selector: path12.join(" > ") };
2510
2551
  });
2511
2552
  const ref = `@e${ctx.nextRefId++}`;
2512
2553
  const displayRole = step.role || elInfo.tag;
@@ -2526,6 +2567,73 @@ async function find(page, step, ctx) {
2526
2567
  };
2527
2568
  }
2528
2569
 
2570
+ // src/lib/screenshot.ts
2571
+ var import_crypto3 = require("crypto");
2572
+ var import_fs6 = __toESM(require("fs"));
2573
+ var import_path7 = __toESM(require("path"));
2574
+ var log8 = createLogger("screenshot");
2575
+ var MAX_AGE_MS = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_AGE_MS || String(24 * 60 * 60 * 1e3), 10);
2576
+ var MAX_FILES = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_FILES || "500", 10);
2577
+ var PRUNE_THROTTLE_MS = 5 * 60 * 1e3;
2578
+ var lastPruneAt = 0;
2579
+ function saveScreenshot(buffer, filename, screenshotDir, publicUrl) {
2580
+ import_fs6.default.mkdirSync(screenshotDir, { recursive: true, mode: 448 });
2581
+ const ext = import_path7.default.extname(filename);
2582
+ const unique = `${import_path7.default.basename(filename, ext)}-${(0, import_crypto3.randomBytes)(8).toString("hex")}${ext}`;
2583
+ import_fs6.default.writeFileSync(import_path7.default.join(screenshotDir, unique), buffer, { mode: 384 });
2584
+ maybePrune(screenshotDir);
2585
+ return `${publicUrl}/screenshots/${unique}`;
2586
+ }
2587
+ function maybePrune(dir) {
2588
+ const now = Date.now();
2589
+ if (now - lastPruneAt < PRUNE_THROTTLE_MS) return;
2590
+ lastPruneAt = now;
2591
+ pruneScreenshots(dir);
2592
+ }
2593
+ function pruneScreenshots(dir, opts = {}) {
2594
+ const maxAgeMs = opts.maxAgeMs ?? MAX_AGE_MS;
2595
+ const maxFiles = opts.maxFiles ?? MAX_FILES;
2596
+ const now = opts.now ?? Date.now();
2597
+ try {
2598
+ const entries = import_fs6.default.readdirSync(dir).filter((f) => f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".png")).map((f) => {
2599
+ const full = import_path7.default.join(dir, f);
2600
+ try {
2601
+ return { full, mtimeMs: import_fs6.default.statSync(full).mtimeMs };
2602
+ } catch {
2603
+ return null;
2604
+ }
2605
+ }).filter((e) => e !== null);
2606
+ let removed = 0;
2607
+ const survivors = [];
2608
+ for (const e of entries) {
2609
+ if (now - e.mtimeMs > maxAgeMs) {
2610
+ try {
2611
+ import_fs6.default.unlinkSync(e.full);
2612
+ removed++;
2613
+ } catch {
2614
+ }
2615
+ } else {
2616
+ survivors.push(e);
2617
+ }
2618
+ }
2619
+ if (survivors.length > maxFiles) {
2620
+ survivors.sort((a, b) => a.mtimeMs - b.mtimeMs);
2621
+ for (const e of survivors.slice(0, survivors.length - maxFiles)) {
2622
+ try {
2623
+ import_fs6.default.unlinkSync(e.full);
2624
+ removed++;
2625
+ } catch {
2626
+ }
2627
+ }
2628
+ }
2629
+ if (removed > 0) log8.debug(`pruned ${removed} old screenshot(s) from ${dir}`);
2630
+ return removed;
2631
+ } catch (err) {
2632
+ log8.warn(`screenshot prune failed: ${err instanceof Error ? err.message : String(err)}`);
2633
+ return 0;
2634
+ }
2635
+ }
2636
+
2529
2637
  // src/lib/snapshot.ts
2530
2638
  async function takeSnapshot(page, ctx, options) {
2531
2639
  const interactiveOnly = options?.interactiveOnly ?? true;
@@ -2570,13 +2678,63 @@ async function takeSnapshot(page, ctx, options) {
2570
2678
  if (rect.bottom < 0 || rect.top > window.innerHeight + 200) return false;
2571
2679
  return true;
2572
2680
  }
2681
+ function isFillable(el) {
2682
+ if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true;
2683
+ if (el.hasAttribute("contenteditable") && el.getAttribute("contenteditable") !== "false") return true;
2684
+ if (el.tagName !== "INPUT") return false;
2685
+ const t = (el.type || "text").toLowerCase();
2686
+ return !["button", "submit", "reset", "image", "checkbox", "radio", "file", "hidden", "range", "color"].includes(t);
2687
+ }
2688
+ function unreachableReason(el) {
2689
+ const rect = el.getBoundingClientRect();
2690
+ const style = getComputedStyle(el);
2691
+ let opacity = 1;
2692
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
2693
+ const o = parseFloat(n === el ? style.opacity : getComputedStyle(n).opacity);
2694
+ const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
2695
+ if (!isNaN(o) && !animating) opacity *= o;
2696
+ }
2697
+ if (opacity < 0.05) return "transparent";
2698
+ const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
2699
+ const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
2700
+ const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
2701
+ const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
2702
+ if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) return "offscreen";
2703
+ const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
2704
+ let clipped = false;
2705
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
2706
+ const s = getComputedStyle(a);
2707
+ const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
2708
+ if (!clipsX && !clipsY) continue;
2709
+ const ar = a.getBoundingClientRect();
2710
+ const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
2711
+ const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
2712
+ if (tiny) return "clipped";
2713
+ if (outside) clipped = true;
2714
+ }
2715
+ const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
2716
+ if (inViewport) {
2717
+ const hit = document.elementFromPoint(cx, cy);
2718
+ if (hit && (hit === el || el.contains(hit))) return null;
2719
+ return clipped ? "clipped" : null;
2720
+ }
2721
+ return null;
2722
+ }
2723
+ const baitName = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
2724
+ function looksLikeBait(el) {
2725
+ const name = el.getAttribute("name") || "", id = el.id || "";
2726
+ if (baitName.test(name) || baitName.test(id)) return true;
2727
+ const type = (el.getAttribute("type") || "text").toLowerCase();
2728
+ const textual = /^(text|email|url|tel|search|number)$/.test(type);
2729
+ return textual && el.getAttribute("tabindex") === "-1";
2730
+ }
2573
2731
  function buildSelector(el) {
2574
- const path13 = [];
2732
+ const path12 = [];
2575
2733
  let current = el;
2576
2734
  while (current && current !== document.body && current !== document.documentElement) {
2577
2735
  let seg = current.tagName.toLowerCase();
2578
2736
  if (current.id && /^[a-zA-Z][\w-]*$/.test(current.id)) {
2579
- path13.unshift(`#${current.id}`);
2737
+ path12.unshift(`#${current.id}`);
2580
2738
  break;
2581
2739
  }
2582
2740
  const parent = current.parentElement;
@@ -2588,10 +2746,10 @@ async function takeSnapshot(page, ctx, options) {
2588
2746
  seg += `:nth-of-type(${idx})`;
2589
2747
  }
2590
2748
  }
2591
- path13.unshift(seg);
2749
+ path12.unshift(seg);
2592
2750
  current = parent;
2593
2751
  }
2594
- return path13.join(" > ");
2752
+ return path12.join(" > ");
2595
2753
  }
2596
2754
  function getName(el) {
2597
2755
  const ariaLabel = el.getAttribute("aria-label");
@@ -2612,6 +2770,11 @@ async function takeSnapshot(page, ctx, options) {
2612
2770
  if (results.length >= maxElements2) break;
2613
2771
  if (interactiveOnly2 && !isInteractive(el)) continue;
2614
2772
  if (!isVisible(el)) continue;
2773
+ let suspect;
2774
+ if (isFillable(el)) {
2775
+ if (unreachableReason(el)) continue;
2776
+ if (looksLikeBait(el)) suspect = "honeypot?";
2777
+ }
2615
2778
  const tag = el.tagName.toLowerCase();
2616
2779
  const role = el.getAttribute("role") || "";
2617
2780
  const type = el.type || "";
@@ -2624,7 +2787,8 @@ async function takeSnapshot(page, ctx, options) {
2624
2787
  checked: el.checked || false,
2625
2788
  disabled: el.disabled || false,
2626
2789
  selector: buildSelector(el),
2627
- isVisible: true
2790
+ isVisible: true,
2791
+ suspect
2628
2792
  });
2629
2793
  }
2630
2794
  return results;
@@ -2645,6 +2809,7 @@ async function takeSnapshot(page, ctx, options) {
2645
2809
  const state = [];
2646
2810
  if (el.disabled) state.push("disabled");
2647
2811
  if (el.checked) state.push("checked");
2812
+ if (el.suspect) state.push(el.suspect);
2648
2813
  let description = "";
2649
2814
  if (el.placeholder && el.name !== el.placeholder) description = `placeholder="${el.placeholder}"`;
2650
2815
  if (el.type && !["text", "submit", "button", ""].includes(el.type)) {
@@ -2716,12 +2881,12 @@ async function annotatedScreenshot(page, ctx) {
2716
2881
  return true;
2717
2882
  }
2718
2883
  function buildSelector(el) {
2719
- const path13 = [];
2884
+ const path12 = [];
2720
2885
  let current = el;
2721
2886
  while (current && current !== document.body && current !== document.documentElement) {
2722
2887
  let seg = current.tagName.toLowerCase();
2723
2888
  if (current.id && /^[a-zA-Z][\w-]*$/.test(current.id)) {
2724
- path13.unshift(`#${current.id}`);
2889
+ path12.unshift(`#${current.id}`);
2725
2890
  break;
2726
2891
  }
2727
2892
  const parent = current.parentElement;
@@ -2733,10 +2898,10 @@ async function annotatedScreenshot(page, ctx) {
2733
2898
  seg += `:nth-of-type(${idx})`;
2734
2899
  }
2735
2900
  }
2736
- path13.unshift(seg);
2901
+ path12.unshift(seg);
2737
2902
  current = parent;
2738
2903
  }
2739
- return path13.join(" > ");
2904
+ return path12.join(" > ");
2740
2905
  }
2741
2906
  function getName(el) {
2742
2907
  const ariaLabel = el.getAttribute("aria-label");
@@ -2840,18 +3005,72 @@ async function snapshot(page, step, ctx) {
2840
3005
  return { elementCount: snap.nodes.length, snapshot: snap.text };
2841
3006
  }
2842
3007
 
2843
- // src/lib/captcha/recaptcha.ts
3008
+ // src/lib/captcha/vision.ts
2844
3009
  var import_sdk = __toESM(require("@anthropic-ai/sdk"));
2845
- var log10 = createLogger("captcha-solver");
2846
- var MAX_ROUNDS = 8;
2847
- var MAX_DURATION_MS = 45e3;
2848
- var TILE_SETTLE_MS = TIMING.TILE_SETTLE;
2849
- var MODEL = "claude-haiku-4-5-20251001";
2850
- function getClient() {
3010
+ var CAPTCHA_MODEL = process.env.IFRAMER_CAPTCHA_MODEL || "claude-haiku-4-5-20251001";
3011
+ var CaptchaApiError = class extends Error {
3012
+ };
3013
+ function getCaptchaClient() {
2851
3014
  const apiKey = process.env.ANTHROPIC_API_KEY;
2852
3015
  if (!apiKey) throw new Error("ANTHROPIC_API_KEY not set \u2014 required for captcha auto-solve");
2853
3016
  return new import_sdk.default({ apiKey });
2854
3017
  }
3018
+ function toCaptchaApiError(err) {
3019
+ if (err instanceof import_sdk.default.APIConnectionError) {
3020
+ const cause = err.cause instanceof Error ? `: ${err.cause.message}` : "";
3021
+ return new CaptchaApiError(`Claude API call failed (no response): ${err.message}${cause}`);
3022
+ }
3023
+ if (err instanceof import_sdk.default.APIError) {
3024
+ const body = err.error;
3025
+ return new CaptchaApiError(`Claude API call failed (${err.status ?? "no response"}): ${body?.error?.message ?? err.message}`);
3026
+ }
3027
+ return new CaptchaApiError(`Claude API call failed: ${err instanceof Error ? err.message : String(err)}`);
3028
+ }
3029
+ async function askVision(client, params) {
3030
+ try {
3031
+ const response = await client.messages.create({ ...params, model: CAPTCHA_MODEL });
3032
+ return response.content.flatMap((b) => b.type === "text" ? [b.text] : []).join("\n").trim();
3033
+ } catch (err) {
3034
+ throw toCaptchaApiError(err);
3035
+ }
3036
+ }
3037
+ var MAX_CLICKS = 12;
3038
+ function parseChallengeAnswer(text, width, height) {
3039
+ const candidates = text.match(/\{[^{}]*\}/g);
3040
+ if (!candidates) return null;
3041
+ const toPoint = (p) => {
3042
+ if (!Array.isArray(p) || p.length !== 2) return null;
3043
+ const [x, y] = p;
3044
+ if (typeof x !== "number" || typeof y !== "number") return null;
3045
+ return x >= 0 && y >= 0 && x <= width && y <= height ? { x, y } : null;
3046
+ };
3047
+ for (const raw of candidates.reverse()) {
3048
+ let parsed;
3049
+ try {
3050
+ parsed = JSON.parse(raw);
3051
+ } catch {
3052
+ continue;
3053
+ }
3054
+ if (Array.isArray(parsed.drag)) {
3055
+ if (parsed.drag.length !== 2) return null;
3056
+ const from = toPoint(parsed.drag[0]);
3057
+ const to = toPoint(parsed.drag[1]);
3058
+ return from && to ? { drag: { from, to } } : null;
3059
+ }
3060
+ if (Array.isArray(parsed.clicks)) {
3061
+ if (parsed.clicks.length > MAX_CLICKS) return null;
3062
+ const clicks = parsed.clicks.map(toPoint);
3063
+ return clicks.every((c) => c !== null) ? { clicks } : null;
3064
+ }
3065
+ }
3066
+ return null;
3067
+ }
3068
+
3069
+ // src/lib/captcha/recaptcha.ts
3070
+ var log9 = createLogger("captcha-solver");
3071
+ var MAX_ROUNDS = 8;
3072
+ var MAX_DURATION_MS = 45e3;
3073
+ var TILE_SETTLE_MS = TIMING.TILE_SETTLE;
2855
3074
  function extractTarget(prompt) {
2856
3075
  const lines = prompt.split("\n").map((l) => l.trim()).filter(Boolean);
2857
3076
  for (let i = 0; i < lines.length; i++) {
@@ -2880,7 +3099,7 @@ async function screenshotFullGrid(page, challengeInfo) {
2880
3099
  const buf = await page.screenshot({ type: "jpeg", quality: 85, clip: gridClip });
2881
3100
  return buf.toString("base64");
2882
3101
  } catch (err) {
2883
- log10.error(`full grid screenshot failed: ${err instanceof Error ? err.message : String(err)}`);
3102
+ log9.error(`full grid screenshot failed: ${err instanceof Error ? err.message : String(err)}`);
2884
3103
  return null;
2885
3104
  }
2886
3105
  }
@@ -2915,40 +3134,33 @@ async function classifyTiles(client, fullGridBase64, tiles, target, rows, cols)
2915
3134
  tiles.map(async (tile) => {
2916
3135
  const tileRow = Math.floor(tile.index / cols) + 1;
2917
3136
  const tileCol = tile.index % cols + 1;
2918
- try {
2919
- const response = await client.messages.create({
2920
- model: MODEL,
2921
- max_tokens: 10,
2922
- messages: [
2923
- {
2924
- role: "user",
2925
- content: [
2926
- {
2927
- type: "image",
2928
- source: { type: "base64", media_type: "image/jpeg", data: fullGridBase64 }
2929
- },
2930
- {
2931
- type: "image",
2932
- source: { type: "base64", media_type: "image/jpeg", data: tile.imageBase64 }
2933
- },
2934
- {
2935
- type: "text",
2936
- text: `Image 1 is a full picture divided into a ${rows}x${cols} grid. Image 2 is the tile at row ${tileRow}, column ${tileCol} of that grid.
3137
+ const answer = await askVision(client, {
3138
+ max_tokens: 256,
3139
+ messages: [
3140
+ {
3141
+ role: "user",
3142
+ content: [
3143
+ {
3144
+ type: "image",
3145
+ source: { type: "base64", media_type: "image/jpeg", data: fullGridBase64 }
3146
+ },
3147
+ {
3148
+ type: "image",
3149
+ source: { type: "base64", media_type: "image/jpeg", data: tile.imageBase64 }
3150
+ },
3151
+ {
3152
+ type: "text",
3153
+ text: `Image 1 is a full picture divided into a ${rows}x${cols} grid. Image 2 is the tile at row ${tileRow}, column ${tileCol} of that grid.
2937
3154
 
2938
3155
  Does this tile contain a ${target}? Reply ONLY "yes" or "no".`
2939
- }
2940
- ]
2941
- }
2942
- ]
2943
- });
2944
- const answer = (response.content[0].text ?? "").toLowerCase().trim();
2945
- const match = answer.startsWith("yes");
2946
- if (match) log10.debug(`tile ${tile.index} (r${tileRow}c${tileCol}): YES`);
2947
- return { index: tile.index, match };
2948
- } catch (err) {
2949
- log10.error(`tile ${tile.index} classification failed: ${err instanceof Error ? err.message : String(err)}`);
2950
- return { index: tile.index, match: false };
2951
- }
3156
+ }
3157
+ ]
3158
+ }
3159
+ ]
3160
+ });
3161
+ const match = answer.toLowerCase().startsWith("yes");
3162
+ if (match) log9.debug(`tile ${tile.index} (r${tileRow}c${tileCol}): YES`);
3163
+ return { index: tile.index, match };
2952
3164
  })
2953
3165
  );
2954
3166
  return results.filter((r) => r.match).map((r) => r.index);
@@ -2969,7 +3181,7 @@ async function submitForm(page) {
2969
3181
  if (el) {
2970
3182
  const visible = await el.isVisible();
2971
3183
  if (visible) {
2972
- log10.info(`Submitting form via: ${selector}`);
3184
+ log9.info(`Submitting form via: ${selector}`);
2973
3185
  await new Promise((r) => setTimeout(r, 500));
2974
3186
  await humanClick(page, selector);
2975
3187
  await new Promise((r) => setTimeout(r, 2e3));
@@ -2979,117 +3191,130 @@ async function submitForm(page) {
2979
3191
  } catch {
2980
3192
  }
2981
3193
  }
2982
- log10.info("No submit button found \u2014 skipping form submission");
3194
+ log9.info("No submit button found \u2014 skipping form submission");
2983
3195
  return false;
2984
3196
  }
2985
3197
  async function solveRecaptcha(page, monitor) {
2986
3198
  const startTime = Date.now();
2987
- const client = getClient();
3199
+ const client = getCaptchaClient();
2988
3200
  let rounds = 0;
2989
- const checkboxResult = await clickRecaptchaCheckbox(page);
2990
- if (checkboxResult.solved) {
2991
- const submitted = await submitForm(page);
2992
- return { solved: true, rounds: 0, durationMs: Date.now() - startTime, submitted };
2993
- }
2994
- if (!checkboxResult.challengeInfo) {
2995
- return { solved: false, rounds: 0, durationMs: Date.now() - startTime, reason: "No challenge appeared after clicking checkbox" };
2996
- }
2997
- let challengeInfo = checkboxResult.challengeInfo;
2998
- while (rounds < MAX_ROUNDS) {
2999
- if (Date.now() - startTime > MAX_DURATION_MS) {
3000
- return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Timeout exceeded" };
3001
- }
3002
- rounds++;
3003
- monitor?.reportActivity();
3004
- if (!challengeInfo) {
3005
- return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Challenge info lost" };
3006
- }
3007
- const target = extractTarget(challengeInfo.prompt);
3008
- log10.info(`Round ${rounds}: looking for "${target}" in ${challengeInfo.rows}x${challengeInfo.cols} grid`);
3009
- const [fullGridImage, tileImages] = await Promise.all([
3010
- screenshotFullGrid(page, challengeInfo),
3011
- screenshotTiles(page, challengeInfo)
3012
- ]);
3013
- if (!fullGridImage || tileImages.length === 0) {
3014
- return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Failed to screenshot challenge" };
3015
- }
3016
- monitor?.reportActivity();
3017
- const matchingIndices = await classifyTiles(
3018
- client,
3019
- fullGridImage,
3020
- tileImages,
3021
- target,
3022
- challengeInfo.rows,
3023
- challengeInfo.cols
3024
- );
3025
- log10.info(`Round ${rounds}: matched tiles [${matchingIndices.join(", ")}]`);
3026
- monitor?.reportActivity();
3027
- if (matchingIndices.length > 0) {
3028
- await clickChallengeTiles(page, matchingIndices);
3029
- const isDynamic = challengeInfo.prompt.toLowerCase().includes("none left");
3030
- if (isDynamic) {
3031
- await new Promise((r) => setTimeout(r, TILE_SETTLE_MS));
3032
- const newInfo = await getChallengeInfo(page);
3033
- if (newInfo) {
3034
- const [newFullGrid, newTiles] = await Promise.all([
3035
- screenshotFullGrid(page, newInfo),
3036
- screenshotTiles(page, newInfo)
3037
- ]);
3038
- if (newFullGrid && newTiles.length > 0) {
3039
- const replacedTiles = newTiles.filter((t) => matchingIndices.includes(t.index));
3040
- if (replacedTiles.length > 0) {
3041
- monitor?.reportActivity();
3042
- const newMatches = await classifyTiles(
3043
- client,
3044
- newFullGrid,
3045
- replacedTiles,
3046
- target,
3047
- newInfo.rows,
3048
- newInfo.cols
3049
- );
3050
- if (newMatches.length > 0) {
3051
- log10.info(`Round ${rounds}: dynamic tiles matched [${newMatches.join(", ")}]`);
3052
- await clickChallengeTiles(page, newMatches);
3053
- await new Promise((r) => setTimeout(r, TILE_SETTLE_MS));
3201
+ try {
3202
+ const checkboxResult = await clickRecaptchaCheckbox(page);
3203
+ if (checkboxResult.solved) {
3204
+ const submitted = await submitForm(page);
3205
+ return { solved: true, rounds: 0, durationMs: Date.now() - startTime, submitted };
3206
+ }
3207
+ if (!checkboxResult.challengeInfo) {
3208
+ return { solved: false, rounds: 0, durationMs: Date.now() - startTime, reason: "No challenge appeared after clicking checkbox" };
3209
+ }
3210
+ let challengeInfo = checkboxResult.challengeInfo;
3211
+ while (rounds < MAX_ROUNDS) {
3212
+ if (Date.now() - startTime > MAX_DURATION_MS) {
3213
+ return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Timeout exceeded" };
3214
+ }
3215
+ rounds++;
3216
+ monitor?.reportActivity();
3217
+ if (!challengeInfo) {
3218
+ return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Challenge info lost" };
3219
+ }
3220
+ const target = extractTarget(challengeInfo.prompt);
3221
+ log9.info(`Round ${rounds}: looking for "${target}" in ${challengeInfo.rows}x${challengeInfo.cols} grid`);
3222
+ const [fullGridImage, tileImages] = await Promise.all([
3223
+ screenshotFullGrid(page, challengeInfo),
3224
+ screenshotTiles(page, challengeInfo)
3225
+ ]);
3226
+ if (!fullGridImage || tileImages.length === 0) {
3227
+ return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Failed to screenshot challenge" };
3228
+ }
3229
+ monitor?.reportActivity();
3230
+ const matchingIndices = await classifyTiles(
3231
+ client,
3232
+ fullGridImage,
3233
+ tileImages,
3234
+ target,
3235
+ challengeInfo.rows,
3236
+ challengeInfo.cols
3237
+ );
3238
+ log9.info(`Round ${rounds}: matched tiles [${matchingIndices.join(", ")}]`);
3239
+ monitor?.reportActivity();
3240
+ if (matchingIndices.length > 0) {
3241
+ await clickChallengeTiles(page, matchingIndices);
3242
+ const isDynamic = challengeInfo.prompt.toLowerCase().includes("none left");
3243
+ if (isDynamic) {
3244
+ await new Promise((r) => setTimeout(r, TILE_SETTLE_MS));
3245
+ const newInfo = await getChallengeInfo(page);
3246
+ if (newInfo) {
3247
+ const [newFullGrid, newTiles] = await Promise.all([
3248
+ screenshotFullGrid(page, newInfo),
3249
+ screenshotTiles(page, newInfo)
3250
+ ]);
3251
+ if (newFullGrid && newTiles.length > 0) {
3252
+ const replacedTiles = newTiles.filter((t) => matchingIndices.includes(t.index));
3253
+ if (replacedTiles.length > 0) {
3254
+ monitor?.reportActivity();
3255
+ const newMatches = await classifyTiles(
3256
+ client,
3257
+ newFullGrid,
3258
+ replacedTiles,
3259
+ target,
3260
+ newInfo.rows,
3261
+ newInfo.cols
3262
+ );
3263
+ if (newMatches.length > 0) {
3264
+ log9.info(`Round ${rounds}: dynamic tiles matched [${newMatches.join(", ")}]`);
3265
+ await clickChallengeTiles(page, newMatches);
3266
+ await new Promise((r) => setTimeout(r, TILE_SETTLE_MS));
3267
+ }
3054
3268
  }
3055
3269
  }
3056
3270
  }
3057
3271
  }
3058
3272
  }
3273
+ const verifyResult = await clickChallengeVerify(page);
3274
+ monitor?.reportActivity();
3275
+ if (verifyResult.solved) {
3276
+ log9.info(`Solved in ${rounds} rounds, ${Date.now() - startTime}ms`);
3277
+ const submitted = await submitForm(page);
3278
+ return { solved: true, rounds, durationMs: Date.now() - startTime, submitted };
3279
+ }
3280
+ challengeInfo = verifyResult.challengeInfo || null;
3281
+ if (!challengeInfo) {
3282
+ return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Challenge disappeared after verify" };
3283
+ }
3284
+ log9.info(`Round ${rounds}: not solved, new challenge appeared`);
3059
3285
  }
3060
- const verifyResult = await clickChallengeVerify(page);
3061
- monitor?.reportActivity();
3062
- if (verifyResult.solved) {
3063
- log10.info(`Solved in ${rounds} rounds, ${Date.now() - startTime}ms`);
3064
- const submitted = await submitForm(page);
3065
- return { solved: true, rounds, durationMs: Date.now() - startTime, submitted };
3066
- }
3067
- challengeInfo = verifyResult.challengeInfo || null;
3068
- if (!challengeInfo) {
3069
- return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Challenge disappeared after verify" };
3286
+ return { solved: false, rounds, durationMs: Date.now() - startTime, reason: `Max rounds (${MAX_ROUNDS}) exceeded` };
3287
+ } catch (err) {
3288
+ if (err instanceof CaptchaApiError) {
3289
+ return { solved: false, rounds, durationMs: Date.now() - startTime, reason: err.message };
3070
3290
  }
3071
- log10.info(`Round ${rounds}: not solved, new challenge appeared`);
3291
+ throw err;
3072
3292
  }
3073
- return { solved: false, rounds, durationMs: Date.now() - startTime, reason: `Max rounds (${MAX_ROUNDS}) exceeded` };
3074
3293
  }
3075
3294
 
3076
3295
  // src/lib/captcha/hcaptcha.ts
3077
- var import_sdk2 = __toESM(require("@anthropic-ai/sdk"));
3078
- var log11 = createLogger("hcaptcha-solver");
3296
+ var log10 = createLogger("hcaptcha-solver");
3079
3297
  var MAX_ROUNDS2 = 8;
3080
3298
  var MAX_DURATION_MS2 = 6e4;
3081
- var MODEL2 = "claude-haiku-4-5-20251001";
3082
- function getClient2() {
3083
- const apiKey = process.env.ANTHROPIC_API_KEY;
3084
- if (!apiKey) throw new Error("ANTHROPIC_API_KEY not set \u2014 required for captcha auto-solve");
3085
- return new import_sdk2.default({ apiKey });
3086
- }
3299
+ var CHALLENGE_FRAME = 'iframe[title="hCaptcha challenge"], iframe[title*="hcaptcha challenge" i]';
3087
3300
  function rand2(min, max) {
3088
3301
  return Math.random() * (max - min) + min;
3089
3302
  }
3090
3303
  function sleep4(ms) {
3091
3304
  return new Promise((r) => setTimeout(r, ms));
3092
3305
  }
3306
+ async function hasToken(page, waitMs = 0) {
3307
+ const deadline = Date.now() + waitMs;
3308
+ for (; ; ) {
3309
+ const found = await page.evaluate(
3310
+ () => Array.from(document.querySelectorAll('[name="h-captcha-response"]')).some(
3311
+ (el) => el.value.length > 0
3312
+ )
3313
+ ).catch(() => false);
3314
+ if (found || Date.now() >= deadline) return found;
3315
+ await sleep4(250);
3316
+ }
3317
+ }
3093
3318
  async function clickCheckbox(page) {
3094
3319
  const checkboxFrame = await page.waitForSelector(
3095
3320
  'iframe[src*="hcaptcha.com"], iframe[data-hcaptcha-widget-id], iframe[title*="hCaptcha"]',
@@ -3104,187 +3329,119 @@ async function clickCheckbox(page) {
3104
3329
  const cy = box.y + box.height * rand2(0.3, 0.7);
3105
3330
  await humanClickXY(page, cx, cy);
3106
3331
  await sleep4(2500);
3107
- const frame = await checkboxFrame.contentFrame();
3108
- if (frame) {
3109
- try {
3110
- const checked = await frame.evaluate(() => {
3111
- const cb = document.querySelector("#checkbox");
3112
- return cb?.getAttribute("aria-checked") === "true";
3113
- });
3114
- if (checked) return true;
3115
- } catch {
3116
- }
3117
- }
3118
- return false;
3332
+ return hasToken(page);
3119
3333
  }
3120
- async function getChallengeInfo2(page) {
3121
- const challengeFrame = await page.waitForSelector(
3122
- 'iframe[title="hCaptcha challenge"], iframe[title*="hcaptcha challenge" i]',
3123
- { timeout: 8e3 }
3124
- ).catch(() => null);
3334
+ async function getChallenge(page) {
3335
+ const challengeFrame = await page.waitForSelector(CHALLENGE_FRAME, { timeout: 8e3 }).catch(() => null);
3125
3336
  if (!challengeFrame) return null;
3126
- const frameBox = await challengeFrame.boundingBox();
3127
- if (!frameBox) return null;
3128
3337
  const frame = await challengeFrame.contentFrame();
3129
3338
  if (!frame) return null;
3130
- const info = await frame.evaluate(() => {
3131
- const promptEl = document.querySelector(
3132
- ".prompt-text, .task-instructions, [class*='prompt'], [class*='task-description']"
3133
- );
3134
- const prompt = promptEl ? promptEl.innerText.trim() : "";
3135
- const tileEls = document.querySelectorAll(
3136
- ".task-image, [class*='task-grid'] > *, [class*='challenge-container'] .image-wrapper, .image-wrapper"
3137
- );
3138
- const count = tileEls.length;
3139
- let rows = 3, cols = 3;
3140
- if (count === 16) {
3141
- rows = 4;
3142
- cols = 4;
3143
- } else if (count === 9) {
3144
- rows = 3;
3145
- cols = 3;
3146
- } else if (count === 6) {
3147
- rows = 2;
3148
- cols = 3;
3149
- }
3150
- return { prompt, count, rows, cols };
3151
- }).catch(() => ({ prompt: "", count: 0, rows: 3, cols: 3 }));
3152
- if (!info.prompt && info.count === 0) return null;
3153
- const gridPadTop = 150;
3154
- const gridPadLeft = 20;
3155
- const gridPadRight = 20;
3156
- const gridPadBottom = 80;
3157
- const gridWidth = frameBox.width - gridPadLeft - gridPadRight;
3158
- const gridHeight = frameBox.height - gridPadTop - gridPadBottom;
3159
- const tileW = gridWidth / info.cols;
3160
- const tileH = gridHeight / info.rows;
3161
- const tiles = [];
3162
- for (let r = 0; r < info.rows; r++) {
3163
- for (let c = 0; c < info.cols; c++) {
3164
- tiles.push({
3165
- index: r * info.cols + c,
3166
- centerX: Math.round(frameBox.x + gridPadLeft + c * tileW + tileW / 2),
3167
- centerY: Math.round(frameBox.y + gridPadTop + r * tileH + tileH / 2)
3168
- });
3169
- }
3170
- }
3171
- const verifyBtnBox = await frame.evaluate(() => {
3172
- const btn = document.querySelector('.button-submit.button, [aria-label="Verify"], [aria-label="Skip Challenge"]');
3173
- if (!btn) return null;
3174
- const r = btn.getBoundingClientRect();
3175
- return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
3176
- }).catch(() => null);
3177
- const verifyButton = verifyBtnBox ? { x: Math.round(frameBox.x + verifyBtnBox.x), y: Math.round(frameBox.y + verifyBtnBox.y) } : { x: Math.round(frameBox.x + frameBox.width - 55), y: Math.round(frameBox.y + frameBox.height - 30) };
3178
- log11.info(`Round challenge: "${info.prompt}" (${info.rows}x${info.cols})`);
3179
- return { prompt: info.prompt, rows: info.rows, cols: info.cols, tiles, verifyButton, frameBox };
3180
- }
3181
- async function screenshotChallenge(page, challenge) {
3182
- const { frameBox } = challenge;
3183
- try {
3184
- await sleep4(1e3);
3185
- const challengeEl = await page.$('iframe[title="hCaptcha challenge"], iframe[title*="hcaptcha challenge" i]').catch(() => null);
3186
- let buf;
3187
- if (challengeEl) {
3188
- buf = Buffer.from(await challengeEl.screenshot({ type: "jpeg", quality: 85 }));
3189
- } else {
3190
- buf = await page.screenshot({ type: "jpeg", quality: 85, clip: frameBox });
3191
- }
3192
- return buf.toString("base64");
3193
- } catch (err) {
3194
- log11.error(`screenshot failed: ${err instanceof Error ? err.message : String(err)}`);
3195
- return null;
3339
+ const prompt = await frame.evaluate(() => {
3340
+ const el = document.querySelector(".prompt-text, .task-instructions, [class*='prompt'], [class*='task-description']");
3341
+ return el ? el.innerText.trim() : "";
3342
+ }).catch(() => "");
3343
+ if (!prompt) return null;
3344
+ const target = await frame.$("canvas").catch(() => null) ?? challengeFrame;
3345
+ const box = await target.boundingBox();
3346
+ if (!box) return null;
3347
+ log10.info(`Round challenge: "${prompt}" (${Math.round(box.width)}x${Math.round(box.height)})`);
3348
+ return { prompt, target, box, frame };
3349
+ }
3350
+ async function askForAnswer(client, challenge) {
3351
+ const buf = await challenge.target.screenshot({ type: "jpeg", quality: 85, scale: "css" });
3352
+ const width = Math.round(challenge.box.width);
3353
+ const height = Math.round(challenge.box.height);
3354
+ const text = await askVision(client, {
3355
+ max_tokens: 1024,
3356
+ messages: [{
3357
+ role: "user",
3358
+ content: [
3359
+ { type: "image", source: { type: "base64", media_type: "image/jpeg", data: Buffer.from(buf).toString("base64") } },
3360
+ {
3361
+ type: "text",
3362
+ text: `This image is ${width}x${height} pixels. The task shown with it: "${challenge.prompt}".
3363
+ Work out the answer, then end your reply with one line of JSON, using pixel coordinates in this image.
3364
+ To click: {"clicks":[[x,y],[x,y]]}
3365
+ To drag: {"drag":[[fromX,fromY],[toX,toY]]}
3366
+ If you can't tell: {"clicks":[]}`
3367
+ }
3368
+ ]
3369
+ }]
3370
+ });
3371
+ const answer = parseChallengeAnswer(text, width, height);
3372
+ if (!answer) log10.warn(`no usable answer in model reply: "${text.slice(0, 120)}"`);
3373
+ return answer;
3374
+ }
3375
+ async function perform(page, challenge, answer) {
3376
+ const { x, y } = challenge.box;
3377
+ if ("drag" in answer) {
3378
+ await humanMove(page, x + answer.drag.from.x, y + answer.drag.from.y);
3379
+ await sleep4(rand2(100, 250));
3380
+ await page.mouse.down();
3381
+ await humanMove(page, x + answer.drag.to.x, y + answer.drag.to.y);
3382
+ await sleep4(rand2(100, 250));
3383
+ await page.mouse.up();
3384
+ return;
3196
3385
  }
3197
- }
3198
- async function classifyTiles2(client, screenshotBase64, challenge) {
3199
- const { prompt, rows, cols } = challenge;
3200
- const total = rows * cols;
3201
- try {
3202
- const response = await client.messages.create({
3203
- model: MODEL2,
3204
- max_tokens: 30,
3205
- system: `Output ONLY a comma-separated list of tile numbers or "none". Valid examples: "0,3,5" or "2" or "none". Output nothing else.`,
3206
- messages: [{
3207
- role: "user",
3208
- content: [
3209
- { type: "image", source: { type: "base64", media_type: "image/jpeg", data: screenshotBase64 } },
3210
- { type: "text", text: `Task: "${prompt}". Tiles 0-${total - 1} left-to-right top-to-bottom in a ${rows}\xD7${cols} grid. Which tiles match? Reply ONLY with numbers or "none".` }
3211
- ]
3212
- }]
3213
- });
3214
- const text = (response.content[0].text ?? "").trim();
3215
- log11.debug(`classify response: "${text}"`);
3216
- if (text.toLowerCase().startsWith("none")) return [];
3217
- return text.split(/[,\s]+/).map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n) && n >= 0 && n < total);
3218
- } catch (err) {
3219
- log11.error(`classification failed: ${err instanceof Error ? err.message : String(err)}`);
3220
- return [];
3386
+ for (const click2 of answer.clicks) {
3387
+ await humanClickXY(page, x + click2.x, y + click2.y);
3388
+ await sleep4(rand2(150, 400));
3221
3389
  }
3222
3390
  }
3223
- async function isSolved(page) {
3224
- const el = await page.$('iframe[title="hCaptcha challenge"], iframe[title*="hcaptcha challenge" i]').catch(() => null);
3225
- if (!el) return true;
3226
- const visible = await el.isVisible().catch(() => false);
3227
- return !visible;
3228
- }
3229
- async function clickVerify(page, challenge) {
3230
- await humanClickXY(page, challenge.verifyButton.x, challenge.verifyButton.y);
3391
+ async function clickSubmit(page, challenge) {
3392
+ const button = await challenge.frame.$(".button-submit").catch(() => null);
3393
+ const box = button ? await button.boundingBox() : null;
3394
+ if (!box) throw new Error("hCaptcha submit button not found");
3395
+ await humanClickXY(page, box.x + box.width / 2, box.y + box.height / 2);
3231
3396
  await sleep4(2500);
3232
- return isSolved(page);
3233
3397
  }
3234
3398
  async function solveHCaptcha(page, monitor) {
3235
3399
  const startTime = Date.now();
3236
- const client = getClient2();
3400
+ const client = getCaptchaClient();
3401
+ const result = (solved, reason) => ({
3402
+ solved,
3403
+ rounds,
3404
+ durationMs: Date.now() - startTime,
3405
+ ...reason ? { reason } : {}
3406
+ });
3237
3407
  let rounds = 0;
3238
- let solvedOnCheckbox = false;
3239
3408
  try {
3240
- solvedOnCheckbox = await clickCheckbox(page);
3409
+ if (await clickCheckbox(page)) {
3410
+ log10.info("Solved on checkbox click (no challenge)");
3411
+ return result(true);
3412
+ }
3413
+ while (rounds < MAX_ROUNDS2) {
3414
+ if (Date.now() - startTime > MAX_DURATION_MS2) return result(false, "Timeout exceeded");
3415
+ rounds++;
3416
+ monitor?.reportActivity();
3417
+ const challenge = await getChallenge(page);
3418
+ if (!challenge) {
3419
+ if (await hasToken(page, 3e3)) return result(true);
3420
+ return result(false, "The challenge closed without a token");
3421
+ }
3422
+ await sleep4(1e3);
3423
+ const answer = await askForAnswer(client, challenge);
3424
+ monitor?.reportActivity();
3425
+ log10.info(`Round ${rounds}: ${answer ? JSON.stringify(answer) : "no answer"}`);
3426
+ if (answer) await perform(page, challenge, answer);
3427
+ await sleep4(rand2(500, 1e3));
3428
+ await clickSubmit(page, challenge);
3429
+ monitor?.reportActivity();
3430
+ if (await hasToken(page)) {
3431
+ log10.info(`Solved in ${rounds} rounds, ${Date.now() - startTime}ms`);
3432
+ return result(true);
3433
+ }
3434
+ log10.info(`Round ${rounds}: not solved, retrying`);
3435
+ }
3436
+ return result(false, `Max rounds (${MAX_ROUNDS2}) exceeded`);
3241
3437
  } catch (err) {
3242
- return { solved: false, rounds: 0, durationMs: Date.now() - startTime, reason: err instanceof Error ? err.message : String(err) };
3243
- }
3244
- if (solvedOnCheckbox) {
3245
- log11.info("Solved on checkbox click (no challenge)");
3246
- return { solved: true, rounds: 0, durationMs: Date.now() - startTime };
3247
- }
3248
- while (rounds < MAX_ROUNDS2) {
3249
- if (Date.now() - startTime > MAX_DURATION_MS2) {
3250
- return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Timeout exceeded" };
3251
- }
3252
- rounds++;
3253
- monitor?.reportActivity();
3254
- const challenge = await getChallengeInfo2(page);
3255
- if (!challenge) {
3256
- log11.info("Challenge frame gone \u2014 assuming solved");
3257
- return { solved: true, rounds, durationMs: Date.now() - startTime };
3258
- }
3259
- const screenshotBase64 = await screenshotChallenge(page, challenge);
3260
- if (!screenshotBase64) {
3261
- return { solved: false, rounds, durationMs: Date.now() - startTime, reason: "Failed to screenshot challenge" };
3262
- }
3263
- monitor?.reportActivity();
3264
- const matchingIndices = await classifyTiles2(client, screenshotBase64, challenge);
3265
- log11.info(`Round ${rounds}: clicking tiles [${matchingIndices.join(", ")}]`);
3266
- monitor?.reportActivity();
3267
- for (const idx of matchingIndices) {
3268
- const tile = challenge.tiles[idx];
3269
- if (!tile) continue;
3270
- await humanClickXY(page, tile.centerX + rand2(-5, 5), tile.centerY + rand2(-5, 5));
3271
- await sleep4(rand2(150, 400));
3272
- }
3273
- await sleep4(rand2(500, 1e3));
3274
- const solved = await clickVerify(page, challenge);
3275
- monitor?.reportActivity();
3276
- if (solved) {
3277
- log11.info(`Solved in ${rounds} rounds, ${Date.now() - startTime}ms`);
3278
- return { solved: true, rounds, durationMs: Date.now() - startTime };
3279
- }
3280
- log11.info(`Round ${rounds}: not solved, retrying`);
3281
- await sleep4(rand2(500, 1e3));
3282
- }
3283
- return { solved: false, rounds, durationMs: Date.now() - startTime, reason: `Max rounds (${MAX_ROUNDS2}) exceeded` };
3438
+ if (err instanceof CaptchaApiError) return result(false, err.message);
3439
+ return result(false, err instanceof Error ? err.message : String(err));
3440
+ }
3284
3441
  }
3285
3442
 
3286
3443
  // src/lib/actions/handlers/captcha.ts
3287
- var log12 = createLogger("actions");
3444
+ var log11 = createLogger("actions");
3288
3445
  async function screenshotTiles2(page, ci) {
3289
3446
  const tileSize = ci.bframeBox ? Math.round((ci.bframeBox.width - CAPTCHA_GRID.GRID_PADDING) / ci.cols) : CAPTCHA_GRID.DEFAULT_TILE_SIZE;
3290
3447
  const tiles = [];
@@ -3336,7 +3493,15 @@ async function recaptchaAnswer(page, step) {
3336
3493
  return { solved: false, tiles: [] };
3337
3494
  }
3338
3495
  async function solveCaptcha(page, _step, _ctx, monitor) {
3496
+ const startTime = Date.now();
3339
3497
  await page.waitForTimeout(TIMING.CAPTCHA_DETECT_WAIT);
3498
+ const widget = await page.waitForSelector(
3499
+ 'iframe[src*="recaptcha/api2/anchor"], iframe[src*="recaptcha/enterprise/anchor"], iframe[title*="reCAPTCHA"], iframe[src*="hcaptcha.com"], iframe[title*="hCaptcha"]',
3500
+ { timeout: 5e3 }
3501
+ ).catch(() => null);
3502
+ if (!widget) {
3503
+ return { solved: false, noCaptcha: true, rounds: 0, durationMs: Date.now() - startTime, reason: "No captcha on this page" };
3504
+ }
3340
3505
  const isHCaptcha = await page.evaluate(() => {
3341
3506
  const iframes = Array.from(document.querySelectorAll("iframe"));
3342
3507
  return iframes.some((f) => {
@@ -3345,11 +3510,15 @@ async function solveCaptcha(page, _step, _ctx, monitor) {
3345
3510
  return src.includes("hcaptcha.com") || title.includes("hcaptcha") || !!document.querySelector("[data-hcaptcha-widget-id]");
3346
3511
  });
3347
3512
  }).catch((err) => {
3348
- log12.warn(`captcha detection failed: ${err}`);
3513
+ log11.warn(`captcha detection failed: ${err}`);
3349
3514
  return false;
3350
3515
  });
3351
- log12.info(`detected: ${isHCaptcha ? "hCaptcha" : "reCAPTCHA"}`);
3352
- return isHCaptcha ? await solveHCaptcha(page, monitor) : await solveRecaptcha(page, monitor);
3516
+ log11.info(`detected: ${isHCaptcha ? "hCaptcha" : "reCAPTCHA"}`);
3517
+ const result = isHCaptcha ? await solveHCaptcha(page, monitor) : await solveRecaptcha(page, monitor);
3518
+ if (!result.solved) {
3519
+ throw new Error(`Captcha not solved after ${result.rounds} round(s): ${result.reason ?? "unknown reason"}`);
3520
+ }
3521
+ return result;
3353
3522
  }
3354
3523
 
3355
3524
  // src/lib/browser/form-fill.ts
@@ -3407,10 +3576,10 @@ async function findSubmitButton(page, opts) {
3407
3576
  }
3408
3577
 
3409
3578
  // src/lib/knowledge.ts
3410
- var import_fs9 = __toESM(require("fs"));
3579
+ var import_fs7 = __toESM(require("fs"));
3411
3580
  var import_path8 = __toESM(require("path"));
3412
3581
  init_paths();
3413
- var log13 = createLogger("knowledge");
3582
+ var log12 = createLogger("knowledge");
3414
3583
  function getKnowledgeDir() {
3415
3584
  return import_path8.default.join(getDataDir(), "knowledge");
3416
3585
  }
@@ -3449,12 +3618,12 @@ function sanitizeDomain(input) {
3449
3618
  return normalized.replace(/[^a-z0-9.-]/g, "_");
3450
3619
  }
3451
3620
  function ensureDir() {
3452
- import_fs9.default.mkdirSync(getKnowledgeDir(), { recursive: true });
3621
+ import_fs7.default.mkdirSync(getKnowledgeDir(), { recursive: true });
3453
3622
  }
3454
3623
  function readKnowledge(domain) {
3455
3624
  const p = getKnowledgePath(domain);
3456
3625
  try {
3457
- return import_fs9.default.readFileSync(p, "utf8");
3626
+ return import_fs7.default.readFileSync(p, "utf8");
3458
3627
  } catch {
3459
3628
  return null;
3460
3629
  }
@@ -3477,8 +3646,8 @@ function mergeKnowledge(domain, updates) {
3477
3646
  notes: dedupeNotes([...existing?.notes ?? [], ...updates.notes ?? []])
3478
3647
  };
3479
3648
  const md = renderMarkdown(merged);
3480
- import_fs9.default.writeFileSync(getKnowledgePath(domain), md, "utf8");
3481
- log13.info(`knowledge updated: ${merged.domain} (${merged.endpoints.length} endpoints)`);
3649
+ import_fs7.default.writeFileSync(getKnowledgePath(domain), md, "utf8");
3650
+ log12.info(`knowledge updated: ${merged.domain} (${merged.endpoints.length} endpoints)`);
3482
3651
  }
3483
3652
  function dedupeEndpoints(endpoints) {
3484
3653
  const seen = /* @__PURE__ */ new Map();
@@ -3696,7 +3865,7 @@ var EMAIL_FORM_ANCHOR = 'input[type="email"], input[name*="email" i], input[type
3696
3865
  var PASSWORD_FORM_ANCHOR = PASSWORD_SELECTOR;
3697
3866
 
3698
3867
  // src/lib/actions/handlers/login/index.ts
3699
- var log14 = createLogger("actions");
3868
+ var log13 = createLogger("actions");
3700
3869
  async function login(page, step, ctx) {
3701
3870
  const resolved = await resolveCredential(ctx.store, ctx.userId, ctx.token, step.domain);
3702
3871
  if (!resolved) {
@@ -3708,7 +3877,7 @@ async function login(page, step, ctx) {
3708
3877
  }
3709
3878
  const { credential, matchedDomain } = resolved;
3710
3879
  if (matchedDomain !== normalizeDomain(step.domain)) {
3711
- log14.info(`login: credentials for ${step.domain} resolved via parent domain ${matchedDomain}`);
3880
+ log13.info(`login: credentials for ${step.domain} resolved via parent domain ${matchedDomain}`);
3712
3881
  }
3713
3882
  const beforeUrl = page.url();
3714
3883
  const hasExplicitSelectors = !!(step.usernameSelector || step.passwordSelector || step.submitSelector);
@@ -3741,7 +3910,7 @@ async function runExplicitFlow(page, step, ctx, credential) {
3741
3910
  }
3742
3911
  }
3743
3912
  async function runAutoDetect(page, step, ctx, credential, beforeUrl) {
3744
- log14.info(`login: auto-detecting form on ${beforeUrl}`);
3913
+ log13.info(`login: auto-detecting form on ${beforeUrl}`);
3745
3914
  await page.waitForLoadState("domcontentloaded").catch(() => {
3746
3915
  });
3747
3916
  await page.waitForTimeout(500);
@@ -3751,7 +3920,7 @@ async function runAutoDetect(page, step, ctx, credential, beforeUrl) {
3751
3920
  return { url: location.href, title: document.title, pwdVisible };
3752
3921
  }, PASSWORD_SELECTOR).catch(() => ({ url: page.url(), title: "", pwdVisible: false }));
3753
3922
  if (!initialCheck.pwdVisible && !LOGIN_URL_RE.test(initialCheck.url)) {
3754
- log14.info(`login: already logged in (no password field, URL=${initialCheck.url})`);
3923
+ log13.info(`login: already logged in (no password field, URL=${initialCheck.url})`);
3755
3924
  return {
3756
3925
  loggedIn: true,
3757
3926
  alreadyLoggedIn: true,
@@ -3771,7 +3940,7 @@ async function runAutoDetect(page, step, ctx, credential, beforeUrl) {
3771
3940
  async function runNoPasswordBranch(page, step, ctx, credential, beforeUrl) {
3772
3941
  const currentUrl = page.url();
3773
3942
  if (!LOGIN_URL_RE.test(currentUrl)) {
3774
- log14.info(`login: no password field and URL left login area (${currentUrl}) \u2014 treating as success`);
3943
+ log13.info(`login: no password field and URL left login area (${currentUrl}) \u2014 treating as success`);
3775
3944
  return {
3776
3945
  loggedIn: true,
3777
3946
  alreadyLoggedIn: true,
@@ -3798,7 +3967,7 @@ async function runNoPasswordBranch(page, step, ctx, credential, beforeUrl) {
3798
3967
  const hasCloudflare = !!document.querySelector('[class*="cf-" i], iframe[src*="challenges.cloudflare"]');
3799
3968
  return { title: document.title, visibleText, inputCount, hiddenPassword, hasCaptcha, hasCloudflare };
3800
3969
  }).catch(() => ({ title: "", visibleText: "", inputCount: 0, hiddenPassword: false, hasCaptcha: false, hasCloudflare: false }));
3801
- log14.warn(`login: no visible password or email field on ${currentUrl} \u2014 title="${pageDiag.title}", inputs=${pageDiag.inputCount}`);
3970
+ log13.warn(`login: no visible password or email field on ${currentUrl} \u2014 title="${pageDiag.title}", inputs=${pageDiag.inputCount}`);
3802
3971
  const indicators = [];
3803
3972
  if (pageDiag.hasCaptcha) indicators.push("CAPTCHA detected");
3804
3973
  if (pageDiag.hasCloudflare) indicators.push("Cloudflare challenge");
@@ -3806,12 +3975,12 @@ async function runNoPasswordBranch(page, step, ctx, credential, beforeUrl) {
3806
3975
  if (pageDiag.hiddenPassword) indicators.push("password field exists but is hidden/disabled");
3807
3976
  const indicatorStr = indicators.length > 0 ? ` (${indicators.join(", ")})` : "";
3808
3977
  throw new Error(
3809
- `login: no visible password or email field on ${currentUrl} after 5000ms${indicatorStr}. Page title: "${pageDiag.title}". The site may be showing a bot-detection wall, captcha, or an unsupported login flow. Retry with a stronger browser mode (binary-headful or docker-headful).`
3978
+ `login: no visible password or email field on ${currentUrl} after 5000ms${indicatorStr}. Page title: "${pageDiag.title}". The site may be showing a bot-detection wall, captcha, or an unsupported login flow. Retry with a stronger browser mode (binary-headful).`
3810
3979
  );
3811
3980
  }
3812
3981
  async function runEmailFirstFlow(page, credential, beforeUrl, emailEl) {
3813
3982
  const currentUrl = page.url();
3814
- log14.info(`login: no password field but found email input \u2014 running email-first flow on ${currentUrl}`);
3983
+ log13.info(`login: no password field but found email input \u2014 running email-first flow on ${currentUrl}`);
3815
3984
  await fillHandleNative(page, emailEl, credential.username);
3816
3985
  const submitEl = await findSubmitButton(page, {
3817
3986
  formAnchor: EMAIL_FORM_ANCHOR,
@@ -3825,7 +3994,7 @@ async function runEmailFirstFlow(page, credential, beforeUrl, emailEl) {
3825
3994
  await submitEl.evaluate((el) => el.click());
3826
3995
  });
3827
3996
  } else {
3828
- log14.warn("login: email-first flow, no submit button found \u2014 pressing Enter");
3997
+ log13.warn("login: email-first flow, no submit button found \u2014 pressing Enter");
3829
3998
  await emailEl.press("Enter").catch(() => {
3830
3999
  });
3831
4000
  }
@@ -3839,7 +4008,7 @@ async function runEmailFirstFlow(page, credential, beforeUrl, emailEl) {
3839
4008
  ]);
3840
4009
  const laterPasswordHandle = await page.$(PASSWORD_SELECTOR);
3841
4010
  if (laterPasswordHandle && credential.password) {
3842
- log14.info("login: password field appeared after email submit \u2014 filling it");
4011
+ log13.info("login: password field appeared after email submit \u2014 filling it");
3843
4012
  await fillHandleNative(page, laterPasswordHandle, credential.password);
3844
4013
  const laterSubmit = await page.$('button[type="submit"]:not([disabled])');
3845
4014
  if (laterSubmit) {
@@ -3878,7 +4047,7 @@ async function runPasswordFlow(page, credential, beforeUrl, passwordHandle) {
3878
4047
  if (usernameEl && credential.username) {
3879
4048
  await fillHandleNative(page, usernameEl, credential.username);
3880
4049
  } else if (!usernameEl) {
3881
- log14.warn("login: no username field detected, proceeding with password only");
4050
+ log13.warn("login: no username field detected, proceeding with password only");
3882
4051
  }
3883
4052
  if (credential.password) {
3884
4053
  await fillHandleNative(page, passwordHandle, credential.password);
@@ -3895,7 +4064,7 @@ async function runPasswordFlow(page, credential, beforeUrl, passwordHandle) {
3895
4064
  await submitEl.evaluate((el) => el.click());
3896
4065
  });
3897
4066
  } else {
3898
- log14.warn("login: no submit button detected, pressing Enter in password field");
4067
+ log13.warn("login: no submit button detected, pressing Enter in password field");
3899
4068
  await passwordHandle.press("Enter").catch(() => {
3900
4069
  });
3901
4070
  }
@@ -3913,13 +4082,13 @@ async function handleOtp(page, step, ctx, credential, beforeUrl) {
3913
4082
  let code = null;
3914
4083
  if (credential.totp_secret) {
3915
4084
  code = generateTOTP(credential.totp_secret);
3916
- log14.info(`login: generated TOTP from stored secret for ${step.domain}`);
4085
+ log13.info(`login: generated TOTP from stored secret for ${step.domain}`);
3917
4086
  } else if (ctx.elicitOtp) {
3918
- log14.info(`login: prompting user for OTP for ${step.domain}`);
4087
+ log13.info(`login: prompting user for OTP for ${step.domain}`);
3919
4088
  try {
3920
4089
  code = await ctx.elicitOtp(step.domain);
3921
4090
  } catch (err) {
3922
- log14.warn(`login: OTP elicitation failed: ${err instanceof Error ? err.message : String(err)}`);
4091
+ log13.warn(`login: OTP elicitation failed: ${err instanceof Error ? err.message : String(err)}`);
3923
4092
  }
3924
4093
  if (!code) {
3925
4094
  throw new Error(`login: OTP required but user did not provide one for ${step.domain}`);
@@ -3993,6 +4162,8 @@ async function executeAction(page, step, ctx, monitor) {
3993
4162
 
3994
4163
  // src/lib/stale-monitor.ts
3995
4164
  var DEFAULT_STALE_TIMEOUT_MS = 2e4;
4165
+ var PROBE_BUDGET_MS = 3e3;
4166
+ var UNRESPONSIVE_GRACE_MS = 8e3;
3996
4167
  var StaleStateMonitor = class _StaleStateMonitor {
3997
4168
  page;
3998
4169
  timeoutMs;
@@ -4004,21 +4175,24 @@ var StaleStateMonitor = class _StaleStateMonitor {
4004
4175
  }
4005
4176
  async snapshot() {
4006
4177
  try {
4007
- const [url, state] = await Promise.all([
4008
- Promise.resolve(this.page.url()),
4178
+ const url = this.page.url();
4179
+ let unresponsive = false;
4180
+ const state = await withBudget(
4009
4181
  this.page.evaluate(() => ({
4010
4182
  documentReadyState: document.readyState,
4011
4183
  bodyTextLength: document.body?.innerText?.length || 0,
4012
4184
  elementCount: document.querySelectorAll("*").length
4013
- })).catch(() => ({
4014
- documentReadyState: "unknown",
4015
- bodyTextLength: 0,
4016
- elementCount: 0
4017
- }))
4018
- ]);
4185
+ })),
4186
+ PROBE_BUDGET_MS,
4187
+ "state probe"
4188
+ ).catch((e) => {
4189
+ unresponsive = e instanceof BudgetExceededError;
4190
+ return { documentReadyState: "unknown", bodyTextLength: 0, elementCount: 0 };
4191
+ });
4019
4192
  return {
4020
4193
  url,
4021
4194
  ...state,
4195
+ unresponsive,
4022
4196
  timestamp: Date.now()
4023
4197
  };
4024
4198
  } catch {
@@ -4062,6 +4236,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
4062
4236
  return new Promise((resolve, reject) => {
4063
4237
  let resolved = false;
4064
4238
  let checkInterval = null;
4239
+ let probing = false;
4240
+ let unresponsiveSince = null;
4065
4241
  const cleanup = () => {
4066
4242
  resolved = true;
4067
4243
  if (checkInterval) clearInterval(checkInterval);
@@ -4069,11 +4245,25 @@ var StaleStateMonitor = class _StaleStateMonitor {
4069
4245
  this.timer = null;
4070
4246
  };
4071
4247
  checkInterval = setInterval(async () => {
4072
- if (resolved) return;
4248
+ if (resolved || probing) return;
4073
4249
  const elapsed = Date.now() - this.lastActivity;
4074
4250
  if (elapsed < this.timeoutMs) return;
4251
+ probing = true;
4075
4252
  try {
4076
4253
  const currentSnapshot = await this.snapshot();
4254
+ if (resolved) return;
4255
+ if (currentSnapshot.unresponsive) {
4256
+ unresponsiveSince ??= Date.now();
4257
+ if (Date.now() - unresponsiveSince < UNRESPONSIVE_GRACE_MS) return;
4258
+ cleanup();
4259
+ reject(new StaleStateError(
4260
+ `The tab stopped responding: its JavaScript did not answer for ${Math.round((Date.now() - unresponsiveSince) / 1e3)}s (a blocking dialog, a frozen re-render, or a navigation that never finished)`,
4261
+ this.timeoutMs,
4262
+ true
4263
+ ));
4264
+ return;
4265
+ }
4266
+ unresponsiveSince = null;
4077
4267
  if (_StaleStateMonitor.hasChanged(beforeSnapshot, currentSnapshot)) {
4078
4268
  this.lastActivity = Date.now();
4079
4269
  return;
@@ -4081,6 +4271,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
4081
4271
  } catch {
4082
4272
  this.lastActivity = Date.now();
4083
4273
  return;
4274
+ } finally {
4275
+ probing = false;
4084
4276
  }
4085
4277
  cleanup();
4086
4278
  reject(new StaleStateError(
@@ -4104,10 +4296,13 @@ var StaleStateMonitor = class _StaleStateMonitor {
4104
4296
  };
4105
4297
  var StaleStateError = class extends Error {
4106
4298
  timeoutMs;
4107
- constructor(message, timeoutMs) {
4299
+ /** True when the page stopped answering at all (vs. answering but not changing). */
4300
+ unresponsive;
4301
+ constructor(message, timeoutMs, unresponsive = false) {
4108
4302
  super(message);
4109
4303
  this.name = "StaleStateError";
4110
4304
  this.timeoutMs = timeoutMs;
4305
+ this.unresponsive = unresponsive;
4111
4306
  }
4112
4307
  };
4113
4308
 
@@ -4119,7 +4314,7 @@ var RecaptchaDetector = class {
4119
4314
  () => [
4120
4315
  'iframe[src*="recaptcha/api2/anchor"], iframe[title*="reCAPTCHA"]',
4121
4316
  'iframe[src*="recaptcha/api2/bframe"]',
4122
- '.g-recaptcha:not([data-size="invisible"]), [data-sitekey]:not([data-size="invisible"])'
4317
+ '.g-recaptcha:not([data-size="invisible"]), [data-sitekey]:not([data-size="invisible"]):not(.h-captcha):not(.cf-turnstile)'
4123
4318
  ].some((sel) => {
4124
4319
  const el = document.querySelector(sel);
4125
4320
  if (!el) return false;
@@ -4142,7 +4337,8 @@ var HCaptchaDetector = class {
4142
4337
  () => [
4143
4338
  'iframe[src*="hcaptcha.com"]',
4144
4339
  'iframe[title*="hCaptcha"]',
4145
- "[data-hcaptcha-widget-id]"
4340
+ "[data-hcaptcha-widget-id]",
4341
+ '.h-captcha:not([data-size="invisible"])'
4146
4342
  ].some((sel) => {
4147
4343
  const el = document.querySelector(sel);
4148
4344
  if (!el) return false;
@@ -4206,8 +4402,8 @@ var LoginWallDetector = class {
4206
4402
  }
4207
4403
  };
4208
4404
  var defaultDetectors = [
4209
- new RecaptchaDetector(),
4210
4405
  new HCaptchaDetector(),
4406
+ new RecaptchaDetector(),
4211
4407
  new CookieConsentDetector(),
4212
4408
  new LoginWallDetector()
4213
4409
  ];
@@ -4318,10 +4514,14 @@ async function capturePageState(page, ctx, opts) {
4318
4514
  url = page.url();
4319
4515
  } catch {
4320
4516
  }
4321
- const title = await page.title().catch(() => "");
4517
+ const title = await withBudget(page.title(), 2e3, "title").catch(() => "");
4322
4518
  if (!screenshot2) return { url, title };
4323
4519
  try {
4324
- const buf = await page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
4520
+ const buf = await withBudget(
4521
+ page.screenshot({ type: "jpeg", quality: 50, fullPage: false, timeout: 12e3 }),
4522
+ 13e3,
4523
+ "screenshot"
4524
+ );
4325
4525
  const screenshotUrl = saveScreenshot(buf, `${namePrefix}-${Date.now()}.jpg`, ctx.screenshotDir, ctx.publicUrl);
4326
4526
  return { url, title, screenshotUrl };
4327
4527
  } catch {
@@ -4389,8 +4589,8 @@ function isLikelyId(segment) {
4389
4589
  function isRecord(x) {
4390
4590
  return typeof x === "object" && x !== null && !Array.isArray(x);
4391
4591
  }
4392
- function parameterizePath(path13) {
4393
- const parts = path13.split("/");
4592
+ function parameterizePath(path12) {
4593
+ const parts = path12.split("/");
4394
4594
  let idCount = 0;
4395
4595
  const parameterized = parts.map((part) => {
4396
4596
  if (part && isLikelyId(part)) {
@@ -4479,16 +4679,16 @@ function gqlActionFromBody(body) {
4479
4679
  return void 0;
4480
4680
  }
4481
4681
  function classifyRequest(req) {
4482
- const path13 = req.path;
4483
- const lowerPath = path13.toLowerCase();
4682
+ const path12 = req.path;
4683
+ const lowerPath = path12.toLowerCase();
4484
4684
  const ct = (req.requestHeaders["content-type"] || req.requestHeaders["Content-Type"] || "").toLowerCase();
4485
4685
  const body = req.requestBody;
4486
4686
  if (ct.includes("application/grpc")) {
4487
- return { protocol: "grpc-web", action: path13.replace(/^\//, "") };
4687
+ return { protocol: "grpc-web", action: path12.replace(/^\//, "") };
4488
4688
  }
4489
4689
  const soapAction = req.requestHeaders["soapaction"] || req.requestHeaders["SOAPAction"];
4490
4690
  if (soapAction || ct.includes("text/xml") || ct.includes("application/soap+xml")) {
4491
- return { protocol: "soap", action: (soapAction || path13).replace(/^["/]|["/]$/g, "") };
4691
+ return { protocol: "soap", action: (soapAction || path12).replace(/^["/]|["/]$/g, "") };
4492
4692
  }
4493
4693
  if (/\/graphql\b/.test(lowerPath) || hasGraphQLShape(body)) {
4494
4694
  return { protocol: "graphql", action: gqlActionFromBody(body) ?? "anonymous" };
@@ -4503,7 +4703,7 @@ function classifyRequest(req) {
4503
4703
  const friendly = gqlActionFromBody(body);
4504
4704
  if (friendly) return { protocol: "form-rpc", action: friendly };
4505
4705
  }
4506
- return { protocol: "rest", action: `${req.method} ${parameterizePath(path13)}` };
4706
+ return { protocol: "rest", action: `${req.method} ${parameterizePath(path12)}` };
4507
4707
  }
4508
4708
  function inferVerb(protocol, action, method, responseBody) {
4509
4709
  const lower = action.toLowerCase();
@@ -4538,8 +4738,8 @@ function buildFunctionName(protocol, action, method, verb) {
4538
4738
  if (protocol === "rest") {
4539
4739
  const parts = action.split(" ");
4540
4740
  const httpMethod = parts[0];
4541
- const path13 = parts.slice(1).join(" ");
4542
- const segs = path13.split("/").filter((s) => s && !s.startsWith("{"));
4741
+ const path12 = parts.slice(1).join(" ");
4742
+ const segs = path12.split("/").filter((s) => s && !s.startsWith("{"));
4543
4743
  const verbPrefix = httpMethod === "GET" ? verb === "list" ? "list" : "get" : httpMethod === "POST" ? "create" : httpMethod === "PUT" ? "update" : httpMethod === "PATCH" ? "patch" : httpMethod === "DELETE" ? "delete" : httpMethod.toLowerCase();
4544
4744
  return camelCase(`${verbPrefix} ${segs.join(" ")}`) || camelCase(action);
4545
4745
  }
@@ -4693,10 +4893,37 @@ var ApiCapture = class {
4693
4893
  await new Promise((r) => setTimeout(r, 100));
4694
4894
  }
4695
4895
  }
4896
+ /** Requests fired but not yet answered. */
4897
+ hasPending() {
4898
+ return this.pendingRequests.size > 0;
4899
+ }
4696
4900
  getResults() {
4697
4901
  return buildCapturedApi(this.requests);
4698
4902
  }
4903
+ /** Every HTTP >= 400 response, compact, in capture order. */
4904
+ getServerErrors() {
4905
+ return serverErrorsFrom(this.requests);
4906
+ }
4699
4907
  };
4908
+ var SERVER_ERROR_BODY_MAX = 600;
4909
+ function serverErrorsFrom(requests) {
4910
+ const out = [];
4911
+ for (const r of requests) {
4912
+ if (r.responseStatus < 400) continue;
4913
+ let body;
4914
+ if (r.responseBody !== void 0 && r.responseBody !== null) {
4915
+ const raw = typeof r.responseBody === "string" ? r.responseBody : JSON.stringify(r.responseBody);
4916
+ const flat = raw.replace(/\s+/g, " ").trim();
4917
+ body = flat.length > SERVER_ERROR_BODY_MAX ? flat.slice(0, SERVER_ERROR_BODY_MAX) + "\u2026" : flat;
4918
+ if (/^\s*<(!doctype|html)/i.test(raw)) {
4919
+ const m = raw.match(/<title[^>]*>([^<]*)<\/title>|<h1[^>]*>([^<]*)<\/h1>/i);
4920
+ body = m ? `[html] ${(m[1] || m[2] || "").trim()}` : "[html page]";
4921
+ }
4922
+ }
4923
+ out.push({ stepIndex: r.triggeredAtStep, method: r.method, url: r.url, status: r.responseStatus, body });
4924
+ }
4925
+ return out;
4926
+ }
4700
4927
  function extractAuth(requests) {
4701
4928
  const auth2 = { cookies: {}, tokens: {} };
4702
4929
  for (const req of requests) {
@@ -4784,7 +5011,7 @@ function buildCapturedApi(requests) {
4784
5011
  }
4785
5012
 
4786
5013
  // src/lib/browser/tab-tracker.ts
4787
- var log15 = createLogger("tabs");
5014
+ var log14 = createLogger("tabs");
4788
5015
  var TabTracker = class {
4789
5016
  constructor(context, initial) {
4790
5017
  this.context = context;
@@ -4803,14 +5030,14 @@ var TabTracker = class {
4803
5030
  this.pages.push(p);
4804
5031
  this.newlyOpened.push(p);
4805
5032
  p.on("close", () => this.onClose(p));
4806
- log15.debug(`new tab opened: ${safeUrl(p)}`);
5033
+ log14.debug(`new tab opened: ${safeUrl(p)}`);
4807
5034
  };
4808
5035
  onClose = (p) => {
4809
5036
  this.pages = this.pages.filter((x) => x !== p);
4810
5037
  this.newlyOpened = this.newlyOpened.filter((x) => x !== p);
4811
5038
  if (this.activePage === p) {
4812
5039
  this.activePage = this.pages[this.pages.length - 1] ?? p;
4813
- log15.debug(`active tab closed, fell back to: ${safeUrl(this.activePage)}`);
5040
+ log14.debug(`active tab closed, fell back to: ${safeUrl(this.activePage)}`);
4814
5041
  }
4815
5042
  };
4816
5043
  /** The page the pipeline should currently drive. */
@@ -4853,7 +5080,7 @@ var TabTracker = class {
4853
5080
  });
4854
5081
  this.activePage = target;
4855
5082
  const sw = { url, title: await target.title().catch(() => "") };
4856
- log15.info(`followed new tab \u2192 ${sw.url}`);
5083
+ log14.info(`followed new tab \u2192 ${sw.url}`);
4857
5084
  return sw;
4858
5085
  }
4859
5086
  /** Drop pending new-tab opens without following them. Used when a step opened
@@ -4879,7 +5106,7 @@ function safeUrl(p) {
4879
5106
  }
4880
5107
 
4881
5108
  // src/lib/knowledge/component-map.ts
4882
- var import_fs10 = __toESM(require("fs"));
5109
+ var import_fs8 = __toESM(require("fs"));
4883
5110
  var import_path9 = __toESM(require("path"));
4884
5111
  function anchorsPath(domain) {
4885
5112
  return import_path9.default.join(getKnowledgeDir(), `${sanitizeDomain(domain)}.anchors.json`);
@@ -4887,7 +5114,7 @@ function anchorsPath(domain) {
4887
5114
  function loadComponentMap(domain) {
4888
5115
  const norm = normalizeDomain(domain);
4889
5116
  try {
4890
- const raw = import_fs10.default.readFileSync(anchorsPath(norm), "utf8");
5117
+ const raw = import_fs8.default.readFileSync(anchorsPath(norm), "utf8");
4891
5118
  const parsed = JSON.parse(raw);
4892
5119
  return {
4893
5120
  domain: parsed.domain || norm,
@@ -4905,8 +5132,8 @@ function loadAnchors(domain) {
4905
5132
  return map;
4906
5133
  }
4907
5134
  function write(cm) {
4908
- import_fs10.default.mkdirSync(getKnowledgeDir(), { recursive: true });
4909
- import_fs10.default.writeFileSync(anchorsPath(cm.domain), JSON.stringify(cm, null, 2), "utf8");
5135
+ import_fs8.default.mkdirSync(getKnowledgeDir(), { recursive: true });
5136
+ import_fs8.default.writeFileSync(anchorsPath(cm.domain), JSON.stringify(cm, null, 2), "utf8");
4910
5137
  }
4911
5138
  function recordAnchorResult(domain, name, ok, now) {
4912
5139
  try {
@@ -4935,6 +5162,7 @@ function safePageUrl(page) {
4935
5162
  }
4936
5163
  function classifyError(err, step) {
4937
5164
  if (err instanceof StaleStateError) return "stale-state";
5165
+ if (step.type === "solve-captcha") return "captcha-unsolvable";
4938
5166
  const msg = err.message.toLowerCase();
4939
5167
  if (step.type === "login" && (msg.includes("no visible password field") || msg.includes("password field was not visible"))) {
4940
5168
  return "bot-blocked";
@@ -4942,13 +5170,38 @@ function classifyError(err, step) {
4942
5170
  if (msg.includes("timeout") || msg.includes("timed out")) return "timeout";
4943
5171
  if (msg.includes("not found") || msg.includes("no element") || msg.includes("waiting for selector")) return "element-not-found";
4944
5172
  if (msg.includes("navigation") || msg.includes("net::err")) return "navigation-failed";
4945
- if (step.type === "solve-captcha") return "captcha-unsolvable";
4946
5173
  return "action-failed";
4947
5174
  }
5175
+ async function refineError(page, step, ctx, errorType, message) {
5176
+ if (errorType !== "stale-state" && errorType !== "timeout") return { errorType, message };
5177
+ const raw = step.selector;
5178
+ if (typeof raw !== "string") return { errorType, message };
5179
+ let resolved;
5180
+ try {
5181
+ resolved = resolveSelector(raw, ctx);
5182
+ } catch {
5183
+ return { errorType, message };
5184
+ }
5185
+ try {
5186
+ const probe = page.evaluate((sel) => !!document.querySelector(sel), resolved);
5187
+ const budget = new Promise((r) => setTimeout(() => r(null), TIMING.FAILURE_PROBE));
5188
+ const found = await Promise.race([probe, budget]);
5189
+ if (found !== false) return { errorType, message };
5190
+ } catch {
5191
+ return { errorType, message };
5192
+ }
5193
+ const shown = resolved === raw ? `"${raw}"` : `"${raw}" (\u2192 ${resolved})`;
5194
+ return {
5195
+ errorType: "element-not-found",
5196
+ message: `No element matches ${shown} on the current page \u2014 the step waited for it to appear and it never did. It most likely existed when you looked and was re-rendered away before this step ran. (${message})`
5197
+ };
5198
+ }
4948
5199
  function isRetryable(errorType) {
4949
5200
  return errorType === "stale-state" || errorType === "timeout" || errorType === "element-not-found";
4950
5201
  }
4951
- function getSuggestion(errorType, step) {
5202
+ var UNRESPONSIVE_SUGGESTION = "The tab's JavaScript stopped answering during this step. Run a single `read` or `snapshot` on its own to see if it recovered (it usually does). If this step froze it, don't repeat it as-is: for a route change in a single-page app use `evaluate` with `location.assign(url)`; for a click, use a fresh `@e` ref from `find`; avoid `evaluate` calls that scroll or re-render.";
5203
+ function getSuggestion(errorType, step, message = "") {
5204
+ if (message.includes("tab stopped responding")) return UNRESPONSIVE_SUGGESTION;
4952
5205
  const usedEphemeralRef = typeof step.selector === "string" && step.selector.startsWith("@e");
4953
5206
  if (usedEphemeralRef && (errorType === "element-not-found" || errorType === "timeout" || errorType === "stale-state")) {
4954
5207
  return "The @e ref went stale \u2014 the page (likely a React/SPA) re-rendered and dropped it since the snapshot. Take a fresh `snapshot`/`find` right before acting, or save the element as a durable `@a:` anchor with the `remember` tool.";
@@ -4957,11 +5210,11 @@ function getSuggestion(errorType, step) {
4957
5210
  case "stale-state":
4958
5211
  return "The page stopped responding. The step may have triggered a very slow load or the server may be unreachable.";
4959
5212
  case "element-not-found":
4960
- return `Selector not found. The page structure may have changed. Take a screenshot to inspect the current state.`;
5213
+ return "Selector not found. Either it is wrong, or the page re-rendered the element away (SPAs swap DOM nodes on every state change, so a selector that worked a second ago can return nothing now). Take a fresh `snapshot`/`find` immediately before acting and retry; if it keeps vanishing, `wait-for` it first.";
4961
5214
  case "navigation-failed":
4962
5215
  return "Navigation failed. The URL may be unreachable, blocked, or require authentication.";
4963
5216
  case "captcha-unsolvable":
4964
- return "Automatic reCAPTCHA solving failed. The challenge may require human intervention.";
5217
+ return "Automatic captcha solving failed. Ask a person to solve it in a binary-headful window, then continue from the same page.";
4965
5218
  case "timeout":
4966
5219
  return "Operation timed out. The page may be slow or the element may not appear.";
4967
5220
  default:
@@ -4975,6 +5228,9 @@ var PipelineRunner = class {
4975
5228
  }
4976
5229
  ctx;
4977
5230
  hooks;
5231
+ /** Live progress, readable while run() is still pending — lets an outer
5232
+ * watchdog report WHICH step hung and what already finished. */
5233
+ progress = { stepIndex: -1, results: [] };
4978
5234
  async run(initialPage, pipeline) {
4979
5235
  const tracker = new TabTracker(initialPage.context(), initialPage);
4980
5236
  try {
@@ -4992,6 +5248,7 @@ var PipelineRunner = class {
4992
5248
  const continueOnError = opts.continueOnError ?? false;
4993
5249
  const results = [];
4994
5250
  const obstacles = [];
5251
+ this.progress.results = results;
4995
5252
  const navStep = pipeline.steps.find((s) => s.type === "navigate");
4996
5253
  try {
4997
5254
  const host = new URL(navStep?.url || safePageUrl(initialPage) || "http://x").hostname;
@@ -5009,12 +5266,18 @@ var PipelineRunner = class {
5009
5266
  if (capture) capture.start();
5010
5267
  const finishCapture = async () => {
5011
5268
  if (!capture) return void 0;
5269
+ await capture.drain(TIMING.CAPTURE_SETTLE, TIMING.CAPTURE_PENDING_MAX);
5012
5270
  capture.stop();
5271
+ for (const se of capture.getServerErrors()) {
5272
+ const r = results.find((x) => x.stepIndex === se.stepIndex);
5273
+ if (r) (r.serverErrors ??= []).push(se);
5274
+ }
5013
5275
  return capture.getResults();
5014
5276
  };
5015
5277
  for (let i = 0; i < pipeline.steps.length; i++) {
5016
5278
  if (capture) capture.setStep(i);
5017
5279
  const step = pipeline.steps[i];
5280
+ this.progress.stepIndex = i;
5018
5281
  const page = tracker.active();
5019
5282
  const urlBefore = safePageUrl(page);
5020
5283
  const monitor = new StaleStateMonitor(page, staleTimeoutMs);
@@ -5034,9 +5297,10 @@ var PipelineRunner = class {
5034
5297
  throw err;
5035
5298
  }
5036
5299
  const asError = err instanceof Error ? err : new Error(String(err));
5037
- const errorType = classifyError(asError, step);
5300
+ const refined = await refineError(tracker.active(), step, this.ctx, classifyError(asError, step), asError.message);
5301
+ const errorType = refined.errorType;
5038
5302
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
5039
- stepResult = failedStepResult(step, asError.message, Date.now() - startTime, i);
5303
+ stepResult = failedStepResult(step, refined.message, Date.now() - startTime, i);
5040
5304
  results.push(stepResult);
5041
5305
  recordAnchor(step, false);
5042
5306
  return {
@@ -5050,9 +5314,9 @@ var PipelineRunner = class {
5050
5314
  failedAtStep: i,
5051
5315
  failedStep: step,
5052
5316
  errorType,
5053
- message: asError.message,
5317
+ message: refined.message,
5054
5318
  pageState,
5055
- suggestion: getSuggestion(errorType, step),
5319
+ suggestion: getSuggestion(errorType, step, refined.message),
5056
5320
  retryable: isRetryable(errorType)
5057
5321
  },
5058
5322
  durationMs: Date.now() - startTime,
@@ -5086,8 +5350,16 @@ var PipelineRunner = class {
5086
5350
  }
5087
5351
  results.push(stepResult);
5088
5352
  if (!stepResult.ok && !continueOnError) {
5353
+ const refined = await refineError(
5354
+ tracker.active(),
5355
+ step,
5356
+ this.ctx,
5357
+ classifyError(new Error(stepResult.error || ""), step),
5358
+ stepResult.error || "Step failed"
5359
+ );
5360
+ stepResult.error = refined.message;
5361
+ const errorType = refined.errorType;
5089
5362
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
5090
- const errorType = classifyError(new Error(stepResult.error || ""), step);
5091
5363
  return {
5092
5364
  ok: false,
5093
5365
  completedSteps: i,
@@ -5101,7 +5373,7 @@ var PipelineRunner = class {
5101
5373
  errorType,
5102
5374
  message: stepResult.error || "Step failed",
5103
5375
  pageState,
5104
- suggestion: getSuggestion(errorType, step),
5376
+ suggestion: getSuggestion(errorType, step, refined.message),
5105
5377
  retryable: isRetryable(errorType)
5106
5378
  },
5107
5379
  durationMs: Date.now() - startTime,
@@ -5111,7 +5383,7 @@ var PipelineRunner = class {
5111
5383
  if (step.type === "navigate" && continueOnObstacle) {
5112
5384
  const obstacleStart = Date.now();
5113
5385
  const obstaclePage = tracker.active();
5114
- const obstacle = await detectObstacles(obstaclePage);
5386
+ const obstacle = await withBudget(detectObstacles(obstaclePage), 1e4, "obstacle check").catch(() => null);
5115
5387
  if (obstacle) {
5116
5388
  const resolution = await resolveObstacle(obstaclePage, obstacle, this.ctx, monitor);
5117
5389
  obstacles.push({
@@ -5161,11 +5433,8 @@ var PipelineRunner = class {
5161
5433
  }
5162
5434
  };
5163
5435
 
5164
- // src/lib/execution/pipeline-executor.ts
5165
- init_persistence();
5166
-
5167
5436
  // src/lib/block-detection.ts
5168
- var log16 = createLogger("block-detection");
5437
+ var log15 = createLogger("block-detection");
5169
5438
  async function detectBlock(page) {
5170
5439
  try {
5171
5440
  const [title, url, bodyText] = await Promise.all([
@@ -5185,7 +5454,7 @@ async function detectBlock(page) {
5185
5454
  const hasCfChallenge = await page.evaluate(() => {
5186
5455
  return !!document.querySelector('iframe[src*="challenges.cloudflare.com"]');
5187
5456
  }).catch((err) => {
5188
- log16.warn(`CF challenge check failed, assuming blocked: ${err}`);
5457
+ log15.warn(`CF challenge check failed, assuming blocked: ${err}`);
5189
5458
  return true;
5190
5459
  });
5191
5460
  if (hasCfChallenge) {
@@ -5204,7 +5473,7 @@ async function detectBlock(page) {
5204
5473
  const hasCaptchaIframe = await page.evaluate(() => {
5205
5474
  return !!(document.querySelector('iframe[src*="recaptcha"]') || document.querySelector('iframe[src*="hcaptcha"]'));
5206
5475
  }).catch((err) => {
5207
- log16.warn(`captcha iframe check failed, assuming blocked: ${err}`);
5476
+ log15.warn(`captcha iframe check failed, assuming blocked: ${err}`);
5208
5477
  return true;
5209
5478
  });
5210
5479
  if (hasCaptchaIframe) {
@@ -5218,7 +5487,7 @@ async function detectBlock(page) {
5218
5487
  }
5219
5488
  return { blocked: false };
5220
5489
  } catch (err) {
5221
- log16.warn(`page evaluation failed, assuming blocked: ${err}`);
5490
+ log15.warn(`page evaluation failed, assuming blocked: ${err}`);
5222
5491
  return { blocked: true, reason: "evaluation-failed" };
5223
5492
  }
5224
5493
  }
@@ -5306,14 +5575,14 @@ function extractKnowledgeFromRun(pipeline, result, sessionData, mode) {
5306
5575
  // src/lib/extension/cdp-relay.ts
5307
5576
  var import_http = __toESM(require("http"));
5308
5577
  var import_ws2 = require("ws");
5309
- var import_crypto7 = require("crypto");
5578
+ var import_crypto8 = require("crypto");
5310
5579
 
5311
5580
  // src/lib/extension/bridge.ts
5312
5581
  var import_ws = require("ws");
5313
- var import_crypto5 = __toESM(require("crypto"));
5582
+ var import_crypto6 = __toESM(require("crypto"));
5314
5583
 
5315
5584
  // src/lib/version.ts
5316
- var import_fs11 = __toESM(require("fs"));
5585
+ var import_fs9 = __toESM(require("fs"));
5317
5586
  var import_path10 = __toESM(require("path"));
5318
5587
  var cached = null;
5319
5588
  function getVersion() {
@@ -5328,7 +5597,7 @@ function getVersion() {
5328
5597
  ];
5329
5598
  for (const p of candidates) {
5330
5599
  try {
5331
- const v = JSON.parse(import_fs11.default.readFileSync(p, "utf8")).version;
5600
+ const v = JSON.parse(import_fs9.default.readFileSync(p, "utf8")).version;
5332
5601
  if (v) return cached = v;
5333
5602
  } catch {
5334
5603
  }
@@ -5339,8 +5608,12 @@ function getVersion() {
5339
5608
  // src/lib/extension/bridge.ts
5340
5609
  var REQUEST_TIMEOUT_MS = 18e4;
5341
5610
  var HEARTBEAT_MS = 15e3;
5611
+ var CONNECT_GRACE_MS = 35e3;
5612
+ var NOT_CONNECTED_MESSAGE = "No iframer extension is connected. Open Chrome and make sure the iframer extension is enabled (setup: `iframer install extension chrome`). It pairs by itself; the popup says why if it can't.";
5342
5613
  var ExtensionBridge = class {
5343
5614
  wss = null;
5615
+ attachedAt = 0;
5616
+ clientWaiters = [];
5344
5617
  clients = /* @__PURE__ */ new Map();
5345
5618
  pending = /* @__PURE__ */ new Map();
5346
5619
  nextReqId = 1;
@@ -5358,21 +5631,17 @@ var ExtensionBridge = class {
5358
5631
  /** Attach the WS server to the already-listening HTTP server. Idempotent. */
5359
5632
  attach(server2) {
5360
5633
  if (this.wss) return;
5634
+ this.attachedAt = Date.now();
5361
5635
  this.wss = new import_ws.WebSocketServer({ server: server2, path: "/extension/ws" });
5362
5636
  this.wss.on("connection", (ws, req) => {
5363
- let expected = "";
5364
- try {
5365
- expected = getLocalToken();
5366
- } catch {
5367
- expected = "";
5368
- }
5369
- if (!expected) {
5637
+ const accepted = getPairingTokens();
5638
+ if (accepted.length === 0) {
5370
5639
  ws.close(4001, "unauthorized");
5371
5640
  return;
5372
5641
  }
5373
5642
  const queryToken = new URL(req.url || "", "http://127.0.0.1").searchParams.get("token");
5374
5643
  if (queryToken !== null) {
5375
- if (queryToken !== expected) {
5644
+ if (!accepted.includes(queryToken)) {
5376
5645
  ws.close(4001, "unauthorized");
5377
5646
  return;
5378
5647
  }
@@ -5384,7 +5653,7 @@ var ExtensionBridge = class {
5384
5653
  clearTimeout(timer);
5385
5654
  try {
5386
5655
  const m = JSON.parse(data.toString());
5387
- if (m?.type === "auth" && m.token === expected) {
5656
+ if (m?.type === "auth" && typeof m.token === "string" && accepted.includes(m.token)) {
5388
5657
  this.acceptClient(ws);
5389
5658
  return;
5390
5659
  }
@@ -5396,7 +5665,7 @@ var ExtensionBridge = class {
5396
5665
  }
5397
5666
  acceptClient(ws) {
5398
5667
  const client = {
5399
- clientId: import_crypto5.default.randomUUID(),
5668
+ clientId: import_crypto6.default.randomUUID(),
5400
5669
  socket: ws,
5401
5670
  connectedAt: Date.now(),
5402
5671
  tabs: [],
@@ -5404,6 +5673,7 @@ var ExtensionBridge = class {
5404
5673
  };
5405
5674
  this.clients.set(client.clientId, client);
5406
5675
  this.startHeartbeat(client);
5676
+ for (const wake of this.clientWaiters.splice(0)) wake();
5407
5677
  try {
5408
5678
  ws.send(JSON.stringify({ type: "server_info", version: getVersion() }));
5409
5679
  } catch {
@@ -5499,6 +5769,22 @@ var ExtensionBridge = class {
5499
5769
  hasClients() {
5500
5770
  return this.clients.size > 0;
5501
5771
  }
5772
+ /** Resolves once a client is connected, or when the startup grace window ends.
5773
+ * After that window it returns at once, so a machine with no extension still
5774
+ * gets its "not connected" answer immediately. */
5775
+ async waitForClient() {
5776
+ if (this.clients.size > 0) return;
5777
+ const left = this.attachedAt + CONNECT_GRACE_MS - Date.now();
5778
+ if (left <= 0) return;
5779
+ await new Promise((resolve) => {
5780
+ const timer = setTimeout(resolve, left);
5781
+ timer.unref?.();
5782
+ this.clientWaiters.push(() => {
5783
+ clearTimeout(timer);
5784
+ resolve();
5785
+ });
5786
+ });
5787
+ }
5502
5788
  status() {
5503
5789
  return {
5504
5790
  connected: this.clients.size > 0,
@@ -5515,6 +5801,7 @@ var ExtensionBridge = class {
5515
5801
  /** List tabs across ALL connected clients, tagged with profile/client, and
5516
5802
  * refresh the tabId → client ownership map used for routing. */
5517
5803
  async listTabs() {
5804
+ await this.waitForClient();
5518
5805
  const all = [];
5519
5806
  this.tabOwner.clear();
5520
5807
  this.collidingTabs.clear();
@@ -5550,10 +5837,9 @@ var ExtensionBridge = class {
5550
5837
  if (!c) throw new Error(`No connected extension with clientId ${clientId}.`);
5551
5838
  return c;
5552
5839
  }
5840
+ await this.waitForClient();
5553
5841
  if (this.clients.size === 0) {
5554
- throw new Error(
5555
- "No iframer extension is connected. Open Chrome, install/enable the iframer extension, and pair it (paste the token, dot goes green)."
5556
- );
5842
+ throw new Error(NOT_CONNECTED_MESSAGE);
5557
5843
  }
5558
5844
  if (this.clients.size === 1) {
5559
5845
  return [...this.clients.values()][0];
@@ -5578,16 +5864,15 @@ var ExtensionBridge = class {
5578
5864
  }
5579
5865
  /** Pick a client for a browser-level op that has no tabId (create tab, etc.).
5580
5866
  * Explicit clientId wins; otherwise require exactly one connected profile. */
5581
- resolveClientNoTab(clientId) {
5867
+ async resolveClientNoTab(clientId) {
5582
5868
  if (clientId) {
5583
5869
  const c = this.clients.get(clientId);
5584
5870
  if (!c) throw new Error(`No connected extension with clientId ${clientId}.`);
5585
5871
  return c;
5586
5872
  }
5873
+ await this.waitForClient();
5587
5874
  if (this.clients.size === 0) {
5588
- throw new Error(
5589
- "No iframer extension is connected. Open Chrome, install/enable the iframer extension, and pair it (paste the token, dot goes green)."
5590
- );
5875
+ throw new Error(NOT_CONNECTED_MESSAGE);
5591
5876
  }
5592
5877
  if (this.clients.size === 1) return [...this.clients.values()][0];
5593
5878
  throw new Error(
@@ -5605,7 +5890,7 @@ var ExtensionBridge = class {
5605
5890
  }
5606
5891
  /** Group tabs into a native Chrome tab group. */
5607
5892
  async groupTabs(tabIds, opts = {}, clientId) {
5608
- const client = clientId ? this.resolveClientNoTab(clientId) : await this.resolveClient(tabIds[0]);
5893
+ const client = clientId ? await this.resolveClientNoTab(clientId) : await this.resolveClient(tabIds[0]);
5609
5894
  return this.send(client, "group_tabs", {
5610
5895
  tabIds,
5611
5896
  title: opts.title,
@@ -5616,22 +5901,22 @@ var ExtensionBridge = class {
5616
5901
  }
5617
5902
  /** Remove tabs from their group (chrome.tabs.ungroup). */
5618
5903
  async ungroupTabs(tabIds, clientId) {
5619
- const client = clientId ? this.resolveClientNoTab(clientId) : await this.resolveClient(tabIds[0]);
5904
+ const client = clientId ? await this.resolveClientNoTab(clientId) : await this.resolveClient(tabIds[0]);
5620
5905
  return this.send(client, "ungroup_tabs", { tabIds });
5621
5906
  }
5622
5907
  /** Rename / recolor / collapse an existing group by id. */
5623
5908
  async updateGroup(groupId, opts, clientId) {
5624
- const client = this.resolveClientNoTab(clientId);
5909
+ const client = await this.resolveClientNoTab(clientId);
5625
5910
  return this.send(client, "update_group", { groupId, ...opts });
5626
5911
  }
5627
5912
  /** List all tab groups across the browser. */
5628
5913
  async listGroups(clientId) {
5629
- const client = this.resolveClientNoTab(clientId);
5914
+ const client = await this.resolveClientNoTab(clientId);
5630
5915
  return this.send(client, "list_groups", {});
5631
5916
  }
5632
5917
  /** Open a new tab in the user's real Chrome via chrome.tabs.create. */
5633
5918
  async createTab(url, opts = {}, clientId) {
5634
- const client = this.resolveClientNoTab(clientId);
5919
+ const client = await this.resolveClientNoTab(clientId);
5635
5920
  const res = await this.send(client, "create_tab", {
5636
5921
  url,
5637
5922
  active: opts.active,
@@ -5679,7 +5964,7 @@ function cdpKey(clientId, tabId) {
5679
5964
  var extensionBridge = new ExtensionBridge();
5680
5965
 
5681
5966
  // src/lib/extension/cdp-relay.ts
5682
- var log17 = createLogger("cdp-relay");
5967
+ var log16 = createLogger("cdp-relay");
5683
5968
  var CdpRelay = class {
5684
5969
  constructor(tabId, clientId, focus) {
5685
5970
  this.tabId = tabId;
@@ -5693,7 +5978,7 @@ var CdpRelay = class {
5693
5978
  wss = null;
5694
5979
  pw = null;
5695
5980
  port = 0;
5696
- path = `/cdp/${(0, import_crypto7.randomUUID)()}`;
5981
+ path = `/cdp/${(0, import_crypto8.randomUUID)()}`;
5697
5982
  tabSessionId = "pw-tab-1";
5698
5983
  targetInfo = null;
5699
5984
  ownerClientId = "";
@@ -5739,11 +6024,11 @@ var CdpRelay = class {
5739
6024
  res.end();
5740
6025
  });
5741
6026
  this.httpServer.on("upgrade", (req) => {
5742
- if (process.env.IFRAMER_RELAY_DEBUG) log17.info(`[relay] upgrade request url=${req.url}`);
6027
+ if (process.env.IFRAMER_RELAY_DEBUG) log16.info(`[relay] upgrade request url=${req.url}`);
5743
6028
  });
5744
6029
  this.wss = new import_ws2.WebSocketServer({ server: this.httpServer, path: this.path });
5745
6030
  this.wss.on("connection", (ws) => {
5746
- if (process.env.IFRAMER_RELAY_DEBUG) log17.info(`[relay] playwright connected`);
6031
+ if (process.env.IFRAMER_RELAY_DEBUG) log16.info(`[relay] playwright connected`);
5747
6032
  if (this.pw) {
5748
6033
  ws.close(4e3, "relay already has a client");
5749
6034
  return;
@@ -5782,7 +6067,7 @@ var CdpRelay = class {
5782
6067
  }
5783
6068
  }
5784
6069
  async onPwMessage(data) {
5785
- if (process.env.IFRAMER_RELAY_DEBUG) log17.info(`[relay] raw pw msg (${data?.length ?? 0} bytes): ${data?.toString().slice(0, 120)}`);
6070
+ if (process.env.IFRAMER_RELAY_DEBUG) log16.info(`[relay] raw pw msg (${data?.length ?? 0} bytes): ${data?.toString().slice(0, 120)}`);
5786
6071
  let msg;
5787
6072
  try {
5788
6073
  msg = JSON.parse(data.toString());
@@ -5790,7 +6075,7 @@ var CdpRelay = class {
5790
6075
  return;
5791
6076
  }
5792
6077
  const { id, sessionId, method, params } = msg;
5793
- if (process.env.IFRAMER_RELAY_DEBUG) log17.info(`[relay] pw\u2192 ${method} (id=${id}, sess=${sessionId || "-"})`);
6078
+ if (process.env.IFRAMER_RELAY_DEBUG) log16.info(`[relay] pw\u2192 ${method} (id=${id}, sess=${sessionId || "-"})`);
5794
6079
  if (!method) return;
5795
6080
  try {
5796
6081
  const result = await this.handleCdpCommand(method, params, sessionId);
@@ -5852,7 +6137,7 @@ var CdpRelay = class {
5852
6137
  return await Promise.race([
5853
6138
  first,
5854
6139
  new Promise((_, reject) => {
5855
- const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 1e4);
6140
+ const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 5e3);
5856
6141
  t.unref?.();
5857
6142
  })
5858
6143
  ]);
@@ -5903,14 +6188,32 @@ var CdpRelay = class {
5903
6188
  try {
5904
6189
  await extensionBridge.cdpDetach(this.ownerClientId, this.tabId);
5905
6190
  } catch (e) {
5906
- log17.warn(`cdp detach failed: ${e}`);
6191
+ log16.warn(`cdp detach failed: ${e}`);
5907
6192
  }
5908
6193
  }
5909
6194
  }
5910
6195
  };
5911
6196
 
5912
6197
  // src/lib/execution/pipeline-executor.ts
5913
- var log18 = createLogger("iframer");
6198
+ var log17 = createLogger("iframer");
6199
+ function blankPipelineResult(pipeline, modeUsed, error) {
6200
+ return {
6201
+ ok: false,
6202
+ completedSteps: 0,
6203
+ totalSteps: pipeline.steps.length,
6204
+ results: [],
6205
+ obstacles: [],
6206
+ error: {
6207
+ failedAtStep: 0,
6208
+ failedStep: pipeline.steps[0],
6209
+ pageState: { url: "", title: "" },
6210
+ ...error
6211
+ },
6212
+ durationMs: 0,
6213
+ modeUsed,
6214
+ finalState: { url: "", title: "" }
6215
+ };
6216
+ }
5914
6217
  var PipelineExecutor = class {
5915
6218
  constructor(deps) {
5916
6219
  this.deps = deps;
@@ -5948,8 +6251,24 @@ var PipelineExecutor = class {
5948
6251
  const forcedMode = opts.mode;
5949
6252
  const autoEscalate = opts.autoEscalate !== false;
5950
6253
  const instanceId = opts.instanceId || DEFAULT_INSTANCE;
6254
+ if (forcedMode === "extension") {
6255
+ return blankPipelineResult(pipeline, "extension", {
6256
+ errorType: "action-failed",
6257
+ message: 'mode="extension" requires options.tabId (the real-Chrome tab to drive).',
6258
+ suggestion: "Call the `tabs` tool (or GET /extension/tabs) to list open tabs, then pass that tab id. Nothing was launched.",
6259
+ retryable: false
6260
+ });
6261
+ }
5951
6262
  const firstNav = pipeline.steps.find((s) => s.type === "navigate");
5952
6263
  const domain = firstNav ? new URL(firstNav.url).hostname : null;
6264
+ if (!firstNav && !this.deps.daemon.findLiveMode(instanceId)) {
6265
+ return blankPipelineResult(pipeline, forcedMode || "headless", {
6266
+ errorType: "action-failed",
6267
+ message: `No navigate step and no live browser for instanceId "${instanceId}" to act on.`,
6268
+ suggestion: "Add a navigate step, or reattach to a live window (check `status` / `iframer instances` for instanceId). To drive a real Chrome tab, pass options.tabId from the `tabs` tool. Nothing was launched.",
6269
+ retryable: false
6270
+ });
6271
+ }
5953
6272
  const availableModes = this.deps.availableModes();
5954
6273
  const liveMode = forcedMode ? null : this.deps.daemon.findLiveMode(instanceId);
5955
6274
  let mode;
@@ -5969,10 +6288,8 @@ var PipelineExecutor = class {
5969
6288
  if (domain) this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
5970
6289
  const nextMode = this.deps.domainModes.getNextMode(failedMode, availableModes);
5971
6290
  if (nextMode) {
5972
- log18.info(`Auto-escalating from ${failedMode} to ${nextMode} for ${domain}`);
5973
- if (failedMode !== "docker-headful") {
5974
- await this.deps.daemon.stopMode(failedMode, instanceId);
5975
- }
6291
+ log17.info(`Auto-escalating from ${failedMode} to ${nextMode} for ${domain}`);
6292
+ await this.deps.daemon.stopMode(failedMode, instanceId);
5976
6293
  result = await this.executeWithMode(userId, token, pipeline, nextMode, instanceId);
5977
6294
  result.modeEscalated = true;
5978
6295
  result.modeUsed = nextMode;
@@ -5982,10 +6299,8 @@ var PipelineExecutor = class {
5982
6299
  this.deps.domainModes.recordFailure(domain, nextMode, result.error?.message || "blocked");
5983
6300
  const thirdMode = this.deps.domainModes.getNextMode(nextMode, availableModes);
5984
6301
  if (thirdMode) {
5985
- log18.info(`Auto-escalating from ${nextMode} to ${thirdMode} for ${domain}`);
5986
- if (nextMode !== "docker-headful") {
5987
- await this.deps.daemon.stopMode(nextMode, instanceId);
5988
- }
6302
+ log17.info(`Auto-escalating from ${nextMode} to ${thirdMode} for ${domain}`);
6303
+ await this.deps.daemon.stopMode(nextMode, instanceId);
5989
6304
  result = await this.executeWithMode(userId, token, pipeline, thirdMode, instanceId);
5990
6305
  result.modeEscalated = true;
5991
6306
  result.modeUsed = thirdMode;
@@ -6001,9 +6316,6 @@ var PipelineExecutor = class {
6001
6316
  return result;
6002
6317
  }
6003
6318
  async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
6004
- if (mode === "docker-headful") {
6005
- return this.executeDocker(userId, token, pipeline, respectful);
6006
- }
6007
6319
  return this.executeLocal(userId, token, pipeline, mode, instanceId, respectful);
6008
6320
  }
6009
6321
  /** Execute against the user's real Chrome tab via the extension CDP relay,
@@ -6013,6 +6325,7 @@ var PipelineExecutor = class {
6013
6325
  const startTime = Date.now();
6014
6326
  const relay = new CdpRelay(tabId, clientId, pipeline.options?.focus);
6015
6327
  let browser;
6328
+ let runner;
6016
6329
  try {
6017
6330
  await relay.start();
6018
6331
  browser = await import_playwright_core.chromium.connectOverCDP(relay.httpEndpoint(), { timeout: 3e4 });
@@ -6025,7 +6338,8 @@ var PipelineExecutor = class {
6025
6338
  if (!page) throw new Error("no page available for the tab (is it still open?)");
6026
6339
  const ctx = this.deps.refStore.makeContext(userId, token);
6027
6340
  if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
6028
- const runner = new PipelineRunner(ctx);
6341
+ ctx.driver = "extension";
6342
+ runner = new PipelineRunner(ctx);
6029
6343
  const typeChars = pipeline.steps.reduce((n, s) => {
6030
6344
  const v = s.value;
6031
6345
  return (s.type === "human-type" || s.type === "type-code") && typeof v === "string" ? n + v.length : n;
@@ -6042,7 +6356,7 @@ var PipelineExecutor = class {
6042
6356
  watchdog = setTimeout(
6043
6357
  () => reject(
6044
6358
  new Error(
6045
- `pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 the tab may have stopped rendering (minimized window?). Un-minimize the Chrome window or retry with options.focus=true.`
6359
+ `pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 a call into the tab never returned.`
6046
6360
  )
6047
6361
  ),
6048
6362
  capMs
@@ -6059,30 +6373,33 @@ var PipelineExecutor = class {
6059
6373
  try {
6060
6374
  extractKnowledgeFromRun(pipeline, result, null, "extension");
6061
6375
  } catch (e) {
6062
- log18.warn(`knowledge update failed: ${getErrorMessage(e)}`);
6376
+ log17.warn(`knowledge update failed: ${getErrorMessage(e)}`);
6063
6377
  }
6064
6378
  }
6065
6379
  return result;
6066
6380
  } catch (err) {
6067
6381
  const msg = getErrorMessage(err);
6068
6382
  const stalled = msg.includes("pipeline exceeded");
6383
+ const done = runner ? runner.progress.results.filter((r) => r.ok) : [];
6384
+ const hungAt = stalled && runner && runner.progress.stepIndex >= 0 ? runner.progress.stepIndex : 0;
6385
+ const hungStep = pipeline.steps[hungAt];
6069
6386
  return {
6070
6387
  ok: false,
6071
- completedSteps: 0,
6388
+ completedSteps: stalled ? done.length : 0,
6072
6389
  totalSteps: pipeline.steps.length,
6073
- results: [],
6390
+ results: stalled ? done : [],
6074
6391
  finalState: { url: "", title: "" },
6075
6392
  obstacles: [],
6076
6393
  durationMs: Date.now() - startTime,
6077
6394
  modeUsed: "extension",
6078
6395
  error: {
6079
- failedAtStep: 0,
6080
- failedStep: pipeline.steps[0],
6081
- errorType: "action-failed",
6082
- message: `Extension mode failed: ${msg}`,
6396
+ failedAtStep: hungAt,
6397
+ failedStep: hungStep,
6398
+ errorType: stalled ? "stale-state" : "action-failed",
6399
+ message: stalled ? `Extension mode: step ${hungAt} (${hungStep?.type}) never returned; ${msg}` : `Extension mode failed: ${msg}`,
6083
6400
  pageState: { url: "", title: "" },
6084
- suggestion: stalled ? "STOP retrying and tell the user what happened: the Chrome tab being driven stopped responding \u2014 its window is likely minimized or the page is wedged. Ask them to un-minimize the Chrome window (leaving it behind other windows is fine), or ask permission to rerun with options.focus=true to bring it to the front." : "Ensure the iframer extension is connected (green dot) and the tab is still open. See chrome://extensions.",
6085
- retryable: !stalled
6401
+ suggestion: stalled ? `Step ${hungAt} (${hungStep?.type}) hung inside the tab. Steps before it completed. Run a single \`read\` or \`snapshot\` on its own to check the tab recovered (it usually does), then continue from that step in its own execute call. Don't repeat the same step unchanged: for a route change use \`evaluate\` with \`location.assign(url)\`, for a click use a fresh \`@e\` ref from \`find\`. If the tab stays unresponsive, tell the user.` : "Ensure the iframer extension is connected (green dot) and the tab is still open. See chrome://extensions.",
6402
+ retryable: stalled
6086
6403
  }
6087
6404
  };
6088
6405
  } finally {
@@ -6096,42 +6413,6 @@ var PipelineExecutor = class {
6096
6413
  }
6097
6414
  }
6098
6415
  }
6099
- /** Execute via Docker session-manager. */
6100
- async executeDocker(userId, token, pipeline, respectful = false) {
6101
- let session = getSession(userId);
6102
- if (!session) {
6103
- const firstNav = pipeline.steps.find((s) => s.type === "navigate");
6104
- await this.deps.startSession(userId, token, firstNav ? { url: firstNav.url } : {});
6105
- session = getSession(userId);
6106
- }
6107
- resetTimeout(userId);
6108
- const ctx = this.deps.refStore.makeContext(userId, token);
6109
- const runner = new PipelineRunner(ctx, protectionHooks(false, respectful));
6110
- const result = await runner.run(session.page, pipeline);
6111
- if (result.ok) {
6112
- const blockResult = await detectBlock(session.page);
6113
- if (blockResult.blocked) {
6114
- const pageState = await capturePageState(session.page, ctx, { screenshot: true, namePrefix: "block" });
6115
- return {
6116
- ...result,
6117
- ok: false,
6118
- modeUsed: "docker-headful",
6119
- error: {
6120
- failedAtStep: result.completedSteps - 1,
6121
- failedStep: pipeline.steps[result.completedSteps - 1],
6122
- errorType: "bot-blocked",
6123
- message: `Page blocked by bot detection: ${blockResult.reason}`,
6124
- pageState,
6125
- suggestion: "The page is blocked by bot detection. Try a different browser mode.",
6126
- retryable: true
6127
- }
6128
- };
6129
- }
6130
- }
6131
- this.deps.refStore.sync(userId, ctx);
6132
- result.modeUsed = "docker-headful";
6133
- return result;
6134
- }
6135
6416
  /** Execute via local Chrome daemon (headless + binary-headful). */
6136
6417
  async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
6137
6418
  const startTime = Date.now();
@@ -6165,7 +6446,7 @@ var PipelineExecutor = class {
6165
6446
  acquired = false;
6166
6447
  await this.deps.daemon.stopMode(mode, instanceId);
6167
6448
  const available = this.deps.availableModes();
6168
- const headed = available.includes("binary-headful") ? "binary-headful" : "docker-headful";
6449
+ const headed = "binary-headful";
6169
6450
  if (!available.includes(headed)) throw new Error("A headed browser is required but unavailable.");
6170
6451
  await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
6171
6452
  const index = err.stepIndex;
@@ -6201,7 +6482,7 @@ var PipelineExecutor = class {
6201
6482
  errorType: "bot-blocked",
6202
6483
  message: `Page blocked by bot detection: ${blockResult.reason}`,
6203
6484
  pageState,
6204
- suggestion: `The page was blocked in ${mode} mode. ${mode === "headless" ? "Try docker-headful mode." : mode === "docker-headful" ? "Try binary-headful mode." : "All modes exhausted."}`,
6485
+ suggestion: `The page was blocked in ${mode} mode. ${mode === "headless" ? "Try binary-headful mode." : "All modes exhausted."}`,
6205
6486
  retryable: true
6206
6487
  }
6207
6488
  };
@@ -6220,7 +6501,7 @@ var PipelineExecutor = class {
6220
6501
  try {
6221
6502
  extractKnowledgeFromRun(pipeline, result, updatedSession, mode);
6222
6503
  } catch (err) {
6223
- log18.warn(`knowledge update failed: ${getErrorMessage(err)}`);
6504
+ log17.warn(`knowledge update failed: ${getErrorMessage(err)}`);
6224
6505
  }
6225
6506
  }
6226
6507
  this.deps.refStore.sync(userId, ctx);
@@ -6253,7 +6534,6 @@ var PipelineExecutor = class {
6253
6534
  };
6254
6535
 
6255
6536
  // src/lib/execution/fetch-service.ts
6256
- init_persistence();
6257
6537
  var FetchService = class {
6258
6538
  constructor(store) {
6259
6539
  this.store = store;
@@ -6422,12 +6702,10 @@ var CaptureManager = class {
6422
6702
 
6423
6703
  // src/lib/auth/credential-store.ts
6424
6704
  var CredentialStore = class {
6425
- constructor(store, config) {
6705
+ constructor(store) {
6426
6706
  this.store = store;
6427
- this.config = config;
6428
6707
  }
6429
6708
  store;
6430
- config;
6431
6709
  async storeCredential(userId, token, credential) {
6432
6710
  const credKey = await deriveKey(token, "credentials");
6433
6711
  const normalizedDomain = normalizeDomain(credential.domain);
@@ -6450,53 +6728,16 @@ var CredentialStore = class {
6450
6728
  async deleteCredential(userId, domain) {
6451
6729
  await this.store.deleteCredential(userId, normalizeDomain(domain));
6452
6730
  }
6453
- async loginWithCredentials(userId, token, domain, selectors) {
6454
- const session = getSession(userId);
6455
- if (!session) return { ok: false, url: "", title: "", error: "No active interactive session. Start one first." };
6456
- const resolved = await resolveCredential(this.store, userId, token, domain);
6457
- if (!resolved) {
6458
- const stored = await this.store.listCredentialDomains(userId);
6459
- return { ok: false, url: "", title: "", error: `No credentials stored for ${normalizeDomain(domain)}. Stored: ${stored.join(", ") || "(none)"}` };
6460
- }
6461
- const { credential } = resolved;
6462
- const page = session.page;
6463
- resetTimeout(userId);
6464
- if (selectors.username && credential.username) {
6465
- await humanType(page, selectors.username, credential.username);
6466
- await page.waitForTimeout(TIMING.PRE_NAVIGATE[0] + Math.random() * (TIMING.PRE_NAVIGATE[1] - TIMING.PRE_NAVIGATE[0]));
6467
- }
6468
- if (selectors.password && credential.password) {
6469
- await humanType(page, selectors.password, credential.password);
6470
- await page.waitForTimeout(TIMING.PRE_NAVIGATE[0] + Math.random() * (TIMING.PRE_NAVIGATE[1] - TIMING.PRE_NAVIGATE[0]));
6471
- }
6472
- if (selectors.submit) {
6473
- await humanClick(page, selectors.submit);
6474
- await page.waitForLoadState("domcontentloaded").catch(() => {
6475
- });
6476
- await page.waitForTimeout(TIMING.POST_LOGIN_WAIT);
6477
- }
6478
- if (selectors.totp && credential.totp_secret) {
6479
- const totp = generateTOTP(credential.totp_secret);
6480
- await page.click(selectors.totp);
6481
- await page.keyboard.type(totp, { delay: 50 });
6482
- await page.waitForTimeout(TIMING.POST_TOTP_WAIT);
6483
- }
6484
- const buf = await page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
6485
- const screenshotUrl = saveScreenshot(buf, `login-${Date.now()}.jpg`, this.config.screenshotDir, this.config.publicUrl);
6486
- return { ok: true, url: page.url(), title: await page.title(), screenshotUrl };
6487
- }
6488
6731
  };
6489
6732
 
6490
6733
  // src/lib/iframer.ts
6491
- var log19 = createLogger("iframer");
6492
- var DEFAULT_SCREENSHOT_DIR = import_path11.default.join(import_path11.default.dirname((0, import_url.fileURLToPath)(importMetaUrl)), "../../.screenshots");
6493
- var DEFAULT_PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3021}`;
6734
+ var log18 = createLogger("iframer");
6735
+ var DEFAULT_PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3022}`;
6494
6736
  var DEFAULT_STALE_TIMEOUT_MS3 = 2e4;
6495
6737
  var Iframer = class {
6496
6738
  store;
6497
6739
  daemon;
6498
6740
  domainModes;
6499
- operatingMode;
6500
6741
  config;
6501
6742
  refStore;
6502
6743
  executor;
@@ -6505,25 +6746,23 @@ var Iframer = class {
6505
6746
  credentials;
6506
6747
  constructor(config = {}) {
6507
6748
  this.config = {
6508
- screenshotDir: config.screenshotDir || DEFAULT_SCREENSHOT_DIR,
6749
+ screenshotDir: config.screenshotDir || getScreenshotDir(),
6509
6750
  publicUrl: config.publicUrl || DEFAULT_PUBLIC_URL,
6510
6751
  staleTimeoutMs: config.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS3
6511
6752
  };
6512
6753
  this.store = createStore({ dataDir: config.dataDir });
6513
6754
  this.daemon = new BrowserDaemon(config.sessionTimeoutMs);
6514
6755
  this.domainModes = new DomainModeStore();
6515
- this.operatingMode = config.mode || "local";
6516
6756
  this.refStore = new RefStore(this.store, this.config);
6517
6757
  this.fetchService = new FetchService(this.store);
6518
6758
  this.captureManager = new CaptureManager(this.daemon);
6519
- this.credentials = new CredentialStore(this.store, this.config);
6759
+ this.credentials = new CredentialStore(this.store);
6520
6760
  this.executor = new PipelineExecutor({
6521
6761
  daemon: this.daemon,
6522
6762
  store: this.store,
6523
6763
  domainModes: this.domainModes,
6524
6764
  refStore: this.refStore,
6525
- availableModes: () => this.getAvailableModes(),
6526
- startSession: (userId, token, options) => this.startSession(userId, token, options)
6765
+ availableModes: () => this.getAvailableModes()
6527
6766
  });
6528
6767
  }
6529
6768
  // ─── Mode Detection ─────────────────────────────────────────────────
@@ -6531,7 +6770,6 @@ var Iframer = class {
6531
6770
  const modes = ["headless"];
6532
6771
  const { binaryHeadful } = checkModeAvailability();
6533
6772
  if (binaryHeadful) modes.push("binary-headful");
6534
- if (this.operatingMode === "docker") modes.push("docker-headful");
6535
6773
  return modes;
6536
6774
  }
6537
6775
  async getModeAvailability() {
@@ -6541,10 +6779,6 @@ var Iframer = class {
6541
6779
  "binary-headful": {
6542
6780
  available: binaryHeadful,
6543
6781
  reason: binaryHeadful ? void 0 : "No display available"
6544
- },
6545
- "docker-headful": {
6546
- available: this.operatingMode === "docker",
6547
- reason: this.operatingMode === "docker" ? void 0 : "Docker not configured"
6548
6782
  }
6549
6783
  };
6550
6784
  }
@@ -6556,36 +6790,7 @@ var Iframer = class {
6556
6790
  execute(userId, token, pipeline, runtime) {
6557
6791
  return this.executor.execute(userId, token, pipeline, runtime);
6558
6792
  }
6559
- // ─── Interactive Sessions (Docker mode) ──────────────────────────
6560
- async startSession(userId, token, options = {}) {
6561
- const existing = getSession(userId);
6562
- if (existing) {
6563
- resetTimeout(userId);
6564
- return {
6565
- noVncUrl: `http://localhost:${existing.wsPort}/vnc.html?autoconnect=true`,
6566
- wsPort: existing.wsPort
6567
- };
6568
- }
6569
- const session = await startSession(userId);
6570
- const encryptionKey = await deriveKey(token);
6571
- const blob = await this.store.getSession(userId);
6572
- let sessionData = null;
6573
- if (blob && blob.length > 0) {
6574
- sessionData = JSON.parse(decrypt(blob, encryptionKey));
6575
- if (sessionData) await injectCookies(session.context, sessionData);
6576
- }
6577
- if (options.url) {
6578
- await session.page.goto(options.url, { waitUntil: "domcontentloaded", timeout: TIMEOUTS.NAVIGATION });
6579
- if (sessionData) await injectStorage(session.page, sessionData);
6580
- }
6581
- return {
6582
- noVncUrl: `http://localhost:${session.wsPort}/vnc.html?autoconnect=true`,
6583
- wsPort: session.wsPort
6584
- };
6585
- }
6586
- getSession(userId) {
6587
- return getSession(userId);
6588
- }
6793
+ // ─── Sessions ────────────────────────────────────────────────────
6589
6794
  async stopSession(userId, token) {
6590
6795
  let sessionSaved = false;
6591
6796
  if (token) {
@@ -6599,17 +6804,10 @@ var Iframer = class {
6599
6804
  sessionSaved = true;
6600
6805
  }
6601
6806
  } catch (err) {
6602
- log19.warn(`stopSession: failed to extract daemon state for ${inst.mode}::${inst.instanceId}: ${getErrorMessage(err)}`);
6807
+ log18.warn(`stopSession: failed to extract daemon state for ${inst.mode}::${inst.instanceId}: ${getErrorMessage(err)}`);
6603
6808
  }
6604
6809
  }
6605
6810
  }
6606
- const dockerSessionData = await stopSession(userId);
6607
- if (dockerSessionData && token) {
6608
- const encryptionKey = await deriveKey(token);
6609
- const encrypted = encrypt(JSON.stringify(dockerSessionData), encryptionKey);
6610
- await this.store.setSession(userId, encrypted);
6611
- sessionSaved = true;
6612
- }
6613
6811
  await this.daemon.stopAll();
6614
6812
  return { ok: true, sessionSaved };
6615
6813
  }
@@ -6637,30 +6835,21 @@ var Iframer = class {
6637
6835
  listInstances() {
6638
6836
  return this.daemon.instancesInfo();
6639
6837
  }
6838
+ /** Close browsers that never navigated anywhere. See
6839
+ * BrowserDaemon.sweepBlankInstances — called from the server's reap tick. */
6840
+ sweepBlankBrowsers() {
6841
+ return this.daemon.sweepBlankInstances();
6842
+ }
6640
6843
  /** Kill all browser instances and reset state. Next execute call will
6641
6844
  * launch a fresh browser automatically — no manual restart needed. */
6642
6845
  async restartBrowser() {
6643
6846
  const health = this.browserHealth();
6644
6847
  await this.daemon.stopAll(true);
6645
- await cleanupAllSessions();
6646
6848
  return {
6647
6849
  killed: health.modes,
6648
6850
  message: health.modes.length > 0 ? `Killed browser(s): ${health.modes.join(", ")}. Next execute call will launch a fresh instance.` : "No browsers were running. Next execute call will launch fresh."
6649
6851
  };
6650
6852
  }
6651
- // ─── Screenshots ─────────────────────────────────────────────────
6652
- async screenshot(userId) {
6653
- const session = getSession(userId);
6654
- if (!session) return null;
6655
- resetTimeout(userId);
6656
- const buf = await session.page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
6657
- const screenshotUrl = saveScreenshot(buf, `screenshot-${Date.now()}.jpg`, this.config.screenshotDir, this.config.publicUrl);
6658
- return {
6659
- screenshotUrl,
6660
- url: session.page.url(),
6661
- title: await session.page.title()
6662
- };
6663
- }
6664
6853
  // ─── Credentials ─────────────────────────────────────────────────
6665
6854
  storeCredential(userId, token, credential) {
6666
6855
  return this.credentials.storeCredential(userId, token, credential);
@@ -6674,9 +6863,6 @@ var Iframer = class {
6674
6863
  deleteCredential(userId, domain) {
6675
6864
  return this.credentials.deleteCredential(userId, domain);
6676
6865
  }
6677
- loginWithCredentials(userId, token, domain, selectors) {
6678
- return this.credentials.loginWithCredentials(userId, token, domain, selectors);
6679
- }
6680
6866
  // ─── Session Data ─────────────────────────────────────────────────
6681
6867
  async clearSession(userId) {
6682
6868
  await this.store.deleteSession(userId);
@@ -6685,7 +6871,6 @@ var Iframer = class {
6685
6871
  async shutdown() {
6686
6872
  await this.daemon.stopAll(true);
6687
6873
  await closeBrowser();
6688
- await cleanupAllSessions();
6689
6874
  if ("close" in this.store && typeof this.store.close === "function") {
6690
6875
  this.store.close();
6691
6876
  }
@@ -6715,20 +6900,18 @@ function errorHandler(err, _req, res, _next) {
6715
6900
  }
6716
6901
 
6717
6902
  // src/api/routes.ts
6718
- var import_fs12 = __toESM(require("fs"));
6903
+ var import_fs10 = __toESM(require("fs"));
6719
6904
  function auth(req) {
6720
6905
  return req;
6721
6906
  }
6722
- var iframer = new Iframer({
6723
- mode: process.env.IFRAMER_MODE || "local"
6724
- });
6907
+ var iframer = new Iframer();
6725
6908
  function registerRoutes(app2) {
6726
6909
  app2.get("/health", (_req, res) => res.json({ ok: true }));
6727
6910
  app2.get("/browsers", async (_req, res) => {
6728
6911
  const browsers = [];
6729
6912
  try {
6730
6913
  const execPath = import_patchright3.chromium.executablePath();
6731
- browsers.push({ name: "chromium", installed: import_fs12.default.existsSync(execPath), executablePath: execPath });
6914
+ browsers.push({ name: "chromium", installed: import_fs10.default.existsSync(execPath), executablePath: execPath });
6732
6915
  } catch {
6733
6916
  browsers.push({ name: "chromium", installed: false, executablePath: null });
6734
6917
  }
@@ -6773,88 +6956,11 @@ function registerRoutes(app2) {
6773
6956
  const result = await iframer.execute(r.userId, r.token, { steps, options });
6774
6957
  res.json(result);
6775
6958
  }));
6776
- app2.post("/interactive/start", asyncHandler(async (req, res) => {
6777
- const { url } = req.body || {};
6778
- const r = auth(req);
6779
- const result = await iframer.startSession(r.userId, r.token, { url });
6780
- const existing = iframer.getSession(r.userId);
6781
- res.json({
6782
- ok: true,
6783
- noVncUrl: result.noVncUrl,
6784
- wsPort: result.wsPort,
6785
- message: existing ? "Session already active" : void 0
6786
- });
6787
- }));
6788
- app2.get("/interactive/status", (req, res) => {
6789
- const session = iframer.getSession(auth(req).userId);
6790
- if (!session) return res.json({ ok: true, active: false });
6791
- res.json({
6792
- ok: true,
6793
- active: true,
6794
- noVncUrl: `http://localhost:${session.wsPort}/vnc.html?autoconnect=true`,
6795
- wsPort: session.wsPort,
6796
- createdAt: session.createdAt.toISOString()
6797
- });
6798
- });
6799
6959
  app2.post("/interactive/stop", asyncHandler(async (req, res) => {
6800
6960
  const r = auth(req);
6801
6961
  const result = await iframer.stopSession(r.userId, r.token);
6802
6962
  res.json(result);
6803
6963
  }));
6804
- app2.get("/interactive/screenshot", asyncHandler(async (req, res) => {
6805
- const r = auth(req);
6806
- const session = iframer.getSession(r.userId);
6807
- if (!session) throw new AppError(404, "No active interactive session");
6808
- if (req.query.format === "raw") {
6809
- const buf = await session.page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
6810
- res.set("Content-Type", "image/jpeg");
6811
- res.send(buf);
6812
- return;
6813
- }
6814
- const result = await iframer.screenshot(r.userId);
6815
- if (!result) throw new AppError(404, "No active interactive session");
6816
- res.json({ ok: true, ...result });
6817
- }));
6818
- app2.post("/interactive/act", asyncHandler(async (req, res) => {
6819
- const r = auth(req);
6820
- const session = iframer.getSession(r.userId);
6821
- if (!session) throw new AppError(404, "No active interactive session");
6822
- const { action, screenshot: wantScreenshot = true } = req.body || {};
6823
- if (!action || !action.type) throw new AppError(400, "Missing action.type");
6824
- const result = await iframer.execute(r.userId, r.token, {
6825
- steps: [action],
6826
- options: { screenshotAfterEach: wantScreenshot, continueOnObstacle: false }
6827
- });
6828
- const stepResult = result.results[0];
6829
- res.json({
6830
- ok: result.ok,
6831
- result: stepResult?.result,
6832
- screenshotUrl: stepResult?.screenshotUrl || result.finalState?.screenshotUrl,
6833
- url: result.finalState?.url,
6834
- title: result.finalState?.title,
6835
- error: result.error?.message
6836
- });
6837
- }));
6838
- app2.post("/interactive/batch", asyncHandler(async (req, res) => {
6839
- const r = auth(req);
6840
- const session = iframer.getSession(r.userId);
6841
- if (!session) throw new AppError(404, "No active interactive session");
6842
- const { actions, screenshot: wantScreenshot = true, continueOnError = false } = req.body || {};
6843
- if (!Array.isArray(actions) || actions.length === 0) {
6844
- throw new AppError(400, "actions must be a non-empty array");
6845
- }
6846
- const result = await iframer.execute(r.userId, r.token, {
6847
- steps: actions,
6848
- options: { continueOnError, screenshotAfterEach: false }
6849
- });
6850
- res.json({
6851
- ok: result.ok,
6852
- results: result.results.map((r2) => ({ index: r2.stepIndex, ok: r2.ok, result: r2.result, error: r2.error })),
6853
- screenshotUrl: wantScreenshot ? result.finalState?.screenshotUrl : void 0,
6854
- url: result.finalState?.url,
6855
- title: result.finalState?.title
6856
- });
6857
- }));
6858
6964
  app2.delete("/session", asyncHandler(async (req, res) => {
6859
6965
  await iframer.clearSession(auth(req).userId);
6860
6966
  res.json({ ok: true });
@@ -6877,20 +6983,6 @@ function registerRoutes(app2) {
6877
6983
  await iframer.deleteCredential(auth(req).userId, req.params.domain);
6878
6984
  res.json({ ok: true, message: `Credentials for ${req.params.domain} deleted` });
6879
6985
  }));
6880
- app2.post("/credentials/login", asyncHandler(async (req, res) => {
6881
- const { domain, usernameSelector, passwordSelector, submitSelector, totpSelector } = req.body || {};
6882
- if (!domain) throw new AppError(400, "Missing domain");
6883
- const r = auth(req);
6884
- const result = await iframer.loginWithCredentials(r.userId, r.token, domain, {
6885
- username: usernameSelector,
6886
- password: passwordSelector,
6887
- submit: submitSelector,
6888
- totp: totpSelector
6889
- });
6890
- if (!result.ok) throw new AppError(400, result.error || "Login failed");
6891
- const { ok: _ok, ...resultRest } = result;
6892
- res.json({ ok: true, message: "Login attempted", ...resultRest });
6893
- }));
6894
6986
  app2.get("/extension/status", (_req, res) => {
6895
6987
  res.json({ ok: true, version: getVersion(), ...extensionBridge.status() });
6896
6988
  });
@@ -6975,23 +7067,23 @@ function tokenAuth(req, res, next) {
6975
7067
 
6976
7068
  // index.ts
6977
7069
  var app = (0, import_express.default)();
6978
- var PORT = parseInt(process.env.PORT || "3021", 10);
7070
+ var PORT = parseInt(process.env.PORT || "3022", 10);
6979
7071
  var REAP_INTERVAL_MS = 6e4;
6980
7072
  var IDLE_EXIT_MS = parseInt(process.env.IFRAMER_SERVER_IDLE_EXIT_MS || String(30 * 60 * 1e3), 10);
6981
7073
  var SHUTDOWN_DEADLINE_MS = 1e4;
6982
- var SERVER_DIR = import_path12.default.dirname((0, import_url2.fileURLToPath)(importMetaUrl));
7074
+ var SERVER_DIR = import_path11.default.dirname((0, import_url.fileURLToPath)(importMetaUrl));
6983
7075
  var OWN_VERSION = (() => {
6984
- for (const p of [import_path12.default.join(SERVER_DIR, "package.json"), import_path12.default.join(SERVER_DIR, "..", "package.json")]) {
7076
+ for (const p of [import_path11.default.join(SERVER_DIR, "package.json"), import_path11.default.join(SERVER_DIR, "..", "package.json")]) {
6985
7077
  try {
6986
- const pkg = JSON.parse(import_fs13.default.readFileSync(p, "utf8"));
7078
+ const pkg = JSON.parse(import_fs11.default.readFileSync(p, "utf8"));
6987
7079
  if (pkg.name === "iframer-toolkit") return pkg.version;
6988
7080
  } catch {
6989
7081
  }
6990
7082
  }
6991
7083
  return void 0;
6992
7084
  })();
6993
- var SCREENSHOT_DIR = import_path12.default.join(import_path12.default.dirname((0, import_url2.fileURLToPath)(importMetaUrl)), ".screenshots");
6994
- import_fs13.default.mkdirSync(SCREENSHOT_DIR, { recursive: true });
7085
+ var SCREENSHOT_DIR = getScreenshotDir();
7086
+ import_fs11.default.mkdirSync(SCREENSHOT_DIR, { recursive: true, mode: 448 });
6995
7087
  app.use("/screenshots", import_express.default.static(SCREENSHOT_DIR));
6996
7088
  app.use(import_express.default.json());
6997
7089
  var lastActivity = Date.now();
@@ -7053,6 +7145,10 @@ var reapTimer = setInterval(async () => {
7053
7145
  await reapOrphanBrowsers();
7054
7146
  } catch {
7055
7147
  }
7148
+ try {
7149
+ iframer.sweepBlankBrowsers();
7150
+ } catch {
7151
+ }
7056
7152
  const idleMs = Date.now() - lastActivity;
7057
7153
  if (idleMs > IDLE_EXIT_MS && !iframer.browserHealth().alive && !extensionBridge.hasClients()) {
7058
7154
  gracefulShutdown(`idle for ${Math.round(idleMs / 6e4)}min with no browsers`);