diff --git a/src/core/OriginHeaders.ts b/src/core/OriginHeaders.ts index dd263cd..36d912e 100644 --- a/src/core/OriginHeaders.ts +++ b/src/core/OriginHeaders.ts @@ -31,6 +31,7 @@ export class OriginHeaders { private installedPage: Page | null = null; private routeHandler: RouteHandler | null = null; private rollbackRoutes: Array<{ page: Page; handler: RouteHandler }> = []; + private readonly sessionRoutes = new Map(); constructor(config?: OriginHeaderConfig) { if (config) { @@ -91,6 +92,20 @@ export class OriginHeaders { this.routeHandler = nextHandler; } + /** + * Install a route owned by the current Talox session without replacing routes + * already installed on sibling pages. Repeated installs on the same page are + * idempotent and failed registrations never claim ownership. + */ + async installSessionPage(page: Page): Promise { + if (this.sessionRoutes.has(page)) return; + + const handler = this.createRouteHandler(); + const routePromise = page.route("**/*", handler); + if (routePromise) await routePromise; + this.sessionRoutes.set(page, handler); + } + private createRouteHandler(): RouteHandler { return async (route: Route) => { const request = route.request(); @@ -153,6 +168,16 @@ export class OriginHeaders { } } + const sessionRoutes = [...this.sessionRoutes]; + this.sessionRoutes.clear(); + for (const [page, handler] of sessionRoutes) { + try { + await page.unroute("**/*", handler); + } catch { + // NOSONAR — page may already be closed + } + } + this.config.clear(); } } diff --git a/src/core/controller/SessionManager.ts b/src/core/controller/SessionManager.ts index 6da94ce..10ecbe5 100644 --- a/src/core/controller/SessionManager.ts +++ b/src/core/controller/SessionManager.ts @@ -41,6 +41,8 @@ import { import { VisionGate } from "../VisionGate.js"; import type { EventBus } from "./EventBus.js"; +type PreNavigationPageHook = (page: Page) => Promise; + /** * Orchestrates the full browser session lifecycle: launching browsers (with * stealth injection, fingerprint randomization, and behavioral DNA), managing @@ -209,7 +211,7 @@ export class SessionManager { * Observe-mode bypass: if `this.settings.observeBypass` is true the escalation * is skipped (handled by AdaptationEngine before this point). */ - async setHeadedMode(headed: boolean): Promise { + async setHeadedMode(headed: boolean, beforeNavigation?: PreNavigationPageHook): Promise { const context = this.browserManager.getContext(); const page = this.getPage(); const profile = this.profile; @@ -253,6 +255,7 @@ export class SessionManager { await this.injectNetworkGuard(newPage); await this.injectStealthScripts(newPage); await this.attachSecurityHooks(newPage); + if (beforeNavigation) await beforeNavigation(newPage); // Re-install auto-dialog handler on new page if (this.settings.autoDialogHandling) { @@ -302,13 +305,14 @@ export class SessionManager { // ─── Multi-Page ────────────────────────────────────────────────────────────── - async openPage(url: string): Promise { + async openPage(url: string, beforeNavigation?: PreNavigationPageHook): Promise { const page = await this.browserManager.newPage(); await this.injectNetworkGuard(page); await this.injectStealthScripts(page); await this.attachSecurityHooks(page); + if (beforeNavigation) await beforeNavigation(page); // Install auto-dialog handler on new page if (this.settings.autoDialogHandling) { diff --git a/src/core/controller/TaloxController.ts b/src/core/controller/TaloxController.ts index a548b17..04107d2 100644 --- a/src/core/controller/TaloxController.ts +++ b/src/core/controller/TaloxController.ts @@ -319,9 +319,8 @@ export class TaloxController { /** Install per-origin headers if configured. */ private async setupOriginHeaders(page: import("playwright-core").Page): Promise { if (!this.originHeaderConfig) return; - const headers = new OriginHeaders(this.originHeaderConfig); - this.originHeaders = headers; - await headers.install(page); + this.originHeaders ??= new OriginHeaders(this.originHeaderConfig); + await this.originHeaders.installSessionPage(page); } /** Start session-wide HAR recording if configured. */ @@ -756,7 +755,7 @@ export class TaloxController { async setHeaded(headed: boolean): Promise { const frame = this.getAttentionFrame(); - await this._session.setHeadedMode(headed); + await this._session.setHeadedMode(headed, (page) => this.setupOriginHeaders(page)); if (frame) this.setAttentionFrameForActivePage(frame); const page = this._session.getPlaywrightPage(); if (page && this.harRecorder?.isRecording()) { @@ -1014,7 +1013,7 @@ export class TaloxController { // ═══════════════════════════════════════════════════════════════════════════ async openPage(url: string): Promise { - const state = await this._session.openPage(url); + const state = await this._session.openPage(url, (page) => this.setupOriginHeaders(page)); this.retargetCrossOriginManagerToActivePage(); this.retargetVideoRecorderToActivePage(); return state; diff --git a/tests/unit/OriginHeadersSessionLifecycle.test.ts b/tests/unit/OriginHeadersSessionLifecycle.test.ts new file mode 100644 index 0000000..4035a73 --- /dev/null +++ b/tests/unit/OriginHeadersSessionLifecycle.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { OriginHeaders } from "../../src/core/OriginHeaders.js"; + +function createPage() { + return { + route: vi.fn().mockResolvedValue(undefined), + unroute: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("OriginHeaders session lifecycle", () => { + it("keeps sibling-page routes installed and is idempotent per page", async () => { + const headers = new OriginHeaders({ + "https://api.example.com": { Authorization: "Bearer secret" }, + }); + const firstPage = createPage(); + const secondPage = createPage(); + + await headers.installSessionPage(firstPage as any); + await headers.installSessionPage(secondPage as any); + await headers.installSessionPage(firstPage as any); + + expect(firstPage.route).toHaveBeenCalledTimes(1); + expect(secondPage.route).toHaveBeenCalledTimes(1); + expect(firstPage.unroute).not.toHaveBeenCalled(); + expect(secondPage.unroute).not.toHaveBeenCalled(); + + await headers.dispose(); + expect(firstPage.unroute).toHaveBeenCalledTimes(1); + expect(secondPage.unroute).toHaveBeenCalledTimes(1); + }); + + it("does not claim ownership when a session route registration fails", async () => { + const headers = new OriginHeaders({ + "https://api.example.com": { Authorization: "Bearer secret" }, + }); + const page = createPage(); + page.route.mockRejectedValue(new Error("route unavailable")); + + await expect(headers.installSessionPage(page as any)).rejects.toThrow("route unavailable"); + await headers.dispose(); + + expect(page.unroute).not.toHaveBeenCalled(); + }); + + it("disposes every session route best-effort even when one page is already gone", async () => { + const headers = new OriginHeaders({ + "https://api.example.com": { Authorization: "Bearer secret" }, + }); + const closedPage = createPage(); + const livePage = createPage(); + + await headers.installSessionPage(closedPage as any); + await headers.installSessionPage(livePage as any); + closedPage.unroute.mockRejectedValue(new Error("page closed")); + + await expect(headers.dispose()).resolves.toBeUndefined(); + expect(closedPage.unroute).toHaveBeenCalledTimes(1); + expect(livePage.unroute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/OriginHeadersSessionRouting.test.ts b/tests/unit/OriginHeadersSessionRouting.test.ts new file mode 100644 index 0000000..aada858 --- /dev/null +++ b/tests/unit/OriginHeadersSessionRouting.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const snapshotMocks = vi.hoisted(() => ({ + capture: vi.fn(), + restore: vi.fn(), +})); + +vi.mock("../../src/core/SessionSnapshot.js", () => ({ + captureSessionSnapshot: snapshotMocks.capture, + restoreSessionSnapshot: snapshotMocks.restore, +})); + +import { TaloxController } from "../../src/core/controller/TaloxController.js"; + +const snapshot = { + url: "https://api.example.com/account", + title: "Account", + capturedAt: "2026-08-27T00:00:00.000Z", + cookies: [], + localStorage: {}, + sessionStorage: {}, + scrollX: 0, + scrollY: 0, +}; + +const emptyState = { + url: "https://api.example.com/account", + title: "Account", + nodes: [], + interactiveElements: [], + console: { logs: [], errors: [], warnings: [] }, + network: { requests: [], failedRequests: [] }, + bugs: [], + timestamp: "2026-08-27T00:00:00.000Z", +}; + +function createPage(order: string[]) { + return { + route: vi.fn(async () => { + order.push("route"); + }), + unroute: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + goto: vi.fn(async () => { + order.push("goto"); + }), + close: vi.fn().mockResolvedValue(undefined), + isClosed: vi.fn(() => false), + }; +} + +function createController() { + return new TaloxController("/tmp/talox-origin-session-routing", { + originHeaders: { + "https://api.example.com": { Authorization: "Bearer secret" }, + }, + settings: { + automaticThinkingEnabled: false, + autoDialogHandling: false, + }, + }); +} + +function setQaProfile(session: any, id: string): void { + session.profile = { + id, + class: "qa", + purpose: "origin routing regression", + userDataDir: `/tmp/talox-origin-session-routing/${id}`, + metadata: { createdAt: "", lastUsed: "" }, + }; +} + +function installCollectorStub(session: any, page: any) { + const collector = { + getPage: () => page, + collect: vi.fn(async () => ({ ...emptyState, bugs: [] })), + }; + session.createStateCollector = vi.fn(() => collector); + vi.spyOn(session.rulesEngine, "analyze").mockReturnValue([]); + return collector; +} + +describe("OriginHeaders session routing", () => { + beforeEach(() => { + snapshotMocks.capture.mockReset(); + snapshotMocks.restore.mockReset(); + snapshotMocks.capture.mockResolvedValue(snapshot); + snapshotMocks.restore.mockResolvedValue(undefined); + }); + + it("installs security then origin headers before the first openPage navigation", async () => { + const order: string[] = []; + const controller = createController(); + const session = controller._session as any; + const page = createPage(order); + setQaProfile(session, "origin-routing-profile"); + vi.spyOn(session.browserManager, "newPage").mockResolvedValue(page as any); + vi.spyOn(session, "injectStealthScripts").mockResolvedValue(undefined); + installCollectorStub(session, page); + + await controller.openPage("https://api.example.com/account"); + + expect(order).toEqual(["route", "route", "goto"]); + }); + + it("keeps the surviving sibling route through open, switch, and close", async () => { + const controller = createController(); + const session = controller._session as any; + const firstPage = createPage([]); + const secondPage = createPage([]); + const firstCollector = { getPage: () => firstPage }; + setQaProfile(session, "origin-sibling-profile"); + session.pages = [firstCollector]; + session.activePageIndex = 0; + await (controller as any).setupOriginHeaders(firstPage as any); + vi.spyOn(session.browserManager, "newPage").mockResolvedValue(secondPage as any); + vi.spyOn(session, "injectStealthScripts").mockResolvedValue(undefined); + installCollectorStub(session, secondPage); + + await controller.openPage("https://api.example.com/account"); + controller.switchPage(0); + await controller.closePage(1); + + expect(firstPage.unroute).not.toHaveBeenCalled(); + expect(firstPage.route).toHaveBeenCalledTimes(1); + await (controller as any).disposeOriginHeaders(); + expect(firstPage.unroute).toHaveBeenCalledTimes(1); + }); + + it("installs origin headers on the recreated page before snapshot restoration navigation", async () => { + const order: string[] = []; + const controller = createController(); + const session = controller._session as any; + const oldPage = createPage([]); + const newPage = createPage(order); + const oldContext = {}; + const newContext = { newPage: vi.fn(async () => newPage) }; + const oldCollector = { getPage: () => oldPage }; + const newCollector = installCollectorStub(session, newPage); + setQaProfile(session, "origin-headed-profile"); + session.pages = [oldCollector]; + session.activePageIndex = 0; + vi.spyOn(session.browserManager, "getContext").mockReturnValue(oldContext as any); + vi.spyOn(session.browserManager, "close").mockResolvedValue(undefined); + vi.spyOn(session.browserManager, "launch").mockResolvedValue(newContext as any); + vi.spyOn(session, "injectStealthScripts").mockResolvedValue(undefined); + session.createStateCollector = vi.fn(() => newCollector); + snapshotMocks.restore.mockImplementation(async () => { + order.push("restore"); + }); + + await controller.setHeaded(true); + session.stopSessionIdleMonitor(); + + expect(order).toEqual(["route", "route", "restore"]); + }); +});