From 5de3395204182d2404ee121781f8cdfc86216b00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 14:36:48 +0000 Subject: [PATCH] fix: resolve gcloud python path --- CHANGELOG.md | 1 + src/hooks/gmail-setup-utils.test.ts | 47 +++++++++ src/hooks/gmail-setup-utils.ts | 156 ++++++++++++++++++++-------- src/infra/path-env.test.ts | 51 +++++++++ src/infra/path-env.ts | 5 + 5 files changed, 218 insertions(+), 42 deletions(-) create mode 100644 src/hooks/gmail-setup-utils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d7c079912..6c17cfc22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixes - Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm. +- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp. - Agent tools: scope the Discord tool to Discord surface runs. - Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style. - macOS Connections: move to sidebar + detail layout with structured sections and header actions. diff --git a/src/hooks/gmail-setup-utils.test.ts b/src/hooks/gmail-setup-utils.test.ts new file mode 100644 index 000000000..ae0618b98 --- /dev/null +++ b/src/hooks/gmail-setup-utils.test.ts @@ -0,0 +1,47 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +beforeEach(() => { + vi.resetModules(); +}); + +describe("resolvePythonExecutablePath", () => { + it("resolves a working python path and caches the result", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-python-")); + const originalPath = process.env.PATH; + try { + const realPython = path.join(tmp, "python-real"); + await fs.writeFile(realPython, "#!/bin/sh\nexit 0\n", "utf-8"); + await fs.chmod(realPython, 0o755); + + const shimDir = path.join(tmp, "shims"); + await fs.mkdir(shimDir, { recursive: true }); + const shim = path.join(shimDir, "python3"); + await fs.writeFile( + shim, + `#!/bin/sh\nif [ \"$1\" = \"-c\" ]; then\n echo \"${realPython}\"\n exit 0\nfi\nexit 1\n`, + "utf-8", + ); + await fs.chmod(shim, 0o755); + + process.env.PATH = `${shimDir}${path.delimiter}/usr/bin`; + + const { resolvePythonExecutablePath } = await import( + "./gmail-setup-utils.js" + ); + + const resolved = await resolvePythonExecutablePath(); + expect(resolved).toBe(realPython); + + process.env.PATH = "/bin"; + const cached = await resolvePythonExecutablePath(); + expect(cached).toBe(realPython); + } finally { + process.env.PATH = originalPath; + await fs.rm(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/src/hooks/gmail-setup-utils.ts b/src/hooks/gmail-setup-utils.ts index d9d94d111..bf0b7e132 100644 --- a/src/hooks/gmail-setup-utils.ts +++ b/src/hooks/gmail-setup-utils.ts @@ -6,7 +6,106 @@ import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { normalizeServePath } from "./gmail.js"; +let cachedPythonPath: string | null | undefined; + +function findExecutablesOnPath(bins: string[]): string[] { + const pathEnv = process.env.PATH ?? ""; + const parts = pathEnv.split(path.delimiter).filter(Boolean); + const seen = new Set(); + const matches: string[] = []; + for (const part of parts) { + for (const bin of bins) { + const candidate = path.join(part, bin); + if (seen.has(candidate)) continue; + try { + fs.accessSync(candidate, fs.constants.X_OK); + matches.push(candidate); + seen.add(candidate); + } catch { + // keep scanning + } + } + } + return matches; +} + +function ensurePathIncludes(dirPath: string, position: "append" | "prepend") { + const pathEnv = process.env.PATH ?? ""; + const parts = pathEnv.split(path.delimiter).filter(Boolean); + if (parts.includes(dirPath)) return; + const next = + position === "prepend" ? [dirPath, ...parts] : [...parts, dirPath]; + process.env.PATH = next.join(path.delimiter); +} + +function ensureGcloudOnPath(): boolean { + if (hasBinary("gcloud")) return true; + const candidates = [ + "/opt/homebrew/share/google-cloud-sdk/bin/gcloud", + "/usr/local/share/google-cloud-sdk/bin/gcloud", + "/opt/homebrew/Caskroom/google-cloud-sdk/latest/google-cloud-sdk/bin/gcloud", + "/usr/local/Caskroom/google-cloud-sdk/latest/google-cloud-sdk/bin/gcloud", + ]; + for (const candidate of candidates) { + try { + fs.accessSync(candidate, fs.constants.X_OK); + ensurePathIncludes(path.dirname(candidate), "append"); + return true; + } catch { + // keep scanning + } + } + return false; +} + +export async function resolvePythonExecutablePath(): Promise { + if (cachedPythonPath !== undefined) { + return cachedPythonPath ?? undefined; + } + const candidates = findExecutablesOnPath(["python3", "python"]); + for (const candidate of candidates) { + const res = await runCommandWithTimeout( + [ + candidate, + "-c", + "import os, sys; print(os.path.realpath(sys.executable))", + ], + { timeoutMs: 2_000 }, + ); + if (res.code !== 0) continue; + const resolved = res.stdout.trim().split(/\s+/)[0]; + if (!resolved) continue; + try { + fs.accessSync(resolved, fs.constants.X_OK); + cachedPythonPath = resolved; + return resolved; + } catch { + // keep scanning + } + } + cachedPythonPath = null; + return undefined; +} + +async function gcloudEnv(): Promise { + if (process.env.CLOUDSDK_PYTHON) return undefined; + const pythonPath = await resolvePythonExecutablePath(); + if (!pythonPath) return undefined; + return { CLOUDSDK_PYTHON: pythonPath }; +} + +async function runGcloudCommand( + args: string[], + timeoutMs: number, +): Promise>> { + return await runCommandWithTimeout(["gcloud", ...args], { + timeoutMs, + env: await gcloudEnv(), + }); +} + export async function ensureDependency(bin: string, brewArgs: string[]) { + if (bin === "gcloud" && ensureGcloudOnPath()) return; if (hasBinary(bin)) return; if (process.platform !== "darwin") { throw new Error(`${bin} not installed; install it and retry`); @@ -14,8 +113,10 @@ export async function ensureDependency(bin: string, brewArgs: string[]) { if (!hasBinary("brew")) { throw new Error("Homebrew not installed (install brew and retry)"); } + const brewEnv = bin === "gcloud" ? await gcloudEnv() : undefined; const result = await runCommandWithTimeout(["brew", "install", ...brewArgs], { timeoutMs: 600_000, + env: brewEnv, }); if (result.code !== 0) { throw new Error( @@ -28,31 +129,19 @@ export async function ensureDependency(bin: string, brewArgs: string[]) { } export async function ensureGcloudAuth() { - const res = await runCommandWithTimeout( - [ - "gcloud", - "auth", - "list", - "--filter", - "status:ACTIVE", - "--format", - "value(account)", - ], - { timeoutMs: 30_000 }, + const res = await runGcloudCommand( + ["auth", "list", "--filter", "status:ACTIVE", "--format", "value(account)"], + 30_000, ); if (res.code === 0 && res.stdout.trim()) return; - const login = await runCommandWithTimeout(["gcloud", "auth", "login"], { - timeoutMs: 600_000, - }); + const login = await runGcloudCommand(["auth", "login"], 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, - }); + const result = await runGcloudCommand(args, 120_000); if (result.code !== 0) { throw new Error(result.stderr || result.stdout || "gcloud command failed"); } @@ -60,17 +149,9 @@ export async function runGcloud(args: string[]) { } export async function ensureTopic(projectId: string, topicName: string) { - const describe = await runCommandWithTimeout( - [ - "gcloud", - "pubsub", - "topics", - "describe", - topicName, - "--project", - projectId, - ], - { timeoutMs: 30_000 }, + const describe = await runGcloudCommand( + ["pubsub", "topics", "describe", topicName, "--project", projectId], + 30_000, ); if (describe.code === 0) return; await runGcloud([ @@ -89,17 +170,9 @@ export async function ensureSubscription( topicName: string, pushEndpoint: string, ) { - const describe = await runCommandWithTimeout( - [ - "gcloud", - "pubsub", - "subscriptions", - "describe", - subscription, - "--project", - projectId, - ], - { timeoutMs: 30_000 }, + const describe = await runGcloudCommand( + ["pubsub", "subscriptions", "describe", subscription, "--project", projectId], + 30_000, ); if (describe.code === 0) { await runGcloud([ @@ -188,9 +261,8 @@ export async function resolveProjectIdFromGogCredentials(): Promise< const clientId = extractGogClientId(parsed); const projectNumber = extractProjectNumber(clientId); if (!projectNumber) continue; - const res = await runCommandWithTimeout( + const res = await runGcloudCommand( [ - "gcloud", "projects", "list", "--filter", @@ -198,7 +270,7 @@ export async function resolveProjectIdFromGogCredentials(): Promise< "--format", "value(projectId)", ], - { timeoutMs: 30_000 }, + 30_000, ); if (res.code !== 0) continue; const projectId = res.stdout.trim().split(/\s+/)[0]; diff --git a/src/infra/path-env.test.ts b/src/infra/path-env.test.ts index 686e3d641..7f2bf2379 100644 --- a/src/infra/path-env.test.ts +++ b/src/infra/path-env.test.ts @@ -60,4 +60,55 @@ describe("ensureClawdisCliOnPath", () => { else process.env.CLAWDIS_PATH_BOOTSTRAPPED = originalFlag; } }); + + it("prepends mise shims when available", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-path-")); + const originalPath = process.env.PATH; + const originalFlag = process.env.CLAWDIS_PATH_BOOTSTRAPPED; + const originalMiseDataDir = process.env.MISE_DATA_DIR; + try { + const relayDir = path.join(tmp, "Relay"); + await fs.mkdir(relayDir, { recursive: true }); + const relayCli = path.join(relayDir, "clawdis"); + await fs.writeFile(relayCli, "#!/bin/sh\necho ok\n", "utf-8"); + await fs.chmod(relayCli, 0o755); + + const localBinDir = path.join(tmp, "node_modules", ".bin"); + await fs.mkdir(localBinDir, { recursive: true }); + const localCli = path.join(localBinDir, "clawdis"); + await fs.writeFile(localCli, "#!/bin/sh\necho ok\n", "utf-8"); + await fs.chmod(localCli, 0o755); + + const miseDataDir = path.join(tmp, "mise"); + const shimsDir = path.join(miseDataDir, "shims"); + await fs.mkdir(shimsDir, { recursive: true }); + process.env.MISE_DATA_DIR = miseDataDir; + process.env.PATH = "/usr/bin"; + delete process.env.CLAWDIS_PATH_BOOTSTRAPPED; + + ensureClawdisCliOnPath({ + execPath: relayCli, + cwd: tmp, + homeDir: tmp, + platform: "darwin", + }); + + const updated = process.env.PATH ?? ""; + const parts = updated.split(path.delimiter); + const relayIndex = parts.indexOf(relayDir); + const localIndex = parts.indexOf(localBinDir); + const shimsIndex = parts.indexOf(shimsDir); + expect(relayIndex).toBeGreaterThanOrEqual(0); + expect(localIndex).toBeGreaterThan(relayIndex); + expect(shimsIndex).toBeGreaterThan(localIndex); + } finally { + process.env.PATH = originalPath; + if (originalFlag === undefined) + delete process.env.CLAWDIS_PATH_BOOTSTRAPPED; + else process.env.CLAWDIS_PATH_BOOTSTRAPPED = originalFlag; + if (originalMiseDataDir === undefined) delete process.env.MISE_DATA_DIR; + else process.env.MISE_DATA_DIR = originalMiseDataDir; + await fs.rm(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/src/infra/path-env.ts b/src/infra/path-env.ts index 484b0ce9d..6770079a0 100644 --- a/src/infra/path-env.ts +++ b/src/infra/path-env.ts @@ -70,6 +70,11 @@ function candidateBinDirs(opts: EnsureClawdisPathOpts): string[] { if (isExecutable(path.join(localBinDir, "clawdis"))) candidates.push(localBinDir); + const miseDataDir = + process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise"); + const miseShims = path.join(miseDataDir, "shims"); + if (isDirectory(miseShims)) candidates.push(miseShims); + // Common global install locations (macOS first). if (platform === "darwin") { candidates.push(path.join(homeDir, "Library", "pnpm"));