diff --git a/mcontainer/cli.py b/mcontainer/cli.py index a3d5dab..b31b131 100644 --- a/mcontainer/cli.py +++ b/mcontainer/cli.py @@ -143,6 +143,11 @@ def create_session( [], "--network", "-N", help="Connect to additional Docker networks" ), name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"), + run_command: Optional[str] = typer.Option( + None, + "--run", + help="Command to execute inside the container before starting the shell", + ), no_connect: bool = typer.Option( False, "--no-connect", help="Don't automatically connect to the session" ), @@ -259,6 +264,7 @@ def create_session( volumes=volume_mounts, networks=all_networks, mcp=all_mcps, + run_command=run_command, uid=target_uid, gid=target_gid, ) @@ -275,12 +281,22 @@ def create_session( # Auto-connect based on user config, unless overridden by --no-connect flag auto_connect = user_config.get("defaults.connect", True) - if not no_connect and auto_connect: + # Connect if auto_connect is enabled and --no-connect wasn't used. + # The --run command no longer prevents connection. + should_connect = not no_connect and auto_connect + if should_connect: container_manager.connect_session(session.id) else: - console.print( - f"\nConnect to the session with:\n mc session connect {session.id}" - ) + # Explain why connection was skipped + if no_connect: + console.print("\nConnection skipped due to --no-connect.") + console.print( + f"Connect manually with:\n mc session connect {session.id}" + ) + elif not auto_connect: + console.print( + f"\nAuto-connect disabled. Connect with:\n mc session connect {session.id}" + ) else: console.print("[red]Failed to create session[/red]") diff --git a/mcontainer/container.py b/mcontainer/container.py index 50b7928..ccf3e21 100644 --- a/mcontainer/container.py +++ b/mcontainer/container.py @@ -145,6 +145,7 @@ class ContainerManager: volumes: Optional[Dict[str, Dict[str, str]]] = None, networks: Optional[List[str]] = None, mcp: Optional[List[str]] = None, + run_command: Optional[str] = None, uid: Optional[int] = None, gid: Optional[int] = None, ) -> Optional[Session]: @@ -157,6 +158,7 @@ class ContainerManager: session_name: Optional session name mount_local: Whether to mount the specified local directory to /app (ignored if project is None) volumes: Optional additional volumes to mount (dict of {host_path: {"bind": container_path, "mode": mode}}) + run_command: Optional command to execute before starting the shell 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 @@ -412,12 +414,51 @@ class ContainerManager: env_vars["MCP_NAMES"] = json.dumps(mcp_names) # Add user-specified networks + # Default MC network + default_network = self.config_manager.config.docker.get( + "network", "mc-network" + ) + + # Get network list, ensuring default is first and no duplicates + network_list_set = {default_network} + if networks: + network_list_set.update(networks) + network_list = ( + [default_network] + [n for n in networks if n != default_network] + if networks + else [default_network] + ) + if networks: for network in networks: if network not in network_list: + # This check is slightly redundant now but harmless network_list.append(network) print(f"Adding network {network} to session") + # Determine container command and entrypoint + container_command = None + entrypoint = None # Keep this initially None to mean "use Dockerfile default unless overridden" + target_shell = "/bin/bash" # Default final shell + + if run_command: + # Set environment variable for mc-init.sh to pick up + env_vars["MC_RUN_COMMAND"] = run_command + # Set the container's command to be the final shell + container_command = [target_shell] + logger.info( + f"Setting MC_RUN_COMMAND and targeting shell {target_shell}" + ) + else: + # Use default behavior (often defined by image's ENTRYPOINT/CMD) + # Set the container's command to be the final shell if none specified by Dockerfile CMD + # Note: Dockerfile CMD is ["tail", "-f", "/dev/null"], so this might need adjustment + # if we want interactive shell by default without --run. Let's default to bash for now. + container_command = [target_shell] + logger.info( + "Using default container entrypoint/command for interactive shell." + ) + # Create container container = self.client.containers.create( image=driver.image, @@ -437,6 +478,8 @@ class ContainerManager: "mc.mcps": ",".join(mcp_names) if mcp_names else "", }, network=network_list[0], # Connect to the first network initially + command=container_command, # Set the command + entrypoint=entrypoint, # Set the entrypoint (might be None) ports={f"{port}/tcp": None for port in driver.ports}, ) @@ -544,16 +587,15 @@ class ContainerManager: created_at=container.attrs["Created"], ports=ports, mcps=mcp_names, - # Assuming Session model has uid and gid fields + run_command=run_command, # Store the command uid=uid, gid=gid, ) - # Save session to the session manager as JSON-compatible dict + # Save session to the session manager # 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 + # uid and gid are already part of the model dump now self.session_manager.add_session(session_id, session_data_to_save) return session @@ -591,10 +633,8 @@ class ContainerManager: 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]" + f"[yellow]Warning: Session data missing for {session_id}. Attaching as default container user.[/yellow]" ) else: container_id = session_data.get("container_id") @@ -619,23 +659,18 @@ class ContainerManager: print(f"Error checking container status for session {session_id}: {e}") return False - # 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...") + # Attach to the container's main process TTY + # This allows seeing the output of --run command followed by the shell + # The user context (UID/GID) is determined when the container is created, + # attach respects that context. + print( + f"Attaching to session {session_id} (container: {container_id[:12]})..." + ) + print("Type 'exit' or Ctrl+P, Ctrl+Q (by default) to detach.") + cmd = ["docker", "attach", container_id] - cmd.extend([container_id, "/bin/bash"]) - - # Use execvp to replace the current process with docker exec + # Use execvp to replace the current process with docker attach # This provides a more seamless shell experience os.execvp("docker", cmd) # execvp does not return if successful diff --git a/mcontainer/drivers/goose/mc-init.sh b/mcontainer/drivers/goose/mc-init.sh index 26b68bd..253d301 100755 --- a/mcontainer/drivers/goose/mc-init.sh +++ b/mcontainer/drivers/goose/mc-init.sh @@ -118,5 +118,29 @@ echo "MC driver initialization 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 "$@" +# Run the user command first, if set, as mcuser +if [ -n "$MC_RUN_COMMAND" ]; then + echo '--- Executing initial command: $MC_RUN_COMMAND ---'; + gosu mcuser sh -c "$MC_RUN_COMMAND"; # Run user command as mcuser + COMMAND_EXIT_CODE=$?; + echo "--- Initial command finished (exit code: $COMMAND_EXIT_CODE) ---"; +fi; + +# Determine the final command (the interactive shell) +FINAL_CMD=("$@") +if [ ${#FINAL_CMD[@]} -eq 0 ]; then + # Default to /bin/bash if CMD wasn't passed or was empty + FINAL_CMD=("/bin/bash") +fi + +# If the final command is bash, ensure it runs interactively +# Check if the first argument is /bin/bash and -i is not already present +if [ "${FINAL_CMD[0]}" = "/bin/bash" ] && [[ ! " ${FINAL_CMD[@]} " =~ " -i " ]]; then + # Add the -i flag to the command array + FINAL_CMD+=("-i") +fi + +echo "--- Starting interactive shell (${FINAL_CMD[*]}) ---"; +# Now exec gosu directly into the final command, replacing this script process +# "${FINAL_CMD[@]}" ensures arguments are passed correctly (e.g., /bin/bash -i) +exec gosu mcuser "${FINAL_CMD[@]}" diff --git a/mcontainer/models.py b/mcontainer/models.py index 4c5b8bf..a2762c3 100644 --- a/mcontainer/models.py +++ b/mcontainer/models.py @@ -105,6 +105,9 @@ class Session(BaseModel): created_at: str ports: Dict[int, int] = Field(default_factory=dict) mcps: List[str] = Field(default_factory=list) # List of MCP server names + run_command: Optional[str] = None # Command executed on start + uid: Optional[int] = None # Store UID used + gid: Optional[int] = None # Store GID used class Config(BaseModel):