Four layers, three filesystem roots, one product

AI agent browser isolation is four orthogonal layers, not one toggle.

When you read about isolation for an AI agent driving a browser, almost every article describes one thing: a fresh container per session, somewhere in the cloud, behind an API. That is one product shape, and a perfectly good one for general agentic browsing. It is also a bundle. Underneath, an AI test agent is making four separate isolation decisions every time it launches a browser, and the interesting product question is which knobs you get per layer. This page walks the four layers, names the file in @m13v/assrt where each one lives, and shows what changes when you flip the relevant flag.

M
Matthew Diakonov
11 min read
Layer 1

Profile

Cookies, localStorage, IndexedDB, installed extensions. Lives at ~/.assrt/browser-profile. Persists between runs unless you ask it not to.

Layer 2

Session

Whether the run starts logged in. Derived from layer 1 plus the mode flag. Persistent profile means logged in, isolated means logged out, extension mode means whatever your real Chrome is.

Layer 3

Process

Whether a previous Chromium left a wedged PID pinned to your profile. Handled by killOrphanChromeProcesses at browser.ts:191-214 before launch.

Layer 4

Artifacts

Per-run screenshots, video, execution.log, events.json. Lives at tmpdir()/assrt/<UUID>. Always isolated, regardless of profile mode.

The point of decomposing it this way is that these four layers do not have to move together. You can want a logged-in session (layer 2 on) but a freshly-launched Chromium (layer 3 on). You can want a real Chrome (layer 1 off) but per-run artifact directories (layer 4 on). A single isolation flag cannot express any of those combinations.

Layer 1

Profile: where the cookies live, and why three modes

The profile is the disk directory Chromium passes to the --user-data-dir flag. It holds cookies, localStorage, IndexedDB, the cache, the list of installed extensions, the saved logins, and a handful of Chromium-internal lock files. In Assrt, the persistent profile is at ~/.assrt/browser-profile, declared at browser.ts:313 inside the persistent branch of launchLocal(). The path is constructed with join(homedir(), ".assrt", "browser-profile") and created with mkdirSync(userDataDir, { recursive: true }) on first use.

That is one of three possible profile modes. The full launch decision lives at browser.ts:296-347 and resolves in this order: extension mode wins if the extension flag is set, isolated wins next if isolated is set or the env var ASSRT_ISOLATED is truthy, otherwise the persistent branch runs. The first two are short. The third is what most of this page is about.

Two modes, two args lists

// /Users/matthewdi/assrt-mcp/src/core/browser.ts, lines 296-310
// Isolated mode. Nothing on disk, nothing to clean up.
// The agent always starts in a Chromium with no cookies,
// no localStorage, no service workers, no installed extensions.

const args = [
  cliPath,
  "--viewport-size", "1600x900",
  "--output-mode", "file",
  "--output-dir", outputDir,
  "--caps", "devtools",
];

if (extension) {
  args.push("--extension"); // skip profile entirely, attach to real Chrome
} else if (isolated) {
  args.push("--isolated"); // in-memory profile, nothing persisted
}
-60% more housekeeping in persistent mode

The shape of the difference matters more than the line count. Isolated mode hands one extra flag to @playwright/mcp and is done. Persistent mode runs an orphan-PID walk, an lstat-then-unlink loop over three Chromium lock files, and a structured fallback that flips the local mode variable from persistent to isolated when any of the unlinks fail. The fallback is what makes persistent mode safe to use in CI and on a developer laptop without hand-holding: when something has gone wrong, the code chooses correctness over inheritance.

1 path

The persistent profile path is computed exactly once, with join(homedir(), '.assrt', 'browser-profile'). Every assrt_test run that does not pass isolated or extension lands in that same directory.

browser.ts:313

Layer 2

Session: derived from layer 1, but worth naming

Session is not a separate flag. It is a consequence of layer 1 plus what your dev server stores in cookies. It is worth naming because most reasoning bugs about isolation come from confusing profile and session. The persistent profile inherits whatever session your last run ended in: log in once, every subsequent run inherits the cookies. Isolated mode starts every run logged out: the in-memory profile has no cookies, no localStorage, no service workers. Extension mode inherits whatever session your real Chrome has: same cookies, same OAuth state, same MFA tokens.

