diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6f78332..2c653dd 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -34,3 +34,45 @@ jobs: - name: What the build used if: always() run: df -h / | tail -1 + # The destination is one installation for macOS, Windows and Linux (D-56, + # P11a step 0): the workspace must build and pass its tests on each, not + # only here. `just check` (fmt, clippy, rustdoc, proofs' friends) stays + # the Linux job's; these run the build and the tests. The job tells + # which OS a failure is on; neither is allowed to hide the other. + portable: + name: portable (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + experimental: false + - os: windows-latest + experimental: true + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.experimental }} + # Same disk discipline as above: no incremental caches, line tables only. + env: + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: line-tables-only + CARGO_PROFILE_TEST_DEBUG: line-tables-only + steps: + - uses: actions/checkout@v4 + - name: Rust (the version mise.toml pins) + shell: bash + run: rustup toolchain install 1.99 --profile minimal && rustup default 1.99 + - name: Room for the build + shell: bash + run: df -h . | tail -1 + - name: The server-only build (no viewer) + run: cargo build -p ledger-cli --no-default-features + - name: Everything compiles + run: cargo build --workspace --all-targets + - name: Tests + run: cargo test --workspace + - name: The real OS keyring keeps and forgets a token + run: cargo test -p ledger-host --lib -- --ignored os_keyring + - name: What the build used + if: always() + shell: bash + run: df -h . | tail -1 diff --git a/Cargo.lock b/Cargo.lock index d2e4997..9cc5845 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,6 +249,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "approx" version = "0.5.1" @@ -877,6 +888,12 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -3964,6 +3981,7 @@ dependencies = [ name = "ledger-host" version = "0.1.0" dependencies = [ + "apple-native-keyring-store", "base64 0.22.1", "keyring-core", "ledger-core", @@ -3978,6 +3996,7 @@ dependencies = [ "tempfile", "tokio", "tokio-util", + "windows-native-keyring-store", "zbus-secret-service-keyring-store", "zeroize", ] @@ -8732,6 +8751,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" diff --git a/README.md b/README.md index e3eef51..227292a 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,11 @@ Sheet is frozen as of 2026-10-11. The remaining work, its order and its hand-off are `plan/served.md` section 8.0; the destination is one installed app for Mac, Windows and Linux (P11), then the hosted service the plan describes and does not build. Today the agent path runs on -Linux: tokens live in the OS keyring and only the Linux Secret Service -store exists, so on macOS the viewer and server run but `wanderbooks -connect` and the agent bridge wait for P11's keyring stores; Windows is -WSL2 only. +Linux: tokens live in the OS keyring (the Secret Service on Linux, the +login Keychain on macOS, the Credential Manager on Windows; the last two +are written and compiled by the CI matrix, P11a step 0, and not yet +proven on a machine of ours); the rest of the host is Unix-only, so +Windows is WSL2 only until its sockets and data root are ported. See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and [`plan/roadmap.md`](plan/roadmap.md) for where it's going. @@ -90,8 +91,9 @@ curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme ``` (Linux today. On macOS the same line installs and sets up, and the -viewer runs, but connecting an agent waits for P11's keyring store; on -Windows run it inside WSL2.) +viewer runs; connecting an agent uses the login Keychain, new with +P11a step 0 and proven only by the CI matrix so far. On Windows run it +inside WSL2.) Already installed, three commands: `wanderbooks setup`, `wanderbooks books create acme`, `wanderbooks connect claude --book acme` (or `connect codex`). diff --git a/crates/ledger-host/Cargo.toml b/crates/ledger-host/Cargo.toml index ebd75f7..a90562b 100644 --- a/crates/ledger-host/Cargo.toml +++ b/crates/ledger-host/Cargo.toml @@ -19,7 +19,8 @@ base64 = "0.22" sha2 = "0.10" tokio-util = { version = "0.7", features = ["rt"] } # Agents' tokens are kept in the OS keyring (plan 3.3, 3.5): the Secret -# Service on Linux. macOS and Windows stores come with P11. +# Service on Linux, the login Keychain on macOS, the Credential Manager on +# Windows (P11a step 0). keyring-core = "1" # The owner passphrase is typed at the terminal, unechoed. rpassword = "7" @@ -32,6 +33,12 @@ libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] zbus-secret-service-keyring-store = { version = "1", features = ["rt-async-io-crypto-rust"] } +[target.'cfg(target_os = "macos")'.dependencies] +apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] } + +[target.'cfg(windows)'.dependencies] +windows-native-keyring-store = "1.1.0" + [dev-dependencies] tempfile = "3" diff --git a/crates/ledger-host/src/tokens.rs b/crates/ledger-host/src/tokens.rs index b1368c4..3585c02 100644 --- a/crates/ledger-host/src/tokens.rs +++ b/crates/ledger-host/src/tokens.rs @@ -188,6 +188,49 @@ fn delete_all(items: &I, account: &Account) -> Result<(), KeyringError /// The OS keyring. struct Os; +/// What a keyring error says (INVARIANT 4: locked is said as such). +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn refused(e: keyring_core::Error) -> String { + match e { + keyring_core::Error::NoStorageAccess(_) => { + format!("this computer's keyring is locked, or wouldn't let it be read ({e})") + } + e => format!("the keyring refused: {e}"), + } +} + +/// Make an item for `account` in a store's default collection. +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn make_in( + store: &dyn keyring_core::api::CredentialStoreApi, + account: &Account, + text: &str, +) -> Result<(), String> { + let label = account.label(); + let modifiers = std::collections::HashMap::from([("label", label.as_str())]); + // Only the Secret Service takes a label; the other stores refuse + // modifiers they don't know, so they are built without. + let modifiers = cfg!(target_os = "linux").then_some(&modifiers); + store + .build(SERVICE, &account.key(), modifiers) + .and_then(|entry| entry.set_password(text)) + .map_err(refused) +} + +/// An item's text, and its removal, the same on every store. +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn read_item(item: &keyring_core::Entry) -> Result { + item.get_password().map_err(refused) +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +fn remove_item(item: keyring_core::Entry) -> Result<(), String> { + match item.delete_credential() { + Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), + Err(e) => Err(refused(e)), + } +} + #[cfg(target_os = "linux")] mod os { use super::{Account, Items, SERVICE}; @@ -207,13 +250,49 @@ mod os { } } - /// What a keyring error says (INVARIANT 4: locked is said as such). - fn refused(e: keyring_core::Error) -> String { - match e { - keyring_core::Error::NoStorageAccess(_) => { - format!("this computer's keyring is locked, or wouldn't let it be read ({e})") - } - e => format!("the keyring refused: {e}"), + impl Items for Service { + type Item = keyring_core::Entry; + + fn find(&self, account: &Account) -> Result, String> { + let key = account.key(); + let spec = HashMap::from([("service", SERVICE), ("username", key.as_str())]); + self.0.search(&spec).map_err(super::refused) + } + + fn read(&self, item: &Self::Item) -> Result { + super::read_item(item) + } + + fn remove(&self, item: Self::Item) -> Result<(), String> { + super::remove_item(item) + } + + fn make(&self, account: &Account, text: &str) -> Result<(), String> { + super::make_in(&*self.0, account, text) + } + } +} + +/// The macOS login Keychain (plan 3.3, D-56): one generic password per +/// account, under the service "wanderbooks". +#[cfg(target_os = "macos")] +mod os { + use super::{Account, Items, SERVICE}; + use apple_native_keyring_store::keychain::Store; + use keyring_core::api::CredentialStoreApi; + use std::collections::HashMap; + use std::sync::Arc; + + /// The login keychain, reached for one keeper call. + pub(super) struct Service(Arc); + + impl Service { + pub(super) fn reach() -> Result { + Store::new().map(Self).map_err(|e| { + format!( + "this Mac's keychain can't be reached ({e}): is the login keychain unlocked?" + ) + }) } } @@ -222,33 +301,77 @@ mod os { fn find(&self, account: &Account) -> Result, String> { let key = account.key(); - let spec = HashMap::from([("service", SERVICE), ("username", key.as_str())]); - self.0.search(&spec).map_err(refused) + let spec = HashMap::from([("service", SERVICE), ("user", key.as_str())]); + self.0.search(&spec).map_err(super::refused) } fn read(&self, item: &Self::Item) -> Result { - item.get_password().map_err(refused) + super::read_item(item) } fn remove(&self, item: Self::Item) -> Result<(), String> { - match item.delete_credential() { - Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), - Err(e) => Err(refused(e)), - } + super::remove_item(item) } fn make(&self, account: &Account, text: &str) -> Result<(), String> { - let label = account.label(); - let modifiers = HashMap::from([("label", label.as_str())]); - self.0 - .build(SERVICE, &account.key(), Some(&modifiers)) - .and_then(|entry| entry.set_password(text)) - .map_err(refused) + super::make_in(&*self.0, account, text) } } } -#[cfg(target_os = "linux")] +/// The Windows Credential Manager: one generic credential per account +/// (its target name is unique, so a second item for an account cannot +/// exist; INVARIANT 4 holds by construction and is still checked). +#[cfg(windows)] +mod os { + use super::{Account, Items, SERVICE}; + use keyring_core::api::CredentialStoreApi; + use std::sync::Arc; + use windows_native_keyring_store::Store; + + /// The Credential Manager, reached for one keeper call. + pub(super) struct Service(Arc); + + impl Service { + pub(super) fn reach() -> Result { + Store::new() + .map(Self) + .map_err(|e| format!("this computer's Credential Manager can't be reached ({e})")) + } + } + + impl Items for Service { + type Item = keyring_core::Entry; + + /// The credential for the account, if one is there: its target + /// name is the account, so a probe finds it or finds none. + fn find(&self, account: &Account) -> Result, String> { + let entry = self + .0 + .build(SERVICE, &account.key(), None) + .map_err(super::refused)?; + match entry.get_password() { + Ok(_) => Ok(vec![entry]), + Err(keyring_core::Error::NoEntry) => Ok(Vec::new()), + Err(e) => Err(super::refused(e)), + } + } + + fn read(&self, item: &Self::Item) -> Result { + super::read_item(item) + } + + fn remove(&self, item: Self::Item) -> Result<(), String> { + super::remove_item(item) + } + + fn make(&self, account: &Account, text: &str) -> Result<(), String> { + super::make_in(&*self.0, account, text) + } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos", windows))] impl Keeper for Os { fn get(&self, account: &Account) -> Result, KeyringError> { let service = os::Service::reach().map_err(KeyringError::Unavailable)?; @@ -266,7 +389,8 @@ impl Keeper for Os { } } -#[cfg(not(target_os = "linux"))] +/// Any other OS has no keyring store yet. +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] impl Keeper for Os { fn get(&self, _: &Account) -> Result, KeyringError> { Err(KeyringError::Unavailable(NOT_YET.into())) @@ -279,9 +403,8 @@ impl Keeper for Os { } } -#[cfg(not(target_os = "linux"))] -const NOT_YET: &str = - "agents' tokens are kept in the keyring on Linux only, until the macOS and Windows apps (P11)"; +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +const NOT_YET: &str = "agents' tokens are kept in the keyring on Linux, macOS and Windows only"; /// Files, for tests (INVARIANT 3): a folder per collection (`default/` /// where tokens are kept), a file a token. A collection holding a file @@ -532,7 +655,7 @@ mod tests { /// The real keyring, on a desktop with one (`cargo test -p /// ledger-host -- --ignored os_keyring`): kept, read, replaced and /// forgotten, under a service no one else uses. - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos", windows))] #[test] #[ignore] fn os_keyring_keeps_and_forgets() { diff --git a/docs/architecture.md b/docs/architecture.md index e3ad2c0..66e8e7b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,7 +36,7 @@ the specification in `bookspec/` (which never depends on the engine). | `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels) out | `ledger-core`, `ledger-reader` | | `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` | | `ledger-mcp` | the books server: the MCP tools (122), resources, the handbook, scopes, the door and the access log, backups' scheduling | all of the above | -| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in the OS keyring, the owner's commands, uploads and downloads | `ledger-store`, `ledger-mcp`, `ledger-config` | +| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in the OS keyring (Secret Service, macOS Keychain, Windows Credential Manager), the owner's commands, uploads and downloads | `ledger-store`, `ledger-mcp`, `ledger-config` | | `ledger-gui` | the viewer: read-only, live, egui; the Books menu hands work to the server | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-package`, `ledger-vault`, `ledger-host` | | `ledger-cli` | `wanderbooks`, the one executable: every mode (`serve`, `server`, `host`, `mcp`, `gui`, `setup`, `books`, `token`, `connect`, `doctor`, …) and the over-the-wire test suites | everything | | `bookspec/checker`, `bookspec/runner` | the example schema and parser, the rule catalogue, the `Engine` trait; the per-rule runner | nothing of ours (the adapter in `ledger-core`'s tests depends on them) | diff --git a/docs/packaging.md b/docs/packaging.md new file mode 100644 index 0000000..1739682 --- /dev/null +++ b/docs/packaging.md @@ -0,0 +1,42 @@ +# Packaging and updates: the trial (P11a step 1) + +Status 2026-10-29: **the trial has not been run.** It needs clean Linux, +macOS and Windows machines for the six formats (`.deb`, `.rpm`, AUR, +AppImage, `.dmg`, NSIS `.exe`), and the one machine that built step 0 is a +Linux desktop with neither a macOS nor a Windows target, no container +runtime and none of the packager tools installed. The tools are pinned in +`mise.toml` now (`cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, +`cargo-generate-rpm` 0.21.1), so the trial is `mise install` and the VMs. +Nothing below is a result; it is what is known before the trial, and the +table is filled by the person who runs it. + +| format | built by | installs as | updated by | signed by | +|---|---|---|---|---| +| `.deb` | `cargo packager` | (trial) | apt repository (step 3) | (trial) | +| `.rpm` | `cargo-generate-rpm` | (trial) | dnf repository (step 3) | (trial) | +| AUR `PKGBUILD` | hand-written | (trial) | the package manager | n/a | +| AppImage | `cargo packager` | (trial) | `cargo-packager-updater` | (trial) | +| `.dmg` | `cargo packager` | (trial) | `cargo-packager-updater` | Developer ID (step 5) | +| NSIS `.exe` | `cargo packager` | (trial) | `cargo-packager-updater` | Windows identity (step 5) | + +## What step 0 found about the platforms (read before the macOS and Windows rows) + +The agent path was Linux-only because of the keyring. Step 0 adds the +macOS login Keychain and the Windows Credential Manager behind +`ledger-host/src/tokens.rs::Keeper`; CI (`portable` job) is the evidence. +Beyond the keyring, the Windows host is a port, not a `cfg` gap. Today +Unix-only, found by grep (2026-10-29): + +- `ledger-host`: `bridge`, `host`, `start`, `opener` and others are + `#[cfg(unix)]` modules; `host.rs`/`start.rs` use `setsid`, `umask`, + `close_range`, `geteuid`, Unix sockets and `libc` unguarded; + `commands.rs` calls `isatty`/`tcgetpgrp`. +- `ledger-mcp`: the owner-only socket check reads peer credentials + (`SO_PEERCRED`, `getpeereid`); `admin`/`local` listen on Unix sockets. + Windows needs named pipes with a peer SID check (plan 3.9). +- `ledger-store`: `ops.rs` takes `flock` locks; `root.rs` has no owner-only + DACL (`set_mode` is a no-op off Unix) and the data root falls back to + `%USERPROFILE%`, not the known-folder `%LOCALAPPDATA%`; `refuse.rs` + (seccomp) and `renameat2` are Linux-only (macOS needs its own + equivalents or `cfg` fences). +- The `portable` job runs Windows as allowed-to-fail until that port lands. diff --git a/docs/testing.md b/docs/testing.md index 5fb0086..a37c665 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3401,3 +3401,22 @@ arms, the 8 KiB cut, the 64 KiB hand-over threshold and `page - 1`. `ledger-package::attention`: the extraction `seq` comparison and the `Failed` filter, the owed-account name test, the credit side and the `settles_entry` skip. + +## P11a step 0: keyring stores for macOS and Windows (2026-10-29) + +No new test body: the keeper's laws are tested once, over the `Items` +trait, through the `file_keyring` knob (`the_test_keyring_keeps_and_forgets`, +`two_items_for_one_token_are_said_never_one_of_them_given`, +`a_locked_or_unreachable_keyring_is_an_error_never_no_token`, +`a_token_kept_is_read_back_or_refused`, all in `ledger-host/src/tokens.rs`), +and the macOS Keychain and Windows Credential Manager stores implement +that same trait. The ignored `os_keyring_keeps_and_forgets` (same file) is +now compiled on all three OSes; the `portable` job of +`.github/workflows/check.yml` runs it on `macos-latest` and +`windows-latest` beside `cargo build --workspace --all-targets` and +`cargo test --workspace`. Not run on the author's machine (Linux only; the +Secret Service run needs a desktop): the CI run is the evidence. + +**For P12 (mutation targets, none run, D-33):** `tokens.rs`'s `os` modules +are not reachable by mutation testing (they need the real OS store); the +`Items` logic they share is, through the file store. diff --git a/mise.toml b/mise.toml index 0cd80e8..ecc8d98 100644 --- a/mise.toml +++ b/mise.toml @@ -23,3 +23,8 @@ node = "26" # A compile cache shared by every jj workspace's target dir: point # cargo's build.rustc-wrapper at it (~/.cargo/config.toml). sccache = "0.18.0" + +# P11a: the packaging trial and the installers (docs/packaging.md). +"cargo:cargo-packager" = "0.11.8" +"cargo:cargo-packager-updater" = "0.2.3" +"cargo:cargo-generate-rpm" = "0.21.1" diff --git a/plan/served.md b/plan/served.md index 4b6519c..850fd1d 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1596,7 +1596,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:** none yet. +8. **As built:** (steps 0 and 1 only, 2026-10-29; steps 2 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**: 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. #### P11b: the agent dock and books homes (D-27)