feat: add runtime secretstore diagnostics and setup helpers
This commit is contained in:
parent
7072cb2038
commit
7d159bfdbd
15 changed files with 934 additions and 74 deletions
|
|
@ -51,17 +51,21 @@ type DoctorManifestValidator func(manifest.File, string) []string
|
|||
type DoctorOptions struct {
|
||||
ConfigCheck DoctorCheck
|
||||
SecretStoreCheck DoctorCheck
|
||||
SecretBackendPolicy secretstore.BackendPolicy
|
||||
RequiredSecrets []DoctorSecret
|
||||
SecretStoreFactory func() (secretstore.Store, error)
|
||||
ManifestDir string
|
||||
ManifestValidator DoctorManifestValidator
|
||||
ManifestCheck DoctorCheck
|
||||
ConnectivityCheck DoctorCheck
|
||||
BitwardenOptions BitwardenDoctorOptions
|
||||
DisableAutoBitwardenCheck bool
|
||||
ExtraChecks []DoctorCheck
|
||||
}
|
||||
|
||||
type BitwardenDoctorOptions struct {
|
||||
Command string
|
||||
Shell string
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +91,9 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
|
|||
if options.ConnectivityCheck != nil {
|
||||
checks = append(checks, options.ConnectivityCheck)
|
||||
}
|
||||
if shouldAutoIncludeBitwardenCheck(options) {
|
||||
checks = append(checks, BitwardenReadyCheck(options.BitwardenOptions))
|
||||
}
|
||||
checks = append(checks, options.ExtraChecks...)
|
||||
|
||||
results := make([]DoctorResult, 0, len(checks))
|
||||
|
|
@ -231,6 +238,7 @@ func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck {
|
|||
return func(context.Context) DoctorResult {
|
||||
err := checkBitwardenReady(secretstore.Options{
|
||||
BitwardenCommand: strings.TrimSpace(options.Command),
|
||||
Shell: strings.TrimSpace(options.Shell),
|
||||
LookupEnv: options.LookupEnv,
|
||||
})
|
||||
if err == nil {
|
||||
|
|
@ -274,6 +282,33 @@ func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck {
|
|||
}
|
||||
}
|
||||
|
||||
func shouldAutoIncludeBitwardenCheck(options DoctorOptions) bool {
|
||||
if options.DisableAutoBitwardenCheck {
|
||||
return false
|
||||
}
|
||||
|
||||
if options.SecretBackendPolicy == secretstore.BackendBitwardenCLI {
|
||||
return true
|
||||
}
|
||||
|
||||
if options.SecretStoreFactory == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
store, err := options.SecretStoreFactory()
|
||||
if err == nil {
|
||||
return secretstore.EffectiveBackendPolicy(store) == secretstore.BackendBitwardenCLI
|
||||
}
|
||||
|
||||
if errors.Is(err, secretstore.ErrBWNotLoggedIn) ||
|
||||
errors.Is(err, secretstore.ErrBWLocked) ||
|
||||
errors.Is(err, secretstore.ErrBWUnavailable) {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(err.Error())), "bitwarden")
|
||||
}
|
||||
|
||||
func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck {
|
||||
return func(context.Context) DoctorResult {
|
||||
store, err := factory()
|
||||
|
|
|
|||
|
|
@ -253,6 +253,88 @@ func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T)
|
|||
})
|
||||
}
|
||||
|
||||
func TestRunDoctorAutoInjectsBitwardenCheckForBitwardenPolicy(t *testing.T) {
|
||||
prev := checkBitwardenReady
|
||||
t.Cleanup(func() {
|
||||
checkBitwardenReady = prev
|
||||
})
|
||||
|
||||
lookupCalled := false
|
||||
checkBitwardenReady = func(options secretstore.Options) error {
|
||||
if options.Shell != "fish" {
|
||||
t.Fatalf("Shell = %q, want fish", options.Shell)
|
||||
}
|
||||
if options.BitwardenCommand != "bw" {
|
||||
t.Fatalf("BitwardenCommand = %q, want bw", options.BitwardenCommand)
|
||||
}
|
||||
if options.LookupEnv == nil {
|
||||
t.Fatal("LookupEnv should be forwarded")
|
||||
}
|
||||
_, _ = options.LookupEnv("BW_SESSION")
|
||||
return fmt.Errorf("%w: run unlock", secretstore.ErrBWLocked)
|
||||
}
|
||||
|
||||
report := RunDoctor(context.Background(), DoctorOptions{
|
||||
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
|
||||
BitwardenOptions: BitwardenDoctorOptions{
|
||||
Command: "bw",
|
||||
Shell: "fish",
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
lookupCalled = true
|
||||
return "", false
|
||||
},
|
||||
},
|
||||
ManifestCheck: func(context.Context) DoctorResult {
|
||||
return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"}
|
||||
},
|
||||
})
|
||||
|
||||
if !lookupCalled {
|
||||
t.Fatal("LookupEnv should be used by auto bitwarden check")
|
||||
}
|
||||
|
||||
var found *DoctorResult
|
||||
for i := range report.Results {
|
||||
if report.Results[i].Name == "bitwarden" {
|
||||
found = &report.Results[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("report results = %#v, want auto bitwarden check", report.Results)
|
||||
}
|
||||
if found.Status != DoctorStatusFail {
|
||||
t.Fatalf("bitwarden status = %q, want fail", found.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoctorCanDisableAutoBitwardenCheck(t *testing.T) {
|
||||
prev := checkBitwardenReady
|
||||
t.Cleanup(func() {
|
||||
checkBitwardenReady = prev
|
||||
})
|
||||
|
||||
checkBitwardenReady = func(options secretstore.Options) error {
|
||||
t.Fatal("checkBitwardenReady should not be called when auto check is disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
report := RunDoctor(context.Background(), DoctorOptions{
|
||||
DisableAutoBitwardenCheck: true,
|
||||
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
|
||||
ManifestCheck: func(context.Context) DoctorResult {
|
||||
return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"}
|
||||
},
|
||||
})
|
||||
|
||||
if len(report.Results) != 1 {
|
||||
t.Fatalf("result count = %d, want 1", len(report.Results))
|
||||
}
|
||||
if report.Results[0].Name != "manifest" {
|
||||
t.Fatalf("result name = %q, want manifest", report.Results[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredResolvedFieldsCheckReportsSources(t *testing.T) {
|
||||
check := RequiredResolvedFieldsCheck(ResolveOptions{
|
||||
Fields: []FieldSpec{
|
||||
|
|
|
|||
76
cli/setup_secret.go
Normal file
76
cli/setup_secret.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
type SetupSecretWriteOptions struct {
|
||||
Store secretstore.Store
|
||||
SecretName string
|
||||
SecretLabel string
|
||||
TokenEnv string
|
||||
Value SetupValue
|
||||
}
|
||||
|
||||
func WriteSetupSecretVerified(options SetupSecretWriteOptions) error {
|
||||
if options.Store == nil {
|
||||
return errors.New("secret store must not be nil")
|
||||
}
|
||||
|
||||
secretName := strings.TrimSpace(options.SecretName)
|
||||
if secretName == "" {
|
||||
return errors.New("secret name must not be empty")
|
||||
}
|
||||
|
||||
secretLabel := strings.TrimSpace(options.SecretLabel)
|
||||
if secretLabel == "" {
|
||||
secretLabel = secretName
|
||||
}
|
||||
|
||||
if options.Value.KeptStoredSecret {
|
||||
return verifyStoredSetupSecret(options.Store, secretName, options.TokenEnv)
|
||||
}
|
||||
if !options.Value.Set {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := secretstore.SetSecretVerified(options.Store, secretName, secretLabel, options.Value.String); err != nil {
|
||||
if errors.Is(err, secretstore.ErrReadOnly) {
|
||||
tokenEnv := strings.TrimSpace(options.TokenEnv)
|
||||
if tokenEnv != "" {
|
||||
return fmt.Errorf("secret store is read-only, export %s and retry setup: %w", tokenEnv, err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("save secret %q during setup: %w", secretName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyStoredSetupSecret(store secretstore.Store, secretName, tokenEnv string) error {
|
||||
secret, err := store.GetSecret(secretName)
|
||||
if err != nil {
|
||||
if errors.Is(err, secretstore.ErrNotFound) {
|
||||
tokenEnv = strings.TrimSpace(tokenEnv)
|
||||
if tokenEnv != "" {
|
||||
return fmt.Errorf(
|
||||
"secret %q is not readable after setup, export %s and retry: %w",
|
||||
secretName,
|
||||
tokenEnv,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("verify secret %q after setup: %w", secretName, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return fmt.Errorf("secret %q is empty after setup", secretName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
116
cli/setup_secret_test.go
Normal file
116
cli/setup_secret_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||
)
|
||||
|
||||
func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) {
|
||||
store := &setupSecretStore{secrets: map[string]string{}}
|
||||
|
||||
err := WriteSetupSecretVerified(SetupSecretWriteOptions{
|
||||
Store: store,
|
||||
SecretName: "api-token",
|
||||
SecretLabel: "API token",
|
||||
Value: SetupValue{
|
||||
Type: SetupFieldSecret,
|
||||
String: "secret-v1",
|
||||
Set: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSetupSecretVerified returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := store.secrets["api-token"]; got != "secret-v1" {
|
||||
t.Fatalf("stored secret = %q, want secret-v1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSetupSecretVerifiedReturnsContextForReadOnlyStores(t *testing.T) {
|
||||
store := &setupSecretStore{
|
||||
secrets: map[string]string{},
|
||||
setErr: secretstore.ErrReadOnly,
|
||||
}
|
||||
|
||||
err := WriteSetupSecretVerified(SetupSecretWriteOptions{
|
||||
Store: store,
|
||||
SecretName: "api-token",
|
||||
TokenEnv: "GRAYLOG_MCP_API_TOKEN",
|
||||
Value: SetupValue{
|
||||
Type: SetupFieldSecret,
|
||||
String: "secret-v1",
|
||||
Set: true,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, secretstore.ErrReadOnly) {
|
||||
t.Fatalf("error = %v, want ErrReadOnly", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") {
|
||||
t.Fatalf("error = %v, want token env remediation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSetupSecretVerifiedValidatesKeptStoredSecret(t *testing.T) {
|
||||
store := &setupSecretStore{
|
||||
secrets: map[string]string{},
|
||||
getErr: secretstore.ErrNotFound,
|
||||
}
|
||||
|
||||
err := WriteSetupSecretVerified(SetupSecretWriteOptions{
|
||||
Store: store,
|
||||
SecretName: "api-token",
|
||||
TokenEnv: "GRAYLOG_MCP_API_TOKEN",
|
||||
Value: SetupValue{
|
||||
Type: SetupFieldSecret,
|
||||
String: "stored-token",
|
||||
Set: true,
|
||||
KeptStoredSecret: true,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, secretstore.ErrNotFound) {
|
||||
t.Fatalf("error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "GRAYLOG_MCP_API_TOKEN") {
|
||||
t.Fatalf("error = %v, want token env remediation", err)
|
||||
}
|
||||
}
|
||||
|
||||
type setupSecretStore struct {
|
||||
secrets map[string]string
|
||||
setErr error
|
||||
getErr error
|
||||
}
|
||||
|
||||
func (s *setupSecretStore) SetSecret(name, label, secret string) error {
|
||||
if s.setErr != nil {
|
||||
return s.setErr
|
||||
}
|
||||
s.secrets[name] = secret
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *setupSecretStore) GetSecret(name string) (string, error) {
|
||||
if s.getErr != nil {
|
||||
return "", s.getErr
|
||||
}
|
||||
value, ok := s.secrets[name]
|
||||
if !ok {
|
||||
return "", secretstore.ErrNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *setupSecretStore) DeleteSecret(name string) error {
|
||||
delete(s.secrets, name)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -73,6 +73,20 @@ _ = enabled
|
|||
_ = scopes
|
||||
```
|
||||
|
||||
Pour persister un secret de setup avec write+read-back et messages homogènes :
|
||||
|
||||
```go
|
||||
if err := cli.WriteSetupSecretVerified(cli.SetupSecretWriteOptions{
|
||||
Store: store,
|
||||
SecretName: "api-token",
|
||||
SecretLabel: "API token",
|
||||
TokenEnv: "MY_MCP_API_TOKEN",
|
||||
Value: apiToken,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`).
|
||||
Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif.
|
||||
|
||||
|
|
@ -123,6 +137,10 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
|||
ServiceName: "my-mcp",
|
||||
})
|
||||
}),
|
||||
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
|
||||
BitwardenOptions: cli.BitwardenDoctorOptions{
|
||||
LookupEnv: os.LookupEnv,
|
||||
},
|
||||
RequiredSecrets: []cli.DoctorSecret{
|
||||
{Name: "api-token", Label: "API token"},
|
||||
},
|
||||
|
|
@ -158,15 +176,12 @@ if report.HasFailures() {
|
|||
}
|
||||
```
|
||||
|
||||
Pour une policy `bitwarden-cli`, tu peux ajouter un check dédié avec remédiations :
|
||||
Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement.
|
||||
Pour le désactiver explicitement :
|
||||
|
||||
```go
|
||||
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||
ExtraChecks: []cli.DoctorCheck{
|
||||
cli.BitwardenReadyCheck(cli.BitwardenDoctorOptions{
|
||||
LookupEnv: os.LookupEnv,
|
||||
}),
|
||||
},
|
||||
DisableAutoBitwardenCheck: true,
|
||||
})
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@
|
|||
- `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.
|
||||
- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage).
|
||||
- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`.
|
||||
- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`.
|
||||
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
|
||||
|
|
|
|||
|
|
@ -131,5 +131,81 @@ effective := secretstore.EffectiveBackendPolicy(store)
|
|||
fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any...
|
||||
```
|
||||
|
||||
Pour obtenir en un seul appel une description runtime (source manifeste, policy déclarée/effective, disponibilité) :
|
||||
|
||||
```go
|
||||
desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{
|
||||
ServiceName: "my-mcp",
|
||||
LookupEnv: os.LookupEnv,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(secretstore.FormatBackendStatus(desc))
|
||||
// declared=... effective=... display=... ready=... source=...
|
||||
```
|
||||
|
||||
Pour un préflight réutilisable dans `setup`, `config show` et `config test` :
|
||||
|
||||
```go
|
||||
report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{
|
||||
ServiceName: "my-mcp",
|
||||
LookupEnv: os.LookupEnv,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(report.Status) // ready | fail
|
||||
fmt.Println(report.Summary) // message court
|
||||
fmt.Println(report.Remediation) // action recommandée
|
||||
```
|
||||
|
||||
## Debug Bitwarden en 60 secondes
|
||||
|
||||
1. Vérifier l'état de session :
|
||||
|
||||
```bash
|
||||
bw status
|
||||
```
|
||||
|
||||
2. Déverrouiller le vault et exporter `BW_SESSION` :
|
||||
|
||||
- Bash/Zsh :
|
||||
|
||||
```bash
|
||||
export BW_SESSION="$(bw unlock --raw)"
|
||||
```
|
||||
|
||||
- Fish :
|
||||
|
||||
```fish
|
||||
set -x BW_SESSION (bw unlock --raw)
|
||||
```
|
||||
|
||||
- PowerShell :
|
||||
|
||||
```powershell
|
||||
$env:BW_SESSION = (bw unlock --raw)
|
||||
```
|
||||
|
||||
3. Vérifier lecture/écriture rapide :
|
||||
|
||||
```go
|
||||
if err := store.SetSecret("debug-token", "Debug token", "ok"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := store.GetSecret("debug-token")
|
||||
return err
|
||||
```
|
||||
|
||||
4. Interpréter les erreurs typées :
|
||||
|
||||
- `secretstore.ErrBWNotLoggedIn` : `bw login` requis.
|
||||
- `secretstore.ErrBWLocked` : vault verrouillé ou `BW_SESSION` absent.
|
||||
- `secretstore.ErrBWUnavailable` : CLI/réseau indisponible.
|
||||
- `secretstore.ErrBackendUnavailable` : policy non satisfiable dans le contexte courant.
|
||||
|
||||
En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`.
|
||||
Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`.
|
||||
|
|
|
|||
|
|
@ -1731,38 +1731,20 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if !tokenValue.KeptStoredSecret {
|
||||
store, err := r.openSecretStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := secretstore.SetSecretVerified(store, r.SecretName, "API token", tokenValue.String); err != nil {
|
||||
if errors.Is(err, secretstore.ErrReadOnly) {
|
||||
return fmt.Errorf(
|
||||
"secret store is read-only, export %s and retry setup",
|
||||
r.TokenEnv,
|
||||
)
|
||||
} else {
|
||||
if err := cli.WriteSetupSecretVerified(cli.SetupSecretWriteOptions{
|
||||
Store: store,
|
||||
SecretName: r.SecretName,
|
||||
SecretLabel: "API token",
|
||||
TokenEnv: r.TokenEnv,
|
||||
Value: tokenValue,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verifiedToken, err := r.readToken()
|
||||
if err != nil {
|
||||
if errors.Is(err, secretstore.ErrNotFound) {
|
||||
return fmt.Errorf(
|
||||
"secret %q is not readable after setup, export %s and retry",
|
||||
r.SecretName,
|
||||
r.TokenEnv,
|
||||
)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(verifiedToken) == "" {
|
||||
return fmt.Errorf("secret %q is empty after setup", r.SecretName)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(stdout, "Configuration saved for profile %q. Secret readability confirmed.\n", profileName)
|
||||
return err
|
||||
|
|
@ -1837,13 +1819,14 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er
|
|||
report := cli.RunDoctor(ctx, cli.DoctorOptions{
|
||||
ConfigCheck: cli.NewConfigCheck(r.ConfigStore),
|
||||
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore),
|
||||
SecretBackendPolicy: r.activeBackendPolicy(),
|
||||
RequiredSecrets: []cli.DoctorSecret{
|
||||
{Name: r.SecretName, Label: "API token"},
|
||||
},
|
||||
SecretStoreFactory: r.openSecretStore,
|
||||
ManifestCheck: r.manifestDoctorCheck(),
|
||||
ExtraChecks: []cli.DoctorCheck{
|
||||
r.bitwardenDoctorCheck(),
|
||||
BitwardenOptions: cli.BitwardenDoctorOptions{
|
||||
LookupEnv: os.LookupEnv,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -1947,16 +1930,6 @@ func (r Runtime) manifestDoctorCheck() cli.DoctorCheck {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r Runtime) bitwardenDoctorCheck() cli.DoctorCheck {
|
||||
if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cli.BitwardenReadyCheck(cli.BitwardenDoctorOptions{
|
||||
LookupEnv: os.LookupEnv,
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
const manifestTemplate = `binary_name = "{{.BinaryName}}"
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
|||
`var embeddedManifest = `,
|
||||
"ManifestSource",
|
||||
"ManifestCheck: r.manifestDoctorCheck()",
|
||||
"SecretBackendPolicy: r.activeBackendPolicy()",
|
||||
"cli.WriteSetupSecretVerified",
|
||||
} {
|
||||
if !strings.Contains(string(appGo), snippet) {
|
||||
t.Fatalf("app.go missing snippet %q", snippet)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -68,6 +70,7 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
|
|||
if err := EnsureBitwardenReady(Options{
|
||||
BitwardenCommand: command,
|
||||
LookupEnv: options.LookupEnv,
|
||||
Shell: options.Shell,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"secret backend policy %q cannot use bitwarden CLI command %q right now: %w",
|
||||
|
|
@ -85,6 +88,7 @@ func EnsureBitwardenReady(options Options) error {
|
|||
if command == "" {
|
||||
command = defaultBitwardenCommand
|
||||
}
|
||||
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
|
||||
|
||||
lookupEnv := options.LookupEnv
|
||||
if lookupEnv == nil {
|
||||
|
|
@ -111,18 +115,18 @@ func EnsureBitwardenReady(options Options) error {
|
|||
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
|
||||
case "locked":
|
||||
return fmt.Errorf(
|
||||
"%w: run `export %s=\"$(bw unlock --raw)\"` then retry",
|
||||
"%w: run `%s` then retry",
|
||||
ErrBWLocked,
|
||||
bitwardenSessionEnvName,
|
||||
unlockCommand,
|
||||
)
|
||||
case "unlocked":
|
||||
session, ok := lookupEnv(bitwardenSessionEnvName)
|
||||
if !ok || strings.TrimSpace(session) == "" {
|
||||
return fmt.Errorf(
|
||||
"%w: environment variable %q is missing; run `export %s=\"$(bw unlock --raw)\"` then retry",
|
||||
"%w: environment variable %q is missing; run `%s` then retry",
|
||||
ErrBWLocked,
|
||||
bitwardenSessionEnvName,
|
||||
bitwardenSessionEnvName,
|
||||
unlockCommand,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -135,6 +139,57 @@ func EnsureBitwardenReady(options Options) error {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
item, err := s.findItem(secretName, name)
|
||||
|
|
|
|||
|
|
@ -140,6 +140,54 @@ func TestEnsureBitwardenReadyAcceptsUnlockedSession(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEnsureBitwardenReadyAdaptsUnlockRemediationToShell(t *testing.T) {
|
||||
t.Run("fish", func(t *testing.T) {
|
||||
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)
|
||||
})
|
||||
|
||||
err := EnsureBitwardenReady(Options{BitwardenCommand: "bw", Shell: "fish"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrBWLocked) {
|
||||
t.Fatalf("error = %v, want ErrBWLocked", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "set -x BW_SESSION (bw unlock --raw)") {
|
||||
t.Fatalf("error = %v, want fish unlock remediation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("powershell", func(t *testing.T) {
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if len(args) == 1 && args[0] == "status" {
|
||||
return []byte(`{"status":"unlocked"}`), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||
})
|
||||
|
||||
err := EnsureBitwardenReady(Options{
|
||||
BitwardenCommand: "bw",
|
||||
Shell: "powershell",
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
return "", false
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrBWLocked) {
|
||||
t.Fatalf("error = %v, want ErrBWLocked", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "$env:BW_SESSION = (bw unlock --raw)") {
|
||||
t.Fatalf("error = %v, want powershell unlock remediation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) {
|
||||
withBitwardenSession(t)
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
|
|
|
|||
|
|
@ -19,26 +19,43 @@ type OpenFromManifestOptions struct {
|
|||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
Shell string
|
||||
ManifestLoader ManifestLoader
|
||||
ExecutableResolver ExecutableResolver
|
||||
}
|
||||
|
||||
func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
|
||||
policy, err := resolveManifestBackendPolicy(options)
|
||||
manifestPolicy, err := resolveManifestPolicy(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Open(Options{
|
||||
ServiceName: options.ServiceName,
|
||||
BackendPolicy: policy,
|
||||
BackendPolicy: manifestPolicy.Policy,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: strings.TrimSpace(options.BitwardenCommand),
|
||||
Shell: strings.TrimSpace(options.Shell),
|
||||
})
|
||||
}
|
||||
|
||||
func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolicy, error) {
|
||||
resolution, err := resolveManifestPolicy(options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolution.Policy, nil
|
||||
}
|
||||
|
||||
type manifestPolicyResolution struct {
|
||||
Policy BackendPolicy
|
||||
Source string
|
||||
}
|
||||
|
||||
func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) {
|
||||
manifestLoader := options.ManifestLoader
|
||||
if manifestLoader == nil {
|
||||
manifestLoader = manifest.LoadDefault
|
||||
|
|
@ -51,7 +68,7 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic
|
|||
|
||||
executablePath, err := executableResolver()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve executable path for manifest lookup: %w", err)
|
||||
return manifestPolicyResolution{}, fmt.Errorf("resolve executable path for manifest lookup: %w", err)
|
||||
}
|
||||
|
||||
startDir := filepath.Dir(strings.TrimSpace(executablePath))
|
||||
|
|
@ -62,19 +79,32 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic
|
|||
file, manifestPath, err := manifestLoader(startDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return BackendAuto, nil
|
||||
return manifestPolicyResolution{
|
||||
Policy: BackendAuto,
|
||||
Source: "",
|
||||
}, nil
|
||||
}
|
||||
return "", fmt.Errorf("load runtime manifest from %q: %w", startDir, err)
|
||||
return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" {
|
||||
return BackendAuto, nil
|
||||
return manifestPolicyResolution{
|
||||
Policy: BackendAuto,
|
||||
Source: strings.TrimSpace(manifestPath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
policy, err := normalizeBackendPolicy(BackendPolicy(file.SecretStore.BackendPolicy))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid secret_store.backend_policy in manifest %q: %w", strings.TrimSpace(manifestPath), err)
|
||||
return manifestPolicyResolution{}, fmt.Errorf(
|
||||
"invalid secret_store.backend_policy in manifest %q: %w",
|
||||
strings.TrimSpace(manifestPath),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return policy, nil
|
||||
return manifestPolicyResolution{
|
||||
Policy: policy,
|
||||
Source: strings.TrimSpace(manifestPath),
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
196
secretstore/runtime.go
Normal file
196
secretstore/runtime.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const DefaultManifestSource = "default:auto (manifest not found)"
|
||||
|
||||
type DescribeRuntimeOptions struct {
|
||||
ServiceName string
|
||||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
Shell string
|
||||
ManifestLoader ManifestLoader
|
||||
ExecutableResolver ExecutableResolver
|
||||
}
|
||||
|
||||
type RuntimeDescription struct {
|
||||
ManifestSource string
|
||||
DeclaredPolicy BackendPolicy
|
||||
EffectivePolicy BackendPolicy
|
||||
DisplayName string
|
||||
Ready bool
|
||||
ReadyError error
|
||||
}
|
||||
|
||||
type PreflightStatus string
|
||||
|
||||
const (
|
||||
PreflightStatusReady PreflightStatus = "ready"
|
||||
PreflightStatusFail PreflightStatus = "fail"
|
||||
)
|
||||
|
||||
type PreflightOptions = DescribeRuntimeOptions
|
||||
|
||||
type PreflightReport struct {
|
||||
Status PreflightStatus
|
||||
Summary string
|
||||
Remediation string
|
||||
Runtime RuntimeDescription
|
||||
}
|
||||
|
||||
func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) {
|
||||
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
|
||||
ServiceName: options.ServiceName,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
Shell: options.Shell,
|
||||
ManifestLoader: options.ManifestLoader,
|
||||
ExecutableResolver: options.ExecutableResolver,
|
||||
})
|
||||
if err != nil {
|
||||
return RuntimeDescription{}, err
|
||||
}
|
||||
|
||||
desc := RuntimeDescription{
|
||||
ManifestSource: manifestSourceLabel(resolution.Source),
|
||||
DeclaredPolicy: resolution.Policy,
|
||||
EffectivePolicy: resolution.Policy,
|
||||
DisplayName: BackendDisplayName(resolution.Policy),
|
||||
}
|
||||
|
||||
store, openErr := Open(Options{
|
||||
ServiceName: options.ServiceName,
|
||||
BackendPolicy: resolution.Policy,
|
||||
LookupEnv: options.LookupEnv,
|
||||
KWalletAppID: options.KWalletAppID,
|
||||
KWalletFolder: options.KWalletFolder,
|
||||
BitwardenCommand: options.BitwardenCommand,
|
||||
Shell: options.Shell,
|
||||
})
|
||||
if openErr != nil {
|
||||
desc.Ready = false
|
||||
desc.ReadyError = openErr
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
desc.Ready = true
|
||||
if effective := EffectiveBackendPolicy(store); strings.TrimSpace(string(effective)) != "" {
|
||||
desc.EffectivePolicy = effective
|
||||
desc.DisplayName = BackendDisplayName(effective)
|
||||
}
|
||||
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) {
|
||||
desc, err := DescribeRuntime(options)
|
||||
if err != nil {
|
||||
return PreflightReport{}, err
|
||||
}
|
||||
|
||||
if desc.Ready {
|
||||
return PreflightReport{
|
||||
Status: PreflightStatusReady,
|
||||
Summary: "secret backend is ready",
|
||||
Runtime: desc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
summary, remediation := summarizePreflightFailure(desc.ReadyError)
|
||||
return PreflightReport{
|
||||
Status: PreflightStatusFail,
|
||||
Summary: summary,
|
||||
Remediation: remediation,
|
||||
Runtime: desc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BackendDisplayName(policy BackendPolicy) string {
|
||||
switch policy {
|
||||
case BackendBitwardenCLI:
|
||||
return "Bitwarden CLI"
|
||||
case BackendEnvOnly:
|
||||
return "Environment variables"
|
||||
case BackendKWalletOnly:
|
||||
return "KWallet"
|
||||
case BackendAuto:
|
||||
return "automatic backend selection"
|
||||
case BackendKeyringAny:
|
||||
return BackendName()
|
||||
default:
|
||||
trimmed := strings.TrimSpace(string(policy))
|
||||
if trimmed == "" {
|
||||
return "unknown backend"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func FormatBackendStatus(desc RuntimeDescription) string {
|
||||
source := manifestSourceLabel(desc.ManifestSource)
|
||||
display := strings.TrimSpace(desc.DisplayName)
|
||||
if display == "" {
|
||||
display = BackendDisplayName(desc.EffectivePolicy)
|
||||
}
|
||||
|
||||
effective := desc.EffectivePolicy
|
||||
if strings.TrimSpace(string(effective)) == "" {
|
||||
effective = desc.DeclaredPolicy
|
||||
}
|
||||
|
||||
parts := []string{
|
||||
fmt.Sprintf("declared=%s", normalizeStatusPolicy(desc.DeclaredPolicy)),
|
||||
fmt.Sprintf("effective=%s", normalizeStatusPolicy(effective)),
|
||||
fmt.Sprintf("display=%s", display),
|
||||
fmt.Sprintf("ready=%t", desc.Ready),
|
||||
fmt.Sprintf("source=%s", source),
|
||||
}
|
||||
if desc.ReadyError != nil {
|
||||
parts = append(parts, fmt.Sprintf("error=%s", strings.TrimSpace(desc.ReadyError.Error())))
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func summarizePreflightFailure(err error) (string, string) {
|
||||
if err == nil {
|
||||
return "secret backend is unavailable", ""
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, ErrBWNotLoggedIn):
|
||||
return "bitwarden login is required", strings.TrimSpace(err.Error())
|
||||
case errors.Is(err, ErrBWLocked):
|
||||
return "bitwarden vault is locked or BW_SESSION is missing", strings.TrimSpace(err.Error())
|
||||
case errors.Is(err, ErrBWUnavailable):
|
||||
return "bitwarden CLI is unavailable", strings.TrimSpace(err.Error())
|
||||
case errors.Is(err, ErrBackendUnavailable):
|
||||
return "secret backend is unavailable", strings.TrimSpace(err.Error())
|
||||
default:
|
||||
return "secret backend preflight failed", strings.TrimSpace(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func manifestSourceLabel(source string) string {
|
||||
trimmed := strings.TrimSpace(source)
|
||||
if trimmed == "" {
|
||||
return DefaultManifestSource
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func normalizeStatusPolicy(policy BackendPolicy) string {
|
||||
trimmed := strings.TrimSpace(string(policy))
|
||||
if trimmed == "" {
|
||||
return string(BackendAuto)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
155
secretstore/runtime_test.go
Normal file
155
secretstore/runtime_test.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
|
||||
desc, err := DescribeRuntime(DescribeRuntimeOptions{
|
||||
ServiceName: "graylog-mcp",
|
||||
LookupEnv: func(string) (string, bool) { return "", false },
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeRuntime returned error: %v", err)
|
||||
}
|
||||
|
||||
if desc.ManifestSource == "" {
|
||||
t.Fatal("ManifestSource should not be empty")
|
||||
}
|
||||
if desc.DeclaredPolicy != BackendEnvOnly {
|
||||
t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendEnvOnly)
|
||||
}
|
||||
if desc.EffectivePolicy != BackendEnvOnly {
|
||||
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendEnvOnly)
|
||||
}
|
||||
if desc.DisplayName == "" {
|
||||
t.Fatal("DisplayName should not be empty")
|
||||
}
|
||||
if !desc.Ready {
|
||||
t.Fatalf("Ready = %v, want true", desc.Ready)
|
||||
}
|
||||
if desc.ReadyError != nil {
|
||||
t.Fatalf("ReadyError = %v, want nil", desc.ReadyError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) {
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case len(args) == 1 && args[0] == "--version":
|
||||
return []byte("2026.1.0\n"), nil
|
||||
case len(args) == 1 && args[0] == "status":
|
||||
return []byte(`{"status":"locked"}`), nil
|
||||
default:
|
||||
return nil, errors.New("unexpected bitwarden invocation")
|
||||
}
|
||||
})
|
||||
|
||||
desc, err := DescribeRuntime(DescribeRuntimeOptions{
|
||||
ServiceName: "graylog-mcp",
|
||||
Shell: "fish",
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeRuntime returned error: %v", err)
|
||||
}
|
||||
|
||||
if desc.DeclaredPolicy != BackendBitwardenCLI {
|
||||
t.Fatalf("DeclaredPolicy = %q, want %q", desc.DeclaredPolicy, BackendBitwardenCLI)
|
||||
}
|
||||
if desc.EffectivePolicy != BackendBitwardenCLI {
|
||||
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI)
|
||||
}
|
||||
if desc.Ready {
|
||||
t.Fatalf("Ready = %v, want false", desc.Ready)
|
||||
}
|
||||
if !errors.Is(desc.ReadyError, ErrBWLocked) {
|
||||
t.Fatalf("ReadyError = %v, want ErrBWLocked", desc.ReadyError)
|
||||
}
|
||||
if !strings.Contains(desc.ReadyError.Error(), "set -x BW_SESSION (bw unlock --raw)") {
|
||||
t.Fatalf("ReadyError = %v, want fish remediation", desc.ReadyError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightFromManifestReturnsTypedStatusAndRemediation(t *testing.T) {
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case len(args) == 1 && args[0] == "--version":
|
||||
return []byte("2026.1.0\n"), nil
|
||||
case len(args) == 1 && args[0] == "status":
|
||||
return []byte(`{"status":"locked"}`), nil
|
||||
default:
|
||||
return nil, errors.New("unexpected bitwarden invocation")
|
||||
}
|
||||
})
|
||||
|
||||
report, err := PreflightFromManifest(PreflightOptions{
|
||||
ServiceName: "graylog-mcp",
|
||||
Shell: "fish",
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "graylog-mcp", "bin", "graylog-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightFromManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
if report.Status != PreflightStatusFail {
|
||||
t.Fatalf("Status = %q, want %q", report.Status, PreflightStatusFail)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(report.Summary), "locked") {
|
||||
t.Fatalf("Summary = %q, want lock hint", report.Summary)
|
||||
}
|
||||
if !strings.Contains(report.Remediation, "set -x BW_SESSION (bw unlock --raw)") {
|
||||
t.Fatalf("Remediation = %q, want fish remediation", report.Remediation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBackendStatusIncludesDeclaredEffectiveAndReadiness(t *testing.T) {
|
||||
line := FormatBackendStatus(RuntimeDescription{
|
||||
ManifestSource: "/opt/graylog-mcp/mcp.toml",
|
||||
DeclaredPolicy: BackendBitwardenCLI,
|
||||
EffectivePolicy: BackendBitwardenCLI,
|
||||
DisplayName: "Bitwarden CLI",
|
||||
Ready: false,
|
||||
ReadyError: ErrBWLocked,
|
||||
})
|
||||
|
||||
for _, needle := range []string{
|
||||
"declared=bitwarden-cli",
|
||||
"effective=bitwarden-cli",
|
||||
"display=Bitwarden CLI",
|
||||
"ready=false",
|
||||
"source=/opt/graylog-mcp/mcp.toml",
|
||||
"error=",
|
||||
} {
|
||||
if !strings.Contains(line, needle) {
|
||||
t.Fatalf("line = %q, want substring %q", line, needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ type Options struct {
|
|||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
Shell string
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
|
|
|
|||
Loading…
Reference in a new issue