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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions