diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 533952f..13fdd57 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -12,6 +12,7 @@ import ( const ( CommandSetup = "setup" + CommandLogin = "login" CommandMCP = "mcp" CommandConfig = "config" CommandUpdate = "update" @@ -38,6 +39,7 @@ type Handler func(context.Context, Invocation) error type Hooks struct { Setup Handler + Login Handler MCP Handler Config Handler ConfigShow Handler @@ -83,6 +85,13 @@ var commands = []commandDef{ return h.Setup }, }, + { + Name: CommandLogin, + Description: "Authentifier et deverrouiller Bitwarden pour persister BW_SESSION.", + Handler: func(h Hooks) Handler { + return h.Login + }, + }, { Name: CommandMCP, Description: "Executer la logique MCP principale du binaire.", diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go index 5e1f899..ca2c34a 100644 --- a/bootstrap/bootstrap_test.go +++ b/bootstrap/bootstrap_test.go @@ -41,6 +41,37 @@ func TestRunRoutesSetupHook(t *testing.T) { } } +func TestRunRoutesLoginHook(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + var got Invocation + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Version: "v1.2.3", + Args: []string{"login", "--force"}, + Stdout: &stdout, + Stderr: &stderr, + Hooks: Hooks{ + Login: func(_ context.Context, inv Invocation) error { + got = inv + return nil + }, + }, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + if got.Command != CommandLogin { + t.Fatalf("invocation command = %q, want %q", got.Command, CommandLogin) + } + wantArgs := []string{"--force"} + if !slices.Equal(got.Args, wantArgs) { + t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs) + } +} + func TestRunReturnsUnknownCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -215,6 +246,29 @@ func TestRunPrintsCommandHelp(t *testing.T) { } } +func TestRunPrintsLoginCommandHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := Run(context.Background(), Options{ + BinaryName: "my-mcp", + Args: []string{"help", "login"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("Run error = %v", err) + } + + text := stdout.String() + if !strings.Contains(text, "my-mcp login [args]") { + t.Fatalf("command help output = %q", text) + } + if !strings.Contains(strings.ToLower(text), "bitwarden") { + t.Fatalf("command help output missing bitwarden description: %q", text) + } +} + func TestRunPrintsConfigHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/docs/bootstrap-cli.md b/docs/bootstrap-cli.md index 74695ff..403de43 100644 --- a/docs/bootstrap-cli.md +++ b/docs/bootstrap-cli.md @@ -18,6 +18,9 @@ func main() { Setup: func(ctx context.Context, inv bootstrap.Invocation) error { return runSetup(ctx, inv.Args) }, + Login: func(ctx context.Context, inv bootstrap.Invocation) error { + return runLogin(ctx, inv.Args) + }, MCP: func(ctx context.Context, inv bootstrap.Invocation) error { return runMCP(ctx, inv.Args) }, @@ -41,6 +44,7 @@ func main() { Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`. Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`). +La commande `login` est optionnelle et peut être branchée pour gérer un unlock Bitwarden interactif. La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`). Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale. Le flag global `--debug` est supporté et active le debug des appels Bitwarden diff --git a/docs/packages.md b/docs/packages.md index 1e197e2..beec9cb 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -1,6 +1,6 @@ # Packages -- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. +- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `login`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites. - `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`. - `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`. - `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding. diff --git a/docs/secrets.md b/docs/secrets.md index c1f5ee2..07d527e 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -91,6 +91,34 @@ if err := secretstore.EnsureBitwardenReady(secretstore.Options{ } ``` +Pour lancer un flux interactif `bw login` / `bw unlock --raw`, récupérer `BW_SESSION` +et le persister localement (fichier `0600` sous le répertoire de config utilisateur) : + +```go +session, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ + ServiceName: "email-mcp", + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, +}) +if err != nil { + return err +} +fmt.Println("session chargée:", len(session) > 0) +``` + +Pour réinjecter automatiquement une session persistée dans l'environnement courant : + +```go +loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ + ServiceName: "email-mcp", +}) +if err != nil { + return err +} +fmt.Println("session restaurée depuis disque:", loaded) +``` + Pour stocker un secret structuré en JSON : ```go @@ -174,7 +202,7 @@ Les commandes `bw` exécutées seront affichées (avec redaction des payloads se bw status ``` -2. Déverrouiller le vault et exporter `BW_SESSION` : +2. Déverrouiller le vault et exporter `BW_SESSION` (ou utiliser `LoginBitwarden`) : - Bash/Zsh : diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 2d54d99..9723a58 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -1659,6 +1659,7 @@ func (r Runtime) Run(ctx context.Context, args []string) error { Args: args, Hooks: bootstrap.Hooks{ Setup: r.runSetup, + Login: r.runLogin, MCP: r.runMCP, ConfigShow: r.runConfigShow, ConfigTest: r.runConfigTest, @@ -1751,6 +1752,42 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error { return err } +func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error { + if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI { + return fmt.Errorf( + "commande login disponible uniquement avec secret_store.backend_policy=%q", + secretstore.BackendBitwardenCLI, + ) + } + + stdin := inv.Stdin + if stdin == nil { + stdin = os.Stdin + } + + stdout := inv.Stdout + if stdout == nil { + stdout = os.Stdout + } + + stderr := inv.Stderr + if stderr == nil { + stderr = os.Stderr + } + + if _, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{ + ServiceName: r.BinaryName, + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + }); err != nil { + return err + } + + _, err := fmt.Fprintf(stdout, "Session Bitwarden persistée pour %q.\n", r.BinaryName) + return err +} + func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error { stdout := inv.Stdout if stdout == nil { @@ -1878,9 +1915,18 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error } func (r Runtime) openSecretStore() (secretstore.Store, error) { + policy := r.activeBackendPolicy() + if policy == secretstore.BackendBitwardenCLI { + if _, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{ + ServiceName: r.BinaryName, + }); err != nil { + return nil, err + } + } + return secretstore.Open(secretstore.Options{ ServiceName: r.BinaryName, - BackendPolicy: r.activeBackendPolicy(), + BackendPolicy: policy, LookupEnv: func(name string) (string, bool) { if name == r.SecretName { return os.LookupEnv(r.TokenEnv) diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 2e351eb..d67353f 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -67,6 +67,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "config.NewStore[Profile]", "secretstore.Open(secretstore.Options", + "secretstore.EnsureBitwardenSessionEnv", + "secretstore.LoginBitwarden", "update.Run", "manifest.LoadDefaultOrEmbedded", "bootstrap.Run", @@ -76,6 +78,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "ManifestSource", "ManifestCheck: r.manifestDoctorCheck()", "SecretBackendPolicy: r.activeBackendPolicy()", + "Login: r.runLogin,", + "func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error {", "secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)", "func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {", "cli.WriteSetupSecretVerified", diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index 439b146..25765ca 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -33,8 +33,15 @@ const ( ) type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) +type bitwardenInteractiveRunner func( + command string, + stdin io.Reader, + stdout, stderr io.Writer, + args ...string, +) ([]byte, error) var runBitwardenCLI bitwardenRunner = executeBitwardenCLI +var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive var bitwardenLoaderActive atomic.Bool var bitwardenDebugOutput io.Writer = os.Stderr @@ -66,6 +73,17 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string debug: debugEnabled, } + if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ + ServiceName: serviceName, + }); err != nil { + return nil, fmt.Errorf( + "secret backend policy %q cannot load persisted bitwarden session for service %q: %w", + policy, + serviceName, + errors.Join(ErrBackendUnavailable, err), + ) + } + if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { if errors.Is(err, exec.ErrNotFound) { return nil, fmt.Errorf( @@ -109,27 +127,12 @@ func EnsureBitwardenReady(options Options) error { debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) unlockCommand := bitwardenUnlockRemediation(command, options.Shell) - lookupEnv := options.LookupEnv - if lookupEnv == nil { - lookupEnv = os.LookupEnv - } - - output, err := runBitwardenCommand(command, debugEnabled, nil, "status") + status, err := readBitwardenStatus(command, debugEnabled) if err != nil { - return fmt.Errorf("check bitwarden CLI status: %w", err) + return err } - trimmed := strings.TrimSpace(string(output)) - if trimmed == "" { - return fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable) - } - - var status bitwardenStatusOutput - if err := json.Unmarshal([]byte(trimmed), &status); err != nil { - return fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err)) - } - - switch strings.ToLower(strings.TrimSpace(status.Status)) { + switch status { case "unauthenticated": return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn) case "locked": @@ -139,7 +142,15 @@ func EnsureBitwardenReady(options Options) error { unlockCommand, ) case "unlocked": + lookupEnv := options.LookupEnv + if lookupEnv == nil { + lookupEnv = os.LookupEnv + } + session, ok := lookupEnv(bitwardenSessionEnvName) + if !ok || strings.TrimSpace(session) == "" { + session, ok = os.LookupEnv(bitwardenSessionEnvName) + } if !ok || strings.TrimSpace(session) == "" { return fmt.Errorf( "%w: environment variable %q is missing; run `%s` then retry", @@ -153,11 +164,30 @@ func EnsureBitwardenReady(options Options) error { return fmt.Errorf( "%w: unsupported bitwarden status %q", ErrBWUnavailable, - strings.TrimSpace(status.Status), + status, ) } } +func readBitwardenStatus(command string, debug bool) (string, error) { + output, err := runBitwardenCommand(command, debug, nil, "status") + if err != nil { + return "", fmt.Errorf("check bitwarden CLI status: %w", err) + } + + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return "", fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable) + } + + var status bitwardenStatusOutput + if err := json.Unmarshal([]byte(trimmed), &status); err != nil { + return "", fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err)) + } + + return strings.ToLower(strings.TrimSpace(status.Status)), nil +} + func bitwardenUnlockRemediation(command, shellHint string) string { unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command)) @@ -536,6 +566,19 @@ func runBitwardenCommand(command string, debug bool, stdin []byte, args ...strin return runBitwardenCLI(command, stdin, args...) } +func runBitwardenInteractiveCommand( + command string, + debug bool, + stdin io.Reader, + stdout, stderr io.Writer, + args ...string, +) ([]byte, error) { + if debug { + logBitwardenCommand(command, args...) + } + return runBitwardenInteractiveCLI(command, stdin, stdout, stderr, args...) +} + func isBitwardenDebugEnabled(explicit bool) bool { if explicit { return true @@ -615,6 +658,41 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, return stdout.Bytes(), nil } +func executeBitwardenCLIInteractive( + command string, + stdin io.Reader, + stdout, stderr io.Writer, + args ...string, +) ([]byte, error) { + stopLoader := startBitwardenLoader() + defer stopLoader() + + cmd := exec.Command(command, args...) + if stdin != nil { + cmd.Stdin = stdin + } + + var stdoutBuffer bytes.Buffer + if stdout == nil { + cmd.Stdout = &stdoutBuffer + } else { + cmd.Stdout = io.MultiWriter(stdout, &stdoutBuffer) + } + + var stderrBuffer bytes.Buffer + if stderr == nil { + cmd.Stderr = &stderrBuffer + } else { + cmd.Stderr = io.MultiWriter(stderr, &stderrBuffer) + } + + if err := cmd.Run(); err != nil { + return nil, normalizeBitwardenExecutionError(err, stderrBuffer.String(), stdoutBuffer.String()) + } + + return stdoutBuffer.Bytes(), nil +} + func startBitwardenLoader() func() { if !shouldShowBitwardenLoader() { return func() {} diff --git a/secretstore/bitwarden_session.go b/secretstore/bitwarden_session.go new file mode 100644 index 0000000..ff4d814 --- /dev/null +++ b/secretstore/bitwarden_session.go @@ -0,0 +1,197 @@ +package secretstore + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const bitwardenSessionFileName = "bw-session" + +var bitwardenUserConfigDir = os.UserConfigDir + +type BitwardenSessionOptions struct { + ServiceName string +} + +type BitwardenLoginOptions struct { + ServiceName string + BitwardenCommand string + BitwardenDebug bool + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +func SaveBitwardenSession(options BitwardenSessionOptions, session string) (string, error) { + trimmedSession := strings.TrimSpace(session) + if trimmedSession == "" { + return "", errors.New("bitwarden session must not be empty") + } + + path, err := resolveBitwardenSessionPath(options) + if err != nil { + return "", err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create bitwarden session dir %q: %w", dir, err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return "", fmt.Errorf("set bitwarden session dir permissions %q: %w", dir, err) + } + + tmpFile, err := os.CreateTemp(dir, "bw-session-*.tmp") + if err != nil { + return "", fmt.Errorf("create temp bitwarden session in %q: %w", dir, err) + } + + tmpPath := tmpFile.Name() + cleanup := true + defer func() { + _ = tmpFile.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() + + if err := tmpFile.Chmod(0o600); err != nil { + return "", fmt.Errorf("set bitwarden session temp file permissions %q: %w", tmpPath, err) + } + if _, err := tmpFile.WriteString(trimmedSession + "\n"); err != nil { + return "", fmt.Errorf("write bitwarden session temp file %q: %w", tmpPath, err) + } + if err := tmpFile.Close(); err != nil { + return "", fmt.Errorf("close bitwarden session temp file %q: %w", tmpPath, err) + } + if err := os.Rename(tmpPath, path); err != nil { + return "", fmt.Errorf("replace bitwarden session file %q: %w", path, err) + } + if err := os.Chmod(path, 0o600); err != nil { + return "", fmt.Errorf("set bitwarden session file permissions %q: %w", path, err) + } + + cleanup = false + return path, nil +} + +func LoadBitwardenSession(options BitwardenSessionOptions) (string, error) { + path, err := resolveBitwardenSessionPath(options) + if err != nil { + return "", err + } + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", ErrNotFound + } + return "", fmt.Errorf("read bitwarden session file %q: %w", path, err) + } + + session := strings.TrimSpace(string(data)) + if session == "" { + return "", ErrNotFound + } + + return session, nil +} + +func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) { + if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" { + return false, nil + } + + session, err := LoadBitwardenSession(options) + if err != nil { + if errors.Is(err, ErrNotFound) { + return false, nil + } + return false, err + } + + if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { + return false, fmt.Errorf("set %s from persisted bitwarden session: %w", bitwardenSessionEnvName, err) + } + + return true, nil +} + +func LoginBitwarden(options BitwardenLoginOptions) (string, error) { + serviceName := strings.TrimSpace(options.ServiceName) + if serviceName == "" { + return "", errors.New("service name must not be empty") + } + + command := strings.TrimSpace(options.BitwardenCommand) + if command == "" { + command = defaultBitwardenCommand + } + debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) + + stdin := options.Stdin + if stdin == nil { + stdin = os.Stdin + } + stdout := options.Stdout + if stdout == nil { + stdout = os.Stdout + } + stderr := options.Stderr + if stderr == nil { + stderr = os.Stderr + } + + status, err := readBitwardenStatus(command, debugEnabled) + if err != nil { + return "", err + } + + switch status { + case "unauthenticated": + if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil { + return "", fmt.Errorf("login to bitwarden CLI: %w", err) + } + case "locked", "unlocked": + default: + return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status) + } + + unlockOutput, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, nil, stderr, "unlock", "--raw") + if err != nil { + return "", fmt.Errorf("unlock bitwarden vault: %w", err) + } + + session := strings.TrimSpace(string(unlockOutput)) + if session == "" { + return "", errors.New("bitwarden CLI returned an empty session after unlock") + } + + if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { + return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err) + } + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil { + return "", fmt.Errorf("persist bitwarden session: %w", err) + } + + return session, nil +} + +func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) { + serviceName := strings.TrimSpace(options.ServiceName) + if serviceName == "" { + return "", errors.New("service name must not be empty") + } + + userConfigDir, err := bitwardenUserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve user config dir for bitwarden session: %w", err) + } + + return filepath.Join(userConfigDir, serviceName, bitwardenSessionFileName), nil +} diff --git a/secretstore/bitwarden_session_test.go b/secretstore/bitwarden_session_test.go new file mode 100644 index 0000000..3b8e3e4 --- /dev/null +++ b/secretstore/bitwarden_session_test.go @@ -0,0 +1,231 @@ +package secretstore + +import ( + "errors" + "fmt" + "io" + "os" + "slices" + "testing" +) + +func TestLoginBitwardenRunsInteractiveFlowAndPersistsSession(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":"unauthenticated"}`), nil + } + return nil, fmt.Errorf("unexpected args: %v", args) + }) + + var calls [][]string + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + calls = append(calls, slices.Clone(args)) + switch { + case len(args) == 1 && args[0] == "login": + return nil, nil + case len(args) == 2 && args[0] == "unlock" && args[1] == "--raw": + return []byte("persisted-session\n"), nil + default: + return nil, fmt.Errorf("unexpected interactive args: %v", args) + } + }) + + session, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "email-mcp", + BitwardenCommand: "bw", + }) + if err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + if session != "persisted-session" { + t.Fatalf("session = %q, want persisted-session", session) + } + if got := os.Getenv("BW_SESSION"); got != "persisted-session" { + t.Fatalf("BW_SESSION = %q, want persisted-session", got) + } + + if len(calls) != 2 { + t.Fatalf("interactive call count = %d, want 2", len(calls)) + } + if !slices.Equal(calls[0], []string{"login"}) { + t.Fatalf("interactive call #1 args = %v, want [login]", calls[0]) + } + if !slices.Equal(calls[1], []string{"unlock", "--raw"}) { + t.Fatalf("interactive call #2 args = %v, want [unlock --raw]", calls[1]) + } + + persisted, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("LoadBitwardenSession returned error: %v", err) + } + if persisted != "persisted-session" { + t.Fatalf("persisted session = %q, want persisted-session", persisted) + } +} + +func TestLoginBitwardenSkipsInteractiveLoginWhenAlreadyAuthenticated(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) + }) + + var calls [][]string + withBitwardenInteractiveRunner(t, func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error) { + calls = append(calls, slices.Clone(args)) + if len(args) == 2 && args[0] == "unlock" && args[1] == "--raw" { + return []byte("session-locked\n"), nil + } + return nil, fmt.Errorf("unexpected interactive args: %v", args) + }) + + session, err := LoginBitwarden(BitwardenLoginOptions{ + ServiceName: "email-mcp", + BitwardenCommand: "bw", + }) + if err != nil { + t.Fatalf("LoginBitwarden returned error: %v", err) + } + if session != "session-locked" { + t.Fatalf("session = %q, want session-locked", session) + } + + if len(calls) != 1 { + t.Fatalf("interactive call count = %d, want 1", len(calls)) + } + if !slices.Equal(calls[0], []string{"unlock", "--raw"}) { + t.Fatalf("interactive call args = %v, want [unlock --raw]", calls[0]) + } +} + +func TestBitwardenSessionEnvLoadsFromPersistedSession(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + path, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session") + if err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat persisted session file: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("session file mode = %o, want 600", got) + } + + loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err) + } + if !loaded { + t.Fatal("expected session to be loaded from persisted file") + } + if got := os.Getenv("BW_SESSION"); got != "persisted-session" { + t.Fatalf("BW_SESSION = %q, want persisted-session", got) + } +} + +func TestBitwardenSessionEnvDoesNotOverrideExistingValue(t *testing.T) { + t.Setenv("BW_SESSION", "from-env") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-session"); err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + loaded, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if err != nil { + t.Fatalf("EnsureBitwardenSessionEnv returned error: %v", err) + } + if loaded { + t.Fatal("expected existing BW_SESSION to be kept") + } + if got := os.Getenv("BW_SESSION"); got != "from-env" { + t.Fatalf("BW_SESSION = %q, want from-env", got) + } +} + +func TestOpenBitwardenCLILoadsPersistedSessionWhenEnvironmentIsMissing(t *testing.T) { + t.Setenv("BW_SESSION", "") + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}, "persisted-open-session"); err != nil { + t.Fatalf("SaveBitwardenSession returned error: %v", err) + } + + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + if _, ok := store.(*bitwardenStore); !ok { + t.Fatalf("store type = %T, want *bitwardenStore", store) + } + if got := os.Getenv("BW_SESSION"); got != "persisted-open-session" { + t.Fatalf("BW_SESSION = %q, want persisted-open-session", got) + } +} + +func TestLoadBitwardenSessionReturnsNotFoundWhenFileMissing(t *testing.T) { + configDir := t.TempDir() + withBitwardenUserConfigDir(t, func() (string, error) { + return configDir, nil + }) + + _, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: "email-mcp"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("error = %v, want ErrNotFound", err) + } +} + +func withBitwardenInteractiveRunner( + t *testing.T, + runner func(command string, stdin io.Reader, stdout, stderr io.Writer, args ...string) ([]byte, error), +) { + t.Helper() + + previous := runBitwardenInteractiveCLI + runBitwardenInteractiveCLI = runner + t.Cleanup(func() { + runBitwardenInteractiveCLI = previous + }) +} + +func withBitwardenUserConfigDir(t *testing.T, resolver func() (string, error)) { + t.Helper() + + previous := bitwardenUserConfigDir + bitwardenUserConfigDir = resolver + t.Cleanup(func() { + bitwardenUserConfigDir = previous + }) +}