feature/docker-install-helper #3

Merged
jknapp merged 5 commits from feature/docker-install-helper into main 2026-05-01 20:01:58 +00:00
5 changed files with 177 additions and 24 deletions
Showing only changes of commit 5974347913 - Show all commits

View File

@@ -88,6 +88,40 @@ This project uses **Flight Control** (bundled with Triple-C) for structured deve
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#;
const SANDBOX_INSTRUCTIONS: &str = r#"## Sandbox Mode
This container has Claude Code's bash sandbox enabled, managed by Triple-C
(toggle it from the project's "Sandbox mode" switch in the Triple-C UI).
Bash commands run inside `bubblewrap` with filesystem and network isolation
(`enableWeakerNestedSandbox` is on because we are inside Docker).
### When a command fails because of sandbox restrictions
Triple-C disables the `dangerouslyDisableSandbox` escape hatch
(`allowUnsandboxedCommands: false`), so failing commands cannot bypass the
sandbox at runtime. To make a blocked command work, edit
`~/.claude/settings.json` and restart Claude Code:
| Need | Setting |
|---|---|
| Write to a path outside the project (e.g. `~/.kube`) | Add to `sandbox.filesystem.allowWrite` |
| Reach a new domain | Will prompt; or add permanently to `sandbox.allowedDomains` |
| Run a specific tool entirely outside the sandbox | Add a glob (e.g. `"docker *"`) to `sandbox.excludedCommands` |
### Docker commands
The `docker` CLI does not work inside the sandbox. If this project has
"Allow container spawning" enabled in Triple-C and you need to run
`docker` commands, add `"docker *"` to `sandbox.excludedCommands` in
`~/.claude/settings.json`. Other tools known to be sandbox-incompatible
include `watchman` — pass `--no-watchman` to `jest`.
### Disabling sandbox mode
Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it
on every container start. To turn sandbox off, stop the container in
Triple-C, flip the "Sandbox mode" switch off, then start the container."#;
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
/// instructions, appending port mapping docs, and appending scheduler docs.
/// Used by both create_container() and container_needs_recreation() to ensure
@@ -97,6 +131,7 @@ fn build_claude_instructions(
project_instructions: Option<&str>,
port_mappings: &[PortMapping],
mission_control_enabled: bool,
sandbox_enabled: bool,
) -> Option<String> {
let mut combined = merge_claude_instructions(
global_instructions,
@@ -126,6 +161,13 @@ fn build_claude_instructions(
None => SCHEDULER_INSTRUCTIONS.to_string(),
});
if sandbox_enabled {
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS),
None => SANDBOX_INSTRUCTIONS.to_string(),
});
}
combined
}
@@ -227,6 +269,7 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
bedrock.model_id.as_deref().unwrap_or("").to_string(),
format!("{}", bedrock.disable_prompt_caching),
bedrock.service_tier.as_deref().unwrap_or("").to_string(),
];
sha256_hex(&parts.join("|"))
} else {
@@ -312,8 +355,16 @@ fn merge_claude_code_settings(
}
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
fn compute_claude_code_settings_fingerprint(settings: Option<&ClaudeCodeSettings>) -> String {
match settings {
/// The `sandbox_enabled` flag is included so that toggling sandbox mode forces
/// a container recreation (re-injecting the merged settings.json). When
/// sandbox is off the historical fingerprint is preserved unchanged so that
/// upgrading triple-c does not spuriously flag every existing container for
/// recreation.
fn compute_claude_code_settings_fingerprint(
settings: Option<&ClaudeCodeSettings>,
sandbox_enabled: bool,
) -> String {
let base_fp = match settings {
None => String::new(),
Some(s) => {
let parts = vec![
@@ -328,30 +379,59 @@ fn compute_claude_code_settings_fingerprint(settings: Option<&ClaudeCodeSettings
];
sha256_hex(&parts.join("|"))
}
};
if sandbox_enabled {
sha256_hex(&format!("{}|sandbox=true", base_fp))
} else {
base_fp
}
}
/// Build the settings.json content for Claude Code from ClaudeCodeSettings.
/// Build the settings.json content for Claude Code.
/// Returns a JSON string of the settings to be written to ~/.claude/settings.json.
fn build_claude_code_settings_json(settings: &ClaudeCodeSettings) -> Option<String> {
/// Always emits a `sandbox.enabled` key reflecting the current per-project
/// toggle so that flipping it off in triple-c overrides any prior on-state
/// stored in the persisted settings.json (which lives in a named volume).
fn build_claude_code_settings_json(
settings: Option<&ClaudeCodeSettings>,
sandbox_enabled: bool,
) -> Option<String> {
let mut map = serde_json::Map::new();
if let Some(ref tui) = settings.tui_mode {
map.insert("tui".to_string(), serde_json::json!(tui));
}
if let Some(ref effort) = settings.effort {
map.insert("effort".to_string(), serde_json::json!(effort));
}
if settings.auto_scroll_disabled {
map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
}
if settings.focus_mode {
map.insert("focusMode".to_string(), serde_json::json!(true));
}
if settings.show_thinking_summaries {
map.insert("showThinkingSummaries".to_string(), serde_json::json!(true));
if let Some(s) = settings {
if let Some(ref tui) = s.tui_mode {
map.insert("tui".to_string(), serde_json::json!(tui));
}
if let Some(ref effort) = s.effort {
map.insert("effort".to_string(), serde_json::json!(effort));
}
if s.auto_scroll_disabled {
map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
}
if s.focus_mode {
map.insert("focusMode".to_string(), serde_json::json!(true));
}
if s.show_thinking_summaries {
map.insert("showThinkingSummaries".to_string(), serde_json::json!(true));
}
}
// Always emit `sandbox.enabled` so that toggling the per-project sandbox
// off in triple-c clears any prior on-state in the persisted
// settings.json (which lives in a named volume that survives recreation).
// Inside a Docker container we can't rely on privileged user namespaces,
// so `enableWeakerNestedSandbox` is required when sandbox is on.
let sandbox_obj = if sandbox_enabled {
serde_json::json!({
"enabled": true,
"enableWeakerNestedSandbox": true,
"allowUnsandboxedCommands": false,
})
} else {
serde_json::json!({ "enabled": false })
};
map.insert("sandbox".to_string(), sandbox_obj);
if map.is_empty() {
None
} else {
@@ -586,6 +666,13 @@ pub async fn create_container(
if bedrock.disable_prompt_caching {
env_vars.push("DISABLE_PROMPT_CACHING=1".to_string());
}
if let Some(ref tier) = bedrock.service_tier {
let trimmed = tier.trim();
if !trimmed.is_empty() {
env_vars.push(format!("ANTHROPIC_BEDROCK_SERVICE_TIER={}", trimmed));
}
}
}
}
@@ -652,6 +739,7 @@ pub async fn create_container(
project.claude_instructions.as_deref(),
&project.port_mappings,
project.mission_control_enabled,
project.sandbox_mode_enabled,
);
if let Some(ref instructions) = combined_instructions {
@@ -683,11 +771,16 @@ pub async fn create_container(
if cc.prompt_caching_1h {
env_vars.push("ENABLE_PROMPT_CACHING_1H=1".to_string());
}
}
// settings.json-based settings (written by the entrypoint)
if let Some(settings_json) = build_claude_code_settings_json(cc) {
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json));
}
// settings.json-based settings (written by the entrypoint).
// Always invoked so per-project sandbox state is injected even when no
// ClaudeCodeSettings struct is present.
if let Some(settings_json) = build_claude_code_settings_json(
merged_cc_settings.as_ref(),
project.sandbox_mode_enabled,
) {
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json));
}
let mut mounts: Vec<Mount> = Vec::new();
@@ -821,7 +914,7 @@ pub async fn create_container(
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(),
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref()));
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled));
labels.insert("triple-c.instructions-fingerprint".to_string(),
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string());
@@ -1179,6 +1272,7 @@ pub async fn container_needs_recreation(
project.claude_instructions.as_deref(),
&project.port_mappings,
project.mission_control_enabled,
project.sandbox_mode_enabled,
);
let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default();
let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default();
@@ -1192,7 +1286,7 @@ pub async fn container_needs_recreation(
global_claude_code_settings,
project.claude_code_settings.as_ref(),
);
let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref());
let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref(), project.sandbox_mode_enabled);
let container_cc_fp = get_label("triple-c.claude-code-settings-fingerprint").unwrap_or_default();
if container_cc_fp != expected_cc_fp {
log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp);

