Compare commits

...

6 Commits

Author SHA1 Message Date
82c487184a 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>
2026-02-27 18:39:20 -08:00
96f8acc40d Fix Docker socket mount failing on Windows
All checks were successful
Build App / build-linux (push) Successful in 3m24s
Build App / build-windows (push) Successful in 3m51s
The Windows named pipe (//./pipe/docker_engine) cannot be bind-mounted
into a Linux container. Use /var/run/docker.sock as the mount source
on Windows, which Docker Desktop exposes for container mounts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:49:00 -08:00
b77b9679b1 Auto-increment app version using git commit count in CI builds
All checks were successful
Build App / build-windows (push) Successful in 3m11s
Build App / build-linux (push) Successful in 4m7s
Version is computed as 0.1.{commit_count} and patched into
tauri.conf.json, package.json, and Cargo.toml at build time.
Release tags now use v0.1.N format instead of build-{sha}.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:12:24 -08:00
0a4f207556 Fix stopping one project killing all project terminal sessions
All checks were successful
Build App / build-windows (push) Successful in 3m11s
Build App / build-linux (push) Successful in 6m15s
close_all_sessions() was called when stopping/removing/rebuilding a
project, which shut down exec sessions for every project. Track
container_id per session and use close_sessions_for_container() to
only close sessions belonging to the target project.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:55:38 +00:00
839dd9f105 Update project documentation with architecture and recent changes
Expand Triple-C.md from a one-liner to comprehensive docs covering
architecture, container lifecycle, mounts, auth modes, sibling
containers, Docker socket handling, key files, and CSS/styling notes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:50:41 -08:00
df3d434877 Fix SSH keys, git config, and HTTPS token not applied on container restart
All checks were successful
Build App / build-linux (push) Successful in 2m26s
Build App / build-windows (push) Successful in 3m17s
Recreate the container when SSH key path, git name, git email, or git
HTTPS token change — not just when the docker socket toggle changes.
The claude config named volume persists across recreation so no data
is lost.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:37:06 +00:00
11 changed files with 437 additions and 36 deletions

View File

@@ -22,6 +22,24 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute version
id: version
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION="0.1.${COMMIT_COUNT}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
- name: Set app version
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
echo "Patched version to ${VERSION}"
- name: Install system dependencies
run: |
@@ -80,12 +98,12 @@ jobs:
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="build-$(echo ${{ gitea.sha }} | cut -c1-7)"
TAG="v${{ steps.version.outputs.VERSION }}"
# Create release
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Linux Build ${TAG}\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
echo "Release ID: ${RELEASE_ID}"
@@ -109,6 +127,25 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute version
id: version
run: |
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
set "VERSION=0.1.%COMMIT_COUNT%"
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
echo Computed version: %VERSION%
- name: Set app version
shell: powershell
run: |
$version = "${{ steps.version.outputs.VERSION }}"
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
Write-Host "Patched version to $version"
- name: Install Rust stable
run: |
@@ -186,9 +223,9 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set "TAG=build-win-%COMMIT_SHA:~0,7%"
set "TAG=v${{ steps.version.outputs.VERSION }}-win"
echo Creating release %TAG%...
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Windows Build %TAG%\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
:found
echo Release ID: %RELEASE_ID%

View File

@@ -1,3 +1,104 @@
# Triple-C (Claude-Code-Container)
Triple C is a container intended to limit what files Claude Code has access to, so when you run with `--dangerously-skip-permissions` Claude only has access to files/projects you provide to it.
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. When running with `--dangerously-skip-permissions`, Claude only has access to the files and projects you explicitly provide to it.
## Architecture
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
- **Backend**: Rust (Tauri v2 framework)
- **Terminal**: xterm.js with WebGL rendering
- **Docker API**: bollard (pure Rust Docker client)
### Layout Structure
```
┌─────────────────────────────────────────────────────┐
│ TopBar (terminal tabs + Docker/Image status) │
├────────────┬────────────────────────────────────────┤
│ Sidebar │ Main Content (terminal views) │
│ (25% w, │ │
│ responsive│ │
│ min/max) │ │
├────────────┴────────────────────────────────────────┤
│ StatusBar (project/terminal counts) │
└─────────────────────────────────────────────────────┘
```
### Container Lifecycle
1. **Create**: New container created with bind mounts, env vars, and labels
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group
3. **Terminal**: `docker exec` launches Claude Code with a PTY
4. **Stop**: Container halted (filesystem persists in named volume)
5. **Restart**: Existing container restarted; recreated if settings changed (e.g., Docker access toggled)
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
### Mounts
| Target in Container | Source | Type | Notes |
|---|---|---|---|
| `/workspace` | Project directory | Bind | Read-write |
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
| `/var/run/docker.sock` | Host Docker socket | Bind | Only if "Allow container spawning" is ON |
### Authentication Modes
Each project can independently use one of:
- **`/login`** (OAuth): User runs `claude login` inside the terminal. Token persisted in the config volume.
- **API Key**: Stored in the OS keychain, injected as `ANTHROPIC_API_KEY` env var.
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
### Container Spawning (Sibling Containers)
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
### Docker Socket Path
The socket path is OS-aware:
- **Linux/macOS**: `/var/run/docker.sock`
- **Windows**: `//./pipe/docker_engine`
Users can override this in Settings via the global `docker_socket_path` option.
## Key Files
| File | Purpose |
|---|---|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar) |
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark` |
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/settings/SettingsPanel.tsx` | API key, Docker, AWS settings |
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection |
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions |
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, inspection |
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions for terminal interaction |
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, etc.) |
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS) |
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools |
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config |
## CSS / Styling Notes
- Uses **Tailwind CSS v4** with the Vite plugin (`@tailwindcss/vite`)
- All colors use CSS custom properties defined in `index.css` `:root`
- `color-scheme: dark` is set on `:root` so native form controls (select dropdowns, scrollbars) render in dark mode
- **Do not** add a global `* { padding: 0 }` reset — Tailwind v4 uses CSS `@layer`, and unlayered CSS overrides all layered utilities. Tailwind's built-in Preflight handles resets.
## Container Image
**Base**: Ubuntu 24.04
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)

