@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.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +12 -0
- package/dist/index.d.ts +53 -1
- package/dist/index.js +387 -4
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/session.ts +196 -4
- package/src/stealth.ts +195 -0
- package/src/tools.ts +56 -0
- package/src/types.ts +7 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @poncho-ai/browser@0.
|
|
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
|
[34mCLI[39m Building entry: src/index.ts
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
[34mCLI[39m tsup v8.5.1
|
|
8
8
|
[34mCLI[39m Target: es2022
|
|
9
9
|
[34mESM[39m Build start
|
|
10
|
-
[32mESM[39m [1mdist/index.js [22m[
|
|
11
|
-
[32mESM[39m ⚡️ Build success in
|
|
10
|
+
[32mESM[39m [1mdist/index.js [22m[32m34.91 KB[39m
|
|
11
|
+
[32mESM[39m ⚡️ Build success in 218ms
|
|
12
12
|
[34mDTS[39m Build start
|
|
13
|
-
[32mDTS[39m ⚡️ Build success in
|
|
14
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[
|
|
13
|
+
[32mDTS[39m ⚡️ Build success in 5616ms
|
|
14
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m12.44 KB[39m
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1137
|
+
STEALTH_ARGS,
|
|
1138
|
+
STEALTH_INIT_SCRIPT,
|
|
1139
|
+
buildStealthArgs,
|
|
1140
|
+
createBrowserTools,
|
|
1141
|
+
defaultUserAgent
|
|
759
1142
|
};
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
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<
|
|
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
|
-
|
|
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
|
}
|