Skip to content

Commit 5de3395

Browse files
committed
fix: resolve gcloud python path
1 parent 4e4655f commit 5de3395

5 files changed

Lines changed: 218 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
### Fixes
1414
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
15+
- Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp.
1516
- Agent tools: scope the Discord tool to Discord surface runs.
1617
- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style.
1718
- macOS Connections: move to sidebar + detail layout with structured sections and header actions.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
beforeEach(() => {
8+
vi.resetModules();
9+
});
10+
11+
describe("resolvePythonExecutablePath", () => {
12+
it("resolves a working python path and caches the result", async () => {
13+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-python-"));
14+
const originalPath = process.env.PATH;
15+
try {
16+
const realPython = path.join(tmp, "python-real");
17+
await fs.writeFile(realPython, "#!/bin/sh\nexit 0\n", "utf-8");
18+
await fs.chmod(realPython, 0o755);
19+
20+
const shimDir = path.join(tmp, "shims");
21+
await fs.mkdir(shimDir, { recursive: true });
22+
const shim = path.join(shimDir, "python3");
23+
await fs.writeFile(
24+
shim,
25+
`#!/bin/sh\nif [ \"$1\" = \"-c\" ]; then\n echo \"${realPython}\"\n exit 0\nfi\nexit 1\n`,
26+
"utf-8",
27+
);
28+
await fs.chmod(shim, 0o755);
29+
30+
process.env.PATH = `${shimDir}${path.delimiter}/usr/bin`;
31+
32+
const { resolvePythonExecutablePath } = await import(
33+
"./gmail-setup-utils.js"
34+
);
35+
36+
const resolved = await resolvePythonExecutablePath();
37+
expect(resolved).toBe(realPython);
38+
39+
process.env.PATH = "/bin";
40+
const cached = await resolvePythonExecutablePath();
41+
expect(cached).toBe(realPython);
42+
} finally {
43+
process.env.PATH = originalPath;
44+
await fs.rm(tmp, { recursive: true, force: true });
45+
}
46+
});
47+
});

src/hooks/gmail-setup-utils.ts

Lines changed: 114 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,117 @@ import { runCommandWithTimeout } from "../process/exec.js";
66
import { resolveUserPath } from "../utils.js";
77
import { normalizeServePath } from "./gmail.js";
88

