@f5-sales-demo/xcsh 19.98.7 → 19.98.9

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.98.7",
4
+ "version": "19.98.9",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -57,13 +57,13 @@
57
57
  "dependencies": {
58
58
  "@agentclientprotocol/sdk": "1.3.0",
59
59
  "@mozilla/readability": "^0.6",
60
- "@f5-sales-demo/xcsh-stats": "19.98.7",
61
- "@f5-sales-demo/pi-agent-core": "19.98.7",
62
- "@f5-sales-demo/pi-ai": "19.98.7",
63
- "@f5-sales-demo/pi-natives": "19.98.7",
64
- "@f5-sales-demo/pi-resource-management": "19.98.7",
65
- "@f5-sales-demo/pi-tui": "19.98.7",
66
- "@f5-sales-demo/pi-utils": "19.98.7",
60
+ "@f5-sales-demo/xcsh-stats": "19.98.9",
61
+ "@f5-sales-demo/pi-agent-core": "19.98.9",
62
+ "@f5-sales-demo/pi-ai": "19.98.9",
63
+ "@f5-sales-demo/pi-natives": "19.98.9",
64
+ "@f5-sales-demo/pi-resource-management": "19.98.9",
65
+ "@f5-sales-demo/pi-tui": "19.98.9",
66
+ "@f5-sales-demo/pi-utils": "19.98.9",
67
67
  "@sinclair/typebox": "^0.34",
68
68
  "@xterm/headless": "^6.0",
69
69
  "ajv": "^8.20",
@@ -74,7 +74,7 @@
74
74
  "linkedom": "^0.18",
75
75
  "lru-cache": "11.5.2",
76
76
  "markit-ai": "0.5.3",
77
- "puppeteer": "^24.37",
77
+ "puppeteer": "^25.3",
78
78
  "yaml": "2.9.0",
79
79
  "zod": "4.4.3"
80
80
  },
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.98.7",
21
- "commit": "0a7dd046f51c31f5c15b62cf4fb7a2b77f179d4e",
22
- "shortCommit": "0a7dd04",
20
+ "version": "19.98.9",
21
+ "commit": "f920b739147c8af141a54972d353e0522a276fcc",
22
+ "shortCommit": "f920b73",
23
23
  "branch": "main",
24
- "tag": "v19.98.7",
25
- "commitDate": "2026-07-28T11:57:17Z",
26
- "buildDate": "2026-07-28T12:27:02.779Z",
24
+ "tag": "v19.98.9",
25
+ "commitDate": "2026-07-28T14:28:13Z",
26
+ "buildDate": "2026-07-28T15:05:19.433Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/0a7dd046f51c31f5c15b62cf4fb7a2b77f179d4e",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.7"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/f920b739147c8af141a54972d353e0522a276fcc",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.9"
33
33
  };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Assembles the stealth init script injected into every page.
