mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 17:44:33 +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:
@@ -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.`,
|
||||
|
||||
Reference in New Issue
Block a user