fix: isolate state between duplicate VSCode workspaces using sessionId

- Add session-specific keys to ContextProxy to prevent state sharing
- Use vscode.env.sessionId to create unique state keys per window
- Maintain shared keys for API configurations that should persist
- Handle cases where sessionId is not available (e.g., in tests)

Fixes #7698
This commit is contained in:
Roo Code 2025-09-05 07:59:16 +00:00
parent 90e7d09596
commit 02e8716cb6
2 changed files with 85 additions and 25 deletions

View file

@ -36,6 +36,7 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({
export class ContextProxy {
private readonly originalContext: vscode.ExtensionContext
private readonly sessionId: string
private stateCache: GlobalState
private secretCache: SecretState
@ -43,6 +44,10 @@ export class ContextProxy {
constructor(context: vscode.ExtensionContext) {
this.originalContext = context
// Use sessionId to isolate state between multiple VSCode windows
// This ensures each window maintains its own independent state
// Fallback to empty string if sessionId is not available (e.g., in tests)
this.sessionId = vscode.env.sessionId || ""
this.stateCache = {}
this.secretCache = {}
this._isInitialized = false
@ -55,8 +60,9 @@ export class ContextProxy {
public async initialize() {
for (const key of GLOBAL_STATE_KEYS) {
try {
// Revert to original assignment
this.stateCache[key] = this.originalContext.globalState.get(key)
// Use session-specific key for state isolation
const sessionKey = this.getSessionKey(key)
this.stateCache[key] = this.originalContext.globalState.get(sessionKey)
} catch (error) {
logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`)
}
@ -91,13 +97,38 @@ export class ContextProxy {
this._isInitialized = true
}
/**
* Creates a session-specific key by combining the base key with the session ID.
* This ensures state isolation between multiple VSCode windows.
*
* @param key The base state key
* @returns The session-specific key
*/
private getSessionKey(key: string): string {
// For certain keys that should be shared across sessions (like API configs),
// we don't add the session prefix
const sharedKeys = ["listApiConfigMeta", "currentApiConfigName", "apiProvider"]
if (sharedKeys.includes(key)) {
return key
}
// If no sessionId is available (e.g., in tests), use the key as-is
if (!this.sessionId) {
return key
}
// For all other keys, add session prefix to isolate state
return `session_${this.sessionId}_${key}`
}
/**
* Migrates old nested openRouterImageGenerationSettings to the new flattened structure
*/
private async migrateImageGenerationSettings() {
try {
// Check if there's an old nested structure
const oldNestedSettings = this.originalContext.globalState.get<any>("openRouterImageGenerationSettings")
// Check if there's an old nested structure (use session-specific key)
const sessionKey = this.getSessionKey("openRouterImageGenerationSettings")
const oldNestedSettings = this.originalContext.globalState.get<any>(sessionKey)
if (oldNestedSettings && typeof oldNestedSettings === "object") {
logger.info("Migrating old nested image generation settings to flattened structure")
@ -114,16 +145,14 @@ export class ContextProxy {
// Migrate the selected model if it exists and we don't already have one
if (oldNestedSettings.selectedModel && !this.stateCache.openRouterImageGenerationSelectedModel) {
await this.originalContext.globalState.update(
"openRouterImageGenerationSelectedModel",
oldNestedSettings.selectedModel,
)
const modelSessionKey = this.getSessionKey("openRouterImageGenerationSelectedModel")
await this.originalContext.globalState.update(modelSessionKey, oldNestedSettings.selectedModel)
this.stateCache.openRouterImageGenerationSelectedModel = oldNestedSettings.selectedModel
logger.info("Migrated openRouterImageGenerationSelectedModel to global state")
}
// Clean up the old nested structure
await this.originalContext.globalState.update("openRouterImageGenerationSettings", undefined)
await this.originalContext.globalState.update(sessionKey, undefined)
logger.info("Removed old nested openRouterImageGenerationSettings")
}
} catch (error) {
@ -166,7 +195,9 @@ export class ContextProxy {
getGlobalState<K extends GlobalStateKey>(key: K, defaultValue: GlobalState[K]): GlobalState[K]
getGlobalState<K extends GlobalStateKey>(key: K, defaultValue?: GlobalState[K]): GlobalState[K] {
if (isPassThroughStateKey(key)) {
const value = this.originalContext.globalState.get<GlobalState[K]>(key)
// Use session-specific key for pass-through state as well
const sessionKey = this.getSessionKey(key)
const value = this.originalContext.globalState.get<GlobalState[K]>(sessionKey)
return value === undefined || value === null ? defaultValue : value
}
@ -175,12 +206,14 @@ export class ContextProxy {
}
updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
const sessionKey = this.getSessionKey(key)
if (isPassThroughStateKey(key)) {
return this.originalContext.globalState.update(key, value)
return this.originalContext.globalState.update(sessionKey, value)
}
this.stateCache[key] = value
return this.originalContext.globalState.update(key, value)
return this.originalContext.globalState.update(sessionKey, value)
}
private getAllGlobalState(): GlobalState {
@ -362,7 +395,10 @@ export class ContextProxy {
this.secretCache = {}
await Promise.all([
...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)),
...GLOBAL_STATE_KEYS.map((key) => {
const sessionKey = this.getSessionKey(key)
return this.originalContext.globalState.update(sessionKey, undefined)
}),
...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)),
...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)),
])

View file

@ -15,6 +15,9 @@ vi.mock("vscode", () => ({
Production: 2,
Test: 3,
},
env: {
sessionId: "test-session-id-12345",
},
}))
describe("ContextProxy", () => {
@ -72,11 +75,21 @@ describe("ContextProxy", () => {
it("should initialize state cache with all global state keys", () => {
// +1 for the migration check of old nested settings
expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 1)
// When sessionId is available, session-specific keys are used for non-shared keys
// In test environment with mocked sessionId, check for session-specific keys
const sharedKeys = ["listApiConfigMeta", "currentApiConfigName", "apiProvider"]
for (const key of GLOBAL_STATE_KEYS) {
expect(mockGlobalState.get).toHaveBeenCalledWith(key)
if (sharedKeys.includes(key)) {
expect(mockGlobalState.get).toHaveBeenCalledWith(key)
} else {
expect(mockGlobalState.get).toHaveBeenCalledWith(`session_test-session-id-12345_${key}`)
}
}
// Also check for migration call
expect(mockGlobalState.get).toHaveBeenCalledWith("openRouterImageGenerationSettings")
// Also check for migration call with session-specific key
expect(mockGlobalState.get).toHaveBeenCalledWith(
"session_test-session-id-12345_openRouterImageGenerationSettings",
)
})
it("should initialize secret cache with all secret keys", () => {
@ -116,9 +129,9 @@ describe("ContextProxy", () => {
// Use a pass-through key (taskHistory)
const result = proxy.getGlobalState("taskHistory")
// Should get value directly from original context
// Should get value directly from original context with session-specific key (when sessionId is available)
expect(result).toBe("pass-through-value")
expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory")
expect(mockGlobalState.get).toHaveBeenCalledWith("session_test-session-id-12345_taskHistory")
})
it("should respect default values for pass-through state keys", async () => {
@ -149,7 +162,7 @@ describe("ContextProxy", () => {
it("should update state directly in original context", async () => {
await proxy.updateGlobalState("apiProvider", "deepseek")
// Should have called original context
// Should have called original context (apiProvider is a shared key, no session prefix)
expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "deepseek")
// Should have stored the value in cache
@ -172,16 +185,19 @@ describe("ContextProxy", () => {
await proxy.updateGlobalState("taskHistory", historyItems)
// Should update original context
expect(mockGlobalState.update).toHaveBeenCalledWith("taskHistory", historyItems)
// Should update original context with session-specific key (when sessionId is available)
expect(mockGlobalState.update).toHaveBeenCalledWith(
"session_test-session-id-12345_taskHistory",
historyItems,
)
// Setup mock for subsequent get
mockGlobalState.get.mockReturnValue(historyItems)
// Should get fresh value from original context
// Should get fresh value from original context with session-specific key (when sessionId is available)
const storedValue = proxy.getGlobalState("taskHistory")
expect(storedValue).toBe(historyItems)
expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory")
expect(mockGlobalState.get).toHaveBeenCalledWith("session_test-session-id-12345_taskHistory")
})
})
@ -387,9 +403,17 @@ describe("ContextProxy", () => {
// Reset all state
await proxy.resetAllState()
// Should have called update with undefined for each key
// Should have called update with undefined for each key using session-specific keys (when sessionId is available)
const sharedKeys = ["listApiConfigMeta", "currentApiConfigName", "apiProvider"]
for (const key of GLOBAL_STATE_KEYS) {
expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined)
if (sharedKeys.includes(key)) {
expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined)
} else {
expect(mockGlobalState.update).toHaveBeenCalledWith(
`session_test-session-id-12345_${key}`,
undefined,
)
}
}
// Total calls should include initial setup + reset operations