feat: add gmail hooks wizard

This commit is contained in:
Peter Steinberger
2025-12-24 19:39:36 +00:00
parent aeb5455555
commit 523d9ec3c2
10 changed files with 1332 additions and 1 deletions

View File

@@ -7,6 +7,7 @@
- Custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.
- Group chat activation modes: per-group `/activation mention|always` command with status visibility.
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
- Hook mappings + Gmail Pub/Sub helper (`clawdis hooks gmail setup/run`) with auto-renew + Tailscale Funnel support.
### Breaking
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.

View File

@@ -318,13 +318,27 @@ Enable a simple HTTP webhook surface on the Gateway HTTP server.
Defaults:
- enabled: `false`
- path: `/hooks`
- maxBodyBytes: `262144` (256 KB)
```json5
{
hooks: {
enabled: true,
token: "shared-secret",
path: "/hooks"
path: "/hooks",
presets: ["gmail"],
transformsDir: "~/.clawdis/hooks",
mappings: [
{
match: { path: "gmail" },
action: "agent",
wakeMode: "now",
name: "Gmail",
sessionKey: "hook:gmail:{{messages[0].id}}",
messageTemplate:
"From: {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}",
},
],
}
}
```
@@ -337,9 +351,37 @@ Requests must include the hook token:
Endpoints:
- `POST /hooks/wake``{ text, mode?: "now"|"next-heartbeat" }`
- `POST /hooks/agent``{ message, name?, sessionKey?, wakeMode?, deliver?, channel?, to?, thinking?, timeoutSeconds? }`
- `POST /hooks/<name>` → resolved via `hooks.mappings`
`/hooks/agent` always posts a summary into the main session (and can optionally trigger an immediate heartbeat via `wakeMode: "now"`).
Mapping notes:
- `match.path` matches the sub-path after `/hooks` (e.g. `/hooks/gmail``gmail`).
- `match.source` matches a payload field (e.g. `{ source: "gmail" }`) so you can use a generic `/hooks/ingest` path.
- Templates like `{{messages[0].subject}}` read from the payload.
- `transform` can point to a JS/TS module that returns a hook action.
Gmail helper config (used by `clawdis hooks gmail setup` / `run`):
```json5
{
hooks: {
gmail: {
account: "clawdbot@gmail.com",
topic: "projects/<project-id>/topics/gog-gmail-watch",
subscription: "gog-gmail-watch-push",
pushToken: "shared-push-token",
hookUrl: "http://127.0.0.1:18789/hooks/gmail",
includeBody: true,
maxBytes: 20000,
renewEveryMinutes: 720,
serve: { bind: "127.0.0.1", port: 8788, path: "/gmail-pubsub" },
tailscale: { mode: "funnel", path: "/gmail-pubsub" },
}
}
}
```
### `canvasHost` (LAN/tailnet Canvas file server + live reload)
The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it.

179
docs/gmail-pubsub.md Normal file
View File