9+
let cachedPythonPath: string | null | undefined;
10+
11+
function findExecutablesOnPath(bins: string[]): string[] {
12+
const pathEnv = process.env.PATH ?? "";
13+
const parts = pathEnv.split(path.delimiter).filter(Boolean);
14+
const seen = new Set<string>();
15+
const matches: string[] = [];
16+
for (const part of parts) {
17+
for (const bin of bins) {
18+
const candidate = path.join(part, bin);
19+
if (seen.has(candidate)) continue;
20+
try {
21+
fs.accessSync(candidate, fs.constants.X_OK);
22+
matches.push(candidate);
23+
seen.add(candidate);
24+
} catch {
25+
// keep scanning
26+
}
27+
}
28+
}
29+
return matches;
30+
}
31+
32+
function ensurePathIncludes(dirPath: string, position: "append" | "prepend") {
33+
const pathEnv = process.env.PATH ?? "";
34+
const parts = pathEnv.split(path.delimiter).filter(Boolean);
35+
if (parts.includes(dirPath)) return;
36+
const next =
37+
position === "prepend" ? [dirPath, ...parts] : [...parts, dirPath];
38+
process.env.PATH = next.join(path.delimiter);
39+
}
40+
41+
function ensureGcloudOnPath(): boolean {
42+
if (hasBinary("gcloud")) return true;
43+
const candidates = [
44+
"/opt/homebrew/share/google-cloud-sdk/bin/gcloud",
45+
"/usr/local/share/google-cloud-sdk/bin/gcloud",
46+
"/opt/homebrew/Caskroom/google-cloud-sdk/latest/google-cloud-sdk/bin/gcloud",
47+
"/usr/local/Caskroom/google-cloud-sdk/latest/google-cloud-sdk/bin/gcloud",
48+
];
49+
for (const candidate of candidates) {
50+
try {
51+
fs.accessSync(candidate, fs.constants.X_OK);
52+
ensurePathIncludes(path.dirname(candidate), "append");
53+
return true;
54+
} catch {
55+
// keep scanning
56+
}
57+
}
58+
return false;
59+
}
60+
61+
export async function resolvePythonExecutablePath(): Promise<string | undefined> {
62+
if (cachedPythonPath !== undefined) {
63+
return cachedPythonPath ?? undefined;
64+
}
65+
const candidates = findExecutablesOnPath(["python3", "python"]);
66+
for (const candidate of candidates) {
67+
const res = await runCommandWithTimeout(
68+
[
69+
candidate,
70+
"-c",
71+
"import os, sys; print(os.path.realpath(sys.executable))",
72+
],
73+
{ timeoutMs: 2_000 },
74+
);
75+
if (res.code !== 0) continue;
76+
const resolved = res.stdout.trim().split(/\s+/)[0];
77+
if (!resolved) continue;
78+
try {
79+
fs.accessSync(resolved, fs.constants.X_OK);
80+
cachedPythonPath = resolved;
81+
return resolved;
82+
} catch {
83+
// keep scanning
84+
}
85+
}
86+
cachedPythonPath = null;
87+
return undefined;
88+
}
89+
90+
async function gcloudEnv(): Promise<NodeJS.ProcessEnv | undefined> {
91+
if (process.env.CLOUDSDK_PYTHON) return undefined;
92+
const pythonPath = await resolvePythonExecutablePath();
93+
if (!pythonPath) return undefined;
94+
return { CLOUDSDK_PYTHON: pythonPath };
95+
}
96+
97+
async function runGcloudCommand(
98+
args: string[],
99+
timeoutMs: number,
100+
): Promise<Awaited<ReturnType<typeof runCommandWithTimeout>>> {
101+
return await runCommandWithTimeout(["gcloud", ...args], {
102+
timeoutMs,
103+
env: await gcloudEnv(),
104+
});
105+
}
106+
9107
export async function ensureDependency(bin: string, brewArgs: string[]) {
108+
if (bin === "gcloud" && ensureGcloudOnPath()) return;
10109
if (hasBinary(bin)) return;
11110
if (process.platform !== "darwin") {
12111
throw new Error(`${bin} not installed; install it and retry`);
13112
}
14113
if (!hasBinary("brew")) {
15114
throw new Error("Homebrew not installed (install brew and retry)");
16115
}
116+
const brewEnv = bin === "gcloud" ? await gcloudEnv() : undefined;
17117
const result = await runCommandWithTimeout(["brew", "install", ...brewArgs], {
18118
timeoutMs: 600_000,
119+
env: brewEnv,
19120
});
20121
if (result.code !== 0) {
21122
throw new Error(
@@ -28,49 +129,29 @@ export async function ensureDependency(bin: string, brewArgs: string[]) {
28129
}
29130

30131
export async function ensureGcloudAuth() {
31-
const res = await runCommandWithTimeout(
32-
[
33-
"gcloud",
34-
"auth",
35-
"list",
36-
"--filter",
37-
"status:ACTIVE",
38-
"--format",
39-
"value(account)",
40-
],
41-
{ timeoutMs: 30_000 },
132+
const res = await runGcloudCommand(
133+
["auth", "list", "--filter", "status:ACTIVE", "--format", "value(account)"],
134+
30_000,
42135
);
43136
if (res.code === 0 && res.stdout.trim()) return;
44-
const login = await runCommandWithTimeout(["gcloud", "auth", "login"], {
45-
timeoutMs: 600_000,
46-
});
137+
const login = await runGcloudCommand(["auth", "login"], 600_000);
47138
if (login.code !== 0) {
48139
throw new Error(login.stderr || "gcloud auth login failed");
49140
}
50141
}
51142

52143
export async function runGcloud(args: string[]) {
53-
const result = await runCommandWithTimeout(["gcloud", ...args], {
54-
timeoutMs: 120_000,
55-
});
144+
const result = await runGcloudCommand(args, 120_000);
56145
if (result.code !== 0) {
57146
throw new Error(result.stderr || result.stdout || "gcloud command failed");
58147
}
59148
return result;
60149
}
61150

62151
export async function ensureTopic(projectId: string, topicName: string) {
63-
const describe = await runCommandWithTimeout(
64-
[
65-
"gcloud",
66-
"pubsub",
67-
"topics",
68-
"describe",
69-
topicName,
70-
"--project",
71-
projectId,
72-
],
73-
{ timeoutMs: 30_000 },
152+
const describe = await runGcloudCommand(
153+
["pubsub", "topics", "describe", topicName, "--project", projectId],
154+
30_000,
74155
);
75156
if (describe.code === 0) return;
76157
await runGcloud([
@@ -89,17 +170,9 @@ export async function ensureSubscription(
89170
topicName: string,
90171
pushEndpoint: string,
91172
) {
92-
const describe = await runCommandWithTimeout(
93-
[
94-
"gcloud",
95-
"pubsub",
96-
"subscriptions",
97-
"describe",
98-
subscription,
99-
"--project",
100-
projectId,
101-
],
102-
{ timeoutMs: 30_000 },
173+
const describe = await runGcloudCommand(
174+
["pubsub", "subscriptions", "describe", subscription, "--project", projectId],
175+
30_000,
103176
);
104177
if (describe.code === 0) {
105178
await runGcloud([
@@ -188,17 +261,16 @@ export async function resolveProjectIdFromGogCredentials(): Promise<
188261
const clientId = extractGogClientId(parsed);
189262
const projectNumber = extractProjectNumber(clientId);
190263
if (!projectNumber) continue;
191-
const res = await runCommandWithTimeout(
264+
const res = await runGcloudCommand(
192265
[
193-
"gcloud",
194266
"projects",
195267
"list",
196268
"--filter",
197269
`projectNumber=${projectNumber}`,
198270
"--format",
199271
"value(projectId)",
200272
],
201-
{ timeoutMs: 30_000 },
273+
30_000,
202274
);
203275
if (res.code !== 0) continue;
204276
const projectId = res.stdout.trim().split(/\s+/)[0];

src/infra/path-env.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,55 @@ describe("ensureClawdisCliOnPath", () => {
6060
else process.env.CLAWDIS_PATH_BOOTSTRAPPED = originalFlag;
6161
}
6262
});
63+
64+
it("prepends mise shims when available", async () => {
65+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-path-"));
66+
const originalPath = process.env.PATH;
67+
const originalFlag = process.env.CLAWDIS_PATH_BOOTSTRAPPED;
68+
const originalMiseDataDir = process.env.MISE_DATA_DIR;
69+
try {
70+
const relayDir = path.join(tmp, "Relay");
71+
await fs.mkdir(relayDir, { recursive: true });
72+
const relayCli = path.join(relayDir, "clawdis");
73+
await fs.writeFile(relayCli, "#!/bin/sh\necho ok\n", "utf-8");
74+
await fs.chmod(relayCli, 0o755);
75+
76+
const localBinDir = path.join(tmp, "node_modules", ".bin");
77+
await fs.mkdir(localBinDir, { recursive: true });
78+
const localCli = path.join(localBinDir, "clawdis");
79+
await fs.writeFile(localCli, "#!/bin/sh\necho ok\n", "utf-8");
80+
await fs.chmod(localCli, 0o755);
81+
82+
const miseDataDir = path.join(tmp, "mise");
83+
const shimsDir = path.join(miseDataDir, "shims");
84+
await fs.mkdir(shimsDir, { recursive: true });
85+
process.env.MISE_DATA_DIR = miseDataDir;
86+
process.env.PATH = "/usr/bin";
87+
delete process.env.CLAWDIS_PATH_BOOTSTRAPPED;
88+
89+
ensureClawdisCliOnPath({
90+
execPath: relayCli,
91+
cwd: tmp,
92+
homeDir: tmp,
93+
platform: "darwin",
94+
});
95+
96+
const updated = process.env.PATH ?? "";
97+
const parts = updated.split(path.delimiter);
98+
const relayIndex = parts.indexOf(relayDir);
99+
const localIndex = parts.indexOf(localBinDir);
100+
const shimsIndex = parts.indexOf(shimsDir);
101+
expect(relayIndex).toBeGreaterThanOrEqual(0);
102+
expect(localIndex).toBeGreaterThan(relayIndex);
103+
expect(shimsIndex).toBeGreaterThan(localIndex);
104+
} finally {
105+
process.env.PATH = originalPath;
106+
if (originalFlag === undefined)
107+
delete process.env.CLAWDIS_PATH_BOOTSTRAPPED;
108+
else process.env.CLAWDIS_PATH_BOOTSTRAPPED = originalFlag;
109+
if (originalMiseDataDir === undefined) delete process.env.MISE_DATA_DIR;
110+
else process.env.MISE_DATA_DIR = originalMiseDataDir;
111+
await fs.rm(tmp, { recursive: true, force: true });
112+
}
113+
});
63114
});

src/infra/path-env.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ function candidateBinDirs(opts: EnsureClawdisPathOpts): string[] {
7070
if (isExecutable(path.join(localBinDir, "clawdis")))
7171
candidates.push(localBinDir);
7272

73+
const miseDataDir =
74+
process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise");
75+
const miseShims = path.join(miseDataDir, "shims");
76+
if (isDirectory(miseShims)) candidates.push(miseShims);
77+
7378
// Common global install locations (macOS first).
7479
if (platform === "darwin") {
7580
candidates.push(path.join(homeDir, "Library", "pnpm"));

0 commit comments

Comments
 (0)