diff --git a/CHANGELOG.md b/CHANGELOG.md index 441dde77ba..683b1b6030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai - Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla. - Cron: deliver text-only output directly when `delivery.to` is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow. - Cron: prevent `cron list`/`cron status` from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x. +- CLI/Dashboard: when `gateway.bind=lan`, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev. - Cron: repair missing/corrupt `nextRunAtMs` for the updated job without globally recomputing unrelated due jobs during `cron update`. (#15750) - Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow. - TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds. diff --git a/src/commands/dashboard.test.ts b/src/commands/dashboard.test.ts new file mode 100644 index 0000000000..3719d95cda --- /dev/null +++ b/src/commands/dashboard.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBindMode } from "../config/types.gateway.js"; +import { dashboardCommand } from "./dashboard.js"; + +const mocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(), + resolveGatewayPort: vi.fn(), + resolveControlUiLinks: vi.fn(), + copyToClipboard: vi.fn(), +})); + +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: mocks.readConfigFileSnapshot, + resolveGatewayPort: mocks.resolveGatewayPort, +})); + +vi.mock("./onboard-helpers.js", () => ({ + resolveControlUiLinks: mocks.resolveControlUiLinks, + detectBrowserOpenSupport: vi.fn(), + openUrl: vi.fn(), + formatControlUiSshHint: vi.fn(() => "ssh hint"), +})); + +vi.mock("../infra/clipboard.js", () => ({ + copyToClipboard: mocks.copyToClipboard, +})); + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +function mockSnapshot(params?: { + token?: string; + bind?: GatewayBindMode; + customBindHost?: string; +}) { + const token = params?.token ?? "abc123"; + mocks.readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: { + gateway: { + auth: { token }, + bind: params?.bind, + customBindHost: params?.customBindHost, + }, + }, + issues: [], + legacyIssues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.resolveControlUiLinks.mockReturnValue({ + httpUrl: "http://127.0.0.1:18789/", + wsUrl: "ws://127.0.0.1:18789", + }); + mocks.copyToClipboard.mockResolvedValue(true); +} + +describe("dashboardCommand bind selection", () => { + beforeEach(() => { + mocks.readConfigFileSnapshot.mockReset(); + mocks.resolveGatewayPort.mockReset(); + mocks.resolveControlUiLinks.mockReset(); + mocks.copyToClipboard.mockReset(); + runtime.log.mockReset(); + runtime.error.mockReset(); + runtime.exit.mockReset(); + }); + + it("maps lan bind to loopback for dashboard URLs", async () => { + mockSnapshot({ bind: "lan" }); + + await dashboardCommand(runtime, { noOpen: true }); + + expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({ + port: 18789, + bind: "loopback", + customBindHost: undefined, + basePath: undefined, + }); + }); + + it("defaults to loopback when bind is unset", async () => { + mockSnapshot(); + + await dashboardCommand(runtime, { noOpen: true }); + + expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({ + port: 18789, + bind: "loopback", + customBindHost: undefined, + basePath: undefined, + }); + }); + + it("preserves custom bind mode", async () => { + mockSnapshot({ bind: "custom", customBindHost: "10.0.0.5" }); + + await dashboardCommand(runtime, { noOpen: true }); + + expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({ + port: 18789, + bind: "custom", + customBindHost: "10.0.0.5", + basePath: undefined, + }); + }); + + it("preserves tailnet bind mode", async () => { + mockSnapshot({ bind: "tailnet" }); + + await dashboardCommand(runtime, { noOpen: true }); + + expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({ + port: 18789, + bind: "tailnet", + customBindHost: undefined, + basePath: undefined, + }); + }); +}); diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index 0fa8cef20b..cc4a4de60c 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -20,15 +20,16 @@ export async function dashboardCommand( const snapshot = await readConfigFileSnapshot(); const cfg = snapshot.valid ? snapshot.config : {}; const port = resolveGatewayPort(cfg); + const bind = cfg.gateway?.bind ?? "loopback"; const basePath = cfg.gateway?.controlUi?.basePath; const customBindHost = cfg.gateway?.customBindHost; const token = cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? ""; - // Dashboard always uses localhost regardless of bind mode to avoid - // secure context errors in browsers (which require HTTPS or localhost) + // LAN URLs fail secure-context checks in browsers. + // Coerce only lan->loopback and preserve other bind modes. const links = resolveControlUiLinks({ port, - bind: "loopback", + bind: bind === "lan" ? "loopback" : bind, customBindHost, basePath, });