@oh-my-pi/pi-coding-agent 16.1.8 → 16.1.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/cli.js +4621 -3814
  3. package/dist/types/cli/flag-tables.d.ts +17 -0
  4. package/dist/types/cli-commands.d.ts +4 -2
  5. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  6. package/dist/types/modes/interactive-mode.d.ts +1 -1
  7. package/dist/types/modes/types.d.ts +1 -1
  8. package/dist/types/session/agent-session.d.ts +13 -0
  9. package/dist/types/tools/browser/launch.d.ts +5 -0
  10. package/package.json +12 -12
  11. package/src/cli/args.ts +0 -1
  12. package/src/cli/flag-tables.ts +42 -0
  13. package/src/cli/profile-bootstrap.ts +1 -11
  14. package/src/cli-commands.ts +48 -3
  15. package/src/config/model-registry.ts +6 -7
  16. package/src/internal-urls/docs-index.generated.txt +1 -1
  17. package/src/lsp/client.ts +39 -25
  18. package/src/modes/controllers/command-controller.ts +18 -3
  19. package/src/modes/controllers/mcp-command-controller.ts +40 -12
  20. package/src/modes/interactive-mode.ts +1 -1
  21. package/src/modes/types.ts +1 -1
  22. package/src/session/agent-session.ts +36 -1
  23. package/src/slash-commands/builtin-registry.ts +24 -66
  24. package/src/tools/browser/launch.ts +107 -31
  25. package/src/tools/puppeteer/00_stealth_tampering.txt +16 -35
  26. package/src/tools/puppeteer/01_stealth_activity.txt +79 -19
  27. package/src/tools/puppeteer/02_stealth_hairline.txt +57 -11
  28. package/src/tools/puppeteer/03_stealth_botd.txt +10 -14
  29. package/src/tools/puppeteer/04_stealth_iframe.txt +156 -63
  30. package/src/tools/puppeteer/05_stealth_webgl.txt +222 -64
  31. package/src/tools/puppeteer/06_stealth_screen.txt +255 -67
  32. package/src/tools/puppeteer/07_stealth_fonts.txt +17 -15
  33. package/src/tools/puppeteer/08_stealth_audio.txt +32 -20
  34. package/src/tools/puppeteer/09_stealth_locale.txt +45 -40
  35. package/src/tools/puppeteer/10_stealth_plugins.txt +12 -8
  36. package/src/tools/puppeteer/11_stealth_hardware.txt +57 -6
  37. package/src/tools/puppeteer/12_stealth_codecs.txt +20 -18
  38. package/src/tools/puppeteer/13_stealth_worker.txt +206 -45
