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 <noreply@anthropic.com>
This commit is contained in:
@@ -105,7 +105,11 @@ pub async fn start_project_container(
|
|||||||
// path, git config, docker socket, etc.) we recreate the container.
|
// path, git config, docker socket, etc.) we recreate the container.
|
||||||
// Safe to recreate: the claude config named volume is keyed by
|
// Safe to recreate: the claude config named volume is keyed by
|
||||||
// project ID (not container ID) so it persists across recreation.
|
// 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
|
.await
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if needs_recreation {
|
if needs_recreation {
|
||||||
@@ -119,6 +123,7 @@ pub async fn start_project_container(
|
|||||||
&image_name,
|
&image_name,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
|
settings.global_claude_instructions.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
@@ -136,6 +141,7 @@ pub async fn start_project_container(
|
|||||||
&image_name,
|
&image_name,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
|
settings.global_claude_instructions.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub async fn create_container(
|
|||||||
image_name: &str,
|
image_name: &str,
|
||||||
aws_config_path: Option<&str>,
|
aws_config_path: Option<&str>,
|
||||||
global_aws: &GlobalAwsSettings,
|
global_aws: &GlobalAwsSettings,
|
||||||
|
global_claude_instructions: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
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![
|
let mut mounts = vec![
|
||||||
// Project directory -> /workspace
|
// Project directory -> /workspace
|
||||||
Mount {
|
Mount {
|
||||||
@@ -288,6 +320,7 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
|||||||
.remove_container(
|
.remove_container(
|
||||||
container_id,
|
container_id,
|
||||||
Some(RemoveContainerOptions {
|
Some(RemoveContainerOptions {
|
||||||
|
v: false, // preserve named volumes (claude config)
|
||||||
force: true,
|
force: true,
|
||||||
..Default::default()
|
..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
|
/// Check whether the existing container's configuration still matches the
|
||||||
/// current project settings. Returns `true` when the container must be
|
/// current project settings. Returns `true` when the container must be
|
||||||
/// recreated (mounts or env vars differ).
|
/// 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 docker = get_docker()?;
|
||||||
let info = docker
|
let info = docker
|
||||||
.inspect_container(container_id, None)
|
.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());
|
.and_then(|hc| hc.mounts.as_ref());
|
||||||
|
|
||||||
// ── Docker socket mount ──────────────────────────────────────────────
|
// ── Docker socket mount ──────────────────────────────────────────────
|
||||||
let has_socket = mounts
|
// Intentionally NOT checked here. Toggling "Allow container spawning"
|
||||||
.map(|m| {
|
// should not trigger a full container recreation (which loses Claude
|
||||||
m.iter()
|
// Code settings stored in the named volume). The change takes effect
|
||||||
.any(|mount| mount.target.as_deref() == Some("/var/run/docker.sock"))
|
// on the next explicit rebuild instead.
|
||||||
})
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SSH key path mount ───────────────────────────────────────────────
|
// ── SSH key path mount ───────────────────────────────────────────────
|
||||||
let ssh_mount_source = mounts
|
let ssh_mount_source = mounts
|
||||||
@@ -371,6 +402,41 @@ pub async fn container_needs_recreation(container_id: &str, project: &Project) -
|
|||||||
return Ok(true);
|
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)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ pub struct AppSettings {
|
|||||||
pub custom_image_name: Option<String>,
|
pub custom_image_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_aws: GlobalAwsSettings,
|
pub global_aws: GlobalAwsSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_claude_instructions: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -62,6 +64,7 @@ impl Default for AppSettings {
|
|||||||
image_source: ImageSource::default(),
|
image_source: ImageSource::default(),
|
||||||
custom_image_name: None,
|
custom_image_name: None,
|
||||||
global_aws: GlobalAwsSettings::default(),
|
global_aws: GlobalAwsSettings::default(),
|
||||||
|
global_claude_instructions: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct EnvVar {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -14,6 +20,10 @@ pub struct Project {
|
|||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
pub git_user_name: Option<String>,
|
pub git_user_name: Option<String>,
|
||||||
pub git_user_email: 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 created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -91,6 +101,8 @@ impl Project {
|
|||||||
git_token: None,
|
git_token: None,
|
||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
git_user_email: None,
|
git_user_email: None,
|
||||||
|
custom_env_vars: Vec::new(),
|
||||||
|
claude_instructions: None,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,6 +287,72 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Environment Variables */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Environment Variables</label>
|
||||||
|
{(project.custom_env_vars ?? []).map((ev, i) => (
|
||||||
|
<div key={i} className="flex gap-1 mb-1">
|
||||||
|
<input
|
||||||
|
value={ev.key}
|
||||||
|
onChange={async (e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={ev.value}
|
||||||
|
onChange={async (e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const vars = (project.custom_env_vars ?? []).filter((_, j) => j !== i);
|
||||||
|
try { await update({ ...project, custom_env_vars: vars }); } catch {}
|
||||||
|
}}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const vars = [...(project.custom_env_vars ?? []), { key: "", value: "" }];
|
||||||
|
try { await update({ ...project, custom_env_vars: vars }); } catch {}
|
||||||
|
}}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add variable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Claude Instructions */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Claude Instructions</label>
|
||||||
|
<textarea
|
||||||
|
value={project.claude_instructions ?? ""}
|
||||||
|
onChange={async (e) => {
|
||||||
|
try { await update({ ...project, claude_instructions: e.target.value || null }); } catch {}
|
||||||
|
}}
|
||||||
|
placeholder="Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)"
|
||||||
|
disabled={!isStopped}
|
||||||
|
rows={3}
|
||||||
|
className="w-full 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 resize-y font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bedrock config */}
|
{/* Bedrock config */}
|
||||||
{project.auth_mode === "bedrock" && (() => {
|
{project.auth_mode === "bedrock" && (() => {
|
||||||
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import ApiKeyInput from "./ApiKeyInput";
|
import ApiKeyInput from "./ApiKeyInput";
|
||||||
import DockerSettings from "./DockerSettings";
|
import DockerSettings from "./DockerSettings";
|
||||||
import AwsSettings from "./AwsSettings";
|
import AwsSettings from "./AwsSettings";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||||
@@ -11,6 +14,22 @@ export default function SettingsPanel() {
|
|||||||
<ApiKeyInput />
|
<ApiKeyInput />
|
||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
<AwsSettings />
|
<AwsSettings />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Claude Instructions</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={appSettings?.global_claude_instructions ?? ""}
|
||||||
|
onChange={async (e) => {
|
||||||
|
if (!appSettings) return;
|
||||||
|
await saveSettings({ ...appSettings, global_claude_instructions: e.target.value || null });
|
||||||
|
}}
|
||||||
|
placeholder="Instructions for Claude Code in all project containers..."
|
||||||
|
rows={4}
|
||||||
|
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)] resize-y font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export interface EnvVar {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -11,6 +16,8 @@ export interface Project {
|
|||||||
git_token: string | null;
|
git_token: string | null;
|
||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
|
custom_env_vars: EnvVar[];
|
||||||
|
claude_instructions: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -75,4 +82,5 @@ export interface AppSettings {
|
|||||||
image_source: ImageSource;
|
image_source: ImageSource;
|
||||||
custom_image_name: string | null;
|
custom_image_name: string | null;
|
||||||
global_aws: GlobalAwsSettings;
|
global_aws: GlobalAwsSettings;
|
||||||
|
global_claude_instructions: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ if [ -n "$GIT_USER_EMAIL" ]; then
|
|||||||
su -s /bin/bash claude -c "git config --global user.email '$GIT_USER_EMAIL'"
|
su -s /bin/bash claude -c "git config --global user.email '$GIT_USER_EMAIL'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Claude instructions ──────────────────────────────────────────────────────
|
||||||
|
if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
|
||||||
|
mkdir -p /home/claude/.claude
|
||||||
|
printf '%s\n' "$CLAUDE_INSTRUCTIONS" > /home/claude/.claude/CLAUDE.md
|
||||||
|
chown claude:claude /home/claude/.claude/CLAUDE.md
|
||||||
|
unset CLAUDE_INSTRUCTIONS
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||||
if [ -S /var/run/docker.sock ]; then
|
if [ -S /var/run/docker.sock ]; then
|
||||||
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
|
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||||
|
|||||||
Reference in New Issue
Block a user