mirror of https://github.com/openclaw/openclaw.git
feat(browser): add headless existing-session MCP support esp for Linux/Docker/VPS (#45769)
* fix(browser): prefer managed default profile in headless mode * test(browser): cover headless default profile fallback * feat(browser): support headless MCP profile resolution * feat(browser): add headless and target-url Chrome MCP modes * feat(browser): allow MCP target URLs in profile creation * docs(browser): document headless MCP existing-session flows * fix(browser): restore playwright browser act helpers * fix(browser): preserve strict selector actions * docs(changelog): add existing-session MCP note
This commit is contained in:
parent
92834c8440
commit
173fe3cb54
|
|
@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
|
||||||
|
|
||||||
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
||||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
||||||
|
- Browser/existing-session: add headless Chrome DevTools MCP support for Linux, Docker, and VPS setups, including explicit browser URL and WebSocket endpoint attach modes for `existing-session`. Thanks @vincentkoc.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,48 @@ curl -s -X POST http://127.0.0.1:18791/start
|
||||||
curl -s http://127.0.0.1:18791/tabs
|
curl -s http://127.0.0.1:18791/tabs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Existing-session MCP on Linux / VPS
|
||||||
|
|
||||||
|
If you want Chrome DevTools MCP instead of the managed `openclaw` CDP profile,
|
||||||
|
you now have two Linux-safe options:
|
||||||
|
|
||||||
|
1. Let MCP launch headless Chrome for an `existing-session` profile:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"browser": {
|
||||||
|
"headless": true,
|
||||||
|
"noSandbox": true,
|
||||||
|
"executablePath": "/usr/bin/google-chrome-stable",
|
||||||
|
"defaultProfile": "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Attach MCP to a running debuggable Chrome instance:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"browser": {
|
||||||
|
"headless": true,
|
||||||
|
"defaultProfile": "user",
|
||||||
|
"profiles": {
|
||||||
|
"user": {
|
||||||
|
"driver": "existing-session",
|
||||||
|
"cdpUrl": "http://127.0.0.1:9222",
|
||||||
|
"color": "#00AA00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `driver: "existing-session"` still uses Chrome MCP transport, not the extension relay.
|
||||||
|
- `cdpUrl` on an `existing-session` profile is interpreted as the MCP browser target (`browserUrl` or `wsEndpoint`), not the normal OpenClaw CDP driver.
|
||||||
|
- If you omit `cdpUrl`, headless MCP launches Chrome itself.
|
||||||
|
|
||||||
### Config Reference
|
### Config Reference
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
|
|
|
||||||
|
|
@ -359,9 +359,13 @@ Notes:
|
||||||
|
|
||||||
## Chrome existing-session via MCP
|
## Chrome existing-session via MCP
|
||||||
|
|
||||||
OpenClaw can also attach to a running Chrome profile through the official
|
OpenClaw can also use the official Chrome DevTools MCP server for two different
|
||||||
Chrome DevTools MCP server. This reuses the tabs and login state already open in
|
flows:
|
||||||
that Chrome profile.
|
|
||||||
|
- desktop attach via `--autoConnect`, which reuses a running Chrome profile and
|
||||||
|
its existing tabs/login state
|
||||||
|
- headless or remote attach, where MCP either launches headless Chrome itself
|
||||||
|
or connects to a running debuggable browser URL/WS endpoint
|
||||||
|
|
||||||
Official background and setup references:
|
Official background and setup references:
|
||||||
|
|
||||||
|
|
@ -375,7 +379,7 @@ Built-in profile:
|
||||||
Optional: create your own custom existing-session profile if you want a
|
Optional: create your own custom existing-session profile if you want a
|
||||||
different name or color.
|
different name or color.
|
||||||
|
|
||||||
Then in Chrome:
|
Desktop attach flow:
|
||||||
|
|
||||||
1. Open `chrome://inspect/#remote-debugging`
|
1. Open `chrome://inspect/#remote-debugging`
|
||||||
2. Enable remote debugging
|
2. Enable remote debugging
|
||||||
|
|
@ -398,30 +402,66 @@ What success looks like:
|
||||||
- `tabs` lists your already-open Chrome tabs
|
- `tabs` lists your already-open Chrome tabs
|
||||||
- `snapshot` returns refs from the selected live tab
|
- `snapshot` returns refs from the selected live tab
|
||||||
|
|
||||||
What to check if attach does not work:
|
What to check if desktop attach does not work:
|
||||||
|
|
||||||
- Chrome is version `144+`
|
- Chrome is version `144+`
|
||||||
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
|
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
|
||||||
- Chrome showed and you accepted the attach consent prompt
|
- Chrome showed and you accepted the attach consent prompt
|
||||||
|
|
||||||
|
Headless / Linux / VPS flow:
|
||||||
|
|
||||||
|
- Set `browser.headless: true`
|
||||||
|
- Set `browser.noSandbox: true` when running as root or in common container/VPS setups
|
||||||
|
- Optional: set `browser.executablePath` to a stable Chrome/Chromium binary path
|
||||||
|
- Optional: set `browser.profiles.<name>.cdpUrl` on an `existing-session` profile to an
|
||||||
|
MCP target like `http://127.0.0.1:9222` or
|
||||||
|
`ws://127.0.0.1:9222/devtools/browser/<id>`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
browser: {
|
||||||
|
headless: true,
|
||||||
|
noSandbox: true,
|
||||||
|
executablePath: "/usr/bin/google-chrome-stable",
|
||||||
|
defaultProfile: "user",
|
||||||
|
profiles: {
|
||||||
|
user: {
|
||||||
|
driver: "existing-session",
|
||||||
|
cdpUrl: "http://127.0.0.1:9222",
|
||||||
|
color: "#00AA00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- without `browser.profiles.<name>.cdpUrl`, headless `existing-session` launches Chrome through MCP
|
||||||
|
- with `browser.profiles.<name>.cdpUrl`, MCP connects to that running browser URL
|
||||||
|
- non-headless `existing-session` keeps using the interactive `--autoConnect` flow
|
||||||
|
|
||||||
Agent use:
|
Agent use:
|
||||||
|
|
||||||
- Use `profile="user"` when you need the user’s logged-in browser state.
|
- Use `profile="user"` when you need the user’s logged-in browser state.
|
||||||
- If you use a custom existing-session profile, pass that explicit profile name.
|
- If you use a custom existing-session profile, pass that explicit profile name.
|
||||||
- Prefer `profile="user"` over `profile="chrome-relay"` unless the user
|
- Prefer `profile="user"` over `profile="chrome-relay"` unless the user
|
||||||
explicitly wants the extension / attach-tab flow.
|
explicitly wants the extension / attach-tab flow.
|
||||||
- Only choose this mode when the user is at the computer to approve the attach
|
- On desktop `--autoConnect`, only choose this mode when the user is at the
|
||||||
prompt.
|
computer to approve the attach prompt.
|
||||||
- the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
|
- The Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
|
||||||
|
for desktop attach, or use MCP headless/browserUrl/wsEndpoint modes for Linux/VPS paths.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- This path is higher-risk than the isolated `openclaw` profile because it can
|
- This path is higher-risk than the isolated `openclaw` profile because it can
|
||||||
act inside your signed-in browser session.
|
act inside your signed-in browser session.
|
||||||
- OpenClaw does not launch Chrome for this driver; it attaches to an existing
|
- OpenClaw uses the official Chrome DevTools MCP server for this driver.
|
||||||
session only.
|
- On desktop, OpenClaw uses MCP `--autoConnect`.
|
||||||
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not
|
- In headless mode, OpenClaw can launch Chrome through MCP or connect MCP to a
|
||||||
the legacy default-profile remote debugging port workflow.
|
configured browser URL/WS endpoint.
|
||||||
- Existing-session screenshots support page captures and `--ref` element
|
- Existing-session screenshots support page captures and `--ref` element
|
||||||
captures from snapshots, but not CSS `--element` selectors.
|
captures from snapshots, but not CSS `--element` selectors.
|
||||||
- Existing-session `wait --url` supports exact, substring, and glob patterns
|
- Existing-session `wait --url` supports exact, substring, and glob patterns
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
buildChromeMcpLaunchPlanForTest,
|
||||||
evaluateChromeMcpScript,
|
evaluateChromeMcpScript,
|
||||||
listChromeMcpTabs,
|
listChromeMcpTabs,
|
||||||
openChromeMcpTab,
|
openChromeMcpTab,
|
||||||
|
|
@ -7,6 +9,10 @@ import {
|
||||||
setChromeMcpSessionFactoryForTest,
|
setChromeMcpSessionFactoryForTest,
|
||||||
} from "./chrome-mcp.js";
|
} from "./chrome-mcp.js";
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", () => ({
|
||||||
|
loadConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
type ToolCall = {
|
type ToolCall = {
|
||||||
name: string;
|
name: string;
|
||||||
arguments?: Record<string, unknown>;
|
arguments?: Record<string, unknown>;
|
||||||
|
|
@ -79,6 +85,99 @@ function createFakeSession(): ChromeMcpSession {
|
||||||
describe("chrome MCP page parsing", () => {
|
describe("chrome MCP page parsing", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetChromeMcpSessionsForTest();
|
await resetChromeMcpSessionsForTest();
|
||||||
|
vi.mocked(loadConfig).mockReturnValue({
|
||||||
|
browser: {
|
||||||
|
profiles: {
|
||||||
|
"chrome-live": {
|
||||||
|
driver: "existing-session",
|
||||||
|
attachOnly: true,
|
||||||
|
color: "#00AA00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses autoConnect for desktop existing-session profiles", () => {
|
||||||
|
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
|
||||||
|
expect(plan.mode).toBe("autoConnect");
|
||||||
|
expect(plan.args).toContain("--autoConnect");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses headless launch flags for headless existing-session profiles", () => {
|
||||||
|
vi.mocked(loadConfig).mockReturnValue({
|
||||||
|
browser: {
|
||||||
|
headless: true,
|
||||||
|
noSandbox: true,
|
||||||
|
executablePath: "/usr/bin/google-chrome-stable",
|
||||||
|
extraArgs: ["--disable-dev-shm-usage"],
|
||||||
|
profiles: {
|
||||||
|
"chrome-live": {
|
||||||
|
driver: "existing-session",
|
||||||
|
attachOnly: true,
|
||||||
|
color: "#00AA00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
|
||||||
|
expect(plan.mode).toBe("headless");
|
||||||
|
expect(plan.args).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"--headless",
|
||||||
|
"--userDataDir",
|
||||||
|
expect.stringContaining("/browser/chrome-live/user-data"),
|
||||||
|
"--executablePath",
|
||||||
|
"/usr/bin/google-chrome-stable",
|
||||||
|
"--chromeArg",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--chromeArg",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--chromeArg",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses browserUrl for MCP profiles configured with an HTTP target", () => {
|
||||||
|
vi.mocked(loadConfig).mockReturnValue({
|
||||||
|
browser: {
|
||||||
|
profiles: {
|
||||||
|
"chrome-live": {
|
||||||
|
driver: "existing-session",
|
||||||
|
attachOnly: true,
|
||||||
|
cdpUrl: "http://127.0.0.1:9222",
|
||||||
|
color: "#00AA00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
|
||||||
|
expect(plan.mode).toBe("browserUrl");
|
||||||
|
expect(plan.args).toEqual(expect.arrayContaining(["--browserUrl", "http://127.0.0.1:9222"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses wsEndpoint for MCP profiles configured with a WebSocket target", () => {
|
||||||
|
vi.mocked(loadConfig).mockReturnValue({
|
||||||
|
browser: {
|
||||||
|
profiles: {
|
||||||
|
"chrome-live": {
|
||||||
|
driver: "existing-session",
|
||||||
|
attachOnly: true,
|
||||||
|
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc",
|
||||||
|
color: "#00AA00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
|
||||||
|
expect(plan.mode).toBe("wsEndpoint");
|
||||||
|
expect(plan.args).toEqual(
|
||||||
|
expect.arrayContaining(["--wsEndpoint", "ws://127.0.0.1:9222/devtools/browser/abc"]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses list_pages text responses when structuredContent is missing", async () => {
|
it("parses list_pages text responses when structuredContent is missing", async () => {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@ import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
|
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
|
||||||
|
import { resolveOpenClawUserDataDir } from "./chrome.js";
|
||||||
import type { BrowserTab } from "./client.js";
|
import type { BrowserTab } from "./client.js";
|
||||||
|
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||||
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
|
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
|
||||||
|
|
||||||
type ChromeMcpStructuredPage = {
|
type ChromeMcpStructuredPage = {
|
||||||
|
|
@ -32,7 +35,6 @@ const DEFAULT_CHROME_MCP_COMMAND = "npx";
|
||||||
const DEFAULT_CHROME_MCP_ARGS = [
|
const DEFAULT_CHROME_MCP_ARGS = [
|
||||||
"-y",
|
"-y",
|
||||||
"chrome-devtools-mcp@latest",
|
"chrome-devtools-mcp@latest",
|
||||||
"--autoConnect",
|
|
||||||
// Direct chrome-devtools-mcp launches do not enable structuredContent by default.
|
// Direct chrome-devtools-mcp launches do not enable structuredContent by default.
|
||||||
"--experimentalStructuredContent",
|
"--experimentalStructuredContent",
|
||||||
"--experimental-page-id-routing",
|
"--experimental-page-id-routing",
|
||||||
|
|
@ -42,6 +44,51 @@ const sessions = new Map<string, ChromeMcpSession>();
|
||||||
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
|
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
|
||||||
let sessionFactory: ChromeMcpSessionFactory | null = null;
|
let sessionFactory: ChromeMcpSessionFactory | null = null;
|
||||||
|
|
||||||
|
type ChromeMcpLaunchPlan = {
|
||||||
|
args: string[];
|
||||||
|
mode: "autoConnect" | "browserUrl" | "wsEndpoint" | "headless";
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildChromeMcpLaunchPlan(profileName: string): ChromeMcpLaunchPlan {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||||
|
const profile = resolveProfile(resolved, profileName);
|
||||||
|
if (!profile || profile.driver !== "existing-session") {
|
||||||
|
throw new BrowserProfileUnavailableError(
|
||||||
|
`Chrome MCP profile "${profileName}" is missing or is not driver=existing-session.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [...DEFAULT_CHROME_MCP_ARGS];
|
||||||
|
if (profile.mcpTargetUrl) {
|
||||||
|
const parsed = new URL(profile.mcpTargetUrl);
|
||||||
|
if (parsed.protocol === "ws:" || parsed.protocol === "wss:") {
|
||||||
|
args.push("--wsEndpoint", profile.mcpTargetUrl);
|
||||||
|
return { args, mode: "wsEndpoint" };
|
||||||
|
}
|
||||||
|
args.push("--browserUrl", profile.mcpTargetUrl);
|
||||||
|
return { args, mode: "browserUrl" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved.headless) {
|
||||||
|
args.push("--autoConnect");
|
||||||
|
return { args, mode: "autoConnect" };
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push("--headless");
|
||||||
|
args.push("--userDataDir", resolveOpenClawUserDataDir(profile.name));
|
||||||
|
if (resolved.executablePath) {
|
||||||
|
args.push("--executablePath", resolved.executablePath);
|
||||||
|
}
|
||||||
|
if (resolved.noSandbox) {
|
||||||
|
args.push("--chromeArg", "--no-sandbox", "--chromeArg", "--disable-setuid-sandbox");
|
||||||
|
}
|
||||||
|
for (const arg of resolved.extraArgs) {
|
||||||
|
args.push("--chromeArg", arg);
|
||||||
|
}
|
||||||
|
return { args, mode: "headless" };
|
||||||
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
|
|
@ -169,9 +216,10 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createRealSession(profileName: string): Promise<ChromeMcpSession> {
|
async function createRealSession(profileName: string): Promise<ChromeMcpSession> {
|
||||||
|
const launchPlan = buildChromeMcpLaunchPlan(profileName);
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: DEFAULT_CHROME_MCP_COMMAND,
|
command: DEFAULT_CHROME_MCP_COMMAND,
|
||||||
args: DEFAULT_CHROME_MCP_ARGS,
|
args: launchPlan.args,
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
});
|
});
|
||||||
const client = new Client(
|
const client = new Client(
|
||||||
|
|
@ -191,9 +239,15 @@ async function createRealSession(profileName: string): Promise<ChromeMcpSession>
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.close().catch(() => {});
|
await client.close().catch(() => {});
|
||||||
|
const hint =
|
||||||
|
launchPlan.mode === "autoConnect"
|
||||||
|
? "Make sure Chrome is running, enable chrome://inspect/#remote-debugging, and approve the connection."
|
||||||
|
: launchPlan.mode === "browserUrl" || launchPlan.mode === "wsEndpoint"
|
||||||
|
? "Make sure the configured browserUrl/wsEndpoint is reachable and Chrome is running with remote debugging enabled."
|
||||||
|
: "Make sure a Chrome executable is available, and use browser.noSandbox=true on Linux containers/root setups when needed.";
|
||||||
throw new BrowserProfileUnavailableError(
|
throw new BrowserProfileUnavailableError(
|
||||||
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
|
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
|
||||||
`Make sure Chrome is running, enable chrome://inspect/#remote-debugging, and approve the connection. ` +
|
`${hint} ` +
|
||||||
`Details: ${String(err)}`,
|
`Details: ${String(err)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -531,6 +585,10 @@ export async function waitForChromeMcpText(params: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildChromeMcpLaunchPlanForTest(profileName: string): ChromeMcpLaunchPlan {
|
||||||
|
return buildChromeMcpLaunchPlan(profileName);
|
||||||
|
}
|
||||||
|
|
||||||
export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void {
|
export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void {
|
||||||
sessionFactory = factory;
|
sessionFactory = factory;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ describe("browser config", () => {
|
||||||
expect(user?.driver).toBe("existing-session");
|
expect(user?.driver).toBe("existing-session");
|
||||||
expect(user?.cdpPort).toBe(0);
|
expect(user?.cdpPort).toBe(0);
|
||||||
expect(user?.cdpUrl).toBe("");
|
expect(user?.cdpUrl).toBe("");
|
||||||
|
expect(user?.mcpTargetUrl).toBeUndefined();
|
||||||
const chromeRelay = resolveProfile(resolved, "chrome-relay");
|
const chromeRelay = resolveProfile(resolved, "chrome-relay");
|
||||||
expect(chromeRelay?.driver).toBe("extension");
|
expect(chromeRelay?.driver).toBe("extension");
|
||||||
expect(chromeRelay?.cdpPort).toBe(18792);
|
expect(chromeRelay?.cdpPort).toBe(18792);
|
||||||
|
|
@ -121,6 +122,24 @@ describe("browser config", () => {
|
||||||
expect(profile?.cdpIsLoopback).toBe(false);
|
expect(profile?.cdpIsLoopback).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports MCP browser URLs for existing-session profiles", () => {
|
||||||
|
const resolved = resolveBrowserConfig({
|
||||||
|
profiles: {
|
||||||
|
user: {
|
||||||
|
driver: "existing-session",
|
||||||
|
cdpUrl: "http://127.0.0.1:9222",
|
||||||
|
color: "#00AA00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = resolveProfile(resolved, "user");
|
||||||
|
expect(profile?.driver).toBe("existing-session");
|
||||||
|
expect(profile?.cdpUrl).toBe("");
|
||||||
|
expect(profile?.mcpTargetUrl).toBe("http://127.0.0.1:9222");
|
||||||
|
expect(profile?.cdpIsLoopback).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses profile cdpUrl when provided", () => {
|
it("uses profile cdpUrl when provided", () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
profiles: {
|
profiles: {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export type ResolvedBrowserProfile = {
|
||||||
cdpUrl: string;
|
cdpUrl: string;
|
||||||
cdpHost: string;
|
cdpHost: string;
|
||||||
cdpIsLoopback: boolean;
|
cdpIsLoopback: boolean;
|
||||||
|
mcpTargetUrl?: string;
|
||||||
color: string;
|
color: string;
|
||||||
driver: "openclaw" | "extension" | "existing-session";
|
driver: "openclaw" | "extension" | "existing-session";
|
||||||
attachOnly: boolean;
|
attachOnly: boolean;
|
||||||
|
|
@ -363,13 +364,18 @@ export function resolveProfile(
|
||||||
: "openclaw";
|
: "openclaw";
|
||||||
|
|
||||||
if (driver === "existing-session") {
|
if (driver === "existing-session") {
|
||||||
// existing-session uses Chrome MCP auto-connect; no CDP port/URL needed
|
const parsed = rawProfileUrl
|
||||||
|
? parseHttpUrl(rawProfileUrl, `browser.profiles.${profileName}.cdpUrl`)
|
||||||
|
: null;
|
||||||
|
// existing-session uses Chrome MCP. It can either auto-connect to a local desktop
|
||||||
|
// session or connect to a debuggable browser URL/WS endpoint when explicitly configured.
|
||||||
return {
|
return {
|
||||||
name: profileName,
|
name: profileName,
|
||||||
cdpPort: 0,
|
cdpPort: 0,
|
||||||
cdpUrl: "",
|
cdpUrl: "",
|
||||||
cdpHost: "",
|
cdpHost: parsed?.parsed.hostname ?? "",
|
||||||
cdpIsLoopback: true,
|
cdpIsLoopback: parsed ? isLoopbackHost(parsed.parsed.hostname) : true,
|
||||||
|
...(parsed ? { mcpTargetUrl: parsed.normalized } : {}),
|
||||||
color: profile.color,
|
color: profile.color,
|
||||||
driver,
|
driver,
|
||||||
attachOnly: true,
|
attachOnly: true,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function getBrowserProfileCapabilities(
|
||||||
if (profile.driver === "existing-session") {
|
if (profile.driver === "existing-session") {
|
||||||
return {
|
return {
|
||||||
mode: "local-existing-session",
|
mode: "local-existing-session",
|
||||||
isRemote: false,
|
isRemote: !profile.cdpIsLoopback,
|
||||||
usesChromeMcp: true,
|
usesChromeMcp: true,
|
||||||
requiresRelay: false,
|
requiresRelay: false,
|
||||||
requiresAttachedTab: false,
|
requiresAttachedTab: false,
|
||||||
|
|
|
||||||
|
|
@ -201,20 +201,27 @@ describe("BrowserProfilesService", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects driver=existing-session when cdpUrl is provided", async () => {
|
it("allows driver=existing-session when cdpUrl is provided as an MCP target", async () => {
|
||||||
const resolved = resolveBrowserConfig({});
|
const resolved = resolveBrowserConfig({});
|
||||||
const { ctx } = createCtx(resolved);
|
const { ctx, state } = createCtx(resolved);
|
||||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||||
|
|
||||||
const service = createBrowserProfilesService(ctx);
|
const service = createBrowserProfilesService(ctx);
|
||||||
|
const result = await service.createProfile({
|
||||||
|
name: "chrome-live",
|
||||||
|
driver: "existing-session",
|
||||||
|
cdpUrl: "http://127.0.0.1:9222",
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
expect(result.transport).toBe("chrome-mcp");
|
||||||
service.createProfile({
|
expect(result.cdpUrl).toBeNull();
|
||||||
name: "chrome-live",
|
expect(result.isRemote).toBe(false);
|
||||||
driver: "existing-session",
|
expect(state.resolved.profiles["chrome-live"]).toEqual({
|
||||||
cdpUrl: "http://127.0.0.1:9222",
|
cdpUrl: "http://127.0.0.1:9222",
|
||||||
}),
|
driver: "existing-session",
|
||||||
).rejects.toThrow(/does not accept cdpUrl/i);
|
attachOnly: true,
|
||||||
|
color: expect.any(String),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes remote profiles without stopping or removing local data", async () => {
|
it("deletes remote profiles without stopping or removing local data", async () => {
|
||||||
|
|
|
||||||
|
|
@ -130,15 +130,19 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (driver === "existing-session") {
|
if (driver === "existing-session") {
|
||||||
throw new BrowserValidationError(
|
profileConfig = {
|
||||||
"driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow",
|
cdpUrl: parsed.normalized,
|
||||||
);
|
driver,
|
||||||
|
attachOnly: true,
|
||||||
|
color: profileColor,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
profileConfig = {
|
||||||
|
cdpUrl: parsed.normalized,
|
||||||
|
...(driver ? { driver } : {}),
|
||||||
|
color: profileColor,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
profileConfig = {
|
|
||||||
cdpUrl: parsed.normalized,
|
|
||||||
...(driver ? { driver } : {}),
|
|
||||||
color: profileColor,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
if (driver === "extension") {
|
if (driver === "extension") {
|
||||||
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");
|
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
installPwToolsCoreTestHooks,
|
installPwToolsCoreTestHooks,
|
||||||
|
getPwToolsCoreSessionMocks,
|
||||||
setPwToolsCoreCurrentPage,
|
setPwToolsCoreCurrentPage,
|
||||||
setPwToolsCoreCurrentRefLocator,
|
setPwToolsCoreCurrentRefLocator,
|
||||||
} from "./pw-tools-core.test-harness.js";
|
} from "./pw-tools-core.test-harness.js";
|
||||||
|
|
@ -92,4 +93,24 @@ describe("pw-tools-core", () => {
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(/not interactable/i);
|
).rejects.toThrow(/not interactable/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Playwright strictness for selector-based actions", async () => {
|
||||||
|
const click = vi.fn(async () => {});
|
||||||
|
const first = vi.fn(() => {
|
||||||
|
throw new Error("selector actions should not call locator.first()");
|
||||||
|
});
|
||||||
|
const locator = vi.fn(() => ({ click, first }));
|
||||||
|
setPwToolsCoreCurrentPage({ locator });
|
||||||
|
|
||||||
|
await mod.clickViaPlaywright({
|
||||||
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
|
targetId: "T1",
|
||||||
|
selector: "button.submit",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(locator).toHaveBeenCalledWith("button.submit");
|
||||||
|
expect(first).not.toHaveBeenCalled();
|
||||||
|
expect(getPwToolsCoreSessionMocks().refLocator).not.toHaveBeenCalled();
|
||||||
|
expect(click).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,24 @@ async function getRestoredPageForTarget(opts: TargetOpts) {
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLocatorForInteraction(
|
||||||
|
page: Awaited<ReturnType<typeof getRestoredPageForTarget>>,
|
||||||
|
params: { ref?: string; selector?: string },
|
||||||
|
) {
|
||||||
|
const resolved = requireRefOrSelector(params.ref, params.selector);
|
||||||
|
if (resolved.ref) {
|
||||||
|
return {
|
||||||
|
locator: refLocator(page, resolved.ref),
|
||||||
|
label: resolved.ref,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const selector = resolved.selector!;
|
||||||
|
return {
|
||||||
|
locator: page.locator(selector),
|
||||||
|
label: selector,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveInteractionTimeoutMs(timeoutMs?: number): number {
|
function resolveInteractionTimeoutMs(timeoutMs?: number): number {
|
||||||
return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000)));
|
return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000)));
|
||||||
}
|
}
|
||||||
|
|
@ -88,12 +106,8 @@ export async function clickViaPlaywright(opts: {
|
||||||
delayMs?: number;
|
delayMs?: number;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
||||||
const page = await getRestoredPageForTarget(opts);
|
const page = await getRestoredPageForTarget(opts);
|
||||||
const label = resolved.ref ?? resolved.selector!;
|
const { locator, label } = resolveLocatorForInteraction(page, opts);
|
||||||
const locator = resolved.ref
|
|
||||||
? refLocator(page, requireRef(resolved.ref))
|
|
||||||
: page.locator(resolved.selector!);
|
|
||||||
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
||||||
try {
|
try {
|
||||||
const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
|
const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
|
||||||
|
|
@ -106,12 +120,14 @@ export async function clickViaPlaywright(opts: {
|
||||||
timeout,
|
timeout,
|
||||||
button: opts.button,
|
button: opts.button,
|
||||||
modifiers: opts.modifiers,
|
modifiers: opts.modifiers,
|
||||||
|
delay: opts.delayMs,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await locator.click({
|
await locator.click({
|
||||||
timeout,
|
timeout,
|
||||||
button: opts.button,
|
button: opts.button,
|
||||||
modifiers: opts.modifiers,
|
modifiers: opts.modifiers,
|
||||||
|
delay: opts.delayMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -126,12 +142,8 @@ export async function hoverViaPlaywright(opts: {
|
||||||
selector?: string;
|
selector?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
||||||
const page = await getRestoredPageForTarget(opts);
|
const page = await getRestoredPageForTarget(opts);
|
||||||
const label = resolved.ref ?? resolved.selector!;
|
const { locator, label } = resolveLocatorForInteraction(page, opts);
|
||||||
const locator = resolved.ref
|
|
||||||
? refLocator(page, requireRef(resolved.ref))
|
|
||||||
: page.locator(resolved.selector!);
|
|
||||||
try {
|
try {
|
||||||
await locator.hover({
|
await locator.hover({
|
||||||
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
||||||
|
|
@ -150,23 +162,21 @@ export async function dragViaPlaywright(opts: {
|
||||||
endSelector?: string;
|
endSelector?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector);
|
|
||||||
const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector);
|
|
||||||
const page = await getRestoredPageForTarget(opts);
|
const page = await getRestoredPageForTarget(opts);
|
||||||
const startLocator = resolvedStart.ref
|
const from = resolveLocatorForInteraction(page, {
|
||||||
? refLocator(page, requireRef(resolvedStart.ref))
|
ref: opts.startRef,
|
||||||
: page.locator(resolvedStart.selector!);
|
selector: opts.startSelector,
|
||||||
const endLocator = resolvedEnd.ref
|
});
|
||||||
? refLocator(page, requireRef(resolvedEnd.ref))
|
const to = resolveLocatorForInteraction(page, {
|
||||||
: page.locator(resolvedEnd.selector!);
|
ref: opts.endRef,
|
||||||
const startLabel = resolvedStart.ref ?? resolvedStart.selector!;
|
selector: opts.endSelector,
|
||||||
const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!;
|
});
|
||||||
try {
|
try {
|
||||||
await startLocator.dragTo(endLocator, {
|
await from.locator.dragTo(to.locator, {
|
||||||
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`);
|
throw toAIFriendlyError(err, `${from.label} -> ${to.label}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,15 +188,11 @@ export async function selectOptionViaPlaywright(opts: {
|
||||||
values: string[];
|
values: string[];
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
||||||
if (!opts.values?.length) {
|
if (!opts.values?.length) {
|
||||||
throw new Error("values are required");
|
throw new Error("values are required");
|
||||||
}
|
}
|
||||||
const page = await getRestoredPageForTarget(opts);
|
const page = await getRestoredPageForTarget(opts);
|
||||||
const label = resolved.ref ?? resolved.selector!;
|
const { locator, label } = resolveLocatorForInteraction(page, opts);
|
||||||
const locator = resolved.ref
|
|
||||||
? refLocator(page, requireRef(resolved.ref))
|
|
||||||
: page.locator(resolved.selector!);
|
|
||||||
try {
|
try {
|
||||||
await locator.selectOption(opts.values, {
|
await locator.selectOption(opts.values, {
|
||||||
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
||||||
|
|
@ -223,13 +229,9 @@ export async function typeViaPlaywright(opts: {
|
||||||
slowly?: boolean;
|
slowly?: boolean;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
||||||
const text = String(opts.text ?? "");
|
const text = String(opts.text ?? "");
|
||||||
const page = await getRestoredPageForTarget(opts);
|
const page = await getRestoredPageForTarget(opts);
|
||||||
const label = resolved.ref ?? resolved.selector!;
|
const { locator, label } = resolveLocatorForInteraction(page, opts);
|
||||||
const locator = resolved.ref
|
|
||||||
? refLocator(page, requireRef(resolved.ref))
|
|
||||||
: page.locator(resolved.selector!);
|
|
||||||
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
||||||
try {
|
try {
|
||||||
if (opts.slowly) {
|
if (opts.slowly) {
|
||||||
|
|
@ -423,14 +425,9 @@ export async function scrollIntoViewViaPlaywright(opts: {
|
||||||
selector?: string;
|
selector?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
||||||
const page = await getRestoredPageForTarget(opts);
|
const page = await getRestoredPageForTarget(opts);
|
||||||
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
|
const timeout = normalizeTimeoutMs(opts.timeoutMs, 20_000);
|
||||||
|
const { locator, label } = resolveLocatorForInteraction(page, opts);
|
||||||
const label = resolved.ref ?? resolved.selector!;
|
|
||||||
const locator = resolved.ref
|
|
||||||
? refLocator(page, requireRef(resolved.ref))
|
|
||||||
: page.locator(resolved.selector!);
|
|
||||||
try {
|
try {
|
||||||
await locator.scrollIntoViewIfNeeded({ timeout });
|
await locator.scrollIntoViewIfNeeded({ timeout });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ function changedProfileInvariants(
|
||||||
next: ResolvedBrowserProfile,
|
next: ResolvedBrowserProfile,
|
||||||
): string[] {
|
): string[] {
|
||||||
const changed: string[] = [];
|
const changed: string[] = [];
|
||||||
|
if (current.mcpTargetUrl !== next.mcpTargetUrl) {
|
||||||
|
changed.push("mcpTargetUrl");
|
||||||
|
}
|
||||||
if (current.cdpUrl !== next.cdpUrl) {
|
if (current.cdpUrl !== next.cdpUrl) {
|
||||||
changed.push("cdpUrl");
|
changed.push("cdpUrl");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createBrowserRouteContext } from "./server-context.js";
|
||||||
|
import type { BrowserServerState } from "./server-context.js";
|
||||||
|
|
||||||
|
function makeState(defaultProfile: string): BrowserServerState {
|
||||||
|
return {
|
||||||
|
server: null,
|
||||||
|
port: 0,
|
||||||
|
resolved: {
|
||||||
|
enabled: true,
|
||||||
|
evaluateEnabled: true,
|
||||||
|
controlPort: 18791,
|
||||||
|
cdpPortRangeStart: 18800,
|
||||||
|
cdpPortRangeEnd: 18899,
|
||||||
|
cdpProtocol: "http",
|
||||||
|
cdpHost: "127.0.0.1",
|
||||||
|
cdpIsLoopback: true,
|
||||||
|
remoteCdpTimeoutMs: 1500,
|
||||||
|
remoteCdpHandshakeTimeoutMs: 3000,
|
||||||
|
color: "#FF4500",
|
||||||
|
headless: true,
|
||||||
|
noSandbox: true,
|
||||||
|
attachOnly: false,
|
||||||
|
defaultProfile,
|
||||||
|
profiles: {
|
||||||
|
openclaw: {
|
||||||
|
cdpPort: 18800,
|
||||||
|
color: "#FF4500",
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
driver: "existing-session",
|
||||||
|
attachOnly: true,
|
||||||
|
color: "#00AA00",
|
||||||
|
},
|
||||||
|
"chrome-relay": {
|
||||||
|
driver: "extension",
|
||||||
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
|
color: "#00AA00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraArgs: [],
|
||||||
|
ssrfPolicy: { dangerouslyAllowPrivateNetwork: true },
|
||||||
|
},
|
||||||
|
profiles: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("browser server-context headless implicit default profile", () => {
|
||||||
|
it("falls back from extension relay to openclaw when no profile is specified", () => {
|
||||||
|
const ctx = createBrowserRouteContext({
|
||||||
|
getState: () => makeState("chrome-relay"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.forProfile().profile.name).toBe("openclaw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps existing-session as the implicit default in headless mode", () => {
|
||||||
|
const ctx = createBrowserRouteContext({
|
||||||
|
getState: () => makeState("user"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.forProfile().profile.name).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit interactive profile requests unchanged in headless mode", () => {
|
||||||
|
const ctx = createBrowserRouteContext({
|
||||||
|
getState: () => makeState("chrome-relay"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.forProfile("chrome-relay").profile.name).toBe("chrome-relay");
|
||||||
|
expect(ctx.forProfile("user").profile.name).toBe("user");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,7 +6,16 @@ import {
|
||||||
} from "./resolved-config-refresh.js";
|
} from "./resolved-config-refresh.js";
|
||||||
import type { BrowserServerState } from "./server-context.types.js";
|
import type { BrowserServerState } from "./server-context.types.js";
|
||||||
|
|
||||||
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
|
let cfgProfiles: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
cdpPort?: number;
|
||||||
|
cdpUrl?: string;
|
||||||
|
color?: string;
|
||||||
|
driver?: "openclaw" | "existing-session";
|
||||||
|
attachOnly?: boolean;
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
// Simulate module-level cache behavior
|
// Simulate module-level cache behavior
|
||||||
let cachedConfig: ReturnType<typeof buildConfig> | null = null;
|
let cachedConfig: ReturnType<typeof buildConfig> | null = null;
|
||||||
|
|
@ -206,4 +215,59 @@ describe("server-context hot-reload profiles", () => {
|
||||||
expect(runtime?.lastTargetId).toBeNull();
|
expect(runtime?.lastTargetId).toBeNull();
|
||||||
expect(runtime?.reconcile?.reason).toContain("cdpPort");
|
expect(runtime?.reconcile?.reason).toContain("cdpPort");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks existing-session runtime state for reconcile when MCP target URL changes", async () => {
|
||||||
|
cfgProfiles = {
|
||||||
|
user: {
|
||||||
|
cdpUrl: "http://127.0.0.1:9222",
|
||||||
|
color: "#00AA00",
|
||||||
|
driver: "existing-session",
|
||||||
|
attachOnly: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
cachedConfig = null;
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const resolved = resolveBrowserConfig({ ...cfg.browser, defaultProfile: "user" }, cfg);
|
||||||
|
const userProfile = resolveProfile(resolved, "user");
|
||||||
|
expect(userProfile).toBeTruthy();
|
||||||
|
expect(userProfile?.mcpTargetUrl).toBe("http://127.0.0.1:9222");
|
||||||
|
|
||||||
|
const state: BrowserServerState = {
|
||||||
|
server: null,
|
||||||
|
port: 18791,
|
||||||
|
resolved,
|
||||||
|
profiles: new Map([
|
||||||
|
[
|
||||||
|
"user",
|
||||||
|
{
|
||||||
|
profile: userProfile!,
|
||||||
|
running: { pid: 123 } as never,
|
||||||
|
lastTargetId: "tab-1",
|
||||||
|
reconcile: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
cfgProfiles.user = {
|
||||||
|
cdpUrl: "http://127.0.0.1:9333",
|
||||||
|
color: "#00AA00",
|
||||||
|
driver: "existing-session",
|
||||||
|
attachOnly: true,
|
||||||
|
};
|
||||||
|
cachedConfig = null;
|
||||||
|
|
||||||
|
refreshResolvedBrowserConfigFromDisk({
|
||||||
|
current: state,
|
||||||
|
refreshConfigFromDisk: true,
|
||||||
|
mode: "cached",
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = state.profiles.get("user");
|
||||||
|
expect(runtime).toBeTruthy();
|
||||||
|
expect(runtime?.profile.mcpTargetUrl).toBe("http://127.0.0.1:9333");
|
||||||
|
expect(runtime?.lastTargetId).toBeNull();
|
||||||
|
expect(runtime?.reconcile?.reason).toContain("mcpTargetUrl");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||||
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
|
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
|
||||||
import type { ResolvedBrowserProfile } from "./config.js";
|
import type { ResolvedBrowserProfile } from "./config.js";
|
||||||
import { resolveProfile } from "./config.js";
|
import { resolveProfile } from "./config.js";
|
||||||
|
import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME } from "./constants.js";
|
||||||
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
|
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
|
||||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||||
|
|
@ -40,6 +41,35 @@ export function listKnownProfileNames(state: BrowserServerState): string[] {
|
||||||
return [...names];
|
return [...names];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveImplicitProfileName(state: BrowserServerState): string {
|
||||||
|
const defaultProfileName = state.resolved.defaultProfile;
|
||||||
|
if (!state.resolved.headless) {
|
||||||
|
return defaultProfileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProfile = resolveProfile(state.resolved, defaultProfileName);
|
||||||
|
if (!defaultProfile) {
|
||||||
|
return defaultProfileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilities = getBrowserProfileCapabilities(defaultProfile);
|
||||||
|
if (!capabilities.requiresRelay) {
|
||||||
|
return defaultProfileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedProfile = resolveProfile(state.resolved, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
|
||||||
|
if (!managedProfile) {
|
||||||
|
return defaultProfileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedCapabilities = getBrowserProfileCapabilities(managedProfile);
|
||||||
|
if (managedCapabilities.requiresRelay) {
|
||||||
|
return defaultProfileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return managedProfile.name;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a profile-scoped context for browser operations.
|
* Create a profile-scoped context for browser operations.
|
||||||
*/
|
*/
|
||||||
|
|
@ -129,7 +159,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||||
|
|
||||||
const forProfile = (profileName?: string): ProfileContext => {
|
const forProfile = (profileName?: string): ProfileContext => {
|
||||||
const current = state();
|
const current = state();
|
||||||
const name = profileName ?? current.resolved.defaultProfile;
|
const name = profileName ?? resolveImplicitProfileName(current);
|
||||||
const profile = resolveBrowserProfileWithHotReload({
|
const profile = resolveBrowserProfileWithHotReload({
|
||||||
current,
|
current,
|
||||||
refreshConfigFromDisk,
|
refreshConfigFromDisk,
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||||
"browser.profiles.*.cdpPort":
|
"browser.profiles.*.cdpPort":
|
||||||
"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.",
|
"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.",
|
||||||
"browser.profiles.*.cdpUrl":
|
"browser.profiles.*.cdpUrl":
|
||||||
"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.",
|
"Per-profile browser endpoint URL. For openclaw/extension drivers this is the CDP URL; for existing-session it is passed to Chrome DevTools MCP as browserUrl/wsEndpoint so headless or remote MCP attach can target a running debuggable browser.",
|
||||||
"browser.profiles.*.driver":
|
"browser.profiles.*.driver":
|
||||||
'Per-profile browser driver mode: "openclaw" (or legacy "clawd") or "extension" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.',
|
'Per-profile browser driver mode: "openclaw" (or legacy "clawd") or "extension" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.',
|
||||||
"browser.profiles.*.attachOnly":
|
"browser.profiles.*.attachOnly":
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export type BrowserProfileConfig = {
|
export type BrowserProfileConfig = {
|
||||||
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
|
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
|
||||||
cdpPort?: number;
|
cdpPort?: number;
|
||||||
/** CDP URL for this profile (use for remote Chrome). */
|
/** CDP URL for this profile (use for remote Chrome, or as browserUrl/wsEndpoint for existing-session MCP attach). */
|
||||||
cdpUrl?: string;
|
cdpUrl?: string;
|
||||||
/** Profile driver (default: openclaw). */
|
/** Profile driver (default: openclaw). */
|
||||||
driver?: "openclaw" | "clawd" | "extension" | "existing-session";
|
driver?: "openclaw" | "clawd" | "extension" | "existing-session";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue