Skip to content
25 changes: 25 additions & 0 deletions src/core/OriginHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Page, RouteHandler>();

constructor(config?: OriginHeaderConfig) {
if (config) {
Expand Down Expand Up @@ -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<void> {
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();
Expand Down Expand Up @@ -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();
}
}
8 changes: 6 additions & 2 deletions src/core/controller/SessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
import { VisionGate } from "../VisionGate.js";
import type { EventBus } from "./EventBus.js";

type PreNavigationPageHook = (page: Page) => Promise<void>;

/**
* Orchestrates the full browser session lifecycle: launching browsers (with
* stealth injection, fingerprint randomization, and behavioral DNA), managing
Expand Down Expand Up @@ -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<void> {
async setHeadedMode(headed: boolean, beforeNavigation?: PreNavigationPageHook): Promise<void> {
const context = this.browserManager.getContext();
const page = this.getPage();
const profile = this.profile;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -302,13 +305,14 @@ export class SessionManager {

// ─── Multi-Page ──────────────────────────────────────────────────────────────

async openPage(url: string): Promise<TaloxPageState> {
async openPage(url: string, beforeNavigation?: PreNavigationPageHook): Promise<TaloxPageState> {
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) {
Expand Down
9 changes: 4 additions & 5 deletions src/core/controller/TaloxController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,8 @@ export class TaloxController {
/** Install per-origin headers if configured. */
private async setupOriginHeaders(page: import("playwright-core").Page): Promise<void> {
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. */
Expand Down Expand Up @@ -756,7 +755,7 @@ export class TaloxController {

async setHeaded(headed: boolean): Promise<void> {
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()) {
Expand Down Expand Up @@ -1014,7 +1013,7 @@ export class TaloxController {
// ═══════════════════════════════════════════════════════════════════════════

async openPage(url: string): Promise<TaloxPageState> {
const state = await this._session.openPage(url);
const state = await this._session.openPage(url, (page) => this.setupOriginHeaders(page));
this.retargetCrossOriginManagerToActivePage();
this.retargetVideoRecorderToActivePage();
return state;
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/OriginHeadersSessionLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
158 changes: 158 additions & 0 deletions tests/unit/OriginHeadersSessionRouting.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});