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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::gateway_settings::GatewaySettings;
|
||||
use crate::models::AppSettings;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -14,7 +15,94 @@ pub async fn update_settings(
|
||||
settings: AppSettings,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
state.settings_store.update(settings)
|
||||
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]
|
||||
@@ -115,3 +203,96 @@ pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user