P2 slice 8: the viewer's menu, setup, no confinement (D-32)

No Landlock, no seccomp, no helper: isolation is "same_user
(advisory)". The lease socket keeps two intents (close, format-upgrade
consent). The viewer holds the books open while it runs and gains a
Books menu: New books, Back up now, Restore (preview, then confirm),
Close, Connect Claude Code / Codex. New commands `wanderbooks books
back-up` and `wanderbooks connect claude|codex`. The scaffold
(`new`/`init` and its templates) is gone; install.sh needs no sudo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:22 -06:00
1 parent 019d8d76a3
commit 3cf359a0cd
28 files changed
+1976 -1931

No files matched your search

+2 -11
View File
@@ -50,7 +50,7 @@
use crate::knobs::{FLAG, Knobs};
use crate::lease::{HostMsg, Kind, Reason};
use crate::opener::{Opener, Starter};
use crate::opener::Opener;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
@@ -1241,15 +1241,7 @@ pub fn run(
let book = ledger_store::root::BookId::parse(&book)
.map_err(|e| e.to_string())?
.to_string();
// The helper first, before any thread (helper.rs INVARIANT 4).
let helper = Arc::new(crate::helper::Client::spawn(root.as_deref(), &knobs)?);
let opener = Opener::new(
root.as_deref(),
knobs,
Starter::Helper(helper.clone()),
Kind::Bridge,
Some(book.clone()),
)?;
let opener = Opener::new(root.as_deref(), knobs, Kind::Bridge, Some(book.clone()))?;
// Kept for the whole session: the client reaches the socket by it.
let socket =
ledger_store::root::SocketPath::new(opener.run_dir(), ledger_mcp::local::MCP_SOCKET)
@@ -1337,7 +1329,6 @@ pub fn run(
Ok::<(), String>(())
});
rt.shutdown_timeout(Duration::from_secs(1));
drop(helper);
result.map_err(Into::into)
}
+553 -123
View File
@@ -1,10 +1,14 @@
//! The owner's commands: `wanderbooks serve`, `close`, `books` and
//! `token` (plan 3.3, 3.9, 5.5).
//! The owner's commands: `wanderbooks serve`, `close`, `books` (list,
//! create, adopt, restore, back-up), `token`, `connect`, `doctor` and
//! `passphrase` (plan 3.3, 3.7, 3.9, 5.5), and the same work for the
//! viewer's Books menu (`new_books`, `back_up_now`, `plan_restore`,
//! `carry_out_restore`, `connect_agent`, `close_books`, `hold_for_viewer`).
//!
//! ```text
//! GOAL: from a terminal the owner holds the books open, closes them, and
//! makes, lists and adopts books, without ever naming or touching
//! the books' private folder.
//! GOAL: from a terminal, or the viewer's menu, the owner holds the books
//! open, closes them, makes, adopts, backs up and restores books and
//! connects agents, without ever naming or touching the books'
//! private folder.
//! SOURCE: plan/served.md 3.9 (a terminal running `wanderbooks serve`, and
//! any command that needs the server, holds a lease; "Close the
//! books" ends every lease), 3.1 (the CLI talks to admin.sock and
@@ -20,12 +24,16 @@
//! 4. A token made is kept in the keyring before anything is said of it,
//! and its text is printed only by `token print`; one that can't be
//! kept is revoked again. A token revoked is forgotten by the keyring
//! too.
//! too. `connect` makes the books' agent token only when the keyring
//! has none, replacing the server's (no one could be using it: the
//! bridge reads its token from this keyring).
//! 5. The viewer's hold lets go only when the owner closes the books;
//! books that stop, or won't open, are tried again.
//! ```
use crate::knobs::{FLAG, Knobs};
use crate::lease::{Ask, HostMsg, Intent, Kind, Reason};
use crate::opener::{HoldError, Lease, Opener, Starter};
use crate::opener::{HoldError, Lease, Opener};
use ledger_mcp::admin::{self, Request, Response};
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -38,10 +46,13 @@ const LIST_WAIT: Duration = Duration::from_secs(30);
const CLOSE_WAIT: Duration = Duration::from_secs(120);
/// Common flags: `--root DIR` and the test knobs.
#[derive(Default)]
struct Common {
root: Option<PathBuf>,
knobs: Knobs,
#[derive(Default, Clone)]
pub struct Common {
/// The data root named (`--root`), else the one this account's host
/// finds.
pub root: Option<PathBuf>,
/// The test knobs.
pub knobs: Knobs,
}
impl Common {
@@ -119,13 +130,7 @@ fn with_server_in(common: &Common, request: Request) -> Result<(Response, PathBu
.build()
.map_err(|e| e.to_string())?;
let outcome = rt.block_on(async {
let mut opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Cli,
None,
)?;
let mut opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)?;
let lease = open(&mut opener).await.map_err(|e| match e {
HoldError::ClosedByOwner => {
"the books were closed just now (Close the books): nothing was done".to_string()
@@ -219,7 +224,6 @@ pub fn serve(
let mut opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Serve,
None,
)?;
@@ -393,9 +397,13 @@ fn books_usage(prog: &str) {
" {prog} adopt DATA-DIR --id ID [--toml FILE] [--config-json FILE] [--vault DIR --vault-key FILE | --no-vault] [--skip-unknown] [--yes]"
);
eprintln!(" {prog} restore BACKUP [--id ID] [--replace] [--yes]");
eprintln!(" {prog} back-up ID [--new-disk]");
eprintln!(" list the books, and whether each is open");
eprintln!(" create start new, empty books (base currency USD unless given)");
eprintln!(" adopt copy existing books in, verified; the original is left as it was");
eprintln!(
" back-up back the books up now to every backup their settings name (--new-disk: the backup folder's disk is new)"
);
eprintln!(
" restore bring back a backup (a git repository's address, or BOOK.git in the backup folder), proven whole first: as new books, or replacing books (kept in legacy/)"
);
@@ -418,6 +426,7 @@ pub fn books(
let mut skip_unknown = false;
let mut yes = false;
let mut replace = false;
let mut new_disk = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
@@ -434,6 +443,7 @@ pub fn books(
"--no-vault" => no_vault = true,
"--skip-unknown" => skip_unknown = true,
"--replace" => replace = true,
"--new-disk" => new_disk = true,
"--yes" => yes = true,
"--help" | "-h" => {
books_usage(prog);
@@ -478,6 +488,20 @@ pub fn books(
);
}
["restore", source] => return restore(&common, source, id, replace, yes),
["back-up", book] => {
return match back_up_now(&common, book, new_disk) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Err(why) => {
eprintln!("error: {why}");
Err(exit(1))
}
};
}
_ => {
books_usage(prog);
return Err(exit(2));
@@ -521,6 +545,386 @@ pub fn books(
}
}
/// Back the books `book` up now (the server does it): what came of it, a
/// line each; Err when it couldn't be done, or a target didn't take it.
pub fn back_up_now(common: &Common, book: &str, new_disk: bool) -> Result<Vec<String>, String> {
match with_server(
common,
Request::BackUpNow {
id: book.to_string(),
new_disk,
},
)? {
Response::BackedUp { head_seq, targets } => {
let lines = backed_up_lines(head_seq, &targets);
if targets.is_empty() || targets.iter().any(|t| t.problem.is_some()) {
Err(lines.join("\n"))
} else {
Ok(lines)
}
}
Response::Refused(why) => Err(why),
other => Err(format!("the books server answered oddly: {other:?}")),
}
}
/// "New books…": new, empty books `id` in `currency` (the server makes
/// them).
pub fn new_books(common: &Common, id: &str, currency: &str) -> Result<String, String> {
match with_server(
common,
Request::Create {
id: id.trim().to_string(),
currency: currency.trim().to_string(),
},
)? {
Response::Created { id, problem: None } => {
Ok(format!("Made new books \"{id}\" ({}).", currency.trim()))
}
Response::Created {
id,
problem: Some(why),
} => Err(format!(
"new books \"{id}\" were made but didn't open: {why}. Closing the books and opening them again tries again."
)),
Response::Refused(why) => Err(why),
other => Err(format!("the books server answered oddly: {other:?}")),
}
}
/// "Close the books": everything holding them lets go (the lease
/// socket's close intent). What came of it.
pub fn close_books(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())?;
match crate::opener::ask_once_within(&run, Ask::Intent(Intent::Close), HOST_WAIT)? {
None => Ok("The books are closed.".into()),
Some(HostMsg::Done { detail }) => Ok(detail
.map(|d| {
let mut c = d.chars();
c.next()
.map(|first| format!("{}{}.", first.to_uppercase(), c.as_str()))
.unwrap_or_default()
})
.filter(|d| !d.is_empty())
.unwrap_or_else(|| "The books are closing.".into())),
Some(HostMsg::Refused(why)) => Err(why),
Some(HostMsg::Uncertain(why)) => Err(why),
Some(other) => Err(format!("the books' host answered oddly: {other:?}")),
}
}
/// The longest an agent's own command may take to add the books.
const CONNECT_WAIT: Duration = Duration::from_secs(60);
/// How long the viewer's hold waits before trying again after books that
/// wouldn't open.
const VIEWER_RETRY: Duration = Duration::from_secs(30);
/// Hold the books `book` open while the viewer runs (a lease of kind
/// app), on a thread of its own: the window never waits for it. It lets
/// go only when the owner closes the books (and when the viewer ends,
/// with the process); books that stop, or won't open, are tried again.
pub fn hold_for_viewer(common: &Common, book: Option<String>) -> std::thread::JoinHandle<()> {
let common = common.clone();
std::thread::spawn(move || {
let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return;
};
rt.block_on(async {
let Ok(mut opener) = Opener::new(common.root.as_deref(), common.knobs, Kind::App, book)
else {
return;
};
loop {
// The same opener each time round (it knows it held the
// books, so it never reopens books the owner closed); its
// failures counted afresh.
opener.forget_failures();
loop {
let mut lease = match open(&mut opener).await {
Ok(lease) => lease,
Err(HoldError::ClosedByOwner) => return,
Err(HoldError::Failed(_)) => break,
};
match lease.next().await {
Some(HostMsg::Ending {
reason: Reason::ClosedByOwner,
..
}) => return,
Some(HostMsg::Ending {
reason: Reason::Restarting | Reason::Closing,
..
}) => {}
_ => {
if opener.failed().await.is_err() {
break;
}
}
}
}
tokio::time::sleep(VIEWER_RETRY).await;
}
});
})
}
/// What a backup made now came to, a line each.
pub fn backed_up_lines(head_seq: u64, targets: &[admin::BackedUpTarget]) -> Vec<String> {
if targets.is_empty() {
return vec![
"No backups are set up: ask your agent to set the private repository and the backup folder.".into(),
];
}
targets
.iter()
.map(|t| match (&t.problem, t.through_seq) {
(None, Some(seq)) if seq == head_seq => {
format!("Backed up to {} (through event {seq}).", t.at)
}
(None, Some(seq)) => format!("Backed up to {} through event {seq}.", t.at),
(None, None) => format!("Not yet backed up to {}.", t.at),
(Some(why), _) => format!("Not backed up to {}: {why}", t.at),
})
.collect()
}
/// `wanderbooks connect claude|codex --book ID`: an agent of the owner's
/// reaches the books.
pub fn connect(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut book: Option<String> = None;
let mut words: Vec<String> = Vec::new();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
continue;
}
match arg.as_str() {
"--book" => book = Some(args.next().ok_or("--book needs an id")?),
"--help" | "-h" => {
eprintln!("usage: {prog} claude|codex --book ID");
eprintln!(
" Lets Claude Code or Codex reach the books: keeps their token in this computer's keyring and adds the books to the agent's own list."
);
return Ok(());
}
s if s.starts_with('-') => {
eprintln!("unknown flag: {s}");
eprintln!("usage: {prog} claude|codex --book ID");
return Err(exit(2));
}
_ => words.push(arg),
}
}
let (Some(agent), Some(book), 1) = (words.first(), book, words.len()) else {
eprintln!("usage: {prog} claude|codex --book ID");
return Err(exit(2));
};
match connect_agent(&common, agent, &book) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Err(why) => {
eprintln!("error: {why}");
Err(exit(1))
}
}
}
/// Let the agent `agent` ("claude" or "codex") reach the books `book`:
/// their token kept in the keyring (made if none is), and `wanderbooks
/// mcp --book <book>` added to the agent's own list as
/// `wanderbooks-<book>`. What was done, a line each.
pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<String>, String> {
let (cli, label) = match agent {
"claude" => ("claude", "Claude Code"),
"codex" => ("codex", "Codex"),
other => return Err(format!("{other:?}: connect claude or codex")),
};
let book = ledger_store::root::BookId::parse(book)
.map_err(|e| e.to_string())?
.to_string();
// The PATH first; then where the agents' installers put them (a
// viewer started from the desktop has a short PATH).
// ($HOME: a test's own home is never the account's.)
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.filter(|h| h.is_absolute());
let usual = [
".local/bin",
".npm-global/bin",
".bun/bin",
".cargo/bin",
".volta/bin",
]
.into_iter()
.filter_map(|d| home.as_ref().map(|h| h.join(d)))
.chain(
["/usr/local/bin", "/opt/homebrew/bin"]
.into_iter()
.map(PathBuf::from),
);
let program = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.chain(usual)
.filter(|d| d.is_absolute())
.map(|d| d.join(cli))
.find(|p| {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
})
.ok_or_else(|| {
format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)")
})?;
let mut lines = Vec::new();
// The token: kept in the keyring, or made now.
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 account = crate::tokens::Account::new(found.path(), &book, crate::tokens::DEFAULT_NAME);
if crate::tokens::keeper(&run, &common.knobs)
.get(&account)?
.is_none()
{
let scopes = ledger_store::auth::Scope::ALL
.iter()
.map(|s| s.as_str().to_string())
.collect();
make_token(common, &book, crate::tokens::DEFAULT_NAME, scopes, true)?;
lines.push(format!(
"Made the books {book}' agent token, kept in this computer's keyring."
));
}
// 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(),
"mcp".into(),
"--book".into(),
book.clone().into(),
];
if common.root.is_some() {
serve.push("--root".into());
serve.push(found.path().as_os_str().to_os_string());
}
serve.extend(common.knobs.args().into_iter().map(Into::into));
let scope: &[&str] = if agent == "claude" {
&["--scope", "user"]
} else {
&[]
};
// Each call within a minute: an agent's command that hangs is said,
// never waited on for good.
let run_cli = |args: Vec<std::ffi::OsString>| -> Result<std::process::Output, String> {
let mut child = std::process::Command::new(&program)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("{label} couldn't be run: {}", e.kind()))?;
let deadline = std::time::Instant::now() + CONNECT_WAIT;
loop {
if child.try_wait().map_err(|e| e.to_string())?.is_some() {
return child.wait_with_output().map_err(|e| e.to_string());
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
return Err(format!("{label} didn't answer in time: nothing was added"));
}
std::thread::sleep(Duration::from_millis(50));
}
};
let mut remove: Vec<std::ffi::OsString> = vec!["mcp".into(), "remove".into()];
remove.extend(scope.iter().map(Into::into));
remove.push(name.clone().into());
let _ = run_cli(remove);
let mut add: Vec<std::ffi::OsString> = vec!["mcp".into(), "add".into()];
add.extend(scope.iter().map(Into::into));
add.push(name.clone().into());
add.push("--".into());
add.extend(serve);
let out = run_cli(add)?;
if !out.status.success() {
let said: String = String::from_utf8_lossy(&out.stderr)
.trim()
.chars()
.take(300)
.collect();
return Err(format!("{label} didn't add the books: {said}"));
}
lines.push(format!(
"{label} reaches the books {book} as \"{name}\" (restart {label} to see it)."
));
Ok(lines)
}
/// Make the token `name` for `book` (the server does) and keep it in this
/// computer's keyring (INVARIANT 4: kept first, or taken back).
fn make_token(
common: &Common,
book: &str,
name: &str,
scopes: Vec<String>,
replace: bool,
) -> Result<admin::TokenInfo, String> {
let request = Request::TokenCreate {
book: book.to_string(),
name: name.to_string(),
scopes,
replace,
};
let (answer, run_dir) = with_server_in(common, request)?;
let (info, text) = match answer {
Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)),
Response::Refused(why) => return Err(why),
other => return Err(format!("the books server answered oddly: {other:?}")),
};
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, book, name);
let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text);
drop(text);
if let Err(why) = kept {
let undone = with_server(
common,
Request::TokenRevoke {
book: book.to_string(),
name: name.to_string(),
// This one only: another may have replaced it.
id: Some(info.id.clone()),
},
);
let after = match undone {
Ok(Response::TokenRevoked(_)) => "so it was revoked again",
_ => "and revoking it again failed too: `wanderbooks token revoke` ends it",
};
let replaced = if replace {
" The token it replaced had already ended."
} else {
""
};
return Err(format!(
"the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}"
));
}
Ok(info)
}
/// The id a backup is restored as when none is given: its repository's
/// name (`acme.git` → `acme`).
fn restore_id(source: &str) -> Option<String> {
@@ -564,6 +968,61 @@ fn restore(
eprintln!("error: {why}");
Err(exit(1))
};
let plan = match plan_restore(common, source, id, replace, true) {
Ok(plan) => plan,
Err(why) => return fail(why),
};
for line in &plan.lines {
println!("{line}");
}
match confirmed("Restore it?", yes) {
Ok(true) => {}
Ok(false) => {
println!("Not restored.");
return Ok(());
}
Err(why) => return fail(format!("not restored: {why}")),
}
match carry_out_restore(common, &plan) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Err(why) => fail(why),
}
}
/// A restore previewed: the backup proven, the id it comes back as, and
/// what it would do, a line each (INVARIANT 3: shown before it is done).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RestorePlan {
/// The backup, as the server is told it (a folder by its full path).
pub source: String,
/// The books' id it comes back as.
pub id: String,
/// Whether it replaces the books of that id (kept in legacy/).
pub replace: bool,
/// The backup's head, proven: the restore is of this backup only.
pub head: (u64, String),
/// The id of the books it holds, if it says.
pub origin: Option<String>,
/// What it would do, a line each.
pub lines: Vec<String>,
}
/// Preview restoring the backup at `source` (`flags`: name the command
/// line's flags in the advice): the id it comes back as is `id`, else the
/// id of the books the backup holds, else the repository's name; refused
/// over books that exist unless `replace`, and replacing none.
pub fn plan_restore(
common: &Common,
source: &str,
id: Option<String>,
replace: bool,
flags: bool,
) -> Result<RestorePlan, String> {
// A folder is named to the server by its full path (the server's
// working folder isn't this one); anything else is an address.
let source = match Path::new(source) {
@@ -571,11 +1030,10 @@ fn restore(
.unwrap_or_else(|_| p.to_path_buf())
.display()
.to_string(),
_ => source.to_string(),
_ => source.trim().to_string(),
};
// One preview (under the id given, or any: it only reports), then the
// id: given, or else the id of the books the backup holds, or else the
// repository's name.
// id.
let probe = id
.clone()
.or_else(|| restore_id(&source))
@@ -589,10 +1047,17 @@ fn restore(
confirmed: false,
expect_head: None,
},
) {
Ok(Response::RestorePreview(p)) => p,
Ok(Response::Refused(why)) | Err(why) => return fail(format!("{source}: {why}")),
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
)? {
Response::RestorePreview(p) => p,
Response::Refused(why) => return Err(format!("{source}: {why}")),
other => return Err(format!("the books server answered oddly: {other:?}")),
};
let flag = |text: &str| {
if flags {
format!(" ({text})")
} else {
String::new()
}
};
let Some(id) = id
.or_else(|| {
@@ -603,87 +1068,98 @@ fn restore(
})
.or_else(|| restore_id(&source))
else {
eprintln!("error: name the restored books with --id ID");
return Err(exit(2));
return Err(format!("name the restored books{}", flag("--id ID")));
};
let exists = match with_server(common, Request::List) {
Ok(Response::Books(books)) => books.iter().any(|b| b.id == id),
Ok(Response::Refused(why)) | Err(why) => return fail(why),
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
let exists = match with_server(common, Request::List)? {
Response::Books(books) => books.iter().any(|b| b.id == id),
Response::Refused(why) => return Err(why),
other => return Err(format!("the books server answered oddly: {other:?}")),
};
if exists && !replace {
return fail(format!(
"there are already books called \"{id}\": restore them under another id (--id), or replace them (--replace; they are kept in legacy/)"
return Err(format!(
"there are already books called \"{id}\": restore them under another id{}, or replace them{}",
flag("--id"),
flag("--replace; they are kept in legacy/")
));
}
if !exists && replace {
return fail(format!(
return Err(format!(
"there are no books called \"{id}\" to replace: nothing was restored"
));
}
let request = |expect_head: Option<(u64, String)>| Request::Restore {
id: id.clone(),
source: source.clone(),
replace,
confirmed: expect_head.is_some(),
expect_head,
};
println!(
let mut lines = vec![format!(
"The backup at {source} is whole: through event {} ({}), {} stored files re-hashed, {} TIN vault records.",
preview.head.0,
short_hash(&preview.head.1),
preview.blobs,
preview.vault_files
);
)];
if let Some(origin) = &preview.origin {
println!("It is a backup of the books \"{origin}\".");
lines.push(format!("It is a backup of the books \"{origin}\"."));
}
if replace {
println!(
lines.push(if replace {
format!(
"It replaces the books \"{id}\": they are closed and kept whole in the legacy folder, not deleted."
);
)
} else {
println!("It comes back as new books \"{id}\", beside any others.");
}
format!("It comes back as new books \"{id}\", beside any others.")
});
match preview.origin.as_deref() {
Some(o) if o == id => {}
Some(o) => println!(
"Under a name other than its books' (\"{o}\"), they won't back up to the private repository those books back up to (that would stop theirs): if these are those books, restore them as \"{o}\" (--id {o}); else name a repository of their own."
),
None => println!(
Some(o) => lines.push(format!(
"Under a name other than its books' (\"{o}\"), they won't back up to the private repository those books back up to (that would stop theirs): if these are those books, restore them as \"{o}\"{}; else name a repository of their own.",
flag(&format!("--id {o}"))
)),
None => lines.push(
"It doesn't say whose books it holds: they won't back up to the private repository their settings name until one of their own is named."
.into(),
),
}
match confirmed("Restore it?", yes) {
Ok(true) => {}
Ok(false) => {
println!("Not restored.");
return Ok(());
}
Err(why) => return fail(format!("not restored: {why}")),
}
match with_server(common, request(Some(preview.head.clone()))) {
Ok(Response::Restored(r)) => {
println!(
Ok(RestorePlan {
source,
id,
replace,
head: preview.head,
origin: preview.origin,
lines,
})
}
/// Restore as `plan` previewed it (only that backup, at that head): what
/// was done, a line each.
pub fn carry_out_restore(common: &Common, plan: &RestorePlan) -> Result<Vec<String>, String> {
let request = Request::Restore {
id: plan.id.clone(),
source: plan.source.clone(),
replace: plan.replace,
confirmed: true,
expect_head: Some(plan.head.clone()),
};
match with_server(common, request)? {
Response::Restored(r) => {
let mut lines = vec![format!(
"Restored as \"{}\": through event {} ({}), {} stored files checked, {} TIN vault records.",
r.id,
r.head.0,
short_hash(&r.head.1),
r.blobs_verified,
r.vault_files
);
)];
if let Some(kept) = &r.replaced {
println!("The books it replaced are kept in the legacy folder as {kept}.");
}
if let Some(why) = r.problem {
return fail(format!(
"the restored books didn't open: {why}. Closing the books and opening them again tries again."
lines.push(format!(
"The books it replaced are kept in the legacy folder as {kept}."
));
}
Ok(())
if let Some(why) = r.problem {
return Err(format!(
"{}\nThe restored books didn't open: {why}. Closing the books and opening them again tries again.",
lines.join("\n")
));
}
Ok(lines)
}
Ok(Response::Refused(why)) | Err(why) => fail(format!("{source}: {why}")),
Ok(other) => fail(format!("the books server answered oddly: {other:?}")),
Response::Refused(why) => Err(format!("{}: {why}", plan.source)),
other => Err(format!("the books server answered oddly: {other:?}")),
}
}
@@ -1118,46 +1594,7 @@ pub fn token(
match words.as_slice() {
["create"] => {
let book = need_book()?;
let request = Request::TokenCreate {
book: book.clone(),
name: name.clone(),
scopes,
replace,
};
let (answer, run_dir) = with_server_in(&common, request).map_err(fail)?;
let (info, text) = match answer {
Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)),
Response::Refused(why) => return Err(fail(why)),
other => return Err(fail(format!("the books server answered oddly: {other:?}"))),
};
// INVARIANT 4: kept first, or taken back.
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text);
drop(text);
if let Err(why) = kept {
let undone = with_server(
&common,
Request::TokenRevoke {
book: book.clone(),
name: name.clone(),
// This one only: another may have replaced it.
id: Some(info.id.clone()),
},
);
let after = match undone {
Ok(Response::TokenRevoked(_)) => "so it was revoked again",
_ => "and revoking it again failed too: `wanderbooks token revoke` ends it",
};
let replaced = if replace {
" The token it replaced had already ended."
} else {
""
};
return Err(fail(format!(
"the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}"
)));
}
let info = make_token(&common, &book, &name, scopes, replace).map_err(fail)?;
println!(
"Made the token {:?} for the books {} ({}), kept in this computer's keyring.",
info.name,
@@ -1222,14 +1659,8 @@ pub fn token(
}
["print"] => {
let book = need_book()?;
let opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Cli,
None,
)
.map_err(|e| fail(e.to_string()))?;
let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)
.map_err(|e| fail(e.to_string()))?;
let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
@@ -1348,7 +1779,6 @@ fn on_the_socket(
let mut opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Cli,
None,
)?;
-337
View File
@@ -1,337 +0,0 @@
//! `wanderbooks helper`: the small unconfined process a confined mode
//! keeps beside it (plan 3.9, "Starting the host cleanly").
//!
//! ```text
//! GOAL: a mode that confines itself (the viewer, the bridge) can still
//! start the host cleanly and hand off the few things a sandbox
//! forbids, through a channel that can ask for nothing else.
//! SOURCE: plan/served.md 3.9 (the helper: spawned over an inherited
//! socket pair before any thread, a fixed message set; it never
//! accepts a path or a command line).
//! INVARIANTS:
//! 1. It talks only over the socket it was handed (descriptor 3), one
//! JSON line each way, and exits when that closes.
//! 2. It accepts exactly start_host, open_with (an attachment id),
//! stage_backup and start_updater, none carrying a path or command;
//! anything else is refused.
//! 3. start_host starts the host cleanly (start.rs).
//! 4. Spawned before the caller starts any thread.
//! 5. A message is exactly its version and one ask, on a line of at
//! most MAX_LINE bytes; anything else ends the helper.
//! ```
use crate::knobs::{FLAG, Knobs};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, Write};
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
/// This protocol's version.
pub const PROTO: u32 = 1;
/// The descriptor the helper is handed its socket on.
const FD: i32 = 3;
/// The longest line either side sends.
const MAX_LINE: u64 = 64 * 1024;
/// A message to the helper (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Message {
/// The sender's protocol version.
pub proto: u32,
/// What it asks.
#[serde(flatten)]
pub ask: HelperAsk,
}
/// What the helper can be asked: exactly these (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum HelperAsk {
/// Start the books' host.
StartHost,
/// Open a document with the system's app for it.
OpenWith {
/// The attachment's id.
attachment: u64,
},
/// Ask the owner for a backup file and stage it for a restore.
StageBackup,
/// Start the updater.
StartUpdater,
}
/// The helper's answer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Answer {
/// Done.
Done,
/// Refused, with why.
Refused(String),
}
/// The caller's end: a running helper.
pub struct Client {
stream: std::sync::Mutex<UnixStream>,
child: std::sync::Mutex<std::process::Child>,
}
impl Client {
/// Spawn a helper for the root (`root`, else this account's), with
/// these knobs. Call it before starting any thread (INVARIANT 4).
pub fn spawn(root: Option<&Path>, knobs: &Knobs) -> Result<Self, String> {
let (ours, theirs) = UnixStream::pair().map_err(|e| e.to_string())?;
let exe = crate::start::this_executable()?;
let mut c = std::process::Command::new(exe);
c.arg("helper");
if let Some(root) = root {
let found = ledger_store::root::Root::find(Some(root)).map_err(|e| e.to_string())?;
c.arg("--root").arg(found.path());
}
c.args(knobs.args())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null());
let fd = theirs.as_raw_fd();
// SAFETY: dup2 and fcntl are async-signal-safe; they give the child
// its end of the pair as descriptor 3, without close-on-exec (which
// a descriptor already numbered 3 would still have).
unsafe {
c.pre_exec(move || {
let ok =
(fd == FD || libc::dup2(fd, FD) >= 0) && libc::fcntl(FD, libc::F_SETFD, 0) >= 0;
if !ok {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let child = c
.spawn()
.map_err(|e| format!("can't start the helper: {e}"))?;
drop(theirs);
Ok(Self {
stream: std::sync::Mutex::new(ours),
child: std::sync::Mutex::new(child),
})
}
/// Ask the helper one thing (blocking).
pub fn ask(&self, ask: HelperAsk) -> Result<Answer, String> {
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
let mut line =
serde_json::to_string(&Message { proto: PROTO, ask }).map_err(|e| e.to_string())?;
line.push('\n');
(&*stream)
.write_all(line.as_bytes())
.map_err(|e| format!("the helper is gone: {}", e.kind()))?;
let mut answer = String::new();
std::io::BufReader::new(std::io::Read::take(&*stream, MAX_LINE))
.read_line(&mut answer)
.map_err(|e| format!("the helper didn't answer: {}", e.kind()))?;
serde_json::from_str(answer.trim_end()).map_err(|_| "the helper is gone".to_string())
}
/// Start the books' host (INVARIANT 3).
pub fn start_host(&self) -> Result<(), String> {
match self.ask(HelperAsk::StartHost)? {
Answer::Done => Ok(()),
Answer::Refused(why) => Err(why),
}
}
}
impl Drop for Client {
fn drop(&mut self) {
// Closing our end ends the helper (INVARIANT 1); reap it.
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
let _ = stream.shutdown(std::net::Shutdown::Both);
drop(stream);
let _ = self.child.lock().unwrap_or_else(|e| e.into_inner()).wait();
}
}
/// What the helper does with one message.
fn answer(ask: HelperAsk, root: Option<&Path>, knobs: &Knobs) -> Answer {
match ask {
HelperAsk::StartHost => match crate::start::start_host(root, knobs) {
Ok(()) => Answer::Done,
Err(why) => Answer::Refused(why),
},
HelperAsk::OpenWith { .. } => {
Answer::Refused("opening documents comes with the viewer's menu".into())
}
HelperAsk::StageBackup => Answer::Refused(
"restore from a terminal for now: `wanderbooks books restore BACKUP`".into(),
),
HelperAsk::StartUpdater => Answer::Refused("updates come with the installers".into()),
}
}
/// `wanderbooks helper`: serve the socket on descriptor 3 until it closes
/// (INVARIANT 1).
pub fn run(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut root: Option<PathBuf> = None;
let mut knobs = Knobs::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
FLAG => knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?,
"--help" | "-h" => {
eprintln!("usage: {prog} [--root DIR]");
eprintln!(" Started by a confined part of wanderbooks, over descriptor 3.");
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
return Err(Box::new(crate::Exit(2)));
}
}
}
// SAFETY: descriptor 3 is ours by INVARIANT 1; if it isn't a socket,
// the peer check below refuses it.
let stream = unsafe { UnixStream::from_raw_fd(FD) };
// Taken: nothing this helper starts inherits it.
// SAFETY: fcntl on our own descriptor.
unsafe { libc::fcntl(FD, libc::F_SETFD, libc::FD_CLOEXEC) };
if !ledger_mcp::local::peer_is_us(&stream) {
eprintln!("{prog}: descriptor 3 isn't a socket to this account's own process");
return Err(Box::new(crate::Exit(2)));
}
serve(std::io::BufReader::new(&stream), &stream, |ask| {
answer(ask, root.as_deref(), &knobs)
});
Ok(())
}
/// Answer each message read from `reader` on `writer`, a line each, with
/// `reply` for the asks of this version, until the input ends or a line
/// is too long or cut off (INVARIANTS 1, 5).
fn serve<R: BufRead, W: Write>(mut reader: R, mut writer: W, reply: impl Fn(HelperAsk) -> Answer) {
loop {
// INVARIANT 5: never more than a line's worth read.
let mut line = String::new();
match std::io::Read::take(&mut reader, MAX_LINE + 1).read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) if !line.ends_with('\n') => break,
Ok(_) => {}
}
let parsed = crate::lease::strict_pair(line.trim_end())
.and_then(|v| serde_json::from_value::<Message>(v).map_err(|e| e.to_string()));
let answer = match parsed {
Ok(m) if m.proto == PROTO => reply(m.ask),
Ok(_) => Answer::Refused("the helper is another version: restart".into()),
Err(e) => Answer::Refused(format!("not understood: {e}")),
};
let mut text = serde_json::to_string(&answer).unwrap_or_default();
text.push('\n');
if writer.write_all(text.as_bytes()).is_err() {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn exactly_four_asks_and_none_carries_a_path() {
for ok in [
json!({"proto": 1, "start_host": null}),
json!({"proto": 1, "open_with": {"attachment": 7}}),
json!({"proto": 1, "stage_backup": null}),
json!({"proto": 1, "start_updater": null}),
] {
assert!(
serde_json::from_value::<Message>(ok.clone()).is_ok(),
"{ok}"
);
}
for bad in [
json!({"proto": 1, "open_with": {"attachment": 7, "path": "/etc/passwd"}}),
json!({"proto": 1, "open_with": {"path": "/x"}}),
json!({"proto": 1, "run": {"command": "sh"}}),
json!({"proto": 1, "start_host": {"root": "/x"}}),
json!({"proto": 1}),
] {
assert!(
serde_json::from_value::<Message>(bad.clone()).is_err(),
"{bad}"
);
}
// Two asks in one message: refused, not half-read.
let two = r#"{"proto":1,"start_host":null,"start_updater":null}"#;
assert!(crate::lease::strict_pair(two).is_err());
}
/// Serve `input`; the answers, a line each.
fn served(input: &[u8]) -> Vec<String> {
let mut out: Vec<u8> = Vec::new();
serve(input, &mut out, |ask| match ask {
HelperAsk::StartHost => Answer::Done,
_ => Answer::Refused("no".into()),
});
String::from_utf8(out)
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
#[test]
fn each_line_is_answered_and_a_bad_one_ends_it() {
let ok = r#"{"proto":1,"start_host":null}"#;
let other = r#"{"proto":2,"start_host":null}"#;
let got = served(format!("{ok}\n{other}\nnonsense\n{ok}\n").as_bytes());
assert_eq!(got[0], r#""done""#);
assert!(got[1].contains("another version"), "{}", got[1]);
assert!(got[2].contains("not understood"), "{}", got[2]);
assert_eq!(got[3], r#""done""#);
assert_eq!(got.len(), 4);
// A line at the limit is read; one byte more ends the helper,
// and so does one cut off without its newline.
let at = format!("{ok}{}\n", " ".repeat(MAX_LINE as usize - ok.len()));
assert_eq!(served(at.as_bytes()), [r#""done""#]);
let over = format!(
"{ok}{}\n{ok}\n",
" ".repeat(MAX_LINE as usize - ok.len() + 1)
);
assert!(served(over.as_bytes()).is_empty());
assert!(served(ok.as_bytes()).is_empty());
// An answer that can't be written ends it.
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let asked = std::cell::Cell::new(0);
serve(format!("{ok}\n{ok}\n").as_bytes(), Broken, |_| {
asked.set(asked.get() + 1);
Answer::Done
});
assert_eq!(asked.get(), 1, "nothing more after the first answer failed");
}
#[test]
fn what_isnt_built_yet_is_refused() {
let k = Knobs::default();
for ask in [
HelperAsk::OpenWith { attachment: 1 },
HelperAsk::StageBackup,
HelperAsk::StartUpdater,
] {
assert!(matches!(answer(ask, None, &k), Answer::Refused(_)));
}
}
}
-55
View File
@@ -539,16 +539,6 @@ impl Host {
Phase::Closing(reason) => HostMsg::Ending { reason, detail: None },
});
}
Event::Intent(Intent::NewBooks { id, currency }, answer)
if self.ready && self.phase == Phase::Open =>
{
// The server does it; the loop goes on meanwhile.
let socket = self.run.join(ledger_mcp::local::ADMIN_SOCKET);
tokio::spawn(async move {
let msg = new_books(socket, id, currency).await;
let _ = answer.send(msg);
});
}
Event::Intent(Intent::Close, answer) if self.phase != Phase::Open => {
// INVARIANT 11; closing anyway: already so.
let _ = answer.send(match self.phase {
@@ -701,27 +691,6 @@ impl Host {
},
Some(Reason::ClosedByOwner),
),
Intent::NewBooks { .. } if self.phase == Phase::Open => (
HostMsg::Refused("the books are still opening: try again in a moment".into()),
None,
),
Intent::NewBooks { .. } => (
HostMsg::Refused("the books are closing: nothing was done".into()),
None,
),
Intent::BackUpNow { .. } => (
HostMsg::Refused(
"the books back themselves up; to back up now, ask your agent (books.back_up_now)"
.into(),
),
None,
),
Intent::Restore { .. } => (
HostMsg::Refused(
"restore from a terminal for now: `wanderbooks books restore BACKUP`".into(),
),
None,
),
Intent::ConsentFormatUpgrade { .. } => (
HostMsg::Refused("no books are waiting for a format upgrade".into()),
None,
@@ -768,30 +737,6 @@ impl Host {
}
}
/// "New books…": the server makes them, through its admin socket.
async fn new_books(socket: PathBuf, id: String, currency: String) -> HostMsg {
use ledger_mcp::admin::{Request, Response};
let answer = tokio::task::spawn_blocking(move || {
ledger_mcp::admin::call(&socket, Request::Create { id, currency })
})
.await;
match answer {
Ok(Ok(Response::Created { id, problem: None })) => HostMsg::Done {
detail: Some(format!("new books {id} made")),
},
Ok(Ok(Response::Created {
id,
problem: Some(why),
})) => HostMsg::Refused(format!("new books {id} were made but didn't open: {why}")),
Ok(Ok(Response::Refused(why))) => HostMsg::Refused(why),
Ok(Ok(_)) => HostMsg::Refused("the books server answered oddly".into()),
Ok(Err(ledger_mcp::admin::CallError::NotSent(why))) => HostMsg::Refused(why),
// Sent, and no answer: never reported as not done.
Ok(Err(ledger_mcp::admin::CallError::Unknown(why))) => HostMsg::Uncertain(why),
Err(_) => HostMsg::Refused("the host hit an internal error".into()),
}
}
/// Take run/update.lock (exclusive), if it is free now.
fn take_update_lock(run: &Path) -> Result<std::fs::File, String> {
let file = std::fs::OpenOptions::new()
+12 -51
View File
@@ -4,7 +4,10 @@
//! GOAL: holders hold the books open, and the owner's few requests reach
//! the server, through one socket that accepts nothing else.
//! SOURCE: plan/served.md 3.9 ("Who holds them", "Three local channels":
//! leases record their kind and PID; exactly five owner intents;
//! leases record their kind and PID; the owner intents (as built,
//! D-32: two; the viewer, unconfined, backs up, restores and makes
//! books through the server's admin socket as the command line
//! does);
//! consent carries the owner passphrase (a replacing restore no
//! longer does: D-31, the books it replaces are kept in legacy/);
//! "Closing": the host says why on each lease; protocol versions N
@@ -13,9 +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 five intents exist: close, back_up_now, new_books,
//! restore (a backup's address or folder, as `books restore` takes
//! it) and consent_format_upgrade; any other request is refused.
//! 2. Exactly two intents exist: close and consent_format_upgrade; any
//! other request is refused.
//! 3. consent_format_upgrade carries the owner passphrase; without one
//! it is refused before anything is done.
//! 4. Before the host ends a lease it sends the reason; a lease that
@@ -102,36 +104,12 @@ pub enum Ask {
Intent(Intent),
}
/// The owner's intents: exactly these five (INVARIANT 2).
/// The owner's intents: exactly these two (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Intent {
/// Close the books now: every lease ends.
Close,
/// Back a book up now.
BackUpNow {
/// Which.
book: String,
},
/// Start new books.
NewBooks {
/// Their id.
id: String,
/// Their base currency.
currency: String,
},
/// Restore a backup, as its own book or replacing one (kept in
/// legacy/).
Restore {
/// The backup: a repository's address, or its folder (`<book>.git`
/// in the backup folder).
source: String,
/// The books' id.
id: String,
/// Replace the books of that id.
#[serde(default)]
replace: bool,
},
/// Let the server upgrade a book's journal format.
ConsentFormatUpgrade {
/// Which.
@@ -277,12 +255,9 @@ mod tests {
}
#[test]
fn exactly_five_intents_are_understood() {
fn exactly_two_intents_are_understood() {
for intent in [
json!("close"),
json!({"back_up_now": {"book": "acme"}}),
json!({"new_books": {"id": "acme", "currency": "USD"}}),
json!({"restore": {"source": "/backups/acme.git", "id": "acme"}}),
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
] {
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
@@ -291,8 +266,9 @@ mod tests {
for bad in [
json!({"proto": 1, "intent": "delete"}),
json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a", "path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"staged": "s1"}}}),
json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a"}}}),
json!({"proto": 1, "intent": {"back_up_now": {"book": "acme"}}}),
json!({"proto": 1, "intent": {"new_books": {"id": "acme", "currency": "USD"}}}),
json!({"proto": 1, "intent": {"run": {"command": "sh"}}}),
json!({"proto": 1, "exec": "sh"}),
json!({"proto": 1}),
@@ -325,22 +301,7 @@ mod tests {
assert!(consent(None).check_passphrase().is_err());
assert!(consent(Some("")).check_passphrase().is_err());
assert!(consent(Some("pw")).check_passphrase().is_ok());
for other in [
// D-31: replacing keeps the old books in legacy/; no passphrase.
Intent::Restore {
source: "/backups/acme.git".into(),
id: "acme".into(),
replace: true,
},
Intent::Close,
Intent::BackUpNow { book: "a".into() },
Intent::NewBooks {
id: "a".into(),
currency: "USD".into(),
},
] {
assert!(other.check_passphrase().is_ok());
}
assert!(Intent::Close.check_passphrase().is_ok());
}
#[test]
+5 -6
View File
@@ -5,18 +5,17 @@
//! the **host**, which starts the books server and holds it open while
//! any lease on its socket lasts, and closes it 10 s after the last one
//! ends. Each part is a mode of the one `wanderbooks` executable:
//! [`host::run`] (`wanderbooks host`), [`helper::run`] (`wanderbooks
//! helper`), [`bridge::run`] (`wanderbooks mcp`), and the commands in
//! [`commands`] (`serve`, `close`, `books`, `token`). Agents' tokens are
//! kept in the OS keyring ([`tokens`]).
//! [`host::run`] (`wanderbooks host`), [`bridge::run`] (`wanderbooks
//! mcp`), and the owner's commands in [`commands`] (`serve`, `close`,
//! `books`, `token`, `connect`, `doctor`, `passphrase`, and what the
//! viewer's Books menu does). Agents' tokens are kept in the OS keyring
//! ([`tokens`]).
#[cfg(unix)]
pub mod bridge;
#[cfg(unix)]
pub mod commands;
#[cfg(unix)]
pub mod helper;
#[cfg(unix)]
pub mod host;
pub mod knobs;
pub mod lease;
+16 -33
View File
@@ -12,7 +12,8 @@
//! INVARIANTS:
//! 1. Before each try the holder waits while a restart holds
//! run/update.lock.
//! 2. No host answering: it starts one (its Starter) and keeps trying;
//! 2. No host answering: it starts one (cleanly, start.rs) and keeps
//! trying;
//! a host that is closing or restarting turns it away and it tries
//! again until a host takes the lease.
//! 3. A start that brings no lease within START_WAIT (or whose host
@@ -87,20 +88,11 @@ impl From<&str> for HoldError {
}
}
/// How a holder starts a host when none answers.
pub enum Starter {
/// Start it itself (a holder that isn't confined).
Direct,
/// Ask its helper to (a confined holder).
Helper(std::sync::Arc<crate::helper::Client>),
}
/// Takes leases for one holder.
pub struct Opener {
root: Option<PathBuf>,
run: PathBuf,
knobs: Knobs,
starter: Starter,
kind: Kind,
book: Option<String>,
failures: u32,
@@ -140,7 +132,6 @@ impl Opener {
pub fn new(
root: Option<&Path>,
knobs: Knobs,
starter: Starter,
kind: Kind,
book: Option<String>,
) -> Result<Self, String> {
@@ -150,7 +141,6 @@ impl Opener {
root: root.map(|_| found.path().to_path_buf()),
run,
knobs,
starter,
kind,
book,
failures: 0,
@@ -192,6 +182,12 @@ impl Opener {
Ok(())
}
/// Start counting failures over (a holder that waited a while after
/// giving up tries again); what it has held is kept (INVARIANT 5).
pub fn forget_failures(&mut self) {
self.failures = 0;
}
/// Take a lease, starting a host if none answers (INVARIANTS 1-4).
pub async fn hold(&mut self) -> Result<Lease, HoldError> {
let mut started: Option<Instant> = None;
@@ -230,8 +226,8 @@ impl Opener {
// for it rather than start another.
let held = host_lock_held(&self.run);
match started {
// A start that fails (a spawn error, the helper
// gone) is a failure like any other (INVARIANT 3).
// A start that fails (a spawn error) is a
// failure like any other (INVARIANT 3).
None if !held => match self.start().await {
Ok(()) => started = Some(Instant::now()),
Err(_) => self.failed().await?,
@@ -256,25 +252,13 @@ impl Opener {
}
}
/// Start a host, through this holder's starter.
/// Start a host, cleanly (start.rs).
async fn start(&self) -> Result<(), String> {
match &self.starter {
Starter::Direct => {
let root = self.root.clone();
let knobs = self.knobs;
tokio::task::spawn_blocking(move || {
crate::start::start_host(root.as_deref(), &knobs)
})
.await
.map_err(|e| e.to_string())?
}
Starter::Helper(helper) => {
let helper = helper.clone();
tokio::task::spawn_blocking(move || helper.start_host())
.await
.map_err(|e| e.to_string())?
}
}
let root = self.root.clone();
let knobs = self.knobs;
tokio::task::spawn_blocking(move || crate::start::start_host(root.as_deref(), &knobs))
.await
.map_err(|e| e.to_string())?
}
/// Say hello on a connection and read the host's first word.
@@ -468,7 +452,6 @@ mod tests {
backoff: Duration::from_millis(1),
..Knobs::default()
},
starter: Starter::Direct,
kind: Kind::Cli,
book: None,
failures: 0,