3
+ *
4
+ * Split out of `browser.ts` so the bundle can be built and asserted without a
5
+ * `BrowserTool` instance — see `test/browser/stealth-bundle.test.ts` (CI) and
6
+ * `test/e2e/stealth-surfaces.e2e.test.ts` (local, real Chrome).
7
+ */
8
+
9
+ import stealthTamperingScript from "./puppeteer/00_stealth_tampering.txt" with { type: "text" };
10
+ import stealthActivityScript from "./puppeteer/01_stealth_activity.txt" with { type: "text" };
11
+ import stealthHairlineScript from "./puppeteer/02_stealth_hairline.txt" with { type: "text" };
12
+ import stealthBotdScript from "./puppeteer/03_stealth_botd.txt" with { type: "text" };
13
+ import stealthIframeScript from "./puppeteer/04_stealth_iframe.txt" with { type: "text" };
14
+ import stealthWebglScript from "./puppeteer/05_stealth_webgl.txt" with { type: "text" };
15
+ import stealthScreenScript from "./puppeteer/06_stealth_screen.txt" with { type: "text" };
16
+ import stealthFontsScript from "./puppeteer/07_stealth_fonts.txt" with { type: "text" };
17
+ import stealthAudioScript from "./puppeteer/08_stealth_audio.txt" with { type: "text" };
18
+ import stealthLocaleScript from "./puppeteer/09_stealth_locale.txt" with { type: "text" };
19
+ import stealthPluginsScript from "./puppeteer/10_stealth_plugins.txt" with { type: "text" };
20
+ import stealthHardwareScript from "./puppeteer/11_stealth_hardware.txt" with { type: "text" };
21
+ import stealthCodecsScript from "./puppeteer/12_stealth_codecs.txt" with { type: "text" };
22
+
23
+ /**
24
+ * The injected surfaces, in load order. Order is load-bearing: `tampering` must
25
+ * run first so later scripts' patched functions are already covered by its
26
+ * `Function.prototype.toString` shim.
27
+ *
28
+ * There is deliberately no `worker` surface. It rewrote every Worker to a `blob:`
29
+ * URL to inject a prelude, which broke relative-URL workers outright and any
30
+ * worker under `worker-src 'self'`, while its prelude was dead code — it called
31
+ * `Object_defineProperty`, a page-realm binding a worker realm does not inherit.
32
+ * Chrome already propagates the page's CDP user-agent override into workers,
33
+ * including the brand list, so nothing was lost by removing it. See #2560 and
34
+ * test/e2e/stealth-workers.e2e.test.ts.
35
+ */
36
+ export const STEALTH_SCRIPTS: ReadonlyArray<{ readonly name: string; readonly source: string }> = [
37
+ { name: "tampering", source: stealthTamperingScript },
38
+ { name: "activity", source: stealthActivityScript },
39
+ { name: "hairline", source: stealthHairlineScript },
40
+ { name: "botd", source: stealthBotdScript },
41
+ { name: "iframe", source: stealthIframeScript },
42
+ { name: "webgl", source: stealthWebglScript },
43
+ { name: "screen", source: stealthScreenScript },
44
+ { name: "fonts", source: stealthFontsScript },
45
+ { name: "audio", source: stealthAudioScript },
46
+ { name: "locale", source: stealthLocaleScript },
47
+ { name: "plugins", source: stealthPluginsScript },
48
+ { name: "hardware", source: stealthHardwareScript },
49
+ { name: "codecs", source: stealthCodecsScript },
50
+ ];
51
+
52
+ export type StealthBundleOptions = {
53
+ /**
54
+ * Name of a global to collect per-script failures into, as
55
+ * `[{ name, message }]`. Omit in production: each script is wrapped in its
56
+ * own try/catch so one failure cannot take down the rest, but recording those
57
+ * failures means writing a `window.<name>` that a detection script could look
58
+ * for — which would defeat the point of the bundle. Tests pass a name so a
59
+ * silently-broken script is assertable instead of invisible.
60
+ */
61
+ readonly errorSink?: string;
62
+ };
63
+
64
+ /** Wraps one script so a failure cannot stop the scripts after it. */
65
+ function wrapScript(script: { name: string; source: string }, errorSink: string | undefined): string {
66
+ const record = errorSink
67
+ ? `(globalThis[${JSON.stringify(errorSink)}] ||= []).push({ name: ${JSON.stringify(script.name)}, message: String(e && e.message || e) });`
68
+ : "";
69
+ return `
70
+ try {
71
+ ${script.source};
72
+ } catch (e) { ${record} }
73
+ `;
74
+ }
75
+
76
+ /**
77
+ * Builds the full init script.
78
+ *
79
+ * Caches pristine builtins up front so the surfaces can still reach unpatched
80
+ * references after earlier surfaces have replaced the page's own.
81
+ *
82
+ * The cache is taken from the CURRENT realm, not a detached iframe. This script
83
+ * is injected via `Page.addScriptToEvaluateOnNewDocument`, which runs before any
84
+ * page script — and before any DOM at all: at that point `document.readyState`
85
+ * is `"loading"` and `document.head`, `document.body` and
86
+ * `document.documentElement` are all null. An earlier version created a helper
87
+ * iframe and appended it to `document.head`, which threw on that first statement
88
+ * outside every per-surface guard, so all fourteen surfaces silently never ran.
89
+ * Because nothing else has executed yet, `globalThis`'s builtins are themselves
90
+ * still pristine, which is exactly what the iframe was there to provide.
91
+ */
92
+ export function buildStealthBundle(options: StealthBundleOptions = {}): string {
93
+ const joint = STEALTH_SCRIPTS.map(script => wrapScript(script, options.errorSink)).join(";\n");
94
+
95
+ return `(() => {
96
+ // Native function cache — this script runs first, so the current realm's
97
+ // builtins have not been touched yet.
98
+ const nativeWindow = globalThis;
99
+
100
+ // Cache pristine native functions
101
+ const Function_toString = nativeWindow.Function.prototype.toString;
102
+ const Object_getOwnPropertyDescriptor = nativeWindow.Object.getOwnPropertyDescriptor;
103
+ const Object_getOwnPropertyDescriptors = nativeWindow.Object.getOwnPropertyDescriptors;
104
+ const Object_getPrototypeOf = nativeWindow.Object.getPrototypeOf;
105
+ const Object_defineProperty = nativeWindow.Object.defineProperty;
106
+ const Object_getOwnPropertyDescriptorOriginal = nativeWindow.Object.getOwnPropertyDescriptor;
107
+ const Object_create = nativeWindow.Object.create;
108
+ const Object_keys = nativeWindow.Object.keys;
109
+ const Object_getOwnPropertyNames = nativeWindow.Object.getOwnPropertyNames;
110
+ const Object_entries = nativeWindow.Object.entries;
111
+ const Object_setPrototypeOf = nativeWindow.Object.setPrototypeOf;
112
+ const Object_assign = nativeWindow.Object.assign;
113
+ const Window_setTimeout = nativeWindow.setTimeout;
114
+ const Math_random = nativeWindow.Math.random;
115
+ const Math_floor = nativeWindow.Math.floor;
116
+ const Math_max = nativeWindow.Math.max;
117
+ const Math_min = nativeWindow.Math.min;
118
+ const Window_Event = nativeWindow.Event;
119
+ const Promise_resolve = nativeWindow.Promise.resolve.bind(nativeWindow.Promise);
120
+ const Window_Blob = nativeWindow.Blob;
121
+ const Window_Proxy = nativeWindow.Proxy;
122
+ const Intl_DateTimeFormat = nativeWindow.Intl.DateTimeFormat;
123
+ const Date_constructor = nativeWindow.Date;
124
+
125
+ ${joint}
126
+ })();`;
127
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Derives the stealth user-agent override from whichever Chrome puppeteer bundles.
3
+ *
4
+ * Split out of `browser.ts` so it can be asserted without launching a browser:
5
+ * every field here is a pure function of the raw user-agent string and the
6
+ * browser version, and the output changes whenever puppeteer's bundled Chrome
7
+ * changes major version. See `test/browser/user-agent-override.test.ts`.
8
+ */
9
+
10
+ const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
11
+
12
+ export type UserAgentOverride = {
13
+ userAgent: string;
14
+ platform: string;
15
+ acceptLanguage: string;
16
+ userAgentMetadata: {
17
+ brands: Array<{ brand: string; version: string }>;
18
+ fullVersion: string;
19
+ platform: string;
20
+ platformVersion: string;
21
+ architecture: string;
22
+ model: string;
23
+ mobile: boolean;
24
+ };
25
+ };
26
+
27
+ /**
28
+ * Chromium's GREASE permutation table, indexed by `majorVersion % 6`.
29
+ * Mirrors `GenerateBrandVersionList` in
30
+ * components/embedder_support/user_agent_utils.cc — Chrome shuffles the brand
31
+ * list per version so that servers cannot hardcode its ordering, and a client
32
+ * that emits a fixed order is detectable for that reason alone.
33
+ */
34
+ const BRAND_ORDERS: ReadonlyArray<readonly [number, number, number]> = [
35
+ [0, 1, 2],
36
+ [0, 2, 1],
37
+ [1, 0, 2],
38
+ [1, 2, 0],
39
+ [2, 0, 1],
40
+ [2, 1, 0],
41
+ ];
42
+
43
+ /** The escape characters Chromium permutes into the greased brand name. */
44
+ const GREASE_ESCAPE_CHARS = [" ", " ", ";"] as const;
45
+
46
+ /** Builds the three-entry brand list for a Chrome major version. */
47
+ function buildBrands(majorVersion: number): Array<{ brand: string; version: string }> {
48
+ const order = BRAND_ORDERS[majorVersion % BRAND_ORDERS.length] ?? BRAND_ORDERS[0];
49
+ const greasedBrand = `${GREASE_ESCAPE_CHARS[order[0]]}Not${GREASE_ESCAPE_CHARS[order[1]]}A${GREASE_ESCAPE_CHARS[order[2]]}Brand`;
50
+ const brands: Array<{ brand: string; version: string }> = [];
51
+ brands[order[0]] = { brand: greasedBrand, version: "99" };
52
+ brands[order[1]] = { brand: "Chromium", version: String(majorVersion) };
53
+ brands[order[2]] = { brand: "Google Chrome", version: String(majorVersion) };
54
+ return brands;
55
+ }
56
+
57
+ /**
58
+ * @param rawUserAgent the browser's own user-agent string, e.g. from `browser.userAgent()`
59
+ * @param browserVersion the browser version string, e.g. from `browser.version()`;
60
+ * used only when the user agent carries no `Chrome/<version>` token
61
+ */
62
+ export function deriveUserAgentOverride(rawUserAgent: string, browserVersion: string): UserAgentOverride {
63
+ let userAgent = rawUserAgent.replace("HeadlessChrome/", "Chrome/");
64
+ if (userAgent.includes("Linux") && !userAgent.includes("Android")) {
65
+ userAgent = userAgent.replace(/\(([^)]+)\)/, "(Windows NT 10.0; Win64; x64)");
66
+ }
67
+
68
+ const uaVersionMatch = userAgent.match(/Chrome\/([\d|.]+)/);
69
+ const fallbackVersionMatch = uaVersionMatch ?? browserVersion.match(/\/([\d|.]+)/);
70
+ const uaVersion = fallbackVersionMatch?.[1] ?? "0";
71
+ const majorVersion = Number.parseInt(uaVersion.split(".")[0] ?? "0", 10) || 0;
72
+ const isAndroid = userAgent.includes("Android");
73
+ const platform = userAgent.includes("Mac OS X")
74
+ ? "MacIntel"
75
+ : isAndroid
76
+ ? "Android"
77
+ : userAgent.includes("Linux")
78
+ ? "Linux"
79
+ : "Win32";
80
+ const platformFull = userAgent.includes("Mac OS X")
81
+ ? "Mac OS X"
82
+ : isAndroid
83
+ ? "Android"
84
+ : userAgent.includes("Linux")
85
+ ? "Linux"
86
+ : "Windows";
87
+ const platformVersion = userAgent.includes("Mac OS X ")
88
+ ? (userAgent.match(/Mac OS X ([^)]+)/)?.[1] ?? "")
89
+ : userAgent.includes("Android ")
90
+ ? (userAgent.match(/Android ([^;]+)/)?.[1] ?? "")
91
+ : userAgent.includes("Windows ")
92
+ ? (userAgent.match(/Windows .*?([\d|.]+);?/)?.[1] ?? "")
93
+ : "";
94
+ const architecture = isAndroid ? "" : "x86";
95
+ const model = isAndroid ? (userAgent.match(/Android.*?;\s([^)]+)/)?.[1] ?? "") : "";
96
+
97
+ return {
98
+ userAgent,
99
+ platform,
100
+ acceptLanguage: STEALTH_ACCEPT_LANGUAGE,
101
+ userAgentMetadata: {
102
+ brands: buildBrands(majorVersion),
103
+ fullVersion: uaVersion,
104
+ platform: platformFull,
105
+ platformVersion,
106
+ architecture,
107
+ model,
108
+ mobile: isAndroid,
109
+ },
110
+ };
111
+ }
@@ -27,22 +27,10 @@ import browserDescription from "../prompts/tools/browser.md" with { type: "text"
27
27
  import type { ToolSession } from "../sdk";
28
28
  import { resizeImage } from "../utils/image-resize";
29
29
  import { htmlToBasicMarkdown } from "../web/scrapers/types";
30
+ import { buildStealthBundle } from "./browser-stealth";
31
+ import { deriveUserAgentOverride, type UserAgentOverride } from "./browser-user-agent";
30
32
  import type { OutputMeta } from "./output-meta";
31
33
  import { expandPath, resolveToCwd } from "./path-utils";
32
- import stealthTamperingScript from "./puppeteer/00_stealth_tampering.txt" with { type: "text" };
33
- import stealthActivityScript from "./puppeteer/01_stealth_activity.txt" with { type: "text" };
34
- import stealthHairlineScript from "./puppeteer/02_stealth_hairline.txt" with { type: "text" };
35
- import stealthBotdScript from "./puppeteer/03_stealth_botd.txt" with { type: "text" };
36
- import stealthIframeScript from "./puppeteer/04_stealth_iframe.txt" with { type: "text" };
37
- import stealthWebglScript from "./puppeteer/05_stealth_webgl.txt" with { type: "text" };
38
- import stealthScreenScript from "./puppeteer/06_stealth_screen.txt" with { type: "text" };
39
- import stealthFontsScript from "./puppeteer/07_stealth_fonts.txt" with { type: "text" };
40
- import stealthAudioScript from "./puppeteer/08_stealth_audio.txt" with { type: "text" };
41
- import stealthLocaleScript from "./puppeteer/09_stealth_locale.txt" with { type: "text" };
42
- import stealthPluginsScript from "./puppeteer/10_stealth_plugins.txt" with { type: "text" };
43
- import stealthHardwareScript from "./puppeteer/11_stealth_hardware.txt" with { type: "text" };
44
- import stealthCodecsScript from "./puppeteer/12_stealth_codecs.txt" with { type: "text" };
45
- import stealthWorkerScript from "./puppeteer/13_stealth_worker.txt" with { type: "text" };
46
34
  import { formatScreenshot } from "./render-utils";
47
35
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
48
36
  import { toolResult } from "./tool-result";
@@ -99,8 +87,16 @@ export function pickCoDrivePage(pages: { url(): string }[]): number {
99
87
  }
100
88
 
101
89
  /**
102
- * Lazy-import puppeteer from a safe CWD so cosmiconfig doesn't choke
103
- * on malformed package.json files in the user's project tree.
90
+ * Lazy-import puppeteer from a safe CWD so its config loader doesn't choke on
91
+ * malformed package.json files in the user's project tree.
92
+ *
93
+ * Puppeteer 25 swapped cosmiconfig for lilconfig, so the original rationale no
94
+ * longer names the right library. Retained deliberately rather than removed:
95
+ * importing puppeteer 25 from a directory containing a deliberately malformed
96
+ * package.json was verified to succeed, but that only exercises import-time
97
+ * loading — whether `launch()` reads config lazily was not established, so
98
+ * dropping the guard would be an unverified behaviour change for a demo-critical
99
+ * path. It costs one chdir.
104
100
  */
105
101
  let puppeteerModule: typeof Puppeteer | undefined;
106
102
  async function loadPuppeteer(): Promise<typeof Puppeteer> {
@@ -123,7 +119,8 @@ const STEALTH_IGNORE_DEFAULT_ARGS = [
123
119
  "--disable-default-apps",
124
120
  "--disable-component-extensions-with-background-pages",
125
121
  ];
126
- const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
122
+ /** Kept in step with the locale profile in puppeteer/09_stealth_locale.txt. */
123
+ const STEALTH_TIMEZONE = "America/New_York";
127
124
  const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__";
128
125
  const INTERACTIVE_AX_ROLES = new Set([
129
126
  "button",
@@ -153,21 +150,6 @@ type PuppeteerCdpClient = {
153
150
  send: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
154
151
  };
155
152
 
156
- type UserAgentOverride = {
157
- userAgent: string;
158
- platform: string;
159
- acceptLanguage: string;
160
- userAgentMetadata: {
161
- brands: Array<{ brand: string; version: string }>;
162
- fullVersion: string;
163
- platform: string;
164
- platformVersion: string;
165
- architecture: string;
166
- model: string;
167
- mobile: boolean;
168
- };
169
- };
170
-
171
153
  function resolvePageClient(page: Page): PuppeteerCdpClient | null {
172
154
  const pageWithClient = page as Page & {
173
155
  _client?: (() => PuppeteerCdpClient) | PuppeteerCdpClient;
@@ -539,7 +521,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
539
521
  if (this.#page && !this.#page.isClosed()) {
540
522
  return this.#page;
541
523
  }
542
- if (!this.#browser?.isConnected()) {
524
+ if (!this.#browser?.connected) {
543
525
  return this.#resetBrowser(params);
544
526
  }
545
527
  // co-drive: reuse the human's current tab when attached, else a fresh page
@@ -698,9 +680,33 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
698
680
  async #applyStealthPatches(page: Page): Promise<void> {
699
681
  this.#patchSourceUrl(page);
700
682
  await this.#applyUserAgentOverride(page);
683
+ await this.#applyTimezoneOverride(page);
701
684
  await this.#injectStealthScripts(page);
702
685
  }
703
686
 
687
+ /**
688
+ * Overrides the timezone through CDP rather than in the injected scripts.
689
+ *
690
+ * Chrome recomputes Date, Intl, getTimezoneOffset and the zone name from the
691
+ * overridden zone, so they all agree. Patching them in page script cannot
692
+ * achieve that: the previous attempt rewrote only the displayed zone NAME and
693
+ * forced its zone into every Intl.DateTimeFormat call, which overrode a
694
+ * caller's explicit `timeZone` and corrupted formatted output (see
695
+ * 09_stealth_locale.txt).
696
+ */
697
+ async #applyTimezoneOverride(page: Page): Promise<void> {
698
+ const client = resolvePageClient(page);
699
+ if (!client) return;
700
+ try {
701
+ await client.send("Emulation.setTimezoneOverride", { timezoneId: STEALTH_TIMEZONE });
702
+ } catch (error) {
703
+ // A rejected zone id must not take the rest of the stealth setup down.
704
+ logger.debug("Failed to apply timezone override", {
705
+ error: error instanceof Error ? error.message : String(error),
706
+ });
707
+ }
708
+ }
709
+
704
710
  async #applyUserAgentOverride(page: Page): Promise<void> {
705
711
  const client = resolvePageClient(page);
706
712
  if (!client) return;
@@ -711,71 +717,8 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
711
717
 
712
718
  async #resolveUserAgentOverride(page: Page): Promise<UserAgentOverride> {
713
719
  if (this.#userAgentOverride) return this.#userAgentOverride;
714
- const rawUserAgent = await page.browser().userAgent();
715
- let userAgent = rawUserAgent.replace("HeadlessChrome/", "Chrome/");
716
- if (userAgent.includes("Linux") && !userAgent.includes("Android")) {
717
- userAgent = userAgent.replace(/\(([^)]+)\)/, "(Windows NT 10.0; Win64; x64)");
718
- }
719
-
720
- const uaVersionMatch = userAgent.match(/Chrome\/([\d|.]+)/);
721
- const fallbackVersionMatch = uaVersionMatch ?? (await page.browser().version()).match(/\/([\d|.]+)/);
722
- const uaVersion = fallbackVersionMatch?.[1] ?? "0";
723
- const majorVersion = Number.parseInt(uaVersion.split(".")[0] ?? "0", 10) || 0;
724
- const isAndroid = userAgent.includes("Android");
725
- const platform = userAgent.includes("Mac OS X")
726
- ? "MacIntel"
727
- : isAndroid
728
- ? "Android"
729
- : userAgent.includes("Linux")
730
- ? "Linux"
731
- : "Win32";
732
- const platformFull = userAgent.includes("Mac OS X")
733
- ? "Mac OS X"
734
- : isAndroid
735
- ? "Android"
736
- : userAgent.includes("Linux")
737
- ? "Linux"
738
- : "Windows";
739
- const platformVersion = userAgent.includes("Mac OS X ")
740
- ? (userAgent.match(/Mac OS X ([^)]+)/)?.[1] ?? "")
741
- : userAgent.includes("Android ")
742
- ? (userAgent.match(/Android ([^;]+)/)?.[1] ?? "")
743
- : userAgent.includes("Windows ")
744
- ? (userAgent.match(/Windows .*?([\d|.]+);?/)?.[1] ?? "")
745
- : "";
746
- const architecture = isAndroid ? "" : "x86";
747
- const model = isAndroid ? (userAgent.match(/Android.*?;\s([^)]+)/)?.[1] ?? "") : "";
748
-
749
- const brandOrders = [
750
- [0, 1, 2],
751
- [0, 2, 1],
752
- [1, 0, 2],
753
- [1, 2, 0],
754
- [2, 0, 1],
755
- [2, 1, 0],
756
- ];
757
- const order = brandOrders[majorVersion % brandOrders.length] ?? brandOrders[0];
758
- const escapedChars = [" ", " ", ";"];
759
- const greaseyBrand = `${escapedChars[order[0]]}Not${escapedChars[order[1]]}A${escapedChars[order[2]]}Brand`;
760
- const brands: { brand: string; version: string }[] = [];
761
- brands[order[0]] = { brand: greaseyBrand, version: "99" };
762
- brands[order[1]] = { brand: "Chromium", version: String(majorVersion) };
763
- brands[order[2]] = { brand: "Google Chrome", version: String(majorVersion) };
764
-
765
- this.#userAgentOverride = {
766
- userAgent,
767
- platform,
768
- acceptLanguage: STEALTH_ACCEPT_LANGUAGE,
769
- userAgentMetadata: {
770
- brands,
771
- fullVersion: uaVersion,
772
- platform: platformFull,
773
- platformVersion,
774
- architecture,
775
- model,
776
- mobile: isAndroid,
777
- },
778
- };
720
+ const browser = page.browser();
721
+ this.#userAgentOverride = deriveUserAgentOverride(await browser.userAgent(), await browser.version());
779
722
  return this.#userAgentOverride;
780
723
  }
781
724
 
@@ -877,70 +820,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
877
820
 
878
821
  /** Injects stealth scripts that cover common puppeteer detection surfaces. */
879
822
  async #injectStealthScripts(page: Page): Promise<void> {
880
- const scripts = [
881
- stealthTamperingScript,
882
- stealthActivityScript,
883
- stealthHairlineScript,
884
- stealthBotdScript,
885
- stealthIframeScript,
886
- stealthWebglScript,
887
- stealthScreenScript,
888
- stealthFontsScript,
889
- stealthAudioScript,
890
- stealthLocaleScript,
891
- stealthPluginsScript,
892
- stealthHardwareScript,
893
- stealthCodecsScript,
894
- stealthWorkerScript,
895
- ];
896
-
897
- const joint = scripts
898
- .map(
899
- script => `
900
- try {
901
- ${script};
902
- } catch (e) {}
903
- `,
904
- )
905
- .join(";\n");
906
-
907
- await page.evaluateOnNewDocument(`(() => {
908
- // Native function cache - captured before any tampering
909
- const iframe = document.createElement("iframe");
910
- iframe.style.display = "none";
911
- document.head.appendChild(iframe);
912
- const nativeWindow = iframe.contentWindow;
913
- if (!nativeWindow) return;
914
-
915
- // Cache pristine native functions
916
- const Function_toString = nativeWindow.Function.prototype.toString;
917
- const Object_getOwnPropertyDescriptor = nativeWindow.Object.getOwnPropertyDescriptor;
918
- const Object_getOwnPropertyDescriptors = nativeWindow.Object.getOwnPropertyDescriptors;
919
- const Object_getPrototypeOf = nativeWindow.Object.getPrototypeOf;
920
- const Object_defineProperty = nativeWindow.Object.defineProperty;
921
- const Object_getOwnPropertyDescriptorOriginal = nativeWindow.Object.getOwnPropertyDescriptor;
922
- const Object_create = nativeWindow.Object.create;
923
- const Object_keys = nativeWindow.Object.keys;
924
- const Object_getOwnPropertyNames = nativeWindow.Object.getOwnPropertyNames;
925
- const Object_entries = nativeWindow.Object.entries;
926
- const Object_setPrototypeOf = nativeWindow.Object.setPrototypeOf;
927
- const Object_assign = nativeWindow.Object.assign;
928
- const Window_setTimeout = nativeWindow.setTimeout;
929
- const Math_random = nativeWindow.Math.random;
930
- const Math_floor = nativeWindow.Math.floor;
931
- const Math_max = nativeWindow.Math.max;
932
- const Math_min = nativeWindow.Math.min;
933
- const Window_Event = nativeWindow.Event;
934
- const Promise_resolve = nativeWindow.Promise.resolve.bind(nativeWindow.Promise);
935
- const Window_Blob = nativeWindow.Blob;
936
- const Window_Proxy = nativeWindow.Proxy;
937
- const Intl_DateTimeFormat = nativeWindow.Intl.DateTimeFormat;
938
- const Date_constructor = nativeWindow.Date;
939
-
940
-
941
- ${joint}
942
-
943
- document.head.removeChild(iframe);})();`);
823
+ await page.evaluateOnNewDocument(buildStealthBundle());
944
824
  }
