CLI: confirm discovered remote gateways before saving config (#55895)

* CLI: require trust confirmation for discovered remote gateways

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>

* CLI: clear discovery pin when remote URL changes

---------

Co-authored-by: nexrin <268879349+nexrin@users.noreply.github.com>
This commit is contained in:
Jacob Tomlinson
2026-03-27 11:43:42 -07:00
committed by GitHub
parent c9d68fb9c2
commit d6affb17d8
2 changed files with 122 additions and 0 deletions

View File

@@ -83,6 +83,7 @@ describe("promptRemoteGatewayConfig", () => {
displayName: "Gateway",
host: "gateway.tailnet.ts.net",
port: 18789,
gatewayTlsFingerprintSha256: "sha256:abc123",
},
]);
@@ -111,12 +112,115 @@ describe("promptRemoteGatewayConfig", () => {
expect(next.gateway?.mode).toBe("remote");
expect(next.gateway?.remote?.url).toBe("wss://gateway.tailnet.ts.net:18789");
expect(next.gateway?.remote?.token).toBe("token-123");
expect(next.gateway?.remote?.tlsFingerprint).toBe("sha256:abc123");
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("Direct remote access defaults to TLS."),
"Direct remote",
);
});
it("rejects discovery endpoint when trust confirmation is declined", async () => {
detectBinary.mockResolvedValue(true);
discoverGatewayBeacons.mockResolvedValue([
{
instanceName: "evil",
displayName: "Evil",
host: "evil.example",
port: 443,
gatewayTlsFingerprintSha256: "sha256:attacker",
},
]);
const select = createSelectPrompter({
"Select gateway": "0",
"Connection method": "direct",
});
const confirm: WizardPrompter["confirm"] = vi.fn(async (params) => {
if (params.message.startsWith("Discover gateway")) {
return true;
}
if (params.message.startsWith("Trust this gateway")) {
return false;
}
return false;
});
const prompter = createPrompter({
confirm,
select,
text: vi.fn(async () => "") as WizardPrompter["text"],
});
await expect(promptRemoteGatewayConfig({} as OpenClawConfig, prompter)).rejects.toThrow(
"not trusted",
);
});
it("trusts discovery endpoint without fingerprint and omits tlsFingerprint", async () => {
detectBinary.mockResolvedValue(true);
discoverGatewayBeacons.mockResolvedValue([
{
instanceName: "gw",
displayName: "Gateway",
host: "gw.example",
port: 18789,
},
]);
const text: WizardPrompter["text"] = vi.fn(async (params) => {
if (params.message === "Gateway WebSocket URL") {
return String(params.initialValue);
}
return "";
}) as WizardPrompter["text"];
const { next } = await runRemotePrompt({
text,
confirm: true,
selectResponses: {
"Select gateway": "0",
"Connection method": "direct",
"Gateway auth": "off",
},
});
expect(next.gateway?.remote?.url).toBe("wss://gw.example:18789");
expect(next.gateway?.remote?.tlsFingerprint).toBeUndefined();
});
it("drops discovery tlsFingerprint when the URL is edited after trust confirmation", async () => {
detectBinary.mockResolvedValue(true);
discoverGatewayBeacons.mockResolvedValue([
{
instanceName: "gateway",
displayName: "Gateway",
host: "gateway.tailnet.ts.net",
port: 18789,
gatewayTlsFingerprintSha256: "sha256:abc123",
},
]);
const text: WizardPrompter["text"] = vi.fn(async (params) => {
if (params.message === "Gateway WebSocket URL") {
return "wss://other.example:443";
}
return "";
}) as WizardPrompter["text"];
const { next } = await runRemotePrompt({
text,
confirm: true,
selectResponses: {
"Select gateway": "0",
"Connection method": "direct",
"Gateway auth": "off",
},
});
expect(next.gateway?.remote?.url).toBe("wss://other.example:443");
expect(next.gateway?.remote?.tlsFingerprint).toBeUndefined();
});
it("does not route from TXT-only discovery metadata", async () => {
detectBinary.mockResolvedValue(true);
discoverGatewayBeacons.mockResolvedValue([

View File

@@ -52,6 +52,8 @@ export async function promptRemoteGatewayConfig(
): Promise<OpenClawConfig> {
let selectedBeacon: GatewayBonjourBeacon | null = null;
let suggestedUrl = cfg.gateway?.remote?.url ?? DEFAULT_GATEWAY_URL;
let discoveryTlsFingerprint: string | undefined;
let trustedDiscoveryUrl: string | undefined;
const hasBonjourTool = (await detectBinary("dns-sd")) || (await detectBinary("avahi-browse"));
const wantsDiscover = hasBonjourTool
@@ -113,10 +115,23 @@ export async function promptRemoteGatewayConfig(
});
if (mode === "direct") {
suggestedUrl = `wss://${host}:${port}`;
const fingerprint = target.endpoint.gatewayTlsFingerprintSha256;
const trusted = await prompter.confirm({
message: `Trust this gateway? Host: ${host}:${port} TLS fingerprint: ${fingerprint ?? "not advertised (connection will not be pinned)"}`,
initialValue: false,
});
if (!trusted) {
throw new Error(
`Discovery endpoint ${host}:${port} not trusted. Re-run onboarding or enter the URL manually.`,
);
}
discoveryTlsFingerprint = fingerprint;
trustedDiscoveryUrl = suggestedUrl;
await prompter.note(
[
"Direct remote access defaults to TLS.",
`Using: ${suggestedUrl}`,
...(fingerprint ? [`TLS pin: ${fingerprint}`] : []),
"If your gateway is loopback-only, choose SSH tunnel and keep ws://127.0.0.1:18789.",
].join("\n"),
"Direct remote",
@@ -141,6 +156,8 @@ export async function promptRemoteGatewayConfig(
validate: (value) => validateGatewayWebSocketUrl(String(value)),
});
const url = ensureWsUrl(String(urlInput));
const pinnedDiscoveryFingerprint =
discoveryTlsFingerprint && url === trustedDiscoveryUrl ? discoveryTlsFingerprint : undefined;
const authChoice = await prompter.select({
message: "Gateway auth",
@@ -231,6 +248,7 @@ export async function promptRemoteGatewayConfig(
url,
...(token !== undefined ? { token } : {}),
...(password !== undefined ? { password } : {}),
...(pinnedDiscoveryFingerprint ? { tlsFingerprint: pinnedDiscoveryFingerprint } : {}),
},
},
};