From b9d14855d02f29ec51522987e338f7ca265d3d56 Mon Sep 17 00:00:00 2001 From: Bin Deng Date: Sun, 15 Feb 2026 04:00:58 +0800 Subject: [PATCH] Fix: Force dashboard command to use localhost URL (#16434) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 3c03b4cc9b1dec96e0541df37910a697493ca285 Co-authored-by: BinHPdev <219093083+BinHPdev@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 16 +++ .../OpenClawProtocol/GatewayModels.swift | 16 +++ src/commands/dashboard.test.ts | 126 ++++++++++++++++++ src/commands/dashboard.ts | 4 +- 5 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/commands/dashboard.test.ts 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/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index a134b4fd5b..0cf9d0c475 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -436,7 +436,11 @@ public struct PollParams: Codable, Sendable { public let question: String public let options: [String] public let maxselections: Int? + public let durationseconds: Int? public let durationhours: Int? + public let silent: Bool? + public let isanonymous: Bool? + public let threadid: String? public let channel: String? public let accountid: String? public let idempotencykey: String @@ -446,7 +450,11 @@ public struct PollParams: Codable, Sendable { question: String, options: [String], maxselections: Int?, + durationseconds: Int?, durationhours: Int?, + silent: Bool?, + isanonymous: Bool?, + threadid: String?, channel: String?, accountid: String?, idempotencykey: String @@ -455,7 +463,11 @@ public struct PollParams: Codable, Sendable { self.question = question self.options = options self.maxselections = maxselections + self.durationseconds = durationseconds self.durationhours = durationhours + self.silent = silent + self.isanonymous = isanonymous + self.threadid = threadid self.channel = channel self.accountid = accountid self.idempotencykey = idempotencykey @@ -465,7 +477,11 @@ public struct PollParams: Codable, Sendable { case question case options case maxselections = "maxSelections" + case durationseconds = "durationSeconds" case durationhours = "durationHours" + case silent + case isanonymous = "isAnonymous" + case threadid = "threadId" case channel case accountid = "accountId" case idempotencykey = "idempotencyKey" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index a134b4fd5b..0cf9d0c475 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -436,7 +436,11 @@ public struct PollParams: Codable, Sendable { public let question: String public let options: [String] public let maxselections: Int? + public let durationseconds: Int? public let durationhours: Int? + public let silent: Bool? + public let isanonymous: Bool? + public let threadid: String? public let channel: String? public let accountid: String? public let idempotencykey: String @@ -446,7 +450,11 @@ public struct PollParams: Codable, Sendable { question: String, options: [String], maxselections: Int?, + durationseconds: Int?, durationhours: Int?, + silent: Bool?, + isanonymous: Bool?, + threadid: String?, channel: String?, accountid: String?, idempotencykey: String @@ -455,7 +463,11 @@ public struct PollParams: Codable, Sendable { self.question = question self.options = options self.maxselections = maxselections + self.durationseconds = durationseconds self.durationhours = durationhours + self.silent = silent + self.isanonymous = isanonymous + self.threadid = threadid self.channel = channel self.accountid = accountid self.idempotencykey = idempotencykey @@ -465,7 +477,11 @@ public struct PollParams: Codable, Sendable { case question case options case maxselections = "maxSelections" + case durationseconds = "durationSeconds" case durationhours = "durationHours" + case silent + case isanonymous = "isAnonymous" + case threadid = "threadId" case channel case accountid = "accountId" case idempotencykey = "idempotencyKey" 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 deef345838..cc4a4de60c 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -25,9 +25,11 @@ export async function dashboardCommand( const customBindHost = cfg.gateway?.customBindHost; const token = cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? ""; + // LAN URLs fail secure-context checks in browsers. + // Coerce only lan->loopback and preserve other bind modes. const links = resolveControlUiLinks({ port, - bind, + bind: bind === "lan" ? "loopback" : bind, customBindHost, basePath, });