Skip to content

Commit ac21e89

Browse files
lifuyuecodex
andauthored
Support existing-session browser CDP endpoints (#91736)
* Support existing-session browser CDP endpoints * Fix browser existing-session test fixture type --------- Co-authored-by: OpenAI Codex <codex@openai.com>
1 parent b71d8e1 commit ac21e89

13 files changed

Lines changed: 273 additions & 23 deletions

docs/cli/browser.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,10 +280,14 @@ Use the built-in `user` profile, or create your own `existing-session` profile:
280280
openclaw browser --browser-profile user tabs
281281
openclaw browser create-profile --name chrome-live --driver existing-session
282282
openclaw browser create-profile --name brave-live --driver existing-session --user-data-dir "~/Library/Application Support/BraveSoftware/Brave-Browser"
283+
openclaw browser create-profile --name chrome-port --driver existing-session --cdp-url http://127.0.0.1:9222
283284
openclaw browser --browser-profile chrome-live tabs
284285
```
285286
286-
This path is host-only. For Docker, headless servers, Browserless, or other remote setups, use a CDP profile instead.
287+
The default existing-session path is host-only Chrome MCP auto-connect. If the browser is already
288+
running with a DevTools endpoint, pass `--cdp-url` so Chrome MCP attaches to that endpoint instead.
289+
For Docker, Browserless, or other remote setups where Chrome MCP semantics are not needed, use a
290+
CDP profile.
287291
288292
Current existing-session limits:
289293

docs/gateway/configuration-reference.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,12 +442,17 @@ See [Inferred commitments](/concepts/commitments).
442442
the selected host or through a connected browser node.
443443
- `existing-session` profiles can set `userDataDir` to target a specific
444444
Chromium-based browser profile such as Brave or Edge.
445+
- `existing-session` profiles can set `cdpUrl` when Chrome is already running
446+
behind a DevTools HTTP(S) discovery endpoint or direct WS(S) endpoint. In that
447+
mode OpenClaw passes the endpoint to Chrome MCP instead of using auto-connect;
448+
`userDataDir` is ignored for Chrome MCP launch arguments.
445449
- `existing-session` profiles keep the current Chrome MCP route limits:
446450
snapshot/ref-driven actions instead of CSS-selector targeting, one-file upload
447451
hooks, no dialog timeout overrides, no `wait --load networkidle`, and no
448452
`responsebody`, PDF export, download interception, or batch actions.
449-
- Local managed `openclaw` profiles auto-assign `cdpPort` and `cdpUrl`; only
450-
set `cdpUrl` explicitly for remote CDP.
453+
- Local managed `openclaw` profiles auto-assign `cdpPort` and `cdpUrl`; set
454+
`cdpUrl` explicitly only for remote CDP profiles or existing-session endpoint
455+
attach.
451456
- Local managed profiles can set `executablePath` to override the global
452457
`browser.executablePath` for that profile. Use this to run one profile in
453458
Chrome and another in Brave.

docs/tools/browser.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,9 @@ main model can read the screenshot directly.
244244
<Accordion title="Ports and reachability">
245245

246246
- Control service binds to loopback on a port derived from `gateway.port` (default `18791` = gateway + 2). Overriding `gateway.port` or `OPENCLAW_GATEWAY_PORT` shifts the derived ports in the same family.
247-
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; set those only for remote CDP. `cdpUrl` defaults to the managed local CDP port when unset.
247+
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; set those only for
248+
remote CDP profiles or existing-session endpoint attach. `cdpUrl` defaults to
249+
the managed local CDP port when unset.
248250
- `remoteCdpTimeoutMs` applies to remote and `attachOnly` CDP HTTP reachability
249251
checks and tab-opening HTTP requests; `remoteCdpHandshakeTimeoutMs` applies to
250252
their CDP WebSocket handshakes.
@@ -298,7 +300,7 @@ main model can read the screenshot directly.
298300
- `color` (top-level and per-profile) tints the browser UI so you can see which profile is active.
299301
- Default profile is `openclaw` (managed standalone). Use `defaultProfile: "user"` to opt into the signed-in user browser.
300302
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
301-
- `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do not set `cdpUrl` for that driver.
303+
- `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. It can attach through Chrome MCP auto-connect, or through `cdpUrl` when you already have a DevTools endpoint for the running browser.
302304
- Set `browser.profiles.<name>.userDataDir` when an existing-session profile should attach to a non-default Chromium user profile (Brave, Edge, etc.). This path also accepts `~` for your OS home directory.
303305

304306
</Accordion>
@@ -687,6 +689,9 @@ What to check if attach does not work:
687689
- the target Chromium-based browser is version `144+`
688690
- remote debugging is enabled in that browser's inspect page
689691
- the browser showed and you accepted the attach consent prompt
692+
- if Chrome was started with an explicit `--remote-debugging-port`, set
693+
`browser.profiles.<name>.cdpUrl` to that DevTools endpoint instead of relying
694+
on Chrome MCP auto-connect
690695
- `openclaw doctor` migrates old extension-based browser config and checks that
691696
Chrome is installed locally for default auto-connect profiles, but it cannot
692697
enable browser-side remote debugging for you

extensions/browser/src/browser/chrome-mcp.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,25 @@ function createFakeSession(): ChromeMcpSession {
120120
} as unknown as ChromeMcpSession;
121121
}
122122

123+
function createToolErrorSession(message: string): ChromeMcpSession {
124+
const callTool = vi.fn(async () => ({
125+
isError: true,
126+
content: [{ type: "text", text: message }],
127+
}));
128+
return {
129+
client: {
130+
callTool,
131+
listTools: vi.fn().mockResolvedValue({ tools: [{ name: "list_pages" }] }),
132+
close: vi.fn().mockResolvedValue(undefined),
133+
connect: vi.fn().mockResolvedValue(undefined),
134+
},
135+
transport: {
136+
pid: 123,
137+
},
138+
ready: Promise.resolve(),
139+
} as unknown as ChromeMcpSession;
140+
}
141+
123142
describe("chrome MCP page parsing", () => {
124143
beforeEach(async () => {
125144
await resetChromeMcpSessionsForTest();
@@ -153,6 +172,33 @@ describe("chrome MCP page parsing", () => {
153172
]);
154173
});
155174

175+
it("suggests cdpUrl when auto-connect cannot read DevToolsActivePort", async () => {
176+
setChromeMcpSessionFactoryForTest(async () =>
177+
createToolErrorSession(
178+
"Could not connect to Chrome in /tmp/chrome-profile. Cause: ENOENT: no such file or directory, open '/tmp/chrome-profile/DevToolsActivePort'",
179+
),
180+
);
181+
182+
await expect(
183+
listChromeMcpTabs("chrome-live", { userDataDir: "/tmp/chrome-profile" }),
184+
).rejects.toThrow(/set browser\.profiles\.chrome-live\.cdpUrl/);
185+
});
186+
187+
it("names the configured endpoint when endpoint attach fails", async () => {
188+
setChromeMcpSessionFactoryForTest(async () =>
189+
createToolErrorSession("Could not connect to Chrome: ECONNREFUSED"),
190+
);
191+
192+
await expect(
193+
listChromeMcpTabs("chrome-live", {
194+
cdpUrl:
195+
"https://alice:supersecretpasswordvalue1234@example.com/chrome?token=supersecrettokenvalue1234567890",
196+
}),
197+
).rejects.toThrow(
198+
/configured Chrome endpoint \(https:\/\/example\.com\/chrome\?token=\*\*\*\)/,
199+
);
200+
});
201+
156202
it("reads screenshot files with the extension written by chrome-devtools-mcp", async () => {
157203
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
158204
setChromeMcpSessionFactoryForTest(factory);

extensions/browser/src/browser/chrome-mcp.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ const CHROME_MCP_HANDSHAKE_TIMEOUT_MS = 30_000;
143143
const CHROME_MCP_STDERR_MAX_BYTES = 8 * 1024;
144144
const CHROME_MCP_PROCESS_EXIT_GRACE_MS = 250;
145145
const CDP_URL_IN_TEXT_RE = /\b(?:https?|wss?):\/\/[^\s"'<>`]+/gi;
146+
const DEVTOOLS_ACTIVE_PORT_RE = /\bDevToolsActivePort\b/i;
147+
const CHROME_CONNECTION_TOOL_ERROR_RE =
148+
/(?:Could not connect to Chrome|DevToolsActivePort|ECONNREFUSED|ECONNRESET|websocket|timed out)/i;
146149
const STALE_SELECTED_PAGE_ERROR =
147150
"The selected page has been closed. Call list_pages to see open pages.";
148151

