fix(gmail): keep tailscale serve path at root

The default Gmail hook path configured by `clawdbot hooks gmail setup` is `/gmail-pubsub`. Tailscale strips the mount path before proxying, so the request lands on `/` and the hook 404s under the default configuration.

When Tailscale is enabled, always listen on `/` internally and keep the public URL on the configured path (defaulting to `/gmail-pubsub`). This makes default and custom paths work reliably.

Alternative (not implemented here): call tailscale with a full target URL so the backend keeps the path, e.g. `tailscale funnel --set-path /gmail-pubsub http://127.0.0.1:8788/gmail-pubsub`. We did not take this path because it requires changing the CLI invocation to pass URLs (not ports) plus extra validation, which is a larger behavior change.
This commit is contained in:
Anton Sotkov
2026-01-10 15:29:02 +02:00
committed by Peter Steinberger
parent 0de3bb36d5
commit 26ce65995f
3 changed files with 41 additions and 18 deletions

View File

@@ -134,6 +134,11 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
const serveBind = opts.bind ?? DEFAULT_GMAIL_SERVE_BIND; const serveBind = opts.bind ?? DEFAULT_GMAIL_SERVE_BIND;
const servePort = opts.port ?? DEFAULT_GMAIL_SERVE_PORT; const servePort = opts.port ?? DEFAULT_GMAIL_SERVE_PORT;
const configuredServePath = opts.path ?? baseConfig.hooks?.gmail?.serve?.path; const configuredServePath = opts.path ?? baseConfig.hooks?.gmail?.serve?.path;
const normalizedServePath =
typeof configuredServePath === "string" &&
configuredServePath.trim().length > 0
? normalizeServePath(configuredServePath)
: DEFAULT_GMAIL_SERVE_PATH;
const includeBody = opts.includeBody ?? true; const includeBody = opts.includeBody ?? true;
const maxBytes = opts.maxBytes ?? DEFAULT_GMAIL_MAX_BYTES; const maxBytes = opts.maxBytes ?? DEFAULT_GMAIL_MAX_BYTES;
@@ -142,18 +147,14 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
const tailscaleMode = opts.tailscale ?? "funnel"; const tailscaleMode = opts.tailscale ?? "funnel";
// Tailscale strips the path before proxying; keep a public path while gog // Tailscale strips the path before proxying; keep a public path while gog
// listens on "/" unless the user explicitly configured a serve path. // listens on "/" whenever Tailscale is enabled.
const servePath = normalizeServePath( const servePath = normalizeServePath(
tailscaleMode !== "off" && !configuredServePath tailscaleMode !== "off" ? "/" : normalizedServePath,
? "/"
: (configuredServePath ?? DEFAULT_GMAIL_SERVE_PATH),
); );
const tailscalePath = normalizeServePath( const tailscalePath = normalizeServePath(
opts.tailscalePath ?? opts.tailscalePath ??
baseConfig.hooks?.gmail?.tailscale?.path ?? baseConfig.hooks?.gmail?.tailscale?.path ??
(tailscaleMode !== "off" (tailscaleMode !== "off" ? normalizedServePath : servePath),
? (configuredServePath ?? DEFAULT_GMAIL_SERVE_PATH)
: servePath),
); );
await runGcloud(["config", "set", "project", projectId, "--quiet"]); await runGcloud(["config", "set", "project", projectId, "--quiet"]);

View File

@@ -85,7 +85,30 @@ describe("gmail hook config", () => {
} }
}); });
it("keeps explicit serve path for tailscale when set", () => { it("keeps the default public path when serve path is explicit", () => {
const result = resolveGmailHookRuntimeConfig(
{
hooks: {
token: "hook-token",
gmail: {
account: "clawdbot@gmail.com",
topic: "projects/demo/topics/gog-gmail-watch",
pushToken: "push-token",
serve: { path: "/gmail-pubsub" },
tailscale: { mode: "funnel" },
},
},
},
{},
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.serve.path).toBe("/");
expect(result.value.tailscale.path).toBe("/gmail-pubsub");
}
});
it("keeps custom public path when serve path is set", () => {
const result = resolveGmailHookRuntimeConfig( const result = resolveGmailHookRuntimeConfig(
{ {
hooks: { hooks: {
@@ -103,7 +126,7 @@ describe("gmail hook config", () => {
); );
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (result.ok) { if (result.ok) {
expect(result.value.serve.path).toBe("/custom"); expect(result.value.serve.path).toBe("/");
expect(result.value.tailscale.path).toBe("/custom"); expect(result.value.tailscale.path).toBe("/custom");
} }
}); });

View File

@@ -160,23 +160,22 @@ export function resolveGmailHookRuntimeConfig(
? Math.floor(servePortRaw) ? Math.floor(servePortRaw)
: DEFAULT_GMAIL_SERVE_PORT; : DEFAULT_GMAIL_SERVE_PORT;
const servePathRaw = overrides.servePath ?? gmail?.serve?.path; const servePathRaw = overrides.servePath ?? gmail?.serve?.path;
const hasExplicitServePath = const normalizedServePathRaw =
typeof servePathRaw === "string" && servePathRaw.trim().length > 0; typeof servePathRaw === "string" && servePathRaw.trim().length > 0
? normalizeServePath(servePathRaw)
: DEFAULT_GMAIL_SERVE_PATH;
const tailscaleMode = const tailscaleMode =
overrides.tailscaleMode ?? gmail?.tailscale?.mode ?? "off"; overrides.tailscaleMode ?? gmail?.tailscale?.mode ?? "off";
// When exposing the push endpoint via Tailscale, the public path is stripped // Tailscale strips the public path before proxying, so listen on "/" when on.
// before proxying; use "/" internally unless the user set a path explicitly.
const servePath = normalizeServePath( const servePath = normalizeServePath(
tailscaleMode !== "off" && !hasExplicitServePath ? "/" : servePathRaw, tailscaleMode !== "off" ? "/" : normalizedServePathRaw,
); );
const tailscalePathRaw = overrides.tailscalePath ?? gmail?.tailscale?.path; const tailscalePathRaw = overrides.tailscalePath ?? gmail?.tailscale?.path;
const tailscalePath = normalizeServePath( const tailscalePath = normalizeServePath(
tailscaleMode !== "off" && !tailscalePathRaw tailscaleMode !== "off"
? hasExplicitServePath ? (tailscalePathRaw ?? normalizedServePathRaw)
? servePathRaw
: DEFAULT_GMAIL_SERVE_PATH
: (tailscalePathRaw ?? servePath), : (tailscalePathRaw ?? servePath),
); );