Initial commit: Triple-C app, container, and CI

Tauri v2 desktop app (React/TypeScript + Rust) for managing
containerized Claude Code environments. Includes Gitea Actions
workflow for building and pushing the sandbox container image,
and a BUILDING.md guide for manual app builds on Linux and Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 04:29:51 +00:00
commit 97a0745ead
65 changed files with 17202 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
use bollard::Docker;
use std::sync::OnceLock;
static DOCKER: OnceLock<Result<Docker, String>> = OnceLock::new();
pub fn get_docker() -> Result<&'static Docker, String> {
let result = DOCKER.get_or_init(|| {
Docker::connect_with_local_defaults()
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))
});
match result {
Ok(docker) => Ok(docker),
Err(e) => Err(e.clone()),
}
}
pub async fn check_docker_available() -> Result<bool, String> {
let docker = get_docker()?;
match docker.ping().await {
Ok(_) => Ok(true),
Err(e) => Err(format!("Docker daemon not responding: {}", e)),
}
}

View File

@@ -0,0 +1,223 @@
use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions,
};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
use std::collections::HashMap;
use super::client::get_docker;
use crate::models::{container_config, ContainerInfo, Project};
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
let docker = get_docker()?;
let container_name = project.container_name();
let filters: HashMap<String, Vec<String>> = HashMap::from([
("name".to_string(), vec![container_name.clone()]),
]);
let containers: Vec<ContainerSummary> = docker
.list_containers(Some(ListContainersOptions {
all: true,
filters,
..Default::default()
}))
.await
.map_err(|e| format!("Failed to list containers: {}", e))?;
// Match exact name (Docker prepends /)
let expected = format!("/{}", container_name);
for c in &containers {
if let Some(names) = &c.names {
if names.iter().any(|n| n == &expected) {
return Ok(c.id.clone());
}
}
}
Ok(None)
}
pub async fn create_container(
project: &Project,
api_key: Option<&str>,
docker_socket_path: &str,
) -> Result<String, String> {
let docker = get_docker()?;
let container_name = project.container_name();
let image = container_config::full_image_name();
let mut env_vars: Vec<String> = Vec::new();
if let Some(key) = api_key {
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
}
if let Some(ref token) = project.git_token {
env_vars.push(format!("GIT_TOKEN={}", token));
}
if let Some(ref name) = project.git_user_name {
env_vars.push(format!("GIT_USER_NAME={}", name));
}
if let Some(ref email) = project.git_user_email {
env_vars.push(format!("GIT_USER_EMAIL={}", email));
}
let mut mounts = vec![
// Project directory -> /workspace
Mount {
target: Some("/workspace".to_string()),
source: Some(project.path.clone()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
},
// Named volume for claude config persistence
Mount {
target: Some("/home/claude/.claude".to_string()),
source: Some(format!("triple-c-claude-config-{}", project.id)),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
},
];
// SSH keys mount (read-only)
if let Some(ref ssh_path) = project.ssh_key_path {
mounts.push(Mount {
target: Some("/home/claude/.ssh".to_string()),
source: Some(ssh_path.clone()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(true),
..Default::default()
});
}
// Docker socket (only if allowed)
if project.allow_docker_access {
mounts.push(Mount {
target: Some("/var/run/docker.sock".to_string()),
source: Some(docker_socket_path.to_string()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
});
}
let mut labels = HashMap::new();
labels.insert("triple-c.managed".to_string(), "true".to_string());
labels.insert("triple-c.project-id".to_string(), project.id.clone());
labels.insert("triple-c.project-name".to_string(), project.name.clone());
let host_config = HostConfig {
mounts: Some(mounts),
..Default::default()
};
let config = Config {
image: Some(image),
hostname: Some("triple-c".to_string()),
env: Some(env_vars),
labels: Some(labels),
working_dir: Some("/workspace".to_string()),
host_config: Some(host_config),
tty: Some(true),
..Default::default()
};
let options = CreateContainerOptions {
name: container_name,
..Default::default()
};
let response = docker
.create_container(Some(options), config)
.await
.map_err(|e| format!("Failed to create container: {}", e))?;
Ok(response.id)
}
pub async fn start_container(container_id: &str) -> Result<(), String> {
let docker = get_docker()?;
docker
.start_container(container_id, None::<StartContainerOptions<String>>)
.await
.map_err(|e| format!("Failed to start container: {}", e))
}
pub async fn stop_container(container_id: &str) -> Result<(), String> {
let docker = get_docker()?;
docker
.stop_container(
container_id,
Some(StopContainerOptions { t: 10 }),
)
.await
.map_err(|e| format!("Failed to stop container: {}", e))
}
pub async fn remove_container(container_id: &str) -> Result<(), String> {
let docker = get_docker()?;
docker
.remove_container(
container_id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
.map_err(|e| format!("Failed to remove container: {}", e))
}
pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInfo>, String> {
if let Some(ref container_id) = project.container_id {
let docker = get_docker()?;
match docker.inspect_container(container_id, None).await {
Ok(info) => {
let status = info
.state
.and_then(|s| s.status)
.map(|s| format!("{:?}", s))
.unwrap_or_else(|| "unknown".to_string());
Ok(Some(ContainerInfo {
container_id: container_id.clone(),
project_id: project.id.clone(),
status,
image: container_config::full_image_name(),
}))
}
Err(_) => Ok(None),
}
} else {
Ok(None)
}
}
pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String> {
let docker = get_docker()?;
let all_containers: Vec<ContainerSummary> = docker
.list_containers(Some(ListContainersOptions::<String> {
all: true,
..Default::default()
}))
.await
.map_err(|e| format!("Failed to list containers: {}", e))?;
// Filter out Triple-C managed containers
let siblings: Vec<ContainerSummary> = all_containers
.into_iter()
.filter(|c| {
if let Some(labels) = &c.labels {
!labels.contains_key("triple-c.managed")
} else {
true
}
})
.collect();
Ok(siblings)
}

View File

@@ -0,0 +1,183 @@
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
use futures_util::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, Mutex};
use super::client::get_docker;
pub struct ExecSession {
pub exec_id: String,
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
shutdown_tx: mpsc::Sender<()>,
}
impl ExecSession {
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
self.input_tx
.send(data)
.map_err(|e| format!("Failed to send input: {}", e))
}
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
let docker = get_docker()?;
docker
.resize_exec(
&self.exec_id,
ResizeExecOptions {
width: cols,
height: rows,
},
)
.await
.map_err(|e| format!("Failed to resize exec: {}", e))
}
pub fn shutdown(&self) {
let _ = self.shutdown_tx.try_send(());
}
}
pub struct ExecSessionManager {
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
}
impl ExecSessionManager {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn create_session<F>(
&self,
container_id: &str,
session_id: &str,
cmd: Vec<String>,
on_output: F,
on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String>
where
F: Fn(Vec<u8>) + Send + 'static,
{
let docker = get_docker()?;
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdin: Some(true),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(true),
cmd: Some(cmd),
working_dir: Some("/workspace".to_string()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create exec: {}", e))?;
let exec_id = exec.id.clone();
let result = docker
.start_exec(&exec_id, None)
.await
.map_err(|e| format!("Failed to start exec: {}", e))?;
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
match result {
StartExecResults::Attached { mut output, mut input } => {
// Output reader task
let session_id_clone = session_id.to_string();
let shutdown_tx_clone = shutdown_tx.clone();
tokio::spawn(async move {
loop {
tokio::select! {
msg = output.next() => {
match msg {
Some(Ok(output)) => {
on_output(output.into_bytes().to_vec());
}
Some(Err(e)) => {
log::error!("Exec output error for {}: {}", session_id_clone, e);
break;
}
None => {
log::info!("Exec output stream ended for {}", session_id_clone);
break;
}
}
}
_ = shutdown_rx.recv() => {
log::info!("Exec session {} shutting down", session_id_clone);
break;
}
}
}
on_exit();
let _ = shutdown_tx_clone;
});
// Input writer task
tokio::spawn(async move {
while let Some(data) = input_rx.recv().await {
if let Err(e) = input.write_all(&data).await {
log::error!("Failed to write to exec stdin: {}", e);
break;
}
}
});
}
StartExecResults::Detached => {
return Err("Exec started in detached mode".to_string());
}
}
let session = ExecSession {
exec_id,
input_tx,
shutdown_tx,
};
self.sessions
.lock()
.await
.insert(session_id.to_string(), session);
Ok(())
}
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
session.send_input(data).await
}
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
session.resize(cols, rows).await
}
pub async fn close_session(&self, session_id: &str) {
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.remove(session_id) {
session.shutdown();
}
}
pub async fn close_all_sessions(&self) {
let mut sessions = self.sessions.lock().await;
for (_, session) in sessions.drain() {
session.shutdown();
}
}
}

View File

@@ -0,0 +1,96 @@
use bollard::image::{BuildImageOptions, ListImagesOptions};
use bollard::models::ImageSummary;
use futures_util::StreamExt;
use std::collections::HashMap;
use std::io::Write;
use super::client::get_docker;
use crate::models::container_config;
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
pub async fn image_exists() -> Result<bool, String> {
let docker = get_docker()?;
let full_name = container_config::full_image_name();
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"reference".to_string(),
vec![full_name],
)]);
let images: Vec<ImageSummary> = docker
.list_images(Some(ListImagesOptions {
filters,
..Default::default()
}))
.await
.map_err(|e| format!("Failed to list images: {}", e))?;
Ok(!images.is_empty())
}
pub async fn build_image<F>(on_progress: F) -> Result<(), String>
where
F: Fn(String) + Send + 'static,
{
let docker = get_docker()?;
let full_name = container_config::full_image_name();
// Create a tar archive in memory containing Dockerfile and entrypoint.sh
let tar_bytes = create_build_context().map_err(|e| format!("Failed to create build context: {}", e))?;
let options = BuildImageOptions {
t: full_name.as_str(),
rm: true,
forcerm: true,
..Default::default()
};
let mut stream = docker.build_image(options, None, Some(tar_bytes.into()));
while let Some(result) = stream.next().await {
match result {
Ok(output) => {
if let Some(stream) = output.stream {
on_progress(stream);
}
if let Some(error) = output.error {
return Err(format!("Build error: {}", error));
}
}
Err(e) => return Err(format!("Build stream error: {}", e)),
}
}
Ok(())
}
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
let mut buf = Vec::new();
{
let mut archive = tar::Builder::new(&mut buf);
// Add Dockerfile
let dockerfile_bytes = DOCKERFILE.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(dockerfile_bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive.append_data(&mut header, "Dockerfile", dockerfile_bytes)?;
// Add entrypoint.sh
let entrypoint_bytes = ENTRYPOINT.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(entrypoint_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
archive.finish()?;
}
// Flush to make sure all data is written
let _ = buf.flush();
Ok(buf)
}

View File

@@ -0,0 +1,9 @@
pub mod client;
pub mod container;
pub mod image;
pub mod exec;
pub use client::*;
pub use container::*;
pub use image::*;
pub use exec::*;