Skip to content

[Security] Isolate browser pages, storage, fallback, and input by agent session #237

Description

@fettpl

Audit metadata

  • Priority: P1
  • Estimated effort: L
  • Implementation risk: MED
  • Category: bug
  • Evidence baseline: 07be5be7ce69bea0c3118744ab90d148b010fce0 (origin/main on 2026-07-17)

Dependencies

  • P1 proposal 041 — Use one protected browser surface for users and agents. Included in the current P1 issue wave.

Description and impact

Pi already sends a browser session ID, but the server discards it and routes all
users/tabs to one global page and fallback URL. Concurrent sessions can navigate,
read, click, type into, screenshot, or inherit cookies from each other's page.
Session-scoped pages and Chromium contexts are required for correctness and
privacy once the visible and agent browser share one host.

Current state

  • frontend/desktop/resources/pi-extensions/browser.ts:9-50 reads
    LOCAL_STUDIO_BROWSER_SESSION_ID and includes sessionId in action bodies.
  • services/agent-runtime/src/http/browser-handlers.ts:67-73 deliberately strips
    that field because the host is global.
  • browser-handlers.ts:47 stores one process-global lastFallbackUrl.
  • services/agent-runtime/src/browser-host/browser-host.ts:75-77 owns one
    activeId and a global page map; all verbs omit a session key.
  • Frame, state, input, and viewport endpoints also operate on the global page.
  • services/agent-runtime/src/pi-runtime-helpers.ts:318 already injects the
    browser session ID into Pi runtime options.

Steps to reproduce

  1. Start two agent sessions, A and B, and enable the embedded browser in both.
  2. From A, navigate to a loopback fixture that sets recognizable page and
    storage state.
  3. From B, request browser state or a screenshot, then navigate or send input.
  4. Return to A and observe that both sessions addressed and mutated the same
    global page; the fallback URL is global as well.

Expected: each session has an isolated page, storage context, fallback state,
input stream, and cleanup lifecycle. Actual: all browser endpoints discard the
provided session identity and operate on one process-global target.

Verification commands

Purpose Command Expected on success
Runtime tests npm --prefix services/agent-runtime test -- browser-session all isolation/capacity cases pass
Frontend tests npm --prefix frontend run test session transport cases pass
Repository gate npm run check exit 0
Integration npm run test:integration browser regressions pass
Desktop build npm --prefix frontend run desktop:dist production bundle built

Scope

In scope:

  • services/agent-runtime/src/browser-host/browser-session.ts (create)
  • services/agent-runtime/src/browser-host/browser-host.ts
  • services/agent-runtime/src/browser-host/chrome.ts and cdp.ts as needed for
    browser contexts/target creation
  • services/agent-runtime/src/http/browser-handlers.ts
  • frontend/desktop/resources/pi-extensions/browser.ts
  • frontend/desktop/resources/pi-extensions/sitegeist-browser.ts
  • frontend/src/features/agent/ui/agent-browser-panel.tsx
  • frontend/src/features/agent/ui/agent-browser-effects.ts
  • frontend/src/features/agent/ui/agent-browser-screencast.tsx
  • services/agent-runtime/src/pi-runtime-helpers.ts and focused tests only if
    the existing injected key needs canonicalization
  • Focused runtime/frontend/regression tests

Out of scope:

  • Separate Chromium OS processes per session; one managed process may host
    isolated browser contexts.
  • Persisting cookies/history after a session is released.
  • Cross-device browser synchronization.
  • Stateless /fetch and /localhosts, which remain independent of page state.

Solution design

Define a canonical session key schema (1-128 characters, restricted stable
ASCII) and transport it in one header for verbs, frame, state, input, and
viewport. Missing/malformed keys return 400 before touching Chromium. BrowserHost
maps each key to a dedicated incognito browser context, one page, frame/poll
state, fallback URL, and last-access timestamp. Creation is coalesced per key.
Use LOCAL_STUDIO_BROWSER_MAX_SESSIONS with default 8 and accepted integer
range 1..32, plus LOCAL_STUDIO_BROWSER_SESSION_IDLE_MS with default 900000
(15 minutes) and accepted range 60000..86400000. Unset values use defaults;
invalid explicit values fail startup through Effect Schema rather than clamping.
Evict only idle sessions and fail closed when all capacity is active.
releaseSession closes page/context and removes all state. UI uses the same
focused session ID already passed to Pi; no focused session means the live
browser is disabled, not mapped to a shared default.

