@f5-sales-demo/xcsh 19.98.6 → 19.98.8

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.6",
4
+ "version": "19.98.8",
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.6",
61
- "@f5-sales-demo/pi-agent-core": "19.98.6",
62
- "@f5-sales-demo/pi-ai": "19.98.6",
63
- "@f5-sales-demo/pi-natives": "19.98.6",
64
- "@f5-sales-demo/pi-resource-management": "19.98.6",
65
- "@f5-sales-demo/pi-tui": "19.98.6",
66
- "@f5-sales-demo/pi-utils": "19.98.6",
60
+ "@f5-sales-demo/xcsh-stats": "19.98.8",
61
+ "@f5-sales-demo/pi-agent-core": "19.98.8",
62
+ "@f5-sales-demo/pi-ai": "19.98.8",
63
+ "@f5-sales-demo/pi-natives": "19.98.8",
64
+ "@f5-sales-demo/pi-resource-management": "19.98.8",
65
+ "@f5-sales-demo/pi-tui": "19.98.8",
66
+ "@f5-sales-demo/pi-utils": "19.98.8",
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.6",
21
- "commit": "9a4b5f250189b2d423b62f15f02c2403c070fb3d",
22
- "shortCommit": "9a4b5f2",
20
+ "version": "19.98.8",
21
+ "commit": "8d1e4f3e9673585d8835fd45b7b6eb7c0f21b529",
22
+ "shortCommit": "8d1e4f3",
23
23
  "branch": "main",
24
- "tag": "v19.98.6",
25
- "commitDate": "2026-07-28T11:16:35Z",
26
- "buildDate": "2026-07-28T11:40:19.737Z",
24
+ "tag": "v19.98.8",
25
+ "commitDate": "2026-07-28T13:27:14Z",
26
+ "buildDate": "2026-07-28T13:48:35.687Z",
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/9a4b5f250189b2d423b62f15f02c2403c070fb3d",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.6"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/8d1e4f3e9673585d8835fd45b7b6eb7c0f21b529",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.98.8"
33
33
  };
@@ -18,8 +18,8 @@
18
18
  * for both bash and python.
19
19
  */
20
20
  import * as path from "node:path";
