diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000000..3972468950 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,133 @@ +# Roo Code GitHub Codespaces Configuration + +This directory contains the configuration for running Roo Code in GitHub Codespaces, providing a fully configured development environment with automatic Chrome/Puppeteer dependency installation. + +## Features + +### Automatic Dependency Installation + +- **Chrome Browser**: Google Chrome Stable is automatically installed with all required dependencies +- **Docker Support**: Docker-in-Docker feature enables containerized browser option +- **Node.js Environment**: Pre-configured TypeScript/Node.js development container +- **VS Code Extensions**: Essential extensions are pre-installed + +### Browser Tool Support + +The configuration ensures the browser tool works seamlessly in Codespaces by: + +1. **Automatic Chrome Installation**: Chrome and all its dependencies are installed during container creation +2. **Dependency Detection**: The extension automatically detects and installs missing dependencies +3. **Docker Fallback**: If Chrome fails, Docker browser container can be used as fallback +4. **Environment Variables**: Proper configuration for Puppeteer to use system Chrome + +## Configuration Options + +### Docker Browser (Optional) + +You can enable Docker-based browser isolation in VS Code settings: + +```json +{ + "roo-cline.browserDocker.enabled": true, + "roo-cline.browserDocker.image": "browserless/chrome:latest", + "roo-cline.browserDocker.autoStart": true +} +``` + +### Benefits of Docker Browser + +- **Isolation**: Browser runs in a separate container +- **Consistency**: Same browser environment across all systems +- **No Dependencies**: No need to install Chrome dependencies on host +- **Security**: Enhanced security through containerization + +## Troubleshooting + +### Browser Tool Not Working? + +1. **Check Chrome Installation**: + + ```bash + google-chrome --version + # or + google-chrome-stable --version + ``` + +2. **Test Chrome Headless**: + + ```bash + google-chrome --headless --no-sandbox --disable-gpu --dump-dom https://example.com + ``` + +3. **Check Missing Dependencies**: + + ```bash + ldd $(which google-chrome) | grep "not found" + ``` + +4. **Install Missing Dependencies Manually**: + + ```bash + sudo apt-get update + sudo apt-get install -y \ + libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \ + libxrandr2 libgbm1 libasound2 + ``` + +5. **Use Docker Browser as Fallback**: + - Enable Docker browser in settings (see Configuration Options above) + - Ensure Docker is running: `docker info` + - The extension will automatically use Docker if Chrome fails + +### Docker Issues? + +1. **Check Docker Status**: + + ```bash + docker info + ``` + +2. **Pull Browser Image Manually**: + + ```bash + docker pull browserless/chrome:latest + ``` + +3. **Test Docker Browser**: + ```bash + docker run -d --name test-browser -p 3000:3000 browserless/chrome:latest + # Visit http://localhost:3000 to verify + docker stop test-browser && docker rm test-browser + ``` + +## Files in This Directory + +- **devcontainer.json**: Main configuration file for the dev container +- **post-create.sh**: Script that runs after container creation (installs dependencies) +- **post-start.sh**: Script that runs each time the container starts (verifies setup) +- **README.md**: This documentation file + +## How It Works + +1. **Container Creation**: When you create a Codespace, it uses the TypeScript/Node.js base image +2. **Feature Installation**: Docker-in-Docker and Chrome features are installed +3. **Post-Create Script**: Installs Chrome dependencies and builds the project +4. **Post-Start Script**: Verifies the environment on each container start +5. **Automatic Detection**: The extension detects the Codespace environment and adapts accordingly + +## Environment Variables + +The following environment variables are set automatically: + +- `CODESPACES=true`: Indicates running in GitHub Codespaces +- `PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true`: Prevents Puppeteer from downloading Chromium +- `PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable`: Points Puppeteer to system Chrome + +## Support + +If you encounter issues with the browser tool in Codespaces: + +1. Check the troubleshooting section above +2. Review the post-start script output for warnings +3. Report issues at: https://github.com/RooCodeInc/Roo-Code/issues diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..a822edf2b5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,70 @@ +{ + "name": "Roo Code Development", + "image": "mcr.microsoft.com/devcontainers/typescript-node:20", + + // Features to add to the dev container + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/chrome:1": { + "version": "stable" + } + }, + + // Configure tool-specific properties + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-vscode.vscode-typescript-next", + "RooVeterinaryInc.roo-cline" + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "bash", + "typescript.tsdk": "node_modules/typescript/lib", + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } + } + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally + "forwardPorts": [3000, 9222], + + // Use 'postCreateCommand' to run commands after the container is created + "postCreateCommand": "bash .devcontainer/post-create.sh", + + // Use 'postStartCommand' to run commands after the container starts + "postStartCommand": "bash .devcontainer/post-start.sh", + + // Set environment variables + "containerEnv": { + "CODESPACES": "true", + "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD": "true", + "PUPPETEER_EXECUTABLE_PATH": "/usr/bin/google-chrome-stable" + }, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root + // "remoteUser": "root", + + // Features documentation: https://containers.dev/features + "mounts": ["source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"], + + // Memory and CPU limits for better performance + "hostRequirements": { + "cpus": 2, + "memory": "4gb", + "storage": "32gb" + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 0000000000..b8953bea94 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -e + +echo "๐Ÿš€ Setting up Roo Code development environment..." + +# Update package lists +echo "๐Ÿ“ฆ Updating package lists..." +sudo apt-get update + +# Install Chrome dependencies that might be missing +echo "๐ŸŒ Installing Chrome dependencies..." +sudo apt-get install -y \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libasound2 \ + libatspi2.0-0 \ + libgtk-3-0 \ + libpango-1.0-0 \ + libcairo2 \ + libxshmfence1 \ + libnss3 \ + libnssutil3 \ + libnspr4 \ + libx11-xcb1 \ + libxcb-dri3-0 \ + fonts-liberation \ + libappindicator3-1 \ + libxss1 \ + lsb-release \ + xdg-utils \ + wget + +# Verify Chrome installation +if ! command -v google-chrome &> /dev/null && ! command -v google-chrome-stable &> /dev/null; then + echo "โš ๏ธ Chrome not found, installing Google Chrome..." + wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' + sudo apt-get update + sudo apt-get install -y google-chrome-stable +fi + +# Install project dependencies +echo "๐Ÿ“š Installing project dependencies..." +npm install -g pnpm +pnpm install + +# Build the project +echo "๐Ÿ”จ Building the project..." +pnpm run bundle + +# Create a symlink for Chrome if needed +if [ -f "/usr/bin/google-chrome-stable" ] && [ ! -f "/usr/bin/google-chrome" ]; then + sudo ln -sf /usr/bin/google-chrome-stable /usr/bin/google-chrome +fi + +# Set up Docker if available +if command -v docker &> /dev/null; then + echo "๐Ÿณ Docker is available, pulling browserless/chrome image..." + docker pull browserless/chrome:latest || true +fi + +echo "โœ… Development environment setup complete!" +echo "" +echo "๐Ÿ“ Notes:" +echo " - Chrome is installed at: $(which google-chrome || which google-chrome-stable || echo 'Not found')" +echo " - Docker is available: $(command -v docker &> /dev/null && echo 'Yes' || echo 'No')" +echo " - The browser tool should now work correctly in this Codespace" +echo "" +echo "๐ŸŽ‰ Happy coding with Roo Code!" \ No newline at end of file diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh new file mode 100644 index 0000000000..bb19907bed --- /dev/null +++ b/.devcontainer/post-start.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -e + +echo "๐Ÿ”„ Starting Roo Code development environment..." + +# Check if Chrome is accessible +if command -v google-chrome &> /dev/null || command -v google-chrome-stable &> /dev/null; then + CHROME_PATH=$(which google-chrome || which google-chrome-stable) + echo "โœ… Chrome found at: $CHROME_PATH" + + # Test Chrome can run headless + echo "๐Ÿงช Testing Chrome headless mode..." + timeout 5 $CHROME_PATH --headless --no-sandbox --disable-gpu --dump-dom https://example.com > /dev/null 2>&1 && \ + echo "โœ… Chrome headless mode works!" || \ + echo "โš ๏ธ Chrome headless test failed, but this might be okay" +else + echo "โš ๏ธ Chrome not found. The browser tool may not work correctly." +fi + +# Check Docker availability +if command -v docker &> /dev/null; then + echo "๐Ÿณ Docker is available" + + # Check if Docker daemon is running + if docker info > /dev/null 2>&1; then + echo "โœ… Docker daemon is running" + + # Optionally pre-pull the browserless image + if [ "${PRELOAD_DOCKER_IMAGES:-false}" = "true" ]; then + echo "๐Ÿ“ฅ Pre-loading Docker browser image..." + docker pull browserless/chrome:latest || true + fi + else + echo "โš ๏ธ Docker daemon is not running. Docker browser option won't be available." + fi +else + echo "โ„น๏ธ Docker is not available. Docker browser option won't be available." +fi + +# Display environment info +echo "" +echo "๐Ÿ“Š Environment Information:" +echo " - Node.js: $(node --version)" +echo " - npm: $(npm --version)" +echo " - pnpm: $(pnpm --version 2>/dev/null || echo 'Not installed')" +echo " - Chrome: $(google-chrome --version 2>/dev/null || google-chrome-stable --version 2>/dev/null || echo 'Not installed')" +echo " - Docker: $(docker --version 2>/dev/null || echo 'Not installed')" +echo "" + +# Check for missing dependencies +MISSING_DEPS=() +for lib in libatk-1.0.so.0 libatk-bridge-2.0.so.0 libcups.so.2; do + if ! ldconfig -p | grep -q $lib; then + MISSING_DEPS+=($lib) + fi +done + +if [ ${#MISSING_DEPS[@]} -gt 0 ]; then + echo "โš ๏ธ Some Chrome dependencies might be missing: ${MISSING_DEPS[*]}" + echo " Run: sudo apt-get update && sudo apt-get install -y libatk1.0-0 libatk-bridge2.0-0 libcups2" +else + echo "โœ… All critical Chrome dependencies are installed" +fi + +echo "" +echo "๐Ÿš€ Roo Code development environment is ready!" \ No newline at end of file diff --git a/src/package.json b/src/package.json index ad47fa6889..b48467da00 100644 --- a/src/package.json +++ b/src/package.json @@ -407,6 +407,21 @@ "minimum": 1, "maximum": 200, "description": "%settings.codeIndex.embeddingBatchSize.description%" + }, + "roo-cline.browserDocker.enabled": { + "type": "boolean", + "default": false, + "description": "%settings.browserDocker.enabled.description%" + }, + "roo-cline.browserDocker.image": { + "type": "string", + "default": "browserless/chrome:latest", + "description": "%settings.browserDocker.image.description%" + }, + "roo-cline.browserDocker.autoStart": { + "type": "boolean", + "default": true, + "description": "%settings.browserDocker.autoStart.description%" } } } diff --git a/src/package.nls.json b/src/package.nls.json index b0b7f401f8..f264e7ea8c 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -41,5 +41,8 @@ "settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)", "settings.apiRequestTimeout.description": "Maximum time in seconds to wait for API responses (0 = no timeout, 1-3600s, default: 600s). Higher values are recommended for local providers like LM Studio and Ollama that may need more processing time.", "settings.newTaskRequireTodos.description": "Require todos parameter when creating new tasks with the new_task tool", - "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60." + "settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60.", + "settings.browserDocker.enabled.description": "Enable Docker-based browser isolation for enhanced security and dependency management. Requires Docker to be installed.", + "settings.browserDocker.image.description": "Docker image to use for the browser container (default: browserless/chrome:latest)", + "settings.browserDocker.autoStart.description": "Automatically start the Docker browser container when needed" } diff --git a/src/services/browser/BrowserSession.ts b/src/services/browser/BrowserSession.ts index 75b432f01d..82bc8c88c4 100644 --- a/src/services/browser/BrowserSession.ts +++ b/src/services/browser/BrowserSession.ts @@ -9,6 +9,14 @@ import delay from "delay" import { fileExistsAtPath } from "../../utils/fs" import { BrowserActionResult } from "../../shared/ExtensionMessage" import { discoverChromeHostUrl, tryChromeHostUrl } from "./browserDiscovery" +import { + detectEnvironment, + installChromeDependencies, + getSystemChromePath, + getDockerBrowserConfig, + startDockerBrowser, + stopDockerBrowser, +} from "./browserEnvironment" // Timeout constants const BROWSER_NAVIGATION_TIMEOUT = 15_000 // 15 seconds @@ -61,16 +69,77 @@ export class BrowserSession { } /** - * Launches a local browser instance + * Launches a local browser instance with automatic dependency handling */ private async launchLocalBrowser(): Promise { console.log("Launching local browser") + + // Detect environment and check for missing dependencies + const env = await detectEnvironment() + + // In Codespaces or containers, try to install dependencies automatically + if ((env.isCodespaces || env.isContainer) && env.missingDependencies.length > 0) { + console.log("Detected missing Chrome dependencies, attempting automatic installation...") + const installed = await installChromeDependencies(this.context) + if (!installed) { + // If automatic installation failed, try Docker as fallback + const dockerConfig = getDockerBrowserConfig(this.context) + if (dockerConfig.enabled && env.hasDocker) { + console.log("Falling back to Docker browser...") + const dockerEndpoint = await startDockerBrowser(dockerConfig) + if (dockerEndpoint) { + await this.connectWithChromeHostUrl(dockerEndpoint) + return + } + } + + // If all else fails, show detailed error + throw new Error( + "Chrome dependencies are missing. Please install them manually:\n" + + "sudo apt-get update && sudo apt-get install -y libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2", + ) + } + } + + // Check if we should use Docker browser + const dockerConfig = getDockerBrowserConfig(this.context) + if (dockerConfig.enabled && dockerConfig.autoStart && env.hasDocker) { + console.log("Using Docker browser as configured...") + const dockerEndpoint = await startDockerBrowser(dockerConfig) + if (dockerEndpoint) { + await this.connectWithChromeHostUrl(dockerEndpoint) + return + } + } + + // Try to use system Chrome if available + const systemChromePath = await getSystemChromePath() + let executablePath: string + + if (systemChromePath) { + console.log(`Using system Chrome at: ${systemChromePath}`) + executablePath = systemChromePath + } else { + // Fall back to puppeteer-chromium-resolver + const stats = await this.ensureChromiumExists() + executablePath = stats.executablePath + } + + // Prepare launch arguments + const args = [ + "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + ] + + // Add sandbox flags for Linux/container environments + if (env.isLinux || env.isContainer || env.isCodespaces) { + args.push("--no-sandbox", "--disable-setuid-sandbox") + } + + // Launch the browser const stats = await this.ensureChromiumExists() this.browser = await stats.puppeteer.launch({ - args: [ - "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - ], - executablePath: stats.executablePath, + args, + executablePath, defaultViewport: this.getViewport(), // headless: false, }) @@ -205,6 +274,13 @@ export class BrowserSession { } this.resetBrowserState() } + + // Stop Docker browser if it was started + const dockerConfig = getDockerBrowserConfig(this.context) + if (dockerConfig.enabled) { + await stopDockerBrowser().catch(() => {}) + } + return {} } diff --git a/src/services/browser/UrlContentFetcher.ts b/src/services/browser/UrlContentFetcher.ts index b271bc2ef4..d3eb61fd7f 100644 --- a/src/services/browser/UrlContentFetcher.ts +++ b/src/services/browser/UrlContentFetcher.ts @@ -8,6 +8,7 @@ import TurndownService from "turndown" import PCR from "puppeteer-chromium-resolver" import { fileExistsAtPath } from "../../utils/fs" import { serializeError } from "serialize-error" +import { detectEnvironment, installChromeDependencies, getSystemChromePath } from "./browserEnvironment" // Timeout constants const URL_FETCH_TIMEOUT = 30_000 // 30 seconds @@ -49,7 +50,35 @@ export class UrlContentFetcher { if (this.browser) { return } - const stats = await this.ensureChromiumExists() + + // Detect environment and check for missing dependencies + const env = await detectEnvironment() + + // In Codespaces or containers, try to install dependencies automatically + if ((env.isCodespaces || env.isContainer) && env.missingDependencies.length > 0) { + console.log("Detected missing Chrome dependencies, attempting automatic installation...") + const installed = await installChromeDependencies(this.context) + if (!installed) { + throw new Error( + "Chrome dependencies are missing. Please install them manually:\n" + + "sudo apt-get update && sudo apt-get install -y libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2", + ) + } + } + + // Try to use system Chrome if available + const systemChromePath = await getSystemChromePath() + let executablePath: string + + if (systemChromePath) { + console.log(`Using system Chrome at: ${systemChromePath}`) + executablePath = systemChromePath + } else { + // Fall back to puppeteer-chromium-resolver + const stats = await this.ensureChromiumExists() + executablePath = stats.executablePath + } + const args = [ "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", "--disable-dev-shm-usage", @@ -58,13 +87,16 @@ export class UrlContentFetcher { "--disable-gpu", "--disable-features=VizDisplayCompositor", ] - if (process.platform === "linux") { - // Fixes network errors on Linux hosts (see https://github.com/puppeteer/puppeteer/issues/8246) - args.push("--no-sandbox") + + // Add sandbox flags for Linux/container environments + if (env.isLinux || env.isContainer || env.isCodespaces) { + args.push("--no-sandbox", "--disable-setuid-sandbox") } + + const stats = await this.ensureChromiumExists() this.browser = await stats.puppeteer.launch({ args, - executablePath: stats.executablePath, + executablePath, }) // (latest version of puppeteer does not add headless to user agent) this.page = await this.browser?.newPage() diff --git a/src/services/browser/__tests__/BrowserSession.spec.ts b/src/services/browser/__tests__/BrowserSession.spec.ts index b69fb2d140..5fe8cbbba6 100644 --- a/src/services/browser/__tests__/BrowserSession.spec.ts +++ b/src/services/browser/__tests__/BrowserSession.spec.ts @@ -9,6 +9,19 @@ vi.mock("vscode", () => ({ Uri: { file: vi.fn((path) => ({ fsPath: path })), }, + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + withProgress: vi.fn((options, task) => task({ report: vi.fn() })), + }, + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((key, defaultValue) => defaultValue), + })), + }, + ProgressLocation: { + Notification: 15, + }, })) // Mock puppeteer-core @@ -69,6 +82,27 @@ vi.mock("../browserDiscovery", () => ({ tryChromeHostUrl: vi.fn().mockResolvedValue(false), })) +// Mock browserEnvironment +vi.mock("../browserEnvironment", () => ({ + detectEnvironment: vi.fn().mockResolvedValue({ + isCodespaces: false, + isContainer: false, + isLinux: false, + hasDocker: false, + hasSystemChrome: false, + missingDependencies: [], + }), + installChromeDependencies: vi.fn().mockResolvedValue(true), + getSystemChromePath: vi.fn().mockResolvedValue(null), + getDockerBrowserConfig: vi.fn().mockReturnValue({ + enabled: false, + image: "browserless/chrome:latest", + autoStart: true, + }), + startDockerBrowser: vi.fn().mockResolvedValue(null), + stopDockerBrowser: vi.fn().mockResolvedValue(undefined), +})) + // Mock delay vi.mock("delay", () => ({ default: vi.fn().mockResolvedValue(undefined), @@ -83,9 +117,29 @@ describe("BrowserSession", () => { let browserSession: BrowserSession let mockContext: any - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() + // Import and clear mocks for browserEnvironment + const browserEnv = await import("../browserEnvironment") + vi.mocked(browserEnv.detectEnvironment).mockResolvedValue({ + isCodespaces: false, + isContainer: false, + isLinux: false, + hasDocker: false, + hasSystemChrome: false, + missingDependencies: [], + }) + vi.mocked(browserEnv.installChromeDependencies).mockResolvedValue(true) + vi.mocked(browserEnv.getSystemChromePath).mockResolvedValue(null) + vi.mocked(browserEnv.getDockerBrowserConfig).mockReturnValue({ + enabled: false, + image: "browserless/chrome:latest", + autoStart: true, + }) + vi.mocked(browserEnv.startDockerBrowser).mockResolvedValue(null) + vi.mocked(browserEnv.stopDockerBrowser).mockResolvedValue(undefined) + // Set up mock context mockContext = { globalState: { diff --git a/src/services/browser/__tests__/browserEnvironment.spec.ts b/src/services/browser/__tests__/browserEnvironment.spec.ts new file mode 100644 index 0000000000..caa4d7ff3c --- /dev/null +++ b/src/services/browser/__tests__/browserEnvironment.spec.ts @@ -0,0 +1,391 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" + +// Use vi.hoisted to ensure mocks are set up before imports +const { execAsync } = vi.hoisted(() => { + return { + execAsync: vi.fn(), + } +}) + +// Mock modules before any imports +vi.mock("fs/promises") +vi.mock("child_process") +vi.mock("util", () => ({ + promisify: vi.fn(() => execAsync), +})) +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + withProgress: vi.fn((options, task) => task({ report: vi.fn() })), + }, + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((key, defaultValue) => defaultValue), + })), + }, + ProgressLocation: { + Notification: 15, + }, +})) + +// Now import the modules after mocking +import * as fs from "fs/promises" +import * as vscode from "vscode" +import { + detectEnvironment, + installChromeDependencies, + getSystemChromePath, + getDockerBrowserConfig, + startDockerBrowser, + stopDockerBrowser, +} from "../browserEnvironment" + +describe("browserEnvironment", () => { + beforeEach(() => { + vi.clearAllMocks() + // Reset environment variables + delete process.env.CODESPACES + // Mock platform + Object.defineProperty(process, "platform", { + value: "linux", + writable: true, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("detectEnvironment", () => { + it("should detect Codespaces environment", async () => { + process.env.CODESPACES = "true" + vi.mocked(fs.access).mockRejectedValue(new Error("No .dockerenv")) + vi.mocked(fs.readFile).mockRejectedValue(new Error("No cgroup")) + execAsync.mockRejectedValue(new Error("No docker")) + + const env = await detectEnvironment() + + expect(env.isCodespaces).toBe(true) + expect(env.isLinux).toBe(true) + }) + + it("should detect container environment via .dockerenv", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined) + execAsync.mockRejectedValue(new Error("No docker")) + + const env = await detectEnvironment() + + expect(env.isContainer).toBe(true) + }) + + it("should detect container environment via cgroup", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("No .dockerenv")) + vi.mocked(fs.readFile).mockResolvedValue("1:name=systemd:/docker/abc123") + execAsync.mockRejectedValue(new Error("No docker")) + + const env = await detectEnvironment() + + expect(env.isContainer).toBe(true) + }) + + it("should detect Docker availability", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("No .dockerenv")) + vi.mocked(fs.readFile).mockRejectedValue(new Error("No cgroup")) + execAsync.mockImplementation((cmd: string) => { + if (cmd === "docker --version") { + return Promise.resolve({ stdout: "Docker version 20.10.0", stderr: "" }) + } + if (cmd.includes("ldconfig")) { + return Promise.resolve({ stdout: "", stderr: "" }) + } + return Promise.reject(new Error("Command not found")) + }) + + const env = await detectEnvironment() + + expect(env.hasDocker).toBe(true) + }) + + it("should detect system Chrome", async () => { + vi.mocked(fs.access).mockImplementation((path) => { + if (path === "/usr/bin/google-chrome") { + return Promise.resolve(undefined) + } + return Promise.reject(new Error("Not found")) + }) + execAsync.mockRejectedValue(new Error("No docker")) + + const env = await detectEnvironment() + + expect(env.hasSystemChrome).toBe(true) + }) + + it("should detect missing dependencies on Linux", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("No .dockerenv")) + vi.mocked(fs.readFile).mockRejectedValue(new Error("No cgroup")) + execAsync.mockImplementation((cmd: string) => { + if (cmd.includes("ldconfig")) { + // Simulate missing libatk-1.0.so.0 + if (cmd.includes("libatk-1.0.so.0")) { + return Promise.reject(new Error("Not found")) + } + return Promise.resolve({ stdout: "library found", stderr: "" }) + } + return Promise.reject(new Error("Command not found")) + }) + + const env = await detectEnvironment() + + expect(env.missingDependencies).toContain("libatk-1.0.so.0") + }) + + it("should not check dependencies on non-Linux platforms", async () => { + Object.defineProperty(process, "platform", { + value: "darwin", + writable: true, + }) + vi.mocked(fs.access).mockRejectedValue(new Error("No .dockerenv")) + vi.mocked(fs.readFile).mockRejectedValue(new Error("No cgroup")) + execAsync.mockRejectedValue(new Error("No docker")) + + const env = await detectEnvironment() + + expect(env.isLinux).toBe(false) + expect(env.missingDependencies).toEqual([]) + }) + }) + + describe("getSystemChromePath", () => { + it("should return path to Google Chrome if found", async () => { + vi.mocked(fs.access).mockImplementation((path) => { + if (path === "/usr/bin/google-chrome") { + return Promise.resolve(undefined) + } + return Promise.reject(new Error("Not found")) + }) + + const path = await getSystemChromePath() + + expect(path).toBe("/usr/bin/google-chrome") + }) + + it("should return path to Chromium if Chrome not found", async () => { + vi.mocked(fs.access).mockImplementation((path) => { + if (path === "/usr/bin/chromium") { + return Promise.resolve(undefined) + } + return Promise.reject(new Error("Not found")) + }) + + const path = await getSystemChromePath() + + expect(path).toBe("/usr/bin/chromium") + }) + + it("should return null if no Chrome/Chromium found", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("Not found")) + + const path = await getSystemChromePath() + + expect(path).toBeNull() + }) + }) + + describe("installChromeDependencies", () => { + it("should skip installation on non-Linux", async () => { + Object.defineProperty(process, "platform", { + value: "darwin", + writable: true, + }) + // Mock execAsync for detectEnvironment calls + execAsync.mockRejectedValue(new Error("Command not found")) + + const mockContext = {} as vscode.ExtensionContext + + const result = await installChromeDependencies(mockContext) + + expect(result).toBe(true) + // execAsync may be called for environment detection, but not for installation + expect(execAsync).not.toHaveBeenCalledWith(expect.stringContaining("apt-get")) + expect(execAsync).not.toHaveBeenCalledWith(expect.stringContaining("sudo")) + }) + + it("should skip installation if no dependencies missing", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("No .dockerenv")) + vi.mocked(fs.readFile).mockRejectedValue(new Error("No cgroup")) + execAsync.mockImplementation((cmd: string) => { + if (cmd.includes("ldconfig")) { + return Promise.resolve({ stdout: "library found", stderr: "" }) + } + return Promise.reject(new Error("Command not found")) + }) + const mockContext = {} as vscode.ExtensionContext + + const result = await installChromeDependencies(mockContext) + + expect(result).toBe(true) + }) + + it("should show error if no sudo access", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("No .dockerenv")) + vi.mocked(fs.readFile).mockRejectedValue(new Error("No cgroup")) + execAsync.mockImplementation((cmd: string) => { + if (cmd === "sudo -n true") { + return Promise.reject(new Error("No sudo")) + } + if (cmd.includes("ldconfig")) { + if (cmd.includes("libatk-1.0.so.0")) { + return Promise.reject(new Error("Not found")) + } + return Promise.resolve({ stdout: "library found", stderr: "" }) + } + return Promise.reject(new Error("Command not found")) + }) + const mockContext = {} as vscode.ExtensionContext + + const result = await installChromeDependencies(mockContext) + + expect(result).toBe(false) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + it("should install dependencies with sudo access", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("No .dockerenv")) + vi.mocked(fs.readFile).mockRejectedValue(new Error("No cgroup")) + execAsync.mockImplementation((cmd: string) => { + if (cmd === "sudo -n true") { + return Promise.resolve({ stdout: "", stderr: "" }) + } + if (cmd.includes("ldconfig")) { + if (cmd.includes("libatk-1.0.so.0")) { + return Promise.reject(new Error("Not found")) + } + return Promise.resolve({ stdout: "library found", stderr: "" }) + } + if (cmd.includes("apt-get")) { + return Promise.resolve({ stdout: "", stderr: "" }) + } + if (cmd.includes("which google-chrome")) { + return Promise.reject(new Error("Not found")) + } + return Promise.resolve({ stdout: "", stderr: "" }) + }) + const mockContext = {} as vscode.ExtensionContext + + const result = await installChromeDependencies(mockContext) + + expect(result).toBe(true) + expect(execAsync).toHaveBeenCalledWith("sudo apt-get update") + expect(execAsync).toHaveBeenCalledWith(expect.stringContaining("sudo apt-get install -y")) + expect(vscode.window.showInformationMessage).toHaveBeenCalled() + }) + }) + + describe("getDockerBrowserConfig", () => { + it("should return default configuration", () => { + const mockContext = {} as vscode.ExtensionContext + + const config = getDockerBrowserConfig(mockContext) + + expect(config).toEqual({ + enabled: false, + image: "browserless/chrome:latest", + autoStart: true, + }) + }) + + it("should return custom configuration from settings", () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key, defaultValue) => { + if (key === "browserDocker.enabled") return true + if (key === "browserDocker.image") return "custom/chrome:v1" + if (key === "browserDocker.autoStart") return false + return defaultValue + }), + } as any) + const mockContext = {} as vscode.ExtensionContext + + const config = getDockerBrowserConfig(mockContext) + + expect(config).toEqual({ + enabled: true, + image: "custom/chrome:v1", + autoStart: false, + }) + }) + }) + + describe("startDockerBrowser", () => { + it("should start new Docker container", async () => { + execAsync.mockImplementation((cmd: string) => { + if (cmd.includes("docker ps -a")) { + return Promise.resolve({ stdout: "", stderr: "" }) + } + if (cmd.includes("docker run")) { + return Promise.resolve({ stdout: "", stderr: "" }) + } + return Promise.resolve({ stdout: "", stderr: "" }) + }) + + const endpoint = await startDockerBrowser({ + enabled: true, + image: "browserless/chrome:latest", + autoStart: true, + }) + + expect(endpoint).toBe("ws://localhost:3000") + expect(execAsync).toHaveBeenCalledWith(expect.stringContaining("docker run")) + }) + + it("should start existing Docker container", async () => { + execAsync.mockImplementation((cmd: string) => { + if (cmd.includes("docker ps -a")) { + return Promise.resolve({ stdout: "roo-browser", stderr: "" }) + } + if (cmd.includes("docker start")) { + return Promise.resolve({ stdout: "", stderr: "" }) + } + return Promise.resolve({ stdout: "", stderr: "" }) + }) + + const endpoint = await startDockerBrowser({ + enabled: true, + image: "browserless/chrome:latest", + autoStart: true, + }) + + expect(endpoint).toBe("ws://localhost:3000") + expect(execAsync).toHaveBeenCalledWith("docker start roo-browser") + expect(execAsync).not.toHaveBeenCalledWith(expect.stringContaining("docker run")) + }) + + it("should return null on error", async () => { + execAsync.mockRejectedValue(new Error("Docker error")) + + const endpoint = await startDockerBrowser({ + enabled: true, + image: "browserless/chrome:latest", + autoStart: true, + }) + + expect(endpoint).toBeNull() + }) + }) + + describe("stopDockerBrowser", () => { + it("should stop Docker container", async () => { + execAsync.mockResolvedValue({ stdout: "", stderr: "" }) + + await stopDockerBrowser() + + expect(execAsync).toHaveBeenCalledWith("docker stop roo-browser") + }) + + it("should not throw on error", async () => { + execAsync.mockRejectedValue(new Error("Container not running")) + + await expect(stopDockerBrowser()).resolves.not.toThrow() + }) + }) +}) diff --git a/src/services/browser/browserEnvironment.ts b/src/services/browser/browserEnvironment.ts new file mode 100644 index 0000000000..479bb6d97c --- /dev/null +++ b/src/services/browser/browserEnvironment.ts @@ -0,0 +1,334 @@ +import * as vscode from "vscode" +import { exec } from "child_process" +import { promisify } from "util" +import * as fs from "fs/promises" +import * as path from "path" + +const execAsync = promisify(exec) + +/** + * Detects the current environment and its characteristics + */ +export interface EnvironmentInfo { + isCodespaces: boolean + isContainer: boolean + isLinux: boolean + hasDocker: boolean + hasSystemChrome: boolean + missingDependencies: string[] +} + +/** + * Chrome/Chromium dependencies required for headless operation + */ +const CHROME_DEPENDENCIES = [ + "libatk-1.0.so.0", + "libatk-bridge-2.0.so.0", + "libcups.so.2", + "libdrm.so.2", + "libxkbcommon.so.0", + "libxcomposite.so.1", + "libxdamage.so.1", + "libxfixes.so.3", + "libxrandr.so.2", + "libgbm.so.1", + "libasound.so.2", + "libatspi.so.0", + "libgtk-3.so.0", + "libpango-1.0.so.0", + "libcairo.so.2", + "libxshmfence.so.1", + "libnss3.so", + "libnssutil3.so", + "libnspr4.so", +] + +/** + * Package names for installing Chrome dependencies + */ +const DEPENDENCY_PACKAGES = [ + "libatk1.0-0", + "libatk-bridge2.0-0", + "libcups2", + "libdrm2", + "libxkbcommon0", + "libxcomposite1", + "libxdamage1", + "libxfixes3", + "libxrandr2", + "libgbm1", + "libasound2", + "libatspi2.0-0", + "libgtk-3-0", + "libpango-1.0-0", + "libcairo2", + "libxshmfence1", + "libnss3", + "libnssutil3", + "libnspr4", + "libx11-xcb1", + "libxcb-dri3-0", +] + +/** + * Detects the current environment + */ +export async function detectEnvironment(): Promise { + const isCodespaces = process.env.CODESPACES === "true" + const isContainer = await checkIfContainer() + const isLinux = process.platform === "linux" + const hasDocker = await checkDockerAvailable() + const hasSystemChrome = await checkSystemChrome() + const missingDependencies = isLinux ? await checkMissingDependencies() : [] + + return { + isCodespaces, + isContainer, + isLinux, + hasDocker, + hasSystemChrome, + missingDependencies, + } +} + +/** + * Checks if running inside a container + */ +async function checkIfContainer(): Promise { + try { + // Check for .dockerenv file + await fs.access("/.dockerenv") + return true + } catch { + // Check for container indicators in cgroup + try { + const cgroup = await fs.readFile("/proc/1/cgroup", "utf-8") + return cgroup.includes("docker") || cgroup.includes("containerd") || cgroup.includes("kubepods") + } catch { + return false + } + } +} + +/** + * Checks if Docker is available + */ +async function checkDockerAvailable(): Promise { + try { + const { stdout } = await execAsync("docker --version") + return stdout.includes("Docker") + } catch { + return false + } +} + +/** + * Checks if system Chrome/Chromium is available + */ +async function checkSystemChrome(): Promise { + const chromePaths = [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + ] + + for (const chromePath of chromePaths) { + try { + await fs.access(chromePath) + return true + } catch { + // Continue checking other paths + } + } + + // Also check via which command + try { + await execAsync("which google-chrome || which chromium || which chromium-browser") + return true + } catch { + return false + } +} + +/** + * Checks for missing Chrome dependencies + */ +async function checkMissingDependencies(): Promise { + const missing: string[] = [] + + for (const dep of CHROME_DEPENDENCIES) { + try { + // Try to find the library using ldconfig + const { stdout } = await execAsync(`ldconfig -p | grep ${dep}`) + if (!stdout) { + missing.push(dep) + } + } catch { + missing.push(dep) + } + } + + return missing +} + +/** + * Attempts to install missing Chrome dependencies + */ +export async function installChromeDependencies(context: vscode.ExtensionContext): Promise { + const env = await detectEnvironment() + + if (!env.isLinux || env.missingDependencies.length === 0) { + return true + } + + // Check if we have sudo access + try { + await execAsync("sudo -n true") + } catch { + vscode.window.showErrorMessage( + "Chrome dependencies are missing but sudo access is required to install them. " + + "Please run: sudo apt-get update && sudo apt-get install -y " + + DEPENDENCY_PACKAGES.join(" "), + ) + return false + } + + // Show progress notification + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Installing Chrome dependencies...", + cancellable: false, + }, + async (progress) => { + try { + progress.report({ message: "Updating package lists..." }) + await execAsync("sudo apt-get update") + + progress.report({ message: "Installing dependencies..." }) + const packages = DEPENDENCY_PACKAGES.join(" ") + await execAsync(`sudo apt-get install -y ${packages}`) + + // Install Chrome if not present + if (!env.hasSystemChrome) { + progress.report({ message: "Installing Google Chrome..." }) + await installChrome() + } + + vscode.window.showInformationMessage("Chrome dependencies installed successfully!") + return true + } catch (error) { + vscode.window.showErrorMessage( + `Failed to install Chrome dependencies: ${error.message}. ` + + "Please install them manually with: sudo apt-get install -y " + + DEPENDENCY_PACKAGES.join(" "), + ) + return false + } + }, + ) +} + +/** + * Installs Google Chrome + */ +async function installChrome(): Promise { + const commands = [ + "wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -", + "sudo sh -c 'echo \"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google.list'", + "sudo apt-get update", + "sudo apt-get install -y google-chrome-stable", + ] + + for (const cmd of commands) { + await execAsync(cmd) + } +} + +/** + * Gets the path to system Chrome/Chromium if available + */ +export async function getSystemChromePath(): Promise { + const chromePaths = [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + ] + + for (const chromePath of chromePaths) { + try { + await fs.access(chromePath) + return chromePath + } catch { + // Continue checking other paths + } + } + + return null +} + +/** + * Configuration for Docker-based browser + */ +export interface DockerBrowserConfig { + enabled: boolean + image: string + autoStart: boolean +} + +/** + * Gets Docker browser configuration from settings + */ +export function getDockerBrowserConfig(context: vscode.ExtensionContext): DockerBrowserConfig { + const config = vscode.workspace.getConfiguration("roo-code") + + return { + enabled: config.get("browserDocker.enabled", false), + image: config.get("browserDocker.image", "browserless/chrome:latest"), + autoStart: config.get("browserDocker.autoStart", true), + } +} + +/** + * Starts a Docker container for browser operations + */ +export async function startDockerBrowser(config: DockerBrowserConfig): Promise { + try { + // Check if container already exists + const { stdout: existingContainer } = await execAsync( + "docker ps -a --filter name=roo-browser --format '{{.Names}}'", + ) + + if (existingContainer.includes("roo-browser")) { + // Start existing container + await execAsync("docker start roo-browser") + } else { + // Create and start new container + await execAsync(`docker run -d --name roo-browser -p 3000:3000 --rm ${config.image}`) + } + + // Wait for container to be ready + await new Promise((resolve) => setTimeout(resolve, 3000)) + + // Return the WebSocket endpoint + return "ws://localhost:3000" + } catch (error) { + console.error("Failed to start Docker browser:", error) + return null + } +} + +/** + * Stops the Docker browser container + */ +export async function stopDockerBrowser(): Promise { + try { + await execAsync("docker stop roo-browser") + } catch { + // Container might not be running + } +}