Compare commits

...

7 Commits

Author SHA1 Message Date
Peter Steinberger
fdf49a38b4 docs: add PR number to changelog 2026-01-03 03:49:22 +01:00
Peter Steinberger
2eff100434 style: format lint fixes 2026-01-03 03:49:08 +01:00
Peter Steinberger
dd1cf54c54 test: cover gmail watcher restart 2026-01-03 03:48:23 +01:00
Peter Steinberger
0f95f32642 docs: thank @jverdi in changelog 2026-01-03 03:34:54 +01:00
Peter Steinberger
f4b488e8bd docs: note gmail watcher auto-start 2026-01-03 03:30:36 +01:00
Peter Steinberger
efd10c49db fix: harden gmail watcher restart 2026-01-03 03:30:27 +01:00
Jared Verdi
aae54b4af2 Gmail watcher: start when gateway (re)starts 2026-01-01 23:09:44 -05:00
7 changed files with 465 additions and 14 deletions

View File

@ -35,6 +35,7 @@
- Browser tools: add remote CDP URL support, Linux launcher options (`executablePath`, `noSandbox`), and surface `cdpUrl` in status.
### Fixes
- Gmail hooks: auto-restart watcher on gateway restart, prevent double-start, redact gog tokens (#87) — thanks @jverdi.
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.

View File

@ -644,6 +644,11 @@ Gmail helper config (used by `clawdis hooks gmail setup` / `run`):
}
```
Gateway auto-start:
- If `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
`gog gmail watch serve` on boot and auto-renews the watch.
- Set `CLAWDIS_SKIP_GMAIL_WATCHER=1` to disable the auto-start (for manual runs).
Note: when `tailscale.mode` is on, Clawdis defaults `serve.path` to `/` so
Tailscale can proxy `/gmail-pubsub` correctly (it strips the set-path prefix).

View File