21
- import { parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
22
- import { lexShellCommand } from "../tools/shell-lex";
21
+ import { expandPath, parseFindPattern, parseSearchPath, resolveToCwd, splitTopLevel } from "../tools/path-utils";
22
+ import { lexShellCommand, type ShellSimpleCommand } from "../tools/shell-lex";
23
23
  import { provenExemptWords } from "./command-operands";
24
24
  import type { SandboxAccess, SandboxPolicy } from "./policy";
25
25
 
@@ -194,6 +194,12 @@ function operandAccess(operator: string): SandboxAccess | "skip" {
194
194
  interface PathCandidate {
195
195
  token: string;
196
196
  access: SandboxAccess;
197
+ /**
198
+ * A directory the shell is about to move into. It must clear both boundaries on its own merits,
199
+ * and the system-root read exemption does not apply: `/usr` being readable is no reason to let
200
+ * the working directory — and with it every unchecked relative path — move there.
201
+ */
202
+ mustBeInTree?: boolean;
197
203
  }
198
204
 
199
205
  /** Write first, so a `<>` denial names the stricter boundary the caller is most likely missing. */
@@ -205,6 +211,9 @@ const REDIRECT_OPERATOR = /[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)/g;
205
211
  /** A whitespace token that is only a redirection operator, so the next token is its operand. */
206
212
  const BARE_REDIRECT = /^[0-9]*(?:&>>|&>|<<<|<<-|<<|>>|>\||<>|>&|<&|>|<)$/;
207
213
 
214
+ /** Where a shell word ends inside a whitespace token: an operator, separator, or grouping. */
215
+ const METACHARACTER = /[;&|<>()]/;
216
+
208
217
  /**
209
218
  * A path glued to its option, as one shell word (#2524).
210
219
  *
@@ -267,7 +276,17 @@ function codePathOccurrences(command: string): PathOccurrence[] {
267
276
  const access = operandAccess(operator[0]);
268
277
  if (access === "skip") continue;
269
278
  const from = operator.index + operator[0].length;
270
- add(stripped.slice(from).replace(/^["']|["']$/g, ""), match.index + openingQuote + from, access);
279
+ // The operand ends at the first shell metacharacter, not at the end of the whitespace
280
+ // token. `>/dev/null; echo x` is one token, and taking all of it produced the "path"
281
+ // `/dev/null;`, which matched no write sink and refused a completely ordinary command
282
+ // (#2540). Truncating only ever shortens a candidate, so nothing blocked becomes allowed.
283
+ const rest = stripped.slice(from);
284
+ const stop = rest.search(METACHARACTER);
285
+ add(
286
+ (stop === -1 ? rest : rest.slice(0, stop)).replace(/^["']|["']$/g, ""),
287
+ match.index + openingQuote + from,
288
+ access,
289
+ );
271
290
  }
272
291
 
273
292
  // An option glued to its value is one word too, and the lexer cannot help: it
@@ -323,13 +342,22 @@ function codePathCandidates(command: string): PathCandidate[] {
323
342
  * confidently, the floor stands alone.
324
343
  */
325
344
  /**
326
- * A redirect target the shell will certainly open, whose path cannot be resolved here
327
- * `printf x >"$TARGET"`. Unlike an operand, there is no reading under which this is data
328
- * rather than a file, so it cannot be waved through (#2534).
345
+ * Something the shell will act on whose path this layer cannot resolve `cd "$DEST"`.
346
+ *
347
+ * Refusal is justified here and *not* for a redirect target, which is the asymmetry worth stating: a
348
+ * redirect target that escapes damages one file, while a directory change silently relocates every
349
+ * later relative path in the session, and relative paths are never candidates at all. So an
350
+ * unresolvable `cd` fails closed, and an unresolvable `> "$LOG"` does not.
351
+ *
352
+ * The reverse was tried (#2552) and refused `make > "$LOG"`, `> "$TMPDIR/f"` and `> out-$$.txt` —
353
+ * ordinary shell, writing in-tree. Narrowing it is not possible either: a variable's *value* can
354
+ * contain `../`, so `> "out-$X"` escapes while looking relative. Resolving that needs the expansion,
355
+ * which only the shell has — Phase 2 (#2554).
329
356
  */
330
357
  interface UnresolvableTarget {
331
358
  text: string;
332
- access: SandboxAccess;
359
+ /** What the shell was about to do, for a message that names the actual problem. */
360
+ what: "directory change";
333
361
  }
334
362
 
335
363
  interface ShellScan {
@@ -337,6 +365,116 @@ interface ShellScan {
337
365
  unresolvable: UnresolvableTarget[];
338
366
  }
339
367
 
368
+ /**
369
+ * Why a directory change was refused. Distinct from `describe` because the remedy differs: the path
370
+ * may well be readable, and the problem is that standing there redefines every relative path the
371
+ * boundary trusts to stay in the tree.
372
+ */
373
+ function describeDirectoryChange(policy: SandboxPolicy, target?: string, unresolved?: string): string {
374
+ const what =
375
+ target === undefined
376
+ ? `a directory this check cannot resolve from the command text (${unresolved})`
377
+ : `${target}, which is outside it`;
378
+ return `Refusing to change the working directory to ${what} (session directory: ${policy.cwd}). Relative paths are trusted to stay inside the session tree, so moving out of it would silently take every later path with it. Use an absolute path, or --allow-path to widen the boundary first.`;
379
+ }
380
+
381
+ /** Builtins that move the shell, and therefore move what every later relative path means. */
382
+ const DIRECTORY_CHANGE = new Set(["cd", "pushd"]);
383
+
384
+ /** `cd`'s own options. All boolean, so the target is the first non-option operand. */
385
+ const DIRECTORY_CHANGE_OPTIONS = new Set(["-L", "-P", "-e", "-@"]);
386
+
387
+ /**
388
+ * Commands whose operand is a script the shell will run. The lexer hands the script over as one
389
+ * word, so a directory change inside it is invisible unless that word is lexed in turn.
390
+ */
391
+ const SCRIPT_RUNNERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "eval"]);
392
+
393
+ /**
394
+ * Prefixes that run the *following words* as a command rather than a script string. `command` is
395
+ * already unwrapped by the lexer; `builtin` is not, and `builtin cd /` would otherwise sail past a
396
+ * gate that only looks at `name`.
397
+ */
398
+ const COMMAND_PREFIXES = new Set(["builtin"]);
399
+
400
+ /** Cheap pre-filter: only lex a script operand that could contain a directory change at all. */
401
+ const DIRECTORY_CHANGE_TOKEN = /(^|[\s;&|(])(cd|pushd)([\s;&|)]|$)/;
402
+
403
+ /**
404
+ * Targets of a directory change: a candidate that must resolve in-tree, or the raw text when it
405
+ * cannot be resolved at all.
406
+ *
407
+ * This gate exists because a relative operand is never a candidate — the floor assumes it resolves
408
+ * under the session directory. `cd` is what breaks that assumption: the bash tool runs one
409
+ * persistent brush-core shell in the agent's own process, so a directory change outlives the call
410
+ * that made it and afterwards `cat tmp/x` reads somewhere else entirely (#2542).
411
+ *
412
+ * It is defence-in-depth, not a boundary. Verified still open: `c=cd; $c /`, `alias g=cd; g /`, and
413
+ * a symlink created in the same command (#2553). Those need the shell's own resolution, which is
414
+ * Phase 2 (#2554). Do not add a seventh spelling here — two adversarial rounds produced six.
415
+ */
416
+ function directoryChangeTargets(commands: readonly ShellSimpleCommand[], depth = 0): (PathCandidate | string)[] {
417
+ const targets: (PathCandidate | string)[] = [];
418
+
419
+ for (const command of commands) {
420
+ if (command.name === undefined) continue;
421
+ let name = command.name;
422
+ let operands = command.words.slice(command.operandStart).filter(word => word.redirect === undefined);
423
+ // `builtin cd sub` is a `cd`; unwrap the prefix before deciding anything.
424
+ while (COMMAND_PREFIXES.has(name) && operands.length > 0 && operands[0].literal) {
425
+ name = operands[0].text;
426
+ operands = operands.slice(1);
427
+ }
428
+
429
+ // A script operand is text the shell will run, so lex it and gate what it contains. `eval
430
+ // 'cd /'` and `sh -c '…'` both arrive here. One level is enough for every real spelling;
431
+ // deeper nesting is unprovable.
432
+ if (SCRIPT_RUNNERS.has(name)) {
433
+ for (const operand of operands) {
434
+ if (!operand.literal) {
435
+ targets.push(operand.text); // `eval "$cmd"` — nothing to read
436
+ continue;
437
+ }
438
+ if (!DIRECTORY_CHANGE_TOKEN.test(operand.text)) continue;
439
+ if (depth > 0) {
440
+ targets.push(operand.text);
441
+ continue;
442
+ }
443
+ const inner = lexShellCommand(operand.text);
444
+ if (inner.unterminated) targets.push(operand.text);
445
+ else targets.push(...directoryChangeTargets(inner.commands, depth + 1));
446
+ }
447
+ continue;
448
+ }
449
+
450
+ if (!DIRECTORY_CHANGE.has(name)) continue;
451
+
452
+ // Options precede the target and are all boolean. One the model does not recognise means the
453
+ // target cannot be located, so the proof fails rather than guessing which operand it is.
454
+ let index = 0;
455
+ let unparseable = false;
456
+ while (index < operands.length && operands[index].text.startsWith("-") && operands[index].text !== "-") {
457
+ if (!DIRECTORY_CHANGE_OPTIONS.has(operands[index].text)) {
458
+ unparseable = true;
459
+ break;
460
+ }
461
+ index++;
462
+ }
463
+ if (unparseable) {
464
+ targets.push(operands.map(word => word.text).join(" "));
465
+ continue;
466
+ }
467
+
468
+ const target = operands[index];
469
+ // `cd` with no operand goes to $HOME; `cd -` returns somewhere only the shell remembers; a
470
+ // non-literal target cannot be resolved from text.
471
+ if (target === undefined) targets.push(`${name} (no target: goes to $HOME)`);
472
+ else if (!target.literal || target.text === "-") targets.push(target.text);
473
+ else targets.push({ token: target.text, access: "read", mustBeInTree: true });
474
+ }
475
+ return targets;
476
+ }
477
+
340
478
  function shellPathCandidates(command: string): ShellScan {
341
479
  const lexed = lexShellCommand(command);
342
480
  // Unbalanced quotes mean every word boundary is a guess: neither the blanking nor the write
@@ -366,18 +504,21 @@ function shellPathCandidates(command: string): ShellScan {
366
504
  // Not gated on `looksLikePath`: that test is for the floor's *guesses* about which fragments of a
367
505
  // command might be filenames. A redirect target is one the shell will certainly open, so a bare
368
506
  // `out.txt` is checked too — it resolves under the cwd, which a read-only cwd does not license.
369
- const unresolvable: UnresolvableTarget[] = [];
370
507
  for (const word of lexed.words) {
371
508
  if (word.redirect === undefined || word.redirect === "here-string") continue;
372
509
  for (const access of word.redirect === "read-write" ? WRITE_AND_READ : [word.redirect]) {
373
- // `literal` is false when the word carries `$VAR`, a substitution, a glob or a brace
374
- // expansion, so `text` is not a stand-in for one filesystem reference. Resolving it
375
- // anyway is what let `>"$TARGET"` through: it became the literal string `$TARGET`
376
- // under the cwd, which the boundary happily allowed.
510
+ // A non-literal target `$VAR`, a substitution, a glob is not checked. `text` is not a
511
+ // stand-in for one filesystem reference, and refusing on that basis rejected ordinary
512
+ // in-tree shell (see UnresolvableTarget). Resolving it is Phase 2's job.
377
513
  if (word.literal) candidates.push({ token: word.text, access });
378
- else unresolvable.push({ text: word.text, access });
379
514
  }
380
515
  }
516
+
517
+ const unresolvable: UnresolvableTarget[] = [];
518
+ for (const target of directoryChangeTargets(lexed.commands)) {
519
+ if (typeof target === "string") unresolvable.push({ text: target, what: "directory change" });
520
+ else candidates.push(target);
521
+ }
381
522
  return { candidates, unresolvable };
382
523
  }
383
524
 
@@ -402,7 +543,12 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
402
543
 
403
544
  const rawCwd = typeof input.cwd === "string" ? input.cwd : undefined;
404
545
  const base = rawCwd ? resolveToCwd(rawCwd, cwd) : cwd;
405
- if (rawCwd && !policy.isAllowed(base, "read")) return deny(policy, base, "read");
546
+ // Both boundaries, for the same reason a `cd` target needs both: relative paths are never
547
+ // scanned, so wherever the command runs is somewhere it can write freely. Read alone let
548
+ // `{ cwd: "/shared/ctx", command: "touch notes.md" }` write into a read-only root.
549
+ if (rawCwd && !(policy.isAllowed(base, "read") && policy.isAllowed(base, "write"))) {
550
+ return { block: true, reason: describeDirectoryChange(policy, base) };
551
+ }
406
552
 
407
553
  const commands: string[] = [];
408
554
  for (const field of fields) {
@@ -429,17 +575,19 @@ function evaluateCodeTool(check: ToolCallCheck, fields: string[], shell: boolean
429
575
  // at this layer at all. That is the Phase 2 OS-sandbox's job; do not read the text
430
576
  // boundary as complete (#2534).
431
577
  for (const target of scan.unresolvable) {
432
- return {
433
- block: true,
434
- reason:
435
- `sandbox: refusing to ${target.access} a redirect target this layer cannot resolve: ` +
436
- `${target.text}. The shell will open it, but its path comes from an expansion, so the ` +
437
- "boundary cannot be checked. Write to an explicit path, or use --allow-path.",
438
- };
578
+ return { block: true, reason: describeDirectoryChange(policy, undefined, target.text) };
439
579
  }
440
580
  const seen = new Set<string>();
441
- for (const { token, access } of scan.candidates) {
442
- if (!seen.add(`${access}\0${token}`)) continue;
581
+ for (const { token, access, mustBeInTree } of scan.candidates) {
582
+ if (!seen.add(`${access}\0${mustBeInTree ? "cd\0" : ""}${token}`)) continue;
583
+ if (mustBeInTree) {
584
+ // `path.resolve`, not `resolveToCwd`: the latter maps an all-slashes path to the cwd,
585
+ // which would read `cd /` as `cd .` and let the shell walk out. Both boundaries,
586
+ // because relative paths go unchecked wherever the shell is standing.
587
+ const moved = path.resolve(base, expandPath(token));
588
+ if (policy.isAllowed(moved, "read") && policy.isAllowed(moved, "write")) continue;
589
+ return { block: true, reason: describeDirectoryChange(policy, moved) };
590
+ }
443
591
  const resolved = resolveToCwd(token, base);
444
592
  if (policy.isAllowed(resolved, access)) continue;
445
593
  // SYSTEM_READ_ROOTS is a read allowance — "directories a subprocess may legitimately
@@ -0,0 +1,121 @@
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
+ import stealthWorkerScript from "./puppeteer/13_stealth_worker.txt" with { type: "text" };
23
+
24
+ /**
25
+ * The injected surfaces, in load order. Order is load-bearing: `tampering` must
26
+ * run first so later scripts' patched functions are already covered by its
27
+ * `Function.prototype.toString` shim.
28
+ */
29
+ export const STEALTH_SCRIPTS: ReadonlyArray<{ readonly name: string; readonly source: string }> = [
30
+ { name: "tampering", source: stealthTamperingScript },
31
+ { name: "activity", source: stealthActivityScript },
32
+ { name: "hairline", source: stealthHairlineScript },
33
+ { name: "botd", source: stealthBotdScript },
34
+ { name: "iframe", source: stealthIframeScript },
35
+ { name: "webgl", source: stealthWebglScript },
36
+ { name: "screen", source: stealthScreenScript },
37
+ { name: "fonts", source: stealthFontsScript },
38
+ { name: "audio", source: stealthAudioScript },
39
+ { name: "locale", source: stealthLocaleScript },
40
+ { name: "plugins", source: stealthPluginsScript },
41
+ { name: "hardware", source: stealthHardwareScript },
42
+ { name: "codecs", source: stealthCodecsScript },
43
+ { name: "worker", source: stealthWorkerScript },
44
+ ];
45
+
46
+ export type StealthBundleOptions = {
47
+ /**
48
+ * Name of a global to collect per-script failures into, as
49
+ * `[{ name, message }]`. Omit in production: each script is wrapped in its
50
+ * own try/catch so one failure cannot take down the rest, but recording those
51
+ * failures means writing a `window.<name>` that a detection script could look
52
+ * for — which would defeat the point of the bundle. Tests pass a name so a
53
+ * silently-broken script is assertable instead of invisible.
54
+ */
55
+ readonly errorSink?: string;
56
+ };
57
+
58
+ /** Wraps one script so a failure cannot stop the scripts after it. */
59
+ function wrapScript(script: { name: string; source: string }, errorSink: string | undefined): string {
60
+ const record = errorSink
61
+ ? `(globalThis[${JSON.stringify(errorSink)}] ||= []).push({ name: ${JSON.stringify(script.name)}, message: String(e && e.message || e) });`
62
+ : "";
63
+ return `
64
+ try {
65
+ ${script.source};
66
+ } catch (e) { ${record} }
67
+ `;
68
+ }
69
+
70
+ /**
71
+ * Builds the full init script.
72
+ *
73
+ * Caches pristine builtins up front so the surfaces can still reach unpatched
74
+ * references after earlier surfaces have replaced the page's own.
75
+ *
76
+ * The cache is taken from the CURRENT realm, not a detached iframe. This script
77
+ * is injected via `Page.addScriptToEvaluateOnNewDocument`, which runs before any
78
+ * page script — and before any DOM at all: at that point `document.readyState`
79
+ * is `"loading"` and `document.head`, `document.body` and
80
+ * `document.documentElement` are all null. An earlier version created a helper
81
+ * iframe and appended it to `document.head`, which threw on that first statement
82
+ * outside every per-surface guard, so all fourteen surfaces silently never ran.
83
+ * Because nothing else has executed yet, `globalThis`'s builtins are themselves
84
+ * still pristine, which is exactly what the iframe was there to provide.
85
+ */
86
+ export function buildStealthBundle(options: StealthBundleOptions = {}): string {
87
+ const joint = STEALTH_SCRIPTS.map(script => wrapScript(script, options.errorSink)).join(";\n");
88
+
89
+ return `(() => {
90
+ // Native function cache — this script runs first, so the current realm's
91
+ // builtins have not been touched yet.
92
+ const nativeWindow = globalThis;
93
+
94
+ // Cache pristine native functions
95
+ const Function_toString = nativeWindow.Function.prototype.toString;
96
+ const Object_getOwnPropertyDescriptor = nativeWindow.Object.getOwnPropertyDescriptor;
97
+ const Object_getOwnPropertyDescriptors = nativeWindow.Object.getOwnPropertyDescriptors;
98
+ const Object_getPrototypeOf = nativeWindow.Object.getPrototypeOf;
99
+ const Object_defineProperty = nativeWindow.Object.defineProperty;
100
+ const Object_getOwnPropertyDescriptorOriginal = nativeWindow.Object.getOwnPropertyDescriptor;
101
+ const Object_create = nativeWindow.Object.create;
102
+ const Object_keys = nativeWindow.Object.keys;
103
+ const Object_getOwnPropertyNames = nativeWindow.Object.getOwnPropertyNames;
104
+ const Object_entries = nativeWindow.Object.entries;
105
+ const Object_setPrototypeOf = nativeWindow.Object.setPrototypeOf;
106
+ const Object_assign = nativeWindow.Object.assign;
107
+ const Window_setTimeout = nativeWindow.setTimeout;
108
+ const Math_random = nativeWindow.Math.random;
109
+ const Math_floor = nativeWindow.Math.floor;
110
+ const Math_max = nativeWindow.Math.max;
111
+ const Math_min = nativeWindow.Math.min;
112
+ const Window_Event = nativeWindow.Event;
113
+ const Promise_resolve = nativeWindow.Promise.resolve.bind(nativeWindow.Promise);
114
+ const Window_Blob = nativeWindow.Blob;
115
+ const Window_Proxy = nativeWindow.Proxy;
116
+ const Intl_DateTimeFormat = nativeWindow.Intl.DateTimeFormat;
117
+ const Date_constructor = nativeWindow.Date;
118
+
119
+ ${joint}
120
+ })();`;
121
+ }
@@ -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.