Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m58s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m4s
Build App / build-windows (push) Successful in 5m48s
Build App / build-linux (push) Successful in 5m10s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 2m27s
Adds Claude Code's auto permission mode as a fifth option between Accept Edits and Bypass, across terminals, resumed sessions, the tab badge, the scheduler task runner and docs. Warns that Auto falls back to prompting when unavailable for the model/backend. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
533 lines
20 KiB
Rust
533 lines
20 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.
|
|
///
|
|
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
|
|
/// before `exec claude`. For Bedrock Profile projects the script additionally
|
|
/// validates the AWS session first, and runs `aws sso login` if it has expired
|
|
/// 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 settings = state.settings_store.get();
|
|
build_claude_terminal_cmd(
|
|
project,
|
|
settings.global_aws.aws_profile.as_deref(),
|
|
session_name,
|
|
)
|
|
}
|
|
|
|
/// Shell line run immediately before `exec claude` in every Claude terminal
|
|
/// session.
|
|
///
|
|
/// `container/entrypoint.sh` already runs `claude update` when the container
|
|
/// starts, but containers here use a stop/start (and often just keep running)
|
|
/// model, so a long-lived container's CLI goes stale between restarts. Running
|
|
/// it per session is what keeps a week-old container current.
|
|
///
|
|
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
|
|
/// network, npm registry down) so a session always opens, and `timeout 60`
|
|
/// bounds how long a user waits for a terminal.
|
|
///
|
|
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
|
|
/// the entrypoint's own `claude update`, and the entrypoint prints "container
|
|
/// ready" only *after* its copy finishes — so "start the project, open a tab"
|
|
/// races two updaters against the same `~/.claude/bin` install, as does
|
|
/// opening two tabs at once. `|| echo` would then hide a half-written install
|
|
/// behind a friendly message and the very next line (`exec claude`) would run
|
|
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
|
|
/// than failing the wait, and `-E 0` makes losing the race a success: the
|
|
/// other holder just updated, so there is nothing left to do.
|
|
pub(crate) const UPDATE_PRELUDE: &str = concat!(
|
|
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
|
|
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
|
|
);
|
|
|
|
/// Single-quote one argument for interpolation into a shell script string.
|
|
fn shell_quote_arg(arg: &str) -> String {
|
|
format!(" '{}'", arg.replace('\'', "'\\''"))
|
|
}
|
|
|
|
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
|
|
/// profile rather than the whole [`AppState`].
|
|
fn build_claude_terminal_cmd(
|
|
project: &Project,
|
|
global_aws_profile: Option<&str>,
|
|
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();
|
|
|
|
// The args are interpolated into a shell script string, so single-quote
|
|
// each one.
|
|
let name_flag = session_name
|
|
.filter(|n| !n.is_empty())
|
|
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
|
.unwrap_or_default();
|
|
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
|
|
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
|
|
|
if !is_bedrock_profile {
|
|
return vec![
|
|
"bash".to_string(),
|
|
"-c".to_string(),
|
|
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
|
|
];
|
|
}
|
|
|
|
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
|
|
|
|
// Build a bash wrapper that validates credentials, re-auths if needed,
|
|
// then exec's into claude.
|
|
|
|
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
|
|
{update_prelude}
|
|
{claude_cmd}
|
|
"#,
|
|
profile = profile,
|
|
update_prelude = UPDATE_PRELUDE,
|
|
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> {
|
|
// The drop target is a host path chosen by the webview, not by the OS drag
|
|
// itself, so it goes through `file_commands`' host-read policy: absolute,
|
|
// no traversal, and nothing whose path passes through a hidden directory
|
|
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
|
// the path with its symlinks already resolved, so a visible directory that
|
|
// *leads* to one of those is refused too. What comes back is that resolved
|
|
// path, and it is what gets opened. Four commands touch a host path now,
|
|
// but only two take it *over IPC*: this one and `download_container_backup`.
|
|
// The Files pane's `download_container_file` and `upload_files_to_container`
|
|
// open their dialog from Rust instead, so for them the policy above is
|
|
// defence in depth and for these two it is the boundary itself.
|
|
// The name is taken from the path the user actually dropped, *before*
|
|
// resolution. Deriving it from the resolved path renames the file behind
|
|
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
|
// a symlink, would land it in the container as `2026-08-23.log`.
|
|
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
|
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
|
|
|
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))?;
|
|
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
|
// regular file, reports `len() == 0`, and passes both the directory check
|
|
// and the size cap below — and `std::fs::File::open` on one blocks forever
|
|
// with no writer, with no timeout anywhere on this path. The upload then
|
|
// never returns, the toast sticks on "Adding N files…" for the session and
|
|
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
|
// shape. This is one of two routes for getting a host file into a
|
|
// container (the Files pane's upload is the other), so it is the wrong
|
|
// place to be clever.
|
|
if !meta.is_file() {
|
|
return Err(if meta.is_dir() {
|
|
format!("{} is a directory — drop individual files", host_path)
|
|
} else {
|
|
format!(
|
|
"{} is not a regular file — only ordinary files can be dropped into a terminal",
|
|
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. The ceiling lives
|
|
// with the code that does the reading, which re-applies it to the open
|
|
// descriptor — this check is here only so the refusal reads like a sentence
|
|
// instead of arriving after a 300 MB read.
|
|
use crate::docker::exec::MAX_DROP_BYTES;
|
|
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 instead.",
|
|
meta.len() as f64 / (1024.0 * 1024.0),
|
|
MAX_DROP_BYTES / (1024 * 1024)
|
|
));
|
|
}
|
|
|
|
|
|
|
|
// 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,
|
|
"/tmp",
|
|
&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(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
|
|
use crate::models::Project;
|
|
|
|
/// A dropped file must be named the way the *user* named it.
|
|
///
|
|
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
|
/// name from the path *after* symlink resolution, so dropping
|
|
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
|
|
/// `2026-08-23.log` — silently landed the file in the container under the
|
|
/// target's name. Nothing errored; the user just got a name they never
|
|
/// typed.
|
|
///
|
|
/// This asserts the shared helper's contract from the terminal side: the
|
|
/// answer comes from the spelling, and a path that does not name a file is
|
|
/// refused rather than silently substituted (it used to fall back to
|
|
/// `"dropped-file"`).
|
|
/// A `Project` with only the fields these tests care about set; the rest
|
|
/// come through serde so the test does not have to track every field.
|
|
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
|
|
serde_json::from_value(serde_json::json!({
|
|
"id": "p1",
|
|
"name": "Test",
|
|
"paths": [],
|
|
"container_id": null,
|
|
"status": "running",
|
|
"backend": backend,
|
|
"bedrock_config": bedrock_config,
|
|
"ollama_config": null,
|
|
"openai_compatible_config": null,
|
|
"allow_docker_access": false,
|
|
"full_permissions": false,
|
|
"ssh_key_path": null,
|
|
"git_user_name": null,
|
|
"git_user_email": null,
|
|
"created_at": "now",
|
|
"updated_at": "now"
|
|
}))
|
|
.expect("test project deserializes")
|
|
}
|
|
|
|
/// Every Claude session updates the CLI before launching it.
|
|
///
|
|
/// `container/entrypoint.sh` only updates at container *start*, and these
|
|
/// containers are long-lived, so a stale CLI is the normal case without
|
|
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
|
|
/// wrapper rather than a bare `claude` argv.
|
|
#[test]
|
|
fn build_terminal_cmd_updates_before_launching_claude() {
|
|
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
|
|
|
|
assert_eq!(cmd[0], "bash");
|
|
assert_eq!(cmd[1], "-c");
|
|
assert!(
|
|
cmd[2].contains(UPDATE_PRELUDE),
|
|
"plain path must run the update prelude: {}",
|
|
cmd[2]
|
|
);
|
|
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
|
|
// The update has to happen *before* the exec, which never returns.
|
|
assert!(
|
|
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
|
|
"prelude must precede the exec: {}",
|
|
cmd[2]
|
|
);
|
|
assert!(
|
|
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
|
|
"the update must stay time-bounded and non-fatal"
|
|
);
|
|
}
|
|
|
|
/// The session name is interpolated into a shell script, so a quote in it
|
|
/// must not break out of its single-quoted argument.
|
|
#[test]
|
|
fn build_terminal_cmd_escapes_a_quoted_session_name() {
|
|
let cmd = build_claude_terminal_cmd(
|
|
&project("anthropic", serde_json::Value::Null),
|
|
None,
|
|
Some("Bob's tab; rm -rf /"),
|
|
);
|
|
|
|
assert!(
|
|
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
|
|
"session name must be single-quote escaped: {}",
|
|
cmd[2]
|
|
);
|
|
}
|
|
|
|
/// Permission flags travel the same escaped path, and an empty name adds
|
|
/// no `-n` at all.
|
|
#[test]
|
|
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
|
|
let mut p = project("anthropic", serde_json::Value::Null);
|
|
p.full_permissions = true;
|
|
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
|
|
|
|
assert!(
|
|
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
|
|
"got: {}",
|
|
cmd[2]
|
|
);
|
|
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
|
|
}
|
|
|
|
/// Auto mode is passed as a `--permission-mode` value, not its own flag.
|
|
#[test]
|
|
fn build_terminal_cmd_passes_auto_permission_mode() {
|
|
let mut p = project("anthropic", serde_json::Value::Null);
|
|
p.permission_mode = Some(crate::models::project::PermissionMode::Auto);
|
|
let cmd = build_claude_terminal_cmd(&p, None, None);
|
|
|
|
assert!(
|
|
cmd[2].contains("exec claude '--permission-mode' 'auto'"),
|
|
"got: {}",
|
|
cmd[2]
|
|
);
|
|
}
|
|
|
|
/// The Bedrock-profile path keeps its AWS validation *and* gains the
|
|
/// prelude, immediately before the exec.
|
|
#[test]
|
|
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
|
|
let cmd = build_claude_terminal_cmd(
|
|
&project("bedrock", serde_json::json!({
|
|
"auth_method": "profile",
|
|
"aws_region": "us-east-1",
|
|
"aws_profile": "acme",
|
|
"model_id": null,
|
|
"disable_prompt_caching": false
|
|
})),
|
|
None,
|
|
Some("it's fine"),
|
|
);
|
|
|
|
assert_eq!(cmd[0], "bash");
|
|
let script = &cmd[2];
|
|
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
|
|
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
|
|
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
|
|
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
|
|
assert!(
|
|
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
|
|
"prelude must precede the exec: {}",
|
|
script
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
|
use crate::commands::file_commands::host_upload_name;
|
|
|
|
assert_eq!(
|
|
host_upload_name("/home/u/Downloads/latest.log").unwrap(),
|
|
"latest.log"
|
|
);
|
|
assert!(
|
|
host_upload_name("/home/u/Downloads/").is_err(),
|
|
"a directory is not a file to drop"
|
|
);
|
|
assert!(
|
|
host_upload_name("/home/u/..").is_err(),
|
|
"the name becomes a tar entry, a container path and an argv element"
|
|
);
|
|
}
|
|
}
|