diff --git a/CHANGELOG.md b/CHANGELOG.md index c3991404d6c..edbd54a8663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..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 start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc. diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index c70cc3228e2..b891f8b3d98 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -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", diff --git a/src/browser/config.ts b/src/browser/config.ts index 231188a2599..417c97f7118 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -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, }; } diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index 76a4c3f9d6a..074e7ea285d 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -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, }); }); diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts index 31fe92d82f9..f6e3d8f8d7f 100644 --- a/src/browser/server-context.remote-tab-ops.test.ts +++ b/src/browser/server-context.remote-tab-ops.test.ts @@ -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" }, diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 32c53d7874d..0dea84c715e 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -278,6 +278,7 @@ function createProfileContext( const ensureBrowserAvailable = async (): Promise => { 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.`, diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index d1099293547..0bed7956d39 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -265,6 +265,7 @@ const TARGET_KEYS = [ "browser.noSandbox", "browser.profiles", "browser.profiles.*.driver", + "browser.profiles.*.attachOnly", "tools", "tools.allow", "tools.deny", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index e5da6bbcee8..702a496cddf 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -232,6 +232,8 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 40f1cf315d6..4dd69ff2e65 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -111,6 +111,7 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index e8bc5e3cfdf..82a404037c4 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -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; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 0034b9846b3..2944cdcc685 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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()