From 5fca51e5152dcf7503781eb707fa04414cf33c05 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Fri, 20 Jun 2025 02:09:12 +0200 Subject: [PATCH] feat: include new image opencode (#14) * feat: include new image opencode * docs: update readme --- README.md | 7 +- cubbi/images/opencode/Dockerfile | 64 ++++++ cubbi/images/opencode/README.md | 55 +++++ cubbi/images/opencode/cubbi_image.yaml | 18 ++ cubbi/images/opencode/opencode_plugin.py | 255 +++++++++++++++++++++++ 5 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 cubbi/images/opencode/Dockerfile create mode 100644 cubbi/images/opencode/README.md create mode 100644 cubbi/images/opencode/cubbi_image.yaml create mode 100644 cubbi/images/opencode/opencode_plugin.py diff --git a/README.md b/README.md index 3fc8286..e685494 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Then compile your first image: ```bash cubbi image build goose +cubbi image build opencode ``` ### For Developers @@ -81,6 +82,7 @@ cubbi session close SESSION_ID # Create a session with a specific image cubbix --image goose +cubbix --image opencode # Create a session with environment variables cubbix -e VAR1=value1 -e VAR2=value2 @@ -131,12 +133,11 @@ cubbi image list # Get detailed information about an image cubbi image info goose +cubbi image info opencode # Build an image cubbi image build goose - -# Build and push an image -cubbi image build goose --push +cubbi image build opencode ``` Images are defined in the `cubbi/images/` directory, with each subdirectory containing: diff --git a/cubbi/images/opencode/Dockerfile b/cubbi/images/opencode/Dockerfile new file mode 100644 index 0000000..c3bb379 --- /dev/null +++ b/cubbi/images/opencode/Dockerfile @@ -0,0 +1,64 @@ +FROM python:3.12-slim + +LABEL maintainer="team@monadical.com" +LABEL description="Goose with MCP servers for Cubbi" + +# Install system dependencies including gosu for user switching and shadow for useradd/groupadd +RUN apt-get update && apt-get install -y --no-install-recommends \ + gosu \ + passwd \ + bash \ + curl \ + bzip2 \ + iputils-ping \ + iproute2 \ + libxcb1 \ + libdbus-1-3 \ + nano \ + tmux \ + git-core \ + vim \ + && rm -rf /var/lib/apt/lists/* + +# Install deps +WORKDIR /tmp +RUN curl -fsSL https://astral.sh/uv/install.sh -o install.sh && \ + sh install.sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx && \ + rm install.sh + +# Install opencode-ai +RUN mkdir -p /opt/node && \ + curl -fsSL https://nodejs.org/dist/v22.16.0/node-v22.16.0-linux-x64.tar.gz -o node.tar.gz && \ + tar -xf node.tar.gz -C /opt/node --strip-components=1 && \ + rm node.tar.gz + +ENV PATH="/opt/node/bin:$PATH" +RUN npm i -g opencode-ai + +# Create app directory +WORKDIR /app + +# Copy initialization system +COPY cubbi_init.py /cubbi/cubbi_init.py +COPY opencode_plugin.py /cubbi/opencode_plugin.py +COPY cubbi_image.yaml /cubbi/cubbi_image.yaml +COPY init-status.sh /cubbi/init-status.sh +RUN chmod +x /cubbi/cubbi_init.py /cubbi/init-status.sh +RUN echo 'PATH="/opt/node/bin:$PATH"' >> /etc/bash.bashrc +RUN echo '[ -x /cubbi/init-status.sh ] && /cubbi/init-status.sh' >> /etc/bash.bashrc + +# Set up environment +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV UV_LINK_MODE=copy + +# Pre-install the cubbi_init +RUN /cubbi/cubbi_init.py --help + +# Set WORKDIR to /app, common practice and expected by cubbi-init.sh +WORKDIR /app + +ENTRYPOINT ["/cubbi/cubbi_init.py"] +CMD ["tail", "-f", "/dev/null"] diff --git a/cubbi/images/opencode/README.md b/cubbi/images/opencode/README.md new file mode 100644 index 0000000..10ec1a2 --- /dev/null +++ b/cubbi/images/opencode/README.md @@ -0,0 +1,55 @@ +# Opencode Image for Cubbi + +This image provides a containerized environment for running [Opencode](https://opencode.ai). + +## Features + +- Pre-configured environment for Opencode AI +- Langfuse logging support + +## Environment Variables + +### Opencode Configuration + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `CUBBI_MODEL` | Model to use with Opencode | No | - | +| `CUBBI_PROVIDER` | Provider to use with Opencode | No | - | + +### Cubbi Core Variables + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `CUBBI_USER_ID` | UID for the container user | No | `1000` | +| `CUBBI_GROUP_ID` | GID for the container user | No | `1000` | +| `CUBBI_RUN_COMMAND` | Command to execute after initialization | No | - | +| `CUBBI_NO_SHELL` | Exit after command execution | No | `false` | +| `CUBBI_CONFIG_DIR` | Directory for persistent configurations | No | `/cubbi-config` | +| `CUBBI_PERSISTENT_LINKS` | Semicolon-separated list of source:target symlinks | No | - | + +### MCP Integration Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `MCP_COUNT` | Number of available MCP servers | No | +| `MCP_NAMES` | JSON array of MCP server names | No | +| `MCP_{idx}_NAME` | Name of MCP server at index | No | +| `MCP_{idx}_TYPE` | Type of MCP server | No | +| `MCP_{idx}_HOST` | Hostname of MCP server | No | +| `MCP_{idx}_URL` | Full URL for remote MCP servers | No | + +## Build + +To build this image: + +```bash +cd drivers/opencode +docker build -t monadical/cubbi-opencode:latest . +``` + +## Usage + +```bash +# Create a new session with this image +cubbix -i opencode +``` diff --git a/cubbi/images/opencode/cubbi_image.yaml b/cubbi/images/opencode/cubbi_image.yaml new file mode 100644 index 0000000..63331fa --- /dev/null +++ b/cubbi/images/opencode/cubbi_image.yaml @@ -0,0 +1,18 @@ +name: opencode +description: Opencode AI environment +version: 1.0.0 +maintainer: team@monadical.com +image: monadical/cubbi-opencode:latest + +init: + pre_command: /cubbi-init.sh + command: /entrypoint.sh + +environment: [] +ports: [] + +volumes: + - mountPath: /app + description: Application directory + +persistent_configs: [] diff --git a/cubbi/images/opencode/opencode_plugin.py b/cubbi/images/opencode/opencode_plugin.py new file mode 100644 index 0000000..39f83f1 --- /dev/null +++ b/cubbi/images/opencode/opencode_plugin.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +Opencode-specific plugin for Cubbi initialization +""" + +import json +import os +from pathlib import Path +from typing import Any, Dict + +from cubbi_init import ToolPlugin + +# Map of environment variables to provider names in auth.json +API_KEY_MAPPINGS = { + "ANTHROPIC_API_KEY": "anthropic", + "GOOGLE_API_KEY": "google", + "OPENAI_API_KEY": "openai", + "OPENROUTER_API_KEY": "openrouter", +} + + +class OpencodePlugin(ToolPlugin): + """Plugin for Opencode AI tool initialization""" + + @property + def tool_name(self) -> str: + return "opencode" + + def _get_user_ids(self) -> tuple[int, int]: + """Get the cubbi user and group IDs from environment""" + user_id = int(os.environ.get("CUBBI_USER_ID", "1000")) + group_id = int(os.environ.get("CUBBI_GROUP_ID", "1000")) + return user_id, group_id + + def _set_ownership(self, path: Path) -> None: + """Set ownership of a path to the cubbi user""" + user_id, group_id = self._get_user_ids() + try: + os.chown(path, user_id, group_id) + except OSError as e: + self.status.log(f"Failed to set ownership for {path}: {e}", "WARNING") + + def _get_user_config_path(self) -> Path: + """Get the correct config path for the cubbi user""" + return Path("/home/cubbi/.config/opencode") + + def _get_user_data_path(self) -> Path: + """Get the correct data path for the cubbi user""" + return Path("/home/cubbi/.local/share/opencode") + + def _ensure_user_config_dir(self) -> Path: + """Ensure config directory exists with correct ownership""" + config_dir = self._get_user_config_path() + + # Create the full directory path + try: + config_dir.mkdir(parents=True, exist_ok=True) + except FileExistsError: + # Directory already exists, which is fine + pass + except OSError as e: + self.status.log( + f"Failed to create config directory {config_dir}: {e}", "ERROR" + ) + return config_dir + + # Set ownership for the directories + config_parent = config_dir.parent + if config_parent.exists(): + self._set_ownership(config_parent) + + if config_dir.exists(): + self._set_ownership(config_dir) + + return config_dir + + def _ensure_user_data_dir(self) -> Path: + """Ensure data directory exists with correct ownership""" + data_dir = self._get_user_data_path() + + # Create the full directory path + try: + data_dir.mkdir(parents=True, exist_ok=True) + except FileExistsError: + # Directory already exists, which is fine + pass + except OSError as e: + self.status.log(f"Failed to create data directory {data_dir}: {e}", "ERROR") + return data_dir + + # Set ownership for the directories + data_parent = data_dir.parent + if data_parent.exists(): + self._set_ownership(data_parent) + + if data_dir.exists(): + self._set_ownership(data_dir) + + return data_dir + + def _create_auth_file(self) -> bool: + """Create auth.json file with configured API keys""" + # Ensure data directory exists + data_dir = self._ensure_user_data_dir() + if not data_dir.exists(): + self.status.log( + f"Data directory {data_dir} does not exist and could not be created", + "ERROR", + ) + return False + + auth_file = data_dir / "auth.json" + auth_data = {} + + # Check each API key and add to auth data if present + for env_var, provider in API_KEY_MAPPINGS.items(): + api_key = os.environ.get(env_var) + if api_key: + auth_data[provider] = {"type": "api", "key": api_key} + self.status.log(f"Added {provider} API key to auth configuration") + + # Only write file if we have at least one API key + if not auth_data: + self.status.log("No API keys found, skipping auth.json creation") + return True + + try: + with auth_file.open("w") as f: + json.dump(auth_data, f, indent=2) + + # Set ownership of the auth file to cubbi user + self._set_ownership(auth_file) + + # Set secure permissions (readable only by owner) + auth_file.chmod(0o600) + + self.status.log(f"Created OpenCode auth configuration at {auth_file}") + return True + except Exception as e: + self.status.log(f"Failed to create auth configuration: {e}", "ERROR") + return False + + def initialize(self) -> bool: + """Initialize Opencode configuration""" + self._ensure_user_config_dir() + + # Create auth.json file with API keys + auth_success = self._create_auth_file() + + # Set up tool configuration + config_success = self.setup_tool_configuration() + + return auth_success and config_success + + def setup_tool_configuration(self) -> bool: + """Set up Opencode configuration file""" + # Ensure directory exists before writing + config_dir = self._ensure_user_config_dir() + if not config_dir.exists(): + self.status.log( + f"Config directory {config_dir} does not exist and could not be created", + "ERROR", + ) + return False + + config_file = config_dir / "config.json" + + # Load or initialize configuration + if config_file.exists(): + with config_file.open("r") as f: + config_data = json.load(f) or {} + else: + config_data = {} + + # Update with environment variables + opencode_model = os.environ.get("CUBBI_MODEL") + opencode_provider = os.environ.get("CUBBI_PROVIDER") + + if opencode_model and opencode_provider: + config_data["model"] = f"{opencode_provider}/{opencode_model}" + self.status.log(f"Set model to {config_data['model']}") + + try: + with config_file.open("w") as f: + json.dump(config_data, f, indent=2) + + # Set ownership of the config file to cubbi user + self._set_ownership(config_file) + + self.status.log(f"Updated Opencode configuration at {config_file}") + return True + except Exception as e: + self.status.log(f"Failed to write Opencode configuration: {e}", "ERROR") + return False + + def integrate_mcp_servers(self, mcp_config: Dict[str, Any]) -> bool: + """Integrate Opencode with available MCP servers""" + if mcp_config["count"] == 0: + self.status.log("No MCP servers to integrate") + return True + + # Ensure directory exists before writing + config_dir = self._ensure_user_config_dir() + if not config_dir.exists(): + self.status.log( + f"Config directory {config_dir} does not exist and could not be created", + "ERROR", + ) + return False + + config_file = config_dir / "config.json" + + if config_file.exists(): + with config_file.open("r") as f: + config_data = json.load(f) or {} + else: + config_data = {} + + if "mcp" not in config_data: + config_data["mcp"] = {} + + for server in mcp_config["servers"]: + server_name = server["name"] + server_host = server.get("host") + server_url = server.get("url") + + if server_name and server_host: + mcp_url = f"http://{server_host}:8080/sse" + self.status.log(f"Adding MCP extension: {server_name} - {mcp_url}") + + config_data["mcp"][server_name] = { + "type": "remote", + "url": mcp_url, + } + elif server_name and server_url: + self.status.log( + f"Adding remote MCP extension: {server_name} - {server_url}" + ) + + config_data["mcp"][server_name] = { + "type": "remote", + "url": server_url, + } + + try: + with config_file.open("w") as f: + json.dump(config_data, f, indent=2) + + # Set ownership of the config file to cubbi user + self._set_ownership(config_file) + + return True + except Exception as e: + self.status.log(f"Failed to integrate MCP servers: {e}", "ERROR") + return False