Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -505,6 +511,7 @@ export interface WebviewMessage {
| "rooCloudManualUrl"
| "openAiCodexSignIn"
| "openAiCodexSignOut"
| "zooCodeSignOut"
| "switchOrganization"
| "condenseTaskContextRequest"
| "requestIndexingStatus"
Expand Down
92 changes: 86 additions & 6 deletions src/activate/__tests__/handleUri.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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()
})
})
34 changes: 30 additions & 4 deletions src/activate/handleUri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,24 @@ 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)
}
break
}
case "/requesty": {
if (!visibleProvider) return
const code = query.get("code")
const baseUrl = query.get("baseUrl")
if (code) {
Expand All @@ -32,6 +31,33 @@ export const handleUri = async (uri: vscode.Uri) => {
vscode.window.showInformationMessage(getRouterUnavailableSignInMessage())
break
}
case "/auth-callback": {
Comment thread
roomote[bot] marked this conversation as resolved.
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
}
Expand Down
35 changes: 33 additions & 2 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3076,7 +3076,10 @@ export class Task extends EventEmitter<TaskEvents> 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)
Expand All @@ -3098,6 +3101,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
total?: number
},
messageIndex: number = apiReqIndex,
status: "completed" | "cancelled" = "completed",
) => {
if (
tokens.input > 0 ||
Expand Down Expand Up @@ -3155,6 +3159,27 @@ export class Task extends EventEmitter<TaskEvents> 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(() => {})
}
}

Expand Down Expand Up @@ -3208,6 +3233,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
total: bgTotalCost,
},
lastApiReqIndex,
status,
)
} else {
console.warn(
Expand All @@ -3232,13 +3258,18 @@ export class Task extends EventEmitter<TaskEvents> 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) {
Expand Down
49 changes: 46 additions & 3 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -1288,7 +1289,7 @@ export class ClineProvider
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://ph.roocode.com 'strict-dynamic'; connect-src ${webview.cspSource} ${openRouterDomain} https://api.requesty.ai https://ph.roocode.com;">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; 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 ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://ph.roocode.com 'strict-dynamic'; connect-src ${webview.cspSource} ${openRouterDomain} https://api.requesty.ai https://ph.roocode.com;">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<script nonce="${nonce}">
Expand Down Expand Up @@ -1681,6 +1682,15 @@ export class ClineProvider
await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
}

// Zoo Code Auth (for observability telemetry)

async handleZooCodeCallback(_token: string) {
// Auth mutation (token storage, subscription check, success toast) was already
// performed by handleAuthCallback() in handleUri.ts before this method was called.
// This method only needs to refresh the webview state to reflect the new auth status.
await this.postStateToWebview()
}

// Requesty

async handleRequestyCallback(code: string, baseUrl: string | null) {
Expand Down Expand Up @@ -2156,6 +2166,38 @@ export class ClineProvider
const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands)
const cwd = this.cwd
const currentTask = this.getCurrentTask()
let zooCodeState: {
zooCodeIsAuthenticated: boolean
zooCodeUserName: string | undefined
zooCodeUserEmail: string | undefined
zooCodeUserImage: string | undefined
zooCodeBaseUrl: string
deviceName: string
} = {
zooCodeIsAuthenticated: false,
zooCodeUserName: undefined,
zooCodeUserEmail: undefined,
zooCodeUserImage: undefined,
zooCodeBaseUrl: "https://www.zoocode.dev",
deviceName: os.hostname(),
}

try {
const { isZooCodeAuthenticated, getCachedZooCodeUserInfo, getZooCodeBaseUrl } = await import(
"../../services/zoo-code-auth"
)
const userInfo = getCachedZooCodeUserInfo()
zooCodeState = {
zooCodeIsAuthenticated: await isZooCodeAuthenticated(),
zooCodeUserName: userInfo.name,
zooCodeUserEmail: userInfo.email,
zooCodeUserImage: userInfo.image,
zooCodeBaseUrl: getZooCodeBaseUrl(),
deviceName: os.hostname(),
}
} catch {
// Keep the default unauthenticated state if the optional Zoo Code auth service is unavailable.
}

return {
version: this.context.extension?.packageJSON?.version ?? "",
Expand Down Expand Up @@ -2279,6 +2321,7 @@ export class ClineProvider
return false
}
})(),
...zooCodeState,
debug: vscode.workspace.getConfiguration(Package.name).get<boolean>("debug", false),
}
}
Expand Down
12 changes: 12 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2421,6 +2421,18 @@ export const webviewMessageHandler = async (
await provider.postStateToWebview()
break
}
case "zooCodeSignOut": {
try {
const { disconnectZooCode } = await import("../../services/zoo-code-auth")
await disconnectZooCode()
await provider.postStateToWebview()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (error) {
provider.log(
`Failed to sign out of Zoo Code: ${error instanceof Error ? error.message : String(error)}`,
)
}
break
}
case "switchOrganization": {
try {
const organizationId = message.organizationId ?? null
Expand Down
Loading
Loading