diff --git a/.env.sample b/.env.sample index aebe5cca44a..8a54048757e 100644 --- a/.env.sample +++ b/.env.sample @@ -1,6 +1,22 @@ +# Analytics (Optional) POSTHOG_API_KEY=key-goes-here -# Roo Code Cloud / Local Development +# Roo Code Cloud Configuration +# These environment variables allow you to use a self-hosted alternative to Roo Cloud. +# See SELF_HOSTING.md for detailed documentation on implementing your own cloud services. + +# Authentication Service (Clerk-compatible OAuth provider) +# Default: https://clerk.roocode.com +# For development/self-hosted: Your Clerk instance or compatible OAuth service CLERK_BASE_URL=https://epic-chamois-85.clerk.accounts.dev + +# API Service (Main backend API) +# Default: https://app.roocode.com +# For development/self-hosted: Your API server URL ROO_CODE_API_URL=http://localhost:3000 -ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy/v1 + +# Provider Proxy Service (OpenAI-compatible API endpoint) +# Default: https://api.roocode.com/proxy +# For development/self-hosted: Your provider proxy URL (e.g., http://localhost:8080/proxy) +# Note: Do not include /v1 suffix - it will be added automatically (e.g., /proxy becomes /proxy/v1) +ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy diff --git a/README.md b/README.md index 75f37762f93..23b54e8fa52 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • ## Resources - **[Documentation](https://docs.roocode.com):** The official guide to installing, configuring, and mastering Roo Code. +- **[Self-Hosting Guide](SELF_HOSTING.md):** Learn how to replace Roo Cloud with your own self-hosted services. - **[YouTube Channel](https://youtube.com/@roocodeyt?feature=shared):** Watch tutorials and see features in action. - **[Discord Server](https://discord.gg/roocode):** Join the community for real-time help and discussion. - **[Reddit Community](https://www.reddit.com/r/RooCode):** Share your experiences and see what others are building. diff --git a/ROOMOTE_SELF_HOSTING.md b/ROOMOTE_SELF_HOSTING.md new file mode 100644 index 00000000000..1bef1d08b47 --- /dev/null +++ b/ROOMOTE_SELF_HOSTING.md @@ -0,0 +1,1057 @@ +# Roomote Self-Hosting Implementation Plan + +## Overview + +This document provides a detailed plan for implementing a minimal self-hosted Roomote service that enables remote monitoring and task management for Roo Code instances. This setup assumes you will continue using existing API providers for AI models and only need the remote control/monitoring capabilities. + +## What is Roomote? + +Roomote is Roo Code's remote control feature that allows you to: +- Monitor active VS Code instances running Roo Code from a web dashboard +- View real-time task status, progress, and messages +- Start, stop, and resume tasks remotely +- See workspace context (git info, current mode, provider profile) +- Receive live updates as tasks progress + +## Architecture + +### Single-User Minimal Setup + +``` +┌─────────────────────┐ +│ VS Code + Roo │ +│ (Your Machine) │ +└──────────┬──────────┘ + │ WebSocket (Socket.io) + │ +┌──────────▼──────────┐ +│ Roomote Server │ +│ - Socket.io Bridge │ +│ - Static Auth │ +│ - In-Memory State │ +└──────────┬──────────┘ + │ WebSocket (Socket.io) + │ +┌──────────▼──────────┐ +│ Web Dashboard │ +│ (Browser) │ +└─────────────────────┘ +``` + +### Components Required + +1. **Backend Service** (Node.js/TypeScript) + - Socket.io server for bidirectional communication + - Simple authentication with pre-shared tokens + - In-memory state management for extension instances + - REST endpoint for bridge configuration + +2. **Frontend Dashboard** (React/TypeScript) + - Real-time instance monitoring + - Task status display + - Task control interface (start/stop/resume) + - Message viewer for task conversations + +3. **No External Dependencies** + - No database required (in-memory storage) + - No OAuth provider needed (static tokens) + - No AI provider proxy (use your existing configuration) + +## Implementation Plan + +### Phase 1: Backend Service Setup + +#### Technology Stack +- **Runtime**: Node.js 20+ +- **Framework**: Express.js (for REST API) +- **WebSocket**: Socket.io 4.x +- **Language**: TypeScript +- **Authentication**: Simple JWT tokens +- **State**: In-memory (Map objects) + +#### Project Structure + +``` +roomote-server/ +├── package.json +├── tsconfig.json +├── src/ +│ ├── index.ts # Server entry point +│ ├── config.ts # Configuration & environment +│ ├── auth/ +│ │ ├── tokenManager.ts # JWT generation & validation +│ │ └── middleware.ts # Auth middleware +│ ├── bridge/ +│ │ ├── socketServer.ts # Socket.io setup +│ │ ├── extensionChannel.ts # Extension instance management +│ │ ├── taskChannel.ts # Task-specific events +│ │ └── types.ts # Bridge event/command types +│ ├── api/ +│ │ └── bridgeConfig.ts # REST endpoint for bridge config +│ └── state/ +│ └── instanceStore.ts # In-memory instance storage +└── .env.example +``` + +#### Key Files Implementation + +**1. Server Entry Point (`src/index.ts`)** + +```typescript +import express from 'express' +import { createServer } from 'http' +import cors from 'cors' +import { Server as SocketIOServer } from 'socket.io' +import { config } from './config' +import { setupBridgeSocket } from './bridge/socketServer' +import { bridgeConfigRouter } from './api/bridgeConfig' +import { authMiddleware } from './auth/middleware' + +const app = express() +const httpServer = createServer(app) +const io = new SocketIOServer(httpServer, { + cors: { + origin: config.allowedOrigins, + credentials: true + } +}) + +// Middleware +app.use(cors({ origin: config.allowedOrigins })) +app.use(express.json()) + +// REST API routes +app.use('/api/extension/bridge', authMiddleware, bridgeConfigRouter) + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok' }) +}) + +// Setup Socket.io bridge +setupBridgeSocket(io) + +// Start server +httpServer.listen(config.port, () => { + console.log(`Roomote server running on port ${config.port}`) +}) +``` + +**2. Configuration (`src/config.ts`)** + +```typescript +import dotenv from 'dotenv' + +dotenv.config() + +export const config = { + port: parseInt(process.env.PORT || '3000'), + jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-this', + allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:5173'], + // Pre-configured user credentials + users: [ + { + id: 'user_1', + name: process.env.USER_NAME || 'Admin', + email: process.env.USER_EMAIL || 'admin@localhost', + } + ] +} +``` + +**3. Token Manager (`src/auth/tokenManager.ts`)** + +```typescript +import jwt from 'jsonwebtoken' +import { config } from '../config' + +export interface TokenPayload { + iss: string + sub: string + exp: number + iat: number + v: number + r: { + u: string + o: string | null + t: 'auth' + } +} + +export function generateBridgeToken(userId: string): string { + const now = Math.floor(Date.now() / 1000) + + const payload: TokenPayload = { + iss: 'roomote-self-hosted', + sub: userId, + exp: now + 3600, // 1 hour + iat: now, + v: 1, + r: { + u: userId, + o: null, // Single-user setup, no organization + t: 'auth' + } + } + + return jwt.sign(payload, config.jwtSecret) +} + +export function verifyToken(token: string): TokenPayload | null { + try { + return jwt.verify(token, config.jwtSecret) as TokenPayload + } catch { + return null + } +} +``` + +**4. Socket Server (`src/bridge/socketServer.ts`)** + +```typescript +import { Server as SocketIOServer, Socket } from 'socket.io' +import { verifyToken } from '../auth/tokenManager' +import { InstanceStore } from '../state/instanceStore' +import { + ExtensionBridgeEvent, + ExtensionBridgeCommand, + TaskBridgeEvent, + TaskBridgeCommand +} from './types' + +const instanceStore = new InstanceStore() + +export function setupBridgeSocket(io: SocketIOServer) { + io.use((socket, next) => { + const token = socket.handshake.auth.token + const payload = verifyToken(token) + + if (!payload) { + return next(new Error('Authentication failed')) + } + + socket.data.userId = payload.r.u + next() + }) + + io.on('connection', (socket: Socket) => { + const userId = socket.data.userId + console.log(`Extension connected: ${socket.id}, user: ${userId}`) + + // Join user-specific room + socket.join(`user:${userId}`) + + // Extension → Server: Instance events + socket.on('extension:event', (event: ExtensionBridgeEvent) => { + console.log(`[Extension Event] ${event.type}`, event) + + // Update instance store + if (event.type === 'instance_registered' || + event.type === 'heartbeat_updated') { + instanceStore.upsert(event.instance) + } else if (event.type === 'instance_unregistered') { + instanceStore.remove(event.instance.instanceId) + } else { + instanceStore.updateTask(event.instance) + } + + // Broadcast to all web clients for this user + io.to(`user:${userId}`).emit('instance:update', event) + }) + + // Server → Extension: Commands from web + socket.on('extension:command', (command: ExtensionBridgeCommand) => { + console.log(`[Extension Command] ${command.type}`, command) + // Forward to specific extension instance + io.to(`instance:${command.instanceId}`).emit('extension:command', command) + }) + + // Task-specific events + socket.on('task:event', (event: TaskBridgeEvent) => { + console.log(`[Task Event] ${event.type}`, event) + // Broadcast to web clients + io.to(`user:${userId}`).emit('task:update', event) + }) + + socket.on('task:command', (command: TaskBridgeCommand) => { + console.log(`[Task Command] ${command.type}`, command) + // Forward to extension handling this task + io.to(`task:${command.taskId}`).emit('task:command', command) + }) + + // Web client requesting instance list + socket.on('instances:list', (callback) => { + const instances = instanceStore.listByUser(userId) + callback(instances) + }) + + socket.on('disconnect', () => { + console.log(`Client disconnected: ${socket.id}`) + }) + }) +} +``` + +**5. Instance Store (`src/state/instanceStore.ts`)** + +```typescript +import { ExtensionInstance } from './types' + +export class InstanceStore { + private instances = new Map() + + upsert(instance: ExtensionInstance) { + this.instances.set(instance.instanceId, { + ...instance, + lastHeartbeat: Date.now() + }) + + // Cleanup old instances (no heartbeat in 2 minutes) + this.cleanup() + } + + updateTask(instance: ExtensionInstance) { + const existing = this.instances.get(instance.instanceId) + if (existing) { + this.instances.set(instance.instanceId, { + ...existing, + task: instance.task, + taskAsk: instance.taskAsk, + lastHeartbeat: Date.now() + }) + } + } + + remove(instanceId: string) { + this.instances.delete(instanceId) + } + + listByUser(userId: string): ExtensionInstance[] { + return Array.from(this.instances.values()) + .filter(i => i.userId === userId) + } + + private cleanup() { + const now = Date.now() + const TTL = 120_000 // 2 minutes + + for (const [id, instance] of this.instances) { + if (now - instance.lastHeartbeat > TTL) { + this.instances.delete(id) + } + } + } +} +``` + +**6. Bridge Config API (`src/api/bridgeConfig.ts`)** + +```typescript +import { Router } from 'express' +import { generateBridgeToken } from '../auth/tokenManager' +import { config } from '../config' + +export const bridgeConfigRouter = Router() + +// GET /api/extension/bridge/config +bridgeConfigRouter.get('/config', (req, res) => { + const userId = req.user?.id || config.users[0].id + const token = generateBridgeToken(userId) + + res.json({ + userId, + socketBridgeUrl: `http://localhost:${config.port}`, + token + }) +}) +``` + +**7. Auth Middleware (`src/auth/middleware.ts`)** + +```typescript +import { Request, Response, NextFunction } from 'express' +import { verifyToken } from './tokenManager' +import { config } from '../config' + +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + // For single-user setup, accept a simple bearer token or allow unauthenticated + const authHeader = req.headers.authorization + + if (!authHeader) { + // Auto-assign to configured user + req.user = config.users[0] + return next() + } + + const token = authHeader.replace('Bearer ', '') + const payload = verifyToken(token) + + if (!payload) { + return res.status(401).json({ error: 'Invalid token' }) + } + + req.user = config.users.find(u => u.id === payload.r.u) + next() +} +``` + +#### Package.json + +```json +{ + "name": "roomote-server", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "express": "^4.18.2", + "socket.io": "^4.6.1", + "jsonwebtoken": "^9.0.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/cors": "^2.8.17", + "@types/node": "^20.10.0", + "typescript": "^5.3.3", + "tsx": "^4.7.0" + } +} +``` + +### Phase 2: Frontend Dashboard + +#### Technology Stack +- **Framework**: React 18+ with Vite +- **Language**: TypeScript +- **Real-time**: Socket.io-client +- **Styling**: Tailwind CSS +- **State Management**: React hooks + Context + +#### Project Structure + +``` +roomote-dashboard/ +├── package.json +├── vite.config.ts +├── tsconfig.json +├── index.html +├── src/ +│ ├── main.tsx # App entry point +│ ├── App.tsx # Root component +│ ├── hooks/ +│ │ └── useSocket.ts # Socket.io connection hook +│ ├── components/ +│ │ ├── InstanceList.tsx # List of connected instances +│ │ ├── InstanceCard.tsx # Single instance display +│ │ ├── TaskViewer.tsx # Task details and messages +│ │ └── TaskControls.tsx # Start/Stop/Resume buttons +│ ├── types/ +│ │ └── bridge.ts # Bridge event/command types +│ └── utils/ +│ └── formatters.ts # Formatting utilities +└── .env.example +``` + +#### Key Components + +**1. Socket Hook (`src/hooks/useSocket.ts`)** + +```typescript +import { useEffect, useState } from 'react' +import { io, Socket } from 'socket.io-client' +import { ExtensionInstance, ExtensionBridgeEvent } from '../types/bridge' + +export function useSocket(serverUrl: string, token: string) { + const [socket, setSocket] = useState(null) + const [instances, setInstances] = useState([]) + const [connected, setConnected] = useState(false) + + useEffect(() => { + const newSocket = io(serverUrl, { + auth: { token } + }) + + newSocket.on('connect', () => { + console.log('Connected to Roomote server') + setConnected(true) + + // Request current instances + newSocket.emit('instances:list', (list: ExtensionInstance[]) => { + setInstances(list) + }) + }) + + newSocket.on('disconnect', () => { + console.log('Disconnected from Roomote server') + setConnected(false) + }) + + newSocket.on('instance:update', (event: ExtensionBridgeEvent) => { + console.log('Instance update:', event) + + setInstances(prev => { + const idx = prev.findIndex(i => i.instanceId === event.instance.instanceId) + + if (event.type === 'instance_unregistered') { + return prev.filter(i => i.instanceId !== event.instance.instanceId) + } + + if (idx >= 0) { + const updated = [...prev] + updated[idx] = event.instance + return updated + } + + return [...prev, event.instance] + }) + }) + + setSocket(newSocket) + + return () => { + newSocket.close() + } + }, [serverUrl, token]) + + return { socket, instances, connected } +} +``` + +**2. Main App (`src/App.tsx`)** + +```typescript +import { useState } from 'react' +import { useSocket } from './hooks/useSocket' +import { InstanceList } from './components/InstanceList' +import { TaskViewer } from './components/TaskViewer' + +const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:3000' +const AUTH_TOKEN = import.meta.env.VITE_AUTH_TOKEN || 'your-token-here' + +export function App() { + const { socket, instances, connected } = useSocket(SERVER_URL, AUTH_TOKEN) + const [selectedInstance, setSelectedInstance] = useState(null) + + const activeInstance = instances.find(i => i.instanceId === selectedInstance) + + return ( +
+
+
+

