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

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