945
825
 
946
826
  async execute(
@@ -58,6 +58,3 @@ Object.getOwnPropertyDescriptor = function (obj, prop) {
58
58
 
59
59
  return descriptor;
60
60
  };
61
-
62
- // Cleanup
63
- document.head.removeChild(iframe);
@@ -372,6 +372,18 @@ if (navigator.permissions?.query) {
372
372
  }
373
373
  return originalQuery.call(this, parameters);
374
374
  };
375
+ // Mask the replacement, or String(navigator.permissions.query) hands a
376
+ // detector this function's source where Chrome reports native code. The
377
+ // tampering surface masks every function it patches, but this one is
378
+ // replaced here, after that surface has already run.
379
+ // Binding the cached native toString to the ORIGINAL query reproduces
380
+ // Chrome's exact output, same idiom as 04_stealth_iframe / 13_stealth_worker.
381
+ Object_defineProperty(navigator.permissions.query, "toString", {
382
+ value: Function_toString.bind(originalQuery),
383
+ writable: false,
384
+ configurable: true,
385
+ enumerable: false,
386
+ });
375
387
  }
376
388
  }
377
389
 
@@ -23,24 +23,34 @@ const addContentWindowProxy = (iframe) => {
23
23
  }
24
24
  };
25
25
 
26
+ // The native accessor, captured from the prototype so the shim below can hand
27
+ // off to it. Without this the shim has nothing to delegate to.
28
+ const nativeSrcdocDescriptor = Object_getOwnPropertyDescriptor(HTMLIFrameElement.prototype, "srcdoc");
29
+
26
30
  const handleIframeCreation = (target, thisArg, args) => {
27
31
  const iframe = target.apply(thisArg, args);
28
- const originalIframe = iframe;
29
- const originalSrcdoc = originalIframe.srcdoc;
30
32
 
33
+ // A one-shot shim: install the contentWindow proxy on the first srcdoc
34
+ // assignment, then step aside so the element behaves natively.
35
+ //
36
+ // It must step aside by DELETING itself. An earlier version redefined srcdoc
37
+ // as a non-writable data property and then assigned through the same element,
38
+ // so the write silently failed against its own frozen property and the native
39
+ // setter was never reached: srcdoc stayed "", the attribute was never set, and
40
+ // every dynamically created srcdoc iframe loaded empty.
31
41
  Object_defineProperty(iframe, "srcdoc", {
32
42
  configurable: true,
33
43
  get() {
34
- return originalSrcdoc;
44
+ return nativeSrcdocDescriptor?.get ? nativeSrcdocDescriptor.get.call(this) : "";
35
45
  },
36
46
  set(newValue) {
37
47
  addContentWindowProxy(this);
38
- Object_defineProperty(iframe, "srcdoc", {
39
- configurable: false,
40
- writable: false,
41
- value: originalSrcdoc,
42
- });
43
- originalIframe.srcdoc = newValue;
48
+ delete iframe.srcdoc;
49
+ if (nativeSrcdocDescriptor?.set) {
50
+ nativeSrcdocDescriptor.set.call(iframe, newValue);
51
+ } else {
52
+ iframe.setAttribute("srcdoc", newValue);
53
+ }
44
54
  },
45
55
  });
46
56
 
@@ -1,7 +1,6 @@
1
1
  // Define a consistent locale profile
2
2
  const locale = "en-US";
3
3
  const languages = ["en-US", "en"];
4
- const timezone = "America/New_York";
5
4
 
6
5
  // Override navigator language properties
7
6
  Object_defineProperty(navigator, "language", {
@@ -15,32 +14,22 @@ Object_defineProperty(navigator, "languages", {
15
14
  enumerable: true,
16
15
  });
17
16
 
18
- // Override Intl.DateTimeFormat for timezone consistency
19
- const OriginalDateTimeFormat = Intl_DateTimeFormat;
20
- Intl.DateTimeFormat = class extends OriginalDateTimeFormat {
21
- constructor(locales, options) {
22
- const mergedOptions = { ...options, timeZone: timezone };
23
- super(locales, mergedOptions);
24
- }
25
- resolvedOptions() {
26
- const options = super.resolvedOptions();
27
- options.timeZone = timezone;
28
- return options;
29
- }
30
- };
31
-
32
- // Ensure Date timezone is consistent
33
- const originalDateConstructor = Date_constructor;
34
- const originalToString = originalDateConstructor.prototype.toString;
35
- const originalToTimeString = originalDateConstructor.prototype.toTimeString;
36
-
37
- Date.prototype.toString = function () {
38
- return originalToString
39
- .call(this)
40
- .replace(/\(.*\)$/, "(Eastern Standard Time)");
41
- };
42
- Date.prototype.toTimeString = function () {
43
- return originalToTimeString
44
- .call(this)
45
- .replace(/\(.*\)$/, "(Eastern Standard Time)");
46
- };
17
+ // Timezone is NOT spoofed here.
18
+ //
19
+ // It is applied through CDP Emulation.setTimezoneOverride in browser.ts, which
20
+ // shifts the clock itself: Date, Intl, getTimezoneOffset and the zone name all
21
+ // agree, because Chrome computes them from the overridden zone.
22
+ //
23
+ // Two earlier attempts in this file were worse than nothing:
24
+ //
25
+ // 1. Wrapping Intl.DateTimeFormat and forcing `timeZone` into every options
26
+ // object overrode the CALLER's explicit zone, so
27
+ // `new Intl.DateTimeFormat("en-US", { timeZone: "UTC" })` silently formatted
28
+ // in New York — 08:00 where the page asked for 12:00. That corrupts data a
29
+ // page displays or submits, which is far worse than a fingerprinting tell.
30
+ //
31
+ // 2. Rewriting Date.prototype.toString/toTimeString only replaced the zone
32
+ // NAME, never the numeric offset, and hardcoded "Eastern Standard Time" —
33
+ // so in July it claimed EST while getTimezoneOffset still returned 240
34
+ // (EDT). Self-contradictory, and a detector comparing the two sees it
35
+ // immediately.
@@ -1,74 +0,0 @@
1
- const patchWorkerConstructor = (name, OriginalWorker) => {
2
- if (typeof OriginalWorker !== "function") return;
3
-
4
- const buildWrappedUrl = (scriptURL, options) => {
5
- const ua = navigator.userAgent;
6
- const platform = navigator.platform;
7
- const uaData = navigator.userAgentData && typeof navigator.userAgentData.toJSON === "function"
8
- ? navigator.userAgentData.toJSON()
9
- : navigator.userAgentData;
10
-
11
- const preludeLines = [
12
- "try {",
13
- `const ua = ${JSON.stringify(ua)};`,
14
- `const platform = ${JSON.stringify(platform)};`,
15
- "Object_defineProperty(self.navigator, 'userAgent', { get: () => ua, configurable: true });",
16
- "Object_defineProperty(self.navigator, 'platform', { get: () => platform, configurable: true });",
17
- ];
18
-
19
- if (uaData) {
20
- preludeLines.push(`const uaData = ${JSON.stringify(uaData)};`);
21
- preludeLines.push(
22
- "Object_defineProperty(self.navigator, 'userAgentData', { get: () => uaData, configurable: true });",
23
- );
24
- }
25
-
26
- preludeLines.push("} catch (e) {};");
27
- const prelude = preludeLines.join("\n");
28
- const importLine = options?.type === "module"
29
- ? `import ${JSON.stringify(String(scriptURL))};`
30
- : `importScripts(${JSON.stringify(String(scriptURL))});`;
31
- const blob = new Window_Blob([prelude, "\n", importLine], { type: "application/javascript" });
32
- const url = URL.createObjectURL(blob);
33
- return url;
34
- };
35
-
36
- const handler = {
37
- construct(target, args) {
38
- try {
39
- const scriptURL = args?.[0];
40
- const options = args?.[1];
41
- if (!scriptURL) {
42
- return new target(...(args || []));
43
- }
44
- const wrappedUrl = buildWrappedUrl(scriptURL, options);
45
- const worker = new target(wrappedUrl, options);
46
- URL.revokeObjectURL(wrappedUrl);
47
- return worker;
48
- } catch {
49
- return new target(...(args || []));
50
- }
51
- },
52
- apply(target, thisArg, args) {
53
- return Reflect.apply(target, thisArg, args || []);
54
- },
55
- };
56
-
57
- const proxied = new Window_Proxy(OriginalWorker, handler);
58
- Object_defineProperty(window, name, {
59
- value: proxied,
60
- writable: true,
61
- configurable: true,
62
- });
63
- Object_defineProperty(window[name], "toString", {
64
- value: Function_toString.bind(OriginalWorker),
65
- writable: false,
66
- configurable: true,
67
- enumerable: false,
68
- });
69
- };
70
-
71
- try {
72
- patchWorkerConstructor("Worker", window.Worker);
73
- patchWorkerConstructor("SharedWorker", window.SharedWorker);
74
- } catch {}