Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions crates/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,11 @@ pub fn load(data_dir: &Path) -> Result<Settings, String> {
.map_err(|e| format!("invalid settings file {}: {e}", path.display()))
}

pub fn save(data_dir: &Path, settings: &Settings) -> Result<(), String> {
save_bytes(data_dir, settings).map(|_| ())
}

/// `save`, also returning the exact bytes written so a caller can fingerprint
/// what IT wrote rather than re-reading a path something else may have
/// replaced in the meantime.
/// Write settings.json and return the exact bytes written. The one caller
/// is `Core::save_settings`, which fingerprints what it wrote rather than
/// re-reading a path something else may have replaced in the meantime, so a
/// session's own save is never mistaken for external drift. Nothing outside
/// the core writes settings.json.
pub(crate) fn save_bytes(data_dir: &Path, settings: &Settings) -> Result<Vec<u8>, String> {
let json = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
let destination = settings_path(data_dir);
Expand Down Expand Up @@ -340,10 +338,10 @@ mod tests {
));
std::fs::create_dir_all(&dir).unwrap();
let first = Settings { model: "one".into(), ..Settings::default() };
save(&dir, &first).unwrap();
save_bytes(&dir, &first).unwrap();
let mut second = first.clone();
second.model = "vendor/family/two".into();
save(&dir, &second).unwrap();
save_bytes(&dir, &second).unwrap();
assert_eq!(load(&dir).unwrap().model, "vendor/family/two");
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
Expand Down
46 changes: 38 additions & 8 deletions crates/core/src/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
//! so only the process that runs turns can honestly say *when* a capability
//! file changed relative to the session lifecycle. The ledger lives outside
//! the project (like `trust.json`), where the confined file tools never
//! write. Each record embeds the sha256 of the previous record line, so
//! tampering through bash is at least detectable - the honest ceiling
//! without an OS sandbox, and the same ceiling trust already lives at.
//! write. Each record embeds the sha256 of the previous record line, so a
//! torn write or an edit that does not recompute the chain is detected. A
//! deliberate rewrite of log and pin together is not: there is no key, and
//! bash can reach the data dir. That is the honest ceiling without an OS
//! sandbox, and the same ceiling trust already lives at.
//!
//! Rollback is deliberately a file operation, not a product: `openmax
//! --ledger` prints history with object paths, and restoring is `cp`. The
Expand Down Expand Up @@ -146,14 +148,28 @@ pub fn sha256_hex(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}

/// Per-project ledger directory under the harness data dir, keyed by the
/// canonical root so symlinked spellings share one history.
pub fn project_dir(data_dir: &Path, project_root: &Path) -> PathBuf {
/// The per-project key every per-project store under the data dir uses:
/// the canonical root's hash, so symlinked spellings share one history.
pub fn project_key(project_root: &Path) -> String {
let canonical = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf());
let key = sha256_hex(canonical.to_string_lossy().as_bytes());
data_dir.join("ledger").join(&key[..16])
// The path's bytes, not a lossy rendering: two roots that differ only
// in invalid UTF-8 would otherwise share a key, and with it a ledger and
// a prompt history. Valid UTF-8 paths hash to the same key either way.
#[cfg(unix)]
let bytes = {
use std::os::unix::ffi::OsStrExt;
canonical.as_os_str().as_bytes().to_vec()
};
#[cfg(not(unix))]
let bytes = canonical.to_string_lossy().into_owned().into_bytes();
sha256_hex(&bytes)[..16].to_string()
}

/// Per-project ledger directory under the harness data dir.
pub fn project_dir(data_dir: &Path, project_root: &Path) -> PathBuf {
data_dir.join("ledger").join(project_key(project_root))
}