When you write a test plan for assrt_test, the session model determines whether step one is "log in" or "assume already logged in." Persistent profile lets you skip the login step on every run after the first. Isolated forces you to script it. Extension lets you take advantage of an MFA challenge you already passed in your real browser, which is the single most useful property of extension mode for debugging flaky-only-in-prod issues.

The same test plan, three session modes

The agent navigates to /dashboard. The cookies for app.example.com are already in the profile, so the dev server sets currentUser, the dashboard renders, the agent verifies the assertion in three steps total.

  • Cookies inherited from a prior run, or from your real Chrome
  • Dashboard renders on first navigate
  • Three steps, one assertion, two seconds wall clock
Layer 3

Process: who cleans up the previous Chromium

Process isolation is the layer that catches everyone. If a previous run crashed or was Ctrl-C'd mid-execution, Chromium did not get a chance to clean up. The user-data-dir still has a SingletonLock symlink whose target is the literal string hostname-PID, and depending on what crashed, the process behind that PID may still be alive and pinned to that directory. Launching a new Chromium into the same dir while the old one is still alive is what produces the "opening in existing browser session" mode, which from the agent's point of view looks like a hung navigate.

Assrt's answer is the orphan-PID walk at browser.ts:191-214. It runs unconditionally in the persistent branch, before the SingletonLock cleanup, before the --user-data-dir flag is appended. The walk is a static method on McpBrowserManager that finds every PID associated with the profile from three sources: a ps aux | grep for --user-data-dir=<path>, the PID encoded in the SingletonLock symlink target, and a one-level parent walk for the Playwright MCP wrapper if it owns one of the discovered PIDs. The deduped set gets SIGKILLed twice with a 750 ms gap between sweeps. The double sweep is there because a child process can respawn between the first kill and the second ps; in practice the second sweep usually finds nothing, which is the desired state.

The detail that surprises people is that the walk does not touch your normal Chrome. It only kills processes whose command line contains the literal --user-data-dir=/Users/you/.assrt/browser-profile string. Your real Chrome has a different user-data-dir, so it is invisible to the grep.

Layer 4

Artifacts: per-run UUID dir, always

Layer 4 is the cheapest and runs the same in every mode. Every call to the assrt_test MCP tool mints a fresh runId with crypto.randomUUID() at the very top of the handler, server.ts:429, before any agent state is constructed. The directory is computed as join(tmpdir(), "assrt", runId) at server.ts:430, and screenshots, video, the line-by-line execution log, and the structured event stream all hang off it. Two parallel runs produce two disjoint UUIDs and therefore two disjoint trees on disk.

Why this matters for isolation: the per-run artifact tree is completely independent of the profile. A run in isolated mode and a run in persistent mode write artifacts to the same shape of directory, just under different UUIDs. A run in extension mode attached to your real Chrome still writes its screenshots to /tmp/assrt/<UUID>/screenshots/, not into your Chrome profile. There is exactly one shared mutable path in any of these modes, the persistent profile dir, and that one is protected by layers 2 and 3.

Cloud isolated browsers vs local Assrt

Cloud isolation services collapse the four layers into one container per session. That is the right shape for general agentic browsing. For an AI test agent driving your own dev server, the trades look like this.

FeatureCloud isolated browserAssrt (local)
Profile isolation (cookies, localStorage)Container per session, ephemeralThree modes: persistent at ~/.assrt/browser-profile, in-memory with --isolated, real Chrome with --extension
Session isolation (logged-in vs out)Always logged out unless you script the login each runPersistent profile carries logins across runs; isolated and extension modes have explicit semantics
Process isolation (orphan Chromium cleanup)Implicit, via container teardownkillOrphanChromeProcesses scans ps aux and the SingletonLock target, SIGKILLs anything pinned to the user-data-dir (browser.ts:191-214)
Artifact isolation (screenshots, video, logs)Bundled inside the session container, downloaded afterPer-run UUID dir under tmpdir(), independent of profile mode (server.ts:429-432)
Where the agent runsRemote VM behind an APILocal Node.js process driving local Chromium over MCP stdio
Network round trip per clickYes, over the public internetNo, the agent and the browser are on the same host
Can connect to your real Chrome sessionNoYes, via --extension and the Playwright MCP Chrome extension

Both shapes are valid; the choice depends on whether the work is general agent browsing or end-to-end testing of code you own.

What ends up on disk after one assrt_test run

Three filesystem roots, three different lifetimes. After one run with the default settings (persistent profile, headless), an ls ~/.assrt shows you the literal layout:

