The books can live on a machine of the owner's own (plan 3.8, 8.2 P9a, D-51). Three things make that honest instead of nominal: - `--isolation same_user|enforced` sets `server::Isolation`, carried through `Served` and `BooksServer` to `books.start`. Locally the books stay `same_user (advisory)`; under the self-host unit the server is told `enforced` and reports `service_user (enforced)`. The server never guesses, and the literal it used to report is gone. - `--http [ADDR]` binds the same MCP router on loopback (default 127.0.0.1:7411) beside the Unix sockets, before the ready line, for the TLS proxy or tunnel in front of the server. It is the same bearer door and the same graceful close; a failed bind is reported and is not fatal (3.9). `serve` carries both flags through the host, so the system unit can ask for them once. `deploy/wanderbooks-server.service` is the unit: a dedicated account, a 0700 data root, `ProtectSystem=strict`, no seccomp or Landlock (D-32), and loopback-only networking. `deploy/README.md` is the recipe: install, setup, move the books by `books adopt` with its head check (D-31: a copied book is the transport), mint a bearer token, put Caddy or Tailscale Funnel in front of the loopback door, and point Claude Code and Codex at it. The two-account CI job (`.github/workflows/two-user.yml`) makes the deployment and runs `crates/ledger-cli/tests/two_user.rs` as another account: `agent_uid_cannot_read_tenant` proves the tenant root and its journal are refused, then reads, writes, uploads, files, exports and verifies over HTTP with the bearer (in a second book, so the moved books' head is not advanced); `self_host_adopt_same_head` compares the moved books' head and event count with the source clone's. `isolation_label_is_enforced_under_the_unit` runs everywhere, including the unit-file read that keeps the recipe from drifting. Accounting reason for the shape: nothing here changes an entry, a report or a figure. It changes who can reach them: the move is lossless and verifiable at the head, and the boundary is the operating system's, not a promise in our text. No journal code, kernel function, Kani harness or Lean step is touched, so `just prove` is not required. No go-ahead needed: no new journal feature or event; the live books are unaffected.
9.8 KiB
The books on a machine of your own
Plan 8.2 P9a, section 3.8. This is the recipe for the enforced deployment:
the books live on a machine you control, in a folder only a dedicated
account can read, and agents reach them through the server with a token.
Locally the books are same_user (advisory) — hidden from the agent, not
locked against it; here they are behind an account and a machine boundary.
Nothing here needs the owner's books: every step works on a copy.
The pieces:
wanderbooks-server.service— the system unit (one account, one data root, loopback only).wanderbooks— the server-only build of the one executable (cargo build --release -p ledger-cli --no-default-features).- Caddy (or Tailscale Funnel, or cloudflared) — TLS and the door from the internet.
crates/ledger-cli/tests/two_user.rs— the two-account checks the CI job.github/workflows/two-user.ymlruns, and which you can run on one machine (step 9).
1. The account and the folder
sudo useradd --system --create-home --home-dir /var/lib/wanderbooks \
--shell /usr/sbin/nologin wanderbooks
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/wanderbooks
sudo install -m 0755 target/release/wanderbooks /usr/local/bin/wanderbooks
sudo install -D -m 0644 deploy/wanderbooks-server.service \
/etc/systemd/system/wanderbooks-server.service
The executable is the server-only build: no viewer, no window libraries. On a Linux package install (P11) this is the same binary and the package manager writes the file; until then, copy it as above.
2. Make the data root, once
sudo -u wanderbooks -H /usr/local/bin/wanderbooks setup --root /srv/wanderbooks
setup makes the folder private (0700), records it for that account and
asks for the owner passphrase when a terminal is attached. Self-host has
no /h/ pages (D-28, D-51); the passphrase guards a format upgrade only.
Set it at a terminal if the machine has none:
ssh -t you@server 'sudo -u wanderbooks -H /usr/local/bin/wanderbooks passphrase'
3. Bring the books over
The transport is a backup clone or a copied data folder (D-31: there is no
fetch, and no separate archive verifier). On the machine the books are
on now:
wanderbooks close # nothing writes while it is copied
wanderbooks books list # note the id, e.g. acme
cp -a "$XDG_DATA_HOME/wanderbooks/books/acme" /media/usb/acme
The copy must hold the journal, the stored files, the vault and its key;
cp -a of the book's folder holds all four. If you would rather move a
verified backup, make one (wanderbooks books back-up acme) and clone the
repository it wrote; both are the same books.
Bring that folder to the server, start the service, and adopt it:
sudo systemctl daemon-reload
sudo systemctl enable --now wanderbooks-server
sudo -u wanderbooks -H /usr/local/bin/wanderbooks books adopt /media/usb/acme \
--root /srv/wanderbooks --id acme
The preview names the source's head (Head: event N (short hash)); with
--yes (or a y at the prompt) the move is confirmed against exactly
that head, so a source that changed after the preview is refused. The
original is left as it was; nothing is deleted.
The head check. Ask the agent on the server for books.verify (or
books.start): both report the head. Compare it with books.verify on
the machine the books came from, taken before the move. They are the same
event count, sequence number and hash: the move loses nothing. (The copy
is written in this version's journal format; a source written by an older
version gains one format event at adoption, and then the counts differ by
exactly that event, which the preview says: "The copy is in this version's
journal format".)
4. Keep the machine's hardening
The unit says --isolation enforced, so books.start reports
service_user (enforced): the server is told, it never guesses. The unit
also sets ProtectSystem=strict, NoNewPrivileges=yes, ProtectHome=yes
and IPAddressDeny=any with IPAddressAllow=localhost, and no seccomp or
Landlock lines (D-32, D-51).
The loopback-only rule is deliberate: the server reaches nothing out. A book whose backup target is a remote git repository needs that host allowed, or its backups fail:
# /etc/systemd/system/wanderbooks-server.service.d/backup.conf
[Service]
IPAddressAllow=203.0.113.10
A backup to a folder on this machine needs nothing. The tunnel and Caddy run as their own services and reach 127.0.0.1:7411, which the rule allows.
5. A token for each client
The token is a static bearer (wb_ and 32 random bytes); the server keeps
only its SHA-256. Mint one per client, named for it:
sudo -u wanderbooks -H /usr/local/bin/wanderbooks token create \
--root /srv/wanderbooks --book acme --name laptop
sudo -u wanderbooks -H /usr/local/bin/wanderbooks token print \
--root /srv/wanderbooks --book acme --name laptop
token create keeps the text in the server account's OS keyring and
token print shows it once; copy it to that client and nowhere else.
Revoke it with token revoke --book acme --name laptop when a client is
lost.
A headless machine has no Secret Service by default, and token create
refuses to mint a token it cannot keep. Run the two commands inside a
session that has one, for example:
sudo -u wanderbooks -H dbus-run-session -- sh -c '
gnome-keyring-daemon --unlock --components=secrets >/dev/null
/usr/local/bin/wanderbooks token create --root /srv/wanderbooks --book acme --name laptop
/usr/local/bin/wanderbooks token print --root /srv/wanderbooks --book acme --name laptop
'
The keyring lives only for that session, which is enough: the text is copied to the laptop, and the server checks the token's SHA-256 from the books, not from the keyring. (Making a token without keeping it anywhere is not a command; if your machine has a persistent Secret Service, use it.)
6. TLS and the door
The server listens on 127.0.0.1:7411 for the tunnel, and on its own Unix sockets for anything running as the same account. Put TLS in front of the loopback port. Two ways that need no open inbound port:
Tailscale Funnel (a tailnet name, TLS included):
sudo tailscale funnel --bg 7411
The books are then at https://<machine>.<tailnet>.ts.net/b/acme/mcp.
Caddy (a domain you own, automatic certificates):
books.example.com {
reverse_proxy 127.0.0.1:7411
}
with cloudflared tunnel --url http://127.0.0.1:7411 instead of Caddy if
you use a Cloudflare tunnel. Either way the path is passed through
unchanged: the MCP endpoint is https://<host>/b/acme/mcp.
Nothing else listens. Do not forward 7411 on the router, and do not put the Unix sockets behind the proxy: they check the caller's account (3.9) and a proxy would hide it.
7. Point the agent at it
Claude Code (a client that sends headers):
claude mcp add --transport http wanderbooks-acme https://books.example.com/b/acme/mcp \
--header "Authorization: Bearer <the token from step 5>"
Codex (~/.codex/config.toml; the token stays in the environment, not
the file):
[mcp_servers.wanderbooks-acme]
url = "https://books.example.com/b/acme/mcp"
bearer_token_env_var = "WANDERBOOKS_ACME_TOKEN"
export WANDERBOOKS_ACME_TOKEN='<the token from step 5>'
The plan's local rule is that no client configuration holds a token (the
bridge reads the keyring). For a remote machine there is no command yet
that keeps a remote token in the client's keyring, so this recipe puts it
in Claude's configuration (mode 0600) or in Codex's environment. That
gap is recorded in plan/served.md 8.2 P9a's As-built block.
8. First start
Start a session and ask for books.start. It answers
isolation: "service_user (enforced)", and every tool works over the TLS
door with the token. Logs:
journalctl -u wanderbooks-server -f
The server runs no jobs in the background: serve holds the books open
until systemd stops it. On a stop it finishes the call in progress, makes
a last backup and closes (3.9), so TimeoutStopSec=600 is generous rather
than needed.
Backups still run on their own, from the books' settings, as on the desktop (3.7, D-30, D-31); this machine runs git itself.
9. Prove the boundary (what CI runs)
.github/workflows/two-user.yml runs the same job on every change:
- makes two accounts,
wanderbooks(server) and the runner (agent); - makes books with this version in a desktop root, copies them, and
adopts the copy into
/srv/wanderbooks; - serves them as
wanderbookswith--isolation enforced --http 127.0.0.1:17411, and mints a token; - runs
crates/ledger-cli/tests/two_user.rsas the runner withWB_TWO_USER=1and the deployment's settings. The test proves the runner cannot open/srv/wanderbooksor its journal, then reads, writes, uploads, files, exports and verifies over the HTTP door (in a second book,tunnel, so the moved books' head is never advanced), and compares the moved books' head with the source's.
agent_uid_cannot_read_tenant and self_host_adopt_same_head skip
themselves when WB_TWO_USER is unset, so an ordinary cargo test on a
laptop stays green; isolation_label_is_enforced_under_the_unit needs no
second account and always runs.
On one machine without a second account, run the client side in a user namespace (a different user ID than the server's):
WB_TWO_USER=1 WB_TENANT_ROOT=... WB_SERVER_URL=http://127.0.0.1:17411 \
WB_BOOK=acme WB_TOKEN=... WB_WRITE_BOOK=tunnel WB_WRITE_TOKEN=... \
WB_SOURCE_DIR=... \
unshare --user --map-auto --setuid 0 --setgid 0 -- target/debug/deps/two_user-<hash> --nocapture
The two books and their tokens are made by the job (or by hand as in
steps 2 to 5): acme is the moved books, and tunnel is empty, so the
write checks never move acme's head. WB_SOURCE_DIR is the journal the
books were moved from, readable by the agent account.