From 82c487184ab546b88fd367ad4412d7245deeb85a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 27 Feb 2026 18:39:20 -0800 Subject: [PATCH] Add custom env vars and Claude instructions for projects Support per-project environment variables injected into containers, plus global and per-project Claude Code instructions written to ~/.claude/CLAUDE.md inside the container on start. Reserved env var prefixes are blocked, and changes trigger automatic container recreation. Co-Authored-By: Claude Opus 4.6 --- .../src/commands/project_commands.rs | 8 +- app/src-tauri/src/docker/container.rs | 88 ++++++++++++++++--- app/src-tauri/src/models/app_settings.rs | 3 + app/src-tauri/src/models/project.rs | 12 +++ app/src/components/projects/ProjectCard.tsx | 66 ++++++++++++++ app/src/components/settings/SettingsPanel.tsx | 19 ++++ app/src/lib/types.ts | 8 ++ container/entrypoint.sh | 8 ++ 8 files changed, 200 insertions(+), 12 deletions(-) diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index dc495a4..33dfd75 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -105,7 +105,11 @@ pub async fn start_project_container( // path, git config, docker socket, etc.) we recreate the container. // Safe to recreate: the claude config named volume is keyed by // project ID (not container ID) so it persists across recreation. - let needs_recreation = docker::container_needs_recreation(&existing_id, &project) + let needs_recreation = docker::container_needs_recreation( + &existing_id, + &project, + settings.global_claude_instructions.as_deref(), + ) .await .unwrap_or(false); if needs_recreation { @@ -119,6 +123,7 @@ pub async fn start_project_container( &image_name, aws_config_path.as_deref(), &settings.global_aws, + settings.global_claude_instructions.as_deref(), ).await?; docker::start_container(&new_id).await?; new_id @@ -136,6 +141,7 @@ pub async fn start_project_container( &image_name, aws_config_path.as_deref(), &settings.global_aws, + settings.global_claude_instructions.as_deref(), ).await?; docker::start_container(&new_id).await?; new_id diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index e3417ea..1290da8 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -45,6 +45,7 @@ pub async fn create_container( image_name: &str, aws_config_path: Option<&str>, global_aws: &GlobalAwsSettings, + global_claude_instructions: Option<&str>, ) -> Result { let docker = get_docker()?; let container_name = project.container_name(); @@ -150,6 +151,37 @@ pub async fn create_container( } } + // Custom environment variables + let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"]; + let mut custom_env_fingerprint_parts: Vec = Vec::new(); + for env_var in &project.custom_env_vars { + let key = env_var.key.trim(); + if key.is_empty() { + continue; + } + let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p)); + if is_reserved { + log::warn!("Skipping reserved env var: {}", key); + continue; + } + env_vars.push(format!("{}={}", key, env_var.value)); + custom_env_fingerprint_parts.push(format!("{}={}", key, env_var.value)); + } + custom_env_fingerprint_parts.sort(); + let custom_env_fingerprint = custom_env_fingerprint_parts.join(","); + env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint)); + + // Claude instructions (global + per-project) + let combined_instructions = match (global_claude_instructions, project.claude_instructions.as_deref()) { + (Some(g), Some(p)) => Some(format!("{}\n\n{}", g, p)), + (Some(g), None) => Some(g.to_string()), + (None, Some(p)) => Some(p.to_string()), + (None, None) => None, + }; + if let Some(ref instructions) = combined_instructions { + env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); + } + let mut mounts = vec![ // Project directory -> /workspace Mount { @@ -288,6 +320,7 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> { .remove_container( container_id, Some(RemoveContainerOptions { + v: false, // preserve named volumes (claude config) force: true, ..Default::default() }), @@ -299,7 +332,11 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> { /// Check whether the existing container's configuration still matches the /// current project settings. Returns `true` when the container must be /// recreated (mounts or env vars differ). -pub async fn container_needs_recreation(container_id: &str, project: &Project) -> Result { +pub async fn container_needs_recreation( + container_id: &str, + project: &Project, + global_claude_instructions: Option<&str>, +) -> Result { let docker = get_docker()?; let info = docker .inspect_container(container_id, None) @@ -312,16 +349,10 @@ pub async fn container_needs_recreation(container_id: &str, project: &Project) - .and_then(|hc| hc.mounts.as_ref()); // ── Docker socket mount ────────────────────────────────────────────── - let has_socket = mounts - .map(|m| { - m.iter() - .any(|mount| mount.target.as_deref() == Some("/var/run/docker.sock")) - }) - .unwrap_or(false); - if has_socket != project.allow_docker_access { - log::info!("Docker socket mismatch (container={}, project={})", has_socket, project.allow_docker_access); - return Ok(true); - } + // Intentionally NOT checked here. Toggling "Allow container spawning" + // should not trigger a full container recreation (which loses Claude + // Code settings stored in the named volume). The change takes effect + // on the next explicit rebuild instead. // ── SSH key path mount ─────────────────────────────────────────────── let ssh_mount_source = mounts @@ -371,6 +402,41 @@ pub async fn container_needs_recreation(container_id: &str, project: &Project) - return Ok(true); } + // ── Custom environment variables ────────────────────────────────────── + let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"]; + let mut expected_parts: Vec = Vec::new(); + for env_var in &project.custom_env_vars { + let key = env_var.key.trim(); + if key.is_empty() { + continue; + } + let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p)); + if is_reserved { + continue; + } + expected_parts.push(format!("{}={}", key, env_var.value)); + } + expected_parts.sort(); + let expected_fingerprint = expected_parts.join(","); + let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default(); + if container_fingerprint != expected_fingerprint { + log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint); + return Ok(true); + } + + // ── Claude instructions ─────────────────────────────────────────────── + let expected_instructions = match (global_claude_instructions, project.claude_instructions.as_deref()) { + (Some(g), Some(p)) => Some(format!("{}\n\n{}", g, p)), + (Some(g), None) => Some(g.to_string()), + (None, Some(p)) => Some(p.to_string()), + (None, None) => None, + }; + let container_instructions = get_env("CLAUDE_INSTRUCTIONS"); + if container_instructions.as_deref() != expected_instructions.as_deref() { + log::info!("CLAUDE_INSTRUCTIONS mismatch"); + return Ok(true); + } + Ok(false) } diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 1303b6a..661c2ec 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -50,6 +50,8 @@ pub struct AppSettings { pub custom_image_name: Option, #[serde(default)] pub global_aws: GlobalAwsSettings, + #[serde(default)] + pub global_claude_instructions: Option, } impl Default for AppSettings { @@ -62,6 +64,7 @@ impl Default for AppSettings { image_source: ImageSource::default(), custom_image_name: None, global_aws: GlobalAwsSettings::default(), + global_claude_instructions: None, } } } diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index 4f8d9f7..e87b9da 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -1,5 +1,11 @@ use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EnvVar { + pub key: String, + pub value: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { pub id: String, @@ -14,6 +20,10 @@ pub struct Project { pub git_token: Option, pub git_user_name: Option, pub git_user_email: Option, + #[serde(default)] + pub custom_env_vars: Vec, + #[serde(default)] + pub claude_instructions: Option, pub created_at: String, pub updated_at: String, } @@ -91,6 +101,8 @@ impl Project { git_token: None, git_user_name: None, git_user_email: None, + custom_env_vars: Vec::new(), + claude_instructions: None, created_at: now.clone(), updated_at: now, } diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index e3736a5..33b0787 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -287,6 +287,72 @@ export default function ProjectCard({ project }: Props) { + {/* Environment Variables */} +
+ + {(project.custom_env_vars ?? []).map((ev, i) => ( +
+ { + const vars = [...(project.custom_env_vars ?? [])]; + vars[i] = { ...vars[i], key: e.target.value }; + try { await update({ ...project, custom_env_vars: vars }); } catch {} + }} + placeholder="KEY" + disabled={!isStopped} + className="w-1/3 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono" + /> + { + const vars = [...(project.custom_env_vars ?? [])]; + vars[i] = { ...vars[i], value: e.target.value }; + try { await update({ ...project, custom_env_vars: vars }); } catch {} + }} + placeholder="value" + disabled={!isStopped} + className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono" + /> + +
+ ))} + +
+ + {/* Claude Instructions */} +
+ +