mirror of https://github.com/openclaw/openclaw.git
browser: drop headless/remote MCP attach modes, simplify existing-session to autoConnect-only (#46628)
This commit is contained in:
parent
2f7e548a57
commit
3704293e6f
|
|
@ -8,7 +8,6 @@ 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.
|
||||
- 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
|
||||
|
||||
|
|
@ -97,6 +96,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
|
||||
- Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic.
|
||||
- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix
|
||||
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931)
|
||||
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
|
||||
|
||||
## 2026.3.12
|
||||
|
|
|
|||
|
|
@ -110,48 +110,6 @@ curl -s -X POST http://127.0.0.1:18791/start
|
|||
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
|
||||
|
||||
| Option | Description | Default |
|
||||
|
|
|
|||
|
|
@ -359,13 +359,9 @@ Notes:
|
|||
|
||||
## Chrome existing-session via MCP
|
||||
|
||||
OpenClaw can also use the official Chrome DevTools MCP server for two different
|
||||
flows:
|
||||
|
||||
- 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
|
||||
OpenClaw can also attach to a running Chrome profile through the official
|
||||
Chrome DevTools MCP server. This reuses the tabs and login state already open in
|
||||
that Chrome profile.
|
||||
|
||||
Official background and setup references:
|
||||
|
||||
|
|
@ -379,7 +375,7 @@ Built-in profile:
|
|||
Optional: create your own custom existing-session profile if you want a
|
||||
different name or color.
|
||||
|
||||
Desktop attach flow:
|
||||
Then in Chrome:
|
||||
|
||||
1. Open `chrome://inspect/#remote-debugging`
|
||||
2. Enable remote debugging
|
||||
|
|
@ -402,66 +398,30 @@ What success looks like:
|
|||
- `tabs` lists your already-open Chrome tabs
|
||||
- `snapshot` returns refs from the selected live tab
|
||||
|
||||
What to check if desktop attach does not work:
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
- 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.
|
||||
- Prefer `profile="user"` over `profile="chrome-relay"` unless the user
|
||||
explicitly wants the extension / attach-tab flow.
|
||||
- On desktop `--autoConnect`, 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`
|
||||
for desktop attach, or use MCP headless/browserUrl/wsEndpoint modes for Linux/VPS paths.
|
||||
- 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:
|
||||
|
||||
- This path is higher-risk than the isolated `openclaw` profile because it can
|
||||
act inside your signed-in browser session.
|
||||
- OpenClaw uses the official Chrome DevTools MCP server for this driver.
|
||||
- On desktop, OpenClaw uses MCP `--autoConnect`.
|
||||
- In headless mode, OpenClaw can launch Chrome through MCP or connect MCP to a
|
||||
configured browser URL/WS endpoint.
|
||||
- OpenClaw does not launch Chrome for this driver; it attaches to an existing
|
||||
session only.
|
||||
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not
|
||||
the legacy default-profile remote debugging port workflow.
|
||||
- Existing-session screenshots support page captures and `--ref` element
|
||||
captures from snapshots, but not CSS `--element` selectors.
|
||||
- Existing-session `wait --url` supports exact, substring, and glob patterns
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ export async function executeActAction(params: {
|
|||
throw new Error(
|
||||
isRelayProfile
|
||||
? "No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry."
|
||||
: `No Chrome tabs found for profile="${profile}". Make sure Chrome is running with remote debugging enabled (chrome://inspect/#remote-debugging), approve any attach prompt, and verify open tabs. Then retry.`,
|
||||
: `No Chrome tabs found for profile="${profile}". Make sure Chrome (v146+) is running and has open tabs, then retry.`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ export function createBrowserTool(opts?: {
|
|||
description: [
|
||||
"Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
|
||||
"Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).",
|
||||
'For the logged-in user browser on the local host, use profile="user". Chrome must be running with remote debugging enabled (chrome://inspect/#remote-debugging). The user must approve the browser attach prompt. Use only when existing logins/cookies matter and the user is present.',
|
||||
'For the logged-in user browser on the local host, use profile="user". Chrome (v146+) must be running. Use only when existing logins/cookies matter and the user is present.',
|
||||
'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".',
|
||||
"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.',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
buildChromeMcpLaunchPlanForTest,
|
||||
evaluateChromeMcpScript,
|
||||
listChromeMcpTabs,
|
||||
openChromeMcpTab,
|
||||
|
|
@ -9,10 +7,6 @@ import {
|
|||
setChromeMcpSessionFactoryForTest,
|
||||
} from "./chrome-mcp.js";
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
type ToolCall = {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
|
|
@ -85,99 +79,6 @@ function createFakeSession(): ChromeMcpSession {
|
|||
describe("chrome MCP page parsing", () => {
|
||||
beforeEach(async () => {
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,8 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
|
||||
import { resolveOpenClawUserDataDir } from "./chrome.js";
|
||||
import type { BrowserTab } from "./client.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
|
||||
|
||||
type ChromeMcpStructuredPage = {
|
||||
|
|
@ -35,6 +32,7 @@ const DEFAULT_CHROME_MCP_COMMAND = "npx";
|
|||
const DEFAULT_CHROME_MCP_ARGS = [
|
||||
"-y",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--autoConnect",
|
||||
// Direct chrome-devtools-mcp launches do not enable structuredContent by default.
|
||||
"--experimentalStructuredContent",
|
||||
"--experimental-page-id-routing",
|
||||
|
|
@ -44,51 +42,6 @@ const sessions = new Map<string, ChromeMcpSession>();
|
|||
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
|
||||
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 {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
|
@ -216,10 +169,9 @@ function extractJsonMessage(result: ChromeMcpToolResult): unknown {
|
|||
}
|
||||
|
||||
async function createRealSession(profileName: string): Promise<ChromeMcpSession> {
|
||||
const launchPlan = buildChromeMcpLaunchPlan(profileName);
|
||||
const transport = new StdioClientTransport({
|
||||
command: DEFAULT_CHROME_MCP_COMMAND,
|
||||
args: launchPlan.args,
|
||||
args: DEFAULT_CHROME_MCP_ARGS,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client(
|
||||
|
|
@ -239,15 +191,9 @@ async function createRealSession(profileName: string): Promise<ChromeMcpSession>
|
|||
}
|
||||
} catch (err) {
|
||||
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(
|
||||
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
|
||||
`${hint} ` +
|
||||
`Make sure Chrome (v146+) is running. ` +
|
||||
`Details: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -585,10 +531,6 @@ export async function waitForChromeMcpText(params: {
|
|||
});
|
||||
}
|
||||
|
||||
export function buildChromeMcpLaunchPlanForTest(profileName: string): ChromeMcpLaunchPlan {
|
||||
return buildChromeMcpLaunchPlan(profileName);
|
||||
}
|
||||
|
||||
export function setChromeMcpSessionFactoryForTest(factory: ChromeMcpSessionFactory | null): void {
|
||||
sessionFactory = factory;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ describe("browser config", () => {
|
|||
expect(user?.driver).toBe("existing-session");
|
||||
expect(user?.cdpPort).toBe(0);
|
||||
expect(user?.cdpUrl).toBe("");
|
||||
expect(user?.mcpTargetUrl).toBeUndefined();
|
||||
// chrome-relay is no longer auto-created
|
||||
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
|
||||
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
|
||||
|
|
@ -114,24 +113,6 @@ describe("browser config", () => {
|
|||
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", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
profiles: {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export type ResolvedBrowserProfile = {
|
|||
cdpUrl: string;
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
mcpTargetUrl?: string;
|
||||
color: string;
|
||||
driver: "openclaw" | "extension" | "existing-session";
|
||||
attachOnly: boolean;
|
||||
|
|
@ -331,18 +330,13 @@ export function resolveProfile(
|
|||
: "openclaw";
|
||||
|
||||
if (driver === "existing-session") {
|
||||
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.
|
||||
// existing-session uses Chrome MCP auto-connect; no CDP port/URL needed
|
||||
return {
|
||||
name: profileName,
|
||||
cdpPort: 0,
|
||||
cdpUrl: "",
|
||||
cdpHost: parsed?.parsed.hostname ?? "",
|
||||
cdpIsLoopback: parsed ? isLoopbackHost(parsed.parsed.hostname) : true,
|
||||
...(parsed ? { mcpTargetUrl: parsed.normalized } : {}),
|
||||
cdpHost: "",
|
||||
cdpIsLoopback: true,
|
||||
color: profile.color,
|
||||
driver,
|
||||
attachOnly: true,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function getBrowserProfileCapabilities(
|
|||
if (profile.driver === "existing-session") {
|
||||
return {
|
||||
mode: "local-existing-session",
|
||||
isRemote: !profile.cdpIsLoopback,
|
||||
isRemote: false,
|
||||
usesChromeMcp: true,
|
||||
requiresRelay: false,
|
||||
requiresAttachedTab: false,
|
||||
|
|
|
|||
|
|
@ -201,27 +201,20 @@ describe("BrowserProfilesService", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("allows driver=existing-session when cdpUrl is provided as an MCP target", async () => {
|
||||
it("rejects driver=existing-session when cdpUrl is provided", async () => {
|
||||
const resolved = resolveBrowserConfig({});
|
||||
const { ctx, state } = createCtx(resolved);
|
||||
const { ctx } = createCtx(resolved);
|
||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.createProfile({
|
||||
name: "chrome-live",
|
||||
driver: "existing-session",
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
});
|
||||
|
||||
expect(result.transport).toBe("chrome-mcp");
|
||||
expect(result.cdpUrl).toBeNull();
|
||||
expect(result.isRemote).toBe(false);
|
||||
expect(state.resolved.profiles["chrome-live"]).toEqual({
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
driver: "existing-session",
|
||||
attachOnly: true,
|
||||
color: expect.any(String),
|
||||
});
|
||||
await expect(
|
||||
service.createProfile({
|
||||
name: "chrome-live",
|
||||
driver: "existing-session",
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
}),
|
||||
).rejects.toThrow(/does not accept cdpUrl/i);
|
||||
});
|
||||
|
||||
it("deletes remote profiles without stopping or removing local data", async () => {
|
||||
|
|
|
|||
|
|
@ -130,19 +130,15 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
|||
}
|
||||
}
|
||||
if (driver === "existing-session") {
|
||||
profileConfig = {
|
||||
cdpUrl: parsed.normalized,
|
||||
driver,
|
||||
attachOnly: true,
|
||||
color: profileColor,
|
||||
};
|
||||
} else {
|
||||
profileConfig = {
|
||||
cdpUrl: parsed.normalized,
|
||||
...(driver ? { driver } : {}),
|
||||
color: profileColor,
|
||||
};
|
||||
throw new BrowserValidationError(
|
||||
"driver=existing-session does not accept cdpUrl; it attaches via the Chrome MCP auto-connect flow",
|
||||
);
|
||||
}
|
||||
profileConfig = {
|
||||
cdpUrl: parsed.normalized,
|
||||
...(driver ? { driver } : {}),
|
||||
color: profileColor,
|
||||
};
|
||||
} else {
|
||||
if (driver === "extension") {
|
||||
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installPwToolsCoreTestHooks,
|
||||
getPwToolsCoreSessionMocks,
|
||||
setPwToolsCoreCurrentPage,
|
||||
setPwToolsCoreCurrentRefLocator,
|
||||
} from "./pw-tools-core.test-harness.js";
|
||||
|
|
@ -93,24 +92,4 @@ describe("pw-tools-core", () => {
|
|||
}),
|
||||
).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,24 +43,6 @@ async function getRestoredPageForTarget(opts: TargetOpts) {
|
|||
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 {
|
||||
return Math.max(500, Math.min(60_000, Math.floor(timeoutMs ?? 8000)));
|
||||
}
|
||||
|
|
@ -106,8 +88,12 @@ export async function clickViaPlaywright(opts: {
|
|||
delayMs?: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
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!);
|
||||
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
||||
try {
|
||||
const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
|
||||
|
|
@ -120,14 +106,12 @@ export async function clickViaPlaywright(opts: {
|
|||
timeout,
|
||||
button: opts.button,
|
||||
modifiers: opts.modifiers,
|
||||
delay: opts.delayMs,
|
||||
});
|
||||
} else {
|
||||
await locator.click({
|
||||
timeout,
|
||||
button: opts.button,
|
||||
modifiers: opts.modifiers,
|
||||
delay: opts.delayMs,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -142,8 +126,12 @@ export async function hoverViaPlaywright(opts: {
|
|||
selector?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
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 {
|
||||
await locator.hover({
|
||||
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
||||
|
|
@ -162,21 +150,23 @@ export async function dragViaPlaywright(opts: {
|
|||
endSelector?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector);
|
||||
const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector);
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
const from = resolveLocatorForInteraction(page, {
|
||||
ref: opts.startRef,
|
||||
selector: opts.startSelector,
|
||||
});
|
||||
const to = resolveLocatorForInteraction(page, {
|
||||
ref: opts.endRef,
|
||||
selector: opts.endSelector,
|
||||
});
|
||||
const startLocator = resolvedStart.ref
|
||||
? refLocator(page, requireRef(resolvedStart.ref))
|
||||
: page.locator(resolvedStart.selector!);
|
||||
const endLocator = resolvedEnd.ref
|
||||
? refLocator(page, requireRef(resolvedEnd.ref))
|
||||
: page.locator(resolvedEnd.selector!);
|
||||
const startLabel = resolvedStart.ref ?? resolvedStart.selector!;
|
||||
const endLabel = resolvedEnd.ref ?? resolvedEnd.selector!;
|
||||
try {
|
||||
await from.locator.dragTo(to.locator, {
|
||||
await startLocator.dragTo(endLocator, {
|
||||
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
throw toAIFriendlyError(err, `${from.label} -> ${to.label}`);
|
||||
throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,11 +178,15 @@ export async function selectOptionViaPlaywright(opts: {
|
|||
values: string[];
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
if (!opts.values?.length) {
|
||||
throw new Error("values are required");
|
||||
}
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
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 {
|
||||
await locator.selectOption(opts.values, {
|
||||
timeout: resolveInteractionTimeoutMs(opts.timeoutMs),
|
||||
|
|
@ -229,9 +223,13 @@ export async function typeViaPlaywright(opts: {
|
|||
slowly?: boolean;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
const text = String(opts.text ?? "");
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
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!);
|
||||
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
||||
try {
|
||||
if (opts.slowly) {
|
||||
|
|
@ -425,9 +423,14 @@ export async function scrollIntoViewViaPlaywright(opts: {
|
|||
selector?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
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 {
|
||||
await locator.scrollIntoViewIfNeeded({ timeout });
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,6 @@ function changedProfileInvariants(
|
|||
next: ResolvedBrowserProfile,
|
||||
): string[] {
|
||||
const changed: string[] = [];
|
||||
if (current.mcpTargetUrl !== next.mcpTargetUrl) {
|
||||
changed.push("mcpTargetUrl");
|
||||
}
|
||||
if (current.cdpUrl !== next.cdpUrl) {
|
||||
changed.push("cdpUrl");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
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,16 +6,7 @@ import {
|
|||
} from "./resolved-config-refresh.js";
|
||||
import type { BrowserServerState } from "./server-context.types.js";
|
||||
|
||||
let cfgProfiles: Record<
|
||||
string,
|
||||
{
|
||||
cdpPort?: number;
|
||||
cdpUrl?: string;
|
||||
color?: string;
|
||||
driver?: "openclaw" | "existing-session";
|
||||
attachOnly?: boolean;
|
||||
}
|
||||
> = {};
|
||||
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
|
||||
|
||||
// Simulate module-level cache behavior
|
||||
let cachedConfig: ReturnType<typeof buildConfig> | null = null;
|
||||
|
|
@ -215,59 +206,4 @@ describe("server-context hot-reload profiles", () => {
|
|||
expect(runtime?.lastTargetId).toBeNull();
|
||||
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,7 +2,6 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
|||
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { resolveProfile } from "./config.js";
|
||||
import { DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME } from "./constants.js";
|
||||
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
|
|
@ -41,35 +40,6 @@ export function listKnownProfileNames(state: BrowserServerState): string[] {
|
|||
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.
|
||||
*/
|
||||
|
|
@ -159,7 +129,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
|||
|
||||
const forProfile = (profileName?: string): ProfileContext => {
|
||||
const current = state();
|
||||
const name = profileName ?? resolveImplicitProfileName(current);
|
||||
const name = profileName ?? current.resolved.defaultProfile;
|
||||
const profile = resolveBrowserProfileWithHotReload({
|
||||
current,
|
||||
refreshConfigFromDisk,
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ export const FIELD_HELP: Record<string, string> = {
|
|||
"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.",
|
||||
"browser.profiles.*.cdpUrl":
|
||||
"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.",
|
||||
"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.",
|
||||
"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.',
|
||||
"browser.profiles.*.attachOnly":
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export type BrowserProfileConfig = {
|
||||
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
|
||||
cdpPort?: number;
|
||||
/** CDP URL for this profile (use for remote Chrome, or as browserUrl/wsEndpoint for existing-session MCP attach). */
|
||||
/** CDP URL for this profile (use for remote Chrome). */
|
||||
cdpUrl?: string;
|
||||
/** Profile driver (default: openclaw). */
|
||||
driver?: "openclaw" | "clawd" | "extension" | "existing-session";
|
||||
|
|
|
|||
|
|
@ -674,14 +674,18 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
authOk,
|
||||
authMethod,
|
||||
});
|
||||
// auth.mode=none disables all authentication — device pairing is an
|
||||
// auth mechanism and must also be skipped when the operator opted out.
|
||||
const skipPairing =
|
||||
resolvedAuth.mode === "none" ||
|
||||
shouldSkipBackendSelfPairing({
|
||||
connectParams,
|
||||
isLocalClient,
|
||||
hasBrowserOriginHeader,
|
||||
sharedAuthOk,
|
||||
authMethod,
|
||||
}) || shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk);
|
||||
}) ||
|
||||
shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk);
|
||||
if (device && devicePublicKey && !skipPairing) {
|
||||
const formatAuditList = (items: string[] | undefined): string => {
|
||||
if (!items || items.length === 0) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue