import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import http from "node:http"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { withEnvAsync } from "openclaw/plugin-sdk/test-env"; import { chromium, type BrowserContext } from "playwright-core"; import { afterEach, describe, expect, it } from "vitest"; import { chromeProductRoots, generateChromeExtensionIdForPath, stableChromeExtensionDir, } from "../src/browser/extension-install-layout.js"; import { installChromeExtensionBootstrap } from "../src/browser/extension-install.js"; import { handleGatewayExtensionUpgrade } from "../src/browser/extension-relay/gateway-relay-route.js"; import { createBrowserRouteDispatcher } from "../src/browser/routes/dispatcher.js"; import { createBrowserRouteContext } from "../src/browser/server-context.js"; import { getFreePort } from "../src/browser/test-port.js"; import { getBrowserControlState, stopBrowserControlService } from "../src/control-service.js"; import { relayTestKey } from "./relay-key.test-support.js"; declare const chrome: { runtime: { sendMessage: (message: unknown) => Promise> }; }; const runE2E = process.env.OPENCLAW_BROWSER_EXTENSION_E2E === "1" && (process.platform === "linux" || process.platform === "darwin"); const cleanups: Array<() => Promise> = []; const STORE_ORIGIN = "chrome-extension://kcdjddhmeafeomebliikmbpblkmkfoig/"; afterEach(async () => { for (const cleanup of cleanups.splice(0).toReversed()) { await cleanup().catch(() => undefined); } }); async function waitForExtensionId(context: BrowserContext, extensionPath: string): Promise { const browser = context.browser(); if (!browser) { throw new Error("Chromium browser connection unavailable"); } const cdp = await browser.newBrowserCDPSession(); const expected = await fs.realpath(extensionPath); const deadline = Date.now() + 15_000; do { const result = (await cdp.send("Extensions.getExtensions")) as { extensions: Array<{ id: string; path: string }>; }; for (const extension of result.extensions) { if ( (await fs.realpath(extension.path).catch(() => path.resolve(extension.path))) === expected ) { return extension.id; } } await new Promise((resolve) => { setTimeout(resolve, 100); }); } while (Date.now() < deadline); throw new Error("Chromium did not report the loaded OpenClaw extension"); } async function loadUnpackedExtension( context: BrowserContext, extensionPath: string, ): Promise { const browser = context.browser(); if (!browser) { throw new Error("Chromium browser connection unavailable"); } const cdp = await browser.newBrowserCDPSession(); await cdp.send("Extensions.loadUnpacked", { path: extensionPath }); } async function exactOwnedManifestsExist( manifestPaths: string[], expectedOrigins: string[], ): Promise { for (const manifestPath of manifestPaths) { try { const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as { name?: unknown; path?: unknown; allowed_origins?: unknown; key?: unknown; }; if ( manifest.name !== "ai.openclaw.browser_bootstrap" || typeof manifest.path !== "string" || Object.hasOwn(manifest, "key") || !Array.isArray(manifest.allowed_origins) || JSON.stringify(manifest.allowed_origins) !== JSON.stringify(expectedOrigins) || !(await fs.readFile(manifest.path, "utf8")).includes( "# OpenClaw native messaging bootstrap v1", ) ) { return false; } } catch { return false; } } return manifestPaths.length > 0; } async function seedLinuxSecurePreferences(params: { userDataDir: string; extensionId: string; extensionPath: string; }): Promise { const profileDir = path.join(params.userDataDir, "Default"); await fs.mkdir(profileDir, { recursive: true, mode: 0o700 }); await fs.writeFile( path.join(profileDir, "Secure Preferences"), `${JSON.stringify({ extensions: { settings: { [params.extensionId]: { location: 4, path: params.extensionPath }, }, }, })}\n`, { mode: 0o600 }, ); } function decodeSingleNativeResponse(frame: Buffer): Record { if (frame.length < 4) { throw new Error("native host returned no response frame"); } const length = os.endianness() === "LE" ? frame.readUInt32LE() : frame.readUInt32BE(); if (frame.length !== length + 4) { throw new Error( `native host did not return exactly one response frame (bytes=${frame.length}, declared=${length})`, ); } const parsed: unknown = JSON.parse(frame.subarray(4).toString("utf8")); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("native host returned an invalid response payload"); } return parsed as Record; } describe.runIf(runE2E)("Chrome native bootstrap Chromium E2E", () => { it("pre-registers before the first native call, auto-pairs, and revokes a paused tab", async () => { const root = await fs.realpath( await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-extension-e2e-")), ); cleanups.push(async () => await fs.rm(root, { recursive: true, force: true })); const homeDir = path.join(root, "home"); const stateDir = path.join(root, "custom-state"); const configPath = path.join(root, "custom-config", "openclaw.json"); const gatewayPort = await getFreePort(); let relayPort = await getFreePort(); while (relayPort === gatewayPort) { relayPort = await getFreePort(); } const linuxConfigHome = path.join(homeDir, ".config"); const chromeRootEnv = process.platform === "linux" ? { CHROME_CONFIG_HOME: linuxConfigHome, XDG_CONFIG_HOME: linuxConfigHome } : {}; const userDataDir = process.platform === "darwin" ? path.join(homeDir, "Library", "Application Support", "Google", "Chrome for Testing") : path.join(linuxConfigHome, "chromium"); await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true, mode: 0o700 }); await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 }); const token = relayTestKey(3); await fs.writeFile( path.join(stateDir, "credentials", "browser-extension-relay.secret"), `${token}\n`, { mode: 0o600 }, ); await fs.writeFile( configPath, `${JSON.stringify({ gateway: { port: gatewayPort }, browser: { profiles: { e2e: { driver: "extension", cdpPort: relayPort } } } })}\n`, { mode: 0o600 }, ); await withEnvAsync( { OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_GATEWAY_PORT: String(gatewayPort), }, async () => { const extensionSource = path.dirname(fileURLToPath(import.meta.url)); const nativeHostPath = await fs.realpath( path.resolve("extensions/browser/native-host-entry.ts"), ); const tsxPath = await fs.realpath(path.resolve("node_modules/.bin/tsx")); const tsxTsconfigPath = path.resolve("tsconfig.json"); const deps = { platform: process.platform, homeDir, stateDir, env: { HOME: homeDir, ...chromeRootEnv, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_GATEWAY_PORT: String(gatewayPort), }, nodePath: tsxPath, nativeHostPath, }; const gatewayServer = http.createServer((_req, res) => { res.writeHead(426); res.end(); }); gatewayServer.on("upgrade", (req, socket, head) => { void handleGatewayExtensionUpgrade(req, socket, head); }); await new Promise((resolve) => { gatewayServer.listen(gatewayPort, "127.0.0.1", resolve); }); cleanups.push( async () => await new Promise((resolve) => { gatewayServer.close(() => resolve()); }), ); cleanups.push(stopBrowserControlService); const browserEnv: NodeJS.ProcessEnv = { ...process.env, HOME: homeDir, ...chromeRootEnv, TSX_TSCONFIG_PATH: tsxTsconfigPath, }; delete browserEnv.OPENCLAW_STATE_DIR; delete browserEnv.OPENCLAW_CONFIG_PATH; delete browserEnv.VITEST; delete browserEnv.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR; const launchChromium = async () => await chromium.launchPersistentContext(userDataDir, { channel: "chromium", headless: true, env: browserEnv, ignoreDefaultArgs: ["--disable-extensions"], args: ["--enable-unsafe-extension-debugging"], }); let context = await launchChromium(); process.stderr.write("[browser-extension-e2e] chromium launched\n"); cleanups.push(async () => await context.close()); const installed = stableChromeExtensionDir(deps); const predictedId = generateChromeExtensionIdForPath(installed, process.platform); const expectedOrigins = [ predictedId, generateChromeExtensionIdForPath(extensionSource, process.platform), ] .toSorted() .map((id) => `chrome-extension://${id}/`); expectedOrigins.push(STORE_ORIGIN); expectedOrigins.sort(); const relevantManifestPaths = chromeProductRoots(deps) .filter((productRoot) => productRoot.userDataDir === userDataDir) .map((productRoot) => path.join(productRoot.nativeManifestDir, "ai.openclaw.browser_bootstrap.json"), ); const installPromise = installChromeExtensionBootstrap({ bundledDir: extensionSource, pluginRoot: path.resolve("extensions/browser"), waitMs: 15_000, deps, }); await expect .poll( async () => await exactOwnedManifestsExist(relevantManifestPaths, expectedOrigins), { timeout: 15_000, }, ) .toBe(true); process.stderr.write("[browser-extension-e2e] deterministic native host pre-registered\n"); await loadUnpackedExtension(context, installed); const extensionId = await waitForExtensionId(context, installed); expect(extensionId).toBe(predictedId); process.stderr.write("[browser-extension-e2e] unpacked extension loaded\n"); await context.close(); if (process.platform === "linux") { // Linux CDP loads are transient and omit the protected record written by Load unpacked. // Seed that exact record only after Chromium confirms the path-derived extension ID. await seedLinuxSecurePreferences({ userDataDir, extensionId, extensionPath: installed }); } const status = await installPromise; expect(status.manualSetupRequired, JSON.stringify(status)).toBe(false); expect( status.discovered.some( (entry) => entry.extensionPath === installed && entry.extensionId === predictedId, ), ).toBe(true); process.stderr.write("[browser-extension-e2e] Secure Preferences identity verified\n"); context = await launchChromium(); await loadUnpackedExtension(context, installed); expect(await waitForExtensionId(context, installed)).toBe(predictedId); process.stderr.write("[browser-extension-e2e] persisted extension reloaded\n"); const controlled = await context.newPage(); await controlled.goto( `data:text/html,${encodeURIComponent( 'OpenClaw E2E
', )}`, ); const extensionPage = await context.newPage(); await extensionPage.goto(`chrome-extension://${extensionId}/options.html`); let extensionStatus: Record = {}; try { await expect .poll( async () => { extensionStatus = await extensionPage.evaluate( async () => await chrome.runtime.sendMessage({ type: "getStatus" }), ); return extensionStatus.paired; }, { timeout: 15_000 }, ) .toBe(true); } catch (error) { throw new Error(`Extension did not auto-pair: ${JSON.stringify(extensionStatus)}`, { cause: error, }); } expect(extensionStatus).toMatchObject({ paired: true, accessMode: "all" }); try { await expect .poll( () => getBrowserControlState()?.extensionRelays?.get("e2e")?.bridge.extensionConnected, { timeout: 15_000 }, ) .toBe(true); } catch (error) { extensionStatus = await extensionPage.evaluate( async () => await chrome.runtime.sendMessage({ type: "getStatus" }), ); throw new Error(`Extension relay did not connect: ${JSON.stringify(extensionStatus)}`, { cause: error, }); } const relay = getBrowserControlState()?.extensionRelays?.get("e2e"); if (!relay || relay.port !== relayPort) { throw new Error("Gateway wakeup did not start the configured extension relay"); } const browserState = getBrowserControlState(); const extensionProfile = browserState?.resolved.profiles.e2e; if (!browserState || !extensionProfile) { throw new Error("Browser E2E state did not contain the extension profile"); } const existingSessionProfile = "e2e-existing-session"; const relayAuthorization = `Basic ${Buffer.from( `openclaw-internal:${relay.internalToken}`, ).toString("base64")}`; const relayVersionResponse = await fetch(`http://127.0.0.1:${relay.port}/json/version`, { headers: { Authorization: relayAuthorization }, }); const relayVersion = (await relayVersionResponse.json()) as { webSocketDebuggerUrl?: string; }; if (!relayVersion.webSocketDebuggerUrl) { throw new Error("Authenticated extension relay did not return a WebSocket endpoint"); } browserState.resolved.profiles[existingSessionProfile] = { ...extensionProfile, driver: "existing-session", attachOnly: true, cdpUrl: `http://openclaw-internal:${encodeURIComponent(relay.internalToken)}@127.0.0.1:${relay.port}`, mcpArgs: [ "--wsEndpoint", relayVersion.webSocketDebuggerUrl, "--wsHeaders", JSON.stringify({ Authorization: relayAuthorization }), ], }; browserState.resolved.ssrfPolicy = undefined; const routeContext = createBrowserRouteContext({ getState: () => browserState, refreshConfigFromDisk: false, }); const dispatcher = createBrowserRouteDispatcher(routeContext); const tabsResponse = await dispatcher.dispatch({ method: "GET", path: "/tabs", query: { profile: existingSessionProfile }, }); const tabs = (tabsResponse.body as { tabs?: Array<{ targetId?: string; url?: string }> }) .tabs; const controlledTab = tabs?.find((tab) => tab.url?.startsWith("data:text/html")); if (!controlledTab?.targetId) { throw new Error(`Existing-session E2E tab missing: ${JSON.stringify(tabsResponse.body)}`); } const snapshotResponse = await dispatcher.dispatch({ method: "GET", path: "/snapshot", query: { profile: existingSessionProfile, targetId: controlledTab.targetId, format: "ai", }, }); const refs = (snapshotResponse.body as { refs?: Record }).refs; const targetRef = Object.entries(refs ?? {}).find( ([, info]) => info.name === "Offscreen target", )?.[0]; if (!targetRef) { throw new Error(`Offscreen target ref missing: ${JSON.stringify(snapshotResponse.body)}`); } await controlled.evaluate(() => window.scrollTo(0, 0)); const screenshotResponse = await dispatcher.dispatch({ method: "POST", path: "/screenshot", query: { profile: existingSessionProfile }, body: { targetId: controlledTab.targetId, ref: targetRef, labels: true, type: "png", }, }); const screenshot = screenshotResponse.body as { path?: string; labelsCount?: number; }; expect(screenshotResponse.status, JSON.stringify(screenshotResponse.body)).toBe(200); expect(screenshot.labelsCount).toBe(1); if (!screenshot.path) { throw new Error("Labeled ref screenshot did not return a path"); } const proofPath = path.resolve( ".artifacts/browser-lifecycle/existing-session-offscreen-labeled-ref.png", ); await fs.mkdir(path.dirname(proofPath), { recursive: true }); await fs.copyFile(screenshot.path, proofPath); const screenshotDataUrl = `data:image/png;base64,${( await fs.readFile(screenshot.path) ).toString("base64")}`; const orangePixels = await controlled.evaluate(async (imageUrl) => { const image = new Image(); image.src = imageUrl; await image.decode(); const canvas = document.createElement("canvas"); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const canvasContext = canvas.getContext("2d"); if (!canvasContext) { return 0; } canvasContext.drawImage(image, 0, 0); const pixels = canvasContext.getImageData(0, 0, canvas.width, canvas.height).data; let matches = 0; for (let index = 0; index < pixels.length; index += 4) { if ( (pixels[index] ?? 0) > 220 && (pixels[index + 1] ?? 255) >= 40 && (pixels[index + 1] ?? 255) <= 120 && (pixels[index + 2] ?? 255) < 80 && (pixels[index + 3] ?? 0) > 200 ) { matches += 1; } } return matches; }, screenshotDataUrl); expect(orangePixels).toBeGreaterThan(20); process.stderr.write(`[browser-extension-e2e] screenshot proof ${proofPath}\n`); const registration = status.registrations.find( (entry) => relevantManifestPaths.includes(entry.manifestPath) && entry.state === "owned", ); if (!registration) { throw new Error("Active Chromium native host registration missing"); } const manifest = JSON.parse(await fs.readFile(registration.manifestPath, "utf8")) as { path: string; }; const requestBody = Buffer.from( JSON.stringify({ v: 1, op: "bootstrap", nonce: "BwcHBwcHBwcHBwcHBwcHBw" }), ); const requestFrame = Buffer.alloc(requestBody.length + 4); if (os.endianness() === "LE") { requestFrame.writeUInt32LE(requestBody.length); } else { requestFrame.writeUInt32BE(requestBody.length); } requestBody.copy(requestFrame, 4); const hostProbe = spawnSync(manifest.path, [`chrome-extension://${extensionId}/`], { input: requestFrame, env: browserEnv, timeout: 30_000, }); expect( hostProbe.status, `native host exit=${hostProbe.status} signal=${hostProbe.signal} stderr=${hostProbe.stderr.toString("utf8")}`, ).toBe(0); const nativeResponse = decodeSingleNativeResponse(hostProbe.stdout); if ( nativeResponse.ok !== true || nativeResponse.nonce !== "BwcHBwcHBwcHBwcHBwcHBw" || typeof nativeResponse.pairingString !== "string" ) { throw new Error("native host did not bootstrap successfully"); } const fragmentAt = nativeResponse.pairingString.lastIndexOf("#"); if (fragmentAt < 0) { throw new Error("native host returned an invalid local bootstrap response"); } let relayUrl: URL; try { relayUrl = new URL(nativeResponse.pairingString.slice(0, fragmentAt)); } catch { throw new Error("native host returned an invalid local bootstrap response"); } if ( relayUrl.hostname !== "127.0.0.1" || relayUrl.port !== String(gatewayPort) || relayUrl.pathname !== "/browser/extension" || relayUrl.searchParams.get("gateway") !== `ws://127.0.0.1:${gatewayPort}` || nativeResponse.pairingString.slice(fragmentAt + 1) !== token ) { throw new Error("native host did not use the custom installation context"); } const storeHostProbe = spawnSync(manifest.path, [STORE_ORIGIN], { input: requestFrame, env: browserEnv, timeout: 30_000, }); expect( storeHostProbe.status, `Store native host exit=${storeHostProbe.status} signal=${storeHostProbe.signal} stderr=${storeHostProbe.stderr.toString("utf8")}`, ).toBe(0); expect(decodeSingleNativeResponse(storeHostProbe.stdout)).toMatchObject({ ok: true, nonce: "BwcHBwcHBwcHBwcHBwcHBw", }); process.stderr.write("[browser-extension-e2e] launcher probe passed\n"); await expect .poll(() => relay.bridge.accessibleTabs().some((tab) => tab.url.startsWith("data:text/html")), ) .toBe(true); const tabId = relay.bridge .accessibleTabs() .find((tab) => tab.url.startsWith("data:text/html"))?.tabId; if (tabId === undefined) { throw new Error("Ungrouped E2E tab was not exposed in All tabs mode"); } await extensionPage.evaluate( async ({ tabId: id }) => await chrome.runtime.sendMessage({ type: "toggleTabAccess", tabId: id, accessMode: "all", grant: false, }), { tabId }, ); await expect .poll(() => relay.bridge.accessibleTabs().some((tab) => tab.tabId === tabId)) .toBe(false); const extensionContext = routeContext.forProfile("e2e"); await extensionPage.evaluate( async () => await chrome.runtime.sendMessage({ type: "unpair" }), ); await expect.poll(() => relay.bridge.extensionConnected).toBe(false); const pageCountBeforeUnavailableSelection = context.pages().length; await expect(extensionContext.ensureTabAvailable()).rejects.toThrow(); expect(context.pages()).toHaveLength(pageCountBeforeUnavailableSelection); }, ); }, 120_000); });