From 2986447935c96b184ff6c2015fd41d21ede284f1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 03:10:35 +0100 Subject: [PATCH] fix: improve gmail tailscale errors --- CHANGELOG.md | 1 + src/hooks/gmail-setup-utils.ts | 85 +++++++++++++++++++++++++++------- 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbc068de..535a46edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312. - Browser: fix `browser snapshot`/`browser act` timeouts under Bun by patching Playwright’s CDP WebSocket selection. Thanks @azade-c for PR #307. - Browser: add `--browser-profile` flag and honor profile in tabs routes + browser tool. Thanks @jamesgroat for PR #324. +- Gmail: include tailscale command exit codes/output when hook setup fails (easier debugging). - Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322. - Telegram: include sender identity in group envelope headers. (#336) - Messages: stop defaulting ack reactions to 👀 when identity emoji is missing. diff --git a/src/hooks/gmail-setup-utils.ts b/src/hooks/gmail-setup-utils.ts index 83249db9a..c9bc2ad5b 100644 --- a/src/hooks/gmail-setup-utils.ts +++ b/src/hooks/gmail-setup-utils.ts @@ -2,11 +2,59 @@ import fs from "node:fs"; import path from "node:path"; import { hasBinary } from "../agents/skills.js"; -import { runCommandWithTimeout } from "../process/exec.js"; +import { runCommandWithTimeout, type SpawnResult } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { normalizeServePath } from "./gmail.js"; let cachedPythonPath: string | null | undefined; +const MAX_OUTPUT_CHARS = 800; + +function trimOutput(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (trimmed.length <= MAX_OUTPUT_CHARS) return trimmed; + return `${trimmed.slice(0, MAX_OUTPUT_CHARS)}…`; +} + +function formatCommandFailure(command: string, result: SpawnResult): string { + const code = result.code ?? "null"; + const signal = result.signal ? `, signal=${result.signal}` : ""; + const killed = result.killed ? ", killed=true" : ""; + const stderr = trimOutput(result.stderr); + const stdout = trimOutput(result.stdout); + const lines = [`${command} failed (code=${code}${signal}${killed})`]; + if (stderr) lines.push(`stderr: ${stderr}`); + if (stdout) lines.push(`stdout: ${stdout}`); + return lines.join("\n"); +} + +function formatCommandResult(command: string, result: SpawnResult): string { + const code = result.code ?? "null"; + const signal = result.signal ? `, signal=${result.signal}` : ""; + const killed = result.killed ? ", killed=true" : ""; + const stderr = trimOutput(result.stderr); + const stdout = trimOutput(result.stdout); + const lines = [`${command} exited (code=${code}${signal}${killed})`]; + if (stderr) lines.push(`stderr: ${stderr}`); + if (stdout) lines.push(`stdout: ${stdout}`); + return lines.join("\n"); +} + +function formatJsonParseFailure( + command: string, + result: SpawnResult, + err: unknown, +): string { + const reason = err instanceof Error ? err.message : String(err); + return `${command} returned invalid JSON: ${reason}\n${formatCommandResult( + command, + result, + )}`; +} + +function formatCommand(command: string, args: string[]): string { + return [command, ...args].join(" "); +} function findExecutablesOnPath(bins: string[]): string[] { const pathEnv = process.env.PATH ?? ""; @@ -218,18 +266,20 @@ export async function ensureTailscaleEndpoint(params: { }): Promise { if (params.mode === "off") return ""; - const status = await runCommandWithTimeout( - ["tailscale", "status", "--json"], - { - timeoutMs: 30_000, - }, - ); + const statusArgs = ["status", "--json"]; + const statusCommand = formatCommand("tailscale", statusArgs); + const status = await runCommandWithTimeout(["tailscale", ...statusArgs], { + timeoutMs: 30_000, + }); if (status.code !== 0) { - throw new Error(status.stderr || "tailscale status failed"); + throw new Error(formatCommandFailure(statusCommand, status)); + } + let parsed: { Self?: { DNSName?: string } }; + try { + parsed = JSON.parse(status.stdout) as { Self?: { DNSName?: string } }; + } catch (err) { + throw new Error(formatJsonParseFailure(statusCommand, status, err)); } - 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"); @@ -238,7 +288,6 @@ export async function ensureTailscaleEndpoint(params: { const target = String(params.port); const pathArg = normalizeServePath(params.path); const funnelArgs = [ - "tailscale", params.mode, "--bg", "--set-path", @@ -246,11 +295,15 @@ export async function ensureTailscaleEndpoint(params: { "--yes", target, ]; - const funnelResult = await runCommandWithTimeout(funnelArgs, { - timeoutMs: 30_000, - }); + const funnelCommand = formatCommand("tailscale", funnelArgs); + const funnelResult = await runCommandWithTimeout( + ["tailscale", ...funnelArgs], + { + timeoutMs: 30_000, + }, + ); if (funnelResult.code !== 0) { - throw new Error(funnelResult.stderr || "tailscale funnel failed"); + throw new Error(formatCommandFailure(funnelCommand, funnelResult)); } const baseUrl = `https://${dnsName}${pathArg}`;