fix(mcp): honor Windows PATH key casing in doctor (#121595)

This commit is contained in:
Peter Steinberger
2026-08-10 09:55:41 -07:00
committed by GitHub
parent 01884775aa
commit 63776cf5a0
7 changed files with 126 additions and 17 deletions
+1 -1
View File
@@ -1961,7 +1961,7 @@
"test:unit:fast:audit": "node --import tsx scripts/test-unit-fast-audit.mts",
"test:voicecall:closedloop": "node --import tsx scripts/test-voicecall-closedloop.mts",
"test:watch": "node --import tsx scripts/test-projects.mts --watch",
"test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/process-env.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts src/cli/mcp-cli.path-case.windows.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:schtasks:integration": "node --import tsx scripts/run-with-env.mts CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
+6 -2
View File
@@ -73,6 +73,8 @@ const WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE =
/^src\/media-understanding\/attachments\.(?:cache(?:\.test)?|file-url\.windows\.test|normalize(?:\.test)?)\.ts$/;
const WINDOWS_HOME_DISPLAY_SCOPE_RE =
/^(?:src\/(?:utils(?:\.test)?|infra\/(?:home-display|path-guards)|commands\/agents\.commands\.list(?:\.test)?|cli\/daemon-cli\/status\.print(?:\.test)?|agents\/(?:sandbox\/fs-paths|sessions\/tools\/render-utils)(?:\.test)?)|packages\/terminal-core\/src\/display-string(?:\.test)?)\.ts$/;
const WINDOWS_MCP_ENV_SCOPE_RE =
/^src\/(?:cli\/mcp-cli(?:\.path-case\.windows)?\.test|cli\/mcp-cli|infra\/process-env(?:\.test)?)\.ts$/;
const CONTROL_UI_I18N_SCOPE_RE =
/^(ui\/src\/i18n\/|ui\/config\/control-ui-locales\.ts$|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:catalog|config|raw-copy|sync-plan)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
const CONTROL_UI_RAW_COPY_SOURCE_RE = /^ui\/src\/(?:app|components|lib|pages)\/.*\.tsx?$/;
@@ -183,7 +185,8 @@ export function detectChangedScope(changedPaths) {
WINDOWS_DAEMON_SCOPE_RE.test(path) ||
WINDOWS_USAGE_TEMPLATE_SCOPE_RE.test(path) ||
WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE.test(path) ||
WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path)) &&
WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path) ||
WINDOWS_MCP_ENV_SCOPE_RE.test(path)) &&
(!facts.isTestOnly ||
WINDOWS_TEST_SCOPE_RE.test(path) ||
WINDOWS_FILE_URL_SCOPE_RE.test(path) ||
@@ -191,7 +194,8 @@ export function detectChangedScope(changedPaths) {
WINDOWS_DAEMON_SCOPE_RE.test(path) ||
WINDOWS_USAGE_TEMPLATE_SCOPE_RE.test(path) ||
WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE.test(path) ||
WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path))
WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path) ||
WINDOWS_MCP_ENV_SCOPE_RE.test(path))
) {
runWindows = true;
}
+52
View File
@@ -0,0 +1,52 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../config/home-env.test-harness.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import {
cleanupMcpCliTestState,
createWorkspace,
lastLogLine,
mockLog,
resetMcpCliTestState,
runMcpCommand,
} from "./mcp-cli.test-harness.js";
describe.runIf(process.platform === "win32")("MCP doctor Windows PATH casing", () => {
beforeEach(() => {
resetMcpCliTestState();
});
afterEach(async () => {
await cleanupMcpCliTestState();
});
it("finds a real stdio command through an arbitrarily cased configured PATH", async () => {
await withTempHome("openclaw-cli-mcp-path-case-home-", async () => {
try {
const workspaceDir = await createWorkspace();
const binDir = path.join(workspaceDir, "Mixed Case Bin");
await fs.mkdir(binDir, { recursive: true });
await fs.writeFile(path.join(binDir, "DOCS-MCP.CMD"), "@echo off\r\nexit /b 0\r\n", "utf8");
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
await runMcpCommand([
"mcp",
"set",
"docs",
JSON.stringify({ command: "docs-mcp", env: { pAtH: binDir } }),
]);
mockLog.mockClear();
await runMcpCommand(["mcp", "doctor", "--json"]);
expect(JSON.parse(lastLogLine())).toMatchObject({
ok: true,
servers: [{ name: "docs", ok: true, issues: [] }],
});
} finally {
closeOpenClawStateDatabaseForTest();
}
});
});
});
+41
View File
@@ -560,6 +560,47 @@ describe("mcp cli", () => {
});
});
it.runIf(process.platform !== "win32")(
"does not treat Path as PATH when checking MCP stdio commands",
async () => {
await withTempHome("openclaw-cli-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
const binDir = path.join(workspaceDir, "bin");
const commandPath = path.join(binDir, "mis-cased-path-mcp");
await fs.mkdir(binDir, { recursive: true });
await fs.writeFile(commandPath, "#!/bin/sh\nexit 0\n", "utf-8");
await fs.chmod(commandPath, 0o755);
vi.spyOn(process, "cwd").mockReturnValue(workspaceDir);
await runMcpCommand([
"mcp",
"set",
"docs",
JSON.stringify({ command: "mis-cased-path-mcp", env: { Path: binDir } }),
]);
mockLog.mockClear();
await expect(runMcpCommand(["mcp", "doctor", "--json"])).rejects.toThrow("__exit__:1");
expect(JSON.parse(lastLogLine())).toMatchObject({
ok: false,
servers: [
{
name: "docs",
ok: false,
issues: [
{
level: "error",
message: "stdio command not found or not executable: mis-cased-path-mcp",
},
],
},
],
});
});
},
);
it("resolves relative configured PATH entries from the MCP stdio cwd", async () => {
await withTempHome("openclaw-cli-mcp-home-", async () => {
const workspaceDir = await createWorkspace();
+4 -14
View File
@@ -39,6 +39,7 @@ import {
startOAuthLoopbackCallbackServer,
type OAuthLoopbackCallbackServer,
} from "../infra/oauth-loopback-callback.js";
import { resolveEnvironmentValue } from "../infra/process-env.js";
import { serveOpenClawChannelMcp } from "../mcp/channel-server.js";
import { defaultRuntime } from "../runtime.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
@@ -267,19 +268,6 @@ function executableCandidates(command: string): string[] {
return [command, ...extensions.map((extension) => `${command}${extension.toLowerCase()}`)];
}
function resolveEffectivePath(env: Record<string, string> | undefined): string {
if (!env) {
return process.env.PATH ?? "";
}
if (typeof env.PATH === "string") {
return env.PATH;
}
if (process.platform === "win32" && typeof env.Path === "string") {
return env.Path;
}
return process.env.PATH ?? "";
}
async function commandExists(
command: string,
cwd: unknown,
@@ -290,7 +278,9 @@ async function commandExists(
if (hasPathSeparator) {
return isExecutable(resolveConfiguredPath(command, cwd));
}
const pathEntries = resolveEffectivePath(env)
const configuredPath =
process.platform === "win32" ? resolveEnvironmentValue(env, "PATH") : env?.PATH;
const pathEntries = (configuredPath ?? process.env.PATH ?? "")
.split(path.delimiter)
.map((entry) => entry.trim() || ".");
for (const pathEntry of pathEntries) {
@@ -201,6 +201,21 @@ describe("detectChangedScope Windows routing", () => {
}
});
it("routes MCP environment resolution and native doctor coverage to Windows", () => {
for (const mcpPath of [
"src/cli/mcp-cli.ts",
"src/cli/mcp-cli.test.ts",
"src/cli/mcp-cli.path-case.windows.test.ts",
"src/infra/process-env.ts",
"src/infra/process-env.test.ts",
]) {
expect(detectChangedScope([mcpPath]), mcpPath).toMatchObject({
runNode: true,
runWindows: true,
});
}
});
it("routes SecretRef path-security changes and native fixtures to Windows", () => {
for (const secretRefPath of [
"src/commands/doctor-gateway-auth-token.ts",
+7
View File
@@ -355,4 +355,11 @@ describe("package scripts", () => {
expect(script).toContain("src/agents/sandbox/fs-paths.test.ts");
expect(script).toContain("src/agents/sessions/tools/render-utils.test.ts");
});
it("runs MCP environment and native doctor coverage in Windows CI", () => {
const script = readPackageJson().scripts["test:windows:ci"];
expect(script).toContain("src/infra/process-env.test.ts");
expect(script).toContain("src/cli/mcp-cli.path-case.windows.test.ts");
});
});