diff --git a/app/src-tauri/src/commands/docker_commands.rs b/app/src-tauri/src/commands/docker_commands.rs index 84bc03d..d8f7a32 100644 --- a/app/src-tauri/src/commands/docker_commands.rs +++ b/app/src-tauri/src/commands/docker_commands.rs @@ -1,7 +1,7 @@ use tauri::State; use crate::docker; -use crate::models::ContainerInfo; +use crate::models::{container_config, ContainerInfo}; use crate::AppState; #[tauri::command] @@ -10,8 +10,10 @@ pub async fn check_docker() -> Result { } #[tauri::command] -pub async fn check_image_exists() -> Result { - docker::image_exists().await +pub async fn check_image_exists(state: State<'_, AppState>) -> Result { + let settings = state.settings_store.get(); + let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name); + docker::image_exists(&image_name).await } #[tauri::command] diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index d8c5c7e..a7c881a 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1,7 +1,7 @@ use tauri::State; use crate::docker; -use crate::models::{AuthMode, Project, ProjectStatus}; +use crate::models::{container_config, AuthMode, Project, ProjectStatus}; use crate::storage::secure; use crate::AppState; @@ -57,6 +57,10 @@ pub async fn start_project_container( .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; + // 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); + // Get API key only if auth mode requires it let api_key = match project.auth_mode { AuthMode::ApiKey => { @@ -65,16 +69,14 @@ pub async fn start_project_container( Some(key) } AuthMode::Login => { - // Login mode: no API key needed, user runs `claude login` in the container. - // Auth state persists in the .claude config volume. None } AuthMode::Bedrock => { - // Bedrock mode: no Anthropic API key needed, uses AWS credentials. let bedrock = project.bedrock_config.as_ref() .ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?; - if bedrock.aws_region.is_empty() { - return Err("AWS region is required for Bedrock auth mode.".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 auth mode. Set it per-project or in global AWS settings.".to_string()); } None } @@ -84,12 +86,19 @@ pub async fn start_project_container( state.projects_store.update_status(&project_id, ProjectStatus::Starting)?; // Ensure image exists - if !docker::image_exists().await? { - return Err("Docker image not built. Please build the image first.".to_string()); + if !docker::image_exists(&image_name).await? { + state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?; + return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name)); } // Determine docker socket path - let docker_socket = default_docker_socket(); + 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(); // Check for existing container let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? { @@ -98,7 +107,14 @@ pub async fn start_project_container( existing_id } else { // Create new container - let new_id = docker::create_container(&project, api_key.as_deref(), &docker_socket).await?; + let new_id = docker::create_container( + &project, + api_key.as_deref(), + &docker_socket, + &image_name, + aws_config_path.as_deref(), + &settings.global_aws, + ).await?; docker::start_container(&new_id).await?; new_id }; diff --git a/app/src-tauri/src/commands/settings_commands.rs b/app/src-tauri/src/commands/settings_commands.rs index 7b4088f..5203509 100644 --- a/app/src-tauri/src/commands/settings_commands.rs +++ b/app/src-tauri/src/commands/settings_commands.rs @@ -1,4 +1,9 @@ +use tauri::State; + +use crate::docker; +use crate::models::AppSettings; use crate::storage::secure; +use crate::AppState; #[tauri::command] pub async fn set_api_key(key: String) -> Result<(), String> { @@ -14,3 +19,88 @@ pub async fn has_api_key() -> Result { pub async fn delete_api_key() -> Result<(), String> { secure::delete_api_key() } + +#[tauri::command] +pub async fn get_settings(state: State<'_, AppState>) -> Result { + Ok(state.settings_store.get()) +} + +#[tauri::command] +pub async fn update_settings( + settings: AppSettings, + state: State<'_, AppState>, +) -> Result { + state.settings_store.update(settings) +} + +#[tauri::command] +pub async fn pull_image( + image_name: String, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + use tauri::Emitter; + docker::pull_image(&image_name, move |msg| { + let _ = app_handle.emit("image-pull-progress", msg); + }) + .await +} + +#[tauri::command] +pub async fn detect_aws_config() -> Result, String> { + if let Some(home) = dirs::home_dir() { + let aws_dir = home.join(".aws"); + if aws_dir.exists() { + return Ok(Some(aws_dir.to_string_lossy().to_string())); + } + } + Ok(None) +} + +#[tauri::command] +pub async fn list_aws_profiles() -> Result, String> { + let mut profiles = Vec::new(); + + let home = match dirs::home_dir() { + Some(h) => h, + None => return Ok(profiles), + }; + + // Parse ~/.aws/credentials + let credentials_path = home.join(".aws").join("credentials"); + if credentials_path.exists() { + if let Ok(contents) = std::fs::read_to_string(&credentials_path) { + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let profile = trimmed[1..trimmed.len() - 1].to_string(); + if !profiles.contains(&profile) { + profiles.push(profile); + } + } + } + } + } + + // Parse ~/.aws/config (profiles are prefixed with "profile ") + let config_path = home.join(".aws").join("config"); + if config_path.exists() { + if let Ok(contents) = std::fs::read_to_string(&config_path) { + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let section = &trimmed[1..trimmed.len() - 1]; + let profile = if let Some(name) = section.strip_prefix("profile ") { + name.to_string() + } else { + section.to_string() + }; + if !profiles.contains(&profile) { + profiles.push(profile); + } + } + } + } + } + + Ok(profiles) +} diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 7fedf98..f8d6c5c 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -6,7 +6,7 @@ use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum}; use std::collections::HashMap; use super::client::get_docker; -use crate::models::{container_config, AuthMode, BedrockAuthMethod, ContainerInfo, Project}; +use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, GlobalAwsSettings, Project}; pub async fn find_existing_container(project: &Project) -> Result, String> { let docker = get_docker()?; @@ -42,10 +42,12 @@ pub async fn create_container( project: &Project, api_key: Option<&str>, docker_socket_path: &str, + image_name: &str, + aws_config_path: Option<&str>, + global_aws: &GlobalAwsSettings, ) -> Result { let docker = get_docker()?; let container_name = project.container_name(); - let image = container_config::full_image_name(); let mut env_vars: Vec = Vec::new(); @@ -55,14 +57,32 @@ pub async fn create_container( let uid = std::process::Command::new("id").arg("-u").output(); let gid = std::process::Command::new("id").arg("-g").output(); if let Ok(out) = uid { - let val = String::from_utf8_lossy(&out.stdout).trim().to_string(); - env_vars.push(format!("HOST_UID={}", val)); + if out.status.success() { + let val = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !val.is_empty() { + log::debug!("Host UID detected: {}", val); + env_vars.push(format!("HOST_UID={}", val)); + } + } else { + log::debug!("Failed to detect host UID (exit code {:?})", out.status.code()); + } } if let Ok(out) = gid { - let val = String::from_utf8_lossy(&out.stdout).trim().to_string(); - env_vars.push(format!("HOST_GID={}", val)); + if out.status.success() { + let val = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !val.is_empty() { + log::debug!("Host GID detected: {}", val); + env_vars.push(format!("HOST_GID={}", val)); + } + } else { + log::debug!("Failed to detect host GID (exit code {:?})", out.status.code()); + } } } + #[cfg(windows)] + { + log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping"); + } if let Some(key) = api_key { env_vars.push(format!("ANTHROPIC_API_KEY={}", key)); @@ -82,7 +102,16 @@ pub async fn create_container( if project.auth_mode == AuthMode::Bedrock { if let Some(ref bedrock) = project.bedrock_config { env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string()); - env_vars.push(format!("AWS_REGION={}", bedrock.aws_region)); + + // AWS region: per-project overrides global + let region = if !bedrock.aws_region.is_empty() { + Some(bedrock.aws_region.clone()) + } else { + global_aws.aws_region.clone() + }; + if let Some(ref r) = region { + env_vars.push(format!("AWS_REGION={}", r)); + } match bedrock.auth_method { BedrockAuthMethod::StaticCredentials => { @@ -97,8 +126,11 @@ pub async fn create_container( } } BedrockAuthMethod::Profile => { - if let Some(ref profile) = bedrock.aws_profile { - env_vars.push(format!("AWS_PROFILE={}", profile)); + // Per-project profile overrides global + let profile = bedrock.aws_profile.as_ref() + .or(global_aws.aws_profile.as_ref()); + if let Some(p) = profile { + env_vars.push(format!("AWS_PROFILE={}", p)); } } BedrockAuthMethod::BearerToken => { @@ -148,22 +180,32 @@ pub async fn create_container( }); } - // AWS config mount (read-only, for profile-based auth) - if project.auth_mode == AuthMode::Bedrock { + // AWS config mount (read-only) + // Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set + let should_mount_aws = if project.auth_mode == AuthMode::Bedrock { if let Some(ref bedrock) = project.bedrock_config { - if bedrock.auth_method == BedrockAuthMethod::Profile { - if let Some(home) = dirs::home_dir() { - let aws_dir = home.join(".aws"); - if aws_dir.exists() { - mounts.push(Mount { - target: Some("/home/claude/.aws".to_string()), - source: Some(aws_dir.to_string_lossy().to_string()), - typ: Some(MountTypeEnum::BIND), - read_only: Some(true), - ..Default::default() - }); - } - } + bedrock.auth_method == BedrockAuthMethod::Profile + } else { + false + } + } else { + false + }; + + if should_mount_aws || aws_config_path.is_some() { + let aws_dir = aws_config_path + .map(|p| std::path::PathBuf::from(p)) + .or_else(|| dirs::home_dir().map(|h| h.join(".aws"))); + + if let Some(ref aws_path) = aws_dir { + if aws_path.exists() { + mounts.push(Mount { + target: Some("/home/claude/.aws".to_string()), + source: Some(aws_path.to_string_lossy().to_string()), + typ: Some(MountTypeEnum::BIND), + read_only: Some(true), + ..Default::default() + }); } } } @@ -190,7 +232,7 @@ pub async fn create_container( }; let config = Config { - image: Some(image), + image: Some(image_name.to_string()), hostname: Some("triple-c".to_string()), env: Some(env_vars), labels: Some(labels), @@ -257,11 +299,17 @@ pub async fn get_container_info(project: &Project) -> Result Ok(None), @@ -282,7 +330,6 @@ pub async fn list_sibling_containers() -> Result, String> .await .map_err(|e| format!("Failed to list containers: {}", e))?; - // Filter out Triple-C managed containers let siblings: Vec = all_containers .into_iter() .filter(|c| { diff --git a/app/src-tauri/src/docker/image.rs b/app/src-tauri/src/docker/image.rs index d36e5be..de6cebd 100644 --- a/app/src-tauri/src/docker/image.rs +++ b/app/src-tauri/src/docker/image.rs @@ -1,4 +1,4 @@ -use bollard::image::{BuildImageOptions, ListImagesOptions}; +use bollard::image::{BuildImageOptions, CreateImageOptions, ListImagesOptions}; use bollard::models::ImageSummary; use futures_util::StreamExt; use std::collections::HashMap; @@ -10,13 +10,12 @@ use crate::models::container_config; const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile"); const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh"); -pub async fn image_exists() -> Result { +pub async fn image_exists(image_name: &str) -> Result { let docker = get_docker()?; - let full_name = container_config::full_image_name(); let filters: HashMap> = HashMap::from([( "reference".to_string(), - vec![full_name], + vec![image_name.to_string()], )]); let images: Vec = docker @@ -30,14 +29,65 @@ pub async fn image_exists() -> Result { Ok(!images.is_empty()) } +pub async fn pull_image(image_name: &str, on_progress: F) -> Result<(), String> +where + F: Fn(String) + Send + 'static, +{ + let docker = get_docker()?; + + // Parse image name into from_image and tag + let (from_image, tag) = if let Some(pos) = image_name.rfind(':') { + // Check that the colon is part of a tag, not a port + let after_colon = &image_name[pos + 1..]; + if after_colon.contains('/') { + // The colon is part of a port (e.g., host:port/repo) + (image_name, "latest") + } else { + (&image_name[..pos], after_colon) + } + } else { + (image_name, "latest") + }; + + let options = CreateImageOptions { + from_image, + tag, + ..Default::default() + }; + + let mut stream = docker.create_image(Some(options), None, None); + + while let Some(result) = stream.next().await { + match result { + Ok(info) => { + let mut msg_parts = Vec::new(); + if let Some(ref status) = info.status { + msg_parts.push(status.clone()); + } + if let Some(ref progress) = info.progress { + msg_parts.push(progress.clone()); + } + if !msg_parts.is_empty() { + on_progress(msg_parts.join(" ")); + } + if let Some(ref error) = info.error { + return Err(format!("Pull error: {}", error)); + } + } + Err(e) => return Err(format!("Pull stream error: {}", e)), + } + } + + Ok(()) +} + pub async fn build_image(on_progress: F) -> Result<(), String> where F: Fn(String) + Send + 'static, { let docker = get_docker()?; - let full_name = container_config::full_image_name(); + let full_name = container_config::local_build_image_name(); - // Create a tar archive in memory containing Dockerfile and entrypoint.sh let tar_bytes = create_build_context().map_err(|e| format!("Failed to create build context: {}", e))?; let options = BuildImageOptions { @@ -71,7 +121,6 @@ fn create_build_context() -> Result, std::io::Error> { { let mut archive = tar::Builder::new(&mut buf); - // Add Dockerfile let dockerfile_bytes = DOCKERFILE.as_bytes(); let mut header = tar::Header::new_gnu(); header.set_size(dockerfile_bytes.len() as u64); @@ -79,7 +128,6 @@ fn create_build_context() -> Result, std::io::Error> { header.set_cksum(); archive.append_data(&mut header, "Dockerfile", dockerfile_bytes)?; - // Add entrypoint.sh let entrypoint_bytes = ENTRYPOINT.as_bytes(); let mut header = tar::Header::new_gnu(); header.set_size(entrypoint_bytes.len() as u64); @@ -90,7 +138,6 @@ fn create_build_context() -> Result, std::io::Error> { archive.finish()?; } - // Flush to make sure all data is written let _ = buf.flush(); Ok(buf) } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b00cec8..a41a448 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -5,9 +5,11 @@ mod storage; use docker::exec::ExecSessionManager; use storage::projects_store::ProjectsStore; +use storage::settings_store::SettingsStore; pub struct AppState { pub projects_store: ProjectsStore, + pub settings_store: SettingsStore, pub exec_manager: ExecSessionManager, } @@ -20,6 +22,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .manage(AppState { projects_store: ProjectsStore::new(), + settings_store: SettingsStore::new(), exec_manager: ExecSessionManager::new(), }) .invoke_handler(tauri::generate_handler![ @@ -41,6 +44,11 @@ pub fn run() { commands::settings_commands::set_api_key, commands::settings_commands::has_api_key, commands::settings_commands::delete_api_key, + commands::settings_commands::get_settings, + commands::settings_commands::update_settings, + commands::settings_commands::pull_image, + commands::settings_commands::detect_aws_config, + commands::settings_commands::list_aws_profiles, // Terminal commands::terminal_commands::open_terminal_session, commands::terminal_commands::terminal_input, diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 76a528b..1303b6a 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -1,11 +1,55 @@ use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ImageSource { + Registry, + LocalBuild, + Custom, +} + +impl Default for ImageSource { + fn default() -> Self { + Self::Registry + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobalAwsSettings { + #[serde(default)] + pub aws_config_path: Option, + #[serde(default)] + pub aws_profile: Option, + #[serde(default)] + pub aws_region: Option, +} + +impl Default for GlobalAwsSettings { + fn default() -> Self { + Self { + aws_config_path: None, + aws_profile: None, + aws_region: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppSettings { + #[serde(default)] pub default_ssh_key_path: Option, + #[serde(default)] pub default_git_user_name: Option, + #[serde(default)] pub default_git_user_email: Option, + #[serde(default)] pub docker_socket_path: Option, + #[serde(default)] + pub image_source: ImageSource, + #[serde(default)] + pub custom_image_name: Option, + #[serde(default)] + pub global_aws: GlobalAwsSettings, } impl Default for AppSettings { @@ -15,6 +59,9 @@ impl Default for AppSettings { default_git_user_name: None, default_git_user_email: None, docker_socket_path: None, + image_source: ImageSource::default(), + custom_image_name: None, + global_aws: GlobalAwsSettings::default(), } } } diff --git a/app/src-tauri/src/models/container_config.rs b/app/src-tauri/src/models/container_config.rs index 1058466..e8fccb2 100644 --- a/app/src-tauri/src/models/container_config.rs +++ b/app/src-tauri/src/models/container_config.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use super::app_settings::ImageSource; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContainerInfo { pub container_id: String, @@ -8,9 +10,22 @@ pub struct ContainerInfo { pub image: String, } -pub const IMAGE_NAME: &str = "triple-c"; +pub const LOCAL_IMAGE_NAME: &str = "triple-c"; pub const IMAGE_TAG: &str = "latest"; +pub const REGISTRY_IMAGE: &str = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest"; -pub fn full_image_name() -> String { - format!("{IMAGE_NAME}:{IMAGE_TAG}") +pub fn local_build_image_name() -> String { + format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}") +} + +pub fn resolve_image_name(source: &ImageSource, custom: &Option) -> String { + match source { + ImageSource::Registry => REGISTRY_IMAGE.to_string(), + ImageSource::LocalBuild => local_build_image_name(), + ImageSource::Custom => custom + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(REGISTRY_IMAGE) + .to_string(), + } } diff --git a/app/src-tauri/src/storage/mod.rs b/app/src-tauri/src/storage/mod.rs index 3bfc9da..8fe3915 100644 --- a/app/src-tauri/src/storage/mod.rs +++ b/app/src-tauri/src/storage/mod.rs @@ -1,5 +1,7 @@ pub mod projects_store; pub mod secure; +pub mod settings_store; pub use projects_store::*; pub use secure::*; +pub use settings_store::*; diff --git a/app/src-tauri/src/storage/settings_store.rs b/app/src-tauri/src/storage/settings_store.rs new file mode 100644 index 0000000..98b50ee --- /dev/null +++ b/app/src-tauri/src/storage/settings_store.rs @@ -0,0 +1,76 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; + +use crate::models::AppSettings; + +pub struct SettingsStore { + settings: Mutex, + file_path: PathBuf, +} + +impl SettingsStore { + pub fn new() -> Self { + let data_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("triple-c"); + + fs::create_dir_all(&data_dir).ok(); + + let file_path = data_dir.join("settings.json"); + + let settings = if file_path.exists() { + match fs::read_to_string(&file_path) { + Ok(data) => match serde_json::from_str(&data) { + Ok(parsed) => parsed, + Err(e) => { + log::error!("Failed to parse settings.json: {}. Using defaults.", e); + let backup = file_path.with_extension("json.bak"); + if let Err(be) = fs::copy(&file_path, &backup) { + log::error!("Failed to back up corrupted settings.json: {}", be); + } + AppSettings::default() + } + }, + Err(e) => { + log::error!("Failed to read settings.json: {}", e); + AppSettings::default() + } + } + } else { + AppSettings::default() + }; + + Self { + settings: Mutex::new(settings), + file_path, + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> { + self.settings.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn save(&self, settings: &AppSettings) -> Result<(), String> { + let data = serde_json::to_string_pretty(settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + let tmp_path = self.file_path.with_extension("json.tmp"); + fs::write(&tmp_path, data) + .map_err(|e| format!("Failed to write temp settings file: {}", e))?; + fs::rename(&tmp_path, &self.file_path) + .map_err(|e| format!("Failed to rename settings file: {}", e))?; + Ok(()) + } + + pub fn get(&self) -> AppSettings { + self.lock().clone() + } + + pub fn update(&self, new_settings: AppSettings) -> Result { + let mut settings = self.lock(); + *settings = new_settings.clone(); + self.save(&settings)?; + Ok(new_settings) + } +} diff --git a/app/src/App.tsx b/app/src/App.tsx index b5ca760..e251e8c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,12 +10,13 @@ import { useAppState } from "./store/appState"; export default function App() { const { checkDocker, checkImage } = useDocker(); - const { checkApiKey } = useSettings(); + const { checkApiKey, loadSettings } = useSettings(); const { refresh } = useProjects(); const { sessions, activeSessionId } = useAppState(); // Initialize on mount useEffect(() => { + loadSettings(); checkDocker(); checkImage(); checkApiKey(); diff --git a/app/src/components/settings/AwsSettings.tsx b/app/src/components/settings/AwsSettings.tsx new file mode 100644 index 0000000..8c1a6ee --- /dev/null +++ b/app/src/components/settings/AwsSettings.tsx @@ -0,0 +1,109 @@ +import { useState, useEffect } from "react"; +import { useSettings } from "../../hooks/useSettings"; +import * as commands from "../../lib/tauri-commands"; + +export default function AwsSettings() { + const { appSettings, saveSettings } = useSettings(); + const [profiles, setProfiles] = useState([]); + const [detecting, setDetecting] = useState(false); + + const globalAws = appSettings?.global_aws ?? { + aws_config_path: null, + aws_profile: null, + aws_region: null, + }; + + // Load profiles when component mounts or aws_config_path changes + useEffect(() => { + commands.listAwsProfiles().then(setProfiles).catch(() => setProfiles([])); + }, [globalAws.aws_config_path]); + + const handleDetect = async () => { + setDetecting(true); + try { + const path = await commands.detectAwsConfig(); + if (path && appSettings) { + const updated = { + ...appSettings, + global_aws: { ...globalAws, aws_config_path: path }, + }; + await saveSettings(updated); + // Refresh profiles after detection + const p = await commands.listAwsProfiles(); + setProfiles(p); + } + } finally { + setDetecting(false); + } + }; + + const handleChange = async (field: string, value: string | null) => { + if (!appSettings) return; + await saveSettings({ + ...appSettings, + global_aws: { ...globalAws, [field]: value || null }, + }); + }; + + return ( +
+ +
+

