Electron Stagewright docs

ADR-018: Storage plugin via a transport storage seam

Status: Accepted (cookies + storage snapshot on both Playwright and CDP; per-key localStorage / sessionStorage and IndexedDB shipped via a renderer-eval gate — see Status Updates)

Context

An agent driving an Electron app cannot read, seed, or assert the app's storage without a seam. "Skip the login screen by seeding the auth cookie." "Did the cart survive a reload?" "Assert the app persisted the right localStorage value." Today the only path to any of this is renderer eval (running app JavaScript to poke document.cookie / localStorage), which carries the eval threat model and the --allow-eval opt-in — too heavy for the common case of reading a cookie or checking a persisted value.

ADR-003's transport capability matrix needs a storage capability, but until this amendment it had no storage seam and no consumer. This is the storage analog of the network capture plugin (ADR-016) and the clock plugin (ADR-017): the same "a transport seam + a capability gate + a plugin that drives it" shape.

Decision

1. A dedicated storage seam on the transport, gated by canAccessStorage

TransportSession gains a storage seam — getCookies(filter?), setCookie(cookie), clearCookies(filter?), storageSnapshot() — plus the types StorageCookie, CookieFilter, StorageOrigin, StorageSnapshot. Two transports implement it and flip canAccessStorage from false to true:

@electron-stagewright/plugin-storage drives that seam: storage_cookies, storage_set_cookie, storage_clear_cookies, storage_snapshot. The plugin keeps the orchestration (the gate, the cookie filter, the url-or-domain refine, cookie-value redaction, error envelopes) in TypeScript; the transport owns the actual store.

A seam — not eval — because cookie access and a storage snapshot are first-class browser-context operations, not arbitrary JavaScript; they should not inherit the eval threat model or the --allow-eval opt-in.

Writing (storage_set_cookie, storage_clear_cookies) modifies app state, so it is bounded the same way as the other modify-capable plugins: the canAccessStorage capability and the operator-loaded plugin.

Rationale

Alternatives considered

Consequences

Status Update — per-key Web Storage via a renderer-eval gate

The deferred "per-key localStorage / sessionStorage get/set/remove" alternative shipped, resolving the permission fork the original ADR left open. IndexedDB stays deferred.

What landed

The plugin gains a SECOND tool family — storage_local_* and storage_session_* (get/set/remove/keys/ clear, ten tools) — for single-key Web Storage access. Reading or mutating one key needs renderer JavaScript (localStorage.getItem / setItem / removeItem, the same for sessionStorage), which the no-eval cookie/snapshot seam cannot serve. They ride the existing transport.evaluate('renderer', …) seam via a fixed source string (web-storage.ts WEB_STORAGE_BODY); the agent supplies op/scope/key/ value DATA, never code. storage_*_get also takes a keys[] multi-key form (one round-trip), and every result carries the active page's origin (Web Storage is per-origin). The cookie + snapshot tools are unchanged and stay no-eval.

The permission decision (the fork the original ADR named)

The original "Alternatives considered" entry noted that folding renderer storage in would "either bypass the operator's renderer-eval opt-in or require a new plugin-facing eval permission in the core dispatcher." It is resolved by reusing the existing per-target eval opt-in (--allow-eval=renderer, ADR-014), surfaced to plugins through a new ctx.allowEvalRenderer — the renderer twin of the existing ctx.allowEval (which maps to the main target). This is NOT a new permission concept: it exposes the EvalPolicy.renderer flag that already exists. A new dedicated storage-eval permission was REJECTED — it would fragment ADR-014's per-target least-privilege model and duplicate an opt-in with the same blast radius. So the plugin is now hybrid: the cookie/snapshot tools are no-eval; the per-key tools are renderer-eval gated.

Gating (registration-time primary, runtime defense-in-depth)

The per-key tools declare requiresEvalFlag: true, evalTarget: 'renderer', so the dispatcher hides them unless the server permits renderer eval — the operator-facing authorization (a no-renderer-eval server never registers them; calling one then names --allow-eval=renderer). The handlers ALSO re-assert if (!ctx.allowEvalRenderer) → storage.EVAL_REQUIRED at the tool boundary: the transport evaluate method bypasses the tool-registration gate, so the re-assert keeps an authorization bypass impossible if the tool is ever registered unconditionally (the C9 implementation contract). The transport capability is checked at runtime too (supportsRendererEvalstorage.UNSUPPORTED), since the eval POLICY is server-wide but a session's transport may still not support renderer eval (the injector). A renderer storage-access failure (quota exceeded, opaque origin) becomes storage.ACCESS_FAILED, never a raw throw.

Security

Web Storage values are returned verbatim, not redacted — they are app state, and redacting them would defeat the read tools' assert-a-persisted-value purpose (the same asymmetry the snapshot already documents for its localStorage half). The security model gains a row for the renderer-eval per-key storage surface. The renderer-eval gate is the operator's primary control over this surface.

Both transports

supportsRendererEval is true on both the Playwright launch transport (page.evaluate) and the CDP attach transport (Runtime.evaluate against a page target), and false on the injector — so the per-key tools work on the same transports as the cookie/snapshot seam, and storage.UNSUPPORTED on the injector.

Still deferred

IndexedDB read/write — async and structured (databases / object stores / cursors), a materially larger surface that warrants its own slice. The per-key Web Storage tools cover the common single-value case.

Status Update — IndexedDB read/write (the last deferred surface)

The remaining deferral — IndexedDB — shipped on the same renderer-eval gate, completing the storage plugin's surface (cookies, the storage snapshot, per-key Web Storage, IndexedDB).

What landed

A third tool family — storage_idb_* (schema / get / keys / count / set / delete / clear, seven tools) — over a fixed ASYNC renderer body (indexeddb.ts INDEXEDDB_BODY). get reads one record by key, a key range, or all (bounded by limit, with a truncated flag); index reads via a store index; set / delete / clear mutate records; schema lists databases or a database's stores. The agent supplies database / store / key / value / op as DATA, never code. Reuses the renderer-eval gate the per-key slice built: the same evalGated registration marker, requireRendererEval (→ storage.EVAL_REQUIRED / storage.UNSUPPORTED), and ctx.allowEvalRenderer. No core change.

Existing-schemas-only (the scope boundary)

The body opens a database WITHOUT a version, so it never triggers a create/upgrade; an accidental open of a non-existent database is aborted in onupgradeneeded and reported as database_not_found. A missing database or object store is storage.NOT_FOUND (a new namespaced code) — the plugin never creates or upgrades a schema, because a version change mutates the app's own data model irreversibly, well beyond a testing seam. Creating stores via a version upgrade is explicitly out of scope (a foot-gun deliberately not exposed).

Async correctness + wire-safety

The body promisifies the event-based IndexedDB API and resolves a WRITE only after the transaction COMMITS (tx.oncomplete), so a reported write has actually persisted (the gated real-Electron smoke re-reads a written record to prove it); the connection is closed in finally so a read never blocks a later app-driven upgrade. IndexedDB values are structured-clone (a superset of JSON), so the body normalises every returned value — Date → ISO string, and a Blob / ArrayBuffer / typed array or a circular reference → a typed placeholder ({ __type, byteLength? }) — so it round-trips over the MCP wire rather than shipping {} or crashing the read. This is a documented partial, like the CDP localStorage best-effort: binary values are described, not faithfully transferred.

Security

IndexedDB record values are returned verbatim by default (app state, like the per-key tools), with an opt-in redactValues config that masks read values for an app that keeps secrets in IndexedDB. The security model gains an IndexedDB row alongside the Web Storage one; the renderer-eval grant is the operator's control over both. Writes (set / delete / clear) MODIFY app data, bounded by the same renderer-eval gate + operator-loaded plugin.

Both transports

Reuses supportsRendererEval (Playwright page.evaluate, CDP Runtime.evaluate); the injector returns storage.UNSUPPORTED. The body unit tests run off-Electron against fake-indexeddb (a spec-compliant in-memory IndexedDB, a dev-only dependency) so the async/transaction logic is covered in CI without real Electron.

References