mirror of https://github.com/openclaw/openclaw.git
fix(browser): add browser session selection
This commit is contained in:
parent
b857a8d8bc
commit
5c40c1c78a
|
|
@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus.
|
||||
- iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show `/pair qr` instructions on the connect step. (#45054) Thanks @ngutman.
|
||||
- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.
|
||||
- Browser/agents: add `browserSession="agent" | "user"` so agent browser calls can explicitly choose the isolated OpenClaw browser or a logged-in user browser, with docs for when user presence and attach approval are required.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
|
@ -139,6 +140,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
|
||||
- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
|
||||
- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
|
||||
- Browser/existing-session: stop reporting fake CDP ports/URLs for live attached Chrome sessions, render `transport: chrome-mcp` in CLI/status output instead of `port: 0`, and keep timeout diagnostics transport-aware when no direct CDP URL exists.
|
||||
- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write.
|
||||
- Feishu/event dedupe: keep early duplicate suppression aligned with the shared Feishu message-id contract and release the pre-queue dedupe marker after failed dispatch so retried events can recover instead of being dropped until the short TTL expires. (#43762) Thanks @yunweibang.
|
||||
- Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ Back to the main browser docs: [Browser](/tools/browser).
|
|||
|
||||
OpenClaw controls a **dedicated Chrome profile** (named `openclaw`, orange‑tinted UI). This is separate from your daily browser profile.
|
||||
|
||||
For agent browser tool calls:
|
||||
|
||||
- Default choice: the agent should use its isolated `openclaw` browser.
|
||||
- Use the **user browser** only when existing logged-in sessions matter and the user is at the computer to click/approve any attach prompt.
|
||||
- If you need to force the choice, use `browserSession="agent"` or `browserSession="user"`.
|
||||
- If you have multiple user-browser profiles, specify the profile explicitly instead of guessing.
|
||||
|
||||
Two easy ways to access it:
|
||||
|
||||
1. **Ask the agent to open the browser** and then log in yourself.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,15 @@ Gateway.
|
|||
- `existing-session`: official Chrome MCP attach flow for a running Chrome
|
||||
profile.
|
||||
|
||||
For agent browser tool calls:
|
||||
|
||||
- Default: use the isolated `openclaw` browser.
|
||||
- Use the **user browser** only when existing logged-in sessions matter and the
|
||||
user is at the computer to click/approve any attach prompt.
|
||||
- If you need to force the choice, use `browserSession="agent"` or
|
||||
`browserSession="user"`.
|
||||
- `profile` is the explicit override when you already know which profile to use.
|
||||
|
||||
Set `browser.defaultProfile: "openclaw"` if you want managed mode by default.
|
||||
|
||||
## Configuration
|
||||
|
|
@ -70,7 +79,7 @@ Browser settings live in `~/.openclaw/openclaw.json`.
|
|||
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
||||
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
|
||||
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
|
||||
defaultProfile: "chrome",
|
||||
defaultProfile: "openclaw",
|
||||
color: "#FF4500",
|
||||
headless: false,
|
||||
noSandbox: false,
|
||||
|
|
@ -79,8 +88,7 @@ Browser settings live in `~/.openclaw/openclaw.json`.
|
|||
profiles: {
|
||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||
work: { cdpPort: 18801, color: "#0066CC" },
|
||||
chromeLive: {
|
||||
cdpPort: 18802,
|
||||
"chrome-live": {
|
||||
driver: "existing-session",
|
||||
attachOnly: true,
|
||||
color: "#00AA00",
|
||||
|
|
@ -324,7 +332,7 @@ openclaw browser extension install
|
|||
2. Use it:
|
||||
|
||||
- CLI: `openclaw browser --browser-profile chrome tabs`
|
||||
- Agent tool: `browser` with `profile="chrome"`
|
||||
- Agent tool: `browser` with `browserSession="user"` (or `profile="chrome"`)
|
||||
|
||||
Optional: if you want a different name or relay port, create your own profile:
|
||||
|
||||
|
|
@ -340,6 +348,8 @@ Notes:
|
|||
|
||||
- This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions).
|
||||
- Detach by clicking the extension icon again.
|
||||
- Agent use: prefer `browserSession="user"` for logged-in sites. The user must be
|
||||
present to click the extension and attach the tab.
|
||||
|
||||
## Chrome existing-session via MCP
|
||||
|
||||
|
|
@ -379,6 +389,7 @@ openclaw browser --browser-profile chrome-live snapshot --format ai
|
|||
What success looks like:
|
||||
|
||||
- `status` shows `driver: existing-session`
|
||||
- `status` shows `transport: chrome-mcp`
|
||||
- `status` shows `running: true`
|
||||
- `tabs` lists your already-open Chrome tabs
|
||||
- `snapshot` returns refs from the selected live tab
|
||||
|
|
@ -388,6 +399,14 @@ What to check if attach does not work:
|
|||
- Chrome is version `144+`
|
||||
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
|
||||
- Chrome showed and you accepted the attach consent prompt
|
||||
|
||||
Agent use:
|
||||
|
||||
- Use `browserSession="user"` when you need the user’s logged-in browser state.
|
||||
- If you know the profile name, pass `profile="chrome-live"` (or your custom
|
||||
existing-session profile).
|
||||
- Only choose this mode when the user is at the computer to approve the attach
|
||||
prompt.
|
||||
- the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
|
||||
|
||||
Notes:
|
||||
|
|
|
|||
|
|
@ -310,13 +310,18 @@ Profile management:
|
|||
|
||||
Common parameters:
|
||||
|
||||
- `browserSession` (`agent` | `user`)
|
||||
- `profile` (optional; defaults to `browser.defaultProfile`)
|
||||
- `target` (`sandbox` | `host` | `node`)
|
||||
- `node` (optional; picks a specific node id/name)
|
||||
Notes:
|
||||
- Requires `browser.enabled=true` (default is `true`; set `false` to disable).
|
||||
- `browserSession="agent"` is the safe default: isolated OpenClaw-managed browser.
|
||||
- `browserSession="user"` means the real local host browser. Use it only when existing logins/cookies matter and the user is present to click/approve any attach prompt.
|
||||
- `browserSession="user"` is host-only; do not combine it with sandbox/node targets.
|
||||
- All actions accept optional `profile` parameter for multi-instance support.
|
||||
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "chrome").
|
||||
- `profile` overrides `browserSession` when both are supplied.
|
||||
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`).
|
||||
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
|
||||
- Port range: 18800-18899 (~100 profiles max).
|
||||
- Remote profiles are attach-only (no start/stop/reset).
|
||||
|
|
|
|||
|
|
@ -160,6 +160,8 @@ describe("createOpenClawCodingTools", () => {
|
|||
it("mentions Chrome extension relay in browser tool description", () => {
|
||||
const browser = createBrowserTool();
|
||||
expect(browser.description).toMatch(/Chrome extension/i);
|
||||
expect(browser.description).toMatch(/browserSession="agent"/i);
|
||||
expect(browser.description).toMatch(/browserSession="user"/i);
|
||||
expect(browser.description).toMatch(/profile="chrome"/i);
|
||||
});
|
||||
it("keeps browser tool schema properties after normalization", () => {
|
||||
|
|
@ -172,6 +174,7 @@ describe("createOpenClawCodingTools", () => {
|
|||
};
|
||||
expect(parameters.properties?.action).toBeDefined();
|
||||
expect(parameters.properties?.target).toBeDefined();
|
||||
expect(parameters.properties?.browserSession).toBeDefined();
|
||||
expect(parameters.properties?.targetUrl).toBeDefined();
|
||||
expect(parameters.properties?.request).toBeDefined();
|
||||
expect(parameters.required ?? []).toContain("action");
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
|
|||
] as const;
|
||||
|
||||
const BROWSER_TARGETS = ["sandbox", "host", "node"] as const;
|
||||
const BROWSER_SESSION_CHOICES = ["agent", "user"] as const;
|
||||
|
||||
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
|
||||
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
|
||||
|
|
@ -88,6 +89,7 @@ const BrowserActSchema = Type.Object({
|
|||
export const BrowserToolSchema = Type.Object({
|
||||
action: stringEnum(BROWSER_TOOL_ACTIONS),
|
||||
target: optionalStringEnum(BROWSER_TARGETS),
|
||||
browserSession: optionalStringEnum(BROWSER_SESSION_CHOICES),
|
||||
node: Type.Optional(Type.String()),
|
||||
profile: Type.Optional(Type.String()),
|
||||
targetUrl: Type.Optional(Type.String()),
|
||||
|
|
|
|||
|
|
@ -54,7 +54,45 @@ const browserConfigMocks = vi.hoisted(() => ({
|
|||
resolveBrowserConfig: vi.fn(() => ({
|
||||
enabled: true,
|
||||
controlPort: 18791,
|
||||
profiles: {},
|
||||
defaultProfile: "openclaw",
|
||||
})),
|
||||
resolveProfile: vi.fn((resolved: Record<string, unknown>, name: string) => {
|
||||
const profile = (resolved.profiles as Record<string, Record<string, unknown>> | undefined)?.[
|
||||
name
|
||||
];
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
const driver =
|
||||
profile.driver === "extension"
|
||||
? "extension"
|
||||
: profile.driver === "existing-session"
|
||||
? "existing-session"
|
||||
: "openclaw";
|
||||
if (driver === "existing-session") {
|
||||
return {
|
||||
name,
|
||||
driver,
|
||||
cdpPort: 0,
|
||||
cdpUrl: "",
|
||||
cdpHost: "",
|
||||
cdpIsLoopback: true,
|
||||
color: typeof profile.color === "string" ? profile.color : "#FF4500",
|
||||
attachOnly: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
driver,
|
||||
cdpPort: typeof profile.cdpPort === "number" ? profile.cdpPort : 18792,
|
||||
cdpUrl: typeof profile.cdpUrl === "string" ? profile.cdpUrl : "http://127.0.0.1:18792",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
color: typeof profile.color === "string" ? profile.color : "#FF4500",
|
||||
attachOnly: profile.attachOnly === true,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
||||
|
||||
|
|
@ -117,9 +155,27 @@ function mockSingleBrowserProxyNode() {
|
|||
function resetBrowserToolMocks() {
|
||||
vi.clearAllMocks();
|
||||
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||
browserConfigMocks.resolveBrowserConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
controlPort: 18791,
|
||||
profiles: {},
|
||||
defaultProfile: "openclaw",
|
||||
});
|
||||
nodesUtilsMocks.listNodes.mockResolvedValue([]);
|
||||
}
|
||||
|
||||
function setResolvedBrowserProfiles(
|
||||
profiles: Record<string, Record<string, unknown>>,
|
||||
defaultProfile = "openclaw",
|
||||
) {
|
||||
browserConfigMocks.resolveBrowserConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
controlPort: 18791,
|
||||
profiles,
|
||||
defaultProfile,
|
||||
});
|
||||
}
|
||||
|
||||
function registerBrowserToolAfterEachReset() {
|
||||
afterEach(() => {
|
||||
resetBrowserToolMocks();
|
||||
|
|
@ -131,6 +187,7 @@ async function runSnapshotToolCall(params: {
|
|||
refs?: "aria" | "dom";
|
||||
maxChars?: number;
|
||||
profile?: string;
|
||||
browserSession?: "agent" | "user";
|
||||
}) {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", { action: "snapshot", ...params });
|
||||
|
|
@ -243,6 +300,90 @@ describe("browser tool snapshot maxChars", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('uses the isolated openclaw profile for browserSession="agent"', async () => {
|
||||
await runSnapshotToolCall({ browserSession: "agent", snapshotFormat: "ai" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
profile: "openclaw",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the host user browser for browserSession="user"', async () => {
|
||||
setResolvedBrowserProfiles({
|
||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||
chrome: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
|
||||
});
|
||||
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||
await tool.execute?.("call-1", {
|
||||
action: "snapshot",
|
||||
browserSession: "user",
|
||||
snapshotFormat: "ai",
|
||||
});
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
profile: "chrome",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a sole existing-session profile for browserSession="user"', async () => {
|
||||
setResolvedBrowserProfiles({
|
||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
||||
});
|
||||
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||
await tool.execute?.("call-1", {
|
||||
action: "snapshot",
|
||||
browserSession: "user",
|
||||
snapshotFormat: "ai",
|
||||
});
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
profile: "chrome-live",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when browserSession="user" is ambiguous', async () => {
|
||||
setResolvedBrowserProfiles({
|
||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||
personal: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
||||
work: { driver: "existing-session", attachOnly: true, color: "#0066CC" },
|
||||
});
|
||||
const tool = createBrowserTool();
|
||||
|
||||
await expect(
|
||||
tool.execute?.("call-1", {
|
||||
action: "snapshot",
|
||||
browserSession: "user",
|
||||
snapshotFormat: "ai",
|
||||
}),
|
||||
).rejects.toThrow(/Multiple user-browser profiles are configured/);
|
||||
});
|
||||
|
||||
it('rejects browserSession="user" with target="sandbox"', async () => {
|
||||
setResolvedBrowserProfiles({
|
||||
chrome: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
|
||||
});
|
||||
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||
|
||||
await expect(
|
||||
tool.execute?.("call-1", {
|
||||
action: "snapshot",
|
||||
browserSession: "user",
|
||||
target: "sandbox",
|
||||
snapshotFormat: "ai",
|
||||
}),
|
||||
).rejects.toThrow(/cannot use the sandbox browser/);
|
||||
});
|
||||
|
||||
it("lets the server choose snapshot format when the user does not request one", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", { action: "snapshot", profile: "chrome" });
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ import {
|
|||
browserStatus,
|
||||
browserStop,
|
||||
} from "../../browser/client.js";
|
||||
import { resolveBrowserConfig } from "../../browser/config.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "../../browser/config.js";
|
||||
import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME } from "../../browser/constants.js";
|
||||
import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "../../browser/paths.js";
|
||||
import { getBrowserProfileCapabilities } from "../../browser/profile-capabilities.js";
|
||||
import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "../../browser/proxy-files.js";
|
||||
import {
|
||||
trackSessionBrowserTab,
|
||||
|
|
@ -278,6 +280,60 @@ function resolveBrowserBaseUrl(params: {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function listUserBrowserProfiles() {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
return Object.keys(resolved.profiles ?? {})
|
||||
.map((name) => resolveProfile(resolved, name))
|
||||
.filter((profile): profile is NonNullable<typeof profile> => Boolean(profile))
|
||||
.filter((profile) => {
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
return capabilities.requiresRelay || capabilities.usesChromeMcp;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBrowserToolProfile(params: {
|
||||
profile?: string;
|
||||
browserSession?: "agent" | "user";
|
||||
}): string | undefined {
|
||||
if (params.profile) {
|
||||
return params.profile;
|
||||
}
|
||||
if (!params.browserSession) {
|
||||
return undefined;
|
||||
}
|
||||
if (params.browserSession === "agent") {
|
||||
return DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME;
|
||||
}
|
||||
|
||||
const userProfiles = listUserBrowserProfiles();
|
||||
const defaultUserProfile = userProfiles.find(
|
||||
(profile) => profile.name !== DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
|
||||
);
|
||||
if (defaultUserProfile?.name === "chrome") {
|
||||
return defaultUserProfile.name;
|
||||
}
|
||||
const chromeRelay = userProfiles.find((profile) => profile.name === "chrome");
|
||||
if (chromeRelay) {
|
||||
return chromeRelay.name;
|
||||
}
|
||||
if (userProfiles.length === 1) {
|
||||
return userProfiles[0]?.name;
|
||||
}
|
||||
const chromeLive = userProfiles.find((profile) => profile.name === "chrome-live");
|
||||
if (chromeLive) {
|
||||
return chromeLive.name;
|
||||
}
|
||||
if (userProfiles.length === 0) {
|
||||
throw new Error(
|
||||
'No user-browser profile is configured. Use profile="chrome" for the extension relay or create an existing-session profile first.',
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Multiple user-browser profiles are configured (${userProfiles.map((profile) => profile.name).join(", ")}). Pass profile="<name>".`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createBrowserTool(opts?: {
|
||||
sandboxBridgeUrl?: string;
|
||||
allowHostControl?: boolean;
|
||||
|
|
@ -291,10 +347,12 @@ export function createBrowserTool(opts?: {
|
|||
name: "browser",
|
||||
description: [
|
||||
"Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
|
||||
'Profiles: use profile="chrome" for Chrome extension relay takeover (your existing Chrome tabs). Use profile="openclaw" for the isolated openclaw-managed browser.',
|
||||
'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS use profile="chrome" (do not ask which profile).',
|
||||
'Browser choice: use browserSession="agent" by default for the isolated OpenClaw browser. Use browserSession="user" only when logged-in browser state matters and the user is present to click/approve browser attach prompts.',
|
||||
'browserSession="user" means the real local user browser on the host, not sandbox/node browsers. If user presence is unclear, ask first.',
|
||||
'profile remains the explicit override. Use profile="chrome" for Chrome extension relay takeover (existing Chrome tabs). Use profile="openclaw" for the isolated OpenClaw-managed browser.',
|
||||
'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS use browserSession="user" and prefer profile="chrome" (do not ask which profile unless ambiguous).',
|
||||
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
|
||||
"Chrome extension relay needs an attached tab: user must click the OpenClaw Browser Relay toolbar icon on the tab (badge ON). If no tab is connected, ask them to attach it.",
|
||||
"User-browser flows need user interaction: Chrome extension relay needs the user to click the OpenClaw Browser Relay toolbar icon on the tab (badge ON); existing-session may require approving a browser attach prompt.",
|
||||
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
|
||||
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
|
||||
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
|
||||
|
|
@ -305,13 +363,33 @@ export function createBrowserTool(opts?: {
|
|||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const profile = readStringParam(params, "profile");
|
||||
const browserSession = readStringParam(params, "browserSession") as
|
||||
| "agent"
|
||||
| "user"
|
||||
| undefined;
|
||||
const profile = resolveBrowserToolProfile({
|
||||
profile: readStringParam(params, "profile"),
|
||||
browserSession,
|
||||
});
|
||||
const requestedNode = readStringParam(params, "node");
|
||||
let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined;
|
||||
|
||||
if (requestedNode && target && target !== "node") {
|
||||
throw new Error('node is only supported with target="node".');
|
||||
}
|
||||
if (browserSession === "user") {
|
||||
if (requestedNode || target === "node") {
|
||||
throw new Error('browserSession="user" only supports the local host browser.');
|
||||
}
|
||||
if (target === "sandbox") {
|
||||
throw new Error(
|
||||
'browserSession="user" cannot use the sandbox browser; use target="host" or omit target.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!target && !requestedNode && browserSession === "user") {
|
||||
target = "host";
|
||||
}
|
||||
|
||||
if (!target && !requestedNode && profile === "chrome") {
|
||||
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
|
||||
export type BrowserTransport = "cdp" | "chrome-mcp";
|
||||
|
||||
export type BrowserStatus = {
|
||||
enabled: boolean;
|
||||
profile?: string;
|
||||
driver?: "openclaw" | "extension" | "existing-session";
|
||||
transport?: BrowserTransport;
|
||||
running: boolean;
|
||||
cdpReady?: boolean;
|
||||
cdpHttp?: boolean;
|
||||
pid: number | null;
|
||||
cdpPort: number;
|
||||
cdpUrl?: string;
|
||||
cdpPort: number | null;
|
||||
cdpUrl?: string | null;
|
||||
chosenBrowser: string | null;
|
||||
detectedBrowser?: string | null;
|
||||
detectedExecutablePath?: string | null;
|
||||
|
|
@ -24,8 +27,9 @@ export type BrowserStatus = {
|
|||
|
||||
export type ProfileStatus = {
|
||||
name: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
transport?: BrowserTransport;
|
||||
cdpPort: number | null;
|
||||
cdpUrl: string | null;
|
||||
color: string;
|
||||
driver: "openclaw" | "extension" | "existing-session";
|
||||
running: boolean;
|
||||
|
|
@ -155,8 +159,9 @@ export async function browserResetProfile(
|
|||
export type BrowserCreateProfileResult = {
|
||||
ok: true;
|
||||
profile: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
transport?: BrowserTransport;
|
||||
cdpPort: number | null;
|
||||
cdpUrl: string | null;
|
||||
color: string;
|
||||
isRemote: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -178,7 +178,9 @@ describe("BrowserProfilesService", () => {
|
|||
driver: "existing-session",
|
||||
});
|
||||
|
||||
expect(result.cdpPort).toBe(0);
|
||||
expect(result.transport).toBe("chrome-mcp");
|
||||
expect(result.cdpPort).toBeNull();
|
||||
expect(result.cdpUrl).toBeNull();
|
||||
expect(result.isRemote).toBe(false);
|
||||
expect(state.resolved.profiles["chrome-live"]).toEqual({
|
||||
driver: "existing-session",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
BrowserResourceExhaustedError,
|
||||
BrowserValidationError,
|
||||
} from "./errors.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import {
|
||||
allocateCdpPort,
|
||||
allocateColor,
|
||||
|
|
@ -32,8 +33,9 @@ export type CreateProfileParams = {
|
|||
export type CreateProfileResult = {
|
||||
ok: true;
|
||||
profile: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
transport: "cdp" | "chrome-mcp";
|
||||
cdpPort: number | null;
|
||||
cdpUrl: string | null;
|
||||
color: string;
|
||||
isRemote: boolean;
|
||||
};
|
||||
|
|
@ -181,12 +183,14 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
|||
if (!resolved) {
|
||||
throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`);
|
||||
}
|
||||
const capabilities = getBrowserProfileCapabilities(resolved);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
profile: name,
|
||||
cdpPort: resolved.cdpPort,
|
||||
cdpUrl: resolved.cdpUrl,
|
||||
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
|
||||
cdpPort: capabilities.usesChromeMcp ? null : resolved.cdpPort,
|
||||
cdpUrl: capabilities.usesChromeMcp ? null : resolved.cdpUrl,
|
||||
color: resolved.color,
|
||||
isRemote: !resolved.cdpIsLoopback,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BrowserProfileUnavailableError } from "../errors.js";
|
||||
import { registerBrowserBasicRoutes } from "./basic.js";
|
||||
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
|
||||
|
||||
vi.mock("../chrome-mcp.js", () => ({
|
||||
getChromeMcpPid: vi.fn(() => 4321),
|
||||
}));
|
||||
|
||||
describe("basic browser routes", () => {
|
||||
it("maps existing-session status failures to JSON browser errors", async () => {
|
||||
const { app, getHandlers } = createBrowserRouteApp();
|
||||
|
|
@ -21,8 +25,8 @@ describe("basic browser routes", () => {
|
|||
profile: {
|
||||
name: "chrome-live",
|
||||
driver: "existing-session",
|
||||
cdpPort: 18802,
|
||||
cdpUrl: "http://127.0.0.1:18802",
|
||||
cdpPort: 0,
|
||||
cdpUrl: "",
|
||||
color: "#00AA00",
|
||||
attachOnly: true,
|
||||
},
|
||||
|
|
@ -42,4 +46,49 @@ describe("basic browser routes", () => {
|
|||
expect(response.statusCode).toBe(409);
|
||||
expect(response.body).toMatchObject({ error: "attach failed" });
|
||||
});
|
||||
|
||||
it("reports Chrome MCP transport without fake CDP fields", async () => {
|
||||
const { app, getHandlers } = createBrowserRouteApp();
|
||||
registerBrowserBasicRoutes(app, {
|
||||
state: () => ({
|
||||
resolved: {
|
||||
enabled: true,
|
||||
headless: false,
|
||||
noSandbox: false,
|
||||
executablePath: undefined,
|
||||
},
|
||||
profiles: new Map(),
|
||||
}),
|
||||
forProfile: () =>
|
||||
({
|
||||
profile: {
|
||||
name: "chrome-live",
|
||||
driver: "existing-session",
|
||||
cdpPort: 0,
|
||||
cdpUrl: "",
|
||||
color: "#00AA00",
|
||||
attachOnly: true,
|
||||
},
|
||||
isHttpReachable: async () => true,
|
||||
isReachable: async () => true,
|
||||
}) as never,
|
||||
} as never);
|
||||
|
||||
const handler = getHandlers.get("/");
|
||||
expect(handler).toBeTypeOf("function");
|
||||
|
||||
const response = createBrowserRouteResponse();
|
||||
await handler?.({ params: {}, query: { profile: "chrome-live" } }, response.res);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
profile: "chrome-live",
|
||||
driver: "existing-session",
|
||||
transport: "chrome-mcp",
|
||||
running: true,
|
||||
cdpPort: null,
|
||||
cdpUrl: null,
|
||||
pid: 4321,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
|
|||
]);
|
||||
|
||||
const profileState = current.profiles.get(profileCtx.profile.name);
|
||||
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
|
||||
let detectedBrowser: string | null = null;
|
||||
let detectedExecutablePath: string | null = null;
|
||||
let detectError: string | null = null;
|
||||
|
|
@ -98,14 +99,15 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
|
|||
enabled: current.resolved.enabled,
|
||||
profile: profileCtx.profile.name,
|
||||
driver: profileCtx.profile.driver,
|
||||
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
|
||||
running: cdpReady,
|
||||
cdpReady,
|
||||
cdpHttp,
|
||||
pid: getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp
|
||||
pid: capabilities.usesChromeMcp
|
||||
? getChromeMcpPid(profileCtx.profile.name)
|
||||
: (profileState?.running?.pid ?? null),
|
||||
cdpPort: profileCtx.profile.cdpPort,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
cdpPort: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpPort,
|
||||
cdpUrl: capabilities.usesChromeMcp ? null : profileCtx.profile.cdpUrl,
|
||||
chosenBrowser: profileState?.running?.exe.kind ?? null,
|
||||
detectedBrowser,
|
||||
detectedExecutablePath,
|
||||
|
|
|
|||
|
|
@ -160,12 +160,13 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
|||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
|
||||
let tabCount = 0;
|
||||
let running = false;
|
||||
const profileCtx = createProfileContext(opts, profile);
|
||||
|
||||
if (getBrowserProfileCapabilities(profile).usesChromeMcp) {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
try {
|
||||
running = await profileCtx.isReachable(300);
|
||||
if (running) {
|
||||
|
|
@ -199,8 +200,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
|||
|
||||
result.push({
|
||||
name,
|
||||
cdpPort: profile.cdpPort,
|
||||
cdpUrl: profile.cdpUrl,
|
||||
transport: capabilities.usesChromeMcp ? "chrome-mcp" : "cdp",
|
||||
cdpPort: capabilities.usesChromeMcp ? null : profile.cdpPort,
|
||||
cdpUrl: capabilities.usesChromeMcp ? null : profile.cdpUrl,
|
||||
color: profile.color,
|
||||
driver: profile.driver,
|
||||
running,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Server } from "node:http";
|
||||
import type { RunningChrome } from "./chrome.js";
|
||||
import type { BrowserTransport } from "./client.js";
|
||||
import type { BrowserTab } from "./client.js";
|
||||
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
|
||||
|
||||
|
|
@ -53,8 +54,9 @@ export type ProfileContext = {
|
|||
|
||||
export type ProfileStatus = {
|
||||
name: string;
|
||||
cdpPort: number;
|
||||
cdpUrl: string;
|
||||
transport: BrowserTransport;
|
||||
cdpPort: number | null;
|
||||
cdpUrl: string | null;
|
||||
color: string;
|
||||
driver: ResolvedBrowserProfile["driver"];
|
||||
running: boolean;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
|
||||
import { createBrowserProgram } from "./browser-cli-test-helpers.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const runtimeLog = vi.fn();
|
||||
const runtimeError = vi.fn();
|
||||
const runtimeExit = vi.fn();
|
||||
return {
|
||||
callBrowserRequest: vi.fn<
|
||||
(
|
||||
opts: unknown,
|
||||
req: { path?: string },
|
||||
runtimeOpts?: { timeoutMs?: number },
|
||||
) => Promise<Record<string, unknown>>
|
||||
>(async () => ({})),
|
||||
runtimeLog,
|
||||
runtimeError,
|
||||
runtimeExit,
|
||||
runtime: {
|
||||
log: runtimeLog,
|
||||
error: runtimeError,
|
||||
exit: runtimeExit,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./browser-cli-shared.js", () => ({
|
||||
callBrowserRequest: mocks.callBrowserRequest,
|
||||
}));
|
||||
|
||||
vi.mock("./cli-utils.js", () => ({
|
||||
runCommandWithRuntime: async (
|
||||
_runtime: unknown,
|
||||
action: () => Promise<void>,
|
||||
onError: (err: unknown) => void,
|
||||
) => await action().catch(onError),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: mocks.runtime,
|
||||
}));
|
||||
|
||||
function createProgram() {
|
||||
const { program, browser, parentOpts } = createBrowserProgram();
|
||||
registerBrowserManageCommands(browser, parentOpts);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("browser manage output", () => {
|
||||
beforeEach(() => {
|
||||
mocks.callBrowserRequest.mockClear();
|
||||
mocks.runtimeLog.mockClear();
|
||||
mocks.runtimeError.mockClear();
|
||||
mocks.runtimeExit.mockClear();
|
||||
});
|
||||
|
||||
it("shows chrome-mcp transport for existing-session status without fake CDP fields", async () => {
|
||||
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
|
||||
req.path === "/"
|
||||
? {
|
||||
enabled: true,
|
||||
profile: "chrome-live",
|
||||
driver: "existing-session",
|
||||
transport: "chrome-mcp",
|
||||
running: true,
|
||||
cdpReady: true,
|
||||
cdpHttp: true,
|
||||
pid: 4321,
|
||||
cdpPort: null,
|
||||
cdpUrl: null,
|
||||
chosenBrowser: null,
|
||||
userDataDir: null,
|
||||
color: "#00AA00",
|
||||
headless: false,
|
||||
noSandbox: false,
|
||||
executablePath: null,
|
||||
attachOnly: true,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["browser", "--browser-profile", "chrome-live", "status"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string;
|
||||
expect(output).toContain("transport: chrome-mcp");
|
||||
expect(output).not.toContain("cdpPort:");
|
||||
expect(output).not.toContain("cdpUrl:");
|
||||
});
|
||||
|
||||
it("shows chrome-mcp transport in browser profiles output", async () => {
|
||||
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
|
||||
req.path === "/profiles"
|
||||
? {
|
||||
profiles: [
|
||||
{
|
||||
name: "chrome-live",
|
||||
driver: "existing-session",
|
||||
transport: "chrome-mcp",
|
||||
running: true,
|
||||
tabCount: 2,
|
||||
isDefault: false,
|
||||
isRemote: false,
|
||||
cdpPort: null,
|
||||
cdpUrl: null,
|
||||
color: "#00AA00",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(["browser", "profiles"], { from: "user" });
|
||||
|
||||
const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string;
|
||||
expect(output).toContain("chrome-live: running (2 tabs) [existing-session]");
|
||||
expect(output).toContain("transport: chrome-mcp");
|
||||
expect(output).not.toContain("port: 0");
|
||||
});
|
||||
|
||||
it("shows chrome-mcp transport after creating an existing-session profile", async () => {
|
||||
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
|
||||
req.path === "/profiles/create"
|
||||
? {
|
||||
ok: true,
|
||||
profile: "chrome-live",
|
||||
transport: "chrome-mcp",
|
||||
cdpPort: null,
|
||||
cdpUrl: null,
|
||||
color: "#00AA00",
|
||||
isRemote: false,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(
|
||||
["browser", "create-profile", "--name", "chrome-live", "--driver", "existing-session"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string;
|
||||
expect(output).toContain('Created profile "chrome-live"');
|
||||
expect(output).toContain("transport: chrome-mcp");
|
||||
expect(output).not.toContain("port: 0");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Command } from "commander";
|
||||
import type {
|
||||
BrowserTransport,
|
||||
BrowserCreateProfileResult,
|
||||
BrowserDeleteProfileResult,
|
||||
BrowserResetProfileResult,
|
||||
|
|
@ -101,6 +102,29 @@ function logBrowserTabs(tabs: BrowserTab[], json?: boolean) {
|
|||
);
|
||||
}
|
||||
|
||||
function usesChromeMcpTransport(params: {
|
||||
transport?: BrowserTransport;
|
||||
driver?: "openclaw" | "extension" | "existing-session";
|
||||
}): boolean {
|
||||
return params.transport === "chrome-mcp" || params.driver === "existing-session";
|
||||
}
|
||||
|
||||
function formatBrowserConnectionSummary(params: {
|
||||
transport?: BrowserTransport;
|
||||
driver?: "openclaw" | "extension" | "existing-session";
|
||||
isRemote?: boolean;
|
||||
cdpPort?: number | null;
|
||||
cdpUrl?: string | null;
|
||||
}): string {
|
||||
if (usesChromeMcpTransport(params)) {
|
||||
return "transport: chrome-mcp";
|
||||
}
|
||||
if (params.isRemote) {
|
||||
return `cdpUrl: ${params.cdpUrl ?? "(unset)"}`;
|
||||
}
|
||||
return `port: ${params.cdpPort ?? "(unset)"}`;
|
||||
}
|
||||
|
||||
export function registerBrowserManageCommands(
|
||||
browser: Command,
|
||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||
|
|
@ -122,8 +146,15 @@ export function registerBrowserManageCommands(
|
|||
`profile: ${status.profile ?? "openclaw"}`,
|
||||
`enabled: ${status.enabled}`,
|
||||
`running: ${status.running}`,
|
||||
`cdpPort: ${status.cdpPort}`,
|
||||
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
||||
`transport: ${
|
||||
usesChromeMcpTransport(status) ? "chrome-mcp" : (status.transport ?? "cdp")
|
||||
}`,
|
||||
...(!usesChromeMcpTransport(status)
|
||||
? [
|
||||
`cdpPort: ${status.cdpPort ?? "(unset)"}`,
|
||||
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
||||
]
|
||||
: []),
|
||||
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
||||
`detectedBrowser: ${status.detectedBrowser ?? "unknown"}`,
|
||||
`detectedPath: ${detectedDisplay}`,
|
||||
|
|
@ -407,7 +438,7 @@ export function registerBrowserManageCommands(
|
|||
const status = p.running ? "running" : "stopped";
|
||||
const tabs = p.running ? ` (${p.tabCount} tabs)` : "";
|
||||
const def = p.isDefault ? " [default]" : "";
|
||||
const loc = p.isRemote ? `cdpUrl: ${p.cdpUrl}` : `port: ${p.cdpPort}`;
|
||||
const loc = formatBrowserConnectionSummary(p);
|
||||
const remote = p.isRemote ? " [remote]" : "";
|
||||
const driver = p.driver !== "openclaw" ? ` [${p.driver}]` : "";
|
||||
return `${p.name}: ${status}${tabs}${def}${remote}${driver}\n ${loc}, color: ${p.color}`;
|
||||
|
|
@ -453,7 +484,7 @@ export function registerBrowserManageCommands(
|
|||
if (printJsonResult(parent, result)) {
|
||||
return;
|
||||
}
|
||||
const loc = result.isRemote ? ` cdpUrl: ${result.cdpUrl}` : ` port: ${result.cdpPort}`;
|
||||
const loc = ` ${formatBrowserConnectionSummary(result)}`;
|
||||
defaultRuntime.log(
|
||||
info(
|
||||
`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}${
|
||||
|
|
|
|||
|
|
@ -79,6 +79,36 @@ describe("runBrowserProxyCommand", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("includes chrome-mcp transport in timeout diagnostics when no CDP URL exists", async () => {
|
||||
dispatcherMocks.dispatch
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise(() => {});
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
body: {
|
||||
running: true,
|
||||
transport: "chrome-mcp",
|
||||
cdpHttp: true,
|
||||
cdpReady: false,
|
||||
cdpUrl: null,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
profile: "chrome-live",
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome-live; status\(running=true, cdpHttp=true, cdpReady=false, transport=chrome-mcp\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps non-timeout browser errors intact", async () => {
|
||||
dispatcherMocks.dispatch.mockResolvedValue({
|
||||
status: 500,
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ async function readBrowserProxyStatus(params: {
|
|||
const body = response.body as Record<string, unknown>;
|
||||
return {
|
||||
running: body.running,
|
||||
transport: body.transport,
|
||||
cdpHttp: body.cdpHttp,
|
||||
cdpReady: body.cdpReady,
|
||||
cdpUrl: body.cdpUrl,
|
||||
|
|
@ -194,6 +195,9 @@ function formatBrowserProxyTimeoutMessage(params: {
|
|||
`cdpHttp=${String(params.status.cdpHttp)}`,
|
||||
`cdpReady=${String(params.status.cdpReady)}`,
|
||||
];
|
||||
if (typeof params.status.transport === "string" && params.status.transport.trim()) {
|
||||
statusParts.push(`transport=${params.status.transport}`);
|
||||
}
|
||||
if (typeof params.status.cdpUrl === "string" && params.status.cdpUrl.trim()) {
|
||||
statusParts.push(`cdpUrl=${params.status.cdpUrl}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue