Merge branch 'RooCodeInc:main' into main

This commit is contained in:
Murilo Pires 2025-07-07 12:18:29 -03:00 committed by GitHub
commit ab10140c23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 478 additions and 153 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()
})
})
})

View file

@ -190,10 +190,8 @@ export const parseOpenRouterModel = ({
const supportsPromptCache = typeof cacheWritesPrice !== "undefined" && typeof cacheReadsPrice !== "undefined"
const useMaxTokens = OPEN_ROUTER_REASONING_BUDGET_MODELS.has(id) || id.startsWith("anthropic/")
const modelInfo: ModelInfo = {
maxTokens: useMaxTokens ? maxTokens || 0 : 0,
maxTokens: maxTokens || Math.ceil(model.context_length * 0.2),
contextWindow: model.context_length,
supportsImages: modality?.includes("image") ?? false,
supportsPromptCache,

View file

@ -26,6 +26,7 @@ If asked to create a project mode, create it in .roomodes in the workspace root.
* groups: Array of allowed tool groups (can be empty). Each group can be specified either as a string (e.g., "edit" to allow editing any file) or with file restrictions (e.g., ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }] to only allow editing markdown files)
- The following fields are optional but highly recommended:
* description: A short, human-readable description of what this mode does (5 words)
* whenToUse: A clear description of when this mode should be selected and what types of tasks it's best suited for. This helps the Orchestrator mode make better decisions.
* customInstructions: Additional instructions for how the mode should operate
@ -36,6 +37,7 @@ Both files should follow this structure (in YAML format):
customModes:
- slug: designer # Required: unique slug with lowercase letters, numbers, and hyphens
name: Designer # Required: mode display name
description: UI/UX design systems expert # Optional but recommended: short description (5 words)
roleDefinition: >-
You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes:
- Creating and maintaining design systems
@ -43,8 +45,8 @@ customModes:
- Working with CSS, HTML, and modern frontend frameworks
- Ensuring consistent user experiences across platforms # Required: non-empty
whenToUse: >-
Use this mode when creating or modifying UI components, implementing design systems,
or ensuring responsive web interfaces. This mode is especially effective with CSS,
Use this mode when creating or modifying UI components, implementing design systems,
or ensuring responsive web interfaces. This mode is especially effective with CSS,
HTML, and modern frontend frameworks. # Optional but recommended
groups: # Required: array of tool groups (can be empty)
- read # Read files group (read_file, fetch_instructions, search_files, list_files, list_code_definition_names)

View file

@ -22,7 +22,7 @@ export async function newTaskTool(
const partialMessage = JSON.stringify({
tool: "newTask",
mode: removeClosingTag("mode", mode),
message: removeClosingTag("message", message),
content: removeClosingTag("message", message),
})
await cline.ask("tool", partialMessage, block.partial).catch(() => {})

View file

@ -66,7 +66,7 @@ describe("getMaxTokensForModel", () => {
expect(getModelMaxOutputTokens({ modelId, model, settings })).toBe(8000)
})
it("should return undefined for non-thinking models with undefined maxTokens", () => {
it("should return 20% of context window for non-thinking models with undefined maxTokens", () => {
const model: ModelInfo = {
contextWindow: 200_000,
supportsPromptCache: true,
@ -76,7 +76,8 @@ describe("getMaxTokensForModel", () => {
modelMaxTokens: 4000,
}
expect(getModelMaxOutputTokens({ modelId, model, settings })).toBeUndefined()
// Should return 20% of context window when maxTokens is undefined
expect(getModelMaxOutputTokens({ modelId, model, settings })).toBe(40000)
})
test("should return maxTokens from modelInfo when thinking is false", () => {

View file

@ -71,7 +71,9 @@ export const getModelMaxOutputTokens = ({
return ANTHROPIC_DEFAULT_MAX_TOKENS
}
return model.maxTokens ?? undefined
// If maxTokens is 0 or undefined, fall back to 20% of context window
// This matches the sliding window logic
return model.maxTokens || Math.ceil(model.contextWindow * 0.2)
}
// GetModelsOptions