mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(browser): honor profile attachOnly for loopback CDP (#31429)
* config(browser): allow profile attachOnly field * config(schema): accept profile attachOnly * browser(config): resolve per-profile attachOnly * browser(runtime): honor profile attachOnly checks * browser(routes): expose profile attachOnly in status * config(labels): add browser profile attachOnly label * config(help): document browser profile attachOnly * test(config): cover profile attachOnly resolution * test(browser): cover profile attachOnly runtime path * test(config): include profile attachOnly help target * changelog: note profile attachOnly override * browser(runtime): prioritize attachOnly over loopback ownership error * test(browser): cover attachOnly ws-failure ownership path
This commit is contained in:
@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
|
||||
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
|
||||
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Profile attach-only override: support `browser.profiles.<name>.attachOnly` (fallback to global `browser.attachOnly`) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
|
||||
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
|
||||
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
|
||||
|
||||
@@ -125,6 +125,30 @@ describe("browser config", () => {
|
||||
expect(remote?.cdpIsLoopback).toBe(false);
|
||||
});
|
||||
|
||||
it("inherits attachOnly from global browser config when profile override is not set", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
attachOnly: true,
|
||||
profiles: {
|
||||
remote: { cdpUrl: "http://127.0.0.1:9222", color: "#0066CC" },
|
||||
},
|
||||
});
|
||||
|
||||
const remote = resolveProfile(resolved, "remote");
|
||||
expect(remote?.attachOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("allows profile attachOnly to override global browser attachOnly", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
attachOnly: false,
|
||||
profiles: {
|
||||
remote: { cdpUrl: "http://127.0.0.1:9222", attachOnly: true, color: "#0066CC" },
|
||||
},
|
||||
});
|
||||
|
||||
const remote = resolveProfile(resolved, "remote");
|
||||
expect(remote?.attachOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("uses base protocol for profiles with only cdpPort", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
cdpUrl: "https://example.com:9443",
|
||||
|
||||
@@ -46,6 +46,7 @@ export type ResolvedBrowserProfile = {
|
||||
cdpIsLoopback: boolean;
|
||||
color: string;
|
||||
driver: "openclaw" | "extension";
|
||||
attachOnly: boolean;
|
||||
};
|
||||
|
||||
function normalizeHexColor(raw: string | undefined) {
|
||||
@@ -341,6 +342,7 @@ export function resolveProfile(
|
||||
cdpIsLoopback: isLoopbackHost(cdpHost),
|
||||
color: profile.color,
|
||||
driver,
|
||||
attachOnly: profile.attachOnly ?? resolved.attachOnly,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
|
||||
headless: current.resolved.headless,
|
||||
noSandbox: current.resolved.noSandbox,
|
||||
executablePath: current.resolved.executablePath ?? null,
|
||||
attachOnly: current.resolved.attachOnly,
|
||||
attachOnly: profileCtx.profile.attachOnly,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as cdpModule from "./cdp.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import * as pwAiModule from "./pw-ai-module.js";
|
||||
import type { BrowserServerState } from "./server-context.js";
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -98,6 +99,48 @@ function createJsonListFetchMock(entries: JsonListEntry[]) {
|
||||
}
|
||||
|
||||
describe("browser server-context remote profile tab operations", () => {
|
||||
it("uses profile-level attachOnly when global attachOnly is false", async () => {
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.attachOnly = false;
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpPort: 18800,
|
||||
attachOnly: true,
|
||||
color: "#FF4500",
|
||||
};
|
||||
|
||||
const reachableMock = vi.mocked(chromeModule.isChromeReachable).mockResolvedValueOnce(false);
|
||||
const launchMock = vi.mocked(chromeModule.launchOpenClawChrome);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
|
||||
await expect(ctx.forProfile("openclaw").ensureBrowserAvailable()).rejects.toThrow(
|
||||
/attachOnly is enabled/i,
|
||||
);
|
||||
expect(reachableMock).toHaveBeenCalled();
|
||||
expect(launchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps attachOnly websocket failures off the loopback ownership error path", async () => {
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.attachOnly = false;
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpPort: 18800,
|
||||
attachOnly: true,
|
||||
color: "#FF4500",
|
||||
};
|
||||
|
||||
const httpReachableMock = vi.mocked(chromeModule.isChromeReachable).mockResolvedValueOnce(true);
|
||||
const wsReachableMock = vi.mocked(chromeModule.isChromeCdpReady).mockResolvedValueOnce(false);
|
||||
const launchMock = vi.mocked(chromeModule.launchOpenClawChrome);
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
|
||||
await expect(ctx.forProfile("openclaw").ensureBrowserAvailable()).rejects.toThrow(
|
||||
/attachOnly is enabled and CDP websocket/i,
|
||||
);
|
||||
expect(httpReachableMock).toHaveBeenCalled();
|
||||
expect(wsReachableMock).toHaveBeenCalled();
|
||||
expect(launchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses Playwright tab operations when available", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
|
||||
|
||||
@@ -278,6 +278,7 @@ function createProfileContext(
|
||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||
const current = state();
|
||||
const remoteCdp = !profile.cdpIsLoopback;
|
||||
const attachOnly = profile.attachOnly;
|
||||
const isExtension = profile.driver === "extension";
|
||||
const profileState = getProfileState();
|
||||
const httpReachable = await isHttpReachable();
|
||||
@@ -303,13 +304,13 @@ function createProfileContext(
|
||||
}
|
||||
|
||||
if (!httpReachable) {
|
||||
if ((current.resolved.attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
|
||||
if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
|
||||
await opts.onEnsureAttachTarget(profile);
|
||||
if (await isHttpReachable(1200)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (current.resolved.attachOnly || remoteCdp) {
|
||||
if (attachOnly || remoteCdp) {
|
||||
throw new Error(
|
||||
remoteCdp
|
||||
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
|
||||
@@ -326,17 +327,9 @@ function createProfileContext(
|
||||
return;
|
||||
}
|
||||
|
||||
// HTTP responds but WebSocket fails - port in use by something else.
|
||||
// Skip this check for remote CDP profiles since we never own the remote process.
|
||||
if (!profileState.running && !remoteCdp) {
|
||||
throw new Error(
|
||||
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` +
|
||||
`Run action=reset-profile profile=${profile.name} to kill the process.`,
|
||||
);
|
||||
}
|
||||
|
||||
// We own it but WebSocket failed - restart
|
||||
if (current.resolved.attachOnly || remoteCdp) {
|
||||
// HTTP responds but WebSocket fails. For attachOnly/remote profiles, never perform
|
||||
// local ownership/restart handling; just run attach retries and surface attach errors.
|
||||
if (attachOnly || remoteCdp) {
|
||||
if (opts.onEnsureAttachTarget) {
|
||||
await opts.onEnsureAttachTarget(profile);
|
||||
if (await isReachable(1200)) {
|
||||
@@ -350,9 +343,18 @@ function createProfileContext(
|
||||
);
|
||||
}
|
||||
|
||||
// HTTP responds but WebSocket fails - port in use by something else.
|
||||
if (!profileState.running) {
|
||||
throw new Error(
|
||||
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` +
|
||||
`Run action=reset-profile profile=${profile.name} to kill the process.`,
|
||||
);
|
||||
}
|
||||
|
||||
// We own it but WebSocket failed - restart
|
||||
// At this point profileState.running is always non-null: the !remoteCdp guard
|
||||
// above throws when running is null, and the remoteCdp path always exits via
|
||||
// the attachOnly/remoteCdp block. Add an explicit guard for TypeScript.
|
||||
// above throws when running is null, and attachOnly/remoteCdp paths always
|
||||
// exit via the block above. Add an explicit guard for TypeScript.
|
||||
if (!profileState.running) {
|
||||
throw new Error(
|
||||
`Unexpected state for profile "${profile.name}": no running process to restart.`,
|
||||
|
||||
@@ -265,6 +265,7 @@ const TARGET_KEYS = [
|
||||
"browser.noSandbox",
|
||||
"browser.profiles",
|
||||
"browser.profiles.*.driver",
|
||||
"browser.profiles.*.attachOnly",
|
||||
"tools",
|
||||
"tools.allow",
|
||||
"tools.deny",
|
||||
|
||||
@@ -232,6 +232,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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: "clawd" or "extension" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.',
|
||||
"browser.profiles.*.attachOnly":
|
||||
"Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.",
|
||||
"browser.profiles.*.color":
|
||||
"Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.",
|
||||
"browser.evaluateEnabled":
|
||||
|
||||
@@ -111,6 +111,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"browser.profiles.*.cdpPort": "Browser Profile CDP Port",
|
||||
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL",
|
||||
"browser.profiles.*.driver": "Browser Profile Driver",
|
||||
"browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode",
|
||||
"browser.profiles.*.color": "Browser Profile Accent Color",
|
||||
tools: "Tools",
|
||||
"tools.allow": "Tool Allowlist",
|
||||
|
||||
@@ -5,6 +5,8 @@ export type BrowserProfileConfig = {
|
||||
cdpUrl?: string;
|
||||
/** Profile driver (default: openclaw). */
|
||||
driver?: "openclaw" | "extension";
|
||||
/** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */
|
||||
attachOnly?: boolean;
|
||||
/** Profile color (hex). Auto-assigned at creation. */
|
||||
color: string;
|
||||
};
|
||||
|
||||
@@ -272,6 +272,7 @@ export const OpenClawSchema = z
|
||||
cdpPort: z.number().int().min(1).max(65535).optional(),
|
||||
cdpUrl: z.string().optional(),
|
||||
driver: z.union([z.literal("clawd"), z.literal("extension")]).optional(),
|
||||
attachOnly: z.boolean().optional(),
|
||||
color: HexColorSchema,
|
||||
})
|
||||
.strict()
|
||||
|
||||
Reference in New Issue
Block a user