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
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user