Files
Triple-C/app/src-tauri/src/commands/settings_commands.rs
T
shadow-testandClaude Opus 5 2de00b3c55 Fix review findings: secrets in snapshots, URL spoofing, migration data loss
Adversarial review of the branch produced findings across four areas.
This addresses them, plus the Windows CI environment.

Secrets. commit_container_snapshot baked the container's full env into
the per-project snapshot image, so the shared OAuth token — and the AWS
keys, git token and gateway master key — outlived revocation and were
readable via docker inspect. Verified against Engine 29.6 that a commit
body's config merges over the container's: keys cannot be dropped but
can be overwritten, so all of them now commit as KEY=. clear_claude_token
additionally rewrites images from earlier builds and reports honestly
when a tag could not be rewritten.

The recommendation to move the token out of env entirely was not taken,
with reasoning: apiKeyHelper is a different auth method that outranks
CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no
file-based delivery exists. The durable exposure — the image — is what
is closed here. Separately noted, not fixed: entrypoint.sh captures the
token into the scheduler's .env inside the persisted volume.

URL spoofing. Three call sites reached openUrl with container-controlled
strings, one of which the review missed (the WebLinksAddon handler).
The sign-in URL was scraped from container output with a longest-match
tie-break and no userinfo check, so claude.ai@evil.tld rendered as
"claude.ai…" in a truncating element. There is now one sanitizer in
front of every sink — scheme allowlist, no userinfo, C0/C1 and quote
rejection, host allowlist for the sign-in case, first-match — and the
origin renders un-truncated. The toast is keyed so a changed URL
remounts, closing a bait-and-switch where the user read one URL and
clicked another.

Migration. The rollback pin was best-effort: a tag failure was logged
and the migration continued past remove_container, after which the
final commit overwrote the only copy of the old system layer. It now
aborts before anything destructive and reads the tag back. /var was
destroyed while the ordinary recreate path preserves it — making the
"safe" alternative to Reset more destructive than Reset's alternative;
data-bearing subtrees are now detected and disclosed in the pre-flight
rather than copied, since tarring a live database onto a different
base's packages is a corruption risk. resume_migration now verifies the
migration-state label instead of reporting success for a container that
never swapped. dismiss actually resolves the record rather than leaving
the feature permanently refusing to migrate. Start and Reset are guarded
while a migration is live.

Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and
advertised URL are derived together so they cannot drift. Disabling it
now stops it. App exit runs teardown concurrently under a budget with a
visible shutting-down state instead of blocking for minutes. Auto-starts
retry when Docker is not up yet, and the polling-recovery path now
reconciles, so interrupted migrations are still recovered. Auth-bridge
forwards are capped, closing a container-driven fd exhaustion.

Windows CI. build-windows failed on this branch with "linker link.exe
not found". The runner had no MSVC build tools and the workflow assumed
a hand-provisioned machine, so a bare runner registers, accepts jobs and
fails at link time after downloading the whole crate graph. The job now
installs the VC++ workload when vswhere cannot find it, matching how it
already conditionally installs Rust and Node.

192 Rust tests, 274 frontend tests, both builds clean, zero warnings.

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

299 lines
10 KiB
Rust

