mcp-framework/secretstore/bitwarden.go

937 lines
22 KiB
Go
Raw Permalink Normal View History

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
2026-05-02 13:30:18 +00:00
lookupEnv func(string) (string, bool)
shell string
2026-05-02 13:03:35 +00:00
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),
)
}
2026-05-02 13:03:35 +00:00
session, _ := os.LookupEnv(bitwardenSessionEnvName)
cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv()
store := &bitwardenStore{
command: command,
serviceName: serviceName,
debug: debugEnabled,
2026-05-02 13:30:18 +00:00
lookupEnv: options.LookupEnv,
shell: options.Shell,
2026-05-02 13:03:35 +00:00
cache: newBitwardenCache(bitwardenCacheOptions{
ServiceName: serviceName,
Session: session,
TTL: defaultBitwardenCacheTTL,
CacheDir: resolveBitwardenCacheDir(serviceName),
Enabled: cacheEnabled,
}),
}
2026-05-02 13:30:18 +00:00
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) {
2026-05-02 13:30:18 +00:00
return fmt.Errorf(
"requires bitwarden CLI command %q in PATH: %w",
command,
2026-05-02 13:30:18 +00:00
errors.Join(ErrBackendUnavailable, err),
)
}
2026-05-02 13:30:18 +00:00
return fmt.Errorf(
"cannot verify bitwarden CLI command %q: %w",
command,
errors.Join(ErrBackendUnavailable, err),
)
}
2026-05-02 13:30:18 +00:00
if err := EnsureBitwardenReady(options); err != nil {
return fmt.Errorf(
"cannot use bitwarden CLI command %q right now: %w",
command,
errors.Join(ErrBackendUnavailable, err),
)
}
2026-05-02 13:30:18 +00:00
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)
2026-05-02 13:30:18 +00:00
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
}
2026-05-02 13:03:35 +00:00
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
}
2026-05-02 13:03:35 +00:00
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)
2026-05-02 13:03:35 +00:00
if s.cache != nil {
if secret, ok := s.cache.load(name, secretName); ok {
return secret, nil
}
}
2026-05-02 13:30:18 +00:00
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)
}
2026-05-02 13:03:35 +00:00
if s.cache != nil {
s.cache.store(name, secretName, secret)
}
return secret, nil
}
func (s *bitwardenStore) DeleteSecret(name string) error {
secretName := s.scopedName(name)
2026-05-02 13:30:18 +00:00
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) {
2026-05-02 13:03:35 +00:00
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
}
2026-05-02 13:03:35 +00:00
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)
}
2026-05-02 13:30:18 +00:00
func (s *bitwardenStore) ensureReady() error {
s.refreshSessionEnv()
2026-05-02 13:30:18 +00:00
return verifyBitwardenCLIReady(Options{
BitwardenCommand: s.command,
BitwardenDebug: s.debug,
LookupEnv: s.lookupEnv,
Shell: s.shell,
})
}
func (s *bitwardenStore) refreshSessionEnv() {
session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName})
if err != nil || strings.TrimSpace(session) == "" {
return
}
_ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session))
}
type bitwardenResolvedItem struct {
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
}
}