import { logger } from "@f5-sales-demo/pi-utils"; import { type AcquireAction, acquirePage, decideAcquireAction, isChromeRunning, restoreDefaultChromeWithoutDebugPort, } from "./acquire"; import { ensureAuthenticated } from "./auth"; import { CdpPageActions } from "./cdp-page-actions"; import { locateChrome } from "./chrome-locate"; import { ExtensionBrowserProvider } from "./extension-provider"; import type { PageActions } from "./page-actions"; export interface AcquiredBrowser { page: PageActions; mode: string; release(): Promise; } export interface BrowserProviderStatus { debuggableNow: boolean; chromeRunning: boolean; chromeInstalled: boolean; plannedAction: AcquireAction; detail: string; } export interface BrowserProvider { readonly name: string; acquire(consoleUrl: string): Promise; status(): Promise; } type Settings = { get(key: string): unknown }; const DEBUG_PORT = 9222; /** Probe the loopback debug endpoint without attaching. */ async function probeDebuggableDefault(): Promise { try { const r = await fetch(`http://127.0.0.1:${DEBUG_PORT}/json/version`); return r.ok; } catch { return false; } } const DETAIL: Record = { attach: "A debuggable Chrome is reachable on 127.0.0.1:9222 — xcsh will attach and co-drive it.", launch: "Chrome is installed and not running — xcsh will launch it on your real profile with a loopback debug port.", relaunch: "Your Chrome is running without a debug port — xcsh will gracefully quit and reopen it on your real profile (consent granted).", dedicated: "Your Chrome is running without a debug port and relaunch is off — xcsh will use an isolated profile (run `/chrome relaunch` or set browser.allowChromeRelaunch to use your real session).", "no-chrome": "Google Chrome was not found — install it or set browser.chromePath.", }; export class CdpBrowserProvider implements BrowserProvider { readonly name = "cdp"; #settings: Settings; #probes: { probeDebuggable: () => Promise; chromeRunning: () => boolean; chromeInstalled: () => boolean }; #lifecycle: { acquirePage: typeof acquirePage; ensureAuthenticated: typeof ensureAuthenticated; restoreDefaultChromeWithoutDebugPort: typeof restoreDefaultChromeWithoutDebugPort; }; constructor( settings: Settings, probes?: { probeDebuggable: () => Promise; chromeRunning: () => boolean; chromeInstalled: () => boolean; }, lifecycle?: { acquirePage: typeof acquirePage; ensureAuthenticated: typeof ensureAuthenticated; restoreDefaultChromeWithoutDebugPort: typeof restoreDefaultChromeWithoutDebugPort; }, ) { this.#settings = settings; this.#probes = probes ?? { probeDebuggable: probeDebuggableDefault, chromeRunning: () => isChromeRunning(), chromeInstalled: () => locateChrome({ settings }) != null, }; this.#lifecycle = lifecycle ?? { acquirePage, ensureAuthenticated, restoreDefaultChromeWithoutDebugPort, }; } async status(): Promise { const debuggableNow = await this.#probes.probeDebuggable(); const chromeRunning = this.#probes.chromeRunning(); const chromeInstalled = this.#probes.chromeInstalled(); const allowRelaunch = this.#settings.get("browser.allowChromeRelaunch") === true; const plannedAction = decideAcquireAction({ debuggableNow, chromeRunning, chromeInstalled, allowRelaunch }); return { debuggableNow, chromeRunning, chromeInstalled, plannedAction, detail: DETAIL[plannedAction] }; } async acquire(consoleUrl: string): Promise { const { browser, page, mode } = await this.#lifecycle.acquirePage({ settings: this.#settings, debugPort: DEBUG_PORT, }); await this.#lifecycle.ensureAuthenticated(page, consoleUrl); const dropPort = this.#settings.get("browser.dropPortAfter") === true; const relaunched = mode === "relaunched-default"; return { page: new CdpPageActions(page), mode, release: async () => { await browser.disconnect().catch(() => {}); if (dropPort && relaunched) { try { await this.#lifecycle.restoreDefaultChromeWithoutDebugPort(this.#settings); } catch { logger.warn("Failed to restore Chrome without the debug port"); } } }, }; } } /** * Select the best available browser provider: if the Chrome extension bridge is * reachable (the extension is loaded + xcsh chrome setup ran), use it (real profile, * no debug port needed). Otherwise fall back to CDP (dedicated profile). * * The probe is bounded: if the extension doesn't connect within `probeTimeoutMs`, * the CDP provider is used — so this never blocks a session indefinitely. */ /** * Shared bridge server singleton. When main.ts starts the bridge early (instant-on), * it sets this so ALL subsequent `selectProvider()` calls reuse the same bridge * instead of starting a conflicting second one on the same port. */ let _sharedBridgeServer: import("./extension-bridge").BridgeServer | null = null; /** Publish (or, with `null`, clear) the process-shared bridge. Clearing is * required when a partially-started bridge is torn down (e.g. session bootstrap * failed after bind) so a later `selectProvider()` never reuses a closed bridge. */ export function setSharedBridgeServer(server: import("./extension-bridge").BridgeServer | null): void { _sharedBridgeServer = server; } export async function selectProvider( settings: Settings, opts?: { probeTimeoutMs?: number; bridgeServer?: import("./extension-bridge").BridgeServer }, ): Promise { // `XCSH_BROWSER_PROVIDER=extension` forces the extension (real Chrome) and // disables the CDP fallback — required for the NL-driven console automation // flagship, where falling back to a separate CDP profile is never wanted. // `XCSH_BROWSER_PROVIDER=cdp` forces CDP. The probe timeout is configurable // via `XCSH_BRIDGE_PROBE_MS` (default 5s) — the extension can take a few // seconds to (re)connect after the bridge socket is (re)bound, and 5s races // that reconnect, so callers expecting the extension should raise it. const forced = process.env.XCSH_BROWSER_PROVIDER?.toLowerCase(); const envProbe = Number(process.env.XCSH_BRIDGE_PROBE_MS); // Forced-extension default is 45s: the MV3 service worker can suspend between // runs, after which only its ~30s reconnect alarm re-attaches it — a shorter // probe races (and loses to) that alarm. 45s clears the alarm + reconnect. const bridgeProbeTimeoutMs = opts?.probeTimeoutMs ?? (Number.isFinite(envProbe) && envProbe > 0 ? envProbe : forced === "extension" ? 45_000 : 5_000); if (forced === "cdp") return new CdpBrowserProvider(settings); const server = opts?.bridgeServer ?? _sharedBridgeServer; if (!server) { if (forced === "extension") { throw new Error("The extension provider requires a manager-bound bridge."); } return new CdpBrowserProvider(settings); } try { const deadline = Date.now() + bridgeProbeTimeoutMs; while (Date.now() < deadline) { if (server.connected) { return new ExtensionBrowserProvider({ server }); } await Bun.sleep(300); } if (forced === "extension") { throw new Error("The manager-bound Chrome extension bridge did not authenticate before the deadline."); } } catch (err) { if (forced === "extension") throw err; // never silently fall back to CDP when extension is required // Auto mode may use CDP when the already-configured extension bridge is unavailable. } return new CdpBrowserProvider(settings); }