Implementation plan

Step 1: Define and enforce one session-key contract

Create an Effect schema/parser and a stable header name shared by server,
extension, and UI. Parse before JSON/body dispatch. Remove the body-field strip
and global fallback URL.

Verify: every stateful endpoint returns 400 for missing, empty, oversized,
Unicode/control, and malformed keys; valid boundary keys pass.

Step 2: Create one Chromium context and page per key

Use browser-level CDP Target.createBrowserContext and target creation within
that context. Store all page/fallback/poll/viewport state in a session record.
Serialize duplicate creation and teardown for the same key.

Verify: two simultaneous first calls for one key create one context; two
different keys have different context/target IDs and independent cookies/storage.

Step 3: Thread the key through every stateful operation

Require the parsed key for navigate/get URL/text/HTML/screenshot/click/fill/
scroll/history/reload, frame, state, input, and viewport. Keep /fetch and
/localhosts stateless. Never accept a page ID belonging to another context.

Verify: a test navigates A and B to distinct fixtures, sends input to A, and
asserts B's URL, DOM, frame, cookies, history, and input log remain unchanged.

Step 4: Wire visible and Pi callers to the same key

Set the header in both Pi extensions and every screencast/UI fetch using the
focused agent session ID. Remove any fallback to an empty/default key. Ensure
session changes unsubscribe old polling before starting the new one.

Verify: frontend tests assert every stateful request carries the focused key
and switching tabs stops old frame/input traffic.

Step 5: Add bounded lifecycle management

Track last access, use an Effect fiber for TTL cleanup, enforce the default
maximum of 8 contexts and 15-minute idle TTL, validate the two configuration
bounds above, and expose idempotent releaseSession. Do not evict a session with
in-flight navigation/input/frame work. Close all contexts on browser-host
shutdown.

Verify: fake-clock tests cover idle eviction, active protection, LRU capacity,
all-active refusal, release idempotency, context cleanup, both defaults, minimum
and maximum accepted values, and rejection immediately outside each range.

Step 6: Run gates and reinstall desktop

Run runtime/frontend/full tests, build desktop:dist, replace the canonical app
using AGENTS.md, relaunch, and query /api/desktop-health.

Verify: npm --prefix services/agent-runtime run check && npm --prefix frontend run check:quality && npm --prefix frontend run test && npm run check && npm run test:integration && npm --prefix frontend run desktop:dist → all exit 0; installed health is HTTP 200 with ok: true.

Test plan

  • Header schema boundaries and conflicting body/header values.
  • Two sessions: URL, DOM/text, screenshot/frame, history, viewport, input,
    cookies/localStorage, fallback URL, and navigation errors are isolated.
  • Concurrent create/release and request during release.
  • Missing session never creates a page/context.
  • TTL, hard capacity, LRU idle eviction, all-active refusal, and shutdown cleanup.
  • Pi and visible pane for one session share state; another pane cannot observe it.

Acceptance criteria

  • Every stateful browser endpoint requires one validated session key.
  • Each key owns an isolated Chromium context and complete browser state.
  • No process-global active page or fallback URL remains.
  • UI and Pi callers for one session share exactly that context.
  • Missing/malformed keys fail before side effects.
  • Capacity, TTL, release, and shutdown cleanup are tested.
  • Browser capacity defaults to 8, idle TTL defaults to 15 minutes, and all
    explicit configuration is schema-validated within documented bounds.
  • Cross-session isolation regressions and all gates pass.
  • Reinstalled desktop health returns 200.

Risks and stop conditions

Stop and report if:

  • Managed Chromium cannot create isolated browser contexts through a supported
    CDP endpoint.
  • audit proposal 041 has not removed the separate desktop webview.
  • A caller lacks a stable agent session ID.
  • Isolation would still share cookies/service workers/storage between contexts.
  • Desktop reinstall or health verification cannot complete.

Maintenance considerations

The session key is an isolation key, not authentication; existing route access
controls still apply. Reviewers should inspect every endpoint, including frame
and input. audit proposal 043 should call releaseSession when a Pi runtime is disposed.


Generated from Local Studio 2.0 main-branch audit proposal 042.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions