fix(browser): avoid restart hint for external profiles

This commit is contained in:
Peter Steinberger
2026-04-25 19:17:47 +01:00
parent d6ef1fcf24
commit 1380dc170e
4 changed files with 307 additions and 5 deletions

View File

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

View File

@@ -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<void>;
};
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<void>((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<void>((resolve) => server.close(() => resolve()));
}
});
});

View File

@@ -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"));

View File

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