feat: add automatic Chrome dependency installation and Docker browser support for Codespaces

- Implement automatic detection and installation of Chrome dependencies
- Add optional Docker-based browser isolation with configurable settings
- Create comprehensive devcontainer configuration for GitHub Codespaces
- Add environment detection for Codespaces, containers, and Linux systems
- Implement fallback mechanisms: system Chrome -> Docker -> downloaded Chrome
- Add new VS Code settings for Docker browser configuration
- Include post-create and post-start scripts for Codespaces setup
- Add comprehensive tests for browser environment functionality

Fixes #7990
This commit is contained in:
Roo Code 2025-09-15 16:11:27 +00:00
parent 9d33c109c9
commit dbe8a5f13d
11 changed files with 1263 additions and 12 deletions

133
.devcontainer/README.md Normal file
View file

@ -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

View file

@ -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"
}
}

View file

@ -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!"

View file

@ -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!"

View file

@ -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%"
}
}
}

View file

@ -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"
}

View file

@ -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<void> {
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 {}
}

View file

@ -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()

View file

@ -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: {

View file

@ -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()
})
})
})

View file

@ -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<EnvironmentInfo> {
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<boolean> {
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<boolean> {
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<boolean> {
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<string[]> {
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<boolean> {
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<void> {
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<string | null> {
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<boolean>("browserDocker.enabled", false),
image: config.get<string>("browserDocker.image", "browserless/chrome:latest"),
autoStart: config.get<boolean>("browserDocker.autoStart", true),
}
}
/**
* Starts a Docker container for browser operations
*/
export async function startDockerBrowser(config: DockerBrowserConfig): Promise<string | null> {
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<void> {
try {
await execAsync("docker stop roo-browser")
} catch {
// Container might not be running
}
}