Merge pull request #1056 from jiwangyihao/fix/browser-stealth-target-setup

fix(browser): bound stealth target setup during tab open
This commit is contained in:
Can Bölük
2026-05-14 04:46:38 +02:00
committed by GitHub
3 changed files with 221 additions and 10 deletions
+1
View File
@@ -24,6 +24,7 @@
### Fixed
- Fixed headless `browser.open` tab startup on slow Chromium target enumeration by making worker-side stealth user-agent target setup selective, bounded, and best-effort for non-active targets. Worker startup errors are now surfaced directly instead of degrading into the generic tab worker initialization timeout.
- Fixed token display for sessions and subagents inflating far beyond the context window. `token_total` status-line segment and the subagent overlay token counter now show `input + output + cacheWrite` instead of `input + output + cacheRead + cacheWrite`. With prompt caching, `cacheRead` per turn equals the full cached context — summing it across all turns produces a cumulative total that is N×context_size (e.g. a 5-turn session with a 1 M-token context reported ~5 M tokens). Cache activity is still visible via the dedicated `cache_read`/`cache_write` status-line segments; billing cost is unaffected.
- Fixed ACP clients missing `config_option_update` notifications when the thinking level changed via any path other than the client's own `session/set_session_config_option` call (slash commands, model auto-adjust, extension UI). `AgentSession` now emits a `thinking_level_changed` event from `setThinkingLevel`, and `AcpAgent` subscribes to each managed session for the session's lifetime and pushes a fresh `config_option_update` whenever the effective level changes — independent of any active prompt turn. The subscription is installed inside `#scheduleBootstrapUpdates`'s 50 ms timer so it shares the same race guard that prevents Zed's `Received session notification for unknown session` drop when notifications fire before `session/new` (or fork) returns; the pre-bootstrap thinking level is reported in the response's `configOptions`. The `session/set_session_config_option` handler keeps its own push only when the subscription has not yet been installed, so client-driven thinking changes still notify pre-bootstrap, post-bootstrap they flow through the subscription exactly once. Subscriptions are released in `#disposeSessionRecord`.
- Fixed MCP OAuth refresh failing with `HTTP 401 invalid_client` for servers that require Dynamic Client Registration (RFC 7591) and have no `oauth.clientId` configured (e.g. `mcp.linear.app`). `MCPOAuthFlow` registered a fresh public PKCE client on each authorize and discarded the issued `client_id` once the flow object went out of scope; refresh then called the provider's `/token` endpoint without a `client_id`. The flow now exposes `resolvedClientId` / `registeredClientSecret` getters, `MCPCommandController#handleOAuthFlow` returns them alongside `credentialId`, and both the initial-connect and `/mcp reauth` paths persist them into `auth.{clientId,clientSecret}` (used at refresh) and `oauth.{clientId,clientSecret}` (used by subsequent `/mcp reauth` to skip re-registration). The `MCPAddWizard` `onOAuth` callback type is now `Promise<MCPAddWizardOAuthResult>` and `#launchOAuthFlow` folds the registered credentials into wizard state. Servers with a statically-configured `oauth.clientId` (Notion, Slack, Datadog) are unaffected — `#tryRegisterClient` short-circuits and the write-back is a no-op. ([#1061](https://github.com/can1357/oh-my-pi/pull/1061) by [@ldx](https://github.com/ldx)).
@@ -3,7 +3,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { $which, getPuppeteerDir, logger } from "@oh-my-pi/pi-utils";
import * as browsers from "@puppeteer/browsers";
import type { Browser, CDPSession, Page, default as Puppeteer } from "puppeteer-core";
import type { Browser, CDPSession, Page, default as Puppeteer, Target } from "puppeteer-core";
import { PUPPETEER_REVISIONS } from "puppeteer-core/internal/revisions.js";
import stealthTamperingScript from "../puppeteer/00_stealth_tampering.txt" with { type: "text" };
import stealthActivityScript from "../puppeteer/01_stealth_activity.txt" with { type: "text" };
@@ -37,6 +37,8 @@ const STEALTH_IGNORE_DEFAULT_ARGS = [
];
const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
const USER_AGENT_TARGET_TIMEOUT_MS = 5_000;
const USER_AGENT_TARGET_TYPES = new Set(["page", "webview", "background_page"]);
const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__";
/**
@@ -463,6 +465,7 @@ export interface UserAgentSession {
async function configureUserAgentTargets(
browser: Browser,
state: { browserSession: CDPSession | null; override: UserAgentOverride },
targetTimeoutMs = USER_AGENT_TARGET_TIMEOUT_MS,
): Promise<void> {
if (!state.browserSession) {
state.browserSession = await browser.target().createCDPSession();
@@ -471,23 +474,72 @@ async function configureUserAgentTargets(
waitForDebuggerOnStart: false,
flatten: true,
});
state.browserSession.on("Target.attachedToTarget", async (event: { sessionId: string }) => {
const connection = state.browserSession?.connection();
const session = connection?.session(event.sessionId);
if (!session) return;
await sendUserAgentOverride(wrapSession(session), state.override);
});
state.browserSession.on(
"Target.attachedToTarget",
async (event: { sessionId: string; targetInfo?: { type?: string } }) => {
if (!targetInfoSupportsUserAgentOverride(event.targetInfo)) return;
const connection = state.browserSession?.connection();
const session = connection?.session(event.sessionId);
if (!session) return;
await withSoftTimeout(
sendUserAgentOverride(wrapSession(session), state.override),
targetTimeoutMs,
"new target user-agent override",
);
},
);
}
const targets = browser.targets();
const targets = browser.targets().filter(targetSupportsUserAgentOverride);
await Promise.all(
targets.map(async target => {
const session = await target.createCDPSession();
await sendUserAgentOverride(wrapSession(session), state.override);
await withSoftTimeout(
applyTargetUserAgentOverride(target, state.override),
targetTimeoutMs,
"target user-agent override",
);
}),
);
}
function targetSupportsUserAgentOverride(target: Target): boolean {
return targetInfoSupportsUserAgentOverride({ type: target.type() });
}
function targetInfoSupportsUserAgentOverride(targetInfo: { type?: string } | undefined): boolean {
return Boolean(targetInfo?.type && USER_AGENT_TARGET_TYPES.has(targetInfo.type));
}
async function applyTargetUserAgentOverride(target: Target, override: UserAgentOverride): Promise<void> {
const session = await target.createCDPSession();
try {
await sendUserAgentOverride(wrapSession(session), override);
} finally {
await session.detach().catch(() => undefined);
}
}
async function withSoftTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T | undefined> {
let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<undefined>(resolve => {
timeout = setTimeout(() => {
logger.debug(`Timed out applying ${label}`);
resolve(undefined);
}, timeoutMs);
});
try {
return await Promise.race([
promise.catch(error => {
logger.debug(`Failed to apply ${label}`, { error: error instanceof Error ? error.message : String(error) });
return undefined;
}),
timeoutPromise,
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
async function injectStealthScripts(page: Page): Promise<void> {
const scripts = [
stealthTamperingScript,
@@ -574,3 +626,14 @@ export async function applyStealthPatches(
state.browserSession = targetState.browserSession;
await injectStealthScripts(page);
}
export function targetSupportsUserAgentOverrideForTest(target: Target): boolean {
return targetSupportsUserAgentOverride(target);
}
export async function configureUserAgentTargetsForTest(
browser: Browser,
state: { browserSession: CDPSession | null; override: UserAgentOverride },
targetTimeoutMs?: number,
): Promise<void> {
await configureUserAgentTargets(browser, state, targetTimeoutMs);
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from "bun:test";
import type { Browser, CDPSession, Target } from "puppeteer-core";
import {
configureUserAgentTargetsForTest,
targetSupportsUserAgentOverrideForTest,
} from "../../src/tools/browser/launch";
type SentCommand = {
method: string;
params?: Record<string, unknown>;
};
class FakeSession {
readonly commands: SentCommand[] = [];
detached = false;
readonly #delayMs: number;
constructor(delayMs = 0) {
this.#delayMs = delayMs;
}
async send(method: string, params?: Record<string, unknown>): Promise<unknown> {
this.commands.push({ method, params });
if (this.#delayMs > 0) await Bun.sleep(this.#delayMs);
return {};
}
async detach(): Promise<void> {
this.detached = true;
}
on(): void {}
connection(): { session: () => null } {
return { session: () => null };
}
}
class FakeTarget {
readonly session: FakeSession;
createCalls = 0;
readonly #type: string;
constructor(type: string, delayMs = 0) {
this.#type = type;
this.session = new FakeSession(delayMs);
}
type(): string {
return this.#type;
}
async createCDPSession(): Promise<CDPSession> {
this.createCalls++;
return this.session as unknown as CDPSession;
}
}
class FakeBrowser {
readonly browserTarget = new FakeTarget("browser");
readonly #targets: FakeTarget[];
constructor(targets: FakeTarget[]) {
this.#targets = targets;
}
target(): Target {
return this.browserTarget as unknown as Target;
}
targets(): Target[] {
return this.#targets as unknown as Target[];
}
}
const override = {
userAgent: "Mozilla/5.0 Chrome/142.0.0.0 Safari/537.36",
acceptLanguage: "en-US,en",
platform: "Win32",
userAgentMetadata: {
brands: [{ brand: "Chromium", version: "142" }],
fullVersion: "142.0.0.0",
platform: "Windows",
platformVersion: "10.0.0",
architecture: "x86",
model: "",
mobile: false,
},
};
describe("browser stealth target setup", () => {
it("attempts user-agent override for page-like existing targets and skips non-page worker/browser targets", async () => {
const page = new FakeTarget("page");
const webview = new FakeTarget("webview");
const backgroundPage = new FakeTarget("background_page");
const serviceWorker = new FakeTarget("service_worker");
const sharedWorker = new FakeTarget("shared_worker");
const browserTarget = new FakeTarget("browser");
const other = new FakeTarget("other");
const browser = new FakeBrowser([
page,
webview,
backgroundPage,
serviceWorker,
sharedWorker,
browserTarget,
other,
]);
await configureUserAgentTargetsForTest(browser as unknown as Browser, { browserSession: null, override });
expect(page.createCalls).toBe(1);
expect(webview.createCalls).toBe(1);
expect(backgroundPage.createCalls).toBe(1);
expect(serviceWorker.createCalls).toBe(0);
expect(sharedWorker.createCalls).toBe(0);
expect(browserTarget.createCalls).toBe(0);
expect(other.createCalls).toBe(0);
expect(page.session.detached).toBe(true);
expect(webview.session.detached).toBe(true);
expect(backgroundPage.session.detached).toBe(true);
});
it("classifies only targets with page surfaces as user-agent override targets", () => {
const supportedTypes = ["page", "webview", "background_page"];
const skippedTypes = ["browser", "service_worker", "shared_worker", "other"];
for (const type of supportedTypes) {
expect(targetSupportsUserAgentOverrideForTest({ type: () => type } as unknown as Target)).toBe(true);
}
for (const type of skippedTypes) {
expect(targetSupportsUserAgentOverrideForTest({ type: () => type } as unknown as Target)).toBe(false);
}
});
it("gives a slow page-like target a bounded but non-trivial window to receive user-agent override", async () => {
const slowPage = new FakeTarget("page", 200);
const browser = new FakeBrowser([slowPage]);
const started = performance.now();
await configureUserAgentTargetsForTest(browser as unknown as Browser, { browserSession: null, override }, 50);
const elapsed = performance.now() - started;
expect(elapsed).toBeGreaterThanOrEqual(45);
expect(elapsed).toBeLessThan(150);
});
});