feat(host): Keychain and Credential Manager token stores, a macOS/Windows CI job (P11a step 0)

The agent path was Linux-only because tokens had a keyring store only
there (every other OS hit NOT_YET, so `connect` and the bridge failed).
D-56's destination is one installation for macOS, Windows and Linux.
Both new stores sit behind the same Items trait as the Secret Service,
so INVARIANT 4 (one account, one item, locked is an error) is kept once
and tested once through the file keyring. Windows credentials have a
unique target name, so a probe stands in for a search.

New dependencies (ledger-host only, target-gated): apple-native-keyring-store
(feature keychain) and windows-native-keyring-store, the stores the
keyring-core 1 API we already use is published with. No ledger-core change.

The `portable` job builds and tests the workspace on macos-latest and
windows-latest. Windows is allowed to fail: the rest of the host is
Unix-only (inventory in docs/packaging.md). Not verified locally: no
macOS or Windows target is installed; CI is the evidence.

Also pins cargo-packager, cargo-packager-updater and cargo-generate-rpm
in mise.toml for the trial, and records that the trial is not yet run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 12:41:01 -06:00
1 parent f98471516e
commit ee4f8cd58a
10 files changed
+307 -35

No files matched your search

+42
View File
@@ -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
Generated
+32
View File
@@ -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",
]
@@ -8723,6 +8742,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"
+8 -6
View File
@@ -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`).
+8 -1
View File
@@ -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"
+149 -26
View File
@@ -188,6 +188,49 @@ fn delete_all<I: Items>(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<String, String> {
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<Vec<Self::Item>, 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<String, String> {
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<Store>);
impl Service {
pub(super) fn reach() -> Result<Self, String> {
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<Vec<Self::Item>, 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<String, String> {
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<Store>);
impl Service {
pub(super) fn reach() -> Result<Self, String> {
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<Vec<Self::Item>, 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<String, String> {
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<Option<String>, 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<Option<String>, 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() {
+1 -1
View File
@@ -35,7 +35,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-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) |
+42
View File
@@ -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.
+19
View File
@@ -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.
+5
View File
@@ -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"
+1 -1
View File
@@ -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)