~/.assrt/
  browser-profile/        # layer 1, persists across runs
    Default/
      Cookies
      Local Storage/
      IndexedDB/
      ...
    SingletonLock         # only present while Chromium is alive
    SingletonSocket
    SingletonCookie
  extension-token         # layer 1, extension mode only
  playwright-output/      # MCP file output, snapshot .yml + screenshots

/tmp/assrt/
  <UUID>/                 # layer 4, one per run
    screenshots/
      00_step1_navigate.png
      01_step2_click.png
      ...
    video/
      <run>.webm
    execution.log
    events.json

The split is intentional. The profile dir is large and slow to recreate (Chromium populates a few hundred files on first launch). The extension token is small and reusable. The per-run artifact tree is large but ephemeral, and on most systems tmpdir() is wiped on reboot. Three different decisions about what to keep for how long, three different roots, no shared mutable file across runs except the profile, which is itself protected by layers 2 and 3.

A picking rule, written down

The four-layer decomposition produces a picking rule that is shorter than the long form usually is.

  • Default (persistent profile, headless). Use when you are running the agent against a local dev server you control, the test plan inherits a logged-in state from a previous run, and you do not want to re-script login on every run. This is the right setting for the loop that runs every time you save a file.
  • Isolated (isolated: true). Use when the test must start clean, when you are testing the sign-up flow itself, when you want the test to be reproducible across machines without relying on prior state, or when CI is running on an ephemeral runner where persistence does not buy you anything.
  • Extension (extension: true). Use when you need to repro a bug from your real session, when captcha or anti-bot heuristics block headless Chromium, or when the bug is OAuth-related and you would otherwise have to redo the entire OAuth dance per run. The cost is your real cookies and your real history are in the loop, so this is not the right mode for clean CI runs.

For two agents on one host that both want persistent, see the fallback behavior in layer 3: the second one transparently runs isolated for that call. If you want both agents on persistent state, give them different profile dirs (the path is the only shared resource) or run extension mode and let them attach to different tabs in the same Chrome.

Want to wire this into your own CI?

Show us the test loop you have today and we will walk through which isolation mode each step should run in, with the actual flags, in 30 minutes.

Frequently asked questions

What does 'browser isolation' mean for an AI agent, and why is it different from a security sandbox?

