Build App (Preview) / compute-version (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-linux (pull_request) Successful in 5m30s
Build App (Preview) / build-windows (pull_request) Successful in 5m55s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Review caught that the device guard was wired to the wrong call. The
daemon does not resolve `--device` at create: verified against Docker
29.7, `docker create --device /dev/does-not-exist` succeeds and prints an
id, and runc only resolves the device — and validates sysctls — when it
builds the container. So on a host with no tun module the create returns
fine and `start` fails, which means the explanation never ran and the
user saw the raw daemon string naming a path they would go looking for on
the wrong machine. The unit tests fed the create-side string straight in,
so they confirmed a function no real failure could reach.
Move the guard onto `start_container`, covering create as well in case a
future daemon checks earlier. It no longer takes `vpn_support_enabled` —
`start_container` has a container id and no project, and nothing else in
Triple-C ever requests a device, so an error naming /dev/net/tun is
unambiguous on its own. The test now uses the daemon's verbatim message
via bollard's real Display format.
Also from review:
* Soften the security claim. Docker does not enable user-namespace
remapping by default, so this is a real CAP_NET_ADMIN in the initial
user namespace with only the network namespace confining it. It
cannot touch host interfaces, but "confers no authority outside the
container" was too strong: within its namespace it can set
promiscuous mode and add addresses, routes and NAT on the shared
docker0 segment, which puts sibling containers — the LiteLLM gateway
among them — within ARP-spoofing reach, and it can flush netfilter
rules sandbox mode may rely on. Said plainly in the code, CLAUDE.md
and HOW-TO-USE.
* Drop Tailscale from the list of clients needing this. Its
--tun=userspace-networking mode needs neither the capability nor the
device, and listing it invites granting NET_ADMIN for nothing.
* Say in the toggle's own hint that changing it recreates the
container, matching how every other recreation-triggering setting is
labelled. The tab's generic "stop the container first" chip does not
tell the user what is about to happen.
* Add RuntimeSection tests: saves on, saves off explicitly rather than
dropping the key, reflects state, is disabled while running, and
carries the recreation warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
444 lines
17 KiB
Rust
444 lines
17 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct EnvVar {
|
|
pub key: String,
|
|
pub value: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct ProjectPath {
|
|
pub host_path: String,
|
|
pub mount_name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct PortMapping {
|
|
pub host_port: u16,
|
|
pub container_port: u16,
|
|
#[serde(default = "default_protocol")]
|
|
pub protocol: String,
|
|
}
|
|
|
|
fn default_protocol() -> String {
|
|
"tcp".to_string()
|
|
}
|
|
|
|
fn default_full_permissions() -> bool {
|
|
true
|
|
}
|
|
|
|
/// `use_shared_auth_token` defaults to **on**: once the user has run
|
|
/// `claude setup-token` once, every existing Anthropic-backend project should
|
|
/// pick the token up without being edited one by one. Projects deliberately
|
|
/// pinned to their own `claude login` identity opt out.
|
|
fn default_use_shared_auth_token() -> bool {
|
|
true
|
|
}
|
|
|
|
/// How much autonomy Claude Code is granted inside the container.
|
|
///
|
|
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
|
|
/// the single definition of that mapping and must be used by every call site.
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum PermissionMode {
|
|
/// Read-only planning mode.
|
|
Plan,
|
|
/// Claude Code's own default behavior (prompts for permission).
|
|
#[default]
|
|
Default,
|
|
/// Auto-accept file edits, prompt for everything else.
|
|
AcceptEdits,
|
|
/// Skip all permission prompts.
|
|
Bypass,
|
|
}
|
|
|
|
impl PermissionMode {
|
|
/// The CLI flags this mode adds to a `claude` invocation.
|
|
/// Defined once here so every call site stays in sync.
|
|
pub fn cli_args(&self) -> Vec<String> {
|
|
match self {
|
|
PermissionMode::Plan => vec!["--permission-mode".to_string(), "plan".to_string()],
|
|
PermissionMode::Default => Vec::new(),
|
|
PermissionMode::AcceptEdits => {
|
|
vec!["--permission-mode".to_string(), "acceptEdits".to_string()]
|
|
}
|
|
PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()],
|
|
}
|
|
}
|
|
|
|
/// The wire value used for the `TRIPLE_C_PERMISSION_MODE` container env var.
|
|
/// Matches the serde `camelCase` representation.
|
|
pub fn as_env_value(&self) -> &'static str {
|
|
match self {
|
|
PermissionMode::Plan => "plan",
|
|
PermissionMode::Default => "default",
|
|
PermissionMode::AcceptEdits => "acceptEdits",
|
|
PermissionMode::Bypass => "bypass",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Settings for Claude Code CLI behavior inside the container.
|
|
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
pub struct ClaudeCodeSettings {
|
|
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
|
|
#[serde(default)]
|
|
pub tui_mode: Option<String>,
|
|
/// Effort level: None = default, Some("low"|"medium"|"high")
|
|
#[serde(default)]
|
|
pub effort: Option<String>,
|
|
/// Disable auto-scroll in fullscreen TUI mode
|
|
#[serde(default)]
|
|
pub auto_scroll_disabled: bool,
|
|
/// Enable focus mode (collapsed tool output)
|
|
#[serde(default)]
|
|
pub focus_mode: bool,
|
|
/// Show thinking summaries in responses
|
|
#[serde(default)]
|
|
pub show_thinking_summaries: bool,
|
|
/// Enable session recap when returning to a session
|
|
#[serde(default)]
|
|
pub enable_session_recap: bool,
|
|
/// Strip credentials from subprocess environments
|
|
#[serde(default)]
|
|
pub env_scrub: bool,
|
|
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
|
#[serde(default)]
|
|
pub prompt_caching_1h: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Project {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub paths: Vec<ProjectPath>,
|
|
pub container_id: Option<String>,
|
|
pub status: ProjectStatus,
|
|
#[serde(alias = "auth_mode")]
|
|
pub backend: Backend,
|
|
pub bedrock_config: Option<BedrockConfig>,
|
|
pub ollama_config: Option<OllamaConfig>,
|
|
#[serde(default, alias = "llama_cpp_config")]
|
|
pub llamacpp_config: Option<LlamaCppConfig>,
|
|
#[serde(alias = "litellm_config")]
|
|
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
|
|
pub allow_docker_access: bool,
|
|
#[serde(default)]
|
|
pub sandbox_mode_enabled: bool,
|
|
#[serde(default)]
|
|
pub mission_control_enabled: bool,
|
|
/// Opt in to the auth bridge: while the container runs, its loopback
|
|
/// listeners are mirrored onto the host's loopback so browser OAuth
|
|
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
|
|
/// Purely host-side — it deliberately has no container-recreation label,
|
|
/// because toggling it changes nothing about the container itself.
|
|
#[serde(default)]
|
|
pub auth_bridge_enabled: bool,
|
|
/// Opt in to the browser-view pane, which watches and takes over the
|
|
/// browser Claude drives with Playwright inside the container. Purely
|
|
/// host-side like `auth_bridge_enabled`, so it likewise has no
|
|
/// container-recreation label.
|
|
#[serde(default)]
|
|
pub browser_view_enabled: bool,
|
|
/// Grant the container what a VPN client needs to build a tunnel:
|
|
/// `CAP_NET_ADMIN`, the `/dev/net/tun` device, and the WireGuard
|
|
/// `src_valid_mark` sysctl. Without all three a client (PIA, WireGuard,
|
|
/// OpenVPN) installs and runs but its connection attempt hangs until it
|
|
/// times out, because it cannot create the tunnel interface or touch the
|
|
/// routing table.
|
|
///
|
|
/// Off by default and deliberately opt-in: `NET_ADMIN` lets anything in the
|
|
/// container reconfigure its own network stack, which reaches further than
|
|
/// it sounds — see `vpn_host_config` for what it does and does not confer.
|
|
/// Unlike `auth_bridge_enabled` this *is*
|
|
/// container state, so it carries a `triple-c.vpn-support` label and is
|
|
/// compared in `container_needs_recreation` — capabilities and devices are
|
|
/// fixed at creation and can only change by recreating the container.
|
|
#[serde(default)]
|
|
pub vpn_support_enabled: bool,
|
|
/// Use the shared, long-lived Claude Code OAuth token (from
|
|
/// `claude setup-token`, held in the OS keychain) for this project instead
|
|
/// of requiring its own `claude login`. Only consulted when `backend` is
|
|
/// [`Backend::Anthropic`] and a token has actually been stored.
|
|
///
|
|
/// Defaults to **true** so a single `setup-token` run covers every project;
|
|
/// turn it off to pin a project to the identity it logged in with inside
|
|
/// its own container.
|
|
#[serde(default = "default_use_shared_auth_token")]
|
|
pub use_shared_auth_token: bool,
|
|
/// Legacy binary permission flag. Superseded by `permission_mode`, but kept
|
|
/// because it is the value already stored in users' `projects.json`; it is
|
|
/// the fallback in `effective_permission_mode()` so old projects keep
|
|
/// behaving identically without a data migration.
|
|
#[serde(default = "default_full_permissions")]
|
|
pub full_permissions: bool,
|
|
/// Per-project permission mode. `None` means "not set yet" → fall back to
|
|
/// the legacy `full_permissions` flag.
|
|
#[serde(default)]
|
|
pub permission_mode: Option<PermissionMode>,
|
|
pub ssh_key_path: Option<String>,
|
|
/// Per-project override for the corporate CA certificate path (file or
|
|
/// directory). Blank falls back to `AppSettings::ca_cert_path`.
|
|
///
|
|
/// `#[serde(default)]` rather than a required field: every project stored
|
|
/// before this existed must keep loading.
|
|
#[serde(default)]
|
|
pub ca_cert_path: Option<String>,
|
|
#[serde(skip_serializing, default)]
|
|
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 port_mappings: Vec<PortMapping>,
|
|
#[serde(default)]
|
|
pub claude_instructions: Option<String>,
|
|
#[serde(default)]
|
|
pub claude_code_settings: Option<ClaudeCodeSettings>,
|
|
/// User-defined display names for terminal tabs, keyed by session id.
|
|
#[serde(default)]
|
|
pub renamed_session_names: HashMap<String, String>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum ProjectStatus {
|
|
Stopped,
|
|
Starting,
|
|
Running,
|
|
Stopping,
|
|
Error,
|
|
}
|
|
|
|
/// Which AI model backend/provider the project uses.
|
|
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
|
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
|
/// - `Ollama`: Local or remote Ollama server
|
|
/// - `LlamaCpp`: A local or remote `llama-server` (llama.cpp)
|
|
/// - `OpenAiCompatible`: Any endpoint that speaks the Anthropic Messages API
|
|
/// (e.g. LiteLLM). See [`Backend::uses_custom_endpoint`].
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Backend {
|
|
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
|
#[serde(alias = "login", alias = "api_key")]
|
|
Anthropic,
|
|
Bedrock,
|
|
Ollama,
|
|
/// Serialises as `llama_cpp`; the aliases accept the spellings a
|
|
/// hand-edited `projects.json` is likely to contain.
|
|
#[serde(alias = "llamacpp", alias = "llama-cpp", alias = "llama.cpp")]
|
|
LlamaCpp,
|
|
#[serde(alias = "lite_llm", alias = "litellm")]
|
|
OpenAiCompatible,
|
|
}
|
|
|
|
impl Default for Backend {
|
|
fn default() -> Self {
|
|
Self::Anthropic
|
|
}
|
|
}
|
|
|
|
impl Backend {
|
|
/// Whether this backend points Claude Code at a non-Anthropic HTTP endpoint
|
|
/// via `ANTHROPIC_BASE_URL`.
|
|
///
|
|
/// Those endpoints serve whatever model *they* were started with, so
|
|
/// Claude Code's built-in `opus`/`sonnet`/`haiku`/`fable` aliases resolve to
|
|
/// Anthropic model ids the server has never heard of. Every backend for
|
|
/// which this returns `true` therefore gets the
|
|
/// `ANTHROPIC_DEFAULT_*_MODEL` alias vars pinned to the configured model —
|
|
/// see `docker::container::compute_model_aliases`.
|
|
///
|
|
/// Bedrock is deliberately excluded: it talks to AWS, which does host the
|
|
/// real Anthropic model ids, so Claude Code's own defaults are correct
|
|
/// there. Anthropic is excluded for the same reason.
|
|
pub fn uses_custom_endpoint(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
Backend::Ollama | Backend::LlamaCpp | Backend::OpenAiCompatible
|
|
)
|
|
}
|
|
}
|
|
|
|
/// How Bedrock authenticates with AWS.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum BedrockAuthMethod {
|
|
StaticCredentials,
|
|
Profile,
|
|
BearerToken,
|
|
}
|
|
|
|
impl Default for BedrockAuthMethod {
|
|
fn default() -> Self {
|
|
Self::StaticCredentials
|
|
}
|
|
}
|
|
|
|
/// AWS Bedrock configuration for a project.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BedrockConfig {
|
|
pub auth_method: BedrockAuthMethod,
|
|
pub aws_region: String,
|
|
#[serde(skip_serializing, default)]
|
|
pub aws_access_key_id: Option<String>,
|
|
#[serde(skip_serializing, default)]
|
|
pub aws_secret_access_key: Option<String>,
|
|
#[serde(skip_serializing, default)]
|
|
pub aws_session_token: Option<String>,
|
|
pub aws_profile: Option<String>,
|
|
#[serde(skip_serializing, default)]
|
|
pub aws_bearer_token: Option<String>,
|
|
pub model_id: Option<String>,
|
|
pub disable_prompt_caching: bool,
|
|
/// Optional value for the `ANTHROPIC_BEDROCK_SERVICE_TIER` env var
|
|
/// (e.g. "priority"). Empty/None means leave unset.
|
|
#[serde(default)]
|
|
pub service_tier: Option<String>,
|
|
}
|
|
|
|
/// Ollama configuration for a project.
|
|
/// Ollama natively implements the Anthropic Messages API at `/v1/messages`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct OllamaConfig {
|
|
/// The base URL of the Ollama server (e.g., "http://host.docker.internal:11434" or "http://192.168.1.100:11434")
|
|
pub base_url: String,
|
|
/// Optional model override (e.g., "qwen3.5:27b")
|
|
pub model_id: Option<String>,
|
|
/// Optional override for the model the `haiku` alias resolves to.
|
|
/// Blank falls back to `model_id`. See [`Backend::uses_custom_endpoint`].
|
|
#[serde(default)]
|
|
pub haiku_model_id: Option<String>,
|
|
}
|
|
|
|
/// llama.cpp (`llama-server`) configuration for a project.
|
|
///
|
|
/// `llama-server` natively implements the Anthropic Messages API at
|
|
/// `POST /v1/messages` (plus `/v1/messages/count_tokens`), so Claude Code can
|
|
/// talk to it directly through `ANTHROPIC_BASE_URL` — exactly like Ollama, with
|
|
/// no translation shim.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LlamaCppConfig {
|
|
/// The base URL of the llama-server instance. `llama-server`'s default
|
|
/// listen port is 8080 (`--port PORT | port to listen (default: 8080)`).
|
|
pub base_url: String,
|
|
/// Optional model override. `llama-server` serves whatever model it was
|
|
/// started with, so this is mostly the id Claude Code should *say* it is
|
|
/// using — but it is also what the model aliases are pinned to.
|
|
pub model_id: Option<String>,
|
|
/// Optional override for the model the `haiku` alias resolves to.
|
|
/// Blank falls back to `model_id`.
|
|
#[serde(default)]
|
|
pub haiku_model_id: Option<String>,
|
|
}
|
|
|
|
/// OpenAI Compatible endpoint configuration for a project.
|
|
///
|
|
/// Despite the name (kept for backward compatibility with existing
|
|
/// `projects.json` data), the endpoint must implement the **Anthropic Messages
|
|
/// API** — Claude Code only ever speaks `POST /v1/messages`. Gateways such as
|
|
/// LiteLLM expose an Anthropic-shaped route and work; a bare
|
|
/// `/v1/chat/completions` server does not.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct OpenAiCompatibleConfig {
|
|
/// The base URL of the endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
|
|
pub base_url: String,
|
|
/// API key for the endpoint
|
|
#[serde(skip_serializing, default)]
|
|
pub api_key: Option<String>,
|
|
/// Optional model override
|
|
pub model_id: Option<String>,
|
|
/// Optional override for the model the `haiku` alias resolves to.
|
|
/// Blank falls back to `model_id`.
|
|
#[serde(default)]
|
|
pub haiku_model_id: Option<String>,
|
|
}
|
|
|
|
impl Project {
|
|
pub fn new(name: String, paths: Vec<ProjectPath>) -> Self {
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
name,
|
|
paths,
|
|
container_id: None,
|
|
status: ProjectStatus::Stopped,
|
|
backend: Backend::default(),
|
|
bedrock_config: None,
|
|
ollama_config: None,
|
|
llamacpp_config: None,
|
|
openai_compatible_config: None,
|
|
allow_docker_access: false,
|
|
sandbox_mode_enabled: false,
|
|
mission_control_enabled: false,
|
|
auth_bridge_enabled: false,
|
|
browser_view_enabled: false,
|
|
vpn_support_enabled: false,
|
|
use_shared_auth_token: default_use_shared_auth_token(),
|
|
full_permissions: false,
|
|
permission_mode: None,
|
|
ssh_key_path: None,
|
|
ca_cert_path: None,
|
|
git_token: None,
|
|
git_user_name: None,
|
|
git_user_email: None,
|
|
custom_env_vars: Vec::new(),
|
|
port_mappings: Vec::new(),
|
|
claude_instructions: None,
|
|
claude_code_settings: None,
|
|
renamed_session_names: HashMap::new(),
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
}
|
|
}
|
|
|
|
/// The permission mode to actually use for this project.
|
|
/// Falls back to the legacy `full_permissions` boolean when the newer
|
|
/// `permission_mode` field has never been set.
|
|
pub fn effective_permission_mode(&self) -> PermissionMode {
|
|
self.permission_mode.unwrap_or(if self.full_permissions {
|
|
PermissionMode::Bypass
|
|
} else {
|
|
PermissionMode::Default
|
|
})
|
|
}
|
|
|
|
pub fn container_name(&self) -> String {
|
|
format!("triple-c-{}", self.id)
|
|
}
|
|
|
|
/// Migrate a project JSON value from old single-`path` format to new `paths` format.
|
|
/// If the value already has `paths`, it is returned unchanged.
|
|
pub fn migrate_from_value(mut val: serde_json::Value) -> serde_json::Value {
|
|
if let Some(obj) = val.as_object_mut() {
|
|
if obj.contains_key("paths") {
|
|
return val;
|
|
}
|
|
if let Some(path_val) = obj.remove("path") {
|
|
let path_str = path_val.as_str().unwrap_or("").to_string();
|
|
let mount_name = path_str
|
|
.trim_end_matches(['/', '\\'])
|
|
.rsplit(['/', '\\'])
|
|
.next()
|
|
.unwrap_or("workspace")
|
|
.to_string();
|
|
let project_path = serde_json::json!([{
|
|
"host_path": path_str,
|
|
"mount_name": if mount_name.is_empty() { "workspace".to_string() } else { mount_name },
|
|
}]);
|
|
obj.insert("paths".to_string(), project_path);
|
|
}
|
|
}
|
|
val
|
|
}
|
|
}
|