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:
Vincent Koc
2026-03-02 00:49:57 -08:00
committed by GitHub
parent 29c3ce9454
commit 5d53b61d9e
11 changed files with 96 additions and 17 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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,
};
}

View File

@@ -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,
});
});

View File

@@ -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" },

View File

@@ -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.`,

View File

@@ -265,6 +265,7 @@ const TARGET_KEYS = [
"browser.noSandbox",
"browser.profiles",
"browser.profiles.*.driver",
"browser.profiles.*.attachOnly",
"tools",
"tools.allow",
"tools.deny",

View File

@@ -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":

View File

@@ -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",

View File

@@ -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;
};

View File

@@ -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()