mcp-framework/secretstore/bitwarden.go
thibaud-lclr 078aa17285 fix(secretstore): relire la session Bitwarden depuis le fichier avant chaque opération
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>
2026-05-13 13:57:58 +02:00

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
}
}