fix(onboard): preserve TLS in SSH dashboard hints (#130313)

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-26 12:50:09 -07:00
committed by GitHub
parent 8e9db5e166
commit 0569c6ea6b
6 changed files with 113 additions and 7 deletions
+38 -3
View File
@@ -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");
+3 -1
View File
@@ -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 {
@@ -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",
+2
View File
@@ -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;
+32 -1
View File
@@ -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.",
+7 -2
View File
@@ -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:",