diff --git a/CHANGELOG.md b/CHANGELOG.md index 04326db0b..25a1392ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/automation/gmail-pubsub.md b/docs/automation/gmail-pubsub.md index d1f9263fa..f36485f8c 100644 --- a/docs/automation/gmail-pubsub.md +++ b/docs/automation/gmail-pubsub.md @@ -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 ` or `--tailscale off`. diff --git a/docs/cli/index.md b/docs/cli/index.md index f77d973c7..7ea3b5056 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -292,7 +292,7 @@ Subcommands: Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub). Subcommands: -- `hooks gmail setup` (requires `--account `; 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 `; 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` diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f6dd9c1e1..3d8121ff4 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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) diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index a4a280d73..1bb7ec924 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -71,6 +71,10 @@ export function registerHooksCli(program: Command) { "funnel", ) .option("--tailscale-path ", "Path for tailscale serve/funnel") + .option( + "--tailscale-target ", + "Tailscale serve/funnel target (port, host:port, or URL)", + ) .option("--push-endpoint ", "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 for tailscale serve/funnel") + .option( + "--tailscale-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): GmailRunOptions { renewEveryMinutes: numberOption(raw.renewMinutes), tailscale: stringOption(raw.tailscale) as GmailRunOptions["tailscale"], tailscalePath: stringOption(raw.tailscalePath), + tailscaleTarget: stringOption(raw.tailscaleTarget), }; } diff --git a/src/config/types.ts b/src/config/types.ts index 6ca0d56e0..87ed2817f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 44212317a..8b07744aa 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), diff --git a/src/hooks/gmail-ops.ts b/src/hooks/gmail-ops.ts index b99e7d8fb..b455c3b6b 100644 --- a/src/hooks/gmail-ops.ts +++ b/src/hooks/gmail-ops.ts @@ -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, }); } diff --git a/src/hooks/gmail-setup-utils.ts b/src/hooks/gmail-setup-utils.ts index c9bc2ad5b..f427eaf7b 100644 --- a/src/hooks/gmail-setup-utils.ts +++ b/src/hooks/gmail-setup-utils.ts @@ -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 { 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, diff --git a/src/hooks/gmail-watcher.ts b/src/hooks/gmail-watcher.ts index 0b3133066..442437cc9 100644 --- a/src/hooks/gmail-watcher.ts +++ b/src/hooks/gmail-watcher.ts @@ -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}`, diff --git a/src/hooks/gmail.test.ts b/src/hooks/gmail.test.ts index 2dd69295e..d011f0904 100644 --- a/src/hooks/gmail.test.ts +++ b/src/hooks/gmail.test.ts @@ -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", + ); + } + }); }); diff --git a/src/hooks/gmail.ts b/src/hooks/gmail.ts index 772903356..f15e851a4 100644 --- a/src/hooks/gmail.ts +++ b/src/hooks/gmail.ts @@ -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, }, }, };