Browsers

log can drive more than one browser, and every one of them works the same way, so a session never needs to know which browser it is in. Each account picks its browser in Twenty (socialAccounts.browserDriver: adsPower or camoufox; empty means Camoufox). Read this to understand how a browser is opened and held to the same checks, or to add a new one.

One Playwright layer for every browser

TS  log facebook host / log inspect / log flow / log facebook warmup
      └─ openInspectSession(account)  ── browser: browserFor(account)  (src/browser/driver)
Py  inspectSession.py
      └─ browserDriver.open_browser(config)                (python/browser/browserDriver.py)
           camoufox: AsyncCamoufox(launch_kwargs) → new_context  (the gate's launch and context guards)
           adsPower: gate_attached → profile proxy → Local API start → connect_over_cdp
                     → the profile's context → SessionHar → check_attached
      └─ the same Playwright context and page for everything after:
           HumanMouse, scrollMotion, Recorder (per-step HAR), the flows, saveState
  • All the Playwright work is async (playwright.async_api).
  • AdsPower is Chromium and Camoufox is Firefox, but both are driven through the same Playwright API.
  • Neither uses Puppeteer. ws.puppeteer is only the name AdsPower's Local API gives the CDP websocket it returns, which Playwright's connect_over_cdp takes.

In TypeScript, each browser is a folder under src/browser/ with the same parts:

  • src/browser/driver: the session machine (XState v5) and the list of capabilities every browser fills;
  • src/browser/<name>/types.ts: its row of the parity table below;
  • src/browser/<name>/client.ts: its BrowserDriver;
  • src/browser/<name>/<name>.test.ts: its tests.

How AdsPower is used

These practices come from an earlier production setup that ran on AdsPower, and are kept here:

PracticeIn log
A Local API client: start, stop and list profiles, then Playwright over CDPsrc/browser/adsPower/client.ts (v2 endpoints) and python/browser/adsPowerBrowser.py
One AdsPower window per account, held openlog facebook host opens the account's profile once a day and keeps it open; every action is a tab
Never close the AdsPower contextSessions close only their own tabs. The profile is stopped through the Local API at the end, never by context.close()
No stealth scriptsNothing is injected into any page
No cookie injection: the profile keeps its own cookiesThe profile keeps its own. Twenty's session is carried in once, only into a profile that has no Facebook login yet
The login check still appliesThe connect steps, the page check and signIn run the same in either browser
Keep a fallbackCamoufox stays: an account without browserDriver runs on it

Parity table

Every browser must fill the same capabilities. bun run check:browsers (pre-commit and pre-push) refuses a browser that is missing its folder, row, driver, tests, Python opener or column below. A capability may be marked not built: that is listed on every commit, not refused.

The table is generated from src/browser/<name>/types.ts; edit those, not the table.

CapabilityWhat it meanscamoufoxadsPower
headedWindowA visible window you can watch, never headless (rule 11)built: visibleBrowser.launch_kwargs: headless False, humanize, showcursor (rule 11)built: Local API start with headless "0" (python/browser/adsPowerBrowser.py); on a server the window renders on Xvfb
proxyGateThe account's proxy, exit IP in its country and speed checked before the browser starts (rule 12)built: proxyGate.apply() builds the only launch options; the launch guard refuses any otherbuilt: proxyGate.gate_attached() before the profile starts; the proxy is put on the profile only then
selfCheckTimezone, language, Intl and exit IP agree in a page and a worker before Facebook loads (rule 17)built: proxyGate's new_context guard runs _self_check before the caller gets the contextbuilt: proxyGate.check_attached() on the CDP context, the same verdict as Camoufox
sessionHarThe raw session HAR of every request (rules 19, 22)built: Playwright's record_har_path, forced on every context by the gatebuilt: harRecorder.SessionHar from the context events, written as session.har at close
stepHarEach inspect or flow step's own HAR, from the context's events (rule 29)built: harRecorder.Recorder, the inspect session's per-step HARbuilt: harRecorder.Recorder, the inspect session's per-step HAR
humanMouseEvery cursor move is HumanMouse's measured trajectory, nothing injected (rule 32)built: HumanMouse on the Playwright page; Camoufox's humanize per-hop curve capped at 20 msbuilt: HumanMouse on the Playwright page (no cursor is drawn: nothing is injected)
scrollMotionEvery scroll is scrollMotion's wheel trajectorybuilt: scrollMotion wheel trajectories on the Playwright pagebuilt: scrollMotion wheel trajectories on the Playwright page
devicePinnedOne fixed device per account, the same every open (rule 15)built: deviceProfile.py: one Windows identity per account in socialAccounts.deviceProfile (rule 15)built: the account's one AdsPower profile (fingerprint kept by AdsPower), linked in socialAccounts.adsPowerProfileId
localePinnednavigator, Accept-Language and Intl in the home country's locale (rule 16)built: HOME_COUNTRIES locale in the launch; the self-check checks it (rule 16)built: the profile's language from HOME_COUNTRIES; the self-check checks it
sessionCarriedThe account's signed-in session carries over from one open to the nextbuilt: Twenty's cookies and localStorage as the context's storage_state each openbuilt: the profile keeps its own cookies; Twenty's session is carried in once when it has none
sessionSavedThe session is saved back to Twenty (saveState)built: saveState writes the context storage state, saved to Twentybuilt: saveState writes the context storage state, saved to Twenty
inspectSessionlog inspect, the flows, the warm-up and log facebook host run in it (python/session/inspectSession.py)built: python/browser/browserDriver.py open_camoufoxbuilt: python/browser/browserDriver.py open_ads_power
syncSessionspublishSession, connectSession and profileSession (the sync sessions) run in itbuilt: publishSession, connectSession and profileSession launch it (sync API)not built: publishSession, connectSession and profileSession still launch Camoufox (sync API)
oneMachineAtATimeIt refuses to open where another machine already has the account openn/a: Camoufox runs only where log-cli runs; there is no shared profile to open twicenot built: cloud-active before open (docs/deployment.md)

The two capabilities still to build are tracked: syncSessions in #43, oneMachineAtATime in #44.

Adding a browser

  1. Add its name to BROWSER_DRIVERS (src/browser/driver/types.ts).
  2. Write src/browser/<name>/: types.ts with its capabilities row, client.ts with its driver, index.ts, and <name>.test.ts.
  3. Add its opener to python/browser/browserDriver.py (BROWSERS and OPENERS). It must return a gated, self-checked Playwright context and record a session HAR.
  4. Run bun run browsers:docs, and commit.

Setting a browser up: Browser setup. The rules behind this are in AGENTS.md.