fix: harden browser provider activation

This commit is contained in:
Sinabina
2026-07-21 12:13:33 -07:00
parent e3effb3fc4
commit a84a6e233d
65 changed files with 1203 additions and 171 deletions
+2
View File
@@ -382,6 +382,7 @@ export class BrowserManager {
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
// Extensions only work in headed mode, so we use an off-screen window.
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
if (extensionsDir) assertHeadedBrowserProvider();
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
let useHeadless = true;
@@ -1587,6 +1588,7 @@ export class BrowserManager {
* If step 2 fails → return error, headless browser untouched
*/
async handoff(message: string): Promise<string> {
assertHeadedBrowserProvider();
if (this.connectionMode === 'headed' || this.isHeaded) {
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
}
+10 -5
View File
@@ -118,7 +118,7 @@ interface ServerState {
serverPath: string;
binaryVersion?: string;
mode?: 'launched' | 'headed';
/** Hash of (proxyUrl + headed flag), used by D2 daemon-mismatch check. */
/** Hash of proxy, headed mode, and browser-provider intent, used by daemon-mismatch checks. */
configHash?: string;
/** Xvfb child PID for cleanup on disconnect. */
xvfbPid?: number;
@@ -431,8 +431,8 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
// hint. No silent restart — that would drop tab state, cookies, and
// logged-in sessions without warning.
if (desiredHash && state.configHash && state.configHash !== desiredHash) {
console.error(`[browse] existing daemon has different config (proxy/headed mismatch).`);
console.error(`[browse] run 'browse disconnect' first to apply --proxy/--headed.`);
console.error(`[browse] existing daemon has different config (browser provider, proxy, or headed mode).`);
console.error(`[browse] run 'browse disconnect' first to apply the selected browser configuration.`);
process.exit(1);
}
// Same path: existing daemon is plain (no flags) but caller passes
@@ -782,7 +782,7 @@ export interface GlobalFlags {
proxyUrl: string | null;
/** Whether --headed was passed. */
headed: boolean;
/** Hash of (proxy + headed) for daemon-mismatch check. */
/** Hash of proxy, headed mode, and browser-provider intent for daemon-mismatch checks. */
configHash: string;
/** Redacted form of proxyUrl, safe for logs. */
redactedProxyUrl: string;
@@ -842,7 +842,12 @@ export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): G
args: out,
proxyUrl: canonicalProxyUrl,
headed,
configHash: computeConfigHash({ proxyUrl: canonicalProxyUrl, headed }),
configHash: computeConfigHash({
proxyUrl: canonicalProxyUrl,
headed,
browserProvider: env.GSTACK_BROWSER_PROVIDER,
browserExecutable: env.GSTACK_CHROMIUM_PATH,
}),
redactedProxyUrl: redactProxyUrl(canonicalProxyUrl),
};
}
+11 -2
View File
@@ -125,7 +125,7 @@ export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig {
}
/**
* Compute a stable hash of (proxyUrl + headed flag) for daemon-mismatch
* Compute a stable hash of proxy, headed mode, and browser-provider intent for daemon-mismatch
* detection (D2). The hash is deterministic across CLI invocations on the
* same machine and survives daemon restarts via the state file.
*
@@ -135,9 +135,18 @@ export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig {
export function computeConfigHash(opts: {
proxyUrl: string | null | undefined;
headed: boolean;
browserProvider?: string | null;
browserExecutable?: string | null;
}): string {
const proxyKey = canonicalizeProxyUrl(opts.proxyUrl);
const input = JSON.stringify({ proxy: proxyKey, headed: opts.headed });
const browserProvider = opts.browserProvider || null;
const browserExecutable = browserProvider === "installed" ? opts.browserExecutable || null : null;
const input = JSON.stringify({
proxy: proxyKey,
headed: opts.headed,
browserProvider,
browserExecutable,
});
return createHash('sha256').update(input).digest('hex').slice(0, 16);
}
+12 -5
View File
@@ -355,11 +355,18 @@ export async function handleWriteCommand(
}
} catch (err: any) {
// Enhanced error guidance: clicking <option> elements always fails (not visible / timeout)
const isOption = 'locator' in resolved
? await resolved.locator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
: await target.locator(resolved.selector).evaluate(
el => el.tagName === 'OPTION'
).catch(() => false);
// Do not start a second auto-wait after the click has already timed out.
// Missing selectors used to spend 5s in click(), then block again in
// evaluate() until the outer client killed the command. count() is an
// immediate query and keeps the helpful option guidance only when one
// unique element actually exists.
const optionLocator = 'locator' in resolved
? resolved.locator
: target.locator(resolved.selector);
const optionCount = await optionLocator.count().catch(() => 0);
const isOption = optionCount === 1
? await optionLocator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
: false;
if (isOption) {
throw new Error(
`Cannot click <option> elements. Use 'browse select <parent-select> <value>' instead of 'click' for dropdown options.`