A security sandbox isolates a process from the kernel: namespaces, seccomp, the things Chromium itself uses internally to keep a renderer from reading your home directory. Browser isolation for an AI agent is a different question. The agent already runs as your user, drives Chromium with your privileges, and writes to wherever you point it. The interesting question is what state two consecutive runs of that agent share, and what state two concurrent agents share. That decomposes into four orthogonal layers: profile (cookies, localStorage, extensions), session (whether you start a run logged in), process (whether a previous Chromium left a wedged PID behind), and artifacts (where this run's screenshots, video, and event log go). A cloud 'isolated browser' service collapses all four into one remote container per session. Locally, you usually want different answers per layer.

How do I pick between persistent profile, isolated, and extension mode in Assrt?

There are three switches in `assrt_test`, defined in the MCP tool schema at server.ts:354-357. Default (no flags): persistent profile at ~/.assrt/browser-profile, headless, ephemeral process. `isolated: true`: in-memory profile, nothing on disk, every run starts logged out. `extension: true`: connect to your real running Chrome via the Playwright extension, reuse your normal session and tabs. The picking rule is about what you are actually testing. Testing a logged-in flow against a dev server, default profile is right; the first run you log in by hand, every subsequent run inherits the cookies. Testing a signup flow that has to start clean, set isolated. Debugging a flake against a real production session that is hard to reproduce in headless, use extension mode and let the agent drive your normal Chrome.

If I use extension mode, what gets isolated and what does not?

Almost nothing gets isolated. Extension mode is the opposite end of the spectrum: the agent attaches to your already-running Chrome over Chrome DevTools Protocol via the Playwright extension. Your real cookies, your real history, your real installed extensions, and your real fingerprint are all in the loop. Profile isolation is zero, session isolation is zero, process isolation is the implicit kind you get from your Chrome being one process with multiple tabs. Artifact isolation, however, still works: every assrt_test call still mints a fresh runId at server.ts:429 and writes screenshots, video, and events.json under tmpdir/assrt/<UUID>, completely separate from your Chrome profile dir. The trade is reality (real session, real captcha posture) for reproducibility. The token that authorizes the extension connection is saved at ~/.assrt/extension-token by browser.ts:236-240, separately from the browser profile.

What stops two AI agents on the same machine from clobbering each other if both pick persistent mode?

The persistent profile is one directory: ~/.assrt/browser-profile. If two MCP server processes try to launch into it concurrently, the first one wins. The second one runs `killOrphanChromeProcesses()` (browser.ts:191-214), which finds no orphans because the first Chrome is alive and not orphaned, then tries to lstat and unlink SingletonLock (browser.ts:326-342). The unlink fails because Chrome is actively holding the lock, so the code at browser.ts:336-340 falls back to `--isolated` for that one call. The losing agent runs in an in-memory profile and logs the fallback. This is the intended behavior: two truly concurrent persistent runs against one profile is not coherent state, so one agent transparently drops to isolated mode. Per-run artifacts are still safe because they live under a fresh UUID dir, so neither agent overwrites the other's screenshots.

Where does each layer of state actually live on disk after I run a test?

Three different filesystem roots, three different lifetimes. ~/.assrt/browser-profile holds the persistent Chromium profile: cookies, localStorage, IndexedDB, the things you would expect inside a Chrome user-data-dir. It survives every assrt_test run unless you delete it or pass isolated. ~/.assrt/extension-token is a single text file with the Playwright extension token, written by browser.ts:236-240 the first time you pass extensionToken. It survives forever and is reused by every subsequent extension-mode run. tmpdir()/assrt/<runId> holds the per-run artifacts: screenshots/NN_stepN_action.png, video/<webm>, execution.log, events.json. It is created at server.ts:430 with crypto.randomUUID() and is independent of the other two. After one run, you can `ls ~/.assrt` and see the literal directory structure.

Why not just use a remote isolated browser service like the cloud vendors offer?

You can, and for a lot of agentic web automation those services are the right call: each session is a fresh container, sessions cannot leak state into each other, and you do not need to think about any of this. Assrt is not trying to compete with that mental model for general agent browsing. It is built for testing your own dev server. Two specific things break with the remote-container model when the work is end-to-end testing: latency on every action because the browser is not on the box your dev server is on, and the inability to attach to your already-running Chrome (extension mode) when you want to repro a bug from your real session. The local model also does not need a network round trip per click, which matters when an LLM is steering and the action count is high.

Does any of this work on Windows or only macOS and Linux?

The orphan-PID walk uses ps aux and SIGKILL via process.kill, which means it only does useful work on POSIX hosts. On Windows the ps walk returns an empty set, the SingletonLock-as-symlink layer is also Linux/macOS specific, and the persistent profile path still works because Chromium handles its own cleanup most of the time. The functional fallback is the same in both directions: if anything in the orphan walk or the singleton scrub fails, the code path at browser.ts:336-340 drops to --isolated for that run and logs why. So Windows users get persistent profile when nothing has crashed, isolated mode when something has, with no manual intervention.

Can I see the launch decision Assrt made for a given run?

Yes, the launch path logs each branch to stderr. browser.ts:286, 306, 309, 345, 350 emit lines like `[browser] launch mode: extension`, `[browser] profile mode: isolated (in-memory, no persistence)`, `[browser] profile mode: persistent (/Users/you/.assrt/browser-profile)`, `[browser] removed stale SingletonLock from browser profile`, and `[browser] could not remove SingletonLock, falling back to isolated mode`. With `npx @m13v/assrt run --url ...`, those are visible directly in the terminal. From the MCP tool, they end up in the MCP server's stderr, which Claude Code surfaces in its tool-call log. There is no hidden state machine: every isolation decision is one stderr line you can grep for.

Is the per-run artifact directory really safe under crypto.randomUUID, or could two parallel runs collide?

Practically, no, they cannot collide. crypto.randomUUID returns RFC 4122 v4 UUIDs with 122 bits of entropy. Even at a sustained rate of one billion runs per second across all your machines combined, you would not hit a collision in any human-scale time. The reason it matters is that the run directory is the one place all run-scoped state lives: screenshots, video, the event stream the cloud uploader reads, the execution log. If two runs landed at the same UUID, the second one would overwrite files mid-run for the first. That is the failure mode randomUUID rules out. You can verify the path your run used by reading the JSON tool result from assrt_test, which echoes back runDir and the full file list.

assrtOpen-source AI testing framework
© 2026 Assrt. MIT License.

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.