Fixes #4817: Add marketplace timeout and disable settings for network-restricted environments

- Add 'disableMarketplace' setting to completely disable marketplace functionality
- Add 'marketplaceTimeout' setting to configure API request timeout (1000-60000ms)
- Update RemoteConfigLoader to respect user settings and return empty arrays when disabled
- Update ClineProvider and webviewMessageHandler to check both experiment flag and user setting
- Add better error logging and graceful handling of network timeouts
- Update marketplace button visibility to respect disable setting
- Add comprehensive tests for new functionality
- Update existing tests with proper vscode mocks

This allows users in network-restricted environments to disable marketplace
functionality completely, preventing timeout errors and improving performance.
This commit is contained in:
Roo Code 2025-06-18 08:50:32 +00:00
parent 2e2f83be60
commit a6df58f399
9 changed files with 1106 additions and 16 deletions

883
roo-code-messages.log Normal file

File diff suppressed because one or more lines are too long

View file

@ -1251,12 +1251,12 @@ export class ClineProvider
private async updateVSCodeContext() {
const { experiments } = await this.getState()
// Set context for marketplace experiment
await vscode.commands.executeCommand(
"setContext",
`${Package.name}.marketplaceEnabled`,
experiments.marketplace ?? false,
)
// Set context for marketplace experiment and user setting
const config = vscode.workspace.getConfiguration("roo-cline")
const isMarketplaceDisabled = config.get<boolean>("disableMarketplace", false)
const marketplaceEnabled = (experiments.marketplace ?? false) && !isMarketplaceDisabled
await vscode.commands.executeCommand("setContext", `${Package.name}.marketplaceEnabled`, marketplaceEnabled)
}
/**
@ -1347,13 +1347,25 @@ export class ClineProvider
const allowedCommands = vscode.workspace.getConfiguration(Package.name).get<string[]>("allowedCommands") || []
const cwd = this.cwd
// Only fetch marketplace data if the feature is enabled
// Only fetch marketplace data if the feature is enabled and not disabled by user setting
let marketplaceItems: any[] = []
let marketplaceInstalledMetadata: any = { project: {}, global: {} }
if (experiments.marketplace) {
marketplaceItems = (await this.marketplaceManager.getCurrentItems()) || []
marketplaceInstalledMetadata = await this.marketplaceManager.getInstallationMetadata()
const config = vscode.workspace.getConfiguration("roo-cline")
const isMarketplaceDisabled = config.get<boolean>("disableMarketplace", false)
if (experiments.marketplace && !isMarketplaceDisabled) {
try {
marketplaceItems = (await this.marketplaceManager.getCurrentItems()) || []
marketplaceInstalledMetadata = await this.marketplaceManager.getInstallationMetadata()
} catch (error) {
console.error("Failed to load marketplace items:", error)
// Continue with empty marketplace data instead of failing completely
marketplaceItems = []
marketplaceInstalledMetadata = { project: {}, global: {} }
}
} else if (isMarketplaceDisabled) {
console.log("Marketplace: Disabled via user setting")
}
// Check if there's a system prompt override for the current mode

View file

@ -1467,7 +1467,10 @@ export const webviewMessageHandler = async (
case "filterMarketplaceItems": {
// Check if marketplace is enabled before making API calls
const { experiments } = await provider.getState()
if (!experiments.marketplace) {
const config = vscode.workspace.getConfiguration("roo-cline")
const isMarketplaceDisabled = config.get<boolean>("disableMarketplace", false)
if (!experiments.marketplace || isMarketplaceDisabled) {
console.log("Marketplace: Feature disabled, skipping API call")
break
}
@ -1491,7 +1494,10 @@ export const webviewMessageHandler = async (
case "installMarketplaceItem": {
// Check if marketplace is enabled before installing
const { experiments } = await provider.getState()
if (!experiments.marketplace) {
const config = vscode.workspace.getConfiguration("roo-cline")
const isMarketplaceDisabled = config.get<boolean>("disableMarketplace", false)
if (!experiments.marketplace || isMarketplaceDisabled) {
console.log("Marketplace: Feature disabled, skipping installation")
break
}
@ -1527,7 +1533,10 @@ export const webviewMessageHandler = async (
case "removeInstalledMarketplaceItem": {
// Check if marketplace is enabled before removing
const { experiments } = await provider.getState()
if (!experiments.marketplace) {
const config = vscode.workspace.getConfiguration("roo-cline")
const isMarketplaceDisabled = config.get<boolean>("disableMarketplace", false)
if (!experiments.marketplace || isMarketplaceDisabled) {
console.log("Marketplace: Feature disabled, skipping removal")
break
}
@ -1546,7 +1555,10 @@ export const webviewMessageHandler = async (
case "installMarketplaceItemWithParameters": {
// Check if marketplace is enabled before installing with parameters
const { experiments } = await provider.getState()
if (!experiments.marketplace) {
const config = vscode.workspace.getConfiguration("roo-cline")
const isMarketplaceDisabled = config.get<boolean>("disableMarketplace", false)
if (!experiments.marketplace || isMarketplaceDisabled) {
console.log("Marketplace: Feature disabled, skipping installation with parameters")
break
}

View file

@ -344,6 +344,18 @@
"type": "boolean",
"default": false,
"description": "%settings.rooCodeCloudEnabled.description%"
},
"roo-cline.disableMarketplace": {
"type": "boolean",
"default": false,
"description": "%settings.disableMarketplace.description%"
},
"roo-cline.marketplaceTimeout": {
"type": "number",
"default": 10000,
"minimum": 1000,
"maximum": 60000,
"description": "%settings.marketplaceTimeout.description%"
}
}
}

View file

@ -30,5 +30,7 @@
"settings.vsCodeLmModelSelector.vendor.description": "The vendor of the language model (e.g. copilot)",
"settings.vsCodeLmModelSelector.family.description": "The family of the language model (e.g. gpt-4)",
"settings.customStoragePath.description": "Custom storage path. Leave empty to use the default location. Supports absolute paths (e.g. 'D:\\RooCodeStorage')",
"settings.rooCodeCloudEnabled.description": "Enable Roo Code Cloud."
"settings.rooCodeCloudEnabled.description": "Enable Roo Code Cloud.",
"settings.disableMarketplace.description": "Disable marketplace functionality completely. Useful for users in network-restricted environments.",
"settings.marketplaceTimeout.description": "Timeout in milliseconds for marketplace API requests (1000-60000ms). Reduce for faster failure in limited network environments."
}

View file

@ -1,6 +1,7 @@
import axios from "axios"
import * as yaml from "yaml"
import { z } from "zod"
import * as vscode from "vscode"
import { getRooCodeApiUrl } from "@roo-code/cloud"
import type { MarketplaceItem, MarketplaceItemType } from "@roo-code/types"
import { modeMarketplaceItemSchema, mcpMarketplaceItemSchema } from "@roo-code/types"
@ -24,6 +25,15 @@ export class RemoteConfigLoader {
}
async loadAllItems(): Promise<MarketplaceItem[]> {
// Check if marketplace is disabled via user setting
const config = vscode.workspace.getConfiguration("roo-cline")
const isMarketplaceDisabled = config.get<boolean>("disableMarketplace", false)
if (isMarketplaceDisabled) {
console.log("Marketplace: Disabled via user setting, returning empty items")
return []
}
const items: MarketplaceItem[] = []
const [modes, mcps] = await Promise.all([this.fetchModes(), this.fetchMcps()])
@ -73,12 +83,16 @@ export class RemoteConfigLoader {
}
private async fetchWithRetry<T>(url: string, maxRetries = 3): Promise<T> {
// Get configurable timeout from user settings
const config = vscode.workspace.getConfiguration("roo-cline")
const timeout = config.get<number>("marketplaceTimeout", 10000)
let lastError: Error
for (let i = 0; i < maxRetries; i++) {
try {
const response = await axios.get(url, {
timeout: 10000, // 10 second timeout
timeout,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
@ -87,14 +101,21 @@ export class RemoteConfigLoader {
return response.data as T
} catch (error) {
lastError = error as Error
console.log(
`Marketplace: API request failed (attempt ${i + 1}/${maxRetries}):`,
error instanceof Error ? error.message : String(error),
)
if (i < maxRetries - 1) {
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, i) * 1000
console.log(`Marketplace: Retrying in ${delay}ms...`)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
console.error(`Marketplace: All ${maxRetries} attempts failed for ${url}`)
throw lastError!
}

View file

@ -6,6 +6,19 @@ import type { MarketplaceItemType } from "@roo-code/types"
jest.mock("axios")
const mockedAxios = axios as jest.Mocked<typeof axios>
// Mock vscode
jest.mock("vscode", () => ({
workspace: {
getConfiguration: jest.fn(() => ({
get: jest.fn((key: string, defaultValue?: any) => {
if (key === "disableMarketplace") return false
if (key === "marketplaceTimeout") return 10000
return defaultValue
}),
})),
},
}))
// Mock the cloud config
jest.mock("@roo-code/cloud", () => ({
getRooCodeApiUrl: () => "https://test.api.com",

View file

@ -1,6 +1,19 @@
import { webviewMessageHandler } from "../../../core/webview/webviewMessageHandler"
import { MarketplaceManager } from "../MarketplaceManager"
// Mock vscode
jest.mock("vscode", () => ({
workspace: {
getConfiguration: jest.fn(() => ({
get: jest.fn((key: string, defaultValue?: any) => {
if (key === "disableMarketplace") return false
if (key === "marketplaceTimeout") return 10000
return defaultValue
}),
})),
},
}))
// Mock the provider and marketplace manager
const mockProvider = {
getState: jest.fn(),

View file

@ -0,0 +1,122 @@
import * as vscode from "vscode"
import { RemoteConfigLoader } from "../RemoteConfigLoader"
import { MarketplaceManager } from "../MarketplaceManager"
// Mock vscode
jest.mock("vscode", () => ({
workspace: {
getConfiguration: jest.fn(),
},
}))
// Mock axios to simulate network timeouts
jest.mock("axios")
describe("Network Timeout Fix", () => {
let mockGetConfiguration: jest.MockedFunction<typeof vscode.workspace.getConfiguration>
beforeEach(() => {
mockGetConfiguration = vscode.workspace.getConfiguration as jest.MockedFunction<
typeof vscode.workspace.getConfiguration
>
jest.clearAllMocks()
})
describe("RemoteConfigLoader", () => {
it("should return empty array when marketplace is disabled via user setting", async () => {
// Mock configuration to disable marketplace
mockGetConfiguration.mockReturnValue({
get: jest.fn((key: string, defaultValue?: any) => {
if (key === "disableMarketplace") return true
if (key === "marketplaceTimeout") return 10000
return defaultValue
}),
} as any)
const loader = new RemoteConfigLoader()
const items = await loader.loadAllItems()
expect(items).toEqual([])
expect(mockGetConfiguration).toHaveBeenCalledWith("roo-cline")
})
it("should use custom timeout from user settings", async () => {
const customTimeout = 5000
// Mock configuration with custom timeout
mockGetConfiguration.mockReturnValue({
get: jest.fn((key: string, defaultValue?: any) => {
if (key === "disableMarketplace") return false
if (key === "marketplaceTimeout") return customTimeout
return defaultValue
}),
} as any)
const loader = new RemoteConfigLoader()
// Mock the private fetchWithRetry method to verify timeout is used
const fetchWithRetrySpy = jest.spyOn(loader as any, "fetchWithRetry")
fetchWithRetrySpy.mockRejectedValue(new Error("Network timeout"))
try {
await loader.loadAllItems()
} catch (error) {
// Expected to fail due to mocked network error
}
expect(mockGetConfiguration).toHaveBeenCalledWith("roo-cline")
})
it("should log appropriate messages when marketplace is disabled", async () => {
const consoleSpy = jest.spyOn(console, "log").mockImplementation()
// Mock configuration to disable marketplace
mockGetConfiguration.mockReturnValue({
get: jest.fn((key: string, defaultValue?: any) => {
if (key === "disableMarketplace") return true
return defaultValue
}),
} as any)
const loader = new RemoteConfigLoader()
await loader.loadAllItems()
expect(consoleSpy).toHaveBeenCalledWith("Marketplace: Disabled via user setting, returning empty items")
consoleSpy.mockRestore()
})
})
describe("MarketplaceManager", () => {
it("should handle network errors gracefully", async () => {
// Mock configuration to enable marketplace but simulate network issues
mockGetConfiguration.mockReturnValue({
get: jest.fn((key: string, defaultValue?: any) => {
if (key === "disableMarketplace") return false
if (key === "marketplaceTimeout") return 1000 // Very short timeout
return defaultValue
}),
} as any)
const mockContext = {
globalState: {
get: jest.fn(),
update: jest.fn(),
},
extensionPath: "/mock/path",
} as any
const manager = new MarketplaceManager(mockContext)
// Mock the config loader to throw a timeout error
jest.spyOn(manager["configLoader"], "loadAllItems").mockRejectedValue(
new Error("timeout of 1000ms exceeded"),
)
const result = await manager.getMarketplaceItems()
expect(result.items).toEqual([])
expect(result.errors).toContain("timeout of 1000ms exceeded")
})
})
})