Merge pull request #740 from jeffersonwarrior/main

feat: add Tailscale binary detection, custom gateway IP binding, and health probe auth fix
This commit is contained in:
Peter Steinberger
2026-01-13 05:22:48 +00:00
committed by GitHub
23 changed files with 589 additions and 98 deletions

View File

@@ -25,6 +25,7 @@ import {
import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import { listChatProviders } from "../providers/registry.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -220,16 +221,61 @@ async function promptGatewayConfig(
let bind = guardCancel(
await select({
message: "Gateway bind",
message: "Gateway bind mode",
options: [
{ value: "loopback", label: "Loopback (127.0.0.1)" },
{ value: "lan", label: "LAN" },
{ value: "tailnet", label: "Tailnet" },
{ value: "auto", label: "Auto" },
{
value: "auto",
label: "Auto (Tailnet → LAN)",
hint: "Prefer Tailnet IP, fall back to all interfaces if unavailable",
},
{
value: "lan",
label: "LAN (All interfaces)",
hint: "Bind to 0.0.0.0 - accessible from anywhere on your network",
},
{
value: "loopback",
label: "Loopback (Local only)",
hint: "Bind to 127.0.0.1 - secure, local-only access",
},
{
value: "custom",
label: "Custom IP",
hint: "Specify a specific IP address, with 0.0.0.0 fallback if unavailable",
},
],
}),
runtime,
) as "loopback" | "lan" | "tailnet" | "auto";
) as "auto" | "lan" | "loopback" | "custom";
let customBindHost: string | undefined;
if (bind === "custom") {
const input = guardCancel(
await text({
message: "Custom IP address",
placeholder: "192.168.1.100",
validate: (value) => {
if (!value) return "IP address is required for custom bind mode";
const trimmed = value.trim();
const parts = trimmed.split(".");
if (parts.length !== 4)
return "Invalid IPv4 address (e.g., 192.168.1.100)";
if (
parts.every((part) => {
const n = parseInt(part, 10);
return (
!Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n)
);
})
)
return undefined;
return "Invalid IPv4 address (each octet must be 0-255)";
},
}),
runtime,
);
customBindHost = typeof input === "string" ? input : undefined;
}
let authMode = guardCancel(
await select({
@@ -268,6 +314,23 @@ async function promptGatewayConfig(
runtime,
) as "off" | "serve" | "funnel";
// Detect Tailscale binary before proceeding with serve/funnel setup
if (tailscaleMode !== "off") {
const tailscaleBin = await findTailscaleBinary();
if (!tailscaleBin) {
note(
[
"Tailscale binary not found in PATH or /Applications.",
"Ensure Tailscale is installed from:",
" https://tailscale.com/download/mac",
"",
"You can continue setup, but serve/funnel will fail at runtime.",
].join("\n"),
"Tailscale Warning",
);
}
}
let tailscaleResetOnExit = false;
if (tailscaleMode !== "off") {
note(
@@ -348,6 +411,7 @@ async function promptGatewayConfig(
port,
bind,
auth: authConfig,
...(customBindHost && { customBindHost }),
tailscale: {
...next.gateway?.tailscale,
mode: tailscaleMode,
@@ -943,16 +1007,32 @@ export async function runConfigureWizard(
const links = resolveControlUiLinks({
bind,
port: gatewayPort,
customBindHost: nextConfig.gateway?.customBindHost,
basePath: nextConfig.gateway?.controlUi?.basePath,
});
const gatewayProbe = await probeGatewayReachable({
// Try both new and old passwords since gateway may still have old config
const newPassword =
nextConfig.gateway?.auth?.password ??
process.env.CLAWDBOT_GATEWAY_PASSWORD;
const oldPassword =
baseConfig.gateway?.auth?.password ??
process.env.CLAWDBOT_GATEWAY_PASSWORD;
const token =
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN;
let gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
token:
nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
password:
nextConfig.gateway?.auth?.password ??
process.env.CLAWDBOT_GATEWAY_PASSWORD,
token,
password: newPassword,
});
// If new password failed and it's different from old password, try old too
if (!gatewayProbe.ok && newPassword !== oldPassword && oldPassword) {
gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,
token,
password: oldPassword,
});
}
const gatewayStatusLine = gatewayProbe.ok
? "Gateway: reachable"
: `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`;

View File

@@ -78,6 +78,7 @@ describe("dashboardCommand", () => {
expect(mocks.resolveControlUiLinks).toHaveBeenCalledWith({
port: 18789,
bind: "loopback",
customBindHost: undefined,
basePath: undefined,
});
expect(mocks.copyToClipboard).toHaveBeenCalledWith(

View File

@@ -25,10 +25,16 @@ export async function dashboardCommand(
const port = resolveGatewayPort(cfg);
const bind = cfg.gateway?.bind ?? "loopback";
const basePath = cfg.gateway?.controlUi?.basePath;
const customBindHost = cfg.gateway?.customBindHost;
const token =
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? "";
const links = resolveControlUiLinks({ port, bind, basePath });
const links = resolveControlUiLinks({
port,
bind,
customBindHost,
basePath,
});
const authedUrl = token
? `${links.httpUrl}?token=${encodeURIComponent(token)}`
: links.httpUrl;

View File

@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { openUrl, resolveBrowserOpenCommand } from "./onboard-helpers.js";
import {
openUrl,
resolveBrowserOpenCommand,
resolveControlUiLinks,
} from "./onboard-helpers.js";
const mocks = vi.hoisted(() => ({
runCommandWithTimeout: vi.fn(async () => ({
@@ -10,12 +14,17 @@ const mocks = vi.hoisted(() => ({
signal: null,
killed: false,
})),
pickPrimaryTailnetIPv4: vi.fn(() => undefined),
}));
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: mocks.runCommandWithTimeout,
}));
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: mocks.pickPrimaryTailnetIPv4,
}));
describe("openUrl", () => {
it("quotes URLs on win32 so '&' is not treated as cmd separator", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
@@ -45,3 +54,25 @@ describe("resolveBrowserOpenCommand", () => {
expect(resolved.quoteUrl).toBe(true);
});
});
describe("resolveControlUiLinks", () => {
it("uses customBindHost for custom bind", () => {
const links = resolveControlUiLinks({
port: 18789,
bind: "custom",
customBindHost: "192.168.1.100",
});
expect(links.httpUrl).toBe("http://192.168.1.100:18789/");
expect(links.wsUrl).toBe("ws://192.168.1.100:18789");
});
it("falls back to loopback for invalid customBindHost", () => {
const links = resolveControlUiLinks({
port: 18789,
bind: "custom",
customBindHost: "192.168.001.100",
});
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
});
});

View File

@@ -410,16 +410,21 @@ export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
export function resolveControlUiLinks(params: {
port: number;
bind?: "auto" | "lan" | "tailnet" | "loopback";
bind?: "auto" | "lan" | "loopback" | "custom";
customBindHost?: string;
basePath?: string;
}): { httpUrl: string; wsUrl: string } {
const port = params.port;
const bind = params.bind ?? "loopback";
const customBindHost = params.customBindHost?.trim();
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const host =
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
? (tailnetIPv4 ?? "127.0.0.1")
: "127.0.0.1";
const host = (() => {
if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) {
return customBindHost;
}
if (bind === "auto" && tailnetIPv4) return tailnetIPv4 ?? "127.0.0.1";
return "127.0.0.1";
})();
const basePath = normalizeControlUiBasePath(params.basePath);
const uiPath = basePath ? `${basePath}/` : "/";
const wsPath = basePath ? basePath : "";
@@ -428,3 +433,12 @@ export function resolveControlUiLinks(params: {
wsUrl: `ws://${host}:${port}${wsPath}`,
};
}
function isValidIPv4(host: string): boolean {
const parts = host.split(".");
if (parts.length !== 4) return false;
return parts.every((part) => {
const n = Number.parseInt(part, 10);
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
});
}

View File

@@ -28,7 +28,7 @@ export type AuthChoice =
| "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
export type GatewayBind = "loopback" | "lan" | "auto" | "custom";
export type TailscaleMode = "off" | "serve" | "funnel";
export type NodeManagerChoice = "npm" | "pnpm" | "bun";
export type ProviderChoice = ChatProviderId;

View File

@@ -271,6 +271,7 @@ export async function statusAllCommand(
? resolveControlUiLinks({
port,
bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
basePath: cfg.gateway?.controlUi?.basePath,
}).httpUrl
: null;

View File

@@ -818,6 +818,7 @@ export async function statusCommand(
const links = resolveControlUiLinks({
port: resolveGatewayPort(cfg),
bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
basePath: cfg.gateway?.controlUi?.basePath,
});
return links.httpUrl;