Files
openclaw/extensions/browser/chrome-extension/bootstrap.chromium.test.ts
Peter Steinberger 5350b92dc8 fix(browser): wake extension relay through gateway (#122537)
* fix(browser): wake extension relay through gateway

* fix(browser): preserve manual extension relay pairing
2026-08-12 02:13:03 -07:00

426 lines
16 KiB
TypeScript

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 { 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<Record<string, unknown>> };
};
const runE2E =
process.env.OPENCLAW_BROWSER_EXTENSION_E2E === "1" &&
(process.platform === "linux" || process.platform === "darwin");
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).toReversed()) {
await cleanup().catch(() => undefined);
}
});
async function waitForExtensionId(context: BrowserContext, extensionPath: string): Promise<string> {
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<void> {
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<boolean> {
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<void> {
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<string, unknown> {
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<string, unknown>;
}
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<void>((resolve) => {
gatewayServer.listen(gatewayPort, "127.0.0.1", resolve);
});
cleanups.push(
async () =>
await new Promise<void>((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}/`);
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,<title>OpenClaw E2E</title><p>ready</p>");
const extensionPage = await context.newPage();
await extensionPage.goto(`chrome-extension://${extensionId}/options.html`);
let extensionStatus: Record<string, unknown> = {};
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 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");
}
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);
},
);
}, 120_000);
});