Add layered settings, and fix a migration mechanism that silently lied

Two things, because the second could not be built on the first.

The schema stamp was part of the same execute_batch as the CREATE TABLE IF NOT
EXISTS statements, and it wrote unconditionally. Opening an older file therefore
added no columns but flipped the version forward anyway; opening a *newer* file
stamped it back down and then wrote rows the newer build could not read. Both
produced a database whose recorded version was a lie, and every future schema
change would have inherited it.

Now the version is read before anything is applied, migrations are ordered and
forward-only inside one transaction, a newer file is refused with SchemaTooNew
rather than downgraded, and a supported version raised without a step to reach
it fails at the first open instead of claiming success. Tested by stamping a
file at version 99 and asserting both the refusal and that the stamp is left
untouched.

lumbridge-settings resolves compiled default -> settings.toml -> environment.
The environment sits above the file deliberately: decision 0016 calls
LUMBRIDGE_CLAUDE_OAUTH=0 "one switch off", and a switch a config file can
silently re-enable is not a switch. A pinned value renders disabled and names
the variable, rather than accepting an edit that would do nothing.

Every field carries a WriteAuthority. Routing all writes through Configure is
the obvious design and would hand a layout-only agent the program every future
pane launches — the guarantee decision 0006 exists to make. Anything naming a
program, path or destination is Human-only, asserted by a test that reads the
path rather than trusting the author.

Four paths are permanently not settings, with the reason recorded beside each
and a test asserting their absence: the usage endpoint URL, the credentials
path, the client identity, and the shell program. A configuration file that can
redirect where an access token is sent is a credential exfiltration path with a
friendly name.

Environment access is a trait rather than std::env, because the workspace forbids
unsafe, set_var is unsafe in Rust 2024, and the layering rule has to be testable
without mutating the process running the test.

Verified live with LUMBRIDGE_CLAUDE_OAUTH=0: the account-endpoint row reads off,
greyed, "pinned by LUMBRIDGE_CLAUDE_OAUTH". The Advanced page names every file,
endpoint and child process Lumbridge touches and states that nothing is sent
anywhere else — as a fact, not as a toggle nobody can flip.

File loading, comment-preserving writes and editable controls are not in this
pass; 0022 records why that order is the honest one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Metal Agent
2026-09-01 00:06:06 -07:00
co-authored by Claude Opus 5
parent 72887cb4ab
commit e5d7a3efd5
14 changed files with 1431 additions and 14 deletions
Generated
+34
View File
@@ -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"
+1
View File
@@ -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",
+1
View File
@@ -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" }
+5 -3
View File
@@ -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> {
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)),
]
}
+110 -1
View File
@@ -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<Page>,
/// 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>) {
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<Self>) -> 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>) {
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,
+236
View File
@@ -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<gpui::AnyElement> = 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::<Vec<_>>()
.join(", ")
)),
)
})
.into_any_element()
}
+1 -1
View File
@@ -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;
+10 -4
View File
@@ -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)
},
}
}
}
+15
View File
@@ -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
+637
View File
@@ -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<String>;
}
/// The real environment.
#[derive(Clone, Copy, Debug, Default)]
pub struct ProcessEnv;
impl EnvSource for ProcessEnv {
fn get(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
}
/// A fixed environment, for tests.
#[derive(Clone, Debug, Default)]
pub struct MapEnv(BTreeMap<String, String>);
impl MapEnv {
#[must_use]
pub fn new<const N: usize>(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<String> {
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<String>,
pub accent: Option<String>,
}
#[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<bool>,
/// Follow Claude Code's session transcripts for token spend.
pub claude_transcripts: Option<bool>,
/// Probe Codex's app-server for its quota windows.
pub codex_app_server: Option<bool>,
/// Where the status-line bridge writes. `LUMBRIDGE_CLAUDE_FEED` overrides.
pub claude_rate_limit_feed: Option<String>,
}
#[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<bool>,
}
/// 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<T> {
pub value: T,
pub origin: Origin,
}
/// Everything, resolved.
#[derive(Clone, Debug)]
pub struct Settings {
pub theme: Resolved<String>,
pub accent: Resolved<String>,
pub claude_account_endpoint: Resolved<bool>,
pub claude_transcripts: Resolved<bool>,
pub codex_app_server: Resolved<bool>,
pub claude_rate_limit_feed: Resolved<Option<String>>,
pub allow_osc52_clipboard: Resolved<bool>,
/// Keys the file carried that this build ignored.
pub unknown: Vec<UnknownKey>,
}
/// 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<UnknownKey>,
env: &impl EnvSource,
) -> Self {
fn layer<T: Clone>(file: Option<T>, default: T) -> Resolved<T> {
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<UnknownKey>), 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<UnknownKey>) {
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");
}
}
+96
View File
@@ -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"));
}
}
+3
View File
@@ -13,3 +13,6 @@ rusqlite = { version = "0.40.2", default-features = false, features = ["bundled"
[lints]
workspace = true
[dev-dependencies]
tempfile = "3.27.0"
+196 -5
View File
@@ -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<u32> = 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
);
}
}
@@ -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.