feat(secretstore): add runtime manifest helper for backend opening

This commit is contained in:
thibaud-lclr 2026-04-14 16:40:50 +02:00
parent 8c4f88ea93
commit 26238eb31b
7 changed files with 265 additions and 20 deletions

View file

@ -45,7 +45,7 @@ go run ./cmd/my-mcp help
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
- `manifest` : lecture de `mcp.toml` à la racine du projet, 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`, wiring de base et README de démarrage).
- `secretstore` : lecture/écriture de secrets dans le wallet natif.
- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helper runtime `OpenFromManifest`.
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
## Utilisation type
@ -288,7 +288,20 @@ Backends keyring typiques :
- Linux : Secret Service ou KWallet selon l'environnement
- Windows : Credential Manager
Exemple :
Ouverture recommandée depuis la policy du manifeste runtime (`mcp.toml` résolu
près de l'exécutable) :
```go
store, err := secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: "my-mcp",
LookupEnv: os.LookupEnv,
})
if err != nil {
return err
}
```
Exemple bas niveau :
```go
store, err := secretstore.Open(secretstore.Options{
@ -480,7 +493,7 @@ mais peut réutiliser les checks communs et ajouter ses propres hooks :
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
ConfigCheck: cli.NewConfigCheck(store),
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(func() (secretstore.Store, error) {
return secretstore.Open(secretstore.Options{
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: "my-mcp",
})
}),
@ -488,7 +501,7 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
{Name: "api-token", Label: "API token"},
},
SecretStoreFactory: func() (secretstore.Store, error) {
return secretstore.Open(secretstore.Options{
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: "my-mcp",
})
},

View file

@ -377,7 +377,6 @@ type Runtime struct {
ProfileEnv string
TokenEnv string
SecretName string
SecretStorePolicy string
}
func Run(ctx context.Context, args []string, version string) error {
@ -420,7 +419,6 @@ func NewRuntime(version string) (Runtime, error) {
ProfileEnv: profileEnv,
TokenEnv: tokenEnv,
SecretName: binaryName + "-api-token",
SecretStorePolicy: firstNonEmpty(scaffoldInfo.SecretStorePolicy, "{{.SecretStorePolicy}}"),
}, nil
}
@ -609,9 +607,8 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error
}
func (r Runtime) openSecretStore() (secretstore.Store, error) {
return secretstore.Open(secretstore.Options{
ServiceName: r.BinaryName,
BackendPolicy: secretstore.BackendPolicy(r.SecretStorePolicy),
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
ServiceName: r.BinaryName,
LookupEnv: func(name string) (string, bool) {
if name == r.SecretName {
return os.LookupEnv(r.TokenEnv)

View file

@ -65,7 +65,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
}
for _, snippet := range []string{
"config.NewStore[Profile]",
"secretstore.Open",
"secretstore.OpenFromManifest",
"update.Run",
"manifest.LoadDefault",
"bootstrap.Run",

View file

@ -0,0 +1,80 @@
package secretstore
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.lclr.dev/AI/mcp-framework/manifest"
)
type ManifestLoader func(startDir string) (manifest.File, string, error)
type ExecutableResolver func() (string, error)
type OpenFromManifestOptions struct {
ServiceName string
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
ManifestLoader ManifestLoader
ExecutableResolver ExecutableResolver
}
func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
policy, err := resolveManifestBackendPolicy(options)
if err != nil {
return nil, err
}
return Open(Options{
ServiceName: options.ServiceName,
BackendPolicy: policy,
LookupEnv: options.LookupEnv,
KWalletAppID: options.KWalletAppID,
KWalletFolder: options.KWalletFolder,
})
}
func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolicy, error) {
manifestLoader := options.ManifestLoader
if manifestLoader == nil {
manifestLoader = manifest.LoadDefault
}
executableResolver := options.ExecutableResolver
if executableResolver == nil {
executableResolver = os.Executable
}
executablePath, err := executableResolver()
if err != nil {
return "", fmt.Errorf("resolve executable path for manifest lookup: %w", err)
}
startDir := filepath.Dir(strings.TrimSpace(executablePath))
if strings.TrimSpace(startDir) == "" {
startDir = "."
}
file, manifestPath, err := manifestLoader(startDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return BackendAuto, nil
}
return "", fmt.Errorf("load runtime manifest from %q: %w", startDir, err)
}
if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" {
return BackendAuto, 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 policy, nil
}

View file

@ -0,0 +1,139 @@
package secretstore
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/99designs/keyring"
"gitea.lclr.dev/AI/mcp-framework/manifest"
)
func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) {
var gotStartDir string
store, err := OpenFromManifest(OpenFromManifestOptions{
ServiceName: "email-mcp",
LookupEnv: func(name string) (string, bool) {
if name == "EMAIL_TOKEN" {
return "from-env", true
}
return "", false
},
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
},
ManifestLoader: func(startDir string) (manifest.File, string, error) {
gotStartDir = startDir
return manifest.File{
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendEnvOnly)},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err != nil {
t.Fatalf("OpenFromManifest returned error: %v", err)
}
wantDir := filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin")
if gotStartDir != wantDir {
t.Fatalf("manifest loader startDir = %q, want %q", gotStartDir, wantDir)
}
value, err := store.GetSecret("EMAIL_TOKEN")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "from-env" {
t.Fatalf("GetSecret = %q, want from-env", value)
}
}
func TestOpenFromManifestFallsBackToAutoWhenManifestIsMissing(t *testing.T) {
withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) {
t.Fatal("unexpected keyring open call")
return nil, nil
})
store, err := OpenFromManifest(OpenFromManifestOptions{
ServiceName: "email-mcp",
LookupEnv: func(name string) (string, bool) {
if name == "EMAIL_TOKEN" {
return "env-token", true
}
return "", false
},
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
},
ManifestLoader: func(string) (manifest.File, string, error) {
return manifest.File{}, "", os.ErrNotExist
},
})
if err != nil {
t.Fatalf("OpenFromManifest returned error: %v", err)
}
value, err := store.GetSecret("EMAIL_TOKEN")
if err != nil {
t.Fatalf("GetSecret returned error: %v", err)
}
if value != "env-token" {
t.Fatalf("GetSecret = %q, want env-token", value)
}
}
func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing.T) {
_, err := OpenFromManifest(OpenFromManifestOptions{
ServiceName: "email-mcp",
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
},
ManifestLoader: func(startDir string) (manifest.File, string, error) {
return manifest.File{
SecretStore: manifest.SecretStore{BackendPolicy: "totally-invalid"},
}, filepath.Join(startDir, manifest.DefaultFile), nil
},
})
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrInvalidBackendPolicy) {
t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err)
}
if !strings.Contains(err.Error(), "secret_store.backend_policy") {
t.Fatalf("error = %v, want manifest policy context", err)
}
if !strings.Contains(err.Error(), manifest.DefaultFile) {
t.Fatalf("error = %v, want manifest path", err)
}
}
func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) {
execErr := errors.New("boom")
_, err := OpenFromManifest(OpenFromManifestOptions{
ExecutableResolver: func() (string, error) {
return "", execErr
},
})
if !errors.Is(err, execErr) {
t.Fatalf("error = %v, want wrapped executable resolver error", err)
}
}
func TestOpenFromManifestReturnsManifestLoaderError(t *testing.T) {
loadErr := errors.New("cannot parse manifest")
_, err := OpenFromManifest(OpenFromManifestOptions{
ExecutableResolver: func() (string, error) {
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
},
ManifestLoader: func(string) (manifest.File, string, error) {
return manifest.File{}, "", loadErr
},
})
if !errors.Is(err, loadErr) {
t.Fatalf("error = %v, want wrapped manifest loader error", err)
}
}

