mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
7320d79c08
commit
26886127ae
2 changed files with 234 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue