fix: implement workspace-specific API configuration storage

- Add workspace-specific storage keys based on workspace path hash
- Maintain backward compatibility with automatic migration from global storage
- Prevent API configuration mixing between multiple VSCode projects
- Add comprehensive test coverage for workspace isolation

Fixes #9071
This commit is contained in:
Roo Code 2025-11-06 10:08:31 +00:00
parent 7320d79c08
commit 26886127ae
2 changed files with 234 additions and 6 deletions

View file

@ -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<typeof providerProfilesSchema>
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<string, string> = 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<ProviderProfiles> {
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

View file

@ -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)
})
})
})