From 26238eb31bc9bc835bfe826c5747483ee3ac8f38 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 16:40:50 +0200 Subject: [PATCH] feat(secretstore): add runtime manifest helper for backend opening --- README.md | 21 ++++- scaffold/scaffold.go | 7 +- scaffold/scaffold_test.go | 2 +- secretstore/manifest_open.go | 80 +++++++++++++++++ secretstore/manifest_open_test.go | 139 ++++++++++++++++++++++++++++++ secretstore/store.go | 33 ++++--- secretstore/store_test.go | 3 + 7 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 secretstore/manifest_open.go create mode 100644 secretstore/manifest_open_test.go diff --git a/README.md b/README.md index b3fb9b3..db1fb4d 100644 --- a/README.md +++ b/README.md @@ -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", }) }, diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 06b5da4..bd2c96e 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -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) diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 3e65462..c166a3b 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -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", diff --git a/secretstore/manifest_open.go b/secretstore/manifest_open.go new file mode 100644 index 0000000..71e0390 --- /dev/null +++ b/secretstore/manifest_open.go @@ -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 +} diff --git a/secretstore/manifest_open_test.go b/secretstore/manifest_open_test.go new file mode 100644 index 0000000..bfe783a --- /dev/null +++ b/secretstore/manifest_open_test.go @@ -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) + } +} diff --git a/secretstore/store.go b/secretstore/store.go index 3cb7e6f..1cd61d1 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -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) +} diff --git a/secretstore/store_test.go b/secretstore/store_test.go index c7a2f68..818ea41 100644 --- a/secretstore/store_test.go +++ b/secretstore/store_test.go @@ -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) {