diff --git a/CHANGELOG.md b/CHANGELOG.md index 0467e3be29c..935865e0301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/hooks/gmail-ops.ts b/src/hooks/gmail-ops.ts index 673e1e945e7..73b84f3494c 100644 --- a/src/hooks/gmail-ops.ts +++ b/src/hooks/gmail-ops.ts @@ -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, 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"; diff --git a/src/hooks/gmail-setup-utils.ts b/src/hooks/gmail-setup-utils.ts index 4c74043c843..c3175cf5c25 100644 --- a/src/hooks/gmail-setup-utils.ts +++ b/src/hooks/gmail-setup-utils.ts @@ -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>> { - 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) { diff --git a/src/hooks/gmail-watcher.ts b/src/hooks/gmail-watcher.ts index 19f44891300..ebecbaa67ae 100644 --- a/src/hooks/gmail-watcher.ts +++ b/src/hooks/gmail-watcher.ts @@ -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 | 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, ): Promise { - 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) => { diff --git a/src/infra/executable-path.test.ts b/src/infra/executable-path.test.ts index b14e5fd5b16..e77f63b8bc3 100644 --- a/src/infra/executable-path.test.ts +++ b/src/infra/executable-path.test.ts @@ -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(); + }); +}); diff --git a/src/infra/executable-path.ts b/src/infra/executable-path.ts index 39c5910d038..8771d97c47d 100644 --- a/src/infra/executable-path.ts +++ b/src/infra/executable-path.ts @@ -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; +}