Skip to content

Commit 3c048ef

Browse files
authored
fix(browser): preserve no-display launch diagnostics (#102946)
* fix(browser): preserve no-display launch diagnostics * chore: keep changelog release-owned
1 parent d5fb490 commit 3c048ef

29 files changed

Lines changed: 795 additions & 98 deletions

docs/tools/browser.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ main model can read the screenshot directly.
316316
current process. `OPENCLAW_BROWSER_HEADLESS=0` forces headed mode for ordinary
317317
starts and returns an actionable error on Linux hosts without a display server;
318318
an explicit `start --headless` request still wins for that one launch.
319+
- The browser-control route and programmatic client keep the no-display error's
320+
human-readable `error` and expose the stable reason
321+
`no_display_for_headed_profile`. Its `details` contain only `profile`,
322+
`requestedHeadless`, `headlessSource`, and `displayPresent`, so API clients can
323+
choose the correct remediation without matching message text.
319324
- `executablePath` can be set globally or per local managed profile. Per-profile values override `browser.executablePath`, so different managed profiles can launch different Chromium-based browsers. Both forms accept `~` for your OS home directory.
320325
- `color` (top-level and per-profile) tints the browser UI so you can see which profile is active.
321326
- Default profile is `openclaw` (managed standalone). Use `defaultProfile: "user"` to opt into the signed-in user browser.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Browser node-proxy response envelope shared by the node host and Gateway.
3+
*/
4+
import { parseBrowserErrorPayload, type BrowserNoDisplayErrorMetadata } from "./browser/errors.js";
5+
6+
/** Additive opt-in for structured browser route errors over node.invoke. */
7+
export const BROWSER_PROXY_ERROR_ENVELOPE = "browser-v1" as const;
8+
9+
export type BrowserProxyFile = {
10+
path: string;
11+
base64: string;
12+
mimeType?: string;
13+
};
14+
15+
export type BrowserProxyErrorBody =
16+
| { error: string }
17+
| ({ error: string } & BrowserNoDisplayErrorMetadata);
18+
19+
export type BrowserProxySuccess = {
20+
result: unknown;
21+
files?: BrowserProxyFile[];
22+
};
23+
24+
export type BrowserProxyFailure = {
25+
error: {
26+
status: number;
27+
body: BrowserProxyErrorBody;
28+
};
29+
};
30+
31+
export type BrowserProxyEnvelope = BrowserProxySuccess | BrowserProxyFailure;
32+
33+
function normalizeBrowserProxyErrorBody(
34+
value: unknown,
35+
fallback?: string,
36+
): BrowserProxyErrorBody | null {
37+
const parsed = parseBrowserErrorPayload(value);
38+
if (parsed) {
39+
return parsed;
40+
}
41+
return fallback ? { error: fallback } : null;
42+
}
43+
44+
/** Build a route-failure envelope while allowing only closed Browser metadata. */
45+
export function createBrowserProxyFailure(status: number, body: unknown): BrowserProxyFailure {
46+
return {
47+
error: {
48+
status,
49+
body: normalizeBrowserProxyErrorBody(body, `HTTP ${status}`) ?? { error: `HTTP ${status}` },
50+
},
51+
};
52+
}
53+
54+
/** Parse an untrusted node response without forwarding arbitrary metadata. */
55+
export function parseBrowserProxyFailure(value: unknown): BrowserProxyFailure | null {
56+
if (!value || typeof value !== "object" || Array.isArray(value)) {
57+
return null;
58+
}
59+
const error = (value as { error?: unknown }).error;
60+
if (!error || typeof error !== "object" || Array.isArray(error)) {
61+
return null;
62+
}
63+
const candidate = error as { status?: unknown; body?: unknown };
64+
if (
65+
!Number.isInteger(candidate.status) ||
66+
(candidate.status as number) < 400 ||
67+
(candidate.status as number) > 599
68+
) {
69+
return null;
70+
}
71+
const body = normalizeBrowserProxyErrorBody(candidate.body);
72+
if (!body) {
73+
return null;
74+
}
75+
return { error: { status: candidate.status as number, body } };
76+
}

extensions/browser/src/browser-tool.actions.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,18 +288,20 @@ function isChromeStaleTargetError(profile: string | undefined, err: unknown): bo
288288
if (!profile) {
289289
return false;
290290
}
291+
const status =
292+
err && typeof err === "object" && "status" in err ? (err as { status?: unknown }).status : null;
293+
const msg = String(err);
294+
const isTabNotFound = (status === 404 || msg.includes("404:")) && msg.includes("tab not found");
291295
if (profile === "user") {
292-
const msg = String(err);
293-
return msg.includes("404:") && msg.includes("tab not found");
296+
return isTabNotFound;
294297
}
295298
const cfg = browserToolActionDeps.getRuntimeConfig();
296299
const resolved = resolveBrowserConfig(cfg.browser, cfg);
297300
const browserProfile = resolveProfile(resolved, profile);
298301
if (!browserProfile || !getBrowserProfileCapabilities(browserProfile).usesChromeMcp) {
299302
return false;
300303
}
301-
const msg = String(err);
302-
return msg.includes("404:") && msg.includes("tab not found");
304+
return isTabNotFound;
303305
}
304306