fn log_path(dir: &Path) -> PathBuf {
Expand Down Expand Up @@ -1883,6 +1899,20 @@ pub fn describe(changes: &[Change], project_root: &Path) -> Vec<String> {
mod tests {
use super::*;

/// The key hashes the path's bytes, not a lossy rendering: two roots that
/// differ only in invalid UTF-8 bytes spell the same lossy string, and a
/// shared key would let one project's ledger and prompt history answer
/// for the other's.
#[cfg(unix)]
#[test]
fn project_key_distinguishes_byte_distinct_roots() {
use std::os::unix::ffi::OsStrExt;
let a = PathBuf::from(std::ffi::OsStr::from_bytes(b"/nonexistent/open-max-\xff"));
let b = PathBuf::from(std::ffi::OsStr::from_bytes(b"/nonexistent/open-max-\xfe"));
assert_eq!(a.to_string_lossy(), b.to_string_lossy(), "the lossy spellings collide");
assert_ne!(project_key(&a), project_key(&b));
}

/// Verify the hash chain; returns the number of intact records or the
/// index where the chain breaks. A test oracle: production verification
/// goes through `read_verified`, which also checks the pin.
Expand Down
3 changes: 2 additions & 1 deletion crates/core/src/recall.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! `openmax --recall "<query>"`: deliberate recall over this project's own
//! history - session transcripts, compaction archives, compaction digests,
//! session titles, and memory files - ranked, budgeted, and cited.
//! and memory files - ranked, budgeted, and cited. Session titles ride on
//! every hit but are not searched (see `collect_chunks`).
//!
//! The archive PR made every prune reversible ("Full dropped messages:
//! <path>"); the memory PR made facts durable. Recall is the searchable form
Expand Down
5 changes: 3 additions & 2 deletions crates/core/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,8 +464,9 @@ impl Registry {
registry
}

/// A registry with built-ins only: used for sessions that predate the
/// extensibility layer, so their behavior never changes retroactively.
/// A registry with built-ins only: the shape a session without a
/// manifest resolves to until its first turn re-freezes from disk, and
/// the baseline tests build on.
pub fn builtin_only() -> Self {
Self::assemble(Vec::new(), Vec::new())
}
Expand Down
14 changes: 0 additions & 14 deletions crates/core/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,20 +285,6 @@ impl Core {
Ok(())
}

/// Adopt these exact settings as this process's own after a save that
/// went through `config::save` directly (an existing helper with its own
/// tests). Fingerprints the serialization of what was saved, never a
/// re-read of the path.
pub fn adopt_saved_settings(&self, settings: &Settings) {
if let Ok(json) = serde_json::to_string_pretty(settings) {
*self
.settings_disk_fingerprint
.lock()
.unwrap_or_else(|e| e.into_inner()) =
SettingsFingerprint::Bytes(hash_bytes(json.as_bytes()));
}
}

/// Whether settings.json on disk moved since this process last read or
/// wrote it. On drift, records the new content (so each distinct change
/// is reported once) and returns whether the new bytes would parse -
Expand Down
24 changes: 10 additions & 14 deletions crates/core/tests/settings_receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,27 +291,23 @@ async fn a_missing_settings_file_replaced_by_a_directory_is_drift() {
let _ = std::fs::remove_dir_all(&env.dir);
}

/// The fingerprint after a harness save is of the bytes the harness WROTE,
/// never a re-read of the path. Modeled deterministically: the external
/// replacement lands BEFORE the harness adopts its own save (the interval-
/// race outcome). A re-read-based adoption would swallow the foreign bytes
/// as this process's own; fingerprinting the intended bytes keeps them
/// visible as drift.
/// A harness save fingerprints the bytes it writes, under its lock, so
/// there is no interval in which a foreign write could be adopted as this
/// process's own. What the test can observe from outside is the consequence:
/// an external replacement landing after the save still reads as drift.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_racing_external_replacement_is_not_adopted_by_save() {
async fn an_external_replacement_after_a_save_still_reads_as_drift() {
let env = build_env(vec![completion_with_text("only")]).await;
let settings_path = env.data.join("settings.json");
let mut s = env.core.settings.lock().unwrap().clone();
s.model = "scripted-2".into();
// The harness's write (through the direct helper, as /model does)...
open_max_core::config::save(&env.data, &s).unwrap();
// ...an external replacement lands in the interval...
let mut external = s.clone();
// The harness's write, the one path every in-app save takes...
env.core.save_settings(&s).unwrap();
*env.core.settings.lock().unwrap() = s.clone();
// ...then an external replacement lands.
let mut external = s;
external.model = "external-edit".into();
std::fs::write(&settings_path, serde_json::to_string_pretty(&external).unwrap()).unwrap();
// ...and only now does the harness adopt what IT saved.
env.core.adopt_saved_settings(&s);
*env.core.settings.lock().unwrap() = s;
assert!(
env.core.settings_disk_changed().is_some(),
"the foreign bytes must read as drift, not be adopted as this process's own"
Expand Down
116 changes: 34 additions & 82 deletions crates/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use crate::ui::tool_card::{self, DiffText};
use crate::ui::transcript::{
wrap_lines, Term, Transcript,
};
use crate::ui::{context, extensions, markdown, model_picker, ready};
use crate::ui::{context, extensions, markdown, model_picker};

/// Where keyboard focus lives in chat mode.
#[derive(Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -523,7 +523,7 @@ impl App {
files_tx: mpsc::UnboundedSender<Vec<String>>,
) -> Self {
Self {
composer: Composer::new(&core.data_dir),
composer: Composer::new(&core.data_dir, &project),
core,
project,
session_id: None,
Expand Down Expand Up @@ -1537,15 +1537,12 @@ impl App {
}

fn persist_model_selection(&mut self, provider: Option<String>, model: String) {
let current = self.core.settings.lock().unwrap().clone();
match save_model_selection(
&self.core.data_dir,
&current,
provider.clone(),
model.clone(),
) {
Ok(next) => {
self.core.adopt_saved_settings(&next);
let next = model_selection(&self.core.settings.lock().unwrap(), provider.clone(), model.clone());
// The core's own save: it fingerprints the exact bytes it writes,
// under its lock, so a TUI-authored save never reads as drift and a
// racing external edit still does. Adopted in memory only on success.
match self.core.save_settings(&next) {
Ok(()) => {
*self.core.settings.lock().unwrap() = next;
let source = provider
.map(|name| format!(" from {name}"))
Expand Down Expand Up @@ -2121,32 +2118,6 @@ impl App {
self.transcript.push(block);
self.dirty.mark_chat();
}
"theme" => {
match rest.first().map(|s| s.to_ascii_lowercase()).as_deref() {
Some("light" | "day") => {
theme::apply(theme::ThemeId::Light);
self.note("theme: light");
}
Some("dark" | "night") => {
theme::apply(theme::ThemeId::Dark);
self.note("theme: dark");
}
Some("catppuccin" | "mocha" | "cat") => {
theme::apply(theme::ThemeId::Catppuccin);
self.note("theme: catppuccin");
}
Some("mono" | "bw") => {
theme::set_tokens(theme::Tokens::mono());
self.note("theme: mono");
}
_ => self.note("usage: /theme dark|light|mono|catppuccin"),
}
self.transcript.invalidate_styles();
self.hist_reuse_key = None;
self.tail_width = 0;
self.thinking_source.clear();
self.dirty = Dirty::all();
}
"model" if raw_rest.is_empty() => {
let settings = self.core.settings.lock().unwrap().clone();
self.model_picker = Some(model_picker::ModelPickerState::load(
Expand Down Expand Up @@ -3352,13 +3323,7 @@ impl App {
self.perf_layout_ms = layout_started.elapsed().as_secs_f64() * 1000.0;
self.perf_selection_ms = 0.0;
if self.composer.is_empty() {
ready::render(
Rect {
width: content_w,
..area
},
frame.buffer_mut(),
);
render_ready(Rect { width: content_w, ..area }, frame.buffer_mut());
}
return;
}
Expand Down Expand Up @@ -3705,17 +3670,24 @@ fn command_parts(command: &str) -> (&str, &str) {
(head, rest)
}

fn save_model_selection(
data_dir: &std::path::Path,
current: &config::Settings,
provider: Option<String>,
model: String,
) -> Result<config::Settings, String> {
/// Restrained orientation for an empty chat session: one dim word. Transient
/// chrome, never transcript content, so it never reaches saved sessions or
/// the model; hidden when the area cannot hold it.
fn render_ready(area: Rect, buf: &mut ratatui::buffer::Buffer) {
if area.width < 5 || area.height == 0 {
return;
}
Paragraph::new(Line::from(Span::styled("READY", Style::default().fg(theme::DIM()))))
.render(Rect { height: 1, ..area }, buf);
}

/// The settings a model selection produces; pure, so the caller decides how
/// and whether to persist them.
fn model_selection(current: &config::Settings, provider: Option<String>, model: String) -> config::Settings {
let mut next = current.clone();
next.provider = provider;
next.model = model;
config::save(data_dir, &next)?;
Ok(next)
next
}

/// Single source of truth for `/help` and onboarding copy.
Expand Down Expand Up @@ -4360,7 +4332,7 @@ mod tests {
conversation_layout, elapsed_label, header_path_line, help_line, home_shortened, kv,
compact_count, is_shift_tab, paint_text_selection, parse_change_counts, plural,
wide_status_right,
presence_title, rect_contains, save_model_selection, turn_end_rings,
model_selection, presence_title, rect_contains, turn_end_rings,
App, Dirty, Focus, Presence, TermEvent, MIN_DRAW_INTERVAL, TICK, WAIT_TICK,
};
use std::time::Duration;
Expand Down Expand Up @@ -4624,18 +4596,13 @@ mod tests {
fn model_selection_persists_provider_and_complete_id() {
let dir = crate::test_temp_dir("openmax-model-save");
fs::create_dir_all(&dir).unwrap();
let current = config::Settings::default();
let (core, _rx) = open_max_core::state::Core::new(dir.clone()).unwrap();
let exact = "openrouter/vendor/family/model".to_string();
let saved = save_model_selection(
&dir,
&current,
Some("openrouter".into()),
exact.clone(),
)
.unwrap();
assert_eq!(saved.provider.as_deref(), Some("openrouter"));
assert_eq!(saved.model, exact);
let disk = config::load(&dir).unwrap();
let next = model_selection(&config::Settings::default(), Some("openrouter".into()), exact.clone());
assert_eq!(next.provider.as_deref(), Some("openrouter"));
assert_eq!(next.model, exact);
core.save_settings(&next).unwrap();
let disk = config::load(&core.data_dir).unwrap();
assert_eq!(disk.provider.as_deref(), Some("openrouter"));
assert_eq!(disk.model, exact);
fs::remove_dir_all(dir).unwrap();
Expand All @@ -4650,21 +4617,6 @@ mod tests {
assert_eq!(command_parts("model"), ("model", ""));
}

#[test]
fn model_selection_failure_leaves_current_settings_unchanged() {
let current = config::Settings::default();
let missing = crate::test_temp_dir("openmax-model-save-missing").join("nested");
let result = save_model_selection(
&missing,
&current,
Some("other".into()),
"other/model".into(),
);
assert!(result.is_err());
assert!(current.provider.is_none());
assert_eq!(current.model, config::Settings::default().model);
}

#[test]
fn approval_card_has_focused_default_and_clipped_detail() {
let lines = approval_card_lines(
Expand Down Expand Up @@ -4877,7 +4829,7 @@ mod tests {
let (mut app, dir) = app_fixture();
let on_disk = {
let settings = app.core.settings.lock().unwrap().clone();
config::save(&dir, &settings).unwrap();
app.core.save_settings(&settings).unwrap();
config::load(&dir).unwrap().approval_mode
};
assert_eq!(on_disk, config::ApprovalMode::Ask);
Expand Down Expand Up @@ -4985,7 +4937,7 @@ mod tests {
let (mut app, dir) = app_fixture();
{
let settings = app.core.settings.lock().unwrap().clone();
config::save(&dir, &settings).unwrap();
app.core.save_settings(&settings).unwrap();
}

app.on_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE))
Expand Down Expand Up @@ -5015,7 +4967,7 @@ mod tests {
let (mut app, dir) = app_fixture();
{
let settings = app.core.settings.lock().unwrap().clone();
config::save(&dir, &settings).unwrap();
app.core.save_settings(&settings).unwrap();
}
app.pending_approval =
Some(("id".into(), "bash".into(), "sum".into(), "detail".into(), vec![]));
Expand Down
6 changes: 0 additions & 6 deletions crates/tui/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,6 @@ pub const COMMANDS: &[CommandSpec] = &[
"endpoint, cache, performance, privacy, and network details",
true,
),
command(
"theme",
"dark|light|mono|catppuccin",
"switch appearance",
false,
),
command("quit", "", "exit", true),
];

Expand Down
Loading
Loading