The agent's DEFAULT browser launches as a normal branded Google Chrome rather than Playwright's bundled Chromium. This stealth identity applies to the spawned per-instance Chrome (the default transport); the optional cdp attach drives the user's OWN already-running Chrome, whose identity the user owns and which this ADR does not govern.
- The default browser is a spawned per-instance Chrome.
packages/runtime/src/tools/chrome-launch.ts(launchSpawnedChrome) drives a real branded Chrome viachromium.launchPersistentContextover Playwright's PIPE transport with--headless=new, the sharedCHROME_LAUNCH_ARGS, the clean UA, the per-instance--user-data-dir, and a free-picked--remote-debugging-portin the launch args (the TCP debug port is an extra local-only endpoint used by the sign-in screencast bridge, not how the automation is driven —launchPersistentContextdrives the spawned page over the pipe inherently). Teardown closes the persistent context; if that wedges, it reaps the Chrome bound to the instance's profile dir by scanning for the matching--user-data-dir(neverkillall, never the user's:9222). There is no runtime-spawned visible managed window; the only non-spawned option is the explicit user-suppliedcdpattach (see Browser Automation Engine).
The identity choices are centralized in packages/runtime/src/tools/chrome-discovery.ts and used by launchSpawnedChrome:
- Branded identity.
resolveBrowserLaunchTargetprefers the detected branded Google Chrome stable binary (launched viaexecutablePath) over the bundled Chromium. The bundled Chromium remains the automatic fallback — used when no branded Chrome is installed, or when a branded launch fails to start/drive.GINI_CHROME_PATHstill wins unconditionally (explicit binary). We launch byexecutablePathrather than Playwright'schannel: "chrome"so the launch drives exactly the binary we already probe for the UA (cleanChromeUserAgent) — a channel launch leans on Playwright's own separate channel detection, which can resolve a different binary than the one we discovered and divergence there would mislabel the UA. (This identity logic governs the spawned transport; the cdp attach drives the user's own Chrome, whose binary we never resolve.) - Cleared
navigator.webdriver. The spawned launch carries--disable-blink-features=AutomationControlled(in the sharedCHROME_LAUNCH_ARGS), which makesnavigator.webdriverreadfalse. - Normalized headless UA. For headless launches,
cleanChromeUserAgentderives a reduced Chrome UA (major version only, no "Headless" token) from the resolved binary's--versionand passes it as theuserAgentcontext option. When the version can't be determined, the override is skipped. - Keychain-independent persistent login. Every launch carries
--password-store=basic(inCHROME_LAUNCH_ARGS). Chrome otherwise encrypts its cookie/credential store with a key from the macOS Keychain ("Chrome Safe Storage"); on a headless Mac (Gini's structural deployment model — see Connector Secret Storage) the Keychain is frequently locked, so cookies written under one launch can't be decrypted on the next and the agent appears logged out. The basic store uses a stable file-based key, so a login set once stays consistent across relaunches and crashes.
The profile is per instance. chromeProfileDirFor(instance) resolves to ~/.gini/instances/<instance>/chrome-profile, and every spawned launch and relaunch shares that one dir — so a sign-in done through the in-chat screencast modal is visible to every subsequent browser tool call in the instance. There is one shared browser per instance; cookies bleed across tasks within an instance, per the explicit product decision. Two failure modes broke "login set once stays logged in" and are addressed here:
- Keychain-encrypted cookies couldn't be decrypted across launches on a locked-Keychain Mac →
--password-store=basic(above). - An externally-killed Chrome (crash, or the user quitting the branded Chrome the agent now shares) used to wedge the runtime on a stale, dead handle so every later tool call failed until a gateway restart.
isContextConnected(inpackages/runtime/src/tools/browser.ts) now probesBrowser.isConnected()instead ofcontext.pages()— which returns[]without throwing after an external kill — soensureSharedrelaunches andgetOrCreatedrops a session whose Chrome died mid-task. The browser self-heals; cookies already flushed to the persistent profile survive the relaunch. - A wedged Chrome that won't close — a page stuck on a heavy/bot-protected navigation can leave
context.close()unresolved forever, which used to hang the whole teardown and strand any task waiting on a relaunch against the same profile dir. Teardown now bounds everyclose()/disconnect()(settledWithin,teardownCloseTimeoutMs); on timeout it reaps the spawned Chromium by OS pid — playwright-core'sBrowserexposes noprocess(), so the child is found by scanning for the process whose--user-data-diris the instance profile dir andSIGKILLed — which frees the profile-dir lock so the relaunch (and the resumed task) proceed.
Issue #218: sites such as Yelp detected the agent browser as an automation/test browser and refused to behave normally. Three tells, all stemming from how the browser was launched:
findChromePathpreferred Playwright's bundled Chromium, which on disk is literallyGoogle Chrome for Testing.app. That build advertises a "Chrome for Testing" identity that automation-integrity checks flag.- Under CDP,
navigator.webdriveristrueby default — spoofing the UA alone does not clear it (noted in the issue thread). - Headless Chrome leaks
HeadlessChromeinto bothnavigator.userAgentand the wireUser-Agentheader. Worse, that string mismatches the (already branded)Sec-CH-UAclient hints the same headless build sends — the inconsistency is itself a detection signal.
Spikes against playwright-core 1.60 + the installed Chrome confirmed each fix in isolation: the AutomationControlled flag clears navigator.webdriver in all modes; launching the detected branded Chrome binary via executablePath produces the real branded Chrome with a clean UA and brands (identical clean signals to channel: "chrome", while letting us drive the exact binary we probe); and setting the userAgent option to a reduced, "Headless"-stripped UA makes both the navigator UA and the wire header consistent with the existing branded Sec-CH-UA.
This deliberately reverses the prior "prefer bundled Chromium" default. That earlier stance existed for CDP-protocol stability: the bundled Chromium is built against playwright-core's pinned protocol revision, while a system Chrome can be arbitrarily ahead and produce silent /devtools/browser/<id> handshake hangs on protocol drift. The fallback preserves that safety net: when a branded launch can't start or drive (the protocol-drift failure mode), launchSpawnedChrome falls back to the bundled Chromium so the agent browser stays available. Only a branded target retries on the bundled binary — an override or already-bundled target has no better fallback and rethrows. The branded-first default trades a slightly larger surface for protocol drift (mitigated by the fallback) against no longer presenting as "Chrome for Testing".
- Launch identity and args are chosen in one place:
packages/runtime/src/tools/chrome-discovery.ts. New launch sites should calllaunchSpawnedChromerather than invokingchromium.launchPersistentContextwith their own args, so the stealth args, the branded→bundled fallback, and the headless UA normalization stay consistent. CHROME_LAUNCH_ARGSis the single source of shared launch args. Adding or changing a flag there propagates to the spawned launch.- The launch records the binary that actually backed the context (the resolved
executablePath). A branded launch records the branded path, and a fallback launch records the bundled one — don't reintroduce achromium.executablePath()fallback that would mislabel the launched binary. - The headless UA override is best-effort. On platforms where
--versiondoesn't print to stdout (Windowschrome.exe),cleanChromeUserAgentreturnsundefinedand the launch proceeds with no override — same behavior as before, no regression.
CHROME_LAUNCH_ARGScontains--disable-blink-features=AutomationControlledand--password-store=basic, and the spawn launcher (launchSpawnedChrome) passes them through.- The spawned launch picks a free debug port at or above
DEFAULT_CDP_PORT_BASE(well above the conventional9222), so it never collides with — or attaches to — a user's personal debugging Chrome on9222; on teardown it closes the persistent context and, only if that wedges, reaps the Chrome bound to the instance's profile dir (neverkillall). - The spawned launch and every relaunch target the per-instance
chrome-profiledir (chromeProfileDirFor(instance)), so they share one signed-in profile. - A login (persistent cookie + localStorage) set in the instance's browser is still present after a gateway restart, and after the spawned Chrome is killed and relaunched.
- With branded Chrome installed,
resolveBrowserLaunchTargetreturns{ executablePath: <branded path>, branded: true }; a branded target always carries a non-nullexecutablePath. GINI_CHROME_PATHpointing at an existing binary yields{ executablePath: <path>, branded: false }.cleanChromeUserAgent(null)returnsundefined; for a binary whose--versionprintsGoogle Chrome 142.0.7000.1, it returns a UA containingChrome/142.0.0.0and the platform token, with no "Headless".- In a real headless launch, the wire
User-Agenthas no "Headless" and is consistent withSec-CH-UA, andnavigator.webdriverisfalse.