305307
function stripTargetIdFromActRequest(

extensions/browser/src/browser-tool.test.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,7 @@ function nodeInvokeCall(callIndex: number): {
466466
path?: string;
467467
profile?: string;
468468
timeoutMs?: number;
469+
errorEnvelope?: string;
469470
query?: { refs?: string };
470471
body?: Record<string, unknown>;
471472
};
@@ -1019,6 +1020,7 @@ describe("browser tool snapshot maxChars", () => {
10191020
expect(request.nodeId).toBe("node-1");
10201021
expect(request.command).toBe("browser.proxy");
10211022
expect(request.params?.timeoutMs).toBe(20_000);
1023+
expect(request.params?.errorEnvelope).toBe("browser-v1");
10221024
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
10231025
});
10241026

@@ -1051,6 +1053,79 @@ describe("browser tool snapshot maxChars", () => {
10511053
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
10521054
});
10531055

1056+
it("preserves validated browser errors returned by a node proxy", async () => {
1057+
mockSingleBrowserProxyNode();
1058+
gatewayMocks.callGatewayTool.mockResolvedValueOnce({
1059+
ok: true,
1060+
payload: {
1061+
error: {
1062+
status: 409,
1063+
body: {
1064+
error: "headed mode needs a display",
1065+
reason: "no_display_for_headed_profile",
1066+
details: {
1067+
profile: "openclaw",
1068+
requestedHeadless: false,
1069+
headlessSource: "config",
1070+
displayPresent: false,
1071+
},
1072+
},
1073+
},
1074+
},
1075+
});
1076+
const tool = createBrowserTool();
1077+
1078+
const error = await tool.execute!("call-1", {
1079+
action: "start",
1080+
target: "node",
1081+
profile: "openclaw",
1082+
}).catch((err: unknown) => err);
1083+
1084+
expect(error).toMatchObject({
1085+
name: "BrowserServiceError",
1086+
message: "headed mode needs a display",
1087+
status: 409,
1088+
reason: "no_display_for_headed_profile",
1089+
details: {
1090+
profile: "openclaw",
1091+
requestedHeadless: false,
1092+
headlessSource: "config",
1093+
displayPresent: false,
1094+
},
1095+
});
1096+
});
1097+
1098+
it("drops unrecognized metadata returned by a node proxy", async () => {
1099+
mockSingleBrowserProxyNode();
1100+
gatewayMocks.callGatewayTool.mockResolvedValueOnce({
1101+
ok: true,
1102+
payload: {
1103+
error: {
1104+
status: 409,
1105+
body: {
1106+
error: "headed mode needs a display",
1107+
reason: "untrusted_reason",
1108+
details: { remediation: "run arbitrary text" },
1109+
},
1110+
},
1111+
},
1112+
});
1113+
const tool = createBrowserTool();
1114+
1115+
const error = await tool.execute!("call-1", {
1116+
action: "start",
1117+
target: "node",
1118+
profile: "openclaw",
1119+
}).catch((err: unknown) => err);
1120+
1121+
expect(error).toMatchObject({
1122+
name: "BrowserServiceError",
1123+
message: "headed mode needs a display",
1124+
});
1125+
expect(error).not.toHaveProperty("reason", "untrusted_reason");
1126+
expect(error).not.toHaveProperty("details.remediation");
1127+
});
1128+
10541129
it("returns a browser doctor report on host", async () => {
10551130
const tool = createBrowserTool();
10561131
await tool.execute?.("call-1", { action: "doctor" });
@@ -2139,7 +2214,10 @@ describe("browser tool act stale target recovery", () => {
21392214
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
21402215
});
21412216
gatewayMocks.callGatewayTool
2142-
.mockRejectedValueOnce(new Error("INVALID_REQUEST: Error: 404: tab not found"))
2217+
.mockResolvedValueOnce({
2218+
ok: true,
2219+
payload: { error: { status: 404, body: { error: "tab not found" } } },
2220+
})
21432221
.mockResolvedValueOnce({
21442222
ok: true,
21452223
payload: { result: { tabs: [{ targetId: "only-tab" }] } },

extensions/browser/src/browser-tool.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
* maps high-level actions onto browser control client calls.
66
*/
77
import crypto from "node:crypto";
8+
import {
9+
BROWSER_PROXY_ERROR_ENVELOPE,
10+
parseBrowserProxyFailure,
11+
type BrowserProxyEnvelope,
12+
type BrowserProxyFile,
13+
type BrowserProxySuccess,
14+
} from "./browser-proxy-envelope.js";
815
import { describeBrowserTool } from "./browser-tool-description.js";
916
import {
1017
executeActAction,
@@ -56,6 +63,7 @@ import {
5663
trackSessionBrowserTab,
5764
untrackSessionBrowserTab,
5865
} from "./browser-tool.runtime.js";
66+
import { BrowserServiceError } from "./browser/client-fetch.js";
5967
import { DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS } from "./browser/constants.js";
6068
import { parseBrowserNavigationUrl } from "./browser/navigation-guard.js";
6169
import { normalizeBrowserScreenshot } from "./browser/screenshot.js";
@@ -249,17 +257,6 @@ function readActRequestParam(params: Record<string, unknown>) {
249257
return request as Parameters<typeof browserAct>[1];
250258
}
251259

252-
type BrowserProxyFile = {
253-
path: string;
254-
base64: string;
255-
mimeType?: string;
256-
};
257-
258-
type BrowserProxyResult = {
259-
result: unknown;
260-
files?: BrowserProxyFile[];
261-
};
262-
263260
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
264261
const BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS = 5_000;
265262

@@ -360,7 +357,7 @@ async function callBrowserProxy(params: {
360357
body?: unknown;
361358
timeoutMs?: number;
362359
profile?: string;
363-
}): Promise<BrowserProxyResult> {
360+
}): Promise<BrowserProxySuccess> {
364361
const proxyTimeoutMs =
365362
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
366363
? Math.max(1, Math.floor(params.timeoutMs))
@@ -379,27 +376,35 @@ async function callBrowserProxy(params: {
379376
body: params.body,
380377
timeoutMs: proxyTimeoutMs,
381378
profile: params.profile,
379+
errorEnvelope: BROWSER_PROXY_ERROR_ENVELOPE,
382380
},
383381
idempotencyKey: crypto.randomUUID(),
384382
},
385383
{ scopes: ["operator.admin"] },
386384
);
387385
const parsed = unwrapBrowserProxyPayload(payload);
386+
const failure = parseBrowserProxyFailure(parsed);
387+
if (failure) {
388+
const { status, body } = failure.error;
389+
throw new BrowserServiceError(body.error, "reason" in body ? body : undefined, status);
390+
}
388391
if (!parsed || typeof parsed !== "object" || !("result" in parsed)) {
389392
throw new Error("browser proxy failed");
390393
}
391394
return parsed;
392395
}
393396

394-
function unwrapBrowserProxyPayload(payload: { payload?: unknown; payloadJSON?: unknown } | null) {
397+
function unwrapBrowserProxyPayload(
398+
payload: { payload?: unknown; payloadJSON?: unknown } | null,
399+
): BrowserProxyEnvelope | null {
395400
if (payload?.payload !== undefined) {
396-
return payload.payload;
401+
return payload.payload as BrowserProxyEnvelope;
397402
}
398403
if (typeof payload?.payloadJSON !== "string" || !payload.payloadJSON.trim()) {
399404
return null;
400405
}
401406
try {
402-
return JSON.parse(payload.payloadJSON) as BrowserProxyResult;
407+
return JSON.parse(payload.payloadJSON) as BrowserProxyEnvelope;
403408
} catch {
404409
return null;
405410
}

extensions/browser/src/browser/chrome.internal.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
stopOpenClawChrome,
6969
} from "./chrome.js";
7070
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
71+
import { BROWSER_ERROR_REASONS, BrowserProfileUnavailableError } from "./errors.js";
7172

7273
const CHROME_TEST_WS_MAX_PAYLOAD_BYTES = 1024 * 1024;
7374

@@ -573,6 +574,34 @@ describe("chrome.ts internal", () => {
573574
expect(spawnMock).not.toHaveBeenCalled();
574575
});
575576

577+
it("returns structured no-display details before spawning headed Chrome", async () => {
578+
const profile = {
579+
...makeProfile(51110),
580+
driver: "openclaw",
581+
attachOnly: false,
582+
headless: false,
583+
headlessSource: "profile",
584+
} as ResolvedBrowserProfile;
585+
const error = await launchOpenClawChrome(makeResolved(), profile, {
586+
platform: "linux",
587+
env: { DISPLAY: undefined, WAYLAND_DISPLAY: undefined },
588+
}).catch((err: unknown) => err);
589+
590+
expect(error).toBeInstanceOf(BrowserProfileUnavailableError);
591+
expect(error).toMatchObject({
592+
metadata: {
593+
reason: BROWSER_ERROR_REASONS.noDisplayForHeadedProfile,
594+
details: {
595+
profile: profile.name,
596+
requestedHeadless: false,
597+
headlessSource: "profile",
598+
displayPresent: false,
599+
},
600+
},
601+
});
602+
expect(spawnMock).not.toHaveBeenCalled();
603+
});
604+
576605
it("throws when no supported browser executable is found", async () => {
577606
// Strip all candidate executables — override config so no explicit
578607
// path is set, then mock existsSync to return false for everything.
@@ -2032,7 +2061,6 @@ describe("chrome.ts internal", () => {
20322061
);
20332062
});
20342063
const profile = makeLoopbackProfile(54324);
2035-
const { BrowserProfileUnavailableError } = await import("./errors.js");
20362064
await expect(launchOpenClawChrome(makeResolved(), profile)).rejects.toBeInstanceOf(
20372065
BrowserProfileUnavailableError,
20382066
);

extensions/browser/src/browser/chrome.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ import {
7777
DEFAULT_OPENCLAW_BROWSER_COLOR,
7878
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
7979
} from "./constants.js";
80-
import { BrowserProfileUnavailableError } from "./errors.js";
80+
import { BROWSER_ERROR_REASONS, BrowserProfileUnavailableError } from "./errors.js";
8181
import { ensureOutputDirectory } from "./output-directories.js";
8282
import { DEFAULT_DOWNLOAD_DIR } from "./paths.js";
8383

@@ -973,7 +973,17 @@ export async function launchOpenClawChrome(
973973
launchOptions,
974974
);
975975
if (missingDisplayError) {
976-
throw new BrowserProfileUnavailableError(missingDisplayError);
976+
throw new BrowserProfileUnavailableError(missingDisplayError.message, {
977+
metadata: {
978+
reason: BROWSER_ERROR_REASONS.noDisplayForHeadedProfile,
979+
details: {
980+
profile: profile.name,
981+
requestedHeadless: false,
982+
headlessSource: missingDisplayError.headlessSource,
983+
displayPresent: false,
984+
},
985+
},
986+
});
977987
}
978988

979989
// Surface `loopbackMode=block` before spawning Chrome. The CDP fetch and

0 commit comments

Comments
 (0)