mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
* fix: require auth for sandbox browser cdp relay * addressing review-skill * addressing review-skill * addressing review-skill * addressing codex review * addressing claude review * docs: add changelog entry for PR merge
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
|
|
|
|
vi.mock("../chrome-mcp.js", () => ({
|
|
getChromeMcpPid: vi.fn(() => 4321),
|
|
}));
|
|
|
|
const { BrowserProfileUnavailableError } = await import("../errors.js");
|
|
const { registerBrowserBasicRoutes } = await import("./basic.js");
|
|
|
|
function createExistingSessionProfileState(params?: {
|
|
isHttpReachable?: (timeoutMs?: number) => Promise<boolean>;
|
|
isTransportAvailable?: (timeoutMs?: number) => Promise<boolean>;
|
|
isReachable?: (
|
|
timeoutMs?: number,
|
|
options?: { ephemeral?: boolean; signal?: AbortSignal },
|
|
) => Promise<boolean>;
|
|
}) {
|
|
return {
|
|
resolved: {
|
|
enabled: true,
|
|
headless: false,
|
|
noSandbox: false,
|
|
executablePath: undefined,
|
|
},
|
|
profiles: new Map(),
|
|
forProfile: () =>
|
|
({
|
|
profile: {
|
|
name: "chrome-live",
|
|
driver: "existing-session",
|
|
cdpPort: 0,
|
|
cdpUrl: "",
|
|
userDataDir: "/tmp/brave-profile",
|
|
color: "#00AA00",
|
|
executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
headless: false,
|
|
attachOnly: true,
|
|
},
|
|
isHttpReachable: params?.isHttpReachable ?? (async () => true),
|
|
isTransportAvailable: params?.isTransportAvailable ?? (async () => true),
|
|
isReachable: params?.isReachable ?? (async () => true),
|
|
}) as never,
|
|
};
|
|
}
|
|
|
|
function readFirstReachabilityCall(
|
|
isReachable: ReturnType<typeof vi.fn>,
|
|
): [number | undefined, { ephemeral?: boolean; signal?: AbortSignal } | undefined] {
|
|
const [call] = isReachable.mock.calls as Array<
|
|
[number | undefined, { ephemeral?: boolean; signal?: AbortSignal } | undefined]
|
|
>;
|
|
if (!call) {
|
|
throw new Error("expected reachability probe call");
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function createManagedProfileState(profileOverrides?: Record<string, unknown>) {
|
|
return {
|
|
resolved: {
|
|
enabled: true,
|
|
headless: false,
|
|
headlessSource: "default",
|
|
noSandbox: false,
|
|
executablePath: undefined,
|
|
},
|
|
profiles: new Map(),
|
|
forProfile: () =>
|
|
({
|
|
profile: {
|
|
name: "openclaw",
|
|
driver: "openclaw",
|
|
cdpPort: 18800,
|
|
cdpUrl: "http://127.0.0.1:18800",
|
|
cdpHost: "127.0.0.1",
|
|
cdpIsLoopback: true,
|
|
userDataDir: "/tmp/openclaw-profile",
|
|
color: "#FF4500",
|
|
headless: false,
|
|
headlessSource: "default",
|
|
attachOnly: false,
|
|
...profileOverrides,
|
|
},
|
|
isHttpReachable: async () => false,
|
|
isTransportAvailable: async () => false,
|
|
isReachable: async () => false,
|
|
}) as never,
|
|
};
|
|
}
|
|
|
|
async function callBasicRouteWithState(params: {
|
|
query?: Record<string, string>;
|
|
state: ReturnType<typeof createExistingSessionProfileState>;
|
|
}) {
|
|
const { app, getHandlers } = createBrowserRouteApp();
|
|
registerBrowserBasicRoutes(app, {
|
|
state: () => params.state,
|
|
forProfile: params.state.forProfile,
|
|
} as never);
|
|
|
|
const handler = getHandlers.get("/");
|
|
expect(handler).toBeTypeOf("function");
|
|
|
|
const response = createBrowserRouteResponse();
|
|
await handler?.({ params: {}, query: params.query ?? { profile: "chrome-live" } }, response.res);
|
|
return response;
|
|
}
|
|
|
|
async function callStartRoute(params: {
|
|
profile?: Record<string, unknown>;
|
|
query?: Record<string, unknown>;
|
|
}) {
|
|
const ensureBrowserAvailable = vi.fn(async () => {});
|
|
const profile = {
|
|
name: "openclaw",
|
|
driver: "openclaw",
|
|
cdpPort: 18800,
|
|
cdpUrl: "http://127.0.0.1:18800",
|
|
cdpHost: "127.0.0.1",
|
|
cdpIsLoopback: true,
|
|
userDataDir: "/tmp/openclaw-profile",
|
|
color: "#FF4500",
|
|
headless: false,
|
|
headlessSource: "default",
|
|
attachOnly: false,
|
|
...params.profile,
|
|
};
|
|
const { app, postHandlers } = createBrowserRouteApp();
|
|
registerBrowserBasicRoutes(app, {
|
|
state: () => ({ resolved: { enabled: true, headless: false }, profiles: new Map() }),
|
|
forProfile: () =>
|
|
({
|
|
profile,
|
|
ensureBrowserAvailable,
|
|
}) as never,
|
|
} as never);
|
|
|
|
const handler = postHandlers.get("/start");
|
|
expect(handler).toBeTypeOf("function");
|
|
|
|
const response = createBrowserRouteResponse();
|
|
await handler?.({ params: {}, query: params.query ?? {} }, response.res);
|
|
return { response, ensureBrowserAvailable };
|
|
}
|
|
|
|
function responseBodyRecord(response: { body: unknown }): Record<string, unknown> {
|
|
if (!response.body || typeof response.body !== "object") {
|
|
throw new Error("expected JSON response body");
|
|
}
|
|
return response.body as Record<string, unknown>;
|
|
}
|
|
|
|
describe("basic browser routes", () => {
|
|
it("reports Linux no-display headless fallback for local managed profiles", async () => {
|
|
const originalPlatform = process.platform;
|
|
const originalDisplay = process.env.DISPLAY;
|
|
const originalWayland = process.env.WAYLAND_DISPLAY;
|
|
Object.defineProperty(process, "platform", { value: "linux" });
|
|
delete process.env.DISPLAY;
|
|
delete process.env.WAYLAND_DISPLAY;
|
|
try {
|
|
const response = await callBasicRouteWithState({
|
|
query: { profile: "openclaw" },
|
|
state: createManagedProfileState(),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const body = responseBodyRecord(response);
|
|
expect(body.profile).toBe("openclaw");
|
|
expect(body.headless).toBe(true);
|
|
expect(body.headlessSource).toBe("linux-display-fallback");
|
|
} finally {
|
|
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
if (originalDisplay === undefined) {
|
|
delete process.env.DISPLAY;
|
|
} else {
|
|
process.env.DISPLAY = originalDisplay;
|
|
}
|
|
if (originalWayland === undefined) {
|
|
delete process.env.WAYLAND_DISPLAY;
|
|
} else {
|
|
process.env.WAYLAND_DISPLAY = originalWayland;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("reports request-local headless source for tracked local launches", async () => {
|
|
const state = createManagedProfileState();
|
|
const profile = (state.forProfile() as { profile: unknown }).profile as never;
|
|
state.profiles.set("openclaw", {
|
|
profile,
|
|
running: {
|
|
pid: 222,
|
|
exe: { kind: "chromium", path: "/usr/bin/chromium" },
|
|
userDataDir: "/tmp/openclaw-profile",
|
|
cdpPort: 18800,
|
|
startedAt: Date.now(),
|
|
proc: {} as never,
|
|
headless: true,
|
|
headlessSource: "request",
|
|
},
|
|
});
|
|
|
|
const response = await callBasicRouteWithState({
|
|
query: { profile: "openclaw" },
|
|
state,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const body = responseBodyRecord(response);
|
|
expect(body.profile).toBe("openclaw");
|
|
expect(body.pid).toBe(222);
|
|
expect(body.chosenBrowser).toBe("chromium");
|
|
expect(body.headless).toBe(true);
|
|
expect(body.headlessSource).toBe("request");
|
|
});
|
|
|
|
it("redacts CDP URL credentials from status responses", async () => {
|
|
const response = await callBasicRouteWithState({
|
|
query: { profile: "openclaw" },
|
|
state: createManagedProfileState({
|
|
cdpUrl: "http://openclaw:relay-token@127.0.0.1:18800",
|
|
}),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const body = responseBodyRecord(response);
|
|
expect(body.cdpUrl).toBe("http://127.0.0.1:18800");
|
|
});
|
|
|
|
it("maps existing-session status failures to JSON browser errors", async () => {
|
|
const response = await callBasicRouteWithState({
|
|
state: createExistingSessionProfileState({
|
|
isTransportAvailable: async () => {
|
|
throw new BrowserProfileUnavailableError("attach failed");
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(responseBodyRecord(response).error).toBe("attach failed");
|
|
});
|
|
|
|
it("reports Chrome MCP transport without fake CDP fields", async () => {
|
|
const response = await callBasicRouteWithState({
|
|
state: createExistingSessionProfileState(),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const body = responseBodyRecord(response);
|
|
expect(body.profile).toBe("chrome-live");
|
|
expect(body.driver).toBe("existing-session");
|
|
expect(body.transport).toBe("chrome-mcp");
|
|
expect(body.running).toBe(true);
|
|
expect(body.cdpPort).toBeNull();
|
|
expect(body.cdpUrl).toBeNull();
|
|
expect(body.userDataDir).toBe("/tmp/brave-profile");
|
|
expect(body.executablePath).toBe(
|
|
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
);
|
|
expect(body.pid).toBe(4321);
|
|
});
|
|
|
|
it("passes valid start headless override to local managed profiles", async () => {
|
|
const { response, ensureBrowserAvailable } = await callStartRoute({
|
|
query: { headless: "true" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body).toEqual({ ok: true, profile: "openclaw" });
|
|
expect(ensureBrowserAvailable).toHaveBeenCalledWith({ headless: true });
|
|
});
|
|
|
|
it("rejects invalid start headless values", async () => {
|
|
const { response, ensureBrowserAvailable } = await callStartRoute({
|
|
query: { headless: "maybe" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(responseBodyRecord(response).error).toBe(
|
|
'Invalid headless value. Use "true" or "false".',
|
|
);
|
|
expect(ensureBrowserAvailable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects start headless override for existing-session profiles", async () => {
|
|
const { response, ensureBrowserAvailable } = await callStartRoute({
|
|
profile: {
|
|
name: "chrome-live",
|
|
driver: "existing-session",
|
|
cdpPort: 0,
|
|
cdpUrl: "",
|
|
cdpHost: "",
|
|
cdpIsLoopback: true,
|
|
attachOnly: true,
|
|
},
|
|
query: { headless: "true" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(responseBodyRecord(response).error).toBe(
|
|
'Headless start override is only supported for locally launched openclaw profiles. Profile "chrome-live" is attach-only, remote, or existing-session.',
|
|
);
|
|
expect(ensureBrowserAvailable).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports pageReady=false when Chrome MCP transport is up but page tools are unreachable", async () => {
|
|
const response = await callBasicRouteWithState({
|
|
state: createExistingSessionProfileState({
|
|
isTransportAvailable: async () => true,
|
|
isReachable: async () => false,
|
|
}),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const body = responseBodyRecord(response);
|
|
expect(body.profile).toBe("chrome-live");
|
|
expect(body.driver).toBe("existing-session");
|
|
expect(body.transport).toBe("chrome-mcp");
|
|
expect(body.running).toBe(true);
|
|
expect(body.cdpReady).toBe(true);
|
|
expect(body.pageReady).toBe(false);
|
|
});
|
|
|
|
it("reports pageReady=false when the page-reachability probe throws", async () => {
|
|
const response = await callBasicRouteWithState({
|
|
state: createExistingSessionProfileState({
|
|
isTransportAvailable: async () => true,
|
|
isReachable: async () => {
|
|
throw new Error('Chrome MCP "list_pages" timed out after 5000ms.');
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const body = responseBodyRecord(response);
|
|
expect(body.cdpReady).toBe(true);
|
|
expect(body.pageReady).toBe(false);
|
|
});
|
|
|
|
it("reports pageReady=true when both transport and page tools succeed", async () => {
|
|
const isHttpReachable = vi.fn(async () => true);
|
|
const isTransportAvailable = vi.fn(async () => true);
|
|
const isReachable = vi.fn(async () => true);
|
|
|
|
const response = await callBasicRouteWithState({
|
|
state: createExistingSessionProfileState({
|
|
isHttpReachable,
|
|
isTransportAvailable,
|
|
isReachable,
|
|
}),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(isTransportAvailable).toHaveBeenCalledTimes(1);
|
|
expect(isTransportAvailable).toHaveBeenCalledWith(5_000);
|
|
const [timeoutMs, reachabilityOptions] = readFirstReachabilityCall(isReachable);
|
|
expect(timeoutMs).toBe(7_000);
|
|
expect(reachabilityOptions?.ephemeral).toBe(true);
|
|
expect(reachabilityOptions?.signal).toBeInstanceOf(AbortSignal);
|
|
expect(isHttpReachable).not.toHaveBeenCalled();
|
|
const body = responseBodyRecord(response);
|
|
expect(body.cdpHttp).toBe(true);
|
|
expect(body.cdpReady).toBe(true);
|
|
expect(body.pageReady).toBe(true);
|
|
expect(body.running).toBe(true);
|
|
});
|
|
|
|
it("keeps Chrome MCP page-readiness inside the status budget", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(1_000);
|
|
const isReachable = vi.fn(async () => true);
|
|
try {
|
|
const response = await callBasicRouteWithState({
|
|
state: createExistingSessionProfileState({
|
|
isTransportAvailable: async () => {
|
|
vi.setSystemTime(4_000);
|
|
return true;
|
|
},
|
|
isReachable,
|
|
}),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const [timeoutMs, reachabilityOptions] = readFirstReachabilityCall(isReachable);
|
|
expect(timeoutMs).toBe(4_000);
|
|
expect(reachabilityOptions?.ephemeral).toBe(true);
|
|
expect(reachabilityOptions?.signal).toBeInstanceOf(AbortSignal);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("page-readiness probe runs in ephemeral mode so status does not seed a cached session", async () => {
|
|
const isReachable = vi.fn<
|
|
(
|
|
timeoutMs?: number,
|
|
options?: { ephemeral?: boolean; signal?: AbortSignal },
|
|
) => Promise<boolean>
|
|
>(async () => true);
|
|
|
|
await callBasicRouteWithState({
|
|
state: createExistingSessionProfileState({
|
|
isTransportAvailable: async () => true,
|
|
isReachable,
|
|
}),
|
|
});
|
|
|
|
expect(isReachable).toHaveBeenCalledTimes(1);
|
|
const [, reachabilityOptions] = readFirstReachabilityCall(isReachable);
|
|
expect(reachabilityOptions?.ephemeral).toBe(true);
|
|
expect(reachabilityOptions?.signal).toBeInstanceOf(AbortSignal);
|
|
});
|
|
|
|
it("skips the page-reachability probe when transport is unavailable", async () => {
|
|
const isReachable = vi.fn(async () => true);
|
|
|
|
const response = await callBasicRouteWithState({
|
|
state: createExistingSessionProfileState({
|
|
isTransportAvailable: async () => false,
|
|
isReachable,
|
|
}),
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(isReachable).not.toHaveBeenCalled();
|
|
const body = responseBodyRecord(response);
|
|
expect(body.cdpReady).toBe(false);
|
|
expect(body.pageReady).toBe(false);
|
|
expect(body.running).toBe(false);
|
|
});
|
|
});
|