fix(onboard): open browser handoff in display-less WSL (#124704)

* fix(onboard): use canonical browser open support

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* test(onboard): type WSL browser detector mock

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(onboard): preserve WSL detection cache

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(onboard): remove stale browser platform probe

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* test(onboard): tolerate injected browser probes

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-16 22:33:46 -07:00
committed by GitHub
parent 816ae22921
commit 0c4e2f6681
6 changed files with 175 additions and 83 deletions
+3 -2
View File
@@ -150,10 +150,11 @@ the same plan the conversational `openclaw setup` chat would apply on "yes" —
then offers plugin and skill recommendations from installed apps; app names
are matched through your configured model and ClawHub search, and the step can
be disabled with [`wizard.appRecommendations`](/gateway/configuration-reference#wizard).
In a macOS, Linux, or Windows desktop session, it then opens the authenticated
When the platform has a supported browser opener, it then opens the authenticated
Control UI dashboard and waits up to 60 seconds for the browser client to
connect. The short-lived handoff gives that exact signed browser a durable
administrator credential. On headless Linux or over SSH, it prints a prominent
administrator credential. This includes display-less WSL when `wslview` is installed.
On headless Linux, WSL without an opener, or over SSH without a display, it prints a prominent
copy-pasteable dashboard URL, including an SSH port-forward command for a
loopback Gateway, and waits up to five minutes. A successful connection
continues in the browser; an unreachable Gateway or a timeout falls back to the
+50 -30
View File
@@ -6,7 +6,6 @@ import {
} from "../../packages/gateway-protocol/src/client-info.js";
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import {
detectGraphicalSession,
resolveConnectedControlUiPresenceKeys,
runBrowserHatchHandoff,
} from "./onboard-browser-handoff.js";
@@ -15,6 +14,7 @@ const sharedMocks = vi.hoisted(() => ({
callGateway: vi.fn(),
waitForControlUiDocument: vi.fn(),
issueControlUiBrowserHandoff: vi.fn(),
detectBrowserOpenSupport: vi.fn(),
resolveAdvertisedLanHostCore: vi.fn(),
resolveAdvertisedControlUiLinks: vi.fn(),
}));
@@ -36,6 +36,7 @@ vi.mock("./control-ui-handoff.js", async (importOriginal) => ({
vi.mock("./onboard-helpers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./onboard-helpers.js")>()),
detectBrowserOpenSupport: sharedMocks.detectBrowserOpenSupport,
resolveAdvertisedControlUiLinks: sharedMocks.resolveAdvertisedControlUiLinks,
}));
@@ -57,35 +58,12 @@ beforeEach(() => {
browserUrl: `${url}#bootstrapToken=one-time-bootstrap`,
expiresAtMs: 123_456,
}));
sharedMocks.detectBrowserOpenSupport.mockReset().mockResolvedValue({ ok: false });
sharedMocks.resolveAdvertisedLanHostCore.mockReset();
sharedMocks.resolveAdvertisedLanHostCore.mockResolvedValue(null);
sharedMocks.resolveAdvertisedControlUiLinks.mockReset();
});
describe("detectGraphicalSession", () => {
it.each([
{ platform: "darwin", env: {}, expected: true },
{ platform: "darwin", env: { SSH_CONNECTION: "client server" }, expected: false },
{ platform: "darwin", env: { SSH_TTY: "/dev/ttys001" }, expected: false },
{ platform: "linux", env: {}, expected: false },
{ platform: "linux", env: { DISPLAY: ":0" }, expected: true },
{ platform: "linux", env: { WAYLAND_DISPLAY: "wayland-0" }, expected: true },
{
platform: "linux",
env: { DISPLAY: ":0", SSH_CONNECTION: "client server" },
expected: false,
},
{ platform: "win32", env: {}, expected: true },
{ platform: "win32", env: { SSH_TTY: "ssh" }, expected: false },
] satisfies Array<{
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
expected: boolean;
}>)("$platform with $env reports graphical=$expected", ({ platform, env, expected }) => {
expect(detectGraphicalSession(env, platform)).toBe(expected);
});
});
const connectedControlUiPresence = {
host: GATEWAY_CLIENT_IDS.CONTROL_UI,
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
@@ -151,8 +129,10 @@ describe("runBrowserHatchHandoff", () => {
it.each([
{ platform: "darwin" as const, env: {} },
{ platform: "linux" as const, env: { DISPLAY: ":0" } },
{ platform: "linux" as const, env: { WSL_DISTRO_NAME: "Ubuntu" } },
{ platform: "win32" as const, env: {} },
])("opens once in a $platform GUI session", async ({ platform, env }) => {
sharedMocks.detectBrowserOpenSupport.mockResolvedValueOnce({ ok: true, command: "opener" });
const prompter = createWizardPrompter();
const openBrowser = vi.fn(async () => true);
const probePresence = vi
@@ -173,6 +153,9 @@ describe("runBrowserHatchHandoff", () => {
expect(result).toEqual({ handedOff: true });
expect(openBrowser).toHaveBeenCalledOnce();
expect(sharedMocks.detectBrowserOpenSupport).toHaveBeenCalledWith(
expect.objectContaining({ env, platform }),
);
expect(openBrowser).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=one-time-bootstrap",
);
@@ -220,10 +203,7 @@ describe("runBrowserHatchHandoff", () => {
}
});
it.each([
{ name: "headless Linux", env: {} },
{ name: "Linux SSH", env: { DISPLAY: ":0", SSH_CONNECTION: "client server" } },
])("prints only the clean URL and waits longer in $name", async ({ env }) => {
it("prints only the clean URL and waits longer in headless Linux", async () => {
const prompter = createWizardPrompter();
const openBrowser = vi.fn(async () => true);
const probePresence = vi.fn(async () => ({ reachable: true as const, clientKeys: [] }));
@@ -235,7 +215,7 @@ describe("runBrowserHatchHandoff", () => {
const result = await runBrowserHatchHandoff(
{ config: {}, prompter },
{
env,
env: {},
platform: "linux",
openBrowser,
resolveTarget: async () => target,
@@ -267,7 +247,45 @@ describe("runBrowserHatchHandoff", () => {
expect(displayed).not.toContain("#bootstrapToken=");
});
it("attempts a forwarded-display SSH browser open and uses the GUI timeout on failure", async () => {
sharedMocks.detectBrowserOpenSupport.mockResolvedValueOnce({ ok: true, command: "xdg-open" });
const prompter = createWizardPrompter();
const openBrowser = vi.fn(async () => false);
const pollForClient = vi.fn(async () => ({
connected: false as const,
reason: "timeout" as const,
}));
const env = {
DISPLAY: "localhost:10.0",
SSH_CONNECTION: "192.0.2.1 12345 192.0.2.2 22",
};
const result = await runBrowserHatchHandoff(
{ config: {}, prompter },
{
env,
platform: "linux",
openBrowser,
resolveTarget: async () => target,
probePresence: async () => ({ reachable: true, clientKeys: [] }),
pollForClient,
},
);
expect(result).toEqual({ handedOff: false, reason: "timeout" });
expect(sharedMocks.detectBrowserOpenSupport).toHaveBeenCalledWith(
expect.objectContaining({ env, platform: "linux" }),
);
expect(openBrowser).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=one-time-bootstrap",
);
expect(pollForClient).toHaveBeenCalledWith(
expect.objectContaining({ target, timeoutMs: 60_000 }),
);
});
it("prints the URL when browser launch fails", async () => {
sharedMocks.detectBrowserOpenSupport.mockResolvedValueOnce({ ok: true, command: "open" });
const prompter = createWizardPrompter();
await runBrowserHatchHandoff(
@@ -519,6 +537,7 @@ describe("runBrowserHatchHandoff", () => {
});
it("bounds the final presence probe by the remaining handoff time", async () => {
sharedMocks.detectBrowserOpenSupport.mockResolvedValueOnce({ ok: true, command: "open" });
const prompter = createWizardPrompter();
const probeTimeouts: number[] = [];
let elapsedMs = 0;
@@ -645,6 +664,7 @@ describe("runBrowserHatchHandoff", () => {
});
it("fails safely when a GUI browser bootstrap cannot be issued", async () => {
sharedMocks.detectBrowserOpenSupport.mockResolvedValueOnce({ ok: true, command: "open" });
const prompter = createWizardPrompter();
sharedMocks.issueControlUiBrowserHandoff.mockRejectedValue(new Error("state unavailable"));
const openBrowser = vi.fn();
+7 -24
View File
@@ -20,6 +20,7 @@ import {
type ControlUiHandoffTarget,
} from "./control-ui-handoff.js";
import {
detectBrowserOpenSupport,
formatControlUiSshHint,
openUrl,
resolveAdvertisedControlUiLinks,
@@ -81,24 +82,6 @@ type BrowserHatchHandoffDeps = {
sleep?: (ms: number) => Promise<void>;
};
function hasSshSession(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.SSH_CONNECTION || env.SSH_TTY);
}
/** Pure graphical-session detection used before attempting a browser launch. */
export function detectGraphicalSession(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): boolean {
if (hasSshSession(env)) {
return false;
}
if (platform === "darwin" || platform === "win32") {
return true;
}
if (platform === "linux") {
return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
}
return false;
}
async function resolveBrowserHatchTarget(
config: OpenClawConfig,
env: NodeJS.ProcessEnv,
@@ -233,11 +216,11 @@ export async function runBrowserHatchHandoff(
deps: BrowserHatchHandoffDeps = {},
): Promise<BrowserHatchHandoffResult> {
const env = deps.env ?? process.env;
const platform = deps.platform ?? process.platform;
const graphical = detectGraphicalSession(env, platform);
if (params.suppressTokenOutput === true || params.config.gateway?.controlUi?.enabled === false) {
return { handedOff: false, reason: "target-unavailable" };
}
const browserSupport = await detectBrowserOpenSupport(deps);
const canOpenBrowser = browserSupport.ok;
let target: BrowserHatchTarget;
try {
target = await (deps.resolveTarget ?? resolveBrowserHatchTarget)(params.config, env);
@@ -274,7 +257,7 @@ export async function runBrowserHatchHandoff(
}
let opened = false;
if (graphical) {
if (canOpenBrowser) {
try {
const browserHandoff = await (deps.issueBrowserHandoff ?? issueControlUiBrowserHandoff)(
target.dashboardUrl,
@@ -294,9 +277,9 @@ export async function runBrowserHatchHandoff(
const remoteBind = bind === "lan" || bind === "tailnet" || bind === "custom";
// Plain HTTP on a remote host cannot create the device identity required by
// the Control UI. Keep those browsers on a tunneled localhost secure context.
const directRemoteDisplay = !graphical && remoteBind && target.tlsConfig?.enabled === true;
const directRemoteDisplay = !canOpenBrowser && remoteBind && target.tlsConfig?.enabled === true;
const tunnelHint =
!graphical && !directRemoteDisplay
!canOpenBrowser && !directRemoteDisplay
? (target.sshHint ??
(remoteBind
? formatControlUiSshHint({
@@ -335,7 +318,7 @@ export async function runBrowserHatchHandoff(
const wait = await (deps.pollForClient ?? waitForDashboardClient)({
target,
baselineClientKeys: new Set(baseline.clientKeys),
timeoutMs: graphical ? GUI_HANDOFF_TIMEOUT_MS : HEADLESS_HANDOFF_TIMEOUT_MS,
timeoutMs: canOpenBrowser ? GUI_HANDOFF_TIMEOUT_MS : HEADLESS_HANDOFF_TIMEOUT_MS,
probe: probePresence,
...(deps.now ? { now: deps.now } : {}),
...(deps.sleep ? { sleep: deps.sleep } : {}),
+73 -6
View File
@@ -3,10 +3,13 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SpawnResult } from "../process/exec-result.js";
const { detectBinaryMock, getWindowsInstallRootsMock, runCommandWithTimeoutMock } = vi.hoisted(
() => ({
detectBinaryMock: vi.fn(async () => false),
type DetectBinary = typeof import("./detect-binary.js").detectBinary;
const { detectBinaryMock, getWindowsInstallRootsMock, readFileMock, runCommandWithTimeoutMock } =
vi.hoisted(() => ({
detectBinaryMock: vi.fn<DetectBinary>(async () => false),
getWindowsInstallRootsMock: vi.fn(() => ({ systemRoot: "C:\\Windows" })),
readFileMock: vi.fn(async () => "6.8.0-generic"),
runCommandWithTimeoutMock: vi.fn<() => Promise<SpawnResult>>(async () => ({
stdout: "",
stderr: "",
@@ -15,8 +18,7 @@ const { detectBinaryMock, getWindowsInstallRootsMock, runCommandWithTimeoutMock
killed: false,
termination: "exit",
})),
}),
);
}));
vi.mock("./detect-binary.js", () => ({
detectBinary: detectBinaryMock,
@@ -33,13 +35,25 @@ vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: runCommandWithTimeoutMock,
}));
import { openUrl, resolveBrowserOpenCommand } from "./browser-open.js";
vi.mock("node:fs/promises", async () => {
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
return {
...actual,
default: { ...actual, readFile: readFileMock },
readFile: readFileMock,
};
});
import { detectBrowserOpenSupport, openUrl, resolveBrowserOpenCommand } from "./browser-open.js";
import { resetWSLStateForTests } from "./wsl.js";
afterEach(() => {
resetWSLStateForTests();
vi.restoreAllMocks();
vi.unstubAllEnvs();
detectBinaryMock.mockReset().mockResolvedValue(false);
getWindowsInstallRootsMock.mockReset().mockReturnValue({ systemRoot: "C:\\Windows" });
readFileMock.mockReset().mockResolvedValue("6.8.0-generic");
runCommandWithTimeoutMock.mockReset().mockResolvedValue({
stdout: "",
stderr: "",
@@ -93,6 +107,45 @@ describe("openUrl", () => {
});
describe("resolveBrowserOpenCommand", () => {
it("retains process-level WSL detection caching through the resolver", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
vi.stubEnv("DISPLAY", "");
vi.stubEnv("WAYLAND_DISPLAY", "");
vi.stubEnv("WSL_INTEROP", "");
vi.stubEnv("WSL_DISTRO_NAME", "");
vi.stubEnv("WSLENV", "");
await expect(resolveBrowserOpenCommand()).resolves.toEqual({
argv: null,
reason: "no-display",
});
await expect(resolveBrowserOpenCommand()).resolves.toEqual({
argv: null,
reason: "no-display",
});
expect(readFileMock).toHaveBeenCalledTimes(1);
});
it("reports display-less WSL support only when wslview is installed", async () => {
detectBinaryMock.mockImplementation(async (binary) => binary === "wslview");
await expect(
detectBrowserOpenSupport({
platform: "linux",
env: { WSL_DISTRO_NAME: "Ubuntu" },
}),
).resolves.toEqual({ ok: true, command: "wslview" });
detectBinaryMock.mockResolvedValue(false);
await expect(
detectBrowserOpenSupport({
platform: "linux",
env: { WSL_DISTRO_NAME: "Ubuntu" },
}),
).resolves.toEqual({ ok: false, reason: "wsl-no-wslview" });
});
it("does not resolve Windows browser launching through a relative SystemRoot", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
vi.stubEnv("SystemRoot", ".\\fake-root");
@@ -136,4 +189,18 @@ describe("resolveBrowserOpenCommand", () => {
expect(resolved).toEqual({ argv: null, reason: "ssh-no-display" });
});
it("resolves xdg-open over Linux SSH with a forwarded display", async () => {
detectBinaryMock.mockImplementation(async (binary) => binary === "xdg-open");
const resolved = await resolveBrowserOpenCommand({
platform: "linux",
env: {
DISPLAY: "localhost:10.0",
SSH_CONNECTION: "192.0.2.1 12345 192.0.2.2 22",
},
});
expect(resolved).toEqual({ argv: ["xdg-open"], command: "xdg-open" });
});
});
+17 -10
View File
@@ -19,6 +19,11 @@ type BrowserOpenSupport = {
command?: string;
};
type BrowserOpenEnvironment = {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
};
function shouldSkipBrowserOpenInTests(): boolean {
if (process.env.VITEST) {
return true;
@@ -44,13 +49,13 @@ function normalizeBrowserOpenUrl(raw: string): string | null {
}
/** Resolve the platform command used to open an HTTP(S) URL in a browser. */
export async function resolveBrowserOpenCommand(): Promise<BrowserOpenCommand> {
const platform = process.platform;
const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
const isSsh =
Boolean(process.env.SSH_CLIENT) ||
Boolean(process.env.SSH_TTY) ||
Boolean(process.env.SSH_CONNECTION);
export async function resolveBrowserOpenCommand(
environment: BrowserOpenEnvironment = {},
): Promise<BrowserOpenCommand> {
const platform = environment.platform ?? process.platform;
const env = environment.env ?? process.env;
const hasDisplay = Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
const isSsh = Boolean(env.SSH_CLIENT) || Boolean(env.SSH_TTY) || Boolean(env.SSH_CONNECTION);
if (isSsh && !hasDisplay && platform !== "win32" && platform !== "darwin") {
return { argv: null, reason: "ssh-no-display" };
@@ -70,7 +75,7 @@ export async function resolveBrowserOpenCommand(): Promise<BrowserOpenCommand> {
}
if (platform === "linux") {
const wsl = await isWSL();
const wsl = await isWSL(environment);
if (!hasDisplay && !wsl) {
return { argv: null, reason: "no-display" };
}
@@ -93,8 +98,10 @@ export async function resolveBrowserOpenCommand(): Promise<BrowserOpenCommand> {
}
/** Report whether browser opening is currently available. */
export async function detectBrowserOpenSupport(): Promise<BrowserOpenSupport> {
const resolved = await resolveBrowserOpenCommand();
export async function detectBrowserOpenSupport(
environment: BrowserOpenEnvironment = {},
): Promise<BrowserOpenSupport> {
const resolved = await resolveBrowserOpenCommand(environment);
if (!resolved.argv) {
return { ok: false, reason: resolved.reason };
}
+25 -11
View File
@@ -52,25 +52,39 @@ export function isWSL2Sync(): boolean {
}
/** Asynchronously detects WSL from env vars and `/proc/sys/kernel/osrelease`, with process cache. */
export async function isWSL(): Promise<boolean> {
if (wslCached !== null) {
export async function isWSL(
environment: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {},
): Promise<boolean> {
const cacheProcessEnvironment =
environment.env === undefined && environment.platform === undefined;
if (cacheProcessEnvironment && wslCached !== null) {
return wslCached;
}
if (process.platform !== "linux") {
wslCached = false;
return wslCached;
if ((environment.platform ?? process.platform) !== "linux") {
if (cacheProcessEnvironment) {
wslCached = false;
}
return false;
}
if (isWSLEnv()) {
wslCached = true;
return wslCached;
if (isWSLEnv(environment.env ?? process.env)) {
if (cacheProcessEnvironment) {
wslCached = true;
}
return true;
}
try {
const release = normalizeLowercaseStringOrEmpty(
await fs.readFile("/proc/sys/kernel/osrelease", "utf8"),
);
wslCached = release.includes("microsoft") || release.includes("wsl");
const detected = release.includes("microsoft") || release.includes("wsl");
if (cacheProcessEnvironment) {
wslCached = detected;
}
return detected;
} catch {
wslCached = false;
if (cacheProcessEnvironment) {
wslCached = false;
}
return false;
}
return wslCached;
}