diff --git a/CHANGELOG.md b/CHANGELOG.md index 039b5fc..f407c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### 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). +- **Secretstore — isolation des sessions Bitwarden entre MCPs** : `bw unlock` n'est plus appelé si le vault est déjà ouvert et qu'une session valide existe (env, fichier service, ou fichier partagé). Le login écrit désormais dans un fichier partagé (`~/.config/mcp-framework/bw-session`) que les autres MCPs lisent en priorité, évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit aussi ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage. ### Changements cassants diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index c192db4..792f223 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -394,11 +394,18 @@ func (s *bitwardenStore) ensureReady() error { } func (s *bitwardenStore) refreshSessionEnv() { - session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: s.serviceName}) - if err != nil || strings.TrimSpace(session) == "" { + // Prefer the shared file: it holds the latest session from any MCP login. + if session, err := LoadBitwardenSession(BitwardenSessionOptions{ + ServiceName: bitwardenSharedSessionName, + }); err == nil && strings.TrimSpace(session) != "" { + _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) return } - _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) + if session, err := LoadBitwardenSession(BitwardenSessionOptions{ + ServiceName: s.serviceName, + }); err == nil && strings.TrimSpace(session) != "" { + _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) + } } type bitwardenResolvedItem struct { diff --git a/secretstore/bitwarden_session.go b/secretstore/bitwarden_session.go index ff4d814..3b20af4 100644 --- a/secretstore/bitwarden_session.go +++ b/secretstore/bitwarden_session.go @@ -9,7 +9,10 @@ import ( "strings" ) -const bitwardenSessionFileName = "bw-session" +const ( + bitwardenSessionFileName = "bw-session" + bitwardenSharedSessionName = "mcp-framework" +) var bitwardenUserConfigDir = os.UserConfigDir @@ -156,7 +159,20 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) { if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil { return "", fmt.Errorf("login to bitwarden CLI: %w", err) } - case "locked", "unlocked": + case "unlocked": + // Vault is already unlocked. Reuse an existing session to avoid calling + // bw unlock again, which would generate a new token and invalidate the + // tokens held by other running MCP processes. + if existing := loadAnyBitwardenSession(serviceName); existing != "" { + if err := os.Setenv(bitwardenSessionEnvName, existing); err != nil { + return "", fmt.Errorf("set %s from existing session: %w", bitwardenSessionEnvName, err) + } + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, existing); err != nil { + return "", fmt.Errorf("persist bitwarden session: %w", err) + } + return existing, nil + } + case "locked": default: return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status) } @@ -178,10 +194,31 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) { if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil { return "", fmt.Errorf("persist bitwarden session: %w", err) } + // Save to shared file so other MCP processes can reuse this session + // without needing to call bw unlock themselves. + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, session); err != nil { + return "", fmt.Errorf("persist shared bitwarden session: %w", err) + } return session, nil } +// loadAnyBitwardenSession looks for an existing valid session in: the process +// environment, the service-specific file, and the shared file written by any +// MCP login. Returns the first non-empty session found. +func loadAnyBitwardenSession(serviceName string) string { + if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" { + return strings.TrimSpace(session) + } + if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}); err == nil { + return session + } + if session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}); err == nil { + return session + } + return "" +} + func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) { serviceName := strings.TrimSpace(options.ServiceName) if serviceName == "" { diff --git a/secretstore/bitwarden_session_test.go b/secretstore/bitwarden_session_test.go index 3b8e3e4..8a8e54d 100644 --- a/secretstore/bitwarden_session_test.go +++ b/secretstore/bitwarden_session_test.go @@ -207,6 +207,112 @@ func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) { } } +func TestLoginBitwardenPersistsToSharedFileAfterUnlock(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"locked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" { + return []byte("shared-session\n"), nil + } + return nil, fmt.Errorf("unexpected interactive args: %v", args) + }) + + if _, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "graylog-mcp", + BitwardenCommand: "bw", + }); err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + + shared, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}) + if err != nil { + t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err) + } + if shared != "shared-session" { + t.Fatalf("shared session = %q, want shared-session", shared) + } +} + +func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithEnvSession(t *testing.T) { + t.Setenv("BW_SESSION", "existing-session") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unlocked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + return nil, fmt.Errorf("bw unlock must not be called when vault is already unlocked with a session: %v", args) + }) + + session, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "email-mcp", + BitwardenCommand: "bw", + }) + if err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + if session != "existing-session" { + t.Fatalf("session = %q, want existing-session", session) + } +} + +func TestLoginBitwardenSkipsUnlockWhenAlreadyUnlockedWithSharedSession(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + // graylog-mcp logged in earlier and wrote to the shared file + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "graylog-session"); err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + if len(args) == 1 && args[0] == "status" { + return []byte(`{"status":"unlocked"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + return nil, fmt.Errorf("bw unlock must not be called when shared session exists: %v", args) + }) + + session, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "email-mcp", + BitwardenCommand: "bw", + }) + if err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + if session != "graylog-session" { + t.Fatalf("session = %q, want graylog-session (from shared file)", session) + } + + // The shared session must also be persisted to the service-specific file + persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("LoadBitwardenSession returned error: %v", err) + } + if persisted != "graylog-session" { + t.Fatalf("email-mcp persisted session = %q, want graylog-session", persisted) + } +} + func withBitwardenInteractiveRunner( t *testing.T, runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error),