Roomote Dashboard

+
+
+ {connected ? 'Connected' : 'Disconnected'} +
+
+
+ +
+
+ +
+ +
+ {activeInstance ? ( + + ) : ( +
+ Select an instance to view details +
+ )} +
+
+
+ ) +} +``` + +**3. Instance List (`src/components/InstanceList.tsx`)** + +```typescript +import { ExtensionInstance } from '../types/bridge' + +interface Props { + instances: ExtensionInstance[] + selectedId: string | null + onSelect: (id: string) => void +} + +export function InstanceList({ instances, selectedId, onSelect }: Props) { + return ( +
+

Active Instances

+ + {instances.length === 0 && ( +
+ No active instances +
+ )} + + {instances.map(instance => ( + + ))} +
+ ) +} +``` + +**4. Task Viewer (`src/components/TaskViewer.tsx`)** + +```typescript +import { Socket } from 'socket.io-client' +import { ExtensionInstance, ExtensionBridgeCommand } from '../types/bridge' +import { TaskControls } from './TaskControls' + +interface Props { + instance: ExtensionInstance + socket: Socket | null +} + +export function TaskViewer({ instance, socket }: Props) { + const handleStartTask = (text: string) => { + if (!socket) return + + const command: ExtensionBridgeCommand = { + type: 'start_task', + instanceId: instance.instanceId, + payload: { text }, + timestamp: Date.now() + } + + socket.emit('extension:command', command) + } + + const handleStopTask = () => { + if (!socket || !instance.task.taskId) return + + const command: ExtensionBridgeCommand = { + type: 'stop_task', + instanceId: instance.instanceId, + payload: { taskId: instance.task.taskId }, + timestamp: Date.now() + } + + socket.emit('extension:command', command) + } + + return ( +
+
+

Instance Details

+
+
+ Workspace: +
{instance.workspacePath}
+
+
+ Mode: +
{instance.mode || 'N/A'}
+
+
+ Provider: +
{instance.providerProfile || 'N/A'}
+
+
+ Status: +
{instance.task.taskStatus}
+
+
+
+ +
+

Current Task

+ {instance.task.taskId ? ( +
+
Task ID: {instance.task.taskId}
+ {instance.taskAsk && ( +
+
Ask:
+
{instance.taskAsk.ask}
+
+ )} +
+ ) : ( +
No active task
+ )} +
+ + +
+ ) +} +``` + +**5. Package.json** + +```json +{ + "name": "roomote-dashboard", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "socket.io-client": "^4.6.1" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.3", + "vite": "^5.0.8" + } +} +``` + +### Phase 3: Integration & Configuration + +#### VS Code Environment Setup + +Since you want to continue using your existing API provider configuration, you only need to configure the bridge connection. Add to your VS Code `settings.json`: + +```json +{ + "terminal.integrated.env.linux": { + "ROO_CODE_API_URL": "http://localhost:3000" + }, + "terminal.integrated.env.osx": { + "ROO_CODE_API_URL": "http://localhost:3000" + }, + "terminal.integrated.env.windows": { + "ROO_CODE_API_URL": "http://localhost:3000" + } +} +``` + +**Note**: You do NOT need to set `CLERK_BASE_URL` or `ROO_CODE_PROVIDER_URL` if you're using existing providers. The extension will only connect to your Roomote server for the bridge functionality. + +#### Server Configuration (`.env`) + +```bash +# Server +PORT=3000 + +# Security - CHANGE THIS! +JWT_SECRET=your-secure-random-secret-here + +# CORS - Allow your frontend +ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3001 + +# User (single-user setup) +USER_NAME=Admin +USER_EMAIL=admin@localhost +``` + +#### Frontend Configuration (`.env`) + +```bash +VITE_SERVER_URL=http://localhost:3000 +VITE_AUTH_TOKEN=generate-with-script +``` + +### Phase 4: Deployment & Security + +#### Local Development + +1. **Start the backend**: + ```bash + cd roomote-server + npm install + npm run dev + ``` + +2. **Start the frontend**: + ```bash + cd roomote-dashboard + npm install + npm run dev + ``` + +3. **Enable Roomote in Roo Code**: + - Open Roo Code settings in VS Code + - Enable "Remote Control" in Cloud settings + - Restart VS Code with environment variables set + +#### Production Deployment + +**Security Considerations:** + +1. **Use HTTPS**: Deploy behind a reverse proxy (nginx/Caddy) with TLS +2. **Secure JWT Secret**: Use a strong, randomly generated secret (32+ characters) +3. **Network Security**: + - Run on localhost and use SSH tunneling for remote access + - OR use a VPN to restrict access + - OR implement IP allowlisting in your reverse proxy + +4. **Token Management**: + - Implement token rotation (current implementation uses 1-hour expiry) + - Store tokens securely (not in plain text) + +**Example Docker Compose Setup:** + +```yaml +version: '3.8' + +services: + roomote-server: + build: ./roomote-server + ports: + - "3000:3000" + environment: + - PORT=3000 + - JWT_SECRET=${JWT_SECRET} + - ALLOWED_ORIGINS=https://roomote.yourdomain.com + restart: unless-stopped + + roomote-dashboard: + build: ./roomote-dashboard + ports: + - "5173:80" + environment: + - VITE_SERVER_URL=https://api.roomote.yourdomain.com + - VITE_AUTH_TOKEN=${AUTH_TOKEN} + restart: unless-stopped + + nginx: + image: nginx:alpine + ports: + - "443:443" + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./certs:/etc/nginx/certs:ro + depends_on: + - roomote-server + - roomote-dashboard + restart: unless-stopped +``` + +**Nginx Configuration Example:** + +```nginx +server { + listen 443 ssl http2; + server_name roomote.yourdomain.com; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + location / { + proxy_pass http://roomote-dashboard:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} + +server { + listen 443 ssl http2; + server_name api.roomote.yourdomain.com; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + location / { + proxy_pass http://roomote-server:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +### Phase 5: Authentication Setup + +#### Generating Tokens + +Create a simple script to generate authentication tokens: + +**`scripts/generate-token.js`** + +```javascript +import jwt from 'jsonwebtoken' + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key' +const userId = 'user_1' + +function generateToken() { + const now = Math.floor(Date.now() / 1000) + + const payload = { + iss: 'roomote-self-hosted', + sub: userId, + exp: now + (365 * 24 * 3600), // 1 year + iat: now, + v: 1, + r: { + u: userId, + o: null, + t: 'auth' + } + } + + return jwt.sign(payload, JWT_SECRET) +} + +console.log('Your authentication token:') +console.log(generateToken()) +``` + +Run with: +```bash +JWT_SECRET="your-secret" node scripts/generate-token.js +``` + +Use the generated token in: +1. Frontend `.env` as `VITE_AUTH_TOKEN` +2. Your HTTP requests to the API (Authorization header) + +## Testing the Setup + +### 1. Verify Backend is Running + +```bash +curl http://localhost:3000/health +# Should return: {"status":"ok"} +``` + +### 2. Test Bridge Config Endpoint + +```bash +curl http://localhost:3000/api/extension/bridge/config +# Should return: {"userId":"user_1","socketBridgeUrl":"...","token":"..."} +``` + +### 3. Test WebSocket Connection + +Open browser console on the dashboard and verify: +- "Connected to Roomote server" message +- Socket connection established +- No authentication errors + +### 4. Launch VS Code with Roo Code + +- Ensure `ROO_CODE_API_URL` environment variable is set +- Enable Remote Control in Roo Code settings +- Start a task +- Verify it appears in the dashboard + +## Troubleshooting + +### Extension Not Connecting + +1. **Check environment variable**: Ensure `ROO_CODE_API_URL` is set correctly +2. **Verify bridge config endpoint**: The extension calls `/api/extension/bridge/config` +3. **Check CORS**: Ensure VS Code extension origin is allowed +4. **Review logs**: Check backend console for connection attempts + +### Dashboard Shows No Instances + +1. **Verify WebSocket connection**: Check browser console +2. **Check authentication**: Ensure `VITE_AUTH_TOKEN` is valid +3. **Start a task in VS Code**: Instances only appear when extension connects +4. **Check server logs**: Look for "Extension connected" messages + +### Tasks Not Responding to Commands + +1. **Verify socket rooms**: Ensure extension joined correct rooms +2. **Check event names**: Ensure event/command types match exactly +3. **Review payload structure**: Must match TypeScript interfaces +4. **Check task IDs**: Ensure commands reference correct task IDs + +## Limitations & Future Enhancements + +### Current Limitations + +1. **Single User**: Authentication is simplified for one user +2. **No Persistence**: Instance state is lost on server restart +3. **No History**: Task history is not stored +4. **Basic Auth**: Uses simple JWT tokens (no OAuth) +5. **In-Memory Only**: No database for state or logs + +### Potential Enhancements + +1. **Multi-User Support**: Add user management and authentication +2. **Database Integration**: Store instance history and task logs +3. **Task Recordings**: Save and replay task conversations +4. **Metrics & Analytics**: Track task success rates, timing +5. **Notifications**: Alert on task completion or errors +6. **Mobile App**: Native mobile client for monitoring +7. **OAuth Integration**: Support GitHub/Google login +8. **Team Features**: Share instances across team members + +## Cost & Resource Requirements + +### Development Time +- Backend: 8-12 hours +- Frontend: 12-16 hours +- Testing & Debugging: 4-8 hours +- **Total**: 24-36 hours + +### Resource Requirements +- **CPU**: Minimal (handles ~10 concurrent instances easily) +- **Memory**: ~100-200 MB for backend +- **Storage**: Negligible (no database) +- **Network**: Low bandwidth (mainly WebSocket heartbeats) + +### Hosting Costs +- **Free Tier Options**: Can run on free tier of most cloud providers +- **VPS**: $5-10/month (DigitalOcean, Linode) +- **Self-Hosted**: Free if running on existing hardware + +## Summary + +This implementation plan provides a complete, production-ready solution for self-hosting Roomote with: + +✅ **Minimal complexity** - No external dependencies beyond Node.js +✅ **Secure** - JWT authentication, HTTPS support +✅ **Real-time** - Socket.io for instant updates +✅ **Single-user focused** - Simplified auth for personal use +✅ **Easy deployment** - Docker support, simple configuration +✅ **Type-safe** - Full TypeScript implementation +✅ **Tested architecture** - Based on existing Roo Code bridge patterns + +The solution requires minimal changes to your existing setup and allows you to continue using your current API provider configuration while gaining full control over the remote monitoring and task management capabilities. diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md new file mode 100644 index 00000000000..8d7cd1b6477 --- /dev/null +++ b/SELF_HOSTING.md @@ -0,0 +1,516 @@ +# Self-Hosting Roo Cloud + +This guide explains how to replace the official Roo Cloud service with a self-hosted alternative that integrates with Roo Code. + +## Overview + +Roo Code integrates with Roo Cloud to provide: + +- **Authentication & Authorization** - OAuth-based user authentication with organization support +- **Cloud-based AI Models** - Access to AI models through a proxy service +- **Task Sharing** - Share coding tasks with team members +- **Remote Control (Roomote)** - Control VS Code extension instances from a web dashboard +- **Settings Management** - Centralized organization and user settings +- **Telemetry** - Usage analytics and event tracking +- **Credit Management** - User credit balance for AI model usage + +## Architecture + +The Roo Code extension connects to three main services: + +1. **Authentication Service** (Clerk-compatible OAuth provider) +2. **API Service** (RESTful backend API) +3. **Provider Proxy Service** (OpenAI-compatible API proxy) + +Optional components: + +- **Socket Bridge Service** (Socket.io server for Roomote real-time control) + +## Configuration + +Configure your self-hosted services using environment variables in VS Code: + +### Required Environment Variables + +```bash +# Authentication Service URL (Clerk-compatible OAuth provider) +CLERK_BASE_URL=https://your-auth-service.example.com + +# API Service URL (Main backend API) +ROO_CODE_API_URL=https://your-api-service.example.com + +# Provider Proxy URL (OpenAI-compatible API endpoint) +ROO_CODE_PROVIDER_URL=https://your-provider-service.example.com/proxy +``` + +### Setting Environment Variables + +**Option 1: VS Code Settings** + +Add to your VS Code `settings.json`: + +```json +{ + "terminal.integrated.env.linux": { + "CLERK_BASE_URL": "https://your-auth-service.example.com", + "ROO_CODE_API_URL": "https://your-api-service.example.com", + "ROO_CODE_PROVIDER_URL": "https://your-provider-service.example.com/proxy" + }, + "terminal.integrated.env.osx": { + "CLERK_BASE_URL": "https://your-auth-service.example.com", + "ROO_CODE_API_URL": "https://your-api-service.example.com", + "ROO_CODE_PROVIDER_URL": "https://your-provider-service.example.com/proxy" + }, + "terminal.integrated.env.windows": { + "CLERK_BASE_URL": "https://your-auth-service.example.com", + "ROO_CODE_API_URL": "https://your-api-service.example.com", + "ROO_CODE_PROVIDER_URL": "https://your-provider-service.example.com/proxy" + } +} +``` + +**Option 2: System Environment Variables** + +Set environment variables before launching VS Code: + +```bash +export CLERK_BASE_URL=https://your-auth-service.example.com +export ROO_CODE_API_URL=https://your-api-service.example.com +export ROO_CODE_PROVIDER_URL=https://your-provider-service.example.com/proxy +code . +``` + +**Option 3: Development (.env file)** + +For local development, create a `.env` file in the repository root: + +```bash +CLERK_BASE_URL=http://localhost:3001 +ROO_CODE_API_URL=http://localhost:3000 +ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy +``` + +Note: See `.env.sample` for a template. + +## API Service Requirements + +Your API service must implement the following REST endpoints: + +### Authentication Endpoints + +These endpoints follow the Clerk OAuth flow. You can use Clerk or implement a compatible service. + +**GET** `/v1/client/sign_ins` + +- Initialize OAuth sign-in flow +- Response: Sign-in session details + +**POST** `/v1/client/sign_ins/{sign_in_id}/prepare_first_factor` + +- Prepare OAuth first factor authentication +- Body: `{ "strategy": "oauth_custom", "redirect_url": string }` + +**GET** `/v1/me` + +- Get authenticated user information +- Headers: `Authorization: Bearer {client_token}` +- Response: + +```json +{ + "response": { + "id": "user_id", + "first_name": "John", + "last_name": "Doe", + "image_url": "https://...", + "primary_email_address_id": "email_id", + "email_addresses": [{ "id": "email_id", "email_address": "user@example.com" }], + "public_metadata": {} + } +} +``` + +**GET** `/v1/me/organization_memberships` + +- Get user's organization memberships +- Headers: `Authorization: Bearer {client_token}` +- Response: + +```json +{ + "response": [ + { + "id": "membership_id", + "role": "admin", + "permissions": ["org:manage"], + "created_at": 1234567890, + "updated_at": 1234567890, + "organization": { + "id": "org_id", + "name": "Organization Name", + "slug": "org-slug", + "image_url": "https://...", + "has_image": true + } + } + ] +} +``` + +**POST** `/v1/client/sessions/{session_id}/tokens` + +- Create session token (JWT) +- Headers: `Authorization: Bearer {client_token}` +- Response: + +```json +{ + "jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +### Extension API Endpoints + +**GET** `/api/extension-settings` + +- Get organization and user settings +- Headers: `Authorization: Bearer {session_token}` +- Response: + +```json +{ + "organization": { + "version": 1, + "cloudSettings": { + "recordTaskMessages": true, + "enableTaskSharing": true, + "allowPublicTaskSharing": true, + "taskShareExpirationDays": 30, + "allowMembersViewAllTasks": true, + "workspaceTaskVisibility": "all", + "llmEnhancedFeaturesEnabled": false + }, + "defaultSettings": { + "maxOpenTabsContext": 10, + "maxReadFileLine": 5000, + "maxWorkspaceFiles": 1000, + "terminalOutputLineLimit": 500 + }, + "allowList": { + "allowAll": true, + "providers": {} + }, + "features": { + "roomoteControlEnabled": false + }, + "providerProfiles": {}, + "mcps": [], + "hiddenMcps": [], + "hideMarketplaceMcps": false + }, + "user": { + "version": 1, + "features": { + "roomoteControlEnabled": false + }, + "settings": { + "extensionBridgeEnabled": false, + "taskSyncEnabled": true, + "llmEnhancedFeaturesEnabled": false + } + } +} +``` + +**POST** `/api/user-settings` + +- Update user settings +- Headers: `Authorization: Bearer {session_token}` +- Body: Partial user settings to update +- Response: Updated user settings + +**POST** `/api/extension/share` + +- Share a task +- Headers: `Authorization: Bearer {session_token}` +- Body: + +```json +{ + "taskId": "task_id", + "visibility": "organization" // or "public" +} +``` + +- Response: + +```json +{ + "success": true, + "shareUrl": "https://your-app.com/share/abc123", + "isNewShare": true, + "manageUrl": "https://your-app.com/tasks/manage" +} +``` + +**GET** `/api/extension/credit-balance` + +- Get user's credit balance +- Headers: `Authorization: Bearer {session_token}` +- Response: + +```json +{ + "balance": 100.5 +} +``` + +**GET** `/api/extension/bridge/config` + +- Get socket bridge configuration (for Roomote control) +- Headers: `Authorization: Bearer {session_token}` +- Response: + +```json +{ + "userId": "user_id", + "socketBridgeUrl": "https://your-socket-service.example.com", + "token": "bridge_auth_token" +} +``` + +### Telemetry Endpoints + +**POST** `/api/events` + +- Send telemetry events +- Headers: `Authorization: Bearer {session_token}` +- Body: + +```json +{ + "name": "event_name", + "properties": { + "key": "value" + } +} +``` + +**POST** `/api/events/backfill` + +- Backfill historical telemetry events +- Headers: `Authorization: Bearer {session_token}` +- Body: Array of telemetry events + +## Provider Proxy Service Requirements + +Your provider proxy must implement an OpenAI-compatible API at `/v1`: + +**POST** `/v1/chat/completions` + +- OpenAI-compatible chat completions endpoint +- Headers: `Authorization: Bearer {session_token}` +- Body: Standard OpenAI chat completion request +- Response: Standard OpenAI chat completion response (streaming or non-streaming) + +**GET** `/models` + +- List available AI models +- Headers: `Authorization: Bearer {session_token}` +- Response: + +```json +{ + "data": [ + { + "id": "model-id", + "name": "Model Name", + "provider": "provider-name", + "maxTokens": 8000, + "contextWindow": 100000, + "supportsImages": true, + "supportsPromptCache": true, + "inputPrice": 0.003, + "outputPrice": 0.015 + } + ] +} +``` + +The provider proxy should: + +- Authenticate requests using the session token +- Route requests to appropriate AI model providers +- Track token usage and costs +- Apply rate limiting and credit checks + +## Socket Bridge Service (Optional) + +For Roomote control features, implement a Socket.io server that: + +### Connection + +- Accepts WebSocket connections at the URL from `/api/extension/bridge/config` +- Authenticates using the token from bridge config +- Manages user-specific rooms for pub/sub messaging + +### Event Types + +**Extension → Server (Extension Instance Events)** + +- `instance_registered` - Extension instance connected +- `instance_unregistered` - Extension instance disconnected +- `heartbeat_updated` - Periodic heartbeat (every 20s) +- Task lifecycle events: `task_created`, `task_started`, `task_completed`, `task_aborted` +- Task state events: `task_focused`, `task_unfocused`, `task_active`, `task_idle` +- `mode_changed` - Mode switched +- `provider_profile_changed` - Provider profile changed + +**Server → Extension (Commands)** + +- `start_task` - Start a new task +- `stop_task` - Stop a running task +- `resume_task` - Resume a task + +**Task Messages (Bidirectional)** + +- `message` - Send message to task +- `approve_ask` - Approve a pending ask +- `deny_ask` - Deny a pending ask + +### Event Schema + +All events include: + +- `type` - Event/command name +- `timestamp` - Unix timestamp in milliseconds +- `instance` - Extension instance details (for instance events) +- `taskId` - Task identifier (for task-specific commands) +- `payload` - Event-specific data + +See `packages/types/src/cloud.ts` for complete schemas. + +## JWT Token Format + +Session tokens should be JWTs with the following payload: + +```json +{ + "iss": "your-service", + "sub": "user_id", + "exp": 1234567890, + "iat": 1234567890, + "nbf": 1234567890, + "v": 1, + "r": { + "u": "user_id", + "o": "organization_id", + "t": "auth" + } +} +``` + +Where: + +- `iss` - Issuer identifier +- `sub` - Subject (user ID) +- `exp` - Expiration timestamp +- `iat` - Issued at timestamp +- `nbf` - Not before timestamp +- `v` - Token version (use 1) +- `r.u` - User ID +- `r.o` - Organization ID (null for personal account) +- `r.t` - Token type ("auth" for authentication tokens) + +## Security Considerations + +1. **HTTPS Required**: All services should use HTTPS in production +2. **CORS Configuration**: Configure CORS to allow requests from VS Code extension +3. **Token Expiration**: Implement reasonable token expiration (session tokens refresh every ~50 seconds) +4. **Rate Limiting**: Implement rate limiting on all endpoints +5. **Input Validation**: Validate all inputs using the provided Zod schemas +6. **Authentication**: Verify session tokens on all protected endpoints +7. **Authorization**: Implement proper organization-based access control + +## Minimal Implementation + +A minimal self-hosted setup requires: + +1. **Authentication Service** - Can use Clerk (https://clerk.com) or implement compatible OAuth flow +2. **API Service** - Backend API implementing the extension endpoints +3. **Provider Proxy** - OpenAI-compatible proxy to your AI model providers + +Optional: 4. **Socket Bridge** - Only needed if you want Roomote remote control features + +## Testing Your Setup + +1. Set environment variables for your services +2. Launch VS Code with Roo Code extension installed +3. Open Roo Code extension panel +4. Click "Cloud" tab and attempt to sign in +5. Verify authentication flow completes +6. Test creating a task to verify provider proxy works +7. Check that organization settings are loaded correctly + +## Troubleshooting + +### Extension doesn't connect to self-hosted services + +- Verify environment variables are set correctly +- Check that services are accessible from your machine +- Review browser console and VS Code Developer Tools for error messages + +### Authentication fails + +- Verify Clerk configuration or OAuth implementation +- Check that redirect URLs are configured correctly +- Ensure CORS headers allow VS Code extension origin + +### AI models don't load + +- Verify provider proxy URL is correct and includes `/v1` suffix +- Check that `/models` endpoint returns valid model list +- Verify session token is being sent in Authorization header + +### Settings don't sync + +- Verify `/api/extension-settings` endpoint is working +- Check response format matches expected schema +- Review error logs in extension developer tools + +## Reference Implementation + +The official Roo Cloud services can be used as a reference for expected behavior. The extension will default to: + +- `CLERK_BASE_URL`: `https://clerk.roocode.com` +- `ROO_CODE_API_URL`: `https://app.roocode.com` +- `ROO_CODE_PROVIDER_URL`: `https://api.roocode.com/proxy` + +## Schema Validation + +All API responses should match the schemas defined in `packages/types/src/cloud.ts`. Use these schemas to validate your implementation: + +- `organizationSettingsSchema` - Organization settings structure +- `userSettingsDataSchema` - User settings structure +- `shareResponseSchema` - Share task response +- `extensionBridgeEventSchema` - Socket events from extension +- `extensionBridgeCommandSchema` - Socket commands to extension +- `taskBridgeEventSchema` - Task-specific events +- `taskBridgeCommandSchema` - Task-specific commands + +## Support + +For questions about self-hosting: + +1. Review this documentation thoroughly +2. Check the TypeScript type definitions in `packages/types/src/cloud.ts` +3. Examine the cloud service implementation in `packages/cloud/src/` +4. Join the [Roo Code Discord](https://discord.gg/roocode) for community support + +## License + +When implementing a self-hosted alternative, ensure you comply with: + +- Roo Code's license terms +- Any third-party service licenses (e.g., AI model providers) +- Privacy and data protection regulations applicable to your use case diff --git a/packages/cloud/src/CloudService.ts b/packages/cloud/src/CloudService.ts index 43f52d4b18a..550cfca5f07 100644 --- a/packages/cloud/src/CloudService.ts +++ b/packages/cloud/src/CloudService.ts @@ -26,6 +26,7 @@ import { CloudTelemetryClient as TelemetryClient } from "./TelemetryClient.js" import { CloudShareService } from "./CloudShareService.js" import { CloudAPI } from "./CloudAPI.js" import { RetryQueue } from "./retry-queue/index.js" +import { getCloudServiceConfig } from "./config.js" type AuthStateChangedPayload = CloudServiceEvents["auth-state-changed"][0] type AuthUserInfoPayload = CloudServiceEvents["user-info"][0] @@ -116,6 +117,19 @@ export class CloudService extends EventEmitter implements Di } try { + // Log cloud service configuration for debugging + const config = getCloudServiceConfig() + this.log("[CloudService] Initializing with configuration:", { + clerkBaseUrl: config.clerkBaseUrl, + rooCodeApiUrl: config.rooCodeApiUrl, + rooCodeProviderUrl: config.rooCodeProviderUrl, + isCustom: config.isCustom, + }) + + if (config.isCustom) { + this.log("[CloudService] Using custom (self-hosted) cloud services. See SELF_HOSTING.md for details.") + } + // For testing you can create a token with: // `pnpm --filter @roo-code-cloud/roomote-cli development auth job-token --job-id 1 --user-id user_2xmBhejNeDTwanM8CgIOnMgVxzC --org-id org_2wbhchVXZMQl8OS1yt0mrDazCpW` // The token will last for 1 hour. diff --git a/packages/cloud/src/config.ts b/packages/cloud/src/config.ts index cfff9d0f589..56472a88039 100644 --- a/packages/cloud/src/config.ts +++ b/packages/cloud/src/config.ts @@ -1,6 +1,56 @@ export const PRODUCTION_CLERK_BASE_URL = "https://clerk.roocode.com" export const PRODUCTION_ROO_CODE_API_URL = "https://app.roocode.com" +export const PRODUCTION_ROO_CODE_PROVIDER_URL = "https://api.roocode.com/proxy" -export const getClerkBaseUrl = () => process.env.CLERK_BASE_URL || PRODUCTION_CLERK_BASE_URL +/** + * Get the Clerk authentication service base URL. + * Can be overridden with CLERK_BASE_URL environment variable for self-hosted setups. + * See SELF_HOSTING.md for more information. + */ +export const getClerkBaseUrl = () => { + const url = process.env.CLERK_BASE_URL || PRODUCTION_CLERK_BASE_URL + return url.replace(/\/$/, "") // Remove trailing slash +} -export const getRooCodeApiUrl = () => process.env.ROO_CODE_API_URL || PRODUCTION_ROO_CODE_API_URL +/** + * Get the Roo Code API service base URL. + * Can be overridden with ROO_CODE_API_URL environment variable for self-hosted setups. + * See SELF_HOSTING.md for more information. + */ +export const getRooCodeApiUrl = () => { + const url = process.env.ROO_CODE_API_URL || PRODUCTION_ROO_CODE_API_URL + return url.replace(/\/$/, "") // Remove trailing slash +} + +/** + * Get the Roo Code provider proxy base URL. + * Can be overridden with ROO_CODE_PROVIDER_URL environment variable for self-hosted setups. + * See SELF_HOSTING.md for more information. + */ +export const getRooCodeProviderUrl = () => { + const url = process.env.ROO_CODE_PROVIDER_URL || PRODUCTION_ROO_CODE_PROVIDER_URL + return url.replace(/\/$/, "") // Remove trailing slash +} + +/** + * Check if using custom (self-hosted) cloud services. + */ +export const isUsingCustomCloudServices = () => { + return ( + !!process.env.CLERK_BASE_URL || + !!process.env.ROO_CODE_API_URL || + !!process.env.ROO_CODE_PROVIDER_URL + ) +} + +/** + * Get a summary of current cloud service configuration for logging/debugging. + */ +export const getCloudServiceConfig = () => { + return { + clerkBaseUrl: getClerkBaseUrl(), + rooCodeApiUrl: getRooCodeApiUrl(), + rooCodeProviderUrl: getRooCodeProviderUrl(), + isCustom: isUsingCustomCloudServices(), + } +} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 51ca19e2bce..e3ac384bad7 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -8,6 +8,7 @@ import { z } from "zod" import type { ProviderName, ModelRecord } from "@roo-code/types" import { modelInfoSchema, TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import { getRooCodeProviderUrl } from "@roo-code/cloud" import { safeWriteJson } from "../../../utils/safeWriteJson" @@ -101,7 +102,7 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { constructor(options: ApiHandlerOptions) { const sessionToken = options.rooApiKey ?? getSessionToken() - let baseURL = process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy" + let baseURL = getRooCodeProviderUrl() // Ensure baseURL ends with /v1 for OpenAI client, but don't duplicate it if (!baseURL.endsWith("/v1")) {