feat: add bitwarden login flow with persisted BW_SESSION
This commit is contained in:
parent
2920f5980a
commit
6e80d3418e
10 changed files with 673 additions and 22 deletions
|
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CommandSetup = "setup"
|
CommandSetup = "setup"
|
||||||
|
CommandLogin = "login"
|
||||||
CommandMCP = "mcp"
|
CommandMCP = "mcp"
|
||||||
CommandConfig = "config"
|
CommandConfig = "config"
|
||||||
CommandUpdate = "update"
|
CommandUpdate = "update"
|
||||||
|
|
@ -38,6 +39,7 @@ type Handler func(context.Context, Invocation) error
|
||||||
|
|
||||||
type Hooks struct {
|
type Hooks struct {
|
||||||
Setup Handler
|
Setup Handler
|
||||||
|
Login Handler
|
||||||
MCP Handler
|
MCP Handler
|
||||||
Config Handler
|
Config Handler
|
||||||
ConfigShow Handler
|
ConfigShow Handler
|
||||||
|
|
@ -83,6 +85,13 @@ var commands = []commandDef{
|
||||||
return h.Setup
|
return h.Setup
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: CommandLogin,
|
||||||
|
Description: "Authentifier et deverrouiller Bitwarden pour persister BW_SESSION.",
|
||||||
|
Handler: func(h Hooks) Handler {
|
||||||
|
return h.Login
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: CommandMCP,
|
Name: CommandMCP,
|
||||||
Description: "Executer la logique MCP principale du binaire.",
|
Description: "Executer la logique MCP principale du binaire.",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestRunReturnsUnknownCommand(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr 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) {
|
func TestRunPrintsConfigHelp(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ func main() {
|
||||||
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
|
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runSetup(ctx, inv.Args)
|
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 {
|
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runMCP(ctx, inv.Args)
|
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`.
|
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`).
|
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`).
|
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.
|
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
|
Le flag global `--debug` est supporté et active le debug des appels Bitwarden
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Packages
|
# 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`.
|
- `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()`.
|
- `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.
|
- `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.
|
||||||
|
|
|
||||||
|
|
@ -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 :
|
Pour stocker un secret structuré en JSON :
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
@ -174,7 +202,7 @@ Les commandes `bw` exécutées seront affichées (avec redaction des payloads se
|
||||||
bw status
|
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 :
|
- Bash/Zsh :
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1659,6 +1659,7 @@ func (r Runtime) Run(ctx context.Context, args []string) error {
|
||||||
Args: args,
|
Args: args,
|
||||||
Hooks: bootstrap.Hooks{
|
Hooks: bootstrap.Hooks{
|
||||||
Setup: r.runSetup,
|
Setup: r.runSetup,
|
||||||
|
Login: r.runLogin,
|
||||||
MCP: r.runMCP,
|
MCP: r.runMCP,
|
||||||
ConfigShow: r.runConfigShow,
|
ConfigShow: r.runConfigShow,
|
||||||
ConfigTest: r.runConfigTest,
|
ConfigTest: r.runConfigTest,
|
||||||
|
|
@ -1751,6 +1752,42 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error {
|
||||||
return err
|
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 {
|
func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error {
|
||||||
stdout := inv.Stdout
|
stdout := inv.Stdout
|
||||||
if stdout == nil {
|
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) {
|
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{
|
return secretstore.Open(secretstore.Options{
|
||||||
ServiceName: r.BinaryName,
|
ServiceName: r.BinaryName,
|
||||||
BackendPolicy: r.activeBackendPolicy(),
|
BackendPolicy: policy,
|
||||||
LookupEnv: func(name string) (string, bool) {
|
LookupEnv: func(name string) (string, bool) {
|
||||||
if name == r.SecretName {
|
if name == r.SecretName {
|
||||||
return os.LookupEnv(r.TokenEnv)
|
return os.LookupEnv(r.TokenEnv)
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
||||||
for _, snippet := range []string{
|
for _, snippet := range []string{
|
||||||
"config.NewStore[Profile]",
|
"config.NewStore[Profile]",
|
||||||
"secretstore.Open(secretstore.Options",
|
"secretstore.Open(secretstore.Options",
|
||||||
|
"secretstore.EnsureBitwardenSessionEnv",
|
||||||
|
"secretstore.LoginBitwarden",
|
||||||
"update.Run",
|
"update.Run",
|
||||||
"manifest.LoadDefaultOrEmbedded",
|
"manifest.LoadDefaultOrEmbedded",
|
||||||
"bootstrap.Run",
|
"bootstrap.Run",
|
||||||
|
|
@ -76,6 +78,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
||||||
"ManifestSource",
|
"ManifestSource",
|
||||||
"ManifestCheck: r.manifestDoctorCheck()",
|
"ManifestCheck: r.manifestDoctorCheck()",
|
||||||
"SecretBackendPolicy: r.activeBackendPolicy()",
|
"SecretBackendPolicy: r.activeBackendPolicy()",
|
||||||
|
"Login: r.runLogin,",
|
||||||
|
"func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error {",
|
||||||
"secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)",
|
"secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)",
|
||||||
"func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {",
|
"func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {",
|
||||||
"cli.WriteSetupSecretVerified",
|
"cli.WriteSetupSecretVerified",
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,15 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error)
|
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 runBitwardenCLI bitwardenRunner = executeBitwardenCLI
|
||||||
|
var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive
|
||||||
var bitwardenLoaderActive atomic.Bool
|
var bitwardenLoaderActive atomic.Bool
|
||||||
var bitwardenDebugOutput io.Writer = os.Stderr
|
var bitwardenDebugOutput io.Writer = os.Stderr
|
||||||
|
|
||||||
|
|
@ -66,6 +73,17 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
|
||||||
debug: debugEnabled,
|
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 _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil {
|
||||||
if errors.Is(err, exec.ErrNotFound) {
|
if errors.Is(err, exec.ErrNotFound) {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
|
|
@ -109,27 +127,12 @@ func EnsureBitwardenReady(options Options) error {
|
||||||
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
||||||
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
|
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
|
||||||
|
|
||||||
lookupEnv := options.LookupEnv
|
status, err := readBitwardenStatus(command, debugEnabled)
|
||||||
if lookupEnv == nil {
|
|
||||||
lookupEnv = os.LookupEnv
|
|
||||||
}
|
|
||||||
|
|
||||||
output, err := runBitwardenCommand(command, debugEnabled, nil, "status")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check bitwarden CLI status: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
trimmed := strings.TrimSpace(string(output))
|
switch status {
|
||||||
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)) {
|
|
||||||
case "unauthenticated":
|
case "unauthenticated":
|
||||||
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
|
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
|
||||||
case "locked":
|
case "locked":
|
||||||
|
|
@ -139,7 +142,15 @@ func EnsureBitwardenReady(options Options) error {
|
||||||
unlockCommand,
|
unlockCommand,
|
||||||
)
|
)
|
||||||
case "unlocked":
|
case "unlocked":
|
||||||
|
lookupEnv := options.LookupEnv
|
||||||
|
if lookupEnv == nil {
|
||||||
|
lookupEnv = os.LookupEnv
|
||||||
|
}
|
||||||
|
|
||||||
session, ok := lookupEnv(bitwardenSessionEnvName)
|
session, ok := lookupEnv(bitwardenSessionEnvName)
|
||||||
|
if !ok || strings.TrimSpace(session) == "" {
|
||||||
|
session, ok = os.LookupEnv(bitwardenSessionEnvName)
|
||||||
|
}
|
||||||
if !ok || strings.TrimSpace(session) == "" {
|
if !ok || strings.TrimSpace(session) == "" {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: environment variable %q is missing; run `%s` then retry",
|
"%w: environment variable %q is missing; run `%s` then retry",
|
||||||
|
|
@ -153,11 +164,30 @@ func EnsureBitwardenReady(options Options) error {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: unsupported bitwarden status %q",
|
"%w: unsupported bitwarden status %q",
|
||||||
ErrBWUnavailable,
|
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 {
|
func bitwardenUnlockRemediation(command, shellHint string) string {
|
||||||
unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command))
|
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...)
|
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 {
|
func isBitwardenDebugEnabled(explicit bool) bool {
|
||||||
if explicit {
|
if explicit {
|
||||||
return true
|
return true
|
||||||
|
|
@ -615,6 +658,41 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte,
|
||||||
return stdout.Bytes(), nil
|
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() {
|
func startBitwardenLoader() func() {
|
||||||
if !shouldShowBitwardenLoader() {
|
if !shouldShowBitwardenLoader() {
|
||||||
return func() {}
|
return func() {}
|
||||||
|
|
|
||||||
197
secretstore/bitwarden_session.go
Normal file
197
secretstore/bitwarden_session.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
231
secretstore/bitwarden_session_test.go
Normal file
231
secretstore/bitwarden_session_test.go
Normal file
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue