Back to blog
Mehran Mozaffari·

Borrowing the User's Browser: How BrowserSkill Solves Agent Auth Without Leaking Secrets

What BrowserSkill Actually Does Under the Hood

The architecture is a three-part loop, and I think it's worth being precise about where each piece lives, because the failure modes cluster around the seams.

First, there's the bsk CLI daemon. This runs as a local process on your machine. It listens for commands from your agent—which could be anything capable of executing shell commands: Cursor, Claude Code, a custom Python loop, whatever. The daemon exposes a small command surface: bsk screenshot, bsk click, bsk session. Nothing exotic. That's deliberate—the daemon is the transport layer, not the intelligence.

Second, there's the Chrome extension. This is the piece that lives inside your actual browser process. When you install it, it sits there dormant until the daemon asks for something. The extension has the privilege the daemon doesn't: it can talk to the browser's tab and window APIs directly.

Third, there's the bridge between them—WebSocket or Native Messaging over localhost. The daemon speaks to the extension over this IPC channel, and the extension responds with tab state, screenshots, or action confirmations.

The borrow cycle runs like this: the agent issues a bsk session command. The daemon forwards a borrow request to the extension over IPC. The extension allocates an Agent Window—a separate visual window in the same browser process. Same cookie jar, same session storage, same WebSocket connections. Not a fresh context. It's a new window, not a new browser instance. The agent then executes its actions against that window: screenshots, clicks, form fills. When done, it issues a release, and the extension returns the tab to its reserved state.

The critical part: the agent never sees credentials. It never receives cookie values, never touches session tokens. It inherits state—the fact that you're logged in—without serializing what that state is. This is the fundamental difference from approaches that export cookies as JSON into the agent context, which is a security liability I've never liked. Once a cookie value lands in the model context, it's telemetry.

Contrast with the two common alternatives. Playwright's launchPersistentContext with a userDataDir points at your actual profile directory. The mechanical problem: Chrome locks that directory while running. You can't launch a persistent context against a profile that's already open—you get a profile lock error or you silently end up with a second process racing on the same storage. The --remote-debugging-port approach is worse. It lets you attach directly to the live profile, but you're now in the user's actual visual space. Focus steals. Tabs hijack. Your agent's navigation command yanks the user's cursor to a different page mid-typing.

BrowserSkill's Agent Window avoids both: it's in the browser process but out of the user's immediate visual flow. Different window, same state.

flowchart LR
    subgraph UserChrome["User's Chrome Browser"]
        Ext["Chrome Extension"]
        AgentWin["Agent Window<br/>(borrowed tab)"]
        UserTabs["Real Open Tabs<br/>(user's active context)"]
        CookieJar["Shared Cookie Jar<br/>+ sessionStorage"]
        Ext --> AgentWin
        Ext --> UserTabs
        AgentWin --> CookieJar
        UserTabs --> CookieJar
    end

    subgraph Daemon["Local bsk Daemon"]
        IPC["WebSocket /<br/>Native Messaging IPC"]
        CLI["bsk CLI<br/>screenshot / click / session"]
    end

    subgraph Agent["Agent Shell"]
        Shell["Terminal-capable agent<br/>(Cursor, Claude Code, etc.)"]
    end

    Agent -->|"bsk session<br/>(borrow request)"| CLI
    CLI --> IPC
    IPC -->|"allocate Agent Window"| Ext
    AgentWin -->|"screenshot/click/action results"| Ext
    Ext -->|"tab state back over IPC"| IPC
    IPC --> CLI
    CLI -->|"session state to agent"| Agent
    Agent -->|"bsk release<br/>(return tab)"| CLI
    CLI --> IPC
    IPC -->|"release Agent Window"| Ext
    Ext -->|"tab returned to pool"| UserTabs

Why a Separate Agent Window Beats Hijacking My Main Profile

There are three ways to give an agent access to a browser that's already logged in, and they differ on a dimension most people overlook: whose focus is the agent stealing.

The --remote-debugging-port hijack is the brute-force approach. You launch Chrome with that flag, point Playwright or Puppeteer at the debugging endpoint, and you're attached to the user's actual live profile. The mechanical problem is that you're now operating in their exact visual space. When your agent calls page.click(), it's clicking in the window the user is looking at. The cursor jumps. The tab switches. If the user is mid-typing in a form and the agent decides to navigate, their input is gone mid-keystroke. Worse, there's a race on sessionStorage—the user and the agent are both writing to the same origin's storage from different tabs, with no synchronization. The agent can trigger a token refresh that invalidates the user's active tab, or vice versa. And because you're attached to their main window, a crash takes everything down with it.

The launchPersistentContext with a userDataDir approach is subtly different. You're pointing at the profile directory, not the live window. Chrome locks that directory while the browser is running, so you can't actually use it—you'd need to close Chrome first, which defeats the purpose, or launch a second process against a copy, which is fragile and slow. The storage state is copied at launch time, so it goes stale the moment the user interacts with anything.

