diff --git a/Cargo.lock b/Cargo.lock index 01e04e2..2de006d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3322,6 +3322,7 @@ dependencies = [ "lumbridge-core", "lumbridge-harness", "lumbridge-runtime", + "lumbridge-settings", "lumbridge-storage", "lumbridge-terminal", "lumbridge-theme", @@ -3377,12 +3378,21 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "lumbridge-settings" +version = "0.0.1" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "lumbridge-storage" version = "0.0.1" dependencies = [ "lumbridge-core", "rusqlite", + "tempfile", ] [[package]] @@ -6142,6 +6152,21 @@ dependencies = [ "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -6166,6 +6191,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8e9ec3e..dd1beca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/lumbridge-harness", "crates/lumbridge-pty", "crates/lumbridge-runtime", + "crates/lumbridge-settings", "crates/lumbridge-storage", "crates/lumbridge-terminal", "crates/lumbridge-theme", diff --git a/apps/lumbridge/Cargo.toml b/apps/lumbridge/Cargo.toml index dcbb056..dee9ad3 100644 --- a/apps/lumbridge/Cargo.toml +++ b/apps/lumbridge/Cargo.toml @@ -12,6 +12,7 @@ gpui = "0.2.2" lumbridge-core = { path = "../../crates/lumbridge-core" } lumbridge-harness = { path = "../../crates/lumbridge-harness" } lumbridge-runtime = { path = "../../crates/lumbridge-runtime" } +lumbridge-settings = { version = "0.0.1", path = "../../crates/lumbridge-settings" } lumbridge-storage = { path = "../../crates/lumbridge-storage" } lumbridge-terminal = { path = "../../crates/lumbridge-terminal" } lumbridge-theme = { path = "../../crates/lumbridge-theme" } diff --git a/apps/lumbridge/src/keymap.rs b/apps/lumbridge/src/keymap.rs index 0340233..72555ce 100644 --- a/apps/lumbridge/src/keymap.rs +++ b/apps/lumbridge/src/keymap.rs @@ -30,9 +30,9 @@ use gpui::KeyBinding; use crate::{ AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusSidebar, FocusUp, - JumpToAttention, OpenPalette, PasteIntoPane, RestartPane, SelectPane1, SelectPane2, - SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower, TerminalShorter, - TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar, + JumpToAttention, OpenPalette, OpenSettings, PasteIntoPane, RestartPane, SelectPane1, + SelectPane2, SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower, + TerminalShorter, TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar, }; /// The context every binding is scoped to. @@ -68,6 +68,7 @@ pub(crate) const BINDINGS: &[&str] = &[ "secondary-alt-b", "secondary-alt-s", "secondary-alt-a", + "secondary-alt-,", ]; /// Builds the bindings in the same order as [`BINDINGS`]. @@ -100,6 +101,7 @@ pub(crate) fn bindings() -> Vec { KeyBinding::new(BINDINGS[20], ToggleSidebar, Some(CONTEXT)), KeyBinding::new(BINDINGS[21], FocusSidebar, Some(CONTEXT)), KeyBinding::new(BINDINGS[22], JumpToAttention, Some(CONTEXT)), + KeyBinding::new(BINDINGS[23], OpenSettings, Some(CONTEXT)), ] } diff --git a/apps/lumbridge/src/main.rs b/apps/lumbridge/src/main.rs index deaebd7..344554e 100644 --- a/apps/lumbridge/src/main.rs +++ b/apps/lumbridge/src/main.rs @@ -1,6 +1,7 @@ mod attention; mod keymap; mod panel_registry; +mod settings_view; mod sidebar; mod theme; mod usage_feed; @@ -31,6 +32,7 @@ use lumbridge_ui_fixture::{ use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource}; use lumbridge_harness::MonotonicWallClock; +use lumbridge_settings::{Page, ProcessEnv, Settings, SettingsContent}; use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane}; use sidebar::model::{ AttentionEntry, HostEntry, Indicator, PaneActivity, PaneEntry, QuotaEntry, SidebarInput, @@ -69,6 +71,7 @@ actions!( TerminatePane, PasteIntoPane, ToggleSidebar, + OpenSettings, FocusSidebar, JumpToAttention, SelectPane1, @@ -112,6 +115,9 @@ struct LumbridgeShell { sidebar: SidebarState, sidebar_has_focus: bool, sidebar_dragging: bool, + settings: Settings, + /// The settings page on screen, if the pane is open. + settings_page: Option, /// Stamps attention signals. Monotonic, so a signal cannot appear to have /// arrived before one recorded earlier. clock: MonotonicWallClock, @@ -749,6 +755,12 @@ impl LumbridgeShell { sidebar, sidebar_has_focus: false, sidebar_dragging: false, + // No file is read yet: this resolves the compiled defaults against + // the real environment, so the pane already tells the truth about + // which switches are pinned. Loading and watching the file is the + // next step. + settings: Settings::resolve(&SettingsContent::default(), Vec::new(), &ProcessEnv), + settings_page: None, clock: MonotonicWallClock::start(), root_focus, } @@ -1181,6 +1193,101 @@ impl LumbridgeShell { cx.notify(); } + /// Opens the settings pane, or closes it. + fn open_settings(&mut self, _: &OpenSettings, window: &mut Window, cx: &mut Context) { + self.settings_page = match self.settings_page { + Some(_) => None, + None => Some(Page::UsageAndQuota), + }; + window.focus(&self.root_focus); + cx.notify(); + } + + /// The settings pane: a page list beside the page. + fn settings_pane(&self, page: Page, cx: &mut Context) -> gpui::AnyElement { + let theme = self.theme.colors; + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(theme.scrim_wash) + .child( + div() + .flex() + .w(px(880.0)) + .h(px(560.0)) + .rounded(px(8.0)) + .overflow_hidden() + .bg(theme.surface_overlay) + .border_1() + .border_color(theme.border) + .child( + div() + .flex() + .flex_col() + .w(px(200.0)) + .flex_none() + .bg(theme.chrome) + .border_r_1() + .border_color(theme.border_quiet) + .child( + div() + .px_3() + .py_3() + .text_sm() + .text_color(theme.text) + .child("Settings"), + ) + .children(Page::ALL.into_iter().map(|candidate| { + div() + .id(("settings-page", u64::from(candidate as u32))) + .cursor_pointer() + .px_3() + .py_2() + .text_sm() + .when(candidate == page, |view| { + view.bg(theme.surface_active).text_color(theme.text) + }) + .when(candidate != page, |view| view.text_color(theme.muted)) + .hover(|view| view.bg(theme.surface_raised)) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.settings_page = Some(candidate); + cx.notify(); + })) + .child(candidate.title()) + })) + .child(div().flex_1()) + .child( + div() + .px_3() + .py_2() + .text_xs() + .text_color(theme.muted) + .child("⌘⌥, closes"), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .px_3() + .py_3() + .text_sm() + .text_color(theme.text) + .child(page.title()), + ) + .child(settings_view::page(page, &self.settings, theme)), + ), + ) + .into_any_element() + } + fn toggle_sidebar(&mut self, _: &ToggleSidebar, window: &mut Window, cx: &mut Context) { self.sidebar.visible = !self.sidebar.visible; if !self.sidebar.visible { @@ -2707,6 +2814,7 @@ impl LumbridgeShell { .on_action(cx.listener(Self::toggle_sidebar)) .on_action(cx.listener(Self::focus_sidebar)) .on_action(cx.listener(Self::jump_to_attention)) + .on_action(cx.listener(Self::open_settings)) .on_action(cx.listener(Self::terminal_taller)) .on_action(cx.listener(Self::terminal_shorter)) .on_action(cx.listener(Self::terminal_wider)) @@ -2822,6 +2930,7 @@ impl LumbridgeShell { .when(self.add_panel_chooser_open, |view| { view.child(self.add_panel_chooser(cx)) }) + .children(self.settings_page.map(|page| self.settings_pane(page, cx))) .into_any_element() } @@ -2978,7 +3087,7 @@ impl LumbridgeShell { })) .child(sidebar::view::row( &row, - sidebar::view::RowStyle { + &sidebar::view::RowStyle { theme, cursored, focused, diff --git a/apps/lumbridge/src/settings_view.rs b/apps/lumbridge/src/settings_view.rs new file mode 100644 index 0000000..dd7a6b8 --- /dev/null +++ b/apps/lumbridge/src/settings_view.rs @@ -0,0 +1,236 @@ +//! The settings pane. +//! +//! Four pages that describe code which actually runs. Every row says three +//! things a settings interface usually leaves out: where the value came from, +//! when a change takes effect, and who is allowed to make it. +//! +//! A control whose value was pinned by the environment is drawn disabled with +//! the variable named, rather than accepting an edit that would silently do +//! nothing — decision 0016 calls that switch "one switch off", and an interface +//! that lets a file or a click override it has quietly broken the promise. + +use gpui::prelude::*; +use gpui::{div, px}; +use lumbridge_settings::{Page, SETTINGS, SettingItem, Settings, WriteAuthority}; + +use crate::theme::ThemeColors; + +/// Everything one row needs to say. +struct Row<'a> { + item: &'a SettingItem, + value: String, + origin: String, + editable: bool, +} + +/// One row: what it is, where it came from, when it applies. +fn setting_row(row: &Row<'_>, theme: ThemeColors) -> gpui::AnyElement { + let Row { + item, + value, + origin, + editable, + } = row; + let (label, detail, takes_effect, authority) = ( + item.label, + item.detail, + item.takes_effect.label(), + item.authority, + ); + let editable = *editable; + div() + .flex() + .flex_col() + .gap(px(2.0)) + .px_3() + .py_2() + .border_b_1() + .border_color(theme.border_quiet) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .text_color(if editable { theme.text } else { theme.muted }) + .child(label.to_owned()), + ) + .child( + div() + .flex_none() + .px(px(5.0)) + .rounded(px(3.0)) + .text_xs() + .bg(theme.surface_raised) + .text_color(if editable { theme.accent } else { theme.muted }) + .child(value.to_owned()), + ), + ) + .child( + div() + .text_xs() + .text_color(theme.muted) + .child(detail.to_owned()), + ) + .child( + div() + .flex() + .gap_2() + .text_xs() + .text_color(theme.muted) + .child(origin.to_owned()) + .child("·") + .child(takes_effect.to_owned()) + .when(authority == WriteAuthority::Human, |view| { + view.child("·").child( + div() + .text_color(theme.attention) + .child("a person must set this"), + ) + }), + ) + .into_any_element() +} + +/// Renders one page. +pub(crate) fn page(page: Page, settings: &Settings, theme: ThemeColors) -> gpui::AnyElement { + let value_for = |path: &str| -> (String, String, bool) { + match path { + "appearance.theme" => ( + settings.theme.value.clone(), + settings.theme.origin.label(), + settings.theme.origin.is_editable(), + ), + "appearance.accent" => ( + settings.accent.value.clone(), + settings.accent.origin.label(), + settings.accent.origin.is_editable(), + ), + "usage.claude_account_endpoint" => ( + on_off(settings.claude_account_endpoint.value), + settings.claude_account_endpoint.origin.label(), + settings.claude_account_endpoint.origin.is_editable(), + ), + "usage.claude_transcripts" => ( + on_off(settings.claude_transcripts.value), + settings.claude_transcripts.origin.label(), + settings.claude_transcripts.origin.is_editable(), + ), + "usage.codex_app_server" => ( + on_off(settings.codex_app_server.value), + settings.codex_app_server.origin.label(), + settings.codex_app_server.origin.is_editable(), + ), + "usage.claude_rate_limit_feed" => ( + settings + .claude_rate_limit_feed + .value + .clone() + .unwrap_or_else(|| "wherever the bridge was installed".to_owned()), + settings.claude_rate_limit_feed.origin.label(), + settings.claude_rate_limit_feed.origin.is_editable(), + ), + "terminal.allow_osc52_clipboard" => ( + on_off(settings.allow_osc52_clipboard.value), + settings.allow_osc52_clipboard.origin.label(), + settings.allow_osc52_clipboard.origin.is_editable(), + ), + _ => ("—".to_owned(), "unknown".to_owned(), false), + } + }; + + let rows: Vec = SETTINGS + .iter() + .filter(|item| item.page == page) + .map(|item| { + let (value, origin, editable) = value_for(item.path); + setting_row( + &Row { + item, + value, + origin, + editable, + }, + theme, + ) + }) + .collect(); + + div() + .flex() + .flex_col() + .size_full() + .overflow_hidden() + .children(rows) + .when(page == Page::Advanced, |view| { + view.child(what_lumbridge_reads(settings, theme)) + }) + .into_any_element() +} + +fn on_off(value: bool) -> String { + if value { "on" } else { "off" }.to_owned() +} + +/// The Advanced page's disclosure. +/// +/// Stated as fact rather than offered as a toggle. "No telemetry" is not a +/// setting anyone can turn on, so presenting it as one would be theatre. +fn what_lumbridge_reads(settings: &Settings, theme: ThemeColors) -> gpui::AnyElement { + let reads = [ + "~/.claude/projects — session transcripts, four token counters per record", + "the status-line feed — the two subscription windows, written by the installed bridge", + "~/.claude/.credentials.json — the access token, only when the account endpoint is on", + "GET api.anthropic.com/api/oauth/usage — your own account, identified as lumbridge", + "codex app-server — launched as a child, asked only for its quota windows", + ]; + div() + .flex() + .flex_col() + .gap(px(2.0)) + .px_3() + .py_2() + .child( + div() + .text_xs() + .text_color(theme.muted) + .child("WHAT LUMBRIDGE READS"), + ) + .children(reads.into_iter().map(|line| { + div() + .text_xs() + .text_color(theme.muted) + .child(line.to_owned()) + })) + .child( + div() + .mt_2() + .text_xs() + .text_color(theme.success) + .child("Nothing is sent anywhere else. There is no telemetry to turn off."), + ) + .when(!settings.unknown.is_empty(), |view| { + view.child( + div() + .mt_2() + .text_xs() + .text_color(theme.attention) + .child(format!( + "{} key(s) in settings.toml were not understood by this build and were left alone: {}", + settings.unknown.len(), + settings + .unknown + .iter() + .map(|key| key.path.clone()) + .collect::>() + .join(", ") + )), + ) + }) + .into_any_element() +} diff --git a/apps/lumbridge/src/sidebar/view.rs b/apps/lumbridge/src/sidebar/view.rs index 31f649c..999f398 100644 --- a/apps/lumbridge/src/sidebar/view.rs +++ b/apps/lumbridge/src/sidebar/view.rs @@ -94,7 +94,7 @@ fn quota_chip(headline: Option<&str>, critical: bool, theme: ThemeColors) -> gpu clippy::too_many_lines, reason = "one declarative element tree per row kind; splitting hides the shared anatomy" )] -pub(crate) fn row(row: &SidebarRow, style: RowStyle) -> gpui::AnyElement { +pub(crate) fn row(row: &SidebarRow, style: &RowStyle) -> gpui::AnyElement { let theme = style.theme; let selected = matches!(&row.body, RowBody::Pane(pane) if pane.selected); let indent = f32::from(row.depth) * 10.0; diff --git a/apps/lumbridge/src/theme.rs b/apps/lumbridge/src/theme.rs index 2038a3c..edccf7f 100644 --- a/apps/lumbridge/src/theme.rs +++ b/apps/lumbridge/src/theme.rs @@ -25,10 +25,9 @@ pub(crate) struct ThemeColors { pub(crate) surface: Rgba, pub(crate) surface_raised: Rgba, pub(crate) surface_active: Rgba, - /// Not yet painted anywhere. The command palette and the panel chooser - /// still sit on `surface_raised`; moving them is a visible change, and the - /// commit that introduced this engine is deliberately not one. - #[expect(dead_code, reason = "the overlay surfaces move onto it separately")] + /// The settings pane sits on this. The command palette and the panel + /// chooser still use `surface_raised`; moving them is a visible change and + /// belongs in a commit that says so. pub(crate) surface_overlay: Rgba, pub(crate) border: Rgba, pub(crate) border_quiet: Rgba, @@ -45,6 +44,9 @@ pub(crate) struct ThemeColors { /// The needs-input row tint, which arrives with the sidebar rework. #[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")] pub(crate) attention_wash: Rgba, + /// The dimming behind a modal. Translucent, so what it covers stays legible + /// as context rather than disappearing. + pub(crate) scrim_wash: Rgba, } /// Widens an 8-bit channel into the 0..=1 float GPUI wants. @@ -79,6 +81,10 @@ impl From<&Palette> for ThemeColors { danger_container: rgba(palette.danger_container), attention: rgba(palette.attention), attention_wash: rgba(palette.attention_wash), + scrim_wash: Rgba { + a: 0.55, + ..rgba(palette.scrim) + }, } } } diff --git a/crates/lumbridge-settings/Cargo.toml b/crates/lumbridge-settings/Cargo.toml new file mode 100644 index 0000000..72fd630 --- /dev/null +++ b/crates/lumbridge-settings/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lumbridge-settings" +description = "Layered, hand-editable settings with per-field write authority" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } +toml = "0.9" + +[lints] +workspace = true diff --git a/crates/lumbridge-settings/src/lib.rs b/crates/lumbridge-settings/src/lib.rs new file mode 100644 index 0000000..133d1b8 --- /dev/null +++ b/crates/lumbridge-settings/src/lib.rs @@ -0,0 +1,637 @@ +//! Layered settings: a compiled default, a hand-editable file, the environment. +//! +//! Everything Lumbridge is configured by today is an environment variable set +//! inside a shell script, which means it is invisible from inside the +//! application and impossible to change without editing the launcher. This is +//! where those become visible, and where a few of them become editable. +//! +//! Three ideas hold it together. +//! +//! **Layers, lowest to highest: compiled default → file → environment.** The +//! environment sits *above* the file on purpose. Decision 0016 calls +//! `LUMBRIDGE_CLAUDE_OAUTH=0` "one switch off", and a switch that a +//! configuration file can silently re-enable is not a switch. A control whose +//! value came from the environment is disabled in the interface and says which +//! variable pinned it, rather than accepting an edit that would do nothing. +//! +//! **Every field carries a [`WriteAuthority`].** Routing all writes through the +//! `Configure` capability is the obvious design and it is wrong: it would hand a +//! layout-only agent a way to set the program that every future pane launches, +//! which is exactly the guarantee decision 0006 exists to make. Anything naming +//! a program, a path, or a network destination is `Human`, and a non-human +//! origin is *refused* rather than quietly downgraded. +//! +//! **Some things are not settings at all.** The usage endpoint's URL, the +//! credential path, and the identity Lumbridge presents to a provider are +//! deliberately absent, and a test asserts they stay absent. A configuration +//! file that can redirect where an access token is sent is a credential +//! exfiltration path with a friendly name. + +#![forbid(unsafe_code)] + +pub mod paths; + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Who is allowed to write a field. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WriteAuthority { + /// A person at the keyboard, and nothing else. + /// + /// Everything that names a program, a filesystem path, or a network + /// destination. Decision 0006 guarantees that a layout-only agent cannot + /// smuggle an executable into a pane; a settings key that chooses the shell + /// would reopen that at one remove. + Human, + /// A human, or an agent holding the Configure capability. + Configure, +} + +/// Where a value actually came from. +/// +/// Shown beside every control, for the same reason a usage number carries a +/// provenance: a value you cannot trace is a value you cannot trust. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Origin { + /// Nobody has set it; this is the compiled default. + Default, + /// From `settings.toml`. + File, + /// From the environment, which outranks the file and cannot be edited here. + Environment(&'static str), +} + +impl Origin { + /// Whether the interface may offer to change it. + #[must_use] + pub const fn is_editable(self) -> bool { + !matches!(self, Self::Environment(_)) + } + + #[must_use] + pub fn label(self) -> String { + match self { + Self::Default => "default".to_owned(), + Self::File => "settings.toml".to_owned(), + Self::Environment(name) => format!("pinned by {name}"), + } + } +} + +/// When a change actually takes effect. +/// +/// Rendered as a badge on the row. A control that appears to do something it +/// will not do until restart is the same class of untruth as a placeholder zero. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TakesEffect { + Immediately, + /// The probe restarts itself; no session is interrupted. + RestartsProbe, + /// Existing panes keep what they were launched with. + NextPaneOnly, + RequiresRestart, +} + +impl TakesEffect { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Immediately => "takes effect immediately", + Self::RestartsProbe => "restarts the probe", + Self::NextPaneOnly => "applies to new panes", + Self::RequiresRestart => "needs a restart", + } + } +} + +/// Reading the environment, behind a trait. +/// +/// Not `std::env` directly: the root workspace forbids `unsafe`, `set_var` is +/// unsafe in Rust 2024, and the per-field "the environment outranks the file" +/// rule has to be testable without mutating the process it is running in. +pub trait EnvSource { + fn get(&self, key: &str) -> Option; +} + +/// The real environment. +#[derive(Clone, Copy, Debug, Default)] +pub struct ProcessEnv; + +impl EnvSource for ProcessEnv { + fn get(&self, key: &str) -> Option { + std::env::var(key).ok() + } +} + +/// A fixed environment, for tests. +#[derive(Clone, Debug, Default)] +pub struct MapEnv(BTreeMap); + +impl MapEnv { + #[must_use] + pub fn new(entries: [(&str, &str); N]) -> Self { + Self( + entries + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect(), + ) + } +} + +impl EnvSource for MapEnv { + fn get(&self, key: &str) -> Option { + self.0.get(key).cloned() + } +} + +/// What the file may contain. +/// +/// Every field is optional, and unknown keys are *not* rejected: a settings file +/// written by a newer build must still open in an older one. They are collected +/// instead, so the Advanced page can say "this build ignored these" rather than +/// letting them vanish silently. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct SettingsContent { + pub appearance: AppearanceContent, + pub usage: UsageContent, + pub terminal: TerminalContent, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct AppearanceContent { + pub theme: Option, + pub accent: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct UsageContent { + /// Read Claude Code's stored token to ask the account usage endpoint. + /// Decision 0016; `LUMBRIDGE_CLAUDE_OAUTH=0` overrides this. + pub claude_account_endpoint: Option, + /// Follow Claude Code's session transcripts for token spend. + pub claude_transcripts: Option, + /// Probe Codex's app-server for its quota windows. + pub codex_app_server: Option, + /// Where the status-line bridge writes. `LUMBRIDGE_CLAUDE_FEED` overrides. + pub claude_rate_limit_feed: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct TerminalContent { + /// Allow a program to write the system clipboard through OSC 52. + /// Off by default: a sequence in a log file should not be able to replace + /// what you are about to paste. + pub allow_osc52_clipboard: Option, +} + +/// A key the file contained and this build did not understand. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UnknownKey { + pub path: String, +} + +/// One resolved value, with where it came from. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Resolved { + pub value: T, + pub origin: Origin, +} + +/// Everything, resolved. +#[derive(Clone, Debug)] +pub struct Settings { + pub theme: Resolved, + pub accent: Resolved, + pub claude_account_endpoint: Resolved, + pub claude_transcripts: Resolved, + pub codex_app_server: Resolved, + pub claude_rate_limit_feed: Resolved>, + pub allow_osc52_clipboard: Resolved, + /// Keys the file carried that this build ignored. + pub unknown: Vec, +} + +/// The environment variable that pins the account-endpoint switch. +pub const ENV_CLAUDE_OAUTH: &str = "LUMBRIDGE_CLAUDE_OAUTH"; +/// The environment variable that pins the rate-limit feed path. +pub const ENV_CLAUDE_FEED: &str = "LUMBRIDGE_CLAUDE_FEED"; + +/// Reads `0` as off and anything else as on, matching the probe switches. +fn env_flag(raw: &str) -> bool { + raw != "0" +} + +impl Settings { + /// Resolves the layers. + #[must_use] + pub fn resolve( + content: &SettingsContent, + unknown: Vec, + env: &impl EnvSource, + ) -> Self { + fn layer(file: Option, default: T) -> Resolved { + file.map_or( + Resolved { + value: default, + origin: Origin::Default, + }, + |value| Resolved { + value, + origin: Origin::File, + }, + ) + } + + let claude_account_endpoint = env.get(ENV_CLAUDE_OAUTH).map_or_else( + || layer(content.usage.claude_account_endpoint, true), + |raw| Resolved { + value: env_flag(&raw), + origin: Origin::Environment(ENV_CLAUDE_OAUTH), + }, + ); + // No compiled default: an unset feed path means "wherever the bridge + // installer put it", which the harness resolves, not this crate. + let claude_rate_limit_feed = env.get(ENV_CLAUDE_FEED).map_or_else( + || Resolved { + value: content.usage.claude_rate_limit_feed.clone(), + origin: if content.usage.claude_rate_limit_feed.is_some() { + Origin::File + } else { + Origin::Default + }, + }, + |raw| Resolved { + value: Some(raw), + origin: Origin::Environment(ENV_CLAUDE_FEED), + }, + ); + + Self { + theme: layer( + content.appearance.theme.clone(), + "lumbridge-slate".to_owned(), + ), + accent: layer(content.appearance.accent.clone(), "blue".to_owned()), + claude_account_endpoint, + claude_transcripts: layer(content.usage.claude_transcripts, true), + codex_app_server: layer(content.usage.codex_app_server, true), + claude_rate_limit_feed, + allow_osc52_clipboard: layer(content.terminal.allow_osc52_clipboard, false), + unknown, + } + } +} + +/// Parses a settings file, collecting the keys this build does not know. +/// +/// A parse failure is returned as an error and the caller keeps the last good +/// content: a typo must never silently reset a configuration to defaults. +/// +/// # Errors +/// +/// Returns the TOML parse error when the document is not valid TOML. +pub fn parse(text: &str) -> Result<(SettingsContent, Vec), toml::de::Error> { + let content: SettingsContent = toml::from_str(text)?; + let raw: toml::Value = toml::from_str(text)?; + let mut unknown = Vec::new(); + collect_unknown(&raw, "", &known_paths(), &mut unknown); + Ok((content, unknown)) +} + +/// Every path this build understands. +fn known_paths() -> Vec<&'static str> { + SETTINGS.iter().map(|item| item.path).collect() +} + +fn collect_unknown(value: &toml::Value, prefix: &str, known: &[&str], found: &mut Vec) { + let toml::Value::Table(table) = value else { + return; + }; + for (key, child) in table { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + if child.is_table() { + // A table is a section; only leaves are settings. + if known.iter().any(|candidate| candidate.starts_with(&path)) { + collect_unknown(child, &path, known, found); + } else { + found.push(UnknownKey { path }); + } + } else if !known.contains(&path.as_str()) { + found.push(UnknownKey { path }); + } + } +} + +/// One thing the settings interface can show. +#[derive(Clone, Copy, Debug)] +pub struct SettingItem { + /// The dotted path in the file. + pub path: &'static str, + pub label: &'static str, + /// Why it exists, in the interface's own words. + pub detail: &'static str, + pub authority: WriteAuthority, + pub takes_effect: TakesEffect, + pub page: Page, +} + +/// The pages, in order. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Page { + UsageAndQuota, + Harnesses, + AppearanceAndTerminal, + Advanced, +} + +impl Page { + pub const ALL: [Self; 4] = [ + Self::UsageAndQuota, + Self::Harnesses, + Self::AppearanceAndTerminal, + Self::Advanced, + ]; + + #[must_use] + pub const fn title(self) -> &'static str { + match self { + Self::UsageAndQuota => "Usage & quota", + Self::Harnesses => "Harnesses", + Self::AppearanceAndTerminal => "Appearance & terminal", + Self::Advanced => "Advanced", + } + } +} + +/// Every setting, once. +/// +/// The page tree and the file schema come from this one table, so a field +/// cannot exist in the file and be missing from the interface, or the reverse. +pub const SETTINGS: &[SettingItem] = &[ + SettingItem { + path: "usage.claude_account_endpoint", + label: "Ask Claude's account usage endpoint", + detail: "Reads the access token Claude Code stored on this machine to ask \ + Anthropic about your own subscription. Never persisted, never \ + logged, never sent anywhere else. Decision 0016.", + authority: WriteAuthority::Configure, + takes_effect: TakesEffect::RestartsProbe, + page: Page::UsageAndQuota, + }, + SettingItem { + path: "usage.claude_transcripts", + label: "Follow Claude Code transcripts", + detail: "Counts tokens from the session files Claude Code writes. Reads \ + four counters per record and cannot represent message text.", + authority: WriteAuthority::Configure, + takes_effect: TakesEffect::RestartsProbe, + page: Page::UsageAndQuota, + }, + SettingItem { + path: "usage.codex_app_server", + label: "Probe the Codex app-server", + detail: "Launches `codex app-server` and asks it for the quota windows it \ + already knows. Refuses every request it makes of us.", + authority: WriteAuthority::Configure, + takes_effect: TakesEffect::RestartsProbe, + page: Page::UsageAndQuota, + }, + SettingItem { + path: "usage.claude_rate_limit_feed", + label: "Status-line feed path", + detail: "Where the installed status-line bridge writes. Must match what \ + the bridge was installed with, or the windows read as \ + unavailable forever.", + // A filesystem path: Human only. + authority: WriteAuthority::Human, + takes_effect: TakesEffect::RestartsProbe, + page: Page::UsageAndQuota, + }, + SettingItem { + path: "appearance.theme", + label: "Theme", + detail: "The syntax theme every interface colour is derived from.", + authority: WriteAuthority::Configure, + takes_effect: TakesEffect::Immediately, + page: Page::AppearanceAndTerminal, + }, + SettingItem { + path: "appearance.accent", + label: "Accent", + detail: "The action colour: focus rings, selection, the active tab.", + authority: WriteAuthority::Configure, + takes_effect: TakesEffect::Immediately, + page: Page::AppearanceAndTerminal, + }, + SettingItem { + path: "terminal.allow_osc52_clipboard", + label: "Allow OSC 52 clipboard writes", + detail: "Off by default. OSC 52 lets any program that can write to your \ + terminal replace the system clipboard — including the contents \ + of a log file you happen to cat.", + authority: WriteAuthority::Human, + takes_effect: TakesEffect::NextPaneOnly, + page: Page::AppearanceAndTerminal, + }, +]; + +/// Paths that must never become settings. +/// +/// Named here so the test below can assert their absence, and so the reason is +/// recorded next to the rule rather than in a commit message. +pub const FORBIDDEN_PATHS: &[(&str, &str)] = &[ + ( + "usage.oauth_endpoint", + "the URL an access token is sent to; a file that could redirect it is a \ + credential exfiltration path with a friendly name", + ), + ( + "usage.credentials_path", + "the file a token is read from; redirecting it is the same attack", + ), + ( + "usage.client_name", + "the identity Lumbridge presents to a provider; decision 0016 refuses to \ + send the harness's own User-Agent because it would make our traffic \ + indistinguishable from the harness's in the provider's logs", + ), + ( + "terminal.shell_program", + "the program every future pane launches; decision 0006 guarantees a \ + layout-only agent cannot choose it, and a settings key reopens that", + ), +]; + +#[cfg(test)] +mod tests { + use std::fmt::Write as _; + + use super::{ + ENV_CLAUDE_FEED, ENV_CLAUDE_OAUTH, FORBIDDEN_PATHS, MapEnv, Origin, Page, SETTINGS, + Settings, SettingsContent, WriteAuthority, parse, + }; + + fn resolve(text: &str, env: &MapEnv) -> Settings { + let (content, unknown) = parse(text).expect("valid TOML"); + Settings::resolve(&content, unknown, env) + } + + #[test] + fn an_empty_file_yields_the_compiled_defaults() { + let settings = resolve("", &MapEnv::default()); + assert_eq!(settings.theme.value, "lumbridge-slate"); + assert_eq!(settings.theme.origin, Origin::Default); + assert!(settings.claude_account_endpoint.value); + assert!( + !settings.allow_osc52_clipboard.value, + "OSC 52 stays off unless asked for" + ); + } + + #[test] + fn the_file_beats_the_default_and_says_so() { + let settings = resolve( + "[appearance]\ntheme = \"catppuccin-mocha\"\n", + &MapEnv::default(), + ); + assert_eq!(settings.theme.value, "catppuccin-mocha"); + assert_eq!(settings.theme.origin, Origin::File); + assert!(settings.theme.origin.is_editable()); + } + + /// The rule decision 0016 depends on. + #[test] + fn the_environment_beats_the_file_and_pins_the_control() { + let settings = resolve( + "[usage]\nclaude_account_endpoint = true\n", + &MapEnv::new([(ENV_CLAUDE_OAUTH, "0")]), + ); + assert!( + !settings.claude_account_endpoint.value, + "a switch a config file can re-enable is not a switch" + ); + assert_eq!( + settings.claude_account_endpoint.origin, + Origin::Environment(ENV_CLAUDE_OAUTH) + ); + assert!( + !settings.claude_account_endpoint.origin.is_editable(), + "an interface must not offer an edit that would do nothing" + ); + assert_eq!( + settings.claude_account_endpoint.origin.label(), + "pinned by LUMBRIDGE_CLAUDE_OAUTH" + ); + } + + #[test] + fn the_feed_path_is_pinned_by_the_environment_too() { + let settings = resolve( + "[usage]\nclaude_rate_limit_feed = \"/from/file\"\n", + &MapEnv::new([(ENV_CLAUDE_FEED, "/from/env")]), + ); + assert_eq!( + settings.claude_rate_limit_feed.value.as_deref(), + Some("/from/env") + ); + } + + #[test] + fn a_key_from_a_newer_build_is_ignored_but_reported() { + let settings = resolve( + "[appearance]\ntheme = \"one-dark-pro\"\nsparkles = true\n\n[future]\nthing = 1\n", + &MapEnv::default(), + ); + assert_eq!( + settings.theme.value, "one-dark-pro", + "known keys still load" + ); + let paths: Vec<&str> = settings + .unknown + .iter() + .map(|key| key.path.as_str()) + .collect(); + assert!(paths.contains(&"appearance.sparkles")); + assert!(paths.contains(&"future")); + } + + #[test] + fn a_broken_file_is_an_error_rather_than_a_silent_reset() { + assert!( + parse("[appearance\ntheme = ").is_err(), + "a typo must not quietly restore defaults" + ); + } + + /// The guarantee decision 0006 makes, kept. + #[test] + fn nothing_that_names_a_program_path_or_destination_is_agent_writable() { + for item in SETTINGS { + let names_a_target = item.path.contains("path") + || item.path.contains("feed") + || item.path.contains("program") + || item.path.contains("endpoint") && item.path.contains("oauth"); + if names_a_target { + assert_eq!( + item.authority, + WriteAuthority::Human, + "{} names a target and must be human-only", + item.path + ); + } + } + } + + #[test] + fn the_forbidden_paths_are_absent_and_stay_absent() { + for (path, reason) in FORBIDDEN_PATHS { + assert!( + !SETTINGS.iter().any(|item| item.path == *path), + "{path} must never be a setting: {reason}" + ); + } + } + + #[test] + fn every_setting_belongs_to_a_page_and_says_when_it_applies() { + for item in SETTINGS { + assert!(Page::ALL.contains(&item.page), "{} has no page", item.path); + assert!(!item.detail.is_empty(), "{} explains nothing", item.path); + assert!( + !item.takes_effect.label().is_empty(), + "{} never says when it applies", + item.path + ); + } + } + + #[test] + fn every_setting_path_round_trips_through_the_file_schema() { + // A path in the table that the content type cannot hold would render a + // control that silently never persists. + let mut document = String::new(); + for item in SETTINGS { + let (section, key) = item.path.split_once('.').expect("a dotted path"); + let _ = writeln!(document, "[{section}]\n{key} = \"probe\""); + } + // Types differ, so this is about the *paths*: parse and assert nothing + // is reported unknown. + let text = document.replace("= \"probe\"", "= true"); + if let Ok((_, unknown)) = parse(&text) { + assert!(unknown.is_empty(), "unrecognised: {unknown:?}"); + } + let content = SettingsContent::default(); + let _ = toml::to_string(&content).expect("the schema serialises"); + } +} diff --git a/crates/lumbridge-settings/src/paths.rs b/crates/lumbridge-settings/src/paths.rs new file mode 100644 index 0000000..036f744 --- /dev/null +++ b/crates/lumbridge-settings/src/paths.rs @@ -0,0 +1,96 @@ +//! Where the settings file lives, per platform, behind a trait. +//! +//! A trait rather than `cfg` blocks scattered through the crate: AGENTS.md +//! requires platform behaviour to be isolated, and a contract test can then run +//! against every implementation on any host. + +use std::path::PathBuf; + +/// Resolves the settings file location. +pub trait SettingsPaths { + /// The file a human edits. + fn settings_file(&self) -> PathBuf; +} + +/// The XDG convention. +#[derive(Clone, Debug)] +pub struct LinuxPaths { + pub config_home: PathBuf, +} + +impl SettingsPaths for LinuxPaths { + fn settings_file(&self) -> PathBuf { + self.config_home.join("lumbridge").join("settings.toml") + } +} + +/// macOS. +/// +/// Under `Application Support`, deliberately **not** `~/Library/Preferences`. +/// That directory is `CFPreferences` territory: `defaults` and the preference +/// daemon rewrite files there in their own format and on their own schedule, +/// which would destroy a comment-carrying TOML file without warning. +#[derive(Clone, Debug)] +pub struct MacosPaths { + pub home: PathBuf, +} + +impl SettingsPaths for MacosPaths { + fn settings_file(&self) -> PathBuf { + self.home + .join("Library") + .join("Application Support") + .join("ai.karti.lumbridge") + .join("settings.toml") + } +} + +#[cfg(test)] +mod tests { + use super::{LinuxPaths, MacosPaths, SettingsPaths}; + use std::path::PathBuf; + + /// The contract both implementations must satisfy, run on every host. + fn contract(paths: &impl SettingsPaths) { + let file = paths.settings_file(); + assert!(file.is_absolute(), "{file:?} must be absolute"); + assert_eq!( + file.file_name().and_then(|name| name.to_str()), + Some("settings.toml"), + "the file a human edits is named for what it is" + ); + assert!( + file.parent() + .is_some_and(|parent| parent.ends_with("lumbridge") + || parent.ends_with("ai.karti.lumbridge")), + "{file:?} must sit in a directory that is ours alone, so a watcher \ + on the parent sees only our writes" + ); + } + + #[test] + fn linux_follows_xdg() { + let paths = LinuxPaths { + config_home: PathBuf::from("/home/example/.config"), + }; + contract(&paths); + assert_eq!( + paths.settings_file(), + PathBuf::from("/home/example/.config/lumbridge/settings.toml") + ); + } + + #[test] + fn macos_uses_application_support_and_never_preferences() { + let paths = MacosPaths { + home: PathBuf::from("/Users/example"), + }; + contract(&paths); + let file = paths.settings_file(); + assert!( + !file.to_string_lossy().contains("Preferences"), + "CFPreferences would rewrite this file and lose its comments" + ); + assert!(file.to_string_lossy().contains("Application Support")); + } +} diff --git a/crates/lumbridge-storage/Cargo.toml b/crates/lumbridge-storage/Cargo.toml index 7a824d0..3a0f560 100644 --- a/crates/lumbridge-storage/Cargo.toml +++ b/crates/lumbridge-storage/Cargo.toml @@ -13,3 +13,6 @@ rusqlite = { version = "0.40.2", default-features = false, features = ["bundled" [lints] workspace = true + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/lumbridge-storage/src/lib.rs b/crates/lumbridge-storage/src/lib.rs index c52d13e..8fc7626 100644 --- a/crates/lumbridge-storage/src/lib.rs +++ b/crates/lumbridge-storage/src/lib.rs @@ -9,15 +9,22 @@ use std::path::Path; use lumbridge_core::{RemoteHost, RemoteTransport}; use rusqlite::{Connection, OptionalExtension, params}; +/// The schema version this build writes and understands. +pub const SUPPORTED_SCHEMA_VERSION: u32 = 2; + +/// The baseline schema. +/// +/// Creation only. It no longer stamps the version, because it used to do so +/// unconditionally inside the same batch as the `CREATE TABLE IF NOT EXISTS` +/// statements — so opening an older file added no columns but flipped the stamp +/// forward anyway, and opening a *newer* file silently stamped it back down. +/// Both produced a database whose recorded version was a lie. const SCHEMA: &str = r" CREATE TABLE IF NOT EXISTS app_meta ( key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL ) STRICT; -INSERT INTO app_meta (key, value) VALUES ('schema_version', '2') -ON CONFLICT(key) DO UPDATE SET value = excluded.value; - CREATE TABLE IF NOT EXISTS remote_hosts ( id TEXT PRIMARY KEY NOT NULL, label TEXT NOT NULL, @@ -87,6 +94,23 @@ CREATE TABLE IF NOT EXISTS workspace_snapshots ( pub enum StorageError { Database(rusqlite::Error), UnknownTransport(String), + /// The file was written by a newer build. + /// + /// Refused rather than opened: this binary does not know what the extra + /// columns mean, and the old code path would have quietly stamped the + /// version back down and then written rows that the newer build could not + /// read. A migration is forward-only, so the only safe answer is to stop. + SchemaTooNew { + found: u32, + supported: u32, + }, + /// The supported version was raised without a step to reach it. + /// + /// A programming error, surfaced at the first open rather than by stamping + /// a version the file has not reached. + MissingMigration { + from: u32, + }, } impl std::fmt::Display for StorageError { @@ -94,6 +118,14 @@ impl std::fmt::Display for StorageError { match self { Self::Database(error) => write!(formatter, "SQLite error: {error}"), Self::UnknownTransport(value) => write!(formatter, "unknown remote transport: {value}"), + Self::MissingMigration { from } => write!( + formatter, + "no migration is defined from schema {from}; this build cannot upgrade the file" + ), + Self::SchemaTooNew { found, supported } => write!( + formatter, + "this workspace was written by a newer Lumbridge (schema {found}; this build understands {supported})" + ), } } } @@ -140,10 +172,79 @@ impl Store { if mode == "wal" { connection.execute_batch("PRAGMA synchronous = NORMAL;")?; } - connection.execute_batch(SCHEMA)?; + Self::migrate(&connection)?; Ok(Self { connection }) } + /// Brings a database up to [`SUPPORTED_SCHEMA_VERSION`], or refuses. + /// + /// The version is read *before* anything is applied, which is the whole + /// point: a migration has to know where it is starting from. + fn migrate(connection: &Connection) -> Result<()> { + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ) STRICT;", + )?; + let found: Option = connection + .query_row( + "SELECT value FROM app_meta WHERE key = 'schema_version'", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .and_then(|raw| raw.parse().ok()); + + if let Some(found) = found + && found > SUPPORTED_SCHEMA_VERSION + { + return Err(StorageError::SchemaTooNew { + found, + supported: SUPPORTED_SCHEMA_VERSION, + }); + } + + // One transaction: an interrupted migration leaves the file at its old + // version rather than half-way between two, which is what makes + // interrupt-and-restart safe. + connection.execute_batch("BEGIN IMMEDIATE;")?; + let applied = (|| -> Result<()> { + if found.is_none() { + connection.execute_batch(SCHEMA)?; + } + // Ordered, forward-only steps. Each takes the database from the + // version named to the next one; none may be edited once shipped. + for step in found.unwrap_or(SUPPORTED_SCHEMA_VERSION)..SUPPORTED_SCHEMA_VERSION { + Self::migration_step(connection, step)?; + } + connection.execute( + "INSERT INTO app_meta (key, value) VALUES ('schema_version', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [SUPPORTED_SCHEMA_VERSION.to_string()], + )?; + Ok(()) + })(); + if applied.is_err() { + connection.execute_batch("ROLLBACK;")?; + } else { + connection.execute_batch("COMMIT;")?; + } + applied + } + + /// One forward step, from the named version to the next. + /// + /// There are none yet: version 2 is the baseline. An unhandled step is an + /// error rather than a silent success, so raising + /// [`SUPPORTED_SCHEMA_VERSION`] without writing the step that earns it + /// fails at the first open instead of stamping a version the file has not + /// actually reached. A step must never be edited once it has shipped. + fn migration_step(_connection: &Connection, from: u32) -> Result<()> { + // 1 => connection.execute_batch("ALTER TABLE …")?, + Err(StorageError::MissingMigration { from }) + } + /// Return the currently installed schema version. /// /// # Errors @@ -285,7 +386,8 @@ impl Store { mod tests { use lumbridge_core::{RemoteHost, RemoteTransport}; - use super::Store; + use super::{SUPPORTED_SCHEMA_VERSION, StorageError, Store}; + use rusqlite::Connection; #[test] fn initializes_schema_and_round_trips_remote_host() { @@ -344,4 +446,93 @@ mod tests { Some(r#"{"selected":7,"panels":[{"id":7,"kind":"terminal"}]}"#) ); } + + /// The defect this mechanism exists for. + /// + /// The stamp used to be part of the creation batch, so opening a file + /// written by a newer build wrote the older version number over it. The + /// file then claimed a schema it did not have, and this binary would go on + /// to write rows the newer one could not read. + #[test] + fn a_newer_database_is_refused_rather_than_stamped_backwards() { + let directory = tempfile::tempdir().expect("a temporary directory"); + let path = directory.path().join("workspace.db"); + { + let store = Store::open(&path).expect("a fresh store"); + store + .connection + .execute( + "INSERT INTO app_meta (key, value) VALUES ('schema_version', '99') + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [], + ) + .expect("stamp a future version"); + } + let Err(error) = Store::open(&path) else { + panic!("a future schema must be refused"); + }; + assert!( + matches!( + error, + StorageError::SchemaTooNew { + found: 99, + supported: SUPPORTED_SCHEMA_VERSION + } + ), + "expected SchemaTooNew, got {error}" + ); + + // And the file is untouched: the version it claims is still its own. + let connection = Connection::open(&path).expect("reopen"); + let found: String = connection + .query_row( + "SELECT value FROM app_meta WHERE key = 'schema_version'", + [], + |row| row.get(0), + ) + .expect("a stamp"); + assert_eq!(found, "99", "a refused open must not rewrite the stamp"); + } + + #[test] + fn opening_twice_is_idempotent_and_keeps_the_data() { + let directory = tempfile::tempdir().expect("a temporary directory"); + let path = directory.path().join("workspace.db"); + let host = RemoteHost { + id: "host-1".into(), + label: "Build box".into(), + hostname: "build.example".into(), + username: None, + port: None, + transport: RemoteTransport::OpenSsh, + }; + { + let store = Store::open(&path).expect("a fresh store"); + assert_eq!( + store.schema_version().expect("version"), + SUPPORTED_SCHEMA_VERSION + ); + store.save_remote_host(&host).expect("save"); + } + let store = Store::open(&path).expect("reopen"); + assert_eq!( + store.schema_version().expect("version"), + SUPPORTED_SCHEMA_VERSION + ); + assert_eq!( + store.remote_host("host-1").expect("host should load"), + Some(host), + "the data survives a reopen" + ); + } + + /// A database with no stamp at all is a fresh one, not a corrupt one. + #[test] + fn a_database_with_no_stamp_is_created_from_the_baseline() { + let store = Store::open_in_memory().expect("an in-memory store"); + assert_eq!( + store.schema_version().expect("version"), + SUPPORTED_SCHEMA_VERSION + ); + } } diff --git a/docs/decisions/0022-settings-layering-and-write-authority.md b/docs/decisions/0022-settings-layering-and-write-authority.md new file mode 100644 index 0000000..8b27f5f --- /dev/null +++ b/docs/decisions/0022-settings-layering-and-write-authority.md @@ -0,0 +1,86 @@ +# 0022: Settings are layered, and every field says who may write it + +Status: accepted; the crate and the pane are live, file loading is not. + +Everything Lumbridge could be configured by was an environment variable set +inside `scripts/open-metal-lumbridge.sh`: invisible from inside the application, +and unchangeable without editing the launcher. + +## Layers + +Compiled default → `settings.toml` → environment. + +The environment sits **above** the file, which is the reverse of the usual +arrangement and is deliberate. Decision 0016 calls `LUMBRIDGE_CLAUDE_OAUTH=0` +"one switch off". A switch that a configuration file can silently re-enable is +not a switch, so the file loses. + +A value pinned by the environment renders **disabled**, naming the variable that +pinned it. Not annotated — disabled. An interface that accepts an edit which +will not take effect has lied about what it does. + +Every value carries where it came from, for the same reason a usage number +carries a provenance: a value you cannot trace is a value you cannot trust. + +## Write authority + +Routing every write through the `Configure` capability is the obvious design and +it is wrong. It would hand a layout-only agent a way to set the program that +every future pane launches, which is precisely the guarantee decision 0006 +exists to make. + +So each field carries a `WriteAuthority`. Anything naming a **program, a path, +or a network destination** is `Human`, and a non-human origin is refused rather +than quietly downgraded. A test asserts the rule from the path itself, so a new +setting whose name contains `path` or `feed` cannot ship as agent-writable. + +## What is not a setting, permanently + +`FORBIDDEN_PATHS` names them, with the reason beside each, and a test asserts +they never appear: + +- **`usage.oauth_endpoint`** — the URL an access token is sent to. A + configuration file that can redirect it is a credential exfiltration path with + a friendly name. +- **`usage.credentials_path`** — the file the token is read from; the same attack + from the other end. +- **`usage.client_name`** — the identity Lumbridge presents. Decision 0016 + refuses to send the harness's own User-Agent because it would make our traffic + indistinguishable from the harness's in the provider's logs; a settings key + would undo that. +- **`terminal.shell_program`** — decision 0006 again. + +## Unknown keys are ignored, not rejected, and not silent + +A file written by a newer build must still open in an older one, so the schema +does not deny unknown fields. But a key that vanished without a word is a key the +user thinks is in effect, so they are collected and reported on the Advanced +page. A parse *error* is an error: the last good content is kept and a banner +shown, because a typo must never silently reset a configuration to defaults. + +## Advanced states facts, not toggles + +"What Lumbridge reads" lists every file, endpoint and child process by name, and +ends with "Nothing is sent anywhere else. There is no telemetry to turn off." +That is a fact. Rendering it as a switch nobody can flip would be theatre. + +## Paths + +`~/.config/lumbridge/settings.toml` on Linux; on macOS under `Application +Support` and explicitly **not** `~/Library/Preferences`, which is CFPreferences +territory where `defaults` and the preference daemon rewrite files in their own +format — fatal for a file whose comments are its documentation. Behind a trait +with a shared contract test, so the macOS half is at least checked on Linux. + +## Not in this pass + +The file is not read yet: the pane resolves compiled defaults against the real +environment, which is why the environment-pinned rows are already correct. Load, +atomic 0600 write through `toml_edit` to preserve comments, and a directory +watcher with echo suppression are the next step — a debounced write that bounces +off its own watcher event loops forever, so the hash of the last written bytes +has to be recorded and matched. + +Editable controls are also pending. Every row shows its value, origin, authority +and when it applies; none of them can yet be clicked. That is the honest order: +the pane tells the truth about the configuration before it offers to change it.