Quand un MCP appelle login/unlock, le token est écrit dans le fichier de session mais les autres MCPs conservent leur token obsolète dans l'environnement du processus. Désormais, bitwardenStore.ensureReady() appelle refreshSessionEnv() qui relit le fichier avant chaque vérification, ce qui permet à tous les MCPs de rester opérationnels après une rotation de session. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
936 lines
22 KiB
Go
936 lines
22 KiB
Go
package secretstore
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultBitwardenCommand = "bw"
|
|
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
|
|
bitwardenSessionEnvName = "BW_SESSION"
|
|
bitwardenSecretFieldName = "mcp-secret"
|
|
bitwardenServiceFieldName = "mcp-service"
|
|
bitwardenSecretNameFieldName = "mcp-secret-name"
|
|
bitwardenLoaderMessage = "Waiting BitWarden..."
|
|
bitwardenLoaderInterval = 90 * time.Millisecond
|
|
bitwardenLoaderColorBase = "\033[38;5;39m"
|
|
bitwardenLoaderColorWave = "\033[38;5;81m"
|
|
bitwardenLoaderColorFocus = "\033[38;5;117m"
|
|
bitwardenLoaderResetColor = "\033[0m"
|
|
bitwardenLoaderClearLine = "\r\033[2K"
|
|
)
|
|
|
|
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 startBitwardenLoaderFunc = startBitwardenLoader
|
|
var bitwardenLoaderActive atomic.Bool
|
|
var bitwardenDebugOutput io.Writer = os.Stderr
|
|
|
|
type bitwardenStore struct {
|
|
command string
|
|
serviceName string
|
|
debug bool
|
|
lookupEnv func(string) (string, bool)
|
|
shell string
|
|
cache *bitwardenCache
|
|
}
|
|
|
|
type bitwardenListItem struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type bitwardenStatusOutput struct {
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
func newBitwardenStore(options Options, policy BackendPolicy, serviceName string) (Store, error) {
|
|
command := strings.TrimSpace(options.BitwardenCommand)
|
|
if command == "" {
|
|
command = defaultBitwardenCommand
|
|
}
|
|
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
|
|
|
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),
|
|
)
|
|
}
|
|
|
|
session, _ := os.LookupEnv(bitwardenSessionEnvName)
|
|
cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv()
|
|
store := &bitwardenStore{
|
|
command: command,
|
|
serviceName: serviceName,
|
|
debug: debugEnabled,
|
|
lookupEnv: options.LookupEnv,
|
|
shell: options.Shell,
|
|
cache: newBitwardenCache(bitwardenCacheOptions{
|
|
ServiceName: serviceName,
|
|
Session: session,
|
|
TTL: defaultBitwardenCacheTTL,
|
|
CacheDir: resolveBitwardenCacheDir(serviceName),
|
|
Enabled: cacheEnabled,
|
|
}),
|
|
}
|
|
|
|
return store, nil
|
|
}
|
|
|
|
func verifyBitwardenCLIReady(options Options) error {
|
|
command := strings.TrimSpace(options.BitwardenCommand)
|
|
if command == "" {
|
|
command = defaultBitwardenCommand
|
|
}
|
|
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
|
|
|
if _, err := runBitwardenCommand(command, debugEnabled, nil, "--version"); err != nil {
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
return fmt.Errorf(
|
|
"requires bitwarden CLI command %q in PATH: %w",
|
|
command,
|
|
errors.Join(ErrBackendUnavailable, err),
|
|
)
|
|
}
|
|
|
|
return fmt.Errorf(
|
|
"cannot verify bitwarden CLI command %q: %w",
|
|
command,
|
|
errors.Join(ErrBackendUnavailable, err),
|
|
)
|
|
}
|
|
|
|
if err := EnsureBitwardenReady(options); err != nil {
|
|
return fmt.Errorf(
|
|
"cannot use bitwarden CLI command %q right now: %w",
|
|
command,
|
|
errors.Join(ErrBackendUnavailable, err),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func EnsureBitwardenReady(options Options) error {
|
|
command := strings.TrimSpace(options.BitwardenCommand)
|
|
if command == "" {
|
|
command = defaultBitwardenCommand
|
|
}
|
|
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
|
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
|
|
|
|
status, err := readBitwardenStatus(command, debugEnabled)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch status {
|
|
case "unauthenticated":
|
|
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
|
|
case "locked":
|
|
return fmt.Errorf(
|
|
"%w: run `%s` then retry",
|
|
ErrBWLocked,
|
|
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",
|
|
ErrBWLocked,
|
|
bitwardenSessionEnvName,
|
|
unlockCommand,
|
|
)
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf(
|
|
"%w: unsupported bitwarden status %q",
|
|
ErrBWUnavailable,
|
|
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))
|
|
|
|
switch detectShellFlavor(shellHint) {
|
|
case "fish":
|
|
return fmt.Sprintf("set -x %s (%s)", bitwardenSessionEnvName, unlockCommand)
|
|
case "powershell":
|
|
return fmt.Sprintf("$env:%s = (%s)", bitwardenSessionEnvName, unlockCommand)
|
|
case "cmd":
|
|
return fmt.Sprintf(
|
|
"for /f \"usebackq delims=\" %%i in (`%s`) do set %s=%%i",
|
|
unlockCommand,
|
|
bitwardenSessionEnvName,
|
|
)
|
|
default:
|
|
return fmt.Sprintf("export %s=\"$(%s)\"", bitwardenSessionEnvName, unlockCommand)
|
|
}
|
|
}
|
|
|
|
func detectShellFlavor(shellHint string) string {
|
|
raw := strings.TrimSpace(shellHint)
|
|
if raw == "" {
|
|
raw = strings.TrimSpace(os.Getenv("SHELL"))
|
|
}
|
|
if raw == "" {
|
|
raw = strings.TrimSpace(os.Getenv("COMSPEC"))
|
|
}
|
|
if raw == "" && runtime.GOOS == "windows" {
|
|
return "powershell"
|
|
}
|
|
|
|
lower := strings.ToLower(strings.TrimSpace(raw))
|
|
base := strings.ToLower(filepath.Base(lower))
|
|
|
|
switch {
|
|
case strings.Contains(lower, "powershell"),
|
|
strings.Contains(lower, "pwsh"),
|
|
base == "powershell",
|
|
base == "powershell.exe",
|
|
base == "pwsh",
|
|
base == "pwsh.exe":
|
|
return "powershell"
|
|
case strings.Contains(lower, "fish"), base == "fish":
|
|
return "fish"
|
|
case strings.Contains(lower, "cmd.exe"), base == "cmd", base == "cmd.exe":
|
|
return "cmd"
|
|
default:
|
|
return "posix"
|
|
}
|
|
}
|
|
|
|
func (s *bitwardenStore) SetSecret(name, label, secret string) error {
|
|
secretName := s.scopedName(name)
|
|
if err := s.ensureReady(); err != nil {
|
|
return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err)
|
|
}
|
|
|
|
item, payload, err := s.findItem(secretName, name)
|
|
switch {
|
|
case errors.Is(err, ErrNotFound):
|
|
template, err := s.itemTemplate()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
setBitwardenSecretPayload(template, s.serviceName, name, secretName, label, secret)
|
|
encoded, err := s.encodePayload(template)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.execute(
|
|
fmt.Sprintf("create bitwarden item for secret %q", name),
|
|
nil,
|
|
"create",
|
|
"item",
|
|
encoded,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if s.cache != nil {
|
|
s.cache.invalidate(name, secretName)
|
|
s.cache.store(name, secretName, secret)
|
|
}
|
|
return nil
|
|
case err != nil:
|
|
return err
|
|
}
|
|
|
|
setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret)
|
|
encoded, err := s.encodePayload(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.execute(
|
|
fmt.Sprintf("update bitwarden item for secret %q", name),
|
|
nil,
|
|
"edit",
|
|
"item",
|
|
item.ID,
|
|
encoded,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.cache != nil {
|
|
s.cache.invalidate(name, secretName)
|
|
s.cache.store(name, secretName, secret)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *bitwardenStore) GetSecret(name string) (string, error) {
|
|
secretName := s.scopedName(name)
|
|
if s.cache != nil {
|
|
if secret, ok := s.cache.load(name, secretName); ok {
|
|
return secret, nil
|
|
}
|
|
}
|
|
|
|
if err := s.ensureReady(); err != nil {
|
|
return "", fmt.Errorf("prepare bitwarden CLI for reading secret %q: %w", name, err)
|
|
}
|
|
|
|
_, payload, err := s.findItem(secretName, name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
secret, ok := readBitwardenSecret(payload)
|
|
if !ok {
|
|
return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName)
|
|
}
|
|
|
|
if s.cache != nil {
|
|
s.cache.store(name, secretName, secret)
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
func (s *bitwardenStore) DeleteSecret(name string) error {
|
|
secretName := s.scopedName(name)
|
|
if err := s.ensureReady(); err != nil {
|
|
return fmt.Errorf("prepare bitwarden CLI for deleting secret %q: %w", name, err)
|
|
}
|
|
|
|
item, _, err := s.findItem(secretName, name)
|
|
if errors.Is(err, ErrNotFound) {
|
|
if s.cache != nil {
|
|
s.cache.invalidate(name, secretName)
|
|
}
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.execute(
|
|
fmt.Sprintf("delete bitwarden item for secret %q", name),
|
|
nil,
|
|
"delete",
|
|
"item",
|
|
item.ID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.cache != nil {
|
|
s.cache.invalidate(name, secretName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *bitwardenStore) scopedName(name string) string {
|
|
return fmt.Sprintf("%s/%s", s.serviceName, name)
|
|
}
|
|
|
|
func (s *bitwardenStore) ensureReady() error {
|
|
s.refreshSessionEnv()
|
|
return verifyBitwardenCLIReady(Options{
|
|
BitwardenCommand: s.command,
|
|
BitwardenDebug: s.debug,
|
|
LookupEnv: s.lookupEnv,
|
|
Shell: s.shell,
|
|
})
|
|
}
|
|
|
|
func (s *bitwardenStore) refreshSessionEnv() {
|
|
session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: s.serviceName})
|
|
if err != nil || strings.TrimSpace(session) == "" {
|
|
return
|
|
}
|
|
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
|
|
}
|
|
|
|
type bitwardenResolvedItem struct {
|
|
item bitwardenListItem
|
|
payload map[string]any
|
|
}
|
|
|
|
func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, map[string]any, error) {
|
|
output, err := s.execute(
|
|
fmt.Sprintf("list bitwarden items for secret %q", secretName),
|
|
nil,
|
|
"list",
|
|
"items",
|
|
"--search",
|
|
secretName,
|
|
)
|
|
if err != nil {
|
|
return bitwardenListItem{}, nil, err
|
|
}
|
|
if strings.TrimSpace(string(output)) == "" {
|
|
return bitwardenListItem{}, nil, ErrNotFound
|
|
}
|
|
|
|
var items []bitwardenListItem
|
|
if err := json.Unmarshal(output, &items); err != nil {
|
|
return bitwardenListItem{}, nil, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err)
|
|
}
|
|
|
|
matches := make([]bitwardenListItem, 0, len(items))
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item.Name) != secretName {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(item.ID) == "" {
|
|
continue
|
|
}
|
|
matches = append(matches, item)
|
|
}
|
|
|
|
if len(matches) == 0 {
|
|
return bitwardenListItem{}, nil, ErrNotFound
|
|
}
|
|
|
|
markedMatches := make([]bitwardenResolvedItem, 0, len(matches))
|
|
legacyMatches := make([]bitwardenResolvedItem, 0, len(matches))
|
|
for _, item := range matches {
|
|
payload, err := s.itemByID(item.ID)
|
|
if err != nil {
|
|
return bitwardenListItem{}, nil, err
|
|
}
|
|
|
|
resolved := bitwardenResolvedItem{
|
|
item: item,
|
|
payload: payload,
|
|
}
|
|
|
|
if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) {
|
|
markedMatches = append(markedMatches, resolved)
|
|
continue
|
|
}
|
|
legacyMatches = append(legacyMatches, resolved)
|
|
}
|
|
|
|
switch len(markedMatches) {
|
|
case 0:
|
|
switch len(legacyMatches) {
|
|
case 0:
|
|
return bitwardenListItem{}, nil, ErrNotFound
|
|
case 1:
|
|
return legacyMatches[0].item, legacyMatches[0].payload, nil
|
|
default:
|
|
return bitwardenListItem{}, nil, fmt.Errorf(
|
|
"multiple legacy bitwarden items match secret %q for service %q",
|
|
secretName,
|
|
s.serviceName,
|
|
)
|
|
}
|
|
case 1:
|
|
return markedMatches[0].item, markedMatches[0].payload, nil
|
|
default:
|
|
return bitwardenListItem{}, nil, fmt.Errorf(
|
|
"multiple bitwarden items share marker for secret %q and service %q",
|
|
secretName,
|
|
s.serviceName,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (s *bitwardenStore) itemTemplate() (map[string]any, error) {
|
|
output, err := s.execute("load bitwarden item template", nil, "get", "template", "item")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output, &payload); err != nil {
|
|
return nil, fmt.Errorf("decode bitwarden item template: %w", err)
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
func (s *bitwardenStore) itemByID(id string) (map[string]any, error) {
|
|
trimmedID := strings.TrimSpace(id)
|
|
if trimmedID == "" {
|
|
return nil, errors.New("bitwarden item id must not be empty")
|
|
}
|
|
|
|
output, err := s.execute(
|
|
fmt.Sprintf("read bitwarden item %q", trimmedID),
|
|
nil,
|
|
"get",
|
|
"item",
|
|
trimmedID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(output, &payload); err != nil {
|
|
return nil, fmt.Errorf("decode bitwarden item %q: %w", trimmedID, err)
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) {
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", fmt.Errorf("encode bitwarden payload: %w", err)
|
|
}
|
|
|
|
output, err := s.execute("encode bitwarden payload", raw, "encode")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
encoded := strings.TrimSpace(string(output))
|
|
if encoded == "" {
|
|
return "", errors.New("bitwarden CLI returned an empty encoded payload")
|
|
}
|
|
|
|
return encoded, nil
|
|
}
|
|
|
|
func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) {
|
|
output, err := runBitwardenCommand(s.command, s.debug, stdin, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", operation, err)
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
func setBitwardenSecretPayload(payload map[string]any, serviceName, rawSecretName, secretName, label, secret string) {
|
|
payload["type"] = 2
|
|
payload["name"] = secretName
|
|
payload["notes"] = strings.TrimSpace(label)
|
|
payload["secureNote"] = map[string]any{"type": 0}
|
|
payload["fields"] = []map[string]any{
|
|
{
|
|
"name": bitwardenSecretFieldName,
|
|
"value": secret,
|
|
"type": 1,
|
|
},
|
|
{
|
|
"name": bitwardenServiceFieldName,
|
|
"value": strings.TrimSpace(serviceName),
|
|
"type": 0,
|
|
},
|
|
{
|
|
"name": bitwardenSecretNameFieldName,
|
|
"value": strings.TrimSpace(rawSecretName),
|
|
"type": 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
func readBitwardenSecret(payload map[string]any) (string, bool) {
|
|
return readBitwardenField(payload, bitwardenSecretFieldName)
|
|
}
|
|
|
|
func readBitwardenField(payload map[string]any, fieldName string) (string, bool) {
|
|
rawFields, ok := payload["fields"]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
fields, ok := rawFields.([]any)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
for _, rawField := range fields {
|
|
field, ok := rawField.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
name, _ := field["name"].(string)
|
|
if strings.TrimSpace(name) != strings.TrimSpace(fieldName) {
|
|
continue
|
|
}
|
|
|
|
value, ok := field["value"].(string)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
return value, true
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName string) bool {
|
|
markedService, ok := readBitwardenField(payload, bitwardenServiceFieldName)
|
|
if !ok {
|
|
return false
|
|
}
|
|
markedSecretName, ok := readBitwardenField(payload, bitwardenSecretNameFieldName)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
return strings.TrimSpace(markedService) == strings.TrimSpace(serviceName) &&
|
|
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 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
|
|
}
|
|
|
|
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 := startBitwardenLoaderFunc()
|
|
defer stopLoader()
|
|
|
|
cmd := exec.Command(command, args...)
|
|
if stdin != nil {
|
|
cmd.Stdin = bytes.NewReader(stdin)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, normalizeBitwardenExecutionError(err, stderr.String(), stdout.String())
|
|
}
|
|
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
func executeBitwardenCLIInteractive(
|
|
command string,
|
|
stdin io.Reader,
|
|
stdout, stderr io.Writer,
|
|
args ...string,
|
|
) ([]byte, error) {
|
|
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() {}
|
|
}
|
|
if !bitwardenLoaderActive.CompareAndSwap(false, true) {
|
|
return func() {}
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
stopped := make(chan struct{})
|
|
|
|
go func() {
|
|
defer close(stopped)
|
|
|
|
ticker := time.NewTicker(bitwardenLoaderInterval)
|
|
defer ticker.Stop()
|
|
|
|
phase := 0
|
|
for {
|
|
_, _ = fmt.Fprint(os.Stdout, bitwardenLoaderFrame(phase))
|
|
phase++
|
|
|
|
select {
|
|
case <-done:
|
|
_, _ = fmt.Fprint(os.Stdout, bitwardenLoaderClearLine)
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}()
|
|
|
|
var stopOnce sync.Once
|
|
return func() {
|
|
stopOnce.Do(func() {
|
|
close(done)
|
|
<-stopped
|
|
bitwardenLoaderActive.Store(false)
|
|
})
|
|
}
|
|
}
|
|
|
|
func shouldShowBitwardenLoader() bool {
|
|
term := strings.TrimSpace(os.Getenv("TERM"))
|
|
if term == "" || strings.EqualFold(term, "dumb") {
|
|
return false
|
|
}
|
|
|
|
info, err := os.Stdout.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return (info.Mode() & os.ModeCharDevice) != 0
|
|
}
|
|
|
|
func bitwardenLoaderFrame(phase int) string {
|
|
chars := []rune(bitwardenLoaderMessage)
|
|
if len(chars) == 0 {
|
|
return bitwardenLoaderClearLine
|
|
}
|
|
|
|
waveIndex := phase % len(chars)
|
|
if waveIndex < 0 {
|
|
waveIndex += len(chars)
|
|
}
|
|
|
|
var frame strings.Builder
|
|
frame.Grow(len(chars)*14 + len(bitwardenLoaderClearLine) + len(bitwardenLoaderResetColor))
|
|
frame.WriteString(bitwardenLoaderClearLine)
|
|
|
|
for idx, char := range chars {
|
|
frame.WriteString(bitwardenLoaderColorForIndex(idx, waveIndex, len(chars)))
|
|
frame.WriteRune(char)
|
|
}
|
|
|
|
frame.WriteString(bitwardenLoaderResetColor)
|
|
return frame.String()
|
|
}
|
|
|
|
func bitwardenLoaderColorForIndex(idx, waveIndex, length int) string {
|
|
if length <= 1 {
|
|
return bitwardenLoaderColorFocus
|
|
}
|
|
|
|
distance := idx - waveIndex
|
|
if distance < 0 {
|
|
distance = -distance
|
|
}
|
|
if wrapped := length - distance; wrapped < distance {
|
|
distance = wrapped
|
|
}
|
|
|
|
switch distance {
|
|
case 0:
|
|
return bitwardenLoaderColorFocus
|
|
case 1:
|
|
return bitwardenLoaderColorWave
|
|
default:
|
|
return bitwardenLoaderColorBase
|
|
}
|
|
}
|
|
|
|
func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error {
|
|
detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText)
|
|
classification := classifyBitwardenError(detail)
|
|
if classification == nil {
|
|
classification = ErrBWUnavailable
|
|
}
|
|
|
|
wrapped := errors.Join(classification, err)
|
|
if strings.TrimSpace(detail) == "" {
|
|
return wrapped
|
|
}
|
|
|
|
return fmt.Errorf("%w: %s", wrapped, detail)
|
|
}
|
|
|
|
func sanitizeBitwardenErrorDetail(stderrText, stdoutText string) string {
|
|
raw := strings.TrimSpace(stderrText)
|
|
if raw == "" {
|
|
raw = strings.TrimSpace(stdoutText)
|
|
}
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
|
|
lines := strings.Split(raw, "\n")
|
|
cleaned := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
|
|
lower := strings.ToLower(trimmed)
|
|
if strings.HasPrefix(trimmed, "at ") ||
|
|
strings.HasPrefix(lower, "node:internal") ||
|
|
strings.HasPrefix(lower, "internal/") ||
|
|
strings.HasPrefix(lower, "npm ") {
|
|
continue
|
|
}
|
|
|
|
cleaned = append(cleaned, trimmed)
|
|
}
|
|
|
|
if len(cleaned) == 0 {
|
|
return ""
|
|
}
|
|
|
|
if len(cleaned) == 1 {
|
|
return cleaned[0]
|
|
}
|
|
|
|
return cleaned[0] + " | " + cleaned[1]
|
|
}
|
|
|
|
func classifyBitwardenError(detail string) error {
|
|
lower := strings.ToLower(strings.TrimSpace(detail))
|
|
|
|
switch {
|
|
case strings.Contains(lower, "not logged in"), strings.Contains(lower, "unauthenticated"):
|
|
return ErrBWNotLoggedIn
|
|
case strings.Contains(lower, "vault is locked"), strings.Contains(lower, "is locked"):
|
|
return ErrBWLocked
|
|
case strings.Contains(lower, "failed to fetch"),
|
|
strings.Contains(lower, "econnrefused"),
|
|
strings.Contains(lower, "etimedout"),
|
|
strings.Contains(lower, "unable to connect"),
|
|
strings.Contains(lower, "network"):
|
|
return ErrBWUnavailable
|
|
default:
|
|
return nil
|
|
}
|
|
}
|