@@ -0,0 +1,179 @@
---
summary: "Gmail Pub/Sub push wired into Clawdis webhooks via gogcli"
read_when:
- Wiring Gmail inbox triggers to Clawdis
- Setting up Pub/Sub push for agent wake
---
# Gmail Pub/Sub -> Clawdis
Goal: Gmail watch -> Pub/Sub push -> `gog gmail watch serve` -> Clawdis webhook.
## Prereqs
- `gcloud` installed and logged in.
- `gog` (gogcli) installed and authorized for the Gmail account.
- Clawdis hooks enabled (see `docs/webhook.md`).
- `tailscale` logged in if you want a public HTTPS endpoint via Funnel.
Example hook config (enable Gmail preset mapping):
```json5
{
hooks: {
enabled: true,
token: "CLAWDIS_HOOK_TOKEN",
path: "/hooks",
presets: ["gmail"]
}
}
```
To customize payload handling, add `hooks.mappings` or a JS/TS transform module
under `hooks.transformsDir` (see `docs/webhook.md`).
## Wizard (recommended)
Use the Clawdis helper to wire everything together (installs deps on macOS via brew):
```bash
clawdis hooks gmail setup \
--account clawdbot@gmail.com
```
Defaults:
- Uses Tailscale Funnel for the public push endpoint.
- Writes `hooks.gmail` config for `clawdis hooks gmail run`.
- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`).
Want a custom endpoint? Use `--push-endpoint <url>` or `--tailscale off`.
Platform note: on macOS the wizard installs `gcloud`, `gogcli`, and `tailscale`
via Homebrew; on Linux install them manually first.
Run the daemon (starts `gog gmail watch serve` + auto-renew):
```bash
clawdis hooks gmail run
```
## One-time setup
1) Select the GCP project **that owns the OAuth client** used by `gog`.
```bash
gcloud auth login
gcloud config set project <project-id>
```
Note: Gmail watch requires the Pub/Sub topic to live in the same project as the OAuth client.
2) Enable APIs:
```bash
gcloud services enable gmail.googleapis.com pubsub.googleapis.com
```
3) Create a topic:
```bash
gcloud pubsub topics create gog-gmail-watch
```
4) Allow Gmail push to publish:
```bash
gcloud pubsub topics add-iam-policy-binding gog-gmail-watch \
--member=serviceAccount:gmail-api-push@system.gserviceaccount.com \
--role=roles/pubsub.publisher
```
## Start the watch
```bash
gog gmail watch start \
--account clawdbot@gmail.com \
--label INBOX \
--topic projects/<project-id>/topics/gog-gmail-watch
```
Save the `history_id` from the output (for debugging).
## Run the push handler
Local example (shared token auth):
```bash
gog gmail watch serve \
--account clawdbot@gmail.com \
--bind 127.0.0.1 \
--port 8788 \
--path /gmail-pubsub \
--token <shared> \
--hook-url http://127.0.0.1:18789/hooks/gmail \
--hook-token CLAWDIS_HOOK_TOKEN \
--include-body \
--max-bytes 20000
```
Notes:
- `--token` protects the push endpoint (`x-gog-token` or `?token=`).
- `--hook-url` points to Clawdis `/hooks/gmail` (mapped; isolated run + summary to main).
- `--include-body` and `--max-bytes` control the body snippet sent to Clawdis.
Recommended: `clawdis hooks gmail run` wraps the same flow and auto-renews the watch.
## Expose the handler (dev)
For local testing, tunnel the handler and use the public URL in the push subscription:
```bash
cloudflared tunnel --url http://127.0.0.1:8788 --no-autoupdate
```
Use the generated URL as the push endpoint:
```bash
gcloud pubsub subscriptions create gog-gmail-watch-push \
--topic gog-gmail-watch \
--push-endpoint "https://<public-url>/gmail-pubsub?token=<shared>"
```
Production: use a stable HTTPS endpoint and configure Pub/Sub OIDC JWT, then run:
```bash
gog gmail watch serve --verify-oidc --oidc-email <svc@...>
```
## Test
Send a message to the watched inbox:
```bash
gog gmail send \
--account clawdbot@gmail.com \
--to clawdbot@gmail.com \
--subject "watch test" \
--body "ping"
```
Check watch state and history:
```bash
gog gmail watch status --account clawdbot@gmail.com
gog gmail history --account clawdbot@gmail.com --since <historyId>
```
## Troubleshooting
- `Invalid topicName`: project mismatch (topic not in the OAuth client project).
- `User not authorized`: missing `roles/pubsub.publisher` on the topic.
- Empty messages: Gmail push only provides `historyId`; fetch via `gog gmail history`.
## Cleanup
```bash
gog gmail watch stop --account clawdbot@gmail.com
gcloud pubsub subscriptions delete gog-gmail-watch-push
gcloud pubsub topics delete gog-gmail-watch
```

View File

@@ -80,6 +80,20 @@ Effect:
- Always posts a summary into the **main** session
- If `wakeMode=now`, triggers an immediate heartbeat
### `POST /hooks/<name>` (mapped)
Custom hook names are resolved via `hooks.mappings` (see configuration). A mapping can
turn arbitrary payloads into `wake` or `agent` actions, with optional templates or
code transforms.
Mapping options (summary):
- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping.
- `hooks.mappings` lets you define `match`, `action`, and templates in config.
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
- TS transforms require a TS loader (e.g. `tsx`) or precompiled `.js` at runtime.
- `clawdis hooks gmail setup` writes `hooks.gmail` config for `clawdis hooks gmail run`.
## Responses
- `200` for `/hooks/wake`
@@ -104,6 +118,13 @@ curl -X POST http://127.0.0.1:18789/hooks/agent \
-d '{"message":"Summarize inbox","name":"Email","wakeMode":"next-heartbeat"}'
```
```bash
curl -X POST http://127.0.0.1:18789/hooks/gmail \
-H 'Authorization: Bearer SECRET' \
-H 'Content-Type: application/json' \
-d '{"source":"gmail","messages":[{"from":"Ada","subject":"Hello","snippet":"Hi"}]}'
```
## Security
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.

182
src/cli/hooks-cli.ts Normal file
View File

