diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index ea3e18f3ef7b..0def8f8ba475 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -1,6 +1,8 @@ import { ExtensionContext } from "vscode" +import * as vscode from "vscode" import { z, ZodError } from "zod" import deepEqual from "fast-deep-equal" +import * as crypto from "crypto" import { type ProviderSettingsWithId, @@ -16,6 +18,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Mode, modes } from "../../shared/modes" import { buildApiHandler } from "../../api" +import { getWorkspacePath } from "../../utils/path" // Type-safe model migrations mapping type ModelMigrations = { @@ -54,7 +57,9 @@ export type ProviderProfiles = z.infer export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" + private static readonly GLOBAL_KEY = "api_config" // Legacy global key private readonly defaultConfigId = this.generateId() + private workspaceId: string | null = null private readonly defaultModeApiConfigs: Record = Object.fromEntries( modes.map((mode) => [mode.slug, this.defaultConfigId]), @@ -77,6 +82,7 @@ export class ProviderSettingsManager { constructor(context: ExtensionContext) { this.context = context + this.workspaceId = this.getWorkspaceIdentifier() // TODO: We really shouldn't have async methods in the constructor. this.initialize().catch(console.error) @@ -575,16 +581,58 @@ export class ProviderSettingsManager { public async resetAllConfigs() { return await this.lock(async () => { await this.context.secrets.delete(this.secretsKey) + // Also delete the global key if exists + await this.context.secrets.delete(this.globalSecretsKey) }) } + /** + * Get a unique workspace identifier based on the workspace folder path. + * Returns null if no workspace is open (falls back to global storage). + */ + private getWorkspaceIdentifier(): string | null { + const workspacePath = getWorkspacePath() + if (!workspacePath || workspacePath === "") { + return null + } + + // Create a hash of the workspace path for a shorter, consistent identifier + const hash = crypto.createHash("sha256").update(workspacePath).digest("hex") + // Use first 8 characters of hash for brevity + return hash.substring(0, 8) + } + private get secretsKey() { - return `${ProviderSettingsManager.SCOPE_PREFIX}api_config` + // If we have a workspace, use workspace-specific key + if (this.workspaceId) { + return `${ProviderSettingsManager.SCOPE_PREFIX}ws_${this.workspaceId}` + } + // Fall back to global key for non-workspace scenarios + return this.globalSecretsKey + } + + private get globalSecretsKey() { + return `${ProviderSettingsManager.SCOPE_PREFIX}${ProviderSettingsManager.GLOBAL_KEY}` } private async load(): Promise { try { - const content = await this.context.secrets.get(this.secretsKey) + // First try to load from workspace-specific key + let content = await this.context.secrets.get(this.secretsKey) + + // If no workspace-specific config and we have a workspace, check for migration from global + if (!content && this.workspaceId) { + const globalContent = await this.context.secrets.get(this.globalSecretsKey) + if (globalContent) { + // Migrate global config to workspace-specific + console.log(`[ProviderSettingsManager] Migrating global config to workspace-specific storage`) + content = globalContent + // Save to workspace-specific key + await this.context.secrets.store(this.secretsKey, globalContent) + // Note: We don't delete the global config here to maintain backward compatibility + // for other workspaces that might still be using it + } + } if (!content) { return this.defaultProviderProfiles diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index b710dc6cca8e..7cc20e80510a 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -6,6 +6,19 @@ import type { ProviderSettings } from "@roo-code/types" import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" +// Mock getWorkspacePath +import { getWorkspacePath } from "../../../utils/path" +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn(() => "/test/workspace"), +})) + +// Mock vscode module +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: "/test/workspace" } }], + }, +})) + // Mock VSCode ExtensionContext const mockSecrets = { get: vi.fn(), @@ -458,7 +471,10 @@ describe("ProviderSettingsManager", () => { }, } - expect(mockSecrets.store.mock.calls[0][0]).toEqual("roo_cline_config_api_config") + // Should use workspace-specific key (hash of /test/workspace) + const crypto = await import("crypto") + const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8) + expect(mockSecrets.store.mock.calls[0][0]).toEqual(`roo_cline_config_ws_${workspaceHash}`) expect(storedConfig).toEqual(expectedConfig) }) @@ -508,7 +524,10 @@ describe("ProviderSettingsManager", () => { }, } - expect(mockSecrets.store.mock.calls[0][0]).toEqual("roo_cline_config_api_config") + // Should use workspace-specific key + const crypto = await import("crypto") + const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8) + expect(mockSecrets.store.mock.calls[0][0]).toEqual(`roo_cline_config_ws_${workspaceHash}`) expect(storedConfig).toEqual(expectedConfig) }) @@ -551,8 +570,10 @@ describe("ProviderSettingsManager", () => { } const storedConfig = JSON.parse(mockSecrets.store.mock.calls[mockSecrets.store.mock.calls.length - 1][1]) + const crypto = await import("crypto") + const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8) expect(mockSecrets.store.mock.calls[mockSecrets.store.mock.calls.length - 1][0]).toEqual( - "roo_cline_config_api_config", + `roo_cline_config_ws_${workspaceHash}`, ) expect(storedConfig).toEqual(expectedConfig) }) @@ -757,7 +778,11 @@ describe("ProviderSettingsManager", () => { await providerSettingsManager.resetAllConfigs() - // Should have called delete with the correct config key + // Should have called delete with the workspace-specific key + const crypto = await import("crypto") + const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8) + expect(mockSecrets.delete).toHaveBeenCalledWith(`roo_cline_config_ws_${workspaceHash}`) + // Should also try to delete the global key for backward compatibility expect(mockSecrets.delete).toHaveBeenCalledWith("roo_cline_config_api_config") }) }) @@ -1236,4 +1261,159 @@ describe("ProviderSettingsManager", () => { expect(result.activeProfileId).toBe("local-id") }) }) + + describe("Workspace-specific storage", () => { + it("should use workspace-specific key when workspace is available", async () => { + vi.mocked(getWorkspacePath).mockReturnValue("/test/workspace") + const manager = new ProviderSettingsManager(mockContext) + + mockSecrets.get.mockResolvedValue(null) + + const config: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "test-key", + } + + await manager.saveConfig("test", config) + + const crypto = await import("crypto") + const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8) + expect(mockSecrets.store).toHaveBeenCalledWith(`roo_cline_config_ws_${workspaceHash}`, expect.any(String)) + }) + + it("should fall back to global key when no workspace is available", async () => { + vi.mocked(getWorkspacePath).mockReturnValue("") + const manager = new ProviderSettingsManager(mockContext) + + mockSecrets.get.mockResolvedValue(null) + + const config: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "test-key", + } + + await manager.saveConfig("test", config) + + expect(mockSecrets.store).toHaveBeenCalledWith("roo_cline_config_api_config", expect.any(String)) + }) + + it("should migrate from global to workspace-specific storage", async () => { + vi.mocked(getWorkspacePath).mockReturnValue("/test/workspace") + + const globalConfig = { + currentApiConfigName: "global-config", + apiConfigs: { + "global-config": { + apiProvider: "anthropic", + apiKey: "global-key", + id: "global-id", + }, + }, + } + + const crypto = await import("crypto") + const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8) + const workspaceKey = `roo_cline_config_ws_${workspaceHash}` + + // Set up the mock to properly simulate migration behavior + let storedConfig: string | undefined + mockSecrets.get.mockImplementation((key) => { + if (key === workspaceKey) { + // Return stored config if it was migrated + return Promise.resolve(storedConfig || null) + } else if (key === "roo_cline_config_api_config") { + // Return global config for migration + return Promise.resolve(JSON.stringify(globalConfig)) + } + return Promise.resolve(null) + }) + + mockSecrets.store.mockImplementation((key, value) => { + if (key === workspaceKey) { + storedConfig = value + } + return Promise.resolve() + }) + + const manager = new ProviderSettingsManager(mockContext) + // Wait for initialization to complete (which triggers migration) + await new Promise((resolve) => setTimeout(resolve, 100)) + + const configs = await manager.listConfig() + + // Should have migrated the global config + expect(configs).toEqual([ + { name: "global-config", id: "global-id", apiProvider: "anthropic", modelId: undefined }, + ]) + + // Should have saved to workspace-specific key + expect(mockSecrets.store).toHaveBeenCalledWith(workspaceKey, JSON.stringify(globalConfig)) + }) + + it("should maintain separate configs for different workspaces", async () => { + // First workspace + vi.mocked(getWorkspacePath).mockReturnValue("/workspace/project1") + const manager1 = new ProviderSettingsManager(mockContext) + + const config1 = { + currentApiConfigName: "project1-config", + apiConfigs: { + "project1-config": { + apiProvider: "anthropic", + apiKey: "project1-key", + id: "project1-id", + }, + }, + } + + const crypto = await import("crypto") + const workspace1Hash = crypto + .createHash("sha256") + .update("/workspace/project1") + .digest("hex") + .substring(0, 8) + mockSecrets.get.mockImplementation((key) => { + if (key === `roo_cline_config_ws_${workspace1Hash}`) { + return JSON.stringify(config1) + } + return null + }) + + const configs1 = await manager1.listConfig() + expect(configs1).toEqual([{ name: "project1-config", id: "project1-id", apiProvider: "anthropic" }]) + + // Second workspace + vi.mocked(getWorkspacePath).mockReturnValue("/workspace/project2") + const manager2 = new ProviderSettingsManager(mockContext) + + const config2 = { + currentApiConfigName: "project2-config", + apiConfigs: { + "project2-config": { + apiProvider: "openai", + apiKey: "project2-key", + id: "project2-id", + }, + }, + } + + const workspace2Hash = crypto + .createHash("sha256") + .update("/workspace/project2") + .digest("hex") + .substring(0, 8) + mockSecrets.get.mockImplementation((key) => { + if (key === `roo_cline_config_ws_${workspace2Hash}`) { + return JSON.stringify(config2) + } + return null + }) + + const configs2 = await manager2.listConfig() + expect(configs2).toEqual([{ name: "project2-config", id: "project2-id", apiProvider: "openai" }]) + + // Verify different workspace hashes + expect(workspace1Hash).not.toEqual(workspace2Hash) + }) + }) })