@@ -267,6 +270,41 @@ function extractToolErrorMessage(result: ChromeMcpToolResult, name: string): str
267270
return message || `Chrome MCP tool "${name}" failed.`;
268271
}
269272

273+
function formatChromeMcpEndpointForDiagnostic(browserUrl: string): string {
274+
return redactToolPayloadText(redactCdpUrl(browserUrl) ?? browserUrl);
275+
}
276+
277+
function formatChromeMcpToolErrorMessage(params: {
278+
profileName: string;
279+
options: NormalizedChromeMcpProfileOptions;
280+
toolName: string;
281+
message: string;
282+
}): string {
283+
const detail = redactChromeMcpDiagnosticTextWithLocalPaths(params.message);
284+
const profileLabel = redactChromeMcpProfileLabelForDiagnostic(params.profileName);
285+
if (params.options.browserUrl && CHROME_CONNECTION_TOOL_ERROR_RE.test(params.message)) {
286+
return (
287+
`Chrome MCP tool "${params.toolName}" failed for profile "${profileLabel}" while using ` +
288+
`the configured Chrome endpoint (${formatChromeMcpEndpointForDiagnostic(params.options.browserUrl)}). ` +
289+
`Details: ${detail}`
290+
);
291+
}
292+
if (
293+
!params.options.browserUrl &&
294+
params.options.userDataDir &&
295+
DEVTOOLS_ACTIVE_PORT_RE.test(params.message)
296+
) {
297+
const cdpUrlPath = path.isAbsolute(params.profileName)
298+
? "this existing-session profile's cdpUrl"
299+
: `browser.profiles.${params.profileName}.cdpUrl`;
300+
return (
301+
`${detail} If this browser was started with --remote-debugging-port, set ${cdpUrlPath} ` +
302+
"to that DevTools endpoint instead of relying on Chrome MCP auto-connect."
303+
);
304+
}
305+
return detail;
306+
}
307+
270308
function shouldReconnectForToolError(name: string, message: string): boolean {
271309
return name === "list_pages" && message.includes(STALE_SELECTED_PAGE_ERROR);
272310
}
@@ -1194,9 +1232,10 @@ async function callTool(
11941232
if (signal?.aborted) {
11951233
throw signal.reason ?? new Error("aborted");
11961234
}
1235+
const normalizedProfileOptions = normalizeChromeMcpOptions(profileOptions);
11971236

11981237
for (let attempt = 0; attempt < 2; attempt += 1) {
1199-
const lease = await leaseSession(profileName, profileOptions, options);
1238+
const lease = await leaseSession(profileName, normalizedProfileOptions, options);
12001239
const rawCall = lease.session.client.callTool({
12011240
name,
12021241
arguments: args,
@@ -1273,7 +1312,14 @@ async function callTool(
12731312
continue;
12741313
}
12751314
}
1276-
throw new Error(message);
1315+
throw new Error(
1316+
formatChromeMcpToolErrorMessage({
1317+
profileName,
1318+
options: normalizedProfileOptions,
1319+
toolName: name,
1320+
message,
1321+
}),
1322+
);
12771323
}
12781324
return result;
12791325
}

extensions/browser/src/browser/config-mutations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export async function createBrowserProfileConfig(params: {
120120
nextProfileConfig = {
121121
cdpUrl: params.parsedCdpUrl,
122122
...(params.driver ? { driver: params.driver } : {}),
123+
...(params.driver === "existing-session" ? { attachOnly: true } : {}),
123124
color: profileColor,
124125
};
125126
} else if (params.driver === "existing-session") {

extensions/browser/src/browser/profiles-service.test.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,20 +275,57 @@ describe("BrowserProfilesService", () => {
275275
expect(profiles["chrome-live"]?.attachOnly).toBe(true);
276276
});
277277

278-
it("rejects driver=existing-session when cdpUrl is provided", async () => {
278+
it("accepts driver=existing-session with cdpUrl", async () => {
279279
const resolved = resolveBrowserConfig({});
280-
const { ctx } = createCtx(resolved);
280+
const { ctx, state } = createCtx(resolved);
281281
vi.mocked(getRuntimeConfig).mockReturnValue({ browser: { profiles: {} } });
282282

283+
const service = createBrowserProfilesService(ctx);
284+
const result = await service.createProfile({
285+
name: "chrome-live",
286+
driver: "existing-session",
287+
cdpUrl: "http://127.0.0.1:9222/",
288+
});
289+
290+
expect(result.transport).toBe("chrome-mcp");
291+
expect(result.cdpPort).toBeNull();
292+
expect(result.cdpUrl).toBe("http://127.0.0.1:9222");
293+
expect(result.userDataDir).toBeNull();
294+
const resolvedProfile = state.resolved.profiles["chrome-live"];
295+
expect(resolvedProfile?.driver).toBe("existing-session");
296+
expect(resolvedProfile?.attachOnly).toBe(true);
297+
expect(resolvedProfile?.cdpUrl).toBe("http://127.0.0.1:9222");
298+
const profiles = writtenBrowserConfig().profiles as Record<
299+
string,
300+
{ cdpUrl?: string; driver?: string }
301+
>;
302+
expect(profiles["chrome-live"]?.driver).toBe("existing-session");
303+
expect(profiles["chrome-live"]?.cdpUrl).toBe("http://127.0.0.1:9222");
304+
});
305+
306+
it("rejects private-network cdpUrl for existing-session when strict SSRF mode is enabled", async () => {
307+
const resolved = resolveBrowserConfig({
308+
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
309+
});
310+
const { ctx } = createCtx(resolved);
311+
312+
vi.mocked(getRuntimeConfig).mockReturnValue({
313+
browser: {
314+
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
315+
profiles: {},
316+
},
317+
});
318+
283319
const service = createBrowserProfilesService(ctx);
284320

285321
await expect(
286322
service.createProfile({
287323
name: "chrome-live",
288324
driver: "existing-session",
289-
cdpUrl: "http://127.0.0.1:9222",
325+
cdpUrl: "http://10.0.0.42:9222",
290326
}),
291-
).rejects.toThrow(/does not accept cdpUrl/i);
327+
).rejects.toThrow(/private\/internal\/special-use ip address/i);
328+
expect(writeConfigFile).not.toHaveBeenCalled();
292329
});
293330

294331
it("creates existing-session profiles with an explicit userDataDir", async () => {

extensions/browser/src/browser/profiles-service.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,6 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
101101
}
102102

103103
if (rawCdpUrl) {
104-
if (driver === "existing-session") {
105-
throw new BrowserValidationError(
106-
"driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow",
107-
);
108-
}
109104
let parsed: ReturnType<typeof parseHttpUrl>;
110105
try {
111106
parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
@@ -139,7 +134,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
139134
profile: name,
140135
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
141136
cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort,
142-
cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl,
137+
cdpUrl: resolved.cdpUrl || null,
143138
userDataDir: resolved.userDataDir ?? null,
144139
color: resolved.color,
145140
isRemote: !resolved.cdpIsLoopback,

extensions/browser/src/browser/routes/basic.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext)
204204
? getChromeMcpPid(profileCtx.profile.name)
205205
: (profileState?.running?.pid ?? null),
206206
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
207-
cdpUrl: capabilities.usesChromeMcp ? null : (redactCdpUrl(profileCtx.profile.cdpUrl) ?? null),
207+
cdpUrl: profileCtx.profile.cdpUrl ? (redactCdpUrl(profileCtx.profile.cdpUrl) ?? null) : null,
208208
chosenBrowser: profileState?.running?.exe.kind ?? null,
209209
detectedBrowser,
210210
detectedExecutablePath,

extensions/browser/src/browser/server-context.existing-session.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const chromeMcp = chromeMcpMock;
3333
type ChromeLiveProfile = {
3434
driver?: string;
3535
name?: string;
36+
cdpUrl?: string;
3637
userDataDir?: string;
3738
};
3839

@@ -138,6 +139,30 @@ describe("browser server-context existing-session profile", () => {
138139
expect(listOptions).toEqual({ ephemeral: true });
139140
});
140141

142+
it("reports endpoint cdpUrl for existing-session profiles", async () => {
143+
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
144+
const state = makeState();
145+
state.resolved.profiles["chrome-live"] = {
146+
...state.resolved.profiles["chrome-live"],
147+
cdpUrl: "http://openclaw:relay-token@127.0.0.1:9222",
148+
};
149+
const ctx = createBrowserRouteContext({ getState: () => state });
150+
151+
const profiles = await ctx.listProfiles();
152+
153+
expect(profiles).toHaveLength(1);
154+
expect(profiles[0]?.transport).toBe("chrome-mcp");
155+
expect(profiles[0]?.cdpPort).toBeNull();
156+
expect(profiles[0]?.cdpUrl).toBe("http://127.0.0.1:9222");
157+
const [, ensuredProfile] =
158+
(
159+
vi.mocked(chromeMcp.ensureChromeMcpAvailable).mock.calls as unknown as Array<
160+
[string, ChromeLiveProfile, { ephemeral?: boolean; timeoutMs?: number }]
161+
>
162+
)[0] ?? [];
163+
expect(ensuredProfile?.cdpUrl).toBe("http://openclaw:relay-token@127.0.0.1:9222");
164+
});
165+
141166
it("keeps the next real attach on the normal sticky session path after an idle status probe", async () => {
142167
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
143168
const state = makeState();

0 commit comments

Comments
 (0)