diff --git a/CHANGELOG.md b/CHANGELOG.md index 4643a6c..63678dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.1.58] - 11-06-2026 + +### Added + +- **Complete auth persistence** — named `profile` / `storageStatePath` now persist **cookies + localStorage + IndexedDB** (`storageState({ indexedDB: true })`), so sessions that store their auth token in IndexedDB (modern SPAs, Firebase Auth, …) reopen already logged in. The state is saved **at login time** (right after `browser_login` succeeds) and on session close — auth is no longer lost if a session crashes before a clean teardown. Works identically headless and headed on Chromium. `userDataDir` (full persistent Chromium profile) is unchanged and already covers everything natively. Verified live (IndexedDB token written, then replayed after reopen). + ## [0.1.57] - 11-06-2026 ### Added diff --git a/README.md b/README.md index a4d2d86..8d5846d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Shadow DOM + iframes), multi-step plans, structured extraction, visual diff, and guardrails** for payments and bookings. It drives real Chromium, so it reads **Next.js / SPA** pages after hydration — not just static HTML. -> 44 MCP tools · stealth + rotating proxies · HTTP fast-path (single, batch & crawl) · full-site content + screenshot snapshots · structured per-card product extraction · form fill + file upload · virtualized-list scraping + autoscroll · tabs / dialogs / downloads · console + network logs · MCP screenshot resources · `FUSE_CAPS` tool-group filtering · named auth profiles · `blockResources` · HAR record/replay · pixel visual-diff · human handoff + live view. +> 44 MCP tools · stealth + rotating proxies · HTTP fast-path (single, batch & crawl) · full-site content + screenshot snapshots · structured per-card product extraction · form fill + file upload · virtualized-list scraping + autoscroll · tabs / dialogs / downloads · console + network logs · MCP screenshot resources · `FUSE_CAPS` tool-group filtering · named auth profiles (cookies + localStorage + IndexedDB, saved at login) · `blockResources` · HAR record/replay · pixel visual-diff · human handoff + live view. ## Install diff --git a/docs/configuration.md b/docs/configuration.md index 7dd3463..963254d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,8 +24,8 @@ Every field is optional. Defaults are applied by `resolveConfig` (`src/agent/con | `cdpHeaders` | `Record` | `null` | Extra headers for the CDP connect handshake — e.g. `{ Authorization: "Bearer " }` for an authenticated Browserless endpoint. (Token can also be passed inline as `?token=` in `cdpEndpoint`.) | | `cdpCloseOnDone` | `boolean` | `true` for `ws/wss`, else `false` | Close the CDP session on teardown. Remote endpoints (Browserless) get a fresh context that is closed when done; a local attach never closes the user's browser (only the link is dropped). | | `cdpTimeoutMs` | `number` | `20000` | Timeout (ms) for the CDP connect. | -| `storageStatePath` | `string` | `null` | Path to a Playwright storage-state JSON (cookies + localStorage) to load/persist a logged-in session. See [./sessions.md](./sessions.md). | -| `profile` | `string` | `null` | Named persistent auth profile — shorthand for `storageStatePath` at `~/.fuse-browser/profiles/.json` (`FUSE_BROWSER_HOME` overrides the home dir; ignored when `storageStatePath` is set). Name: letters/digits then `-`/`_`, max 41 chars. | +| `storageStatePath` | `string` | `null` | Path to a Playwright storage-state JSON to load/persist a logged-in session. Persistence covers **cookies + localStorage + IndexedDB**, and the auth is saved **at login time** (right after a successful `browser_login`) **and on close** — not only on teardown. See [./sessions.md](./sessions.md). | +| `profile` | `string` | `null` | Named persistent auth profile — shorthand for `storageStatePath` at `~/.fuse-browser/profiles/.json` (`FUSE_BROWSER_HOME` overrides the home dir; ignored when `storageStatePath` is set). Same full persistence (cookies + localStorage + IndexedDB, saved at login + on close). Name: letters/digits then `-`/`_`, max 41 chars. | | `blockResources` | `string[]` | `null` (off) | Resource types aborted at the network layer to speed up batch runs: `image`, `media`, `font`, `stylesheet`, `script`, `xhr`, `fetch`, `websocket`, `manifest`, `other`. Unknown types are ignored. | | `harPath` | `string` | `null` | Record network traffic to this HAR file. See [./sessions.md](./sessions.md). | | `harMode` | `"minimal" \| "full"` | `"minimal"` | HAR recording detail. `minimal` records metadata only; `full` records response bodies. | diff --git a/package.json b/package.json index 138b234..13a03f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/browser-mcp", - "version": "0.1.57", + "version": "0.1.58", "description": "MCP server + CLI giving AI agents a real, stealth browser (Patchright/Playwright) — per-country identity, self-healing actions, snapshots, multi-step plans, structured extraction, CDP attach.", "license": "MIT", "author": "Fusengine", diff --git a/src/agent/probe-run.ts b/src/agent/probe-run.ts index 12864b6..046c2df 100644 --- a/src/agent/probe-run.ts +++ b/src/agent/probe-run.ts @@ -2,7 +2,7 @@ * Orchestrate a single probe run: launch, navigate, act, capture, report. * @module agent/probe-run */ -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { type ActionInput } from "../actions/perform.js"; import { prepareBookingCurrency } from "../consent/booking-currency.js"; import { handleCommonConsent } from "../consent/consent.js"; @@ -14,12 +14,13 @@ import { mainText } from "../extraction/main-text.js"; import { visualObservation } from "../extraction/visual.js"; import type { ProbeReport } from "../interfaces/report.js"; import type { ProbeOptions } from "../interfaces/types.js"; -import { ensureDir, sha1 } from "../lib/fs.js"; +import { sha1 } from "../lib/fs.js"; import { withBreaker } from "../net/breaker-guard.js"; import { DEFAULT_GOTO, gotoWithRetry } from "../net/navigate.js"; import { assertRobotsAllowed } from "../net/robots-guard.js"; import { throttleHost } from "../net/throttle.js"; import { reportProxyBlocked } from "../proxy/pool.js"; +import { persistStorageState } from "../session/persist-auth.js"; import { domSignature } from "../state/dom-signature.js"; import { runActions } from "./actions-loop.js"; import type { ResolvedConfig } from "./config.js"; @@ -67,10 +68,7 @@ export async function runProbe( await page.screenshot({ path: screenshotPath, fullPage: true }); const visual = options.observeVisual ? await visualObservation(page, screenshotPath) : {}; const serp = await extractSerpStep(page, options, config); - if (config.storageStatePath) { - ensureDir(dirname(config.storageStatePath)); - await context.storageState({ path: config.storageStatePath }); - } + await persistStorageState(context, config.storageStatePath); const report = buildReport({ config, targetUrl, diff --git a/src/server/tools/act.ts b/src/server/tools/act.ts index d6ba779..7e2b6d8 100644 --- a/src/server/tools/act.ts +++ b/src/server/tools/act.ts @@ -7,6 +7,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { type ActionInput, performAction } from "../../actions/perform.js"; import type { SessionManager } from "../../session/manager.js"; +import { persistStorageState } from "../../session/persist-auth.js"; import { runWithMemory } from "../../state/action-memory.js"; import { jsonResult } from "../result.js"; import { withSession } from "./with-session.js"; @@ -29,6 +30,11 @@ function actTool( const result = await runWithMemory(s.config.siteMemoryDir, s.page, action, (act) => performAction(s.page, act, s.config.humanMode), ); + // Persist auth immediately after a successful login so the session is + // captured at login time, not only on close (cookies + localStorage + IndexedDB). + if (action.type === "login" && result.ok) { + await persistStorageState(s.context, s.config.storageStatePath); + } return jsonResult({ result, url: s.page.url() }); }); }); diff --git a/src/session/close.ts b/src/session/close.ts index 68b0b4f..85e382e 100644 --- a/src/session/close.ts +++ b/src/session/close.ts @@ -2,8 +2,7 @@ * Session teardown: persist storage state, then close context + browser. * @module session/close */ -import { dirname } from "node:path"; -import { ensureDir } from "../lib/fs.js"; +import { persistStorageState } from "./persist-auth.js"; import type { SessionData } from "./session.js"; /** @@ -20,14 +19,7 @@ export async function closeSession(session: SessionData): Promise { } return; } - if (session.config.storageStatePath) { - try { - ensureDir(dirname(session.config.storageStatePath)); - await session.context.storageState({ path: session.config.storageStatePath }); - } catch { - /* best-effort: never block teardown */ - } - } + await persistStorageState(session.context, session.config.storageStatePath); try { await session.context.close(); } catch { diff --git a/src/session/persist-auth.ts b/src/session/persist-auth.ts new file mode 100644 index 0000000..a4918f1 --- /dev/null +++ b/src/session/persist-auth.ts @@ -0,0 +1,32 @@ +/** + * Persist a context's full auth state (cookies + localStorage + IndexedDB) to + * a storage-state JSON. Best-effort: never throws, so it can run on teardown, + * mid-probe, or right after a login without ever blocking the caller. + * @module session/persist-auth + */ +import { dirname } from "node:path"; +import type { BrowserContext } from "playwright"; +import { ensureDir } from "../lib/fs.js"; + +/** + * Save the context's storage state to `path`, including IndexedDB. + * + * No-op when `path` is falsy. The Playwright `indexedDB: true` flag (≥1.51) + * captures IndexedDB databases alongside cookies + localStorage, so a reload + * with the same storage state replays a complete logged-in session. + * + * @param context - The live browser context to snapshot. + * @param path - Destination storage-state JSON path, or falsy to skip. + */ +export async function persistStorageState( + context: BrowserContext, + path: string | null | undefined, +): Promise { + if (!path) return; + try { + ensureDir(dirname(path)); + await context.storageState({ path, indexedDB: true }); + } catch { + /* best-effort: never block teardown / login / probe */ + } +} diff --git a/tests/live/live-persist-idb.ts b/tests/live/live-persist-idb.ts new file mode 100644 index 0000000..d063a97 --- /dev/null +++ b/tests/live/live-persist-idb.ts @@ -0,0 +1,85 @@ +/** + * Live proof that IndexedDB is persisted across sessions via storageStatePath. + * A local HTTP origin serves a page that, on load, reads a token from IndexedDB + * and renders it into the DOM (so it is observable through browser_extract). + * With `?seed=` the page first writes the token to IndexedDB. + * + * Flow: open (storageStatePath) → navigate ?seed= (write) → close (save) + * → reopen (same storageStatePath) → navigate (no seed, read) → extract text. + * If the token reappears, IndexedDB was replayed from the saved storage state. + * + * Run: `node --import tsx tests/live/live-persist-idb.ts` + * @module tests/live/live-persist-idb + */ +import { createServer, type Server } from "node:http"; +import { rmSync } from "node:fs"; +import { check, connect, payload, state } from "./live-checks.js"; + +const TOKEN = `idb-${Date.now()}`; +const PROFILE = "/tmp/fuse-idb-profile.json"; + +/** Page that writes (?seed) then reads a token from IndexedDB into #out. */ +const PAGE = `
pending
`; + +/** Start the local origin on an ephemeral 127.0.0.1 port. */ +async function startOrigin(): Promise<{ server: Server; port: number }> { + const server = createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/html" }); + res.end(PAGE); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + return { server, port }; +} + +async function main(): Promise { + rmSync(PROFILE, { force: true }); + const { server, port } = await startOrigin(); + const base = `http://127.0.0.1:${port}/`; + + const c1 = await connect(); + const o1 = payload(await c1.callTool({ name: "browser_open", arguments: { storageStatePath: PROFILE } })); + const s1 = String(o1.sessionId); + await c1.callTool({ name: "browser_navigate", arguments: { sessionId: s1, url: `${base}?seed=${TOKEN}`, waitMs: 1500 } }); + const wrote = payload(await c1.callTool({ name: "browser_extract", arguments: { sessionId: s1, kind: "text" } })); + check("wrote token to IndexedDB", String(wrote.text).includes(`wrote:${TOKEN}`), String(wrote.text).slice(0, 60)); + await c1.callTool({ name: "browser_close", arguments: { sessionId: s1 } }); + await c1.close(); + + const c2 = await connect(); + const o2 = payload(await c2.callTool({ name: "browser_open", arguments: { storageStatePath: PROFILE } })); + const s2 = String(o2.sessionId); + await c2.callTool({ name: "browser_navigate", arguments: { sessionId: s2, url: base, waitMs: 1500 } }); + const read = payload(await c2.callTool({ name: "browser_extract", arguments: { sessionId: s2, kind: "text" } })); + check("IndexedDB token replayed after reopen", String(read.text).includes(`read:${TOKEN}`), String(read.text).slice(0, 60)); + await c2.callTool({ name: "browser_close", arguments: { sessionId: s2 } }); + await c2.close(); + + await new Promise((resolve) => server.close(() => resolve())); + rmSync(PROFILE, { force: true }); + console.log(state.failures === 0 ? "\nRESULT: IndexedDB persisté/rejoué OK" : `\nRESULT: ${state.failures} échec(s)`); + process.exit(state.failures === 0 ? 0 : 1); +} + +main().catch((err) => { + console.error("FATAL:", err); + process.exit(1); +}); diff --git a/tests/unit/persist-auth.test.ts b/tests/unit/persist-auth.test.ts new file mode 100644 index 0000000..31f819e --- /dev/null +++ b/tests/unit/persist-auth.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import type { BrowserContext } from "playwright"; +import { persistStorageState } from "../../src/session/persist-auth.js"; + +/** Minimal BrowserContext stand-in recording storageState() calls. */ +function fakeContext(opts?: { reject?: boolean }): { + ctx: BrowserContext; + calls: Array>; +} { + const calls: Array> = []; + const ctx = { + storageState: async (args?: Record) => { + calls.push(args ?? {}); + if (opts?.reject) throw new Error("save failed"); + return {}; + }, + } as unknown as BrowserContext; + return { ctx, calls }; +} + +describe("persistStorageState", () => { + test("saves with { path, indexedDB: true }", async () => { + const { ctx, calls } = fakeContext(); + await persistStorageState(ctx, "/tmp/fuse-persist-auth.json"); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ path: "/tmp/fuse-persist-auth.json", indexedDB: true }); + }); + + test("no-op when path is undefined", async () => { + const { ctx, calls } = fakeContext(); + await persistStorageState(ctx, undefined); + expect(calls).toHaveLength(0); + }); + + test("no-op when path is null", async () => { + const { ctx, calls } = fakeContext(); + await persistStorageState(ctx, null); + expect(calls).toHaveLength(0); + }); + + test("never throws when storageState rejects", async () => { + const { ctx } = fakeContext({ reject: true }); + await expect(persistStorageState(ctx, "/tmp/fuse-persist-auth.json")).resolves.toBeUndefined(); + }); +});