BrowserSkill's Agent Window takes the third path. The extension opens a new window within the same browser process. It shares the cookie jar—so authentication is instantly inherited—but it's a separate window from the user's active context. The agent's clicks happen in a window the user can ignore. The agent's navigation doesn't touch the user's open tabs. Focus stays where the user left it.

I've found the key insight is that sharing a cookie jar is not the same as sharing a window. The session state is a shared resource, but the interaction surface is separate. That's the design win.

The honest limitation: the state is still shared. If the user logs out of a service in another tab on the same origin, the agent's borrowed tab immediately loses session validity. If the user switches workspace in their org, the agent might submit a mutation into a workspace it wasn't scoped to. The borrow-and-return contract doesn't isolate state—it just separates the view.

Approach Session State Source Focus Behavior Setup Strongest Risk
BrowserSkill Agent Window Shared cookie jar via extension, live session state Separate window, user's focus untouched Extension + local daemon install Shared-state race—a user logout or workspace switch in the user's tab invalidates the agent tab
Remote-debugging profile hijack Live profile via debugging port, no cookie serialization Steals focus from user's main window, cursor jumps, tabs switch Launch Chrome with --remote-debugging-port, attach via CDP Focus-stealing, sessionStorage races, crash takes down user's main window
launchPersistentContext + userDataDir Copy of profile directory at launch time, snapshot state Completely isolated process window Must close Chrome first or use a copy, then launch with the directory Stale session—snapshot goes invalid the moment user interacts with anything in their live browser

Where the Borrow-and-Return Contract Breaks in Practice

The borrow-and-return contract is elegant on the happy path, but I've come to treat it as a lease with no enforcement. The agent should release the tab when done, but there's no watchdog guaranteeing it. That's where the real production risk lives.

The first failure mode is the shared-origin race. The agent borrows a tab on app.acme.com. The user, meanwhile, is in another tab on the same origin—maybe they're switching orgs, refreshing a token, or submitting a mutation. Both the user and the agent are writing to the same cookie jar and session storage. If the user's mutation triggers a session refresh, the agent's borrowed tab now holds a stale session. The next bsk click returns a 401 or 409, or worse, silently no-ops because the origin's JS is now in a different state than the agent's last screenshot. I'd watch for this on any multi-tenant SaaS app where workspace switching is common. The agent doesn't crash, it just goes dumb—it keeps acting on a snapshot that's no longer connected to the live state.

The second failure mode is the leak you don't notice. The agent crashes, times out, or hits an uncaught exception before issuing bsk release. The tab remains attached, redirect to wherever the agent last navigated, with a half-filled form or a partially-submitted cart. If my agent times out mid-payment form, the tab still has the card number typed in—it's sitting there in a window I might not notice because it's separate from my active tabs. The extension has no garbage collection for borrowed tabs. No TTL, no idle timeout, no "release after three commands with no response."

The third is the non-idempotency problem. In a sandboxed Playwright context, a failed action is harmless—you throw away the context and start over. Here, there's no context to throw away. The agent just submitted an order, or updated a production record, or sent an email. A retry loop on a hallucinated action isn't a retry on a queued operation—it's a retry on a side effect that's already persisted. This is where the "cookie jar sharing" advantage becomes a liability: you get real sessions for free, but you also get real production data mutation, with no rollback.

There are also environmental limits. The extension's Manifest V3 background service worker can get suspended by the browser's lifecycle management, which severs the IPC connection mid-task. OS sleep or browser hibernation does the same. And in containerized agent environments, the bsk daemon is subject to the sandbox's process reaping—if the agent shell runs in a subshell that gets cleaned up between commands, the daemon dies and the connection drops. The research calls out BSK_AUTO_START and BSK_HOME as the knobs: you want BSK_AUTO_START=0 inside transient containers, and a persistent host-mounted BSK_HOME so the daemon survives command boundaries.

stateDiagram-v2
    [*] --> IDLE
    IDLE --> BORROWED: Request from daemon
    BORROWED --> EXECUTING: Agent issues commands
    EXECUTING --> RELEASED: Agent issues release
    RELEASED --> IDLE: Tab returned to pool
    
    BORROWED --> STALE_ORIGIN: User mutation in same origin\n(token refresh, logout, workspace switch)
    STALE_ORIGIN --> [*]: Session invalidated, agent tab silent fail
    
    EXECUTING --> LEAKED: Agent crash or timeout\nno release command
    LEAKED --> [*]: Tab left attached, indeterminate state
    
    RELEASED --> DIRTY: Half-filled form or\npartial mutation persisted
    DIRTY --> [*]: Real production data side effect
    
    note right of BORROWED
        User's independent tab mutations are external events
        that trigger STALE_ORIGIN without agent awareness
    end note

The Security Blast Radius of a Live, Logged-In Session

