Compare commits

..
6 Commits
Author SHA1 Message Date
swaits a92eda210e fix(host): gate the update module's callers for non-unix builds
check / check (push) Waiting to run
portable-macos / portable (macos-latest) (push) Waiting to run
portable-windows / portable (windows-latest) (push) Waiting to run
two-user / two-user (push) Waiting to run
The update module is `#[cfg(unix)]` (self-update replaces the running
program's file, which Windows forbids), but three commands in
`ledger-host` used `crate::update` unconditionally, so the server-only
Windows build could not resolve the module. GitHub's portable-windows
job failed on main 3aa1206a with nine E0433 errors in `ledger-host`.

Gate `update_waiting`, `check_for_updates` and `apply_update` with
`#[cfg(unix)]`, leaving their Unix bodies unchanged, and give non-unix
builds a stub of each: nothing is waiting, and checking for or accepting
an update is refused with "self-update isn't available on this platform"
(the same `Err` path a refused update takes). `UPDATE_WAIT` is gated with
the Unix `apply_update` it serves.
2026-11-05 10:17:50 -07:00
swaits 3aa1206afc merge: P12 step 1, the fuzz targets, the op-sequence extension and the mutation recipes (branch P12, 48062b89)
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
2026-11-05 08:49:36 -07:00
swaits 758c175ff2 merge: P11a, the in-app update accept, the update notice and translocation (branch P11a, 7b177451) 2026-11-05 08:49:36 -07:00
swaits 7b1774512b docs: record the in-app accept, the update notice and translocation (P11a)
check / check (push) Waiting to run
portable-macos / portable (macos-latest) (push) Waiting to run
portable-windows / portable (windows-latest) (push) Waiting to run
two-user / two-user (push) Waiting to run
The interface spec gains `books.start`'s allocated `update_waiting` field
and says only the owner can accept it. The packaging and architecture
notes now describe the lease intent, the viewer's notice and "Check for
updates", and the translocated-app refusal instead of listing them as not
built. The testing record names each new test and the mutation targets it
gives P12. The plan's P11a status and As-built block record what landed
and what still waits: the Mac trial, the Windows installer (P11c's) and
signing (D-64).
2026-11-05 08:29:20 -07:00
swaits e2754898d8 fix: refuse to connect an agent from a translocated macOS app
A `.dmg` downloaded from the internet keeps its quarantine bit, so
Gatekeeper may run the app from a read-only copy under
`/private/var/folders/…/AppTranslocation/…` that vanishes when the app
closes. Connected from there, an agent would be pointed at a path that
will not exist the next time it runs. Connect refuses instead, before a
token is made or an agent's list is touched, and says to move the app out
of Downloads and open it again (P11a step 6).

The rule is on the path the program would give the agent
(`start::translocation_refusal` over `this_executable`), so the same check
covers the CLI's `connect` and the viewer's Connect. The test stands in
for Gatekeeper's copy on any Unix by naming a file under an
`AppTranslocation` path in `APPIMAGE`; the agent never runs and no books
home is made, while the same program at an ordinary path connects. A unit
test holds the marker to a whole path part, not letters in a name.
2026-11-05 08:29:16 -07:00
swaits 53a233d687 feat: accept a staged update in the app
The host has staged a downloaded, signature-checked update since step 4,
but the only way to apply it was `wanderbooks update` at a terminal, and
nothing in the viewer or on an agent's first call said one waited. The
owner's accept is the name plan 8.0.3 allocated for it: the lease socket
gains `Intent::ApplyUpdate`, and the host installs the staged copy from a
thread and lets its existing watch for a replaced program (host.rs
INVARIANT 7) restart onto it once no call runs, so the books stay open and
every holder's session carries on. With no host running there is no call
to cut off, so the viewer applies it itself.

The viewer reads `<root>/updates/staged/meta.json` every few seconds and
shows a standing notice with "Update now"; Books gains "Check for updates"
(which looks at the feed at once, whatever the daily look last did) and
"Update to X". `books.start` gains the allocated `update_waiting` field
and a next step telling the agent to tell the human; no tool can accept.

`apply_staged` waits up to five seconds for the update lock: running the
new tests showed the host's own look can hold it for a moment after it
writes `meta.json`, and the owner's accept should wait that out rather
than answer "nothing waits".

Tests: `books_start_says_an_update_waits` and
`the_owner_accepts_a_staged_update_in_the_app` in
`crates/ledger-cli/tests/update.rs` (a bridge writes all through the
restart, each account in the chart exactly once);
`the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts`
and `books_outside_a_data_root_have_no_update_notice` in
`crates/ledger-gui/tests/update_notice.rs`.
2026-11-05 08:29:11 -07:00
17 changed files with 996 additions and 21 deletions

No files matched your search

+15
View File
@@ -172,6 +172,21 @@ impl ledger_gui::owner::Owner for ViewerOwner {
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String> {
ledger_host::commands::upgrade_format(&self.0, book, passphrase)
}
fn update_waiting(&self) -> Option<ledger_gui::owner::UpdateWaiting> {
ledger_host::commands::update_waiting(&self.0).map(|u| ledger_gui::owner::UpdateWaiting {
version: u.version,
staged_at: u.staged_at,
})
}
fn check_for_updates(&self) -> Result<Vec<String>, String> {
ledger_host::commands::check_for_updates(&self.0).map(|l| vec![l])
}
fn apply_update(&self) -> Result<Vec<String>, String> {
ledger_host::commands::apply_update(&self.0).map(|l| vec![l])
}
}
/// `wanderbooks gui`: the viewer, in this process. For books of the data
+133
View File
@@ -0,0 +1,133 @@
//! A macOS app run translocated refuses to connect an agent (P11a step 6):
//! Gatekeeper runs a quarantined download from a read-only copy under
//! `/private/var/folders/…/AppTranslocation/…`, and that path goes away
//! with the app; an agent pointed at it would lose the books. The owner
//! moves the app out of Downloads first.
//!
//! The check is on the path the program would give the agent
//! (`ledger_host::start::this_executable`), so a test can stand in for
//! macOS on any Unix: `APPIMAGE` names a file there, and a path with an
//! `AppTranslocation` part is refused just as the real one is.
#![cfg(unix)]
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const KNOBS: &str =
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
struct Home {
dir: tempfile::TempDir,
}
impl Home {
fn new() -> Self {
let dir = tempfile::Builder::new()
.prefix("wb-translocation-")
.tempdir()
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["setup", "--home"])
.arg(dir.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
Self { dir }
}
fn root(&self) -> PathBuf {
ledger_store::root::default_root(self.dir.path())
}
/// The program with a root of its own and `PATH` holding the stand-in
/// agent. Nothing here touches the account's real data root.
fn cmd(&self, mode: &str, path: &Path) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
c.arg(mode)
.arg("--root")
.arg(self.root())
.args(["--test-knobs", KNOBS])
.env_clear()
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
.env("HOME", self.dir.path())
.stdin(Stdio::null());
c
}
}
/// A stand-in `claude` that leaves a mark when it is run, so the test can
/// say the agent never was.
fn fake_agent(home: &Home) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let bin = home.dir.path().join("agent-bin");
std::fs::create_dir_all(&bin).unwrap();
let script = bin.join("claude");
std::fs::write(
&script,
format!(
"#!/bin/sh\ntouch '{}'\nexit 0\n",
home.dir.path().join("agent-ran").display()
),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
}
#[test]
fn translocated_app_refuses_to_connect() {
let home = Home::new();
let out = home
.cmd("books", Path::new("/nonexistent"))
.args(["create", "beta"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let bin = fake_agent(&home);
// What macOS hands the program from Gatekeeper's copy: a path under
// AppTranslocation. A bare file is enough (this_executable only asks
// that it is one).
let translocated = home.dir.path().join(
"private/var/folders/x/AppTranslocation/uuid/d/Wanderbooks.app/Contents/MacOS/wanderbooks",
);
std::fs::create_dir_all(translocated.parent().unwrap()).unwrap();
std::fs::write(&translocated, b"").unwrap();
let out = home
.cmd("connect", &bin)
.env("APPIMAGE", &translocated)
.args(["claude", "--book", "beta"])
.output()
.unwrap();
assert!(
!out.status.success(),
"a translocated app connected an agent"
);
let said = String::from_utf8_lossy(&out.stderr);
assert!(said.contains("move wanderbooks out of Downloads"), "{said}");
// Refused before anything was done: no agent ran, no books home was
// made for a path that will vanish.
assert!(!home.dir.path().join("agent-ran").exists(), "the agent ran");
assert!(
!home.dir.path().join("Documents/Wanderbooks/beta").exists(),
"a books home was made"
);
// The same program at an ordinary path connects: it is the
// translocation, not the agent or the books, that was refused.
let ordinary = home.dir.path().join("Wanderbooks.AppImage");
std::fs::write(&ordinary, b"").unwrap();
let out = home
.cmd("connect", &bin)
.env("APPIMAGE", &ordinary)
.args(["claude", "--book", "beta"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
assert!(
home.dir.path().join("agent-ran").exists(),
"the agent wasn't run"
);
}
+134
View File
@@ -1040,3 +1040,137 @@ fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() {
"the tampered copy was kept"
);
}
/// What the owner's app does when the owner accepts: one `apply_update`
/// on the host's lease socket (the viewer's `Owner::apply_update`, scripted).
fn owner_accepts(home: &Home) -> Result<Option<ledger_host::lease::HostMsg>, String> {
ledger_host::opener::ask_once_within(
&home.root().join("run"),
ledger_host::lease::Ask::Intent(ledger_host::lease::Intent::ApplyUpdate),
Duration::from_secs(90),
)
}
#[test]
fn books_start_says_an_update_waits() {
let home = Home::new("start-says");
home.create("acme");
let release = Release::new();
let next = newer(&home, "start-says");
release.publish("9.0.0", &next, |_| {});
let mut agent = Agent::start(&home, "acme");
let quiet = agent.call("books.start", json!({})).unwrap();
assert!(quiet.get("update_waiting").is_none(), "{quiet}");
name_feed(&home, &release);
// The host's look is once a day (a test knob makes it soon); a mark
// older than that lets the next poll look now.
let _ = std::fs::remove_file(home.updates().join("checked"));
assert!(
wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")),
"nothing was staged"
);
let start = agent.call("books.start", json!({})).unwrap();
assert_eq!(start["update_waiting"]["version"], "9.0.0", "{start}");
assert!(
start["update_waiting"]["staged_at"]
.as_str()
.unwrap()
.ends_with('Z')
);
assert!(
start["update_waiting"]["how"]
.as_str()
.unwrap()
.contains("only the owner"),
"{start}"
);
// An agent is told to tell the human, and is given no way to accept.
let steps = start["next_steps"].as_array().unwrap();
assert!(
steps
.iter()
.any(|s| s["why"].as_str().unwrap_or_default().contains("9.0.0")),
"{steps:?}"
);
let text = start.to_string();
assert!(
!text.contains(&home.dir.path().display().to_string()),
"a path in books.start"
);
}
#[test]
fn the_owner_accepts_a_staged_update_in_the_app() {
let home = Home::new("accept");
home.create("acme");
let mut agent = Agent::start(&home, "acme");
agent.add_account("4000").unwrap();
let host_before = wait_host(&home);
// Nothing staged: refused, and nothing changes.
match owner_accepts(&home) {
Ok(Some(ledger_host::lease::HostMsg::Refused(_))) => {}
other => panic!("accepting nothing: {other:?}"),
}
let release = Release::new();
let next = newer(&home, "accept");
release.publish("9.0.0", &next, |_| {});
name_feed(&home, &release);
let _ = std::fs::remove_file(home.updates().join("checked"));
assert!(
wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")),
"nothing was staged"
);
let old = home.program();
assert_eq!(sha256(&home.program()), sha256(&old));
// The owner accepts, in the app: the answer says it is installed.
match owner_accepts(&home) {
Ok(Some(ledger_host::lease::HostMsg::Done { detail })) => {
assert!(detail.unwrap_or_default().contains("9.0.0"));
}
other => panic!("accepting: {other:?}"),
}
assert_eq!(sha256(&home.program()), sha256(&next));
// Writes all through the restart onto it: none lost, none made twice,
// the session carries on with no reconnect.
let mut written = vec!["4000".to_string()];
let deadline = Instant::now() + Duration::from_secs(90);
let mut k = 0u32;
loop {
assert!(Instant::now() < deadline, "the host never restarted");
let code = format!("{}", 5000 + k);
k += 1;
let r = agent.add_account(&code);
assert!(r.is_ok(), "a write across the accept: {r:?}");
written.push(code);
if home.pids("host").len() == 1 && home.pids("host")[0] != host_before {
break;
}
}
let host = wait_host(&home);
assert!(wait_for(20, || !home.pids("server").is_empty()));
let exe = |pid: u32| std::fs::read_link(format!("/proc/{pid}/exe")).unwrap();
assert!(!exe(host).to_string_lossy().contains("(deleted)"));
assert_eq!(
sha256(&std::fs::read(home.updates().join("previous/wanderbooks")).unwrap()),
sha256(&old),
"the old program is kept for rollback"
);
agent.add_account("4999").unwrap();
written.push("4999".into());
let chart = agent.call("books.chart", json!({})).unwrap();
let mut in_books: Vec<String> = chart["accounts"]
.as_array()
.unwrap()
.iter()
.filter_map(|a| a["code"].as_str().map(str::to_string))
.filter(|c| c.starts_with('4') || c.starts_with('5'))
.collect();
in_books.sort();
written.sort();
assert_eq!(in_books, written);
}
+114 -5
View File
@@ -132,6 +132,16 @@ pub struct RestorePlan {
pub lines: Vec<String>,
}
/// An update the host has downloaded and checked, waiting for the owner to
/// accept it (P11a step 4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateWaiting {
/// Its version.
pub version: String,
/// When it was staged (UTC, RFC 3339).
pub staged_at: String,
}
/// The owner's actions, done by the program the viewer runs in (ledger-cli
/// over the books server and the agents' own commands). Each blocks: the
/// menu calls it off the UI thread.
@@ -172,6 +182,21 @@ pub trait Owner: Send + Sync {
}
/// Hold the books `book` open while the viewer runs (the viewer's lease).
fn hold(&self, _book: &str) {}
/// The update waiting for the owner's accept, if one is (read from the
/// data root; cheap, called every few seconds).
fn update_waiting(&self) -> Option<UpdateWaiting> {
None
}
/// "Check for updates": look at the release feed now and stage a newer
/// version if there is one. What came of it, in plain words.
fn check_for_updates(&self) -> Result<Vec<String>, String> {
Err("not here".into())
}
/// Accept the waiting update: the books' host installs it and restarts
/// onto it when no call is running, the books staying open.
fn apply_update(&self) -> Result<Vec<String>, String> {
Err("not here".into())
}
/// How to run `agent` in the books home of `book` (made if need be): the
/// dock starts it on a terminal of its own (D-27).
fn agent_start(&self, _agent: Agent, _book: &str) -> Result<AgentStart, String> {
@@ -291,8 +316,14 @@ pub struct OwnerMenu {
/// The books whose format upgrade was asked for since the viewer
/// opened them (INVARIANT 5: once each open).
asked_upgrade: Option<String>,
/// The update waiting, as last read, and when (re-read every few
/// seconds and after any job).
update: Option<(std::time::Instant, Option<UpdateWaiting>)>,
}
/// How long a reading of the waiting update is kept.
const UPDATE_LOOK: std::time::Duration = std::time::Duration::from_secs(5);
impl OwnerMenu {
/// The id of the books shown, if they are books of a data root: found
/// once for each load.
@@ -304,15 +335,73 @@ impl OwnerMenu {
self.book.as_ref().and_then(|(_, id)| id.clone())
}
/// The standing notice of a format upgrade the books wait for, above
/// the page, and the go-ahead asked for once each open (INVARIANT 5).
pub fn notice(&mut self, ui: &mut Ui, books: &Arc<Books>) {
if owner().is_none() {
return;
/// The update waiting, if any (read now and then, not every frame).
fn update_waiting(
&mut self,
ctx: &egui::Context,
owner: &Arc<dyn Owner>,
) -> Option<UpdateWaiting> {
if self
.update
.as_ref()
.is_none_or(|(at, _)| at.elapsed() > UPDATE_LOOK)
{
self.update = Some((std::time::Instant::now(), owner.update_waiting()));
}
ctx.request_repaint_after(UPDATE_LOOK);
self.update.as_ref().and_then(|(_, u)| u.clone())
}
/// The standing notices above the page: an update waiting for the
/// owner's accept, and a format upgrade the books wait for, whose
/// go-ahead is asked for once each open (INVARIANT 5).
pub fn notice(&mut self, ui: &mut Ui, books: &Arc<Books>) {
let Some(owner) = owner() else {
return;
};
let Some(book) = self.book_of(books) else {
return;
};
if let Some(update) = self.update_waiting(ui.ctx(), &owner) {
let p = crate::theme::pal(ui.ctx());
let mut start: Option<Box<dyn FnOnce() -> Outcome + Send>> = None;
egui::Frame::new()
.fill(p.warning_soft)
.inner_margin(egui::Margin::symmetric(20, 10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!(
"{} Version {} of wanderbooks is ready.",
icon::ARROW_FAT_UP,
update.version
))
.font(crate::theme::semibold(13.0))
.color(p.warning),
);
ui.label(
RichText::new(
"It is downloaded and checked. The books stay open while it starts, and no work in progress is cut off.",
)
.font(crate::theme::regular(13.0))
.color(p.text),
);
let free = self.job.is_none();
if ui
.add_enabled(free, egui::Button::new("Update now"))
.on_hover_text("Start using the new version")
.clicked()
{
let o = owner.clone();
start = Some(Box::new(move || o.apply_update()));
}
});
});
if let Some(work) = start {
self.start(ui.ctx(), "Update".into(), work);
}
}
let Some(upgrade) = books.format_upgrade.clone() else {
return;
};
@@ -378,6 +467,7 @@ impl OwnerMenu {
{
self.done = Some((what.clone(), done));
self.job = None;
self.update = None;
}
let text = if compact {
RichText::new(icon::BOOKS).size(16.0).color(p.muted)
@@ -391,6 +481,7 @@ impl OwnerMenu {
button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0));
}
let busy = self.job.is_some();
let waiting = self.update_waiting(ui.ctx(), &owner);
let mut start: Option<(String, Box<dyn FnOnce() -> Outcome + Send>)> = None;
let response = ui
.add_enabled_ui(!busy, |ui| {
@@ -449,6 +540,24 @@ impl OwnerMenu {
}
}
ui.separator();
if ui
.button(format!("{} Check for updates", icon::ARROWS_CLOCKWISE))
.on_hover_text("Look for a newer version of wanderbooks now")
.clicked()
{
let o = owner.clone();
start = Some(("Check for updates".into(), Box::new(move || o.check_for_updates())));
}
if let Some(u) = &waiting
&& ui
.button(format!("{} Update to {}", icon::ARROW_FAT_UP, u.version))
.on_hover_text("Start using the version that is downloaded and checked: the books stay open")
.clicked()
{
let o = owner.clone();
start = Some(("Update".into(), Box::new(move || o.apply_update())));
}
ui.separator();
if ui
.button(format!("{} Close the books", icon::POWER))
.on_hover_text("Close the books now: agents' sessions and every other holder let go")
+199
View File
@@ -0,0 +1,199 @@
//! The viewer's update notice and its "Check for updates" menu item
//! (owner.rs, P11a step 4): a staged update the owner reports shows a
//! standing notice with an "Update now" button; the menu can look for one;
//! accepting hands the work to the owner, off the UI thread.
use chrono::NaiveDate;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_core::Currency;
use ledger_gui::App;
use ledger_gui::books::Books;
use ledger_gui::data::{self, Source};
use ledger_gui::nav::Route;
use ledger_gui::owner::{Owner, RestorePlan, UpdateWaiting};
use ledger_store::Store;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
/// An owner with an update that waits once it has been "found".
#[derive(Default)]
struct Fake {
asked: Mutex<Vec<String>>,
staged: Mutex<bool>,
}
impl Fake {
fn said(&self, what: &str) {
self.asked
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(what.into());
}
}
impl Owner for Fake {
fn close_books(&self) -> Result<String, String> {
Err("not in these tests".into())
}
fn new_books(&self, _: &str, _: &str) -> Result<String, String> {
Err("not in these tests".into())
}
fn back_up_now(&self, _: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
Err("not in these tests".into())
}
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn connect(&self, _: ledger_gui::owner::Agent, _: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn upgrade_format(&self, _: &str, _: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn update_waiting(&self) -> Option<UpdateWaiting> {
(*self.staged.lock().unwrap()).then(|| UpdateWaiting {
version: "9.1.0".into(),
staged_at: "2026-10-30T08:00:00Z".into(),
})
}
fn check_for_updates(&self) -> Result<Vec<String>, String> {
self.said("check");
*self.staged.lock().unwrap() = true;
Ok(vec!["Version 9.1.0 is downloaded and checked.".into()])
}
fn apply_update(&self) -> Result<Vec<String>, String> {
self.said("apply");
*self.staged.lock().unwrap() = false;
Ok(vec!["Version 9.1.0 is installed.".into()])
}
}
fn alone() -> std::sync::MutexGuard<'static, ()> {
static ONE: Mutex<()> = Mutex::new(());
ONE.lock().unwrap_or_else(|e| e.into_inner())
}
fn fake() -> Arc<Fake> {
static FAKE: OnceLock<Arc<Fake>> = OnceLock::new();
FAKE.get_or_init(|| {
let f = Arc::new(Fake::default());
ledger_gui::owner::set_owner(f.clone());
f
})
.clone()
}
fn today() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap()
}
/// Books in a data root (`root`: a folder holding the root's marker), or
/// outside one (as the demo's are).
fn books(root: bool) -> (tempfile::TempDir, PathBuf, Books) {
let dir = tempfile::Builder::new()
.prefix("wb-gui-owner-")
.tempdir()
.unwrap();
let book = if root {
std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap();
dir.path().join("books").join("acme")
} else {
dir.path().join("books-data")
};
std::fs::create_dir_all(&book).unwrap();
drop(Store::create(&book, Currency::USD).unwrap());
let b = data::load_once(&Source {
data_dir: book.clone(),
today: Some(today()),
})
.unwrap();
(dir, book, b)
}
fn harness(b: Books) -> Harness<'static, App> {
let mut h = Harness::builder()
.with_size(egui::vec2(1200.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Overview));
h.run_steps(3);
h
}
fn wait_for(h: &mut Harness<'static, App>, f: &Fake, n: usize) {
for _ in 0..200 {
h.run_steps(2);
if f.asked.lock().unwrap().len() >= n {
h.run_steps(4);
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("the owner wasn't asked: {:?}", f.asked.lock().unwrap());
}
#[test]
fn the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts() {
let _alone = alone();
let f = fake();
let (_dir, _book, b) = books(true);
let mut h = harness(b);
// Nothing waits: no notice.
assert!(h.query_by_label_contains("is ready.").is_none());
// "Check for updates" asks the owner to look; what it finds shows.
h.get_by_label_contains(" Books").click();
h.run_steps(3);
h.get_by_label_contains("Check for updates").click();
wait_for(&mut h, &f, 1);
assert_eq!(f.asked.lock().unwrap()[0], "check");
// The job says so, and the notice says so too: either is the same
// version, so ask for at least one rather than exactly one.
assert!(
h.query_all_by_label_contains("downloaded and checked")
.next()
.is_some()
);
// The standing notice names the version; accepting hands it to the
// owner, and the notice goes once it is installed.
for _ in 0..60 {
h.run_steps(2);
if h.query_by_label_contains("Version 9.1.0 of wanderbooks is ready")
.is_some()
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(
h.query_by_label_contains("Version 9.1.0 of wanderbooks is ready")
.is_some(),
"no notice"
);
h.get_by_label("Update now").click();
wait_for(&mut h, &f, 2);
assert_eq!(f.asked.lock().unwrap()[1], "apply");
for _ in 0..60 {
h.run_steps(2);
if h.query_by_label_contains("is ready.").is_none() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(h.query_by_label_contains("is ready.").is_none());
}
#[test]
fn books_outside_a_data_root_have_no_update_notice() {
let _alone = alone();
let f = fake();
*f.staged.lock().unwrap() = true;
let (_dir, _book, b) = books(false);
let h = harness(b);
assert!(h.query_by_label_contains("is ready.").is_none());
*f.staged.lock().unwrap() = false;
}
+119 -1
View File
@@ -1031,6 +1031,119 @@ pub fn close_books(common: &Common) -> Result<String, String> {
}
}
/// An update the host has downloaded and checked, waiting for the owner
/// (the viewer's notice; P11a step 4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateWaiting {
/// Its version.
pub version: String,
/// When it was staged (UTC, RFC 3339).
pub staged_at: String,
}
/// The update waiting for the owner's accept, if there is one.
#[cfg(unix)]
pub fn update_waiting(common: &Common) -> Option<UpdateWaiting> {
let found = ledger_store::root::Root::find(common.root.as_deref()).ok()?;
let u = found.staged_update()?;
// One this program already is (or is older than) is not waiting: the
// next look drops it.
let newer = crate::update::parse_version(&u.version)
.zip(crate::update::parse_version(
crate::update::program_version(),
))
.is_some_and(|(staged, mine)| staged > mine);
newer.then_some(UpdateWaiting {
version: u.version,
staged_at: u.staged_at,
})
}
/// Nothing waits on a platform the self-update isn't built for.
#[cfg(not(unix))]
pub fn update_waiting(_common: &Common) -> Option<UpdateWaiting> {
None
}
/// "Check for updates": look at the feed now, whatever the daily look
/// last did, and stage a newer release if there is one (it never swaps
/// the program: accepting is [`apply_update`]). What came of it.
#[cfg(unix)]
pub fn check_for_updates(common: &Common) -> Result<String, String> {
let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let mine = crate::update::program_version();
if let crate::update::Install::PackageManaged(manager) = crate::update::this_install()? {
return Err(format!(
"this install is updated by {manager}, not here: use the package manager"
));
}
match crate::update::stage_if_due(&found, mine, Duration::ZERO) {
Ok(Some(version)) => Ok(format!(
"Version {version} is downloaded and checked. Accept it to start using it."
)),
Ok(None) => Ok(match update_waiting(common) {
Some(u) => format!(
"Version {} is downloaded and checked. Accept it to start using it.",
u.version
),
None => format!("You have the newest version ({mine})."),
}),
Err(why) => Err(why.to_string()),
}
}
/// Checking for updates isn't built for this platform: say so, and
/// change nothing.
#[cfg(not(unix))]
pub fn check_for_updates(_common: &Common) -> Result<String, String> {
Err(NO_SELF_UPDATE.into())
}
/// Accept the staged update: the books' host installs it and restarts
/// onto it once no call is running, the books staying open meanwhile
/// (the lease socket's apply_update). With no host running, it is
/// installed here, since no call can be cut off. What came of it.
#[cfg(unix)]
pub fn apply_update(common: &Common) -> Result<String, String> {
let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let said = |detail: Option<String>| detail.unwrap_or_else(|| "The update is accepted.".into());
match crate::opener::ask_once_within(&run, Ask::Intent(Intent::ApplyUpdate), UPDATE_WAIT)? {
Some(HostMsg::Done { detail }) => Ok(said(detail)),
Some(HostMsg::Refused(why)) => Err(why),
Some(HostMsg::Uncertain(why)) => Err(why),
Some(other) => Err(format!("the books' host answered oddly: {other:?}")),
None => match crate::update::apply_staged(&found, crate::update::program_version()) {
Ok(Some(version)) => Ok(format!(
"Version {version} is installed; it starts the next time the books open."
)),
Ok(None) => Err(
"no newer version waits, or this install is updated by its package manager".into(),
),
Err(why) => Err(why.to_string()),
},
}
}
/// Accepting an update isn't built for this platform: say so, and change
/// nothing.
#[cfg(not(unix))]
pub fn apply_update(_common: &Common) -> Result<String, String> {
Err(NO_SELF_UPDATE.into())
}
/// The longest the host may take to install an accepted update (a copy
/// and a health check of the new program).
#[cfg(unix)]
const UPDATE_WAIT: Duration = Duration::from_secs(90);
/// What the app says when the self-update is asked for on a platform it
/// isn't built for (Windows, today).
#[cfg(not(unix))]
const NO_SELF_UPDATE: &str = "self-update isn't available on this platform";
/// The longest an agent's own command may take to add the books.
const CONNECT_WAIT: Duration = Duration::from_secs(60);
@@ -1178,6 +1291,12 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
"{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)"
)
})?;
// A translocated macOS app's path (P11a step 6) is not a place to point
// an agent: the copy goes away with the app.
let exe = crate::start::this_executable()?;
if let Some(why) = crate::start::translocation_refusal(&exe) {
return Err(why.to_string());
}
let mut lines = Vec::new();
// The token: kept in its file, or made now.
let found =
@@ -1195,7 +1314,6 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
));
}
// The agent's own list: the books' entry made anew.
let exe = crate::start::this_executable()?;
let name = format!("wanderbooks-{book}");
let mut serve: Vec<std::ffi::OsString> = vec![
exe.into_os_string(),
+52 -1
View File
@@ -648,6 +648,9 @@ impl Host {
Event::Intent(Intent::ConsentFormatUpgrade { book, passphrase }, answer) => {
self.consent(book, passphrase.unwrap_or_default(), answer);
}
Event::Intent(Intent::ApplyUpdate, answer) => {
self.apply_update(answer);
}
Event::Intent(Intent::Close, answer) => {
let _ = answer.send(HostMsg::Done {
detail: Some("the books are closing".into()),
@@ -715,7 +718,7 @@ impl Host {
let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone());
std::thread::spawn(move || {
match crate::update::stage_if_due(&root, crate::update::program_version(), every) {
Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"),
Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts when you accept it in the app, the next time the books open, or at `wanderbooks update`"),
Ok(None) => {}
Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"),
}
@@ -860,6 +863,54 @@ impl Host {
}
}
/// The owner's accept of the staged update (P11a step 4): install it
/// now, as `wanderbooks update` does, and let the host's watch for a
/// replaced program (INVARIANT 7) restart onto it once no call is
/// running: the server finishes its calls before it closes, and the
/// leases carry on across it. The answer is sent when the file is
/// in place (or why not), from a thread, so the loop never waits.
fn apply_update(&self, answer: oneshot::Sender<HostMsg>) {
if !matches!(self.phase, Phase::Open) {
let _ = answer.send(HostMsg::Refused(
"the books are closing or restarting: ask again in a moment".into(),
));
return;
}
#[cfg(unix)]
{
let root = self.root.clone();
std::thread::spawn(move || {
let said = match crate::update::apply_staged(
&root,
crate::update::program_version(),
) {
Ok(Some(version)) => {
eprintln!(
"wanderbooks host: version {version} accepted: restarting onto it when no call is running"
);
HostMsg::Done {
detail: Some(format!(
"version {version} is installed; the books restart onto it as soon as no call is running, and stay open meanwhile"
)),
}
}
Ok(None) => HostMsg::Refused(
"no newer version waits, or this install is updated by its package manager"
.into(),
),
Err(why) => HostMsg::Refused(why.to_string()),
};
let _ = answer.send(said);
});
}
#[cfg(not(unix))]
{
let _ = answer.send(HostMsg::Refused(
"updating from the app isn't built for this system yet".into(),
));
}
}
/// Close the server's input: it closes the books (INVARIANT 3).
fn close_server(&mut self) {
self.server_stdin = None;
+9 -3
View File
@@ -16,7 +16,8 @@
//! 1. A connection's first line is one Ask; a Hold keeps the
//! connection (the lease lasts until it closes), anything else is
//! answered once and the connection closed.
//! 2. Exactly two intents exist: close and consent_format_upgrade; any
//! 2. Exactly three intents exist: close, consent_format_upgrade and
//! apply_update; any
//! other request is refused.
//! 3. consent_format_upgrade carries the owner passphrase; without one
//! it is refused before anything is done. With one, the host hands it
@@ -106,7 +107,7 @@ pub enum Ask {
Intent(Intent),
}
/// The owner's intents: exactly these two (INVARIANT 2).
/// The owner's intents: exactly these three (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Intent {
@@ -120,6 +121,10 @@ pub enum Intent {
#[serde(default)]
passphrase: Option<ledger_mcp::admin::Passphrase>,
},
/// Accept the update the host has staged (P11a step 4): the host
/// installs it, and restarts onto it once no call is running, the
/// books staying open to every holder across it.
ApplyUpdate,
}
impl Intent {
@@ -258,9 +263,10 @@ mod tests {
}
#[test]
fn exactly_two_intents_are_understood() {
fn exactly_three_intents_are_understood() {
for intent in [
json!("close"),
json!("apply_update"),
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
] {
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
+37
View File
@@ -83,6 +83,21 @@ pub fn this_executable() -> Result<PathBuf, String> {
Ok(exe)
}
/// Why this program can't point an agent at itself, if it can't (P11a
/// step 6): macOS runs a quarantined app from a read-only copy under
/// `/private/var/folders/…/AppTranslocation/…`, a path that goes away when
/// the app closes. The app may run, but an agent connected from it would
/// lose the books, so the person moves it out of Downloads first.
/// `exe` is the path this program would give the agent
/// ([`this_executable`]).
pub fn translocation_refusal(exe: &Path) -> Option<&'static str> {
exe.components()
.any(|part| part.as_os_str() == "AppTranslocation")
.then_some(
"wanderbooks is running from the copy macOS makes of a quarantined download, and that copy goes away when the app closes: move wanderbooks out of Downloads (Applications, for example), open it there, and connect the agent again",
)
}
/// The AppImage this process runs from: `APPIMAGE` when it is an
/// absolute path to a file.
pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
@@ -633,6 +648,28 @@ mod tests {
let exe = this_executable().unwrap();
assert!(exe.is_file(), "{exe:?}");
}
#[test]
fn only_a_translocated_path_refuses_an_agent() {
assert_eq!(
translocation_refusal(Path::new(
"/Applications/Wanderbooks.app/Contents/MacOS/wanderbooks"
)),
None
);
let copy = translocation_refusal(Path::new(
"/private/var/folders/xy/T/AppTranslocation/1234/d/Wanderbooks.app/Contents/MacOS/wanderbooks",
))
.expect("a translocated path is refused");
assert!(copy.contains("out of Downloads"), "{copy}");
// A part that only shares letters is not the marker macOS makes.
assert_eq!(
translocation_refusal(Path::new(
"/Applications/AppTranslocation Notes/wanderbooks"
)),
None
);
}
}
#[cfg(all(test, windows))]
+6 -2
View File
@@ -157,7 +157,8 @@ pub fn install_kind(exe: &Path, appimage: Option<&Path>) -> Install {
Install::SelfUpdating(exe.to_path_buf())
}
fn this_install() -> Result<Install, String> {
/// How the running program was installed.
pub fn this_install() -> Result<Install, String> {
let exe = crate::start::this_executable()?;
let image = std::env::var_os("APPIMAGE").map(PathBuf::from);
Ok(install_kind(&exe, image.as_deref()))
@@ -494,7 +495,10 @@ pub fn apply_staged(
let Install::SelfUpdating(target) = this_install()? else {
return Ok(None);
};
let Ok(_lock) = take_lock(&updates) else {
// The host's own look at the feed can still hold the lock for a moment
// after it writes `meta.json`; the owner's accept should wait that out,
// not answer "nothing waits" (P11a step 4).
let Ok(_lock) = take_lock_within(&updates, Duration::from_secs(5)) else {
return Ok(None);
};
let meta: serde_json::Value = std::fs::read(dir.join(META))
+35
View File
@@ -242,6 +242,17 @@ pub struct FormatUpgradeOut {
pub how: String,
}
/// An update the host has downloaded and checked, waiting for the owner.
#[derive(Debug, Serialize, JsonSchema)]
pub struct UpdateWaitingOut {
/// The version waiting.
pub version: String,
/// When the host staged it (UTC, RFC 3339).
pub staged_at: String,
/// Who can accept it, and how.
pub how: String,
}
/// Something to do next.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NextStepOut {
@@ -391,6 +402,10 @@ pub struct StartOut {
/// the owner gives the go-ahead. No tool can: tell the human.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format_upgrade: Option<FormatUpgradeOut>,
/// A newer version the host has downloaded and checked (P11a step 4):
/// the owner accepts it in the app. No tool can: tell the human.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_waiting: Option<UpdateWaitingOut>,
/// Who is asking.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub you: Option<YouOut>,
@@ -963,6 +978,25 @@ impl BooksServer {
"Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.",
));
}
// A newer version waits for the owner (P11a step 4): said once,
// after a format upgrade, which comes first.
let update_waiting = ledger_store::root::root_of_book_dir(store.data_dir())
.and_then(ledger_store::root::staged_update_in)
.map(|u| UpdateWaitingOut {
version: u.version,
staged_at: u.staged_at,
how: "only the owner accepts it: in the app (Books, then Update), or `wanderbooks update` at a terminal. No tool can.".to_string(),
});
if let Some(u) = &update_waiting {
next_steps.push(step(
"books.handbook",
None,
&format!(
"Tell the human: version {} of wanderbooks is downloaded and checked, and waits for them to accept it in the app (Books, then Update). Nothing changes until they do; no tool can.",
u.version
),
));
}
if onboarding || focus == "onboarding" {
next_steps.push(NextStepOut {
tool: None,
@@ -1240,6 +1274,7 @@ impl BooksServer {
title: handbook::title(md).to_string(),
})
.collect(),
update_waiting,
format_upgrade: upgrade.map(|u| FormatUpgradeOut {
guard: ledger_store::error::FORMAT_UPGRADE_PENDING.to_string(),
format: u.from.format,
+45
View File
@@ -661,6 +661,12 @@ impl Root {
.map_err(|e| self.hide(e))
}
/// The update the host has downloaded, checked and staged under
/// `updates/staged/`, if one waits (P11a step 4).
pub fn staged_update(&self) -> Option<StagedUpdate> {
staged_update_in(&self.path)
}
/// The folder a book lives in.
pub fn book_dir(&self, id: &BookId) -> PathBuf {
self.path.join(BOOKS_DIR).join(id.as_str())
@@ -2394,3 +2400,42 @@ mod unit {
assert_eq!(keys, book.join("keys"));
}
}
/// An update waiting in `<root>/updates/staged/`: downloaded, checked and
/// kept by the host, applied when the owner accepts it or the books next
/// start.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StagedUpdate {
/// Its version.
pub version: String,
/// When it was staged, UTC (RFC 3339).
pub staged_at: String,
}
/// The update staged in the data root `root`, if one waits. What it says
/// is shown to people and agents, so a version that is not plainly one
/// (digits, letters, dots, dashes, at most 40) is no update.
pub fn staged_update_in(root: &Path) -> Option<StagedUpdate> {
let meta = root.join("updates").join("staged").join("meta.json");
let value: serde_json::Value = serde_json::from_slice(&std::fs::read(&meta).ok()?).ok()?;
let version = value["version"].as_str()?;
let plain = !version.is_empty()
&& version.len() <= 40
&& version
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
if !plain {
return None;
}
let at: chrono::DateTime<chrono::Utc> = std::fs::metadata(&meta).ok()?.modified().ok()?.into();
Some(StagedUpdate {
version: version.to_string(),
staged_at: at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
})
}
/// The data root a book's folder (`<root>/books/<id>`) is in, if it is one.
pub fn root_of_book_dir(book_dir: &Path) -> Option<&Path> {
let root = book_dir.parent()?.parent()?;
root.join(MARKER_FILE).is_file().then_some(root)
}
+15 -1
View File
@@ -417,7 +417,19 @@ downgrading is theirs). Anything else (an AppImage, whose file is named by
call is cut off), after a second check against the key, or by
`wanderbooks update`, the owner's say-so, which reuses the staged copy. A
local release is named in `<root>/updates/feed` (a `file://` address, then
the key file), the same rule as `--trusted-key`.
the key file), the same rule as `--trusted-key`;
- **the owner accepts one in the app**: the viewer reads the staged
`meta.json` (`Owner::update_waiting`) and shows a notice with "Update
now", and Books has "Check for updates" (`Owner::check_for_updates`) and
"Update to X"; accepting is the host's lease intent `apply_update`, which
installs the staged copy as `update` does and lets the host's own
replaced-program watch restart onto it once no call runs. `books.start`
carries the same notice to an agent as `update_waiting`, with a next step
to tell the human; no tool accepts it;
- **a translocated macOS app refuses to connect agents**
(`start::translocation_refusal`): a quarantined download runs from a
read-only copy under `AppTranslocation` that vanishes with the app, so
the person moves it out of Downloads first.
`deploy/packaging/` holds the Linux packaging: `packager.toml`
(cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD),
@@ -428,6 +440,8 @@ throwaway-keyed feed on this machine) and `test-linux-packages.sh`.
`.github/workflows/release.yml` builds them on a version tag. The macOS
bundle is `packager-macos.toml`, `Info.plist` and `build-macos.sh` (an
unsigned `.dmg` from CI's `macos` job; signing and notarization are step 5).
A translocated app (Gatekeeper's copy of a quarantined download) may run
but refuses to connect an agent, saying how to move it out of Downloads.
## The result envelope and the guards (`ledger-mcp`, P5)
+8 -1
View File
@@ -124,7 +124,8 @@ settings-and-conventions section says how to use them.
codes), backup: { last_at, through_seq, stale }, prompts: [{ name, args
}], needs_attention: { items: [{ id, severity, kind, title, tool?, args?
}], counts: { kind: n } }, truncated?, handbook_sections,
format_upgrade?: { guard, format, adds, how }, next_steps: [{ tool |
format_upgrade?: { guard, format, adds, how }, update_waiting?: { version,
staged_at, how }, next_steps: [{ tool |
prompt, args?, why, required }], isolation }`. The connect text names it
as the first call. `book.url` is the book's path on this server
(`/b/ID/mcp`), never a path on a disk. `you` is the token's own facts
@@ -145,6 +146,12 @@ settings-and-conventions section says how to use them.
(below); its next step comes first, before anything else, at every
start until it is done: `books.handbook {section: "format-upgrade"}`,
"Tell the human first: …".
`update_waiting` is there only while the host has downloaded and checked
a newer version of wanderbooks (`<root>/updates/staged/`): its version,
when it was staged (UTC, RFC 3339) and how it may be accepted. Only the
owner can accept it, in the app (Books, then Update) or with
`wanderbooks update` at a terminal; no tool can. Its next step asks the
agent to tell the human, and nothing changes until they accept.
`isolation` is `same_user (advisory)` locally (D-32): the books are
hidden from agents in the owner's own account, not locked against them.
On the owner's own machine, where the server runs as a separate account
+28 -5
View File
@@ -135,8 +135,17 @@ subset. **Not checked: that the `.dmg` builds, mounts or runs**: that needs
the CI run (Actions, release, Run workflow, this branch) and then a Mac.
Open items for the trial on a Mac: whether `wanderbooks update` replacing
the program inside a signed `.app` breaks its signature (it will: step 5
must decide, probably by updating a whole `.app`); the translocated-app
check (`translocated_app_refuses_to_connect`) is not built.
must decide, probably by updating a whole `.app`). **Translocation
(P11a step 6):** a `.dmg` downloaded from the internet keeps its
quarantine bit, so Gatekeeper may run the app from a read-only copy under
`/private/var/folders/…/AppTranslocation/…` that goes away when it
closes. `wanderbooks connect` (and the viewer's Connect, which is the same
function) refuses there, before a token is made or an agent's list is
touched, and says to move the app out of Downloads and open it again:
`ledger_host::start::translocation_refusal` reads the path the program
would give the agent. Checked on Linux by standing in for that path
(`translocated_app_refuses_to_connect`, `only_a_translocated_path_refuses_an_agent`);
the real Gatekeeper copy still needs the trial on a Mac.
## Updates staged by the host (step 4)
@@ -148,9 +157,23 @@ first; `wanderbooks update` applies it too and reuses the staged copy;
`update --check` says "already downloaded". A local release is named by
`<root>/updates/feed` (a `file://` address, then the key file). Without a
compiled-in release key and without that file, the look does nothing but
log why. Not built: the in-app "restart now to update" choice and the
viewer's notice, which need an admin-socket intent and a place in the
viewer (see the unit's As-built block).
log why.
The owner accepts a staged update **in the app** (P11a step 4). The
viewer reads `<root>/updates/staged/meta.json` every few seconds
(`Owner::update_waiting`) and shows a standing notice above the page,
"Version X of wanderbooks is ready", with "Update now"; Books also has
"Check for updates" (`Owner::check_for_updates`, which looks at the feed
at once whatever the daily look did) and "Update to X" while one waits.
Accepting sends the host's lease intent `apply_update`; the host installs
the staged copy exactly as `wanderbooks update` does and its existing
watch for a replaced program (host.rs INVARIANT 7) restarts onto it once
no call is running, the books staying open and every holder's session
carrying on. With no host running there is no call to cut off, so the
viewer applies it itself. `books.start` tells an agent the same thing
(`update_waiting`) and adds a next step to tell the human; no tool can
accept it. The accepted update replaces the program file, not a whole
`.app`, which step 5 must revisit for a signed bundle.
## What `update` and `rollback` need from a release
+45
View File
@@ -3856,6 +3856,51 @@ add), `swap_in`'s temporary-then-rename (`update_is_atomic`), `healthy`
`parse_version`'s rejections.
*(Consolidated, with the other units' targets, in "P12: the checklists" at the end of this file.)*
## P11a steps 4 and 6: the in-app accept, the viewer's notice, translocation (2026-11-05)
The accept is the host's lease intent `apply_update` (the name
`plan/served.md` 8.0.3 allocates); the viewer's names are
`Owner::update_waiting`, `Owner::check_for_updates` and `Owner::apply_update`,
and the agent's is `books.start`'s `update_waiting`.
- `crates/ledger-cli/tests/update.rs` (Linux):
`books_start_says_an_update_waits` — a quiet `books.start` has no
`update_waiting`; after the host stages a signed 9.0.0, `books.start`
names its version and staged time and says only the owner accepts it,
its next step says to tell the human, and no path appears in the answer;
`the_owner_accepts_a_staged_update_in_the_app` — accepting with nothing
staged is refused and changes nothing; once 9.0.0 is staged, the same
intent (`opener::ask_once_within` with `Intent::ApplyUpdate`, what the
viewer's `apply_update` sends) installs it, the program becomes the
release's bytes, the old version is kept under `updates/previous/`, and
an agent's bridge writes all through the host's restart onto it: every
account is in the chart exactly once.
- `crates/ledger-gui/tests/update_notice.rs`:
`the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts`
(Books, Check for updates asks the owner and its words show; the standing
notice names the version; Update now asks the owner to apply it, and the
notice goes once it is installed) and
`books_outside_a_data_root_have_no_update_notice`.
- `crates/ledger-cli/tests/translocation.rs`:
`translocated_app_refuses_to_connect` — with the program's path standing
in for Gatekeeper's copy (`APPIMAGE` names a file under
`AppTranslocation`), `connect claude` is refused before a token is made
or a home is made, and the stand-in agent never runs; the same program at
an ordinary path connects. `crates/ledger-host/src/start.rs`:
`only_a_translocated_path_refuses_an_agent` (the marker is a path part,
not letters in a name).
Not checked here: the real Gatekeeper copy and the `.dmg` (needs a Mac,
the owner's trial); the Windows installer job is P11c's, not on this branch.
**For P12 (mutation targets, none run, D-33):** `update_waiting`'s
"newer than this program" comparison (a mutant that offers the running
version must die on `books_start_says_an_update_waits`'s quiet first
call), `staged_update_in`'s plausibility check (a staged version with a
path separator must not become a notice), and `translocation_refusal`'s
component comparison (a part that merely contains the word must not
refuse).
## P5: the agent surface (2026-10-29)
Every tool result carries the envelope (`next_steps`, `guards`, `warnings`,
+2 -2
View File
@@ -1645,7 +1645,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
#### P11a: packaging and updates (D-19, D-22, D-24, D-25, D-26)
1. **Status:** in progress: step 0 landed 2026-10-29 (`5e220cca`) as far as the macOS store and the portable CI job; its keyring stores are removed by U0e (D-63), and the Windows half of step 0 moved to P11c (D-62). Steps 1 to 7 open, macOS and Linux first. P11 was split 2026-10-26 (D-54): P11a is installers and updates; P11b the agent dock and books homes.
1. **Status:** in progress: steps 0-4 and the macOS half of step 6 landed 2026-10-29 (the AppImage restart, the daily staged update, the unsigned `.dmg` job); the in-app accept, the viewer's update notice and "Check for updates", and the translocation refusal landed 2026-11-05. Step 5 (signing) is deferred by D-64; left: the release workflow's first run (the owner's click), the Windows installer (P11c's), the trials on a Mac and a Windows machine. P11 was split 2026-10-26 (D-54): P11a is installers and updates; P11b the agent dock and books homes.
2. **Depends on:** none in code. From the owner before the signing steps: the Apple Developer ID certificate and notarization credentials as CI secrets; the Windows signing identity (Azure Artifact Signing if eligible, else an OV certificate); GitHub Releases enabled on the public mirror; the release hardware token; clean VMs or runners for the install tests. Q-4 (keep the two-key release manifest of D-25, or minisign alone) before step 5.
3. **Feature and schema:** none.
4. **Build:**
@@ -1660,7 +1660,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
5. **Files:** owns `crates/ledger-host/src/update.rs`, `deploy/packaging/*`, `.github/workflows/release.yml`, `docs/packaging.md`; touches `commands.rs`, `crates/ledger-gui/src/owner.rs`, `mise.toml`.
6. **Tests and proofs:** named above.
7. **Done when:** (1) on a clean Linux VM the AppImage installs, opens, onboards and posts an entry with no terminal and no admin password, and updates itself from a test feed; (2) on Arch, Debian and Fedora the packages install and upgrade with the head unchanged; (3) an update leaves every book's head and goldens unchanged and a bridge session carries on across it; (4) a backup made on one system restores on another; (5) macOS and Windows: the same as (1), once the signing prerequisites exist (record which are done in the As-built block); (6) `just check` green.
8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `<root>/updates/<version>/`, keep the running version under `updates/previous/` with the journal format it knew, swap by renaming a complete copy made beside the target, `--help` health check with automatic put-back; `rollback` refuses when a book declares a format or feature the kept version lacks; a package marks itself with `share/wanderbooks/install-method` and then both refuse. The restart is the host's existing one (nothing new in `host.rs`). Tests: `crates/ledger-cli/tests/update.rs` (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`, `update_waits_for_a_running_call`, `package_managed_install_refuses_update`, `rollback_refuses_after_a_book_consented_to_a_newer_format`, `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`) all pass (8 in 12 s); five unit tests in `update.rs`. *Step 3:* `deploy/packaging/` (`packager.toml`, the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD`, `build-linux.sh`, `repo-indexes.sh`, `local-release.sh`, `test-linux-packages.sh`), the `just` recipes `package-linux`, `local-release`, `release-trixie`, `test-linux-packages`, and `.github/workflows/release.yml` (Linux packages on a `v*` tag, attached to the release, apt and dnf indexes pushed to `gh-pages`; untested, GitHub only). `linux_packages_install_and_upgrade` is `test-linux-packages.sh`: install 0.1.0 from an apt, a dnf repository and `makepkg` over the AUR PKGBUILLine truncated
8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `<root>/updates/<version>/`, keep the running version under `updates/previous/` with the journal format it knew, swap by renaming a complete copy made beside the target, `--help` health check with automatic put-back; `rollback` refuses when a book declares a format or feature the kept version lacks; a package marks itself with `share/wanderbooks/install-method` and then both refuse. The restart is the host's existing one (nothing new in `host.rs`). Tests: `crates/ledger-cli/tests/update.rs` (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`, `update_waits_for_a_running_call`, `package_managed_install_refuses_update`, `rollback_refuses_after_a_book_consented_to_a_newer_format`, `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`) all pass (8 in 12 s); five unit tests in `update.rs`. *Step 3:* `deploy/packaging/` (`packager.toml`, the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD`, `build-linux.sh`, `repo-indexes.sh`, `local-release.sh`, `test-linux-packages.sh`), the `just` recipes `package-linux`, `local-release`, `release-trixie`, `test-linux-packages`, and `.github/workflows/release.yml` (Linux packages on a `v*` tag, attached to the release, apt and dnf indexes pushed to `gh-pages`; untested, GitHub only). `linux_packages_install_and_upgrade` is `test-linux-packages.sh`: install 0.1.0 from an apt, a dnf repository and `makepkg` over the AUR PKGBUILLine truncated
#### P11b: the agent dock and books homes (D-27)