fix: gmail setup fails to load gcloud when an unsupported Python is first on PATH (#112983)

* fix(gmail-setup): skip python interpreters gcloud can't use

resolvePythonExecutablePath accepted the first python3/python found on
PATH without checking its version, so macOS' bundled Python 3.9 (earlier
on PATH than a Homebrew 3.10-3.14 install) was chosen as CLOUDSDK_PYTHON
and gcloud failed to load. Query each candidate's version and skip any
outside gcloud's supported 3.10-3.14 range so a compatible interpreter
later on PATH is selected instead.

Closes #112712

* test(gmail): cover gcloud Python upper bound

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Sanjay Santhanam
2026-07-25 07:53:38 -07:00
committed by GitHub
parent 53be78269e
commit 65526ed389
2 changed files with 101 additions and 4 deletions
+73 -2
View File
@@ -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,
+28 -2
View File
@@ -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<string | undefined> {
if (cachedPythonPath !== undefined) {
return cachedPythonPath ?? undefined;
@@ -123,16 +135,30 @@ async function resolvePythonExecutablePath(): Promise<string | 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))"],
[
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;