Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/Aevatar.Bootstrap.Extensions.AI/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILLMProviderFactory>(sp =>
{
var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);
var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();
return new ReloadableLLMProviderFactory(
() => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor),
Expand All @@ -391,8 +391,11 @@ private static void RegisterMeaiProviders(
return;
}

var factory = BuildLlmProviderFactory(configuration, options, secretsStoreAccessor);
services.TryAddSingleton<ILLMProviderFactory>(factory);
services.TryAddSingleton<ILLMProviderFactory>(sp =>
{
var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);
return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor);
});
}

private static ILLMProviderFactory BuildLlmProviderFactory(
Expand Down Expand Up @@ -586,11 +589,21 @@ private static NyxIdLLMProviderFactory BuildNyxIdFactory(
return new ConfiguredProvider("nyxid", "nyxid", model, gatewayEndpoint, string.Empty);
}

private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(AevatarAIFeatureOptions options)
private static Func<IAevatarSecretsStore> 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<IAevatarSecretsStore>();
if (registered != null)
return () => registered;

return static () => new AevatarSecretsStore();
}

Expand Down
26 changes: 24 additions & 2 deletions src/Aevatar.Bootstrap/Hosting/WebApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ public sealed class AevatarDefaultHostOptions
public bool EnableOpenApiDocument { get; set; } = true;

public string OpenApiDocumentRoute { get; set; } = "/api/openapi.json";

/// <summary>
/// Whether the host may use the local file secrets store
/// (<c>~/.aevatar/secrets.json</c>) and register
/// <c>AevatarSecretsStore</c>.
/// <para>
/// <c>true</c> (default): legacy behavior — secrets.json is loaded into
/// <see cref="Microsoft.Extensions.Configuration.IConfiguration"/> and a
/// read/write store is registered. Suitable for local dev, CLI tools,
/// localnet, and demos.
/// </para>
/// <para>
/// <c>false</c>: production / mainnet hosts. The host must not load or
/// persist secrets to disk. <c>secrets.json</c> is skipped and the
/// read-only <c>EnvironmentSecretsStore</c> is registered; secrets must
/// come from configuration / <c>AEVATAR_</c>-prefixed environment
/// variables. Mutation methods on the store throw on call.
/// </para>
/// </summary>
public bool AllowLocalFileSecretsStore { get; set; } = true;
}

public static class WebApplicationBuilderExtensions
Expand All @@ -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
{
Expand Down
11 changes: 9 additions & 2 deletions src/Aevatar.Bootstrap/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: AddAevatarBootstrap(..., false) still leaves EnvironmentSecretsStore unresolvable

The env-only path still depends on callers pre-registering IConfiguration even when they use this bootstrap overload, which already receives the configuration instance. In plain ServiceCollection usage, services.AddAevatarBootstrap(configuration, allowLocalFileSecretsStore: false) registers EnvironmentSecretsStore, but resolving IAevatarSecretsStore later fails because DI has no IConfiguration. The new test masks this by calling services.AddSingleton(configuration) before the bootstrap call; production callers of this overload should not need that extra undocumented step. Please register the passed configuration here, e.g. services.TryAddSingleton<IConfiguration>(configuration), before AddAevatarConfig(false).

services.AddHttpClient();
services.AddAevatarActorRuntime(configuration);
RegisterConnectorBuilders(services);
Expand Down
20 changes: 16 additions & 4 deletions src/Aevatar.Configuration/AevatarConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,29 @@ public static class AevatarConfigLoader
/// 将 ~/.aevatar/ 下的配置文件添加到 IConfigurationBuilder。
/// </summary>
/// <param name="builder">配置构建器。</param>
/// <param name="allowLocalFileStore">
/// 是否允许加载 <c>~/.aevatar/secrets.json</c>。生产/mainnet 入口必须传
/// <c>false</c>,确保 secrets 仅来自部署平台注入的环境变量。其他 host 可
/// 保留默认值。<c>config.json</c>、<c>mcp.json</c>、<c>connectors.json</c>
/// 等非敏感文件不受此开关影响。
/// </param>
/// <returns>构建器(链式调用)。</returns>
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);
Expand Down
99 changes: 99 additions & 0 deletions src/Aevatar.Configuration/EnvironmentSecretsStore.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Read-only secrets store that resolves keys from <see cref="IConfiguration"/>.
/// Mutation methods throw <see cref="InvalidOperationException"/> by design:
/// hosts opting into this store have explicitly disabled local file persistence.
/// </summary>
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;
}

