Compare commits
5 commits
200674778b
...
7c999d2aba
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c999d2aba | |||
| 846894c1a7 | |||
| 7c016e8c5e | |||
| 90dbed4d37 | |||
| 078aa17285 |
5 changed files with 246 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue