Просмотр исходного кода

Cloud: support syncing provider profiles from the cloud (#6540)

John Richmond 5 месяцев назад
Родитель
Сommit
8353ca2519

+ 2 - 0
packages/types/src/cloud.ts

@@ -2,6 +2,7 @@ import { z } from "zod"
 
 import { globalSettingsSchema } from "./global-settings.js"
 import { mcpMarketplaceItemSchema } from "./marketplace.js"
+import { discriminatedProviderSettingsWithIdSchema } from "./provider-settings.js"
 
 /**
  * CloudUserInfo
@@ -114,6 +115,7 @@ export const organizationSettingsSchema = z.object({
 	hiddenMcps: z.array(z.string()).optional(),
 	hideMarketplaceMcps: z.boolean().optional(),
 	mcps: z.array(mcpMarketplaceItemSchema).optional(),
+	providerProfiles: z.record(z.string(), discriminatedProviderSettingsWithIdSchema).optional(),
 })
 
 export type OrganizationSettings = z.infer<typeof organizationSettingsSchema>

+ 7 - 0
packages/types/src/provider-settings.ts

@@ -327,6 +327,13 @@ export const providerSettingsSchema = z.object({
 })
 
 export type ProviderSettings = z.infer<typeof providerSettingsSchema>
+
+export const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() })
+export const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and(
+	z.object({ id: z.string().optional() }),
+)
+export type ProviderSettingsWithId = z.infer<typeof providerSettingsWithIdSchema>
+
 export const PROVIDER_SETTINGS_KEYS = providerSettingsSchema.keyof().options
 
 export const MODEL_ID_KEYS: Partial<keyof ProviderSettings>[] = [

+ 218 - 10
src/core/config/ProviderSettingsManager.ts

@@ -1,27 +1,30 @@
 import { ExtensionContext } from "vscode"
 import { z, ZodError } from "zod"
+import deepEqual from "fast-deep-equal"
 
 import {
-	type ProviderSettingsEntry,
-	providerSettingsSchema,
-	providerSettingsSchemaDiscriminated,
+	type ProviderSettingsWithId,
+	providerSettingsWithIdSchema,
+	discriminatedProviderSettingsWithIdSchema,
+	isSecretStateKey,
+	ProviderSettingsEntry,
 	DEFAULT_CONSECUTIVE_MISTAKE_LIMIT,
 } from "@roo-code/types"
 import { TelemetryService } from "@roo-code/telemetry"
 
 import { Mode, modes } from "../../shared/modes"
 
-const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() })
-const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and(
-	z.object({ id: z.string().optional() }),
-)
-
-type ProviderSettingsWithId = z.infer<typeof providerSettingsWithIdSchema>
+export interface SyncCloudProfilesResult {
+	hasChanges: boolean
+	activeProfileChanged: boolean
+	activeProfileId: string
+}
 
 export const providerProfilesSchema = z.object({
 	currentApiConfigName: z.string(),
 	apiConfigs: z.record(z.string(), providerSettingsWithIdSchema),
 	modeApiConfigs: z.record(z.string(), z.string()).optional(),
+	cloudProfileIds: z.array(z.string()).optional(),
 	migrations: z
 		.object({
 			rateLimitSecondsMigrated: z.boolean().optional(),
@@ -304,7 +307,7 @@ export class ProviderSettingsManager {
 				const id = config.id || existingId || this.generateId()
 
 				// Filter out settings from other providers.
-				const filteredConfig = providerSettingsSchemaDiscriminated.parse(config)
+				const filteredConfig = discriminatedProviderSettingsWithIdSchema.parse(config)
 				providerProfiles.apiConfigs[name] = { ...filteredConfig, id }
 				await this.store(providerProfiles)
 				return id
@@ -529,4 +532,209 @@ export class ProviderSettingsManager {
 			throw new Error(`Failed to write provider profiles to secrets: ${error}`)
 		}
 	}
+
+	private findUniqueProfileName(baseName: string, existingNames: Set<string>): string {
+		if (!existingNames.has(baseName)) {
+			return baseName
+		}
+
+		// Try _local first
+		const localName = `${baseName}_local`
+		if (!existingNames.has(localName)) {
+			return localName
+		}
+
+		// Try _1, _2, etc.
+		let counter = 1
+		let candidateName: string
+		do {
+			candidateName = `${baseName}_${counter}`
+			counter++
+		} while (existingNames.has(candidateName))
+
+		return candidateName
+	}
+
+	public async syncCloudProfiles(
+		cloudProfiles: Record<string, ProviderSettingsWithId>,
+		currentActiveProfileName?: string,
+	): Promise<SyncCloudProfilesResult> {
+		try {
+			return await this.lock(async () => {
+				const providerProfiles = await this.load()
+				const changedProfiles: string[] = []
+				const existingNames = new Set(Object.keys(providerProfiles.apiConfigs))
+
+				let activeProfileChanged = false
+				let activeProfileId = ""
+
+				if (currentActiveProfileName && providerProfiles.apiConfigs[currentActiveProfileName]) {
+					activeProfileId = providerProfiles.apiConfigs[currentActiveProfileName].id || ""
+				}
+
+				const currentCloudIds = new Set(providerProfiles.cloudProfileIds || [])
+				const newCloudIds = new Set(
+					Object.values(cloudProfiles)
+						.map((p) => p.id)
+						.filter((id): id is string => Boolean(id)),
+				)
+
+				// Step 1: Delete profiles that are cloud-managed but not in the new cloud profiles
+				for (const [name, profile] of Object.entries(providerProfiles.apiConfigs)) {
+					if (profile.id && currentCloudIds.has(profile.id) && !newCloudIds.has(profile.id)) {
+						// Check if we're deleting the active profile
+						if (name === currentActiveProfileName) {
+							activeProfileChanged = true
+							activeProfileId = "" // Clear the active profile ID since it's being deleted
+						}
+						delete providerProfiles.apiConfigs[name]
+						changedProfiles.push(name)
+						existingNames.delete(name)
+					}
+				}
+
+				// Step 2: Process each cloud profile
+				for (const [cloudName, cloudProfile] of Object.entries(cloudProfiles)) {
+					if (!cloudProfile.id) {
+						continue // Skip profiles without IDs
+					}
+
+					// Find existing profile with matching ID
+					const existingEntry = Object.entries(providerProfiles.apiConfigs).find(
+						([_, profile]) => profile.id === cloudProfile.id,
+					)
+
+					if (existingEntry) {
+						// Step 3: Update existing profile
+						const [existingName, existingProfile] = existingEntry
+
+						// Check if this is the active profile
+						const isActiveProfile = existingName === currentActiveProfileName
+
+						// Merge settings, preserving secret keys
+						const updatedProfile: ProviderSettingsWithId = { ...cloudProfile }
+						for (const [key, value] of Object.entries(existingProfile)) {
+							if (isSecretStateKey(key) && value !== undefined) {
+								;(updatedProfile as any)[key] = value
+							}
+						}
+
+						// Check if the profile actually changed using deepEqual
+						const profileChanged = !deepEqual(existingProfile, updatedProfile)
+
+						// Handle name change
+						if (existingName !== cloudName) {
+							// Remove old entry
+							delete providerProfiles.apiConfigs[existingName]
+							existingNames.delete(existingName)
+
+							// Handle name conflict
+							let finalName = cloudName
+							if (existingNames.has(cloudName)) {
+								// There's a conflict - rename the existing non-cloud profile
+								const conflictingProfile = providerProfiles.apiConfigs[cloudName]
+								if (conflictingProfile.id !== cloudProfile.id) {
+									const newName = this.findUniqueProfileName(cloudName, existingNames)
+									providerProfiles.apiConfigs[newName] = conflictingProfile
+									existingNames.add(newName)
+									changedProfiles.push(newName)
+								}
+								delete providerProfiles.apiConfigs[cloudName]
+								existingNames.delete(cloudName)
+							}
+
+							// Add updated profile with new name
+							providerProfiles.apiConfigs[finalName] = updatedProfile
+							existingNames.add(finalName)
+							changedProfiles.push(finalName)
+							if (existingName !== finalName) {
+								changedProfiles.push(existingName) // Mark old name as changed (deleted)
+							}
+
+							// If this was the active profile, mark it as changed
+							if (isActiveProfile) {
+								activeProfileChanged = true
+								activeProfileId = cloudProfile.id || ""
+							}
+						} else if (profileChanged) {
+							// Same name, but profile content changed - update in place
+							providerProfiles.apiConfigs[existingName] = updatedProfile
+							changedProfiles.push(existingName)
+
+							// If this was the active profile and settings changed, mark it as changed
+							if (isActiveProfile) {
+								activeProfileChanged = true
+								activeProfileId = cloudProfile.id || ""
+							}
+						}
+						// If name is the same and profile hasn't changed, do nothing
+					} else {
+						// Step 4: Add new cloud profile
+						let finalName = cloudName
+
+						// Handle name conflict with existing non-cloud profile
+						if (existingNames.has(cloudName)) {
+							const existingProfile = providerProfiles.apiConfigs[cloudName]
+							if (existingProfile.id !== cloudProfile.id) {
+								// Rename the existing profile
+								const newName = this.findUniqueProfileName(cloudName, existingNames)
+								providerProfiles.apiConfigs[newName] = existingProfile
+								existingNames.add(newName)
+								changedProfiles.push(newName)
+
+								// Remove the old entry
+								delete providerProfiles.apiConfigs[cloudName]
+								existingNames.delete(cloudName)
+							}
+						}
+
+						// Add the new cloud profile (without secret keys)
+						const newProfile: ProviderSettingsWithId = { ...cloudProfile }
+						// Remove any secret keys from cloud profile
+						for (const key of Object.keys(newProfile)) {
+							if (isSecretStateKey(key)) {
+								delete (newProfile as any)[key]
+							}
+						}
+
+						providerProfiles.apiConfigs[finalName] = newProfile
+						existingNames.add(finalName)
+						changedProfiles.push(finalName)
+					}
+				}
+
+				// Step 5: Handle case where all profiles might be deleted
+				if (Object.keys(providerProfiles.apiConfigs).length === 0 && changedProfiles.length > 0) {
+					// Create a default profile only if we have changed profiles
+					const defaultProfile = { id: this.generateId() }
+					providerProfiles.apiConfigs["default"] = defaultProfile
+					activeProfileChanged = true
+					activeProfileId = defaultProfile.id || ""
+					changedProfiles.push("default")
+				}
+
+				// Step 6: If active profile was deleted, find a replacement
+				if (activeProfileChanged && !activeProfileId) {
+					const firstProfile = Object.values(providerProfiles.apiConfigs)[0]
+					if (firstProfile?.id) {
+						activeProfileId = firstProfile.id
+					}
+				}
+
+				// Step 7: Update cloudProfileIds
+				providerProfiles.cloudProfileIds = Array.from(newCloudIds)
+
+				// Save the updated profiles
+				await this.store(providerProfiles)
+
+				return {
+					hasChanges: changedProfiles.length > 0,
+					activeProfileChanged,
+					activeProfileId,
+				}
+			})
+		} catch (error) {
+			throw new Error(`Failed to sync cloud profiles: ${error}`)
+		}
+	}
 }

+ 444 - 1
src/core/config/__tests__/ProviderSettingsManager.spec.ts

@@ -4,7 +4,7 @@ import { ExtensionContext } from "vscode"
 
 import type { ProviderSettings } from "@roo-code/types"
 
-import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager"
+import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager"
 
 // Mock VSCode ExtensionContext
 const mockSecrets = {
@@ -678,4 +678,447 @@ describe("ProviderSettingsManager", () => {
 			)
 		})
 	})
+
+	describe("syncCloudProfiles", () => {
+		it("should add new cloud profiles without secret keys", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "default",
+				apiConfigs: {
+					default: { id: "default-id" },
+				},
+				cloudProfileIds: [],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {
+				"cloud-profile": {
+					id: "cloud-id-1",
+					apiProvider: "anthropic" as const,
+					apiKey: "secret-key", // This should be removed
+					apiModelId: "claude-3-opus-20240229",
+				},
+			}
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(false)
+			expect(result.activeProfileId).toBe("")
+
+			const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
+			expect(storedConfig.apiConfigs["cloud-profile"]).toEqual({
+				id: "cloud-id-1",
+				apiProvider: "anthropic",
+				apiModelId: "claude-3-opus-20240229",
+				// apiKey should be removed
+			})
+			expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"])
+		})
+
+		it("should update existing cloud profiles by ID, preserving secret keys", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "default",
+				apiConfigs: {
+					default: { id: "default-id" },
+					"existing-cloud": {
+						id: "cloud-id-1",
+						apiProvider: "anthropic" as const,
+						apiKey: "existing-secret",
+						apiModelId: "claude-3-haiku-20240307",
+					},
+				},
+				cloudProfileIds: ["cloud-id-1"],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {
+				"updated-name": {
+					id: "cloud-id-1",
+					apiProvider: "anthropic" as const,
+					apiKey: "new-secret", // Should be ignored
+					apiModelId: "claude-3-opus-20240229",
+				},
+			}
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(false)
+			expect(result.activeProfileId).toBe("")
+
+			const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
+			expect(storedConfig.apiConfigs["updated-name"]).toEqual({
+				id: "cloud-id-1",
+				apiProvider: "anthropic",
+				apiKey: "existing-secret", // Preserved
+				apiModelId: "claude-3-opus-20240229", // Updated
+			})
+			expect(storedConfig.apiConfigs["existing-cloud"]).toBeUndefined()
+			expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"])
+		})
+
+		it("should delete cloud profiles not in the new cloud profiles", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "default",
+				apiConfigs: {
+					default: { id: "default-id" },
+					"cloud-profile-1": { id: "cloud-id-1", apiProvider: "anthropic" as const },
+					"cloud-profile-2": { id: "cloud-id-2", apiProvider: "openai" as const },
+				},
+				cloudProfileIds: ["cloud-id-1", "cloud-id-2"],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {
+				"cloud-profile-1": {
+					id: "cloud-id-1",
+					apiProvider: "anthropic" as const,
+				},
+				// cloud-profile-2 is missing, should be deleted
+			}
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(false)
+			expect(result.activeProfileId).toBe("")
+
+			const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
+			expect(storedConfig.apiConfigs["cloud-profile-1"]).toBeDefined()
+			expect(storedConfig.apiConfigs["cloud-profile-2"]).toBeUndefined()
+			expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"])
+		})
+
+		it("should rename existing non-cloud profile when cloud profile has same name", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "default",
+				apiConfigs: {
+					default: { id: "default-id" },
+					"conflict-name": { id: "local-id", apiProvider: "openai" as const },
+				},
+				cloudProfileIds: [],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {
+				"conflict-name": {
+					id: "cloud-id-1",
+					apiProvider: "anthropic" as const,
+				},
+			}
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(false)
+			expect(result.activeProfileId).toBe("")
+
+			const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
+			expect(storedConfig.apiConfigs["conflict-name"]).toEqual({
+				id: "cloud-id-1",
+				apiProvider: "anthropic",
+			})
+			expect(storedConfig.apiConfigs["conflict-name_local"]).toEqual({
+				id: "local-id",
+				apiProvider: "openai",
+			})
+			expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"])
+		})
+
+		it("should handle multiple naming conflicts with incremental suffixes", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "default",
+				apiConfigs: {
+					default: { id: "default-id" },
+					"conflict-name": { id: "local-id-1", apiProvider: "openai" as const },
+					"conflict-name_local": { id: "local-id-2", apiProvider: "vertex" as const },
+				},
+				cloudProfileIds: [],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {
+				"conflict-name": {
+					id: "cloud-id-1",
+					apiProvider: "anthropic" as const,
+				},
+			}
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(false)
+			expect(result.activeProfileId).toBe("")
+
+			const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
+			expect(storedConfig.apiConfigs["conflict-name"]).toEqual({
+				id: "cloud-id-1",
+				apiProvider: "anthropic",
+			})
+			expect(storedConfig.apiConfigs["conflict-name_1"]).toEqual({
+				id: "local-id-1",
+				apiProvider: "openai",
+			})
+			expect(storedConfig.apiConfigs["conflict-name_local"]).toEqual({
+				id: "local-id-2",
+				apiProvider: "vertex",
+			})
+		})
+
+		it("should handle empty cloud profiles by deleting all cloud-managed profiles", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "default",
+				apiConfigs: {
+					default: { id: "default-id" },
+					"cloud-profile-1": { id: "cloud-id-1", apiProvider: "anthropic" as const },
+					"cloud-profile-2": { id: "cloud-id-2", apiProvider: "openai" as const },
+				},
+				cloudProfileIds: ["cloud-id-1", "cloud-id-2"],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {}
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(false)
+			expect(result.activeProfileId).toBe("")
+
+			const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
+			expect(storedConfig.apiConfigs["cloud-profile-1"]).toBeUndefined()
+			expect(storedConfig.apiConfigs["cloud-profile-2"]).toBeUndefined()
+			expect(storedConfig.apiConfigs["default"]).toBeDefined()
+			expect(storedConfig.cloudProfileIds).toEqual([])
+		})
+
+		it("should skip cloud profiles without IDs", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "default",
+				apiConfigs: {
+					default: { id: "default-id" },
+				},
+				cloudProfileIds: [],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {
+				"valid-profile": {
+					id: "cloud-id-1",
+					apiProvider: "anthropic" as const,
+				},
+				"invalid-profile": {
+					// Missing id
+					apiProvider: "openai" as const,
+				},
+			}
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(false)
+			expect(result.activeProfileId).toBe("")
+
+			const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
+			expect(storedConfig.apiConfigs["valid-profile"]).toBeDefined()
+			expect(storedConfig.apiConfigs["invalid-profile"]).toBeUndefined()
+			expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"])
+		})
+
+		it("should handle complex sync scenario with multiple operations", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "default",
+				apiConfigs: {
+					default: { id: "default-id" },
+					"keep-cloud": { id: "cloud-id-1", apiProvider: "anthropic" as const, apiKey: "secret1" },
+					"delete-cloud": { id: "cloud-id-2", apiProvider: "openai" as const },
+					"rename-me": { id: "local-id", apiProvider: "vertex" as const },
+				},
+				cloudProfileIds: ["cloud-id-1", "cloud-id-2"],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {
+				"updated-keep": {
+					id: "cloud-id-1",
+					apiProvider: "anthropic" as const,
+					apiKey: "new-secret", // Should be ignored
+					apiModelId: "claude-3-opus-20240229",
+				},
+				"rename-me": {
+					id: "cloud-id-3",
+					apiProvider: "openai" as const,
+				},
+				// delete-cloud is missing (should be deleted)
+				// new profile
+				"new-cloud": {
+					id: "cloud-id-4",
+					apiProvider: "vertex" as const,
+				},
+			}
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles)
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(false)
+			expect(result.activeProfileId).toBe("")
+
+			const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
+
+			// Check deletions
+			expect(storedConfig.apiConfigs["delete-cloud"]).toBeUndefined()
+			expect(storedConfig.apiConfigs["keep-cloud"]).toBeUndefined()
+
+			// Check updates
+			expect(storedConfig.apiConfigs["updated-keep"]).toEqual({
+				id: "cloud-id-1",
+				apiProvider: "anthropic",
+				apiKey: "secret1", // preserved
+				apiModelId: "claude-3-opus-20240229",
+			})
+
+			// Check renames
+			expect(storedConfig.apiConfigs["rename-me_local"]).toEqual({
+				id: "local-id",
+				apiProvider: "vertex",
+			})
+			expect(storedConfig.apiConfigs["rename-me"]).toEqual({
+				id: "cloud-id-3",
+				apiProvider: "openai",
+			})
+
+			// Check new additions
+			expect(storedConfig.apiConfigs["new-cloud"]).toEqual({
+				id: "cloud-id-4",
+				apiProvider: "vertex",
+			})
+
+			expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1", "cloud-id-3", "cloud-id-4"])
+		})
+
+		it("should throw error if secrets storage fails", async () => {
+			mockSecrets.get.mockResolvedValue(
+				JSON.stringify({
+					currentApiConfigName: "default",
+					apiConfigs: { default: { id: "default-id" } },
+					cloudProfileIds: [],
+				}),
+			)
+			mockSecrets.store.mockRejectedValue(new Error("Storage failed"))
+
+			await expect(providerSettingsManager.syncCloudProfiles({})).rejects.toThrow(
+				"Failed to sync cloud profiles: Error: Failed to write provider profiles to secrets: Error: Storage failed",
+			)
+		})
+
+		it("should track active profile changes when active profile is updated", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "active-profile",
+				apiConfigs: {
+					"active-profile": {
+						id: "active-id",
+						apiProvider: "anthropic" as const,
+						apiKey: "old-key",
+					},
+				},
+				cloudProfileIds: ["active-id"],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {
+				"active-profile": {
+					id: "active-id",
+					apiProvider: "anthropic" as const,
+					apiModelId: "claude-3-opus-20240229", // Updated setting
+				},
+			}
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "active-profile")
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(true)
+			expect(result.activeProfileId).toBe("active-id")
+		})
+
+		it("should track active profile changes when active profile is deleted", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "active-profile",
+				apiConfigs: {
+					"active-profile": { id: "active-id", apiProvider: "anthropic" as const },
+					"backup-profile": { id: "backup-id", apiProvider: "openai" as const },
+				},
+				cloudProfileIds: ["active-id"],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {} // Active profile deleted
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "active-profile")
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(true)
+			expect(result.activeProfileId).toBe("backup-id") // Should switch to first available
+		})
+
+		it("should create default profile when all profiles are deleted", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "only-profile",
+				apiConfigs: {
+					"only-profile": { id: "only-id", apiProvider: "anthropic" as const },
+				},
+				cloudProfileIds: ["only-id"],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {} // All profiles deleted
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "only-profile")
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(true)
+			expect(result.activeProfileId).toBeTruthy() // Should have new default profile ID
+
+			const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1])
+			expect(storedConfig.apiConfigs["default"]).toBeDefined()
+			expect(storedConfig.apiConfigs["default"].id).toBe(result.activeProfileId)
+		})
+
+		it("should not mark active profile as changed when it's not affected", async () => {
+			const existingConfig: ProviderProfiles = {
+				currentApiConfigName: "local-profile",
+				apiConfigs: {
+					"local-profile": { id: "local-id", apiProvider: "anthropic" as const },
+					"cloud-profile": { id: "cloud-id", apiProvider: "openai" as const },
+				},
+				cloudProfileIds: ["cloud-id"],
+			}
+
+			mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
+
+			const cloudProfiles = {
+				"cloud-profile": {
+					id: "cloud-id",
+					apiProvider: "openai" as const,
+					apiModelId: "gpt-4", // Updated cloud profile
+				},
+			}
+
+			const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "local-profile")
+
+			expect(result.hasChanges).toBe(true)
+			expect(result.activeProfileChanged).toBe(false)
+			expect(result.activeProfileId).toBe("local-id")
+		})
+	})
 })

+ 76 - 0
src/core/webview/ClineProvider.ts

@@ -15,6 +15,7 @@ import {
 	type ProviderSettings,
 	type RooCodeSettings,
 	type ProviderSettingsEntry,
+	type ProviderSettingsWithId,
 	type TelemetryProperties,
 	type TelemetryPropertiesProvider,
 	type CodeActionId,
@@ -153,6 +154,76 @@ export class ClineProvider
 			})
 
 		this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager)
+
+		// Initialize cloud profile sync
+		this.initializeCloudProfileSync().catch((error) => {
+			this.log(`Failed to initialize cloud profile sync: ${error}`)
+		})
+	}
+
+	/**
+	 * Initialize cloud profile synchronization
+	 */
+	private async initializeCloudProfileSync() {
+		try {
+			// Check if authenticated and sync profiles
+			if (CloudService.hasInstance() && CloudService.instance.isAuthenticated()) {
+				await this.syncCloudProfiles()
+			}
+
+			// Set up listener for future updates
+			if (CloudService.hasInstance()) {
+				CloudService.instance.on("settings-updated", this.handleCloudSettingsUpdate)
+			}
+		} catch (error) {
+			this.log(`Error in initializeCloudProfileSync: ${error}`)
+		}
+	}
+
+	/**
+	 * Handle cloud settings updates
+	 */
+	private handleCloudSettingsUpdate = async () => {
+		try {
+			await this.syncCloudProfiles()
+		} catch (error) {
+			this.log(`Error handling cloud settings update: ${error}`)
+		}
+	}
+
+	/**
+	 * Synchronize cloud profiles with local profiles
+	 */
+	private async syncCloudProfiles() {
+		try {
+			const settings = CloudService.instance.getOrganizationSettings()
+			if (!settings?.providerProfiles) {
+				return
+			}
+
+			const currentApiConfigName = this.getGlobalState("currentApiConfigName")
+			const result = await this.providerSettingsManager.syncCloudProfiles(
+				settings.providerProfiles,
+				currentApiConfigName,
+			)
+
+			if (result.hasChanges) {
+				// Update list
+				await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
+
+				if (result.activeProfileChanged && result.activeProfileId) {
+					// Reload full settings for new active profile
+					const profile = await this.providerSettingsManager.getProfile({
+						id: result.activeProfileId,
+					})
+					await this.activateProviderProfile({ name: profile.name })
+				}
+
+				await this.postStateToWebview()
+			}
+		} catch (error) {
+			this.log(`Error syncing cloud profiles: ${error}`)
+		}
 	}
 
 	// Adds a new Cline instance to clineStack, marking the start of a new task.
@@ -282,6 +353,11 @@ export class ClineProvider
 
 		this.clearWebviewResources()
 
+		// Clean up cloud service event listener
+		if (CloudService.hasInstance()) {
+			CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate)
+		}
+
 		while (this.disposables.length) {
 			const x = this.disposables.pop()