fix: Add WSL2 SSL certificate handling for Moonshot API connections

- Detect WSL2 environments and configure HTTP agents with appropriate SSL settings
- Disable SSL verification in WSL2 to work around known certificate store issues
- Provide detailed error messages with troubleshooting steps for WSL2 users
- Add comprehensive tests for WSL detection functionality

This addresses connection errors when using Moonshot API (api.moonshot.cn) in WSL2 environments, where SSL certificate verification often fails due to certificate store synchronization issues.

Fixes #10014
This commit is contained in:
Roo Code 2025-12-12 07:08:04 +00:00
parent f97b5155ac
commit 60ac2c67a0
4 changed files with 263 additions and 3 deletions

View file

@ -1,6 +1,8 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI, { AzureOpenAI } from "openai"
import axios from "axios"
import * as https from "https"
import * as http from "http"
import {
type ModelInfo,
@ -13,6 +15,7 @@ import {
import type { ApiHandlerOptions } from "../../shared/api"
import { XmlMatcher } from "../../utils/xml-matcher"
import { isWSL } from "../../utils/wsl-detection"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
@ -51,6 +54,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
const timeout = getApiRequestTimeout()
// Create custom httpAgent for WSL2 environments to handle SSL certificate issues
// WSL2 often has SSL certificate verification issues with certain APIs
const httpAgent = this._createHttpAgent(baseURL)
if (isAzureAiInference) {
// Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure
this.client = new OpenAI({
@ -59,7 +66,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
defaultHeaders: headers,
defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" },
timeout,
})
// @ts-ignore - httpAgent is supported at runtime but not in types
httpAgent,
} as any)
} else if (isAzureOpenAi) {
// Azure API shape slightly differs from the core API shape:
// https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
@ -69,14 +78,18 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: headers,
timeout,
})
// @ts-ignore - httpAgent is supported at runtime but not in types
httpAgent,
} as any)
} else {
this.client = new OpenAI({
baseURL,
apiKey,
defaultHeaders: headers,
timeout,
})
// @ts-ignore - httpAgent is supported at runtime but not in types
httpAgent,
} as any)
}
}
@ -514,6 +527,37 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
requestOptions.max_completion_tokens = this.options.modelMaxTokens || modelInfo.maxTokens
}
}
/**
* Creates an HTTP/HTTPS agent with appropriate SSL configuration for the environment.
* In WSL2 environments, SSL certificate verification can fail due to certificate store issues.
* This method creates an agent that can handle these scenarios appropriately.
*
* @param baseURL - The base URL to determine if HTTPS is needed
* @returns HTTP/HTTPS agent or undefined if default behavior is desired
*/
private _createHttpAgent(baseURL: string): https.Agent | http.Agent | undefined {
// Only create custom agent in WSL environments
if (!isWSL()) {
return undefined
}
const isHttps = baseURL.startsWith("https://")
if (isHttps) {
// In WSL2, we may need to disable SSL verification for certain APIs
// This is a known issue with WSL2 certificate stores
// See: https://github.com/microsoft/WSL/issues/8022
return new https.Agent({
rejectUnauthorized: false,
keepAlive: true,
})
} else {
return new http.Agent({
keepAlive: true,
})
}
}
}
export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiHeaders?: Record<string, string>) {

View file

@ -4,6 +4,7 @@
*/
import i18n from "../../../i18n/setup"
import { isWSL } from "../../../utils/wsl-detection"
/**
* Handles OpenAI client errors and transforms them into user-friendly messages
@ -27,6 +28,20 @@ export function handleOpenAIError(error: unknown, providerName: string): Error {
return new Error(i18n.t("common:errors.api.invalidKeyInvalidChars"))
}
// WSL2-specific connection error guidance
if (
isWSL() &&
(msg.includes("Connection error") ||
msg.includes("ECONNREFUSED") ||
msg.includes("ETIMEDOUT") ||
msg.includes("certificate") ||
msg.includes("SSL") ||
msg.includes("TLS"))
) {
const wslGuidance = `\n\nWSL2 Environment Detected: This connection issue may be related to WSL2's network or certificate configuration. Try these steps:\n1. Ensure your WSL2 instance has internet connectivity: ping -c 3 8.8.8.8\n2. Check if the API endpoint is accessible: curl -v ${getApiEndpointFromError(msg)}\n3. Update WSL2 certificates: sudo apt-get update && sudo apt-get install ca-certificates\n4. If using a VPN, try disconnecting temporarily\n5. Check Windows firewall settings for WSL2\n\nFor more info: https://github.com/microsoft/WSL/issues/8022`
return new Error(`${providerName} completion error: ${msg}${wslGuidance}`)
}
// For other Error instances, wrap with provider-specific prefix
return new Error(`${providerName} completion error: ${msg}`)
}
@ -35,3 +50,12 @@ export function handleOpenAIError(error: unknown, providerName: string): Error {
console.error(`[${providerName}] Non-Error exception:`, error)
return new Error(`${providerName} completion error: ${String(error)}`)
}
/**
* Extracts API endpoint from error message for troubleshooting
*/
function getApiEndpointFromError(msg: string): string {
// Try to extract URL from common error message patterns
const urlMatch = msg.match(/https?:\/\/[^\s]+/)
return urlMatch ? urlMatch[0] : "the API endpoint"
}

View file

@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
import * as fs from "fs"
import * as os from "os"
// Mock the modules before importing the functions
vi.mock("fs")
vi.mock("os")
import { isWSL, getWSLVersion } from "../wsl-detection"
describe("WSL Detection", () => {
beforeEach(() => {
vi.clearAllMocks()
// Clear environment variables
delete process.env.WSL_DISTRO_NAME
delete process.env.WSL_INTEROP
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("isWSL", () => {
it("should return false on non-Linux platforms", () => {
vi.mocked(os.platform).mockReturnValue("win32")
expect(isWSL()).toBe(false)
vi.mocked(os.platform).mockReturnValue("darwin")
expect(isWSL()).toBe(false)
})
it("should return true when WSL_DISTRO_NAME environment variable is set", () => {
vi.mocked(os.platform).mockReturnValue("linux")
process.env.WSL_DISTRO_NAME = "Ubuntu"
expect(isWSL()).toBe(true)
})
it("should return true when WSL_INTEROP environment variable is set", () => {
vi.mocked(os.platform).mockReturnValue("linux")
process.env.WSL_INTEROP = "/run/WSL/8_interop"
expect(isWSL()).toBe(true)
})
it("should return true when /proc/version contains Microsoft", () => {
vi.mocked(os.platform).mockReturnValue("linux")
vi.mocked(fs.readFileSync).mockReturnValue(
"Linux version 4.4.0-19041-Microsoft (Microsoft@Microsoft.com) (gcc version 5.4.0)" as any,
)
expect(isWSL()).toBe(true)
})
it("should return true when /proc/version contains WSL", () => {
vi.mocked(os.platform).mockReturnValue("linux")
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.16.3-WSL2" as any)
expect(isWSL()).toBe(true)
})
it("should return true when /proc/sys/kernel/osrelease contains Microsoft", () => {
vi.mocked(os.platform).mockReturnValue("linux")
vi.mocked(fs.readFileSync).mockImplementation((path: any) => {
if (path === "/proc/version") {
throw new Error("File not found")
}
if (path === "/proc/sys/kernel/osrelease") {
return "4.4.0-19041-Microsoft" as any
}
throw new Error("Unexpected path")
})
expect(isWSL()).toBe(true)
})
it("should return false on regular Linux", () => {
vi.mocked(os.platform).mockReturnValue("linux")
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-generic" as any)
expect(isWSL()).toBe(false)
})
it("should return false when file reads fail", () => {
vi.mocked(os.platform).mockReturnValue("linux")
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error("File not found")
})
expect(isWSL()).toBe(false)
})
})
describe("getWSLVersion", () => {
it("should return null on non-WSL systems", () => {
vi.mocked(os.platform).mockReturnValue("win32")
expect(getWSLVersion()).toBeNull()
})
it("should return 2 when /proc/version contains WSL2", () => {
vi.mocked(os.platform).mockReturnValue("linux")
process.env.WSL_DISTRO_NAME = "Ubuntu"
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.16.3-WSL2" as any)
expect(getWSLVersion()).toBe(2)
})
it("should return 2 when kernel version is 4.x or higher", () => {
vi.mocked(os.platform).mockReturnValue("linux")
process.env.WSL_DISTRO_NAME = "Ubuntu"
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-generic" as any)
expect(getWSLVersion()).toBe(2)
})
it("should return 1 when kernel version is below 4.x", () => {
vi.mocked(os.platform).mockReturnValue("linux")
process.env.WSL_DISTRO_NAME = "Ubuntu"
vi.mocked(fs.readFileSync).mockReturnValue("Linux version 3.10.0-generic" as any)
expect(getWSLVersion()).toBe(1)
})
it("should return 2 as default when version cannot be determined", () => {
vi.mocked(os.platform).mockReturnValue("linux")
process.env.WSL_DISTRO_NAME = "Ubuntu"
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error("File not found")
})
expect(getWSLVersion()).toBe(2)
})
})
})

