Compare commits

..

No commits in common. "7c999d2abad37a7b98358464aeea2b26dd2e03cd" and "200674778bde0cdbdf5550db5b45ca64e2f17a40" have entirely different histories.

5 changed files with 8 additions and 246 deletions

View file

@ -2,10 +2,6 @@
## [Unreleased] ## [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 ## [v1.12.0] — 2026-05-13
### Nouvelles fonctionnalités ### Nouvelles fonctionnalités

View file

@ -384,7 +384,6 @@ 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,
@ -393,14 +392,6 @@ 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 { type bitwardenResolvedItem struct {
item bitwardenListItem item bitwardenListItem
payload map[string]any payload map[string]any

View file

@ -9,10 +9,7 @@ import (
"strings" "strings"
) )
const ( const bitwardenSessionFileName = "bw-session"
bitwardenSessionFileName = "bw-session"
bitwardenSharedSessionName = "mcp-framework"
)
var bitwardenUserConfigDir = os.UserConfigDir var bitwardenUserConfigDir = os.UserConfigDir
@ -111,14 +108,10 @@ func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) {
session, err := LoadBitwardenSession(options) session, err := LoadBitwardenSession(options)
if err != nil { 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, nil
} }
return false, err
} }
if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { if err := os.Setenv(bitwardenSessionEnvName, session); err != nil {
@ -163,20 +156,7 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil { if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil {
return "", fmt.Errorf("login to bitwarden CLI: %w", err) return "", fmt.Errorf("login to bitwarden CLI: %w", err)
} }
case "unlocked": case "locked", "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: default:
return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status) return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status)
} }
@ -195,25 +175,13 @@ func LoginBitwarden(options BitwardenLoginOptions) (string, error) {
return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err) return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err)
} }
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, session); err != nil { if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil {
return "", fmt.Errorf("persist bitwarden session: %w", err) return "", fmt.Errorf("persist bitwarden session: %w", err)
} }
return session, nil 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) { func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) {
serviceName := strings.TrimSpace(options.ServiceName) serviceName := strings.TrimSpace(options.ServiceName)
if 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]) t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1])
} }
persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}) persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"})
if err != nil { if err != nil {
t.Fatalf("LoadBitwardenSession (shared) returned error: %v", err) t.Fatalf("LoadBitwardenSession returned error: %v", err)
} }
if persisted != "persisted-session" { if persisted != "persisted-session" {
t.Fatalf("shared persisted session = %q, want persisted-session", persisted) t.Fatalf("persisted session = %q, want persisted-session", persisted)
} }
} }
@ -207,112 +207,6 @@ 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( func withBitwardenInteractiveRunner(
t *testing.T, t *testing.T,
runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error), runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error),

View file

@ -66,9 +66,6 @@ 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)
@ -575,9 +572,6 @@ 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" {
@ -734,84 +728,6 @@ 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: 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) { func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
withBitwardenSession(t) withBitwardenSession(t)
fakeCLI := newFakeBitwardenCLI("bw") fakeCLI := newFakeBitwardenCLI("bw")
@ -852,9 +768,6 @@ 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(