Add password-encrypted settings export/import #40

Merged
jknapp merged 4 commits from feat/settings-export-import into main 2026-08-27 21:53:42 +00:00
21 changed files with 1360 additions and 9 deletions
Showing only changes of commit 722d9aeff1 - Show all commits
+34
View File
@@ -552,6 +552,40 @@ survived 92 commits and fourteen days in the public GitHub mirror, past five aud
independent reviews, because every one of them read the code under change and this sat in a test
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
## Settings export/import
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
(triple-c#35). Exports the *host* environment — global `AppSettings` (already the non-secret
shape persisted to `settings.json`) plus the global secrets that live in the OS keychain instead:
the shared Claude Code OAuth login and the model gateway's two keys. Per-project settings,
per-project secrets, and anything in a project's Docker volumes are deliberately out of scope —
this is not a project backup.
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
encryption. A wrong password fails GCM's authentication tag rather than producing silent
garbage. The salt and nonce are not secret and are written in the clear in the file's own
header — the salt's job is only to make two exports of the same password derive different keys,
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
every export already gives it.
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
handing Rust a host path string is the exact shape of bug that produced this app's past
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
a path ever crossing back over IPC.
- **The password is re-entered, not cached, between preview and apply.** Nothing here holds
decrypted plaintext — secrets included — in memory for longer than one command's execution.
`preview_settings_import` returns counts and presence flags only (`SettingsImportPreview`),
never a secret value, so it's safe to hand to the frontend and render directly.
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
An import is "restore this environment," so the settings half is a full replace, not a
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
"the source machine never had this configured," not "delete this on import" — a user who wants
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
gateway key).
## Testing
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
+143
View File
@@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -47,6 +82,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -280,6 +327,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -310,6 +363,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -569,6 +631,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -753,6 +826,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.20.11"
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1550,6 +1633,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gio"
version = "0.18.4"
@@ -2114,6 +2207,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2831,6 +2933,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.3"
@@ -2913,6 +3021,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3194,6 +3313,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -5149,6 +5280,8 @@ dependencies = [
name = "triple-c"
version = "0.4.0"
dependencies = [
"aes-gcm",
"argon2",
"axum",
"base64 0.22.1",
"bollard",
@@ -5287,6 +5420,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
+2
View File
@@ -36,6 +36,8 @@ tower-http = { version = "0.6", features = ["cors"] }
base64 = "0.22"
rand = "0.9"
local-ip-address = "0.6"
argon2 = "0.5"
aes-gcm = "0.10"
[dev-dependencies]
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
+1
View File
@@ -10,6 +10,7 @@ pub mod install_helper_commands;
pub mod migration_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod settings_export_commands;
pub mod stt_commands;
pub mod terminal_commands;
pub mod update_commands;
@@ -0,0 +1,232 @@
//! Settings export/import — see triple-c#35.
//!
//! Exports the *host* environment (global `AppSettings` plus the global
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
//! the model gateway's two keys), encrypted with a user-chosen password —
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
//! out of scope: per-project settings, per-project secrets, and anything
//! living in a project's Docker volumes.
//!
//! **The save/open dialogs are opened from Rust**, the same pattern
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
//! establish and document at length: a frontend-driven dialog handing Rust a
//! host path string is the exact shape of bug that produced this app's past
//! criticals, so the boundary here is drawn the same place. The frontend can
//! ask for a picker; it cannot name a host path as an *input*. `preview_
//! settings_import` resolves the chosen path itself and remembers it
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
//! the same file without the path ever crossing back over IPC.
//!
//! The password is re-entered (not cached) between preview and apply, so
//! that nothing here holds decrypted plaintext — export/import secrets
//! included — in memory for longer than one command's execution.
use std::path::PathBuf;
use tauri::State;
use tauri_plugin_dialog::DialogExt;
use crate::models::{
ExportedSecrets, SettingsExportPayload, SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
};
use crate::storage::{secure, settings_crypto};
use crate::AppState;
const FILE_EXTENSION: &str = "triplec";
fn suggested_export_name() -> String {
// Timestamped so exporting more than once doesn't silently overwrite an
// earlier file just because the save dialog defaults to the same name.
format!(
"triple-c-settings-{}.{}",
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
FILE_EXTENSION
)
}
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
let (tx, rx) = tokio::sync::oneshot::channel();
window
.dialog()
.file()
.set_parent(window)
.set_title("Export Triple-C settings")
.set_file_name(suggested)
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
.save_file(move |picked| {
let _ = tx.send(picked);
});
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
}
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
let (tx, rx) = tokio::sync::oneshot::channel();
window
.dialog()
.file()
.set_parent(window)
.set_title("Import Triple-C settings")
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
.pick_file(move |picked| {
let _ = tx.send(picked);
});
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
}
/// Gather the current global secrets. A missing secret reads as `None` — a
/// keychain read failure is treated as "nothing to export" for that one
/// entry rather than aborting the whole export, matching how the rest of
/// this app degrades a keychain error to "absent" (`has_claude_oauth_token`,
/// `has_gateway_api_key`) rather than surfacing it as a hard failure.
fn gather_secrets() -> ExportedSecrets {
ExportedSecrets {
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
}
}
/// Export the current global settings and secrets to a password-encrypted
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
/// deliberately distinguishable from one so the frontend shows nothing
/// rather than a "failed" toast for a plain cancel.
#[tauri::command]
pub async fn export_settings(
password: String,
window: tauri::Window,
state: State<'_, AppState>,
) -> Result<bool, String> {
if password.is_empty() {
return Err("A password is required to export settings.".to_string());
}
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
return Ok(false);
};
let secrets = gather_secrets();
if secrets.is_empty() {
log::info!("Exporting settings with no global secrets configured on this machine");
}
let payload = SettingsExportPayload {
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
exported_at: chrono::Utc::now().to_rfc3339(),
app_version: env!("CARGO_PKG_VERSION").to_string(),
settings: state.settings_store.get(),
secrets,
};
let plaintext = serde_json::to_vec(&payload)
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?;
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
std::fs::write(&dest, encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
Ok(true)
}
/// Open a file picker, decrypt the chosen file with `password`, and return a
/// preview (counts and presence flags only — never a secret value) for a
/// confirmation UI. `Ok(None)` means the picker was dismissed.
///
/// Remembers the resolved path in `AppState::pending_settings_import` for
/// `apply_settings_import` to re-read; does **not** remember the decrypted
/// payload itself, so the password must be supplied again to actually apply
/// it — seeing the preview is not the same as committing to it.
#[tauri::command]
pub async fn preview_settings_import(
password: String,
window: tauri::Window,
state: State<'_, AppState>,
) -> Result<Option<SettingsImportPreview>, String> {
if password.is_empty() {
return Err("A password is required to open a settings export.".to_string());
}
let Some(path) = pick_import_open_path(&window).await else {
return Ok(None);
};
let payload = read_and_decrypt(&path, &password)?;
let preview = SettingsImportPreview::from_payload(&payload);
*state.pending_settings_import.lock().await = Some(path);
Ok(Some(preview))
}
/// Apply the import a prior `preview_settings_import` call resolved a path
/// for. Fails if no preview is pending — this is not a general "decrypt and
/// apply this file" entry point, deliberately: seeing the preview first is
/// required, not just encouraged, since it is the only place a user is told
/// what an import is about to touch before it touches it.
///
/// Global settings are replaced wholesale — an import is "restore this
/// environment," not a field-by-field merge. Global secrets are handled
/// differently and on purpose: **only secrets actually present in the
/// import are written**; a secret the export doesn't have is left alone on
/// this machine rather than cleared, because an absent secret in the export
/// means "the source machine never had this configured," not "delete this
/// on import." A user who wants to clear a secret already has dedicated UI
/// for that (signing out of shared auth, clearing the gateway key).
#[tauri::command]
pub async fn apply_settings_import(
password: String,
state: State<'_, AppState>,
) -> Result<crate::models::AppSettings, String> {
if password.is_empty() {
return Err("A password is required to import settings.".to_string());
}
let path = state
.pending_settings_import
.lock()
.await
.take()
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
let payload = read_and_decrypt(&path, &password)?;
let saved = crate::commands::settings_commands::update_settings(payload.settings, state).await?;
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
if let Err(e) = secure::store_claude_oauth_token(&token) {
log::warn!("Settings import: could not restore the shared Claude login: {}", e);
}
}
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
if let Err(e) = secure::store_gateway_api_key(&key) {
log::warn!("Settings import: could not restore the gateway provider API key: {}", e);
}
}
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
if let Err(e) = secure::store_gateway_master_key(&key) {
log::warn!("Settings import: could not restore the gateway master key: {}", e);
}
}
Ok(saved)
}
fn non_blank(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
fn read_and_decrypt(path: &std::path::Path, password: &str) -> Result<SettingsExportPayload, String> {
let encrypted = std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
let plaintext = settings_crypto::decrypt(&encrypted, password)?;
let payload: SettingsExportPayload = serde_json::from_slice(&plaintext)
.map_err(|e| format!("This file doesn't look like a valid settings export: {}", e))?;
if payload.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
return Err(format!(
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
Update Triple-C before importing it.",
payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION
));
}
Ok(payload)
}
+14
View File
@@ -29,6 +29,15 @@ pub struct AppState {
pub auth_bridge: Arc<AuthBridgeManager>,
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
pub lifecycle: Arc<Lifecycle>,
/// The file `preview_settings_import` last decrypted successfully, held
/// so `apply_settings_import` can re-read and re-decrypt the same file
/// without the frontend ever passing a host path back to Rust as an
/// argument — see the doc comment on `commands::settings_export_commands`
/// for why that direction specifically is the one this app treats as
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
/// nothing here holds a decrypted secret in memory for longer than one
/// command's execution.
pub pending_settings_import: Arc<tokio::sync::Mutex<Option<std::path::PathBuf>>>,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -222,6 +231,7 @@ pub fn run() {
auth_bridge,
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
lifecycle,
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
})
.setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
@@ -494,6 +504,10 @@ pub fn run() {
commands::settings_commands::inspect_ca_cert_path,
commands::settings_commands::list_aws_profiles,
commands::settings_commands::detect_host_timezone,
// Settings export/import
commands::settings_export_commands::export_settings,
commands::settings_export_commands::preview_settings_import,
commands::settings_export_commands::apply_settings_import,
// Terminal
commands::terminal_commands::open_terminal_session,
commands::terminal_commands::terminal_input,
+2
View File
@@ -3,6 +3,7 @@ pub mod container_config;
pub mod app_settings;
pub mod gateway_settings;
pub mod migration;
pub mod settings_export;
pub mod update_info;
pub use project::*;
@@ -10,4 +11,5 @@ pub use container_config::*;
pub use app_settings::*;
pub use gateway_settings::*;
pub use migration::*;
pub use settings_export::*;
pub use update_info::*;
+180
View File
@@ -0,0 +1,180 @@
//! Settings export/import — see triple-c#35.
//!
//! `SettingsExportPayload` is the whole plaintext export before encryption
//! and after decryption (see `storage::settings_crypto`). It bundles
//! `AppSettings` (already the non-secret shape persisted to `settings.json`)
//! with the global secrets that live in the OS keychain instead — the shared
//! Claude Code OAuth login and the model gateway's two keys. Per-project
//! settings, per-project secrets, and anything living in a project's Docker
//! volumes are deliberately out of scope: this exports the *host*
//! environment, not any one project's.
use serde::{Deserialize, Serialize};
use super::AppSettings;
/// Bumped when the shape of [`SettingsExportPayload`] changes in a way that
/// isn't just an additive, `#[serde(default)]`-covered field — e.g. if a
/// field is ever removed or its meaning changes. `apply_settings_import`
/// checks this before touching anything.
pub const SETTINGS_EXPORT_FORMAT_VERSION: u32 = 1;
/// The global secrets bundled into an export. Deliberately a separate struct
/// from `AppSettings`: these live in the OS keychain, never in
/// `settings.json`, and — outside of this export/import flow — the values
/// themselves never cross into the frontend; see the doc comments on
/// `storage::secure::get_gateway_api_key` and
/// `commands::settings_export_commands` for why that boundary matters here
/// too.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExportedSecrets {
#[serde(default)]
pub claude_oauth_token: Option<String>,
#[serde(default)]
pub gateway_api_key: Option<String>,
#[serde(default)]
pub gateway_master_key: Option<String>,
}
impl ExportedSecrets {
pub fn is_empty(&self) -> bool {
self.claude_oauth_token.is_none()
&& self.gateway_api_key.is_none()
&& self.gateway_master_key.is_none()
}
}
/// The full plaintext payload — this is what gets encrypted on export and
/// what decryption recovers on import. Never written to disk unencrypted;
/// see `storage::settings_crypto`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsExportPayload {
pub format_version: u32,
/// RFC3339. Purely informational — shown in the import preview so a user
/// picking between a few old export files has something to go on.
pub exported_at: String,
/// The exporting app's `CARGO_PKG_VERSION`. Also informational: every
/// field below already round-trips through `#[serde(default)]`-covered
/// `AppSettings`, so an older or newer export still deserializes; this is
/// for a human to notice "this is from a much older version" if an import
/// ever looks wrong, not something the code branches on.
pub app_version: String,
pub settings: AppSettings,
#[serde(default)]
pub secrets: ExportedSecrets,
}
/// What `preview_settings_import` hands the frontend before anything is
/// applied — counts and presence flags only, **never** a secret value itself,
/// so this type is safe to return across the IPC boundary and render
/// directly. The confirmation UI is built from this.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsImportPreview {
pub exported_at: String,
pub app_version: String,
pub custom_env_var_count: usize,
pub gateway_model_count: usize,
pub has_claude_code_settings: bool,
pub has_claude_oauth_token: bool,
pub has_gateway_api_key: bool,
pub has_gateway_master_key: bool,
}
impl SettingsImportPreview {
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
Self {
exported_at: payload.exported_at.clone(),
app_version: payload.app_version.clone(),
custom_env_var_count: payload.settings.global_custom_env_vars.len(),
gateway_model_count: payload.settings.gateway.models.len(),
has_claude_code_settings: payload.settings.global_claude_code_settings.is_some(),
has_claude_oauth_token: payload
.secrets
.claude_oauth_token
.as_deref()
.is_some_and(|t| !t.trim().is_empty()),
has_gateway_api_key: payload
.secrets
.gateway_api_key
.as_deref()
.is_some_and(|k| !k.trim().is_empty()),
has_gateway_master_key: payload
.secrets
.gateway_master_key
.as_deref()
.is_some_and(|k| !k.trim().is_empty()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::AppSettings;
fn payload_with(secrets: ExportedSecrets) -> SettingsExportPayload {
let mut settings = AppSettings::default();
settings.global_custom_env_vars = vec![
crate::models::EnvVar { key: "A".to_string(), value: "1".to_string() },
crate::models::EnvVar { key: "B".to_string(), value: "2".to_string() },
];
SettingsExportPayload {
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
exported_at: "2026-08-27T00:00:00Z".to_string(),
app_version: "0.4.14".to_string(),
settings,
secrets,
}
}
#[test]
fn the_preview_never_carries_a_secret_value() {
let payload = payload_with(ExportedSecrets {
claude_oauth_token: Some("sk-super-secret-token".to_string()),
gateway_api_key: Some("sk-another-secret".to_string()),
gateway_master_key: Some("sk-triple-c-yet-another".to_string()),
});
let preview = SettingsImportPreview::from_payload(&payload);
let serialized = serde_json::to_string(&preview).unwrap();
assert!(!serialized.contains("sk-super-secret-token"));
assert!(!serialized.contains("sk-another-secret"));
assert!(!serialized.contains("sk-triple-c-yet-another"));
assert!(preview.has_claude_oauth_token);
assert!(preview.has_gateway_api_key);
assert!(preview.has_gateway_master_key);
}
#[test]
fn a_blank_secret_reads_as_absent_in_the_preview() {
// A keychain entry that exists but holds only whitespace must not
// read as "present" — same "blank counts as absent" rule the
// keychain layer itself applies when storing these.
let payload = payload_with(ExportedSecrets {
claude_oauth_token: Some(" ".to_string()),
gateway_api_key: None,
gateway_master_key: None,
});
let preview = SettingsImportPreview::from_payload(&payload);
assert!(!preview.has_claude_oauth_token);
assert!(!preview.has_gateway_api_key);
assert!(!preview.has_gateway_master_key);
}
#[test]
fn counts_reflect_the_real_settings() {
let payload = payload_with(ExportedSecrets::default());
let preview = SettingsImportPreview::from_payload(&payload);
assert_eq!(preview.custom_env_var_count, 2);
}
#[test]
fn an_empty_secrets_bundle_reports_itself_as_empty() {
assert!(ExportedSecrets::default().is_empty());
assert!(!ExportedSecrets {
claude_oauth_token: Some("x".to_string()),
..Default::default()
}
.is_empty());
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod migration_store;
pub mod pending_cleanup;
pub mod projects_store;
pub mod secure;
pub mod settings_crypto;
pub mod settings_store;
#[allow(unused_imports)]
+30 -6
View File
@@ -321,28 +321,52 @@ pub fn delete_gateway_api_key() -> Result<(), String> {
/// only enforces auth when a master key is configured, so Triple-C always
/// configures one.
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
if !existing.trim().is_empty() {
if let Some(existing) = get_gateway_master_key()? {
return Ok(existing);
}
}
regenerate_gateway_master_key()
}
/// Read the gateway master key without minting one if none exists yet.
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
/// effect the read half of that function must not have — settings export
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
/// one exists".
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
.filter(|k| !k.trim().is_empty()))
}
/// Mint a new gateway master key, invalidating the old one. Projects using the
/// previous value must be updated.
pub fn regenerate_gateway_master_key() -> Result<String, String> {
// LiteLLM requires the master key to start with `sk-`.
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
store_gateway_master_key(&key)?;
Ok(key)
}
/// Store an exact given gateway master key, replacing any previous one.
///
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
/// fresh random value: this exists for settings import (triple-c#35), where
/// restoring the *same* key an export captured is the point — projects on
/// the destination machine may not exist yet, but a project migrated or
/// re-added later that still has the old key pasted into its config must
/// keep working against it. Blank input is rejected rather than silently
/// stored, matching every other `store_*` function in this module.
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
if key.trim().is_empty() {
return Err("Refusing to store an empty gateway master key.".to_string());
}
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(&key)
.set_password(key.trim())
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
bump_gateway_secret_version()?;
Ok(key)
bump_gateway_secret_version()
}
@@ -0,0 +1,158 @@
//! Password-based encryption for the settings export/import file — see
//! triple-c#35.
//!
//! The exported payload can carry live credentials (the shared Claude OAuth
//! token, the gateway provider/master keys — see
//! `commands::settings_export_commands`), so this is not encryption for its
//! own sake; a wrong or missing key here is a real credential leak, not a
//! cosmetic bug. Argon2id derives a 256-bit key from the password (memory-
//! hard, meaningfully resistant to GPU/ASIC brute-forcing in a way PBKDF2 at
//! any reasonable iteration count is not), and AES-256-GCM is what actually
//! encrypts — authenticated, so a wrong password is detected by a failed tag
//! check rather than producing silent garbage.
//!
//! File format: `MAGIC (4 bytes) | salt (16 bytes) | nonce (12 bytes) |
//! ciphertext+tag`. The salt and nonce are not secret — they are written in
//! the clear right here, on purpose. The salt's only job is to make two
//! exports with the same password derive different keys (defeats a
//! precomputed-table attack against the password alone); the nonce's job is
//! GCM's requirement that a (key, nonce) pair never repeat. Both hold
//! because a fresh random value is drawn for each, on every call to
//! [`encrypt`].
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use argon2::{Algorithm, Argon2, Params, Version};
use rand::RngCore;
/// Identifies the file as a Triple-C settings export and pins the format —
/// a change to the salt/nonce lengths or the KDF/cipher choice below needs a
/// new magic value, not a silent reinterpretation of old bytes.
const MAGIC: &[u8; 4] = b"TCX1";
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
const HEADER_LEN: usize = MAGIC.len() + SALT_LEN + NONCE_LEN;
/// Argon2id parameters: memory cost in KiB, time cost (iterations),
/// parallelism. `(19 MiB, 2, 1)` is OWASP's documented minimum recommendation
/// for Argon2id — deliberately heavier than a login-flow KDF would use, since
/// this runs once per export/import rather than on every request, so trading
/// roughly a second of wall time for real brute-force resistance costs
/// nothing a user would notice.
fn argon2_params() -> Params {
Params::new(19 * 1024, 2, 1, Some(KEY_LEN)).expect("hardcoded Argon2 params are valid")
}
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; KEY_LEN], String> {
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
let mut key = [0u8; KEY_LEN];
argon2
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|e| format!("Failed to derive encryption key: {}", e))?;
Ok(key)
}
/// Encrypt `plaintext` with a key derived from `password`. Returns the whole
/// file's bytes (header + ciphertext) — see the module doc for the layout.
pub fn encrypt(plaintext: &[u8], password: &str) -> Result<Vec<u8>, String> {
let mut salt = [0u8; SALT_LEN];
rand::rng().fill_bytes(&mut salt);
let key = derive_key(password, &salt)?;
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| format!("Encryption failed: {}", e))?;
let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
out.extend_from_slice(MAGIC);
out.extend_from_slice(&salt);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Decrypt a file produced by [`encrypt`]. The one error this returns for a
/// wrong password is deliberately generic ("wrong password, or the file is
/// corrupted") rather than distinguishing the two: GCM's authentication tag
/// fails to verify for the wrong key on essentially any ciphertext, so there
/// is no reliable way to tell "wrong password" from "corrupted file" apart,
/// and guessing would be worse than saying so.
pub fn decrypt(data: &[u8], password: &str) -> Result<Vec<u8>, String> {
if data.len() < HEADER_LEN {
return Err("This does not look like a Triple-C settings export (file too short).".to_string());
}
if &data[..MAGIC.len()] != MAGIC {
return Err("This does not look like a Triple-C settings export (unrecognized file).".to_string());
}
let salt = &data[MAGIC.len()..MAGIC.len() + SALT_LEN];
let nonce_bytes = &data[MAGIC.len() + SALT_LEN..HEADER_LEN];
let ciphertext = &data[HEADER_LEN..];
let key = derive_key(password, salt)?;
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| "Wrong password, or the file is corrupted.".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_round_trip_with_the_right_password_recovers_the_plaintext() {
let plaintext = b"{\"settings\": \"whatever\"}";
let encrypted = encrypt(plaintext, "correct horse battery staple").unwrap();
let decrypted = decrypt(&encrypted, "correct horse battery staple").unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn the_wrong_password_fails_rather_than_returning_garbage() {
let encrypted = encrypt(b"secret payload", "correct password").unwrap();
let result = decrypt(&encrypted, "wrong password");
assert!(result.is_err(), "decrypting with the wrong password must fail, not silently succeed");
}
#[test]
fn two_exports_of_the_same_plaintext_and_password_produce_different_files() {
// If this ever failed it would mean the salt or nonce stopped being
// randomized — either one repeating is a real security regression
// (a fixed salt lets an attacker precompute against the password
// alone; a repeated (key, nonce) pair breaks GCM's guarantees
// outright), not just a cosmetic one.
let a = encrypt(b"same plaintext", "same password").unwrap();
let b = encrypt(b"same plaintext", "same password").unwrap();
assert_ne!(a, b, "two independent exports must not be byte-identical");
}
#[test]
fn corrupting_a_single_byte_of_ciphertext_is_detected() {
let mut encrypted = encrypt(b"tamper-evident payload", "a password").unwrap();
let last = encrypted.len() - 1;
encrypted[last] ^= 0xFF;
assert!(decrypt(&encrypted, "a password").is_err());
}
#[test]
fn a_file_that_is_too_short_is_rejected_cleanly_not_by_panicking() {
assert!(decrypt(b"short", "any password").is_err());
assert!(decrypt(b"", "any password").is_err());
}
#[test]
fn a_file_with_the_wrong_magic_is_rejected() {
let mut encrypted = encrypt(b"payload", "password").unwrap();
encrypted[0] = b'X';
assert!(decrypt(&encrypted, "password").is_err());
}
}
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ExportSettingsModal from "./ExportSettingsModal";
const exportSettings = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
exportSettings: (password: string) => exportSettings(password),
}));
beforeEach(() => {
vi.clearAllMocks();
});
function fillPasswords(password: string, confirm: string) {
fireEvent.change(screen.getByLabelText("Password"), { target: { value: password } });
fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: confirm } });
}
describe("ExportSettingsModal", () => {
it("keeps the submit button disabled until the passwords are long enough and match", () => {
render(<ExportSettingsModal onClose={vi.fn()} />);
const submit = screen.getByRole("button", { name: /choose where to save/i });
expect(submit).toBeDisabled();
fillPasswords("short", "short");
expect(submit).toBeDisabled();
expect(screen.getByText(/use at least 8 characters/i)).toBeInTheDocument();
fillPasswords("longenoughpassword", "different");
expect(submit).toBeDisabled();
expect(screen.getByText(/don't match/i)).toBeInTheDocument();
fillPasswords("longenoughpassword", "longenoughpassword");
expect(submit).not.toBeDisabled();
});
it("exports with the entered password and shows success", async () => {
exportSettings.mockResolvedValue(true);
render(<ExportSettingsModal onClose={vi.fn()} />);
fillPasswords("longenoughpassword", "longenoughpassword");
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
await waitFor(() => expect(exportSettings).toHaveBeenCalledWith("longenoughpassword"));
await waitFor(() => expect(screen.getByText(/settings exported/i)).toBeInTheDocument());
});
it("closes quietly when the save dialog is dismissed", async () => {
exportSettings.mockResolvedValue(false);
const onClose = vi.fn();
render(<ExportSettingsModal onClose={onClose} />);
fillPasswords("longenoughpassword", "longenoughpassword");
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(screen.queryByText(/settings exported/i)).not.toBeInTheDocument();
});
it("shows an error rather than closing when the export fails", async () => {
exportSettings.mockRejectedValue("Disk is full");
const onClose = vi.fn();
render(<ExportSettingsModal onClose={onClose} />);
fillPasswords("longenoughpassword", "longenoughpassword");
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
await waitFor(() => expect(screen.getByText("Disk is full")).toBeInTheDocument());
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
import { useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Field, { inputClass } from "../ui/Field";
import { exportSettings } from "../../lib/tauri-commands";
interface Props {
onClose: () => void;
}
const MIN_PASSWORD_LENGTH = 8;
/**
* Password entry for exporting global settings. The save dialog itself opens
* from Rust once a password is confirmed here — see the doc comment on
* `commands::settings_export_commands` for why the host path never
* round-trips through this component.
*/
export default function ExportSettingsModal({ onClose }: Props) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const mismatch = confirmPassword.length > 0 && password !== confirmPassword;
const tooShort = password.length > 0 && password.length < MIN_PASSWORD_LENGTH;
const canSubmit = password.length >= MIN_PASSWORD_LENGTH && password === confirmPassword;
const handleExport = async () => {
setError(null);
setBusy(true);
try {
const saved = await exportSettings(password);
if (saved) setDone(true);
// `false` means the save dialog was dismissed — close quietly, same as
// if the user had cancelled the modal itself.
else onClose();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title="Export settings"
description="Saves your global settings and any stored credentials (a shared Claude login, gateway keys) to one encrypted file. Project-specific settings and container data are not included."
widthClassName="w-[28rem]"
dismissible={!busy}
onClose={onClose}
footer={
done ? (
<Button size="md" variant="primary" onClick={onClose}>
Done
</Button>
) : (
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
size="md"
variant="primary"
onClick={() => void handleExport()}
disabled={!canSubmit || busy}
>
{busy ? "Exporting…" : "Choose where to save…"}
</Button>
</>
)
}
>
{done ? (
<p className="text-[13px] text-[var(--success)]">
Settings exported. Keep the password somewhere safe there is no way to recover
the file without it.
</p>
) : (
<div className="space-y-3">
<Field label="Password" hint={`At least ${MIN_PASSWORD_LENGTH} characters. You'll need this exact password to import the file later.`}>
{(id) => (
<input
id={id}
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
className={inputClass}
/>
)}
</Field>
<Field label="Confirm password">
{(id) => (
<input
id={id}
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={busy}
className={inputClass}
/>
)}
</Field>
{tooShort && (
<p className="text-xs text-[var(--error)]">
Use at least {MIN_PASSWORD_LENGTH} characters.
</p>
)}
{mismatch && <p className="text-xs text-[var(--error)]">Passwords don't match.</p>}
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
</div>
)}
</Modal>
);
}
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ImportSettingsModal from "./ImportSettingsModal";
import type { AppSettings, SettingsImportPreview } from "../../lib/types";
const previewSettingsImport = vi.fn();
const applySettingsImport = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
previewSettingsImport: (password: string) => previewSettingsImport(password),
applySettingsImport: (password: string) => applySettingsImport(password),
}));
beforeEach(() => {
vi.clearAllMocks();
});
const samplePreview: SettingsImportPreview = {
exported_at: "2026-08-27T00:00:00Z",
app_version: "0.4.14",
custom_env_var_count: 2,
gateway_model_count: 0,
has_claude_code_settings: false,
has_claude_oauth_token: true,
has_gateway_api_key: false,
has_gateway_master_key: false,
};
describe("ImportSettingsModal", () => {
it("keeps 'Choose file' disabled until a password is entered", () => {
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
expect(screen.getByRole("button", { name: /choose file/i })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
expect(screen.getByRole("button", { name: /choose file/i })).not.toBeDisabled();
});
it("shows the preview and confirms with the same password used to open it", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
applySettingsImport.mockResolvedValue({} as AppSettings);
const onImported = vi.fn();
render(<ImportSettingsModal onClose={vi.fn()} onImported={onImported} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await waitFor(() => expect(previewSettingsImport).toHaveBeenCalledWith("hunter2"));
expect(await screen.findByText(/2 global custom env vars/i)).toBeInTheDocument();
expect(screen.getByText(/your shared claude login/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
await waitFor(() => expect(applySettingsImport).toHaveBeenCalledWith("hunter2"));
await waitFor(() => expect(onImported).toHaveBeenCalledWith({}));
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
});
it("closes quietly when the file picker is dismissed", async () => {
previewSettingsImport.mockResolvedValue(null);
const onClose = vi.fn();
render(<ImportSettingsModal onClose={onClose} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("shows an error when the password is wrong rather than a blank preview", async () => {
previewSettingsImport.mockRejectedValue("Wrong password, or the file is corrupted.");
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "wrong" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(await screen.findByText(/wrong password, or the file is corrupted/i)).toBeInTheDocument();
});
it("shows an error if applying the import fails, without claiming success", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
applySettingsImport.mockRejectedValue("Keychain write failed");
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await screen.findByText(/2 global custom env vars/i);
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
expect(await screen.findByText("Keychain write failed")).toBeInTheDocument();
expect(screen.queryByText(/settings imported/i)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,139 @@
import { useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Field, { inputClass } from "../ui/Field";
import { applySettingsImport, previewSettingsImport } from "../../lib/tauri-commands";
import { describeImport } from "../../lib/settingsImportPreview";
import type { AppSettings, SettingsImportPreview } from "../../lib/types";
interface Props {
onClose: () => void;
/** Fired once the import is actually applied, so the caller can refresh
* whatever reads settings from the store. */
onImported: (settings: AppSettings) => void;
}
/**
* Two phases: enter the password and pick the file (backend resolves the
* file dialog itself — see `commands::settings_export_commands`), then
* confirm a preview before anything is actually applied. The same password
* is reused for the second call rather than asking again; nothing about
* that call needs a fresh secret; the backend just doesn't cache the
* *decrypted payload* between the two.
*/
export default function ImportSettingsModal({ onClose, onImported }: Props) {
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<SettingsImportPreview | null>(null);
const [applied, setApplied] = useState(false);
const handleChooseFile = async () => {
setError(null);
setBusy(true);
try {
const result = await previewSettingsImport(password);
if (result) setPreview(result);
else onClose(); // File picker dismissed.
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const handleConfirm = async () => {
setError(null);
setBusy(true);
try {
const settings = await applySettingsImport(password);
setApplied(true);
onImported(settings);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title="Import settings"
description={
preview
? "Review what this file will change before applying it."
: "Choose a Triple-C settings export and enter the password it was created with."
}
widthClassName="w-[28rem]"
dismissible={!busy}
onClose={onClose}
footer={
applied ? (
<Button size="md" variant="primary" onClick={onClose}>
Done
</Button>
) : preview ? (
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={() => void handleConfirm()} disabled={busy}>
{busy ? "Importing…" : "Import"}
</Button>
</>
) : (
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
size="md"
variant="primary"
onClick={() => void handleChooseFile()}
disabled={!password || busy}
>
{busy ? "Opening…" : "Choose file…"}
</Button>
</>
)
}
>
{applied ? (
<p className="text-[13px] text-[var(--success)]">Settings imported.</p>
) : preview ? (
<div className="space-y-3">
<p className="text-xs text-[var(--text-secondary)]">
Exported {new Date(preview.exported_at).toLocaleString()} from Triple-C{" "}
{preview.app_version}.
</p>
<div>
<p className="text-[13px] font-medium text-[var(--text-primary)]">This will replace:</p>
<ul className="mt-1 list-disc pl-4 text-[13px] text-[var(--text-secondary)] space-y-0.5">
{describeImport(preview).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
</div>
) : (
<div className="space-y-3">
<Field label="Password">
{(id) => (
<input
id={id}
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
className={inputClass}
/>
)}
</Field>
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
</div>
)}
</Modal>
);
}
+38 -1
View File
@@ -19,9 +19,11 @@ import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
import CertificateSettings from "./CertificateSettings";
import ExportSettingsModal from "./ExportSettingsModal";
import ImportSettingsModal from "./ImportSettingsModal";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
const { appSettings, saveSettings, setAppSettings } = useSettings();
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
@@ -33,6 +35,8 @@ export default function SettingsPanel() {
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
// Sync local state when appSettings change
useEffect(() => {
@@ -269,6 +273,39 @@ export default function SettingsPanel() {
</div>
</AccordionSection>
<AccordionSection id="backup" title="Backup" defaultOpen={false}>
<div className="space-y-2">
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Export your global settings and stored credentials (a shared Claude login,
gateway keys) to one password-encrypted file, or restore them on a new machine.
Project-specific settings and container data are never included.
</p>
<div className="flex gap-2">
<button
onClick={() => setShowExportModal(true)}
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Export settings
</button>
<button
onClick={() => setShowImportModal(true)}
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Import settings
</button>
</div>
</div>
</AccordionSection>
{showExportModal && <ExportSettingsModal onClose={() => setShowExportModal(false)} />}
{showImportModal && (
<ImportSettingsModal
onClose={() => setShowImportModal(false)}
onImported={(settings) => setAppSettings(settings)}
/>
)}
{showInstructionsModal && (
<ClaudeInstructionsModal
instructions={globalInstructions}
+4
View File
@@ -36,5 +36,9 @@ export function useSettings() {
appSettings,
loadSettings,
saveSettings,
/** For a command that already returns the new `AppSettings` itself
* (settings import) — updates the store without a redundant
* `updateSettings` round trip through the backend. */
setAppSettings,
};
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { describeImport } from "./settingsImportPreview";
import type { SettingsImportPreview } from "./types";
function preview(overrides: Partial<SettingsImportPreview> = {}): SettingsImportPreview {
return {
exported_at: "2026-08-27T00:00:00Z",
app_version: "0.4.14",
custom_env_var_count: 0,
gateway_model_count: 0,
has_claude_code_settings: false,
has_claude_oauth_token: false,
has_gateway_api_key: false,
has_gateway_master_key: false,
...overrides,
};
}
describe("describeImport", () => {
it("always names the settings replacement, even with nothing else set", () => {
expect(describeImport(preview())).toEqual([
"Your global settings (all of them — this replaces what's here now)",
]);
});
it("singularizes a count of exactly one", () => {
const items = describeImport(preview({ custom_env_var_count: 1, gateway_model_count: 1 }));
expect(items).toContain("1 global custom env var");
expect(items).toContain("1 gateway model");
});
it("pluralizes counts greater than one", () => {
const items = describeImport(preview({ custom_env_var_count: 3, gateway_model_count: 2 }));
expect(items).toContain("3 global custom env vars");
expect(items).toContain("2 gateway models");
});
it("names every present secret and setting without naming absent ones", () => {
const items = describeImport(
preview({
has_claude_code_settings: true,
has_claude_oauth_token: true,
has_gateway_api_key: true,
has_gateway_master_key: true,
}),
);
expect(items).toContain("Global Claude Code settings");
expect(items).toContain("Your shared Claude login");
expect(items).toContain("The gateway provider API key");
expect(items).toContain("The gateway master key");
// None of the count-based items, since both counts are 0.
expect(items.some((i) => i.includes("env var"))).toBe(false);
expect(items.some((i) => i.includes("gateway model"))).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { SettingsImportPreview } from "./types";
/** Named things a `SettingsImportPreview` says an import will change, for
* `ImportSettingsModal`'s confirmation list. */
export function describeImport(preview: SettingsImportPreview): string[] {
const items: string[] = ["Your global settings (all of them — this replaces what's here now)"];
if (preview.custom_env_var_count > 0) {
items.push(
`${preview.custom_env_var_count} global custom env var${preview.custom_env_var_count === 1 ? "" : "s"}`,
);
}
if (preview.has_claude_code_settings) items.push("Global Claude Code settings");
if (preview.gateway_model_count > 0) {
items.push(`${preview.gateway_model_count} gateway model${preview.gateway_model_count === 1 ? "" : "s"}`);
}
if (preview.has_claude_oauth_token) items.push("Your shared Claude login");
if (preview.has_gateway_api_key) items.push("The gateway provider API key");
if (preview.has_gateway_master_key) items.push("The gateway master key");
return items;
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -42,6 +42,15 @@ export const inspectCaCertPath = (path: string) =>
export const detectHostTimezone = () =>
invoke<string>("detect_host_timezone");
// Settings export/import — `false`/`null` mean the save/open dialog was
// dismissed, not an error.
export const exportSettings = (password: string) =>
invoke<boolean>("export_settings", { password });
export const previewSettingsImport = (password: string) =>
invoke<SettingsImportPreview | null>("preview_settings_import", { password });
export const applySettingsImport = (password: string) =>
invoke<AppSettings>("apply_settings_import", { password });
// AWS
export const awsSsoRefresh = (projectId: string) =>
invoke<void>("aws_sso_refresh", { projectId });
+14
View File
@@ -292,6 +292,20 @@ export interface AppSettings {
global_claude_code_settings: ClaudeCodeSettings | null;
}
/** What `preview_settings_import` returns before anything is applied
* counts and presence flags only, never a secret value itself. Built from
* this, not from the raw import file, which the frontend never sees. */
export interface SettingsImportPreview {
exported_at: string;
app_version: string;
custom_env_var_count: number;
gateway_model_count: number;
has_claude_code_settings: boolean;
has_claude_oauth_token: boolean;
has_gateway_api_key: boolean;
has_gateway_master_key: boolean;
}
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride
* in the payload rather than rejecting, so the field can render them inline
* while the user is still typing. */