dwarves-gui/src/settings.rs
//! **The Settings tab** — the common settings pane for the dwarves operator shell:
//! the active workspace, the **fat/thin** backend (embedded vs `--server` remote),
//! the theme, and vault-unlock.
//!
//! ## Reuse-first (L5)
//! There is no shared `facett-settings` crate (checked — it does not exist), so this
//! pane is COMPOSED from existing shared primitives rather than a twin:
//! * the theme control drives `facett_core::look::Theme` — the SAME serde-round-tripping
//! theme model (light/dark presets) the whole facett suite uses; picking a preset calls
//! `Theme::apply(ctx)` (no bespoke palette here);
//! * the active workspace mirrors the ONE selection the warehouse selector broadcasts
//! (`{"set_workspace":…}`), so Settings never invents a second source of truth;
//! * vault-unlock is a passphrase gate over the [`crate::profile`] vault seam (names
//! only, never values).
//!
//! [`SettingsConfig`] is a plain serde struct — it ROUND-TRIPS to/from JSON, which is
//! what the gate asserts (`state_json` out == what `update_json` set).
//!
//! ## The backend control offers only modes this BINARY can enter
//! The fat/thin radio pair used to be drawn unconditionally, so a build with no
//! embedded engine offered `Embedded (fat)` — an impossible state drawn identically
//! to a possible one. It is now driven by [`HAS_EMBEDDED_ENGINE`]:
//! * a **fat** build (`dupes-fat`, i.e. the `dwarves-solo` atom) genuinely has
//! both — it carries the engine *and* can gRPC another dwarves server — so the
//! toggle is a real choice and stays;
//! * a **thin** build has one mode, so the toggle is not drawn at all (not greyed,
//! not tooltipped) and the mode is STATED instead — an absent control reads as
//! "nobody has chosen yet", a stated one as "this build can only be thin";
//! * the seam refuses `{"backend":"embedded"}` on a thin build for the same reason,
//! so the control surface and the render cannot disagree about what exists;
//! * and the pane says what thin COSTS ([`THIN_COSTS`]) — a fact that lived only in a
//! `build-variants.toml` comment.
use facett_core::Facet;
/// **Does THIS binary carry the embedded engine at all?**
///
/// The embedded (fat) stack of the demo shell is the LOCAL warehouse read leg —
/// `dwarves::dupes::local`, which links arrow + parquet and opens nornir's Iceberg
/// tree on disk. It exists in this binary iff the `dupes-fat` feature was compiled
/// in, which since 2026-08-03 is exactly the `dwarves-solo` atom
/// (`.nornir/build-variants.toml`; it was `dwarves-ui-fat`, which left the matrix — a
/// UI that reads the warehouse locally IS the solo shape). The default `dwarves-ui`
/// atom declares `linkage = "thin"`, `embeds = []` and does NOT enable it.
///
/// It is a `const` rather than a `#[cfg]` scattered through the pane so that
/// "which modes exist" is decided in ONE place and can be read by the widget, the
/// control seam and `state_json` alike.
pub const HAS_EMBEDDED_ENGINE: bool = cfg!(feature = "dupes-fat");
/// **What a thin build cannot do**, verbatim from the reason the fat read exists
/// (`.nornir/build-variants.toml`). Rendered in the pane rather than left in a TOML
/// comment: choosing thin is not "the same answer, slower" — it is a *poorer* answer,
/// and the user picking it is entitled to know that before they pick.
const THIN_COSTS: &str = "the thin gRPC seam cannot carry the `embeddings` table (a \
768-float `list<float32>` per row does not survive a stringified `scan_preview`), \
so the 🧬 Dupes tab's SEMANTIC tier and leafwatch's semantic leg are UNAVAILABLE — \
duplicate detection is hash-tiers-only and structurally blind to \"the same thing, \
written differently\".";
/// Which backend the shell talks to: the **fat** embedded engine (in-process) or a
/// **thin** remote `dwarves-server`/`nornir-server` over gRPC (the `--server` mode).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
/// Fat: the embedded engine, no server needed.
Embedded,
/// Thin: a remote server over gRPC (`server_endpoint`).
Remote,
}
impl Backend {
fn key(self) -> &'static str {
match self {
Backend::Embedded => "embedded",
Backend::Remote => "remote",
}
}
fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"embedded" | "fat" => Some(Backend::Embedded),
"remote" | "thin" | "server" => Some(Backend::Remote),
_ => None,
}
}
/// **Can this binary actually ENTER this mode?** `Remote` always; `Embedded` only
/// when the embedded engine was compiled in ([`HAS_EMBEDDED_ENGINE`]). A mode the
/// binary cannot enter is never offered and never accepted over the seam.
pub fn is_available(self) -> bool {
match self {
Backend::Embedded => HAS_EMBEDDED_ENGINE,
Backend::Remote => true,
}
}
}
/// The round-tripping settings model (serde) — the pane's whole persisted state.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SettingsConfig {
/// Fat (embedded) vs thin (remote server).
pub backend: Backend,
/// The remote server endpoint used when `backend == Remote`.
pub server_endpoint: String,
/// The active `facett_core::look::Theme` preset name.
pub theme: String,
/// Whether the credential vault is currently unlocked (a session gate; NEVER a
/// passphrase — the passphrase is never stored in the config).
pub vault_unlocked: bool,
}
/// What an unlock attempt answers in a build WITHOUT the `vault` feature.
#[cfg_attr(feature = "vault", allow(dead_code))]
pub const NO_VAULT_NOTE: &str =
"there is no vault in this build (feature `vault`): nothing was unlocked";
impl Default for SettingsConfig {
fn default() -> Self {
Self {
// THIN BY DEFAULT — the same rule `main.rs` already applies to the process
// ("dwarves UI skall alltid default vara thin"). It used to default to
// `Embedded`, which on a build with no embedded engine is a state the
// binary cannot enter, chosen for the user before they touched anything.
backend: Backend::Remote,
server_endpoint: dwarves_embed::DEFAULT_ENDPOINT.to_string(),
theme: "nordisk-dark".to_string(),
vault_unlocked: false,
}
}
}
/// The Settings pane. Owns the [`SettingsConfig`] plus the mirror of the active
/// workspace + the registered-workspace list (for the workspace-select control).
pub struct SettingsPane {
cfg: SettingsConfig,
/// The active workspace (mirrors the selector's broadcast; `""` = none).
active_workspace: String,
/// The registered workspaces (the picker options — seeded from the live registry).
workspaces: Vec<String>,
/// A transient passphrase buffer for the vault-unlock field (never persisted).
passphrase: String,
/// A transient note under the vault control (e.g. a wrong-passphrase error). Never
/// persisted, never a secret.
vault_note: String,
/// A transient note under the backend control — set when something asked this
/// binary for a mode it cannot enter. Never persisted, never a secret.
backend_note: String,
/// A transient buffer for the Claude/Anthropic key field. Consumed by
/// [`SettingsPane::do_save_claude_key`] and cleared immediately — NEVER persisted,
/// NEVER rendered back, NEVER in `state_json`.
#[cfg(feature = "claude-token")]
claude_key: String,
/// A transient note under the Claude control (a save/clear outcome). Never a secret.
claude_note: String,
/// Whether the last applied theme was actually installed on the egui ctx this run
/// (so `ui` only re-applies on change).
applied_theme: Option<String>,
}
impl SettingsPane {
/// Build the pane with the default config and the registered-workspace options.
pub fn new(workspaces: Vec<String>, active_workspace: impl Into<String>) -> Self {
Self {
cfg: SettingsConfig::default(),
active_workspace: active_workspace.into(),
workspaces,
passphrase: String::new(),
vault_note: String::new(),
backend_note: String::new(),
#[cfg(feature = "claude-token")]
claude_key: String::new(),
claude_note: String::new(),
applied_theme: None,
}
}
/// Show the endpoint the shell ACTUALLY reads from (what `--server` /
/// `$DWARVES_SERVER` resolved to at start), instead of the compiled default.
pub fn with_server_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.cfg.server_endpoint = endpoint.into();
self
}
// ── the Claude/Anthropic credential ──────────────────────────────────────
//
// This is WHERE THE CLAUDE TOKEN IS CONFIGURED. The key is sealed in the
// dwarves vault by `dwarves::claude_token` (through the same
// `VaultTokenStore` the OIDC login uses) and read back by the Claude/funnel
// transports as `nornir_claude::resolve_credential`'s session key. The pane
// only ever holds the value in the transient `claude_key` buffer, which is
// cleared the moment it is handed to the vault.
/// The credential status shown in the UI — a SOURCE label and booleans, never
/// key material. With `claude-token` it reflects the real ladder (env →
/// vault); without it, the env rungs only (so the control is still honest
/// about what a default build can see).
#[cfg(feature = "claude-token")]
fn claude_status(&self) -> dwarves::claude_token::CredentialStatus {
dwarves::claude_token::status()
}
/// Store the buffered key in the vault, then clear the buffer.
#[cfg(feature = "claude-token")]
fn do_save_claude_key(&mut self) {
let store = dwarves::claude_token::default_store();
self.claude_note = match dwarves::claude_token::save_key(&store, &self.claude_key) {
// Deliberately no echo of the value — just that it landed.
Ok(()) => "saved — sealed in the vault".to_string(),
Err(e) => format!("save failed: {e}"),
};
self.claude_key.clear();
}
/// Remove the stored key from the vault.
#[cfg(feature = "claude-token")]
fn do_clear_claude_key(&mut self) {
let store = dwarves::claude_token::default_store();
self.claude_note = match dwarves::claude_token::clear_key(&store) {
Ok(()) => "removed from the vault".to_string(),
Err(e) => format!("remove failed: {e}"),
};
self.claude_key.clear();
}
/// The env-only fallback for a build without `claude-token`: the two env rungs
/// are all such a build can consult, and it can store nothing.
#[cfg(not(feature = "claude-token"))]
fn claude_source_key(&self) -> &'static str {
let set = |k: &str| {
std::env::var(k)
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
};
if set("ANTHROPIC_API_KEY") {
"env:ANTHROPIC_API_KEY"
} else if set("ANTHROPIC_AUTH_TOKEN") {
"env:ANTHROPIC_AUTH_TOKEN"
} else {
"none"
}
}
// ── cfg-agnostic status accessors (what `state_json` publishes) ──────────
//
// Feature-independent so the robot seam has the SAME key set in every build:
// a scenario asserts on `claude_source` without knowing how the demo was
// compiled. None of these can return key material.
/// The resolved credential source as a stable wire token.
fn claude_source_token(&self) -> &'static str {
#[cfg(feature = "claude-token")]
{
self.claude_status().source.as_str()
}
#[cfg(not(feature = "claude-token"))]
{
self.claude_source_key()
}
}
/// Is a key stored in the vault? Always `false` without `claude-token`
/// (such a build has no vault-backed Claude key at all).
fn claude_in_vault(&self) -> bool {
#[cfg(feature = "claude-token")]
{
self.claude_status().in_vault
}
#[cfg(not(feature = "claude-token"))]
{
false
}
}
/// Could the vault be read? `true` without `claude-token` — nothing to read,
/// so there is no lock to report.
fn claude_vault_readable(&self) -> bool {
#[cfg(feature = "claude-token")]
{
self.claude_status().vault_readable
}
#[cfg(not(feature = "claude-token"))]
{
true
}
}
/// Unlock the vault with the buffered passphrase. With the `vault` feature this
/// drives the REAL `dwarves::vault::Session` (the same session the Profile tab's
/// `DwarvesVault` + OIDC `VaultTokenStore` read) — a wrong passphrase is rejected and
/// leaves the vault locked. Without the feature there is no vault: it stays locked
/// and says why ([`NO_VAULT_NOTE`]). The passphrase is consumed here, NEVER stored
/// in the config.
fn do_unlock(&mut self) {
#[cfg(feature = "vault")]
{
let v = dwarves::vault::ZnippyVault::default_vault();
match dwarves::vault::Session::unlock(&v, self.passphrase.as_bytes(), 3600) {
Ok(()) => {
self.cfg.vault_unlocked = true;
self.vault_note.clear();
}
Err(e) => {
self.cfg.vault_unlocked = false;
self.vault_note = format!("unlock failed: {e}");
}
}
}
// No `vault` feature ⇒ there is NO vault in this build: nothing is unlocked, and
// the pane says so. (It used to flip the flag to "unlocked" for any passphrase.)
#[cfg(not(feature = "vault"))]
{
self.cfg.vault_unlocked = false;
self.vault_note = NO_VAULT_NOTE.to_string();
}
self.passphrase.clear();
}
/// Lock the vault — clears the real `Session` (vault feature) and the demo gate.
fn do_lock(&mut self) {
#[cfg(feature = "vault")]
{
let _ = dwarves::vault::Session::lock();
}
self.cfg.vault_unlocked = false;
self.passphrase.clear();
self.vault_note.clear();
}
/// The current config (for the host / tests).
pub fn config(&self) -> &SettingsConfig {
&self.cfg
}
/// The one sentence said whenever something asks a thin binary to go fat.
fn no_embedded_reason() -> &'static str {
"this build carries no embedded engine (`dupes-fat` was not compiled in) — \
staying THIN; build the `dwarves-solo` atom (the UI with its server compiled in) for the fat mode"
}
/// **Which rung of the chain supplies the server bearer** — a SOURCE label, never
/// the token itself. HOST-ONLY: the UI authenticates to exactly ONE server, the
/// dwarves forge (`:7900`). It does NOT dial nornir (`:7878`) and so carries no
/// `NORNIR_SERVER_TOKEN`; "none" is an honest answer (an open server needs no
/// bearer).
///
/// ⚠ It used to check ONLY the environment variable, so a shell whose bearer came
/// from `~/.dwarves/token` was labelled *"none (unauthenticated)"* while the client
/// was in fact authenticating perfectly well — a Settings pane reporting the
/// opposite of the truth. It now asks
/// [`dwarves_embed::ClientCredential`](dwarves_embed::ClientCredential), the ONE
/// client resolver, which names the rung that answered.
fn server_token_source() -> String {
dwarves_embed::ClientCredential::resolve().provenance()
}
/// Apply the selected theme preset to the egui context (reused `look::Theme`), once
/// per change. Unknown names fall back to the default theme rather than panicking.
fn apply_theme(&mut self, ctx: &egui::Context) {
if self.applied_theme.as_deref() == Some(self.cfg.theme.as_str()) {
return;
}
let theme = facett_core::look::Theme::by_name(&self.cfg.theme).unwrap_or_default();
theme.apply(ctx);
self.applied_theme = Some(self.cfg.theme.clone());
}
}
impl Facet for SettingsPane {
fn title(&self) -> &str {
"Settings"
}
fn ui(&mut self, ui: &mut egui::Ui) {
self.apply_theme(ui.ctx());
ui.heading("Settings");
ui.separator();
// ── Workspace ────────────────────────────────────────────────────────────
ui.label("Active workspace (set from the top-bar selector):");
ui.horizontal(|ui| {
let shown = if self.active_workspace.is_empty() {
"⚠ none — pick one in the header".to_string()
} else {
self.active_workspace.clone()
};
ui.monospace(shown);
});
if !self.workspaces.is_empty() {
ui.label(format!("registered: {}", self.workspaces.join(", ")));
}
ui.separator();
// ── Backend: fat (embedded) vs thin (remote) ─────────────────────────────
//
// A CHOICE only where a choice exists. A fat build genuinely has both — it
// carries the embedded engine AND can gRPC against another dwarves server, so
// "fat" means *carries the engine*, not *must use it*, and the toggle is real.
// A thin build has exactly one mode, so the toggle is NOT drawn — not greyed,
// not behind a tooltip, gone. What is drawn instead is the mode as a STATED
// FACT, because an absent control reads as "nobody has chosen yet" while a
// stated one reads as "this build can only be thin".
ui.label("Backend:");
if HAS_EMBEDDED_ENGINE {
ui.weak("FAT build — the embedded engine is compiled in; both modes are reachable.");
ui.horizontal(|ui| {
ui.radio_value(&mut self.cfg.backend, Backend::Embedded, "Embedded (fat)");
ui.radio_value(
&mut self.cfg.backend,
Backend::Remote,
"Remote (thin · --server)",
);
});
} else {
ui.horizontal(|ui| {
ui.strong("THIN");
ui.label("— this build carries no embedded engine");
});
ui.weak(
"not a setting: the embedded warehouse read leg (`dupes-fat`) was not \
compiled into this binary, so `Embedded (fat)` is not a mode it can \
enter. Build the `dwarves-solo` atom to get the choice.",
);
}
// WHAT THIN COSTS — surfaced wherever thin is what you are running, because
// the price is a *poorer answer*, not a slower one.
if self.cfg.backend == Backend::Remote {
ui.colored_label(egui::Color32::from_rgb(0xe0, 0xa8, 0x00), THIN_COSTS);
}
if !self.backend_note.is_empty() {
ui.colored_label(
egui::Color32::from_rgb(0xd9, 0x53, 0x4f),
&self.backend_note,
);
}
// Thin is not *whether* but *which*: endpoint, token, workspace. The endpoint
// is the one the panes read from — fixed when the UI starts, so it is SHOWN, not
// edited (the field used to hold the compiled default and accept edits that
// changed nothing). The token is reported by SOURCE only (never a value) and
// the workspace is the ONE selection mirrored from the header, above.
if self.cfg.backend == Backend::Remote {
ui.horizontal(|ui| {
ui.label("server:");
ui.monospace(&self.cfg.server_endpoint);
ui.weak("(set at start: --server / $DWARVES_SERVER)");
});
ui.horizontal(|ui| {
ui.label("token:");
ui.monospace(Self::server_token_source());
});
ui.horizontal(|ui| {
ui.label("workspace:");
ui.monospace(if self.active_workspace.is_empty() {
"⚠ none — pick one in the header"
} else {
self.active_workspace.as_str()
});
});
}
ui.separator();
// ── Theme (reused facett_core::look presets) ─────────────────────────────
ui.label("Theme:");
egui::ComboBox::from_id_salt("dwarves_settings_theme")
.selected_text(self.cfg.theme.clone())
.show_ui(ui, |ui| {
for name in facett_core::look::Theme::preset_names() {
ui.selectable_value(&mut self.cfg.theme, name.clone(), name);
}
});
ui.separator();
// ── Vault unlock (passphrase gate; names only, never values) ─────────────
ui.label("Vault:");
ui.horizontal(|ui| {
if self.cfg.vault_unlocked {
ui.label("🔓 unlocked");
if ui.button("Lock").clicked() {
self.do_lock();
}
} else {
ui.add(
egui::TextEdit::singleline(&mut self.passphrase)
.password(true)
.hint_text("passphrase"),
);
if ui
.add_enabled(!self.passphrase.is_empty(), egui::Button::new("Unlock"))
.clicked()
{
// The passphrase gates the real session; it is NEVER stored.
self.do_unlock();
}
}
});
if !self.vault_note.is_empty() {
ui.colored_label(egui::Color32::from_rgb(0xd9, 0x53, 0x4f), &self.vault_note);
}
ui.separator();
// ── Claude / Anthropic credential (WHERE THE TOKEN IS CONFIGURED) ────────
//
// Rendered in EVERY build so the setting is discoverable — a default build
// shows the resolved source and says what it would take to store one, rather
// than leaving the user hunting for a config that appears to not exist.
ui.label("Claude (Anthropic) API key:");
#[cfg(feature = "claude-token")]
{
let st = self.claude_status();
ui.horizontal(|ui| {
let (dot, colour) = if st.is_configured() {
("●", egui::Color32::from_rgb(0x4c, 0xaf, 0x50))
} else {
("○", egui::Color32::from_rgb(0xd9, 0x53, 0x4f))
};
ui.colored_label(colour, dot);
ui.label(st.source.describe());
});
if !st.vault_readable {
ui.colored_label(
egui::Color32::from_rgb(0xe0, 0xa8, 0x00),
"vault is locked — unlock it above to read or store a key",
);
}
ui.horizontal(|ui| {
// The value is masked on entry and never rendered back.
ui.add(
egui::TextEdit::singleline(&mut self.claude_key)
.password(true)
.hint_text("sk-ant-…"),
);
if ui
.add_enabled(
!self.claude_key.trim().is_empty(),
egui::Button::new("Save"),
)
.clicked()
{
self.do_save_claude_key();
}
if st.in_vault && ui.button("Remove").clicked() {
self.do_clear_claude_key();
}
});
if st.source == dwarves::claude_token::CredentialSource::Vault {
ui.weak("stored in the vault as the `anthropic` secret");
} else if st.in_vault {
ui.weak("a key is stored here, but an environment variable takes precedence");
}
}
#[cfg(not(feature = "claude-token"))]
{
ui.horizontal(|ui| {
let src = self.claude_source_key();
let configured = src != "none";
let (dot, colour) = if configured {
("●", egui::Color32::from_rgb(0x4c, 0xaf, 0x50))
} else {
("○", egui::Color32::from_rgb(0xd9, 0x53, 0x4f))
};
ui.colored_label(colour, dot);
ui.label(if configured {
src
} else {
"no Claude credential configured"
});
});
ui.weak(
"this build reads the key from ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN only — \
rebuild with `--features claude-token` to store one in the vault",
);
}
if !self.claude_note.is_empty() {
ui.weak(&self.claude_note);
}
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"kind": "settings",
"backend": self.cfg.backend.key(),
"server_endpoint": self.cfg.server_endpoint,
"theme": self.cfg.theme,
// Which modes this BINARY can enter — the same fact the widget draws from,
// so a robot can assert that a thin build never offers the fat mode.
"has_embedded_engine": HAS_EMBEDDED_ENGINE,
"backend_choices": if HAS_EMBEDDED_ENGINE {
vec!["embedded", "remote"]
} else {
vec!["remote"]
},
"backend_note": self.backend_note,
"server_token_source": Self::server_token_source(),
"vault_unlocked": self.cfg.vault_unlocked,
"vault_note": self.vault_note,
"active_workspace": self.active_workspace,
"workspaces": self.workspaces,
// The Claude credential, as OBSERVABLE STATUS ONLY. The key itself is
// never here, never in `config`, and therefore never in a snapshot or a
// persisted state file — `claude_key` is a transient buffer cleared on
// save. What rides is a source label + booleans.
"claude_source": self.claude_source_token(),
"claude_configured": self.claude_source_token() != "none",
"claude_in_vault": self.claude_in_vault(),
"claude_vault_readable": self.claude_vault_readable(),
"claude_note": self.claude_note,
// The full round-tripping config (what a persist layer serializes).
"config": self.cfg,
})
}
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
return;
};
// The selector's broadcast — track the active workspace (never a 2nd source).
if let Some(ws) = v.get("set_workspace").and_then(|t| t.as_str()) {
self.active_workspace = ws.to_string();
return;
}
// A whole-config round-trip (persist restore). A config persisted by a FAT
// build and reloaded by a THIN one carries `backend = embedded`, which this
// binary cannot enter — so the restore is clamped rather than obeyed, and it
// says so instead of silently sitting in an impossible state.
if let Some(cfg) = v.get("config") {
if let Ok(mut cfg) = serde_json::from_value::<SettingsConfig>(cfg.clone()) {
if !cfg.backend.is_available() {
cfg.backend = Backend::Remote;
self.backend_note = format!(
"restored config asked for `embedded`; {}",
Self::no_embedded_reason()
);
} else {
self.backend_note.clear();
}
self.cfg = cfg;
return;
}
}
// Individual controls (the same edits the widgets make). A mode this binary
// cannot enter is REFUSED at the seam, exactly as the widget refuses to draw
// it — the control surface and the render must not disagree about what exists.
if let Some(b) = v
.get("backend")
.and_then(|t| t.as_str())
.and_then(Backend::parse)
{
if b.is_available() {
self.cfg.backend = b;
self.backend_note.clear();
} else {
self.backend_note = Self::no_embedded_reason().to_string();
}
}
if let Some(e) = v.get("server_endpoint").and_then(|t| t.as_str()) {
self.cfg.server_endpoint = e.to_string();
}
if let Some(t) = v.get("theme").and_then(|t| t.as_str()) {
self.cfg.theme = t.to_string();
}
// `vault_unlock` is the persisted config GATE (a bool restore), NOT a real
// unlock — persist-reload sets it directly. A REAL unlock is driven headlessly
// via `{"unlock":"<passphrase>"}` (drives `dwarves::vault::Session`), and lock
// via `{"lock":true}`.
if let Some(u) = v.get("vault_unlock").and_then(|t| t.as_bool()) {
self.cfg.vault_unlocked = u;
}
if let Some(pw) = v.get("unlock").and_then(|t| t.as_str()) {
self.passphrase = pw.to_string();
self.do_unlock();
}
if v.get("lock").and_then(|t| t.as_bool()) == Some(true) {
self.do_lock();
}
// The Claude credential: `{"claude_key":"sk-ant-…"}` stores it (the same path
// the Save button drives), `{"clear_claude_key":true}` removes it. The value
// is consumed straight into the vault and the buffer cleared — it is never
// echoed back through `state_json`.
if let Some(k) = v.get("claude_key").and_then(|t| t.as_str()) {
#[cfg(feature = "claude-token")]
{
self.claude_key = k.to_string();
self.do_save_claude_key();
}
#[cfg(not(feature = "claude-token"))]
{
let _ = k;
self.claude_note =
"this build cannot store a key — rebuild with `--features claude-token`"
.to_string();
}
}
if v.get("clear_claude_key").and_then(|t| t.as_bool()) == Some(true) {
#[cfg(feature = "claude-token")]
{
self.do_clear_claude_key();
}
#[cfg(not(feature = "claude-token"))]
{
self.claude_note = "nothing stored in this build".to_string();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// RED-when-broken (the GATE): the Settings config ROUND-TRIPS over the seam —
/// setting backend/theme/endpoint/vault via `update_json` shows up in `state_json`,
/// and the serialized `config` deserializes back to the same struct.
#[test]
fn settings_config_round_trips_over_the_seam() {
let mut p = SettingsPane::new(vec!["nordisk".into(), "skade".into()], "nordisk");
// Defaults: THIN/remote (the law: a UI defaults thin), dark theme, vault locked.
let s = p.state_json();
assert_eq!(s["kind"], "settings");
assert_eq!(s["backend"], "remote");
assert_eq!(s["vault_unlocked"], false);
assert_eq!(s["active_workspace"], "nordisk");
// Flip to thin/remote + a light theme + unlock the vault, all over the seam.
p.update_json(r#"{"backend":"remote"}"#);
p.update_json(r#"{"server_endpoint":"http://127.0.0.1:7900"}"#);
p.update_json(r#"{"theme":"nordisk-light"}"#);
p.update_json(r#"{"vault_unlock":true}"#);
let s = p.state_json();
assert_eq!(s["backend"], "remote");
assert_eq!(s["server_endpoint"], "http://127.0.0.1:7900");
assert_eq!(s["theme"], "nordisk-light");
assert_eq!(s["vault_unlocked"], true);
// The whole config serializes and deserializes back byte-identically.
let cfg_json = s["config"].clone();
let restored: SettingsConfig = serde_json::from_value(cfg_json).unwrap();
assert_eq!(restored, *p.config());
// A whole-config restore over the seam replaces the state (persist reload).
let mut p2 = SettingsPane::new(vec![], "");
p2.update_json(&serde_json::json!({ "config": restored }).to_string());
assert_eq!(p2.state_json()["backend"], "remote");
assert_eq!(p2.state_json()["theme"], "nordisk-light");
assert_eq!(p2.state_json()["vault_unlocked"], true);
}
/// The active workspace mirrors the selector's broadcast — never a second source of
/// truth. RED-when-broken if Settings stopped tracking `set_workspace`.
#[test]
fn settings_tracks_the_selected_workspace() {
let mut p = SettingsPane::new(vec!["nordisk".into(), "skade".into()], "");
assert_eq!(p.state_json()["active_workspace"], "");
p.update_json(r#"{"set_workspace":"skade"}"#);
assert_eq!(p.state_json()["active_workspace"], "skade");
}
/// ROBOT SCENARIO (task #69 item 4): Settings' Unlock drives the REAL
/// `dwarves::vault::Session` — a WRONG passphrase is rejected (stays locked, surfaces
/// the error), the RIGHT one unlocks (the session key the Profile tab's `DwarvesVault`
/// + OIDC `VaultTokenStore` read), and Lock clears it. Only compiled with
/// `--features vault`; driven headless over `update_json`/`state_json`.
#[cfg(feature = "vault")]
#[test]
fn robot_settings_drives_real_vault_unlock() {
use dwarves::vault::{Session, ZnippyVault};
// The ONE shared crate-level env lock (never a per-module twin — L5).
let _g = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let sess = dir.path().join("vault.session");
let vpath = dir.path().join("vault.bin");
// SAFETY: single-threaded under ENV; isolate BOTH the session and the default
// vault path (HOME) to this dir so the real `~/.dwarves` is never touched.
let prev_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("DWARVES_VAULT_SESSION", &sess);
std::env::set_var("HOME", dir.path());
}
// Seed a vault at the default path (HOME/.dwarves/vault.bin).
std::fs::create_dir_all(dir.path().join(".dwarves")).unwrap();
let v = ZnippyVault::at(&vpath);
let _ = &v; // the pane opens the DEFAULT vault; seed THAT one:
let dv = ZnippyVault::default_vault();
dv.add(b"correct-pw", "crates-io", "crates-io-token", b"cio_X")
.unwrap();
let mut p = SettingsPane::new(vec!["nordisk".into()], "nordisk");
assert_eq!(p.state_json()["vault_unlocked"], false);
// Wrong passphrase → rejected, stays locked, error surfaced.
p.update_json(r#"{"unlock":"WRONG"}"#);
let s = p.state_json();
assert_eq!(
s["vault_unlocked"], false,
"wrong passphrase must not unlock: {s}"
);
assert!(s["vault_note"]
.as_str()
.unwrap_or_default()
.contains("unlock failed"));
assert!(
Session::key_for_path(dv.path()).is_none(),
"no session cached on a bad unlock"
);
// Right passphrase → the REAL session unlocks.
p.update_json(r#"{"unlock":"correct-pw"}"#);
let s = p.state_json();
let unlocked = s["vault_unlocked"] == true;
assert!(
unlocked,
"the correct passphrase unlocks the real vault: {s}"
);
assert!(
Session::key_for_path(dv.path()).is_some(),
"a real session key is cached"
);
// Lock clears the real session.
p.update_json(r#"{"lock":true}"#);
assert_eq!(p.state_json()["vault_unlocked"], false);
assert!(
Session::key_for_path(dv.path()).is_none(),
"lock clears the real session"
);
unsafe {
std::env::remove_var("DWARVES_VAULT_SESSION");
match &prev_home {
Some(h) => std::env::set_var("HOME", h),
None => std::env::remove_var("HOME"),
}
}
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"dwarves-gui",
"settings_drives_real_vault_unlock",
unlocked,
"Settings' Unlock/Lock drive the REAL dwarves::vault::Session: a wrong passphrase is rejected (stays locked + surfaces the error), the correct one caches the session key the Profile tab reads, and Lock clears it — driven headless, no network",
);
}
/// **RED-when-broken: the fat/thin control never offers a mode the binary cannot
/// enter.** Renders the REAL widget and reads the AccessKit tree, so it fails if
/// the radio is merely disabled or hidden behind a tooltip rather than absent —
/// and it drives the seam too, because a control surface that accepts what the
/// widget refuses to draw is the same impossible state by another door.
///
/// It asserts the case that matches how it was COMPILED: with `dupes-fat` the
/// choice is real and both radios must be on screen; without it, `Embedded (fat)`
/// must not be paintable at all and the pane must instead STATE the mode.
#[test]
fn the_backend_control_offers_only_modes_this_binary_can_enter() {
use egui_kittest::kittest::Queryable;
let mut p = SettingsPane::new(vec!["nordisk".into()], "nordisk");
// The seam agrees with the binary about what exists.
let st = p.state_json();
assert_eq!(st["has_embedded_engine"], HAS_EMBEDDED_ENGINE);
if HAS_EMBEDDED_ENGINE {
assert_eq!(
st["backend_choices"],
serde_json::json!(["embedded", "remote"])
);
} else {
assert_eq!(st["backend_choices"], serde_json::json!(["remote"]));
}
// Ask for the fat mode over the seam.
p.update_json(r#"{"backend":"embedded"}"#);
let st = p.state_json();
if HAS_EMBEDDED_ENGINE {
assert_eq!(
st["backend"], "embedded",
"a fat build really can go embedded"
);
assert_eq!(st["backend_note"], "");
} else {
assert_eq!(st["backend"], "remote", "a thin build must stay thin");
assert!(
st["backend_note"]
.as_str()
.unwrap_or_default()
.contains("no embedded engine"),
"…and must SAY why, not silently ignore it: {st}"
);
}
// A whole-config restore (e.g. persisted by a fat build) is clamped the same way.
p.update_json(r#"{"config":{"backend":"embedded","server_endpoint":"http://x:1","theme":"nordisk-dark","vault_unlocked":false}}"#);
assert_eq!(
p.state_json()["backend"],
if HAS_EMBEDDED_ENGINE {
"embedded"
} else {
"remote"
},
"a restored config can never install a mode the binary cannot enter"
);
// …and now the RENDER: what is actually paintable.
let cell = std::cell::RefCell::new(SettingsPane::new(vec!["nordisk".into()], "nordisk"));
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1200.0, 1000.0))
.build_ui(|ui| {
Facet::ui(&mut *cell.borrow_mut(), ui);
});
h.step();
h.step();
let fat_radio_painted = h.query_by_label("Embedded (fat)").is_some();
assert_eq!(
fat_radio_painted, HAS_EMBEDDED_ENGINE,
"the fat radio is painted iff this binary carries the embedded engine"
);
if !HAS_EMBEDDED_ENGINE {
assert!(
h.query_by_label("THIN").is_some(),
"an absent control reads as ambiguous — the mode must be STATED"
);
assert!(
h.query_by_label("— this build carries no embedded engine")
.is_some(),
"…and stated in words, not as a bare badge"
);
}
cell_row(
"settings_backend_offers_only_reachable_modes",
fat_radio_painted == HAS_EMBEDDED_ENGINE,
"the fat/thin control is drawn from what the BINARY can do: with dupes-fat compiled in both radios paint and the seam accepts `embedded`; without it the fat radio is absent from the AccessKit tree entirely (not greyed, not tooltipped), the pane STATES `THIN — this build carries no embedded engine`, and the seam refuses `{\"backend\":\"embedded\"}` with a reason instead of installing a mode the binary cannot enter",
);
}
/// **RED-when-broken: a thin build says what thin COSTS.** The price was buried in
/// a `build-variants.toml` comment; it belongs where the person choosing can read
/// it, because thin is not a slower answer, it is a poorer one.
#[test]
fn a_thin_backend_states_what_it_cannot_answer() {
use egui_kittest::kittest::Queryable;
let cell = std::cell::RefCell::new(SettingsPane::new(vec!["nordisk".into()], "nordisk"));
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1200.0, 1400.0))
.build_ui(|ui| {
Facet::ui(&mut *cell.borrow_mut(), ui);
});
h.step();
h.step();
// The default is thin in every build, so this cost line paints in every build.
let painted = h.query_by_label(THIN_COSTS).is_some();
assert!(painted, "the thin build must name the tier it cannot serve");
assert!(
THIN_COSTS.contains("embeddings"),
"…naming the table that cannot cross"
);
assert!(
THIN_COSTS.contains("SEMANTIC"),
"…and the tier that goes dark"
);
cell_row(
"settings_thin_states_its_cost",
painted,
"with the thin backend selected the Settings pane paints the cost of that choice — that the gRPC seam cannot carry the `embeddings` table and the Dupes tab's SEMANTIC tier plus leafwatch's semantic leg are therefore UNAVAILABLE — instead of leaving it in a build-variants.toml comment",
);
}
/// Emit a coverage-board cell (reuse edda's testmatrix — never a local harness).
fn cell_row(name: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status("dwarves-gui", name, ok, detail);
let _ = (name, ok, detail);
}
/// Every theme the pane offers is a real reused `look::Theme` preset — the picker
/// can't offer a name `by_name` won't resolve (RED if the preset list drifted).
#[test]
fn theme_picker_offers_only_resolvable_presets() {
for name in facett_core::look::Theme::preset_names() {
assert!(
facett_core::look::Theme::by_name(&name).is_some(),
"preset {name} resolves"
);
}
}
}
/// **Robot-UI coverage for the Claude (Anthropic) API-key control** — the config
/// surface whose absence was the original complaint ("I didn't see the config for the
/// claude token"). Driven through the SHARED `nornir-robotui` framework (egui_kittest /
/// AccessKit — REUSE, never a twinned harness).
///
/// These live here rather than in `tests/` because `settings` is a module of the BINARY
/// target (declared in `main.rs`), not of the library — an integration test cannot reach
/// it. No crate-level feature gate: the discoverability scenario runs in EVERY build (the
/// 2026-07-21 silenced-tests trap), and only the leg that writes a real vault is gated,
/// per-test.
#[cfg(all(test, not(target_arch = "wasm32")))]
mod robotui_claude_token_tests {
use super::*;
use facett_core::Facet;
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{status, Assert, Observable, RobotSession, Scenario, Step};
use serde_json::json;
/// The Settings pane as an `Observable` — the robot reads exactly what the pane
/// renders through `Facet::state_json`, no twinned oracle.
struct SettingsApp(SettingsPane);
impl Observable for SettingsApp {
fn state_json(&self) -> serde_json::Value {
Facet::state_json(&self.0)
}
}
/// **The Claude token control is REACHABLE in the DEFAULT build.** A user must be
/// able to FIND it without knowing a build flag — that discoverability is the whole
/// point of the change, so it is asserted on the AccessKit tree (it really paints),
/// not merely in `state_json`.
///
/// RED-when-broken: delete the control's label and the visibility step fails.
#[test]
fn robot_finds_the_claude_token_control_in_settings() {
let _env = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// Isolate the env so a developer's real ANTHROPIC_* export cannot flip this.
// SAFETY: single-threaded under ENV_LOCK.
unsafe {
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
}
let pane = SettingsPane::new(vec!["nordisk".into()], "nordisk");
let harness = Harness::builder()
.with_size(nornir_robotui::egui::Vec2::new(1200.0, 1000.0))
.build_ui_state(
|ui, app: &mut SettingsApp| Facet::ui(&mut app.0, ui),
SettingsApp(pane),
);
let mut robot = RobotSession::new(harness, "dwarves-gui");
robot.run(2); // settle the layout so every widget is in the AccessKit tree
let scenario = Scenario::new("claude-token-control-discoverable")
.step(
Step::new("the Claude API-key control is labelled and on screen")
.assert(Assert::LabelVisible("Claude (Anthropic) API key:".into())),
)
.step(
Step::new("with nothing configured the pane says so honestly").assert(
Assert::State {
pointer: "/claude_source".into(),
expected: json!("none"),
},
),
)
.step(
Step::new("…and reports it as not configured").assert(Assert::State {
pointer: "/claude_configured".into(),
expected: json!(false),
}),
);
let rows = robot.run_scenario(&scenario);
let all_green = rows.iter().all(|r| r.status == status::PASS);
assert!(
all_green,
"the Claude token control is discoverable in Settings: {:?}",
rows.iter()
.map(|r| (&r.test_name, &r.status, &r.message))
.collect::<Vec<_>>()
);
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"dwarves-gui",
"claude_token_control_discoverable",
all_green,
"the Settings pane carries a labelled Claude (Anthropic) API-key control in the DEFAULT build, driven through the shared nornir-robotui framework, and reports an honest credential source when none is configured — the config surface whose absence was the original complaint",
);
}
/// **Storing a key flips the reported source to the vault — and no key material ever
/// rides the seam.** Drives the pane's real storage path with the vault redirected to
/// a tempdir, so nothing touches `~/.dwarves/vault.bin`.
///
/// RED-when-broken: drop the `claude_key` arm in `update_json` and the source never
/// leaves `none`; echo the key into `state_json` and the secret-leak assert fails.
#[cfg(feature = "claude-token")]
#[test]
fn robot_stores_a_claude_key_and_the_source_flips_to_the_vault() {
const SENTINEL: &str = "sk-ant-robot-sentinel-value";
let _env = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
// SAFETY: single-threaded under ENV_LOCK; isolates the vault + credential env
// to this tempdir for the test.
unsafe {
std::env::set_var("DWARVES_VAULT_FILE", dir.path().join("vault.bin"));
std::env::set_var("DWARVES_VAULT_SESSION", dir.path().join("vault.session"));
std::env::set_var("DWARVES_VAULT_PASSPHRASE", "robot-claude-pw");
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
}
let mut pane = SettingsPane::new(vec!["nordisk".into()], "nordisk");
assert_eq!(
Facet::state_json(&pane)["claude_source"],
"none",
"nothing yet"
);
// Store a key through the SAME path the Save button drives.
Facet::update_json(&mut pane, &json!({ "claude_key": SENTINEL }).to_string());
let st = Facet::state_json(&pane);
assert_eq!(
st["claude_source"], "vault:anthropic",
"the vault rung now wins"
);
assert_eq!(st["claude_configured"], true);
assert_eq!(st["claude_in_vault"], true);
// THE SECRET NEVER RIDES THE SEAM — not the key, not a fragment of it.
let blob = st.to_string();
assert!(
!blob.contains(SENTINEL),
"the key must never enter state_json: {blob}"
);
assert!(
!blob.contains("sk-ant"),
"no key-shaped material in state_json: {blob}"
);
// …and it is genuinely sealed on disk, not merely stored.
let on_disk = std::fs::read(dir.path().join("vault.bin")).unwrap();
assert!(
!on_disk
.windows(SENTINEL.len())
.any(|w| w == SENTINEL.as_bytes()),
"the key must be encrypted at rest"
);
// Removing it returns the pane to the honest unconfigured state.
Facet::update_json(&mut pane, &json!({ "clear_claude_key": true }).to_string());
let st = Facet::state_json(&pane);
assert_eq!(st["claude_source"], "none", "removed ⇒ unconfigured again");
assert_eq!(st["claude_in_vault"], false);
// Do not leak the redirected vault path to any later test in this process —
// `DWARVES_VAULT_FILE` outranks the HOME isolation the journey test relies on.
// SAFETY: still under ENV_LOCK.
unsafe {
std::env::remove_var("DWARVES_VAULT_FILE");
std::env::remove_var("DWARVES_VAULT_SESSION");
std::env::remove_var("DWARVES_VAULT_PASSPHRASE");
}
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"dwarves-gui",
"claude_token_stored_in_vault_from_settings",
true,
"a Claude API key typed into Settings is sealed in the dwarves vault through the shared VaultTokenStore, flips the reported credential source to vault:anthropic, and never appears in state_json or in the vault file as plaintext",
);
}
}