feat(hooks): allow gmail tailscale target URLs

This commit is contained in:
Peter Steinberger
2026-01-10 19:19:30 +01:00
parent 0335bccd91
commit 1fe9f648b1
12 changed files with 89 additions and 7 deletions

View File

@@ -14,6 +14,7 @@
- Cron: `wakeMode: "now"` waits for heartbeat completion (and retries when the main lane is busy). (#666) — thanks @roshanasingh4.
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”).
- Hooks/Gmail: keep Tailscale serve path at `/` while preserving the public path. (#668) — thanks @antons.
- Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths.
- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.
- Auth: throttle external CLI credential syncs (Claude/Codex), reduce Keychain reads, and skip sync when cached credentials are still fresh.
- CLI: respect `CLAWDBOT_STATE_DIR` for node pairing + voice wake settings storage. (#664) — thanks @azade-c.

View File

@@ -105,8 +105,9 @@ Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
`hooks.gmail.serve.path` to `/` and keeps the public path at
`hooks.gmail.tailscale.path` (default `/gmail-pubsub`) because Tailscale
strips the set-path prefix before proxying.
If you need the backend to receive the prefixed path, Tailscale can proxy
to a full target URL with a path, but Clawdbot currently passes only a port.
If you need the backend to receive the prefixed path, set
`hooks.gmail.tailscale.target` (or `--tailscale-target`) to a full URL like
`http://127.0.0.1:8788/gmail-pubsub` and match `hooks.gmail.serve.path`.
Want a custom endpoint? Use `--push-endpoint <url>` or `--tailscale off`.

View File

@@ -292,7 +292,7 @@ Subcommands:
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
Subcommands:
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--push-endpoint`, `--json`)
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
- `hooks gmail run` (runtime overrides for the same flags)
### `dns setup`

View File

@@ -2021,6 +2021,8 @@ Gateway auto-start:
Note: when `tailscale.mode` is on, Clawdbot defaults `serve.path` to `/` so
Tailscale can proxy `/gmail-pubsub` correctly (it strips the set-path prefix).
If you need the backend to receive the prefixed path, set
`hooks.gmail.tailscale.target` to a full URL (and align `serve.path`).
### `canvasHost` (LAN/tailnet Canvas file server + live reload)

View File

@@ -71,6 +71,10 @@ export function registerHooksCli(program: Command) {
"funnel",
)
.option("--tailscale-path <path>", "Path for tailscale serve/funnel")
.option(
"--tailscale-target <target>",
"Tailscale serve/funnel target (port, host:port, or URL)",
)
.option("--push-endpoint <url>", "Explicit Pub/Sub push endpoint")
.option("--json", "Output JSON summary", false)
.action(async (opts) => {
@@ -104,6 +108,10 @@ export function registerHooksCli(program: Command) {
"Expose push endpoint via tailscale (funnel|serve|off)",
)
.option("--tailscale-path <path>", "Path for tailscale serve/funnel")
.option(
"--tailscale-target <target>",
"Tailscale serve/funnel target (port, host:port, or URL)",
)
.action(async (opts) => {
try {
const parsed = parseGmailRunOptions(opts);
@@ -138,6 +146,7 @@ function parseGmailSetupOptions(
renewEveryMinutes: numberOption(raw.renewMinutes),
tailscale: stringOption(raw.tailscale) as GmailSetupOptions["tailscale"],
tailscalePath: stringOption(raw.tailscalePath),
tailscaleTarget: stringOption(raw.tailscaleTarget),
pushEndpoint: stringOption(raw.pushEndpoint),
json: Boolean(raw.json),
};
@@ -160,6 +169,7 @@ function parseGmailRunOptions(raw: Record<string, unknown>): GmailRunOptions {
renewEveryMinutes: numberOption(raw.renewMinutes),
tailscale: stringOption(raw.tailscale) as GmailRunOptions["tailscale"],
tailscalePath: stringOption(raw.tailscalePath),
tailscaleTarget: stringOption(raw.tailscaleTarget),
};
}

View File

@@ -297,6 +297,8 @@ export type HooksGmailConfig = {
tailscale?: {
mode?: HooksGmailTailscaleMode;
path?: string;
/** Optional tailscale serve/funnel target (port, host:port, or full URL). */
target?: string;
};
/** Optional model override for Gmail hook processing (provider/model or alias). */
model?: string;

View File

@@ -962,6 +962,7 @@ const HooksGmailSchema = z
.union([z.literal("off"), z.literal("serve"), z.literal("funnel")])
.optional(),
path: z.string().optional(),
target: z.string().optional(),
})
.optional(),
model: z.string().optional(),

View File

@@ -60,6 +60,7 @@ export type GmailSetupOptions = {
renewEveryMinutes?: number;
tailscale?: "off" | "serve" | "funnel";
tailscalePath?: string;
tailscaleTarget?: string;
pushEndpoint?: string;
json?: boolean;
};
@@ -80,6 +81,7 @@ export type GmailRunOptions = {
renewEveryMinutes?: number;
tailscale?: "off" | "serve" | "funnel";
tailscalePath?: string;
tailscaleTarget?: string;
};
const DEFAULT_GMAIL_TOPIC_IAM_MEMBER =
@@ -134,11 +136,18 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
const serveBind = opts.bind ?? DEFAULT_GMAIL_SERVE_BIND;
const servePort = opts.port ?? DEFAULT_GMAIL_SERVE_PORT;
const configuredServePath = opts.path ?? baseConfig.hooks?.gmail?.serve?.path;
const configuredTailscaleTarget =
opts.tailscaleTarget ?? baseConfig.hooks?.gmail?.tailscale?.target;
const normalizedServePath =
typeof configuredServePath === "string" &&
configuredServePath.trim().length > 0
? normalizeServePath(configuredServePath)
: DEFAULT_GMAIL_SERVE_PATH;
const normalizedTailscaleTarget =
typeof configuredTailscaleTarget === "string" &&
configuredTailscaleTarget.trim().length > 0
? configuredTailscaleTarget.trim()
: undefined;
const includeBody = opts.includeBody ?? true;
const maxBytes = opts.maxBytes ?? DEFAULT_GMAIL_MAX_BYTES;
@@ -149,7 +158,9 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
// Tailscale strips the path before proxying; keep a public path while gog
// listens on "/" whenever Tailscale is enabled.
const servePath = normalizeServePath(
tailscaleMode !== "off" ? "/" : normalizedServePath,
tailscaleMode !== "off" && !normalizedTailscaleTarget
? "/"
: normalizedServePath,
);
const tailscalePath = normalizeServePath(
opts.tailscalePath ??
@@ -189,6 +200,7 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
mode: tailscaleMode,
path: tailscalePath,
port: servePort,
target: normalizedTailscaleTarget,
token: pushToken,
});
@@ -236,6 +248,7 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
...baseConfig.hooks?.gmail?.tailscale,
mode: tailscaleMode,
path: tailscalePath,
target: normalizedTailscaleTarget,
},
},
},
@@ -299,6 +312,7 @@ export async function runGmailService(opts: GmailRunOptions) {
renewEveryMinutes: opts.renewEveryMinutes,
tailscaleMode: opts.tailscale,
tailscalePath: opts.tailscalePath,
tailscaleTarget: opts.tailscaleTarget,
};
const resolved = resolveGmailHookRuntimeConfig(config, overrides);
@@ -314,6 +328,7 @@ export async function runGmailService(opts: GmailRunOptions) {
mode: runtimeConfig.tailscale.mode,
path: runtimeConfig.tailscale.path,
port: runtimeConfig.serve.port,
target: runtimeConfig.tailscale.target,
});
}

View File

@@ -261,7 +261,8 @@ export async function ensureSubscription(
export async function ensureTailscaleEndpoint(params: {
mode: "off" | "serve" | "funnel";
path: string;
port: number;
port?: number;
target?: string;
token?: string;
}): Promise<string> {
if (params.mode === "off") return "";
@@ -285,7 +286,15 @@ export async function ensureTailscaleEndpoint(params: {
throw new Error("tailscale DNS name missing; run tailscale up");
}
const target = String(params.port);
const target =
typeof params.target === "string" && params.target.trim().length > 0
? params.target.trim()
: params.port
? String(params.port)
: "";
if (!target) {
throw new Error("tailscale target missing; set a port or target URL");
}
const pathArg = normalizeServePath(params.path);
const funnelArgs = [
params.mode,

View File

@@ -156,6 +156,7 @@ export async function startGmailWatcher(
mode: runtimeConfig.tailscale.mode,
path: runtimeConfig.tailscale.path,
port: runtimeConfig.serve.port,
target: runtimeConfig.tailscale.target,
});
log.info(
`tailscale ${runtimeConfig.tailscale.mode} configured for port ${runtimeConfig.serve.port}`,

View File

@@ -130,4 +130,33 @@ describe("gmail hook config", () => {
expect(result.value.tailscale.path).toBe("/custom");
}
});
it("keeps serve path when tailscale target is set", () => {
const result = resolveGmailHookRuntimeConfig(
{
hooks: {
token: "hook-token",
gmail: {
account: "clawdbot@gmail.com",
topic: "projects/demo/topics/gog-gmail-watch",
pushToken: "push-token",
serve: { path: "/custom" },
tailscale: {
mode: "funnel",
target: "http://127.0.0.1:8788/custom",
},
},
},
},
{},
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.serve.path).toBe("/custom");
expect(result.value.tailscale.path).toBe("/custom");
expect(result.value.tailscale.target).toBe(
"http://127.0.0.1:8788/custom",
);
}
});
});

View File

@@ -33,6 +33,7 @@ export type GmailHookOverrides = {
servePath?: string;
tailscaleMode?: HooksGmailTailscaleMode;
tailscalePath?: string;
tailscaleTarget?: string;
};
export type GmailHookRuntimeConfig = {
@@ -54,6 +55,7 @@ export type GmailHookRuntimeConfig = {
tailscale: {
mode: HooksGmailTailscaleMode;
path: string;
target?: string;
};
};
@@ -164,12 +166,20 @@ export function resolveGmailHookRuntimeConfig(
typeof servePathRaw === "string" && servePathRaw.trim().length > 0
? normalizeServePath(servePathRaw)
: DEFAULT_GMAIL_SERVE_PATH;
const tailscaleTargetRaw =
overrides.tailscaleTarget ?? gmail?.tailscale?.target;
const tailscaleMode =
overrides.tailscaleMode ?? gmail?.tailscale?.mode ?? "off";
const tailscaleTarget =
tailscaleMode !== "off" &&
typeof tailscaleTargetRaw === "string" &&
tailscaleTargetRaw.trim().length > 0
? tailscaleTargetRaw.trim()
: undefined;
// Tailscale strips the public path before proxying, so listen on "/" when on.
const servePath = normalizeServePath(
tailscaleMode !== "off" ? "/" : normalizedServePathRaw,
tailscaleMode !== "off" && !tailscaleTarget ? "/" : normalizedServePathRaw,
);
const tailscalePathRaw = overrides.tailscalePath ?? gmail?.tailscale?.path;
@@ -200,6 +210,7 @@ export function resolveGmailHookRuntimeConfig(
tailscale: {
mode: tailscaleMode,
path: tailscalePath,
target: tailscaleTarget,
},
},
};