diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7fae984c1..ce4996e8a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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` 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)). diff --git a/packages/coding-agent/src/tools/browser/launch.ts b/packages/coding-agent/src/tools/browser/launch.ts index 4b6412f4b..6414fbda5 100644 --- a/packages/coding-agent/src/tools/browser/launch.ts +++ b/packages/coding-agent/src/tools/browser/launch.ts @@ -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 { 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 { + const session = await target.createCDPSession(); + try { + await sendUserAgentOverride(wrapSession(session), override); + } finally { + await session.detach().catch(() => undefined); + } +} + +async function withSoftTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise(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 { 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 { + await configureUserAgentTargets(browser, state, targetTimeoutMs); +} diff --git a/packages/coding-agent/test/tools/browser-stealth-targets.test.ts b/packages/coding-agent/test/tools/browser-stealth-targets.test.ts new file mode 100644 index 000000000..3ca6622df --- /dev/null +++ b/packages/coding-agent/test/tools/browser-stealth-targets.test.ts @@ -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; +}; + +class FakeSession { + readonly commands: SentCommand[] = []; + detached = false; + readonly #delayMs: number; + + constructor(delayMs = 0) { + this.#delayMs = delayMs; + } + + async send(method: string, params?: Record): Promise { + this.commands.push({ method, params }); + if (this.#delayMs > 0) await Bun.sleep(this.#delayMs); + return {}; + } + + async detach(): Promise { + 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 { + 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); + }); +});