fix(secretstore): relire la session Bitwarden depuis le fichier avant chaque opération

Quand un MCP appelle login/unlock, le token est écrit dans le fichier de session
mais les autres MCPs conservent leur token obsolète dans l'environnement du processus.
Désormais, bitwardenStore.ensureReady() appelle refreshSessionEnv() qui relit le
fichier avant chaque vérification, ce qui permet à tous les MCPs de rester
opérationnels après une rotation de session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thibaud-lclr 2026-05-13 12:23:18 +02:00
parent 200674778b
commit 078aa17285
3 changed files with 100 additions and 0 deletions

View file

@ -10,6 +10,10 @@
- **Bootstrap — `StandardConfigTestHandler`** : handler de config test standard sans `ManifestCheck`. Accepte `ConfigCheck`, `OpenStore`, `ConnectivityCheck` et `ExtraChecks`. - **Bootstrap — `StandardConfigTestHandler`** : handler de config test standard sans `ManifestCheck`. Accepte `ConfigCheck`, `OpenStore`, `ConnectivityCheck` et `ExtraChecks`.
- **CLI — `ManifestCheck` opt-in dans `RunDoctor`** : le check de manifeste n'est inclus que si `ManifestDir` est fourni, supprimant une contrainte runtime inutile. - **CLI — `ManifestCheck` opt-in dans `RunDoctor`** : le check de manifeste n'est inclus que si `ManifestDir` est fourni, supprimant une contrainte runtime inutile.
### Corrections
- **Secretstore — rafraîchissement automatique de la session Bitwarden** : chaque MCP relit désormais le fichier de session avant toute opération Bitwarden. Cela résout le cas où débloquer le vault depuis un MCP rendait les autres inopérants (session obsolète ou absente de l'environnement).
### Changements cassants ### Changements cassants
- **Bootstrap — `DefaultLoginHandler` renommé en `BitwardenLoginHandler`** : le nom précédent suggérait à tort que le handler s'applique à tous les MCPs. Les projets sans backend Bitwarden ne définissent pas de hook Login — la commande est masquée automatiquement par `autoDisabledCommands`. - **Bootstrap — `DefaultLoginHandler` renommé en `BitwardenLoginHandler`** : le nom précédent suggérait à tort que le handler s'applique à tous les MCPs. Les projets sans backend Bitwarden ne définissent pas de hook Login — la commande est masquée automatiquement par `autoDisabledCommands`.

View file

@ -384,6 +384,7 @@ func (s *bitwardenStore) scopedName(name string) string {
} }
func (s *bitwardenStore) ensureReady() error { func (s *bitwardenStore) ensureReady() error {
s.refreshSessionEnv()
return verifyBitwardenCLIReady(Options{ return verifyBitwardenCLIReady(Options{
BitwardenCommand: s.command, BitwardenCommand: s.command,
BitwardenDebug: s.debug, BitwardenDebug: s.debug,
@ -392,6 +393,14 @@ func (s *bitwardenStore) ensureReady() error {
}) })
} }
func (s *bitwardenStore) refreshSessionEnv() {
session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: s.serviceName})
if err != nil || strings.TrimSpace(session) == "" {
return
}
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
}
type bitwardenResolvedItem struct { type bitwardenResolvedItem struct {
item bitwardenListItem item bitwardenListItem
payload map[string]any payload map[string]any

View file

@ -66,6 +66,9 @@ func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T)
} }
func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) { func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) {
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw") fakeCLI := newFakeBitwardenCLI("bw")
withBitwardenRunner(t, fakeCLI.run) withBitwardenRunner(t, fakeCLI.run)
@ -572,6 +575,9 @@ func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) {
} }
func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
store := &bitwardenStore{command: "bw", serviceName: "email-mcp"} store := &bitwardenStore{command: "bw", serviceName: "email-mcp"}
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" { if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" {
@ -728,6 +734,84 @@ func stripANSIControlSequences(value string) string {
return strings.ReplaceAll(noANSI, "\r", "") return strings.ReplaceAll(noANSI, "\r", "")
} }
func TestBitwardenStorePicksUpSessionRotatedByAnotherProcess(t *testing.T) {
t.Setenv("BW_SESSION", "old-session")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
withBitwardenUserCacheDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw")
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
ID: "item-1",
Name: "email-mcp/api-token",
Secret: "secret-v1",
MarkerService: "email-mcp",
MarkerSecretName: "api-token",
}
withBitwardenRunner(t, fakeCLI.run)
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "new-session"); err != nil {
t.Fatalf("SaveBitwardenSession returned error: %v", err)
}
if _, err := store.GetSecret("api-token"); err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if got := os.Getenv("BW_SESSION"); got != "new-session" {
t.Fatalf("BW_SESSION = %q, want new-session after session rotation", got)
}
}
func TestBitwardenStorePicksUpSessionFromFileWhenEnvIsEmpty(t *testing.T) {
t.Setenv("BW_SESSION", "")
configDir := t.TempDir()
withBitwardenUserConfigDir(t, func() (string, error) {
return configDir, nil
})
withBitwardenUserCacheDir(t, func() (string, error) {
return t.TempDir(), nil
})
fakeCLI := newFakeBitwardenCLI("bw")
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
ID: "item-1",
Name: "email-mcp/api-token",
Secret: "secret-v1",
MarkerService: "email-mcp",
MarkerSecretName: "api-token",
}
withBitwardenRunner(t, fakeCLI.run)
store, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "file-session"); err != nil {
t.Fatalf("SaveBitwardenSession returned error: %v", err)
}
if _, err := store.GetSecret("api-token"); err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if got := os.Getenv("BW_SESSION"); got != "file-session" {
t.Fatalf("BW_SESSION = %q, want file-session loaded from file after login by another process", got)
}
}
func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
withBitwardenSession(t) withBitwardenSession(t)
fakeCLI := newFakeBitwardenCLI("bw") fakeCLI := newFakeBitwardenCLI("bw")
@ -768,6 +852,9 @@ func withBitwardenSession(t *testing.T) {
withBitwardenUserCacheDir(t, func() (string, error) { withBitwardenUserCacheDir(t, func() (string, error) {
return t.TempDir(), nil return t.TempDir(), nil
}) })
withBitwardenUserConfigDir(t, func() (string, error) {
return t.TempDir(), nil
})
} }
func withBitwardenRunner( func withBitwardenRunner(