Compare commits

...

5 commits

Author SHA1 Message Date
7c999d2aba docs(changelog): déplacer le fix Bitwarden dans Unreleased, restaurer v1.12.0
All checks were successful
CI / test (push) Successful in 12s
Release / release (push) Successful in 7s
2026-05-13 14:00:05 +02:00
846894c1a7 docs(changelog): mettre à jour la description du fix Bitwarden 2026-05-13 13:57:58 +02:00
7c016e8c5e refactor(secretstore): supprimer le fichier de session service-spécifique
Le fichier ~/.config/<service>/bw-session est redondant depuis l'introduction
du fichier partagé mcp-framework. On n'écrit plus que dans le partagé et on lit
uniquement depuis lui dans refreshSessionEnv et loadAnyBitwardenSession.
EnsureBitwardenSessionEnv tente le fichier service-spécifique en premier
(rétrocompat) puis bascule sur le partagé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:57:58 +02:00
90dbed4d37 fix(secretstore): éviter l'invalidation croisée des sessions Bitwarden entre MCPs
Quand deux MCPs appellaient login, le second appelait bw unlock et générait
un nouveau token, invalidant celui du premier. Deux mécanismes corrigent ça :

1. LoginBitwarden ne relance plus bw unlock si le vault est déjà unlocked
   et qu'une session existe (env, fichier service, ou fichier partagé).
2. Le login écrit le token dans ~/.config/mcp-framework/bw-session (partagé)
   en plus du fichier service-spécifique. Les autres MCPs lisent ce fichier
   en priorité via refreshSessionEnv avant chaque opération Bitwarden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:57:58 +02:00
078aa17285 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>
2026-05-13 13:57:58 +02:00
5 changed files with 246 additions and 8 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Corrections
- **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 ou fichier partagé). Le login écrit uniquement dans `~/.config/mcp-framework/bw-session` — fichier commun à tous les MCPs — évitant de générer un nouveau token qui invaliderait les sessions en cours. Chaque MCP relit ce fichier avant chaque opération Bitwarden pour récupérer dynamiquement les sessions créées après son démarrage.
## [v1.12.0] — 2026-05-13
### Nouvelles fonctionnalités

View file

@ -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: bitwardenSharedSessionName})
if err != nil || strings.TrimSpace(session) == "" {
return
}
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
}
type bitwardenResolvedItem struct {
item bitwardenListItem
payload map[string]any

View file

@ -9,7 +9,10 @@ import (
"strings"
)
const bitwardenSessionFileName = "bw-session"
const (
bitwardenSessionFileName = "bw-session"
bitwardenSharedSessionName = "mcp-framework"
)
var bitwardenUserConfigDir = os.UserConfigDir
@ -108,10 +111,14 @@ func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) {
session, err := LoadBitwardenSession(options)
if err != nil {
if errors.Is(err, ErrNotFound) {
if !errors.Is(err, ErrNotFound) {
return false, err
}
// Service-specific file not found; try the shared file written by any MCP login.
session, err = LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil {
return false, nil
}
return false, err
}
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
@ -156,7 +163,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(); 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)
}
@ -175,13 +195,25 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err)
}
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil {
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, session); err != nil {
return "", fmt.Errorf("persist bitwarden session: %w", err)
}
return session, nil
}
// loadAnyBitwardenSession looks for an existing valid session in: the process
// environment, then the shared file written by any MCP login.
func loadAnyBitwardenSession() string {
if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" {
return strings.TrimSpace(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 == "" {

View file

@ -60,12 +60,12 @@ func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(t *testing.T) {
t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1])
}
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil {
t.Fatalf("LoadBitwardenSession returned error: %v", err)
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err)
}
if persisted != "persisted-session" {
t.Fatalf("persisted session = %q, want persisted-session", persisted)
t.Fatalf("shared persisted session = %q, want persisted-session", persisted)
}
}
@ -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),

View file

@ -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: bitwardenSharedSessionName}, "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: bitwardenSharedSessionName}, "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(