@poncho-ai/browser 0.3.4 → 0.5.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/browser@0.3.4 build /Users/cesar/Dev/latitude/poncho-ai/packages/browser
2
+ > @poncho-ai/browser@0.4.0 build /Users/cesar/Dev/latitude/poncho-ai/packages/browser
3
3
  > tsup src/index.ts --format esm --dts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -7,8 +7,8 @@
7
7
  CLI tsup v8.5.1
8
8
  CLI Target: es2022
9
9
  ESM Build start
10
- ESM dist/index.js 24.62 KB
11
- ESM ⚡️ Build success in 34ms
10
+ ESM dist/index.js 34.91 KB
11
+ ESM ⚡️ Build success in 218ms
12
12
  DTS Build start
13
- DTS ⚡️ Build success in 3120ms
14
- DTS dist/index.d.ts 4.13 KB
13
+ DTS ⚡️ Build success in 5616ms
14
+ DTS dist/index.d.ts 12.44 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @poncho-ai/browser
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`540c8e6`](https://github.com/cesr/poncho-ai/commit/540c8e6d895a95c2f215deb4af219069543371d9) Thanks [@cesr](https://github.com/cesr)! - Add `browser_click_text` and `browser_execute_js` tools for interacting with elements that don't appear in the accessibility snapshot (e.g. styled divs acting as buttons). Also force new-tab navigations (`window.open`, `target="_blank"`) to stay in the current tab so agents don't lose context.
8
+
9
+ ## 0.4.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [`d997362`](https://github.com/cesr/poncho-ai/commit/d997362b114f6e9c5d95794cedff2c7675e32ca5) Thanks [@cesr](https://github.com/cesr)! - Add stealth mode to browser automation (enabled by default). Reduces bot-detection fingerprints with a realistic Chrome user-agent, navigator.webdriver override, window.chrome shim, fake plugins, WebGL patches, and anti-automation Chrome flags. Configurable via `stealth` and `userAgent` options in `poncho.config.js`.
14
+
3
15
  ## 0.3.4
4
16
 
5
17
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -56,6 +56,13 @@ interface BrowserConfig {
56
56
  sessionName?: string;
57
57
  executablePath?: string;
58
58
  headless?: boolean;
59
+ /** Custom user-agent string. When `stealth` is enabled a realistic Chrome UA
60
+ * is used by default — set this to override it. */
61
+ userAgent?: string;
62
+ /** Reduce bot-detection fingerprints. Defaults to `true`.
63
+ * Patches navigator.webdriver, injects window.chrome shim, sets a realistic
64
+ * user-agent, and passes anti-automation Chrome flags. */
65
+ stealth?: boolean;
59
66
  storagePersistence?: BrowserStoragePersistence;
60
67
  }
61
68
 
@@ -66,6 +73,8 @@ declare class BrowserSession {
66
73
  private readonly sessionId;
67
74
  private manager;
68
75
  private readonly tabs;
76
+ private _contextStealthInstalled;
77
+ private readonly _uaOverrideApplied;
69
78
  private _lockQueue;
70
79
  private _locked;
71
80
  private _screencastConversation;
@@ -73,6 +82,25 @@ declare class BrowserSession {
73
82
  get profileDir(): string;
74
83
  private lock;
75
84
  private unlock;
85
+ private get stealthEnabled();
86
+ private get stealthUserAgent();
87
+ /**
88
+ * Install the stealth init script on the Playwright BrowserContext.
89
+ * This runs before ALL page scripts on every navigation across every tab.
90
+ * Only needs to be called once per browser launch.
91
+ */
92
+ private installContextStealth;
93
+ /**
94
+ * Force all new-tab navigations (window.open, target="_blank") to open
95
+ * in the current tab instead. Agents operate on a single tab at a time
96
+ * and can't see or interact with popups.
97
+ */
98
+ private installSameTabScript;
99
+ /**
100
+ * Override the user-agent via CDP on the current page target.
101
+ * CDP Network.setUserAgentOverride is per-target, so call per-tab.
102
+ */
103
+ private overrideUserAgentOnPage;
76
104
  private launchFreshManager;
77
105
  private ensureManager;
78
106
  private evictOldestTab;
@@ -100,6 +128,8 @@ declare class BrowserSession {
100
128
  title: string;
101
129
  }>;
102
130
  scroll(conversationId: string, direction: "up" | "down", amount?: number): Promise<void>;
131
+ clickText(conversationId: string, text: string, exact?: boolean): Promise<void>;
132
+ executeJs(conversationId: string, script: string): Promise<unknown>;
103
133
  closeTab(conversationId: string): Promise<void>;
104
134
  navigate(conversationId: string, action: string): Promise<void>;
105
135
  startScreencast(conversationId: string, options?: ScreencastOptions): Promise<void>;
@@ -119,4 +149,26 @@ declare class BrowserSession {
119
149
 
120
150
  declare function createBrowserTools(getSession: () => BrowserSession, getConversationId: () => string): ToolDefinition[];
121
151
 
122
- export { type BrowserConfig, type BrowserFrame, BrowserSession, type BrowserStatus, type BrowserStoragePersistence, type KeyboardInputEvent, type MouseInputEvent, type ScreencastOptions, type ScrollInputEvent, type ViewportOptions, createBrowserTools };
152
+ /**
153
+ * Returns a realistic Chrome user-agent string for the host OS.
154
+ * Uses a recent stable Chrome version to blend in with normal traffic.
155
+ */
156
+ declare function defaultUserAgent(): string;
157
+ /**
158
+ * Static Chromium flags that suppress automation fingerprints.
159
+ */
160
+ declare const STEALTH_ARGS: string[];
161
+ /**
162
+ * Build the full stealth args array including the browser-level user-agent
163
+ * override. The `--user-agent` flag sets the UA globally — including in
164
+ * Web Workers and Service Workers — which context-level overrides can't reach.
165
+ */
166
+ declare function buildStealthArgs(userAgent: string): string[];
167
+ /**
168
+ * JS source injected via Playwright context.addInitScript().
169
+ * Runs before ALL page scripts on every navigation, every tab.
170
+ * Each section has its own try/catch so one failure doesn't block the rest.
171
+ */
172
+ declare const STEALTH_INIT_SCRIPT = "\n(() => {\n // 1. navigator.webdriver \u2192 false\n try {\n Object.defineProperty(Navigator.prototype, 'webdriver', {\n get: () => false,\n configurable: true,\n });\n } catch {}\n\n // 2. window.chrome stub (headless Chromium omits it)\n try {\n if (!window.chrome) {\n const chrome = {\n app: {\n isInstalled: false,\n InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },\n RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },\n getDetails() { return null; },\n getIsInstalled() { return false; },\n installState() { return 'not_installed'; },\n },\n runtime: {\n OnInstalledReason: { CHROME_UPDATE: 'chrome_update', INSTALL: 'install', SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update' },\n OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },\n PlatformArch: { ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' },\n PlatformNaclArch: { ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' },\n PlatformOs: { ANDROID: 'android', CROS: 'cros', FUCHSIA: 'fuchsia', LINUX: 'linux', MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win' },\n RequestUpdateCheckStatus: { NO_UPDATE: 'no_update', THROTTLED: 'throttled', UPDATE_AVAILABLE: 'update_available' },\n connect() { return { onMessage: { addListener() {}, removeListener() {} }, postMessage() {}, disconnect() {} }; },\n sendMessage() {},\n id: undefined,\n },\n csi() { return {}; },\n loadTimes() { return {}; },\n };\n Object.defineProperty(window, 'chrome', {\n value: chrome,\n writable: false,\n enumerable: true,\n configurable: false,\n });\n }\n } catch {}\n\n // 3. navigator.plugins \u2014 expose the plugins a real Chrome has\n try {\n const makeMimeType = (type, desc, suffixes, plugin) => {\n const mt = Object.create(MimeType.prototype);\n Object.defineProperties(mt, {\n type: { get: () => type },\n description: { get: () => desc },\n suffixes: { get: () => suffixes },\n enabledPlugin: { get: () => plugin },\n });\n return mt;\n };\n const fakePluginDefs = [\n { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', hasMime: true },\n { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '', hasMime: false },\n { name: 'Native Client', filename: 'internal-nacl-plugin', description: '', hasMime: false },\n ];\n const plugins = [];\n for (const fp of fakePluginDefs) {\n const p = Object.create(Plugin.prototype);\n const mime = fp.hasMime ? makeMimeType('application/x-google-chrome-pdf', 'Portable Document Format', 'pdf', p) : null;\n Object.defineProperties(p, {\n name: { get: () => fp.name },\n filename: { get: () => fp.filename },\n description: { get: () => fp.description },\n length: { get: () => mime ? 1 : 0 },\n });\n if (mime) Object.defineProperty(p, 0, { get: () => mime });\n Object.defineProperty(p, 'item', { value: (i) => i === 0 && mime ? mime : null });\n Object.defineProperty(p, 'namedItem', { value: (n) => mime && n === mime.type ? mime : null });\n Object.defineProperty(p, Symbol.iterator, { value: function*() { if (mime) yield mime; } });\n plugins.push(p);\n }\n const pluginArray = Object.create(PluginArray.prototype);\n plugins.forEach((p, i) => Object.defineProperty(pluginArray, i, { get: () => p, enumerable: true }));\n Object.defineProperty(pluginArray, 'length', { get: () => plugins.length });\n Object.defineProperty(pluginArray, 'item', { value: (i) => plugins[i] ?? null });\n Object.defineProperty(pluginArray, 'namedItem', { value: (n) => plugins.find(p => p.name === n) ?? null });\n Object.defineProperty(pluginArray, 'refresh', { value: () => {} });\n Object.defineProperty(pluginArray, Symbol.iterator, { value: function*() { for (const p of plugins) yield p; } });\n Object.defineProperty(navigator, 'plugins', { get: () => pluginArray, configurable: true });\n } catch {}\n\n // 4. navigator.languages\n try {\n Object.defineProperty(navigator, 'languages', {\n get: () => Object.freeze(['en-US', 'en']),\n configurable: true,\n });\n } catch {}\n\n // 5. Notification.permission \u2014 return \"default\" (headless returns \"denied\")\n try {\n if (typeof Notification !== 'undefined' && Notification.permission === 'denied') {\n Object.defineProperty(Notification, 'permission', {\n get: () => 'default',\n configurable: true,\n });\n }\n } catch {}\n\n // 6. WebGL vendor/renderer \u2014 hide SwiftShader (headless giveaway)\n try {\n const UNMASKED_VENDOR = 0x9245;\n const UNMASKED_RENDERER = 0x9246;\n const spoofVendor = 'Google Inc. (Intel)';\n const spoofRenderer = 'ANGLE (Intel, Intel(R) Iris(TM) Plus Graphics, OpenGL 4.1)';\n for (const Ctx of [WebGLRenderingContext, typeof WebGL2RenderingContext !== 'undefined' ? WebGL2RenderingContext : null].filter(Boolean)) {\n const orig = Ctx.prototype.getParameter;\n Ctx.prototype.getParameter = function(param) {\n if (param === UNMASKED_VENDOR) return spoofVendor;\n if (param === UNMASKED_RENDERER) return spoofRenderer;\n return orig.call(this, param);\n };\n }\n } catch {}\n\n // 7. Prevent iframe-based detection of navigator.webdriver\n try {\n const origCreate = Document.prototype.createElement;\n Document.prototype.createElement = function(...args) {\n const el = origCreate.apply(this, args);\n if (el.nodeName === 'IFRAME') {\n const origGet = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow')?.get;\n if (origGet) {\n Object.defineProperty(el, 'contentWindow', {\n get() {\n const w = origGet.call(this);\n if (w) {\n try {\n Object.defineProperty(w.navigator, 'webdriver', { get: () => false, configurable: true });\n } catch {}\n }\n return w;\n },\n configurable: true,\n });\n }\n }\n return el;\n };\n } catch {}\n})();\n";
173
+
174
+ export { type BrowserConfig, type BrowserFrame, BrowserSession, type BrowserStatus, type BrowserStoragePersistence, type KeyboardInputEvent, type MouseInputEvent, STEALTH_ARGS, STEALTH_INIT_SCRIPT, type ScreencastOptions, type ScrollInputEvent, type ViewportOptions, buildStealthArgs, createBrowserTools, defaultUserAgent };
package/dist/index.js CHANGED
@@ -1,7 +1,182 @@
1
1
  // src/session.ts
2
2
  import { resolve, join } from "path";
3
- import { homedir, tmpdir } from "os";
3
+ import { homedir, tmpdir, platform as platform2 } from "os";
4
4
  import { mkdir, readFile, unlink } from "fs/promises";
5
+
6
+ // src/stealth.ts
7
+ import { platform } from "os";
8
+ function defaultUserAgent() {
9
+ const chromeVersion = "145.0.7632.117";
10
+ const os = platform();
11
+ if (os === "darwin") {
12
+ return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
13
+ }
14
+ if (os === "win32") {
15
+ return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
16
+ }
17
+ return `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
18
+ }
19
+ var STEALTH_ARGS = [
20
+ "--disable-blink-features=AutomationControlled",
21
+ "--enable-webgl",
22
+ "--use-gl=angle",
23
+ "--enable-features=VaapiVideoDecoder",
24
+ "--ignore-gpu-blocklist"
25
+ ];
26
+ function buildStealthArgs(userAgent) {
27
+ return [...STEALTH_ARGS, `--user-agent=${userAgent}`];
28
+ }
29
+ var STEALTH_INIT_SCRIPT = `
30
+ (() => {
31
+ // 1. navigator.webdriver \u2192 false
32
+ try {
33
+ Object.defineProperty(Navigator.prototype, 'webdriver', {
34
+ get: () => false,
35
+ configurable: true,
36
+ });
37
+ } catch {}
38
+
39
+ // 2. window.chrome stub (headless Chromium omits it)
40
+ try {
41
+ if (!window.chrome) {
42
+ const chrome = {
43
+ app: {
44
+ isInstalled: false,
45
+ InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
46
+ RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
47
+ getDetails() { return null; },
48
+ getIsInstalled() { return false; },
49
+ installState() { return 'not_installed'; },
50
+ },
51
+ runtime: {
52
+ OnInstalledReason: { CHROME_UPDATE: 'chrome_update', INSTALL: 'install', SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update' },
53
+ OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
54
+ PlatformArch: { ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' },
55
+ PlatformNaclArch: { ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' },
56
+ PlatformOs: { ANDROID: 'android', CROS: 'cros', FUCHSIA: 'fuchsia', LINUX: 'linux', MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win' },
57
+ RequestUpdateCheckStatus: { NO_UPDATE: 'no_update', THROTTLED: 'throttled', UPDATE_AVAILABLE: 'update_available' },
58
+ connect() { return { onMessage: { addListener() {}, removeListener() {} }, postMessage() {}, disconnect() {} }; },
59
+ sendMessage() {},
60
+ id: undefined,
61
+ },
62
+ csi() { return {}; },
63
+ loadTimes() { return {}; },
64
+ };
65
+ Object.defineProperty(window, 'chrome', {
66
+ value: chrome,
67
+ writable: false,
68
+ enumerable: true,
69
+ configurable: false,
70
+ });
71
+ }
72
+ } catch {}
73
+
74
+ // 3. navigator.plugins \u2014 expose the plugins a real Chrome has
75
+ try {
76
+ const makeMimeType = (type, desc, suffixes, plugin) => {
77
+ const mt = Object.create(MimeType.prototype);
78
+ Object.defineProperties(mt, {
79
+ type: { get: () => type },
80
+ description: { get: () => desc },
81
+ suffixes: { get: () => suffixes },
82
+ enabledPlugin: { get: () => plugin },
83
+ });
84
+ return mt;
85
+ };
86
+ const fakePluginDefs = [
87
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', hasMime: true },
88
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '', hasMime: false },
89
+ { name: 'Native Client', filename: 'internal-nacl-plugin', description: '', hasMime: false },
90
+ ];
91
+ const plugins = [];
92
+ for (const fp of fakePluginDefs) {
93
+ const p = Object.create(Plugin.prototype);
94
+ const mime = fp.hasMime ? makeMimeType('application/x-google-chrome-pdf', 'Portable Document Format', 'pdf', p) : null;
95
+ Object.defineProperties(p, {
96
+ name: { get: () => fp.name },
97
+ filename: { get: () => fp.filename },
98
+ description: { get: () => fp.description },
99
+ length: { get: () => mime ? 1 : 0 },
100
+ });
101
+ if (mime) Object.defineProperty(p, 0, { get: () => mime });
102
+ Object.defineProperty(p, 'item', { value: (i) => i === 0 && mime ? mime : null });
103
+ Object.defineProperty(p, 'namedItem', { value: (n) => mime && n === mime.type ? mime : null });
104
+ Object.defineProperty(p, Symbol.iterator, { value: function*() { if (mime) yield mime; } });
105
+ plugins.push(p);
106
+ }
107
+ const pluginArray = Object.create(PluginArray.prototype);
108
+ plugins.forEach((p, i) => Object.defineProperty(pluginArray, i, { get: () => p, enumerable: true }));
109
+ Object.defineProperty(pluginArray, 'length', { get: () => plugins.length });
110
+ Object.defineProperty(pluginArray, 'item', { value: (i) => plugins[i] ?? null });
111
+ Object.defineProperty(pluginArray, 'namedItem', { value: (n) => plugins.find(p => p.name === n) ?? null });
112
+ Object.defineProperty(pluginArray, 'refresh', { value: () => {} });
113
+ Object.defineProperty(pluginArray, Symbol.iterator, { value: function*() { for (const p of plugins) yield p; } });
114
+ Object.defineProperty(navigator, 'plugins', { get: () => pluginArray, configurable: true });
115
+ } catch {}
116
+
117
+ // 4. navigator.languages
118
+ try {
119
+ Object.defineProperty(navigator, 'languages', {
120
+ get: () => Object.freeze(['en-US', 'en']),
121
+ configurable: true,
122
+ });
123
+ } catch {}
124
+
125
+ // 5. Notification.permission \u2014 return "default" (headless returns "denied")
126
+ try {
127
+ if (typeof Notification !== 'undefined' && Notification.permission === 'denied') {
128
+ Object.defineProperty(Notification, 'permission', {
129
+ get: () => 'default',
130
+ configurable: true,
131
+ });
132
+ }
133
+ } catch {}
134
+
135
+ // 6. WebGL vendor/renderer \u2014 hide SwiftShader (headless giveaway)
136
+ try {
137
+ const UNMASKED_VENDOR = 0x9245;
138
+ const UNMASKED_RENDERER = 0x9246;
139
+ const spoofVendor = 'Google Inc. (Intel)';
140
+ const spoofRenderer = 'ANGLE (Intel, Intel(R) Iris(TM) Plus Graphics, OpenGL 4.1)';
141
+ for (const Ctx of [WebGLRenderingContext, typeof WebGL2RenderingContext !== 'undefined' ? WebGL2RenderingContext : null].filter(Boolean)) {
142
+ const orig = Ctx.prototype.getParameter;
143
+ Ctx.prototype.getParameter = function(param) {
144
+ if (param === UNMASKED_VENDOR) return spoofVendor;
145
+ if (param === UNMASKED_RENDERER) return spoofRenderer;
146
+ return orig.call(this, param);
147
+ };
148
+ }
149
+ } catch {}
150
+
151
+ // 7. Prevent iframe-based detection of navigator.webdriver
152
+ try {
153
+ const origCreate = Document.prototype.createElement;
154
+ Document.prototype.createElement = function(...args) {
155
+ const el = origCreate.apply(this, args);
156
+ if (el.nodeName === 'IFRAME') {
157
+ const origGet = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow')?.get;
158
+ if (origGet) {
159
+ Object.defineProperty(el, 'contentWindow', {
160
+ get() {
161
+ const w = origGet.call(this);
162
+ if (w) {
163
+ try {
164
+ Object.defineProperty(w.navigator, 'webdriver', { get: () => false, configurable: true });
165
+ } catch {}
166
+ }
167
+ return w;
168
+ },
169
+ configurable: true,
170
+ });
171
+ }
172
+ }
173
+ return el;
174
+ };
175
+ } catch {}
176
+ })();
177
+ `;
178
+
179
+ // src/session.ts
5
180
  var BrowserManagerCtor;
6
181
  async function getBrowserManagerCtor() {
7
182
  if (!BrowserManagerCtor) {
@@ -11,12 +186,56 @@ async function getBrowserManagerCtor() {
11
186
  return BrowserManagerCtor;
12
187
  }
13
188
  var MAX_TABS = 8;
189
+ var SAME_TAB_INIT_SCRIPT = `
190
+ (() => {
191
+ // Override window.open to navigate in-place
192
+ try {
193
+ const origOpen = window.open;
194
+ window.open = function(url, target, features) {
195
+ if (url) {
196
+ location.href = url;
197
+ return window;
198
+ }
199
+ return origOpen.call(this, url, target, features);
200
+ };
201
+ } catch {}
202
+
203
+ // Rewrite target="_blank" on existing and future links
204
+ try {
205
+ const rewrite = (el) => {
206
+ if (el.tagName === 'A' && el.target === '_blank') {
207
+ el.target = '_self';
208
+ }
209
+ };
210
+ // Catch links already in the DOM
211
+ document.addEventListener('DOMContentLoaded', () => {
212
+ document.querySelectorAll('a[target="_blank"]').forEach(rewrite);
213
+ });
214
+ // Catch dynamically added links
215
+ new MutationObserver((mutations) => {
216
+ for (const m of mutations) {
217
+ for (const node of m.addedNodes) {
218
+ if (node.nodeType !== 1) continue;
219
+ rewrite(node);
220
+ if (node.querySelectorAll) {
221
+ node.querySelectorAll('a[target="_blank"]').forEach(rewrite);
222
+ }
223
+ }
224
+ }
225
+ }).observe(document.documentElement, { childList: true, subtree: true });
226
+ } catch {}
227
+ })();
228
+ `;
14
229
  var BrowserSession = class {
15
230
  config;
16
231
  sessionId;
17
232
  manager;
18
233
  // Tab management: conversationId → tab state
19
234
  tabs = /* @__PURE__ */ new Map();
235
+ // Whether context-level stealth init script has been installed
236
+ _contextStealthInstalled = false;
237
+ // Track which tabs have had per-page CDP UA override applied
238
+ _uaOverrideApplied = /* @__PURE__ */ new Set();
20
239
  // Serialization lock for tab-switching operations
21
240
  _lockQueue = [];
22
241
  _locked = false;
@@ -57,18 +276,95 @@ var BrowserSession = class {
57
276
  // -----------------------------------------------------------------------
58
277
  // Core browser + tab management
59
278
  // -----------------------------------------------------------------------
279
+ get stealthEnabled() {
280
+ return this.config.stealth !== false;
281
+ }
282
+ get stealthUserAgent() {
283
+ if (this.config.userAgent) return this.config.userAgent;
284
+ if (this.stealthEnabled) return defaultUserAgent();
285
+ return void 0;
286
+ }
287
+ /**
288
+ * Install the stealth init script on the Playwright BrowserContext.
289
+ * This runs before ALL page scripts on every navigation across every tab.
290
+ * Only needs to be called once per browser launch.
291
+ */
292
+ async installContextStealth(mgr) {
293
+ if (this._contextStealthInstalled) return;
294
+ const ctx = mgr.getContext();
295
+ if (!ctx) {
296
+ console.warn("[poncho][browser] Cannot install stealth: no browser context");
297
+ return;
298
+ }
299
+ try {
300
+ await ctx.addInitScript({ content: STEALTH_INIT_SCRIPT });
301
+ this._contextStealthInstalled = true;
302
+ console.log("[poncho][browser] Stealth init script installed on context");
303
+ } catch (err) {
304
+ console.warn("[poncho][browser] Failed to install stealth init script:", err?.message ?? err);
305
+ }
306
+ }
307
+ /**
308
+ * Force all new-tab navigations (window.open, target="_blank") to open
309
+ * in the current tab instead. Agents operate on a single tab at a time
310
+ * and can't see or interact with popups.
311
+ */
312
+ async installSameTabScript(mgr) {
313
+ const ctx = mgr.getContext();
314
+ if (!ctx) return;
315
+ try {
316
+ await ctx.addInitScript({ content: SAME_TAB_INIT_SCRIPT });
317
+ } catch (err) {
318
+ console.warn("[poncho][browser] Failed to install same-tab init script:", err?.message ?? err);
319
+ }
320
+ }
321
+ /**
322
+ * Override the user-agent via CDP on the current page target.
323
+ * CDP Network.setUserAgentOverride is per-target, so call per-tab.
324
+ */
325
+ async overrideUserAgentOnPage(mgr, conversationId) {
326
+ if (this._uaOverrideApplied.has(conversationId)) return;
327
+ const ua = this.stealthUserAgent;
328
+ if (!ua) return;
329
+ try {
330
+ const cdp = await mgr.getCDPSession();
331
+ await cdp.send("Network.setUserAgentOverride", {
332
+ userAgent: ua,
333
+ acceptLanguage: "en-US,en;q=0.9",
334
+ platform: platform2() === "darwin" ? "macOS" : platform2() === "win32" ? "Win32" : "Linux x86_64"
335
+ });
336
+ this._uaOverrideApplied.add(conversationId);
337
+ } catch (err) {
338
+ console.warn("[poncho][browser] Failed to override UA via CDP:", err?.message ?? err);
339
+ }
340
+ }
60
341
  async launchFreshManager() {
61
342
  const Ctor = await getBrowserManagerCtor();
62
343
  const mgr = new Ctor();
63
344
  const viewport = this.config.viewport ?? { width: 1280, height: 720 };
64
345
  await mkdir(this.profileDir, { recursive: true });
65
- await mgr.launch({
346
+ const launchOpts = {
66
347
  action: "launch",
67
348
  headless: this.config.headless ?? true,
68
349
  viewport: { width: viewport.width ?? 1280, height: viewport.height ?? 720 },
69
350
  executablePath: this.config.executablePath,
70
351
  profile: this.profileDir
71
- });
352
+ };
353
+ if (this.stealthEnabled) {
354
+ const ua = this.stealthUserAgent;
355
+ launchOpts.userAgent = ua;
356
+ launchOpts.args = buildStealthArgs(ua);
357
+ console.log("[poncho][browser] Launching with stealth mode enabled (UA: " + ua + ")");
358
+ } else if (this.config.userAgent) {
359
+ launchOpts.userAgent = this.config.userAgent;
360
+ }
361
+ await mgr.launch(launchOpts);
362
+ this._contextStealthInstalled = false;
363
+ this._uaOverrideApplied.clear();
364
+ if (this.stealthEnabled) {
365
+ await this.installContextStealth(mgr);
366
+ }
367
+ await this.installSameTabScript(mgr);
72
368
  try {
73
369
  const cdp = await mgr.getCDPSession();
74
370
  await cdp.send("Debugger.disable");
@@ -89,6 +385,8 @@ var BrowserSession = class {
89
385
  } catch {
90
386
  }
91
387
  this.manager = void 0;
388
+ this._contextStealthInstalled = false;
389
+ this._uaOverrideApplied.clear();
92
390
  for (const [cid, tab] of this.tabs) {
93
391
  if (tab.tabIndex >= 0) {
94
392
  tab.tabIndex = -1;
@@ -129,6 +427,7 @@ var BrowserSession = class {
129
427
  oldest.tab.url = void 0;
130
428
  this.emitStatus(oldest.cid);
131
429
  this.tabs.delete(oldest.cid);
430
+ this._uaOverrideApplied.delete(oldest.cid);
132
431
  }
133
432
  /** Reconcile tab indices with the manager's actual page list. */
134
433
  async reconcileTabs(mgr) {
@@ -208,6 +507,8 @@ var BrowserSession = class {
208
507
  } catch {
209
508
  }
210
509
  this.manager = void 0;
510
+ this._contextStealthInstalled = false;
511
+ this._uaOverrideApplied.clear();
211
512
  for (const [, t] of this.tabs) {
212
513
  if (t.tabIndex >= 0) {
213
514
  t.tabIndex = -1;
@@ -225,6 +526,10 @@ var BrowserSession = class {
225
526
  async _doOpen(conversationId, url) {
226
527
  const mgr = await this.ensureManager();
227
528
  const tab = await this.switchToConversation(mgr, conversationId);
529
+ if (this.stealthEnabled) {
530
+ await this.installContextStealth(mgr);
531
+ await this.overrideUserAgentOnPage(mgr, conversationId);
532
+ }
228
533
  const page = mgr.getPage();
229
534
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
230
535
  tab.url = page.url();
@@ -308,6 +613,30 @@ var BrowserSession = class {
308
613
  this.unlock();
309
614
  }
310
615
  }
616
+ async clickText(conversationId, text, exact) {
617
+ await this.lock();
618
+ try {
619
+ const mgr = await this.ensureManager();
620
+ const tab = await this.switchToConversation(mgr, conversationId);
621
+ const selector = exact ? `text="${text}"` : `text=${text}`;
622
+ const locator = mgr.getLocator(selector);
623
+ await locator.click();
624
+ tab.url = mgr.getPage().url();
625
+ } finally {
626
+ this.unlock();
627
+ }
628
+ }
629
+ async executeJs(conversationId, script) {
630
+ await this.lock();
631
+ try {
632
+ const mgr = await this.ensureManager();
633
+ await this.switchToConversation(mgr, conversationId);
634
+ const page = mgr.getPage();
635
+ return await page.evaluate(script);
636
+ } finally {
637
+ this.unlock();
638
+ }
639
+ }
311
640
  async closeTab(conversationId) {
312
641
  await this.lock();
313
642
  try {
@@ -341,6 +670,7 @@ var BrowserSession = class {
341
670
  tab.url = void 0;
342
671
  this.emitStatus(conversationId);
343
672
  this.tabs.delete(conversationId);
673
+ this._uaOverrideApplied.delete(conversationId);
344
674
  } finally {
345
675
  this.unlock();
346
676
  }
@@ -564,6 +894,8 @@ var BrowserSession = class {
564
894
  } catch {
565
895
  }
566
896
  this.manager = void 0;
897
+ this._contextStealthInstalled = false;
898
+ this._uaOverrideApplied.clear();
567
899
  for (const [cid, tab] of this.tabs) {
568
900
  tab.active = false;
569
901
  tab.url = void 0;
@@ -654,6 +986,53 @@ function createBrowserTools(getSession, getConversationId) {
654
986
  return { clicked: ref };
655
987
  }
656
988
  },
989
+ {
990
+ name: "browser_click_text",
991
+ description: "Click the first visible element on the page that contains the given text. Use this when an element doesn't appear in the snapshot \u2014 e.g. styled divs acting as buttons. By default matches substring (case-insensitive); set exact=true for exact text match.",
992
+ inputSchema: {
993
+ type: "object",
994
+ properties: {
995
+ text: {
996
+ type: "string",
997
+ description: "The visible text of the element to click"
998
+ },
999
+ exact: {
1000
+ type: "boolean",
1001
+ description: "If true, match the exact full text (case-sensitive). Default: false (substring, case-insensitive)."
1002
+ }
1003
+ },
1004
+ required: ["text"]
1005
+ },
1006
+ handler: async (input) => {
1007
+ const session = getSession();
1008
+ const text = String(input.text ?? "");
1009
+ if (!text) throw new Error("text is required");
1010
+ const exact = input.exact === true;
1011
+ await session.clickText(getConversationId(), text, exact);
1012
+ return { clicked: text, exact };
1013
+ }
1014
+ },
1015
+ {
1016
+ name: "browser_execute_js",
1017
+ description: "Execute JavaScript in the current page context and return the result. Use this to inspect or interact with the DOM when snapshot refs aren't available \u2014 e.g. finding elements by text content, getting bounding boxes, or clicking elements by selector. The script is evaluated via page.evaluate(); return a value to get it back.",
1018
+ inputSchema: {
1019
+ type: "object",
1020
+ properties: {
1021
+ script: {
1022
+ type: "string",
1023
+ description: "JavaScript code to evaluate in the page. Use a return statement or expression to get a result back."
1024
+ }
1025
+ },
1026
+ required: ["script"]
1027
+ },
1028
+ handler: async (input) => {
1029
+ const session = getSession();
1030
+ const script = String(input.script ?? "");
1031
+ if (!script) throw new Error("script is required");
1032
+ const result = await session.executeJs(getConversationId(), script);
1033
+ return { result: result ?? null };
1034
+ }
1035
+ },
657
1036
  {
658
1037
  name: "browser_type",
659
1038
  description: "Type text into a form field identified by its ref from the last snapshot. This clears the field first, then types the new value.",
@@ -755,5 +1134,9 @@ function createBrowserTools(getSession, getConversationId) {
755
1134
  }
756
1135
  export {
757
1136
  BrowserSession,
758
- createBrowserTools
1137
+ STEALTH_ARGS,
1138
+ STEALTH_INIT_SCRIPT,
1139
+ buildStealthArgs,
1140
+ createBrowserTools,
1141
+ defaultUserAgent
759
1142
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/browser",
3
- "version": "0.3.4",
3
+ "version": "0.5.0",
4
4
  "description": "Browser automation for Poncho agents, powered by agent-browser",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { BrowserSession } from "./session.js";
2
2
  export { createBrowserTools } from "./tools.js";
3
+ export { defaultUserAgent, STEALTH_ARGS, buildStealthArgs, STEALTH_INIT_SCRIPT } from "./stealth.js";
3
4
  export type {
4
5
  BrowserConfig,
5
6
  BrowserFrame,
package/src/session.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { resolve, join } from "node:path";
2
- import { homedir, tmpdir } from "node:os";
2
+ import { homedir, tmpdir, platform } from "node:os";
3
3
  import { mkdir, readFile, unlink } from "node:fs/promises";
4
4
  import type {
5
5
  BrowserConfig,
@@ -10,16 +10,26 @@ import type {
10
10
  KeyboardInputEvent,
11
11
  ScrollInputEvent,
12
12
  } from "./types.js";
13
+ import { defaultUserAgent, buildStealthArgs, STEALTH_INIT_SCRIPT } from "./stealth.js";
13
14
 
14
15
  type FrameListener = (frame: BrowserFrame) => void;
15
16
  type StatusListener = (status: BrowserStatus) => void;
16
17
 
17
18
  let BrowserManagerCtor: (new () => BrowserManagerInstance) | undefined;
18
19
 
20
+ interface CDPSessionHandle {
21
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
22
+ }
23
+
24
+ interface BrowserContextHandle {
25
+ addInitScript(script: string | { path?: string; content?: string }): Promise<void>;
26
+ }
27
+
19
28
  interface BrowserManagerInstance {
20
29
  isLaunched(): boolean;
21
30
  launch(options: Record<string, unknown>): Promise<void>;
22
31
  getPage(): { url(): string; title(): Promise<string>; screenshot(opts?: Record<string, unknown>): Promise<Buffer>; goBack(): Promise<unknown>; goForward(): Promise<unknown>; evaluate(fn: string | (() => unknown)): Promise<unknown> };
32
+ getContext(): BrowserContextHandle | null;
23
33
  getSnapshot(options?: { interactive?: boolean; compact?: boolean }): Promise<{ tree: string; refs: Record<string, unknown> }>;
24
34
  getLocatorFromRef(ref: string): { click(): Promise<void> } | null;
25
35
  getLocator(selector: string): { fill(text: string): Promise<void>; click(): Promise<void> };
@@ -36,7 +46,7 @@ interface BrowserManagerInstance {
36
46
  isScreencasting(): boolean;
37
47
  injectMouseEvent(params: Record<string, unknown>): Promise<void>;
38
48
  injectKeyboardEvent(params: Record<string, unknown>): Promise<void>;
39
- getCDPSession(): Promise<{ send(method: string, params?: Record<string, unknown>): Promise<unknown> }>;
49
+ getCDPSession(): Promise<CDPSessionHandle>;
40
50
  saveStorageState(path: string): Promise<void>;
41
51
  close(): Promise<void>;
42
52
  setViewport(width: number, height: number): Promise<void>;
@@ -52,6 +62,51 @@ async function getBrowserManagerCtor(): Promise<new () => BrowserManagerInstance
52
62
 
53
63
  const MAX_TABS = 8;
54
64
 
65
+ /**
66
+ * Init script that forces new-tab navigations (window.open, target="_blank")
67
+ * to open in the current tab. Runs before page scripts on every navigation.
68
+ */
69
+ const SAME_TAB_INIT_SCRIPT = `
70
+ (() => {
71
+ // Override window.open to navigate in-place
72
+ try {
73
+ const origOpen = window.open;
74
+ window.open = function(url, target, features) {
75
+ if (url) {
76
+ location.href = url;
77
+ return window;
78
+ }
79
+ return origOpen.call(this, url, target, features);
80
+ };
81
+ } catch {}
82
+
83
+ // Rewrite target="_blank" on existing and future links
84
+ try {
85
+ const rewrite = (el) => {
86
+ if (el.tagName === 'A' && el.target === '_blank') {
87
+ el.target = '_self';
88
+ }
89
+ };
90
+ // Catch links already in the DOM
91
+ document.addEventListener('DOMContentLoaded', () => {
92
+ document.querySelectorAll('a[target="_blank"]').forEach(rewrite);
93
+ });
94
+ // Catch dynamically added links
95
+ new MutationObserver((mutations) => {
96
+ for (const m of mutations) {
97
+ for (const node of m.addedNodes) {
98
+ if (node.nodeType !== 1) continue;
99
+ rewrite(node);
100
+ if (node.querySelectorAll) {
101
+ node.querySelectorAll('a[target="_blank"]').forEach(rewrite);
102
+ }
103
+ }
104
+ }
105
+ }).observe(document.documentElement, { childList: true, subtree: true });
106
+ } catch {}
107
+ })();
108
+ `;
109
+
55
110
  // Per-conversation tab state
56
111
  interface ConversationTab {
57
112
  tabIndex: number;
@@ -70,6 +125,12 @@ export class BrowserSession {
70
125
  // Tab management: conversationId → tab state
71
126
  private readonly tabs = new Map<string, ConversationTab>();
72
127
 
128
+ // Whether context-level stealth init script has been installed
129
+ private _contextStealthInstalled = false;
130
+
131
+ // Track which tabs have had per-page CDP UA override applied
132
+ private readonly _uaOverrideApplied = new Set<string>();
133
+
73
134
  // Serialization lock for tab-switching operations
74
135
  private _lockQueue: Array<() => void> = [];
75
136
  private _locked = false;
@@ -116,6 +177,73 @@ export class BrowserSession {
116
177
  // Core browser + tab management
117
178
  // -----------------------------------------------------------------------
118
179
 
180
+ private get stealthEnabled(): boolean {
181
+ return this.config.stealth !== false;
182
+ }
183
+
184
+ private get stealthUserAgent(): string | undefined {
185
+ if (this.config.userAgent) return this.config.userAgent;
186
+ if (this.stealthEnabled) return defaultUserAgent();
187
+ return undefined;
188
+ }
189
+
190
+ /**
191
+ * Install the stealth init script on the Playwright BrowserContext.
192
+ * This runs before ALL page scripts on every navigation across every tab.
193
+ * Only needs to be called once per browser launch.
194
+ */
195
+ private async installContextStealth(mgr: BrowserManagerInstance): Promise<void> {
196
+ if (this._contextStealthInstalled) return;
197
+ const ctx = mgr.getContext();
198
+ if (!ctx) {
199
+ console.warn("[poncho][browser] Cannot install stealth: no browser context");
200
+ return;
201
+ }
202
+ try {
203
+ await ctx.addInitScript({ content: STEALTH_INIT_SCRIPT });
204
+ this._contextStealthInstalled = true;
205
+ console.log("[poncho][browser] Stealth init script installed on context");
206
+ } catch (err) {
207
+ console.warn("[poncho][browser] Failed to install stealth init script:", (err as Error)?.message ?? err);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Force all new-tab navigations (window.open, target="_blank") to open
213
+ * in the current tab instead. Agents operate on a single tab at a time
214
+ * and can't see or interact with popups.
215
+ */
216
+ private async installSameTabScript(mgr: BrowserManagerInstance): Promise<void> {
217
+ const ctx = mgr.getContext();
218
+ if (!ctx) return;
219
+ try {
220
+ await ctx.addInitScript({ content: SAME_TAB_INIT_SCRIPT });
221
+ } catch (err) {
222
+ console.warn("[poncho][browser] Failed to install same-tab init script:", (err as Error)?.message ?? err);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Override the user-agent via CDP on the current page target.
228
+ * CDP Network.setUserAgentOverride is per-target, so call per-tab.
229
+ */
230
+ private async overrideUserAgentOnPage(mgr: BrowserManagerInstance, conversationId: string): Promise<void> {
231
+ if (this._uaOverrideApplied.has(conversationId)) return;
232
+ const ua = this.stealthUserAgent;
233
+ if (!ua) return;
234
+ try {
235
+ const cdp = await mgr.getCDPSession();
236
+ await cdp.send("Network.setUserAgentOverride", {
237
+ userAgent: ua,
238
+ acceptLanguage: "en-US,en;q=0.9",
239
+ platform: platform() === "darwin" ? "macOS" : platform() === "win32" ? "Win32" : "Linux x86_64",
240
+ });
241
+ this._uaOverrideApplied.add(conversationId);
242
+ } catch (err) {
243
+ console.warn("[poncho][browser] Failed to override UA via CDP:", (err as Error)?.message ?? err);
244
+ }
245
+ }
246
+
119
247
  private async launchFreshManager(): Promise<BrowserManagerInstance> {
120
248
  const Ctor = await getBrowserManagerCtor();
121
249
  const mgr = new Ctor();
@@ -123,13 +251,36 @@ export class BrowserSession {
123
251
  const viewport = this.config.viewport ?? { width: 1280, height: 720 };
124
252
  await mkdir(this.profileDir, { recursive: true });
125
253
 
126
- await mgr.launch({
254
+ const launchOpts: Record<string, unknown> = {
127
255
  action: "launch",
128
256
  headless: this.config.headless ?? true,
129
257
  viewport: { width: viewport.width ?? 1280, height: viewport.height ?? 720 },
130
258
  executablePath: this.config.executablePath,
131
259
  profile: this.profileDir,
132
- });
260
+ };
261
+
262
+ if (this.stealthEnabled) {
263
+ const ua = this.stealthUserAgent!;
264
+ launchOpts.userAgent = ua;
265
+ launchOpts.args = buildStealthArgs(ua);
266
+ console.log("[poncho][browser] Launching with stealth mode enabled (UA: " + ua + ")");
267
+ } else if (this.config.userAgent) {
268
+ launchOpts.userAgent = this.config.userAgent;
269
+ }
270
+
271
+ await mgr.launch(launchOpts as Parameters<BrowserManagerInstance["launch"]>[0]);
272
+
273
+ // Reset stealth tracking for fresh browser
274
+ this._contextStealthInstalled = false;
275
+ this._uaOverrideApplied.clear();
276
+
277
+ // Install context-level stealth (covers all tabs, all navigations)
278
+ if (this.stealthEnabled) {
279
+ await this.installContextStealth(mgr);
280
+ }
281
+
282
+ // Redirect new-tab navigations into the current tab
283
+ await this.installSameTabScript(mgr);
133
284
 
134
285
  try {
135
286
  const cdp = await mgr.getCDPSession();
@@ -149,6 +300,8 @@ export class BrowserSession {
149
300
  // Manager exists but is dead/stale -- discard it
150
301
  try { await this.manager.close(); } catch { /* */ }
151
302
  this.manager = undefined;
303
+ this._contextStealthInstalled = false;
304
+ this._uaOverrideApplied.clear();
152
305
  // Clear tab state since they belonged to the dead browser
153
306
  for (const [cid, tab] of this.tabs) {
154
307
  if (tab.tabIndex >= 0) {
@@ -186,6 +339,7 @@ export class BrowserSession {
186
339
  oldest.tab.url = undefined;
187
340
  this.emitStatus(oldest.cid);
188
341
  this.tabs.delete(oldest.cid);
342
+ this._uaOverrideApplied.delete(oldest.cid);
189
343
  }
190
344
 
191
345
  /** Reconcile tab indices with the manager's actual page list. */
@@ -267,6 +421,8 @@ export class BrowserSession {
267
421
  console.log("[poncho][browser] Browser died mid-open, relaunching...");
268
422
  try { await this.manager?.close(); } catch { /* */ }
269
423
  this.manager = undefined;
424
+ this._contextStealthInstalled = false;
425
+ this._uaOverrideApplied.clear();
270
426
  for (const [, t] of this.tabs) {
271
427
  if (t.tabIndex >= 0) { t.tabIndex = -1; t.active = false; t.url = undefined; }
272
428
  }
@@ -281,6 +437,13 @@ export class BrowserSession {
281
437
  private async _doOpen(conversationId: string, url: string): Promise<{ title?: string }> {
282
438
  const mgr = await this.ensureManager();
283
439
  const tab = await this.switchToConversation(mgr, conversationId);
440
+
441
+ // Ensure context-level stealth is installed (covers reused managers too)
442
+ if (this.stealthEnabled) {
443
+ await this.installContextStealth(mgr);
444
+ await this.overrideUserAgentOnPage(mgr, conversationId);
445
+ }
446
+
284
447
  const page = mgr.getPage();
285
448
 
286
449
  await (page as unknown as { goto(url: string, opts?: Record<string, unknown>): Promise<unknown> })
@@ -375,6 +538,32 @@ export class BrowserSession {
375
538
  }
376
539
  }
377
540
 
541
+ async clickText(conversationId: string, text: string, exact?: boolean): Promise<void> {
542
+ await this.lock();
543
+ try {
544
+ const mgr = await this.ensureManager();
545
+ const tab = await this.switchToConversation(mgr, conversationId);
546
+ const selector = exact ? `text="${text}"` : `text=${text}`;
547
+ const locator = mgr.getLocator(selector);
548
+ await locator.click();
549
+ tab.url = mgr.getPage().url();
550
+ } finally {
551
+ this.unlock();
552
+ }
553
+ }
554
+
555
+ async executeJs(conversationId: string, script: string): Promise<unknown> {
556
+ await this.lock();
557
+ try {
558
+ const mgr = await this.ensureManager();
559
+ await this.switchToConversation(mgr, conversationId);
560
+ const page = mgr.getPage();
561
+ return await page.evaluate(script);
562
+ } finally {
563
+ this.unlock();
564
+ }
565
+ }
566
+
378
567
  async closeTab(conversationId: string): Promise<void> {
379
568
  await this.lock();
380
569
  try {
@@ -402,6 +591,7 @@ export class BrowserSession {
402
591
  tab.url = undefined;
403
592
  this.emitStatus(conversationId);
404
593
  this.tabs.delete(conversationId);
594
+ this._uaOverrideApplied.delete(conversationId);
405
595
  } finally {
406
596
  this.unlock();
407
597
  }
@@ -632,6 +822,8 @@ export class BrowserSession {
632
822
  await this.persistStorageState();
633
823
  try { await this.manager?.close(); } catch { /* */ }
634
824
  this.manager = undefined;
825
+ this._contextStealthInstalled = false;
826
+ this._uaOverrideApplied.clear();
635
827
  for (const [cid, tab] of this.tabs) {
636
828
  tab.active = false;
637
829
  tab.url = undefined;
package/src/stealth.ts ADDED
@@ -0,0 +1,195 @@
1
+ import { platform } from "node:os";
2
+
3
+ /**
4
+ * Returns a realistic Chrome user-agent string for the host OS.
5
+ * Uses a recent stable Chrome version to blend in with normal traffic.
6
+ */
7
+ export function defaultUserAgent(): string {
8
+ const chromeVersion = "145.0.7632.117";
9
+ const os = platform();
10
+
11
+ if (os === "darwin") {
12
+ return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
13
+ }
14
+
15
+ if (os === "win32") {
16
+ return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
17
+ }
18
+
19
+ return `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
20
+ }
21
+
22
+ /**
23
+ * Static Chromium flags that suppress automation fingerprints.
24
+ */
25
+ export const STEALTH_ARGS: string[] = [
26
+ "--disable-blink-features=AutomationControlled",
27
+ "--enable-webgl",
28
+ "--use-gl=angle",
29
+ "--enable-features=VaapiVideoDecoder",
30
+ "--ignore-gpu-blocklist",
31
+ ];
32
+
33
+ /**
34
+ * Build the full stealth args array including the browser-level user-agent
35
+ * override. The `--user-agent` flag sets the UA globally — including in
36
+ * Web Workers and Service Workers — which context-level overrides can't reach.
37
+ */
38
+ export function buildStealthArgs(userAgent: string): string[] {
39
+ return [...STEALTH_ARGS, `--user-agent=${userAgent}`];
40
+ }
41
+
42
+ /**
43
+ * JS source injected via Playwright context.addInitScript().
44
+ * Runs before ALL page scripts on every navigation, every tab.
45
+ * Each section has its own try/catch so one failure doesn't block the rest.
46
+ */
47
+ export const STEALTH_INIT_SCRIPT = `
48
+ (() => {
49
+ // 1. navigator.webdriver → false
50
+ try {
51
+ Object.defineProperty(Navigator.prototype, 'webdriver', {
52
+ get: () => false,
53
+ configurable: true,
54
+ });
55
+ } catch {}
56
+
57
+ // 2. window.chrome stub (headless Chromium omits it)
58
+ try {
59
+ if (!window.chrome) {
60
+ const chrome = {
61
+ app: {
62
+ isInstalled: false,
63
+ InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
64
+ RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
65
+ getDetails() { return null; },
66
+ getIsInstalled() { return false; },
67
+ installState() { return 'not_installed'; },
68
+ },
69
+ runtime: {
70
+ OnInstalledReason: { CHROME_UPDATE: 'chrome_update', INSTALL: 'install', SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update' },
71
+ OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
72
+ PlatformArch: { ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' },
73
+ PlatformNaclArch: { ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' },
74
+ PlatformOs: { ANDROID: 'android', CROS: 'cros', FUCHSIA: 'fuchsia', LINUX: 'linux', MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win' },
75
+ RequestUpdateCheckStatus: { NO_UPDATE: 'no_update', THROTTLED: 'throttled', UPDATE_AVAILABLE: 'update_available' },
76
+ connect() { return { onMessage: { addListener() {}, removeListener() {} }, postMessage() {}, disconnect() {} }; },
77
+ sendMessage() {},
78
+ id: undefined,
79
+ },
80
+ csi() { return {}; },
81
+ loadTimes() { return {}; },
82
+ };
83
+ Object.defineProperty(window, 'chrome', {
84
+ value: chrome,
85
+ writable: false,
86
+ enumerable: true,
87
+ configurable: false,
88
+ });
89
+ }
90
+ } catch {}
91
+
92
+ // 3. navigator.plugins — expose the plugins a real Chrome has
93
+ try {
94
+ const makeMimeType = (type, desc, suffixes, plugin) => {
95
+ const mt = Object.create(MimeType.prototype);
96
+ Object.defineProperties(mt, {
97
+ type: { get: () => type },
98
+ description: { get: () => desc },
99
+ suffixes: { get: () => suffixes },
100
+ enabledPlugin: { get: () => plugin },
101
+ });
102
+ return mt;
103
+ };
104
+ const fakePluginDefs = [
105
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', hasMime: true },
106
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '', hasMime: false },
107
+ { name: 'Native Client', filename: 'internal-nacl-plugin', description: '', hasMime: false },
108
+ ];
109
+ const plugins = [];
110
+ for (const fp of fakePluginDefs) {
111
+ const p = Object.create(Plugin.prototype);
112
+ const mime = fp.hasMime ? makeMimeType('application/x-google-chrome-pdf', 'Portable Document Format', 'pdf', p) : null;
113
+ Object.defineProperties(p, {
114
+ name: { get: () => fp.name },
115
+ filename: { get: () => fp.filename },
116
+ description: { get: () => fp.description },
117
+ length: { get: () => mime ? 1 : 0 },
118
+ });
119
+ if (mime) Object.defineProperty(p, 0, { get: () => mime });
120
+ Object.defineProperty(p, 'item', { value: (i) => i === 0 && mime ? mime : null });
121
+ Object.defineProperty(p, 'namedItem', { value: (n) => mime && n === mime.type ? mime : null });
122
+ Object.defineProperty(p, Symbol.iterator, { value: function*() { if (mime) yield mime; } });
123
+ plugins.push(p);
124
+ }
125
+ const pluginArray = Object.create(PluginArray.prototype);
126
+ plugins.forEach((p, i) => Object.defineProperty(pluginArray, i, { get: () => p, enumerable: true }));
127
+ Object.defineProperty(pluginArray, 'length', { get: () => plugins.length });
128
+ Object.defineProperty(pluginArray, 'item', { value: (i) => plugins[i] ?? null });
129
+ Object.defineProperty(pluginArray, 'namedItem', { value: (n) => plugins.find(p => p.name === n) ?? null });
130
+ Object.defineProperty(pluginArray, 'refresh', { value: () => {} });
131
+ Object.defineProperty(pluginArray, Symbol.iterator, { value: function*() { for (const p of plugins) yield p; } });
132
+ Object.defineProperty(navigator, 'plugins', { get: () => pluginArray, configurable: true });
133
+ } catch {}
134
+
135
+ // 4. navigator.languages
136
+ try {
137
+ Object.defineProperty(navigator, 'languages', {
138
+ get: () => Object.freeze(['en-US', 'en']),
139
+ configurable: true,
140
+ });
141
+ } catch {}
142
+
143
+ // 5. Notification.permission — return "default" (headless returns "denied")
144
+ try {
145
+ if (typeof Notification !== 'undefined' && Notification.permission === 'denied') {
146
+ Object.defineProperty(Notification, 'permission', {
147
+ get: () => 'default',
148
+ configurable: true,
149
+ });
150
+ }
151
+ } catch {}
152
+
153
+ // 6. WebGL vendor/renderer — hide SwiftShader (headless giveaway)
154
+ try {
155
+ const UNMASKED_VENDOR = 0x9245;
156
+ const UNMASKED_RENDERER = 0x9246;
157
+ const spoofVendor = 'Google Inc. (Intel)';
158
+ const spoofRenderer = 'ANGLE (Intel, Intel(R) Iris(TM) Plus Graphics, OpenGL 4.1)';
159
+ for (const Ctx of [WebGLRenderingContext, typeof WebGL2RenderingContext !== 'undefined' ? WebGL2RenderingContext : null].filter(Boolean)) {
160
+ const orig = Ctx.prototype.getParameter;
161
+ Ctx.prototype.getParameter = function(param) {
162
+ if (param === UNMASKED_VENDOR) return spoofVendor;
163
+ if (param === UNMASKED_RENDERER) return spoofRenderer;
164
+ return orig.call(this, param);
165
+ };
166
+ }
167
+ } catch {}
168
+
169
+ // 7. Prevent iframe-based detection of navigator.webdriver
170
+ try {
171
+ const origCreate = Document.prototype.createElement;
172
+ Document.prototype.createElement = function(...args) {
173
+ const el = origCreate.apply(this, args);
174
+ if (el.nodeName === 'IFRAME') {
175
+ const origGet = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow')?.get;
176
+ if (origGet) {
177
+ Object.defineProperty(el, 'contentWindow', {
178
+ get() {
179
+ const w = origGet.call(this);
180
+ if (w) {
181
+ try {
182
+ Object.defineProperty(w.navigator, 'webdriver', { get: () => false, configurable: true });
183
+ } catch {}
184
+ }
185
+ return w;
186
+ },
187
+ configurable: true,
188
+ });
189
+ }
190
+ }
191
+ return el;
192
+ };
193
+ } catch {}
194
+ })();
195
+ `;
package/src/tools.ts CHANGED
@@ -73,6 +73,62 @@ export function createBrowserTools(
73
73
  return { clicked: ref };
74
74
  },
75
75
  },
76
+ {
77
+ name: "browser_click_text",
78
+ description:
79
+ "Click the first visible element on the page that contains the given text. " +
80
+ "Use this when an element doesn't appear in the snapshot — e.g. styled divs acting as buttons. " +
81
+ "By default matches substring (case-insensitive); set exact=true for exact text match.",
82
+ inputSchema: {
83
+ type: "object",
84
+ properties: {
85
+ text: {
86
+ type: "string",
87
+ description: "The visible text of the element to click",
88
+ },
89
+ exact: {
90
+ type: "boolean",
91
+ description:
92
+ "If true, match the exact full text (case-sensitive). Default: false (substring, case-insensitive).",
93
+ },
94
+ },
95
+ required: ["text"],
96
+ },
97
+ handler: async (input: BrowserToolInput) => {
98
+ const session = getSession();
99
+ const text = String(input.text ?? "");
100
+ if (!text) throw new Error("text is required");
101
+ const exact = input.exact === true;
102
+ await session.clickText(getConversationId(), text, exact);
103
+ return { clicked: text, exact };
104
+ },
105
+ },
106
+ {
107
+ name: "browser_execute_js",
108
+ description:
109
+ "Execute JavaScript in the current page context and return the result. " +
110
+ "Use this to inspect or interact with the DOM when snapshot refs aren't available — " +
111
+ "e.g. finding elements by text content, getting bounding boxes, or clicking elements by selector. " +
112
+ "The script is evaluated via page.evaluate(); return a value to get it back.",
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: {
116
+ script: {
117
+ type: "string",
118
+ description:
119
+ "JavaScript code to evaluate in the page. Use a return statement or expression to get a result back.",
120
+ },
121
+ },
122
+ required: ["script"],
123
+ },
124
+ handler: async (input: BrowserToolInput) => {
125
+ const session = getSession();
126
+ const script = String(input.script ?? "");
127
+ if (!script) throw new Error("script is required");
128
+ const result = await session.executeJs(getConversationId(), script);
129
+ return { result: result ?? null };
130
+ },
131
+ },
76
132
  {
77
133
  name: "browser_type",
78
134
  description:
package/src/types.ts CHANGED
@@ -62,5 +62,12 @@ export interface BrowserConfig {
62
62
  sessionName?: string;
63
63
  executablePath?: string;
64
64
  headless?: boolean;
65
+ /** Custom user-agent string. When `stealth` is enabled a realistic Chrome UA
66
+ * is used by default — set this to override it. */
67
+ userAgent?: string;
68
+ /** Reduce bot-detection fingerprints. Defaults to `true`.
69
+ * Patches navigator.webdriver, injects window.chrome shim, sets a realistic
70
+ * user-agent, and passes anti-automation Chrome flags. */
71
+ stealth?: boolean;
65
72
  storagePersistence?: BrowserStoragePersistence;
66
73
  }