Add custom env vars and Claude instructions for projects
All checks were successful
Build App / build-windows (push) Successful in 3m24s
Build App / build-linux (push) Successful in 5m36s
Build Container / build-container (push) Successful in 56s

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 18:39:20 -08:00
parent 96f8acc40d
commit 82c487184a
8 changed files with 200 additions and 12 deletions

View File

@@ -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

View File

@@ -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<String, String> {
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<String> = 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<bool, String> {
pub async fn container_needs_recreation(
container_id: &str,
project: &Project,
global_claude_instructions: Option<&str>,
) -> Result<bool, String> {
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<String> = 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)
}

View File

@@ -50,6 +50,8 @@ pub struct AppSettings {
pub custom_image_name: Option<String>,
#[serde(default)]
pub global_aws: GlobalAwsSettings,
#[serde(default)]
pub global_claude_instructions: Option<String>,
}
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,
}
}
}

View File

@@ -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<String>,
pub git_user_name: Option<String>,
pub git_user_email: Option<String>,
#[serde(default)]
pub custom_env_vars: Vec<EnvVar>,
#[serde(default)]
pub claude_instructions: Option<String>,
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,
}