
If two automation jobs share one Chrome user data directory, one job's cookies, localStorage and auth state can leak into the other. The practical fix on a hosted Mac is a dedicated --user-data-dir per account, a strict account identifier so paths cannot collide, and a collision check that reads cookies through the browser's own API before you wire real credentials. Browser profiles give you logical session separation; they are not a security boundary against other code running under the same macOS user, so keep untrusted code out of the agent account.
For browser automation that drives web pages — scraping, synthetic monitoring, Playwright or Puppeteer jobs, API-backed workflows — the Figment virtual Intel macOS $29/month plan is a direct starting option. It is a macOS environment with 2 vCPU, 8 GiB RAM and 80 GiB disk that runs Chrome, Chromium, Firefox and Playwright on Intel. Access is through the browser console in the Figment dashboard; outbound HTTP, HTTPS and WebSocket from the VM work normally, which is what agent jobs need. VM capacity is being verified, so request a configuration before you plan around a specific start date.
Step up when the job needs Apple silicon: ARM-only binaries, GPU-accelerated rendering, iOS simulators that require arm64, or more RAM for many parallel Chrome instances. M-series Macs start at $95/month, with the exact chip, RAM and disk confirmed in the quote while supply is being verified. If you want CI setup, backups and monitoring handled, Managed Build Mac is $249/month and includes one M-series Mac, CI setup, backups, monitoring and 30 minutes of monthly support. Managed Mac Team is $999/month with two M-series Macs, two CI runners, team setup, backups, monitoring and one hour of monthly support. Physical plans are request-based; ask for an evaluation on the quote rather than assuming instant availability.
Request a configuration on /mac-hosting/start and state the browser agent workload in the request so the right tier is quoted.
Session isolation in Chrome is driven by --user-data-dir. The Chromium user data directory documentation describes it directly: the default macOS location is ~/Library/Application Support/Google/Chrome, and each profile is a subdirectory (typically Default) inside the user data directory. Pointing --user-data-dir at a fresh path gives a clean profile; reusing the same path resumes the same cookies and storage.
Use a flat layout where any script derives the profile path from a vetted account identifier:
~/browser-profiles/
├── client-alpha/ # cookies, localStorage, auth state for alpha
├── client-beta/
├── staging-preview/
└── prod-smoke-test/
One account, one directory. Chrome enforces single-instance access through SingletonLock inside the user data directory, so do not point two concurrent runs at the same path — let the browser manage its own locking. Do not sanitise account names by replacing disallowed characters: two different source names (client:alpha and client/alpha) can normalise to the same safe string and silently share a profile. Validate instead, and reject anything that is not already in your identifier set.
A strict launcher — unexecuted example, run against your own target URLs — looks like this:
#!/usr/bin/env bash
set -euo pipefail
ACCOUNT="${1:?Usage: $0 <account-id> <url>}"
TARGET_URL="${2:?Usage: $0 <account-id> <url>}"
# Strict allow-list: lower-case letters, digits and hyphens, 3-40 chars.
# Reject anything else rather than rewriting it; rewriting risks collisions.
if [[ ! "${ACCOUNT}" =~ ^[a-z0-9]([a-z0-9-]{1,38}[a-z0-9])$ ]]; then
echo "Refusing account id: ${ACCOUNT}" >&2
exit 2
fi
PROFILE="${HOME}/browser-profiles/${ACCOUNT}"
mkdir -p "${PROFILE}"
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
echo "Launching Chrome account=${ACCOUNT} profile=${PROFILE}"
"${CHROME}" \
--user-data-dir="${PROFILE}" \
--no-first-run \
--no-default-browser-check \
--headless=new \
"${TARGET_URL}" &
echo "chrome_pid=$!"
There is no remote-debugging TCP port in the launcher, so there is nothing to collide on across accounts. If you need DevTools Protocol access, drive Chrome through a library such as Playwright or Puppeteer that opens an inherited pipe or picks a free local port; do not hash-derive ports from account names.
Chrome encrypts cookie values in the on-disk Cookies SQLite database on macOS, so you cannot assert a known plaintext value by querying the column directly. Use the browser's own API instead. Playwright's browser-contexts guide describes contexts as incognito-like profiles that are completely isolated for cookies, localStorage and sessionStorage, and browserType.launchPersistentContext(userDataDir) opens a persistent context bound to a specific directory; context.cookies() and context.addCookies() read and write cookies through the public API, and context.close() shuts the context and its pages down cleanly.
The check below opens two persistent contexts against two different user data directories, seeds a distinct cookie with a one-hour expiry into each for a controlled test origin, closes each context, reopens them and asserts that each profile has its own value and does not have the other's. Treat it as unexecuted example code; adapt TEST_ORIGIN to a local fulfilled origin you control (a fixture served by python3 -m http.server on http://127.0.0.1:8080, or an explicitly test-only service) rather than a public echo endpoint.
// collision-check.mjs — unexecuted example.
// Prereq: `npm i -D playwright && npx playwright install chromium`.
import { chromium } from 'playwright';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import assert from 'node:assert/strict';
const TEST_ORIGIN = 'http://127.0.0.1:8080'; // local fixture you control
async function seed(dir, cookie) {
const ctx = await chromium.launchPersistentContext(dir, { headless: true });
try {
await ctx.addCookies([cookie]);
} finally {
await ctx.close();
}
}
async function read(dir) {
const ctx = await chromium.launchPersistentContext(dir, { headless: true });
try {
return await ctx.cookies(TEST_ORIGIN);
} finally {
await ctx.close();
}
}
const dirA = await mkdtemp(join(tmpdir(), 'profile-a-'));
const dirB = await mkdtemp(join(tmpdir(), 'profile-b-'));
try {
await seed(dirA, { name: 'session_id', value: 'ACCT_A', url: TEST_ORIGIN, expires: Math.floor(Date.now() / 1000) + 3600 });
await seed(dirB, { name: 'session_id', value: 'ACCT_B', url: TEST_ORIGIN, expires: Math.floor(Date.now() / 1000) + 3600 });
const a = (await read(dirA)).filter(c => c.name === 'session_id');
const b = (await read(dirB)).filter(c => c.name === 'session_id');
assert.equal(a.length, 1, 'profile A should have exactly one session_id');
assert.equal(a[0].value, 'ACCT_A', 'profile A must keep its own value');
assert.ok(!a.some(c => c.value === 'ACCT_B'), 'profile A must not see B');
assert.equal(b.length, 1, 'profile B should have exactly one session_id');
assert.equal(b[0].value, 'ACCT_B', 'profile B must keep its own value');
assert.ok(!b.some(c => c.value === 'ACCT_A'), 'profile B must not see A');
console.log('PASS: no cross-profile cookie bleed detected');
} finally {
await rm(dirA, { recursive: true, force: true });
await rm(dirB, { recursive: true, force: true });
}
Both own-value checks and both absence-of-other checks run on every pass, so a silent failure where neither profile stored anything is still caught. The temporary directories are created with mkdtemp and removed in finally, so the script never deletes a fixed, pre-existing profile path.
Use this short checklist on any new host before you trust it with real credentials. Each item is a decision you can tick off in a few minutes, and together they replace long speculative scripts.
.., /, whitespace, uppercase or punctuation, and the refusal is logged.~/browser-profiles/<account> exists for each configured account and no two accounts resolve to the same path./usr/bin/time -l on a short run, or Activity Monitor) and the concurrent agent count is sized from that measurement rather than a guess.SingletonLock; do not blindly delete lock files while a Chrome process is still running against the profile.For migration, start clean: create empty profile directories on the hosted Mac, sign in interactively once through the Figment browser console for each account, and run the collision check. Do not copy a local profile wholesale — live profile directories contain credentials, tokens and encryption material tied to the origin machine, so moving them across hosts is both risky and often broken by Keychain binding. If you must preserve specific state, export only the fields you need (session cookie, selected localStorage keys) through DevTools or a scripted login and re-import on the host through context.addCookies(). When the checklist is green end to end, promote the host to production use and request a configuration on /mac-hosting/start if you need to resize or add Managed Build Mac handling.
Related guides: Disk pressure on a hosted Mac agent · Workspace isolation on a hosted Mac · GitHub Actions self-hosted runner on Mac