package/src/lsp/client.ts CHANGED
@@ -327,9 +327,45 @@ async function startMessageReader(client: LspClient): Promise<void> {
327
327
  try {
328
328
  const message: LspJsonRpcResponse | LspJsonRpcNotification = JSON.parse(messageText);
329
329
 
330
- // Route message
331
- if ("id" in message && message.id !== undefined) {
332
- // Response to a request
330
+ // Route message. A JSON-RPC message carrying a `method` is always
331
+ // server-originated: a request when it also has an `id`, a
332
+ // notification otherwise. A message with only an `id` is a response
333
+ // to one of our requests. Disambiguate on `method` FIRST: a
334
+ // server's request ids live in its own id space and routinely
335
+ // collide with our in-flight client request ids (e.g. a
336
+ // basedpyright `workspace/configuration` pull arriving while a
337
+ // `documentSymbol` request with the same id is pending). Matching
338
+ // pending requests first would swallow that pull as a bogus
339
+ // response -- dropping the config answer the server blocks on and
340
+ // resolving our request with `undefined`, wedging the lazy
341
+ // cold-start handshake (#3001).
342
+ if ("method" in message) {
343
+ if ("id" in message && message.id !== undefined) {
344
+ // Server-initiated request: must be answered.
345
+ await handleServerRequest(client, message as LspJsonRpcRequest);
346
+ } else {
347
+ // Server notification
348
+ if (message.method === "textDocument/publishDiagnostics" && message.params) {
349
+ const params = message.params as PublishDiagnosticsParams;
350
+ client.diagnostics.set(params.uri, {
351
+ diagnostics: params.diagnostics,
352
+ version: params.version ?? null,
353
+ });
354
+ client.diagnosticsVersion += 1;
355
+ } else if (message.method === "$/progress" && message.params) {
356
+ const params = message.params as { token: string | number; value?: { kind?: string } };
357
+ if (params.value?.kind === "begin") {
358
+ client.activeProgressTokens.add(params.token);
359
+ } else if (params.value?.kind === "end") {
360
+ client.activeProgressTokens.delete(params.token);
361
+ if (client.activeProgressTokens.size === 0) {
362
+ client.resolveProjectLoaded();
363
+ }
364
+ }
365
+ }
366
+ }
367
+ } else if ("id" in message && message.id !== undefined) {
368
+ // Response to one of our requests.
333
369
  const pending = client.pendingRequests.get(message.id);
334
370
  if (pending) {
335
371
  client.pendingRequests.delete(message.id);
@@ -338,28 +374,6 @@ async function startMessageReader(client: LspClient): Promise<void> {
338
374
  } else {
339
375
  pending.resolve(message.result);
340
376
  }
341
- } else if ("method" in message) {
342
- await handleServerRequest(client, message as LspJsonRpcRequest);
343
- }
344
- } else if ("method" in message) {
345
- // Server notification
346
- if (message.method === "textDocument/publishDiagnostics" && message.params) {
347
- const params = message.params as PublishDiagnosticsParams;
348
- client.diagnostics.set(params.uri, {
349
- diagnostics: params.diagnostics,
350
- version: params.version ?? null,
351
- });
352
- client.diagnosticsVersion += 1;
353
- } else if (message.method === "$/progress" && message.params) {
354
- const params = message.params as { token: string | number; value?: { kind?: string } };
355
- if (params.value?.kind === "begin") {
356
- client.activeProgressTokens.add(params.token);
357
- } else if (params.value?.kind === "end") {
358
- client.activeProgressTokens.delete(params.token);
359
- if (client.activeProgressTokens.size === 0) {
360
- client.resolveProjectLoaded();
361
- }
362
- }
363
377
  }
364
378
  }
365
379
  } catch (err) {
@@ -85,15 +85,30 @@ export class CommandController {
85
85
  }
86
86
  }
87
87
 
88
- handleDumpCommand() {
88
+ async handleDumpCommand(): Promise<void> {
89
89
  try {
90
90
  const formatted = this.ctx.session.formatSessionAsText();
91
91
  if (!formatted) {
92
92
  this.ctx.showError("No messages to dump yet.");
93
93
  return;
94
94
  }
95
- copyToClipboard(formatted);
96
- this.ctx.showStatus("Session copied to clipboard");
95
+ // Build the LLM request JSON sidecar first so its path (and a
96
+ // raw-context warning) can be appended to the copied transcript.
97
+ let sidecarPath: string | undefined;
98
+ let sidecarError: string | undefined;
99
+ try {
100
+ sidecarPath = await this.ctx.session.dumpLlmRequestToTmpDir();
101
+ } catch (error: unknown) {
102
+ sidecarError = error instanceof Error ? error.message : "Unknown error";
103
+ }
104
+ const doc = sidecarPath
105
+ ? `${formatted}\n\n---\nLLM request JSON: ${sidecarPath}\nThis file persists on disk and may contain raw context/secrets — treat accordingly.`
106
+ : formatted;
107
+ await copyToClipboard(doc);
108
+ const statusParts = ["Session copied to clipboard"];
109
+ if (sidecarPath) statusParts.push(`LLM request JSON: ${sidecarPath}`);
110
+ if (sidecarError) statusParts.push(`LLM request JSON unavailable: ${sidecarError}`);
111
+ this.ctx.showStatus(statusParts.join("\n"));
97
112
  } catch (error: unknown) {
98
113
  this.ctx.showError(`Failed to copy session: ${error instanceof Error ? error.message : "Unknown error"}`);
99
114
  }
@@ -8,7 +8,7 @@ import { type Component, replaceTabs, Spacer, Text } from "@oh-my-pi/pi-tui";
8
8
  import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils";
9
9
  import type { SourceMeta } from "../../capability/types";
10
10
  import { expandEnvVarsDeep } from "../../discovery/helpers";
11
- import { analyzeAuthError, discoverOAuthEndpoints, MCPManager } from "../../mcp";
11
+ import { analyzeAuthError, discoverOAuthEndpoints, loadAllMCPConfigs, MCPManager } from "../../mcp";
12
12
  import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
13
13
  import {
14
14
  addMCPServer,
@@ -1383,7 +1383,7 @@ export class MCPCommandController {
1383
1383
  }
1384
1384
  await setServerDisabled(userConfigPath, name, !enabled);
1385
1385
  if (enabled) {
1386
- await this.#reloadMCP();
1386
+ await this.#connectEnabledMCPServer(name);
1387
1387
  const state = await this.#waitForServerConnectionWithAnimation(name);
1388
1388
  const status =
1389
1389
  state === "connected"
@@ -1419,7 +1419,12 @@ export class MCPCommandController {
1419
1419
 
1420
1420
  const updated: MCPServerConfig = { ...found.config, enabled };
1421
1421
  await updateMCPServer(found.filePath, name, updated);
1422
- await this.#reloadMCP();
1422
+ if (enabled) {
1423
+ await this.#connectEnabledMCPServer(name);
1424
+ } else {
1425
+ await this.ctx.mcpManager?.disconnectServer(name);
1426
+ await this.ctx.session.refreshMCPTools(this.ctx.mcpManager?.getTools() ?? []);
1427
+ }
1423
1428
 
1424
1429
  let status = "";
1425
1430
  if (enabled) {
@@ -1671,6 +1676,37 @@ export class MCPCommandController {
1671
1676
  }
1672
1677
  }
1673
1678
 
1679
+ async #connectEnabledMCPServer(name: string): Promise<void> {
1680
+ if (!this.ctx.mcpManager) {
1681
+ return;
1682
+ }
1683
+
1684
+ const { configs, sources } = await loadAllMCPConfigs(getProjectDir());
1685
+ const config = configs[name];
1686
+ if (!config) {
1687
+ await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
1688
+ return;
1689
+ }
1690
+
1691
+ const source = sources[name];
1692
+ const result = await this.ctx.mcpManager.connectServers({ [name]: config }, source ? { [name]: source } : {});
1693
+ await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
1694
+ this.#showMCPConnectionErrors(result.errors);
1695
+ }
1696
+
1697
+ #showMCPConnectionErrors(errors: Map<string, string>): void {
1698
+ if (errors.size === 0) {
1699
+ return;
1700
+ }
1701
+
1702
+ const errorLines = ["", theme.fg("warning", "Some servers failed to connect:"), ""];
1703
+ for (const [serverName, error] of errors.entries()) {
1704
+ errorLines.push(` ${serverName}: ${error}`);
1705
+ }
1706
+ errorLines.push("");
1707
+ this.#showMessage(errorLines.join("\n"));
1708
+ }
1709
+
1674
1710
  /**
1675
1711
  * Reload MCP manager with new configs
1676
1712
  */
@@ -1686,15 +1722,7 @@ export class MCPCommandController {
1686
1722
  const result = await this.ctx.mcpManager.discoverAndConnect();
1687
1723
  await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
1688
1724
 
1689
- // Show any connection errors
1690
- if (result.errors.size > 0) {
1691
- const errorLines = ["", theme.fg("warning", "Some servers failed to connect:"), ""];
1692
- for (const [serverName, error] of result.errors.entries()) {
1693
- errorLines.push(` ${serverName}: ${error}`);
1694
- }
1695
- errorLines.push("");
1696
- this.#showMessage(errorLines.join("\n"));
1697
- }
1725
+ this.#showMCPConnectionErrors(result.errors);
1698
1726
  }
1699
1727
 
1700
1728
  /**
@@ -3491,7 +3491,7 @@ export class InteractiveMode implements InteractiveModeContext {
3491
3491
  return this.#commandController.handleExportCommand(text);
3492
3492
  }
3493
3493
 
3494
- handleDumpCommand() {
3494
+ async handleDumpCommand(): Promise<void> {
3495
3495
  return this.#commandController.handleDumpCommand();
3496
3496
  }
3497
3497
 
@@ -290,7 +290,7 @@ export interface InteractiveModeContext {
290
290
  handleHotkeysCommand(): void;
291
291
  handleToolsCommand(): void;
292
292
  handleContextCommand(): void;
293
- handleDumpCommand(): void;
293
+ handleDumpCommand(): Promise<void>;
294
294
  handleAdvisorDumpCommand(isRaw?: boolean): void;
295
295
  handleDebugTranscriptCommand(): Promise<void>;
296
296
  handleClearCommand(): Promise<void>;
@@ -103,7 +103,7 @@ import {
103
103
  resolveServiceTier,
104
104
  streamSimple,
105
105
  } from "@oh-my-pi/pi-ai";
106
- import { stripToolDescriptions } from "@oh-my-pi/pi-ai/utils/schema";
106
+ import { stripToolDescriptions, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
107
107
  import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
108
108
  import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
109
109
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
@@ -12606,6 +12606,41 @@ export class AgentSession {
12606
12606
  });
12607
12607
  }
12608
12608
 
12609
+ /**
12610
+ * Dump the current session's LLM-facing request context as JSON to a
12611
+ * auto-named file in `os.tmpdir()`. This is the synchronous
12612
+ * `convertToLlm`-boundary snapshot — system prompt, tools (wire schemas),
12613
+ * thinking/service tier, and converted messages — with no network round-trip
12614
+ * and no arming flag, so advisor/side requests cannot intercept it.
12615
+ *
12616
+ * The file persists on disk and may contain the same raw context/secrets
12617
+ * as `/dump`; treat the path accordingly.
12618
+ *
12619
+ * @returns the written file path, or `undefined` when there are no messages.
12620
+ */
12621
+ async dumpLlmRequestToTmpDir(): Promise<string | undefined> {
12622
+ const messages = this.messages;
12623
+ if (messages.length === 0) return undefined;
12624
+ const llmMessages = await this.convertMessagesToLlm(messages);
12625
+ const payload = {
12626
+ model: this.agent.state.model ?? null,
12627
+ thinkingLevel: this.#thinkingLevel ?? null,
12628
+ serviceTier: this.agent.serviceTier ?? null,
12629
+ systemPrompt: this.agent.state.systemPrompt,
12630
+ tools: this.agent.state.tools.map(tool => ({
12631
+ name: tool.name,
12632
+ description: tool.description,
12633
+ parameters: toolWireSchema(tool),
12634
+ ...(tool.strict !== undefined ? { strict: tool.strict } : {}),
12635
+ ...(tool.customWireName ? { customWireName: tool.customWireName } : {}),
12636
+ })),
12637
+ messages: llmMessages,
12638
+ };
12639
+ const filePath = path.join(os.tmpdir(), `omp-llm-request-${Snowflake.next()}.json`);
12640
+ await Bun.write(filePath, `${JSON.stringify(payload, null, 2)}\n`);
12641
+ return filePath;
12642
+ }
12643
+
12609
12644
  /**
12610
12645
  * Enable or disable the advisor for this session. The setting is overridden for the session,
12611
12646
  * and the runtime is started or stopped to match.
@@ -1,8 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
- import * as os from "node:os";
3
2
  import * as path from "node:path";
4
3
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
5
- import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug";
6
4
  import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
7
5
  import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils";
8
6
  import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
@@ -192,46 +190,6 @@ async function handleUsageResetCommand(
192
190
  await output(describeRedeemOutcome(outcome, target.label));
193
191
  }
194
192
 
195
- const DEBUG_DUMP_NEXT_REQUEST_USAGE = "Usage: /debug dump-next-request <path>";
196
-
197
- function resolveDebugRequestDumpPath(target: string, cwd: string): string {
198
- const expanded =
199
- target === "~"
200
- ? os.homedir()
201
- : target.startsWith("~/") || target.startsWith("~\\")
202
- ? path.join(os.homedir(), target.slice(2))
203
- : target;
204
- return path.resolve(cwd, expanded);
205
- }
206
-
207
- async function handleDebugSubcommand(
208
- args: string,
209
- cwd: string,
210
- output: (text: string) => Promise<void> | void,
211
- ): Promise<SlashCommandResult> {
212
- const { verb, rest } = parseSubcommand(args);
213
- switch (verb) {
214
- case "":
215
- await output(DEBUG_DUMP_NEXT_REQUEST_USAGE);
216
- return commandConsumed();
217
- case "dump-next-request":
218
- case "dump-request":
219
- case "next-request": {
220
- if (!rest) {
221
- await output(DEBUG_DUMP_NEXT_REQUEST_USAGE);
222
- return commandConsumed();
223
- }
224
- const requestPath = resolveDebugRequestDumpPath(rest, cwd);
225
- setNextRequestDebugPath(requestPath);
226
- await output(`Next AI provider request will be dumped to ${requestPath}`);
227
- return commandConsumed();
228
- }
229
- default:
230
- await output(`Unknown /debug subcommand "${verb}". ${DEBUG_DUMP_NEXT_REQUEST_USAGE}`);
231
- return commandConsumed();
232
- }
233
- }
234
-
235
193
  /** Parse the `/shake` subcommand into a {@link ShakeMode}; empty defaults to elide. */
236
194
  function parseShakeMode(args: string): ShakeMode | { error: string } {
237
195
  const verb = args.trim().toLowerCase();
@@ -612,16 +570,33 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
612
570
  },
613
571
  {
614
572
  name: "dump",
615
- description: "Copy session transcript to clipboard",
616
- acpDescription: "Return full transcript as plain text",
573
+ description: "Copy session transcript to clipboard (and write LLM request JSON to tmp)",
574
+ acpDescription: "Return full transcript as plain text, with LLM request JSON path",
617
575
  allowArgs: true,
618
576
  handle: async (_command, runtime) => {
619
577
  const text = runtime.session.formatSessionAsText();
620
- await runtime.output(text || "No messages to dump yet.");
578
+ if (!text) {
579
+ await runtime.output("No messages to dump yet.");
580
+ return commandConsumed();
581
+ }
582
+ let sidecarPath: string | undefined;
583
+ try {
584
+ sidecarPath = await runtime.session.dumpLlmRequestToTmpDir();
585
+ } catch {
586
+ // Sidecar is best-effort; the transcript is still output below.
587
+ }
588
+ const lines = [text];
589
+ if (sidecarPath)
590
+ lines.push(
591
+ "",
592
+ `LLM request JSON: ${sidecarPath}`,
593
+ "This file persists on disk and may contain raw context/secrets — treat accordingly.",
594
+ );
595
+ await runtime.output(lines.join("\n"));
621
596
  return commandConsumed();
622
597
  },
623
- handleTui: (_command, runtime) => {
624
- runtime.ctx.handleDumpCommand();
598
+ handleTui: async (_command, runtime) => {
599
+ await runtime.ctx.handleDumpCommand();
625
600
  runtime.ctx.editor.setText("");
626
601
  },
627
602
  },
@@ -1509,25 +1484,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1509
1484
  {
1510
1485
  name: "debug",
1511
1486
  description: "Open debug tools selector",
1512
- allowArgs: true,
1513
- subcommands: [
1514
- {
1515
- name: "dump-next-request",
1516
- description: "Dump the next AI provider HTTP request as JSON",
1517
- usage: "<path>",
1518
- },
1519
- ],
1520
- handle: async (command, runtime) =>
1521
- handleDebugSubcommand(command.args, runtime.cwd, text => runtime.output(text)),
1522
- handleTui: async (command, runtime) => {
1523
- const args = command.args.trim();
1524
- if (args.length === 0) {
1525
- runtime.ctx.showDebugSelector();
1526
- } else {
1527
- await handleDebugSubcommand(args, runtime.ctx.sessionManager.getCwd(), text =>
1528
- runtime.ctx.showStatus(text),
1529
- );
1530
- }
1487
+ handleTui: async (_command, runtime) => {
1488
+ await runtime.ctx.showDebugSelector();
1531
1489
  runtime.ctx.editor.setText("");
1532
1490
  },
1533
1491
  },
@@ -29,10 +29,23 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1
29
29
  * connection dropped, etc.).
30
30
  */
31
31
  export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
32
+ // Automation-tell launch flags that puppeteer-core adds by default. We suppress
33
+ // them via `ignoreDefaultArgs` (the supported escape hatch) to mirror xxxx's
34
+ // chromiumSwitches patch. `--enable-automation` is the loudest: it sets
35
+ // navigator.webdriver=true and shows the "controlled by automated software" infobar.
36
+ // `ignoreDefaultArgs` does exact-string matching, so each entry must be a flag that
37
+ // puppeteer emits verbatim. The default `--disable-features=...` string can't be
38
+ // matched this way; it is neutralized in the puppeteer-core patch (ChromeLauncher).
32
39
  const STEALTH_IGNORE_DEFAULT_ARGS = [
40
+ "--enable-automation",
33
41
  "--disable-extensions",
34
42
  "--disable-default-apps",
35
43
  "--disable-component-extensions-with-background-pages",
44
+ "--disable-popup-blocking",
45
+ "--disable-client-side-phishing-detection",
46
+ "--allow-pre-commit-input",
47
+ "--disable-ipc-flooding-protection",
48
+ "--metrics-recording-only",
36
49
  ];
37
50
  const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
38
51
 
@@ -309,9 +322,11 @@ export interface UserAgentOverride {
309
322
  userAgentMetadata: {
310
323
  brands: Array<{ brand: string; version: string }>;
311
324
  fullVersion: string;
325
+ fullVersionList: Array<{ brand: string; version: string }>;
312
326
  platform: string;
313
327
  platformVersion: string;
314
328
  architecture: string;
329
+ bitness: string;
315
330
  model: string;
316
331
  mobile: boolean;
317
332
  };
@@ -371,6 +386,26 @@ function patchSourceUrl(page: Page): void {
371
386
  };
372
387
  }
373
388
 
389
+ async function resolveMacOsProductVersion(): Promise<string> {
390
+ if (os.platform() !== "darwin") return "";
391
+ try {
392
+ const plist = await Bun.file("/System/Library/CoreServices/SystemVersion.plist").text();
393
+ return plist.match(/<key>ProductVersion<\/key>\s*<string>([^<]+)<\/string>/)?.[1] ?? "";
394
+ } catch {
395
+ return "";
396
+ }
397
+ }
398
+
399
+ function resolveHostArchitecture(): string {
400
+ if (os.arch() === "arm64") return "arm";
401
+ if (os.arch().includes("64")) return "x86";
402
+ return "";
403
+ }
404
+
405
+ function resolveHostBitness(): string {
406
+ return os.arch().includes("64") ? "64" : "";
407
+ }
408
+
374
409
  async function resolveUserAgentOverride(page: Page): Promise<UserAgentOverride> {
375
410
  const rawUserAgent = await page.browser().userAgent();
376
411
  let userAgent = rawUserAgent.replace("HeadlessChrome/", "Chrome/");
@@ -379,32 +414,24 @@ async function resolveUserAgentOverride(page: Page): Promise<UserAgentOverride>
379
414
  }
380
415
 
381
416
  const uaVersionMatch = userAgent.match(/Chrome\/([\d|.]+)/);
382
- const fallbackVersionMatch = uaVersionMatch ?? (await page.browser().version()).match(/\/([\d|.]+)/);
383
- const uaVersion = fallbackVersionMatch?.[1] ?? "0";
384
- const majorVersion = Number.parseInt(uaVersion.split(".")[0] ?? "0", 10) || 0;
417
+ const browserVersionMatch = (await page.browser().version()).match(/\/([\d|.]+)/);
418
+ const legacyVersion = uaVersionMatch?.[1] ?? browserVersionMatch?.[1] ?? "0";
419
+ const fullVersion = browserVersionMatch?.[1] ?? legacyVersion;
420
+ const majorVersion = Number.parseInt(legacyVersion.split(".")[0] ?? "0", 10) || 0;
385
421
  const isAndroid = userAgent.includes("Android");
386
- const platform = userAgent.includes("Mac OS X")
387
- ? "MacIntel"
388
- : isAndroid
389
- ? "Android"
390
- : userAgent.includes("Linux")
391
- ? "Linux"
392
- : "Win32";
393
- const platformFull = userAgent.includes("Mac OS X")
394
- ? "Mac OS X"
395
- : isAndroid
396
- ? "Android"
397
- : userAgent.includes("Linux")
398
- ? "Linux"
399
- : "Windows";
400
- const platformVersion = userAgent.includes("Mac OS X ")
401
- ? (userAgent.match(/Mac OS X ([^)]+)/)?.[1] ?? "")
422
+ const isMac = userAgent.includes("Mac OS X");
423
+ const isWindows = userAgent.includes("Windows");
424
+ const platform = isMac ? "MacIntel" : isAndroid ? "Android" : userAgent.includes("Linux") ? "Linux" : "Win32";
425
+ const platformFull = isMac ? "macOS" : isAndroid ? "Android" : userAgent.includes("Linux") ? "Linux" : "Windows";
426
+ const platformVersion = isMac
427
+ ? await resolveMacOsProductVersion()
402
428
  : userAgent.includes("Android ")
403
429
  ? (userAgent.match(/Android ([^;]+)/)?.[1] ?? "")
404
- : userAgent.includes("Windows ")
405
- ? (userAgent.match(/Windows .*?([\d|.]+);?/)?.[1] ?? "")
430
+ : isWindows
431
+ ? (userAgent.match(/Windows NT ([\d.]+)/)?.[1] ?? "")
406
432
  : "";
407
- const architecture = isAndroid ? "" : "x86";
433
+ const architecture = isAndroid ? "" : resolveHostArchitecture();
434
+ const bitness = isAndroid ? "" : resolveHostBitness();
408
435
  const model = isAndroid ? (userAgent.match(/Android.*?;\s([^)]+)/)?.[1] ?? "") : "";
409
436
 
410
437
  const brandOrders = [
@@ -422,6 +449,10 @@ async function resolveUserAgentOverride(page: Page): Promise<UserAgentOverride>
422
449
  brands[order[0]!] = { brand: greaseyBrand, version: "99" };
423
450
  brands[order[1]!] = { brand: "Chromium", version: String(majorVersion) };
424
451
  brands[order[2]!] = { brand: "Google Chrome", version: String(majorVersion) };
452
+ const fullVersionList = brands.map(({ brand }) => ({
453
+ brand,
454
+ version: brand === greaseyBrand ? "99.0.0.0" : fullVersion,
455
+ }));
425
456
 
426
457
  return {
427
458
  userAgent,
@@ -429,10 +460,12 @@ async function resolveUserAgentOverride(page: Page): Promise<UserAgentOverride>
429
460
  acceptLanguage: STEALTH_ACCEPT_LANGUAGE,
430
461
  userAgentMetadata: {
431
462
  brands,
432
- fullVersion: uaVersion,
463
+ fullVersion,
464
+ fullVersionList,
433
465
  platform: platformFull,
434
466
  platformVersion,
435
467
  architecture,
468
+ bitness,
436
469
  model,
437
470
  mobile: isAndroid,
438
471
  },
@@ -578,15 +611,29 @@ function buildStealthInjectionScript(scripts: readonly string[] = STEALTH_PATCH_
578
611
  .join(";\n");
579
612
 
580
613
  return `(() => {
581
- // Native function cache - captured before any tampering
582
- const iframe = document.createElement("iframe");
583
- iframe.style.display = "none";
614
+ const Page_Function_toString = Function.prototype.toString;
615
+ const Page_FunctionToStringDescriptor = Object.getOwnPropertyDescriptor(Function.prototype, "toString");
616
+ const Page_Proxy = Proxy;
617
+ const Page_WeakMap = WeakMap;
618
+ const Page_WeakMap_get = Page_WeakMap.prototype.get;
619
+ const Page_WeakMap_set = Page_WeakMap.prototype.set;
620
+ // Native function cache - captured before any tampering.
621
+ // A same-origin iframe yields natives uncontaminated by page-level
622
+ // tampering, but at document-start (when this preload runs) there is
623
+ // no documentElement to attach it to. In that case the page itself
624
+ // hasn't executed yet, so window's own natives are still pristine —
625
+ // fall back to window instead of bailing, otherwise none of the
626
+ // fingerprint patches below would ever run.
627
+ let iframe = null;
584
628
  const container = document.head ?? document.documentElement;
585
- if (!container) return;
586
- container.appendChild(iframe);
629
+ if (container) {
630
+ iframe = document.createElement("iframe");
631
+ iframe.style.display = "none";
632
+ container.appendChild(iframe);
633
+ if (!iframe.contentWindow) iframe = null;
634
+ }
587
635
  try {
588
- const nativeWindow = iframe.contentWindow;
589
- if (!nativeWindow) return;
636
+ const nativeWindow = iframe ? iframe.contentWindow : window;
590
637
 
591
638
  // Cache pristine native functions
592
639
  const Function_toString = nativeWindow.Function.prototype.toString;
@@ -626,9 +673,38 @@ function buildStealthInjectionScript(scripts: readonly string[] = STEALTH_PATCH_
626
673
  const Intl_DateTimeFormat = nativeWindow.Intl.DateTimeFormat;
627
674
  const Date_constructor = nativeWindow.Date;
628
675
 
676
+ const nativeFunctionSources = new Page_WeakMap();
677
+ const makeNativeString = (name) => "function " + (name || "") + "() { [native code] }";
678
+ const registerNativeSource = (fn, source) => {
679
+ if (typeof fn === "function") Reflect_apply(Page_WeakMap_set, nativeFunctionSources, [fn, source]);
680
+ return fn;
681
+ };
682
+ const patchToString = (fn, name) => registerNativeSource(fn, makeNativeString(name));
683
+ if (${scripts.length > 0 ? "true" : "false"}) {
684
+ const functionToStringProxy = new Page_Proxy(Page_Function_toString, {
685
+ apply(target, thisArg, args) {
686
+ const source = Reflect_apply(Page_WeakMap_get, nativeFunctionSources, [thisArg]);
687
+ if (source) return source;
688
+ return Reflect_apply(target, thisArg, args || []);
689
+ },
690
+ get(target, key, receiver) {
691
+ return Reflect_get(target, key, receiver);
692
+ },
693
+ });
694
+ registerNativeSource(functionToStringProxy, makeNativeString("toString"));
695
+ Object_defineProperty(Function.prototype, "toString", {
696
+ ...(Page_FunctionToStringDescriptor || {
697
+ writable: true,
698
+ configurable: true,
699
+ enumerable: false,
700
+ }),
701
+ value: functionToStringProxy,
702
+ });
703
+ }
704
+
629
705
  ${joint}
630
706
  } finally {
631
- if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
707
+ if (iframe && iframe.parentNode) iframe.parentNode.removeChild(iframe);
632
708
  }})();`;
633
709
  }
634
710
 
@@ -1,8 +1,5 @@
1
- // Helper to generate native code string
2
- const makeNativeString = (name) =>
3
- "function " + (name || "") + "() { [native code] }";
4
-
5
- // Patch toString for common fingerprinted functions
1
+ // Register native-looking source for common fingerprinted functions without
2
+ // adding own toString properties.
6
3
  const patchedFns = [
7
4
  [window.alert, "alert"],
8
5
  [window.prompt, "prompt"],
@@ -20,44 +17,28 @@ const patchedFns = [
20
17
  ];
21
18
 
22
19
  for (const [fn, name] of patchedFns) {
23
- if (typeof fn === "function") {
24
- const nativeStr = makeNativeString(name);
25
- Object_defineProperty(fn, "toString", {
26
- value: function toString() { return nativeStr; },
27
- writable: false,
28
- configurable: true,
29
- enumerable: false,
30
- });
31
- }
20
+ patchToString(fn, name);
32
21
  }
33
22
 
34
- // Patch Object.getOwnPropertyDescriptor to return native-looking descriptors
35
- Object.getOwnPropertyDescriptor = function (obj, prop) {
36
- const descriptor = Object_getOwnPropertyDescriptor.call(this, obj, prop);
23
+ // Patch Object.getOwnPropertyDescriptor to return native-looking accessor
24
+ // source through the shared Function.prototype.toString registry.
25
+ const patchedGetOwnPropertyDescriptor = function getOwnPropertyDescriptor(obj, prop) {
26
+ const descriptor = Reflect_apply(Object_getOwnPropertyDescriptor, this, [obj, prop]);
37
27
  if (!descriptor) return descriptor;
38
28
 
39
- // Make patched descriptors look native
40
29
  if (descriptor.get && typeof descriptor.get === "function") {
41
- const getStr = makeNativeString("get " + String(prop));
42
- Object_defineProperty(descriptor.get, "toString", {
43
- value: function toString() { return getStr; },
44
- writable: false,
45
- configurable: true,
46
- enumerable: false,
47
- });
30
+ patchToString(descriptor.get, "get " + String(prop));
48
31
  }
49
32
  if (descriptor.set && typeof descriptor.set === "function") {
50
- const setStr = makeNativeString("set " + String(prop));
51
- Object_defineProperty(descriptor.set, "toString", {
52
- value: function toString() { return setStr; },
53
- writable: false,
54
- configurable: true,
55
- enumerable: false,
56
- });
33
+ patchToString(descriptor.set, "set " + String(prop));
57
34
  }
58
35
 
59
36
  return descriptor;
60
37
  };
61
-
62
- // Cleanup
63
- document.head.removeChild(iframe);
38
+ patchToString(patchedGetOwnPropertyDescriptor, "getOwnPropertyDescriptor");
39
+ Object_defineProperty(Object, "getOwnPropertyDescriptor", {
40
+ value: patchedGetOwnPropertyDescriptor,
41
+ writable: true,
42
+ configurable: true,
43
+ enumerable: false,
44
+ });