Onboarding: fix webchat URL loopback and canonical session

This commit is contained in:
Yash
2026-02-17 00:17:14 +05:30
committed by Peter Steinberger
parent a02bcb3620
commit 59e0e7e4ff
4 changed files with 222 additions and 10 deletions

View File

@@ -1,9 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildWebchatUrl,
normalizeGatewayTokenInput,
openUrl,
resolveBrowserOpenCommand,
resolveControlUiLinks,
resolveLocalBrowserControlUiLinks,
validateGatewayPasswordInput,
} from "./onboard-helpers.js";
@@ -107,6 +109,35 @@ describe("resolveControlUiLinks", () => {
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
});
it("coerces lan bind to loopback for local browser links", () => {
const links = resolveLocalBrowserControlUiLinks({
port: 18789,
bind: "lan",
});
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
});
});
describe("buildWebchatUrl", () => {
it("encodes canonical session key exactly once", () => {
const url = buildWebchatUrl({
httpUrl: "http://127.0.0.1:18789/",
sessionKey: "agent:main:main",
});
expect(url).toBe("http://127.0.0.1:18789/chat?session=agent%3Amain%3Amain");
});
it("preserves base path and appends token in fragment", () => {
const url = buildWebchatUrl({
httpUrl: "http://127.0.0.1:18789/ui/",
sessionKey: "agent:main:main",
token: "abc 123",
});
expect(url).toBe("http://127.0.0.1:18789/ui/chat?session=agent%3Amain%3Amain#token=abc%20123");
expect(url).not.toContain("%2520");
});
});
describe("normalizeGatewayTokenInput", () => {

View File

@@ -8,7 +8,7 @@ import type { RuntimeEnv } from "../runtime.js";
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import { CONFIG_PATH } from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { resolveMainSessionKey, resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { pickPrimaryLanIPv4, isValidIPv4 } from "../gateway/net.js";
@@ -454,12 +454,17 @@ function summarizeError(err: unknown): string {
export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
export function resolveControlUiLinks(params: {
type ControlUiLinksParams = {
port: number;
bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet";
customBindHost?: string;
basePath?: string;
}): { httpUrl: string; wsUrl: string } {
};
export function resolveControlUiLinks(params: ControlUiLinksParams): {
httpUrl: string;
wsUrl: string;
} {
const port = params.port;
const bind = params.bind ?? "loopback";
const customBindHost = params.customBindHost?.trim();
@@ -484,3 +489,39 @@ export function resolveControlUiLinks(params: {
wsUrl: `ws://${host}:${port}${wsPath}`,
};
}
export function resolveLocalBrowserControlUiLinks(params: ControlUiLinksParams): {
httpUrl: string;
wsUrl: string;
} {
const bind = params.bind === "lan" ? "loopback" : params.bind;
return resolveControlUiLinks({
...params,
bind,
});
}
export function resolveCanonicalMainSessionKey(cfg: OpenClawConfig): string {
return resolveMainSessionKey(cfg);
}
export function buildWebchatUrl(params: {
httpUrl: string;
sessionKey: string;
token?: string;
}): string {
const base = new URL(params.httpUrl);
if (!base.pathname.endsWith("/")) {
base.pathname = `${base.pathname}/`;
}
const chatUrl = new URL("chat", base);
chatUrl.searchParams.set("session", params.sessionKey.trim());
const token = params.token?.trim();
if (token) {
chatUrl.hash = `token=${encodeURIComponent(token)}`;
}
return chatUrl.toString();
}

View File

@@ -0,0 +1,124 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OnboardOptions } from "../commands/onboard-types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "./prompts.js";
import { finalizeOnboardingWizard } from "./onboarding.finalize.js";
const mocks = vi.hoisted(() => ({
detectBrowserOpenSupport: vi.fn(async () => ({ ok: true })),
openUrl: vi.fn(async () => true),
probeGatewayReachable: vi.fn(async () => ({ ok: true })),
ensureControlUiAssetsBuilt: vi.fn(async () => ({ ok: true })),
setupOnboardingShellCompletion: vi.fn(async () => {}),
runTui: vi.fn(async () => {}),
}));
vi.mock("../commands/onboard-helpers.js", async (importActual) => {
const actual = await importActual<typeof import("../commands/onboard-helpers.js")>();
return {
...actual,
detectBrowserOpenSupport: mocks.detectBrowserOpenSupport,
openUrl: mocks.openUrl,
probeGatewayReachable: mocks.probeGatewayReachable,
};
});
vi.mock("../infra/control-ui-assets.js", () => ({
ensureControlUiAssetsBuilt: mocks.ensureControlUiAssetsBuilt,
}));
vi.mock("./onboarding.completion.js", () => ({
setupOnboardingShellCompletion: mocks.setupOnboardingShellCompletion,
}));
vi.mock("../tui/tui.js", () => ({
runTui: mocks.runTui,
}));
function createPrompter(overrides?: Partial<WizardPrompter>): WizardPrompter {
const select: WizardPrompter["select"] = vi.fn(async (params) => {
if (params.message === "How do you want to hatch your bot?") {
return "web" as never;
}
return (params.initialValue ?? params.options[0]?.value) as never;
});
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
return {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select,
multiselect,
text: vi.fn(async () => ""),
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
...overrides,
};
}
function createRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit:${code}`);
}),
};
}
describe("finalizeOnboardingWizard", () => {
afterEach(() => {
vi.restoreAllMocks();
mocks.detectBrowserOpenSupport.mockClear();
mocks.openUrl.mockClear();
mocks.probeGatewayReachable.mockClear();
mocks.ensureControlUiAssetsBuilt.mockClear();
mocks.setupOnboardingShellCompletion.mockClear();
mocks.runTui.mockClear();
});
it("opens loopback webchat URL with canonical main session key when bind=lan", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboarding-finalize-"));
const opts: OnboardOptions = {
installDaemon: false,
skipHealth: true,
skipProviders: true,
skipSkills: true,
};
const prompter = createPrompter();
const runtime = createRuntime();
await finalizeOnboardingWizard({
flow: "quickstart",
opts,
baseConfig: {},
nextConfig: {},
workspaceDir,
settings: {
port: 18789,
bind: "lan",
authMode: "token",
gatewayToken: "test token",
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime,
});
expect(mocks.openUrl).toHaveBeenCalledWith(
"http://127.0.0.1:18789/chat?session=agent%3Amain%3Amain#token=test%20token",
);
await fs.rm(workspaceDir, { recursive: true, force: true });
});
});

View File

@@ -18,12 +18,15 @@ import {
import { formatHealthCheckFailure } from "../commands/health-format.js";
import { healthCommand } from "../commands/health.js";
import {
buildWebchatUrl,
detectBrowserOpenSupport,
formatControlUiSshHint,
openUrl,
probeGatewayReachable,
resolveCanonicalMainSessionKey,
waitForGatewayReachable,
resolveControlUiLinks,
resolveLocalBrowserControlUiLinks,
} from "../commands/onboard-helpers.js";
import { resolveGatewayService } from "../daemon/service.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
@@ -250,10 +253,22 @@ export async function finalizeOnboardingWizard(
customBindHost: settings.customBindHost,
basePath: controlUiBasePath,
});
const localBrowserLinks = resolveLocalBrowserControlUiLinks({
bind: settings.bind,
port: settings.port,
customBindHost: settings.customBindHost,
basePath: controlUiBasePath,
});
const canonicalSessionKey = resolveCanonicalMainSessionKey(nextConfig);
const authedUrl =
settings.authMode === "token" && settings.gatewayToken
? `${links.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}`
: links.httpUrl;
? `${localBrowserLinks.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}`
: localBrowserLinks.httpUrl;
const webchatUrl = buildWebchatUrl({
httpUrl: localBrowserLinks.httpUrl,
sessionKey: canonicalSessionKey,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
});
const gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
@@ -273,10 +288,11 @@ export async function finalizeOnboardingWizard(
await prompter.note(
[
`Web UI: ${links.httpUrl}`,
`Web UI: ${localBrowserLinks.httpUrl}`,
settings.authMode === "token" && settings.gatewayToken
? `Web UI (with token): ${authedUrl}`
: undefined,
`WebChat: ${webchatUrl}`,
`Gateway WS: ${links.wsUrl}`,
gatewayStatusLine,
"Docs: https://docs.openclaw.ai/web/control-ui",
@@ -342,7 +358,7 @@ export async function finalizeOnboardingWizard(
} else if (hatchChoice === "web") {
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
controlUiOpened = await openUrl(authedUrl);
controlUiOpened = await openUrl(webchatUrl);
if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
@@ -359,7 +375,7 @@ export async function finalizeOnboardingWizard(
}
await prompter.note(
[
`Dashboard link (with token): ${authedUrl}`,
`WebChat link: ${webchatUrl}`,
controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw."
: "Copy/paste this URL in a browser on this machine to control OpenClaw.",
@@ -402,7 +418,7 @@ export async function finalizeOnboardingWizard(
if (shouldOpenControlUi) {
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
controlUiOpened = await openUrl(authedUrl);
controlUiOpened = await openUrl(webchatUrl);
if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({
port: settings.port,
@@ -420,7 +436,7 @@ export async function finalizeOnboardingWizard(
await prompter.note(
[
`Dashboard link (with token): ${authedUrl}`,
`WebChat link: ${webchatUrl}`,
controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw."
: "Copy/paste this URL in a browser on this machine to control OpenClaw.",