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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user