From ad201cc05825d2eebe4d25020b02644d63582e72 Mon Sep 17 00:00:00 2001 From: John Richmond <5629+jr@users.noreply.github.com> Date: Sun, 6 Jul 2025 20:21:52 -0700 Subject: [PATCH] Cloud: support static cloud settings (#5435) Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- packages/cloud/src/CloudService.ts | 25 ++- packages/cloud/src/CloudSettingsService.ts | 136 ++++++++++++++++ packages/cloud/src/SettingsService.ts | 150 +++--------------- packages/cloud/src/StaticSettingsService.ts | 41 +++++ packages/cloud/src/TelemetryClient.ts | 2 +- .../CloudService.integration.test.ts | 146 +++++++++++++++++ .../cloud/src/__tests__/CloudService.test.ts | 8 +- .../__tests__/StaticSettingsService.test.ts | 102 ++++++++++++ 8 files changed, 466 insertions(+), 144 deletions(-) create mode 100644 packages/cloud/src/CloudSettingsService.ts create mode 100644 packages/cloud/src/StaticSettingsService.ts create mode 100644 packages/cloud/src/__tests__/CloudService.integration.test.ts create mode 100644 packages/cloud/src/__tests__/StaticSettingsService.test.ts diff --git a/packages/cloud/src/CloudService.ts b/packages/cloud/src/CloudService.ts index f48062a1a3..32ea443cd6 100644 --- a/packages/cloud/src/CloudService.ts +++ b/packages/cloud/src/CloudService.ts @@ -12,7 +12,9 @@ import { TelemetryService } from "@roo-code/telemetry" import { CloudServiceCallbacks } from "./types" import type { AuthService } 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 { ShareService, TaskNotFoundError } from "./ShareService" @@ -59,13 +61,20 @@ export class CloudService { this.authService.on("logged-out", 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) diff --git a/packages/cloud/src/CloudSettingsService.ts b/packages/cloud/src/CloudSettingsService.ts new file mode 100644 index 0000000000..6692d8141d --- /dev/null +++ b/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 { + 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 { + await this.context.globalState.update(ORGANIZATION_SETTINGS_CACHE_KEY, this.settings) + } + + private loadCachedSettings(): void { + this.settings = this.context.globalState.get(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 { + this.settings = undefined + await this.cacheSettings() + } + + public dispose(): void { + this.timer.stop() + } +} diff --git a/packages/cloud/src/SettingsService.ts b/packages/cloud/src/SettingsService.ts index f4c36e45b5..c1027dc25c 100644 --- a/packages/cloud/src/SettingsService.ts +++ b/packages/cloud/src/SettingsService.ts @@ -1,135 +1,23 @@ -import * as vscode from "vscode" +import type { OrganizationAllowList, OrganizationSettings } from "@roo-code/types" -import { - ORGANIZATION_ALLOW_ALL, - OrganizationAllowList, - OrganizationSettings, - organizationSettingsSchema, -} 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 -import { getRooCodeApiUrl } from "./Config" -import type { AuthService } from "./auth" -import { RefreshTimer } from "./RefreshTimer" + /** + * Get the current organization settings + * @returns The organization settings or undefined if none available + */ + getSettings(): OrganizationSettings | undefined -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 { - 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 { - await this.context.globalState.update(ORGANIZATION_SETTINGS_CACHE_KEY, this.settings) - } - - private loadCachedSettings(): void { - this.settings = this.context.globalState.get(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 { - this.settings = undefined - await this.cacheSettings() - } - - public dispose(): void { - this.timer.stop() - } + /** + * Dispose of the settings service and clean up resources + */ + dispose(): void } diff --git a/packages/cloud/src/StaticSettingsService.ts b/packages/cloud/src/StaticSettingsService.ts new file mode 100644 index 0000000000..3aac37bda5 --- /dev/null +++ b/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 + } +} diff --git a/packages/cloud/src/TelemetryClient.ts b/packages/cloud/src/TelemetryClient.ts index de37ea503b..e33843a30c 100644 --- a/packages/cloud/src/TelemetryClient.ts +++ b/packages/cloud/src/TelemetryClient.ts @@ -8,7 +8,7 @@ import { BaseTelemetryClient } from "@roo-code/telemetry" import { getRooCodeApiUrl } from "./Config" import type { AuthService } from "./auth" -import { SettingsService } from "./SettingsService" +import type { SettingsService } from "./SettingsService" export class TelemetryClient extends BaseTelemetryClient { constructor( diff --git a/packages/cloud/src/__tests__/CloudService.integration.test.ts b/packages/cloud/src/__tests__/CloudService.integration.test.ts new file mode 100644 index 0000000000..f3cef27718 --- /dev/null +++ b/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) + }) +}) diff --git a/packages/cloud/src/__tests__/CloudService.test.ts b/packages/cloud/src/__tests__/CloudService.test.ts index 5320a51864..1384b6de6b 100644 --- a/packages/cloud/src/__tests__/CloudService.test.ts +++ b/packages/cloud/src/__tests__/CloudService.test.ts @@ -5,7 +5,7 @@ import type { ClineMessage } from "@roo-code/types" import { CloudService } from "../CloudService" import { WebAuthService } from "../auth/WebAuthService" -import { SettingsService } from "../SettingsService" +import { CloudSettingsService } from "../CloudSettingsService" import { ShareService, TaskNotFoundError } from "../ShareService" import { TelemetryClient } from "../TelemetryClient" import { TelemetryService } from "@roo-code/telemetry" @@ -29,7 +29,7 @@ vi.mock("@roo-code/telemetry") vi.mock("../auth/WebAuthService") -vi.mock("../SettingsService") +vi.mock("../CloudSettingsService") vi.mock("../ShareService") @@ -150,7 +150,7 @@ describe("CloudService", () => { } 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(TelemetryClient).mockImplementation(() => mockTelemetryClient as unknown as TelemetryClient) @@ -176,7 +176,7 @@ describe("CloudService", () => { expect(cloudService).toBeInstanceOf(CloudService) expect(WebAuthService).toHaveBeenCalledWith(mockContext, expect.any(Function)) - expect(SettingsService).toHaveBeenCalledWith( + expect(CloudSettingsService).toHaveBeenCalledWith( mockContext, mockAuthService, expect.any(Function), diff --git a/packages/cloud/src/__tests__/StaticSettingsService.test.ts b/packages/cloud/src/__tests__/StaticSettingsService.test.ts new file mode 100644 index 0000000000..26c0ada9cd --- /dev/null +++ b/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() + }) + }) +})