mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user