@ -56,7 +56,12 @@ 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):
Gateway auto-start (recommended):
- 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 `CLAWDIS_SKIP_GMAIL_WATCHER=1` to opt out (useful if you run the daemon yourself).
Manual daemon (starts `gog gmail watch serve` + auto-renew):
```bash
clawdis hooks gmail run

View File

@ -75,6 +75,7 @@ import {
} from "../discord/index.js";
import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
import { isVerbose } from "../globals.js";
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
import {
monitorIMessageProvider,
sendMessageIMessage,
@ -6691,6 +6692,24 @@ export async function startGatewayServer(
logBrowser.error(`server failed to start: ${String(err)}`);
}
// Start Gmail watcher if configured (hooks.gmail.account).
if (process.env.CLAWDIS_SKIP_GMAIL_WATCHER !== "1") {
try {
const gmailResult = await startGmailWatcher(cfgAtStart);
if (gmailResult.started) {
logHooks.info("gmail watcher started");
} else if (
gmailResult.reason &&
gmailResult.reason !== "hooks not enabled" &&
gmailResult.reason !== "no gmail account configured"
) {
logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`);
}
} catch (err) {
logHooks.error(`gmail watcher failed to start: ${String(err)}`);
}
}
// Launch configured providers (WhatsApp Web, Discord, Telegram) so gateway replies via the
// surface the message came from. Tests can opt out via CLAWDIS_SKIP_PROVIDERS.
if (process.env.CLAWDIS_SKIP_PROVIDERS !== "1") {
@ -6749,6 +6768,7 @@ export async function startGatewayServer(
await stopDiscordProvider();
await stopSignalProvider();
await stopIMessageProvider();
await stopGmailWatcher();
cron.stop();
heartbeatRunner.stop();
broadcast("shutdown", {

View File

@ -0,0 +1,124 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const runtimeConfig = {
account: "clawdbot@gmail.com",
label: "INBOX",
topic: "projects/test/topics/gog-gmail-watch",
subscription: "gog-gmail-watch-push",
pushToken: "push-token",
hookToken: "hook-token",
hookUrl: "http://127.0.0.1:18789/hooks/gmail",
includeBody: false,
maxBytes: 0,
renewEveryMinutes: 720,
serve: { bind: "127.0.0.1", port: 8788, path: "/" },
tailscale: { mode: "off", path: "/gmail-pubsub" },
};
class MockChild extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
exitCode: number | null = null;
kill = vi.fn();
}
const spawnMock = vi.fn(() => new MockChild() as unknown as ChildProcess);
vi.mock("node:child_process", async () => {
const actual =
await vi.importActual<typeof import("node:child_process")>(
"node:child_process",
);
return {
...actual,
spawn: spawnMock,
};
});
const runCommandWithTimeoutMock = vi.fn(async () => ({
code: 0,
stdout: "",
stderr: "",
}));
vi.mock("../agents/skills.js", () => ({
hasBinary: () => true,
}));
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: runCommandWithTimeoutMock,
}));
vi.mock("./gmail-setup-utils.js", () => ({
ensureTailscaleEndpoint: vi.fn(async () => {}),
}));
vi.mock("./gmail.js", () => ({
buildGogWatchServeArgs: () => [
"gmail",
"watch",
"serve",
"--token",
runtimeConfig.pushToken,
"--hook-url",
runtimeConfig.hookUrl,
"--hook-token",
runtimeConfig.hookToken,
],
buildGogWatchStartArgs: () => ["gmail", "watch", "start"],
resolveGmailHookRuntimeConfig: () => ({ ok: true, value: runtimeConfig }),
}));
const { startGmailWatcher, stopGmailWatcher } = await import(
"./gmail-watcher.js"
);
const cfg = {
hooks: {
enabled: true,
gmail: { account: runtimeConfig.account },
},
};
beforeEach(() => {
spawnMock.mockClear();
runCommandWithTimeoutMock.mockClear();
});
afterEach(async () => {
await stopGmailWatcher();
vi.useRealTimers();
});
describe("gmail watcher", () => {
it("does not start twice when already running", async () => {
const first = await startGmailWatcher(cfg);
const second = await startGmailWatcher(cfg);
expect(first.started).toBe(true);
expect(second.started).toBe(true);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it("restarts the gog watcher after a process error", async () => {
vi.useFakeTimers();
await startGmailWatcher(cfg);
const child = spawnMock.mock.results[0]?.value as MockChild | undefined;
expect(child).toBeDefined();
child?.emit("error", new Error("boom"));
expect(spawnMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5000);
expect(spawnMock).toHaveBeenCalledTimes(2);
const restartChild = spawnMock.mock.results[1]?.value as
| MockChild
| undefined;
const stopPromise = stopGmailWatcher();
restartChild?.emit("exit", 0, null);
await stopPromise;
});
});

273
src/hooks/gmail-watcher.ts Normal file
View File

@ -0,0 +1,273 @@
/**
* Gmail Watcher Service
*
* Automatically starts `gog gmail watch serve` when the gateway starts,
* if hooks.gmail is configured with an account.
*/
import { type ChildProcess, spawn } from "node:child_process";
import { hasBinary } from "../agents/skills.js";
import type { ClawdisConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import {
buildGogWatchServeArgs,
buildGogWatchStartArgs,
type GmailHookRuntimeConfig,
resolveGmailHookRuntimeConfig,
} from "./gmail.js";
import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js";
const log = createSubsystemLogger("gmail-watcher");
let watcherProcess: ChildProcess | null = null;
let renewInterval: ReturnType<typeof setInterval> | null = null;
let restartTimer: ReturnType<typeof setTimeout> | null = null;
let shuttingDown = false;
let currentConfig: GmailHookRuntimeConfig | null = null;
const redactedValue = "<redacted>";
const redactedFlags = new Set(["--token", "--hook-token", "--hook-url"]);
function redactArgs(args: string[]): string[] {
const redacted = [...args];
for (let i = 0; i < redacted.length; i += 1) {
if (redactedFlags.has(redacted[i] ?? "") && redacted[i + 1]) {
redacted[i + 1] = redactedValue;
i += 1;
}
}
return redacted;
}
function scheduleRestart(reason: string) {
if (shuttingDown || !currentConfig || restartTimer) return;
log.warn(`${reason}; restarting in 5s`);
restartTimer = setTimeout(() => {
restartTimer = null;
if (shuttingDown || !currentConfig) return;
watcherProcess = spawnGogServe(currentConfig);
}, 5000);
}
/**
* Check if gog binary is available
*/
function isGogAvailable(): boolean {
return hasBinary("gog");
}
/**
* Start the Gmail watch (registers with Gmail API)
*/
async function startGmailWatch(
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
): Promise<boolean> {
const args = ["gog", ...buildGogWatchStartArgs(cfg)];
try {
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
if (result.code !== 0) {
const message =
result.stderr || result.stdout || "gog watch start failed";
log.error(`watch start failed: ${message}`);
return false;
}
log.info(`watch started for ${cfg.account}`);
return true;
} catch (err) {
log.error(`watch start error: ${String(err)}`);
return false;
}
}
/**
* Spawn the gog gmail watch serve process
*/
function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
const args = buildGogWatchServeArgs(cfg);
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}
log.info(`starting gog ${redactArgs(args).join(" ")}`);
const child = spawn("gog", args, {
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
child.stdout?.on("data", (data: Buffer) => {
const line = data.toString().trim();
if (line) log.info(`[gog] ${line}`);
});
child.stderr?.on("data", (data: Buffer) => {
const line = data.toString().trim();
if (line) log.warn(`[gog] ${line}`);
});
child.on("error", (err) => {
log.error(`gog process error: ${String(err)}`);
if (watcherProcess === child) {
watcherProcess = null;
}
scheduleRestart("gog process error");
});
child.on("exit", (code, signal) => {
if (shuttingDown) return;
if (watcherProcess === child) {
watcherProcess = null;
}
scheduleRestart(`gog exited (code=${code}, signal=${signal})`);
});
return child;
}
export type GmailWatcherStartResult = {
started: boolean;
reason?: string;
};
/**
* Start the Gmail watcher service.
* Called automatically by the gateway if hooks.gmail is configured.
*/
export async function startGmailWatcher(
cfg: ClawdisConfig,
): Promise<GmailWatcherStartResult> {
// Check if gmail hooks are configured
if (!cfg.hooks?.enabled) {
return { started: false, reason: "hooks not enabled" };
}
if (!cfg.hooks?.gmail?.account) {
return { started: false, reason: "no gmail account configured" };
}
// Check if gog is available
const gogAvailable = isGogAvailable();
if (!gogAvailable) {
return { started: false, reason: "gog binary not found" };
}
// Resolve the full runtime config
const resolved = resolveGmailHookRuntimeConfig(cfg, {});
if (!resolved.ok) {
return { started: false, reason: resolved.error };
}
const runtimeConfig = resolved.value;
if (isGmailWatcherRunning()) {
log.info("gmail watcher already running; skipping start");
return { started: true };
}
if (renewInterval) {
clearInterval(renewInterval);
renewInterval = null;
}
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}
if (watcherProcess) {
watcherProcess = null;
}
currentConfig = runtimeConfig;
// Set up Tailscale endpoint if needed
if (runtimeConfig.tailscale.mode !== "off") {
try {
await ensureTailscaleEndpoint({
mode: runtimeConfig.tailscale.mode,
path: runtimeConfig.tailscale.path,
port: runtimeConfig.serve.port,
});
log.info(
`tailscale ${runtimeConfig.tailscale.mode} configured for port ${runtimeConfig.serve.port}`,
);
} catch (err) {
log.error(`tailscale setup failed: ${String(err)}`);
return {
started: false,
reason: `tailscale setup failed: ${String(err)}`,
};
}
}
// Start the Gmail watch (register with Gmail API)
const watchStarted = await startGmailWatch(runtimeConfig);
if (!watchStarted) {
log.warn("gmail watch start failed, but continuing with serve");
}
// Spawn the gog serve process
shuttingDown = false;
watcherProcess = spawnGogServe(runtimeConfig);
// Set up renewal interval
const renewMs = runtimeConfig.renewEveryMinutes * 60_000;
renewInterval = setInterval(() => {
if (shuttingDown) return;
void startGmailWatch(runtimeConfig);
}, renewMs);
log.info(
`gmail watcher started for ${runtimeConfig.account} (renew every ${runtimeConfig.renewEveryMinutes}m)`,
);
return { started: true };
}
/**
* Stop the Gmail watcher service.
*/
export async function stopGmailWatcher(): Promise<void> {
shuttingDown = true;
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}
if (renewInterval) {
clearInterval(renewInterval);
renewInterval = null;
}
if (watcherProcess) {
log.info("stopping gmail watcher");
watcherProcess.kill("SIGTERM");
// Wait a bit for graceful shutdown
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (watcherProcess) {
watcherProcess.kill("SIGKILL");
}
resolve();
}, 3000);
watcherProcess?.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
watcherProcess = null;
}
currentConfig = null;
log.info("gmail watcher stopped");
}
/**
* Check if the Gmail watcher is running.
*/
export function isGmailWatcherRunning(): boolean {
return (
watcherProcess !== null && !shuttingDown && watcherProcess.exitCode === null
);
}

