Files
Triple-C/app/src-tauri/src/commands/terminal_commands.rs
T
shadow-testandClaude Opus 5 0ac4e5030c Add permission modes and container introspection backend
Permission modes: replaces the binary full_permissions flag with a
PermissionMode enum (Plan/Default/AcceptEdits/Bypass). Flag mapping is
defined once in PermissionMode::cli_args() and used by the terminal, the
web terminal, and the scheduler:
  Plan        -> --permission-mode plan
  Default     -> (no flag)
  AcceptEdits -> --permission-mode acceptEdits
  Bypass      -> --dangerously-skip-permissions
Choices verified against `claude --permission-mode` on 2.1.226.

full_permissions is retained and effective_permission_mode() falls back
to it, so existing projects.json needs no migration.

Bug fix: triple-c-task-runner ran `claude -p ... --dangerously-skip-
permissions` unconditionally, ignoring the project's setting entirely.
It now reads TRIPLE_C_PERMISSION_MODE, which is injected into the
container, added to the reserved env blocklist, propagated through the
entrypoint's cron env filter, and tracked by a new
triple-c.permission-mode label so a change forces recreation.

Introspection: new commands/inspect_commands.rs exposes read-only views
into the container over docker exec — Claude sessions (parsed from
~/.claude/projects/<cwd>/<uuid>.jsonl), installed capabilities (skills,
agents, commands, hooks, plugins, natively-configured MCP servers), and
the triple-c-scheduler task list, logs and notifications.

Task/session ids are validated against a strict allowlist and every
parameterized call runs as a bare argv vector via bollard, so no shell
is involved. Stopped containers return empty results rather than errors.

No UI yet; that lands with the Project Home view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:51:34 -07:00

286 lines
9.3 KiB
Rust

use tauri::{AppHandle, Emitter, State};
use crate::commands::aws_commands;
use crate::models::{Backend, BedrockAuthMethod, Project};
use crate::AppState;
/// Build the command to run in the container terminal.
///
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
.bedrock_config
.as_ref()
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
.unwrap_or(false);
let permission_args = project.effective_permission_mode().cli_args();
if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()];
cmd.extend(permission_args);
if let Some(name) = session_name {
if !name.is_empty() {
cmd.push("-n".to_string());
cmd.push(name.to_string());
}
}
return cmd;
}
let profile = aws_commands::resolve_profile_for_project(
project,
state.settings_store.get().global_aws.aws_profile.as_deref(),
);
// Build a bash wrapper that validates credentials, re-auths if needed,
// then exec's into claude.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
.unwrap_or_default();
// The args are interpolated into a shell script string, so single-quote
// each one (same escaping style as name_flag above).
let permission_flags: String = permission_args
.iter()
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
.collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
let script = format!(
r#"
echo "Validating AWS session for profile '{profile}'..."
if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then
echo "AWS session valid."
else
echo "AWS session expired or invalid."
# Check if this profile uses SSO (has sso_start_url or sso_session configured)
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \
aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then
echo "Starting SSO login..."
echo ""
triple-c-sso-refresh
if [ $? -ne 0 ]; then
echo ""
echo "SSO login failed or was cancelled. Starting Claude anyway..."
echo "You may see authentication errors."
echo ""
fi
else
echo "Profile '{profile}' does not use SSO. Check your AWS credentials."
echo "Starting Claude anyway..."
echo ""
fi
fi
{claude_cmd}
"#,
profile = profile,
claude_cmd = claude_cmd
);
vec![
"bash".to_string(),
"-c".to_string(),
script,
]
}
#[tauri::command]
pub async fn open_terminal_session(
project_id: String,
session_id: String,
session_type: Option<String>,
session_name: Option<String>,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
let cmd = match session_type.as_deref() {
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
};
let output_event = format!("terminal-output-{}", session_id);
let exit_event = format!("terminal-exit-{}", session_id);
let app_handle_output = app_handle.clone();
let app_handle_exit = app_handle.clone();
state
.exec_manager
.create_session(
container_id,
&session_id,
cmd,
move |data| {
let _ = app_handle_output.emit(&output_event, data);
},
Box::new(move || {
let _ = app_handle_exit.emit(&exit_event, ());
}),
)
.await
}
#[tauri::command]
pub async fn terminal_input(
session_id: String,
data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<(), String> {
state.exec_manager.send_input(&session_id, data).await
}
#[tauri::command]
pub async fn terminal_resize(
session_id: String,
cols: u16,
rows: u16,
state: State<'_, AppState>,
) -> Result<(), String> {
state.exec_manager.resize(&session_id, cols, rows).await
}
#[tauri::command]
pub async fn close_terminal_session(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
// Close audio bridge if it exists
let audio_session_id = format!("audio-{}", session_id);
state.exec_manager.close_session(&audio_session_id).await;
// Close terminal session
state.exec_manager.close_session(&session_id).await;
Ok(())
}
#[tauri::command]
pub async fn paste_image_to_terminal(
session_id: String,
image_data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let file_name = format!("clipboard_{}.png", timestamp);
state
.exec_manager
.write_file_to_container(&container_id, &file_name, &image_data)
.await
}
/// Copy a host file (e.g. dragged onto the terminal) into the container so
/// Claude Code can read it, and return the in-container path. Mirrors the
/// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its
/// original name. Returns an error for paths that aren't readable regular files
/// (e.g. a dropped directory).
#[tauri::command]
pub async fn upload_host_file_to_terminal(
session_id: String,
host_path: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let meta = tokio::fs::metadata(&host_path)
.await
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
if meta.is_dir() {
return Err(format!("{} is a directory — drop individual files", host_path));
}
// Guard against ballooning host RAM: the file is packed into an in-memory
// tar before upload, so cap the size of a dropped file.
const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
if meta.len() > MAX_DROP_BYTES {
return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
meta.len() as f64 / (1024.0 * 1024.0),
MAX_DROP_BYTES / (1024 * 1024)
));
}
let base = std::path::Path::new(&host_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "dropped-file".to_string());
// Ensure the destination directory exists rather than relying on Docker's
// archive extractor to create the parent for the uploaded tar entry.
crate::docker::exec::exec_oneshot(
&container_id,
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
)
.await?;
let file_name = format!("triple-c-drops/{}", base);
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
}
#[tauri::command]
pub async fn start_audio_bridge(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
// Get container_id from the terminal session
let container_id = state.exec_manager.get_container_id(&session_id).await?;
// Create audio bridge exec session with ID "audio-{session_id}"
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
let audio_session_id = format!("audio-{}", session_id);
let cmd = vec![
"bash".to_string(),
"-c".to_string(),
"FIFO=/tmp/triple-c-audio-input; [ -p \"$FIFO\" ] || mkfifo \"$FIFO\"; trap '' PIPE; while true; do cat > \"$FIFO\" 2>/dev/null; sleep 0.1; done".to_string(),
];
state
.exec_manager
.create_session_with_tty(
&container_id,
&audio_session_id,
cmd,
false,
|_data| { /* ignore output from the audio bridge */ },
Box::new(|| { /* no exit handler needed */ }),
)
.await
}
#[tauri::command]
pub async fn send_audio_data(
session_id: String,
data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<(), String> {
let audio_session_id = format!("audio-{}", session_id);
state.exec_manager.send_input(&audio_session_id, data).await
}
#[tauri::command]
pub async fn stop_audio_bridge(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let audio_session_id = format!("audio-{}", session_id);
state.exec_manager.close_session(&audio_session_id).await;
Ok(())
}