View file

@ -15,6 +15,7 @@ import (
var ErrNotFound = errors.New("secret not found")
var ErrBackendUnavailable = errors.New("secret backend unavailable")
var ErrReadOnly = errors.New("secret backend is read-only")
var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy")
type BackendPolicy string
@ -77,15 +78,9 @@ var (
)
func Open(options Options) (Store, error) {
policy := options.BackendPolicy
if policy == "" {
policy = BackendAuto
}
switch policy {
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly:
default:
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
policy, err := normalizeBackendPolicy(options.BackendPolicy)
if err != nil {
return nil, err
}
if policy == BackendEnvOnly {
@ -249,7 +244,7 @@ func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]k
}
return []keyring.BackendType{keyring.KWalletBackend}, nil
default:
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
return nil, invalidBackendPolicyError(policy)
}
}
@ -260,3 +255,21 @@ func backendNames(backends []keyring.BackendType) []string {
}
return names
}
func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) {
trimmed := BackendPolicy(strings.TrimSpace(string(policy)))
if trimmed == "" {
return BackendAuto, nil
}
switch trimmed {
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly:
return trimmed, nil
default:
return "", invalidBackendPolicyError(trimmed)
}
}
func invalidBackendPolicyError(policy BackendPolicy) error {
return fmt.Errorf("%w %q", ErrInvalidBackendPolicy, policy)
}

View file

@ -80,6 +80,9 @@ func TestOpenRejectsInvalidPolicy(t *testing.T) {
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrInvalidBackendPolicy) {
t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err)
}
}
func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) {