Skip to content

Commit cdcbad0

Browse files
Angfr95BradGroux
authored andcommitted
fix(windows): resolve gcloud/gog/tailscale PATHEXT shims before spawn to fix ENOENT on Windows
1 parent 557c5bf commit cdcbad0

6 files changed

Lines changed: 242 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai
6767
### Fixes
6868

6969
- Update/restart: probe managed Gateway restarts with the service environment and add a Docker product lane that exercises candidate-owned `openclaw update --yes --json` restarts, so SecretRef-backed local gateway auth cannot regress behind mocked restart checks. Thanks @vincentkoc.
70+
- Webhooks/Gmail/Windows: resolve `gcloud`, `gog`, and `tailscale` PATH/PATHEXT shims before setup and watcher spawns, using the Windows-safe `.cmd` wrapper for long-lived `gog serve` processes. (#74881, fixes #54470) Thanks @Angfr95.
7071
- Plugins/install: honor the beta update channel for onboarding and doctor-managed plugin installs by requesting floating npm and ClawHub specs with `@beta` while keeping persistent install records on the catalog default. Thanks @vincentkoc.
7172
- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc.
7273
- Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc.

src/hooks/gmail-ops.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { spawn } from "node:child_process";
2+
import path from "node:path";
23
import { formatCliCommand } from "../cli/command-format.js";
34
import {
45
getRuntimeConfig,
@@ -9,8 +10,11 @@ import {
910
resolveGatewayPort,
1011
validateConfigObjectWithPlugins,
1112
} from "../config/config.js";
13+
import { resolveExecutable } from "../infra/executable-path.js";
14+
import { getWindowsInstallRoots } from "../infra/windows-install-roots.js";
1215
import { runCommandWithTimeout } from "../process/exec.js";
1316
import { defaultRuntime } from "../runtime.js";
17+
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
1418
import { displayPath } from "../utils.js";
1519
import {
1620
ensureDependency,
@@ -75,6 +79,38 @@ export type GmailRunOptions = GmailCommonOptions & {
7579
};
7680

7781
const DEFAULT_GMAIL_TOPIC_IAM_MEMBER = "serviceAccount:gmail-api-push@system.gserviceaccount.com";
82+
let gogBin: string | undefined;
83+
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/;
84+
85+
function escapeForCmdExe(arg: string): string {
86+
if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) {
87+
throw new Error(`Unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`);
88+
}
89+
if (!arg.includes(" ") && !arg.includes('"')) {
90+
return arg;
91+
}
92+
return `"${arg.replace(/"/g, '""')}"`;
93+
}
94+
95+
function resolveGogServeInvocation(args: string[]): {
96+
args: string[];
97+
command: string;
98+
windowsHide?: true;
99+
windowsVerbatimArguments?: true;
100+
} {
101+
const command = (gogBin ??= resolveExecutable("gog"));
102+
const ext = normalizeLowercaseStringOrEmpty(path.extname(command));
103+
if (process.platform !== "win32" || (ext !== ".cmd" && ext !== ".bat")) {
104+
return { command, args, windowsHide: process.platform === "win32" ? true : undefined };
105+
}
106+
const cmdExe = path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe");
107+
return {
108+
command: cmdExe,
109+
args: ["/d", "/s", "/c", [command, ...args].map(escapeForCmdExe).join(" ")],
110+
windowsHide: true,
111+
windowsVerbatimArguments: true,
112+
};
113+
}
78114

79115
export async function runGmailSetup(opts: GmailSetupOptions) {
80116
await ensureDependency("gcloud", ["--cask", "gcloud-cli"]);
@@ -358,14 +394,19 @@ export async function runGmailService(opts: GmailRunOptions) {
358394
function spawnGogServe(cfg: GmailHookRuntimeConfig) {
359395
const args = buildGogWatchServeArgs(cfg);
360396
defaultRuntime.log(`Starting gog ${buildGogWatchServeLogArgs(cfg).join(" ")}`);
361-
return spawn("gog", args, { stdio: "inherit" });
397+
const invocation = resolveGogServeInvocation(args);
398+
return spawn(invocation.command, invocation.args, {
399+
stdio: "inherit",
400+
windowsHide: invocation.windowsHide,
401+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
402+
});
362403
}
363404

364405
async function startGmailWatch(
365406
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
366407
fatal = false,
367408
) {
368-
const args = ["gog", ...buildGogWatchStartArgs(cfg)];
409+
const args = [(gogBin ??= resolveExecutable("gog")), ...buildGogWatchStartArgs(cfg)];
369410
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
370411
if (result.code !== 0) {
371412
const message = result.stderr || result.stdout || "gog watch start failed";

src/hooks/gmail-setup-utils.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import fs from "node:fs";
22
import path from "node:path";
33
import { hasBinary } from "../agents/skills.js";
44
import { formatErrorMessage } from "../infra/errors.js";
5+
import { resolveExecutable } from "../infra/executable-path.js";
56
import { runCommandWithTimeout, type SpawnResult } from "../process/exec.js";
67
import { resolveUserPath } from "../utils.js";
78
import { normalizeServePath } from "./gmail.js";
89

910
let cachedPythonPath: string | null | undefined;
11+
let gcloudBin: string | undefined;
1012
const MAX_OUTPUT_CHARS = 800;
1113

1214
export function resetGmailSetupUtilsCachesForTest(): void {
@@ -156,7 +158,7 @@ async function runGcloudCommand(
156158
args: string[],
157159
timeoutMs: number,
158160
): Promise<Awaited<ReturnType<typeof runCommandWithTimeout>>> {
159-
return await runCommandWithTimeout(["gcloud", ...args], {
161+
return await runCommandWithTimeout([(gcloudBin ??= resolveExecutable("gcloud")), ...args], {
160162
timeoutMs,
161163
env: await gcloudEnv(),
162164
});
@@ -269,9 +271,10 @@ export async function ensureTailscaleEndpoint(params: {
269271
return "";
270272
}
271273

274+
const tailscaleBin = resolveExecutable("tailscale");
272275
const statusArgs = ["status", "--json"];
273276
const statusCommand = formatCommand("tailscale", statusArgs);
274-
const status = await runCommandWithTimeout(["tailscale", ...statusArgs], {
277+
const status = await runCommandWithTimeout([tailscaleBin, ...statusArgs], {
275278
timeoutMs: 30_000,
276279
});
277280
if (status.code !== 0) {
@@ -300,7 +303,7 @@ export async function ensureTailscaleEndpoint(params: {
300303
const pathArg = normalizeServePath(params.path);
301304
const funnelArgs = [params.mode, "--bg", "--set-path", pathArg, "--yes", target];
302305
const funnelCommand = formatCommand("tailscale", funnelArgs);
303-
const funnelResult = await runCommandWithTimeout(["tailscale", ...funnelArgs], {
306+
const funnelResult = await runCommandWithTimeout([tailscaleBin, ...funnelArgs], {
304307
timeoutMs: 30_000,
305308
});
306309
if (funnelResult.code !== 0) {

src/hooks/gmail-watcher.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
*/
77

88
import { type ChildProcess, spawn } from "node:child_process";
9+
import path from "node:path";
910
import { hasBinary } from "../agents/skills.js";
1011
import type { OpenClawConfig } from "../config/types.openclaw.js";
12+
import { resolveExecutable } from "../infra/executable-path.js";
13+
import { getWindowsInstallRoots } from "../infra/windows-install-roots.js";
1114
import { createSubsystemLogger } from "../logging/subsystem.js";
1215
import { runCommandWithTimeout } from "../process/exec.js";
16+
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
1317
import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js";
1418
import { isAddressInUseError } from "./gmail-watcher-errors.js";
1519
import {
@@ -26,6 +30,38 @@ let watcherProcess: ChildProcess | null = null;
2630
let renewInterval: ReturnType<typeof setInterval> | null = null;
2731
let shuttingDown = false;
2832
let currentConfig: GmailHookRuntimeConfig | null = null;
33+
let gogBin: string | undefined;
34+
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/;
35+
36+
function escapeForCmdExe(arg: string): string {
37+
if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) {
38+
throw new Error(`Unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`);
39+
}
40+
if (!arg.includes(" ") && !arg.includes('"')) {
41+
return arg;
42+
}
43+
return `"${arg.replace(/"/g, '""')}"`;
44+
}
45+
46+
function resolveGogServeInvocation(args: string[]): {
47+
args: string[];
48+
command: string;
49+
windowsHide?: true;
50+
windowsVerbatimArguments?: true;
51+
} {
52+
const command = (gogBin ??= resolveExecutable("gog"));
53+
const ext = normalizeLowercaseStringOrEmpty(path.extname(command));
54+
if (process.platform !== "win32" || (ext !== ".cmd" && ext !== ".bat")) {
55+
return { command, args, windowsHide: process.platform === "win32" ? true : undefined };
56+
}
57+
const cmdExe = path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe");
58+
return {
59+
command: cmdExe,
60+
args: ["/d", "/s", "/c", [command, ...args].map(escapeForCmdExe).join(" ")],
61+
windowsHide: true,
62+
windowsVerbatimArguments: true,
63+
};
64+
}
2965

3066
/**
3167
* Check if gog binary is available
@@ -40,7 +76,7 @@ function isGogAvailable(): boolean {
4076
async function startGmailWatch(
4177
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
4278
): Promise<boolean> {
43-
const args = ["gog", ...buildGogWatchStartArgs(cfg)];
79+
const args = [(gogBin ??= resolveExecutable("gog")), ...buildGogWatchStartArgs(cfg)];
4480
try {
4581
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
4682
if (result.code !== 0) {
@@ -63,10 +99,13 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
6399
const args = buildGogWatchServeArgs(cfg);
64100
log.info(`starting gog ${buildGogWatchServeLogArgs(cfg).join(" ")}`);
65101
let addressInUse = false;
102+
const invocation = resolveGogServeInvocation(args);
66103

67-
const child = spawn("gog", args, {
104+
const child = spawn(invocation.command, invocation.args, {
68105
stdio: ["ignore", "pipe", "pipe"],
69106
detached: false,
107+
windowsHide: invocation.windowsHide,
108+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
70109
});
71110

72111
child.stdout?.on("data", (data: Buffer) => {

src/infra/executable-path.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3-
import { describe, expect, it } from "vitest";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
44
import { withTempDir } from "../test-helpers/temp-dir.js";
55
import {
66
isExecutableFile,
7+
resolveExecutable,
78
resolveExecutableFromPathEnv,
89
resolveExecutablePath,
910
} from "./executable-path.js";
1011

12+
function restoreEnvValue(name: string, value: string | undefined): void {
13+
if (value === undefined) {
14+
delete process.env[name];
15+
} else {
16+
process.env[name] = value;
17+
}
18+
}
19+
1120
describe("executable path helpers", () => {
1221
it("detects executable files and rejects directories or non-executables", async () => {
1322
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
@@ -95,3 +104,95 @@ describe("executable path helpers", () => {
95104
).toBeUndefined();
96105
});
97106
});
107+
108+
describe("resolveExecutable", () => {
109+
afterEach(() => {
110+
vi.restoreAllMocks();
111+
});
112+
113+
it("returns cmd unchanged on non-Windows platforms", () => {
114+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux");
115+
expect(resolveExecutable("gcloud")).toBe("gcloud");
116+
platformSpy.mockRestore();
117+
});
118+
119+
it("returns cmd unchanged when it already carries a known PATHEXT extension on Windows", () => {
120+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
121+
expect(resolveExecutable("gcloud.cmd")).toBe("gcloud.cmd");
122+
expect(resolveExecutable("gcloud.exe")).toBe("gcloud.exe");
123+
expect(resolveExecutable("gcloud.bat")).toBe("gcloud.bat");
124+
expect(resolveExecutable("gcloud.com")).toBe("gcloud.com");
125+
platformSpy.mockRestore();
126+
});
127+
128+
it("resolves to the first .cmd result from PATH on Windows without executing where.exe", async () => {
129+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
130+
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
131+
const binDir = path.join(base, "bin");
132+
await fs.mkdir(binDir, { recursive: true });
133+
const cmdPath = path.join(binDir, "gcloud.cmd");
134+
const exePath = path.join(binDir, "gcloud.exe");
135+
await fs.writeFile(cmdPath, "@echo off\n", "utf8");
136+
await fs.writeFile(exePath, "exe\n", "utf8");
137+
138+
const originalPath = process.env.PATH;
139+
const originalPathext = process.env.PATHEXT;
140+
process.env.PATH = binDir;
141+
process.env.PATHEXT = ".EXE;.CMD;.BAT;.COM";
142+
try {
143+
expect(resolveExecutable("gcloud")).toBe(cmdPath);
144+
} finally {
145+
restoreEnvValue("PATH", originalPath);
146+
restoreEnvValue("PATHEXT", originalPathext);
147+
}
148+
});
149+
platformSpy.mockRestore();
150+
});
151+
152+
it("falls back to .exe when no .cmd match exists on Windows", async () => {
153+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
154+
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
155+
const binDir = path.join(base, "bin");
156+
await fs.mkdir(binDir, { recursive: true });
157+
const exePath = path.join(binDir, "tailscale.exe");
158+
await fs.writeFile(exePath, "exe\n", "utf8");
159+
160+
const originalPath = process.env.PATH;
161+
process.env.PATH = binDir;
162+
try {
163+
expect(resolveExecutable("tailscale")).toBe(exePath);
164+
} finally {
165+
restoreEnvValue("PATH", originalPath);
166+
}
167+
});
168+
platformSpy.mockRestore();
169+
});
170+
171+
it("falls back to first PATH result when no .cmd or .exe match exists on Windows", async () => {
172+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
173+
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
174+
const binDir = path.join(base, "bin");
175+
await fs.mkdir(binDir, { recursive: true });
176+
const ps1Path = path.join(binDir, "gcloud.ps1");
177+
await fs.writeFile(ps1Path, "Write-Output ok\n", "utf8");
178+
179+
const originalPath = process.env.PATH;
180+
const originalPathext = process.env.PATHEXT;
181+
process.env.PATH = binDir;
182+
process.env.PATHEXT = ".PS1";
183+
try {
184+
expect(resolveExecutable("gcloud")).toBe(ps1Path);
185+
} finally {
186+
restoreEnvValue("PATH", originalPath);
187+
restoreEnvValue("PATHEXT", originalPathext);
188+
}
189+
});
190+
platformSpy.mockRestore();
191+
});
192+
193+
it("returns original cmd when no PATH match exists on Windows", () => {
194+
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
195+
expect(resolveExecutable("gog")).toBe("gog");
196+
platformSpy.mockRestore();
197+
});
198+
});

src/infra/executable-path.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ export function resolveExecutableFromPathEnv(
9595
pathEnv: string,
9696
env?: NodeJS.ProcessEnv,
9797
): string | undefined {
98-
const entries = pathEnv.split(path.delimiter).filter(Boolean);
98+
const delimiter = process.platform === "win32" ? ";" : path.delimiter;
99+
const entries = pathEnv.split(delimiter).filter(Boolean);
99100
const extensions = resolveWindowsExecutableExtensions(executable, env);
100101
for (const entry of entries) {
101102
for (const ext of extensions) {
@@ -123,3 +124,50 @@ export function resolveExecutablePath(
123124
options?.env?.PATH ?? options?.env?.Path ?? process.env.PATH ?? process.env.Path ?? "";
124125
return resolveExecutableFromPathEnv(candidate, envPath, options?.env);
125126
}
127+
128+
const KNOWN_PATHEXT = new Set([".com", ".exe", ".bat", ".cmd"]);
129+
130+
/**
131+
* On Windows, resolves a bare command name to its full .cmd or .exe path by
132+
* probing PATH/PATHEXT without executing another resolver. On non-Windows this
133+
* is a no-op.
134+
*/
135+
export function resolveExecutable(cmd: string): string {
136+
if (process.platform !== "win32") {
137+
return cmd;
138+
}
139+
if (KNOWN_PATHEXT.has(normalizeLowercaseStringOrEmpty(path.extname(cmd)))) {
140+
return cmd;
141+
}
142+
143+
const envPath = process.env.PATH ?? process.env.Path ?? "";
144+
const entries = envPath.split(";").filter(Boolean);
145+
const extensions = resolveWindowsExecutableExtensions(cmd, process.env);
146+
const matches: string[] = [];
147+
for (const entry of entries) {
148+
for (const ext of extensions) {
149+
const candidate = path.join(entry, cmd + ext);
150+
if (isExecutableFile(candidate)) {
151+
matches.push(candidate);
152+
}
153+
}
154+
}
155+
156+
const cmdMatch = matches.find(
157+
(match) => normalizeLowercaseStringOrEmpty(path.extname(match)) === ".cmd",
158+
);
159+
if (cmdMatch) {
160+
return cmdMatch;
161+
}
162+
const exeMatch = matches.find(
163+
(match) => normalizeLowercaseStringOrEmpty(path.extname(match)) === ".exe",
164+
);
165+
if (exeMatch) {
166+
return exeMatch;
167+
}
168+
if (matches[0]) {
169+
return matches[0];
170+
}
171+
172+
return cmd;
173+
}

0 commit comments

Comments
 (0)