From 1380dc170eb2916be87be3029b8d3eafc4ff3059 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 19:17:47 +0100 Subject: [PATCH] fix(browser): avoid restart hint for external profiles --- CHANGELOG.md | 3 + .../client-fetch.attach-only.e2e.test.ts | 69 ++++++ .../client-fetch.loopback-auth.test.ts | 199 +++++++++++++++++- .../browser/src/browser/client-fetch.ts | 41 +++- 4 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f8a1a2738..0dba203b511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,9 @@ Docs: https://docs.openclaw.ai Thanks @FuncWei. - WhatsApp/TTS: send visible text separately from PTT voice-note audio instead of relying on hidden voice-note captions. Fixes #51081. +- Browser/client: avoid telling agents to restart OpenClaw for dispatcher + timeouts on external browser profiles such as `attachOnly`, remote CDP, and + existing-session. (#40815) Thanks @0xsline. - Agents/TTS: preserve `[[audio_as_voice]]` directives on trusted text tool-result `MEDIA:` payloads so generated audio still delivers as a voice note. (#46535) Thanks @azade-c. diff --git a/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts b/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts new file mode 100644 index 00000000000..29ffd282b97 --- /dev/null +++ b/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts @@ -0,0 +1,69 @@ +import fs from "node:fs/promises"; +import net from "node:net"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { clearConfigCache } from "../../../../src/config/config.js"; +import { createTempHomeEnv } from "../../test-support.js"; +import { fetchBrowserJson } from "./client-fetch.js"; + +type TempHome = { + home: string; + restore: () => Promise; +}; + +describe("browser client fetch attachOnly diagnostics", () => { + let tempHome: TempHome | undefined; + + afterEach(async () => { + clearConfigCache(); + await tempHome?.restore(); + tempHome = undefined; + }); + + it("does not suggest gateway restart when an attachOnly CDP endpoint hangs", async () => { + tempHome = await createTempHomeEnv("openclaw-browser-client-fetch-live-"); + const server = net.createServer((socket) => { + socket.on("error", () => {}); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as { port: number }).port; + const configPath = path.join(tempHome.home, ".openclaw", "openclaw.json"); + await fs.writeFile( + configPath, + JSON.stringify( + { + browser: { + enabled: true, + defaultProfile: "hung", + attachOnly: true, + profiles: { + hung: { + cdpUrl: `http://127.0.0.1:${port}`, + attachOnly: true, + color: "#00AA00", + }, + }, + }, + }, + null, + 2, + ), + ); + process.env.OPENCLAW_CONFIG_PATH = configPath; + clearConfigCache(); + + try { + const thrown = await fetchBrowserJson("/tabs?profile=hung", { timeoutMs: 200 }).catch( + (err: unknown) => err, + ); + expect(thrown).toBeInstanceOf(Error); + const message = thrown instanceof Error ? thrown.message : String(thrown); + expect(message).toContain("browser profile is external to OpenClaw"); + expect(message).toContain("Restarting the OpenClaw gateway will not launch it"); + expect(message).not.toContain("Restart the OpenClaw gateway"); + expect(message).not.toContain("Do NOT retry the browser tool"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts b/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts index 4b8f1f3e99c..18f4093f4cb 100644 --- a/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts +++ b/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "../../test-support/browser-security-runtime.mock.js"; +import type { OpenClawConfig } from "../config/config.js"; import type { BrowserDispatchResponse } from "./routes/dispatcher.js"; vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { @@ -28,7 +29,7 @@ function okDispatchResponse(): BrowserDispatchResponse { } const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({ + loadConfig: vi.fn<() => OpenClawConfig>(() => ({ gateway: { auth: { token: "loopback-token", @@ -215,6 +216,202 @@ describe("fetchBrowserJson loopback auth", () => { }); }); + it("avoids restart-gateway guidance for attachOnly dispatcher timeouts", async () => { + mocks.loadConfig.mockReturnValue({ + browser: { + attachOnly: true, + defaultProfile: "manual", + profiles: { + manual: { + cdpUrl: "http://127.0.0.1:9222", + attachOnly: true, + color: "#00AA00", + }, + }, + }, + }); + mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout")); + + await expectThrownBrowserFetchError( + () => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=manual"), + { + contains: [ + "Chrome CDP handshake timeout", + "browser profile is external to OpenClaw", + "Restarting the OpenClaw gateway will not launch it", + ], + omits: ["Restart the OpenClaw gateway", "Do NOT retry the browser tool"], + }, + ); + }); + + it("avoids restart-gateway guidance for existing-session dispatcher timeouts", async () => { + mocks.loadConfig.mockReturnValue({ + browser: { + defaultProfile: "user", + profiles: { + user: { + driver: "existing-session", + attachOnly: true, + color: "#00AA00", + }, + }, + }, + }); + mocks.dispatch.mockRejectedValueOnce(new DOMException("operation aborted", "AbortError")); + + await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), { + contains: [ + "operation aborted", + "browser profile is external to OpenClaw", + "Restarting the OpenClaw gateway will not launch it", + ], + omits: ["Restart the OpenClaw gateway", "Do NOT retry the browser tool"], + }); + }); + + it("avoids restart-gateway guidance for remote CDP dispatcher timeouts", async () => { + mocks.loadConfig.mockReturnValue({ + browser: { + defaultProfile: "remote", + profiles: { + remote: { + cdpUrl: "https://browserless.example/chrome?token=test", + color: "#00AA00", + }, + }, + }, + }); + mocks.dispatch.mockRejectedValueOnce(new Error("timed out")); + + await expectThrownBrowserFetchError( + () => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=remote"), + { + contains: [ + "timed out", + "browser profile is external to OpenClaw", + "Restarting the OpenClaw gateway will not launch it", + ], + omits: ["Restart the OpenClaw gateway", "Do NOT retry the browser tool"], + }, + ); + }); + + it("keeps restart-gateway guidance for managed local dispatcher timeouts", async () => { + mocks.loadConfig.mockReturnValue({ + browser: { + defaultProfile: "openclaw", + profiles: { + openclaw: { + cdpPort: 18800, + color: "#FF4500", + }, + }, + }, + }); + mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout")); + + await expectThrownBrowserFetchError( + () => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=openclaw"), + { + contains: ["Chrome CDP handshake timeout", "Restart the OpenClaw gateway"], + omits: ["browser profile is external to OpenClaw", "Do NOT retry the browser tool"], + }, + ); + }); + + it("keeps restart-gateway guidance when dispatcher profile resolution fails", async () => { + mocks.loadConfig.mockImplementation(() => { + throw new Error("config unavailable"); + }); + mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout")); + + await expectThrownBrowserFetchError( + () => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=manual"), + { + contains: ["Chrome CDP handshake timeout", "Restart the OpenClaw gateway"], + omits: ["browser profile is external to OpenClaw", "Do NOT retry the browser tool"], + }, + ); + }); + + it("keeps restart-gateway guidance for unknown dispatcher profiles", async () => { + mocks.loadConfig.mockReturnValue({ + browser: { + defaultProfile: "openclaw", + profiles: { + openclaw: { + cdpPort: 18800, + color: "#FF4500", + }, + }, + }, + }); + mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout")); + + await expectThrownBrowserFetchError( + () => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=missing"), + { + contains: ["Chrome CDP handshake timeout", "Restart the OpenClaw gateway"], + omits: ["browser profile is external to OpenClaw", "Do NOT retry the browser tool"], + }, + ); + }); + + it("uses the default external profile when dispatcher request omits profile", async () => { + mocks.loadConfig.mockReturnValue({ + browser: { + defaultProfile: "manual", + profiles: { + manual: { + cdpUrl: "http://127.0.0.1:9222", + attachOnly: true, + color: "#00AA00", + }, + }, + }, + }); + mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP handshake timeout")); + + await expectThrownBrowserFetchError(() => fetchBrowserJson<{ ok: boolean }>("/tabs"), { + contains: [ + "Chrome CDP handshake timeout", + "browser profile is external to OpenClaw", + "Restarting the OpenClaw gateway will not launch it", + ], + omits: ["Restart the OpenClaw gateway", "Do NOT retry the browser tool"], + }); + }); + + it("keeps no-retry hint but not restart guidance for persistent external profile failures", async () => { + mocks.loadConfig.mockReturnValue({ + browser: { + attachOnly: true, + defaultProfile: "manual", + profiles: { + manual: { + cdpUrl: "http://127.0.0.1:9222", + attachOnly: true, + color: "#00AA00", + }, + }, + }, + }); + mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP connection refused")); + + await expectThrownBrowserFetchError( + () => fetchBrowserJson<{ ok: boolean }>("/tabs?profile=manual"), + { + contains: [ + "Chrome CDP connection refused", + "browser profile is external to OpenClaw", + "Do NOT retry the browser tool", + ], + omits: ["Restart the OpenClaw gateway"], + }, + ); + }); + it("keeps no-retry hint for persistent dispatcher failures", async () => { mocks.dispatch.mockRejectedValueOnce(new Error("Chrome CDP connection refused")); diff --git a/extensions/browser/src/browser/client-fetch.ts b/extensions/browser/src/browser/client-fetch.ts index e20d8e72d79..259896a6320 100644 --- a/extensions/browser/src/browser/client-fetch.ts +++ b/extensions/browser/src/browser/client-fetch.ts @@ -5,6 +5,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { loadConfig } from "../config/config.js"; import { isLoopbackHost } from "../gateway/net.js"; import { getBridgeAuthForPort } from "./bridge-auth-registry.js"; +import { resolveBrowserConfig, resolveProfile } from "./config.js"; import { resolveBrowserControlAuth } from "./control-auth.js"; import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js"; @@ -105,7 +106,39 @@ function isRateLimitStatus(status: number): boolean { return status === 429; } -function resolveBrowserFetchOperatorHint(url: string): string { +type BrowserControlOwnership = "local-managed" | "external-browser" | "unknown"; + +function resolveDispatcherBrowserControlOwnership(url: string): BrowserControlOwnership { + if (isAbsoluteHttp(url)) { + return "unknown"; + } + try { + const cfg = loadConfig(); + const resolved = resolveBrowserConfig(cfg?.browser, cfg); + const parsed = new URL(url, "http://localhost"); + const requestedProfile = parsed.searchParams.get("profile")?.trim(); + const profile = resolveProfile(resolved, requestedProfile || resolved.defaultProfile); + if (!profile) { + return "unknown"; + } + return profile.driver === "openclaw" && profile.cdpIsLoopback && !profile.attachOnly + ? "local-managed" + : "external-browser"; + } catch { + return "unknown"; + } +} + +function resolveBrowserFetchOperatorHint( + url: string, + opts?: { ownership?: BrowserControlOwnership }, +): string { + if (opts?.ownership === "external-browser") { + return ( + "The browser profile is external to OpenClaw; make sure its browser/CDP endpoint " + + "is running and reachable. Restarting the OpenClaw gateway will not launch it." + ); + } const isLocal = !isAbsoluteHttp(url); return isLocal ? `Restart the OpenClaw gateway (OpenClaw.app menubar, or \`${formatCliCommand("openclaw gateway")}\`).` @@ -159,10 +192,10 @@ async function discardResponseBody(res: Response): Promise { function enhanceDispatcherPathError(url: string, err: unknown): Error { const msg = normalizeErrorMessage(err); const kind = classifyBrowserFetchFailure(err); + const ownership = resolveDispatcherBrowserControlOwnership(url); + const operatorHint = resolveBrowserFetchOperatorHint(url, { ownership }); const suffix = - kind === "persistent" - ? `${resolveBrowserFetchOperatorHint(url)} ${BROWSER_TOOL_MODEL_HINT}` - : resolveBrowserFetchOperatorHint(url); + kind === "persistent" ? `${operatorHint} ${BROWSER_TOOL_MODEL_HINT}` : operatorHint; const normalized = msg.endsWith(".") ? msg : `${msg}.`; return new Error(`${normalized} ${suffix}`, err instanceof Error ? { cause: err } : undefined); }