feat(hooks): add Gmail Pub/Sub pull delivery mode

This commit is contained in:
joshp123
2026-06-05 19:16:31 +02:00
parent 4752e9a67d
commit 2c353b697e
14 changed files with 400 additions and 59 deletions

View File

@@ -325,7 +325,7 @@ Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
Wire Gmail inbox triggers to OpenClaw via Google PubSub.
<Note>
**Prerequisites:** `gcloud` CLI, `gog` (gogcli), OpenClaw hooks enabled, Tailscale for the public HTTPS endpoint.
**Prerequisites:** `gcloud` CLI, `gog` (gogcli), and OpenClaw hooks enabled. The `openclaw webhooks gmail setup` helper still configures the Pub/Sub push flow, which also needs Tailscale or another HTTPS push endpoint.
</Note>
### Wizard setup (recommended)
@@ -338,7 +338,7 @@ This writes `hooks.gmail` config, enables the Gmail preset, and uses Tailscale F
### Gateway auto-start
When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts `gog gmail watch serve` on boot and auto-renews the watch. Set `OPENCLAW_SKIP_GMAIL_WATCHER=1` to opt out.
When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts the configured `gog gmail watch` delivery runner on boot and auto-renews the watch. Push configs start `gog gmail watch serve`; pull configs start `gog gmail watch pull`. Set `OPENCLAW_SKIP_GMAIL_WATCHER=1` to opt out.
### Manual one-time setup

View File

@@ -17,14 +17,14 @@ openclaw webhooks gmail setup --account <email> [...]
openclaw webhooks gmail run [--account <email>] [...]
```
| Subcommand | Description |
| ------------- | -------------------------------------------------------------------------------------------- |
| `gmail setup` | Configure Gmail watch, Pub/Sub topic/subscription, and the OpenClaw webhook delivery target. |
| `gmail run` | Run `gog watch serve` plus the watch auto-renew loop. |
| Subcommand | Description |
| ------------- | ---------------------------------------------------------------------------------------- |
| `gmail setup` | Configure the existing Gmail Pub/Sub push flow and the OpenClaw webhook delivery target. |
| `gmail run` | Run the configured `gog gmail watch` delivery process plus the watch auto-renew loop. |
## `webhooks gmail setup`
Configure Gmail watch, Pub/Sub, and OpenClaw webhook delivery.
Configure Gmail watch, Pub/Sub push, and OpenClaw webhook delivery.
```bash
openclaw webhooks gmail setup --account you@example.com
@@ -83,17 +83,18 @@ openclaw webhooks gmail setup --account you@example.com --hook-url https://gatew
## `webhooks gmail run`
Run `gog watch serve` plus the watch auto-renew loop in the foreground.
Run the configured `gog gmail watch` delivery process plus the watch auto-renew loop in the foreground. Push configs start `gog gmail watch serve`; pull configs start `gog gmail watch pull`.
```bash
openclaw webhooks gmail run --account you@example.com
```
`run` accepts the same `gog watch serve`, OpenClaw delivery, Pub/Sub, and Tailscale flags as `setup`, except:
`run` accepts the same push-oriented `gog watch serve`, OpenClaw delivery, Pub/Sub, and Tailscale flags as `setup`, except:
- `--account` is **optional** on `run` (it falls back to the configured account).
- `run` does **not** accept `--project`, `--push-endpoint`, or `--json`.
- `run` flags have no built-in defaults; missing values fall back to the values written by `setup`.
- Pull mode is selected from `hooks.gmail.delivery.mode` in config. Use `hooks.gmail.delivery.subscription` for the full pull subscription path.
| Category | Flags |
| ----------------- | -------------------------------------------------------------------------------- |

View File

