diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba4672..039b5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - **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. +### 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 - **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`. diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 9c580b8..c192db4 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -384,6 +384,7 @@ func (s *bitwardenStore) scopedName(name string) string { } func (s *bitwardenStore) ensureReady() error { + s.refreshSessionEnv() return verifyBitwardenCLIReady(Options{ BitwardenCommand: s.command, 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 { item bitwardenListItem payload map[string]any diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index b895c2c..705a467 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -66,6 +66,9 @@ func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) } func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) { + withBitwardenUserConfigDir(t, func() (string, error) { + return t.TempDir(), nil + }) fakeCLI := newFakeBitwardenCLI("bw") withBitwardenRunner(t, fakeCLI.run) @@ -572,6 +575,9 @@ func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) { } func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) { + withBitwardenUserConfigDir(t, func() (string, error) { + return t.TempDir(), nil + }) store := &bitwardenStore{command: "bw", serviceName: "email-mcp"} withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { 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", "") } +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) { withBitwardenSession(t) fakeCLI := newFakeBitwardenCLI("bw") @@ -768,6 +852,9 @@ func withBitwardenSession(t *testing.T) { withBitwardenUserCacheDir(t, func() (string, error) { return t.TempDir(), nil }) + withBitwardenUserConfigDir(t, func() (string, error) { + return t.TempDir(), nil + }) } func withBitwardenRunner(