diff --git a/README.md b/README.md index 3255ac5..ff3a28e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ Le binaire demande ensuite : Si un mot de passe existe déjà dans le wallet, laisser le champ vide le conserve. +Si le backend de secrets est en lecture seule (`[secret_store].backend_policy = "env-only"`), `setup` ne peut pas persister le mot de passe dans un wallet. Dans ce cas, exporte `EMAIL_MCP_PASSWORD` avant `setup`. La commande sauvegarde alors `host`/`username` et utilise le mot de passe depuis l’environnement. + ### Lancer le serveur MCP ```sh @@ -144,6 +146,11 @@ token_env_names = ["GITEA_TOKEN"] La commande retourne un code de sortie non nul si au moins un check échoue. +Pour l’update, la validation du manifeste accepte : + +- soit `update.latest_release_url` +- soit un couple driver/référentiel (`update.driver`, `update.repository`) avec les champs requis (ex. `update.base_url` pour Gitea) + ## Installation ### Claude Code CLI diff --git a/internal/cli/app.go b/internal/cli/app.go index 2bdd913..bb94956 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -292,8 +292,23 @@ func (a *App) runConfig(ctx context.Context, command string, args []string) erro return err } - if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil { - return mapAppError(err) + if shouldPersistPassword(hasStoredPassword, storedPassword, cred.Password) { + if err := secrets.SetSecret(passwordSecretName(profileName), "IMAP password", cred.Password); err != nil { + switch { + case errors.Is(err, frameworksecretstore.ErrReadOnly): + if strings.TrimSpace(os.Getenv(passwordEnv)) == "" { + return newUserFacingError( + fmt.Sprintf("secret backend is read-only; set %s and rerun `email-mcp setup`", passwordEnv), + err, + ) + } + if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; password is provided via %s\n", passwordEnv); writeErr != nil { + return writeErr + } + default: + return mapAppError(err) + } + } } if cfg.Profiles == nil { @@ -394,8 +409,16 @@ func (a *App) runConfigDelete(_ context.Context, args []string) error { if err != nil { return mapAppError(err) } - if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil && !errors.Is(err, frameworksecretstore.ErrNotFound) { - return mapAppError(err) + if err := secrets.DeleteSecret(passwordSecretName(profileName)); err != nil { + switch { + case errors.Is(err, frameworksecretstore.ErrNotFound): + case errors.Is(err, frameworksecretstore.ErrReadOnly): + if _, writeErr := fmt.Fprintf(a.stdout, "secret backend is read-only; %s cannot be deleted automatically\n", passwordEnv); writeErr != nil { + return writeErr + } + default: + return mapAppError(err) + } } if cfg.Profiles != nil { @@ -680,6 +703,13 @@ func passwordSecretName(profileName string) string { return "imap-password/" + strings.TrimSpace(profileName) } +func shouldPersistPassword(hasStoredPassword bool, storedPassword, newPassword string) bool { + if !hasStoredPassword { + return true + } + return storedPassword != newPassword +} + func parseProfileArgs(command string, args []string) (string, error) { flagSet := flag.NewFlagSet(command, flag.ContinueOnError) flagSet.SetOutput(io.Discard) diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index da09164..34edf8b 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -427,6 +427,56 @@ func TestAppRunConfigDeleteRemovesProfileAndSecret(t *testing.T) { } } +func TestAppRunConfigDeleteIgnoresReadOnlySecretBackend(t *testing.T) { + cfgStore := &configStoreStub{ + cfg: frameworkconfig.FileConfig[ProfileConfig]{ + Version: frameworkconfig.CurrentVersion, + CurrentProfile: "work", + Profiles: map[string]ProfileConfig{ + "work": { + Host: "imap.work.example.com", + Username: "alice", + }, + }, + }, + } + secrets := &secretStoreStub{ + deleteErr: frameworksecretstore.ErrReadOnly, + } + output := &bytes.Buffer{} + + app := NewAppWithDependencies( + nil, + cfgStore, + func() (secretStore, error) { return secrets, nil }, + nil, + nil, + nil, + nil, + nil, + output, + &bytes.Buffer{}, + "dev", + ) + + if err := app.Run([]string{"config", "delete", "--profile", "work"}); err != nil { + t.Fatalf("config delete returned error: %v", err) + } + if _, ok := cfgStore.saved.Profiles["work"]; ok { + t.Fatalf("profile work should have been removed, got %#v", cfgStore.saved.Profiles) + } + + text := output.String() + for _, needle := range []string{ + "secret backend is read-only; EMAIL_MCP_PASSWORD cannot be deleted automatically", + `profile "work" deleted`, + } { + if !strings.Contains(text, needle) { + t.Fatalf("output = %q, want substring %q", text, needle) + } + } +} + func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) { prompter := &capturingPrompterStub{ credential: secretstore.Credential{ @@ -478,6 +528,85 @@ func TestAppRunSetupUsesStoredValuesAsDefaults(t *testing.T) { } } +func TestAppRunSetupAllowsReadOnlySecretBackendWhenPasswordEnvIsSet(t *testing.T) { + t.Setenv(passwordEnv, "env-secret") + + prompter := &configPrompterStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "new-secret", + }, + } + cfgStore := &configStoreStub{} + secrets := &secretStoreStub{setErr: frameworksecretstore.ErrReadOnly} + output := &bytes.Buffer{} + + app := NewAppWithDependencies( + prompter, + cfgStore, + func() (secretStore, error) { return secrets, nil }, + nil, + nil, + nil, + nil, + nil, + output, + &bytes.Buffer{}, + "dev", + ) + + if err := app.Run([]string{"setup"}); err != nil { + t.Fatalf("setup returned error: %v", err) + } + if !secrets.setCalled { + t.Fatal("expected password write attempt") + } + if !cfgStore.saveCalled { + t.Fatal("expected config to be saved") + } + if !strings.Contains(output.String(), "secret backend is read-only; password is provided via EMAIL_MCP_PASSWORD") { + t.Fatalf("unexpected output: %q", output.String()) + } +} + +func TestAppRunSetupFailsOnReadOnlySecretBackendWithoutPasswordEnv(t *testing.T) { + prompter := &configPrompterStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "new-secret", + }, + } + cfgStore := &configStoreStub{} + secrets := &secretStoreStub{setErr: frameworksecretstore.ErrReadOnly} + + app := NewAppWithDependencies( + prompter, + cfgStore, + func() (secretStore, error) { return secrets, nil }, + nil, + nil, + nil, + nil, + nil, + io.Discard, + &bytes.Buffer{}, + "dev", + ) + + err := app.Run([]string{"setup"}) + if err == nil { + t.Fatal("expected setup to fail") + } + if !strings.Contains(err.Error(), "secret backend is read-only; set EMAIL_MCP_PASSWORD and rerun `email-mcp setup`") { + t.Fatalf("unexpected error: %v", err) + } + if cfgStore.saveCalled { + t.Fatal("config must not be saved when password cannot be persisted") + } +} + func TestAppRunConfigShowPrintsResolvedConfiguration(t *testing.T) { cfgStore := &configStoreStub{ cfg: frameworkconfig.FileConfig[ProfileConfig]{ @@ -781,7 +910,9 @@ func TestAppRunDoctorRendersReportAndChecksConnectivity(t *testing.T) { manifestDir := t.TempDir() if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` [update] -latest_release_url = "https://example.com/releases/latest" +driver = "gitea" +repository = "AI/email-mcp" +base_url = "https://gitea.lclr.dev" `), 0o600); err != nil { t.Fatalf("WriteFile returned error: %v", err) } @@ -856,7 +987,9 @@ func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) { manifestDir := t.TempDir() if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` [update] -latest_release_url = "https://example.com/releases/latest" +driver = "gitea" +repository = "AI/email-mcp" +base_url = "https://gitea.lclr.dev" `), 0o600); err != nil { t.Fatalf("WriteFile returned error: %v", err) } @@ -915,7 +1048,9 @@ func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) { manifestDir := t.TempDir() if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` [update] -latest_release_url = "https://example.com/releases/latest" +driver = "gitea" +repository = "AI/email-mcp" +base_url = "https://gitea.lclr.dev" `), 0o600); err != nil { t.Fatalf("WriteFile returned error: %v", err) } @@ -943,6 +1078,74 @@ latest_release_url = "https://example.com/releases/latest" } } +func TestAppRunDoctorFailsWhenManifestUpdateConfigIsInvalid(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempHome) + t.Setenv("HOME", tempHome) + t.Setenv(passwordEnv, "env-secret") + + store := frameworkconfig.NewStore[ProfileConfig](binaryName) + configPath, err := store.ConfigPath() + if err != nil { + t.Fatalf("ConfigPath returned error: %v", err) + } + if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{ + Version: frameworkconfig.CurrentVersion, + CurrentProfile: "work", + Profiles: map[string]ProfileConfig{ + "work": { + Host: "imap.example.com", + Username: "alice", + }, + }, + }); err != nil { + t.Fatalf("Save returned error: %v", err) + } + + manifestDir := t.TempDir() + if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` +[update] +driver = "gitea" +base_url = "https://gitea.lclr.dev" +`), 0o600); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + mail := &doctorMailServiceStub{ + listMailboxes: []imapclient.Mailbox{{Name: "INBOX"}}, + } + output := &bytes.Buffer{} + app := NewAppWithDependencies( + nil, + store, + func() (secretStore, error) { return &secretStoreStub{}, nil }, + func() mcpserver.MailService { return mail }, + nil, + nil, + func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil }, + nil, + output, + &bytes.Buffer{}, + "dev", + ) + + err = app.Run([]string{"doctor"}) + if err == nil { + t.Fatal("expected doctor to fail with invalid manifest update config") + } + if !strings.Contains(err.Error(), "doctor checks failed") { + t.Fatalf("unexpected error: %v", err) + } + + text := output.String() + if !strings.Contains(text, "[FAIL] manifest: manifest validation failed") { + t.Fatalf("unexpected output: %q", text) + } + if !strings.Contains(text, "requires repository") { + t.Fatalf("unexpected output: %q", text) + } +} + func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index c176664..73b7a6a 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + "runtime" "strings" "time" @@ -12,6 +13,7 @@ import ( frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" + frameworkupdate "gitea.lclr.dev/AI/mcp-framework/update" ) func (a *App) runDoctor(ctx context.Context, args []string) error { @@ -29,15 +31,13 @@ func (a *App) runDoctor(ctx context.Context, args []string) error { return fmt.Errorf("mail service is not configured") } + metadata := a.runtimeMetadata() report := frameworkcli.RunDoctor(ctx, frameworkcli.DoctorOptions{ ConfigCheck: frameworkcli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig](binaryName)), SecretStoreCheck: frameworkcli.SecretStoreAvailabilityCheck(a.frameworkSecretStoreFactory()), ManifestDir: a.doctorManifestDir(), ManifestValidator: func(file frameworkmanifest.File, _ string) []string { - if strings.TrimSpace(file.Update.LatestReleaseURL) == "" { - return []string{"update.latest_release_url must not be empty"} - } - return nil + return validateManifestUpdate(file, metadata.BinaryName) }, ConnectivityCheck: a.doctorConnectivityCheck(profileFlag), ExtraChecks: []frameworkcli.DoctorCheck{ @@ -230,3 +230,26 @@ func (a *App) resolveDoctorProfileName(profileFlag string) string { return a.resolveProfileName(profileFlag, cfg.CurrentProfile) } + +func validateManifestUpdate(file frameworkmanifest.File, runtimeBinaryName string) []string { + source := file.Update.ReleaseSource() + issues := make([]string, 0, 2) + + if _, err := frameworkupdate.ResolveLatestReleaseURL("", source); err != nil { + issues = append(issues, err.Error()) + } + + binary := strings.TrimSpace(runtimeBinaryName) + if binary == "" { + binary = strings.TrimSpace(file.BinaryName) + } + if binary == "" { + binary = binaryName + } + + if _, err := frameworkupdate.AssetNameWithTemplate(binary, runtime.GOOS, runtime.GOARCH, source.AssetNameTemplate); err != nil { + issues = append(issues, err.Error()) + } + + return issues +} diff --git a/internal/cli/wire.go b/internal/cli/wire.go index 3bfc388..e3d1d1e 100644 --- a/internal/cli/wire.go +++ b/internal/cli/wire.go @@ -76,6 +76,13 @@ func (f runtimeFactories) withDefaults() runtimeFactories { return frameworksecretstore.Open(frameworksecretstore.Options{ ServiceName: "email-mcp", BackendPolicy: policy, + LookupEnv: func(name string) (string, bool) { + trimmedName := strings.TrimSpace(name) + if strings.HasPrefix(trimmedName, "imap-password/") { + return os.LookupEnv(passwordEnv) + } + return os.LookupEnv(trimmedName) + }, }) } } diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go index a59e1b5..1f0fdc3 100644 --- a/internal/cli/wire_test.go +++ b/internal/cli/wire_test.go @@ -82,3 +82,31 @@ func TestResolveSecretStorePolicyFallsBackToAutoWhenManifestMissing(t *testing.T t.Fatalf("policy = %q, want %q", policy, frameworksecretstore.BackendAuto) } } + +func TestBuildAppOpenSecretStoreMapsProfilePasswordToEnvironment(t *testing.T) { + t.Setenv(passwordEnv, "env-secret") + + app := buildApp(nil, nil, nil, "dev", runtimeFactories{ + loadManifest: func(string) (frameworkmanifest.File, string, error) { + return frameworkmanifest.File{ + SecretStore: frameworkmanifest.SecretStore{ + BackendPolicy: "env-only", + }, + }, "/tmp/mcp.toml", nil + }, + resolveExecutable: func() (string, error) { return "/tmp/bin/email-mcp", nil }, + }) + + store, err := app.openSecretStore() + if err != nil { + t.Fatalf("openSecretStore returned error: %v", err) + } + + value, err := store.GetSecret("imap-password/work") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "env-secret" { + t.Fatalf("GetSecret = %q, want %q", value, "env-secret") + } +}