diff --git a/packages/cloud/eslint.config.mjs b/packages/cloud/eslint.config.mjs new file mode 100644 index 0000000000..694bf73664 --- /dev/null +++ b/packages/cloud/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@roo-code/config-eslint/base" + +/** @type {import("eslint").Linter.Config} */ +export default [...config] diff --git a/packages/cloud/package.json b/packages/cloud/package.json new file mode 100644 index 0000000000..d67b5ae7eb --- /dev/null +++ b/packages/cloud/package.json @@ -0,0 +1,25 @@ +{ + "name": "@roo-code/cloud", + "description": "Roo Code Cloud VSCode integration.", + "version": "0.0.0", + "type": "module", + "exports": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext=ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "clean": "rimraf dist .turbo" + }, + "dependencies": { + "@roo-code/telemetry": "workspace:^", + "@roo-code/types": "workspace:^", + "zod": "^3.25.61" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "20.x", + "@types/vscode": "^1.84.0", + "vitest": "^3.2.3" + } +} diff --git a/packages/cloud/src/CloudAPI.ts b/packages/cloud/src/CloudAPI.ts new file mode 100644 index 0000000000..52c3c2521d --- /dev/null +++ b/packages/cloud/src/CloudAPI.ts @@ -0,0 +1,122 @@ +import { type ShareVisibility, type ShareResponse, shareResponseSchema } from "@roo-code/types" + +import { getRooCodeApiUrl } from "./config" +import type { AuthService } from "./auth" +import { getUserAgent } from "./utils" +import { AuthenticationError, CloudAPIError, NetworkError, TaskNotFoundError } from "./errors" + +interface CloudAPIRequestOptions extends Omit { + timeout?: number + headers?: Record +} + +export class CloudAPI { + private authService: AuthService + private log: (...args: unknown[]) => void + private baseUrl: string + + constructor(authService: AuthService, log?: (...args: unknown[]) => void) { + this.authService = authService + this.log = log || console.log + this.baseUrl = getRooCodeApiUrl() + } + + private async request( + endpoint: string, + options: CloudAPIRequestOptions & { + parseResponse?: (data: unknown) => T + } = {}, + ): Promise { + const { timeout = 10000, parseResponse, headers = {}, ...fetchOptions } = options + + const sessionToken = this.authService.getSessionToken() + + if (!sessionToken) { + throw new AuthenticationError() + } + + const url = `${this.baseUrl}${endpoint}` + + const requestHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${sessionToken}`, + "User-Agent": getUserAgent(), + ...headers, + } + + try { + const response = await fetch(url, { + ...fetchOptions, + headers: requestHeaders, + signal: AbortSignal.timeout(timeout), + }) + + if (!response.ok) { + await this.handleErrorResponse(response, endpoint) + } + + const data = await response.json() + + if (parseResponse) { + return parseResponse(data) + } + + return data as T + } catch (error) { + if (error instanceof TypeError && error.message.includes("fetch")) { + throw new NetworkError(`Network error while calling ${endpoint}`) + } + + if (error instanceof CloudAPIError) { + throw error + } + + if (error instanceof Error && error.name === "AbortError") { + throw new CloudAPIError(`Request to ${endpoint} timed out`, undefined, undefined) + } + + throw new CloudAPIError( + `Unexpected error while calling ${endpoint}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private async handleErrorResponse(response: Response, endpoint: string): Promise { + let responseBody: unknown + + try { + responseBody = await response.json() + } catch { + responseBody = await response.text() + } + + switch (response.status) { + case 401: + throw new AuthenticationError() + case 404: + if (endpoint.includes("/share")) { + throw new TaskNotFoundError() + } + throw new CloudAPIError(`Resource not found: ${endpoint}`, 404, responseBody) + default: + throw new CloudAPIError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + responseBody, + ) + } + } + + async shareTask(taskId: string, visibility: ShareVisibility = "organization"): Promise { + this.log(`[CloudAPI] Sharing task ${taskId} with visibility: ${visibility}`) + + const response = await this.request("/api/extension/share", { + method: "POST", + body: JSON.stringify({ taskId, visibility }), + parseResponse: (data) => shareResponseSchema.parse(data), + }) + + this.log("[CloudAPI] Share response:", response) + return response + } +} diff --git a/packages/cloud/src/CloudService.ts b/packages/cloud/src/CloudService.ts new file mode 100644 index 0000000000..7777d6b220 --- /dev/null +++ b/packages/cloud/src/CloudService.ts @@ -0,0 +1,288 @@ +import * as vscode from "vscode" +import EventEmitter from "events" + +import type { + CloudUserInfo, + TelemetryEvent, + OrganizationAllowList, + OrganizationSettings, + ClineMessage, + ShareVisibility, +} from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { CloudServiceEvents } from "./types" +import { TaskNotFoundError } from "./errors" +import type { AuthService } from "./auth" +import { WebAuthService, StaticTokenAuthService } from "./auth" +import type { SettingsService } from "./SettingsService" +import { CloudSettingsService } from "./CloudSettingsService" +import { StaticSettingsService } from "./StaticSettingsService" +import { TelemetryClient } from "./TelemetryClient" +import { CloudShareService } from "./CloudShareService" +import { CloudAPI } from "./CloudAPI" + +type AuthStateChangedPayload = CloudServiceEvents["auth-state-changed"][0] +type AuthUserInfoPayload = CloudServiceEvents["user-info"][0] +type SettingsPayload = CloudServiceEvents["settings-updated"][0] + +export class CloudService extends EventEmitter implements vscode.Disposable { + private static _instance: CloudService | null = null + + private context: vscode.ExtensionContext + private authStateListener: (data: AuthStateChangedPayload) => void + private authUserInfoListener: (data: AuthUserInfoPayload) => void + private authService: AuthService | null = null + private settingsListener: (data: SettingsPayload) => void + private settingsService: SettingsService | null = null + private telemetryClient: TelemetryClient | null = null + private shareService: CloudShareService | null = null + private cloudAPI: CloudAPI | null = null + private isInitialized = false + private log: (...args: unknown[]) => void + + private constructor(context: vscode.ExtensionContext, log?: (...args: unknown[]) => void) { + super() + + this.context = context + this.log = log || console.log + this.authStateListener = (data: AuthStateChangedPayload) => { + this.emit("auth-state-changed", data) + } + this.authUserInfoListener = (data: AuthUserInfoPayload) => { + this.emit("user-info", data) + } + this.settingsListener = (data: SettingsPayload) => { + this.emit("settings-updated", data) + } + } + + public async initialize(): Promise { + if (this.isInitialized) { + return + } + + try { + const cloudToken = process.env.ROO_CODE_CLOUD_TOKEN + + if (cloudToken && cloudToken.length > 0) { + this.authService = new StaticTokenAuthService(this.context, cloudToken, this.log) + } else { + this.authService = new WebAuthService(this.context, this.log) + } + + await this.authService.initialize() + + this.authService.on("auth-state-changed", this.authStateListener) + this.authService.on("user-info", this.authUserInfoListener) + + // 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.log) + cloudSettingsService.initialize() + + cloudSettingsService.on("settings-updated", this.settingsListener) + + this.settingsService = cloudSettingsService + } + + this.cloudAPI = new CloudAPI(this.authService, this.log) + this.telemetryClient = new TelemetryClient(this.authService, this.settingsService) + this.shareService = new CloudShareService(this.cloudAPI, this.settingsService, this.log) + + try { + TelemetryService.instance.register(this.telemetryClient) + } catch (error) { + this.log("[CloudService] Failed to register TelemetryClient:", error) + } + + this.isInitialized = true + } catch (error) { + this.log("[CloudService] Failed to initialize:", error) + throw new Error(`Failed to initialize CloudService: ${error}`) + } + } + + // AuthService + + public async login(): Promise { + this.ensureInitialized() + return this.authService!.login() + } + + public async logout(): Promise { + this.ensureInitialized() + return this.authService!.logout() + } + + public isAuthenticated(): boolean { + this.ensureInitialized() + return this.authService!.isAuthenticated() + } + + public hasActiveSession(): boolean { + this.ensureInitialized() + return this.authService!.hasActiveSession() + } + + public hasOrIsAcquiringActiveSession(): boolean { + this.ensureInitialized() + return this.authService!.hasOrIsAcquiringActiveSession() + } + + public getUserInfo(): CloudUserInfo | null { + this.ensureInitialized() + return this.authService!.getUserInfo() + } + + public getOrganizationId(): string | null { + this.ensureInitialized() + const userInfo = this.authService!.getUserInfo() + return userInfo?.organizationId || null + } + + public getOrganizationName(): string | null { + this.ensureInitialized() + const userInfo = this.authService!.getUserInfo() + return userInfo?.organizationName || null + } + + public getOrganizationRole(): string | null { + this.ensureInitialized() + const userInfo = this.authService!.getUserInfo() + return userInfo?.organizationRole || null + } + + public hasStoredOrganizationId(): boolean { + this.ensureInitialized() + return this.authService!.getStoredOrganizationId() !== null + } + + public getStoredOrganizationId(): string | null { + this.ensureInitialized() + return this.authService!.getStoredOrganizationId() + } + + public getAuthState(): string { + this.ensureInitialized() + return this.authService!.getState() + } + + public async handleAuthCallback( + code: string | null, + state: string | null, + organizationId?: string | null, + ): Promise { + this.ensureInitialized() + return this.authService!.handleCallback(code, state, organizationId) + } + + // SettingsService + + public getAllowList(): OrganizationAllowList { + this.ensureInitialized() + return this.settingsService!.getAllowList() + } + + public getOrganizationSettings(): OrganizationSettings | undefined { + this.ensureInitialized() + return this.settingsService!.getSettings() + } + + // TelemetryClient + + public captureEvent(event: TelemetryEvent): void { + this.ensureInitialized() + this.telemetryClient!.capture(event) + } + + // ShareService + + public async shareTask( + taskId: string, + visibility: ShareVisibility = "organization", + clineMessages?: ClineMessage[], + ) { + this.ensureInitialized() + + try { + return await this.shareService!.shareTask(taskId, visibility) + } catch (error) { + if (error instanceof TaskNotFoundError && clineMessages) { + // Backfill messages and retry. + await this.telemetryClient!.backfillMessages(clineMessages, taskId) + return await this.shareService!.shareTask(taskId, visibility) + } + throw error + } + } + + public async canShareTask(): Promise { + this.ensureInitialized() + return this.shareService!.canShareTask() + } + + // Lifecycle + + public dispose(): void { + if (this.authService) { + this.authService.off("auth-state-changed", this.authStateListener) + this.authService.off("user-info", this.authUserInfoListener) + } + + if (this.settingsService) { + if (this.settingsService instanceof CloudSettingsService) { + this.settingsService.off("settings-updated", this.settingsListener) + } + this.settingsService.dispose() + } + + this.isInitialized = false + } + + private ensureInitialized(): void { + if (!this.isInitialized) { + throw new Error("CloudService not initialized.") + } + } + + static get instance(): CloudService { + if (!this._instance) { + throw new Error("CloudService not initialized") + } + + return this._instance + } + + static async createInstance( + context: vscode.ExtensionContext, + log?: (...args: unknown[]) => void, + ): Promise { + if (this._instance) { + throw new Error("CloudService instance already created") + } + + this._instance = new CloudService(context, log) + await this._instance.initialize() + return this._instance + } + + static hasInstance(): boolean { + return this._instance !== null && this._instance.isInitialized + } + + static resetInstance(): void { + if (this._instance) { + this._instance.dispose() + this._instance = null + } + } + + static isEnabled(): boolean { + return !!this._instance?.isAuthenticated() + } +} diff --git a/packages/cloud/src/CloudSettingsService.ts b/packages/cloud/src/CloudSettingsService.ts new file mode 100644 index 0000000000..c842d800fc --- /dev/null +++ b/packages/cloud/src/CloudSettingsService.ts @@ -0,0 +1,152 @@ +import * as vscode from "vscode" +import EventEmitter from "events" + +import { + ORGANIZATION_ALLOW_ALL, + OrganizationAllowList, + OrganizationSettings, + organizationSettingsSchema, +} from "@roo-code/types" + +import { getRooCodeApiUrl } from "./config" +import type { AuthService, AuthState } from "./auth" +import { RefreshTimer } from "./RefreshTimer" +import type { SettingsService } from "./SettingsService" + +const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings" + +export interface SettingsServiceEvents { + "settings-updated": [ + data: { + settings: OrganizationSettings + previousSettings: OrganizationSettings | undefined + }, + ] +} + +export class CloudSettingsService extends EventEmitter 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, log?: (...args: unknown[]) => void) { + super() + + this.context = context + this.authService = authService + this.log = log || console.log + + this.timer = new RefreshTimer({ + callback: async () => { + return await this.fetchSettings() + }, + 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("auth-state-changed", (data: { state: AuthState; previousState: AuthState }) => { + if (data.state === "active-session") { + this.timer.start() + } else if (data.previousState === "active-session") { + this.timer.stop() + + if (data.state === "logged-out") { + this.removeSettings() + } + } + }) + + if (this.authService.hasActiveSession()) { + this.timer.start() + } + } + + private async fetchSettings(): 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) { + const previousSettings = this.settings + this.settings = newSettings + await this.cacheSettings() + + this.emit("settings-updated", { + settings: this.settings, + previousSettings, + }) + } + + 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.removeAllListeners() + this.timer.stop() + } +} diff --git a/packages/cloud/src/CloudShareService.ts b/packages/cloud/src/CloudShareService.ts new file mode 100644 index 0000000000..91e0f6aa3f --- /dev/null +++ b/packages/cloud/src/CloudShareService.ts @@ -0,0 +1,43 @@ +import * as vscode from "vscode" + +import type { ShareResponse, ShareVisibility } from "@roo-code/types" + +import type { CloudAPI } from "./CloudAPI" +import type { SettingsService } from "./SettingsService" + +export class CloudShareService { + private cloudAPI: CloudAPI + private settingsService: SettingsService + private log: (...args: unknown[]) => void + + constructor(cloudAPI: CloudAPI, settingsService: SettingsService, log?: (...args: unknown[]) => void) { + this.cloudAPI = cloudAPI + this.settingsService = settingsService + this.log = log || console.log + } + + async shareTask(taskId: string, visibility: ShareVisibility = "organization"): Promise { + try { + const response = await this.cloudAPI.shareTask(taskId, visibility) + + if (response.success && response.shareUrl) { + // Copy to clipboard. + await vscode.env.clipboard.writeText(response.shareUrl) + } + + return response + } catch (error) { + this.log("[ShareService] Error sharing task:", error) + throw error + } + } + + async canShareTask(): Promise { + try { + return !!this.settingsService.getSettings()?.cloudSettings?.enableTaskSharing + } catch (error) { + this.log("[ShareService] Error checking if task can be shared:", error) + return false + } + } +} diff --git a/packages/cloud/src/RefreshTimer.ts b/packages/cloud/src/RefreshTimer.ts new file mode 100644 index 0000000000..e7294222d7 --- /dev/null +++ b/packages/cloud/src/RefreshTimer.ts @@ -0,0 +1,154 @@ +/** + * RefreshTimer - A utility for executing a callback with configurable retry behavior + * + * This timer executes a callback function and schedules the next execution based on the result: + * - If the callback succeeds (returns true), it schedules the next attempt after a fixed interval + * - If the callback fails (returns false), it uses exponential backoff up to a maximum interval + */ + +/** + * Configuration options for the RefreshTimer + */ +export interface RefreshTimerOptions { + /** + * The callback function to execute + * Should return a Promise that resolves to a boolean indicating success (true) or failure (false) + */ + callback: () => Promise + + /** + * Time in milliseconds to wait before next attempt after success + * @default 50000 (50 seconds) + */ + successInterval?: number + + /** + * Initial backoff time in milliseconds for the first failure + * @default 1000 (1 second) + */ + initialBackoffMs?: number + + /** + * Maximum backoff time in milliseconds + * @default 300000 (5 minutes) + */ + maxBackoffMs?: number +} + +/** + * A timer utility that executes a callback with configurable retry behavior + */ +export class RefreshTimer { + private callback: () => Promise + private successInterval: number + private initialBackoffMs: number + private maxBackoffMs: number + private currentBackoffMs: number + private attemptCount: number + private timerId: NodeJS.Timeout | null + private isRunning: boolean + + /** + * Creates a new RefreshTimer + * + * @param options Configuration options for the timer + */ + constructor(options: RefreshTimerOptions) { + this.callback = options.callback + this.successInterval = options.successInterval ?? 50000 // 50 seconds + this.initialBackoffMs = options.initialBackoffMs ?? 1000 // 1 second + this.maxBackoffMs = options.maxBackoffMs ?? 300000 // 5 minutes + this.currentBackoffMs = this.initialBackoffMs + this.attemptCount = 0 + this.timerId = null + this.isRunning = false + } + + /** + * Starts the timer and executes the callback immediately + */ + public start(): void { + if (this.isRunning) { + return + } + + this.isRunning = true + + // Execute the callback immediately + this.executeCallback() + } + + /** + * Stops the timer and cancels any pending execution + */ + public stop(): void { + if (!this.isRunning) { + return + } + + if (this.timerId) { + clearTimeout(this.timerId) + this.timerId = null + } + + this.isRunning = false + } + + /** + * Resets the backoff state and attempt count + * Does not affect whether the timer is running + */ + public reset(): void { + this.currentBackoffMs = this.initialBackoffMs + this.attemptCount = 0 + } + + /** + * Schedules the next attempt based on the success/failure of the current attempt + * + * @param wasSuccessful Whether the current attempt was successful + */ + private scheduleNextAttempt(wasSuccessful: boolean): void { + if (!this.isRunning) { + return + } + + if (wasSuccessful) { + // Reset backoff on success + this.currentBackoffMs = this.initialBackoffMs + this.attemptCount = 0 + + this.timerId = setTimeout(() => this.executeCallback(), this.successInterval) + } else { + // Increment attempt count + this.attemptCount++ + + // Calculate backoff time with exponential increase + // Formula: initialBackoff * 2^(attemptCount - 1) + this.currentBackoffMs = Math.min( + this.initialBackoffMs * Math.pow(2, this.attemptCount - 1), + this.maxBackoffMs, + ) + + this.timerId = setTimeout(() => this.executeCallback(), this.currentBackoffMs) + } + } + + /** + * Executes the callback and handles the result + */ + private async executeCallback(): Promise { + if (!this.isRunning) { + return + } + + try { + const result = await this.callback() + + this.scheduleNextAttempt(result) + } catch (_error) { + // Treat errors as failed attempts + this.scheduleNextAttempt(false) + } + } +} diff --git a/packages/cloud/src/SettingsService.ts b/packages/cloud/src/SettingsService.ts new file mode 100644 index 0000000000..c1027dc25c --- /dev/null +++ b/packages/cloud/src/SettingsService.ts @@ -0,0 +1,23 @@ +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 +} diff --git a/packages/cloud/src/StaticSettingsService.ts b/packages/cloud/src/StaticSettingsService.ts new file mode 100644 index 0000000000..97e6cf7ea8 --- /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 new file mode 100644 index 0000000000..727da03432 --- /dev/null +++ b/packages/cloud/src/TelemetryClient.ts @@ -0,0 +1,169 @@ +import { + TelemetryEventName, + type TelemetryEvent, + rooCodeTelemetryEventSchema, + type ClineMessage, +} from "@roo-code/types" +import { BaseTelemetryClient } from "@roo-code/telemetry" + +import { getRooCodeApiUrl } from "./config" +import type { AuthService } from "./auth" +import type { SettingsService } from "./SettingsService" + +export class TelemetryClient extends BaseTelemetryClient { + constructor( + private authService: AuthService, + private settingsService: SettingsService, + debug = false, + ) { + super( + { + type: "exclude", + events: [TelemetryEventName.TASK_CONVERSATION_MESSAGE], + }, + debug, + ) + } + + private async fetch(path: string, options: RequestInit) { + if (!this.authService.isAuthenticated()) { + return + } + + const token = this.authService.getSessionToken() + + if (!token) { + console.error(`[TelemetryClient#fetch] Unauthorized: No session token available.`) + return + } + + const response = await fetch(`${getRooCodeApiUrl()}/api/${path}`, { + ...options, + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + }) + + if (!response.ok) { + console.error( + `[TelemetryClient#fetch] ${options.method} ${path} -> ${response.status} ${response.statusText}`, + ) + } + } + + public override async capture(event: TelemetryEvent) { + if (!this.isTelemetryEnabled() || !this.isEventCapturable(event.event)) { + if (this.debug) { + console.info(`[TelemetryClient#capture] Skipping event: ${event.event}`) + } + + return + } + + const payload = { + type: event.event, + properties: await this.getEventProperties(event), + } + + if (this.debug) { + console.info(`[TelemetryClient#capture] ${JSON.stringify(payload)}`) + } + + const result = rooCodeTelemetryEventSchema.safeParse(payload) + + if (!result.success) { + console.error( + `[TelemetryClient#capture] Invalid telemetry event: ${result.error.message} - ${JSON.stringify(payload)}`, + ) + + return + } + + try { + await this.fetch(`events`, { method: "POST", body: JSON.stringify(result.data) }) + } catch (error) { + console.error(`[TelemetryClient#capture] Error sending telemetry event: ${error}`) + } + } + + public async backfillMessages(messages: ClineMessage[], taskId: string): Promise { + if (!this.authService.isAuthenticated()) { + if (this.debug) { + console.info(`[TelemetryClient#backfillMessages] Skipping: Not authenticated`) + } + return + } + + const token = this.authService.getSessionToken() + + if (!token) { + console.error(`[TelemetryClient#backfillMessages] Unauthorized: No session token available.`) + return + } + + try { + const mergedProperties = await this.getEventProperties({ + event: TelemetryEventName.TASK_MESSAGE, + properties: { taskId }, + }) + + const formData = new FormData() + formData.append("taskId", taskId) + formData.append("properties", JSON.stringify(mergedProperties)) + + formData.append( + "file", + new File([JSON.stringify(messages)], "task.json", { + type: "application/json", + }), + ) + + if (this.debug) { + console.info( + `[TelemetryClient#backfillMessages] Uploading ${messages.length} messages for task ${taskId}`, + ) + } + + // Custom fetch for multipart - don't set Content-Type header (let browser set it) + const response = await fetch(`${getRooCodeApiUrl()}/api/events/backfill`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + // Note: No Content-Type header - browser will set multipart/form-data with boundary + }, + body: formData, + }) + + if (!response.ok) { + console.error( + `[TelemetryClient#backfillMessages] POST events/backfill -> ${response.status} ${response.statusText}`, + ) + } else if (this.debug) { + console.info(`[TelemetryClient#backfillMessages] Successfully uploaded messages for task ${taskId}`) + } + } catch (error) { + console.error(`[TelemetryClient#backfillMessages] Error uploading messages: ${error}`) + } + } + + public override updateTelemetryState(_didUserOptIn: boolean) {} + + public override isTelemetryEnabled(): boolean { + return true + } + + protected override isEventCapturable(eventName: TelemetryEventName): boolean { + // Ensure that this event type is supported by the telemetry client + if (!super.isEventCapturable(eventName)) { + return false + } + + // Only record message telemetry if a cloud account is present and explicitly configured to record messages + if (eventName === TelemetryEventName.TASK_MESSAGE) { + return this.settingsService.getSettings()?.cloudSettings?.recordTaskMessages || false + } + + // Other telemetry types are capturable at this point + return true + } + + public override async shutdown() {} +} diff --git a/packages/cloud/src/__mocks__/vscode.ts b/packages/cloud/src/__mocks__/vscode.ts new file mode 100644 index 0000000000..ac9082375e --- /dev/null +++ b/packages/cloud/src/__mocks__/vscode.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export const window = { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), +} + +export const env = { + openExternal: vi.fn(), +} + +export const Uri = { + parse: vi.fn((uri: string) => ({ toString: () => uri })), +} + +export interface ExtensionContext { + secrets: { + get: (key: string) => Promise + store: (key: string, value: string) => Promise + delete: (key: string) => Promise + onDidChange: (listener: (e: { key: string }) => void) => { dispose: () => void } + } + globalState: { + get: (key: string) => T | undefined + update: (key: string, value: any) => Promise + } + subscriptions: any[] + extension?: { + packageJSON?: { + version?: string + publisher?: string + name?: string + } + } +} + +// Mock implementation for tests +export const mockExtensionContext: ExtensionContext = { + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + globalState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + }, + subscriptions: [], + extension: { + packageJSON: { + version: "1.0.0", + publisher: "RooVeterinaryInc", + name: "roo-cline", + }, + }, +} 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 new file mode 100644 index 0000000000..607b21de34 --- /dev/null +++ b/packages/cloud/src/__tests__/CloudService.test.ts @@ -0,0 +1,604 @@ +// npx vitest run src/__tests__/CloudService.test.ts + +import * as vscode from "vscode" + +import type { ClineMessage } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { CloudService } from "../CloudService" +import { WebAuthService } from "../auth/WebAuthService" +import { CloudSettingsService } from "../CloudSettingsService" +import { CloudShareService } from "../CloudShareService" +import { TelemetryClient } from "../TelemetryClient" +import { TaskNotFoundError } from "../errors" + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, + env: { + openExternal: vi.fn(), + }, + Uri: { + parse: vi.fn(), + }, +})) + +vi.mock("@roo-code/telemetry") + +vi.mock("../auth/WebAuthService") + +vi.mock("../CloudSettingsService") + +vi.mock("../CloudShareService") + +vi.mock("../TelemetryClient") + +describe("CloudService", () => { + let mockContext: vscode.ExtensionContext + let mockAuthService: { + initialize: ReturnType + login: ReturnType + logout: ReturnType + isAuthenticated: ReturnType + hasActiveSession: ReturnType + hasOrIsAcquiringActiveSession: ReturnType + getUserInfo: ReturnType + getState: ReturnType + getSessionToken: ReturnType + handleCallback: ReturnType + getStoredOrganizationId: ReturnType + on: ReturnType + off: ReturnType + once: ReturnType + emit: ReturnType + } + let mockSettingsService: { + initialize: ReturnType + getSettings: ReturnType + getAllowList: ReturnType + dispose: ReturnType + on: ReturnType + off: ReturnType + } + let mockShareService: { + shareTask: ReturnType + canShareTask: ReturnType + } + let mockTelemetryClient: { + backfillMessages: ReturnType + } + let mockTelemetryService: { + hasInstance: ReturnType + instance: { + register: ReturnType + } + } + + 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 + + mockAuthService = { + initialize: vi.fn().mockResolvedValue(undefined), + login: vi.fn(), + logout: vi.fn(), + isAuthenticated: vi.fn().mockReturnValue(false), + hasActiveSession: vi.fn().mockReturnValue(false), + hasOrIsAcquiringActiveSession: vi.fn().mockReturnValue(false), + getUserInfo: vi.fn(), + getState: vi.fn().mockReturnValue("logged-out"), + getSessionToken: vi.fn(), + handleCallback: vi.fn(), + getStoredOrganizationId: vi.fn().mockReturnValue(null), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + emit: vi.fn(), + } + + mockSettingsService = { + initialize: vi.fn(), + getSettings: vi.fn(), + getAllowList: vi.fn(), + dispose: vi.fn(), + on: vi.fn(), + off: vi.fn(), + } + + mockShareService = { + shareTask: vi.fn(), + canShareTask: vi.fn().mockResolvedValue(true), + } + + mockTelemetryClient = { + backfillMessages: vi.fn().mockResolvedValue(undefined), + } + + mockTelemetryService = { + hasInstance: vi.fn().mockReturnValue(true), + instance: { + register: vi.fn(), + }, + } + + vi.mocked(WebAuthService).mockImplementation(() => mockAuthService as unknown as WebAuthService) + vi.mocked(CloudSettingsService).mockImplementation(() => mockSettingsService as unknown as CloudSettingsService) + vi.mocked(CloudShareService).mockImplementation(() => mockShareService as unknown as CloudShareService) + vi.mocked(TelemetryClient).mockImplementation(() => mockTelemetryClient as unknown as TelemetryClient) + + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + Object.defineProperty(TelemetryService, "instance", { + get: () => mockTelemetryService.instance, + configurable: true, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + CloudService.resetInstance() + }) + + describe("createInstance", () => { + it("should create and initialize CloudService instance", async () => { + const mockLog = vi.fn() + + const cloudService = await CloudService.createInstance(mockContext, mockLog) + + expect(cloudService).toBeInstanceOf(CloudService) + expect(WebAuthService).toHaveBeenCalledWith(mockContext, expect.any(Function)) + expect(CloudSettingsService).toHaveBeenCalledWith(mockContext, mockAuthService, expect.any(Function)) + }) + + it("should set up event listeners for CloudSettingsService", async () => { + const mockLog = vi.fn() + + await CloudService.createInstance(mockContext, mockLog) + + expect(mockSettingsService.on).toHaveBeenCalledWith("settings-updated", expect.any(Function)) + }) + + it("should throw error if instance already exists", async () => { + await CloudService.createInstance(mockContext) + + await expect(CloudService.createInstance(mockContext)).rejects.toThrow( + "CloudService instance already created", + ) + }) + }) + + describe("authentication methods", () => { + let cloudService: CloudService + + beforeEach(async () => { + cloudService = await CloudService.createInstance(mockContext) + }) + + it("should delegate login to AuthService", async () => { + await cloudService.login() + expect(mockAuthService.login).toHaveBeenCalled() + }) + + it("should delegate logout to AuthService", async () => { + await cloudService.logout() + expect(mockAuthService.logout).toHaveBeenCalled() + }) + + it("should delegate isAuthenticated to AuthService", () => { + const result = cloudService.isAuthenticated() + expect(mockAuthService.isAuthenticated).toHaveBeenCalled() + expect(result).toBe(false) + }) + + it("should delegate hasActiveSession to AuthService", () => { + const result = cloudService.hasActiveSession() + expect(mockAuthService.hasActiveSession).toHaveBeenCalled() + expect(result).toBe(false) + }) + + it("should delegate getUserInfo to AuthService", async () => { + await cloudService.getUserInfo() + expect(mockAuthService.getUserInfo).toHaveBeenCalled() + }) + + it("should return organization ID from user info", () => { + const mockUserInfo = { + name: "Test User", + email: "test@example.com", + organizationId: "org_123", + organizationName: "Test Org", + organizationRole: "admin", + } + mockAuthService.getUserInfo.mockReturnValue(mockUserInfo) + + const result = cloudService.getOrganizationId() + expect(mockAuthService.getUserInfo).toHaveBeenCalled() + expect(result).toBe("org_123") + }) + + it("should return null when no organization ID available", () => { + mockAuthService.getUserInfo.mockReturnValue(null) + + const result = cloudService.getOrganizationId() + expect(result).toBe(null) + }) + + it("should return organization name from user info", () => { + const mockUserInfo = { + name: "Test User", + email: "test@example.com", + organizationId: "org_123", + organizationName: "Test Org", + organizationRole: "admin", + } + mockAuthService.getUserInfo.mockReturnValue(mockUserInfo) + + const result = cloudService.getOrganizationName() + expect(mockAuthService.getUserInfo).toHaveBeenCalled() + expect(result).toBe("Test Org") + }) + + it("should return null when no organization name available", () => { + mockAuthService.getUserInfo.mockReturnValue(null) + + const result = cloudService.getOrganizationName() + expect(result).toBe(null) + }) + + it("should return organization role from user info", () => { + const mockUserInfo = { + name: "Test User", + email: "test@example.com", + organizationId: "org_123", + organizationName: "Test Org", + organizationRole: "admin", + } + mockAuthService.getUserInfo.mockReturnValue(mockUserInfo) + + const result = cloudService.getOrganizationRole() + expect(mockAuthService.getUserInfo).toHaveBeenCalled() + expect(result).toBe("admin") + }) + + it("should return null when no organization role available", () => { + mockAuthService.getUserInfo.mockReturnValue(null) + + const result = cloudService.getOrganizationRole() + expect(result).toBe(null) + }) + + it("should delegate getAuthState to AuthService", () => { + const result = cloudService.getAuthState() + expect(mockAuthService.getState).toHaveBeenCalled() + expect(result).toBe("logged-out") + }) + + it("should delegate handleAuthCallback to AuthService", async () => { + await cloudService.handleAuthCallback("code", "state") + expect(mockAuthService.handleCallback).toHaveBeenCalledWith("code", "state", undefined) + }) + + it("should delegate handleAuthCallback with organizationId to AuthService", async () => { + await cloudService.handleAuthCallback("code", "state", "org_123") + expect(mockAuthService.handleCallback).toHaveBeenCalledWith("code", "state", "org_123") + }) + + it("should return stored organization ID from AuthService", () => { + mockAuthService.getStoredOrganizationId.mockReturnValue("org_456") + + const result = cloudService.getStoredOrganizationId() + expect(mockAuthService.getStoredOrganizationId).toHaveBeenCalled() + expect(result).toBe("org_456") + }) + + it("should return null when no stored organization ID available", () => { + mockAuthService.getStoredOrganizationId.mockReturnValue(null) + + const result = cloudService.getStoredOrganizationId() + expect(result).toBe(null) + }) + + it("should return true when stored organization ID exists", () => { + mockAuthService.getStoredOrganizationId.mockReturnValue("org_789") + + const result = cloudService.hasStoredOrganizationId() + expect(result).toBe(true) + }) + + it("should return false when no stored organization ID exists", () => { + mockAuthService.getStoredOrganizationId.mockReturnValue(null) + + const result = cloudService.hasStoredOrganizationId() + expect(result).toBe(false) + }) + }) + + describe("organization settings methods", () => { + let cloudService: CloudService + + beforeEach(async () => { + cloudService = await CloudService.createInstance(mockContext) + }) + + it("should delegate getAllowList to SettingsService", () => { + cloudService.getAllowList() + expect(mockSettingsService.getAllowList).toHaveBeenCalled() + }) + }) + + describe("error handling", () => { + it("should throw error when accessing methods before initialization", () => { + expect(() => CloudService.instance.login()).toThrow("CloudService not initialized") + }) + + it("should throw error when accessing instance before creation", () => { + expect(() => CloudService.instance).toThrow("CloudService not initialized") + }) + }) + + describe("hasInstance", () => { + it("should return false when no instance exists", () => { + expect(CloudService.hasInstance()).toBe(false) + }) + + it("should return true when instance exists and is initialized", async () => { + await CloudService.createInstance(mockContext) + expect(CloudService.hasInstance()).toBe(true) + }) + }) + + describe("dispose", () => { + it("should dispose of all services and clean up", async () => { + const cloudService = await CloudService.createInstance(mockContext) + cloudService.dispose() + + expect(mockSettingsService.dispose).toHaveBeenCalled() + }) + + it("should remove event listeners from CloudSettingsService", async () => { + // Create a mock that will pass the instanceof check + const mockCloudSettingsService = Object.create(CloudSettingsService.prototype) + Object.assign(mockCloudSettingsService, { + initialize: vi.fn(), + getSettings: vi.fn(), + getAllowList: vi.fn(), + dispose: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }) + + // Override the mock to return our properly typed instance + vi.mocked(CloudSettingsService).mockImplementation(() => mockCloudSettingsService) + + const cloudService = await CloudService.createInstance(mockContext) + + // Verify the listener was added + expect(mockCloudSettingsService.on).toHaveBeenCalledWith("settings-updated", expect.any(Function)) + + // Get the listener function that was registered + const registeredListener = mockCloudSettingsService.on.mock.calls.find( + (call: unknown[]) => call[0] === "settings-updated", + )?.[1] + + cloudService.dispose() + + // Verify the listener was removed with the same function + expect(mockCloudSettingsService.off).toHaveBeenCalledWith("settings-updated", registeredListener) + }) + + it("should handle disposal when using StaticSettingsService", async () => { + // Reset the instance first + CloudService.resetInstance() + + // Mock a StaticSettingsService (which doesn't extend CloudSettingsService) + const mockStaticSettingsService = { + initialize: vi.fn(), + getSettings: vi.fn(), + getAllowList: vi.fn(), + dispose: vi.fn(), + on: vi.fn(), // Add on method to avoid initialization error + off: vi.fn(), // Add off method for disposal + } + + // Override the mock to return a service that won't pass instanceof check + vi.mocked(CloudSettingsService).mockImplementation( + () => mockStaticSettingsService as unknown as CloudSettingsService, + ) + + // This should not throw even though the service doesn't pass instanceof check + const _cloudService = await CloudService.createInstance(mockContext) + + // Should not throw when disposing + expect(() => _cloudService.dispose()).not.toThrow() + + // Should still call dispose on the settings service + expect(mockStaticSettingsService.dispose).toHaveBeenCalled() + // Should NOT call off method since it's not a CloudSettingsService instance + expect(mockStaticSettingsService.off).not.toHaveBeenCalled() + }) + }) + + describe("settings event handling", () => { + let _cloudService: CloudService + + beforeEach(async () => { + _cloudService = await CloudService.createInstance(mockContext) + }) + + it("should emit settings-updated event when settings are updated", async () => { + const settingsListener = vi.fn() + _cloudService.on("settings-updated", settingsListener) + + // Get the settings listener that was registered with the settings service + const serviceSettingsListener = mockSettingsService.on.mock.calls.find( + (call) => call[0] === "settings-updated", + )?.[1] + + expect(serviceSettingsListener).toBeDefined() + + // Simulate settings update event + const settingsData = { + settings: { + version: 2, + defaultSettings: {}, + allowList: { allowAll: true, providers: {} }, + }, + previousSettings: { + version: 1, + defaultSettings: {}, + allowList: { allowAll: true, providers: {} }, + }, + } + serviceSettingsListener(settingsData) + + expect(settingsListener).toHaveBeenCalledWith(settingsData) + }) + }) + + describe("shareTask with ClineMessage retry logic", () => { + let cloudService: CloudService + + beforeEach(async () => { + // Reset mocks for shareTask tests + vi.clearAllMocks() + + // Reset authentication state for shareTask tests + mockAuthService.isAuthenticated.mockReturnValue(true) + mockAuthService.hasActiveSession.mockReturnValue(true) + mockAuthService.hasOrIsAcquiringActiveSession.mockReturnValue(true) + mockAuthService.getState.mockReturnValue("active") + + cloudService = await CloudService.createInstance(mockContext) + }) + + it("should call shareTask without retry when successful", async () => { + const taskId = "test-task-id" + const visibility = "organization" + const clineMessages: ClineMessage[] = [ + { + ts: Date.now(), + type: "say", + say: "text", + text: "Hello world", + }, + ] + + const expectedResult = { success: true, shareUrl: "https://example.com/share/123" } + mockShareService.shareTask.mockResolvedValue(expectedResult) + + const result = await cloudService.shareTask(taskId, visibility, clineMessages) + + expect(mockShareService.shareTask).toHaveBeenCalledTimes(1) + expect(mockShareService.shareTask).toHaveBeenCalledWith(taskId, visibility) + expect(mockTelemetryClient.backfillMessages).not.toHaveBeenCalled() + expect(result).toEqual(expectedResult) + }) + + it("should retry with backfill when TaskNotFoundError occurs", async () => { + const taskId = "test-task-id" + const visibility = "organization" + const clineMessages: ClineMessage[] = [ + { + ts: Date.now(), + type: "say", + say: "text", + text: "Hello world", + }, + ] + + const expectedResult = { success: true, shareUrl: "https://example.com/share/123" } + + // First call throws TaskNotFoundError, second call succeeds + mockShareService.shareTask + .mockRejectedValueOnce(new TaskNotFoundError(taskId)) + .mockResolvedValueOnce(expectedResult) + + const result = await cloudService.shareTask(taskId, visibility, clineMessages) + + expect(mockShareService.shareTask).toHaveBeenCalledTimes(2) + expect(mockShareService.shareTask).toHaveBeenNthCalledWith(1, taskId, visibility) + expect(mockShareService.shareTask).toHaveBeenNthCalledWith(2, taskId, visibility) + expect(mockTelemetryClient.backfillMessages).toHaveBeenCalledTimes(1) + expect(mockTelemetryClient.backfillMessages).toHaveBeenCalledWith(clineMessages, taskId) + expect(result).toEqual(expectedResult) + }) + + it("should not retry when TaskNotFoundError occurs but no clineMessages provided", async () => { + const taskId = "test-task-id" + const visibility = "organization" + + const taskNotFoundError = new TaskNotFoundError(taskId) + mockShareService.shareTask.mockRejectedValue(taskNotFoundError) + + await expect(cloudService.shareTask(taskId, visibility)).rejects.toThrow(TaskNotFoundError) + + expect(mockShareService.shareTask).toHaveBeenCalledTimes(1) + expect(mockTelemetryClient.backfillMessages).not.toHaveBeenCalled() + }) + + it("should not retry when non-TaskNotFoundError occurs", async () => { + const taskId = "test-task-id" + const visibility = "organization" + const clineMessages: ClineMessage[] = [ + { + ts: Date.now(), + type: "say", + say: "text", + text: "Hello world", + }, + ] + + const genericError = new Error("Some other error") + mockShareService.shareTask.mockRejectedValue(genericError) + + await expect(cloudService.shareTask(taskId, visibility, clineMessages)).rejects.toThrow(genericError) + + expect(mockShareService.shareTask).toHaveBeenCalledTimes(1) + expect(mockTelemetryClient.backfillMessages).not.toHaveBeenCalled() + }) + + it("should work with default parameters", async () => { + const taskId = "test-task-id" + const expectedResult = { success: true, shareUrl: "https://example.com/share/123" } + mockShareService.shareTask.mockResolvedValue(expectedResult) + + const result = await cloudService.shareTask(taskId) + + expect(mockShareService.shareTask).toHaveBeenCalledTimes(1) + expect(mockShareService.shareTask).toHaveBeenCalledWith(taskId, "organization") + expect(result).toEqual(expectedResult) + }) + }) +}) diff --git a/packages/cloud/src/__tests__/CloudSettingsService.test.ts b/packages/cloud/src/__tests__/CloudSettingsService.test.ts new file mode 100644 index 0000000000..4a85383ba4 --- /dev/null +++ b/packages/cloud/src/__tests__/CloudSettingsService.test.ts @@ -0,0 +1,476 @@ +import * as vscode from "vscode" +import { CloudSettingsService } from "../CloudSettingsService" +import { RefreshTimer } from "../RefreshTimer" +import type { AuthService } from "../auth" +import type { OrganizationSettings } from "@roo-code/types" + +// Mock dependencies +vi.mock("../RefreshTimer") +vi.mock("../config", () => ({ + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +// Mock fetch globally +global.fetch = vi.fn() + +describe("CloudSettingsService", () => { + let mockContext: vscode.ExtensionContext + let mockAuthService: { + getState: ReturnType + getSessionToken: ReturnType + hasActiveSession: ReturnType + on: ReturnType + } + let mockRefreshTimer: { + start: ReturnType + stop: ReturnType + } + let cloudSettingsService: CloudSettingsService + let mockLog: ReturnType + + const mockSettings: OrganizationSettings = { + version: 1, + defaultSettings: {}, + allowList: { + allowAll: true, + providers: {}, + }, + } + + beforeEach(() => { + vi.clearAllMocks() + + mockContext = { + globalState: { + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as vscode.ExtensionContext + + mockAuthService = { + getState: vi.fn().mockReturnValue("logged-out"), + getSessionToken: vi.fn(), + hasActiveSession: vi.fn().mockReturnValue(false), + on: vi.fn(), + } + + mockRefreshTimer = { + start: vi.fn(), + stop: vi.fn(), + } + + mockLog = vi.fn() + + // Mock RefreshTimer constructor + vi.mocked(RefreshTimer).mockImplementation(() => mockRefreshTimer as unknown as RefreshTimer) + + cloudSettingsService = new CloudSettingsService(mockContext, mockAuthService as unknown as AuthService, mockLog) + }) + + afterEach(() => { + cloudSettingsService.dispose() + }) + + describe("constructor", () => { + it("should create CloudSettingsService with proper dependencies", () => { + expect(cloudSettingsService).toBeInstanceOf(CloudSettingsService) + expect(RefreshTimer).toHaveBeenCalledWith({ + callback: expect.any(Function), + successInterval: 30000, + initialBackoffMs: 1000, + maxBackoffMs: 30000, + }) + }) + + it("should use console.log as default logger when none provided", () => { + const service = new CloudSettingsService(mockContext, mockAuthService as unknown as AuthService) + expect(service).toBeInstanceOf(CloudSettingsService) + }) + }) + + describe("initialize", () => { + it("should load cached settings on initialization", () => { + const cachedSettings = { + version: 1, + defaultSettings: {}, + allowList: { allowAll: true, providers: {} }, + } + + // Create a fresh mock context for this test + const testContext = { + globalState: { + get: vi.fn().mockReturnValue(cachedSettings), + update: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as vscode.ExtensionContext + + // Mock auth service to not be logged out + const testAuthService = { + getState: vi.fn().mockReturnValue("active"), + getSessionToken: vi.fn(), + hasActiveSession: vi.fn().mockReturnValue(false), + on: vi.fn(), + } + + // Create a new instance to test initialization + const testService = new CloudSettingsService( + testContext, + testAuthService as unknown as AuthService, + mockLog, + ) + testService.initialize() + + expect(testContext.globalState.get).toHaveBeenCalledWith("organization-settings") + expect(testService.getSettings()).toEqual(cachedSettings) + + testService.dispose() + }) + + it("should clear cached settings if user is logged out", async () => { + const cachedSettings = { + version: 1, + defaultSettings: {}, + allowList: { allowAll: true, providers: {} }, + } + mockContext.globalState.get = vi.fn().mockReturnValue(cachedSettings) + mockAuthService.getState.mockReturnValue("logged-out") + + cloudSettingsService.initialize() + + expect(mockContext.globalState.update).toHaveBeenCalledWith("organization-settings", undefined) + }) + + it("should set up auth service event listeners", () => { + cloudSettingsService.initialize() + + expect(mockAuthService.on).toHaveBeenCalledWith("auth-state-changed", expect.any(Function)) + }) + + it("should start timer if user has active session", () => { + mockAuthService.hasActiveSession.mockReturnValue(true) + + cloudSettingsService.initialize() + + expect(mockRefreshTimer.start).toHaveBeenCalled() + }) + + it("should not start timer if user has no active session", () => { + mockAuthService.hasActiveSession.mockReturnValue(false) + + cloudSettingsService.initialize() + + expect(mockRefreshTimer.start).not.toHaveBeenCalled() + }) + }) + + describe("event emission", () => { + beforeEach(() => { + cloudSettingsService.initialize() + }) + + it("should emit 'settings-updated' event when settings change", async () => { + const eventSpy = vi.fn() + cloudSettingsService.on("settings-updated", eventSpy) + + mockAuthService.getSessionToken.mockReturnValue("valid-token") + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockSettings), + } as unknown as Response) + + // Get the callback function passed to RefreshTimer + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await timerCallback() + + expect(eventSpy).toHaveBeenCalledWith({ + settings: mockSettings, + previousSettings: undefined, + }) + }) + + it("should emit event with previous settings when updating existing settings", async () => { + const eventSpy = vi.fn() + + const previousSettings = { + version: 1, + defaultSettings: {}, + allowList: { allowAll: true, providers: {} }, + } + const newSettings = { + version: 2, + defaultSettings: {}, + allowList: { allowAll: true, providers: {} }, + } + + // Create a fresh mock context for this test + const testContext = { + globalState: { + get: vi.fn().mockReturnValue(previousSettings), + update: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as vscode.ExtensionContext + + // Mock auth service to not be logged out + const testAuthService = { + getState: vi.fn().mockReturnValue("active"), + getSessionToken: vi.fn().mockReturnValue("valid-token"), + hasActiveSession: vi.fn().mockReturnValue(false), + on: vi.fn(), + } + + // Create a new service instance with cached settings + const testService = new CloudSettingsService( + testContext, + testAuthService as unknown as AuthService, + mockLog, + ) + testService.on("settings-updated", eventSpy) + testService.initialize() + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(newSettings), + } as unknown as Response) + + // Get the callback function passed to RefreshTimer for this instance + const timerCallback = + vi.mocked(RefreshTimer).mock.calls[vi.mocked(RefreshTimer).mock.calls.length - 1][0].callback + await timerCallback() + + expect(eventSpy).toHaveBeenCalledWith({ + settings: newSettings, + previousSettings, + }) + + testService.dispose() + }) + + it("should not emit event when settings version is unchanged", async () => { + const eventSpy = vi.fn() + + // Create a fresh mock context for this test + const testContext = { + globalState: { + get: vi.fn().mockReturnValue(mockSettings), + update: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as vscode.ExtensionContext + + // Mock auth service to not be logged out + const testAuthService = { + getState: vi.fn().mockReturnValue("active"), + getSessionToken: vi.fn().mockReturnValue("valid-token"), + hasActiveSession: vi.fn().mockReturnValue(false), + on: vi.fn(), + } + + // Create a new service instance with cached settings + const testService = new CloudSettingsService( + testContext, + testAuthService as unknown as AuthService, + mockLog, + ) + testService.on("settings-updated", eventSpy) + testService.initialize() + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockSettings), // Same version + } as unknown as Response) + + // Get the callback function passed to RefreshTimer for this instance + const timerCallback = + vi.mocked(RefreshTimer).mock.calls[vi.mocked(RefreshTimer).mock.calls.length - 1][0].callback + await timerCallback() + + expect(eventSpy).not.toHaveBeenCalled() + + testService.dispose() + }) + + it("should not emit event when fetch fails", async () => { + const eventSpy = vi.fn() + cloudSettingsService.on("settings-updated", eventSpy) + + mockAuthService.getSessionToken.mockReturnValue("valid-token") + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + } as unknown as Response) + + // Get the callback function passed to RefreshTimer + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await timerCallback() + + expect(eventSpy).not.toHaveBeenCalled() + }) + + it("should not emit event when no auth token available", async () => { + const eventSpy = vi.fn() + cloudSettingsService.on("settings-updated", eventSpy) + + mockAuthService.getSessionToken.mockReturnValue(null) + + // Get the callback function passed to RefreshTimer + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await timerCallback() + + expect(eventSpy).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + }) + }) + + describe("fetchSettings", () => { + beforeEach(() => { + cloudSettingsService.initialize() + }) + + it("should fetch and cache settings successfully", async () => { + mockAuthService.getSessionToken.mockReturnValue("valid-token") + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockSettings), + } as unknown as Response) + + // Get the callback function passed to RefreshTimer + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + const result = await timerCallback() + + expect(result).toBe(true) + expect(fetch).toHaveBeenCalledWith("https://app.roocode.com/api/organization-settings", { + headers: { + Authorization: "Bearer valid-token", + }, + }) + expect(mockContext.globalState.update).toHaveBeenCalledWith("organization-settings", mockSettings) + }) + + it("should handle fetch errors gracefully", async () => { + mockAuthService.getSessionToken.mockReturnValue("valid-token") + vi.mocked(fetch).mockRejectedValue(new Error("Network error")) + + // Get the callback function passed to RefreshTimer + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + const result = await timerCallback() + + expect(result).toBe(false) + expect(mockLog).toHaveBeenCalledWith( + "[cloud-settings] Error fetching organization settings:", + expect.any(Error), + ) + }) + + it("should handle invalid response format", async () => { + mockAuthService.getSessionToken.mockReturnValue("valid-token") + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ invalid: "data" }), + } as unknown as Response) + + // Get the callback function passed to RefreshTimer + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + const result = await timerCallback() + + expect(result).toBe(false) + expect(mockLog).toHaveBeenCalledWith( + "[cloud-settings] Invalid organization settings format:", + expect.any(Object), + ) + }) + }) + + describe("getAllowList", () => { + it("should return settings allowList when available", () => { + mockContext.globalState.get = vi.fn().mockReturnValue(mockSettings) + cloudSettingsService.initialize() + + const allowList = cloudSettingsService.getAllowList() + expect(allowList).toEqual(mockSettings.allowList) + }) + + it("should return default allow all when no settings available", () => { + const allowList = cloudSettingsService.getAllowList() + expect(allowList).toEqual({ allowAll: true, providers: {} }) + }) + }) + + describe("getSettings", () => { + it("should return current settings", () => { + // Create a fresh mock context for this test + const testContext = { + globalState: { + get: vi.fn().mockReturnValue(mockSettings), + update: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as vscode.ExtensionContext + + // Mock auth service to not be logged out + const testAuthService = { + getState: vi.fn().mockReturnValue("active"), + getSessionToken: vi.fn(), + hasActiveSession: vi.fn().mockReturnValue(false), + on: vi.fn(), + } + + const testService = new CloudSettingsService( + testContext, + testAuthService as unknown as AuthService, + mockLog, + ) + testService.initialize() + + const settings = testService.getSettings() + expect(settings).toEqual(mockSettings) + + testService.dispose() + }) + + it("should return undefined when no settings available", () => { + const settings = cloudSettingsService.getSettings() + expect(settings).toBeUndefined() + }) + }) + + describe("dispose", () => { + it("should remove all listeners and stop timer", () => { + const removeAllListenersSpy = vi.spyOn(cloudSettingsService, "removeAllListeners") + + cloudSettingsService.dispose() + + expect(removeAllListenersSpy).toHaveBeenCalled() + expect(mockRefreshTimer.stop).toHaveBeenCalled() + }) + }) + + describe("auth service event handlers", () => { + it("should start timer when auth-state-changed event is triggered with active-session", () => { + cloudSettingsService.initialize() + + // Get the auth-state-changed handler + const authStateChangedHandler = mockAuthService.on.mock.calls.find( + (call) => call[0] === "auth-state-changed", + )?.[1] + expect(authStateChangedHandler).toBeDefined() + + // Simulate active-session state change + authStateChangedHandler({ state: "active-session", previousState: "attempting-session" }) + expect(mockRefreshTimer.start).toHaveBeenCalled() + }) + + it("should stop timer and remove settings when auth-state-changed event is triggered with logged-out", async () => { + cloudSettingsService.initialize() + + // Get the auth-state-changed handler + const authStateChangedHandler = mockAuthService.on.mock.calls.find( + (call) => call[0] === "auth-state-changed", + )?.[1] + expect(authStateChangedHandler).toBeDefined() + + // Simulate logged-out state change from active-session + await authStateChangedHandler({ state: "logged-out", previousState: "active-session" }) + expect(mockRefreshTimer.stop).toHaveBeenCalled() + expect(mockContext.globalState.update).toHaveBeenCalledWith("organization-settings", undefined) + }) + }) +}) diff --git a/packages/cloud/src/__tests__/CloudShareService.test.ts b/packages/cloud/src/__tests__/CloudShareService.test.ts new file mode 100644 index 0000000000..6fae1fbb9f --- /dev/null +++ b/packages/cloud/src/__tests__/CloudShareService.test.ts @@ -0,0 +1,310 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { MockedFunction } from "vitest" +import * as vscode from "vscode" + +import { CloudAPI } from "../CloudAPI" +import { CloudShareService } from "../CloudShareService" +import type { SettingsService } from "../SettingsService" +import type { AuthService } from "../auth" +import { CloudAPIError, TaskNotFoundError } from "../errors" + +// Mock fetch +const mockFetch = vi.fn() +global.fetch = mockFetch as any + +// Mock vscode +vi.mock("vscode", () => ({ + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + showQuickPick: vi.fn(), + }, + env: { + clipboard: { + writeText: vi.fn(), + }, + openExternal: vi.fn(), + }, + Uri: { + parse: vi.fn(), + }, + extensions: { + getExtension: vi.fn(() => ({ + packageJSON: { version: "1.0.0" }, + })), + }, +})) + +// Mock config +vi.mock("../Config", () => ({ + getRooCodeApiUrl: () => "https://app.roocode.com", +})) + +// Mock utils +vi.mock("../utils", () => ({ + getUserAgent: () => "Roo-Code 1.0.0", +})) + +describe("CloudShareService", () => { + let shareService: CloudShareService + let mockAuthService: AuthService + let mockSettingsService: SettingsService + let mockCloudAPI: CloudAPI + let mockLog: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockClear() + + mockLog = vi.fn() + mockAuthService = { + hasActiveSession: vi.fn(), + getSessionToken: vi.fn(), + isAuthenticated: vi.fn(), + } as any + + mockSettingsService = { + getSettings: vi.fn(), + } as any + + mockCloudAPI = new CloudAPI(mockAuthService, mockLog) + shareService = new CloudShareService(mockCloudAPI, mockSettingsService, mockLog) + }) + + describe("shareTask", () => { + it("should share task with organization visibility and copy to clipboard", async () => { + const mockResponseData = { + success: true, + shareUrl: "https://app.roocode.com/share/abc123", + } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponseData), + }) + + const result = await shareService.shareTask("task-123", "organization") + + expect(result.success).toBe(true) + expect(result.shareUrl).toBe("https://app.roocode.com/share/abc123") + expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/share", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer session-token", + "User-Agent": "Roo-Code 1.0.0", + }, + body: JSON.stringify({ taskId: "task-123", visibility: "organization" }), + signal: expect.any(AbortSignal), + }) + expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith("https://app.roocode.com/share/abc123") + }) + + it("should share task with public visibility", async () => { + const mockResponseData = { + success: true, + shareUrl: "https://app.roocode.com/share/abc123", + } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponseData), + }) + + const result = await shareService.shareTask("task-123", "public") + + expect(result.success).toBe(true) + expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/share", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer session-token", + "User-Agent": "Roo-Code 1.0.0", + }, + body: JSON.stringify({ taskId: "task-123", visibility: "public" }), + signal: expect.any(AbortSignal), + }) + }) + + it("should default to organization visibility when not specified", async () => { + const mockResponseData = { + success: true, + shareUrl: "https://app.roocode.com/share/abc123", + } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponseData), + }) + + const result = await shareService.shareTask("task-123") + + expect(result.success).toBe(true) + expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/share", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer session-token", + "User-Agent": "Roo-Code 1.0.0", + }, + body: JSON.stringify({ taskId: "task-123", visibility: "organization" }), + signal: expect.any(AbortSignal), + }) + }) + + it("should handle API error response", async () => { + const mockResponseData = { + success: false, + error: "Task not found", + } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponseData), + }) + + const result = await shareService.shareTask("task-123", "organization") + + expect(result.success).toBe(false) + expect(result.error).toBe("Task not found") + }) + + it("should handle authentication errors", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue(null) + + await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow("Authentication required") + }) + + it("should handle unexpected errors", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockRejectedValue(new Error("Network error")) + + await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow("Network error") + }) + + it("should throw TaskNotFoundError for 404 responses", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + text: vi.fn().mockResolvedValue("Not Found"), + }) + + await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow(TaskNotFoundError) + await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow("Task not found") + }) + + it("should throw generic Error for non-404 HTTP errors", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + text: vi.fn().mockResolvedValue("Internal Server Error"), + }) + + await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow(CloudAPIError) + await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow( + "HTTP 500: Internal Server Error", + ) + }) + + it("should create TaskNotFoundError with correct properties", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + text: vi.fn().mockResolvedValue("Not Found"), + }) + + try { + await shareService.shareTask("task-123", "organization") + expect.fail("Expected TaskNotFoundError to be thrown") + } catch (error) { + expect(error).toBeInstanceOf(TaskNotFoundError) + expect(error).toBeInstanceOf(Error) + expect((error as TaskNotFoundError).message).toBe("Task not found") + } + }) + }) + + describe("canShareTask", () => { + it("should return true when authenticated and sharing is enabled", async () => { + ;(mockAuthService.isAuthenticated as any).mockReturnValue(true) + ;(mockSettingsService.getSettings as any).mockReturnValue({ + cloudSettings: { + enableTaskSharing: true, + }, + }) + + const result = await shareService.canShareTask() + + expect(result).toBe(true) + }) + + it("should return false when authenticated but sharing is disabled", async () => { + ;(mockAuthService.isAuthenticated as any).mockReturnValue(true) + ;(mockSettingsService.getSettings as any).mockReturnValue({ + cloudSettings: { + enableTaskSharing: false, + }, + }) + + const result = await shareService.canShareTask() + + expect(result).toBe(false) + }) + + it("should return false when authenticated and sharing setting is undefined (default)", async () => { + ;(mockAuthService.isAuthenticated as any).mockReturnValue(true) + ;(mockSettingsService.getSettings as any).mockReturnValue({ + cloudSettings: {}, + }) + + const result = await shareService.canShareTask() + + expect(result).toBe(false) + }) + + it("should return false when authenticated and no settings available (default)", async () => { + ;(mockAuthService.isAuthenticated as any).mockReturnValue(true) + ;(mockSettingsService.getSettings as any).mockReturnValue(undefined) + + const result = await shareService.canShareTask() + + expect(result).toBe(false) + }) + + it("should return false when settings service returns undefined", async () => { + ;(mockSettingsService.getSettings as any).mockReturnValue(undefined) + + const result = await shareService.canShareTask() + + expect(result).toBe(false) + }) + + it("should handle errors gracefully", async () => { + ;(mockSettingsService.getSettings as any).mockImplementation(() => { + throw new Error("Settings error") + }) + + const result = await shareService.canShareTask() + + expect(result).toBe(false) + expect(mockLog).toHaveBeenCalledWith( + "[ShareService] Error checking if task can be shared:", + expect.any(Error), + ) + }) + }) +}) diff --git a/packages/cloud/src/__tests__/RefreshTimer.test.ts b/packages/cloud/src/__tests__/RefreshTimer.test.ts new file mode 100644 index 0000000000..2f87488568 --- /dev/null +++ b/packages/cloud/src/__tests__/RefreshTimer.test.ts @@ -0,0 +1,210 @@ +// npx vitest run src/__tests__/RefreshTimer.test.ts + +import type { Mock } from "vitest" + +import { RefreshTimer } from "../RefreshTimer" + +vi.useFakeTimers() + +describe("RefreshTimer", () => { + let mockCallback: Mock + let refreshTimer: RefreshTimer + + beforeEach(() => { + mockCallback = vi.fn() + mockCallback.mockResolvedValue(true) + }) + + afterEach(() => { + if (refreshTimer) { + refreshTimer.stop() + } + + vi.clearAllTimers() + vi.clearAllMocks() + }) + + it("should execute callback immediately when started", () => { + refreshTimer = new RefreshTimer({ + callback: mockCallback, + }) + + refreshTimer.start() + + expect(mockCallback).toHaveBeenCalledTimes(1) + }) + + it("should schedule next attempt after success interval when callback succeeds", async () => { + mockCallback.mockResolvedValue(true) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + successInterval: 50000, // 50 seconds + }) + + refreshTimer.start() + + // Fast-forward to execute the first callback + await Promise.resolve() + + expect(mockCallback).toHaveBeenCalledTimes(1) + + // Fast-forward 50 seconds + vi.advanceTimersByTime(50000) + + // Callback should be called again + expect(mockCallback).toHaveBeenCalledTimes(2) + }) + + it("should use exponential backoff when callback fails", async () => { + mockCallback.mockResolvedValue(false) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + initialBackoffMs: 1000, // 1 second + }) + + refreshTimer.start() + + // Fast-forward to execute the first callback + await Promise.resolve() + + expect(mockCallback).toHaveBeenCalledTimes(1) + + // Fast-forward 1 second + vi.advanceTimersByTime(1000) + + // Callback should be called again + expect(mockCallback).toHaveBeenCalledTimes(2) + + // Fast-forward to execute the second callback + await Promise.resolve() + + // Fast-forward 2 seconds + vi.advanceTimersByTime(2000) + + // Callback should be called again + expect(mockCallback).toHaveBeenCalledTimes(3) + + // Fast-forward to execute the third callback + await Promise.resolve() + }) + + it("should not exceed maximum backoff interval", async () => { + mockCallback.mockResolvedValue(false) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + initialBackoffMs: 1000, // 1 second + maxBackoffMs: 5000, // 5 seconds + }) + + refreshTimer.start() + + // Fast-forward through multiple failures to reach max backoff + await Promise.resolve() // First attempt + vi.advanceTimersByTime(1000) + + await Promise.resolve() // Second attempt (backoff = 2000ms) + vi.advanceTimersByTime(2000) + + await Promise.resolve() // Third attempt (backoff = 4000ms) + vi.advanceTimersByTime(4000) + + await Promise.resolve() // Fourth attempt (backoff would be 8000ms but max is 5000ms) + + // Should be capped at maxBackoffMs (no way to verify without logger) + }) + + it("should reset backoff after a successful attempt", async () => { + // First call fails, second succeeds, third fails + mockCallback.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + initialBackoffMs: 1000, + successInterval: 5000, + }) + + refreshTimer.start() + + // First attempt (fails) + await Promise.resolve() + + // Fast-forward 1 second + vi.advanceTimersByTime(1000) + + // Second attempt (succeeds) + await Promise.resolve() + + // Fast-forward 5 seconds + vi.advanceTimersByTime(5000) + + // Third attempt (fails) + await Promise.resolve() + + // Backoff should be reset to initial value (no way to verify without logger) + }) + + it("should handle errors in callback as failures", async () => { + mockCallback.mockRejectedValue(new Error("Test error")) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + initialBackoffMs: 1000, + }) + + refreshTimer.start() + + // Fast-forward to execute the callback + await Promise.resolve() + + // Error should be treated as a failure (no way to verify without logger) + }) + + it("should stop the timer and cancel pending executions", () => { + refreshTimer = new RefreshTimer({ + callback: mockCallback, + }) + + refreshTimer.start() + + // Stop the timer + refreshTimer.stop() + + // Fast-forward a long time + vi.advanceTimersByTime(1000000) + + // Callback should only have been called once (the initial call) + expect(mockCallback).toHaveBeenCalledTimes(1) + }) + + it("should reset the backoff state", async () => { + mockCallback.mockResolvedValue(false) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + initialBackoffMs: 1000, + }) + + refreshTimer.start() + + // Fast-forward through a few failures + await Promise.resolve() + vi.advanceTimersByTime(1000) + + await Promise.resolve() + vi.advanceTimersByTime(2000) + + // Reset the timer + refreshTimer.reset() + + // Stop and restart to trigger a new execution + refreshTimer.stop() + refreshTimer.start() + + await Promise.resolve() + + // Backoff should be back to initial value (no way to verify without logger) + }) +}) 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() + }) + }) +}) diff --git a/packages/cloud/src/__tests__/TelemetryClient.test.ts b/packages/cloud/src/__tests__/TelemetryClient.test.ts new file mode 100644 index 0000000000..e4c62b1e4e --- /dev/null +++ b/packages/cloud/src/__tests__/TelemetryClient.test.ts @@ -0,0 +1,738 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// npx vitest run src/__tests__/TelemetryClient.test.ts + +import { type TelemetryPropertiesProvider, TelemetryEventName } from "@roo-code/types" + +import { TelemetryClient } from "../TelemetryClient" + +const mockFetch = vi.fn() +global.fetch = mockFetch as any + +describe("TelemetryClient", () => { + const getPrivateProperty = (instance: any, propertyName: string): T => { + return instance[propertyName] + } + + let mockAuthService: any + let mockSettingsService: any + + beforeEach(() => { + vi.clearAllMocks() + + // Create a mock AuthService instead of using the singleton + mockAuthService = { + getSessionToken: vi.fn().mockReturnValue("mock-token"), + getState: vi.fn().mockReturnValue("active-session"), + isAuthenticated: vi.fn().mockReturnValue(true), + hasActiveSession: vi.fn().mockReturnValue(true), + } + + // Create a mock SettingsService + mockSettingsService = { + getSettings: vi.fn().mockReturnValue({ + cloudSettings: { + recordTaskMessages: true, + }, + }), + } + + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({}), + }) + + vi.spyOn(console, "info").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("isEventCapturable", () => { + it("should return true for events not in exclude list", () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_CREATED)).toBe(true) + expect(isEventCapturable(TelemetryEventName.LLM_COMPLETION)).toBe(true) + expect(isEventCapturable(TelemetryEventName.MODE_SWITCH)).toBe(true) + expect(isEventCapturable(TelemetryEventName.TOOL_USED)).toBe(true) + }) + + it("should return false for events in exclude list", () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_CONVERSATION_MESSAGE)).toBe(false) + }) + + it("should return true for TASK_MESSAGE events when recordTaskMessages is true", () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: { + recordTaskMessages: true, + }, + }) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(true) + }) + + it("should return false for TASK_MESSAGE events when recordTaskMessages is false", () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: { + recordTaskMessages: false, + }, + }) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) + }) + + it("should return false for TASK_MESSAGE events when recordTaskMessages is undefined", () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: {}, + }) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) + }) + + it("should return false for TASK_MESSAGE events when cloudSettings is undefined", () => { + mockSettingsService.getSettings.mockReturnValue({}) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) + }) + + it("should return false for TASK_MESSAGE events when getSettings returns undefined", () => { + mockSettingsService.getSettings.mockReturnValue(undefined) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) + }) + }) + + describe("getEventProperties", () => { + it("should merge provider properties with event properties", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const mockProvider: TelemetryPropertiesProvider = { + getTelemetryProperties: vi.fn().mockResolvedValue({ + appVersion: "1.0.0", + vscodeVersion: "1.60.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "code", + }), + } + + client.setProvider(mockProvider) + + const getEventProperties = getPrivateProperty< + (event: { event: TelemetryEventName; properties?: Record }) => Promise> + >(client, "getEventProperties").bind(client) + + const result = await getEventProperties({ + event: TelemetryEventName.TASK_CREATED, + properties: { + customProp: "value", + mode: "override", // This should override the provider's mode. + }, + }) + + expect(result).toEqual({ + appVersion: "1.0.0", + vscodeVersion: "1.60.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "override", // Event property takes precedence. + customProp: "value", + }) + + expect(mockProvider.getTelemetryProperties).toHaveBeenCalledTimes(1) + }) + + it("should handle errors from provider gracefully", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const mockProvider: TelemetryPropertiesProvider = { + getTelemetryProperties: vi.fn().mockRejectedValue(new Error("Provider error")), + } + + const consoleErrorSpy = vi.spyOn(console, "error") + + client.setProvider(mockProvider) + + const getEventProperties = getPrivateProperty< + (event: { event: TelemetryEventName; properties?: Record }) => Promise> + >(client, "getEventProperties").bind(client) + + const result = await getEventProperties({ + event: TelemetryEventName.TASK_CREATED, + properties: { customProp: "value" }, + }) + + expect(result).toEqual({ customProp: "value" }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error getting telemetry properties: Provider error"), + ) + }) + + it("should return event properties when no provider is set", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const getEventProperties = getPrivateProperty< + (event: { event: TelemetryEventName; properties?: Record }) => Promise> + >(client, "getEventProperties").bind(client) + + const result = await getEventProperties({ + event: TelemetryEventName.TASK_CREATED, + properties: { customProp: "value" }, + }) + + expect(result).toEqual({ customProp: "value" }) + }) + }) + + describe("capture", () => { + it("should not capture events that are not capturable", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + await client.capture({ + event: TelemetryEventName.TASK_CONVERSATION_MESSAGE, // In exclude list. + properties: { test: "value" }, + }) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("should not capture TASK_MESSAGE events when recordTaskMessages is false", async () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: { + recordTaskMessages: false, + }, + }) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + await client.capture({ + event: TelemetryEventName.TASK_MESSAGE, + properties: { + taskId: "test-task-id", + message: { + ts: 1, + type: "say", + say: "text", + text: "test message", + }, + }, + }) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("should not capture TASK_MESSAGE events when recordTaskMessages is undefined", async () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: {}, + }) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + await client.capture({ + event: TelemetryEventName.TASK_MESSAGE, + properties: { + taskId: "test-task-id", + message: { + ts: 1, + type: "say", + say: "text", + text: "test message", + }, + }, + }) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("should not send request when schema validation fails", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + await client.capture({ + event: TelemetryEventName.TASK_CREATED, + properties: { test: "value" }, + }) + + expect(mockFetch).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Invalid telemetry event")) + }) + + it("should send request when event is capturable and validation passes", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const providerProperties = { + appName: "roo-code", + appVersion: "1.0.0", + vscodeVersion: "1.60.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "code", + } + + const eventProperties = { + taskId: "test-task-id", + } + + const mockValidatedData = { + type: TelemetryEventName.TASK_CREATED, + properties: { + ...providerProperties, + taskId: "test-task-id", + }, + } + + const mockProvider: TelemetryPropertiesProvider = { + getTelemetryProperties: vi.fn().mockResolvedValue(providerProperties), + } + + client.setProvider(mockProvider) + + await client.capture({ + event: TelemetryEventName.TASK_CREATED, + properties: eventProperties, + }) + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.roocode.com/api/events", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(mockValidatedData), + }), + ) + }) + + it("should attempt to capture TASK_MESSAGE events when recordTaskMessages is true", async () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: { + recordTaskMessages: true, + }, + }) + + const eventProperties = { + appName: "roo-code", + appVersion: "1.0.0", + vscodeVersion: "1.60.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "code", + taskId: "test-task-id", + message: { + ts: 1, + type: "say", + say: "text", + text: "test message", + }, + } + + const mockValidatedData = { + type: TelemetryEventName.TASK_MESSAGE, + properties: eventProperties, + } + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + await client.capture({ + event: TelemetryEventName.TASK_MESSAGE, + properties: eventProperties, + }) + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.roocode.com/api/events", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(mockValidatedData), + }), + ) + }) + + it("should handle fetch errors gracefully", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + mockFetch.mockRejectedValue(new Error("Network error")) + + await expect( + client.capture({ + event: TelemetryEventName.TASK_CREATED, + properties: { test: "value" }, + }), + ).resolves.not.toThrow() + }) + }) + + describe("telemetry state methods", () => { + it("should always return true for isTelemetryEnabled", () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + expect(client.isTelemetryEnabled()).toBe(true) + }) + + it("should have empty implementations for updateTelemetryState and shutdown", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + client.updateTelemetryState(true) + await client.shutdown() + }) + }) + + describe("backfillMessages", () => { + it("should not send request when not authenticated", async () => { + mockAuthService.isAuthenticated.mockReturnValue(false) + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const messages = [ + { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "test message", + }, + ] + + await client.backfillMessages(messages, "test-task-id") + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("should not send request when no session token available", async () => { + mockAuthService.getSessionToken.mockReturnValue(null) + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const messages = [ + { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "test message", + }, + ] + + await client.backfillMessages(messages, "test-task-id") + + expect(mockFetch).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith( + "[TelemetryClient#backfillMessages] Unauthorized: No session token available.", + ) + }) + + it("should send FormData request with correct structure when authenticated", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const providerProperties = { + appName: "roo-code", + appVersion: "1.0.0", + vscodeVersion: "1.60.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "code", + } + + const mockProvider: TelemetryPropertiesProvider = { + getTelemetryProperties: vi.fn().mockResolvedValue(providerProperties), + } + + client.setProvider(mockProvider) + + const messages = [ + { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "test message 1", + }, + { + ts: 2, + type: "ask" as const, + ask: "followup" as const, + text: "test question", + }, + ] + + await client.backfillMessages(messages, "test-task-id") + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.roocode.com/api/events/backfill", + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer mock-token", + }, + body: expect.any(FormData), + }), + ) + + // Verify FormData contents + const call = mockFetch.mock.calls[0] + const formData = call[1].body as FormData + + expect(formData.get("taskId")).toBe("test-task-id") + + // Parse and compare properties as objects since JSON.stringify order can vary + const propertiesJson = formData.get("properties") as string + const parsedProperties = JSON.parse(propertiesJson) + expect(parsedProperties).toEqual({ + taskId: "test-task-id", + ...providerProperties, + }) + // The messages are stored as a File object under the "file" key + const fileField = formData.get("file") as File + expect(fileField).toBeInstanceOf(File) + expect(fileField.name).toBe("task.json") + expect(fileField.type).toBe("application/json") + + // Read the file content to verify the messages + const fileContent = await fileField.text() + expect(fileContent).toBe(JSON.stringify(messages)) + }) + + it("should handle provider errors gracefully", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const mockProvider: TelemetryPropertiesProvider = { + getTelemetryProperties: vi.fn().mockRejectedValue(new Error("Provider error")), + } + + client.setProvider(mockProvider) + + const messages = [ + { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "test message", + }, + ] + + await client.backfillMessages(messages, "test-task-id") + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.roocode.com/api/events/backfill", + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer mock-token", + }, + body: expect.any(FormData), + }), + ) + + // Verify FormData contents - should still work with just taskId + const call = mockFetch.mock.calls[0] + const formData = call[1].body as FormData + + expect(formData.get("taskId")).toBe("test-task-id") + expect(formData.get("properties")).toBe( + JSON.stringify({ + taskId: "test-task-id", + }), + ) + // The messages are stored as a File object under the "file" key + const fileField = formData.get("file") as File + expect(fileField).toBeInstanceOf(File) + expect(fileField.name).toBe("task.json") + expect(fileField.type).toBe("application/json") + + // Read the file content to verify the messages + const fileContent = await fileField.text() + expect(fileContent).toBe(JSON.stringify(messages)) + }) + + it("should work without provider set", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const messages = [ + { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "test message", + }, + ] + + await client.backfillMessages(messages, "test-task-id") + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.roocode.com/api/events/backfill", + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer mock-token", + }, + body: expect.any(FormData), + }), + ) + + // Verify FormData contents - should work with just taskId + const call = mockFetch.mock.calls[0] + const formData = call[1].body as FormData + + expect(formData.get("taskId")).toBe("test-task-id") + expect(formData.get("properties")).toBe( + JSON.stringify({ + taskId: "test-task-id", + }), + ) + // The messages are stored as a File object under the "file" key + const fileField = formData.get("file") as File + expect(fileField).toBeInstanceOf(File) + expect(fileField.name).toBe("task.json") + expect(fileField.type).toBe("application/json") + + // Read the file content to verify the messages + const fileContent = await fileField.text() + expect(fileContent).toBe(JSON.stringify(messages)) + }) + + it("should handle fetch errors gracefully", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + mockFetch.mockRejectedValue(new Error("Network error")) + + const messages = [ + { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "test message", + }, + ] + + await expect(client.backfillMessages(messages, "test-task-id")).resolves.not.toThrow() + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "[TelemetryClient#backfillMessages] Error uploading messages: Error: Network error", + ), + ) + }) + + it("should handle HTTP error responses", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + }) + + const messages = [ + { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "test message", + }, + ] + + await client.backfillMessages(messages, "test-task-id") + + expect(console.error).toHaveBeenCalledWith( + "[TelemetryClient#backfillMessages] POST events/backfill -> 404 Not Found", + ) + }) + + it("should log debug information when debug is enabled", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService, true) + + const messages = [ + { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "test message", + }, + ] + + await client.backfillMessages(messages, "test-task-id") + + expect(console.info).toHaveBeenCalledWith( + "[TelemetryClient#backfillMessages] Uploading 1 messages for task test-task-id", + ) + expect(console.info).toHaveBeenCalledWith( + "[TelemetryClient#backfillMessages] Successfully uploaded messages for task test-task-id", + ) + }) + + it("should handle empty messages array", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + await client.backfillMessages([], "test-task-id") + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.roocode.com/api/events/backfill", + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer mock-token", + }, + body: expect.any(FormData), + }), + ) + + // Verify FormData contents + const call = mockFetch.mock.calls[0] + const formData = call[1].body as FormData + + // The messages are stored as a File object under the "file" key + const fileField = formData.get("file") as File + expect(fileField).toBeInstanceOf(File) + expect(fileField.name).toBe("task.json") + expect(fileField.type).toBe("application/json") + + // Read the file content to verify the empty messages array + const fileContent = await fileField.text() + expect(fileContent).toBe("[]") + }) + }) +}) diff --git a/packages/cloud/src/__tests__/auth/StaticTokenAuthService.spec.ts b/packages/cloud/src/__tests__/auth/StaticTokenAuthService.spec.ts new file mode 100644 index 0000000000..f1ab7f9abc --- /dev/null +++ b/packages/cloud/src/__tests__/auth/StaticTokenAuthService.spec.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import * as vscode from "vscode" + +import { StaticTokenAuthService } from "../../auth/StaticTokenAuthService" + +// Mock vscode +vi.mock("vscode", () => ({ + window: { + showInformationMessage: vi.fn(), + }, + env: { + openExternal: vi.fn(), + uriScheme: "vscode", + }, + Uri: { + parse: vi.fn(), + }, +})) + +describe("StaticTokenAuthService", () => { + let authService: StaticTokenAuthService + let mockContext: vscode.ExtensionContext + let mockLog: (...args: unknown[]) => void + const testToken = "test-static-token" + + beforeEach(() => { + mockLog = vi.fn() + + // Create a minimal mock that satisfies the constructor requirements + const mockContextPartial = { + extension: { + packageJSON: { + publisher: "TestPublisher", + name: "test-extension", + }, + }, + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + secrets: { + get: vi.fn(), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn(), + }, + subscriptions: [], + } + + // Use type assertion for test mocking + mockContext = mockContextPartial as unknown as vscode.ExtensionContext + + authService = new StaticTokenAuthService(mockContext, testToken, mockLog) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should create instance and log static token mode", () => { + expect(authService).toBeInstanceOf(StaticTokenAuthService) + expect(mockLog).toHaveBeenCalledWith("[auth] Using static token authentication mode") + }) + + it("should use console.log as default logger", () => { + const serviceWithoutLog = new StaticTokenAuthService( + mockContext as unknown as vscode.ExtensionContext, + testToken, + ) + // Can't directly test console.log usage, but constructor should not throw + expect(serviceWithoutLog).toBeInstanceOf(StaticTokenAuthService) + }) + }) + + describe("initialize", () => { + it("should start in active-session state", async () => { + await authService.initialize() + expect(authService.getState()).toBe("active-session") + }) + + it("should emit auth-state-changed event on initialize", async () => { + const spy = vi.fn() + authService.on("auth-state-changed", spy) + + await authService.initialize() + + expect(spy).toHaveBeenCalledWith({ state: "active-session", previousState: "initializing" }) + }) + + it("should log successful initialization", async () => { + await authService.initialize() + expect(mockLog).toHaveBeenCalledWith("[auth] Static token auth service initialized in active-session state") + }) + }) + + describe("getSessionToken", () => { + it("should return the provided token", () => { + expect(authService.getSessionToken()).toBe(testToken) + }) + + it("should return different token when constructed with different token", () => { + const differentToken = "different-token" + const differentService = new StaticTokenAuthService(mockContext, differentToken, mockLog) + expect(differentService.getSessionToken()).toBe(differentToken) + }) + }) + + describe("getUserInfo", () => { + it("should return empty object", () => { + expect(authService.getUserInfo()).toEqual({}) + }) + }) + + describe("getStoredOrganizationId", () => { + it("should return null", () => { + expect(authService.getStoredOrganizationId()).toBeNull() + }) + }) + + describe("authentication state methods", () => { + it("should always return true for isAuthenticated", () => { + expect(authService.isAuthenticated()).toBe(true) + }) + + it("should always return true for hasActiveSession", () => { + expect(authService.hasActiveSession()).toBe(true) + }) + + it("should always return true for hasOrIsAcquiringActiveSession", () => { + expect(authService.hasOrIsAcquiringActiveSession()).toBe(true) + }) + + it("should return active-session for getState", () => { + expect(authService.getState()).toBe("active-session") + }) + }) + + describe("disabled authentication methods", () => { + const expectedErrorMessage = "Authentication methods are disabled in StaticTokenAuthService" + + it("should throw error for login", async () => { + await expect(authService.login()).rejects.toThrow(expectedErrorMessage) + }) + + it("should throw error for logout", async () => { + await expect(authService.logout()).rejects.toThrow(expectedErrorMessage) + }) + + it("should throw error for handleCallback", async () => { + await expect(authService.handleCallback("code", "state")).rejects.toThrow(expectedErrorMessage) + }) + + it("should throw error for handleCallback with organization", async () => { + await expect(authService.handleCallback("code", "state", "org_123")).rejects.toThrow(expectedErrorMessage) + }) + }) + + describe("event emission", () => { + it("should be able to register and emit events", async () => { + const authStateChangedSpy = vi.fn() + const userInfoSpy = vi.fn() + + authService.on("auth-state-changed", authStateChangedSpy) + authService.on("user-info", userInfoSpy) + + await authService.initialize() + + expect(authStateChangedSpy).toHaveBeenCalledWith({ state: "active-session", previousState: "initializing" }) + // user-info event is not emitted in static token mode + expect(userInfoSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cloud/src/__tests__/auth/WebAuthService.spec.ts b/packages/cloud/src/__tests__/auth/WebAuthService.spec.ts new file mode 100644 index 0000000000..82fd964b7f --- /dev/null +++ b/packages/cloud/src/__tests__/auth/WebAuthService.spec.ts @@ -0,0 +1,1113 @@ +// npx vitest run src/__tests__/auth/WebAuthService.spec.ts + +import { type Mock } from "vitest" +import crypto from "crypto" +import * as vscode from "vscode" + +import { WebAuthService } from "../../auth/WebAuthService" +import { RefreshTimer } from "../../RefreshTimer" +import { getClerkBaseUrl, getRooCodeApiUrl } from "../../config" +import { getUserAgent } from "../../utils" + +// Mock external dependencies +vi.mock("../../RefreshTimer") +vi.mock("../../config") +vi.mock("../../utils") +vi.mock("crypto") + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +// Mock vscode module +vi.mock("vscode", () => ({ + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, + env: { + openExternal: vi.fn(), + uriScheme: "vscode", + }, + Uri: { + parse: vi.fn((uri: string) => ({ toString: () => uri })), + }, +})) + +describe("WebAuthService", () => { + let authService: WebAuthService + let mockTimer: { + start: Mock + stop: Mock + reset: Mock + } + let mockLog: Mock + let mockContext: { + subscriptions: { push: Mock } + secrets: { + get: Mock + store: Mock + delete: Mock + onDidChange: Mock + } + globalState: { + get: Mock + update: Mock + } + extension: { + packageJSON: { + version: string + publisher: string + name: string + } + } + } + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks() + + // Setup mock context with proper subscriptions array + mockContext = { + subscriptions: { + push: vi.fn(), + }, + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + globalState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + }, + extension: { + packageJSON: { + version: "1.0.0", + publisher: "RooVeterinaryInc", + name: "roo-cline", + }, + }, + } + + // Setup timer mock + mockTimer = { + start: vi.fn(), + stop: vi.fn(), + reset: vi.fn(), + } + const MockedRefreshTimer = vi.mocked(RefreshTimer) + MockedRefreshTimer.mockImplementation(() => mockTimer as unknown as RefreshTimer) + + // Setup config mocks - use production URL by default to maintain existing test behavior + vi.mocked(getClerkBaseUrl).mockReturnValue("https://clerk.roocode.com") + vi.mocked(getRooCodeApiUrl).mockReturnValue("https://api.test.com") + + // Setup utils mock + vi.mocked(getUserAgent).mockReturnValue("Roo-Code 1.0.0") + + // Setup crypto mock + vi.mocked(crypto.randomBytes).mockReturnValue(Buffer.from("test-random-bytes") as never) + + // Setup log mock + mockLog = vi.fn() + + authService = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should initialize with correct default values", () => { + expect(authService.getState()).toBe("initializing") + expect(authService.isAuthenticated()).toBe(false) + expect(authService.hasActiveSession()).toBe(false) + expect(authService.getSessionToken()).toBeUndefined() + expect(authService.getUserInfo()).toBeNull() + }) + + it("should create RefreshTimer with correct configuration", () => { + expect(RefreshTimer).toHaveBeenCalledWith({ + callback: expect.any(Function), + successInterval: 50_000, + initialBackoffMs: 1_000, + maxBackoffMs: 300_000, + }) + }) + + it("should use console.log as default logger", () => { + const serviceWithoutLog = new WebAuthService(mockContext as unknown as vscode.ExtensionContext) + // Can't directly test console.log usage, but constructor should not throw + expect(serviceWithoutLog).toBeInstanceOf(WebAuthService) + }) + }) + + describe("initialize", () => { + it("should handle credentials change and setup event listener", async () => { + await authService.initialize() + + expect(mockContext.subscriptions.push).toHaveBeenCalled() + expect(mockContext.secrets.onDidChange).toHaveBeenCalled() + }) + + it("should not initialize twice", async () => { + await authService.initialize() + const firstCallCount = vi.mocked(mockContext.secrets.onDidChange).mock.calls.length + + await authService.initialize() + expect(mockContext.secrets.onDidChange).toHaveBeenCalledTimes(firstCallCount) + expect(mockLog).toHaveBeenCalledWith("[auth] initialize() called after already initialized") + }) + + it("should transition to logged-out when no credentials exist", async () => { + mockContext.secrets.get.mockResolvedValue(undefined) + + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + await authService.initialize() + + expect(authService.getState()).toBe("logged-out") + expect(authStateChangedSpy).toHaveBeenCalledWith({ state: "logged-out", previousState: "initializing" }) + }) + + it("should transition to attempting-session when valid credentials exist", async () => { + const credentials = { clientToken: "test-token", sessionId: "test-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + await authService.initialize() + + expect(authService.getState()).toBe("attempting-session") + expect(authStateChangedSpy).toHaveBeenCalledWith({ + state: "attempting-session", + previousState: "initializing", + }) + expect(mockTimer.start).toHaveBeenCalled() + }) + + it("should handle invalid credentials gracefully", async () => { + mockContext.secrets.get.mockResolvedValue("invalid-json") + + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + await authService.initialize() + + expect(authService.getState()).toBe("logged-out") + expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse stored credentials:", expect.any(Error)) + }) + + it("should handle credentials change events", async () => { + let onDidChangeCallback: (e: { key: string }) => void + + mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => { + onDidChangeCallback = callback + return { dispose: vi.fn() } + }) + + await authService.initialize() + + // Simulate credentials change event + const newCredentials = { clientToken: "new-token", sessionId: "new-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(newCredentials)) + + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + onDidChangeCallback!({ key: "clerk-auth-credentials" }) + await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling + + expect(authStateChangedSpy).toHaveBeenCalled() + }) + }) + + describe("login", () => { + beforeEach(async () => { + await authService.initialize() + }) + + it("should generate state and open external URL", async () => { + const mockOpenExternal = vi.fn() + const vscode = await import("vscode") + vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal) + + await authService.login() + + expect(crypto.randomBytes).toHaveBeenCalledWith(16) + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "clerk-auth-state", + "746573742d72616e646f6d2d6279746573", + ) + expect(mockOpenExternal).toHaveBeenCalledWith( + expect.objectContaining({ + toString: expect.any(Function), + }), + ) + }) + + it("should use package.json values for redirect URI", async () => { + const mockOpenExternal = vi.fn() + const vscode = await import("vscode") + vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal) + + await authService.login() + + const expectedUrl = + "https://api.test.com/extension/sign-in?state=746573742d72616e646f6d2d6279746573&auth_redirect=vscode%3A%2F%2FRooVeterinaryInc.roo-cline" + expect(mockOpenExternal).toHaveBeenCalledWith( + expect.objectContaining({ + toString: expect.any(Function), + }), + ) + + // Verify the actual URL + const calledUri = mockOpenExternal.mock.calls[0][0] + expect(calledUri.toString()).toBe(expectedUrl) + }) + + it("should handle errors during login", async () => { + vi.mocked(crypto.randomBytes).mockImplementation(() => { + throw new Error("Crypto error") + }) + + await expect(authService.login()).rejects.toThrow("Failed to initiate Roo Code Cloud authentication") + expect(mockLog).toHaveBeenCalledWith("[auth] Error initiating Roo Code Cloud auth: Error: Crypto error") + }) + }) + + describe("handleCallback", () => { + beforeEach(async () => { + await authService.initialize() + }) + + it("should handle invalid parameters", async () => { + const vscode = await import("vscode") + const mockShowInfo = vi.fn() + vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) + + await authService.handleCallback(null, "state") + expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url") + + await authService.handleCallback("code", null) + expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url") + }) + + it("should validate state parameter", async () => { + mockContext.globalState.get.mockReturnValue("stored-state") + + await expect(authService.handleCallback("code", "different-state")).rejects.toThrow( + "Failed to handle Roo Code Cloud callback", + ) + expect(mockLog).toHaveBeenCalledWith("[auth] State mismatch in callback") + }) + + it("should successfully handle valid callback", async () => { + const storedState = "valid-state" + mockContext.globalState.get.mockReturnValue(storedState) + + // Mock successful Clerk sign-in response + const mockResponse = { + ok: true, + json: () => + Promise.resolve({ + response: { created_session_id: "session-123" }, + }), + headers: { + get: (header: string) => (header === "authorization" ? "Bearer token-123" : null), + }, + } + mockFetch.mockResolvedValue(mockResponse) + + const vscode = await import("vscode") + const mockShowInfo = vi.fn() + vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) + + await authService.handleCallback("auth-code", storedState) + + expect(mockContext.secrets.store).toHaveBeenCalledWith( + "clerk-auth-credentials", + JSON.stringify({ clientToken: "Bearer token-123", sessionId: "session-123", organizationId: null }), + ) + expect(mockShowInfo).toHaveBeenCalledWith("Successfully authenticated with Roo Code Cloud") + }) + + it("should handle Clerk API errors", async () => { + const storedState = "valid-state" + mockContext.globalState.get.mockReturnValue(storedState) + + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + }) + + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + await expect(authService.handleCallback("auth-code", storedState)).rejects.toThrow( + "Failed to handle Roo Code Cloud callback", + ) + expect(authStateChangedSpy).toHaveBeenCalled() + }) + }) + + describe("logout", () => { + beforeEach(async () => { + await authService.initialize() + }) + + it("should clear credentials and call Clerk logout", async () => { + // Set up credentials first by simulating a login state + const credentials = { clientToken: "test-token", sessionId: "test-session" } + + // Manually set the credentials in the service + authService["credentials"] = credentials + + // Mock successful logout response + mockFetch.mockResolvedValue({ ok: true }) + + const vscode = await import("vscode") + const mockShowInfo = vi.fn() + vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) + + await authService.logout() + + expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials") + expect(mockContext.globalState.update).toHaveBeenCalledWith("clerk-auth-state", undefined) + expect(mockFetch).toHaveBeenCalledWith( + "https://clerk.roocode.com/v1/client/sessions/test-session/remove", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ) + expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud") + }) + + it("should handle logout without credentials", async () => { + const vscode = await import("vscode") + const mockShowInfo = vi.fn() + vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) + + await authService.logout() + + expect(mockContext.secrets.delete).toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud") + }) + + it("should handle Clerk logout errors gracefully", async () => { + // Set up credentials first by simulating a login state + const credentials = { clientToken: "test-token", sessionId: "test-session" } + + // Manually set the credentials in the service + authService["credentials"] = credentials + + // Mock failed logout response + mockFetch.mockRejectedValue(new Error("Network error")) + + const vscode = await import("vscode") + const mockShowInfo = vi.fn() + vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) + + await authService.logout() + + expect(mockLog).toHaveBeenCalledWith("[auth] Error calling clerkLogout:", expect.any(Error)) + expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud") + }) + }) + + describe("state management", () => { + it("should return correct state", () => { + expect(authService.getState()).toBe("initializing") + }) + + it("should return correct authentication status", async () => { + await authService.initialize() + expect(authService.isAuthenticated()).toBe(false) + + // Create a new service instance with credentials + const credentials = { clientToken: "test-token", sessionId: "test-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + + const authenticatedService = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog) + await authenticatedService.initialize() + + expect(authenticatedService.isAuthenticated()).toBe(true) + expect(authenticatedService.hasActiveSession()).toBe(false) + }) + + it("should return session token only for active sessions", () => { + expect(authService.getSessionToken()).toBeUndefined() + + // Manually set state to active-session for testing + // This would normally happen through refreshSession + authService["state"] = "active-session" + authService["sessionToken"] = "test-jwt" + + expect(authService.getSessionToken()).toBe("test-jwt") + }) + + it("should return correct values for new methods", async () => { + await authService.initialize() + expect(authService.hasOrIsAcquiringActiveSession()).toBe(false) + + // Create a new service instance with credentials (attempting-session) + const credentials = { clientToken: "test-token", sessionId: "test-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + + const attemptingService = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog) + await attemptingService.initialize() + + expect(attemptingService.hasOrIsAcquiringActiveSession()).toBe(true) + expect(attemptingService.hasActiveSession()).toBe(false) + + // Manually set state to active-session for testing + attemptingService["state"] = "active-session" + expect(attemptingService.hasOrIsAcquiringActiveSession()).toBe(true) + expect(attemptingService.hasActiveSession()).toBe(true) + }) + }) + + describe("session refresh", () => { + beforeEach(async () => { + // Set up with credentials + const credentials = { clientToken: "test-token", sessionId: "test-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + await authService.initialize() + }) + + it("should refresh session successfully", async () => { + // Mock successful token creation and user info fetch + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ jwt: "new-jwt-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + response: { + first_name: "John", + last_name: "Doe", + image_url: "https://example.com/avatar.jpg", + primary_email_address_id: "email-1", + email_addresses: [{ id: "email-1", email_address: "john@example.com" }], + }, + }), + }) + + const authStateChangedSpy = vi.fn() + const userInfoSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + authService.on("user-info", userInfoSpy) + + // Trigger refresh by calling the timer callback + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await timerCallback() + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authService.getState()).toBe("active-session") + expect(authService.hasActiveSession()).toBe(true) + expect(authService.getSessionToken()).toBe("new-jwt-token") + expect(authStateChangedSpy).toHaveBeenCalledWith({ + state: "active-session", + previousState: "attempting-session", + }) + expect(userInfoSpy).toHaveBeenCalledWith({ + userInfo: { + name: "John Doe", + email: "john@example.com", + picture: "https://example.com/avatar.jpg", + }, + }) + }) + + it("should handle invalid client token error", async () => { + // Mock 401 response (invalid token) + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }) + + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + + await expect(timerCallback()).rejects.toThrow() + expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials") + expect(mockLog).toHaveBeenCalledWith("[auth] Invalid/Expired client token: clearing credentials") + }) + + it("should handle network errors during refresh", async () => { + mockFetch.mockRejectedValue(new Error("Network error")) + + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + + await expect(timerCallback()).rejects.toThrow("Network error") + expect(mockLog).toHaveBeenCalledWith("[auth] Failed to refresh session", expect.any(Error)) + }) + + it("should transition to inactive-session on first attempt failure", async () => { + // Mock failed token creation response + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }) + + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + // Verify we start in attempting-session state + expect(authService.getState()).toBe("attempting-session") + expect(authService["isFirstRefreshAttempt"]).toBe(true) + + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + + await expect(timerCallback()).rejects.toThrow() + + // Should transition to inactive-session after first failure + expect(authService.getState()).toBe("inactive-session") + expect(authService["isFirstRefreshAttempt"]).toBe(false) + expect(authStateChangedSpy).toHaveBeenCalledWith({ + state: "inactive-session", + previousState: "attempting-session", + }) + }) + + it("should not transition to inactive-session on subsequent failures", async () => { + // First, transition to inactive-session by failing the first attempt + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }) + + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await expect(timerCallback()).rejects.toThrow() + + // Verify we're now in inactive-session + expect(authService.getState()).toBe("inactive-session") + expect(authService["isFirstRefreshAttempt"]).toBe(false) + + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + // Subsequent failure should not trigger another transition + await expect(timerCallback()).rejects.toThrow() + + expect(authService.getState()).toBe("inactive-session") + expect(authStateChangedSpy).not.toHaveBeenCalled() + }) + + it("should clear credentials on 401 during first refresh attempt (bug fix)", async () => { + // Mock 401 response during first refresh attempt + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }) + + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await expect(timerCallback()).rejects.toThrow() + + // Should clear credentials (not just transition to inactive-session) + expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials") + expect(mockLog).toHaveBeenCalledWith("[auth] Invalid/Expired client token: clearing credentials") + + // Simulate credentials cleared event + mockContext.secrets.get.mockResolvedValue(undefined) + await authService["handleCredentialsChange"]() + + expect(authService.getState()).toBe("logged-out") + expect(authStateChangedSpy).toHaveBeenCalledWith({ + state: "logged-out", + previousState: "attempting-session", + }) + }) + }) + + describe("user info", () => { + it("should return null initially", () => { + expect(authService.getUserInfo()).toBeNull() + }) + + it("should parse user info correctly for personal accounts", async () => { + // Set up with credentials for personal account (no organizationId) + const credentials = { clientToken: "test-token", sessionId: "test-session", organizationId: null } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + await authService.initialize() + + // Clear previous mock calls + mockFetch.mockClear() + + // Mock successful responses + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ jwt: "jwt-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + response: { + first_name: "Jane", + last_name: "Smith", + image_url: "https://example.com/jane.jpg", + primary_email_address_id: "email-2", + email_addresses: [ + { id: "email-1", email_address: "jane.old@example.com" }, + { id: "email-2", email_address: "jane@example.com" }, + ], + }, + }), + }) + + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await timerCallback() + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + + const userInfo = authService.getUserInfo() + expect(userInfo).toEqual({ + name: "Jane Smith", + email: "jane@example.com", + picture: "https://example.com/jane.jpg", + }) + }) + + it("should parse user info correctly for organization accounts", async () => { + // Set up with credentials for organization account + const credentials = { clientToken: "test-token", sessionId: "test-session", organizationId: "org_1" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + await authService.initialize() + + // Clear previous mock calls + mockFetch.mockClear() + + // Mock successful responses + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ jwt: "jwt-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + response: { + first_name: "Jane", + last_name: "Smith", + image_url: "https://example.com/jane.jpg", + primary_email_address_id: "email-2", + email_addresses: [ + { id: "email-1", email_address: "jane.old@example.com" }, + { id: "email-2", email_address: "jane@example.com" }, + ], + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + response: [ + { + id: "org_member_id_1", + role: "member", + organization: { + id: "org_1", + name: "Org 1", + }, + }, + ], + }), + }) + + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await timerCallback() + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + + const userInfo = authService.getUserInfo() + expect(userInfo).toEqual({ + name: "Jane Smith", + email: "jane@example.com", + picture: "https://example.com/jane.jpg", + organizationId: "org_1", + organizationName: "Org 1", + organizationRole: "member", + }) + }) + + it("should handle missing user info fields", async () => { + // Set up with credentials for personal account (no organizationId) + const credentials = { clientToken: "test-token", sessionId: "test-session", organizationId: null } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + await authService.initialize() + + // Clear previous mock calls + mockFetch.mockClear() + + // Mock responses with minimal data + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ jwt: "jwt-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + response: { + first_name: "John", + last_name: "Doe", + // Missing other fields + }, + }), + }) + + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await timerCallback() + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + + const userInfo = authService.getUserInfo() + expect(userInfo).toEqual({ + name: "John Doe", + email: undefined, + picture: undefined, + }) + }) + }) + + describe("event emissions", () => { + it("should emit auth-state-changed event for logged-out", async () => { + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + await authService.initialize() + + expect(authStateChangedSpy).toHaveBeenCalledWith({ state: "logged-out", previousState: "initializing" }) + }) + + it("should emit auth-state-changed event for attempting-session", async () => { + const credentials = { clientToken: "test-token", sessionId: "test-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + await authService.initialize() + + expect(authStateChangedSpy).toHaveBeenCalledWith({ + state: "attempting-session", + previousState: "initializing", + }) + }) + + it("should emit auth-state-changed event for active-session", async () => { + // Set up with credentials + const credentials = { clientToken: "test-token", sessionId: "test-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + await authService.initialize() + + // Clear previous mock calls + mockFetch.mockClear() + + // Mock both the token creation and user info fetch + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ jwt: "jwt-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + response: { + first_name: "Test", + last_name: "User", + }, + }), + }) + + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await timerCallback() + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authStateChangedSpy).toHaveBeenCalledWith({ + state: "active-session", + previousState: "attempting-session", + }) + }) + + it("should emit user-info event", async () => { + // Set up with credentials + const credentials = { clientToken: "test-token", sessionId: "test-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + await authService.initialize() + + // Clear previous mock calls + mockFetch.mockClear() + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ jwt: "jwt-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + response: { + first_name: "Test", + last_name: "User", + }, + }), + }) + + const userInfoSpy = vi.fn() + authService.on("user-info", userInfoSpy) + + const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback + await timerCallback() + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(userInfoSpy).toHaveBeenCalledWith({ + userInfo: { + name: "Test User", + email: undefined, + picture: undefined, + }, + }) + }) + }) + + describe("error handling", () => { + it("should handle credentials change errors", async () => { + mockContext.secrets.get.mockRejectedValue(new Error("Storage error")) + + await authService.initialize() + + expect(mockLog).toHaveBeenCalledWith("[auth] Error handling credentials change:", expect.any(Error)) + }) + + it("should handle malformed JSON in credentials", async () => { + mockContext.secrets.get.mockResolvedValue("invalid-json{") + + await authService.initialize() + + expect(authService.getState()).toBe("logged-out") + expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse stored credentials:", expect.any(Error)) + }) + + it("should handle invalid credentials schema", async () => { + mockContext.secrets.get.mockResolvedValue(JSON.stringify({ invalid: "data" })) + + await authService.initialize() + + expect(authService.getState()).toBe("logged-out") + expect(mockLog).toHaveBeenCalledWith("[auth] Invalid credentials format:", expect.any(Array)) + }) + + it("should handle missing authorization header in sign-in response", async () => { + const storedState = "valid-state" + mockContext.globalState.get.mockReturnValue(storedState) + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + response: { created_session_id: "session-123" }, + }), + headers: { + get: () => null, // No authorization header + }, + }) + + await expect(authService.handleCallback("auth-code", storedState)).rejects.toThrow( + "Failed to handle Roo Code Cloud callback", + ) + }) + }) + + describe("timer integration", () => { + it("should stop timer on logged-out transition", async () => { + await authService.initialize() + + expect(mockTimer.stop).toHaveBeenCalled() + }) + + it("should start timer on attempting-session transition", async () => { + const credentials = { clientToken: "test-token", sessionId: "test-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + + await authService.initialize() + + expect(mockTimer.start).toHaveBeenCalled() + }) + }) + + describe("auth credentials key scoping", () => { + it("should use default key when getClerkBaseUrl returns production URL", async () => { + // Mock getClerkBaseUrl to return production URL + vi.mocked(getClerkBaseUrl).mockReturnValue("https://clerk.roocode.com") + + const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog) + const credentials = { clientToken: "test-token", sessionId: "test-session" } + + await service.initialize() + await service["storeCredentials"](credentials) + + expect(mockContext.secrets.store).toHaveBeenCalledWith( + "clerk-auth-credentials", + JSON.stringify(credentials), + ) + }) + + it("should use scoped key when getClerkBaseUrl returns custom URL", async () => { + const customUrl = "https://custom.clerk.com" + // Mock getClerkBaseUrl to return custom URL + vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) + + const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog) + const credentials = { clientToken: "test-token", sessionId: "test-session" } + + await service.initialize() + await service["storeCredentials"](credentials) + + expect(mockContext.secrets.store).toHaveBeenCalledWith( + `clerk-auth-credentials-${customUrl}`, + JSON.stringify(credentials), + ) + }) + + it("should load credentials using scoped key", async () => { + const customUrl = "https://custom.clerk.com" + vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) + + const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog) + const credentials = { clientToken: "test-token", sessionId: "test-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + + await service.initialize() + const loadedCredentials = await service["loadCredentials"]() + + expect(mockContext.secrets.get).toHaveBeenCalledWith(`clerk-auth-credentials-${customUrl}`) + expect(loadedCredentials).toEqual(credentials) + }) + + it("should clear credentials using scoped key", async () => { + const customUrl = "https://custom.clerk.com" + vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) + + const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog) + + await service.initialize() + await service["clearCredentials"]() + + expect(mockContext.secrets.delete).toHaveBeenCalledWith(`clerk-auth-credentials-${customUrl}`) + }) + + it("should listen for changes on scoped key", async () => { + const customUrl = "https://custom.clerk.com" + vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) + + let onDidChangeCallback: (e: { key: string }) => void + + mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => { + onDidChangeCallback = callback + return { dispose: vi.fn() } + }) + + const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog) + await service.initialize() + + // Simulate credentials change event with scoped key + const newCredentials = { clientToken: "new-token", sessionId: "new-session" } + mockContext.secrets.get.mockResolvedValue(JSON.stringify(newCredentials)) + + const authStateChangedSpy = vi.fn() + service.on("auth-state-changed", authStateChangedSpy) + + onDidChangeCallback!({ key: `clerk-auth-credentials-${customUrl}` }) + await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling + + expect(authStateChangedSpy).toHaveBeenCalled() + }) + + it("should not respond to changes on different scoped keys", async () => { + const customUrl = "https://custom.clerk.com" + vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) + + let onDidChangeCallback: (e: { key: string }) => void + + mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => { + onDidChangeCallback = callback + return { dispose: vi.fn() } + }) + + const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog) + await service.initialize() + + const authStateChangedSpy = vi.fn() + service.on("auth-state-changed", authStateChangedSpy) + + // Simulate credentials change event with different scoped key + onDidChangeCallback!({ key: "clerk-auth-credentials-https://other.clerk.com" }) + await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling + + expect(authStateChangedSpy).not.toHaveBeenCalled() + }) + + it("should not respond to changes on default key when using scoped key", async () => { + const customUrl = "https://custom.clerk.com" + vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) + + let onDidChangeCallback: (e: { key: string }) => void + + mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => { + onDidChangeCallback = callback + return { dispose: vi.fn() } + }) + + const service = new WebAuthService(mockContext as unknown as vscode.ExtensionContext, mockLog) + await service.initialize() + + const authStateChangedSpy = vi.fn() + service.on("auth-state-changed", authStateChangedSpy) + + // Simulate credentials change event with default key + onDidChangeCallback!({ key: "clerk-auth-credentials" }) + await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling + + expect(authStateChangedSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cloud/src/auth/AuthService.ts b/packages/cloud/src/auth/AuthService.ts new file mode 100644 index 0000000000..a49ad0104d --- /dev/null +++ b/packages/cloud/src/auth/AuthService.ts @@ -0,0 +1,36 @@ +import EventEmitter from "events" + +import type { CloudUserInfo } from "@roo-code/types" + +export interface AuthServiceEvents { + "auth-state-changed": [ + data: { + state: AuthState + previousState: AuthState + }, + ] + "user-info": [data: { userInfo: CloudUserInfo }] +} + +export type AuthState = "initializing" | "logged-out" | "active-session" | "attempting-session" | "inactive-session" + +export interface AuthService extends EventEmitter { + // Lifecycle + initialize(): Promise + + // Authentication methods + login(): Promise + logout(): Promise + handleCallback(code: string | null, state: string | null, organizationId?: string | null): Promise + + // State methods + getState(): AuthState + isAuthenticated(): boolean + hasActiveSession(): boolean + hasOrIsAcquiringActiveSession(): boolean + + // Token and user info + getSessionToken(): string | undefined + getUserInfo(): CloudUserInfo | null + getStoredOrganizationId(): string | null +} diff --git a/packages/cloud/src/auth/StaticTokenAuthService.ts b/packages/cloud/src/auth/StaticTokenAuthService.ts new file mode 100644 index 0000000000..04821006d5 --- /dev/null +++ b/packages/cloud/src/auth/StaticTokenAuthService.ts @@ -0,0 +1,71 @@ +import EventEmitter from "events" + +import * as vscode from "vscode" + +import type { CloudUserInfo } from "@roo-code/types" + +import type { AuthService, AuthServiceEvents, AuthState } from "./AuthService" + +export class StaticTokenAuthService extends EventEmitter implements AuthService { + private state: AuthState = "active-session" + private token: string + private log: (...args: unknown[]) => void + + constructor(context: vscode.ExtensionContext, token: string, log?: (...args: unknown[]) => void) { + super() + this.token = token + this.log = log || console.log + this.log("[auth] Using static token authentication mode") + } + + public async initialize(): Promise { + const previousState: AuthState = "initializing" + this.state = "active-session" + this.emit("auth-state-changed", { state: this.state, previousState }) + this.log("[auth] Static token auth service initialized in active-session state") + } + + public async login(): Promise { + throw new Error("Authentication methods are disabled in StaticTokenAuthService") + } + + public async logout(): Promise { + throw new Error("Authentication methods are disabled in StaticTokenAuthService") + } + + public async handleCallback( + _code: string | null, + _state: string | null, + _organizationId?: string | null, + ): Promise { + throw new Error("Authentication methods are disabled in StaticTokenAuthService") + } + + public getState(): AuthState { + return this.state + } + + public getSessionToken(): string | undefined { + return this.token + } + + public isAuthenticated(): boolean { + return true + } + + public hasActiveSession(): boolean { + return true + } + + public hasOrIsAcquiringActiveSession(): boolean { + return true + } + + public getUserInfo(): CloudUserInfo | null { + return {} + } + + public getStoredOrganizationId(): string | null { + return null + } +} diff --git a/packages/cloud/src/auth/WebAuthService.ts b/packages/cloud/src/auth/WebAuthService.ts new file mode 100644 index 0000000000..b94957950b --- /dev/null +++ b/packages/cloud/src/auth/WebAuthService.ts @@ -0,0 +1,646 @@ +import crypto from "crypto" +import EventEmitter from "events" + +import * as vscode from "vscode" +import { z } from "zod" + +import type { CloudUserInfo, CloudOrganizationMembership } from "@roo-code/types" + +import { getClerkBaseUrl, getRooCodeApiUrl, PRODUCTION_CLERK_BASE_URL } from "../config" +import { getUserAgent } from "../utils" +import { InvalidClientTokenError } from "../errors" +import { RefreshTimer } from "../RefreshTimer" + +import type { AuthService, AuthServiceEvents, AuthState } from "./AuthService" + +const AUTH_STATE_KEY = "clerk-auth-state" + +/** + * AuthCredentials + */ + +const authCredentialsSchema = z.object({ + clientToken: z.string().min(1, "Client token cannot be empty"), + sessionId: z.string().min(1, "Session ID cannot be empty"), + organizationId: z.string().nullable().optional(), +}) + +type AuthCredentials = z.infer + +/** + * Clerk Schemas + */ + +const clerkSignInResponseSchema = z.object({ + response: z.object({ + created_session_id: z.string(), + }), +}) + +const clerkCreateSessionTokenResponseSchema = z.object({ + jwt: z.string(), +}) + +const clerkMeResponseSchema = z.object({ + response: z.object({ + id: z.string().optional(), + first_name: z.string().nullish(), + last_name: z.string().nullish(), + image_url: z.string().optional(), + primary_email_address_id: z.string().optional(), + email_addresses: z + .array( + z.object({ + id: z.string(), + email_address: z.string(), + }), + ) + .optional(), + }), +}) + +const clerkOrganizationMembershipsSchema = z.object({ + response: z.array( + z.object({ + id: z.string(), + role: z.string(), + permissions: z.array(z.string()).optional(), + created_at: z.number().optional(), + updated_at: z.number().optional(), + organization: z.object({ + id: z.string(), + name: z.string(), + slug: z.string().optional(), + image_url: z.string().optional(), + has_image: z.boolean().optional(), + created_at: z.number().optional(), + updated_at: z.number().optional(), + }), + }), + ), +}) + +export class WebAuthService extends EventEmitter implements AuthService { + private context: vscode.ExtensionContext + private timer: RefreshTimer + private state: AuthState = "initializing" + private log: (...args: unknown[]) => void + private readonly authCredentialsKey: string + + private credentials: AuthCredentials | null = null + private sessionToken: string | null = null + private userInfo: CloudUserInfo | null = null + private isFirstRefreshAttempt: boolean = false + + constructor(context: vscode.ExtensionContext, log?: (...args: unknown[]) => void) { + super() + + this.context = context + this.log = log || console.log + + // Calculate auth credentials key based on Clerk base URL. + const clerkBaseUrl = getClerkBaseUrl() + + if (clerkBaseUrl !== PRODUCTION_CLERK_BASE_URL) { + this.authCredentialsKey = `clerk-auth-credentials-${clerkBaseUrl}` + } else { + this.authCredentialsKey = "clerk-auth-credentials" + } + + this.timer = new RefreshTimer({ + callback: async () => { + await this.refreshSession() + return true + }, + successInterval: 50_000, + initialBackoffMs: 1_000, + maxBackoffMs: 300_000, + }) + } + + private changeState(newState: AuthState): void { + const previousState = this.state + this.state = newState + this.emit("auth-state-changed", { state: newState, previousState }) + } + + private async handleCredentialsChange(): Promise { + try { + const credentials = await this.loadCredentials() + + if (credentials) { + if ( + this.credentials === null || + this.credentials.clientToken !== credentials.clientToken || + this.credentials.sessionId !== credentials.sessionId + ) { + this.transitionToAttemptingSession(credentials) + } + } else { + if (this.state !== "logged-out") { + this.transitionToLoggedOut() + } + } + } catch (error) { + this.log("[auth] Error handling credentials change:", error) + } + } + + private transitionToLoggedOut(): void { + this.timer.stop() + + this.credentials = null + this.sessionToken = null + this.userInfo = null + + this.changeState("logged-out") + + this.log("[auth] Transitioned to logged-out state") + } + + private transitionToAttemptingSession(credentials: AuthCredentials): void { + this.credentials = credentials + + this.sessionToken = null + this.userInfo = null + this.isFirstRefreshAttempt = true + + this.changeState("attempting-session") + + this.timer.start() + + this.log("[auth] Transitioned to attempting-session state") + } + + private transitionToInactiveSession(): void { + this.sessionToken = null + this.userInfo = null + + this.changeState("inactive-session") + + this.log("[auth] Transitioned to inactive-session state") + } + + /** + * Initialize the auth state + * + * This method loads tokens from storage and determines the current auth state. + * It also starts the refresh timer if we have an active session. + */ + public async initialize(): Promise { + if (this.state !== "initializing") { + this.log("[auth] initialize() called after already initialized") + return + } + + await this.handleCredentialsChange() + + this.context.subscriptions.push( + this.context.secrets.onDidChange((e) => { + if (e.key === this.authCredentialsKey) { + this.handleCredentialsChange() + } + }), + ) + } + + private async storeCredentials(credentials: AuthCredentials): Promise { + await this.context.secrets.store(this.authCredentialsKey, JSON.stringify(credentials)) + } + + private async loadCredentials(): Promise { + const credentialsJson = await this.context.secrets.get(this.authCredentialsKey) + if (!credentialsJson) return null + + try { + const parsedJson = JSON.parse(credentialsJson) + const credentials = authCredentialsSchema.parse(parsedJson) + + // Migration: If no organizationId but we have userInfo, add it + if (credentials.organizationId === undefined && this.userInfo?.organizationId) { + credentials.organizationId = this.userInfo.organizationId + await this.storeCredentials(credentials) + this.log("[auth] Migrated credentials with organizationId") + } + + return credentials + } catch (error) { + if (error instanceof z.ZodError) { + this.log("[auth] Invalid credentials format:", error.errors) + } else { + this.log("[auth] Failed to parse stored credentials:", error) + } + return null + } + } + + private async clearCredentials(): Promise { + await this.context.secrets.delete(this.authCredentialsKey) + } + + /** + * Start the login process + * + * This method initiates the authentication flow by generating a state parameter + * and opening the browser to the authorization URL. + */ + public async login(): Promise { + try { + // Generate a cryptographically random state parameter. + const state = crypto.randomBytes(16).toString("hex") + await this.context.globalState.update(AUTH_STATE_KEY, state) + const packageJSON = this.context.extension?.packageJSON + const publisher = packageJSON?.publisher ?? "RooVeterinaryInc" + const name = packageJSON?.name ?? "roo-cline" + const params = new URLSearchParams({ + state, + auth_redirect: `${vscode.env.uriScheme}://${publisher}.${name}`, + }) + const url = `${getRooCodeApiUrl()}/extension/sign-in?${params.toString()}` + await vscode.env.openExternal(vscode.Uri.parse(url)) + } catch (error) { + this.log(`[auth] Error initiating Roo Code Cloud auth: ${error}`) + throw new Error(`Failed to initiate Roo Code Cloud authentication: ${error}`) + } + } + + /** + * Handle the callback from Roo Code Cloud + * + * This method is called when the user is redirected back to the extension + * after authenticating with Roo Code Cloud. + * + * @param code The authorization code from the callback + * @param state The state parameter from the callback + * @param organizationId The organization ID from the callback (null for personal accounts) + */ + public async handleCallback( + code: string | null, + state: string | null, + organizationId?: string | null, + ): Promise { + if (!code || !state) { + vscode.window.showInformationMessage("Invalid Roo Code Cloud sign in url") + return + } + + try { + // Validate state parameter to prevent CSRF attacks. + const storedState = this.context.globalState.get(AUTH_STATE_KEY) + + if (state !== storedState) { + this.log("[auth] State mismatch in callback") + throw new Error("Invalid state parameter. Authentication request may have been tampered with.") + } + + const credentials = await this.clerkSignIn(code) + + // Set organizationId (null for personal accounts) + credentials.organizationId = organizationId || null + + await this.storeCredentials(credentials) + + vscode.window.showInformationMessage("Successfully authenticated with Roo Code Cloud") + this.log("[auth] Successfully authenticated with Roo Code Cloud") + } catch (error) { + this.log(`[auth] Error handling Roo Code Cloud callback: ${error}`) + this.changeState("logged-out") + throw new Error(`Failed to handle Roo Code Cloud callback: ${error}`) + } + } + + /** + * Log out + * + * This method removes all stored tokens and stops the refresh timer. + */ + public async logout(): Promise { + const oldCredentials = this.credentials + + try { + // Clear credentials from storage - onDidChange will handle state transitions + await this.clearCredentials() + await this.context.globalState.update(AUTH_STATE_KEY, undefined) + + if (oldCredentials) { + try { + await this.clerkLogout(oldCredentials) + } catch (error) { + this.log("[auth] Error calling clerkLogout:", error) + } + } + + vscode.window.showInformationMessage("Logged out from Roo Code Cloud") + this.log("[auth] Logged out from Roo Code Cloud") + } catch (error) { + this.log(`[auth] Error logging out from Roo Code Cloud: ${error}`) + throw new Error(`Failed to log out from Roo Code Cloud: ${error}`) + } + } + + public getState(): AuthState { + return this.state + } + + public getSessionToken(): string | undefined { + if (this.state === "active-session" && this.sessionToken) { + return this.sessionToken + } + + return + } + + /** + * Check if the user is authenticated + * + * @returns True if the user is authenticated (has an active, attempting, or inactive session) + */ + public isAuthenticated(): boolean { + return ( + this.state === "active-session" || this.state === "attempting-session" || this.state === "inactive-session" + ) + } + + public hasActiveSession(): boolean { + return this.state === "active-session" + } + + /** + * Check if the user has an active session or is currently attempting to acquire one + * + * @returns True if the user has an active session or is attempting to get one + */ + public hasOrIsAcquiringActiveSession(): boolean { + return this.state === "active-session" || this.state === "attempting-session" + } + + /** + * Refresh the session + * + * This method refreshes the session token using the client token. + */ + private async refreshSession(): Promise { + if (!this.credentials) { + this.log("[auth] Cannot refresh session: missing credentials") + return + } + + try { + const previousState = this.state + this.sessionToken = await this.clerkCreateSessionToken() + + if (previousState !== "active-session") { + this.changeState("active-session") + this.log("[auth] Transitioned to active-session state") + this.fetchUserInfo() + } else { + this.state = "active-session" + } + } catch (error) { + if (error instanceof InvalidClientTokenError) { + this.log("[auth] Invalid/Expired client token: clearing credentials") + this.clearCredentials() + } else if (this.isFirstRefreshAttempt && this.state === "attempting-session") { + this.isFirstRefreshAttempt = false + this.transitionToInactiveSession() + } + this.log("[auth] Failed to refresh session", error) + throw error + } + } + + private async fetchUserInfo(): Promise { + if (!this.credentials) { + return + } + + this.userInfo = await this.clerkMe() + this.emit("user-info", { userInfo: this.userInfo }) + } + + /** + * Extract user information from the ID token + * + * @returns User information from ID token claims or null if no ID token available + */ + public getUserInfo(): CloudUserInfo | null { + return this.userInfo + } + + /** + * Get the stored organization ID from credentials + * + * @returns The stored organization ID, null for personal accounts or if no credentials exist + */ + public getStoredOrganizationId(): string | null { + return this.credentials?.organizationId || null + } + + private async clerkSignIn(ticket: string): Promise { + const formData = new URLSearchParams() + formData.append("strategy", "ticket") + formData.append("ticket", ticket) + + const response = await fetch(`${getClerkBaseUrl()}/v1/client/sign_ins`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": this.userAgent(), + }, + body: formData.toString(), + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const { + response: { created_session_id: sessionId }, + } = clerkSignInResponseSchema.parse(await response.json()) + + // 3. Extract the client token from the Authorization header. + const clientToken = response.headers.get("authorization") + + if (!clientToken) { + throw new Error("No authorization header found in the response") + } + + return authCredentialsSchema.parse({ clientToken, sessionId }) + } + + private async clerkCreateSessionToken(): Promise { + const formData = new URLSearchParams() + formData.append("_is_native", "1") + + // Handle 3 cases for organization_id: + // 1. Have an org id: organization_id=THE_ORG_ID + // 2. Have a personal account: organization_id= (empty string) + // 3. Don't know if you have an org id (old style credentials): don't send organization_id param at all + const organizationId = this.getStoredOrganizationId() + if (this.credentials?.organizationId !== undefined) { + // We have organization context info (either org id or personal account) + formData.append("organization_id", organizationId || "") + } + // If organizationId is undefined, don't send the param at all (old credentials) + + const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${this.credentials!.clientToken}`, + "User-Agent": this.userAgent(), + }, + body: formData.toString(), + signal: AbortSignal.timeout(10000), + }) + + if (response.status === 401 || response.status === 404) { + throw new InvalidClientTokenError() + } else if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = clerkCreateSessionTokenResponseSchema.parse(await response.json()) + + return data.jwt + } + + private async clerkMe(): Promise { + const response = await fetch(`${getClerkBaseUrl()}/v1/me`, { + headers: { + Authorization: `Bearer ${this.credentials!.clientToken}`, + "User-Agent": this.userAgent(), + }, + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const payload = await response.json() + const { response: userData } = clerkMeResponseSchema.parse(payload) + + const userInfo: CloudUserInfo = { + id: userData.id, + picture: userData.image_url, + } + + const names = [userData.first_name, userData.last_name].filter((name) => !!name) + userInfo.name = names.length > 0 ? names.join(" ") : undefined + const primaryEmailAddressId = userData.primary_email_address_id + const emailAddresses = userData.email_addresses + + if (primaryEmailAddressId && emailAddresses) { + userInfo.email = emailAddresses.find( + (email: { id: string }) => primaryEmailAddressId === email.id, + )?.email_address + } + + // Fetch organization info if user is in organization context + try { + const storedOrgId = this.getStoredOrganizationId() + + if (this.credentials?.organizationId !== undefined) { + // We have organization context info + if (storedOrgId !== null) { + // User is in organization context - fetch user's memberships and filter + const orgMemberships = await this.clerkGetOrganizationMemberships() + const userMembership = this.findOrganizationMembership(orgMemberships, storedOrgId) + + if (userMembership) { + this.setUserOrganizationInfo(userInfo, userMembership) + + this.log("[auth] User in organization context:", { + id: userMembership.organization.id, + name: userMembership.organization.name, + role: userMembership.role, + }) + } else { + this.log("[auth] Warning: User not found in stored organization:", storedOrgId) + } + } else { + this.log("[auth] User in personal account context - not setting organization info") + } + } else { + // Old credentials without organization context - fetch organization info to determine context + const orgMemberships = await this.clerkGetOrganizationMemberships() + const primaryOrgMembership = this.findPrimaryOrganizationMembership(orgMemberships) + + if (primaryOrgMembership) { + this.setUserOrganizationInfo(userInfo, primaryOrgMembership) + + this.log("[auth] Legacy credentials: Found organization membership:", { + id: primaryOrgMembership.organization.id, + name: primaryOrgMembership.organization.name, + role: primaryOrgMembership.role, + }) + } else { + this.log("[auth] Legacy credentials: No organization memberships found") + } + } + } catch (error) { + this.log("[auth] Failed to fetch organization info:", error) + // Don't throw - organization info is optional + } + + return userInfo + } + + private findOrganizationMembership( + memberships: CloudOrganizationMembership[], + organizationId: string, + ): CloudOrganizationMembership | undefined { + return memberships?.find((membership) => membership.organization.id === organizationId) + } + + private findPrimaryOrganizationMembership( + memberships: CloudOrganizationMembership[], + ): CloudOrganizationMembership | undefined { + return memberships && memberships.length > 0 ? memberships[0] : undefined + } + + private setUserOrganizationInfo(userInfo: CloudUserInfo, membership: CloudOrganizationMembership): void { + userInfo.organizationId = membership.organization.id + userInfo.organizationName = membership.organization.name + userInfo.organizationRole = membership.role + userInfo.organizationImageUrl = membership.organization.image_url + } + + private async clerkGetOrganizationMemberships(): Promise { + const response = await fetch(`${getClerkBaseUrl()}/v1/me/organization_memberships`, { + headers: { + Authorization: `Bearer ${this.credentials!.clientToken}`, + "User-Agent": this.userAgent(), + }, + signal: AbortSignal.timeout(10000), + }) + + return clerkOrganizationMembershipsSchema.parse(await response.json()).response + } + + private async clerkLogout(credentials: AuthCredentials): Promise { + const formData = new URLSearchParams() + formData.append("_is_native", "1") + + const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${credentials.clientToken}`, + "User-Agent": this.userAgent(), + }, + body: formData.toString(), + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + } + + private userAgent(): string { + return getUserAgent(this.context) + } +} diff --git a/packages/cloud/src/auth/index.ts b/packages/cloud/src/auth/index.ts new file mode 100644 index 0000000000..b04a805295 --- /dev/null +++ b/packages/cloud/src/auth/index.ts @@ -0,0 +1,3 @@ +export type { AuthService, AuthServiceEvents, AuthState } from "./AuthService" +export { WebAuthService } from "./WebAuthService" +export { StaticTokenAuthService } from "./StaticTokenAuthService" diff --git a/packages/cloud/src/config.ts b/packages/cloud/src/config.ts new file mode 100644 index 0000000000..e682d718ce --- /dev/null +++ b/packages/cloud/src/config.ts @@ -0,0 +1,5 @@ +export const PRODUCTION_CLERK_BASE_URL = "https://clerk.roocode.com" +export const PRODUCTION_ROO_CODE_API_URL = "https://app.roocode.com" + +export const getClerkBaseUrl = () => process.env.CLERK_BASE_URL || PRODUCTION_CLERK_BASE_URL +export const getRooCodeApiUrl = () => process.env.ROO_CODE_API_URL || PRODUCTION_ROO_CODE_API_URL diff --git a/packages/cloud/src/errors.ts b/packages/cloud/src/errors.ts new file mode 100644 index 0000000000..7400f26b39 --- /dev/null +++ b/packages/cloud/src/errors.ts @@ -0,0 +1,42 @@ +export class CloudAPIError extends Error { + constructor( + message: string, + public statusCode?: number, + public responseBody?: unknown, + ) { + super(message) + this.name = "CloudAPIError" + Object.setPrototypeOf(this, CloudAPIError.prototype) + } +} + +export class TaskNotFoundError extends CloudAPIError { + constructor(taskId?: string) { + super(taskId ? `Task '${taskId}' not found` : "Task not found", 404) + this.name = "TaskNotFoundError" + Object.setPrototypeOf(this, TaskNotFoundError.prototype) + } +} + +export class AuthenticationError extends CloudAPIError { + constructor(message = "Authentication required") { + super(message, 401) + this.name = "AuthenticationError" + Object.setPrototypeOf(this, AuthenticationError.prototype) + } +} + +export class NetworkError extends CloudAPIError { + constructor(message = "Network error occurred") { + super(message) + this.name = "NetworkError" + Object.setPrototypeOf(this, NetworkError.prototype) + } +} + +export class InvalidClientTokenError extends Error { + constructor() { + super("Invalid/Expired client token") + Object.setPrototypeOf(this, InvalidClientTokenError.prototype) + } +} diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts new file mode 100644 index 0000000000..55f7d908dd --- /dev/null +++ b/packages/cloud/src/index.ts @@ -0,0 +1,4 @@ +export * from "./config" + +export * from "./CloudAPI" +export * from "./CloudService" diff --git a/packages/cloud/src/types.ts b/packages/cloud/src/types.ts new file mode 100644 index 0000000000..78275b32e2 --- /dev/null +++ b/packages/cloud/src/types.ts @@ -0,0 +1,4 @@ +import { AuthServiceEvents } from "./auth" +import { SettingsServiceEvents } from "./CloudSettingsService" + +export type CloudServiceEvents = AuthServiceEvents & SettingsServiceEvents diff --git a/packages/cloud/src/utils.ts b/packages/cloud/src/utils.ts new file mode 100644 index 0000000000..cf87aa5e28 --- /dev/null +++ b/packages/cloud/src/utils.ts @@ -0,0 +1,10 @@ +import * as vscode from "vscode" + +/** + * Get the User-Agent string for API requests + * @param context Optional extension context for more accurate version detection + * @returns User-Agent string in format "Roo-Code {version}" + */ +export function getUserAgent(context?: vscode.ExtensionContext): string { + return `Roo-Code ${context?.extension?.packageJSON?.version || "unknown"}` +} diff --git a/packages/cloud/tsconfig.json b/packages/cloud/tsconfig.json new file mode 100644 index 0000000000..f599e2220d --- /dev/null +++ b/packages/cloud/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@roo-code/config-typescript/vscode-library.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/cloud/vitest.config.ts b/packages/cloud/vitest.config.ts new file mode 100644 index 0000000000..569f167543 --- /dev/null +++ b/packages/cloud/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + environment: "node", + watch: false, + }, + resolve: { + alias: { + vscode: new URL("./src/__mocks__/vscode.ts", import.meta.url).pathname, + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2847df1a1..3e7bb79b64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,6 +353,34 @@ importers: specifier: ^3.2.3 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + packages/cloud: + dependencies: + '@roo-code/telemetry': + specifier: workspace:^ + version: link:../telemetry + '@roo-code/types': + specifier: workspace:^ + version: link:../types + zod: + specifier: ^3.25.61 + version: 3.25.61 + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../config-typescript + '@types/node': + specifier: 20.x + version: 20.17.57 + '@types/vscode': + specifier: ^1.84.0 + version: 1.100.0 + vitest: + specifier: ^3.2.3 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + packages/config-eslint: devDependencies: '@eslint/js': @@ -563,8 +591,8 @@ importers: specifier: ^1.14.0 version: 1.14.0(typescript@5.8.3) '@roo-code/cloud': - specifier: ^0.4.0 - version: 0.4.0 + specifier: workspace:^ + version: link:../packages/cloud '@roo-code/ipc': specifier: workspace:^ version: link:../packages/ipc @@ -657,7 +685,7 @@ importers: version: 12.0.0 openai: specifier: ^5.0.0 - version: 5.5.1(ws@8.18.3)(zod@3.25.61) + version: 5.5.1(ws@8.18.2)(zod@3.25.61) os-name: specifier: ^6.0.0 version: 6.1.0 @@ -1419,10 +1447,6 @@ packages: resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.28.2': - resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==} - engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -1933,9 +1957,6 @@ packages: cpu: [x64] os: [win32] - '@ioredis/commands@1.3.0': - resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1987,16 +2008,16 @@ packages: '@libsql/client@0.15.8': resolution: {integrity: sha512-TskygwF+ToZeWhPPT0WennyGrP3tmkKraaKopT2YwUjqD6DWDRm6SG5iy0VqnaO+HC9FNBCDX0oQPODU3gqqPQ==} - '@libsql/core@0.15.10': - resolution: {integrity: sha512-fAMD+GnGQNdZ9zxeNC8AiExpKnou/97GJWkiDDZbTRHj3c9dvF1y4jsRQ0WE72m/CqTdbMGyU98yL0SJ9hQVeg==} + '@libsql/core@0.15.9': + resolution: {integrity: sha512-4OVdeAmuaCUq5hYT8NNn0nxlO9AcA/eTjXfUZ+QK8MT3Dz7Z76m73x7KxjU6I64WyXX98dauVH2b9XM+d84npw==} - '@libsql/darwin-arm64@0.5.17': - resolution: {integrity: sha512-WTYG2skZsUnZmfZ2v7WFj7s3/5s2PfrYBZOWBKOnxHA8g4XCDc/4bFDaqob9Q2e88+GC7cWeJ8VNkVBFpD2Xxg==} + '@libsql/darwin-arm64@0.5.13': + resolution: {integrity: sha512-ASz/EAMLDLx3oq9PVvZ4zBXXHbz2TxtxUwX2xpTRFR4V4uSHAN07+jpLu3aK5HUBLuv58z7+GjaL5w/cyjR28Q==} cpu: [arm64] os: [darwin] - '@libsql/darwin-x64@0.5.17': - resolution: {integrity: sha512-ab0RlTR4KYrxgjNrZhAhY/10GibKoq6G0W4oi0kdm+eYiAv/Ip8GDMpSaZdAcoKA4T+iKR/ehczKHnMEB8MFxA==} + '@libsql/darwin-x64@0.5.13': + resolution: {integrity: sha512-kzglniv1difkq8opusSXM7u9H0WoEPeKxw0ixIfcGfvlCVMJ+t9UNtXmyNHW68ljdllje6a4C6c94iPmIYafYA==} cpu: [x64] os: [darwin] @@ -2010,38 +2031,38 @@ packages: '@libsql/isomorphic-ws@0.1.5': resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} - '@libsql/linux-arm-gnueabihf@0.5.17': - resolution: {integrity: sha512-PcASh4k47RqC+kMWAbLUKf1y6Do0q8vnUGi0yhKY4ghJcimMExViBimjbjYRSa+WIb/zh3QxNoXOhQAXx3tiuw==} + '@libsql/linux-arm-gnueabihf@0.5.13': + resolution: {integrity: sha512-UEW+VZN2r0mFkfztKOS7cqfS8IemuekbjUXbXCwULHtusww2QNCXvM5KU9eJCNE419SZCb0qaEWYytcfka8qeA==} cpu: [arm] os: [linux] - '@libsql/linux-arm-musleabihf@0.5.17': - resolution: {integrity: sha512-vxOkSLG9Wspit+SNle84nuIzMtr2G2qaxFzW7BhsZBjlZ8+kErf9RXcT2YJQdJYxmBYRbsOrc91gg0jLEQVCqg==} + '@libsql/linux-arm-musleabihf@0.5.13': + resolution: {integrity: sha512-NMDgLqryYBv4Sr3WoO/m++XDjR5KLlw9r/JK4Ym6A1XBv2bxQQNhH0Lxx3bjLW8qqhBD4+0xfms4d2cOlexPyA==} cpu: [arm] os: [linux] - '@libsql/linux-arm64-gnu@0.5.17': - resolution: {integrity: sha512-L8jnaN01TxjBJlDuDTX2W2BKzBkAOhcnKfCOf3xzvvygblxnDOK0whkYwIXeTfwtd/rr4jN/d6dZD/bcHiDxEQ==} + '@libsql/linux-arm64-gnu@0.5.13': + resolution: {integrity: sha512-/wCxVdrwl1ee6D6LEjwl+w4SxuLm5UL9Kb1LD5n0bBGs0q+49ChdPPh7tp175iRgkcrTgl23emymvt1yj3KxVQ==} cpu: [arm64] os: [linux] - '@libsql/linux-arm64-musl@0.5.17': - resolution: {integrity: sha512-HfFD7TzQtmmTwyQsuiHhWZdMRtdNpKJ1p4tbMMTMRECk+971NFHrj69D64cc2ClVTAmn7fA9XibKPil7WN/Q7w==} + '@libsql/linux-arm64-musl@0.5.13': + resolution: {integrity: sha512-xnVAbZIanUgX57XqeI5sNaDnVilp0Di5syCLSEo+bRyBobe/1IAeehNZpyVbCy91U2N6rH1C/mZU7jicVI9x+A==} cpu: [arm64] os: [linux] - '@libsql/linux-x64-gnu@0.5.17': - resolution: {integrity: sha512-5l3XxWqUPVFrtX0xnZaXwqsXs0BFbP4w6ahRFTPSdXU50YBfUOajFznJRB6bJTMsCvraDSD0IkHhjSNfrE1CuQ==} + '@libsql/linux-x64-gnu@0.5.13': + resolution: {integrity: sha512-/mfMRxcQAI9f8t7tU3QZyh25lXgXKzgin9B9TOSnchD73PWtsVhlyfA6qOCfjQl5kr4sHscdXD5Yb3KIoUgrpQ==} cpu: [x64] os: [linux] - '@libsql/linux-x64-musl@0.5.17': - resolution: {integrity: sha512-FvSpWlwc+dIeYIFYlsSv+UdQ/NiZWr+SstwVji+QZ//8NnvzwWQU9cgP+Vpps6Qiq4jyYQm9chJhTYOVT9Y3BA==} + '@libsql/linux-x64-musl@0.5.13': + resolution: {integrity: sha512-rdefPTpQCVwUjIQYbDLMv3qpd5MdrT0IeD0UZPGqhT9AWU8nJSQoj2lfyIDAWEz7PPOVCY4jHuEn7FS2sw9kRA==} cpu: [x64] os: [linux] - '@libsql/win32-x64-msvc@0.5.17': - resolution: {integrity: sha512-f5bGH8+3A5sn6Lrqg8FsQ09a1pYXPnKGXGTFiAYlfQXVst1tUTxDTugnuWcJYKXyzDe/T7ccxyIZXeSmPOhq8A==} + '@libsql/win32-x64-msvc@0.5.13': + resolution: {integrity: sha512-aNcmDrD1Ws+dNZIv9ECbxBQumqB9MlSVEykwfXJpqv/593nABb8Ttg5nAGUPtnADyaGDTrGvPPP81d/KsKho4Q==} cpu: [x64] os: [win32] @@ -3065,12 +3086,6 @@ packages: cpu: [x64] os: [win32] - '@roo-code/cloud@0.4.0': - resolution: {integrity: sha512-1a27RG2YjQFfsU5UlfbQnpj/K/6gYBcysp2FXaX9+VaaTh5ZzReQeHJ9uREnyE059zoFpVuNywwNxGadzyotWw==} - - '@roo-code/types@1.42.0': - resolution: {integrity: sha512-AITVSV6WFd17jE8lQXFy7PkHam8M+mMkT7o9ipGZZ3cV7SbrnmL/Hg/HjkA9lkdJYbcC5dEK94py8KVBQn8Umw==} - '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -3871,8 +3886,8 @@ packages: '@types/node@20.19.1': resolution: {integrity: sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==} - '@types/node@20.19.9': - resolution: {integrity: sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==} + '@types/node@20.19.4': + resolution: {integrity: sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA==} '@types/node@22.15.29': resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==} @@ -5090,10 +5105,6 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -6267,10 +6278,6 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ioredis@5.7.0: - resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==} - engines: {node: '>=12.22.0'} - ip-address@9.0.5: resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} engines: {node: '>= 12'} @@ -6738,8 +6745,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libsql@0.5.17: - resolution: {integrity: sha512-RRlj5XQI9+Wq+/5UY8EnugSWfRmHEw4hn3DKlPrkUgZONsge1PwTtHcpStP6MSNi8ohcbsRgEHJaymA33a8cBw==} + libsql@0.5.13: + resolution: {integrity: sha512-5Bwoa/CqzgkTwySgqHA5TsaUDRrdLIbdM4egdPcaAnqO3aC+qAgS6BwdzuZwARA5digXwiskogZ8H7Yy4XfdOg==} cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] @@ -6939,9 +6946,6 @@ packages: lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - lodash.isarguments@3.1.0: - resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} - lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} @@ -8265,14 +8269,6 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} - redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - redis@5.5.5: resolution: {integrity: sha512-x7vpciikEY7nptGzQrE5I+/pvwFZJDadPk/uEoyGSg/pZ2m/CX2n5EhSgUh+S5T7Gz3uKM6YzWcXEu3ioAsdFQ==} engines: {node: '>= 18'} @@ -8686,9 +8682,6 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} - standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -9788,9 +9781,6 @@ packages: zod@3.25.61: resolution: {integrity: sha512-fzfJgUw78LTNnHujj9re1Ov/JJQkRZZGDMcYqSx7Hp4rPOkKywaFHq0S6GoHeXs0wGNE/sIOutkXgnwzrVOGCQ==} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -10545,8 +10535,6 @@ snapshots: '@babel/runtime@7.27.6': {} - '@babel/runtime@7.28.2': {} - '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -11088,8 +11076,6 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true - '@ioredis/commands@1.3.0': {} - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -11149,25 +11135,25 @@ snapshots: '@libsql/client@0.15.8': dependencies: - '@libsql/core': 0.15.10 + '@libsql/core': 0.15.9 '@libsql/hrana-client': 0.7.0 js-base64: 3.7.7 - libsql: 0.5.17 + libsql: 0.5.13 promise-limit: 2.7.0 transitivePeerDependencies: - bufferutil - utf-8-validate optional: true - '@libsql/core@0.15.10': + '@libsql/core@0.15.9': dependencies: js-base64: 3.7.7 optional: true - '@libsql/darwin-arm64@0.5.17': + '@libsql/darwin-arm64@0.5.13': optional: true - '@libsql/darwin-x64@0.5.17': + '@libsql/darwin-x64@0.5.13': optional: true '@libsql/hrana-client@0.7.0': @@ -11193,25 +11179,25 @@ snapshots: - utf-8-validate optional: true - '@libsql/linux-arm-gnueabihf@0.5.17': + '@libsql/linux-arm-gnueabihf@0.5.13': optional: true - '@libsql/linux-arm-musleabihf@0.5.17': + '@libsql/linux-arm-musleabihf@0.5.13': optional: true - '@libsql/linux-arm64-gnu@0.5.17': + '@libsql/linux-arm64-gnu@0.5.13': optional: true - '@libsql/linux-arm64-musl@0.5.17': + '@libsql/linux-arm64-musl@0.5.13': optional: true - '@libsql/linux-x64-gnu@0.5.17': + '@libsql/linux-x64-gnu@0.5.13': optional: true - '@libsql/linux-x64-musl@0.5.17': + '@libsql/linux-x64-musl@0.5.13': optional: true - '@libsql/win32-x64-msvc@0.5.17': + '@libsql/win32-x64-msvc@0.5.13': optional: true '@lmstudio/lms-isomorphic@0.4.5': @@ -12191,17 +12177,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.2': optional: true - '@roo-code/cloud@0.4.0': - dependencies: - '@roo-code/types': 1.42.0 - ioredis: 5.7.0 - p-wait-for: 5.0.2 - zod: 3.25.76 - transitivePeerDependencies: - - supports-color - - '@roo-code/types@1.42.0': {} - '@sec-ant/readable-stream@0.4.1': {} '@sevinf/maybe@0.5.0': {} @@ -12901,7 +12876,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.2 + '@babel/runtime': 7.27.6 '@types/aria-query': 5.0.4 aria-query: 5.3.0 chalk: 4.1.2 @@ -13189,7 +13164,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@20.19.9': + '@types/node@20.19.4': dependencies: undici-types: 6.21.0 optional: true @@ -13257,7 +13232,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 20.19.9 + '@types/node': 20.19.4 optional: true '@types/yargs-parser@21.0.3': {} @@ -14582,8 +14557,6 @@ snapshots: delayed-stream@1.0.0: {} - denque@2.1.0: {} - depd@2.0.0: {} dequal@2.0.3: {} @@ -15963,20 +15936,6 @@ snapshots: internmap@2.0.3: {} - ioredis@5.7.0: - dependencies: - '@ioredis/commands': 1.3.0 - cluster-key-slot: 1.1.2 - debug: 4.4.1(supports-color@8.1.1) - denque: 2.1.0 - lodash.defaults: 4.2.0 - lodash.isarguments: 3.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - ip-address@9.0.5: dependencies: jsbn: 1.1.0 @@ -16467,20 +16426,20 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libsql@0.5.17: + libsql@0.5.13: dependencies: '@neon-rs/load': 0.0.4 detect-libc: 2.0.2 optionalDependencies: - '@libsql/darwin-arm64': 0.5.17 - '@libsql/darwin-x64': 0.5.17 - '@libsql/linux-arm-gnueabihf': 0.5.17 - '@libsql/linux-arm-musleabihf': 0.5.17 - '@libsql/linux-arm64-gnu': 0.5.17 - '@libsql/linux-arm64-musl': 0.5.17 - '@libsql/linux-x64-gnu': 0.5.17 - '@libsql/linux-x64-musl': 0.5.17 - '@libsql/win32-x64-msvc': 0.5.17 + '@libsql/darwin-arm64': 0.5.13 + '@libsql/darwin-x64': 0.5.13 + '@libsql/linux-arm-gnueabihf': 0.5.13 + '@libsql/linux-arm-musleabihf': 0.5.13 + '@libsql/linux-arm64-gnu': 0.5.13 + '@libsql/linux-arm64-musl': 0.5.13 + '@libsql/linux-x64-gnu': 0.5.13 + '@libsql/linux-x64-musl': 0.5.13 + '@libsql/win32-x64-msvc': 0.5.13 optional: true lie@3.3.0: @@ -16645,8 +16604,6 @@ snapshots: lodash.includes@4.3.0: {} - lodash.isarguments@3.1.0: {} - lodash.isboolean@3.0.3: {} lodash.isequal@4.5.0: {} @@ -17563,9 +17520,9 @@ snapshots: is-inside-container: 1.0.0 is-wsl: 3.1.0 - openai@5.5.1(ws@8.18.3)(zod@3.25.61): + openai@5.5.1(ws@8.18.2)(zod@3.25.61): optionalDependencies: - ws: 8.18.3 + ws: 8.18.2 zod: 3.25.61 option@0.2.4: {} @@ -18315,12 +18272,6 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 - redis-errors@1.2.0: {} - - redis-parser@3.0.0: - dependencies: - redis-errors: 1.2.0 - redis@5.5.5: dependencies: '@redis/bloom': 5.5.5(@redis/client@5.5.5) @@ -18874,8 +18825,6 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 - standard-as-callback@2.1.0: {} - statuses@2.0.1: {} std-env@3.9.0: {} @@ -20193,6 +20142,4 @@ snapshots: zod@3.25.61: {} - zod@3.25.76: {} - zwitch@2.0.4: {} diff --git a/src/extension.ts b/src/extension.ts index f3b8f55911..1a7b6c5aca 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -76,25 +76,12 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize Roo Code Cloud service. const cloudService = await CloudService.createInstance(context, cloudLogger) - - try { - if (cloudService.telemetryClient) { - TelemetryService.instance.register(cloudService.telemetryClient) - } - } catch (error) { - outputChannel.appendLine( - `[CloudService] Failed to register TelemetryClient: ${error instanceof Error ? error.message : String(error)}`, - ) - } - const postStateListener = () => { ClineProvider.getVisibleInstance()?.postStateToWebview() } - cloudService.on("auth-state-changed", postStateListener) cloudService.on("user-info", postStateListener) cloudService.on("settings-updated", postStateListener) - // Add to subscriptions for proper cleanup on deactivate context.subscriptions.push(cloudService) diff --git a/src/package.json b/src/package.json index 13013de7ae..dab031404d 100644 --- a/src/package.json +++ b/src/package.json @@ -420,7 +420,7 @@ "@mistralai/mistralai": "^1.3.6", "@modelcontextprotocol/sdk": "^1.9.0", "@qdrant/js-client-rest": "^1.14.0", - "@roo-code/cloud": "^0.4.0", + "@roo-code/cloud": "workspace:^", "@roo-code/ipc": "workspace:^", "@roo-code/telemetry": "workspace:^", "@roo-code/types": "workspace:^",