Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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]),
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
188 changes: 184 additions & 4 deletions src/core/config/__tests__/ProviderSettingsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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")
})
})
Expand Down Expand Up @@ -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)
})
})
})