feat: add --debug tracing for bitwarden calls

This commit is contained in:
thibaud-lclr 2026-04-20 11:36:07 +02:00
parent 98f07f557d
commit 98bac84ab8
10 changed files with 259 additions and 3 deletions

View file

@ -18,6 +18,8 @@ const (
CommandVersion = "version"
CommandDoctor = "doctor"
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
ConfigSubcommandShow = "show"
ConfigSubcommandTest = "test"
ConfigSubcommandDelete = "delete"
@ -118,7 +120,13 @@ func Run(ctx context.Context, opts Options) error {
return ErrBinaryNameRequired
}
command, commandArgs, showHelp := parseArgs(expandAliases(normalized.Args, normalized.Aliases))
resolvedArgs := expandAliases(normalized.Args, normalized.Aliases)
resolvedArgs, debugEnabled := extractGlobalDebugFlag(resolvedArgs)
if debugEnabled {
_ = os.Setenv(bitwardenDebugEnvName, "1")
}
command, commandArgs, showHelp := parseArgs(resolvedArgs)
if showHelp {
return printHelp(normalized, command, commandArgs)
}
@ -252,6 +260,25 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo
return command, commandArgs, false
}
func extractGlobalDebugFlag(args []string) ([]string, bool) {
args = trimArgs(args)
if len(args) == 0 {
return nil, false
}
filtered := make([]string, 0, len(args))
debugEnabled := false
for _, arg := range args {
if strings.TrimSpace(arg) == "--debug" {
debugEnabled = true
continue
}
filtered = append(filtered, arg)
}
return filtered, debugEnabled
}
func expandAliases(args []string, aliases map[string][]string) []string {
args = trimArgs(args)
if len(args) == 0 || len(aliases) == 0 {
@ -512,6 +539,13 @@ func printGlobalHelp(opts Options) error {
}
}
if _, err := fmt.Fprintln(opts.Stdout, "\nOptions globales:"); err != nil {
return err
}
if _, err := fmt.Fprintln(opts.Stdout, " --debug Active le debug des appels Bitwarden."); err != nil {
return err
}
_, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help <command>\n", opts.BinaryName)
return err
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"os"
"slices"
"strings"
"testing"
@ -482,3 +483,73 @@ func TestRunPrintsDoctorAliasInGlobalHelpWhenEnabled(t *testing.T) {
t.Fatalf("global help output missing default doctor alias details: %q", text)
}
}
func TestRunAcceptsGlobalDebugFlagAndRoutesCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var got Invocation
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "")
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"--debug", "setup", "--profile", "prod"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{
Setup: func(_ context.Context, inv Invocation) error {
got = inv
return nil
},
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if got.Command != CommandSetup {
t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup)
}
wantArgs := []string{"--profile", "prod"}
if !slices.Equal(got.Args, wantArgs) {
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
}
if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" {
t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1")
}
}
func TestRunAcceptsGlobalDebugFlagAfterCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
var got Invocation
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "")
err := Run(context.Background(), Options{
BinaryName: "my-mcp",
Args: []string{"setup", "--debug", "--profile", "prod"},
Stdout: &stdout,
Stderr: &stderr,
Hooks: Hooks{
Setup: func(_ context.Context, inv Invocation) error {
got = inv
return nil
},
},
})
if err != nil {
t.Fatalf("Run error = %v", err)
}
if got.Command != CommandSetup {
t.Fatalf("invocation command = %q, want %q", got.Command, CommandSetup)
}
wantArgs := []string{"--profile", "prod"}
if !slices.Equal(got.Args, wantArgs) {
t.Fatalf("invocation args = %v, want %v", got.Args, wantArgs)
}
if os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG") != "1" {
t.Fatalf("MCP_FRAMEWORK_BITWARDEN_DEBUG = %q, want %q", os.Getenv("MCP_FRAMEWORK_BITWARDEN_DEBUG"), "1")
}
}

View file

@ -65,6 +65,7 @@ type DoctorOptions struct {
type BitwardenDoctorOptions struct {
Command string
Debug bool
Shell string
LookupEnv func(string) (string, bool)
}
@ -238,6 +239,7 @@ func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck {
return func(context.Context) DoctorResult {
err := checkBitwardenReady(secretstore.Options{
BitwardenCommand: strings.TrimSpace(options.Command),
BitwardenDebug: options.Debug,
Shell: strings.TrimSpace(options.Shell),
LookupEnv: options.LookupEnv,
})

View file

@ -43,3 +43,5 @@ Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automati
Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`).
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
(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`).

View file

@ -164,6 +164,10 @@ fmt.Println(report.Remediation) // action recommandée
## Debug Bitwarden en 60 secondes
Tu peux activer les traces d'appels Bitwarden avec le flag CLI global `--debug`
(via `bootstrap`) ou en exportant `MCP_FRAMEWORK_BITWARDEN_DEBUG=1`.
Les commandes `bw` exécutées seront affichées (avec redaction des payloads sensibles).
1. Vérifier l'état de session :
```bash

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@ -17,6 +18,7 @@ import (
const (
defaultBitwardenCommand = "bw"
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
bitwardenSessionEnvName = "BW_SESSION"
bitwardenSecretFieldName = "mcp-secret"
bitwardenServiceFieldName = "mcp-service"
@ -34,10 +36,12 @@ type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte,
var runBitwardenCLI bitwardenRunner = executeBitwardenCLI
var bitwardenLoaderActive atomic.Bool
var bitwardenDebugOutput io.Writer = os.Stderr
type bitwardenStore struct {
command string
serviceName string
debug bool
}
type bitwardenListItem struct {
@ -54,10 +58,12 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
if command == "" {
command = defaultBitwardenCommand
}
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
store := &bitwardenStore{
command: command,
serviceName: serviceName,
debug: debugEnabled,
}
if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil {
@ -80,6 +86,7 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
if err := EnsureBitwardenReady(Options{
BitwardenCommand: command,
BitwardenDebug: debugEnabled,
LookupEnv: options.LookupEnv,
Shell: options.Shell,
}); err != nil {
@ -99,6 +106,7 @@ func EnsureBitwardenReady(options Options) error {
if command == "" {
command = defaultBitwardenCommand
}
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
lookupEnv := options.LookupEnv
@ -106,7 +114,7 @@ func EnsureBitwardenReady(options Options) error {
lookupEnv = os.LookupEnv
}
output, err := runBitwardenCLI(command, nil, "status")
output, err := runBitwardenCommand(command, debugEnabled, nil, "status")
if err != nil {
return fmt.Errorf("check bitwarden CLI status: %w", err)
}
@ -438,7 +446,7 @@ func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) {
}
func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) {
output, err := runBitwardenCLI(s.command, stdin, args...)
output, err := runBitwardenCommand(s.command, s.debug, stdin, args...)
if err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
@ -521,6 +529,71 @@ func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName
strings.TrimSpace(markedSecretName) == strings.TrimSpace(secretName)
}
func runBitwardenCommand(command string, debug bool, stdin []byte, args ...string) ([]byte, error) {
if debug {
logBitwardenCommand(command, args...)
}
return runBitwardenCLI(command, stdin, args...)
}
func isBitwardenDebugEnabled(explicit bool) bool {
if explicit {
return true
}
raw, ok := os.LookupEnv(bitwardenDebugEnvName)
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}
func logBitwardenCommand(command string, args ...string) {
writer := bitwardenDebugOutput
if writer == nil {
return
}
renderedArgs := sanitizeBitwardenDebugArgs(args)
if len(renderedArgs) == 0 {
_, _ = fmt.Fprintf(writer, "[bitwarden debug] %s\n", strings.TrimSpace(command))
return
}
_, _ = fmt.Fprintf(
writer,
"[bitwarden debug] %s %s\n",
strings.TrimSpace(command),
strings.Join(renderedArgs, " "),
)
}
func sanitizeBitwardenDebugArgs(args []string) []string {
if len(args) == 0 {
return nil
}
rendered := make([]string, len(args))
for idx, arg := range args {
rendered[idx] = strings.TrimSpace(arg)
}
if len(rendered) >= 3 && rendered[0] == "create" && rendered[1] == "item" {
rendered[2] = "<redacted>"
}
if len(rendered) >= 4 && rendered[0] == "edit" && rendered[1] == "item" {
rendered[3] = "<redacted>"
}
return rendered
}
func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) {
stopLoader := startBitwardenLoader()
defer stopLoader()

View file

@ -1,10 +1,12 @@
package secretstore
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os/exec"
"path/filepath"
"regexp"
@ -367,6 +369,59 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) {
}
}
func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) {
withBitwardenSession(t)
fakeCLI := newFakeBitwardenCLI("bw")
withBitwardenRunner(t, fakeCLI.run)
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1")
var logs bytes.Buffer
withBitwardenDebugOutput(t, &logs)
_, err := Open(Options{
ServiceName: "email-mcp",
BackendPolicy: BackendBitwardenCLI,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
text := logs.String()
if !strings.Contains(text, "bw --version") {
t.Fatalf("debug logs = %q, want command bw --version", text)
}
if !strings.Contains(text, "bw status") {
t.Fatalf("debug logs = %q, want command bw status", text)
}
}
func TestBitwardenDebugRedactsSensitivePayloadArguments(t *testing.T) {
withBitwardenSession(t)
fakeCLI := newFakeBitwardenCLI("bw")
withBitwardenRunner(t, fakeCLI.run)
var logs bytes.Buffer
withBitwardenDebugOutput(t, &logs)
store, err := Open(Options{
ServiceName: "graylog-mcp",
BackendPolicy: BackendBitwardenCLI,
BitwardenDebug: true,
})
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
t.Fatalf("SetSecret returned error: %v", err)
}
text := logs.String()
if !strings.Contains(text, "bw create item <redacted>") {
t.Fatalf("debug logs = %q, want redacted create payload", text)
}
}
func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) {
frame := bitwardenLoaderFrame(0)
if !strings.HasPrefix(frame, "\r\033[2K") {
@ -455,6 +510,16 @@ func withBitwardenRunner(
})
}
func withBitwardenDebugOutput(t *testing.T, writer io.Writer) {
t.Helper()
previous := bitwardenDebugOutput
bitwardenDebugOutput = writer
t.Cleanup(func() {
bitwardenDebugOutput = previous
})
}
type fakeBitwardenCLI struct {
command string
itemsByID map[string]fakeBitwardenItem

View file

@ -20,6 +20,7 @@ type OpenFromManifestOptions struct {
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
Shell string
ManifestLoader ManifestLoader
ExecutableResolver ExecutableResolver
@ -38,6 +39,7 @@ func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: strings.TrimSpace(options.BitwardenCommand),
BitwardenDebug: options.BitwardenDebug,
Shell: strings.TrimSpace(options.Shell),
})
}

View file

@ -14,6 +14,7 @@ type DescribeRuntimeOptions struct {
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
Shell string
ManifestLoader ManifestLoader
ExecutableResolver ExecutableResolver
@ -73,6 +74,7 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error)
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
BitwardenCommand: options.BitwardenCommand,
BitwardenDebug: options.BitwardenDebug,
Shell: options.Shell,
})
if openErr != nil {

View file

@ -37,6 +37,7 @@ type Options struct {
KWalletAppID string
KWalletFolder string
BitwardenCommand string
BitwardenDebug bool
Shell string
}