View File

@ -80,12 +80,34 @@ describe("web media loading", () => {
// Create a minimal valid GIF (1x1 pixel)
// GIF89a header + minimal image data
const gifBuffer = Buffer.from([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a
0x01, 0x00, 0x01, 0x00, // 1x1 dimensions
0x00, 0x00, 0x00, // no global color table
0x2c, 0x00, 0x00, 0x00, 0x00, // image descriptor
0x01, 0x00, 0x01, 0x00, 0x00, // 1x1 image
0x02, 0x01, 0x44, 0x00, 0x3b, // minimal LZW data + trailer
0x47,
0x49,
0x46,
0x38,
0x39,
0x61, // GIF89a
0x01,
0x00,
0x01,
0x00, // 1x1 dimensions
0x00,
0x00,
0x00, // no global color table
0x2c,
0x00,
0x00,
0x00,
0x00, // image descriptor
0x01,
0x00,
0x01,
0x00,
0x00, // 1x1 image
0x02,
0x01,
0x44,
0x00,
0x3b, // minimal LZW data + trailer
]);
const file = path.join(os.tmpdir(), `clawdis-media-${Date.now()}.gif`);
@ -102,18 +124,19 @@ describe("web media loading", () => {
it("preserves GIF from URL without JPEG conversion", async () => {
const gifBytes = new Uint8Array([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
0x01, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00,
0x2c, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x00,
0x02, 0x01, 0x44, 0x00, 0x3b,
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02,
0x01, 0x44, 0x00, 0x3b,
]);
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: true,
body: true,
arrayBuffer: async () => gifBytes.buffer.slice(gifBytes.byteOffset, gifBytes.byteOffset + gifBytes.byteLength),
arrayBuffer: async () =>
gifBytes.buffer.slice(
gifBytes.byteOffset,
gifBytes.byteOffset + gifBytes.byteLength,
),
headers: { get: () => "image/gif" },
status: 200,
} as Response);