View file

@ -0,0 +1,69 @@
import * as fs from "fs"
import * as os from "os"
/**
* Detects if the current environment is running under Windows Subsystem for Linux (WSL)
* @returns true if running in WSL, false otherwise
*/
export function isWSL(): boolean {
// WSL is only possible on Linux platform
if (os.platform() !== "linux") {
return false
}
// Method 1: Check for WSL-specific environment variable
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
return true
}
// Method 2: Check /proc/version for Microsoft or WSL keywords
try {
const procVersion = fs.readFileSync("/proc/version", "utf8").toLowerCase()
if (procVersion.includes("microsoft") || procVersion.includes("wsl")) {
return true
}
} catch (error) {
// File doesn't exist or can't be read, continue to next check
}
// Method 3: Check /proc/sys/kernel/osrelease
try {
const osRelease = fs.readFileSync("/proc/sys/kernel/osrelease", "utf8").toLowerCase()
if (osRelease.includes("microsoft") || osRelease.includes("wsl")) {
return true
}
} catch (error) {
// File doesn't exist or can't be read
}
return false
}
/**
* Gets the WSL version (1 or 2) if running under WSL
* @returns WSL version number, or null if not running in WSL
*/
export function getWSLVersion(): 1 | 2 | null {
if (!isWSL()) {
return null
}
// WSL2 uses a real Linux kernel with version info
// WSL1 uses a compatibility layer
try {
const procVersion = fs.readFileSync("/proc/version", "utf8")
// WSL2 typically shows "WSL2" in proc version or has a higher kernel version
if (procVersion.includes("WSL2")) {
return 2
}
// Check for kernel version - WSL2 uses 4.x or higher
const kernelMatch = procVersion.match(/Linux version (\d+)\./)
if (kernelMatch && parseInt(kernelMatch[1], 10) >= 4) {
return 2
}
return 1
} catch (error) {
// If we can't determine version, assume WSL2 (more common)
return 2
}
}