This is the part I lose sleep over. When you grant an agent access to a browser, you're not granting it access to a sandbox—you're granting it access to everything that browser profile is authenticated into. That's not a technical distinction, it's a security model distinction. The profile might hold an AWS console with write permissions, a GCP project with billing enabled, corporate email, an internal admin panel, password manager sessions, and a dozen other services that would be catastrophic if an LLM made a mistake in them. The agent doesn't need to exfiltrate your credentials to be dangerous. It just needs to use them. A hallucinated click on "delete bucket" is the same as a malicious click on "delete bucket" from the infrastructure's perspective.

The prompt injection vector here is more nuanced than the classic "read a webpage and panic" scenario. Because the agent has browser commands—bsk click, bsk type, tab switching—an attacker who can get content in front of the agent can issue instructions, not just influence reasoning. A well-crafted instruction embedded in a page the agent navigates to could tell it to switch to a different tab, click through a password manager's autofill, or navigate to an attacker's domain and dump whatever's visible. The agent doesn't have to be tricked into thinking it's doing the right thing. It just has to be given a command it has the authority to execute, and LLM agents are fundamentally terrible at distinguishing between "instructions from the user" and "instructions contained in data."

The exfiltration channel I'd flag most is the CLI artifact path. Every bsk screenshot --out page.png or DOM dump that lands in the agent context goes somewhere. That somewhere is often an LLM vendor's telemetry pipeline. A screenshot of a monitoring dashboard might be fine. A screenshot of an internal portal with a customer's PII, a cloud console with an access key visible, or a password manager's credential list is a data breach with no clear notification path. You won't know it happened until someone asks why the model's logs contain a session token.

My concrete recommendations are not optional. First, never attach an agent to your primary profile. Mandate a dedicated profile—I'd call it Profile: AI-Agent—that holds only scoped, test-only credentials and non-critical sessions. Second, put human confirmation walls on any irreversible write operation. For a bsk click that might submit form, delete data, or mutate real repo state, the agent should yield control to the user in the visible tab rather than proceeding autonomously. Third, scrub screenshots and DOM dumps before they enter agent logs, and audit which URLs the CLI attaches to. The borrow model is elegant precisely because it avoids credential serialization, but it concentrates enormous authority in one place, and that authority needs containment.

Operational Playbook: Running bsk in Sandboxed and Enterprise Environments

The first thing you'll discover when you try to run bsk inside a Docker container or devcontainer is that the daemon doesn't survive the command boundary. Agent environments that execute tools in ephemeral subshells will reap the bsk daemon between invocations. The connection drops. The extension sits there waiting for instructions that never come, and your next command fails with something unhelpful like "no active session."

The fix is to treat the daemon as a host-side service, not a child of your agent shell. Set BSK_HOME to a mounted volume that's shared between your container and the host, so the daemon's state—socket files, session metadata, whatever it persists—survives container restarts. Then set BSK_AUTO_START=0 inside the container. Auto-starting a daemon in a transient environment is how you end up with three zombie processes fighting over the same IPC channel. Instead, run the daemon as a persistent sidecar on the host, with restart-on-fail enabled. If it dies, it comes back. If the container dies, the daemon doesn't care, because it was never a child of the container.

The second gotcha is the extension's own fragility. Manifest V3 background service workers get suspended by the browser when they're idle. If your agent hasn't issued a command in a few minutes, the extension's listener goes to sleep. When the next command arrives, there's a cold-start latency as the service worker wakes, or a dropped message if the wake happens mid-IPC. OS sleep and browser hibernation do the same thing. The pragmatic workaround is to keep the interaction cadence regular—issue a lightweight bsk session heartbeat every minute or so if the task is long-running—rather than firing commands after a 10-minute gap and expecting the connection to be warm.

Enterprise environments add a layer that's completely outside your control. If the user's organization has strict extension whitelisting, the unpacked extension is dead on arrival. No amount of daemon configuration fixes that. You need to know this before you recommend the architecture, not after.

Finally, observability. I'd log every attach operation: which URL the agent borrowed, which tab ID it was assigned, when it was released, and whether it was released cleanly or leaked. That audit trail is your only defense when someone asks "why did this production record get mutated by an agent." You can't retroactively explain a hallucinated click, but you can prove which tab it happened in and when it was released.

flowchart LR
    subgraph Container["Agent Container"]
        Agent["Agent Shell"]
        BSK_HOME["BSK_HOME<br/>(mounted volume)"]
    end

    subgraph Host["Host Machine"]
        Daemon["bsk Daemon<br/>(persistent sidecar,<br/>restart-on-fail)"]
        Sock["IPC socket<br/>in BSK_HOME"]
        Ext["Chrome Extension<br/>(MV3 service worker)"]
        Browser["User's Browser<br/>(Agent Window)"]
    end

    Agent -->|"bsk commands<br/>BSK_AUTO_START=0"| Daemon
    Daemon -->|"writes socket to"| Sock
    Sock -->|"host-mounted, survives container restart"| Ext
    Ext -->|"allocates Agent Window"| Browser
    Daemon -->|"log attach URLs + tab IDs<br/>+ clean/leaked release state"| AuditLog["Agent Audit Log"]

Resources

Updated 2026-09-15 by Mehran Mozaffari.

Related posts