@@ -0,0 +1,182 @@
import type { Command } from "commander";
import { danger } from "../globals.js";
import {
DEFAULT_GMAIL_LABEL,
DEFAULT_GMAIL_MAX_BYTES,
DEFAULT_GMAIL_RENEW_MINUTES,
DEFAULT_GMAIL_SERVE_BIND,
DEFAULT_GMAIL_SERVE_PATH,
DEFAULT_GMAIL_SERVE_PORT,
DEFAULT_GMAIL_SUBSCRIPTION,
DEFAULT_GMAIL_TOPIC,
} from "../hooks/gmail.js";
import {
type GmailRunOptions,
type GmailSetupOptions,
runGmailService,
runGmailSetup,
} from "../hooks/gmail-ops.js";
import { defaultRuntime } from "../runtime.js";
export function registerHooksCli(program: Command) {
const hooks = program
.command("hooks")
.description("Webhook helpers and hook-based integrations");
const gmail = hooks
.command("gmail")
.description("Gmail Pub/Sub hooks (via gogcli)");
gmail
.command("setup")
.description("Configure Gmail watch + Pub/Sub + Clawdis hooks")
.requiredOption("--account <email>", "Gmail account to watch")
.option("--project <id>", "GCP project id (OAuth client owner)")
.option("--topic <name>", "Pub/Sub topic name", DEFAULT_GMAIL_TOPIC)
.option(
"--subscription <name>",
"Pub/Sub subscription name",
DEFAULT_GMAIL_SUBSCRIPTION,
)
.option("--label <label>", "Gmail label to watch", DEFAULT_GMAIL_LABEL)
.option("--hook-url <url>", "Clawdis hook URL")
.option("--hook-token <token>", "Clawdis hook token")
.option("--push-token <token>", "Push token for gog watch serve")
.option(
"--bind <host>",
"gog watch serve bind host",
DEFAULT_GMAIL_SERVE_BIND,
)
.option(
"--port <port>",
"gog watch serve port",
String(DEFAULT_GMAIL_SERVE_PORT),
)
.option("--path <path>", "gog watch serve path", DEFAULT_GMAIL_SERVE_PATH)
.option("--include-body", "Include email body snippets", true)
.option(
"--max-bytes <n>",
"Max bytes for body snippets",
String(DEFAULT_GMAIL_MAX_BYTES),
)
.option(
"--renew-minutes <n>",
"Renew watch every N minutes",
String(DEFAULT_GMAIL_RENEW_MINUTES),
)
.option(
"--tailscale <mode>",
"Expose push endpoint via tailscale (funnel|serve|off)",
"funnel",
)
.option("--tailscale-path <path>", "Path for tailscale serve/funnel")
.option("--push-endpoint <url>", "Explicit Pub/Sub push endpoint")
.option("--json", "Output JSON summary", false)
.action(async (opts) => {
try {
const parsed = parseGmailSetupOptions(opts);
await runGmailSetup(parsed);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
gmail
.command("run")
.description("Run gog watch serve + auto-renew loop")
.option("--account <email>", "Gmail account to watch")
.option("--topic <topic>", "Pub/Sub topic path (projects/.../topics/..)")
.option("--subscription <name>", "Pub/Sub subscription name")
.option("--label <label>", "Gmail label to watch")
.option("--hook-url <url>", "Clawdis hook URL")
.option("--hook-token <token>", "Clawdis hook token")
.option("--push-token <token>", "Push token for gog watch serve")
.option("--bind <host>", "gog watch serve bind host")
.option("--port <port>", "gog watch serve port")
.option("--path <path>", "gog watch serve path")
.option("--include-body", "Include email body snippets")
.option("--max-bytes <n>", "Max bytes for body snippets")
.option("--renew-minutes <n>", "Renew watch every N minutes")
.option(
"--tailscale <mode>",
"Expose push endpoint via tailscale (funnel|serve|off)",
)
.option("--tailscale-path <path>", "Path for tailscale serve/funnel")
.action(async (opts) => {
try {
const parsed = parseGmailRunOptions(opts);
await runGmailService(parsed);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
}
function parseGmailSetupOptions(
raw: Record<string, unknown>,
): GmailSetupOptions {
const accountRaw = raw.account;
const account = typeof accountRaw === "string" ? accountRaw.trim() : "";
if (!account) throw new Error("--account is required");
return {
account,
project: stringOption(raw.project),
topic: stringOption(raw.topic),
subscription: stringOption(raw.subscription),
label: stringOption(raw.label),
hookUrl: stringOption(raw.hookUrl),
hookToken: stringOption(raw.hookToken),
pushToken: stringOption(raw.pushToken),
bind: stringOption(raw.bind),
port: numberOption(raw.port),
path: stringOption(raw.path),
includeBody: booleanOption(raw.includeBody),
maxBytes: numberOption(raw.maxBytes),
renewEveryMinutes: numberOption(raw.renewMinutes),
tailscale: stringOption(raw.tailscale) as GmailSetupOptions["tailscale"],
tailscalePath: stringOption(raw.tailscalePath),
pushEndpoint: stringOption(raw.pushEndpoint),
json: Boolean(raw.json),
};
}
function parseGmailRunOptions(raw: Record<string, unknown>): GmailRunOptions {
return {
account: stringOption(raw.account),
topic: stringOption(raw.topic),
subscription: stringOption(raw.subscription),
label: stringOption(raw.label),
hookUrl: stringOption(raw.hookUrl),
hookToken: stringOption(raw.hookToken),
pushToken: stringOption(raw.pushToken),
bind: stringOption(raw.bind),
port: numberOption(raw.port),
path: stringOption(raw.path),
includeBody: booleanOption(raw.includeBody),
maxBytes: numberOption(raw.maxBytes),
renewEveryMinutes: numberOption(raw.renewMinutes),
tailscale: stringOption(raw.tailscale) as GmailRunOptions["tailscale"],
tailscalePath: stringOption(raw.tailscalePath),
};
}
function stringOption(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function numberOption(value: unknown): number | undefined {
if (value === undefined || value === null) return undefined;
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n) || n <= 0) return undefined;
return Math.floor(n);
}
function booleanOption(value: unknown): boolean | undefined {
if (value === undefined || value === null) return undefined;
return Boolean(value);
}

View File

@@ -16,6 +16,7 @@ import { registerCronCli } from "./cron-cli.js";
import { createDefaultDeps } from "./deps.js";
import { registerDnsCli } from "./dns-cli.js";
import { registerGatewayCli } from "./gateway-cli.js";
import { registerHooksCli } from "./hooks-cli.js";
import { registerNodesCli } from "./nodes-cli.js";
import { forceFreePort } from "./ports.js";
@@ -240,6 +241,7 @@ Examples:
registerNodesCli(program);
registerCronCli(program);
registerDnsCli(program);
registerHooksCli(program);
program
.command("status")

351
src/hooks/gmail-ops.ts Normal file
View File

@@ -0,0 +1,351 @@
import { spawn } from "node:child_process";
import {
type ClawdisConfig,
CONFIG_PATH_CLAWDIS,
loadConfig,
readConfigFileSnapshot,
validateConfigObject,
writeConfigFile,
} from "../config/config.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime } from "../runtime.js";
import {
buildDefaultHookUrl,
buildGogWatchServeArgs,
buildGogWatchStartArgs,
buildTopicPath,
DEFAULT_GMAIL_LABEL,
DEFAULT_GMAIL_MAX_BYTES,
DEFAULT_GMAIL_RENEW_MINUTES,
DEFAULT_GMAIL_SERVE_BIND,
DEFAULT_GMAIL_SERVE_PATH,
DEFAULT_GMAIL_SERVE_PORT,
DEFAULT_GMAIL_SUBSCRIPTION,
DEFAULT_GMAIL_TOPIC,
type GmailHookOverrides,
type GmailHookRuntimeConfig,
generateHookToken,
mergeHookPresets,
normalizeHooksPath,
normalizeServePath,
parseTopicPath,
resolveGmailHookRuntimeConfig,
} from "./gmail.js";
import {
ensureDependency,
ensureGcloudAuth,
ensureSubscription,
ensureTailscaleEndpoint,
ensureTopic,
resolveProjectIdFromGogCredentials,
runGcloud,
} from "./gmail-setup-utils.js";
export type GmailSetupOptions = {
account: string;
project?: string;
topic?: string;
subscription?: string;
label?: string;
hookToken?: string;
pushToken?: string;
hookUrl?: string;
bind?: string;
port?: number;
path?: string;
includeBody?: boolean;
maxBytes?: number;
renewEveryMinutes?: number;
tailscale?: "off" | "serve" | "funnel";
tailscalePath?: string;
pushEndpoint?: string;
json?: boolean;
};
export type GmailRunOptions = {
account?: string;
topic?: string;
subscription?: string;
label?: string;
hookToken?: string;
pushToken?: string;
hookUrl?: string;
bind?: string;
port?: number;
path?: string;
includeBody?: boolean;
maxBytes?: number;
renewEveryMinutes?: number;
tailscale?: "off" | "serve" | "funnel";
tailscalePath?: string;
};
const DEFAULT_GMAIL_TOPIC_IAM_MEMBER =
"serviceAccount:gmail-api-push@system.gserviceaccount.com";
export async function runGmailSetup(opts: GmailSetupOptions) {
await ensureDependency("gcloud", ["--cask", "gcloud-cli"]);
await ensureDependency("gog", ["gogcli"]);
if (opts.tailscale !== "off" && !opts.pushEndpoint) {
await ensureDependency("tailscale", ["tailscale"]);
}
await ensureGcloudAuth();
const configSnapshot = await readConfigFileSnapshot();
if (!configSnapshot.valid) {
throw new Error(`Config invalid: ${CONFIG_PATH_CLAWDIS}`);
}
const baseConfig = configSnapshot.config;
const hooksPath = normalizeHooksPath(baseConfig.hooks?.path);
const hookToken =
opts.hookToken ?? baseConfig.hooks?.token ?? generateHookToken();
const pushToken =
opts.pushToken ?? baseConfig.hooks?.gmail?.pushToken ?? generateHookToken();
const topicInput =
opts.topic ?? baseConfig.hooks?.gmail?.topic ?? DEFAULT_GMAIL_TOPIC;
const parsedTopic = parseTopicPath(topicInput);
const topicName = parsedTopic?.topicName ?? topicInput;
const projectId =
opts.project ??
parsedTopic?.projectId ??
(await resolveProjectIdFromGogCredentials());
if (!projectId) {
throw new Error(
"GCP project id required (use --project or ensure gog credentials are available)",
);
}
const topicPath = buildTopicPath(projectId, topicName);
const subscription = opts.subscription ?? DEFAULT_GMAIL_SUBSCRIPTION;
const label = opts.label ?? DEFAULT_GMAIL_LABEL;
const hookUrl =
opts.hookUrl ??
baseConfig.hooks?.gmail?.hookUrl ??
buildDefaultHookUrl(hooksPath);
const serveBind = opts.bind ?? DEFAULT_GMAIL_SERVE_BIND;
const servePort = opts.port ?? DEFAULT_GMAIL_SERVE_PORT;
const servePath = normalizeServePath(opts.path ?? DEFAULT_GMAIL_SERVE_PATH);
const includeBody = opts.includeBody ?? true;
const maxBytes = opts.maxBytes ?? DEFAULT_GMAIL_MAX_BYTES;
const renewEveryMinutes =
opts.renewEveryMinutes ?? DEFAULT_GMAIL_RENEW_MINUTES;
const tailscaleMode = opts.tailscale ?? "funnel";
const tailscalePath = normalizeServePath(opts.tailscalePath ?? servePath);
await runGcloud(["config", "set", "project", projectId, "--quiet"]);
await runGcloud([
"services",
"enable",
"gmail.googleapis.com",
"pubsub.googleapis.com",
"--project",
projectId,
"--quiet",
]);
await ensureTopic(projectId, topicName);
await runGcloud([
"pubsub",
"topics",
"add-iam-policy-binding",
topicName,
"--project",
projectId,
"--member",
DEFAULT_GMAIL_TOPIC_IAM_MEMBER,
"--role",
"roles/pubsub.publisher",
"--quiet",
]);
const pushEndpoint = opts.pushEndpoint
? opts.pushEndpoint
: await ensureTailscaleEndpoint({
mode: tailscaleMode,
path: tailscalePath,
port: servePort,
token: pushToken,
});
if (!pushEndpoint) {
throw new Error("push endpoint required (set --push-endpoint)");
}
await ensureSubscription(projectId, subscription, topicName, pushEndpoint);
await startGmailWatch(
{
account: opts.account,
label,
topic: topicPath,
},
true,
);
const nextConfig: ClawdisConfig = {
...baseConfig,
hooks: {
...baseConfig.hooks,
enabled: true,
path: hooksPath,
token: hookToken,
presets: mergeHookPresets(baseConfig.hooks?.presets, "gmail"),
gmail: {
...baseConfig.hooks?.gmail,
account: opts.account,
label,
topic: topicPath,
subscription,
pushToken,
hookUrl,
includeBody,
maxBytes,
renewEveryMinutes,
serve: {
...baseConfig.hooks?.gmail?.serve,
bind: serveBind,
port: servePort,
path: servePath,
},
tailscale: {
...baseConfig.hooks?.gmail?.tailscale,
mode: tailscaleMode,
path: tailscalePath,
},
},
},
};
const validated = validateConfigObject(nextConfig);
if (!validated.ok) {
throw new Error(
`Config validation failed: ${validated.issues[0]?.message ?? "invalid"}`,
);
}
await writeConfigFile(validated.config);
const summary = {
projectId,
topic: topicPath,
subscription,
pushEndpoint,
hookUrl,
hookToken,
pushToken,
serve: {
bind: serveBind,
port: servePort,
path: servePath,
},
};
if (opts.json) {
defaultRuntime.log(JSON.stringify(summary, null, 2));
return;
}
defaultRuntime.log("Gmail hooks configured:");
defaultRuntime.log(`- project: ${projectId}`);
defaultRuntime.log(`- topic: ${topicPath}`);
defaultRuntime.log(`- subscription: ${subscription}`);
defaultRuntime.log(`- push endpoint: ${pushEndpoint}`);
defaultRuntime.log(`- hook url: ${hookUrl}`);
defaultRuntime.log(`- config: ${CONFIG_PATH_CLAWDIS}`);
defaultRuntime.log("Next: clawdis hooks gmail run");
}
export async function runGmailService(opts: GmailRunOptions) {
await ensureDependency("gog", ["gogcli"]);
const config = loadConfig();
const overrides: GmailHookOverrides = {
account: opts.account,
topic: opts.topic,
subscription: opts.subscription,
label: opts.label,
hookToken: opts.hookToken,
pushToken: opts.pushToken,
hookUrl: opts.hookUrl,
serveBind: opts.bind,
servePort: opts.port,
servePath: opts.path,
includeBody: opts.includeBody,
maxBytes: opts.maxBytes,
renewEveryMinutes: opts.renewEveryMinutes,
tailscaleMode: opts.tailscale,
tailscalePath: opts.tailscalePath,
};
const resolved = resolveGmailHookRuntimeConfig(config, overrides);
if (!resolved.ok) {
throw new Error(resolved.error);
}
const runtimeConfig = resolved.value;
if (runtimeConfig.tailscale.mode !== "off") {
await ensureDependency("tailscale", ["tailscale"]);
await ensureTailscaleEndpoint({
mode: runtimeConfig.tailscale.mode,
path: runtimeConfig.tailscale.path,
port: runtimeConfig.serve.port,
});
}
await startGmailWatch(runtimeConfig);
let shuttingDown = false;
let child = spawnGogServe(runtimeConfig);
const renewMs = runtimeConfig.renewEveryMinutes * 60_000;
const renewTimer = setInterval(() => {
void startGmailWatch(runtimeConfig);
}, renewMs);
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
clearInterval(renewTimer);
child.kill("SIGTERM");
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
child.on("exit", () => {
if (shuttingDown) return;
defaultRuntime.log("gog watch serve exited; restarting in 2s");
setTimeout(() => {
if (shuttingDown) return;
child = spawnGogServe(runtimeConfig);
}, 2000);
});
}
function spawnGogServe(cfg: GmailHookRuntimeConfig) {
const args = buildGogWatchServeArgs(cfg);
defaultRuntime.log(`Starting gog ${args.join(" ")}`);
return spawn("gog", args, { stdio: "inherit" });
}
async function startGmailWatch(
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
fatal = false,
) {
const args = ["gog", ...buildGogWatchStartArgs(cfg)];
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
if (result.code !== 0) {
const message = result.stderr || result.stdout || "gog watch start failed";
if (fatal) throw new Error(message);
defaultRuntime.error(message);
}
}

View File

@@ -0,0 +1,239 @@
import fs from "node:fs";
import path from "node:path";
import { hasBinary } from "../agents/skills.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { normalizeServePath } from "./gmail.js";
export async function ensureDependency(bin: string, brewArgs: string[]) {
if (hasBinary(bin)) return;
if (process.platform !== "darwin") {
throw new Error(`${bin} not installed; install it and retry`);
}
if (!hasBinary("brew")) {
throw new Error("Homebrew not installed (install brew and retry)");
}
const result = await runCommandWithTimeout(["brew", "install", ...brewArgs], {
timeoutMs: 600_000,
});
if (result.code !== 0) {
throw new Error(
`brew install failed for ${bin}: ${result.stderr || result.stdout}`,
);
}
if (!hasBinary(bin)) {
throw new Error(`${bin} still not available after brew install`);
}
}
export async function ensureGcloudAuth() {
const res = await runCommandWithTimeout(
[
"gcloud",
"auth",
"list",
"--filter",
"status:ACTIVE",
"--format",
"value(account)",
],
{ timeoutMs: 30_000 },
);
if (res.code === 0 && res.stdout.trim()) return;
const login = await runCommandWithTimeout(["gcloud", "auth", "login"], {
timeoutMs: 600_000,
});
if (login.code !== 0) {
throw new Error(login.stderr || "gcloud auth login failed");
}
}
export async function runGcloud(args: string[]) {
const result = await runCommandWithTimeout(["gcloud", ...args], {
timeoutMs: 120_000,
});
if (result.code !== 0) {
throw new Error(result.stderr || result.stdout || "gcloud command failed");
}
return result;
}
export async function ensureTopic(projectId: string, topicName: string) {
const describe = await runCommandWithTimeout(
[
"gcloud",
"pubsub",
"topics",
"describe",
topicName,
"--project",
projectId,
],
{ timeoutMs: 30_000 },
);
if (describe.code === 0) return;
await runGcloud([
"pubsub",
"topics",
"create",
topicName,
"--project",
projectId,
]);
}
export async function ensureSubscription(
projectId: string,
subscription: string,
topicName: string,
pushEndpoint: string,
) {
const describe = await runCommandWithTimeout(
[
"gcloud",
"pubsub",
"subscriptions",
"describe",
subscription,
"--project",
projectId,
],
{ timeoutMs: 30_000 },
);
if (describe.code === 0) {
await runGcloud([
"pubsub",
"subscriptions",
"update",
subscription,
"--project",
projectId,
"--push-endpoint",
pushEndpoint,
]);
return;
}
await runGcloud([
"pubsub",
"subscriptions",
"create",
subscription,
"--project",
projectId,
"--topic",
topicName,
"--push-endpoint",
pushEndpoint,
]);
}
export async function ensureTailscaleEndpoint(params: {
mode: "off" | "serve" | "funnel";
path: string;
port: number;
token?: string;
}): Promise<string> {
if (params.mode === "off") return "";
const status = await runCommandWithTimeout(
["tailscale", "status", "--json"],
{
timeoutMs: 30_000,
},
);
if (status.code !== 0) {
throw new Error(status.stderr || "tailscale status failed");
}
const parsed = JSON.parse(status.stdout) as {
Self?: { DNSName?: string };
};
const dnsName = parsed.Self?.DNSName?.replace(/\.$/, "");
if (!dnsName) {
throw new Error("tailscale DNS name missing; run tailscale up");
}
const target = String(params.port);
const pathArg = normalizeServePath(params.path);
const funnelArgs = [
"tailscale",
params.mode,
"--bg",
"--set-path",
pathArg,
"--yes",
target,
];
const funnelResult = await runCommandWithTimeout(funnelArgs, {
timeoutMs: 30_000,
});
if (funnelResult.code !== 0) {
throw new Error(funnelResult.stderr || "tailscale funnel failed");
}
const baseUrl = `https://${dnsName}${pathArg}`;
return params.token ? `${baseUrl}?token=${params.token}` : baseUrl;
}
export async function resolveProjectIdFromGogCredentials(): Promise<
string | null
> {
const candidates = gogCredentialsPaths();
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) continue;
try {
const raw = fs.readFileSync(candidate, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const clientId = extractGogClientId(parsed);
const projectNumber = extractProjectNumber(clientId);
if (!projectNumber) continue;
const res = await runCommandWithTimeout(
[
"gcloud",
"projects",
"list",
"--filter",
`projectNumber=${projectNumber}`,
"--format",
"value(projectId)",
],
{ timeoutMs: 30_000 },
);
if (res.code !== 0) continue;
const projectId = res.stdout.trim().split(/\s+/)[0];
if (projectId) return projectId;
} catch {
// keep scanning
}
}
return null;
}
function gogCredentialsPaths(): string[] {
const paths: string[] = [];
const xdg = process.env.XDG_CONFIG_HOME;
if (xdg) {
paths.push(path.join(xdg, "gogcli", "credentials.json"));
}
paths.push(resolveUserPath("~/.config/gogcli/credentials.json"));
if (process.platform === "darwin") {
paths.push(
resolveUserPath("~/Library/Application Support/gogcli/credentials.json"),
);
}
return paths;
}
function extractGogClientId(parsed: Record<string, unknown>): string | null {
const installed = parsed.installed as Record<string, unknown> | undefined;
const web = parsed.web as Record<string, unknown> | undefined;
const candidate =
installed?.client_id || web?.client_id || parsed.client_id || "";
return typeof candidate === "string" ? candidate : null;
}
function extractProjectNumber(clientId: string | null): string | null {
if (!clientId) return null;
const match = clientId.match(/^(\d+)-/);
return match?.[1] ?? null;
}

63
src/hooks/gmail.test.ts Normal file
View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { ClawdisConfig } from "../config/config.js";
import {
buildDefaultHookUrl,
buildTopicPath,
parseTopicPath,
resolveGmailHookRuntimeConfig,
} from "./gmail.js";
const baseConfig = {
hooks: {
token: "hook-token",
gmail: {
account: "clawdbot@gmail.com",
topic: "projects/demo/topics/gog-gmail-watch",
pushToken: "push-token",
},
},
} satisfies ClawdisConfig;
describe("gmail hook config", () => {
it("builds default hook url", () => {
expect(buildDefaultHookUrl("/hooks")).toBe(
"http://127.0.0.1:18789/hooks/gmail",
);
});
it("parses topic path", () => {
const topic = buildTopicPath("proj", "topic");
expect(parseTopicPath(topic)).toEqual({
projectId: "proj",
topicName: "topic",
});
});
it("resolves runtime config with defaults", () => {
const result = resolveGmailHookRuntimeConfig(baseConfig, {});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.account).toBe("clawdbot@gmail.com");
expect(result.value.label).toBe("INBOX");
expect(result.value.includeBody).toBe(true);
expect(result.value.serve.port).toBe(8788);
expect(result.value.hookUrl).toBe("http://127.0.0.1:18789/hooks/gmail");
}
});
it("fails without hook token", () => {
const result = resolveGmailHookRuntimeConfig(
{
hooks: {
gmail: {
account: "clawdbot@gmail.com",
topic: "projects/demo/topics/gog-gmail-watch",
pushToken: "push-token",
},
},
},
{},
);
expect(result.ok).toBe(false);
});
});

251
src/hooks/gmail.ts Normal file
View File

@@ -0,0 +1,251 @@
import { randomBytes } from "node:crypto";
import type {
ClawdisConfig,
HooksGmailTailscaleMode,
} from "../config/config.js";
export const DEFAULT_GMAIL_LABEL = "INBOX";
export const DEFAULT_GMAIL_TOPIC = "gog-gmail-watch";
export const DEFAULT_GMAIL_SUBSCRIPTION = "gog-gmail-watch-push";
export const DEFAULT_GMAIL_SERVE_BIND = "127.0.0.1";
export const DEFAULT_GMAIL_SERVE_PORT = 8788;
export const DEFAULT_GMAIL_SERVE_PATH = "/gmail-pubsub";
export const DEFAULT_GMAIL_MAX_BYTES = 20_000;
export const DEFAULT_GMAIL_RENEW_MINUTES = 12 * 60;
export const DEFAULT_HOOKS_PATH = "/hooks";
export const DEFAULT_HOOKS_BASE_URL = "http://127.0.0.1:18789";
export type GmailHookOverrides = {
account?: string;
label?: string;
topic?: string;
subscription?: string;
pushToken?: string;
hookToken?: string;
hookUrl?: string;
includeBody?: boolean;
maxBytes?: number;
renewEveryMinutes?: number;
serveBind?: string;
servePort?: number;
servePath?: string;
tailscaleMode?: HooksGmailTailscaleMode;
tailscalePath?: string;
};
export type GmailHookRuntimeConfig = {
account: string;
label: string;
topic: string;
subscription: string;
pushToken: string;
hookToken: string;
hookUrl: string;
includeBody: boolean;
maxBytes: number;
renewEveryMinutes: number;
serve: {
bind: string;
port: number;
path: string;
};
tailscale: {
mode: HooksGmailTailscaleMode;
path: string;
};
};
export function generateHookToken(bytes = 24): string {
return randomBytes(bytes).toString("hex");
}
export function mergeHookPresets(
existing: string[] | undefined,
preset: string,
): string[] {
const next = new Set(
(existing ?? []).map((item) => item.trim()).filter(Boolean),
);
next.add(preset);
return Array.from(next);
}
export function normalizeHooksPath(raw?: string): string {
const base = raw?.trim() || DEFAULT_HOOKS_PATH;
if (base === "/") return DEFAULT_HOOKS_PATH;
const withSlash = base.startsWith("/") ? base : `/${base}`;
return withSlash.replace(/\/+$/, "");
}
export function normalizeServePath(raw?: string): string {
const base = raw?.trim() || DEFAULT_GMAIL_SERVE_PATH;
const withSlash = base.startsWith("/") ? base : `/${base}`;
return withSlash.replace(/\/+$/, "");
}
export function buildDefaultHookUrl(hooksPath?: string): string {
const basePath = normalizeHooksPath(hooksPath);
return joinUrl(DEFAULT_HOOKS_BASE_URL, `${basePath}/gmail`);
}
export function resolveGmailHookRuntimeConfig(
cfg: ClawdisConfig,
overrides: GmailHookOverrides,
): { ok: true; value: GmailHookRuntimeConfig } | { ok: false; error: string } {
const hooks = cfg.hooks;
const gmail = hooks?.gmail;
const hookToken = overrides.hookToken ?? hooks?.token ?? "";
if (!hookToken) {
return { ok: false, error: "hooks.token missing (needed for gmail hook)" };
}
const account = overrides.account ?? gmail?.account ?? "";
if (!account) {
return { ok: false, error: "gmail account required" };
}
const topic = overrides.topic ?? gmail?.topic ?? "";
if (!topic) {
return { ok: false, error: "gmail topic required" };
}
const subscription =
overrides.subscription ?? gmail?.subscription ?? DEFAULT_GMAIL_SUBSCRIPTION;
const pushToken = overrides.pushToken ?? gmail?.pushToken ?? "";
if (!pushToken) {
return { ok: false, error: "gmail push token required" };
}
const hookUrl =
overrides.hookUrl ?? gmail?.hookUrl ?? buildDefaultHookUrl(hooks?.path);
const includeBody = overrides.includeBody ?? gmail?.includeBody ?? true;
const maxBytesRaw = overrides.maxBytes ?? gmail?.maxBytes;
const maxBytes =
typeof maxBytesRaw === "number" &&
Number.isFinite(maxBytesRaw) &&
maxBytesRaw > 0
? Math.floor(maxBytesRaw)
: DEFAULT_GMAIL_MAX_BYTES;
const renewEveryMinutesRaw =
overrides.renewEveryMinutes ?? gmail?.renewEveryMinutes;
const renewEveryMinutes =
typeof renewEveryMinutesRaw === "number" &&
Number.isFinite(renewEveryMinutesRaw) &&
renewEveryMinutesRaw > 0
? Math.floor(renewEveryMinutesRaw)
: DEFAULT_GMAIL_RENEW_MINUTES;
const serveBind =
overrides.serveBind ?? gmail?.serve?.bind ?? DEFAULT_GMAIL_SERVE_BIND;
const servePortRaw = overrides.servePort ?? gmail?.serve?.port;
const servePort =
typeof servePortRaw === "number" &&
Number.isFinite(servePortRaw) &&
servePortRaw > 0
? Math.floor(servePortRaw)
: DEFAULT_GMAIL_SERVE_PORT;
const servePath = normalizeServePath(
overrides.servePath ?? gmail?.serve?.path,
);
const tailscaleMode =
overrides.tailscaleMode ?? gmail?.tailscale?.mode ?? "off";
const tailscalePath = normalizeServePath(
overrides.tailscalePath ?? gmail?.tailscale?.path ?? servePath,
);
return {
ok: true,
value: {
account,
label: overrides.label ?? gmail?.label ?? DEFAULT_GMAIL_LABEL,
topic,
subscription,
pushToken,
hookToken,
hookUrl,
includeBody,
maxBytes,
renewEveryMinutes,
serve: {
bind: serveBind,
port: servePort,
path: servePath,
},
tailscale: {
mode: tailscaleMode,
path: tailscalePath,
},
},
};
}
export function buildGogWatchStartArgs(
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
): string[] {
return [
"gmail",
"watch",
"start",
"--account",
cfg.account,
"--label",
cfg.label,
"--topic",
cfg.topic,
];
}
export function buildGogWatchServeArgs(cfg: GmailHookRuntimeConfig): string[] {
const args = [
"gmail",
"watch",
"serve",
"--account",
cfg.account,
"--bind",
cfg.serve.bind,
"--port",
String(cfg.serve.port),
"--path",
cfg.serve.path,
"--token",
cfg.pushToken,
"--hook-url",
cfg.hookUrl,
"--hook-token",
cfg.hookToken,
];
if (cfg.includeBody) {
args.push("--include-body");
}
if (cfg.maxBytes > 0) {
args.push("--max-bytes", String(cfg.maxBytes));
}
return args;
}
export function buildTopicPath(projectId: string, topicName: string): string {
return `projects/${projectId}/topics/${topicName}`;
}
export function parseTopicPath(
topic: string,
): { projectId: string; topicName: string } | null {
const match = topic.trim().match(/^projects\/([^/]+)\/topics\/([^/]+)$/i);
if (!match) return null;
return { projectId: match[1] ?? "", topicName: match[2] ?? "" };
}
function joinUrl(base: string, path: string): string {
const url = new URL(base);
const basePath = url.pathname.replace(/\/+$/, "");
const extra = path.startsWith("/") ? path : `/${path}`;
url.pathname = `${basePath}${extra}`;
return url.toString();
}