View File

@@ -73,6 +73,8 @@ pub struct Project {
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,
#[serde(default = "default_full_permissions")]
pub full_permissions: bool,
@@ -159,6 +161,10 @@ pub struct BedrockConfig {
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.
@@ -199,6 +205,7 @@ impl Project {
ollama_config: None,
openai_compatible_config: None,
allow_docker_access: false,
sandbox_mode_enabled: false,
mission_control_enabled: false,
full_permissions: false,
ssh_key_path: None,

View File

@@ -60,6 +60,7 @@ export default function ProjectCard({ project }: Props) {
const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? "");
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
const [bedrockServiceTier, setBedrockServiceTier] = useState(project.bedrock_config?.service_tier ?? "");
// Ollama local state
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
@@ -88,6 +89,7 @@ export default function ProjectCard({ project }: Props) {
setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
setBedrockModelId(project.bedrock_config?.model_id ?? "");
setBedrockServiceTier(project.bedrock_config?.service_tier ?? "");
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
setOllamaModelId(project.ollama_config?.model_id ?? "");
setOpenaiCompatibleBaseUrl(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000");
@@ -192,6 +194,7 @@ export default function ProjectCard({ project }: Props) {
aws_bearer_token: null,
model_id: null,
disable_prompt_caching: false,
service_tier: null,
};
const defaultOllamaConfig: OllamaConfig = {
@@ -339,6 +342,16 @@ export default function ProjectCard({ project }: Props) {
}
};
const handleBedrockServiceTierBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
const trimmed = bedrockServiceTier.trim();
await update({ ...project, bedrock_config: { ...current, service_tier: trimmed || null } });
} catch (err) {
console.error("Failed to update Bedrock service tier:", err);
}
};
const handleOllamaBaseUrlBlur = async () => {
try {
const current = project.ollama_config ?? defaultOllamaConfig;
@@ -692,6 +705,28 @@ export default function ProjectCard({ project }: Props) {
</button>
</div>
{/* Sandbox mode toggle */}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Sandbox mode<Tooltip text="Enables Claude Code's bash sandbox (bubblewrap-based filesystem and network isolation). Triple-C is the source of truth for the on/off state — toggling this overrides any manual /sandbox configuration in the container's settings.json on next start. Uses enableWeakerNestedSandbox since the container runs without privileged user namespaces." /></label>
<button
onClick={async () => {
try {
await update({ ...project, sandbox_mode_enabled: !project.sandbox_mode_enabled });
} catch (err) {
console.error("Failed to update sandbox mode setting:", err);
}
}}
disabled={!isStopped}
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
project.sandbox_mode_enabled
? "bg-[var(--success)] text-white"
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
{project.sandbox_mode_enabled ? "ON" : "OFF"}
</button>
</div>
{/* Mission Control toggle */}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label>
@@ -953,6 +988,19 @@ export default function ProjectCard({ project }: Props) {
className={inputCls}
/>
</div>
{/* Service tier */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Service Tier (optional)<Tooltip text="Sets ANTHROPIC_BEDROCK_SERVICE_TIER. Valid values are determined by AWS Bedrock (e.g. 'priority'). Leave blank for the account default." /></label>
<input
value={bedrockServiceTier}
onChange={(e) => setBedrockServiceTier(e.target.value)}
onBlur={handleBedrockServiceTierBlur}
placeholder="(default)"
disabled={!isStopped}
className={inputCls}
/>
</div>
</div>
);
})()}

View File

@@ -25,6 +25,7 @@ export interface Project {
ollama_config: OllamaConfig | null;
openai_compatible_config: OpenAiCompatibleConfig | null;
allow_docker_access: boolean;
sandbox_mode_enabled: boolean;
mission_control_enabled: boolean;
full_permissions: boolean;
ssh_key_path: string | null;
@@ -61,6 +62,7 @@ export interface BedrockConfig {
aws_bearer_token: string | null;
model_id: string | null;
disable_prompt_caching: boolean;
service_tier: string | null;
}
export interface OllamaConfig {

View File

@@ -31,6 +31,8 @@ RUN for i in 1 2 3 4 5; do \
pkg-config \
libssl-dev \
cron \
bubblewrap \
socat \
&& rm -rf /var/lib/apt/lists/*
# Remove default ubuntu user to free UID 1000 for host-user remapping