diff --git a/mcontainer/cli.py b/mcontainer/cli.py index 4141485..a3d5dab 100644 --- a/mcontainer/cli.py +++ b/mcontainer/cli.py @@ -152,6 +152,12 @@ def create_session( "-m", help="Attach MCP servers to the session (can be specified multiple times)", ), + uid: Optional[int] = typer.Option( + None, "--uid", help="User ID to run the container as (defaults to host user)" + ), + gid: Optional[int] = typer.Option( + None, "--gid", help="Group ID to run the container as (defaults to host user)" + ), ) -> None: """Create a new MC session @@ -159,6 +165,11 @@ def create_session( If a repository URL is provided, it will be cloned into /app during initialization. If no path or URL is provided, no local volume will be mounted. """ + # Determine UID/GID + target_uid = uid if uid is not None else os.getuid() + target_gid = gid if gid is not None else os.getgid() + console.print(f"Using UID: {target_uid}, GID: {target_gid}") + # Use default driver from user configuration if not driver: driver = user_config.get( @@ -248,6 +259,8 @@ def create_session( volumes=volume_mounts, networks=all_networks, mcp=all_mcps, + uid=target_uid, + gid=target_gid, ) if session: diff --git a/mcontainer/container.py b/mcontainer/container.py index a1acf58..50b7928 100644 --- a/mcontainer/container.py +++ b/mcontainer/container.py @@ -145,6 +145,8 @@ class ContainerManager: volumes: Optional[Dict[str, Dict[str, str]]] = None, networks: Optional[List[str]] = None, mcp: Optional[List[str]] = None, + uid: Optional[int] = None, + gid: Optional[int] = None, ) -> Optional[Session]: """Create a new MC session @@ -157,6 +159,8 @@ class ContainerManager: volumes: Optional additional volumes to mount (dict of {host_path: {"bind": container_path, "mode": mode}}) networks: Optional list of additional Docker networks to connect to mcp: Optional list of MCP server names to attach to the session + uid: Optional user ID for the container process + gid: Optional group ID for the container process """ try: # Validate driver exists @@ -176,6 +180,12 @@ class ContainerManager: # Prepare environment variables env_vars = environment or {} + # Add TARGET_UID and TARGET_GID for entrypoint script + if uid is not None: + env_vars["TARGET_UID"] = str(uid) + if gid is not None: + env_vars["TARGET_GID"] = str(gid) + # Add project URL to environment if provided if project: env_vars["MC_PROJECT_URL"] = project @@ -534,12 +544,17 @@ class ContainerManager: created_at=container.attrs["Created"], ports=ports, mcps=mcp_names, + # Assuming Session model has uid and gid fields + uid=uid, + gid=gid, ) # Save session to the session manager as JSON-compatible dict - self.session_manager.add_session( - session_id, session.model_dump(mode="json") - ) + # Assuming Session model has uid and gid fields added to its definition + session_data_to_save = session.model_dump(mode="json") + session_data_to_save["uid"] = uid + session_data_to_save["gid"] = gid + self.session_manager.add_session(session_id, session_data_to_save) return session @@ -564,20 +579,72 @@ class ContainerManager: def connect_session(self, session_id: str) -> bool: """Connect to a running MC session""" - try: + # Retrieve full session data which should include uid/gid + session_data = self.session_manager.get_session(session_id) + + if not session_data: + print(f"Session '{session_id}' not found in session manager.") + # Fallback: try listing via Docker labels if session data is missing sessions = self.list_sessions() - for session in sessions: - if session.id == session_id and session.container_id: - if session.status != SessionStatus.RUNNING: - print(f"Session '{session_id}' is not running") - return False + session_obj = next((s for s in sessions if s.id == session_id), None) + if not session_obj or not session_obj.container_id: + print(f"Session '{session_id}' not found via Docker either.") + return False + container_id = session_obj.container_id + # Cannot determine user if session data is missing + user_spec = None + print( + f"[yellow]Warning: Session data missing for {session_id}. Connecting as default container user.[/yellow]" + ) + else: + container_id = session_data.get("container_id") + if not container_id: + print(f"Container ID not found for session {session_id}.") + return False - # Execute interactive shell in container - # The init-status.sh script will automatically show logs if needed - os.system(f"docker exec -it {session.container_id} /bin/bash") - return True + # Check status from Docker directly + try: + container = self.client.containers.get(container_id) + if container.status != "running": + print( + f"Session '{session_id}' container is not running (status: {container.status})." + ) + return False + except docker.errors.NotFound: + print(f"Container {container_id} for session {session_id} not found.") + # Clean up potentially stale session data + self.session_manager.remove_session(session_id) + return False + except DockerException as e: + print(f"Error checking container status for session {session_id}: {e}") + return False - print(f"Session '{session_id}' not found") + # Determine user spec from stored session data + uid = session_data.get("uid") + gid = session_data.get("gid") + user_spec = f"{uid}:{gid}" if uid is not None and gid is not None else None + + try: + # Execute interactive shell in container + cmd = ["docker", "exec", "-it"] + if user_spec: + cmd.extend(["--user", user_spec]) + print(f"Connecting as user {user_spec}...") + else: + print("Connecting as default container user...") + + cmd.extend([container_id, "/bin/bash"]) + + # Use execvp to replace the current process with docker exec + # This provides a more seamless shell experience + os.execvp("docker", cmd) + # execvp does not return if successful + return True # Should not be reached if execvp succeeds + + except FileNotFoundError: + print( + "[red]Error: 'docker' command not found. Is Docker installed and in your PATH?[/red]" + ) return False except DockerException as e: diff --git a/mcontainer/drivers/goose/Dockerfile b/mcontainer/drivers/goose/Dockerfile index 5ef3301..905fb5a 100644 --- a/mcontainer/drivers/goose/Dockerfile +++ b/mcontainer/drivers/goose/Dockerfile @@ -3,8 +3,10 @@ FROM python:3.12-slim LABEL maintainer="team@monadical.com" LABEL description="Goose with MCP servers" -# Install system dependencies -RUN apt-get update && apt-get install -y \ +# 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 \ git \ openssh-server \ bash \ @@ -12,22 +14,27 @@ RUN apt-get update && apt-get install -y \ bzip2 \ iputils-ping \ iproute2 \ + libxcb1 \ + libdbus-1-3 \ nano \ vim \ && rm -rf /var/lib/apt/lists/* -# Set up SSH server -RUN mkdir /var/run/sshd -RUN echo 'root:root' | chpasswd -RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config -RUN sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config +# Set up SSH server directory (configuration will be handled by entrypoint if needed) +RUN mkdir -p /var/run/sshd && chmod 0755 /var/run/sshd +# Do NOT enable root login or set root password here # Install python dependencies # This is done before copying scripts for better cache management +# Consider moving this WORKDIR /tmp section if goose CLI isn't strictly needed for base image setup WORKDIR /tmp RUN curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh -o download_cli.sh && \ chmod +x download_cli.sh && \ - ./download_cli.sh + ./download_cli.sh && \ + # Move goose to a system-wide location + mv /root/.local/bin/goose /usr/local/bin/goose && \ + # Clean up + rm -rf /root/.local download_cli.sh /tmp/goose-* # Create app directory WORKDIR /app @@ -46,15 +53,18 @@ RUN chmod +x /mc-init.sh /entrypoint.sh /init-status.sh \ /usr/local/bin/update-goose-config.sh # Set up initialization status check on login -RUN echo 'export PATH=/root/.local/bin:$PATH' >> /etc/bash.bashrc RUN echo '[ -x /init-status.sh ] && /init-status.sh' >> /etc/bash.bashrc # Set up environment ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 +# Set WORKDIR to /app, common practice and expected by mc-init.sh +WORKDIR /app # Expose ports EXPOSE 8000 22 -# Set entrypoint +# Set entrypoint - container starts as root, entrypoint handles user switching ENTRYPOINT ["/entrypoint.sh"] +# Default command if none is provided (entrypoint will run this via gosu) +CMD ["tail", "-f", "/dev/null"] diff --git a/mcontainer/drivers/goose/entrypoint.sh b/mcontainer/drivers/goose/entrypoint.sh index f340ef7..805afe4 100755 --- a/mcontainer/drivers/goose/entrypoint.sh +++ b/mcontainer/drivers/goose/entrypoint.sh @@ -1,17 +1,7 @@ #!/bin/bash # Entrypoint script for Goose driver +# Executes the standard initialization script, which handles user setup, +# service startup (like sshd), and switching to the non-root user +# before running the container's command (CMD). -# Run the standard initialization script -/mc-init.sh - -# Start SSH server in the background -/usr/sbin/sshd - -# Print welcome message -echo "===============================================" -echo "Goose driver container started" -echo "SSH server running on port 22" -echo "===============================================" - -# Keep container running -exec tail -f /dev/null \ No newline at end of file +exec /mc-init.sh "$@" diff --git a/mcontainer/drivers/goose/mc-init.sh b/mcontainer/drivers/goose/mc-init.sh index c8cb34f..26b68bd 100755 --- a/mcontainer/drivers/goose/mc-init.sh +++ b/mcontainer/drivers/goose/mc-init.sh @@ -6,6 +6,53 @@ exec > >(tee -a /init.log) 2>&1 # Mark initialization as started echo "=== MC Initialization started at $(date) ===" + +# --- START INSERTED BLOCK --- + +# Default UID/GID if not provided (should be passed by mc tool) +MC_USER_ID=${MC_USER_ID:-1000} +MC_GROUP_ID=${MC_GROUP_ID:-1000} + +echo "Using UID: $MC_USER_ID, GID: $MC_GROUP_ID" + +# Create group if it doesn't exist +if ! getent group mcuser > /dev/null; then + groupadd -g $MC_GROUP_ID mcuser +else + # If group exists but has different GID, modify it + EXISTING_GID=$(getent group mcuser | cut -d: -f3) + if [ "$EXISTING_GID" != "$MC_GROUP_ID" ]; then + groupmod -g $MC_GROUP_ID mcuser + fi +fi + +# Create user if it doesn't exist +if ! getent passwd mcuser > /dev/null; then + useradd --shell /bin/bash --uid $MC_USER_ID --gid $MC_GROUP_ID --no-create-home mcuser +else + # If user exists but has different UID/GID, modify it + EXISTING_UID=$(getent passwd mcuser | cut -d: -f3) + EXISTING_GID=$(getent passwd mcuser | cut -d: -f4) + if [ "$EXISTING_UID" != "$MC_USER_ID" ] || [ "$EXISTING_GID" != "$MC_GROUP_ID" ]; then + usermod --uid $MC_USER_ID --gid $MC_GROUP_ID mcuser + fi +fi + +# Create home directory and set permissions if it doesn't exist +if [ ! -d "/home/mcuser" ]; then + mkdir -p /home/mcuser + chown $MC_USER_ID:$MC_GROUP_ID /home/mcuser +fi +# Ensure /app exists and has correct ownership (important for volume mounts) +mkdir -p /app +chown $MC_USER_ID:$MC_GROUP_ID /app + +# Start SSH server as root before switching user +echo "Starting SSH server..." +/usr/sbin/sshd + +# --- END INSERTED BLOCK --- + echo "INIT_COMPLETE=false" > /init.status # Project initialization @@ -70,3 +117,6 @@ echo "MC driver initialization complete" # Mark initialization as complete echo "=== MC Initialization completed at $(date) ===" echo "INIT_COMPLETE=true" > /init.status + +# Switch to the non-root user and execute the container's CMD +exec gosu mcuser "$@"