/// <summary>
/// Returns a snapshot of secret-shaped configuration entries. Only keys
/// that match the conventions <see cref="GetApiKey"/> understands are
/// included: anything under <c>LLMProviders:</c> (provider definitions,
/// API keys, default name) and any <c>{NAME}_API_KEY</c>-style keys.
/// <para>
/// This is intentionally narrower than dumping the entire
/// <see cref="IConfiguration"/> 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.
/// </para>
/// </summary>
public IReadOnlyDictionary<string, string> GetAll()
{
var snapshot = new Dictionary<string, string>(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.");
}
12 changes: 11 additions & 1 deletion src/Aevatar.Configuration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 是什么、用来做什么
Expand Down
47 changes: 41 additions & 6 deletions src/Aevatar.Configuration/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,58 @@
// 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;

/// <summary>Aevatar Config 的 DI 注册扩展。</summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 注册 Aevatar 配置服务。自动创建 ~/.aevatar/ 目录结构。
/// 注册 Aevatar 配置服务。
/// </summary>
public static IServiceCollection AddAevatarConfig(this IServiceCollection services)
/// <param name="services">DI 容器。</param>
/// <param name="allowLocalFileStore">
/// 是否允许使用本地文件 secrets store(<see cref="AevatarSecretsStore"/>,
/// 读写 <c>~/.aevatar/secrets.json</c>)。
/// <para>
/// <c>true</c>(默认):注册 <see cref="AevatarSecretsStore"/> 并自动创建
/// <c>~/.aevatar/</c> 目录结构,沿用历史行为。
/// </para>
/// <para>
/// <c>false</c>:注册只读的 <see cref="EnvironmentSecretsStore"/>,
/// 仅从 <see cref="IConfiguration"/>(含 <c>AEVATAR_</c> 环境变量)读取,
/// <c>Set</c>/<c>Remove</c> 直接抛 <see cref="InvalidOperationException"/>。
/// 不创建本地目录。生产/mainnet 入口必须传 <c>false</c>。
/// </para>
/// <para>
/// <b>前置条件(仅 <c>false</c> 路径)</b>:DI 容器中必须已注册
/// <see cref="IConfiguration"/>,<see cref="EnvironmentSecretsStore"/>
/// 通过构造函数注入它。<see cref="AddAevatarConfig"/>
/// 自身不注册 <see cref="IConfiguration"/>;通过
/// <c>WebApplicationBuilder</c> 或 <c>HostBuilder</c> 调用本扩展时框架已经
/// 注册好。裸 <see cref="IServiceCollection"/> 调用方需自行
/// <c>services.AddSingleton&lt;IConfiguration&gt;(...)</c>。
/// </para>
/// </param>
public static IServiceCollection AddAevatarConfig(
this IServiceCollection services,
bool allowLocalFileStore = true)
{
AevatarPaths.EnsureDirectories();
services.TryAddSingleton<IAevatarSecretsStore, AevatarSecretsStore>();
services.TryAddSingleton<AevatarSecretsStore>();
if (allowLocalFileStore)
{
AevatarPaths.EnsureDirectories();
services.TryAddSingleton<IAevatarSecretsStore, AevatarSecretsStore>();
services.TryAddSingleton<AevatarSecretsStore>();
}
else
{
services.TryAddSingleton<IAevatarSecretsStore, EnvironmentSecretsStore>();
services.TryAddSingleton<EnvironmentSecretsStore>();
Comment on lines +54 to +55
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register IConfiguration for environment-only secrets mode

When allowLocalFileStore is false, DI now registers EnvironmentSecretsStore, which requires IConfiguration in its constructor, but this extension does not ensure an IConfiguration service exists. In plain ServiceCollection usage (outside WebApplicationBuilder), resolving IAevatarSecretsStore will fail at runtime unless callers manually add configuration, which is a regression introduced by the new environment-only path.

Useful? React with 👍 / 👎.

}
services.TryAddSingleton<ICredentialProvider, SecretsStoreCredentialProvider>();
services.TryAddSingleton<SecretsStoreCredentialProvider>();
return services;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
31 changes: 31 additions & 0 deletions test/Aevatar.Bootstrap.Tests/AIFeatureBootstrapCoverageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
{
["LLMProviders:Providers:deepseek:ApiKey"] = "from-di-registered-store",
["LLMProviders:Providers:deepseek:ProviderType"] = "deepseek",
["LLMProviders:Default"] = "deepseek",
});
var services = new ServiceCollection();
services.AddSingleton<IAevatarSecretsStore>(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<ILLMProviderFactory>();
llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be("deepseek");
llmFactory.GetDefault().Name.Should().Be("deepseek");
}

[Fact]
public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()
{
Expand Down
Loading
Loading