+ Global AWS defaults for Bedrock projects. Per-project settings override these. +

+ + {/* AWS Config Path */} +
+ AWS Config Path +
+ handleChange("aws_config_path", e.target.value)} + placeholder="~/.aws" + className="flex-1 px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" + /> + +
+ {globalAws.aws_config_path && ( + Found + )} +
+ + {/* AWS Profile */} +
+ Default Profile + +
+ + {/* AWS Region */} +
+ Default Region + handleChange("aws_region", e.target.value)} + placeholder="e.g., us-east-1" + className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" + /> +
+
+
+ ); +} diff --git a/app/src/components/settings/DockerSettings.tsx b/app/src/components/settings/DockerSettings.tsx index ffbd904..8041612 100644 --- a/app/src/components/settings/DockerSettings.tsx +++ b/app/src/components/settings/DockerSettings.tsx @@ -1,32 +1,84 @@ import { useState } from "react"; import { useDocker } from "../../hooks/useDocker"; +import { useSettings } from "../../hooks/useSettings"; +import type { ImageSource } from "../../lib/types"; + +const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest"; + +const IMAGE_SOURCE_OPTIONS: { value: ImageSource; label: string; description: string }[] = [ + { value: "registry", label: "Registry", description: "Pull from container registry" }, + { value: "local_build", label: "Local Build", description: "Build from embedded Dockerfile" }, + { value: "custom", label: "Custom", description: "Specify a custom image" }, +]; export default function DockerSettings() { - const { dockerAvailable, imageExists, checkDocker, checkImage, buildImage } = + const { dockerAvailable, imageExists, checkDocker, checkImage, buildImage, pullImage } = useDocker(); - const [building, setBuilding] = useState(false); - const [buildLog, setBuildLog] = useState([]); + const { appSettings, saveSettings } = useSettings(); + const [working, setWorking] = useState(false); + const [log, setLog] = useState([]); const [error, setError] = useState(null); + const [customInput, setCustomInput] = useState(appSettings?.custom_image_name ?? ""); - const handleBuild = async () => { - setBuilding(true); - setBuildLog([]); + const imageSource = appSettings?.image_source ?? "registry"; + + const resolvedImageName = (() => { + switch (imageSource) { + case "registry": return REGISTRY_IMAGE; + case "local_build": return "triple-c:latest"; + case "custom": return customInput || REGISTRY_IMAGE; + } + })(); + + const handleSourceChange = async (source: ImageSource) => { + if (!appSettings) return; + await saveSettings({ ...appSettings, image_source: source }); + // Re-check image existence after changing source + setTimeout(() => checkImage(), 100); + }; + + const handleCustomChange = async (value: string) => { + setCustomInput(value); + if (!appSettings) return; + await saveSettings({ ...appSettings, custom_image_name: value || null }); + }; + + const handlePull = async () => { + setWorking(true); + setLog([]); setError(null); try { - await buildImage((msg) => { - setBuildLog((prev) => [...prev, msg]); + await pullImage(resolvedImageName, (msg) => { + setLog((prev) => [...prev, msg]); }); + await checkImage(); } catch (e) { setError(String(e)); } finally { - setBuilding(false); + setWorking(false); + } + }; + + const handleBuild = async () => { + setWorking(true); + setLog([]); + setError(null); + try { + await buildImage((msg) => { + setLog((prev) => [...prev, msg]); + }); + await checkImage(); + } catch (e) { + setError(String(e)); + } finally { + setWorking(false); } }; return (
-
+
Docker Status @@ -34,32 +86,88 @@ export default function DockerSettings() {
+ {/* Image Source Selector */} +
+ Image Source +
+ {IMAGE_SOURCE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Custom image input */} + {imageSource === "custom" && ( +
+ Custom Image + handleCustomChange(e.target.value)} + placeholder="e.g., myregistry.com/image:tag" + className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" + /> +
+ )} + + {/* Resolved image display */}
Image - - {imageExists === null ? "Checking..." : imageExists ? "Built" : "Not Built"} + + {resolvedImageName}
+
+ Status + + {imageExists === null ? "Checking..." : imageExists ? "Ready" : "Not Found"} + +
+ + {/* Action buttons */}
- + + {imageSource === "local_build" ? ( + + ) : ( + + )}
- {buildLog.length > 0 && ( + {/* Log output */} + {log.length > 0 && (
- {buildLog.map((line, i) => ( + {log.map((line, i) => (
{line}
))}
diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index ea4bef3..5b6f883 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -1,5 +1,6 @@ import ApiKeyInput from "./ApiKeyInput"; import DockerSettings from "./DockerSettings"; +import AwsSettings from "./AwsSettings"; export default function SettingsPanel() { return ( @@ -9,6 +10,7 @@ export default function SettingsPanel() { +
); } diff --git a/app/src/hooks/useDocker.ts b/app/src/hooks/useDocker.ts index a05ab84..b4f0402 100644 --- a/app/src/hooks/useDocker.ts +++ b/app/src/hooks/useDocker.ts @@ -51,11 +51,30 @@ export function useDocker() { [setImageExists], ); + const pullImage = useCallback( + async (imageName: string, onProgress?: (msg: string) => void) => { + const unlisten = onProgress + ? await listen("image-pull-progress", (event) => { + onProgress(event.payload); + }) + : null; + + try { + await commands.pullImage(imageName); + setImageExists(true); + } finally { + unlisten?.(); + } + }, + [setImageExists], + ); + return { dockerAvailable, imageExists, checkDocker, checkImage, buildImage, + pullImage, }; } diff --git a/app/src/hooks/useSettings.ts b/app/src/hooks/useSettings.ts index 4b79939..e6df89c 100644 --- a/app/src/hooks/useSettings.ts +++ b/app/src/hooks/useSettings.ts @@ -1,9 +1,10 @@ import { useCallback } from "react"; import { useAppState } from "../store/appState"; import * as commands from "../lib/tauri-commands"; +import type { AppSettings } from "../lib/types"; export function useSettings() { - const { hasKey, setHasKey } = useAppState(); + const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(); const checkApiKey = useCallback(async () => { try { @@ -29,10 +30,33 @@ export function useSettings() { setHasKey(false); }, [setHasKey]); + const loadSettings = useCallback(async () => { + try { + const settings = await commands.getSettings(); + setAppSettings(settings); + return settings; + } catch (e) { + console.error("Failed to load settings:", e); + return null; + } + }, [setAppSettings]); + + const saveSettings = useCallback( + async (settings: AppSettings) => { + const updated = await commands.updateSettings(settings); + setAppSettings(updated); + return updated; + }, + [setAppSettings], + ); + return { hasKey, checkApiKey, saveApiKey, removeApiKey, + appSettings, + loadSettings, + saveSettings, }; } diff --git a/app/src/lib/constants.ts b/app/src/lib/constants.ts index 4d6a6ab..508ee64 100644 --- a/app/src/lib/constants.ts +++ b/app/src/lib/constants.ts @@ -1,2 +1 @@ export const APP_NAME = "Triple-C"; -export const IMAGE_NAME = "triple-c:latest"; diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 51d9780..6362d74 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ContainerInfo, SiblingContainer } from "./types"; +import type { Project, ContainerInfo, SiblingContainer, AppSettings } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -30,6 +30,15 @@ export const setApiKey = (key: string) => invoke("set_api_key", { key }); export const hasApiKey = () => invoke("has_api_key"); export const deleteApiKey = () => invoke("delete_api_key"); +export const getSettings = () => invoke("get_settings"); +export const updateSettings = (settings: AppSettings) => + invoke("update_settings", { settings }); +export const pullImage = (imageName: string) => + invoke("pull_image", { imageName }); +export const detectAwsConfig = () => + invoke("detect_aws_config"); +export const listAwsProfiles = () => + invoke("list_aws_profiles"); // Terminal export const openTerminalSession = (projectId: string, sessionId: string) => diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index f102150..deaf0ec 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -58,3 +58,21 @@ export interface TerminalSession { projectId: string; projectName: string; } + +export type ImageSource = "registry" | "local_build" | "custom"; + +export interface GlobalAwsSettings { + aws_config_path: string | null; + aws_profile: string | null; + aws_region: string | null; +} + +export interface AppSettings { + default_ssh_key_path: string | null; + default_git_user_name: string | null; + default_git_user_email: string | null; + docker_socket_path: string | null; + image_source: ImageSource; + custom_image_name: string | null; + global_aws: GlobalAwsSettings; +} diff --git a/app/src/store/appState.ts b/app/src/store/appState.ts index aa0b869..a403248 100644 --- a/app/src/store/appState.ts +++ b/app/src/store/appState.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { Project, TerminalSession } from "../lib/types"; +import type { Project, TerminalSession, AppSettings } from "../lib/types"; interface AppState { // Projects @@ -26,6 +26,10 @@ interface AppState { setImageExists: (exists: boolean | null) => void; hasKey: boolean | null; setHasKey: (has: boolean | null) => void; + + // App settings + appSettings: AppSettings | null; + setAppSettings: (settings: AppSettings) => void; } export const useAppState = create((set) => ({ @@ -77,4 +81,8 @@ export const useAppState = create((set) => ({ setImageExists: (exists) => set({ imageExists: exists }), hasKey: null, setHasKey: (has) => set({ hasKey: has }), + + // App settings + appSettings: null, + setAppSettings: (settings) => set({ appSettings: settings }), })); diff --git a/container/Dockerfile b/container/Dockerfile index 0134409..aa2ff9a 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -21,6 +21,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libssl-dev \ && rm -rf /var/lib/apt/lists/* +# Remove default ubuntu user to free UID 1000 for host-user remapping +RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \ + && if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi + # Set UTF-8 locale RUN locale-gen en_US.UTF-8 ENV LANG=en_US.UTF-8 @@ -65,7 +69,7 @@ RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2 && unzip awscliv2.zip && ./aws/install && rm -rf awscliv2.zip aws # ── Non-root user with passwordless sudo ───────────────────────────────────── -RUN useradd -m -s /bin/bash claude \ +RUN useradd -m -s /bin/bash -u 1000 claude \ && echo "claude ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/claude \ && chmod 0440 /etc/sudoers.d/claude diff --git a/container/entrypoint.sh b/container/entrypoint.sh index de2321c..22a11fc 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -1,15 +1,49 @@ #!/bin/bash -set -e +# NOTE: set -e is intentionally omitted. A failing usermod/groupmod must not +# kill the entire entrypoint — SSH setup, git config, and the final exec +# must still run so the container is usable even if remapping fails. # ── UID/GID remapping ────────────────────────────────────────────────────── # Match the container's claude user to the host user's UID/GID so that # bind-mounted files (project dir, docker socket) have correct ownership. -if [ -n "$HOST_UID" ] && [ "$HOST_UID" != "$(id -u claude)" ]; then - usermod -u "$HOST_UID" claude -fi -if [ -n "$HOST_GID" ] && [ "$HOST_GID" != "$(id -g claude)" ]; then - groupmod -g "$HOST_GID" claude -fi +remap_uid_gid() { + local target_uid="${HOST_UID}" + local target_gid="${HOST_GID}" + local current_uid + local current_gid + current_uid=$(id -u claude 2>/dev/null) || { echo "entrypoint: claude user not found"; return 1; } + current_gid=$(id -g claude 2>/dev/null) || { echo "entrypoint: claude group not found"; return 1; } + + # ── GID remapping ── + if [ -n "$target_gid" ] && [ "$target_gid" != "$current_gid" ]; then + # If another group already holds the target GID, move it out of the way + local blocking_group + blocking_group=$(getent group "$target_gid" 2>/dev/null | cut -d: -f1) + if [ -n "$blocking_group" ] && [ "$blocking_group" != "claude" ]; then + echo "entrypoint: moving group '$blocking_group' from GID $target_gid to 65533" + groupmod -g 65533 "$blocking_group" || echo "entrypoint: warning — failed to relocate group '$blocking_group'" + fi + groupmod -g "$target_gid" claude \ + && echo "entrypoint: claude GID -> $target_gid" \ + || echo "entrypoint: warning — groupmod -g $target_gid claude failed" + fi + + # ── UID remapping ── + if [ -n "$target_uid" ] && [ "$target_uid" != "$current_uid" ]; then + # If another user already holds the target UID, move it out of the way + local blocking_user + blocking_user=$(getent passwd "$target_uid" 2>/dev/null | cut -d: -f1) + if [ -n "$blocking_user" ] && [ "$blocking_user" != "claude" ]; then + echo "entrypoint: moving user '$blocking_user' from UID $target_uid to 65533" + usermod -u 65533 "$blocking_user" || echo "entrypoint: warning — failed to relocate user '$blocking_user'" + fi + usermod -u "$target_uid" claude \ + && echo "entrypoint: claude UID -> $target_uid" \ + || echo "entrypoint: warning — usermod -u $target_uid claude failed" + fi +} + +remap_uid_gid # Fix ownership of home directory after UID/GID change chown -R claude:claude /home/claude