From 0c632f48550f0858687fdc4027480db2967eb4a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 02:19:26 +0100 Subject: [PATCH] fix: prefer tailnet IP for local gateway calls --- CHANGELOG.md | 2 ++ src/gateway/call.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++ src/gateway/call.ts | 11 +++++- 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/gateway/call.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e04caa9dc..5793c9967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,11 @@ - Control UI: chat view uses page scroll with sticky header/sidebar and fixed composer (no inner scroll frame). - macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639 - macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan +- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files. - WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj - WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj - Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT). +- CLI: use tailnet IP for local gateway calls when bind is tailnet/auto (fixes #176). - Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`. - Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env). - Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts new file mode 100644 index 000000000..640acf548 --- /dev/null +++ b/src/gateway/call.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadConfig = vi.fn(); +const resolveGatewayPort = vi.fn(); +const pickPrimaryTailnetIPv4 = vi.fn(); + +let lastClientOptions: { + url?: string; + onHelloOk?: () => void | Promise; +} | null = null; + +vi.mock("../config/config.js", () => ({ + loadConfig, + resolveGatewayPort, +})); + +vi.mock("../infra/tailnet.js", () => ({ + pickPrimaryTailnetIPv4, +})); + +vi.mock("./client.js", () => ({ + GatewayClient: class { + constructor(opts: { + url?: string; + onHelloOk?: () => void | Promise; + }) { + lastClientOptions = opts; + } + async request() { + return { ok: true }; + } + start() { + void lastClientOptions?.onHelloOk?.(); + } + stop() {} + }, +})); + +const { callGateway } = await import("./call.js"); + +describe("callGateway url resolution", () => { + beforeEach(() => { + loadConfig.mockReset(); + resolveGatewayPort.mockReset(); + pickPrimaryTailnetIPv4.mockReset(); + lastClientOptions = null; + }); + + it("uses tailnet IP when local bind is tailnet", async () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } }); + resolveGatewayPort.mockReturnValue(18800); + pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1"); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800"); + }); + + it("uses tailnet IP when local bind is auto and tailnet is present", async () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); + resolveGatewayPort.mockReturnValue(18800); + pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.2"); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.url).toBe("ws://100.64.0.2:18800"); + }); + + it("falls back to loopback when local bind is auto without tailnet IP", async () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); + resolveGatewayPort.mockReturnValue(18800); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); + }); +}); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index cfa2d1fc1..5987cbad5 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { loadConfig, resolveGatewayPort } from "../config/config.js"; +import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { GatewayClient } from "./client.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; @@ -29,6 +30,14 @@ export async function callGateway( const remote = isRemoteMode ? config.gateway?.remote : undefined; const authToken = config.gateway?.auth?.token; const localPort = resolveGatewayPort(config); + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const bindMode = config.gateway?.bind ?? "loopback"; + const preferTailnet = + bindMode === "tailnet" || (bindMode === "auto" && !!tailnetIPv4); + const localUrl = + preferTailnet && tailnetIPv4 + ? `ws://${tailnetIPv4}:${localPort}` + : `ws://127.0.0.1:${localPort}`; const url = (typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() @@ -36,7 +45,7 @@ export async function callGateway( (typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined) || - `ws://127.0.0.1:${localPort}`; + localUrl; const token = (typeof opts.token === "string" && opts.token.trim().length > 0 ? opts.token.trim()