use tauri::State;
use crate::docker;
use crate::models::gateway_settings::GatewaySettings;
use crate::models::AppSettings;
use crate::AppState;
#[tauri::command]
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
Ok(state.settings_store.get())
}
#[tauri::command]
pub async fn update_settings(
settings: AppSettings,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let before = state.settings_store.get();
let saved = state.settings_store.update(settings)?;
// Persisting a setting is not the same as applying it. The gateway is the
// one settings block that owns a *container*, so a saved change that the
// running container doesn't reflect is a live desync, not a preference.
reconcile_gateway(&before.gateway, &saved.gateway).await;
Ok(saved)
}
/// What a settings save has to do to the gateway container to stay honest.
///
/// Kept separate from the IPC command and expressed over plain settings so the
/// decision is testable without Docker.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GatewayAction {
/// Nothing to do.
None,
/// The gateway is off — a container left running must be stopped.
StopIfRunning,
/// The published shape moved. A *running* container is now serving on the
/// old binding while status reports the new one, so it has to be recreated.
RestartIfRunning,
}
/// Whether the container's published shape (as opposed to a purely cosmetic
/// field) changed. Provider, models and base URL all change the rendered
/// LiteLLM config, which is only read at boot.
fn gateway_shape_changed(before: &GatewaySettings, after: &GatewaySettings) -> bool {
before.port != after.port
|| before.provider.trim() != after.provider.trim()
|| before.api_base.as_deref().unwrap_or("").trim()
!= after.api_base.as_deref().unwrap_or("").trim()
|| before.valid_models() != after.valid_models()
}
fn gateway_action(before: &GatewaySettings, after: &GatewaySettings) -> GatewayAction {
if !after.enabled {
// Includes the case where it was already disabled: a container found
// running while the feature is off should not stay up.
return GatewayAction::StopIfRunning;
}
if gateway_shape_changed(before, after) {
return GatewayAction::RestartIfRunning;
}
GatewayAction::None
}
/// Apply [`gateway_action`]. Never fails the settings save: the settings *are*
/// saved by this point, and a Docker hiccup must not make the UI think they
/// weren't. Both paths are no-ops when no container exists, so this stays cheap
/// on the overwhelmingly common "gateway not in use" save.
async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
let action = gateway_action(before, after);
if action == GatewayAction::None {
return;
}
let (exists, running) = match docker::gateway::gateway_container_presence().await {
Ok(presence) => presence,
// Docker down: there is nothing running to desync from.
Err(e) => {
log::debug!("Gateway reconcile skipped ({})", e);
return;
}
};
if !exists || !running {
return;
}
match action {
GatewayAction::StopIfRunning => {
log::info!("Model gateway disabled in settings — stopping the container");
if let Err(e) = docker::gateway::stop_gateway_container().await {
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
}
}
GatewayAction::RestartIfRunning => {
log::info!("Model gateway settings changed — recreating the container");
// The fingerprint no longer matches, so this stops, removes and
// recreates with the new port/config in one step.
if let Err(e) = docker::gateway::ensure_gateway_running(after).await {
log::error!("Failed to apply the new model gateway settings: {}", e);
}
}
GatewayAction::None => unreachable!(),
}
}
#[tauri::command]
pub async fn pull_image(
image_name: String,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
use tauri::Emitter;
docker::pull_image(&image_name, move |msg| {
let _ = app_handle.emit("image-pull-progress", msg);
})
.await
}
#[tauri::command]
pub async fn detect_host_timezone() -> Result<String, String> {
// Try the iana-time-zone crate first (cross-platform)
match iana_time_zone::get_timezone() {
Ok(tz) => return Ok(tz),
Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e),
}
// Fallback: check TZ env var
if let Ok(tz) = std::env::var("TZ") {
if !tz.is_empty() {
return Ok(tz);
}
}
// Fallback: read /etc/timezone (Linux)
if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
let tz = tz.trim().to_string();
if !tz.is_empty() {
return Ok(tz);
}
}
// Default to UTC if detection fails
Ok("UTC".to_string())
}
#[tauri::command]
pub async fn detect_aws_config() -> Result<Option<String>, String> {
if let Some(home) = dirs::home_dir() {
let aws_dir = home.join(".aws");
if aws_dir.exists() {
return Ok(Some(aws_dir.to_string_lossy().to_string()));
}
}
Ok(None)
}
#[tauri::command]
pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
let mut profiles = Vec::new();
let home = match dirs::home_dir() {
Some(h) => h,
None => return Ok(profiles),
};
// Parse ~/.aws/credentials
let credentials_path = home.join(".aws").join("credentials");
if credentials_path.exists() {
if let Ok(contents) = std::fs::read_to_string(&credentials_path) {
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let profile = trimmed[1..trimmed.len() - 1].to_string();
if !profiles.contains(&profile) {
profiles.push(profile);
}
}
}
}
}
// Parse ~/.aws/config (profiles are prefixed with "profile ")
let config_path = home.join(".aws").join("config");
if config_path.exists() {
if let Ok(contents) = std::fs::read_to_string(&config_path) {
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let section = &trimmed[1..trimmed.len() - 1];
let profile = if let Some(name) = section.strip_prefix("profile ") {
name.to_string()
} else {
section.to_string()
};
if !profiles.contains(&profile) {
profiles.push(profile);
}
}
}
}
}
Ok(profiles)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::gateway_settings::GatewayModel;
fn enabled_gateway() -> GatewaySettings {
GatewaySettings {
enabled: true,
port: 4000,
provider: "openai".to_string(),
api_base: None,
models: vec![GatewayModel {
name: "gpt-5.1".to_string(),
model_id: "gpt-5.1".to_string(),
}],
}
}
#[test]
fn disabling_the_gateway_stops_it() {
// The bug: turning the toggle off only persisted `enabled: false` and
// hid the Stop button, leaving a container serving with no way to stop
// it.
let before = enabled_gateway();
let mut after = before.clone();
after.enabled = false;
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
// Still true when it was already off — a stray running container is
// still a container that shouldn't be up.
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
}
#[test]
fn changing_the_port_reconciles_the_container() {
// Otherwise status reports the new port while the container keeps the
// old binding, and every project gets a broken ANTHROPIC_BASE_URL.
let before = enabled_gateway();
let mut after = before.clone();
after.port = 4100;
assert_eq!(
gateway_action(&before, &after),
GatewayAction::RestartIfRunning
);
}
#[test]
fn config_changes_that_only_take_effect_at_boot_reconcile_too() {
let before = enabled_gateway();
let mut provider = before.clone();
provider.provider = "groq".to_string();
assert_eq!(
gateway_action(&before, &provider),
GatewayAction::RestartIfRunning
);
let mut api_base = before.clone();
api_base.api_base = Some("https://example.test/v1".to_string());
assert_eq!(
gateway_action(&before, &api_base),
GatewayAction::RestartIfRunning
);
let mut models = before.clone();
models.models[0].model_id = "gpt-4.1".to_string();
assert_eq!(
gateway_action(&before, &models),
GatewayAction::RestartIfRunning
);
}
#[test]
fn saving_an_unchanged_or_half_typed_gateway_touches_nothing() {
let before = enabled_gateway();
assert_eq!(gateway_action(&before, &before), GatewayAction::None);
// Whitespace-only edits don't reach the rendered config.
let mut trimmed = before.clone();
trimmed.provider = " openai ".to_string();
trimmed.api_base = Some(" ".to_string());
assert_eq!(gateway_action(&before, &trimmed), GatewayAction::None);
// A half-filled model row is skipped when rendering, so it must not
// bounce a live container either.
let mut half_typed = before.clone();
half_typed.models.push(GatewayModel {
name: "gpt".to_string(),
model_id: String::new(),
});
assert_eq!(gateway_action(&before, &half_typed), GatewayAction::None);
}
}