View File

@@ -28,14 +28,12 @@ pub async fn remove_project(
// Stop and remove container if it exists
if let Some(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;
}
}
// Close any exec sessions
state.exec_manager.close_all_sessions().await;
state.projects_store.remove(&project_id)
}
@@ -102,20 +100,20 @@ pub async fn start_project_container(
// Check for existing container
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
// Check if docker socket mount matches the current project setting.
// If the user toggled "Allow container spawning" after the container was
// created, we need to recreate the container for the mount change to take
// effect.
let has_socket = docker::container_has_docker_socket(&existing_id).await.unwrap_or(false);
if has_socket != project.allow_docker_access {
log::info!(
"Docker socket mismatch (container has_socket={}, project wants={}), recreating container",
has_socket, project.allow_docker_access
);
// Safe to remove and recreate: the claude config named volume is
// keyed by project ID (not container ID) so it persists across
// container recreation. Bind mounts (workspace, SSH, AWS) are
// host paths and are unaffected.
// Compare the running container's configuration (mounts, env vars)
// against the current project settings. If anything changed (SSH key
// 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,
settings.global_claude_instructions.as_deref(),
)
.await
.unwrap_or(false);
if needs_recreation {
log::info!("Container config changed, recreating container for project {}", project.id);
let _ = docker::stop_container(&existing_id).await;
docker::remove_container(&existing_id).await?;
let new_id = docker::create_container(
@@ -125,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
@@ -142,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
@@ -170,7 +170,7 @@ pub async fn stop_project_container(
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
// Close exec sessions for this project
state.exec_manager.close_all_sessions().await;
state.exec_manager.close_sessions_for_container(container_id).await;
docker::stop_container(container_id).await?;
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
@@ -191,7 +191,7 @@ pub async fn rebuild_project_container(
// Remove existing container
if let Some(ref container_id) = project.container_id {
state.exec_manager.close_all_sessions().await;
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)?;

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 {
@@ -212,9 +244,17 @@ pub async fn create_container(
// Docker socket (only if allowed)
if project.allow_docker_access {
// On Windows, the named pipe (//./pipe/docker_engine) cannot be
// bind-mounted into a Linux container. Docker Desktop exposes the
// daemon socket as /var/run/docker.sock for container mounts.
let mount_source = if docker_socket_path == "//./pipe/docker_engine" {
"/var/run/docker.sock".to_string()
} else {
docker_socket_path.to_string()
};
mounts.push(Mount {
target: Some("/var/run/docker.sock".to_string()),
source: Some(docker_socket_path.to_string()),
source: Some(mount_source),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
@@ -280,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()
}),
@@ -288,25 +329,115 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
.map_err(|e| format!("Failed to remove container: {}", e))
}
/// Check whether an existing container has docker socket mounted.
pub async fn container_has_docker_socket(container_id: &str) -> Result<bool, 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,
global_claude_instructions: Option<&str>,
) -> Result<bool, String> {
let docker = get_docker()?;
let info = docker
.inspect_container(container_id, None)
.await
.map_err(|e| format!("Failed to inspect container: {}", e))?;
let has_socket = info
let mounts = info
.host_config
.and_then(|hc| hc.mounts)
.map(|mounts| {
mounts.iter().any(|m| {
m.target.as_deref() == Some("/var/run/docker.sock")
})
})
.unwrap_or(false);
.as_ref()
.and_then(|hc| hc.mounts.as_ref());
Ok(has_socket)
// ── Docker socket mount ──────────────────────────────────────────────
// 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
.and_then(|m| {
m.iter()
.find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh"))
})
.and_then(|mount| mount.source.as_deref());
let project_ssh = project.ssh_key_path.as_deref();
if ssh_mount_source != project_ssh {
log::info!(
"SSH key path mismatch (container={:?}, project={:?})",
ssh_mount_source,
project_ssh
);
return Ok(true);
}
// ── Git environment variables ────────────────────────────────────────
let env_vars = info
.config
.as_ref()
.and_then(|c| c.env.as_ref());
let get_env = |name: &str| -> Option<String> {
env_vars.and_then(|vars| {
vars.iter()
.find(|v| v.starts_with(&format!("{}=", name)))
.map(|v| v[name.len() + 1..].to_string())
})
};
let container_git_name = get_env("GIT_USER_NAME");
let container_git_email = get_env("GIT_USER_EMAIL");
let container_git_token = get_env("GIT_TOKEN");
if container_git_name.as_deref() != project.git_user_name.as_deref() {
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, project.git_user_name);
return Ok(true);
}
if container_git_email.as_deref() != project.git_user_email.as_deref() {
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, project.git_user_email);
return Ok(true);
}
if container_git_token.as_deref() != project.git_token.as_deref() {
log::info!("GIT_TOKEN mismatch");
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)
}
pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInfo>, String> {

View File

@@ -9,6 +9,7 @@ use super::client::get_docker;
pub struct ExecSession {
pub exec_id: String,
pub container_id: String,
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
shutdown_tx: mpsc::Sender<()>,
}
@@ -140,6 +141,7 @@ impl ExecSessionManager {
let session = ExecSession {
exec_id,
container_id: container_id.to_string(),
input_tx,
shutdown_tx,
};
@@ -175,6 +177,20 @@ impl ExecSessionManager {
}
}
pub async fn close_sessions_for_container(&self, container_id: &str) {
let mut sessions = self.sessions.lock().await;
let ids_to_close: Vec<String> = sessions
.iter()
.filter(|(_, s)| s.container_id == container_id)
.map(|(id, _)| id.clone())
.collect();
for id in ids_to_close {
if let Some(session) = sessions.remove(&id) {
session.shutdown();
}
}
}
pub async fn close_all_sessions(&self) {
let mut sessions = self.sessions.lock().await;
for (_, session) in sessions.drain() {

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,
}

View File

@@ -287,6 +287,72 @@ export default function ProjectCard({ project }: Props) {
</button>
</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 */}
{project.auth_mode === "bedrock" && (() => {
const bc = project.bedrock_config ?? defaultBedrockConfig;

View File

@@ -1,8 +1,11 @@
import ApiKeyInput from "./ApiKeyInput";
import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings";
import { useSettings } from "../../hooks/useSettings";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
return (
<div className="p-4 space-y-6">
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
@@ -11,6 +14,22 @@ export default function SettingsPanel() {
<ApiKeyInput />
<DockerSettings />
<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>
);
}

View File

@@ -1,3 +1,8 @@
export interface EnvVar {
key: string;
value: string;
}
export interface Project {
id: string;
name: string;
@@ -11,6 +16,8 @@ export interface Project {
git_token: string | null;
git_user_name: string | null;
git_user_email: string | null;
custom_env_vars: EnvVar[];
claude_instructions: string | null;
created_at: string;
updated_at: string;
}
@@ -75,4 +82,5 @@ export interface AppSettings {
image_source: ImageSource;
custom_image_name: string | null;
global_aws: GlobalAwsSettings;
global_claude_instructions: string | null;
}

View File

@@ -94,6 +94,14 @@ if [ -n "$GIT_USER_EMAIL" ]; then
su -s /bin/bash claude -c "git config --global user.email '$GIT_USER_EMAIL'"
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 ────────────────────────────────────────────────
if [ -S /var/run/docker.sock ]; then
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)