Compare commits
6 Commits
build-win-
...
v0.1.30
| Author | SHA1 | Date | |
|---|---|---|---|
| 82c487184a | |||
| 96f8acc40d | |||
| b77b9679b1 | |||
| 0a4f207556 | |||
| 839dd9f105 | |||
| df3d434877 |
@@ -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%
|
||||
|
||||
103
Triple-C.md
103
Triple-C.md
@@ -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)
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user