Forráskód Böngészése

Cloud: support static cloud settings (#5435)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
John Richmond 5 hónapja
szülő
commit
ad201cc058

+ 17 - 8
packages/cloud/src/CloudService.ts

@@ -12,7 +12,9 @@ import { TelemetryService } from "@roo-code/telemetry"
 import { CloudServiceCallbacks } from "./types"
 import { CloudServiceCallbacks } from "./types"
 import type { AuthService } from "./auth"
 import type { AuthService } from "./auth"
 import { WebAuthService, StaticTokenAuthService } from "./auth"
 import { WebAuthService, StaticTokenAuthService } from "./auth"
-import { SettingsService } from "./SettingsService"
+import type { SettingsService } from "./SettingsService"
+import { CloudSettingsService } from "./CloudSettingsService"
+import { StaticSettingsService } from "./StaticSettingsService"
 import { TelemetryClient } from "./TelemetryClient"
 import { TelemetryClient } from "./TelemetryClient"
 import { ShareService, TaskNotFoundError } from "./ShareService"
 import { ShareService, TaskNotFoundError } from "./ShareService"
 
 
@@ -59,13 +61,20 @@ export class CloudService {
 			this.authService.on("logged-out", this.authListener)
 			this.authService.on("logged-out", this.authListener)
 			this.authService.on("user-info", this.authListener)
 			this.authService.on("user-info", this.authListener)
 
 
-			this.settingsService = new SettingsService(
-				this.context,
-				this.authService,
-				() => this.callbacks.stateChanged?.(),
-				this.log,
-			)
-			this.settingsService.initialize()
+			// Check for static settings environment variable
+			const staticOrgSettings = process.env.ROO_CODE_CLOUD_ORG_SETTINGS
+			if (staticOrgSettings && staticOrgSettings.length > 0) {
+				this.settingsService = new StaticSettingsService(staticOrgSettings, this.log)
+			} else {
+				const cloudSettingsService = new CloudSettingsService(
+					this.context,
+					this.authService,
+					() => this.callbacks.stateChanged?.(),
+					this.log,
+				)
+				cloudSettingsService.initialize()
+				this.settingsService = cloudSettingsService
+			}
 
 
 			this.telemetryClient = new TelemetryClient(this.authService, this.settingsService)
 			this.telemetryClient = new TelemetryClient(this.authService, this.settingsService)
 
 

+ 136 - 0
packages/cloud/src/CloudSettingsService.ts

@@ -0,0 +1,136 @@
+import * as vscode from "vscode"
+
+import {
+	ORGANIZATION_ALLOW_ALL,
+	OrganizationAllowList,
+	OrganizationSettings,
+	organizationSettingsSchema,
+} from "@roo-code/types"
+
+import { getRooCodeApiUrl } from "./Config"
+import type { AuthService } from "./auth"
+import { RefreshTimer } from "./RefreshTimer"
+import type { SettingsService } from "./SettingsService"
+
+const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings"
+
+export class CloudSettingsService implements SettingsService {
+	private context: vscode.ExtensionContext
+	private authService: AuthService
+	private settings: OrganizationSettings | undefined = undefined
+	private timer: RefreshTimer
+	private log: (...args: unknown[]) => void
+
+	constructor(
+		context: vscode.ExtensionContext,
+		authService: AuthService,
+		callback: () => void,
+		log?: (...args: unknown[]) => void,
+	) {
+		this.context = context
+		this.authService = authService
+		this.log = log || console.log
+
+		this.timer = new RefreshTimer({
+			callback: async () => {
+				return await this.fetchSettings(callback)
+			},
+			successInterval: 30000,
+			initialBackoffMs: 1000,
+			maxBackoffMs: 30000,
+		})
+	}
+
+	public initialize(): void {
+		this.loadCachedSettings()
+
+		// Clear cached settings if we have missed a log out.
+		if (this.authService.getState() == "logged-out" && this.settings) {
+			this.removeSettings()
+		}
+
+		this.authService.on("active-session", () => {
+			this.timer.start()
+		})
+
+		this.authService.on("logged-out", () => {
+			this.timer.stop()
+			this.removeSettings()
+		})
+
+		if (this.authService.hasActiveSession()) {
+			this.timer.start()
+		}
+	}
+
+	private async fetchSettings(callback: () => void): Promise<boolean> {
+		const token = this.authService.getSessionToken()
+
+		if (!token) {
+			return false
+		}
+
+		try {
+			const response = await fetch(`${getRooCodeApiUrl()}/api/organization-settings`, {
+				headers: {
+					Authorization: `Bearer ${token}`,
+				},
+			})
+
+			if (!response.ok) {
+				this.log(
+					"[cloud-settings] Failed to fetch organization settings:",
+					response.status,
+					response.statusText,
+				)
+				return false
+			}
+
+			const data = await response.json()
+			const result = organizationSettingsSchema.safeParse(data)
+
+			if (!result.success) {
+				this.log("[cloud-settings] Invalid organization settings format:", result.error)
+				return false
+			}
+
+			const newSettings = result.data
+
+			if (!this.settings || this.settings.version !== newSettings.version) {
+				this.settings = newSettings
+				await this.cacheSettings()
+				callback()
+			}
+
+			return true
+		} catch (error) {
+			this.log("[cloud-settings] Error fetching organization settings:", error)
+			return false
+		}
+	}
+
+	private async cacheSettings(): Promise<void> {
+		await this.context.globalState.update(ORGANIZATION_SETTINGS_CACHE_KEY, this.settings)
+	}
+
+	private loadCachedSettings(): void {
+		this.settings = this.context.globalState.get<OrganizationSettings>(ORGANIZATION_SETTINGS_CACHE_KEY)
+	}
+
+	public getAllowList(): OrganizationAllowList {
+		return this.settings?.allowList || ORGANIZATION_ALLOW_ALL
+	}
+
+	public getSettings(): OrganizationSettings | undefined {
+		return this.settings
+	}
+
+	private async removeSettings(): Promise<void> {
+		this.settings = undefined
+		await this.cacheSettings()
+	}
+
+	public dispose(): void {
+		this.timer.stop()
+	}
+}

+ 22 - 134
packages/cloud/src/SettingsService.ts

@@ -1,135 +1,23 @@
-import * as vscode from "vscode"
-
-import {
-	ORGANIZATION_ALLOW_ALL,
-	OrganizationAllowList,
-	OrganizationSettings,
-	organizationSettingsSchema,
-} from "@roo-code/types"
-
-import { getRooCodeApiUrl } from "./Config"
-import type { AuthService } from "./auth"
-import { RefreshTimer } from "./RefreshTimer"
-
-const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings"
-
-export class SettingsService {
-	private context: vscode.ExtensionContext
-	private authService: AuthService
-	private settings: OrganizationSettings | undefined = undefined
-	private timer: RefreshTimer
-	private log: (...args: unknown[]) => void
-
-	constructor(
-		context: vscode.ExtensionContext,
-		authService: AuthService,
-		callback: () => void,
-		log?: (...args: unknown[]) => void,
-	) {
-		this.context = context
-		this.authService = authService
-		this.log = log || console.log
-
-		this.timer = new RefreshTimer({
-			callback: async () => {
-				return await this.fetchSettings(callback)
-			},
-			successInterval: 30000,
-			initialBackoffMs: 1000,
-			maxBackoffMs: 30000,
-		})
-	}
-
-	public initialize(): void {
-		this.loadCachedSettings()
-
-		// Clear cached settings if we have missed a log out.
-		if (this.authService.getState() == "logged-out" && this.settings) {
-			this.removeSettings()
-		}
-
-		this.authService.on("active-session", () => {
-			this.timer.start()
-		})
-
-		this.authService.on("logged-out", () => {
-			this.timer.stop()
-			this.removeSettings()
-		})
-
-		if (this.authService.hasActiveSession()) {
-			this.timer.start()
-		}
-	}
-
-	private async fetchSettings(callback: () => void): Promise<boolean> {
-		const token = this.authService.getSessionToken()
-
-		if (!token) {
-			return false
-		}
-
-		try {
-			const response = await fetch(`${getRooCodeApiUrl()}/api/organization-settings`, {
-				headers: {
-					Authorization: `Bearer ${token}`,
-				},
-			})
-
-			if (!response.ok) {
-				this.log(
-					"[cloud-settings] Failed to fetch organization settings:",
-					response.status,
-					response.statusText,
-				)
-				return false
-			}
-
-			const data = await response.json()
-			const result = organizationSettingsSchema.safeParse(data)
-
-			if (!result.success) {
-				this.log("[cloud-settings] Invalid organization settings format:", result.error)
-				return false
-			}
-
-			const newSettings = result.data
-
-			if (!this.settings || this.settings.version !== newSettings.version) {
-				this.settings = newSettings
-				await this.cacheSettings()
-				callback()
-			}
-
-			return true
-		} catch (error) {
-			this.log("[cloud-settings] Error fetching organization settings:", error)
-			return false
-		}
-	}
-
-	private async cacheSettings(): Promise<void> {
-		await this.context.globalState.update(ORGANIZATION_SETTINGS_CACHE_KEY, this.settings)
-	}
-
-	private loadCachedSettings(): void {
-		this.settings = this.context.globalState.get<OrganizationSettings>(ORGANIZATION_SETTINGS_CACHE_KEY)
-	}
-
-	public getAllowList(): OrganizationAllowList {
-		return this.settings?.allowList || ORGANIZATION_ALLOW_ALL
-	}
-
-	public getSettings(): OrganizationSettings | undefined {
-		return this.settings
-	}
-
-	public async removeSettings(): Promise<void> {
-		this.settings = undefined
-		await this.cacheSettings()
-	}
-
-	public dispose(): void {
-		this.timer.stop()
-	}
+import type { OrganizationAllowList, OrganizationSettings } from "@roo-code/types"
+
+/**
+ * Interface for settings services that provide organization settings
+ */
+export interface SettingsService {
+	/**
+	 * Get the organization allow list
+	 * @returns The organization allow list or default if none available
+	 */
+	getAllowList(): OrganizationAllowList
+
+	/**
+	 * Get the current organization settings
+	 * @returns The organization settings or undefined if none available
+	 */
+	getSettings(): OrganizationSettings | undefined
+
+	/**
+	 * Dispose of the settings service and clean up resources
+	 */
+	dispose(): void
 }
 }

+ 41 - 0
packages/cloud/src/StaticSettingsService.ts

@@ -0,0 +1,41 @@
+import {
+	ORGANIZATION_ALLOW_ALL,
+	OrganizationAllowList,
+	OrganizationSettings,
+	organizationSettingsSchema,
+} from "@roo-code/types"
+
+import type { SettingsService } from "./SettingsService"
+
+export class StaticSettingsService implements SettingsService {
+	private settings: OrganizationSettings
+	private log: (...args: unknown[]) => void
+
+	constructor(envValue: string, log?: (...args: unknown[]) => void) {
+		this.log = log || console.log
+		this.settings = this.parseEnvironmentSettings(envValue)
+	}
+
+	private parseEnvironmentSettings(envValue: string): OrganizationSettings {
+		try {
+			const decodedValue = Buffer.from(envValue, "base64").toString("utf-8")
+			const parsedJson = JSON.parse(decodedValue)
+			return organizationSettingsSchema.parse(parsedJson)
+		} catch (error) {
+			this.log(`[StaticSettingsService] failed to parse static settings: ${error.message}`, error)
+			throw new Error("Failed to parse static settings", { cause: error })
+		}
+	}
+
+	public getAllowList(): OrganizationAllowList {
+		return this.settings?.allowList || ORGANIZATION_ALLOW_ALL
+	}
+
+	public getSettings(): OrganizationSettings | undefined {
+		return this.settings
+	}
+
+	public dispose(): void {
+		// No resources to clean up for static settings
+	}
+}

+ 1 - 1
packages/cloud/src/TelemetryClient.ts

@@ -8,7 +8,7 @@ import { BaseTelemetryClient } from "@roo-code/telemetry"
 
 
 import { getRooCodeApiUrl } from "./Config"
 import { getRooCodeApiUrl } from "./Config"
 import type { AuthService } from "./auth"
 import type { AuthService } from "./auth"
-import { SettingsService } from "./SettingsService"
+import type { SettingsService } from "./SettingsService"
 
 
 export class TelemetryClient extends BaseTelemetryClient {
 export class TelemetryClient extends BaseTelemetryClient {
 	constructor(
 	constructor(

+ 146 - 0
packages/cloud/src/__tests__/CloudService.integration.test.ts

@@ -0,0 +1,146 @@
+// npx vitest run src/__tests__/CloudService.integration.test.ts
+
+import * as vscode from "vscode"
+import { CloudService } from "../CloudService"
+import { StaticSettingsService } from "../StaticSettingsService"
+import { CloudSettingsService } from "../CloudSettingsService"
+
+vi.mock("vscode", () => ({
+	ExtensionContext: vi.fn(),
+	window: {
+		showInformationMessage: vi.fn(),
+		showErrorMessage: vi.fn(),
+	},
+	env: {
+		openExternal: vi.fn(),
+	},
+	Uri: {
+		parse: vi.fn(),
+	},
+}))
+
+describe("CloudService Integration - Settings Service Selection", () => {
+	let mockContext: vscode.ExtensionContext
+
+	beforeEach(() => {
+		CloudService.resetInstance()
+
+		mockContext = {
+			subscriptions: [],
+			workspaceState: {
+				get: vi.fn(),
+				update: vi.fn(),
+				keys: vi.fn().mockReturnValue([]),
+			},
+			secrets: {
+				get: vi.fn(),
+				store: vi.fn(),
+				delete: vi.fn(),
+				onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
+			},
+			globalState: {
+				get: vi.fn(),
+				update: vi.fn(),
+				setKeysForSync: vi.fn(),
+				keys: vi.fn().mockReturnValue([]),
+			},
+			extensionUri: { scheme: "file", path: "/mock/path" },
+			extensionPath: "/mock/path",
+			extensionMode: 1,
+			asAbsolutePath: vi.fn((relativePath: string) => `/mock/path/${relativePath}`),
+			storageUri: { scheme: "file", path: "/mock/storage" },
+			extension: {
+				packageJSON: {
+					version: "1.0.0",
+				},
+			},
+		} as unknown as vscode.ExtensionContext
+	})
+
+	afterEach(() => {
+		CloudService.resetInstance()
+		delete process.env.ROO_CODE_CLOUD_ORG_SETTINGS
+		delete process.env.ROO_CODE_CLOUD_TOKEN
+	})
+
+	it("should use CloudSettingsService when no environment variable is set", async () => {
+		// Ensure no environment variables are set
+		delete process.env.ROO_CODE_CLOUD_ORG_SETTINGS
+		delete process.env.ROO_CODE_CLOUD_TOKEN
+
+		const cloudService = await CloudService.createInstance(mockContext)
+
+		// Access the private settingsService to check its type
+		const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService
+		expect(settingsService).toBeInstanceOf(CloudSettingsService)
+	})
+
+	it("should use StaticSettingsService when ROO_CODE_CLOUD_ORG_SETTINGS is set", async () => {
+		const validSettings = {
+			version: 1,
+			cloudSettings: {
+				recordTaskMessages: true,
+				enableTaskSharing: true,
+				taskShareExpirationDays: 30,
+			},
+			defaultSettings: {
+				enableCheckpoints: true,
+			},
+			allowList: {
+				allowAll: true,
+				providers: {},
+			},
+		}
+
+		// Set the environment variable
+		process.env.ROO_CODE_CLOUD_ORG_SETTINGS = Buffer.from(JSON.stringify(validSettings)).toString("base64")
+
+		const cloudService = await CloudService.createInstance(mockContext)
+
+		// Access the private settingsService to check its type
+		const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService
+		expect(settingsService).toBeInstanceOf(StaticSettingsService)
+
+		// Verify the settings are correctly loaded
+		expect(cloudService.getAllowList()).toEqual(validSettings.allowList)
+	})
+
+	it("should throw error when ROO_CODE_CLOUD_ORG_SETTINGS contains invalid data", async () => {
+		// Set invalid environment variable
+		process.env.ROO_CODE_CLOUD_ORG_SETTINGS = "invalid-base64-data"
+
+		await expect(CloudService.createInstance(mockContext)).rejects.toThrow("Failed to initialize CloudService")
+	})
+
+	it("should prioritize static token auth when both environment variables are set", async () => {
+		const validSettings = {
+			version: 1,
+			cloudSettings: {
+				recordTaskMessages: true,
+				enableTaskSharing: true,
+				taskShareExpirationDays: 30,
+			},
+			defaultSettings: {
+				enableCheckpoints: true,
+			},
+			allowList: {
+				allowAll: true,
+				providers: {},
+			},
+		}
+
+		// Set both environment variables
+		process.env.ROO_CODE_CLOUD_TOKEN = "test-token"
+		process.env.ROO_CODE_CLOUD_ORG_SETTINGS = Buffer.from(JSON.stringify(validSettings)).toString("base64")
+
+		const cloudService = await CloudService.createInstance(mockContext)
+
+		// Should use StaticSettingsService for settings
+		const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService
+		expect(settingsService).toBeInstanceOf(StaticSettingsService)
+
+		// Should use StaticTokenAuthService for auth (from the existing logic)
+		expect(cloudService.isAuthenticated()).toBe(true)
+		expect(cloudService.hasActiveSession()).toBe(true)
+	})
+})

+ 4 - 4
packages/cloud/src/__tests__/CloudService.test.ts

@@ -5,7 +5,7 @@ import type { ClineMessage } from "@roo-code/types"
 
 
 import { CloudService } from "../CloudService"
 import { CloudService } from "../CloudService"
 import { WebAuthService } from "../auth/WebAuthService"
 import { WebAuthService } from "../auth/WebAuthService"
-import { SettingsService } from "../SettingsService"
+import { CloudSettingsService } from "../CloudSettingsService"
 import { ShareService, TaskNotFoundError } from "../ShareService"
 import { ShareService, TaskNotFoundError } from "../ShareService"
 import { TelemetryClient } from "../TelemetryClient"
 import { TelemetryClient } from "../TelemetryClient"
 import { TelemetryService } from "@roo-code/telemetry"
 import { TelemetryService } from "@roo-code/telemetry"
@@ -29,7 +29,7 @@ vi.mock("@roo-code/telemetry")
 
 
 vi.mock("../auth/WebAuthService")
 vi.mock("../auth/WebAuthService")
 
 
-vi.mock("../SettingsService")
+vi.mock("../CloudSettingsService")
 
 
 vi.mock("../ShareService")
 vi.mock("../ShareService")
 
 
@@ -150,7 +150,7 @@ describe("CloudService", () => {
 		}
 		}
 
 
 		vi.mocked(WebAuthService).mockImplementation(() => mockAuthService as unknown as WebAuthService)
 		vi.mocked(WebAuthService).mockImplementation(() => mockAuthService as unknown as WebAuthService)
-		vi.mocked(SettingsService).mockImplementation(() => mockSettingsService as unknown as SettingsService)
+		vi.mocked(CloudSettingsService).mockImplementation(() => mockSettingsService as unknown as CloudSettingsService)
 		vi.mocked(ShareService).mockImplementation(() => mockShareService as unknown as ShareService)
 		vi.mocked(ShareService).mockImplementation(() => mockShareService as unknown as ShareService)
 		vi.mocked(TelemetryClient).mockImplementation(() => mockTelemetryClient as unknown as TelemetryClient)
 		vi.mocked(TelemetryClient).mockImplementation(() => mockTelemetryClient as unknown as TelemetryClient)
 
 
@@ -176,7 +176,7 @@ describe("CloudService", () => {
 
 
 			expect(cloudService).toBeInstanceOf(CloudService)
 			expect(cloudService).toBeInstanceOf(CloudService)
 			expect(WebAuthService).toHaveBeenCalledWith(mockContext, expect.any(Function))
 			expect(WebAuthService).toHaveBeenCalledWith(mockContext, expect.any(Function))
-			expect(SettingsService).toHaveBeenCalledWith(
+			expect(CloudSettingsService).toHaveBeenCalledWith(
 				mockContext,
 				mockContext,
 				mockAuthService,
 				mockAuthService,
 				expect.any(Function),
 				expect.any(Function),

+ 102 - 0
packages/cloud/src/__tests__/StaticSettingsService.test.ts

@@ -0,0 +1,102 @@
+// npx vitest run src/__tests__/StaticSettingsService.test.ts
+
+import { StaticSettingsService } from "../StaticSettingsService"
+
+describe("StaticSettingsService", () => {
+	const validSettings = {
+		version: 1,
+		cloudSettings: {
+			recordTaskMessages: true,
+			enableTaskSharing: true,
+			taskShareExpirationDays: 30,
+		},
+		defaultSettings: {
+			enableCheckpoints: true,
+			maxOpenTabsContext: 10,
+		},
+		allowList: {
+			allowAll: false,
+			providers: {
+				anthropic: {
+					allowAll: true,
+				},
+			},
+		},
+	}
+
+	const validBase64 = Buffer.from(JSON.stringify(validSettings)).toString("base64")
+
+	describe("constructor", () => {
+		it("should parse valid base64 encoded JSON settings", () => {
+			const service = new StaticSettingsService(validBase64)
+			expect(service.getSettings()).toEqual(validSettings)
+		})
+
+		it("should throw error for invalid base64", () => {
+			expect(() => new StaticSettingsService("invalid-base64!@#")).toThrow("Failed to parse static settings")
+		})
+
+		it("should throw error for invalid JSON", () => {
+			const invalidJson = Buffer.from("{ invalid json }").toString("base64")
+			expect(() => new StaticSettingsService(invalidJson)).toThrow("Failed to parse static settings")
+		})
+
+		it("should throw error for invalid schema", () => {
+			const invalidSettings = { invalid: "schema" }
+			const invalidBase64 = Buffer.from(JSON.stringify(invalidSettings)).toString("base64")
+			expect(() => new StaticSettingsService(invalidBase64)).toThrow("Failed to parse static settings")
+		})
+	})
+
+	describe("getAllowList", () => {
+		it("should return the allow list from settings", () => {
+			const service = new StaticSettingsService(validBase64)
+			expect(service.getAllowList()).toEqual(validSettings.allowList)
+		})
+	})
+
+	describe("getSettings", () => {
+		it("should return the parsed settings", () => {
+			const service = new StaticSettingsService(validBase64)
+			expect(service.getSettings()).toEqual(validSettings)
+		})
+	})
+
+	describe("dispose", () => {
+		it("should be a no-op for static settings", () => {
+			const service = new StaticSettingsService(validBase64)
+			expect(() => service.dispose()).not.toThrow()
+		})
+	})
+
+	describe("logging", () => {
+		it("should use provided logger for errors", () => {
+			const mockLog = vi.fn()
+			expect(() => new StaticSettingsService("invalid-base64!@#", mockLog)).toThrow()
+
+			expect(mockLog).toHaveBeenCalledWith(
+				expect.stringContaining("[StaticSettingsService] failed to parse static settings:"),
+				expect.any(Error),
+			)
+		})
+
+		it("should use console.log as default logger for errors", () => {
+			const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {})
+			expect(() => new StaticSettingsService("invalid-base64!@#")).toThrow()
+
+			expect(consoleSpy).toHaveBeenCalledWith(
+				expect.stringContaining("[StaticSettingsService] failed to parse static settings:"),
+				expect.any(Error),
+			)
+
+			consoleSpy.mockRestore()
+		})
+
+		it("should not log anything for successful parsing", () => {
+			const mockLog = vi.fn()
+			new StaticSettingsService(validBase64, mockLog)
+
+			expect(mockLog).not.toHaveBeenCalled()
+		})
+	})
+})