From 12a45fa94ec7a52758fd262b5dcd6c19a9e2c67d Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 28 Jun 2026 10:19:54 +0000 Subject: [PATCH] vault: bw sync on every read so reads show the latest values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bw unlock` only decrypts the LOCAL cache, so a persisted (already logged-in) session served stale data — a password changed in the web vault wouldn't appear until the next fresh login. Add a best-effort `bw sync` in openSession (the chokepoint every read shares: get, get --all, list, code, status), so reads reflect current server-side values. Best-effort by design: a transient sync failure warns on stderr and falls back to the cached vault rather than failing the read (an AFK agent shouldn't break on a network blip). status keeps its own explicit sync so a reachability failure still surfaces in its report. CLI v0.10.1. Tests assert the sync runs after unlock and before the read, and that a read still succeeds when sync fails. Co-Authored-By: Claude Opus 4.8 --- cli/README.md | 10 +++++++ cli/VERSION | 2 +- cli/cmd_vault.go | 16 +++++++++-- cli/cmd_vault_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/cli/README.md b/cli/README.md index 92cfc91a..66a92555 100644 --- a/cli/README.md +++ b/cli/README.md @@ -234,6 +234,16 @@ only seed-derived path stays the specially-audited `vault code`. Like `get --json`, the dump is all secret values, so it **refuses a terminal** — pipe it (`homelab vault get --all | jq`). +### v0.10.1 — reads `bw sync` first (always fresh) + +Every vault read (`get`, `get --all`, `list`, `code`, `status`) now runs `bw +sync` when opening its session, so it reflects the latest server-side values. +`bw unlock` only decrypts the *local* cache, so without this a persisted +(already-logged-in) session served stale data — a password changed in the web +vault wouldn't show up until the next login. The sync is **best-effort**: a +transient failure warns on stderr and falls back to the cached vault rather than +failing the read. + ## Build / install Built from source to `/usr/local/bin/homelab` during devvm provisioning diff --git a/cli/VERSION b/cli/VERSION index bf057dbf..c91125db 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -v0.10.0 +v0.10.1 diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index 1c737662..eb430b63 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -274,6 +274,7 @@ func bwUnlockArgs() []string { return []string{"unlock", "--passw func bwGetArgs(field, name string) []string { return []string{"get", field, name} } func bwItemArgs(name string) []string { return []string{"get", "item", name} } func bwStatusArgs() []string { return []string{"status"} } +func bwSyncArgs() []string { return []string{"sync"} } // bwNeedsLogin parses `bw status` JSON and reports whether a `bw login` is // required. Unparseable/empty output → true (safer to attempt login). @@ -440,7 +441,16 @@ func openSession(run cmdRunner, user, uid string) (session, error) { if err != nil { return session{}, err } - return session{env: bwSecretEnv(appdata, creds, sess)}, nil + sessEnv := bwSecretEnv(appdata, creds, sess) + // Pull the latest server-side state so reads reflect current values. `bw + // unlock` only decrypts the LOCAL cache, so a persisted (already-logged-in) + // session would otherwise serve stale data until the next login. Best-effort: + // a transient sync failure must not break a read — fall back to the cached + // vault and warn (status reports reachability separately). + if _, err := run("bw", bwSyncArgs(), sessEnv); err != nil { + fmt.Fprintln(os.Stderr, "homelab vault: warning: bw sync failed; using cached vault (values may be stale): "+err.Error()) + } + return session{env: sessEnv}, nil } type getOpts struct { @@ -702,7 +712,9 @@ func statusSummary(run cmdRunner, user, uid string) string { if err != nil { return "vault: configured, but unlock/login FAILED (creds stale? run `homelab vault setup`): " + err.Error() } - if _, err := run("bw", []string{"sync"}, s.env); err != nil { + // openSession already did a best-effort sync; status re-runs it explicitly so + // a reachability failure surfaces in this report rather than only on stderr. + if _, err := run("bw", bwSyncArgs(), s.env); err != nil { return "vault: configured + unlocked, but sync/reachability failed: " + err.Error() } return "vault: configured, unlocked, reachable ✓" diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index e0e68b27..890827bf 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -819,3 +819,66 @@ func TestVaultHelpMentionsAll(t *testing.T) { t.Error("vault help must document --all") } } + +// --- bw sync on read (freshness) ------------------------------------------ + +func TestBwSyncArgs(t *testing.T) { + if got := bwSyncArgs(); !reflect.DeepEqual(got, []string{"sync"}) { + t.Fatalf("bwSyncArgs = %v", got) + } +} + +// Every read opens a session that first `bw sync`s, so reads reflect the latest +// server-side values: `bw unlock` is local-only, so without a sync a persisted +// (already-logged-in) session serves a stale local cache. +func TestOpenSessionSyncsBeforeRead(t *testing.T) { + f := &fakeRunner{out: map[string]string{ + "vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "pw", + "vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x", + "vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "cs", + "bw status": `{"status":"locked"}`, + "bw unlock": "SESS", + "bw sync": "Syncing complete.", + "bw get password github": "p@ss", + }} + uid := fmt.Sprintf("%d", os.Getuid()) + if _, err := getValue(f.run, "emo", uid, getOpts{name: "github", field: "password"}); err != nil { + t.Fatalf("getValue: %v", err) + } + idx := func(prefix string) int { + for i, c := range f.calls { + if strings.HasPrefix(strings.Join(c, " "), prefix) { + return i + } + } + return -1 + } + syncAt, unlockAt, getAt := idx("bw sync"), idx("bw unlock"), idx("bw get password github") + if syncAt < 0 { + t.Fatal("expected a `bw sync` before the read") + } + if !(unlockAt < syncAt && syncAt < getAt) { + t.Fatalf("order wrong: unlock=%d sync=%d get=%d (want unlock