mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(browser): avoid restart hint for external profiles
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user