Cloud: support static cloud settings (#5435)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
John Richmond 2025-07-06 20:21:52 -07:00 committed by GitHub
parent 25be23354a
commit ad201cc058
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 466 additions and 144 deletions

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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<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()
}
/**
* Dispose of the settings service and clean up resources
*/
dispose(): void
}

View file

@ -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
}
}

View file

@ -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(

View file

@ -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)
})
})

View file

@ -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),

View file

@ -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()
})
})
})