diff --git a/src/Aevatar.Bootstrap.Extensions.AI/ServiceCollectionExtensions.cs b/src/Aevatar.Bootstrap.Extensions.AI/ServiceCollectionExtensions.cs index 3c58cd26d..322637e36 100644 --- a/src/Aevatar.Bootstrap.Extensions.AI/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Bootstrap.Extensions.AI/ServiceCollectionExtensions.cs @@ -376,12 +376,12 @@ private static void RegisterMeaiProviders( if (!options.EnableMEAIProviders) return; - var secretsStoreAccessor = CreateSecretsStoreAccessor(options); if (options.EnableReloadableProviderFactory) { var versionProvider = BuildProviderConfigVersionProvider(options); services.TryAddSingleton(sp => { + var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp); var logger = sp.GetService>(); return new ReloadableLLMProviderFactory( () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor), @@ -391,8 +391,11 @@ private static void RegisterMeaiProviders( return; } - var factory = BuildLlmProviderFactory(configuration, options, secretsStoreAccessor); - services.TryAddSingleton(factory); + services.TryAddSingleton(sp => + { + var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp); + return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor); + }); } private static ILLMProviderFactory BuildLlmProviderFactory( @@ -586,11 +589,21 @@ private static NyxIdLLMProviderFactory BuildNyxIdFactory( return new ConfiguredProvider("nyxid", "nyxid", model, gatewayEndpoint, string.Empty); } - private static Func CreateSecretsStoreAccessor(AevatarAIFeatureOptions options) + private static Func CreateSecretsStoreAccessor( + AevatarAIFeatureOptions options, + IServiceProvider services) { if (options.SecretsStore != null) return () => options.SecretsStore; + // Prefer the DI-registered store so hosts that opted into the + // read-only EnvironmentSecretsStore (e.g. mainnet) are honored + // here too. Falling back to a fresh AevatarSecretsStore() would + // re-open the local secrets.json on disk. + var registered = services.GetService(); + if (registered != null) + return () => registered; + return static () => new AevatarSecretsStore(); } diff --git a/src/Aevatar.Bootstrap/Hosting/WebApplicationBuilderExtensions.cs b/src/Aevatar.Bootstrap/Hosting/WebApplicationBuilderExtensions.cs index 8d46715b7..1b2807365 100644 --- a/src/Aevatar.Bootstrap/Hosting/WebApplicationBuilderExtensions.cs +++ b/src/Aevatar.Bootstrap/Hosting/WebApplicationBuilderExtensions.cs @@ -33,6 +33,26 @@ public sealed class AevatarDefaultHostOptions public bool EnableOpenApiDocument { get; set; } = true; public string OpenApiDocumentRoute { get; set; } = "/api/openapi.json"; + + /// + /// Whether the host may use the local file secrets store + /// (~/.aevatar/secrets.json) and register + /// AevatarSecretsStore. + /// + /// true (default): legacy behavior — secrets.json is loaded into + /// and a + /// read/write store is registered. Suitable for local dev, CLI tools, + /// localnet, and demos. + /// + /// + /// false: production / mainnet hosts. The host must not load or + /// persist secrets to disk. secrets.json is skipped and the + /// read-only EnvironmentSecretsStore is registered; secrets must + /// come from configuration / AEVATAR_-prefixed environment + /// variables. Mutation methods on the store throw on call. + /// + /// + public bool AllowLocalFileSecretsStore { get; set; } = true; } public static class WebApplicationBuilderExtensions @@ -47,8 +67,10 @@ public static WebApplicationBuilder AddAevatarDefaultHost( configureHost?.Invoke(hostOptions); AddApplicationBaseConfiguration(builder); - builder.Configuration.AddAevatarConfig(); - builder.Services.AddAevatarBootstrap(builder.Configuration); + builder.Configuration.AddAevatarConfig(allowLocalFileStore: hostOptions.AllowLocalFileSecretsStore); + builder.Services.AddAevatarBootstrap( + builder.Configuration, + allowLocalFileSecretsStore: hostOptions.AllowLocalFileSecretsStore); builder.Services.AddSingleton(hostOptions); builder.Services.AddSingleton(new AevatarHostMetadata { diff --git a/src/Aevatar.Bootstrap/ServiceCollectionExtensions.cs b/src/Aevatar.Bootstrap/ServiceCollectionExtensions.cs index 2f869e549..350ca1720 100644 --- a/src/Aevatar.Bootstrap/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Bootstrap/ServiceCollectionExtensions.cs @@ -11,9 +11,16 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddAevatarBootstrap( this IServiceCollection services, - IConfiguration configuration) + IConfiguration configuration, + bool allowLocalFileSecretsStore = true) { - services.AddAevatarConfig(); + // EnvironmentSecretsStore (registered by AddAevatarConfig when + // allowLocalFileSecretsStore=false) ctor-injects IConfiguration. The + // overload here already receives the instance, so register it into DI + // up front instead of forcing every caller to add it themselves. + // TryAdd preserves any existing registration from a host builder. + services.TryAddSingleton(configuration); + services.AddAevatarConfig(allowLocalFileSecretsStore); services.AddHttpClient(); services.AddAevatarActorRuntime(configuration); RegisterConnectorBuilders(services); diff --git a/src/Aevatar.Configuration/AevatarConfigLoader.cs b/src/Aevatar.Configuration/AevatarConfigLoader.cs index 0ae069657..f9383148b 100644 --- a/src/Aevatar.Configuration/AevatarConfigLoader.cs +++ b/src/Aevatar.Configuration/AevatarConfigLoader.cs @@ -19,17 +19,29 @@ public static class AevatarConfigLoader /// 将 ~/.aevatar/ 下的配置文件添加到 IConfigurationBuilder。 /// /// 配置构建器。 + /// + /// 是否允许加载 ~/.aevatar/secrets.json。生产/mainnet 入口必须传 + /// false,确保 secrets 仅来自部署平台注入的环境变量。其他 host 可 + /// 保留默认值。config.jsonmcp.jsonconnectors.json + /// 等非敏感文件不受此开关影响。 + /// /// 构建器(链式调用)。 - public static IConfigurationBuilder AddAevatarConfig(this IConfigurationBuilder builder) + public static IConfigurationBuilder AddAevatarConfig( + this IConfigurationBuilder builder, + bool allowLocalFileStore = true) { - // 确保目录存在 - AevatarPaths.EnsureDirectories(); + // 仅在使用本地文件 store 时创建 ~/.aevatar/ 目录树; + // 生产入口(allowLocalFileStore=false)保持零本地文件足迹。 + if (allowLocalFileStore) + AevatarPaths.EnsureDirectories(); // config.json — 非敏感配置(低优先级) builder.AddJsonFile(AevatarPaths.ConfigJson, optional: true, reloadOnChange: true); // secrets.json — 敏感配置(高优先级,覆盖 config.json 的同名 key) - builder.AddJsonFile(AevatarPaths.SecretsJson, optional: true, reloadOnChange: false); + // 生产入口(mainnet)必须显式禁用:禁止把 secrets 落地到本地文件。 + if (allowLocalFileStore) + builder.AddJsonFile(AevatarPaths.SecretsJson, optional: true, reloadOnChange: false); // mcp.json — MCP 服务器配置(Cursor 兼容格式) builder.AddJsonFile(AevatarPaths.MCPJson, optional: true, reloadOnChange: true); diff --git a/src/Aevatar.Configuration/EnvironmentSecretsStore.cs b/src/Aevatar.Configuration/EnvironmentSecretsStore.cs new file mode 100644 index 000000000..88c7f50f9 --- /dev/null +++ b/src/Aevatar.Configuration/EnvironmentSecretsStore.cs @@ -0,0 +1,99 @@ +// ───────────────────────────────────────────────────────────── +// EnvironmentSecretsStore — read-only IAevatarSecretsStore backed by +// IConfiguration (env vars + non-secret config files). +// +// Used by hosts that must not persist secrets to local files +// (e.g. Aevatar.Mainnet.Host.Api). Secrets are expected to come from +// the deploy platform via AEVATAR_-prefixed environment variables. +// Set / Remove deliberately throw so a misconfigured caller fails +// fast at the call site rather than silently falling back to disk. +// ───────────────────────────────────────────────────────────── + +using Microsoft.Extensions.Configuration; + +namespace Aevatar.Configuration; + +/// +/// Read-only secrets store that resolves keys from . +/// Mutation methods throw by design: +/// hosts opting into this store have explicitly disabled local file persistence. +/// +public sealed class EnvironmentSecretsStore : IAevatarSecretsStore +{ + private readonly IConfiguration _configuration; + + public EnvironmentSecretsStore(IConfiguration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public string? Get(string key) + { + if (string.IsNullOrEmpty(key)) return null; + var value = _configuration[key]; + return string.IsNullOrEmpty(value) ? null : value; + } + + public string? GetApiKey(string providerName) + { + if (string.IsNullOrWhiteSpace(providerName)) return null; + + var byProvidersSection = _configuration[$"LLMProviders:Providers:{providerName}:ApiKey"]; + if (!string.IsNullOrWhiteSpace(byProvidersSection)) + return byProvidersSection; + + var byProviderSection = _configuration[$"LLMProviders:{providerName}:ApiKey"]; + if (!string.IsNullOrWhiteSpace(byProviderSection)) + return byProviderSection; + + var byEnvConvention = _configuration[$"{providerName}_API_KEY"]; + if (!string.IsNullOrWhiteSpace(byEnvConvention)) + return byEnvConvention; + + return null; + } + + public string? GetDefaultProvider() + { + var value = _configuration["LLMProviders:Default"]; + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + /// + /// Returns a snapshot of secret-shaped configuration entries. Only keys + /// that match the conventions understands are + /// included: anything under LLMProviders: (provider definitions, + /// API keys, default name) and any {NAME}_API_KEY-style keys. + /// + /// This is intentionally narrower than dumping the entire + /// view: in env-driven hosts, the config root + /// also contains binding URLs, feature flags and connection strings, none + /// of which belong on the secrets API surface. + /// + /// + public IReadOnlyDictionary GetAll() + { + var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in _configuration.AsEnumerable()) + { + if (string.IsNullOrEmpty(kv.Value)) continue; + if (!IsSecretShapedKey(kv.Key)) continue; + snapshot[kv.Key] = kv.Value; + } + return snapshot; + } + + private static bool IsSecretShapedKey(string key) => + key.StartsWith("LLMProviders:", StringComparison.OrdinalIgnoreCase) || + key.EndsWith("_API_KEY", StringComparison.OrdinalIgnoreCase); + + public void Set(string key, string value) => + throw new InvalidOperationException( + "EnvironmentSecretsStore is read-only: this host disables local file secrets persistence. " + + "Inject secrets via AEVATAR_-prefixed environment variables or platform-managed configuration."); + + public void Remove(string key) => + throw new InvalidOperationException( + "EnvironmentSecretsStore is read-only: this host disables local file secrets persistence. " + + "Manage secret removal via AEVATAR_-prefixed environment variables or platform-managed configuration."); +} diff --git a/src/Aevatar.Configuration/README.md b/src/Aevatar.Configuration/README.md index fc4fa4b07..27537cc44 100644 --- a/src/Aevatar.Configuration/README.md +++ b/src/Aevatar.Configuration/README.md @@ -13,12 +13,22 @@ ## 核心类型 - `AevatarConfigLoader`:把本地配置合并到 `IConfigurationBuilder` -- `AevatarSecretsStore`:按 key 读取/写入敏感配置 +- `AevatarSecretsStore`:按 key 读写本地文件 secrets store(`~/.aevatar/secrets.json`),开发/CLI/localnet 默认实现 +- `EnvironmentSecretsStore`:只读 secrets store,仅从 `IConfiguration`(含 `AEVATAR_` 环境变量)读取,`Set`/`Remove` 直接抛 `InvalidOperationException`;mainnet 等生产宿主必须使用此实现,禁止把 secrets 落地到本地文件 - `AevatarMCPConfig`:读取 MCP 服务器配置 - `AevatarConnectorConfig`:读取命名 connector 配置(含 allowlist/timeout/retry) - `AevatarAgentYamlLoader`:扫描并读取 Agent/Workflow YAML - `AevatarPaths`:统一路径定义与目录初始化;另提供 `RepoRoot` / `RepoRootWorkflows`,宿主(如 Api)会从仓库根目录的 `workflows/` 加载 YAML(若存在),用户无需拷贝到 `~/.aevatar`。 +### 选择 secrets store + +`AddAevatarConfig` 的 `bool allowLocalFileStore = true` 参数控制注册哪个 store: + +- `true`(默认):注册 `AevatarSecretsStore`,并把 `~/.aevatar/secrets.json` 加入 `IConfiguration`。 +- `false`:注册 `EnvironmentSecretsStore`,跳过 `secrets.json` 加载。供 mainnet/生产宿主使用。 + +宿主层通过 `AevatarDefaultHostOptions.AllowLocalFileSecretsStore` 暴露此开关,由 `AddAevatarDefaultHost` 透传到 `AddAevatarBootstrap` 与 `AddAevatarConfig`。`Aevatar.Mainnet.Host.Api` 在 bootstrap 链路中显式设置为 `false`。 + ## Connector 作用与配置 ### Connector 是什么、用来做什么 diff --git a/src/Aevatar.Configuration/ServiceCollectionExtensions.cs b/src/Aevatar.Configuration/ServiceCollectionExtensions.cs index 0002b0f3b..d490dd2f5 100644 --- a/src/Aevatar.Configuration/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Configuration/ServiceCollectionExtensions.cs @@ -2,9 +2,10 @@ // ServiceCollectionExtensions — Aevatar Config DI 注册 // ───────────────────────────────────────────────────────────── +using Aevatar.Foundation.Abstractions.Credentials; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Aevatar.Foundation.Abstractions.Credentials; namespace Aevatar.Configuration; @@ -12,13 +13,47 @@ namespace Aevatar.Configuration; public static class ServiceCollectionExtensions { /// - /// 注册 Aevatar 配置服务。自动创建 ~/.aevatar/ 目录结构。 + /// 注册 Aevatar 配置服务。 /// - public static IServiceCollection AddAevatarConfig(this IServiceCollection services) + /// DI 容器。 + /// + /// 是否允许使用本地文件 secrets store(, + /// 读写 ~/.aevatar/secrets.json)。 + /// + /// true(默认):注册 并自动创建 + /// ~/.aevatar/ 目录结构,沿用历史行为。 + /// + /// + /// false:注册只读的 , + /// 仅从 (含 AEVATAR_ 环境变量)读取, + /// Set/Remove 直接抛 。 + /// 不创建本地目录。生产/mainnet 入口必须传 false。 + /// + /// + /// 前置条件(仅 false 路径):DI 容器中必须已注册 + /// + /// 通过构造函数注入它。 + /// 自身不注册 ;通过 + /// WebApplicationBuilderHostBuilder 调用本扩展时框架已经 + /// 注册好。裸 调用方需自行 + /// services.AddSingleton<IConfiguration>(...)。 + /// + /// + public static IServiceCollection AddAevatarConfig( + this IServiceCollection services, + bool allowLocalFileStore = true) { - AevatarPaths.EnsureDirectories(); - services.TryAddSingleton(); - services.TryAddSingleton(); + if (allowLocalFileStore) + { + AevatarPaths.EnsureDirectories(); + services.TryAddSingleton(); + services.TryAddSingleton(); + } + else + { + services.TryAddSingleton(); + services.TryAddSingleton(); + } services.TryAddSingleton(); services.TryAddSingleton(); return services; diff --git a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs index 91076e33e..63b16b432 100644 --- a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs +++ b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs @@ -42,6 +42,11 @@ public static WebApplicationBuilder AddAevatarMainnetHost( options.ServiceName = "Aevatar.Mainnet.Host.Api"; options.EnableWebSockets = true; configureHost?.Invoke(options); + // Mainnet invariant — enforced after the caller's configureHost so + // user callbacks cannot re-enable the local file secrets store. + // Secrets must come from AEVATAR_-prefixed environment variables; + // Set/Remove on the secrets store will throw at the call site. + options.AllowLocalFileSecretsStore = false; }); builder.AddMainnetDistributedOrleansHost(); builder.AddAevatarPlatform(options => diff --git a/test/Aevatar.Bootstrap.Tests/AIFeatureBootstrapCoverageTests.cs b/test/Aevatar.Bootstrap.Tests/AIFeatureBootstrapCoverageTests.cs index 46a936045..590998cf9 100644 --- a/test/Aevatar.Bootstrap.Tests/AIFeatureBootstrapCoverageTests.cs +++ b/test/Aevatar.Bootstrap.Tests/AIFeatureBootstrapCoverageTests.cs @@ -391,6 +391,37 @@ public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterN llmFactory.GetDefault().Name.Should().Be("nyxid"); } + [Fact] + public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore() + { + // Mainnet path: a host registers IAevatarSecretsStore (e.g. the + // read-only EnvironmentSecretsStore) into DI but does not pass + // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell + // back to `new AevatarSecretsStore()` which re-opens secrets.json + // from disk. This asserts that the DI-registered store is honored. + var diRegistered = new InMemorySecretsStore(new Dictionary + { + ["LLMProviders:Providers:deepseek:ApiKey"] = "from-di-registered-store", + ["LLMProviders:Providers:deepseek:ProviderType"] = "deepseek", + ["LLMProviders:Default"] = "deepseek", + }); + var services = new ServiceCollection(); + services.AddSingleton(diRegistered); + var config = new ConfigurationBuilder().Build(); + + services.AddAevatarAIFeatures(config, options => + { + options.EnableMEAIProviders = true; + options.EnableMEAIToTornadoFailover = false; + // intentionally leave options.SecretsStore = null + }); + + using var provider = services.BuildServiceProvider(); + var llmFactory = provider.GetRequiredService(); + llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be("deepseek"); + llmFactory.GetDefault().Name.Should().Be("deepseek"); + } + [Fact] public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder() { diff --git a/test/Aevatar.Bootstrap.Tests/BootstrapServiceCollectionExtensionsTests.cs b/test/Aevatar.Bootstrap.Tests/BootstrapServiceCollectionExtensionsTests.cs index 08789f5e9..f11e3e7b5 100644 --- a/test/Aevatar.Bootstrap.Tests/BootstrapServiceCollectionExtensionsTests.cs +++ b/test/Aevatar.Bootstrap.Tests/BootstrapServiceCollectionExtensionsTests.cs @@ -28,7 +28,7 @@ public void AddAevatarBootstrap_ShouldRegisterRuntimeSecretsAndConnectorBuilders using var provider = services.BuildServiceProvider(); provider.GetService().Should().NotBeNull(); - provider.GetService().Should().NotBeNull(); + provider.GetService().Should().BeOfType(); var connectorBuilders = provider.GetServices().ToList(); connectorBuilders.Should().ContainSingle(x => x.GetType() == typeof(HttpConnectorBuilder)); @@ -36,6 +36,60 @@ public void AddAevatarBootstrap_ShouldRegisterRuntimeSecretsAndConnectorBuilders connectorBuilders.Should().ContainSingle(x => x.GetType() == typeof(TelegramUserConnectorBuilder)); } + [Fact] + public void AddAevatarBootstrap_WhenLocalFileSecretsStoreDisabled_ShouldRegisterEnvironmentSecretsStore() + { + using var home = new TemporaryAevatarHomeScope(); + var services = new ServiceCollection(); + var configuration = BuildConfiguration(new Dictionary + { + ["LLMProviders:Default"] = "deepseek", + }); + + // No services.AddSingleton(configuration) — AddAevatarBootstrap + // must register the IConfiguration it received so the env-only + // EnvironmentSecretsStore can resolve without caller workaround. + services.AddAevatarBootstrap(configuration, allowLocalFileSecretsStore: false); + using var provider = services.BuildServiceProvider(); + + var store = provider.GetRequiredService(); + store.Should().BeOfType(); + store.GetDefaultProvider().Should().Be("deepseek"); + store.Invoking(s => s.Set("k", "v")).Should().Throw(); + store.Invoking(s => s.Remove("k")).Should().Throw(); + } + + [Fact] + public void AddAevatarDefaultHost_WhenLocalFileSecretsStoreDisabled_ShouldSkipSecretsJsonAndUseEnvironmentStore() + { + using var home = new TemporaryAevatarHomeScope(); + File.WriteAllText( + Path.Combine(home.Root, "secrets.json"), + """{"FromSecretsJson":"should-not-load"}"""); + Environment.SetEnvironmentVariable("AEVATAR_LLMProviders__Default", "deepseek"); + try + { + var builder = CreateBuilder(); + + builder.AddAevatarDefaultHost(options => + { + options.AllowLocalFileSecretsStore = false; + options.EnableConnectorBootstrap = false; + options.EnableCors = false; + }); + + using var provider = builder.Services.BuildServiceProvider(); + var store = provider.GetRequiredService(); + store.Should().BeOfType(); + builder.Configuration["FromSecretsJson"].Should().BeNull(); + builder.Configuration["LLMProviders:Default"].Should().Be("deepseek"); + } + finally + { + Environment.SetEnvironmentVariable("AEVATAR_LLMProviders__Default", null); + } + } + [Fact] public void AddAevatarBootstrap_WhenCalledTwice_ShouldNotDuplicateConnectorBuilders() { @@ -195,9 +249,12 @@ public TemporaryAevatarHomeScope() { _previous = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv); _path = Path.Combine(Path.GetTempPath(), $"aevatar-bootstrap-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_path); Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, _path); } + public string Root => _path; + public void Dispose() { Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, _previous); diff --git a/test/Aevatar.Foundation.Abstractions.Tests/EnvironmentSecretsStoreTests.cs b/test/Aevatar.Foundation.Abstractions.Tests/EnvironmentSecretsStoreTests.cs new file mode 100644 index 000000000..e83840386 --- /dev/null +++ b/test/Aevatar.Foundation.Abstractions.Tests/EnvironmentSecretsStoreTests.cs @@ -0,0 +1,103 @@ +using Aevatar.Configuration; +using Microsoft.Extensions.Configuration; +using Shouldly; + +namespace Aevatar.Foundation.Abstractions.Tests; + +public sealed class EnvironmentSecretsStoreTests +{ + [Fact] + public void Get_ShouldReturnConfigurationValue_OrNullWhenMissing() + { + var configuration = BuildConfiguration(new() + { + ["Custom:Value"] = "v", + ["Empty"] = "", + }); + var store = new EnvironmentSecretsStore(configuration); + + store.Get("Custom:Value").ShouldBe("v"); + store.Get("Missing").ShouldBeNull(); + store.Get("Empty").ShouldBeNull(); + } + + [Fact] + public void GetApiKey_ShouldResolveByProviderConventions_InOrder() + { + var configuration = BuildConfiguration(new() + { + ["LLMProviders:Providers:deepseek:ApiKey"] = "k1", + ["LLMProviders:openai:ApiKey"] = "k2", + ["GROQ_API_KEY"] = "k3", + ["LLMProviders:Default"] = "deepseek", + }); + var store = new EnvironmentSecretsStore(configuration); + + // Convention 1: LLMProviders:Providers:{name}:ApiKey + store.GetApiKey("deepseek").ShouldBe("k1"); + // Convention 2: LLMProviders:{name}:ApiKey + store.GetApiKey("openai").ShouldBe("k2"); + // Convention 3: {PROVIDER}_API_KEY + store.GetApiKey("GROQ").ShouldBe("k3"); + store.GetApiKey("missing").ShouldBeNull(); + store.GetDefaultProvider().ShouldBe("deepseek"); + } + + [Fact] + public void GetAll_ShouldOnlyReturnSecretShapedKeys_NotArbitraryConfiguration() + { + var configuration = BuildConfiguration(new() + { + // Secret-shaped: included + ["LLMProviders:Providers:deepseek:ApiKey"] = "k1", + ["LLMProviders:Default"] = "deepseek", + ["GROQ_API_KEY"] = "k2", + // Non-secret config: must NOT leak through GetAll + ["ConnectionStrings:Db"] = "Server=...;Pwd=should-not-leak", + ["Cors:AllowedOrigins:0"] = "https://app.example.com", + ["FeatureFlags:Foo"] = "true", + ["Empty"] = "", + }); + var store = new EnvironmentSecretsStore(configuration); + + var snapshot = store.GetAll(); + + snapshot.ContainsKey("LLMProviders:Providers:deepseek:ApiKey").ShouldBeTrue(); + snapshot.ContainsKey("LLMProviders:Default").ShouldBeTrue(); + snapshot.ContainsKey("GROQ_API_KEY").ShouldBeTrue(); + snapshot.ContainsKey("ConnectionStrings:Db").ShouldBeFalse(); + snapshot.ContainsKey("Cors:AllowedOrigins:0").ShouldBeFalse(); + snapshot.ContainsKey("FeatureFlags:Foo").ShouldBeFalse(); + snapshot.ContainsKey("Empty").ShouldBeFalse(); + } + + [Fact] + public void Set_ShouldThrow_BecauseStoreIsReadOnly() + { + var store = new EnvironmentSecretsStore(BuildConfiguration()); + + Should.Throw(() => store.Set("k", "v")); + } + + [Fact] + public void Remove_ShouldThrow_BecauseStoreIsReadOnly() + { + var store = new EnvironmentSecretsStore(BuildConfiguration()); + + Should.Throw(() => store.Remove("k")); + } + + [Fact] + public void Constructor_ShouldRejectNullConfiguration() + { + Should.Throw(() => new EnvironmentSecretsStore(null!)); + } + + private static IConfiguration BuildConfiguration(Dictionary? values = null) + { + var builder = new ConfigurationBuilder(); + if (values != null) + builder.AddInMemoryCollection(values); + return builder.Build(); + } +} diff --git a/test/Aevatar.Foundation.Abstractions.Tests/ServiceCollectionExtensionsTests.cs b/test/Aevatar.Foundation.Abstractions.Tests/ServiceCollectionExtensionsTests.cs index d2258b3ed..0f20c1809 100644 --- a/test/Aevatar.Foundation.Abstractions.Tests/ServiceCollectionExtensionsTests.cs +++ b/test/Aevatar.Foundation.Abstractions.Tests/ServiceCollectionExtensionsTests.cs @@ -1,5 +1,6 @@ using Aevatar.Configuration; using Aevatar.Foundation.Abstractions.Credentials; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Shouldly; @@ -26,6 +27,52 @@ public void AddAevatarConfig_ShouldRegisterCredentialProviderServices() Directory.Exists(Path.Combine(tempRoot, "skills")).ShouldBeTrue(); } + [Fact] + public void AddAevatarConfig_ByDefault_ShouldRegisterFileBackedSecretsStore() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"aevatar-config-tests-{Guid.NewGuid():N}"); + using var scope = new EnvironmentVariableScope(AevatarPaths.HomeEnv, tempRoot); + var services = new ServiceCollection(); + + services.AddAevatarConfig(); + + services.Any(d => + d.ServiceType == typeof(IAevatarSecretsStore) && + d.ImplementationType == typeof(AevatarSecretsStore)) + .ShouldBeTrue(); + services.Any(d => d.ImplementationType == typeof(EnvironmentSecretsStore)) + .ShouldBeFalse(); + } + + [Fact] + public void AddAevatarConfig_WhenLocalFileStoreDisabled_ShouldRegisterEnvironmentSecretsStore() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"aevatar-config-tests-{Guid.NewGuid():N}"); + using var scope = new EnvironmentVariableScope(AevatarPaths.HomeEnv, tempRoot); + var services = new ServiceCollection(); + + services.AddAevatarConfig(allowLocalFileStore: false); + + services.Any(d => + d.ServiceType == typeof(IAevatarSecretsStore) && + d.ImplementationType == typeof(EnvironmentSecretsStore)) + .ShouldBeTrue(); + services.Any(d => d.ImplementationType == typeof(AevatarSecretsStore)) + .ShouldBeFalse(); + } + + [Fact] + public void AddAevatarConfig_WhenLocalFileStoreDisabled_ShouldNotCreateLocalDirectoryTree() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"aevatar-config-tests-{Guid.NewGuid():N}"); + using var scope = new EnvironmentVariableScope(AevatarPaths.HomeEnv, tempRoot); + var services = new ServiceCollection(); + + services.AddAevatarConfig(allowLocalFileStore: false); + + Directory.Exists(tempRoot).ShouldBeFalse(); + } + private sealed class EnvironmentVariableScope : IDisposable { private readonly string _name; diff --git a/test/Aevatar.Hosting.Tests/MainnetSecretsStoreInvariantTests.cs b/test/Aevatar.Hosting.Tests/MainnetSecretsStoreInvariantTests.cs new file mode 100644 index 000000000..200ee3c5f --- /dev/null +++ b/test/Aevatar.Hosting.Tests/MainnetSecretsStoreInvariantTests.cs @@ -0,0 +1,86 @@ +using Aevatar.Bootstrap.Hosting; +using Aevatar.Configuration; +using Aevatar.Mainnet.Host.Api.Hosting; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Aevatar.Hosting.Tests; + +public sealed class MainnetSecretsStoreInvariantTests +{ + [Fact] + public void AddAevatarMainnetHost_ShouldRegisterEnvironmentSecretsStore_RegardlessOfCallerOverride() + { + using var home = new TemporaryAevatarHomeScope(); + using var runtimeProvider = new EnvironmentVariableScope( + "AEVATAR_ActorRuntime__Provider", "InMemory"); + + var builder = CreateBuilder(); + + // Caller deliberately tries to flip the flag back on. The mainnet + // bootstrap must enforce false AFTER configureHost runs so this is + // ignored — otherwise mainnet would silently re-enable the file store. + builder.AddAevatarMainnetHost(options => + { + options.AllowLocalFileSecretsStore = true; + options.EnableConnectorBootstrap = false; + options.EnableCors = false; + }); + + var hostOptions = builder.Services + .BuildServiceProvider() + .GetRequiredService(); + hostOptions.AllowLocalFileSecretsStore.Should().BeFalse(); + + // The DI registration must reflect the enforced invariant. + var descriptor = builder.Services.Single(d => d.ServiceType == typeof(IAevatarSecretsStore)); + descriptor.ImplementationType.Should().Be(typeof(EnvironmentSecretsStore)); + } + + private static WebApplicationBuilder CreateBuilder() => + WebApplication.CreateBuilder(new WebApplicationOptions + { + EnvironmentName = Environments.Development, + }); + + private sealed class TemporaryAevatarHomeScope : IDisposable + { + private readonly string? _previous; + private readonly string _path; + + public TemporaryAevatarHomeScope() + { + _previous = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv); + _path = Path.Combine(Path.GetTempPath(), $"aevatar-mainnet-invariant-{Guid.NewGuid():N}"); + Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, _path); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, _previous); + if (Directory.Exists(_path)) + Directory.Delete(_path, recursive: true); + } + } + + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly string _name; + private readonly string? _previous; + + public EnvironmentVariableScope(string name, string value) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(_name, _previous); + } + } +}