import { AsyncLocalStorage } from 'node:async_hooks'; import type { ChildProcess } from 'node:child_process'; import { delimiter, join } from 'node:path'; import { parseBoolEnv, readEnvVar, redactSecrets as redactSharedSecrets } from '@chrischall/mcp-utils'; export type Spawner = ( command: string, args: string[], options: { env: NodeJS.ProcessEnv }, ) => ChildProcess; // A payload too large to live in argv. Every argv element is capped — the Fly // runner rejects args over 4 KiB, and the Linux kernel hard-caps a single argv // string at MAX_ARG_STRLEN (128 KiB) regardless of ARG_MAX — so big values // (a long HTML mail body, slide notes) must leave argv entirely. gog exposes // `--x-file` companions for exactly these flags; the executor writes the // payload to a private temp file and passes the path instead. export interface GogFileArg { /** Discriminant separating this from a plain argv string. */ kind: 'file'; /** Flag NAME without leading dashes, e.g. 'body-html-file'. */ flag: string; /** The large payload, verbatim. */ contents: string; /** Temp-file extension without the dot, e.g. 'html'. Defaults to 'txt'. */ ext?: string; } export type GogArg = string | GogFileArg; export function isGogFileArg(arg: GogArg): arg is GogFileArg { return typeof arg !== 'string'; } // An executor runs a FULLY-ASSEMBLED gog arg list (already including // --json/--no-input/--color=never, --account, --readonly, and the service // subcommand) and returns its stdout as a string (or throws). This is the // injection seam that lets the same tool registrars run either by spawning // `gog` (stdio transport) or by forwarding the arg list to a remote HTTP // backend (hosted Cloudflare-Worker connector, which cannot spawn processes). // Elements may be GogFileArgs; EVERY executor is responsible for materializing // them to a private temp file and removing that file afterwards. export type GogExecutor = ( args: GogArg[], opts: { timeout?: number; interactive?: boolean }, ) => Promise; // Ambient override for the executor `run()` uses when no options.spawner is // given. The Worker/Fly path wraps request handling in // `runExecutor.run({ executor }, ...)`; unset, `run()` falls back to spawning. export const runExecutor = new AsyncLocalStorage<{ executor: GogExecutor }>(); export interface RunOptions { account?: string; spawner?: Spawner; interactive?: boolean; timeout?: number; // Inject gog's global --readonly flag, which blocks mutating API requests at // runtime. Independent of (and OR-ed with) the GOG_READONLY env var. readonly?: boolean; // How aggressively to redact the output/error before it reaches the client. // 'full' (default) runs the shared mcp-utils redactor plus the Google token // shapes. 'tokens' runs ONLY the Google token shapes (ya29.…/1//…) — use it // for output that is known-safe but that the broad shared redactor mangles, // most notably an OAuth consent URL whose `classroom.coursework.students`-style // scope names the shared redactor mistakes for secrets. A step-1 auth URL // carries no token, so stripping only real token shapes keeps it intact while // still catching any token that unexpectedly appears. redactMode?: 'full' | 'tokens'; } const TIMEOUT_MS = 30_000; // Minimum gogcli (`gog`) binary version this wrapper's tools assume. Some tools // pass flags/subcommands that only exist in newer gog, so bump this whenever a // change starts relying on a newer gog feature — and label that PR `gogcli-bump` // so the requirement change is surfaced in the release notes (see // .github/release.yml). This is the single source of truth for the required // version; keep the README/CLAUDE.md mention in sync. export const MIN_GOG_VERSION = '0.34.1'; // Interpret the GOG_READONLY kill-switch. `readEnvVar` already treats blank // values, 'undefined'/'null' sentinels, and unresolved .mcpb placeholders // ("${user_config.gog_readonly}") as unset. On top of that, GOG_READONLY is // deliberately fail-safe: any *set* value that isn't an explicit off value // (0/false/no/off) enables readonly — parseBoolEnv's `default: true` covers // unrecognised values (e.g. "enable"), so a typo blocks writes instead of // silently allowing them. function readonlyEnvEnabled(): boolean { return readEnvVar('GOG_READONLY') !== undefined && parseBoolEnv('GOG_READONLY', { default: true }); } // Strip ambient secrets from the child env so gogcli only sees its own // configured credentials. GOG_ACCESS_TOKEN is the original target: gogcli // would otherwise try to use a (potentially stale) directly-passed token // instead of the stored refresh token. The broader patterns are // defense-in-depth — the parent process's shell may have other Google / // cloud / API secrets in scope that the child has no business seeing. function sanitizedEnv(): NodeJS.ProcessEnv { const result: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(process.env)) { if (key === 'GOG_ACCESS_TOKEN') continue; if (key === 'GOOGLE_APPLICATION_CREDENTIALS') continue; if (/(_TOKEN|_SECRET|_API_KEY|_PRIVATE_KEY)$/.test(key)) continue; result[key] = value; } return result; } // Redact bearer/refresh-token patterns from error text before surfacing // it back to the MCP client. If gog ever emits a token in stderr (e.g. // from a verbose log mode), this prevents it from leaking to the model. // The shared mcp-utils redactSecrets covers Bearer/Basic headers, JWTs, // cookies, well-known key shapes (incl. Google AIza… API keys), and secret // query params — but not Google's OAuth2 token shapes, so those stay here. const GOOGLE_TOKEN_PATTERNS: RegExp[] = [ /ya29\.[A-Za-z0-9._\-]+/g, // OAuth2 access tokens /1\/\/[A-Za-z0-9._\-]+/g, // OAuth2 refresh tokens ]; // Strip only Google's OAuth2 token shapes. Precise enough to leave an OAuth // consent URL (client_id, scope names, state, code_challenge) untouched. export function redactGoogleTokens(text: string): string { let redacted = text; for (const re of GOOGLE_TOKEN_PATTERNS) { redacted = redacted.replace(re, '[REDACTED]'); } return redacted; } export function redactSecrets(text: string): string { return redactGoogleTokens(redactSharedSecrets(text)); } // MCP desktop clients often spawn servers with a stripped PATH that excludes // Homebrew, user-local, and Go's default install dirs — so even when gog is // installed, the spawned server can't find it. Augment the child's PATH with // the locations where gogcli is commonly installed. function augmentedPath(): string { const home = process.env.HOME; const candidates = [ process.env.PATH ?? '', '/opt/homebrew/bin', '/usr/local/bin', home ? `${home}/.local/bin` : '', home ? `${home}/go/bin` : '', ]; const seen = new Set(); const parts: string[] = []; for (const c of candidates) { if (!c) continue; for (const dir of c.split(delimiter)) { if (!dir || seen.has(dir)) continue; seen.add(dir); parts.push(dir); } } return parts.join(delimiter); } function formatTimeout(ms: number): string { const seconds = Math.round(ms / 1000); if (seconds >= 60) { const minutes = Math.round(seconds / 60); return `${ms}ms (${minutes} minute${minutes !== 1 ? 's' : ''})`; } return `${ms}ms`; } // Write every GogFileArg to a private temp file, run gog against the resulting // plain argv, and remove the temp dir afterwards — on success, on a non-zero // exit, and on timeout alike. A leaked temp file holds user email content. // // node:fs/promises and node:os are imported LAZILY (matching the lazy // node:child_process import below) so a Cloudflare Worker importing this module // doesn't eagerly pull node builtins, which would break the Worker bundle. async function spawnWithTempFiles( args: GogArg[], opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean }, ): Promise { const { mkdtemp, writeFile, rm } = await import('node:fs/promises'); const { tmpdir } = await import('node:os'); // mkdtemp creates the directory with mode 0700 (owner-only) on POSIX, so the // payload is never world-readable, not even for the instant between the // directory appearing and writeFile's own 0600 mode landing. const dir = await mkdtemp(join(tmpdir(), 'gogcli-mcp-')); try { const argv: string[] = []; for (const arg of args) { if (!isGogFileArg(arg)) { argv.push(arg); continue; } // Name the file after the flag: one command can carry two payloads // (e.g. --body-file and --signature-file, both .txt), and a fixed // basename would have the second silently clobber the first. const path = join(dir, `${arg.flag}.${arg.ext ?? 'txt'}`); await writeFile(path, arg.contents, { encoding: 'utf8', mode: 0o600 }); argv.push(`--${arg.flag}=${path}`); } return await spawnGog(argv, opts); } finally { // Never let a cleanup failure mask the real gog error (or a real result). await rm(dir, { recursive: true, force: true }).catch(() => {}); } } // Spawn-based executor. Deliberately NOT async: when no element is a // GogFileArg (the overwhelmingly common case) it must create no temp dir and // introduce no extra microtask tick before `spawn` is called — the spawn has // to happen synchronously within the `run()` call, which the fake-timer tests // in tests/runner.test.ts depend on. function spawnExecutor( args: GogArg[], opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean }, ): Promise { if (args.some(isGogFileArg)) { return spawnWithTempFiles(args, opts); } return spawnGog(args as string[], opts); } // Owns everything process-specific — building the sanitized child env, PATH // augmentation, spawning, collecting stdout/stderr, and the timeout kill. It // returns raw output (no redaction — `run()` wraps that around whichever // executor runs). The child_process import is LAZY so a Cloudflare Worker // importing this module doesn't eagerly pull node:child_process (which would // break the Worker bundle); the injected `spawner` bypasses it. async function spawnGog( fullArgs: string[], opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean }, ): Promise { const { timeout, interactive = false, spawner, binary = false } = opts; const spawn = spawner ?? (await import('node:child_process')).spawn as unknown as Spawner; const effectiveTimeout = timeout ?? TIMEOUT_MS; return new Promise((resolve, reject) => { const childEnv = { ...sanitizedEnv(), PATH: augmentedPath() }; const child = spawn(readEnvVar('GOG_PATH') ?? 'gog', fullArgs, { env: childEnv }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let settled = false; const timer = setTimeout(() => { settled = true; child.kill(); reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`)); }, effectiveTimeout); child.stdout!.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk); }); child.stderr!.on('data', (chunk: Buffer) => { stderrChunks.push(chunk); }); child.on('close', (code: number | null) => { clearTimeout(timer); if (settled) return; settled = true; const stderr = Buffer.concat(stderrChunks).toString().trim(); if (code === 0) { // Binary mode: return the raw stdout bytes base64-encoded, never a utf8 // string (which would corrupt a PDF/image). No stderr append. if (binary) { resolve(Buffer.concat(stdoutChunks).toString('base64')); return; } const stdout = Buffer.concat(stdoutChunks).toString(); if (interactive && stderr) { resolve(stdout + '\n' + stderr); } else { resolve(stdout); } } else { reject(new Error(stderr || `gog exited with code ${code}`)); } }); child.on('error', (err: Error) => { clearTimeout(timer); if (settled) return; settled = true; if ((err as NodeJS.ErrnoException).code === 'ENOENT') { reject(new Error( 'gog executable not found. Install gogcli (https://github.com/openclaw/gogcli) ' + 'or set GOG_PATH in your MCP client config to the absolute binary path ' + '(run `which gog` in a terminal to find it).', )); return; } reject(err); }); }); } // Assemble the full gog argv: the always-injected flags (--json/--color=never, // --no-input unless interactive, --readonly when opted in), --account, then the // caller's args. Shared by run() and runBinary() so both get identical flags. function assembleArgs( args: GogArg[], opts: { account?: string; interactive: boolean; readonly: boolean }, ): GogArg[] { const effectiveAccount = opts.account ?? readEnvVar('GOG_ACCOUNT'); const fullArgs: GogArg[] = ['--json', '--color=never']; if (!opts.interactive) { fullArgs.push('--no-input'); } // Block all mutating gog API requests at runtime when either the caller opts // in or GOG_READONLY is set in the environment. gog has no native env binding // for --readonly, so the wrapper translates GOG_READONLY into the flag. if (opts.readonly || readonlyEnvEnabled()) { fullArgs.push('--readonly'); } if (effectiveAccount) { fullArgs.push('--account', effectiveAccount); } fullArgs.push(...args); return fullArgs; } export async function run(args: GogArg[], options: RunOptions = {}): Promise { const { account, spawner, interactive = false, timeout, readonly = false, redactMode = 'full' } = options; const redact = redactMode === 'tokens' ? redactGoogleTokens : redactSecrets; const fullArgs = assembleArgs(args, { account, interactive, readonly }); // Pick the executor: an injected spawner keeps the stdio spawn path (and all // its tests) intact and always wins; otherwise an ambient runExecutor store // (the Worker/Fly HTTP-forward path) takes over; otherwise the default lazy // real spawn. Redaction wraps the executor regardless of which one runs — a // successful `gog auth tokens` (or any command echoing a credential) would // otherwise return raw Google tokens (ya29.…/1//…) into model context, where // a sibling tool (gog_gmail_send) could exfiltrate them. const store = runExecutor.getStore(); try { let output: string; if (spawner) { output = await spawnExecutor(fullArgs, { timeout, interactive, spawner }); } else if (store) { output = await store.executor(fullArgs, { timeout, interactive }); } else { output = await spawnExecutor(fullArgs, { timeout, interactive }); } return redact(output); } catch (err) { // A thrown non-Error would make `.message` undefined and redact() blow up // with a TypeError, masking the real failure. Same instanceof guard the // codebase already uses in errorText() (tools/utils.ts). throw new Error(redact(err instanceof Error ? err.message : String(err))); } } // Run gog and return its stdout as raw bytes, base64-encoded — for binary // payloads (a Drive file's bytes) that run()'s utf8 decode + secret redaction // would corrupt. Spawn path only: the hosted-connector executor forwards over // HTTP and hands back a decoded string, so binary cannot survive it — callers // on that path get a clear error instead of a mangled file. No redaction: the // base64 of a user's own binary file is opaque and has no token shapes to leak. export async function runBinary(args: GogArg[], options: RunOptions = {}): Promise { const { account, spawner, timeout, readonly = false } = options; // An injected spawner is the stdio/test path and always wins. Otherwise, if an // ambient forward executor is installed (the Worker/Fly connector), refuse: // its text-only transport can't carry bytes intact. if (!spawner && runExecutor.getStore()) { throw new Error( 'Raw byte retrieval is not available over the hosted connector (its transport is text-only). ' + 'Use the text-extraction path instead, or run the local stdio server to fetch bytes.', ); } const fullArgs = assembleArgs(args, { account, interactive: false, readonly }); return spawnExecutor(fullArgs, { timeout, interactive: false, spawner, binary: true }); }