diff --git a/src/hooks/gmail-setup-utils.test.ts b/src/hooks/gmail-setup-utils.test.ts index 87fb8c378f2e..7fc7cef17c35 100644 --- a/src/hooks/gmail-setup-utils.test.ts +++ b/src/hooks/gmail-setup-utils.test.ts @@ -40,7 +40,7 @@ describe("runGcloud interpreter resolution", () => { await withEnvAsync({ PATH: `${shimDir}${path.delimiter}/usr/bin` }, async () => { runCommandWithTimeoutMock .mockResolvedValueOnce({ - stdout: `${realPython}\n`, + stdout: `${realPython}\n3.12\n`, stderr: "", code: 0, signal: null, @@ -71,6 +71,77 @@ describe("runGcloud interpreter resolution", () => { }, 60_000, ); + + itUnix( + "skips Python versions below and above gcloud's supported range", + async () => { + const { runGcloud } = await loadGmailSetupUtils(); + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-python-ver-")); + try { + const oldPython = path.join(tmp, "python-old"); + await fs.writeFile(oldPython, "#!/bin/sh\nexit 0\n", "utf-8"); + await fs.chmod(oldPython, 0o755); + const goodPython = path.join(tmp, "python-good"); + await fs.writeFile(goodPython, "#!/bin/sh\nexit 0\n", "utf-8"); + await fs.chmod(goodPython, 0o755); + + const shimDirs = ["old", "future", "supported"].map((name) => + path.join(tmp, `${name}-shims`), + ); + for (const shimDir of shimDirs) { + await fs.mkdir(shimDir, { recursive: true }); + const shim = path.join(shimDir, "python3"); + await fs.writeFile(shim, "#!/bin/sh\nexit 0\n", "utf-8"); + await fs.chmod(shim, 0o755); + } + + await withEnvAsync({ PATH: shimDirs.join(path.delimiter) }, async () => { + runCommandWithTimeoutMock + // python3 -> Python 3.9 (unsupported by gcloud): must be skipped. + .mockResolvedValueOnce({ + stdout: `${oldPython}\n3.9\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + }) + // A future Python beyond gcloud's current cap must also be skipped. + .mockResolvedValueOnce({ + stdout: `${path.join(tmp, "python-future")}\n3.15\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + }) + // Python 3.12 is supported and should be selected. + .mockResolvedValueOnce({ + stdout: `${goodPython}\n3.12\n`, + stderr: "", + code: 0, + signal: null, + killed: false, + }) + .mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + + await runGcloud(["config", "list"]); + + expect(runCommandWithTimeoutMock).toHaveBeenLastCalledWith(["gcloud", "config", "list"], { + timeoutMs: 120_000, + env: { CLOUDSDK_PYTHON: goodPython, CLOUDSDK_PYTHON_ARGS: undefined }, + }); + }); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }, + 60_000, + ); }); describe("runGcloud", () => { @@ -99,7 +170,7 @@ describe("runGcloud", () => { async () => { runCommandWithTimeoutMock .mockResolvedValueOnce({ - stdout: `${realPython}\n`, + stdout: `${realPython}\n3.12\n`, stderr: "", code: 0, signal: null, diff --git a/src/hooks/gmail-setup-utils.ts b/src/hooks/gmail-setup-utils.ts index fd633bfcac2f..3f7194fd06c9 100644 --- a/src/hooks/gmail-setup-utils.ts +++ b/src/hooks/gmail-setup-utils.ts @@ -116,6 +116,18 @@ function ensureGcloudOnPath(): boolean { return false; } +// gcloud requires a Python interpreter in this range to run; picking an +// interpreter outside it makes `gcloud` fail to load. See `gcloud topic startup`. +const MIN_GCLOUD_PYTHON: readonly [number, number] = [3, 10]; +const MAX_GCLOUD_PYTHON: readonly [number, number] = [3, 14]; + +function isSupportedGcloudPythonVersion(major: number, minor: number): boolean { + if (major !== MIN_GCLOUD_PYTHON[0]) { + return false; + } + return minor >= MIN_GCLOUD_PYTHON[1] && minor <= MAX_GCLOUD_PYTHON[1]; +} + async function resolvePythonExecutablePath(): Promise { if (cachedPythonPath !== undefined) { return cachedPythonPath ?? undefined; @@ -123,16 +135,30 @@ async function resolvePythonExecutablePath(): Promise { 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))"], + [ + candidate, + "-c", + "import os, sys; print(os.path.realpath(sys.executable)); print('%d.%d' % sys.version_info[:2])", + ], { timeoutMs: 2_000 }, ); if (res.code !== 0) { continue; } - const resolved = res.stdout.trim().split(/\s+/)[0]; + const lines = res.stdout.trim().split(/\r?\n/); + const resolved = lines[0]?.trim().split(/\s+/)[0]; if (!resolved) { continue; } + const version = lines[1]?.trim().match(/^(\d+)\.(\d+)/); + if (!version) { + continue; + } + if (!isSupportedGcloudPythonVersion(Number(version[1]), Number(version[2]))) { + // Skip interpreters gcloud cannot use (e.g. macOS' bundled Python 3.9) + // so a compatible interpreter later on PATH is selected instead. + continue; + } try { fs.accessSync(resolved, fs.constants.X_OK); cachedPythonPath = resolved;