fix(windows): resolve Gmail helper PATHEXT shims

Resolve Gmail setup and watcher helper binaries through Windows PATH/PATHEXT before spawning, without executing where.exe during lookup. Cover gcloud, gog, and tailscale, including the documented CLI Gmail run path, and route long-lived gog .cmd/.bat shims through a pinned cmd.exe wrapper.

Co-authored-by: Iroh <175496729+Angfr95@users.noreply.github.com>
Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
This commit is contained in:
Iroh
2026-05-05 07:21:34 +02:00
committed by GitHub
parent 6c8974f3f5
commit f126f72d63
6 changed files with 242 additions and 9 deletions

View File

@@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- 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.
- 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.
- 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.

View File

@@ -1,4 +1,5 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import {
getRuntimeConfig,
@@ -9,8 +10,11 @@ import {
resolveGatewayPort,
validateConfigObjectWithPlugins,
} from "../config/config.js";
import { resolveExecutable } from "../infra/executable-path.js";
import { getWindowsInstallRoots } from "../infra/windows-install-roots.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime } from "../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { displayPath } from "../utils.js";
import {
ensureDependency,
@@ -75,6 +79,38 @@ export type GmailRunOptions = GmailCommonOptions & {
};
const DEFAULT_GMAIL_TOPIC_IAM_MEMBER = "serviceAccount:gmail-api-push@system.gserviceaccount.com";
let gogBin: string | undefined;
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/;
function escapeForCmdExe(arg: string): string {
if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) {
throw new Error(`Unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`);
}
if (!arg.includes(" ") && !arg.includes('"')) {
return arg;
}
return `"${arg.replace(/"/g, '""')}"`;
}
function resolveGogServeInvocation(args: string[]): {
args: string[];
command: string;
windowsHide?: true;
windowsVerbatimArguments?: true;
} {
const command = (gogBin ??= resolveExecutable("gog"));
const ext = normalizeLowercaseStringOrEmpty(path.extname(command));
if (process.platform !== "win32" || (ext !== ".cmd" && ext !== ".bat")) {
return { command, args, windowsHide: process.platform === "win32" ? true : undefined };
}
const cmdExe = path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe");
return {
command: cmdExe,
args: ["/d", "/s", "/c", [command, ...args].map(escapeForCmdExe).join(" ")],
windowsHide: true,
windowsVerbatimArguments: true,
};
}
export async function runGmailSetup(opts: GmailSetupOptions) {
await ensureDependency("gcloud", ["--cask", "gcloud-cli"]);
@@ -358,14 +394,19 @@ export async function runGmailService(opts: GmailRunOptions) {
function spawnGogServe(cfg: GmailHookRuntimeConfig) {
const args = buildGogWatchServeArgs(cfg);
defaultRuntime.log(`Starting gog ${buildGogWatchServeLogArgs(cfg).join(" ")}`);
return spawn("gog", args, { stdio: "inherit" });
const invocation = resolveGogServeInvocation(args);
return spawn(invocation.command, invocation.args, {
stdio: "inherit",
windowsHide: invocation.windowsHide,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
}
async function startGmailWatch(
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
fatal = false,
) {
const args = ["gog", ...buildGogWatchStartArgs(cfg)];
const args = [(gogBin ??= resolveExecutable("gog")), ...buildGogWatchStartArgs(cfg)];
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
if (result.code !== 0) {
const message = result.stderr || result.stdout || "gog watch start failed";

View File

@@ -2,11 +2,13 @@ import fs from "node:fs";
import path from "node:path";
import { hasBinary } from "../agents/skills.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveExecutable } from "../infra/executable-path.js";
import { runCommandWithTimeout, type SpawnResult } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { normalizeServePath } from "./gmail.js";
let cachedPythonPath: string | null | undefined;
let gcloudBin: string | undefined;
const MAX_OUTPUT_CHARS = 800;
export function resetGmailSetupUtilsCachesForTest(): void {
@@ -156,7 +158,7 @@ async function runGcloudCommand(
args: string[],
timeoutMs: number,
): Promise<Awaited<ReturnType<typeof runCommandWithTimeout>>> {
return await runCommandWithTimeout(["gcloud", ...args], {
return await runCommandWithTimeout([(gcloudBin ??= resolveExecutable("gcloud")), ...args], {
timeoutMs,
env: await gcloudEnv(),
});
@@ -269,9 +271,10 @@ export async function ensureTailscaleEndpoint(params: {
return "";
}
const tailscaleBin = resolveExecutable("tailscale");
const statusArgs = ["status", "--json"];
const statusCommand = formatCommand("tailscale", statusArgs);
const status = await runCommandWithTimeout(["tailscale", ...statusArgs], {
const status = await runCommandWithTimeout([tailscaleBin, ...statusArgs], {
timeoutMs: 30_000,
});
if (status.code !== 0) {
@@ -300,7 +303,7 @@ export async function ensureTailscaleEndpoint(params: {
const pathArg = normalizeServePath(params.path);
const funnelArgs = [params.mode, "--bg", "--set-path", pathArg, "--yes", target];
const funnelCommand = formatCommand("tailscale", funnelArgs);
const funnelResult = await runCommandWithTimeout(["tailscale", ...funnelArgs], {
const funnelResult = await runCommandWithTimeout([tailscaleBin, ...funnelArgs], {
timeoutMs: 30_000,
});
if (funnelResult.code !== 0) {

View File

@@ -6,10 +6,14 @@
*/
import { type ChildProcess, spawn } from "node:child_process";
import path from "node:path";
import { hasBinary } from "../agents/skills.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveExecutable } from "../infra/executable-path.js";
import { getWindowsInstallRoots } from "../infra/windows-install-roots.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js";
import { isAddressInUseError } from "./gmail-watcher-errors.js";
import {
@@ -26,6 +30,38 @@ let watcherProcess: ChildProcess | null = null;
let renewInterval: ReturnType<typeof setInterval> | null = null;
let shuttingDown = false;
let currentConfig: GmailHookRuntimeConfig | null = null;
let gogBin: string | undefined;
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/;
function escapeForCmdExe(arg: string): string {
if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) {
throw new Error(`Unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`);
}
if (!arg.includes(" ") && !arg.includes('"')) {
return arg;
}
return `"${arg.replace(/"/g, '""')}"`;
}
function resolveGogServeInvocation(args: string[]): {
args: string[];
command: string;
windowsHide?: true;
windowsVerbatimArguments?: true;
} {
const command = (gogBin ??= resolveExecutable("gog"));
const ext = normalizeLowercaseStringOrEmpty(path.extname(command));
if (process.platform !== "win32" || (ext !== ".cmd" && ext !== ".bat")) {
return { command, args, windowsHide: process.platform === "win32" ? true : undefined };
}
const cmdExe = path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe");
return {
command: cmdExe,
args: ["/d", "/s", "/c", [command, ...args].map(escapeForCmdExe).join(" ")],
windowsHide: true,
windowsVerbatimArguments: true,
};
}
/**
* Check if gog binary is available
@@ -40,7 +76,7 @@ function isGogAvailable(): boolean {
async function startGmailWatch(
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
): Promise<boolean> {
const args = ["gog", ...buildGogWatchStartArgs(cfg)];
const args = [(gogBin ??= resolveExecutable("gog")), ...buildGogWatchStartArgs(cfg)];
try {
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
if (result.code !== 0) {
@@ -63,10 +99,13 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
const args = buildGogWatchServeArgs(cfg);
log.info(`starting gog ${buildGogWatchServeLogArgs(cfg).join(" ")}`);
let addressInUse = false;
const invocation = resolveGogServeInvocation(args);
const child = spawn("gog", args, {
const child = spawn(invocation.command, invocation.args, {
stdio: ["ignore", "pipe", "pipe"],
detached: false,
windowsHide: invocation.windowsHide,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
child.stdout?.on("data", (data: Buffer) => {

View File

@@ -1,13 +1,22 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
isExecutableFile,
resolveExecutable,
resolveExecutableFromPathEnv,
resolveExecutablePath,
} from "./executable-path.js";
function restoreEnvValue(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
describe("executable path helpers", () => {
it("detects executable files and rejects directories or non-executables", async () => {
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
@@ -95,3 +104,95 @@ describe("executable path helpers", () => {
).toBeUndefined();
});
});
describe("resolveExecutable", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns cmd unchanged on non-Windows platforms", () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux");
expect(resolveExecutable("gcloud")).toBe("gcloud");
platformSpy.mockRestore();
});
it("returns cmd unchanged when it already carries a known PATHEXT extension on Windows", () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
expect(resolveExecutable("gcloud.cmd")).toBe("gcloud.cmd");
expect(resolveExecutable("gcloud.exe")).toBe("gcloud.exe");
expect(resolveExecutable("gcloud.bat")).toBe("gcloud.bat");
expect(resolveExecutable("gcloud.com")).toBe("gcloud.com");
platformSpy.mockRestore();
});
it("resolves to the first .cmd result from PATH on Windows without executing where.exe", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
const binDir = path.join(base, "bin");
await fs.mkdir(binDir, { recursive: true });
const cmdPath = path.join(binDir, "gcloud.cmd");
const exePath = path.join(binDir, "gcloud.exe");
await fs.writeFile(cmdPath, "@echo off\n", "utf8");
await fs.writeFile(exePath, "exe\n", "utf8");
const originalPath = process.env.PATH;
const originalPathext = process.env.PATHEXT;
process.env.PATH = binDir;
process.env.PATHEXT = ".EXE;.CMD;.BAT;.COM";
try {
expect(resolveExecutable("gcloud")).toBe(cmdPath);
} finally {
restoreEnvValue("PATH", originalPath);
restoreEnvValue("PATHEXT", originalPathext);
}
});
platformSpy.mockRestore();
});
it("falls back to .exe when no .cmd match exists on Windows", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
const binDir = path.join(base, "bin");
await fs.mkdir(binDir, { recursive: true });
const exePath = path.join(binDir, "tailscale.exe");
await fs.writeFile(exePath, "exe\n", "utf8");
const originalPath = process.env.PATH;
process.env.PATH = binDir;
try {
expect(resolveExecutable("tailscale")).toBe(exePath);
} finally {
restoreEnvValue("PATH", originalPath);
}
});
platformSpy.mockRestore();
});
it("falls back to first PATH result when no .cmd or .exe match exists on Windows", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
const binDir = path.join(base, "bin");
await fs.mkdir(binDir, { recursive: true });
const ps1Path = path.join(binDir, "gcloud.ps1");
await fs.writeFile(ps1Path, "Write-Output ok\n", "utf8");
const originalPath = process.env.PATH;
const originalPathext = process.env.PATHEXT;
process.env.PATH = binDir;
process.env.PATHEXT = ".PS1";
try {
expect(resolveExecutable("gcloud")).toBe(ps1Path);
} finally {
restoreEnvValue("PATH", originalPath);
restoreEnvValue("PATHEXT", originalPathext);
}
});
platformSpy.mockRestore();
});
it("returns original cmd when no PATH match exists on Windows", () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
expect(resolveExecutable("gog")).toBe("gog");
platformSpy.mockRestore();
});
});

View File

@@ -95,7 +95,8 @@ export function resolveExecutableFromPathEnv(
pathEnv: string,
env?: NodeJS.ProcessEnv,
): string | undefined {
const entries = pathEnv.split(path.delimiter).filter(Boolean);
const delimiter = process.platform === "win32" ? ";" : path.delimiter;
const entries = pathEnv.split(delimiter).filter(Boolean);
const extensions = resolveWindowsExecutableExtensions(executable, env);
for (const entry of entries) {
for (const ext of extensions) {
@@ -123,3 +124,50 @@ export function resolveExecutablePath(
options?.env?.PATH ?? options?.env?.Path ?? process.env.PATH ?? process.env.Path ?? "";
return resolveExecutableFromPathEnv(candidate, envPath, options?.env);
}
const KNOWN_PATHEXT = new Set([".com", ".exe", ".bat", ".cmd"]);
/**
* On Windows, resolves a bare command name to its full .cmd or .exe path by
* probing PATH/PATHEXT without executing another resolver. On non-Windows this
* is a no-op.
*/
export function resolveExecutable(cmd: string): string {
if (process.platform !== "win32") {
return cmd;
}
if (KNOWN_PATHEXT.has(normalizeLowercaseStringOrEmpty(path.extname(cmd)))) {
return cmd;
}
const envPath = process.env.PATH ?? process.env.Path ?? "";
const entries = envPath.split(";").filter(Boolean);
const extensions = resolveWindowsExecutableExtensions(cmd, process.env);
const matches: string[] = [];
for (const entry of entries) {
for (const ext of extensions) {
const candidate = path.join(entry, cmd + ext);
if (isExecutableFile(candidate)) {
matches.push(candidate);
}
}
}
const cmdMatch = matches.find(
(match) => normalizeLowercaseStringOrEmpty(path.extname(match)) === ".cmd",
);
if (cmdMatch) {
return cmdMatch;
}
const exeMatch = matches.find(
(match) => normalizeLowercaseStringOrEmpty(path.extname(match)) === ".exe",
);
if (exeMatch) {
return exeMatch;
}
if (matches[0]) {
return matches[0];
}
return cmd;
}