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.puppeteeris only the name AdsPower's Local API gives the CDP websocket it returns, which Playwright'sconnect_over_cdptakes.
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: itsBrowserDriver;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:
| Practice | In log |
|---|---|
| A Local API client: start, stop and list profiles, then Playwright over CDP | src/browser/adsPower/client.ts (v2 endpoints) and python/browser/adsPowerBrowser.py |
| One AdsPower window per account, held open | log facebook host opens the account's profile once a day and keeps it open; every action is a tab |
| Never close the AdsPower context | Sessions close only their own tabs. The profile is stopped through the Local API at the end, never by context.close() |
| No stealth scripts | Nothing is injected into any page |
| No cookie injection: the profile keeps its own cookies | The 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 applies | The connect steps, the page check and signIn run the same in either browser |
| Keep a fallback | Camoufox 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.
| Capability | What it means | camoufox | adsPower |
|---|---|---|---|
headedWindow | A 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 |
proxyGate | The 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 other | built: proxyGate.gate_attached() before the profile starts; the proxy is put on the profile only then |
selfCheck | Timezone, 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 context | built: proxyGate.check_attached() on the CDP context, the same verdict as Camoufox |
sessionHar | The raw session HAR of every request (rules 19, 22) | built: Playwright's record_har_path, forced on every context by the gate | built: harRecorder.SessionHar from the context events, written as session.har at close |
stepHar | Each inspect or flow step's own HAR, from the context's events (rule 29) | built: harRecorder.Recorder, the inspect session's per-step HAR | built: harRecorder.Recorder, the inspect session's per-step HAR |
humanMouse | Every 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 ms | built: HumanMouse on the Playwright page (no cursor is drawn: nothing is injected) |
scrollMotion | Every scroll is scrollMotion's wheel trajectory | built: scrollMotion wheel trajectories on the Playwright page | built: scrollMotion wheel trajectories on the Playwright page |
devicePinned | One 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 |
localePinned | navigator, 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 |
sessionCarried | The account's signed-in session carries over from one open to the next | built: Twenty's cookies and localStorage as the context's storage_state each open | built: the profile keeps its own cookies; Twenty's session is carried in once when it has none |
sessionSaved | The session is saved back to Twenty (saveState) | built: saveState writes the context storage state, saved to Twenty | built: saveState writes the context storage state, saved to Twenty |
inspectSession | log inspect, the flows, the warm-up and log facebook host run in it (python/session/inspectSession.py) | built: python/browser/browserDriver.py open_camoufox | built: python/browser/browserDriver.py open_ads_power |
syncSessions | publishSession, connectSession and profileSession (the sync sessions) run in it | built: publishSession, connectSession and profileSession launch it (sync API) | not built: publishSession, connectSession and profileSession still launch Camoufox (sync API) |
oneMachineAtATime | It refuses to open where another machine already has the account open | n/a: Camoufox runs only where log-cli runs; there is no shared profile to open twice | not 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
- Add its name to
BROWSER_DRIVERS(src/browser/driver/types.ts). - Write
src/browser/<name>/:types.tswith its capabilities row,client.tswith its driver,index.ts, and<name>.test.ts. - Add its opener to
python/browser/browserDriver.py(BROWSERSandOPENERS). It must return a gated, self-checked Playwright context and record a session HAR. - Run
bun run browsers:docs, and commit.
Setting a browser up: Browser setup. The rules behind this are in AGENTS.md.