diff --git a/PRIVACY.md b/PRIVACY.md index 02e8e151034..06257ae10d2 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -11,6 +11,7 @@ Roo Code respects your privacy and is committed to transparency about how we han - **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. If you choose Roo Code Cloud as the provider (proxy mode), prompts may transit Roo Code servers only to forward them to the upstream model and are not stored. - **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen. - **Telemetry (Usage Data)**: We collect anonymous feature usage and error data to help us improve Roo Code. This telemetry is powered by PostHog and includes your VS Code machine ID, feature usage patterns, and exception reports. This telemetry does **not** collect personally identifiable information, your code, or AI prompts. You can opt out of this telemetry at any time through the settings. +- **Zoo Code Observability (Authenticated Subscribers Only):** If you sign in to Zoo Code and have an active subscription, Zoo Code will send LLM usage telemetry to the Zoo Code backend (zoocode.dev). This includes task ID, AI provider name, model name, token counts (input/output/cache), and estimated cost. This data is linked to your authenticated Zoo Code account. You can stop this collection at any time by signing out via the Zoo Code badge in the chat area. - **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Roo Code makes a secure API call to Roo Code's backend servers to retrieve listing information. These requests send only the query parameters (e.g., extension version, search term) necessary to fulfill the request and do not include your code, prompts, or personally identifiable information. ### **How We Use Your Data (If Collected)** diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 53bc9567f77..202326eb016 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -372,6 +372,12 @@ export type ExtensionState = Pick< mdmCompliant?: boolean taskSyncEnabled: boolean openAiCodexIsAuthenticated?: boolean + zooCodeIsAuthenticated?: boolean + zooCodeUserName?: string + zooCodeUserEmail?: string + zooCodeUserImage?: string + zooCodeBaseUrl?: string + deviceName?: string debug?: boolean /** @@ -505,6 +511,7 @@ export interface WebviewMessage { | "rooCloudManualUrl" | "openAiCodexSignIn" | "openAiCodexSignOut" + | "zooCodeSignOut" | "switchOrganization" | "condenseTaskContextRequest" | "requestIndexingStatus" diff --git a/src/activate/__tests__/handleUri.spec.ts b/src/activate/__tests__/handleUri.spec.ts index 73a0f6cf256..187d9eeeba3 100644 --- a/src/activate/__tests__/handleUri.spec.ts +++ b/src/activate/__tests__/handleUri.spec.ts @@ -6,22 +6,39 @@ vi.mock("vscode", () => ({ import * as vscode from "vscode" -import { handleUri } from "../handleUri" +const { mockGetVisibleInstance, mockHandleZooCodeAuthCallback, mockSetZooCodeUserInfo, mockVisibleProvider } = + vi.hoisted(() => { + const mockVisibleProvider = { + handleOpenRouterCallback: vi.fn(), + handleRequestyCallback: vi.fn(), + handleZooCodeCallback: vi.fn(), + } as any -const mockVisibleProvider = { - handleOpenRouterCallback: vi.fn(), - handleRequestyCallback: vi.fn(), -} as any + return { + mockGetVisibleInstance: vi.fn(() => mockVisibleProvider), + mockHandleZooCodeAuthCallback: vi.fn(), + mockSetZooCodeUserInfo: vi.fn(), + mockVisibleProvider, + } + }) vi.mock("../../core/webview/ClineProvider", () => ({ ClineProvider: { - getVisibleInstance: vi.fn(() => mockVisibleProvider), + getVisibleInstance: mockGetVisibleInstance, }, })) +vi.mock("../../services/zoo-code-auth", () => ({ + handleAuthCallback: mockHandleZooCodeAuthCallback, + setZooCodeUserInfo: mockSetZooCodeUserInfo, +})) + +import { handleUri } from "../handleUri" + describe("handleUri", () => { beforeEach(() => { vi.clearAllMocks() + mockGetVisibleInstance.mockReturnValue(mockVisibleProvider) }) it("ignores legacy cloud auth callback", async () => { @@ -36,4 +53,67 @@ describe("handleUri", () => { "Roo Code Cloud sign-in is currently unavailable. Configure another provider to continue.", ) }) + + it("stores callback user info even when no webview is visible", async () => { + mockGetVisibleInstance.mockReturnValue(null) + mockHandleZooCodeAuthCallback.mockResolvedValue(true) + + await handleUri({ + path: "/auth-callback", + query: "token=zoo_ext_test_token&name=Jane%20Doe&email=jane%40example.com&image=https%3A%2F%2Fexample.com%2Favatar.png", + } as any) + + expect(mockHandleZooCodeAuthCallback).toHaveBeenCalledWith("zoo_ext_test_token") + expect(mockSetZooCodeUserInfo).toHaveBeenCalledWith({ + name: "Jane Doe", + email: "jane@example.com", + image: "https://example.com/avatar.png", + }) + expect(mockVisibleProvider.handleZooCodeCallback).not.toHaveBeenCalled() + }) + + it("refreshes the visible provider after a successful auth callback", async () => { + mockHandleZooCodeAuthCallback.mockResolvedValue(true) + + await handleUri({ + path: "/auth-callback", + query: "token=zoo_ext_test_token", + } as any) + + // When no user info is provided, null values are passed to clear stale data + expect(mockSetZooCodeUserInfo).toHaveBeenCalledWith({ + name: null, + email: null, + image: null, + }) + expect(mockVisibleProvider.handleZooCodeCallback).toHaveBeenCalledWith("zoo_ext_test_token") + }) + + it("clears stale user info fields when re-authing with missing fields", async () => { + mockHandleZooCodeAuthCallback.mockResolvedValue(true) + + // Re-auth with only name - email and image should be cleared + await handleUri({ + path: "/auth-callback", + query: "token=zoo_ext_test_token&name=John%20Doe", + } as any) + + expect(mockSetZooCodeUserInfo).toHaveBeenCalledWith({ + name: "John Doe", + email: null, + image: null, + }) + }) + + it("does not persist user info when auth callback validation fails", async () => { + mockHandleZooCodeAuthCallback.mockResolvedValue(false) + + await handleUri({ + path: "/auth-callback", + query: "token=zoo_ext_test_token&name=Jane%20Doe", + } as any) + + expect(mockSetZooCodeUserInfo).not.toHaveBeenCalled() + expect(mockVisibleProvider.handleZooCodeCallback).not.toHaveBeenCalled() + }) }) diff --git a/src/activate/handleUri.ts b/src/activate/handleUri.ts index fc95aa5e48f..523d254bc3d 100644 --- a/src/activate/handleUri.ts +++ b/src/activate/handleUri.ts @@ -2,18 +2,16 @@ import * as vscode from "vscode" import { getRouterUnavailableSignInMessage } from "../core/config/routerRemoval" import { ClineProvider } from "../core/webview/ClineProvider" +import { handleAuthCallback as handleZooCodeAuthCallback, setZooCodeUserInfo } from "../services/zoo-code-auth" export const handleUri = async (uri: vscode.Uri) => { const path = uri.path const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B")) const visibleProvider = ClineProvider.getVisibleInstance() - if (!visibleProvider) { - return - } - switch (path) { case "/openrouter": { + if (!visibleProvider) return const code = query.get("code") if (code) { await visibleProvider.handleOpenRouterCallback(code) @@ -21,6 +19,7 @@ export const handleUri = async (uri: vscode.Uri) => { break } case "/requesty": { + if (!visibleProvider) return const code = query.get("code") const baseUrl = query.get("baseUrl") if (code) { @@ -32,6 +31,33 @@ export const handleUri = async (uri: vscode.Uri) => { vscode.window.showInformationMessage(getRouterUnavailableSignInMessage()) break } + case "/auth-callback": { + const token = query.get("token") + if (token) { + // Extract user info from callback URL params + // URLSearchParams.get() already decodes percent-encoded values - no need for decodeURIComponent + // Use null (not undefined) for missing values to actively clear stale data + const name = query.get("name") ?? null + const email = query.get("email") ?? null + const image = query.get("image") ?? null + + const success = await handleZooCodeAuthCallback(token) + if (success) { + // Store user info after successful auth validation (regardless of webview visibility) + // Always call setZooCodeUserInfo to clear stale data when fields are missing + await setZooCodeUserInfo({ + name, + email, + image, + }) + // Refresh webview state if a panel is currently open + if (visibleProvider) { + await visibleProvider.handleZooCodeCallback(token) + } + } + } + break + } default: break } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6f630b8b8ed..26b72957296 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3076,7 +3076,10 @@ export class Task extends EventEmitter implements TaskLike { total: totalCost, } - const drainStreamInBackgroundToFindAllUsage = async (apiReqIndex: number) => { + const drainStreamInBackgroundToFindAllUsage = async ( + apiReqIndex: number, + status: "completed" | "cancelled" = "completed", + ) => { const timeoutMs = DEFAULT_USAGE_COLLECTION_TIMEOUT_MS const startTime = performance.now() const modelId = getModelId(this.apiConfiguration) @@ -3098,6 +3101,7 @@ export class Task extends EventEmitter implements TaskLike { total?: number }, messageIndex: number = apiReqIndex, + status: "completed" | "cancelled" = "completed", ) => { if ( tokens.input > 0 || @@ -3155,6 +3159,27 @@ export class Task extends EventEmitter implements TaskLike { cacheReadTokens: tokens.cacheRead, cost: tokens.total ?? costResult.totalCost, }) + + // Zoo Code observability telemetry + import("../../services/zoo-telemetry") + .then(async ({ sendLlmTelemetry }) => { + const mode = await this.getTaskMode().catch(() => "unknown") + return sendLlmTelemetry({ + taskId: this.taskId, + provider: this.apiConfiguration?.apiProvider ?? "unknown", + model: this.apiConfiguration + ? (getModelId(this.apiConfiguration) ?? "unknown") + : "unknown", + mode, + inputTokens: costResult.totalInputTokens, + outputTokens: costResult.totalOutputTokens, + cacheReadTokens: tokens.cacheRead ?? 0, + cacheWriteTokens: tokens.cacheWrite ?? 0, + totalCost: tokens.total ?? costResult.totalCost, + status, + }).catch(() => {}) + }) + .catch(() => {}) } } @@ -3208,6 +3233,7 @@ export class Task extends EventEmitter implements TaskLike { total: bgTotalCost, }, lastApiReqIndex, + status, ) } else { console.warn( @@ -3232,13 +3258,18 @@ export class Task extends EventEmitter implements TaskLike { total: bgTotalCost, }, lastApiReqIndex, + status, ) } } } // Start the background task and handle any errors - drainStreamInBackgroundToFindAllUsage(lastApiReqIndex).catch((error) => { + // Pass "cancelled" status if the task was aborted by the user + drainStreamInBackgroundToFindAllUsage( + lastApiReqIndex, + this.abort ? "cancelled" : "completed", + ).catch((error) => { console.error("Background usage collection failed:", error) }) } catch (error) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 293b04bc137..ed26a101eff 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -240,8 +240,9 @@ export class ClineProvider // Create named listener functions so we can remove them later. const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId) - const onTaskCompleted = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => + const onTaskCompleted = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => { this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) + } const onTaskAborted = async () => { this.emit(RooCodeEventName.TaskAborted, instance.taskId) @@ -1197,7 +1198,7 @@ export class ClineProvider "default-src 'none'", `font-src ${webview.cspSource} data:`, `style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`, - `img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:`, + `img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com https://avatars.githubusercontent.com https://lh3.googleusercontent.com data:`, `media-src ${webview.cspSource}`, `script-src 'unsafe-eval' ${webview.cspSource} https://* https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`, `connect-src ${webview.cspSource} ${openRouterDomain} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`, @@ -1288,7 +1289,7 @@ export class ClineProvider - +