Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ Every field is optional. Defaults are applied by `resolveConfig` (`src/agent/con
| `cdpHeaders` | `Record<string,string>` | `null` | Extra headers for the CDP connect handshake — e.g. `{ Authorization: "Bearer <token>" }` 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/<name>.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/<name>.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. |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 4 additions & 6 deletions src/agent/probe-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/server/tools/act.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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() });
});
});
Expand Down
12 changes: 2 additions & 10 deletions src/session/close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -20,14 +19,7 @@ export async function closeSession(session: SessionData): Promise<void> {
}
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 {
Expand Down
32 changes: 32 additions & 0 deletions src/session/persist-auth.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (!path) return;
try {
ensureDir(dirname(path));
await context.storageState({ path, indexedDB: true });
} catch {
/* best-effort: never block teardown / login / probe */
}
}
85 changes: 85 additions & 0 deletions tests/live/live-persist-idb.ts
Original file line number Diff line number Diff line change
@@ -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=<token>` the page first writes the token to IndexedDB.
*
* Flow: open (storageStatePath) → navigate ?seed=<token> (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 = `<!doctype html><html><body><div id="out">pending</div><script>
const seed = new URLSearchParams(location.search).get("seed");
const req = indexedDB.open("fuse-auth", 1);
req.onupgradeneeded = () => req.result.createObjectStore("kv");
req.onsuccess = () => {
const db = req.result;
const done = (v) => { document.getElementById("out").textContent = v || "EMPTY"; };
if (seed) {
const tx = db.transaction("kv", "readwrite");
tx.objectStore("kv").put(seed, "token");
tx.oncomplete = () => done("wrote:" + seed);
} else {
const tx = db.transaction("kv", "readonly");
const g = tx.objectStore("kv").get("token");
g.onsuccess = () => done("read:" + (g.result || ""));
}
};
</script></body></html>`;

/** 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<void>((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<void> {
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<void>((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);
});
45 changes: 45 additions & 0 deletions tests/unit/persist-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
} {
const calls: Array<Record<string, unknown>> = [];
const ctx = {
storageState: async (args?: Record<string, unknown>) => {
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();
});
});