@@ -771,14 +771,14 @@ Validation and safety notes:
gmail: {
account: "openclaw@gmail.com",
topic: "projects/<project-id>/topics/gog-gmail-watch",
subscription: "gog-gmail-watch-push",
pushToken: "shared-push-token",
delivery: {
mode: "pull",
subscription: "projects/<project-id>/subscriptions/gog-gmail-watch",
},
hookUrl: "http://127.0.0.1:18789/hooks/gmail",
includeBody: true,
maxBytes: 20000,
renewEveryMinutes: 720,
serve: { bind: "127.0.0.1", port: 8788, path: "/" },
tailscale: { mode: "funnel", path: "/gmail-pubsub" },
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
thinking: "off",
},
@@ -786,8 +786,34 @@ Validation and safety notes:
}
```
- Gateway auto-starts `gog gmail watch serve` on boot when configured. Set `OPENCLAW_SKIP_GMAIL_WATCHER=1` to disable.
- Don't run a separate `gog gmail watch serve` alongside the Gateway.
- Pull delivery starts `gog gmail watch pull` on boot. It consumes a Pub/Sub pull subscription from the gateway host and posts processed Gmail notifications into the local OpenClaw hook endpoint. It does not need `pushToken`, `serve`, `tailscale`, or a public HTTP callback URL.
- Pull delivery requires `delivery.subscription` to be a full Pub/Sub subscription resource path: `projects/<project-id>/subscriptions/<subscription>`.
- Do not run a separate `gog gmail watch pull` against the same subscription alongside the Gateway.
- Set `OPENCLAW_SKIP_GMAIL_WATCHER=1` to disable the Gateway-managed Gmail watcher.
Push delivery is still supported for existing deployments and explicit HTTP ingress setups. Omit `delivery.mode` to preserve existing push behavior, or set it explicitly:
```json5
{
hooks: {
gmail: {
account: "openclaw@gmail.com",
topic: "projects/<project-id>/topics/gog-gmail-watch",
delivery: {
mode: "push",
subscription: "gog-gmail-watch-push",
},
pushToken: "shared-push-token",
hookUrl: "http://127.0.0.1:18789/hooks/gmail",
serve: { bind: "127.0.0.1", port: 8788, path: "/" },
tailscale: { mode: "funnel", path: "/gmail-pubsub" },
},
},
}
```
- Push delivery starts `gog gmail watch serve` and requires a Pub/Sub push subscription to reach the callback endpoint.
- `openclaw webhooks gmail setup` remains push-oriented in this release. Pull-mode cloud setup is a later slice after pull delivery is dogfooded.
---

View File

@@ -39,7 +39,7 @@ export function registerWebhooksCli(program: Command) {
gmail
.command("setup")
.description("Configure Gmail watch + Pub/Sub + OpenClaw hooks")
.description("Configure Gmail watch + Pub/Sub push + OpenClaw 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)
@@ -78,10 +78,10 @@ export function registerWebhooksCli(program: Command) {
gmail
.command("run")
.description("Run gog watch serve + auto-renew loop")
.description("Run configured gog gmail watch delivery + 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("--subscription <name>", "Pub/Sub subscription name or full path for pull configs")
.option("--label <label>", "Gmail label to watch")
.option("--hook-url <url>", "OpenClaw hook URL")
.option("--hook-token <token>", "OpenClaw hook token")

View File

@@ -242,6 +242,7 @@ const TARGET_KEYS = [
"hooks.mappings[].channel",
"hooks.mappings[].transform.module",
"hooks.gmail",
"hooks.gmail.delivery.mode",
"hooks.gmail.pushToken",
"hooks.gmail.tailscale.mode",
"hooks.gmail.thinking",
@@ -443,6 +444,7 @@ const ENUM_EXPECTATIONS: Record<string, string[]> = {
"broadcast.strategy": ['"parallel"', '"sequential"'],
"hooks.mappings[].action": ['"wake"', '"agent"'],
"hooks.mappings[].wakeMode": ['"now"', '"next-heartbeat"'],
"hooks.gmail.delivery.mode": ['"push"', '"pull"'],
"hooks.gmail.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
"hooks.gmail.thinking": ['"off"', '"minimal"', '"low"', '"medium"', '"high"'],
"messages.queue.mode": ['"steer"', '"followup"', '"collect"', '"interrupt"'],
@@ -478,6 +480,9 @@ const ENUM_EXPECTATIONS: Record<string, string[]> = {
const TOOLS_HOOKS_TARGET_KEYS = [
"hooks.gmail.account",
"hooks.gmail.allowUnsafeExternalContent",
"hooks.gmail.delivery",
"hooks.gmail.delivery.mode",
"hooks.gmail.delivery.subscription",
"hooks.gmail.hookUrl",
"hooks.gmail.includeBody",
"hooks.gmail.label",

View File

@@ -1805,7 +1805,7 @@ export const FIELD_HELP: Record<string, string> = {
"hooks.mappings[].transform.export":
"Named export to invoke from the transform module; defaults to module default export when omitted. Set this when one file hosts multiple transform handlers.",
"hooks.gmail":
"Gmail push integration settings used for Pub/Sub notifications and optional local callback serving. Keep this scoped to dedicated Gmail automation accounts where possible.",
"Gmail integration settings used for Pub/Sub change notifications and local hook delivery. Keep this scoped to dedicated Gmail automation accounts where possible.",
"hooks.gmail.account":
"Google account identifier used for Gmail watch/subscription operations in this hook integration. Use a dedicated automation mailbox account to isolate operational permissions.",
"hooks.gmail.label":
@@ -1813,9 +1813,15 @@ export const FIELD_HELP: Record<string, string> = {
"hooks.gmail.topic":
"Google Pub/Sub topic name used by Gmail watch to publish change notifications for this account. Ensure the topic IAM grants Gmail publish access before enabling watches.",
"hooks.gmail.subscription":
"Pub/Sub subscription consumed by the gateway to receive Gmail change notifications from the configured topic. Keep subscription ownership clear so multiple consumers do not race unexpectedly.",
"Pub/Sub subscription name used by the Gmail integration. Push mode may use a short subscription name; pull mode should use delivery.subscription with a full Pub/Sub path.",
"hooks.gmail.delivery":
"Gmail Pub/Sub delivery mode configuration. Use pull for no-inbound local consumption, or push only when operating an intentional HTTP ingress path.",
"hooks.gmail.delivery.mode":
'Gmail Pub/Sub delivery mode. Use "pull" to start a local Pub/Sub consumer with no inbound HTTP endpoint, or "push" to start the existing HTTP callback server. Omitted mode preserves existing push behavior.',
"hooks.gmail.delivery.subscription":
"Full Pub/Sub subscription path used by pull mode, for example projects/<project>/subscriptions/<subscription>. Do not share one pull subscription between independent consumers.",
"hooks.gmail.hookUrl":
"Public callback URL Gmail or intermediaries invoke to deliver notifications into this hook pipeline. Keep this URL protected with token validation and restricted network exposure.",
"OpenClaw hook URL invoked by the Gmail watcher after notification processing. Pull mode normally uses a loopback URL; push mode may use the same local hook behind the callback server.",
"hooks.gmail.includeBody":
"When true, fetch and include email body content for downstream mapping/agent processing. Keep false unless body text is required, because this increases payload size and sensitivity.",
"hooks.gmail.allowUnsafeExternalContent":

View File

@@ -913,6 +913,9 @@ export const FIELD_LABELS: Record<string, string> = {
"hooks.gmail.label": "Gmail Hook Label",
"hooks.gmail.topic": "Gmail Hook Pub/Sub Topic",
"hooks.gmail.subscription": "Gmail Hook Subscription",
"hooks.gmail.delivery": "Gmail Hook Delivery",
"hooks.gmail.delivery.mode": "Gmail Hook Delivery Mode",
"hooks.gmail.delivery.subscription": "Gmail Hook Pull Subscription",
"hooks.gmail.pushToken": "Gmail Hook Push Token",
"hooks.gmail.hookUrl": "Gmail Hook Callback URL",
"hooks.gmail.includeBody": "Gmail Hook Include Body",

View File

@@ -37,12 +37,21 @@ export type HookMappingConfig = {
};
export type HooksGmailTailscaleMode = "off" | "serve" | "funnel";
export type HooksGmailDeliveryMode = "push" | "pull";
export type HooksGmailConfig = {
account?: string;
label?: string;
topic?: string;
subscription?: string;
delivery?: {
mode?: HooksGmailDeliveryMode;
/**
* Pull mode requires a full Pub/Sub resource path:
* projects/<project>/subscriptions/<subscription>.
*/
subscription?: string;
};
pushToken?: string;
hookUrl?: string;
includeBody?: boolean;

View File

@@ -116,6 +116,13 @@ export const HooksGmailSchema = z
label: z.string().optional(),
topic: z.string().optional(),
subscription: z.string().optional(),
delivery: z
.object({
mode: z.union([z.literal("push"), z.literal("pull")]).optional(),
subscription: z.string().optional(),
})
.strict()
.optional(),
pushToken: z.string().optional().register(sensitive),
hookUrl: z.string().optional(),
includeBody: z.boolean().optional(),

View File

@@ -24,6 +24,8 @@ import {
} from "./gmail-setup-utils.js";
import {
buildDefaultHookUrl,
buildGogWatchPullArgs,
buildGogWatchPullLogArgs,
buildGogWatchServeLogArgs,
buildGogWatchServeArgs,
buildGogWatchStartArgs,
@@ -39,6 +41,7 @@ import {
type GmailHookOverrides,
type GmailHookRuntimeConfig,
generateHookToken,
isGmailHookPushRuntimeConfig,
mergeHookPresets,
normalizeHooksPath,
normalizeServePath,
@@ -305,7 +308,7 @@ export async function runGmailService(opts: GmailRunOptions) {
const runtimeConfig = resolved.value;
if (runtimeConfig.tailscale.mode !== "off") {
if (isGmailHookPushRuntimeConfig(runtimeConfig) && runtimeConfig.tailscale.mode !== "off") {
await ensureDependency("tailscale", ["tailscale"]);
await ensureTailscaleEndpoint({
mode: runtimeConfig.tailscale.mode,
@@ -318,7 +321,7 @@ export async function runGmailService(opts: GmailRunOptions) {
await startGmailWatch(runtimeConfig);
let shuttingDown = false;
let child = spawnGogServe(runtimeConfig);
let child = spawnGogRunner(runtimeConfig);
const renewMs = runtimeConfig.renewEveryMinutes * 60_000;
const renewTimer = setInterval(() => {
@@ -348,19 +351,21 @@ export async function runGmailService(opts: GmailRunOptions) {
detachSignals();
return;
}
defaultRuntime.log("gog watch serve exited; restarting in 2s");
defaultRuntime.log(`gog watch ${runtimeConfig.delivery.mode} exited; restarting in 2s`);
setTimeout(() => {
if (shuttingDown) {
return;
}
child = spawnGogServe(runtimeConfig);
child = spawnGogRunner(runtimeConfig);
}, 2000);
});
}
function spawnGogServe(cfg: GmailHookRuntimeConfig) {
const args = buildGogWatchServeArgs(cfg);
defaultRuntime.log(`Starting gog ${buildGogWatchServeLogArgs(cfg).join(" ")}`);
function spawnGogRunner(cfg: GmailHookRuntimeConfig) {
const pushMode = isGmailHookPushRuntimeConfig(cfg);
const args = pushMode ? buildGogWatchServeArgs(cfg) : buildGogWatchPullArgs(cfg);
const logArgs = pushMode ? buildGogWatchServeLogArgs(cfg) : buildGogWatchPullLogArgs(cfg);
defaultRuntime.log(`Starting gog ${logArgs.join(" ")}`);
const invocation = resolveGogServeInvocation(args);
return spawn(invocation.command, invocation.args, {
stdio: "inherit",

View File

@@ -45,6 +45,23 @@ function createGmailConfig(account = "me@example.com") {
} as never;
}
function createGmailPullConfig(account = "me@example.com") {
return {
hooks: {
enabled: true,
token: "hook-token",
gmail: {
account,
topic: "projects/demo/topics/gmail",
delivery: {
mode: "pull",
subscription: "projects/demo/subscriptions/gmail-pull",
},
},
},
} as never;
}
function deferredCommandResult() {
let resolve!: (result: { code: number; stdout: string; stderr: string }) => void;
const promise = new Promise<{ code: number; stdout: string; stderr: string }>((settle) => {
@@ -72,6 +89,35 @@ describe("startGmailWatcher", () => {
});
});
it("starts gog pull delivery without Tailscale setup", async () => {
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
await expect(startGmailWatcher(createGmailPullConfig())).resolves.toEqual({
started: true,
});
expect(mocks.runCommandWithTimeout).toHaveBeenCalledTimes(1);
expect(mocks.spawn).toHaveBeenCalledTimes(1);
const spawnArgs = mocks.spawn.mock.calls[0]?.[1] as string[];
expect(spawnArgs).toContain("pull");
expect(spawnArgs).not.toContain("serve");
expect(spawnArgs).toEqual(
expect.arrayContaining([
"gmail",
"watch",
"pull",
"--account",
"me@example.com",
"--subscription",
"projects/demo/subscriptions/gmail-pull",
"--hook-url",
"http://127.0.0.1:18789/hooks/gmail",
"--hook-token",
"hook-token",
]),
);
});
it("does not let a stale cancelled startup clear newer watcher config", async () => {
vi.useFakeTimers();
try {

View File

@@ -1,7 +1,7 @@
/**
* Gmail Watcher Service
*
* Automatically starts `gog gmail watch serve` when the gateway starts,
* Automatically starts the configured `gog gmail watch` runner when the gateway starts,
* if hooks.gmail is configured with an account.
*/
@@ -13,10 +13,13 @@ import { hasBinary } from "../skills/loading/config.js";
import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js";
import { isAddressInUseError } from "./gmail-watcher-errors.js";
import {
buildGogWatchPullArgs,
buildGogWatchPullLogArgs,
buildGogWatchServeLogArgs,
buildGogWatchServeArgs,
buildGogWatchStartArgs,
type GmailHookRuntimeConfig,
isGmailHookPushRuntimeConfig,
resolveGogExecutable,
resolveGogServeInvocation,
resolveGmailHookRuntimeConfig,
@@ -64,11 +67,13 @@ async function startGmailWatch(
}
/**
* Spawn the gog gmail watch serve process
* Spawn the gog gmail watch serve/pull process
*/
function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
const args = buildGogWatchServeArgs(cfg);
log.info(`starting gog ${buildGogWatchServeLogArgs(cfg).join(" ")}`);
function spawnGogWatcherProcess(cfg: GmailHookRuntimeConfig): ChildProcess {
const pushMode = isGmailHookPushRuntimeConfig(cfg);
const args = pushMode ? buildGogWatchServeArgs(cfg) : buildGogWatchPullArgs(cfg);
const logArgs = pushMode ? buildGogWatchServeLogArgs(cfg) : buildGogWatchPullLogArgs(cfg);
log.info(`starting gog ${logArgs.join(" ")}`);
let addressInUse = false;
const invocation = resolveGogServeInvocation(args);
@@ -91,7 +96,7 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
if (!line) {
return;
}
if (isAddressInUseError(line)) {
if (pushMode && isAddressInUseError(line)) {
addressInUse = true;
}
log.warn(`[gog] ${line}`);
@@ -124,7 +129,7 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
if (shuttingDown || !currentConfig) {
return;
}
watcherProcess = spawnGogServe(currentConfig);
watcherProcess = spawnGogWatcherProcess(currentConfig);
}, 5000);
});
@@ -299,7 +304,7 @@ export async function startGmailWatcher(
const oldProcess = watcherProcess;
watcherProcess = null;
await settleProcess(oldProcess);
// Remove lingering spawnGogServe listeners so a late exit (after the
// Remove lingering watcher listeners so a late exit (after the
// settleProcess timeout) cannot trigger a duplicate respawn while
// watcherProcess is null and shuttingDown is false.
oldProcess.removeAllListeners();
@@ -308,7 +313,7 @@ export async function startGmailWatcher(
}
// Set up Tailscale endpoint if needed
if (runtimeConfig.tailscale.mode !== "off") {
if (isGmailHookPushRuntimeConfig(runtimeConfig) && runtimeConfig.tailscale.mode !== "off") {
const cancellation = createGmailWatcherCancellation(options);
try {
await ensureTailscaleEndpoint({
@@ -354,7 +359,7 @@ export async function startGmailWatcher(
return cancelledGmailWatcherStart(runtimeConfig);
}
shuttingDown = false;
watcherProcess = spawnGogServe(runtimeConfig);
watcherProcess = spawnGogWatcherProcess(runtimeConfig);
const renewMs = runtimeConfig.renewEveryMinutes * 60_000;
renewInterval = setInterval(() => {
if (shuttingDown) {
@@ -364,7 +369,7 @@ export async function startGmailWatcher(
}, renewMs);
log.info(
`gmail watcher started for ${runtimeConfig.account} (renew every ${runtimeConfig.renewEveryMinutes}m)`,
`gmail watcher started for ${runtimeConfig.account} (${runtimeConfig.delivery.mode}, renew every ${runtimeConfig.renewEveryMinutes}m)`,
);
return { started: true };

View File

@@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest";
import { type OpenClawConfig, DEFAULT_GATEWAY_PORT } from "../config/config.js";
import {
buildDefaultHookUrl,
buildGogWatchPullLogArgs,
buildGogWatchServeLogArgs,
buildTopicPath,
parseSubscriptionPath,
parseTopicPath,
resolveGmailHookRuntimeConfig,
} from "./gmail.js";
@@ -48,6 +50,10 @@ describe("gmail hook config", () => {
if (!result.ok) {
return;
}
expect(result.value.delivery.mode).toBe("push");
if (result.value.delivery.mode !== "push") {
return;
}
expect(result.value.serve.path).toBe(expected.servePath);
expect(result.value.tailscale.path).toBe(expected.publicPath);
if (expected.target !== undefined) {
@@ -69,24 +75,93 @@ describe("gmail hook config", () => {
});
});
it("parses subscription path", () => {
expect(parseSubscriptionPath("projects/proj/subscriptions/sub")).toEqual({
projectId: "proj",
subscriptionName: "sub",
});
expect(parseSubscriptionPath("sub")).toBeNull();
});
it("resolves runtime config with defaults", () => {
const result = resolveGmailHookRuntimeConfig(baseConfig, {});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.account).toBe("openclaw@gmail.com");
expect(result.value.delivery.mode).toBe("push");
expect(result.value.label).toBe("INBOX");
expect(result.value.includeBody).toBe(true);
if (result.value.delivery.mode !== "push") {
return;
}
expect(result.value.serve.port).toBe(8788);
expect(result.value.hookUrl).toBe(`http://127.0.0.1:${DEFAULT_GATEWAY_PORT}/hooks/gmail`);
}
});
it("resolves pull delivery without push token or callback server settings", () => {
const result = resolveGmailHookRuntimeConfig(
{
hooks: {
token: "hook-token",
gmail: {
account: "openclaw@gmail.com",
topic: "projects/demo/topics/gog-gmail-watch",
delivery: {
mode: "pull",
subscription: "projects/demo/subscriptions/gog-gmail-watch",
},
},
},
},
{},
);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value.delivery).toEqual({
mode: "pull",
subscription: "projects/demo/subscriptions/gog-gmail-watch",
});
expect("pushToken" in result.value).toBe(false);
expect("serve" in result.value).toBe(false);
expect("tailscale" in result.value).toBe(false);
});
it("requires a full Pub/Sub subscription path for pull delivery", () => {
const result = resolveGmailHookRuntimeConfig(
{
hooks: {
token: "hook-token",
gmail: {
account: "openclaw@gmail.com",
topic: "projects/demo/topics/gog-gmail-watch",
subscription: "gog-gmail-watch",
delivery: { mode: "pull" },
},
},
},
{},
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("full Pub/Sub path");
}
});
it("builds watch serve log args without secrets", () => {
const result = resolveGmailHookRuntimeConfig(baseConfig, {});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value.delivery.mode).toBe("push");
if (result.value.delivery.mode !== "push") {
return;
}
const args = buildGogWatchServeLogArgs(result.value);
expect(args).not.toContain("push-token");
@@ -112,6 +187,50 @@ describe("gmail hook config", () => {
]);
});
it("builds watch pull log args without hook secrets", () => {
const result = resolveGmailHookRuntimeConfig(
{
hooks: {
token: "hook-token",
gmail: {
account: "openclaw@gmail.com",
topic: "projects/demo/topics/gog-gmail-watch",
delivery: {
mode: "pull",
subscription: "projects/demo/subscriptions/gog-gmail-watch",
},
},
},
},
{},
);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value.delivery.mode).toBe("pull");
if (result.value.delivery.mode !== "pull") {
return;
}
const args = buildGogWatchPullLogArgs(result.value);
expect(args).not.toContain("hook-token");
expect(args).not.toContain("--hook-token");
expect(args).not.toContain("--hook-url");
expect(args).toEqual([
"gmail",
"watch",
"pull",
"--account",
"openclaw@gmail.com",
"--subscription",
"projects/demo/subscriptions/gog-gmail-watch",
"--include-body",
"--max-bytes",
"20000",
]);
});
it("fails without hook token", () => {
const result = resolveGmailHookRuntimeConfig(
{

View File

@@ -6,6 +6,7 @@ import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/strin
import {
type OpenClawConfig,
DEFAULT_GATEWAY_PORT,
type HooksGmailDeliveryMode,
type HooksGmailTailscaleMode,
resolveGatewayPort,
} from "../config/config.js";
@@ -27,6 +28,7 @@ let gogBin: string | undefined;
export type GmailHookOverrides = {
account?: string;
deliveryMode?: HooksGmailDeliveryMode;
label?: string;
topic?: string;
subscription?: string;
@@ -44,17 +46,24 @@ export type GmailHookOverrides = {
tailscaleTarget?: string;
};
export type GmailHookRuntimeConfig = {
export type GmailHookBaseRuntimeConfig = {
account: string;
label: string;
topic: string;
subscription: string;
pushToken: string;
hookToken: string;
hookUrl: string;
includeBody: boolean;
maxBytes: number;
renewEveryMinutes: number;
};
export type GmailHookPushRuntimeConfig = GmailHookBaseRuntimeConfig & {
delivery: {
mode: "push";
subscription: string;
};
pushToken: string;
serve: {
bind: string;
port: number;
@@ -67,6 +76,27 @@ export type GmailHookRuntimeConfig = {
};
};
export type GmailHookPullRuntimeConfig = GmailHookBaseRuntimeConfig & {
delivery: {
mode: "pull";
subscription: string;
};
};
export type GmailHookRuntimeConfig = GmailHookPushRuntimeConfig | GmailHookPullRuntimeConfig;
export function isGmailHookPushRuntimeConfig(
cfg: GmailHookRuntimeConfig,
): cfg is GmailHookPushRuntimeConfig {
return cfg.delivery.mode === "push";
}
export function isGmailHookPullRuntimeConfig(
cfg: GmailHookRuntimeConfig,
): cfg is GmailHookPullRuntimeConfig {
return cfg.delivery.mode === "pull";
}
export function generateHookToken(bytes = 24): string {
return randomBytes(bytes).toString("hex");
}
@@ -127,12 +157,9 @@ export function resolveGmailHookRuntimeConfig(
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 deliveryMode = overrides.deliveryMode ?? gmail?.delivery?.mode ?? "push";
const configuredSubscription =
overrides.subscription ?? gmail?.delivery?.subscription ?? gmail?.subscription;
const hookUrl =
overrides.hookUrl ??
@@ -155,6 +182,50 @@ export function resolveGmailHookRuntimeConfig(
? Math.floor(renewEveryMinutesRaw)
: DEFAULT_GMAIL_RENEW_MINUTES;
const label = overrides.label ?? gmail?.label ?? DEFAULT_GMAIL_LABEL;
const baseRuntimeConfig = {
account,
label,
topic,
hookToken,
hookUrl,
includeBody,
maxBytes,
renewEveryMinutes,
};
if (deliveryMode === "pull") {
if (!configuredSubscription) {
return { ok: false, error: "gmail pull subscription required" };
}
const subscriptionPath = parseSubscriptionPath(configuredSubscription);
if (!subscriptionPath) {
return {
ok: false,
error:
"gmail pull subscription must be a full Pub/Sub path (projects/<project>/subscriptions/<subscription>)",
};
}
return {
ok: true,
value: {
...baseRuntimeConfig,
subscription: configuredSubscription,
delivery: {
mode: "pull",
subscription: configuredSubscription,
},
},
};
}
const subscription = configuredSubscription ?? DEFAULT_GMAIL_SUBSCRIPTION;
const pushToken = overrides.pushToken ?? gmail?.pushToken ?? "";
if (!pushToken) {
return { ok: false, error: "gmail push token required" };
}
const serveBind = overrides.serveBind ?? gmail?.serve?.bind ?? DEFAULT_GMAIL_SERVE_BIND;
const servePortRaw = overrides.servePort ?? gmail?.serve?.port;
const servePort =
@@ -190,16 +261,13 @@ export function resolveGmailHookRuntimeConfig(
return {
ok: true,
value: {
account,
label: overrides.label ?? gmail?.label ?? DEFAULT_GMAIL_LABEL,
topic,
...baseRuntimeConfig,
subscription,
delivery: {
mode: "push",
subscription,
},
pushToken,
hookToken,
hookUrl,
includeBody,
maxBytes,
renewEveryMinutes,
serve: {
bind: serveBind,
port: servePort,
@@ -215,7 +283,7 @@ export function resolveGmailHookRuntimeConfig(
}
export function buildGogWatchStartArgs(
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
cfg: Pick<GmailHookBaseRuntimeConfig, "account" | "label" | "topic">,
): string[] {
return [
"gmail",
@@ -230,7 +298,7 @@ export function buildGogWatchStartArgs(
];
}
export function buildGogWatchServeArgs(cfg: GmailHookRuntimeConfig): string[] {
export function buildGogWatchServeArgs(cfg: GmailHookPushRuntimeConfig): string[] {
const args = [
"gmail",
"watch",
@@ -259,14 +327,45 @@ export function buildGogWatchServeArgs(cfg: GmailHookRuntimeConfig): string[] {
return args;
}
export function buildGogWatchServeLogArgs(cfg: GmailHookRuntimeConfig): string[] {
return buildGogWatchServeArgs(cfg).filter(
export function buildGogWatchPullArgs(cfg: GmailHookPullRuntimeConfig): string[] {
const args = [
"gmail",
"watch",
"pull",
"--account",
cfg.account,
"--subscription",
cfg.delivery.subscription,
"--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;
}
function removeGogWatchSensitiveArgs(args: string[]): string[] {
return args.filter(
(arg, index, args) =>
!GMAIL_WATCH_SENSITIVE_FLAGS.has(arg) &&
!GMAIL_WATCH_SENSITIVE_FLAGS.has(args[index - 1] ?? ""),
);
}
export function buildGogWatchServeLogArgs(cfg: GmailHookPushRuntimeConfig): string[] {
return removeGogWatchSensitiveArgs(buildGogWatchServeArgs(cfg));
}
export function buildGogWatchPullLogArgs(cfg: GmailHookPullRuntimeConfig): string[] {
return removeGogWatchSensitiveArgs(buildGogWatchPullArgs(cfg));
}
export function resolveGogExecutable(): string {
return (gogBin ??= resolveExecutable("gog"));
}
@@ -313,6 +412,16 @@ export function parseTopicPath(topic: string): { projectId: string; topicName: s
return { projectId: match[1] ?? "", topicName: match[2] ?? "" };
}
export function parseSubscriptionPath(
subscription: string,
): { projectId: string; subscriptionName: string } | null {
const match = subscription.trim().match(/^projects\/([^/]+)\/subscriptions\/([^/]+)$/i);
if (!match) {
return null;
}
return { projectId: match[1] ?? "", subscriptionName: match[2] ?? "" };
}
function joinUrl(base: string, pathLocal: string): string {
const url = new URL(base);
const basePath = url.pathname.replace(/\/+$/, "");