From f9714fec084285cd91bfb6421e4070f26a153622 Mon Sep 17 00:00:00 2001 From: shanevcantwell <153727980+shanevcantwell@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:43:22 -0700 Subject: [PATCH] fix: pass pre-read content to bypass fs.existsSync for WSL agent configs When VS Code runs on Windows host with a WSL remote workspace, selecting an agent config from .continue/agents/ fails because localPathOrUriToPath cannot resolve the vscode-remote:// URI to a local path, causing fs.existsSync to return false and falling through to the deprecated JSON config loader. The fix passes pre-read content (already available in overrideAssistantFile.content) through the PackageIdentifier, and checks for it before the fs.existsSync gate. This reuses the content bypass added to RegistryClient in PR #9739. Fixes #10450 Co-Authored-By: Claude Opus 4.6 --- core/config/profile/LocalProfileLoader.ts | 3 + .../profile/LocalProfileLoader.vitest.ts | 70 +++++++ core/config/profile/doLoadConfig.ts | 10 +- core/config/profile/doLoadConfig.vitest.ts | 185 ++++++++++++++++++ 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 core/config/profile/LocalProfileLoader.vitest.ts create mode 100644 core/config/profile/doLoadConfig.vitest.ts diff --git a/core/config/profile/LocalProfileLoader.ts b/core/config/profile/LocalProfileLoader.ts index 9c57ae94c9c..1ddde69ad8c 100644 --- a/core/config/profile/LocalProfileLoader.ts +++ b/core/config/profile/LocalProfileLoader.ts @@ -64,6 +64,9 @@ export default class LocalProfileLoader implements IProfileLoader { packageIdentifier: { uriType: "file", fileUri: this.overrideAssistantFile?.path ?? getPrimaryConfigFilePath(), + // Pass pre-read content to bypass fs.readFileSync, which fails for + // vscode-remote:// URIs when Windows host connects to WSL (#10450) + content: this.overrideAssistantFile?.content, }, }); diff --git a/core/config/profile/LocalProfileLoader.vitest.ts b/core/config/profile/LocalProfileLoader.vitest.ts new file mode 100644 index 00000000000..0253936aef3 --- /dev/null +++ b/core/config/profile/LocalProfileLoader.vitest.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ControlPlaneClient } from "../../control-plane/client.js"; +import { LLMLogger } from "../../llm/logger.js"; +import { testIde } from "../../test/fixtures.js"; +import LocalProfileLoader from "./LocalProfileLoader.js"; + +// Mock doLoadConfig to capture the arguments it receives +const mockDoLoadConfig = vi.fn().mockResolvedValue({ + config: undefined, + errors: [], + configLoadInterrupted: false, +}); + +vi.mock("./doLoadConfig.js", () => ({ + default: (...args: any[]) => mockDoLoadConfig(...args), +})); + +describe("LocalProfileLoader", () => { + const controlPlaneClient = new ControlPlaneClient( + Promise.resolve(undefined), + testIde, + ); + const llmLogger = new LLMLogger(); + + it("should pass pre-read content in packageIdentifier for override files", async () => { + const overrideFile = { + path: "vscode-remote://wsl+Ubuntu/home/user/.continue/agents/test.yaml", + content: "name: Test\nversion: 1.0.0\nschema: v1\n", + }; + + const loader = new LocalProfileLoader( + testIde, + controlPlaneClient, + llmLogger, + overrideFile, + ); + + await loader.doLoadConfig(); + + expect(mockDoLoadConfig).toHaveBeenCalledWith( + expect.objectContaining({ + packageIdentifier: expect.objectContaining({ + uriType: "file", + fileUri: overrideFile.path, + content: overrideFile.content, + }), + }), + ); + }); + + it("should not include content in packageIdentifier when no override file", async () => { + const loader = new LocalProfileLoader( + testIde, + controlPlaneClient, + llmLogger, + ); + + await loader.doLoadConfig(); + + expect(mockDoLoadConfig).toHaveBeenCalledWith( + expect.objectContaining({ + packageIdentifier: expect.objectContaining({ + uriType: "file", + content: undefined, + }), + }), + ); + }); +}); diff --git a/core/config/profile/doLoadConfig.ts b/core/config/profile/doLoadConfig.ts index 99094fd9057..bcfec2ff8ab 100644 --- a/core/config/profile/doLoadConfig.ts +++ b/core/config/profile/doLoadConfig.ts @@ -114,7 +114,15 @@ export default async function doLoadConfig(options: { let errors: ConfigValidationError[] | undefined; let configLoadInterrupted = false; - if (overrideConfigYaml || fs.existsSync(configYamlPath)) { + const hasPreReadContent = + packageIdentifier.uriType === "file" && + packageIdentifier.content !== undefined; + + if ( + overrideConfigYaml || + hasPreReadContent || + fs.existsSync(configYamlPath) + ) { const result = await loadContinueConfigFromYaml({ ide, ideSettings, diff --git a/core/config/profile/doLoadConfig.vitest.ts b/core/config/profile/doLoadConfig.vitest.ts new file mode 100644 index 00000000000..845a6fc7031 --- /dev/null +++ b/core/config/profile/doLoadConfig.vitest.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { PackageIdentifier } from "@continuedev/config-yaml"; + +// Mock heavy dependencies before importing doLoadConfig +const stubConfig = { + models: [], + rules: [], + tools: [], + slashCommands: [], + contextProviders: [], + modelsByRole: { chat: [], edit: [], apply: [], summarize: [], rerank: [] }, + selectedModelByRole: {}, + mcpServerStatuses: [], + allowAnonymousTelemetry: false, + experimental: {}, +}; +const mockLoadYaml = vi.fn().mockResolvedValue({ + config: { ...stubConfig }, + errors: [], + configLoadInterrupted: false, +}); +const mockLoadJson = vi.fn().mockResolvedValue({ + config: { ...stubConfig }, + errors: [], + configLoadInterrupted: false, +}); + +vi.mock("../yaml/loadYaml", () => ({ + loadContinueConfigFromYaml: (...args: any[]) => mockLoadYaml(...args), +})); +vi.mock("../load", () => ({ + loadContinueConfigFromJson: (...args: any[]) => mockLoadJson(...args), +})); +vi.mock("../migrateSharedConfig", () => ({ + migrateJsonSharedConfig: vi.fn(), +})); +vi.mock("../getWorkspaceContinueRuleDotFiles", () => ({ + getWorkspaceContinueRuleDotFiles: vi + .fn() + .mockResolvedValue({ rules: [], errors: [] }), +})); +vi.mock("../markdown/loadMarkdownRules", () => ({ + loadMarkdownRules: vi.fn().mockResolvedValue({ rules: [], errors: [] }), +})); +vi.mock("../markdown/loadCodebaseRules", () => ({ + CodebaseRulesCache: { getInstance: () => ({ rules: [], errors: [] }) }, +})); +vi.mock("../selectedModels", () => ({ + rectifySelectedModelsFromGlobalContext: (c: any) => c, +})); +vi.mock("../../context/mcp/MCPManagerSingleton", () => ({ + MCPManagerSingleton: { getInstance: () => ({ getStatuses: () => [] }) }, +})); +vi.mock("../../tools", () => ({ + getConfigDependentToolDefinitions: vi.fn().mockResolvedValue([]), +})); +vi.mock("../../tools/callTool", () => ({ + encodeMCPToolUri: vi.fn(), +})); +vi.mock("../../tools/mcpToolName", () => ({ + getMCPToolName: vi.fn(), +})); +vi.mock("../../util/posthog", () => ({ + Telemetry: { setup: vi.fn() }, +})); +vi.mock("../../util/sentry/SentryLogger", () => ({ + SentryLogger: { setup: vi.fn() }, +})); +vi.mock("../../util/tts", () => ({ + TTS: { setup: vi.fn() }, +})); +vi.mock("../../util/GlobalContext", () => ({ + GlobalContext: class { + get() { + return {}; + } + update() {} + }, +})); +vi.mock("../../control-plane/env", () => ({ + getControlPlaneEnv: vi.fn().mockResolvedValue({ + DEFAULT_CONTROL_PLANE_PROXY_URL: "https://proxy.example.com/", + }), + getControlPlaneEnvSync: vi.fn().mockReturnValue({ + CONTROL_PLANE_URL: "https://api.example.com/", + }), +})); +vi.mock("../../control-plane/PolicySingleton", () => ({ + PolicySingleton: { getInstance: () => ({ policy: null }) }, +})); +vi.mock("../../control-plane/TeamAnalytics", () => ({ + TeamAnalytics: { setup: vi.fn(), shutdown: vi.fn() }, +})); +vi.mock("../../promptFiles/initPrompt", () => ({ + initSlashCommand: { name: "init", description: "init" }, +})); + +// Mock fs.existsSync to simulate missing file on disk +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + existsSync: vi.fn().mockReturnValue(false), + }, + }; +}); + +import doLoadConfig from "./doLoadConfig.js"; + +const mockIde = { + getIdeInfo: vi.fn().mockResolvedValue({ + ideType: "vscode", + name: "VS Code", + version: "1.90.0", + remoteName: "wsl", + extensionVersion: "1.3.31", + }), + getUniqueId: vi.fn().mockResolvedValue("test-id"), + getIdeSettings: vi.fn().mockResolvedValue({}), + showToast: vi.fn(), + isTelemetryEnabled: vi.fn().mockResolvedValue(true), + isWorkspaceRemote: vi.fn().mockResolvedValue(true), +} as any; + +const mockControlPlaneClient = { + getAccessToken: vi.fn().mockResolvedValue("token"), + isSignedIn: vi.fn().mockResolvedValue(false), + sessionInfoPromise: Promise.resolve(undefined), +} as any; + +const mockLlmLogger = {} as any; + +describe("doLoadConfig pre-read content bypass", () => { + it("should use YAML loading when packageIdentifier has pre-read content, even if file does not exist on disk", async () => { + mockLoadYaml.mockClear(); + mockLoadJson.mockClear(); + + const packageIdentifier: PackageIdentifier = { + uriType: "file", + fileUri: + "vscode-remote://wsl+Ubuntu/home/user/.continue/agents/test.yaml", + content: "name: Test\nversion: 1.0.0\nschema: v1\n", + }; + + await doLoadConfig({ + ide: mockIde, + controlPlaneClient: mockControlPlaneClient, + llmLogger: mockLlmLogger, + profileId: "test-profile", + overrideConfigYamlByPath: packageIdentifier.fileUri, + orgScopeId: null, + packageIdentifier, + }); + + expect(mockLoadYaml).toHaveBeenCalled(); + expect(mockLoadJson).not.toHaveBeenCalled(); + }); + + it("should fall back to JSON loading when no content and file does not exist", async () => { + mockLoadYaml.mockClear(); + mockLoadJson.mockClear(); + + const packageIdentifier: PackageIdentifier = { + uriType: "file", + fileUri: + "vscode-remote://wsl+Ubuntu/home/user/.continue/agents/test.yaml", + }; + + await doLoadConfig({ + ide: mockIde, + controlPlaneClient: mockControlPlaneClient, + llmLogger: mockLlmLogger, + profileId: "test-profile", + overrideConfigYamlByPath: packageIdentifier.fileUri, + orgScopeId: null, + packageIdentifier, + }); + + expect(mockLoadYaml).not.toHaveBeenCalled(); + expect(mockLoadJson).toHaveBeenCalled(); + }); +});