use tauri::{Emitter, State}; use crate::commands::aws_commands; use crate::docker; use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus}; use crate::storage::secure; use crate::AppState; pub(crate) fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) { let _ = app_handle.emit( "container-progress", serde_json::json!({ "project_id": project_id, "message": message, }), ); } /// Extract secret fields from a project and store them in the OS keychain. fn store_secrets_for_project(project: &Project) -> Result<(), String> { if let Some(ref token) = project.git_token { secure::store_project_secret(&project.id, "git-token", token)?; } if let Some(ref bedrock) = project.bedrock_config { if let Some(ref v) = bedrock.aws_access_key_id { secure::store_project_secret(&project.id, "aws-access-key-id", v)?; } if let Some(ref v) = bedrock.aws_secret_access_key { secure::store_project_secret(&project.id, "aws-secret-access-key", v)?; } if let Some(ref v) = bedrock.aws_session_token { secure::store_project_secret(&project.id, "aws-session-token", v)?; } if let Some(ref v) = bedrock.aws_bearer_token { secure::store_project_secret(&project.id, "aws-bearer-token", v)?; } } if let Some(ref oai_config) = project.openai_compatible_config { if let Some(ref v) = oai_config.api_key { secure::store_project_secret(&project.id, "openai-compatible-api-key", v)?; } } Ok(()) } /// Create the project's container, threading every global setting through. /// /// Exists so that the two ordinary create paths below and base-image migration /// cannot drift apart — a container created by a migration must be /// indistinguishable from one created by a normal start, or the next /// `container_needs_recreation` would immediately throw it away. /// /// `create_image` is what to create *from* (the snapshot or the base); /// `base_image_name` is the configured base, which `create_container` needs in /// order to tell those two apart when it stamps the lineage labels. pub(crate) async fn create_container_for_project( project: &Project, settings: &AppSettings, docker_socket: &str, aws_config_path: Option<&str>, create_image: &str, base_image_name: &str, extras: docker::CreateExtras<'_>, ) -> Result { docker::create_container( project, docker_socket, create_image, base_image_name, extras, aws_config_path, &settings.global_aws, &settings.global_ollama, &settings.global_llamacpp, &settings.global_openai_compatible, settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), settings.global_claude_code_settings.as_ref(), settings.default_ssh_key_path.as_deref(), settings.ca_cert_path.as_deref(), settings.default_git_user_name.as_deref(), settings.default_git_user_email.as_deref(), ) .await } /// Populate secret fields on a project struct from the OS keychain. pub(crate) fn load_secrets_for_project(project: &mut Project) { project.git_token = secure::get_project_secret(&project.id, "git-token") .unwrap_or(None); if let Some(ref mut bedrock) = project.bedrock_config { bedrock.aws_access_key_id = secure::get_project_secret(&project.id, "aws-access-key-id") .unwrap_or(None); bedrock.aws_secret_access_key = secure::get_project_secret(&project.id, "aws-secret-access-key") .unwrap_or(None); bedrock.aws_session_token = secure::get_project_secret(&project.id, "aws-session-token") .unwrap_or(None); bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token") .unwrap_or(None); } if let Some(ref mut oai_config) = project.openai_compatible_config { oai_config.api_key = secure::get_project_secret(&project.id, "openai-compatible-api-key") .unwrap_or(None); } } #[tauri::command] pub async fn list_projects(state: State<'_, AppState>) -> Result, String> { Ok(state.projects_store.list()) } #[tauri::command] pub async fn add_project( name: String, paths: Vec, state: State<'_, AppState>, ) -> Result { // Validate paths if paths.is_empty() { return Err("At least one folder path is required.".to_string()); } let mut seen_names = std::collections::HashSet::new(); for p in &paths { if p.mount_name.is_empty() { return Err("Mount name cannot be empty.".to_string()); } if !p.mount_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') { return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name)); } if !seen_names.insert(p.mount_name.clone()) { return Err(format!("Duplicate mount name '{}'.", p.mount_name)); } } let project = Project::new(name, paths); store_secrets_for_project(&project)?; state.projects_store.add(project) } #[tauri::command] pub async fn remove_project( project_id: String, state: State<'_, AppState>, ) -> Result<(), String> { // Release any host loopback ports the auth bridge holds for this project // before the container (and the project record) go away. state.auth_bridge.stop(&project_id).await; // A migration record outliving its project leaks a state file, a staged // payload tar that can run to several GB, and a `:pre-migration-` tag // holding an entire snapshot image that nothing will ever reference again. crate::commands::migration_commands::purge_migration_artifacts(&project_id).await; // Stop and remove container if it exists if let Some(ref project) = state.projects_store.get(&project_id) { if let Some(ref container_id) = project.container_id { state.exec_manager.close_sessions_for_container(container_id).await; let _ = docker::stop_container(container_id).await; let _ = docker::remove_container(container_id).await; } // Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP // containers first, then the per-project network they were attached to. docker::remove_legacy_mcp_containers(&project.id).await; docker::remove_legacy_project_network(&project.id).await; // Clean up the snapshot image + volumes if let Err(e) = docker::remove_snapshot_image(project).await { log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); } if let Err(e) = docker::remove_project_volumes(project).await { log::warn!("Failed to remove project volumes for project {}: {}", project_id, e); } } // Clean up keychain secrets for this project if let Err(e) = secure::delete_project_secrets(&project_id) { log::warn!("Failed to delete keychain secrets for project {}: {}", project_id, e); } state.projects_store.remove(&project_id) } #[tauri::command] pub async fn update_project( project: Project, app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { store_secrets_for_project(&project)?; let updated = state.projects_store.update(project)?; // `auth_bridge_enabled` can arrive through this generic save as well as // through `set_auth_bridge_enabled`, so reconcile the running bridge with // whatever was just persisted. `start` is idempotent and `stop` is a no-op // when nothing is running, so this is safe on every project save. if updated.auth_bridge_enabled { if let Some(ref container_id) = updated.container_id { if docker::is_container_running(container_id).await.unwrap_or(false) { state .auth_bridge .start( updated.id.clone(), container_id.clone(), app_handle, state.projects_store.clone(), ) .await; } } } else { state.auth_bridge.stop(&updated.id).await; } Ok(updated) } #[tauri::command] pub async fn start_project_container( project_id: String, app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { // A migration removes the container and creates its replacement moments // later. Starting in that window finds no container, creates a second one // under the same name, and the migration's own create then fails on the // name conflict — which sends it into an auto-rollback that also cannot // create. The UI already refuses (`canMigrate` gates on the container being // stopped and no run being in flight); this is the same gate on the side // that actually owns the invariant. if crate::commands::migration_commands::is_migrating(&project_id) { return Err( "A container base update is running for this project. Wait for it to finish, then start the project." .to_string(), ); } let mut project = state .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; // Populate secret fields from the OS keychain so they are available // in memory when building environment variables for the container. load_secrets_for_project(&mut project); // Load settings for image resolution and global AWS let settings = state.settings_store.get(); let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name); // Validate backend requirements if project.backend == Backend::Bedrock { let bedrock = project.bedrock_config.as_ref() .ok_or_else(|| "Bedrock backend selected but no Bedrock configuration found.".to_string())?; // Region can come from per-project or global if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() { return Err("AWS region is required for Bedrock backend. Set it per-project or in global AWS settings.".to_string()); } } if project.backend == Backend::Ollama { let ollama = project.ollama_config.as_ref() .ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?; if ollama.base_url.is_empty() && settings.global_ollama.base_url.as_deref().map(str::trim).unwrap_or("").is_empty() { return Err("Ollama base URL is required. Set it per-project or in global Ollama settings.".to_string()); } } if project.backend == Backend::LlamaCpp { let cfg = project.llamacpp_config.as_ref() .ok_or_else(|| "llama.cpp backend selected but no llama.cpp configuration found.".to_string())?; if cfg.base_url.trim().is_empty() && settings.global_llamacpp.base_url.as_deref().map(str::trim).unwrap_or("").is_empty() { return Err("llama.cpp base URL is required. Set it per-project or in global llama.cpp settings.".to_string()); } } if project.backend == Backend::OpenAiCompatible { let oai_config = project.openai_compatible_config.as_ref() .ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?; if oai_config.base_url.is_empty() && settings.global_openai_compatible.base_url.as_deref().map(str::trim).unwrap_or("").is_empty() { return Err("OpenAI Compatible base URL is required. Set it per-project or in global settings.".to_string()); } } // Update status to starting state.projects_store.update_status(&project_id, ProjectStatus::Starting)?; // Pre-validate AWS SSO session on the host for Bedrock Profile projects. // If the session is expired, trigger `aws sso login` before starting the container // so the entrypoint copies already-fresh credentials from the host mount. if project.backend == Backend::Bedrock { if let Some(ref bedrock) = project.bedrock_config { if bedrock.auth_method == BedrockAuthMethod::Profile { let profile = aws_commands::resolve_profile_for_project( &project, settings.global_aws.aws_profile.as_deref(), ); emit_progress(&app_handle, &project_id, "Validating AWS session..."); let session_valid = tokio::time::timeout( std::time::Duration::from_secs(10), aws_commands::check_sso_session(&profile), ) .await; match session_valid { Ok(Ok(true)) => { emit_progress(&app_handle, &project_id, "AWS session valid."); } Ok(Ok(false)) => { // Session expired — check if this is an SSO profile if aws_commands::is_sso_profile(&profile).await.unwrap_or(false) { emit_progress( &app_handle, &project_id, "AWS session expired. Starting SSO login (check your browser)...", ); match aws_commands::run_sso_login(&profile).await { Ok(()) => { emit_progress( &app_handle, &project_id, "SSO login successful.", ); } Err(e) => { log::warn!( "SSO login failed for profile '{}': {} — continuing anyway", profile, e ); emit_progress( &app_handle, &project_id, "SSO login failed or cancelled. Continuing...", ); } } } else { log::warn!( "AWS session invalid for profile '{}' (not SSO). Continuing...", profile ); } } Ok(Err(e)) => { log::warn!("Failed to check AWS session: {} — continuing anyway", e); } Err(_) => { log::warn!("AWS session check timed out — continuing anyway"); } } } } } // Wrap container operations so that any failure resets status to Stopped. let result: Result = async { // Ensure image exists emit_progress(&app_handle, &project_id, "Checking image..."); if !docker::image_exists(&image_name).await? { return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name)); } // Determine docker socket path let docker_socket = settings.docker_socket_path .as_deref() .map(|s| s.to_string()) .unwrap_or_else(|| default_docker_socket()); // AWS config path from global settings let aws_config_path = settings.global_aws.aws_config_path.clone(); // What we would create this container from *right now*: the project's // snapshot when one exists, else the configured base. This is the value // `container_needs_recreation` compares against the container's // `triple-c.create-image` label — the check that replaced the old // tautological one. It is resolved *before* the commit below, so it // describes the pre-commit world the existing container was born into. let snapshot_image = docker::get_snapshot_image_name(&project); let expected_create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { snapshot_image.clone() } else { image_name.clone() }; let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? { // Check if config changed — if so, snapshot + recreate let needs_recreate = docker::container_needs_recreation( &existing_id, &project, &expected_create_image, &settings.global_aws, &settings.global_ollama, &settings.global_llamacpp, &settings.global_openai_compatible, settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), settings.global_claude_code_settings.as_ref(), settings.default_ssh_key_path.as_deref(), settings.ca_cert_path.as_deref(), settings.default_git_user_name.as_deref(), settings.default_git_user_email.as_deref(), ).await.unwrap_or(false); if needs_recreate { log::info!("Container config changed for project {} — committing snapshot and recreating", project.id); // Snapshot the filesystem before destroying emit_progress(&app_handle, &project_id, "Saving container state..."); if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await { log::warn!("Failed to snapshot container before recreation: {}", e); } emit_progress(&app_handle, &project_id, "Recreating container..."); let _ = docker::stop_container(&existing_id).await; docker::remove_container(&existing_id).await?; // Legacy MCP cleanup: the old container may have been attached to // `triple-c-net-`. Tear down leftover MCP containers and // that network now, before the replacement is created without it. docker::remove_legacy_mcp_containers(&project.id).await; docker::remove_legacy_project_network(&project.id).await; // Create from snapshot image (preserves system-level changes). // Re-resolved after the commit above: when no snapshot existed // before, one does now, and creating from the base instead // would throw away the state that was just saved. let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { snapshot_image.clone() } else { image_name.clone() }; let new_id = create_container_for_project( &project, &settings, &docker_socket, aws_config_path.as_deref(), &create_image, &image_name, docker::CreateExtras::default(), ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; // The commit above moved `:latest` and orphaned the image it // used to point at; the container holding that image open was // removed a few lines up, so now is when Docker will actually // let it go. Detached because this is housekeeping and the // project is already running — and it sweeps every orphan, not // just this one, so recreations that happened before the sweep // existed are cleaned up too. tauri::async_runtime::spawn(async { docker::sweep_orphaned_snapshots().await; }); new_id } else { emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&existing_id).await?; existing_id } } else { // Container doesn't exist (first start, or Docker pruned it). // Check for a snapshot image first — it preserves system-level // changes (apt/pip/npm installs) from the previous session. if expected_create_image == snapshot_image { log::info!("Creating container from snapshot image for project {}", project.id); } let create_image = expected_create_image.clone(); emit_progress(&app_handle, &project_id, "Creating container..."); let new_id = create_container_for_project( &project, &settings, &docker_socket, aws_config_path.as_deref(), &create_image, &image_name, docker::CreateExtras::default(), ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; new_id }; // Sync Bedrock credentials on every start: refresh static/session creds // so rotated keys are picked up without a full container recreation, and // clear stale creds when the project no longer uses static-cred Bedrock. if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await { log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e); } Ok(container_id) }.await; // On failure, reset status to Stopped so the project doesn't get stuck. if let Err(ref e) = result { log::error!("Failed to start container for project {}: {}", project_id, e); let _ = state.projects_store.update_status(&project_id, ProjectStatus::Stopped); } let container_id = result?; // Update project with container info using granular methods (Issue 14: TOCTOU) state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?; state.projects_store.update_status(&project_id, ProjectStatus::Running)?; // Arm the auth bridge if this project opted in. Purely host-side, so it // happens after the container is up and never affects the start itself. if project.auth_bridge_enabled { state .auth_bridge .start( project_id.clone(), container_id.clone(), app_handle.clone(), state.projects_store.clone(), ) .await; } project.container_id = Some(container_id); project.status = ProjectStatus::Running; Ok(project) } #[tauri::command] pub async fn stop_project_container( project_id: String, app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { let project = state .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?; // Drop host listeners first: they only make sense while the container runs. state.auth_bridge.stop(&project_id).await; if let Some(ref container_id) = project.container_id { // Close exec sessions for this project emit_progress(&app_handle, &project_id, "Stopping container..."); state.exec_manager.close_sessions_for_container(container_id).await; if let Err(e) = docker::stop_container(container_id).await { log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e); } } state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?; Ok(()) } #[tauri::command] pub async fn rebuild_project_container( project_id: String, app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { // Reset deletes both volumes and the snapshot image. Doing that while a // migration is mid-flight pulls the ground out from under it and leaves an // orphan migration record pointing at images that no longer exist. if crate::commands::migration_commands::is_migrating(&project_id) { return Err( "A container base update is running for this project. Wait for it to finish before resetting." .to_string(), ); } let project = state .projects_store .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; // Reset supersedes any migration decision that was still pending: the // snapshot image and both volumes are about to go, so a surviving record // could only describe things that no longer exist — while its // `:pre-migration-` tag held a whole snapshot image (multiple GB) alive // with nothing left that could ever use it. crate::commands::migration_commands::purge_migration_artifacts(&project_id).await; // The bridge is bound to the container that is about to be destroyed; // `start_project_container` below re-arms it against the new one. state.auth_bridge.stop(&project_id).await; // Remove existing container if let Some(ref container_id) = project.container_id { state.exec_manager.close_sessions_for_container(container_id).await; let _ = docker::stop_container(container_id).await; docker::remove_container(container_id).await?; state.projects_store.set_container_id(&project_id, None)?; } // Remove snapshot image + volumes so Reset creates from the clean base image if let Err(e) = docker::remove_snapshot_image(&project).await { log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); } if let Err(e) = docker::remove_project_volumes(&project).await { log::warn!("Failed to remove project volumes for project {}: {}", project_id, e); } // Start fresh start_project_container(project_id, app_handle, state).await } /// Reconcile project statuses against actual Docker container state. /// Called by the frontend after Docker is confirmed available. Projects /// marked as Running whose containers are no longer running get reset /// to Stopped. /// /// This is also where an interrupted **base-image migration** is picked up. /// It runs at startup, which is exactly when a migration that died with the app /// needs to be noticed — see /// [`crate::commands::migration_commands::reconcile_migration`]. The migration /// pass runs over *every* project, not just the Running ones, because a project /// whose container was removed mid-migration reports Stopped. #[tauri::command] pub async fn reconcile_project_statuses( app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result, String> { let projects = state.projects_store.list(); for project in &projects { crate::commands::migration_commands::reconcile_migration(project, &app_handle).await; } for project in &projects { // `Starting` and `Stopping` are in here as a backstop, not because // anything is expected to leave a project in one. They are transitional // states owned by an in-flight command, so a project still wearing one // is a project whose command died — a crash mid-start, or a migration // that bailed out between the stop and the swap. Skipping them, as this // loop used to, meant nothing in the app ever put such a project right: // it sat at "Stopping" with the Start button disabled, permanently. // Docker is the authority either way, so the check below is correct for // all four. if !matches!( project.status, ProjectStatus::Running | ProjectStatus::Error | ProjectStatus::Starting | ProjectStatus::Stopping ) { continue; } // ...but never for a project this process is actively migrating: the // container is legitimately absent for part of that run. if crate::commands::migration_commands::is_migrating(&project.id) { continue; } let is_running = if let Some(ref container_id) = project.container_id { docker::is_container_running(container_id).await.unwrap_or(false) } else { false }; if is_running { log::info!( "Project '{}' ({}) container is still running — keeping Running status", project.name, project.id ); // The app may have restarted while the container kept running; the // bridge lives in this process, so re-arm it here. `start` is // idempotent, so a bridge that is already polling is untouched. if project.auth_bridge_enabled { if let Some(ref container_id) = project.container_id { state .auth_bridge .start( project.id.clone(), container_id.clone(), app_handle.clone(), state.projects_store.clone(), ) .await; } } } else { log::info!( "Project '{}' ({}) container is not running — setting to Stopped", project.name, project.id ); let _ = state.projects_store.update_status(&project.id, ProjectStatus::Stopped); } } Ok(state.projects_store.list()) } fn default_docker_socket() -> String { if cfg!(target_os = "windows") { "//./pipe/docker_engine".to_string() } else { "/var/run/docker.sock".to_string() } }