diff --git a/src/commands/dashboard.links.test.ts b/src/commands/dashboard.links.test.ts index d414c240fd85..ea08b9fb8b31 100644 --- a/src/commands/dashboard.links.test.ts +++ b/src/commands/dashboard.links.test.ts @@ -71,14 +71,20 @@ function expectNoLogWith(text: string): void { expect(logMessages().join("\n")).not.toContain(text); } -function mockSnapshot(token: unknown = "abc") { +function mockSnapshot( + token: unknown = "abc", + gatewayOptions?: { + controlUi?: { basePath: string }; + tls?: { enabled: boolean }; + }, +) { readConfigFileSnapshotMock.mockResolvedValue({ path: "/tmp/openclaw.json", exists: true, raw: "{}", parsed: {}, valid: true, - config: { gateway: { auth: { token } } }, + config: { gateway: { auth: { token }, ...gatewayOptions } }, issues: [], legacyIssues: [], }); @@ -216,6 +222,30 @@ describe("dashboardCommand", () => { expect(runtime.log).toHaveBeenCalledWith("ssh hint"); }); + it("preserves Gateway TLS after remote browser delivery fails", async () => { + vi.stubEnv("SSH_CONNECTION", "192.0.2.1 12345 192.0.2.2 22"); + mockSnapshot("shhhh", { + controlUi: { basePath: "/control" }, + tls: { enabled: true }, + }); + resolveControlUiLinksMock.mockReturnValue({ + httpUrl: "https://127.0.0.1:18789/control/", + wsUrl: "wss://127.0.0.1:18789/control", + }); + copyToClipboardMock.mockResolvedValue(false); + detectBrowserOpenSupportMock.mockResolvedValue({ ok: true }); + openUrlMock.mockResolvedValue(false); + formatControlUiSshHintMock.mockReturnValue("ssh hint"); + + await dashboardCommand(runtime); + + expect(formatControlUiSshHintMock).toHaveBeenCalledWith({ + port: 18789, + basePath: "/control", + tlsEnabled: true, + }); + }); + it("reports opener failure without claiming the host has no GUI", async () => { mockSnapshot("shhhh"); copyToClipboardMock.mockResolvedValue(true); @@ -243,6 +273,7 @@ describe("dashboardCommand", () => { expect(formatControlUiSshHintMock).toHaveBeenCalledWith({ port: 18789, basePath: undefined, + tlsEnabled: false, }); expect(runtime.log).toHaveBeenCalledWith("ssh hint"); }); @@ -259,7 +290,11 @@ describe("dashboardCommand", () => { // formatControlUiSshHint must NOT receive the token — the returned // hint string is written to runtime.log, which flows into the same // console-captured log file readable by operator.read-scoped devices. - expect(formatControlUiSshHintMock).toHaveBeenCalledWith({ port: 18789, basePath: undefined }); + expect(formatControlUiSshHintMock).toHaveBeenCalledWith({ + port: 18789, + basePath: undefined, + tlsEnabled: false, + }); const [sshHintOptions] = formatControlUiSshHintMock.mock.calls[0] ?? []; expect(sshHintOptions).not.toHaveProperty("token"); diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index 2a0bb11f268c..d11ee077a5bf 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -186,7 +186,7 @@ export async function dashboardCommand( runtime.log("Run `openclaw doctor`, then retry `openclaw dashboard`."); return; } - const { port, basePath, links, includeTokenInUrl } = target; + const { port, basePath, links, includeTokenInUrl, tlsConfig } = target; runtime.log(`Dashboard URL: ${links.httpUrl}`); runtime.log("One-time browser pairing included in browser/clipboard URL."); @@ -204,6 +204,7 @@ export async function dashboardCommand( hint = formatControlUiSshHint({ port, basePath, + tlsEnabled: tlsConfig?.enabled === true, }); } else { hint = opened @@ -216,6 +217,7 @@ export async function dashboardCommand( hint = formatControlUiSshHint({ port, basePath, + tlsEnabled: tlsConfig?.enabled === true, }); } } else { diff --git a/src/commands/onboard-browser-handoff.test.ts b/src/commands/onboard-browser-handoff.test.ts index 2a9bb3357f48..39e16ab418f9 100644 --- a/src/commands/onboard-browser-handoff.test.ts +++ b/src/commands/onboard-browser-handoff.test.ts @@ -247,6 +247,37 @@ describe("runBrowserHatchHandoff", () => { expect(displayed).not.toContain("#bootstrapToken="); }); + it("prints an HTTPS tunnel destination for a headless loopback TLS Gateway", async () => { + const prompter = createWizardPrompter(); + const config = { + gateway: { + port: 18789, + bind: "loopback" as const, + controlUi: { basePath: "/control" }, + tls: { enabled: true }, + }, + }; + + await runBrowserHatchHandoff( + { config, prompter }, + { + env: {}, + platform: "linux", + waitForDocument: async () => ({ ready: true }), + verifyLoopbackAlias: async () => true, + probePresence: async () => ({ reachable: true, clientKeys: [] }), + pollForClient: async () => ({ connected: false, reason: "timeout" }), + }, + ); + + const displayed = vi + .mocked(prompter.note) + .mock.calls.map(([message]) => message) + .join("\n"); + expect(displayed).toContain("https://localhost:18789/control/"); + expect(displayed).not.toContain("http://localhost:18789/control/"); + }); + it.each([ { openerOutcome: "returns false", diff --git a/src/commands/onboard-browser-handoff.ts b/src/commands/onboard-browser-handoff.ts index 122c268db472..f012d87b529b 100644 --- a/src/commands/onboard-browser-handoff.ts +++ b/src/commands/onboard-browser-handoff.ts @@ -108,6 +108,7 @@ async function resolveBrowserHatchTarget( sshHint: formatControlUiSshHint({ port: shared.port, ...(shared.basePath ? { basePath: shared.basePath } : {}), + tlsEnabled: shared.tlsConfig?.enabled === true, }), } : {}), @@ -300,6 +301,7 @@ export async function runBrowserHatchHandoff( ...(target.config.gateway?.controlUi?.basePath ? { basePath: target.config.gateway.controlUi.basePath } : {}), + tlsEnabled: target.tlsConfig?.enabled === true, }) : undefined)) : undefined; diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 7988ff8c1039..0c8bbca5f848 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -533,8 +533,39 @@ describe("resolveBrowserOpenCommand", () => { }); describe("formatControlUiSshHint", () => { + it.each([ + { + label: "plain HTTP root", + tlsEnabled: false, + basePath: undefined, + expectedUrl: "http://localhost:18789/", + }, + { + label: "plain HTTP base path", + tlsEnabled: false, + basePath: "/control", + expectedUrl: "http://localhost:18789/control/", + }, + { + label: "HTTPS root", + tlsEnabled: true, + basePath: undefined, + expectedUrl: "https://localhost:18789/", + }, + { + label: "HTTPS base path", + tlsEnabled: true, + basePath: "/control", + expectedUrl: "https://localhost:18789/control/", + }, + ])("uses the Gateway transport for $label", ({ tlsEnabled, basePath, expectedUrl }) => { + const hint = formatControlUiSshHint({ port: 18789, basePath, tlsEnabled }); + + expect(hint).toContain(`Then open:\n${expectedUrl}`); + }); + it("includes the IPv4-only BYOH note and workaround", () => { - const hint = formatControlUiSshHint({ port: 18789 }); + const hint = formatControlUiSshHint({ port: 18789, tlsEnabled: false }); expect(hint).toContain("BYOH note: lan, tailnet, and custom bind are currently IPv4-only."); expect(hint).toContain( "If your host is IPv6-only, use an IPv4 sidecar or proxy in front of the Gateway.", diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 9e6e90d56393..1a8653f01210 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -187,10 +187,15 @@ export function applyWizardMetadata( } /** Formats the no-GUI SSH tunnel hint for opening the Control UI remotely. */ -export function formatControlUiSshHint(params: { port: number; basePath?: string }): string { +export function formatControlUiSshHint(params: { + port: number; + basePath?: string; + tlsEnabled: boolean; +}): string { const basePath = normalizeControlUiBasePath(params.basePath); const uiPath = basePath ? `${basePath}/` : "/"; - const localUrl = `http://localhost:${params.port}${uiPath}`; + const protocol = params.tlsEnabled ? "https" : "http"; + const localUrl = `${protocol}://localhost:${params.port}${uiPath}`; const sshTarget = resolveSshTargetHint(); return [ "No GUI detected. Open from your computer:",