Skip to content

Commit 2688612

Browse files
committed
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
1 parent 7320d79 commit 2688612

File tree

2 files changed

+234
-6
lines changed

2 files changed

+234
-6
lines changed

src/core/config/ProviderSettingsManager.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { ExtensionContext } from "vscode"
2+
import * as vscode from "vscode"
23
import { z, ZodError } from "zod"
34
import deepEqual from "fast-deep-equal"
5+
import * as crypto from "crypto"
46

57
import {
68
type ProviderSettingsWithId,
@@ -16,6 +18,7 @@ import { TelemetryService } from "@roo-code/telemetry"
1618

1719
import { Mode, modes } from "../../shared/modes"
1820
import { buildApiHandler } from "../../api"
21+
import { getWorkspacePath } from "../../utils/path"
1922

2023
// Type-safe model migrations mapping
2124
type ModelMigrations = {
@@ -54,7 +57,9 @@ export type ProviderProfiles = z.infer<typeof providerProfilesSchema>
5457

5558
export class ProviderSettingsManager {
5659
private static readonly SCOPE_PREFIX = "roo_cline_config_"
60+
private static readonly GLOBAL_KEY = "api_config" // Legacy global key
5761
private readonly defaultConfigId = this.generateId()
62+
private workspaceId: string | null = null
5863

5964
private readonly defaultModeApiConfigs: Record<string, string> = Object.fromEntries(
6065
modes.map((mode) => [mode.slug, this.defaultConfigId]),
@@ -77,6 +82,7 @@ export class ProviderSettingsManager {
7782

7883
constructor(context: ExtensionContext) {
7984
this.context = context
85+
this.workspaceId = this.getWorkspaceIdentifier()
8086

8187
// TODO: We really shouldn't have async methods in the constructor.
8288
this.initialize().catch(console.error)
@@ -575,16 +581,58 @@ export class ProviderSettingsManager {
575581
public async resetAllConfigs() {
576582
return await this.lock(async () => {
577583
await this.context.secrets.delete(this.secretsKey)
584+
// Also delete the global key if exists
585+
await this.context.secrets.delete(this.globalSecretsKey)
578586
})
579587
}
580588

589+
/**
590+
* Get a unique workspace identifier based on the workspace folder path.
591+
* Returns null if no workspace is open (falls back to global storage).
592+
*/
593+
private getWorkspaceIdentifier(): string | null {
594+
const workspacePath = getWorkspacePath()
595+
if (!workspacePath || workspacePath === "") {
596+
return null
597+
}
598+
599+
// Create a hash of the workspace path for a shorter, consistent identifier
600+
const hash = crypto.createHash("sha256").update(workspacePath).digest("hex")
601+
// Use first 8 characters of hash for brevity
602+
return hash.substring(0, 8)
603+
}
604+
581605
private get secretsKey() {
582-
return `${ProviderSettingsManager.SCOPE_PREFIX}api_config`
606+
// If we have a workspace, use workspace-specific key
607+
if (this.workspaceId) {
608+
return `${ProviderSettingsManager.SCOPE_PREFIX}ws_${this.workspaceId}`
609+
}
610+
// Fall back to global key for non-workspace scenarios
611+
return this.globalSecretsKey
612+
}
613+
614+
private get globalSecretsKey() {
615+
return `${ProviderSettingsManager.SCOPE_PREFIX}${ProviderSettingsManager.GLOBAL_KEY}`
583616
}
584617

585618
private async load(): Promise<ProviderProfiles> {
586619
try {
587-
const content = await this.context.secrets.get(this.secretsKey)
620+
// First try to load from workspace-specific key
621+
let content = await this.context.secrets.get(this.secretsKey)
622+
623+
// If no workspace-specific config and we have a workspace, check for migration from global
624+
if (!content && this.workspaceId) {
625+
const globalContent = await this.context.secrets.get(this.globalSecretsKey)
626+
if (globalContent) {
627+
// Migrate global config to workspace-specific
628+
console.log(`[ProviderSettingsManager] Migrating global config to workspace-specific storage`)
629+
content = globalContent
630+
// Save to workspace-specific key
631+
await this.context.secrets.store(this.secretsKey, globalContent)
632+
// Note: We don't delete the global config here to maintain backward compatibility
633+
// for other workspaces that might still be using it
634+
}
635+
}
588636

589637
if (!content) {
590638
return this.defaultProviderProfiles

src/core/config/__tests__/ProviderSettingsManager.spec.ts

Lines changed: 184 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ import type { ProviderSettings } from "@roo-code/types"
66

77
import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager"
88

9+
// Mock getWorkspacePath
10+
import { getWorkspacePath } from "../../../utils/path"
11+
vi.mock("../../../utils/path", () => ({
12+
getWorkspacePath: vi.fn(() => "/test/workspace"),
13+
}))
14+
15+
// Mock vscode module
16+
vi.mock("vscode", () => ({
17+
workspace: {
18+
workspaceFolders: [{ uri: { fsPath: "/test/workspace" } }],
19+
},
20+
}))
21+
922
// Mock VSCode ExtensionContext
1023
const mockSecrets = {
1124
get: vi.fn(),
@@ -458,7 +471,10 @@ describe("ProviderSettingsManager", () => {
458471
},
459472
}
460473

461-
expect(mockSecrets.store.mock.calls[0][0]).toEqual("roo_cline_config_api_config")
474+
// Should use workspace-specific key (hash of /test/workspace)
475+
const crypto = await import("crypto")
476+
const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8)
477+
expect(mockSecrets.store.mock.calls[0][0]).toEqual(`roo_cline_config_ws_${workspaceHash}`)
462478
expect(storedConfig).toEqual(expectedConfig)
463479
})
464480

@@ -508,7 +524,10 @@ describe("ProviderSettingsManager", () => {
508524
},
509525
}
510526

511-
expect(mockSecrets.store.mock.calls[0][0]).toEqual("roo_cline_config_api_config")
527+
// Should use workspace-specific key
528+
const crypto = await import("crypto")
529+
const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8)
530+
expect(mockSecrets.store.mock.calls[0][0]).toEqual(`roo_cline_config_ws_${workspaceHash}`)
512531
expect(storedConfig).toEqual(expectedConfig)
513532
})
514533

@@ -551,8 +570,10 @@ describe("ProviderSettingsManager", () => {
551570
}
552571

553572
const storedConfig = JSON.parse(mockSecrets.store.mock.calls[mockSecrets.store.mock.calls.length - 1][1])
573+
const crypto = await import("crypto")
574+
const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8)
554575
expect(mockSecrets.store.mock.calls[mockSecrets.store.mock.calls.length - 1][0]).toEqual(
555-
"roo_cline_config_api_config",
576+
`roo_cline_config_ws_${workspaceHash}`,
556577
)
557578
expect(storedConfig).toEqual(expectedConfig)
558579
})
@@ -757,7 +778,11 @@ describe("ProviderSettingsManager", () => {
757778

758779
await providerSettingsManager.resetAllConfigs()
759780

760-
// Should have called delete with the correct config key
781+
// Should have called delete with the workspace-specific key
782+
const crypto = await import("crypto")
783+
const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8)
784+
expect(mockSecrets.delete).toHaveBeenCalledWith(`roo_cline_config_ws_${workspaceHash}`)
785+
// Should also try to delete the global key for backward compatibility
761786
expect(mockSecrets.delete).toHaveBeenCalledWith("roo_cline_config_api_config")
762787
})
763788
})
@@ -1236,4 +1261,159 @@ describe("ProviderSettingsManager", () => {
12361261
expect(result.activeProfileId).toBe("local-id")
12371262
})
12381263
})
1264+
1265+
describe("Workspace-specific storage", () => {
1266+
it("should use workspace-specific key when workspace is available", async () => {
1267+
vi.mocked(getWorkspacePath).mockReturnValue("/test/workspace")
1268+
const manager = new ProviderSettingsManager(mockContext)
1269+
1270+
mockSecrets.get.mockResolvedValue(null)
1271+
1272+
const config: ProviderSettings = {
1273+
apiProvider: "anthropic",
1274+
apiKey: "test-key",
1275+
}
1276+
1277+
await manager.saveConfig("test", config)
1278+
1279+
const crypto = await import("crypto")
1280+
const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8)
1281+
expect(mockSecrets.store).toHaveBeenCalledWith(`roo_cline_config_ws_${workspaceHash}`, expect.any(String))
1282+
})
1283+
1284+
it("should fall back to global key when no workspace is available", async () => {
1285+
vi.mocked(getWorkspacePath).mockReturnValue("")
1286+
const manager = new ProviderSettingsManager(mockContext)
1287+
1288+
mockSecrets.get.mockResolvedValue(null)
1289+
1290+
const config: ProviderSettings = {
1291+
apiProvider: "anthropic",
1292+
apiKey: "test-key",
1293+
}
1294+
1295+
await manager.saveConfig("test", config)
1296+
1297+
expect(mockSecrets.store).toHaveBeenCalledWith("roo_cline_config_api_config", expect.any(String))
1298+
})
1299+
1300+
it("should migrate from global to workspace-specific storage", async () => {
1301+
vi.mocked(getWorkspacePath).mockReturnValue("/test/workspace")
1302+
1303+
const globalConfig = {
1304+
currentApiConfigName: "global-config",
1305+
apiConfigs: {
1306+
"global-config": {
1307+
apiProvider: "anthropic",
1308+
apiKey: "global-key",
1309+
id: "global-id",
1310+
},
1311+
},
1312+
}
1313+
1314+
const crypto = await import("crypto")
1315+
const workspaceHash = crypto.createHash("sha256").update("/test/workspace").digest("hex").substring(0, 8)
1316+
const workspaceKey = `roo_cline_config_ws_${workspaceHash}`
1317+
1318+
// Set up the mock to properly simulate migration behavior
1319+
let storedConfig: string | undefined
1320+
mockSecrets.get.mockImplementation((key) => {
1321+
if (key === workspaceKey) {
1322+
// Return stored config if it was migrated
1323+
return Promise.resolve(storedConfig || null)
1324+
} else if (key === "roo_cline_config_api_config") {
1325+
// Return global config for migration
1326+
return Promise.resolve(JSON.stringify(globalConfig))
1327+
}
1328+
return Promise.resolve(null)
1329+
})
1330+
1331+
mockSecrets.store.mockImplementation((key, value) => {
1332+
if (key === workspaceKey) {
1333+
storedConfig = value
1334+
}
1335+
return Promise.resolve()
1336+
})
1337+
1338+
const manager = new ProviderSettingsManager(mockContext)
1339+
// Wait for initialization to complete (which triggers migration)
1340+
await new Promise((resolve) => setTimeout(resolve, 100))
1341+
1342+
const configs = await manager.listConfig()
1343+
1344+
// Should have migrated the global config
1345+
expect(configs).toEqual([
1346+
{ name: "global-config", id: "global-id", apiProvider: "anthropic", modelId: undefined },
1347+
])
1348+
1349+
// Should have saved to workspace-specific key
1350+
expect(mockSecrets.store).toHaveBeenCalledWith(workspaceKey, JSON.stringify(globalConfig))
1351+
})
1352+
1353+
it("should maintain separate configs for different workspaces", async () => {
1354+
// First workspace
1355+
vi.mocked(getWorkspacePath).mockReturnValue("/workspace/project1")
1356+
const manager1 = new ProviderSettingsManager(mockContext)
1357+
1358+
const config1 = {
1359+
currentApiConfigName: "project1-config",
1360+
apiConfigs: {
1361+
"project1-config": {
1362+
apiProvider: "anthropic",
1363+
apiKey: "project1-key",
1364+
id: "project1-id",
1365+
},
1366+
},
1367+
}
1368+
1369+
const crypto = await import("crypto")
1370+
const workspace1Hash = crypto
1371+
.createHash("sha256")
1372+
.update("/workspace/project1")
1373+
.digest("hex")
1374+
.substring(0, 8)
1375+
mockSecrets.get.mockImplementation((key) => {
1376+
if (key === `roo_cline_config_ws_${workspace1Hash}`) {
1377+
return JSON.stringify(config1)
1378+
}
1379+
return null
1380+
})
1381+
1382+
const configs1 = await manager1.listConfig()
1383+
expect(configs1).toEqual([{ name: "project1-config", id: "project1-id", apiProvider: "anthropic" }])
1384+
1385+
// Second workspace
1386+
vi.mocked(getWorkspacePath).mockReturnValue("/workspace/project2")
1387+
const manager2 = new ProviderSettingsManager(mockContext)
1388+
1389+
const config2 = {
1390+
currentApiConfigName: "project2-config",
1391+
apiConfigs: {
1392+
"project2-config": {
1393+
apiProvider: "openai",
1394+
apiKey: "project2-key",
1395+
id: "project2-id",
1396+
},
1397+
},
1398+
}
1399+
1400+
const workspace2Hash = crypto
1401+
.createHash("sha256")
1402+
.update("/workspace/project2")
1403+
.digest("hex")
1404+
.substring(0, 8)
1405+
mockSecrets.get.mockImplementation((key) => {
1406+
if (key === `roo_cline_config_ws_${workspace2Hash}`) {
1407+
return JSON.stringify(config2)
1408+
}
1409+
return null
1410+
})
1411+
1412+
const configs2 = await manager2.listConfig()
1413+
expect(configs2).toEqual([{ name: "project2-config", id: "project2-id", apiProvider: "openai" }])
1414+
1415+
// Verify different workspace hashes
1416+
expect(workspace1Hash).not.toEqual(workspace2Hash)
1417+
})
1418+
})
12391419
})

0 commit comments

Comments
 (0)