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:
co-authored by
Claude Opus 5
parent
72887cb4ab
commit
e5d7a3efd5
Generated
+34
@@ -3322,6 +3322,7 @@ dependencies = [
|
|||||||
"lumbridge-core",
|
"lumbridge-core",
|
||||||
"lumbridge-harness",
|
"lumbridge-harness",
|
||||||
"lumbridge-runtime",
|
"lumbridge-runtime",
|
||||||
|
"lumbridge-settings",
|
||||||
"lumbridge-storage",
|
"lumbridge-storage",
|
||||||
"lumbridge-terminal",
|
"lumbridge-terminal",
|
||||||
"lumbridge-theme",
|
"lumbridge-theme",
|
||||||
@@ -3377,12 +3378,21 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lumbridge-settings"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"toml 0.9.12+spec-1.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lumbridge-storage"
|
name = "lumbridge-storage"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lumbridge-core",
|
"lumbridge-core",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6142,6 +6152,21 @@ dependencies = [
|
|||||||
"toml_edit 0.22.27",
|
"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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.1.4+spec-1.1.0"
|
version = "1.1.4+spec-1.1.0"
|
||||||
@@ -6166,6 +6191,15 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ members = [
|
|||||||
"crates/lumbridge-harness",
|
"crates/lumbridge-harness",
|
||||||
"crates/lumbridge-pty",
|
"crates/lumbridge-pty",
|
||||||
"crates/lumbridge-runtime",
|
"crates/lumbridge-runtime",
|
||||||
|
"crates/lumbridge-settings",
|
||||||
"crates/lumbridge-storage",
|
"crates/lumbridge-storage",
|
||||||
"crates/lumbridge-terminal",
|
"crates/lumbridge-terminal",
|
||||||
"crates/lumbridge-theme",
|
"crates/lumbridge-theme",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ gpui = "0.2.2"
|
|||||||
lumbridge-core = { path = "../../crates/lumbridge-core" }
|
lumbridge-core = { path = "../../crates/lumbridge-core" }
|
||||||
lumbridge-harness = { path = "../../crates/lumbridge-harness" }
|
lumbridge-harness = { path = "../../crates/lumbridge-harness" }
|
||||||
lumbridge-runtime = { path = "../../crates/lumbridge-runtime" }
|
lumbridge-runtime = { path = "../../crates/lumbridge-runtime" }
|
||||||
|
lumbridge-settings = { version = "0.0.1", path = "../../crates/lumbridge-settings" }
|
||||||
lumbridge-storage = { path = "../../crates/lumbridge-storage" }
|
lumbridge-storage = { path = "../../crates/lumbridge-storage" }
|
||||||
lumbridge-terminal = { path = "../../crates/lumbridge-terminal" }
|
lumbridge-terminal = { path = "../../crates/lumbridge-terminal" }
|
||||||
lumbridge-theme = { path = "../../crates/lumbridge-theme" }
|
lumbridge-theme = { path = "../../crates/lumbridge-theme" }
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ use gpui::KeyBinding;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusSidebar, FocusUp,
|
AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusSidebar, FocusUp,
|
||||||
JumpToAttention, OpenPalette, PasteIntoPane, RestartPane, SelectPane1, SelectPane2,
|
JumpToAttention, OpenPalette, OpenSettings, PasteIntoPane, RestartPane, SelectPane1,
|
||||||
SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower, TerminalShorter,
|
SelectPane2, SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower,
|
||||||
TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar,
|
TerminalShorter, TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The context every binding is scoped to.
|
/// The context every binding is scoped to.
|
||||||
@@ -68,6 +68,7 @@ pub(crate) const BINDINGS: &[&str] = &[
|
|||||||
"secondary-alt-b",
|
"secondary-alt-b",
|
||||||
"secondary-alt-s",
|
"secondary-alt-s",
|
||||||
"secondary-alt-a",
|
"secondary-alt-a",
|
||||||
|
"secondary-alt-,",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Builds the bindings in the same order as [`BINDINGS`].
|
/// 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[20], ToggleSidebar, Some(CONTEXT)),
|
||||||
KeyBinding::new(BINDINGS[21], FocusSidebar, Some(CONTEXT)),
|
KeyBinding::new(BINDINGS[21], FocusSidebar, Some(CONTEXT)),
|
||||||
KeyBinding::new(BINDINGS[22], JumpToAttention, Some(CONTEXT)),
|
KeyBinding::new(BINDINGS[22], JumpToAttention, Some(CONTEXT)),
|
||||||
|
KeyBinding::new(BINDINGS[23], OpenSettings, Some(CONTEXT)),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+110
-1
@@ -1,6 +1,7 @@
|
|||||||
mod attention;
|
mod attention;
|
||||||
mod keymap;
|
mod keymap;
|
||||||
mod panel_registry;
|
mod panel_registry;
|
||||||
|
mod settings_view;
|
||||||
mod sidebar;
|
mod sidebar;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod usage_feed;
|
mod usage_feed;
|
||||||
@@ -31,6 +32,7 @@ use lumbridge_ui_fixture::{
|
|||||||
|
|
||||||
use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource};
|
use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource};
|
||||||
use lumbridge_harness::MonotonicWallClock;
|
use lumbridge_harness::MonotonicWallClock;
|
||||||
|
use lumbridge_settings::{Page, ProcessEnv, Settings, SettingsContent};
|
||||||
use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane};
|
use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane};
|
||||||
use sidebar::model::{
|
use sidebar::model::{
|
||||||
AttentionEntry, HostEntry, Indicator, PaneActivity, PaneEntry, QuotaEntry, SidebarInput,
|
AttentionEntry, HostEntry, Indicator, PaneActivity, PaneEntry, QuotaEntry, SidebarInput,
|
||||||
@@ -69,6 +71,7 @@ actions!(
|
|||||||
TerminatePane,
|
TerminatePane,
|
||||||
PasteIntoPane,
|
PasteIntoPane,
|
||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
|
OpenSettings,
|
||||||
FocusSidebar,
|
FocusSidebar,
|
||||||
JumpToAttention,
|
JumpToAttention,
|
||||||
SelectPane1,
|
SelectPane1,
|
||||||
@@ -112,6 +115,9 @@ struct LumbridgeShell {
|
|||||||
sidebar: SidebarState,
|
sidebar: SidebarState,
|
||||||
sidebar_has_focus: bool,
|
sidebar_has_focus: bool,
|
||||||
sidebar_dragging: 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
|
/// Stamps attention signals. Monotonic, so a signal cannot appear to have
|
||||||
/// arrived before one recorded earlier.
|
/// arrived before one recorded earlier.
|
||||||
clock: MonotonicWallClock,
|
clock: MonotonicWallClock,
|
||||||
@@ -749,6 +755,12 @@ impl LumbridgeShell {
|
|||||||
sidebar,
|
sidebar,
|
||||||
sidebar_has_focus: false,
|
sidebar_has_focus: false,
|
||||||
sidebar_dragging: 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(),
|
clock: MonotonicWallClock::start(),
|
||||||
root_focus,
|
root_focus,
|
||||||
}
|
}
|
||||||
@@ -1181,6 +1193,101 @@ impl LumbridgeShell {
|
|||||||
cx.notify();
|
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>) {
|
fn toggle_sidebar(&mut self, _: &ToggleSidebar, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.sidebar.visible = !self.sidebar.visible;
|
self.sidebar.visible = !self.sidebar.visible;
|
||||||
if !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::toggle_sidebar))
|
||||||
.on_action(cx.listener(Self::focus_sidebar))
|
.on_action(cx.listener(Self::focus_sidebar))
|
||||||
.on_action(cx.listener(Self::jump_to_attention))
|
.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_taller))
|
||||||
.on_action(cx.listener(Self::terminal_shorter))
|
.on_action(cx.listener(Self::terminal_shorter))
|
||||||
.on_action(cx.listener(Self::terminal_wider))
|
.on_action(cx.listener(Self::terminal_wider))
|
||||||
@@ -2822,6 +2930,7 @@ impl LumbridgeShell {
|
|||||||
.when(self.add_panel_chooser_open, |view| {
|
.when(self.add_panel_chooser_open, |view| {
|
||||||
view.child(self.add_panel_chooser(cx))
|
view.child(self.add_panel_chooser(cx))
|
||||||
})
|
})
|
||||||
|
.children(self.settings_page.map(|page| self.settings_pane(page, cx)))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2978,7 +3087,7 @@ impl LumbridgeShell {
|
|||||||
}))
|
}))
|
||||||
.child(sidebar::view::row(
|
.child(sidebar::view::row(
|
||||||
&row,
|
&row,
|
||||||
sidebar::view::RowStyle {
|
&sidebar::view::RowStyle {
|
||||||
theme,
|
theme,
|
||||||
cursored,
|
cursored,
|
||||||
focused,
|
focused,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ fn quota_chip(headline: Option<&str>, critical: bool, theme: ThemeColors) -> gpu
|
|||||||
clippy::too_many_lines,
|
clippy::too_many_lines,
|
||||||
reason = "one declarative element tree per row kind; splitting hides the shared anatomy"
|
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 theme = style.theme;
|
||||||
let selected = matches!(&row.body, RowBody::Pane(pane) if pane.selected);
|
let selected = matches!(&row.body, RowBody::Pane(pane) if pane.selected);
|
||||||
let indent = f32::from(row.depth) * 10.0;
|
let indent = f32::from(row.depth) * 10.0;
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ pub(crate) struct ThemeColors {
|
|||||||
pub(crate) surface: Rgba,
|
pub(crate) surface: Rgba,
|
||||||
pub(crate) surface_raised: Rgba,
|
pub(crate) surface_raised: Rgba,
|
||||||
pub(crate) surface_active: Rgba,
|
pub(crate) surface_active: Rgba,
|
||||||
/// Not yet painted anywhere. The command palette and the panel chooser
|
/// The settings pane sits on this. The command palette and the panel
|
||||||
/// still sit on `surface_raised`; moving them is a visible change, and the
|
/// chooser still use `surface_raised`; moving them is a visible change and
|
||||||
/// commit that introduced this engine is deliberately not one.
|
/// belongs in a commit that says so.
|
||||||
#[expect(dead_code, reason = "the overlay surfaces move onto it separately")]
|
|
||||||
pub(crate) surface_overlay: Rgba,
|
pub(crate) surface_overlay: Rgba,
|
||||||
pub(crate) border: Rgba,
|
pub(crate) border: Rgba,
|
||||||
pub(crate) border_quiet: 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.
|
/// The needs-input row tint, which arrives with the sidebar rework.
|
||||||
#[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")]
|
#[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")]
|
||||||
pub(crate) attention_wash: Rgba,
|
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.
|
/// 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),
|
danger_container: rgba(palette.danger_container),
|
||||||
attention: rgba(palette.attention),
|
attention: rgba(palette.attention),
|
||||||
attention_wash: rgba(palette.attention_wash),
|
attention_wash: rgba(palette.attention_wash),
|
||||||
|
scrim_wash: Rgba {
|
||||||
|
a: 0.55,
|
||||||
|
..rgba(palette.scrim)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,3 +13,6 @@ rusqlite = { version = "0.40.2", default-features = false, features = ["bundled"
|
|||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.27.0"
|
||||||
|
|||||||
@@ -9,15 +9,22 @@ use std::path::Path;
|
|||||||
use lumbridge_core::{RemoteHost, RemoteTransport};
|
use lumbridge_core::{RemoteHost, RemoteTransport};
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
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"
|
const SCHEMA: &str = r"
|
||||||
CREATE TABLE IF NOT EXISTS app_meta (
|
CREATE TABLE IF NOT EXISTS app_meta (
|
||||||
key TEXT PRIMARY KEY NOT NULL,
|
key TEXT PRIMARY KEY NOT NULL,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
) STRICT;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS remote_hosts (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
label TEXT NOT NULL,
|
label TEXT NOT NULL,
|
||||||
@@ -87,6 +94,23 @@ CREATE TABLE IF NOT EXISTS workspace_snapshots (
|
|||||||
pub enum StorageError {
|
pub enum StorageError {
|
||||||
Database(rusqlite::Error),
|
Database(rusqlite::Error),
|
||||||
UnknownTransport(String),
|
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 {
|
impl std::fmt::Display for StorageError {
|
||||||
@@ -94,6 +118,14 @@ impl std::fmt::Display for StorageError {
|
|||||||
match self {
|
match self {
|
||||||
Self::Database(error) => write!(formatter, "SQLite error: {error}"),
|
Self::Database(error) => write!(formatter, "SQLite error: {error}"),
|
||||||
Self::UnknownTransport(value) => write!(formatter, "unknown remote transport: {value}"),
|
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" {
|
if mode == "wal" {
|
||||||
connection.execute_batch("PRAGMA synchronous = NORMAL;")?;
|
connection.execute_batch("PRAGMA synchronous = NORMAL;")?;
|
||||||
}
|
}
|
||||||
connection.execute_batch(SCHEMA)?;
|
Self::migrate(&connection)?;
|
||||||
Ok(Self { 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.
|
/// Return the currently installed schema version.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -285,7 +386,8 @@ impl Store {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use lumbridge_core::{RemoteHost, RemoteTransport};
|
use lumbridge_core::{RemoteHost, RemoteTransport};
|
||||||
|
|
||||||
use super::Store;
|
use super::{SUPPORTED_SCHEMA_VERSION, StorageError, Store};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn initializes_schema_and_round_trips_remote_host() {
|
fn initializes_schema_and_round_trips_remote_host() {
|
||||||
@@ -344,4 +446,93 @@ mod tests {
|
|||||||
Some(r#"{"selected":7,"panels":[{"id":7,"kind":"terminal"}]}"#)
|
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.
|
||||||
Reference in New Issue
Block a user