Compare commits
No commits in common. "7c999d2abad37a7b98358464aeea2b26dd2e03cd" and "200674778bde0cdbdf5550db5b45ca64e2f17a40" have entirely different histories.
7c999d2aba
...
200674778b
5 changed files with 8 additions and 246 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue