From eef8cd32df8477483dd513de0fdf1c8155e1aeca Mon Sep 17 00:00:00 2001 From: Shangxin Date: Wed, 9 Sep 2026 03:32:18 +0000 Subject: [PATCH 01/11] feat(auth): bind stored credentials to explicit transport destinations --- .../run-acp-credential-transport-gates.sh | 42 +++ .../ServerConfigurationValidator.cs | 9 + .../Config/ConfigServerCommandFactory.cs | 26 +- .../Config/ServerConfigurationHandler.cs | 62 +++- .../Credentials/CredentialsHandler.cs | 7 +- .../Models/CredentialBinding.cs | 23 ++ .../Models/ResolvedCredentialBinding.cs | 51 +++ .../Models/ServerConfiguration.cs | 27 ++ .../Services/CredentialBindingPolicy.cs | 144 +++++++++ .../Services/CredentialBindingResolver.cs | 64 ++++ .../Services/IPlatformCapabilityService.cs | 1 + .../Services/ITransportSupportPolicy.cs | 2 + .../Services/TransportSupportPolicy.cs | 2 + .../Client/TransportFactory.cs | 34 +- .../Network/BoundCredentialHeader.cs | 34 ++ .../Network/StreamableHttpTransport.cs | 23 +- .../Network/WebSocketTransport.cs | 67 +++- .../Services/PlatformCapabilityService.cs | 2 + .../Storage/ConfigurationManager.cs | 42 ++- .../YamlModels/ServerConfigurationYaml.cs | 17 +- .../ServerConfigurationValidatorTests.cs | 19 ++ .../Config/CredentialBindingCommandTests.cs | 108 +++++++ .../CredentialBindingResolverTests.cs | 232 ++++++++++++++ .../SalmonEgg.Infrastructure.Tests.csproj | 5 + .../Storage/ConfigSyncPackageServiceTests.cs | 37 +++ .../Storage/ConfigurationManagerTests.cs | 54 ++++ .../CredentialTransportCanaryTests.cs | 300 ++++++++++++++++++ 27 files changed, 1410 insertions(+), 24 deletions(-) create mode 100755 scripts/gates/run-acp-credential-transport-gates.sh create mode 100644 src/SalmonEgg.Domain/Models/CredentialBinding.cs create mode 100644 src/SalmonEgg.Domain/Models/ResolvedCredentialBinding.cs create mode 100644 src/SalmonEgg.Domain/Services/CredentialBindingPolicy.cs create mode 100644 src/SalmonEgg.Domain/Services/CredentialBindingResolver.cs create mode 100644 src/SalmonEgg.Infrastructure/Network/BoundCredentialHeader.cs create mode 100644 tests/SalmonEgg.Cli.Tests/Commands/Config/CredentialBindingCommandTests.cs create mode 100644 tests/SalmonEgg.Domain.Tests/Services/CredentialBindingResolverTests.cs create mode 100644 tests/SalmonEgg.Infrastructure.Tests/Transport/CredentialTransportCanaryTests.cs diff --git a/scripts/gates/run-acp-credential-transport-gates.sh b/scripts/gates/run-acp-credential-transport-gates.sh new file mode 100755 index 000000000..9894810fb --- /dev/null +++ b/scripts/gates/run-acp-credential-transport-gates.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +if [ "$(uname -s)" != "Linux" ]; then + echo "This gate requires Linux to inspect the actual child environment and command line in /proc." >&2 + exit 1 +fi + +dotnet_bin="${DOTNET_BIN:-dotnet}" +configuration="${1:-Release}" +results_dir="${2:-artifacts/acp-credentials}" +mkdir -p "$results_dir" +log_file="$results_dir/transport-gates.log" + +timeout --signal=TERM --kill-after=10s 180s "$dotnet_bin" test \ + --project tests/SalmonEgg.Infrastructure.Tests/SalmonEgg.Infrastructure.Tests.csproj \ + --configuration "$configuration" \ + --no-ansi \ + -p:UseSharedCompilation=false \ + --filter-class SalmonEgg.Infrastructure.Tests.Transport.CredentialTransportCanaryTests \ + --minimum-expected-tests 8 \ + --output Detailed > "$log_file" 2>&1 || { + cat "$log_file" + exit 1 + } +cat "$log_file" + +python3 - "$log_file" <<'PY' +import pathlib +import re +import sys + +text = pathlib.Path(sys.argv[1]).read_text() +passed = re.findall(r'^\s+succeeded:\s+(\d+)\s*$', text, re.M) +skipped = re.findall(r'^\s+skipped:\s+(\d+)\s*$', text, re.M) +if not passed or int(passed[-1]) < 8 or not skipped or int(skipped[-1]) != 0: + raise SystemExit('Credential transport gate requires every real process and endpoint case with zero skips.') +print('[gate] Credential destination, real child environment, HTTP/2 + SSE, WebSocket, and redirect isolation passed.') +PY diff --git a/src/SalmonEgg.Application/Validators/ServerConfigurationValidator.cs b/src/SalmonEgg.Application/Validators/ServerConfigurationValidator.cs index 370d0db26..6bc282467 100644 --- a/src/SalmonEgg.Application/Validators/ServerConfigurationValidator.cs +++ b/src/SalmonEgg.Application/Validators/ServerConfigurationValidator.cs @@ -1,6 +1,7 @@ using System; using FluentValidation; using SalmonEgg.Domain.Models; +using SalmonEgg.Domain.Services; namespace SalmonEgg.Application.Validators { @@ -70,6 +71,14 @@ public ServerConfigurationValidator() .Must(proxy => proxy == null || Enum.IsDefined(typeof(ProxyMode), proxy.Mode)) .WithMessage("Invalid proxy mode"); + RuleFor(x => x).Custom((configuration, context) => + { + if (CredentialBindingPolicy.GetValidationError(configuration) is { } error) + { + context.AddFailure(nameof(ServerConfiguration.CredentialBinding), error); + } + }); + // Validate proxy configuration (if custom) When(x => x.Proxy != null && x.Proxy.Mode == ProxyMode.Custom, () => { diff --git a/src/SalmonEgg.Cli/Commands/Config/ConfigServerCommandFactory.cs b/src/SalmonEgg.Cli/Commands/Config/ConfigServerCommandFactory.cs index edbb07cca..86c6e637d 100644 --- a/src/SalmonEgg.Cli/Commands/Config/ConfigServerCommandFactory.cs +++ b/src/SalmonEgg.Cli/Commands/Config/ConfigServerCommandFactory.cs @@ -98,6 +98,11 @@ private static void AddOptions( command.Options.Add(authenticationOptions.Token); command.Options.Add(authenticationOptions.ApiKey); command.Options.Add(authenticationOptions.Mode); + command.Options.Add(authenticationOptions.CredentialSource); + command.Options.Add(authenticationOptions.Environment); + command.Options.Add(authenticationOptions.Header); + command.Options.Add(authenticationOptions.Scheme); + command.Options.Add(authenticationOptions.ClearBinding); command.Options.Add(proxyOptions.Mode); command.Options.Add(proxyOptions.Url); } @@ -121,13 +126,23 @@ private static ServerConfigurationPatch CreatePatch( ProxySpecified: proxyModeResult is not null, ProxyMode: parseResult.GetValue(proxyOptions.Mode), ProxyUrlSpecified: proxyUrlResult is not null, - ProxyUrl: parseResult.GetValue(proxyOptions.Url)); + ProxyUrl: parseResult.GetValue(proxyOptions.Url), + CredentialSource: parseResult.GetValue(authenticationOptions.CredentialSource), + CredentialEnvironment: parseResult.GetValue(authenticationOptions.Environment), + CredentialHeader: parseResult.GetValue(authenticationOptions.Header), + CredentialScheme: parseResult.GetValue(authenticationOptions.Scheme), + ClearCredentialBinding: parseResult.GetValue(authenticationOptions.ClearBinding)); } private static AuthenticationOptions CreateAuthenticationOptions() => new( new Option("--token-stdin") { Description = "Read the bearer token from stdin (one line)." }, new Option("--api-key-stdin") { Description = "Read the API key from stdin (one line)." }, - new Option("--auth") { Description = "Authentication mode: none, bearer_token, api_key." }); + new Option("--auth") { Description = "Authentication mode: none, bearer_token, api_key." }, + new Option("--credential-source") { Description = "Stored credential to bind: token or api_key." }, + new Option("--credential-env") { Description = "Bind the credential to this environment variable of the configured stdio command." }, + new Option("--credential-header") { Description = "Bind the credential to this HTTP header of the exact configured endpoint." }, + new Option("--credential-scheme") { Description = "Explicit header scheme, for example Bearer. Omit for a raw value." }, + new Option("--clear-credential-binding") { Description = "Remove the injection binding; keep the separately stored credential." }); private static ProxyOptions CreateProxyOptions() => new( new Option("--proxy-mode") { Description = "Proxy mode: none, system, custom." }, @@ -136,7 +151,12 @@ private static ServerConfigurationPatch CreatePatch( private sealed record AuthenticationOptions( Option Token, Option ApiKey, - Option Mode); + Option Mode, + Option CredentialSource, + Option Environment, + Option Header, + Option Scheme, + Option ClearBinding); private sealed record ProxyOptions( Option Mode, diff --git a/src/SalmonEgg.Cli/Commands/Config/ServerConfigurationHandler.cs b/src/SalmonEgg.Cli/Commands/Config/ServerConfigurationHandler.cs index 6eeb48934..158fa234a 100644 --- a/src/SalmonEgg.Cli/Commands/Config/ServerConfigurationHandler.cs +++ b/src/SalmonEgg.Cli/Commands/Config/ServerConfigurationHandler.cs @@ -19,7 +19,12 @@ public sealed record ServerConfigurationPatch( bool ProxySpecified, ProxyMode? ProxyMode, bool ProxyUrlSpecified, - string? ProxyUrl); + string? ProxyUrl, + string? CredentialSource = null, + string? CredentialEnvironment = null, + string? CredentialHeader = null, + string? CredentialScheme = null, + bool ClearCredentialBinding = false); /// /// Handler methods are pure business logic: they call domain interfaces and write to @@ -294,7 +299,7 @@ public async Task UpdateAsync( return CliExitCodes.Failure; } - config = loaded; + config = loaded.Clone(); } catch (ConfigurationPersistenceException ex) { @@ -500,7 +505,45 @@ private static bool TryApplyPatch( }; } - return true; + return TryApplyCredentialBinding(config, patch, out error); + } + + private static bool TryApplyCredentialBinding(ServerConfiguration config, ServerConfigurationPatch patch, out string? error) + { + error = null; + var hasEnvironment = patch.CredentialEnvironment is not null; + var hasHeader = patch.CredentialHeader is not null; + var hasBindingOptions = hasEnvironment || hasHeader || patch.CredentialSource is not null || patch.CredentialScheme is not null; + if ((hasEnvironment && hasHeader) || (patch.ClearCredentialBinding && hasBindingOptions)) + { + error = "Choose one of --credential-env, --credential-header, or --clear-credential-binding."; + return false; + } + + if (patch.ClearCredentialBinding) + { + config.CredentialBinding = null; + return true; + } + + if (hasBindingOptions) + { + if ((!hasEnvironment && !hasHeader) || patch.CredentialSource is not ("token" or "api_key")) + { + error = "A binding requires --credential-source token|api_key and --credential-env NAME or --credential-header NAME."; + return false; + } + + config.CredentialBinding = CredentialBindingPolicy.Create( + config, + patch.CredentialSource == "token" ? Domain.Models.CredentialSource.Token : Domain.Models.CredentialSource.ApiKey, + hasEnvironment ? CredentialTarget.Environment : CredentialTarget.Header, + patch.CredentialEnvironment ?? patch.CredentialHeader!, + patch.CredentialScheme); + } + + error = CredentialBindingPolicy.GetValidationError(config); + return error is null; } private async Task ResolveCredentialInputAsync( @@ -582,6 +625,19 @@ internal static IEnumerable FormatShowLines(ServerConfiguration config) { yield return "auth: configured (credential unavailable)"; } + + if (config.CredentialBinding is { } binding) + { + var source = binding.Source == Domain.Models.CredentialSource.Token ? "token" : "api_key"; + var target = binding.Target == CredentialTarget.Environment ? "environment" : "header"; + yield return $"binding: {source} -> {target} {binding.Name}"; + if (binding.Target == CredentialTarget.Header) + { + yield return $"scheme: {(string.IsNullOrEmpty(binding.Scheme) ? "raw" : binding.Scheme)}"; + } + + yield return $"credential: {(CredentialBindingResolver.Resolve(config).IsSuccess ? "set" : "unavailable or destination changed")}"; + } } private static string TransportLabel(TransportType t) => t switch diff --git a/src/SalmonEgg.Cli/Commands/Credentials/CredentialsHandler.cs b/src/SalmonEgg.Cli/Commands/Credentials/CredentialsHandler.cs index 97bbfc29f..c3ec4ca11 100644 --- a/src/SalmonEgg.Cli/Commands/Credentials/CredentialsHandler.cs +++ b/src/SalmonEgg.Cli/Commands/Credentials/CredentialsHandler.cs @@ -89,7 +89,7 @@ public async Task SetAsync( return CliExitCodes.Failure; } - config = loaded; + config = loaded.Clone(); config.Authentication = token is not null ? new AuthenticationConfig { Token = token } : new AuthenticationConfig { ApiKey = apiKey }; @@ -119,8 +119,9 @@ public async Task ClearAsync(string serverId, CancellationToken cancellatio return CliExitCodes.Failure; } - loaded.Authentication = null; - await _configurationService.SaveConfigurationAsync(loaded).ConfigureAwait(false); + var cleared = loaded.Clone(); + cleared.Authentication = null; + await _configurationService.SaveConfigurationAsync(cleared).ConfigureAwait(false); } catch (ConfigurationPersistenceException ex) { diff --git a/src/SalmonEgg.Domain/Models/CredentialBinding.cs b/src/SalmonEgg.Domain/Models/CredentialBinding.cs new file mode 100644 index 000000000..79bb9a143 --- /dev/null +++ b/src/SalmonEgg.Domain/Models/CredentialBinding.cs @@ -0,0 +1,23 @@ +namespace SalmonEgg.Domain.Models; + +/// +/// Non-secret instructions binding one stored credential to the profile's explicitly selected target. +/// +public sealed record CredentialBinding( + CredentialSource Source, + CredentialTarget Target, + string Name, + string? Scheme, + string TargetIdentity); + +public enum CredentialSource +{ + Token, + ApiKey, +} + +public enum CredentialTarget +{ + Environment, + Header, +} diff --git a/src/SalmonEgg.Domain/Models/ResolvedCredentialBinding.cs b/src/SalmonEgg.Domain/Models/ResolvedCredentialBinding.cs new file mode 100644 index 000000000..4be360e68 --- /dev/null +++ b/src/SalmonEgg.Domain/Models/ResolvedCredentialBinding.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace SalmonEgg.Domain.Models; + +/// +/// A connection-scoped snapshot. Deliberately not a record: diagnostic formatting must not reveal values. +/// +public sealed class ResolvedCredentialBinding +{ + internal ResolvedCredentialBinding( + IReadOnlyDictionary environment, + Uri? endpoint = null, + string? headerName = null, + string? headerValue = null) + { + Environment = environment; + Endpoint = endpoint; + HeaderName = headerName; + HeaderValue = headerValue; + } + + public IReadOnlyDictionary Environment { get; } + + public Uri? Endpoint { get; } + + public string? HeaderName { get; } + + public string? HeaderValue { get; } + + public bool HasHeader => HeaderName is not null; +} + +public sealed class CredentialBindingResolution +{ + private CredentialBindingResolution(ResolvedCredentialBinding? value, string? error) + { + Value = value; + Error = error; + } + + public ResolvedCredentialBinding? Value { get; } + + public string? Error { get; } + + public bool IsSuccess => Value is not null; + + internal static CredentialBindingResolution Success(ResolvedCredentialBinding value) => new(value, null); + + internal static CredentialBindingResolution Failure(string error) => new(null, error); +} diff --git a/src/SalmonEgg.Domain/Models/ServerConfiguration.cs b/src/SalmonEgg.Domain/Models/ServerConfiguration.cs index d07dbc396..8f8dad00f 100644 --- a/src/SalmonEgg.Domain/Models/ServerConfiguration.cs +++ b/src/SalmonEgg.Domain/Models/ServerConfiguration.cs @@ -60,6 +60,12 @@ public class ServerConfiguration /// public AuthenticationConfig? Authentication { get; set; } + /// + /// Explicit non-secret destination for a stored token or API key. Clearing the credential + /// keeps this binding so reconnect cannot silently inherit an unrelated environment value. + /// + public CredentialBinding? CredentialBinding { get; set; } + /// /// 代理配置 /// @@ -102,5 +108,26 @@ public string EndpointDisplay return ServerUrl ?? string.Empty; } } + + public ServerConfiguration Clone() => new() + { + Id = Id, + PersistenceRevision = PersistenceRevision, + Name = Name, + ServerUrl = ServerUrl, + StdioCommand = StdioCommand, + StdioArguments = new List(StdioArguments), + StdioEnvironment = new Dictionary(StdioEnvironment, StringComparer.Ordinal), + Transport = Transport, + Authentication = Authentication is null ? null : new AuthenticationConfig + { + Token = Authentication.Token, + ApiKey = Authentication.ApiKey, + }, + CredentialBinding = CredentialBinding, + Proxy = Proxy is null ? null : new ProxyConfig { Mode = Proxy.Mode, ProxyUrl = Proxy.ProxyUrl }, + ConnectionTimeout = ConnectionTimeout, + Verification = Verification, + }; } } diff --git a/src/SalmonEgg.Domain/Services/CredentialBindingPolicy.cs b/src/SalmonEgg.Domain/Services/CredentialBindingPolicy.cs new file mode 100644 index 000000000..3226ed66b --- /dev/null +++ b/src/SalmonEgg.Domain/Services/CredentialBindingPolicy.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using SalmonEgg.Domain.Models; + +namespace SalmonEgg.Domain.Services; + +/// +/// Owns the persisted binding vocabulary and destination identity; it never reads a credential. +/// +public static class CredentialBindingPolicy +{ + private static readonly HashSet ReservedHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "Host", "Connection", "Content-Length", "Content-Type", "Accept", "Upgrade", "Transfer-Encoding", + "TE", "Trailer", "Keep-Alive", "Proxy-Authorization", "Proxy-Connection", "Cookie", "Set-Cookie", + }; + + public static CredentialBinding Create( + ServerConfiguration configuration, + CredentialSource source, + CredentialTarget target, + string name, + string? scheme = null) + { + ArgumentNullException.ThrowIfNull(configuration); + return new CredentialBinding(source, target, name, scheme, GetTargetIdentity(configuration)); + } + + public static string? GetValidationError(ServerConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + var binding = configuration.CredentialBinding; + if (binding is null) + { + return null; + } + + if (!Enum.IsDefined(binding.Source) || !Enum.IsDefined(binding.Target)) + { + return "The credential binding source or target is unsupported. Choose token or API key and an environment variable or header."; + } + + if (!string.Equals(binding.TargetIdentity, GetTargetIdentity(configuration), StringComparison.Ordinal)) + { + return "The profile destination changed. Bind the credential again to approve its new destination."; + } + + if (binding.Target == CredentialTarget.Environment) + { + return GetEnvironmentBindingError(configuration, binding); + } + + return GetHeaderBindingError(configuration, binding); + } + + public static bool MatchesEndpoint(Uri expected, Uri actual) + => string.Equals(expected.AbsoluteUri, actual.AbsoluteUri, StringComparison.Ordinal); + + private static string GetTargetIdentity(ServerConfiguration configuration) + { + var parts = new List { ((int)configuration.Transport).ToString(CultureInfo.InvariantCulture) }; + if (configuration.Transport == TransportType.Stdio) + { + parts.Add((configuration.StdioCommand ?? string.Empty).Trim()); + parts.Add(StdioCommandLine.CanonicalizeArguments(configuration.StdioArguments)); + foreach (var pair in configuration.StdioEnvironment.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + parts.Add(pair.Key); + parts.Add(pair.Value); + } + } + else + { + parts.Add(Uri.TryCreate(configuration.ServerUrl, UriKind.Absolute, out var endpoint) + ? endpoint.AbsoluteUri + : configuration.ServerUrl ?? string.Empty); + } + + var canonical = StdioCommandLine.CanonicalizeArguments(parts); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); + } + + private static string? GetEnvironmentBindingError(ServerConfiguration configuration, CredentialBinding binding) + { + if (configuration.Transport != TransportType.Stdio || string.IsNullOrWhiteSpace(configuration.StdioCommand)) + { + return "An environment credential requires a configured stdio command."; + } + + if (string.IsNullOrWhiteSpace(binding.Name) || binding.Name != binding.Name.Trim() + || binding.Name.Contains('=') || binding.Name.Any(char.IsControl)) + { + return "Choose a non-empty environment variable name without '=' or null characters."; + } + + if (string.Equals(binding.Name, "PATH", StringComparison.OrdinalIgnoreCase) + || string.Equals(binding.Name, "PATHEXT", StringComparison.OrdinalIgnoreCase)) + { + return "The launcher owns PATH and PATHEXT. Choose the agent's credential environment variable."; + } + + if (!string.IsNullOrEmpty(binding.Scheme)) + { + return "An environment credential has no header scheme. Remove the scheme."; + } + + return null; + } + + private static string? GetHeaderBindingError(ServerConfiguration configuration, CredentialBinding binding) + { + if (configuration.Transport is not (TransportType.WebSocket or TransportType.StreamableHttp) + || !Uri.TryCreate(configuration.ServerUrl, UriKind.Absolute, out var endpoint) + || !string.IsNullOrEmpty(endpoint.UserInfo) + || !string.IsNullOrEmpty(endpoint.Fragment) + || (configuration.Transport == TransportType.WebSocket && endpoint.Scheme is not ("ws" or "wss")) + || (configuration.Transport == TransportType.StreamableHttp && endpoint.Scheme is not ("http" or "https"))) + { + return "A header credential requires a matching HTTP or WebSocket endpoint without user information or a fragment."; + } + + if (!IsHttpToken(binding.Name) || ReservedHeaders.Contains(binding.Name) + || binding.Name.StartsWith("Acp-", StringComparison.OrdinalIgnoreCase) + || binding.Name.StartsWith("Sec-", StringComparison.OrdinalIgnoreCase)) + { + return "Choose an authentication header name, not a transport-controlled header."; + } + + if (!string.IsNullOrEmpty(binding.Scheme) && !IsHttpToken(binding.Scheme)) + { + return "Use one HTTP token for the header scheme, or leave it empty for a raw value."; + } + + return null; + } + + private static bool IsHttpToken(string? value) + => !string.IsNullOrEmpty(value) && value.All(character => char.IsAsciiLetterOrDigit(character) + || "!#$%&'*+-.^_`|~".Contains(character)); +} diff --git a/src/SalmonEgg.Domain/Services/CredentialBindingResolver.cs b/src/SalmonEgg.Domain/Services/CredentialBindingResolver.cs new file mode 100644 index 000000000..b3360802b --- /dev/null +++ b/src/SalmonEgg.Domain/Services/CredentialBindingResolver.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using SalmonEgg.Domain.Models; + +namespace SalmonEgg.Domain.Services; + +/// +/// Resolves only the already hydrated configuration snapshot. Secure storage and persistence retain +/// their existing owner; transport creation performs no hidden reads or writes. +/// +public static class CredentialBindingResolver +{ + public static CredentialBindingResolution Resolve(ServerConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + if (CredentialBindingPolicy.GetValidationError(configuration) is { } error) + { + return CredentialBindingResolution.Failure(error); + } + + var environment = new Dictionary(configuration.StdioEnvironment, StringComparer.Ordinal); + var binding = configuration.CredentialBinding; + if (binding is null) + { + return CredentialBindingResolution.Success(new ResolvedCredentialBinding( + new ReadOnlyDictionary(environment))); + } + + var secret = binding.Source == CredentialSource.Token + ? configuration.Authentication?.Token + : configuration.Authentication?.ApiKey; + if (string.IsNullOrEmpty(secret)) + { + return CredentialBindingResolution.Failure( + "The bound credential is not set. Set it in secure storage or remove the credential binding before connecting."); + } + + if (secret.Contains('\0') || (binding.Target == CredentialTarget.Header && secret.Any(character => character < ' ' || character > '~'))) + { + return CredentialBindingResolution.Failure("The credential contains characters unsupported by its destination. Replace the stored credential."); + } + + if (binding.Target == CredentialTarget.Environment) + { + // Windows environment names are case-insensitive. Remove aliases on all platforms so the + // child receives one unambiguous value without putting OS checks in the domain layer. + foreach (var key in environment.Keys.Where(key => string.Equals(key, binding.Name, StringComparison.OrdinalIgnoreCase)).ToArray()) + { + environment.Remove(key); + } + + environment[binding.Name] = secret; + return CredentialBindingResolution.Success(new ResolvedCredentialBinding( + new ReadOnlyDictionary(environment))); + } + + var headerValue = string.IsNullOrEmpty(binding.Scheme) ? secret : binding.Scheme + " " + secret; + return CredentialBindingResolution.Success(new ResolvedCredentialBinding( + new ReadOnlyDictionary(environment), + new Uri(configuration.ServerUrl), binding.Name, headerValue)); + } +} diff --git a/src/SalmonEgg.Domain/Services/IPlatformCapabilityService.cs b/src/SalmonEgg.Domain/Services/IPlatformCapabilityService.cs index 961762d2c..f6f39e9f6 100644 --- a/src/SalmonEgg.Domain/Services/IPlatformCapabilityService.cs +++ b/src/SalmonEgg.Domain/Services/IPlatformCapabilityService.cs @@ -9,6 +9,7 @@ public interface IPlatformCapabilityService bool SupportsExternalFileOpen { get; } bool SupportsLocalFileExport { get; } bool SupportsStdioTransport { get; } + bool SupportsWebSocketRequestHeaders => false; bool SupportsInteractiveTerminalSurface { get; } bool SupportsLocalTerminal { get; } bool SupportsGamepadInput { get; } diff --git a/src/SalmonEgg.Domain/Services/ITransportSupportPolicy.cs b/src/SalmonEgg.Domain/Services/ITransportSupportPolicy.cs index 91bb68980..3bcc50fe2 100644 --- a/src/SalmonEgg.Domain/Services/ITransportSupportPolicy.cs +++ b/src/SalmonEgg.Domain/Services/ITransportSupportPolicy.cs @@ -6,6 +6,8 @@ public interface ITransportSupportPolicy { TransportType DefaultTransport { get; } + bool SupportsWebSocketRequestHeaders => false; + bool IsSupported(TransportType transport); TransportType Coerce(TransportType requested); diff --git a/src/SalmonEgg.Domain/Services/TransportSupportPolicy.cs b/src/SalmonEgg.Domain/Services/TransportSupportPolicy.cs index 97e1ee60d..bbb9d4a09 100644 --- a/src/SalmonEgg.Domain/Services/TransportSupportPolicy.cs +++ b/src/SalmonEgg.Domain/Services/TransportSupportPolicy.cs @@ -15,6 +15,8 @@ public TransportSupportPolicy(IPlatformCapabilityService capabilities) public TransportType DefaultTransport => _capabilities.SupportsStdioTransport ? TransportType.Stdio : TransportType.WebSocket; + public bool SupportsWebSocketRequestHeaders => _capabilities.SupportsWebSocketRequestHeaders; + public bool IsSupported(TransportType transport) => transport switch { diff --git a/src/SalmonEgg.Infrastructure/Client/TransportFactory.cs b/src/SalmonEgg.Infrastructure/Client/TransportFactory.cs index f277f1b6b..cda804568 100644 --- a/src/SalmonEgg.Infrastructure/Client/TransportFactory.cs +++ b/src/SalmonEgg.Infrastructure/Client/TransportFactory.cs @@ -68,6 +68,18 @@ public SalmonEgg.Domain.Interfaces.Transport.ITransport CreateTransport(ServerCo _logger.Information("Creating transport instance from configuration. TransportType={TransportType}, ProfileId={ProfileId}", configuration.Transport, configuration.Id); var connectTimeout = AcpConnectionTimeoutPolicy.ResolveTimeout(configuration.ConnectionTimeout); + var resolution = CredentialBindingResolver.Resolve(configuration); + if (!resolution.IsSuccess) + { + throw new InvalidOperationException(resolution.Error); + } + + if (resolution.Value!.HasHeader && configuration.Transport == TransportType.WebSocket + && !_transportSupportPolicy.SupportsWebSocketRequestHeaders) + { + throw new NotSupportedException( + "This platform cannot send custom WebSocket authentication headers. Use an HTTP endpoint or connect from the desktop app."); + } return CreateTransportCore( configuration.Transport, @@ -76,7 +88,8 @@ public SalmonEgg.Domain.Interfaces.Transport.ITransport CreateTransport(ServerCo configuration.Transport == TransportType.Stdio ? null : configuration.ServerUrl, connectTimeout, configuration.Proxy, - configuration.Transport == TransportType.Stdio ? configuration.StdioEnvironment : null); + configuration.Transport == TransportType.Stdio ? resolution.Value.Environment : null, + resolution.Value.HasHeader ? resolution.Value : null); } private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateTransportCore( @@ -86,15 +99,16 @@ private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateTransportCore( string? url, TimeSpan connectTimeout, ProxyConfig? proxy = null, - IReadOnlyDictionary? stdioEnvironment = null) + IReadOnlyDictionary? stdioEnvironment = null, + ResolvedCredentialBinding? credential = null) { _logger.Information("Creating transport instance. TransportType={TransportType}", transportType); return transportType switch { TransportType.Stdio => CreateStdioTransport(command, arguments, stdioEnvironment), - TransportType.WebSocket => CreateWebSocketTransport(url, connectTimeout, proxy), - TransportType.StreamableHttp => CreateStreamableHttpTransport(url, connectTimeout, proxy), + TransportType.WebSocket => CreateWebSocketTransport(url, connectTimeout, proxy, credential), + TransportType.StreamableHttp => CreateStreamableHttpTransport(url, connectTimeout, proxy, credential), _ => throw new NotSupportedException($"Unsupported transport type: {transportType}.") }; } @@ -136,7 +150,8 @@ private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateStdioTransport( /// WebSocket URL /// WebSocket 传输实例 /// 当 URL 为空或无效时抛出 - private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateWebSocketTransport(string? url, TimeSpan connectTimeout, ProxyConfig? proxy) + private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateWebSocketTransport( + string? url, TimeSpan connectTimeout, ProxyConfig? proxy, ResolvedCredentialBinding? credential) { if (string.IsNullOrWhiteSpace(url)) { @@ -154,7 +169,8 @@ private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateWebSocketTranspor var inner = new SalmonEgg.Infrastructure.Network.WebSocketTransport( logger, proxyConfiguration: proxy, - connectTimeout: connectTimeout); + connectTimeout: connectTimeout, + credential: credential); return new NetworkTransportAdapter(inner, url.Trim()); } @@ -169,7 +185,8 @@ private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateWebSocketTranspor private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateStreamableHttpTransport( string? url, TimeSpan connectTimeout, - ProxyConfig? proxy) + ProxyConfig? proxy, + ResolvedCredentialBinding? credential) { if (string.IsNullOrWhiteSpace(url)) { @@ -190,7 +207,8 @@ private SalmonEgg.Domain.Interfaces.Transport.ITransport CreateStreamableHttpTra var inner = new SalmonEgg.Infrastructure.Network.StreamableHttpTransport( _logger, proxyConfiguration: proxy, - connectTimeout: connectTimeout); + connectTimeout: connectTimeout, + credential: credential); return new NetworkTransportAdapter(inner, url.Trim()); } } diff --git a/src/SalmonEgg.Infrastructure/Network/BoundCredentialHeader.cs b/src/SalmonEgg.Infrastructure/Network/BoundCredentialHeader.cs new file mode 100644 index 000000000..5aecf33ac --- /dev/null +++ b/src/SalmonEgg.Infrastructure/Network/BoundCredentialHeader.cs @@ -0,0 +1,34 @@ +using System; +using System.Net.Http; +using SalmonEgg.Domain.Models; +using SalmonEgg.Domain.Services; + +namespace SalmonEgg.Infrastructure.Network; + +internal static class BoundCredentialHeader +{ + internal static void EnsureEndpoint(ResolvedCredentialBinding? credential, Uri endpoint) + { + if (credential is { HasHeader: true } + && !CredentialBindingPolicy.MatchesEndpoint(credential.Endpoint!, endpoint)) + { + throw new InvalidOperationException("The connection destination differs from its credential binding. Recreate the connection from the updated profile."); + } + } + + internal static void Apply(ResolvedCredentialBinding? credential, HttpRequestMessage request) + { + if (credential is not { HasHeader: true }) + { + return; + } + + EnsureEndpoint(credential, request.RequestUri!); + // Resolver has validated the field name and every value byte. Avoid value parsers for known + // headers: their FormatException can include the credential in its diagnostic message. + if (!request.Headers.TryAddWithoutValidation(credential.HeaderName!, credential.HeaderValue!)) + { + throw new InvalidOperationException("The authentication header is not supported by this transport."); + } + } +} diff --git a/src/SalmonEgg.Infrastructure/Network/StreamableHttpTransport.cs b/src/SalmonEgg.Infrastructure/Network/StreamableHttpTransport.cs index 6892db730..bc9cff689 100644 --- a/src/SalmonEgg.Infrastructure/Network/StreamableHttpTransport.cs +++ b/src/SalmonEgg.Infrastructure/Network/StreamableHttpTransport.cs @@ -36,6 +36,7 @@ public sealed class StreamableHttpTransport : ITransport, IDisposable private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; + private readonly ResolvedCredentialBinding? _credential; private readonly Subject _messagesSubject = new(); private readonly BehaviorSubject _stateSubject = new(TransportState.Disconnected); // 连接级流、各会话级流与 POST 内联正文是并发生产者;下游(ChatService 顺序管道) @@ -65,22 +66,31 @@ public StreamableHttpTransport( ILogger logger, HttpClient? httpClient = null, ProxyConfig? proxyConfiguration = null, - TimeSpan? connectTimeout = null) + TimeSpan? connectTimeout = null, + ResolvedCredentialBinding? credential = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + if (credential is { HasHeader: true } && httpClient is not null) + { + throw new ArgumentException("A credential-bound transport must own its redirect policy; an arbitrary HttpClient is unsupported.", nameof(httpClient)); + } + + _credential = credential; _ownsHttpClient = httpClient is null; // SSE 流是长连接,HttpClient 级超时须为无限,单次请求超时交由 CancellationToken 控制。 // 注入的 HttpClient(测试用)原样沿用;自建时按代理配置装配 handler。 - _httpClient = httpClient ?? CreateHttpClient(proxyConfiguration); + _httpClient = httpClient ?? CreateHttpClient(proxyConfiguration, credential is { HasHeader: true }); _connectTimeout = connectTimeout ?? TimeSpan.FromSeconds(AcpConnectionTimeoutPolicy.DefaultSeconds); } // 与 WebSocketTransport 对齐:自建 HttpClient 时按 ProxyConfig 装配 handler, // 使 Streamable HTTP 与 WebSocket 走同一套代理事实源;注入 HttpClient 时不覆盖调用方选择。 - private static HttpClient CreateHttpClient(ProxyConfig? proxyConfiguration) + private static HttpClient CreateHttpClient(ProxyConfig? proxyConfiguration, bool hasCredential) { var mode = proxyConfiguration?.Mode ?? ProxyConfig.DefaultMode; - var handler = new HttpClientHandler(); + // Even same-origin redirects may name another agent or tenant. Only the bound endpoint + // receives this credential; the browser handler maps false to Fetch's manual redirect mode. + var handler = new HttpClientHandler { AllowAutoRedirect = !hasCredential }; switch (mode) { @@ -122,6 +132,8 @@ public Task ConnectAsync(string url, CancellationToken ct) throw new ArgumentException($"Invalid Streamable HTTP endpoint URL: {url}", nameof(url)); } + BoundCredentialHeader.EnsureEndpoint(_credential, endpoint); + if (_connectionCts is { IsCancellationRequested: false }) { _logger.Warning("Streamable HTTP transport is already connected to {Url}", url); @@ -333,6 +345,7 @@ private HttpRequestMessage CreateJsonPost(string message, bool includeConnection Version = System.Net.HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher }; + BoundCredentialHeader.Apply(_credential, request); if (includeConnectionId && _connectionId is not null) { @@ -445,6 +458,7 @@ private async Task ReceiveStreamOnceAsync(string? sessionId, CancellationT Version = System.Net.HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher }; + BoundCredentialHeader.Apply(_credential, request); request.Headers.Add("Accept", "text/event-stream"); if (_connectionId is not null) { @@ -562,6 +576,7 @@ private async Task SendTerminateBestEffortAsync() Version = System.Net.HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher }; + BoundCredentialHeader.Apply(_credential, request); request.Headers.Add(ConnectionIdHeader, _connectionId); using var response = await _httpClient.SendAsync(request, terminateCts.Token).ConfigureAwait(false); _logger.Information( diff --git a/src/SalmonEgg.Infrastructure/Network/WebSocketTransport.cs b/src/SalmonEgg.Infrastructure/Network/WebSocketTransport.cs index fb03c0199..c51b68dd9 100644 --- a/src/SalmonEgg.Infrastructure/Network/WebSocketTransport.cs +++ b/src/SalmonEgg.Infrastructure/Network/WebSocketTransport.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using System.Net.Http; using System.Net.WebSockets; using System.Reactive.Disposables; using System.Reactive.Linq; @@ -22,6 +23,8 @@ public class WebSocketTransport : ITransport, ITransportStateSource, IDisposable private readonly ILogger _logger; private readonly ProxyConfig _proxyConfiguration; private readonly Func _clientFactory; + private readonly ResolvedCredentialBinding? _credential; + private readonly HttpMessageInvoker? _credentialInvoker; // Replaced on connect and cleared on teardown, both of which can run while a send is in // flight. Volatile so a send sees the current handle, and every path that uses it more than // once captures it into a local first so it cannot be swapped mid-decision. @@ -36,13 +39,23 @@ public class WebSocketTransport : ITransport, ITransportStateSource, IDisposable /// Initializes a new instance of the WebSocketTransport class. /// /// Logger instance for logging transport events. - public WebSocketTransport(ILogger logger, ProxyConfig? proxyConfiguration = null, TimeSpan? connectTimeout = null) + public WebSocketTransport( + ILogger logger, + ProxyConfig? proxyConfiguration = null, + TimeSpan? connectTimeout = null, + ResolvedCredentialBinding? credential = null) : this( logger, proxyConfiguration, connectTimeout, (uri, proxy) => CreateClient(uri, proxy, connectTimeout ?? TimeSpan.FromSeconds(AcpConnectionTimeoutPolicy.DefaultSeconds))) { + _credential = credential; + if (credential is { HasHeader: true }) + { + _credentialInvoker = CreateCredentialInvoker(_proxyConfiguration); + _clientFactory = CreateCredentialClient; + } } internal WebSocketTransport( @@ -79,6 +92,8 @@ public async Task ConnectAsync(string url, CancellationToken ct) throw new ArgumentException("URL cannot be null or empty", nameof(url)); } + BoundCredentialHeader.EnsureEndpoint(_credential, new Uri(url, UriKind.Absolute)); + var existingClient = _client; if (existingClient != null && existingClient.IsRunning) { @@ -348,6 +363,7 @@ protected virtual void Dispose(bool disposing) // 不再 fire-and-forget 优雅断开与资源释放竞态;需要 NormalClosure 优雅关闭的 // 调用方应先 await DisconnectAsync()。 DisposeClient(); + _credentialInvoker?.Dispose(); // Complete the subjects _messagesSubject?.OnCompleted(); @@ -480,6 +496,55 @@ internal static WebsocketClient CreateClient(Uri uri, ProxyConfig? proxyConfigur return client; } + private IWebsocketClient CreateCredentialClient(Uri uri, ProxyConfig proxyConfiguration) + { + BoundCredentialHeader.EnsureEndpoint(_credential, uri); + return new WebsocketClient(uri, logger: null, connectionFactory: ConnectCredentialSocketAsync) + { + ConnectTimeout = _connectTimeout, + }; + } + + private async Task ConnectCredentialSocketAsync(Uri uri, CancellationToken cancellationToken) + { + // Websocket.Client calls this for every reconnect. Recheck its destination before the + // HTTP upgrade and supply our own invoker: ClientWebSocket otherwise follows redirects. + BoundCredentialHeader.EnsureEndpoint(_credential, uri); + var client = new ClientWebSocket(); + try + { + client.Options.SetRequestHeader(_credential!.HeaderName!, _credential.HeaderValue!); + await client.ConnectAsync(uri, _credentialInvoker!, cancellationToken).ConfigureAwait(false); + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private static HttpMessageInvoker CreateCredentialInvoker(ProxyConfig proxy) + { + var handler = new SocketsHttpHandler { AllowAutoRedirect = false }; + switch (proxy.Mode) + { + case ProxyMode.None: + handler.UseProxy = false; + break; + case ProxyMode.System: + break; + case ProxyMode.Custom when !string.IsNullOrWhiteSpace(proxy.ProxyUrl): + handler.Proxy = new WebProxy(new Uri(proxy.ProxyUrl, UriKind.Absolute)); + break; + default: + handler.Dispose(); + throw new InvalidOperationException("Custom proxy mode requires a proxy URL."); + } + + return new HttpMessageInvoker(handler, disposeHandler: true); + } + private static ProxyConfig CloneProxyConfiguration(ProxyConfig? proxyConfiguration) => new() { diff --git a/src/SalmonEgg.Infrastructure/Services/PlatformCapabilityService.cs b/src/SalmonEgg.Infrastructure/Services/PlatformCapabilityService.cs index a469b9bcb..27b50b621 100644 --- a/src/SalmonEgg.Infrastructure/Services/PlatformCapabilityService.cs +++ b/src/SalmonEgg.Infrastructure/Services/PlatformCapabilityService.cs @@ -41,6 +41,8 @@ internal PlatformCapabilityService( public bool SupportsStdioTransport => _runtimeProbe.IsDesktopProcessHost; + public bool SupportsWebSocketRequestHeaders => !IsBrowserRuntime; + public bool SupportsInteractiveTerminalSurface => _runtimeProbe.HasInteractiveTerminalSurface; public bool SupportsLocalTerminal => SupportsStdioTransport && SupportsInteractiveTerminalSurface; diff --git a/src/SalmonEgg.Infrastructure/Storage/ConfigurationManager.cs b/src/SalmonEgg.Infrastructure/Storage/ConfigurationManager.cs index 5bfb487a4..ad9083d17 100644 --- a/src/SalmonEgg.Infrastructure/Storage/ConfigurationManager.cs +++ b/src/SalmonEgg.Infrastructure/Storage/ConfigurationManager.cs @@ -21,7 +21,7 @@ namespace SalmonEgg.Infrastructure.Storage; public sealed class ConfigurationManager : IConfigurationService, IConfigurationRecoveryService { /// 本程序写入 server 配置时使用的 schema 版本。 - public const int CurrentServerConfigurationSchemaVersion = 4; + public const int CurrentServerConfigurationSchemaVersion = 5; private const int CurrentSchemaVersion = CurrentServerConfigurationSchemaVersion; @@ -579,6 +579,7 @@ private static ServerConfigurationYaml ToYaml(ServerConfiguration config, string Verification = VerificationToString(config.Verification.State), VerifiedAtUtc = config.Verification.VerifiedAtUtc?.ToString("O", CultureInfo.InvariantCulture), Authentication = new AuthenticationYamlV1 { Mode = mode }, + CredentialBinding = ToYamlCredentialBinding(config.CredentialBinding), Proxy = new ProxyYamlV1 { Mode = ProxyModeToString(config.Proxy?.Mode ?? ProxyConfig.DefaultMode), @@ -598,6 +599,7 @@ private static ServerConfiguration FromYaml(ServerConfigurationYaml yamlModel) StdioCommand = yamlModel.StdioCommand ?? string.Empty, StdioArguments = yamlModel.StdioArguments ?? new List(), StdioEnvironment = CloneStdioEnvironment(yamlModel.StdioEnvironment), + CredentialBinding = FromYamlCredentialBinding(yamlModel.CredentialBinding), Transport = TransportFromString(yamlModel.Transport), ConnectionTimeout = AcpConnectionTimeoutPolicy.ResolveSeconds(yamlModel.ConnectionTimeoutSeconds), Verification = VerificationFromYaml(yamlModel.Verification, yamlModel.VerifiedAtUtc) @@ -615,6 +617,44 @@ private static ServerConfiguration FromYaml(ServerConfigurationYaml yamlModel) return config; } + private static CredentialBindingYaml? ToYamlCredentialBinding(CredentialBinding? binding) + => binding is null ? null : new CredentialBindingYaml + { + Source = binding.Source switch + { + CredentialSource.Token => "token", + CredentialSource.ApiKey => "api_key", + _ => throw new InvalidOperationException("Unsupported credential source."), + }, + Target = binding.Target switch + { + CredentialTarget.Environment => "environment", + CredentialTarget.Header => "header", + _ => throw new InvalidOperationException("Unsupported credential target."), + }, + Name = binding.Name, + Scheme = string.IsNullOrEmpty(binding.Scheme) ? null : binding.Scheme, + TargetIdentity = binding.TargetIdentity, + }; + + private static CredentialBinding? FromYamlCredentialBinding(CredentialBindingYaml? binding) + => binding is null ? null : new CredentialBinding( + binding.Source switch + { + "token" => CredentialSource.Token, + "api_key" => CredentialSource.ApiKey, + _ => (CredentialSource)(-1), + }, + binding.Target switch + { + "environment" => CredentialTarget.Environment, + "header" => CredentialTarget.Header, + _ => (CredentialTarget)(-1), + }, + binding.Name, + binding.Scheme, + binding.TargetIdentity); + /// /// Projects the environment overlay for YAML, returning null when there is nothing to persist. /// diff --git a/src/SalmonEgg.Infrastructure/Storage/YamlModels/ServerConfigurationYaml.cs b/src/SalmonEgg.Infrastructure/Storage/YamlModels/ServerConfigurationYaml.cs index 3feaf57a6..7fc3ac961 100644 --- a/src/SalmonEgg.Infrastructure/Storage/YamlModels/ServerConfigurationYaml.cs +++ b/src/SalmonEgg.Infrastructure/Storage/YamlModels/ServerConfigurationYaml.cs @@ -5,7 +5,7 @@ namespace SalmonEgg.Infrastructure.Storage.YamlModels; internal sealed class ServerConfigurationYaml { - public int SchemaVersion { get; set; } = 4; + public int SchemaVersion { get; set; } = 5; public string UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow.ToString("O"); @@ -66,6 +66,8 @@ internal sealed class ServerConfigurationYaml public AuthenticationYamlV1 Authentication { get; set; } = new(); + public CredentialBindingYaml? CredentialBinding { get; set; } + public ProxyYamlV1 Proxy { get; set; } = new(); } @@ -74,6 +76,19 @@ internal sealed class AuthenticationYamlV1 public string Mode { get; set; } = "none"; } +internal sealed class CredentialBindingYaml +{ + public string Source { get; set; } = string.Empty; + + public string Target { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string? Scheme { get; set; } + + public string TargetIdentity { get; set; } = string.Empty; +} + internal sealed class ProxyYamlV1 { public string Mode { get; set; } = string.Empty; diff --git a/tests/SalmonEgg.Application.Tests/Validators/ServerConfigurationValidatorTests.cs b/tests/SalmonEgg.Application.Tests/Validators/ServerConfigurationValidatorTests.cs index 1f3e86659..b40a1086e 100644 --- a/tests/SalmonEgg.Application.Tests/Validators/ServerConfigurationValidatorTests.cs +++ b/tests/SalmonEgg.Application.Tests/Validators/ServerConfigurationValidatorTests.cs @@ -1,6 +1,7 @@ using FluentValidation.TestHelper; using SalmonEgg.Application.Validators; using SalmonEgg.Domain.Models; +using SalmonEgg.Domain.Services; using Xunit; namespace SalmonEgg.Application.Tests.Validators; @@ -25,6 +26,24 @@ public void Validate_WhenValidConfiguration_ShouldNotHaveAnyErrors() result.ShouldNotHaveAnyValidationErrors(); } + [Fact] + public void Validate_CredentialDestinationChanged_RequiresRebindingButPermitsMissingSecret() + { + var profile = new ServerConfiguration + { + Id = "bound-agent", + Name = "Agent", + Transport = TransportType.StreamableHttp, + ServerUrl = "https://agent.example/acp", + }; + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Header, "X-Agent-Key"); + + _validator.TestValidate(profile).ShouldNotHaveAnyValidationErrors(); + profile.ServerUrl = "https://agent.example/another"; + + _validator.TestValidate(profile).ShouldHaveValidationErrorFor(value => value.CredentialBinding); + } + [Fact] public void Validate_WhenIdIsEmpty_ShouldHaveError() { diff --git a/tests/SalmonEgg.Cli.Tests/Commands/Config/CredentialBindingCommandTests.cs b/tests/SalmonEgg.Cli.Tests/Commands/Config/CredentialBindingCommandTests.cs new file mode 100644 index 000000000..81cd73508 --- /dev/null +++ b/tests/SalmonEgg.Cli.Tests/Commands/Config/CredentialBindingCommandTests.cs @@ -0,0 +1,108 @@ +using System.CommandLine; +using SalmonEgg.Cli.Commands.Config; +using SalmonEgg.Cli.Commands.Credentials; +using SalmonEgg.Domain.Models; +using SalmonEgg.Domain.Services; +using SalmonEgg.Infrastructure.Storage; + +namespace SalmonEgg.Cli.Tests.Commands.Config; + +public sealed class CredentialBindingCommandTests +{ + private const string Secret = "cli-binding-secret-canary"; + + [Fact] + public async Task ParseAndRun_ExplicitHeaderBinding_PersistsMetadataAndPrintsOnlyStatus() + { + using var fixture = new HandlerFixture(); + await fixture.SeedAsync("bound", "Agent", "wss://agent.example/acp", token: Secret); + var command = ConfigServerCommandFactory.CreateServerCommand(fixture.Handler); + + var result = await command.Parse([ + "update", "bound", "--credential-source", "token", "--credential-header", "Authorization", + "--credential-scheme", "Bearer", + ]).InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + var loaded = await fixture.Configurations.LoadConfigurationAsync("bound"); + await fixture.Handler.ShowAsync("bound", TestContext.Current.CancellationToken); + + Assert.Equal(CliExitCodes.Success, result); + Assert.Equal("Bearer " + Secret, CredentialBindingResolver.Resolve(loaded!).Value!.HeaderValue); + Assert.Contains("binding: token -> header Authorization", fixture.Output.Lines); + Assert.Contains("credential: set", fixture.Output.Lines); + Assert.DoesNotContain(Secret, string.Join('\n', fixture.Output.Lines.Concat(fixture.Output.Errors))); + Assert.DoesNotContain(Secret, await File.ReadAllTextAsync( + Path.Combine(fixture.AppDataRoot, "config", "servers", "bound.yaml"), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ParseAndRun_DestinationChange_RequiresExplicitNewBinding() + { + using var fixture = new HandlerFixture(); + await fixture.SeedAsync("bound", "Agent", "wss://agent.example/acp", token: Secret); + var loaded = await fixture.Configurations.LoadConfigurationAsync("bound"); + loaded!.CredentialBinding = CredentialBindingPolicy.Create(loaded, CredentialSource.Token, CredentialTarget.Header, "X-Agent-Key"); + await fixture.Configurations.SaveConfigurationAsync(loaded); + var command = ConfigServerCommandFactory.CreateServerCommand(fixture.Handler); + + var rejected = await command.Parse(["update", "bound", "--url", "wss://other.example/acp"]) + .InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(CliExitCodes.Usage, rejected); + Assert.Equal("wss://agent.example/acp", (await fixture.Configurations.LoadConfigurationAsync("bound"))!.ServerUrl); + + var approved = await command.Parse([ + "update", "bound", "--url", "wss://other.example/acp", "--credential-source", "token", + "--credential-header", "X-Agent-Key", + ]).InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(CliExitCodes.Success, approved); + Assert.True(CredentialBindingResolver.Resolve((await fixture.Configurations.LoadConfigurationAsync("bound"))!).IsSuccess); + } + + [Fact] + public async Task ClearAndReplaceCredential_PreserveBindingWithoutFallingBack() + { + using var fixture = new HandlerFixture(); + await fixture.SeedStdioAsync("bound", "Agent", "agent", []); + var loaded = await fixture.Configurations.LoadConfigurationAsync("bound"); + loaded!.Authentication = new AuthenticationConfig { Token = Secret }; + loaded.CredentialBinding = CredentialBindingPolicy.Create(loaded, CredentialSource.Token, CredentialTarget.Environment, "AGENT_TOKEN"); + await fixture.Configurations.SaveConfigurationAsync(loaded); + var credentials = new CredentialsHandler(fixture.Output, fixture.Configurations, new ServerCredentialService(fixture.SecureStorage)); + + Assert.Equal(CliExitCodes.Success, await credentials.ClearAsync("bound", TestContext.Current.CancellationToken)); + var cleared = await fixture.Configurations.LoadConfigurationAsync("bound"); + Assert.Equal(loaded.CredentialBinding, cleared!.CredentialBinding); + Assert.False(CredentialBindingResolver.Resolve(cleared).IsSuccess); + + Assert.Equal(CliExitCodes.Success, await credentials.SetAsync("bound", "replacement", null, TestContext.Current.CancellationToken)); + var updated = await fixture.Configurations.LoadConfigurationAsync("bound"); + Assert.Equal(loaded.CredentialBinding, updated!.CredentialBinding); + Assert.Equal("replacement", CredentialBindingResolver.Resolve(updated).Value!.Environment["AGENT_TOKEN"]); + + var command = ConfigServerCommandFactory.CreateServerCommand(fixture.Handler); + Assert.Equal(CliExitCodes.Success, await command.Parse(["update", "bound", "--clear-credential-binding"]) + .InvokeAsync(cancellationToken: TestContext.Current.CancellationToken)); + var unbound = await fixture.Configurations.LoadConfigurationAsync("bound"); + Assert.Null(unbound!.CredentialBinding); + Assert.Equal("replacement", unbound.Authentication!.Token); + } + + [Theory] + [InlineData("--credential-header", "Authorization")] + [InlineData("--credential-source", "token")] + [InlineData("--credential-scheme", "Bearer")] + public async Task ParseAndRun_IncompleteBinding_ReturnsUsageWithoutWriting(string option, string value) + { + using var fixture = new HandlerFixture(); + await fixture.SeedAsync("bound", "Agent", "wss://agent.example/acp", token: Secret); + var command = ConfigServerCommandFactory.CreateServerCommand(fixture.Handler); + + var result = await command.Parse(["update", "bound", option, value]) + .InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(CliExitCodes.Usage, result); + Assert.Null((await fixture.Configurations.LoadConfigurationAsync("bound"))!.CredentialBinding); + Assert.DoesNotContain(Secret, string.Join('\n', fixture.Output.Errors)); + } +} diff --git a/tests/SalmonEgg.Domain.Tests/Services/CredentialBindingResolverTests.cs b/tests/SalmonEgg.Domain.Tests/Services/CredentialBindingResolverTests.cs new file mode 100644 index 000000000..3acc36527 --- /dev/null +++ b/tests/SalmonEgg.Domain.Tests/Services/CredentialBindingResolverTests.cs @@ -0,0 +1,232 @@ +using SalmonEgg.Domain.Models; +using SalmonEgg.Domain.Services; +using Xunit; + +namespace SalmonEgg.Domain.Tests.Services; + +public sealed class CredentialBindingResolverTests +{ + private const string Secret = "credential-canary-never-log"; + + [Fact] + public void Resolve_NoBinding_DoesNotGuessAnInjectionName() + { + var profile = CreateStdioProfile(); + + var resolved = CredentialBindingResolver.Resolve(profile); + + Assert.True(resolved.IsSuccess); + Assert.DoesNotContain(Secret, resolved.Value!.Environment.Values); + Assert.Null(resolved.Value.HeaderValue); + } + + [Theory] + [InlineData(CredentialSource.Token)] + [InlineData(CredentialSource.ApiKey)] + public void Resolve_EnvironmentBinding_CopiesSnapshotWithoutMutatingProfile(CredentialSource source) + { + var profile = CreateStdioProfile(); + profile.Authentication = source == CredentialSource.Token + ? new AuthenticationConfig { Token = Secret } + : new AuthenticationConfig { ApiKey = Secret }; + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, source, CredentialTarget.Environment, "AGENT_KEY"); + + var resolved = CredentialBindingResolver.Resolve(profile); + profile.StdioEnvironment["MODEL"] = "changed"; + profile.Authentication = null; + + Assert.True(resolved.IsSuccess); + Assert.Equal(Secret, resolved.Value!.Environment["AGENT_KEY"]); + Assert.Equal("small", resolved.Value.Environment["MODEL"]); + Assert.False(profile.StdioEnvironment.ContainsKey("AGENT_KEY")); + Assert.DoesNotContain(Secret, resolved.ToString()); + Assert.DoesNotContain(Secret, resolved.Value.ToString()); + Assert.DoesNotContain(Secret, profile.CredentialBinding.ToString()); + } + + [Theory] + [InlineData(null, Secret)] + [InlineData("Bearer", "Bearer " + Secret)] + public void Resolve_HeaderBinding_UsesOnlyExplicitScheme(string? scheme, string expected) + { + var profile = CreateNetworkProfile(); + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Header, "Authorization", scheme); + + var resolved = CredentialBindingResolver.Resolve(profile); + + Assert.True(resolved.IsSuccess); + Assert.Equal(expected, resolved.Value!.HeaderValue); + Assert.Equal("Authorization", resolved.Value.HeaderName); + Assert.Equal(new Uri(profile.ServerUrl), resolved.Value.Endpoint); + Assert.DoesNotContain(Secret, resolved.Value.Environment.Values); + } + + [Theory] + [InlineData("https://other.example/acp")] + [InlineData("https://agent.example/other")] + [InlineData("https://agent.example/acp?tenant=other")] + public void Resolve_DestinationChanged_RequiresExplicitRebinding(string destination) + { + var profile = CreateNetworkProfile(); + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Header, "X-Api-Key"); + profile.ServerUrl = destination; + + var resolved = CredentialBindingResolver.Resolve(profile); + + Assert.False(resolved.IsSuccess); + Assert.Contains("Bind the credential again", resolved.Error); + Assert.DoesNotContain(Secret, resolved.Error); + } + + [Fact] + public void Resolve_CommandArgumentsOrEnvironmentChanged_RequiresRebinding() + { + var profile = CreateStdioProfile(); + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Environment, "AGENT_KEY"); + + foreach (var change in new Action[] + { + copy => copy.StdioCommand = "other-agent", + copy => copy.StdioArguments.Add("other-script"), + copy => copy.StdioEnvironment["PATH"] = "/other/path", + }) + { + var copy = profile.Clone(); + change(copy); + Assert.False(CredentialBindingResolver.Resolve(copy).IsSuccess); + } + } + + [Fact] + public void Resolve_ClearedCredential_DoesNotFallBackToPlainEnvironment() + { + var profile = CreateStdioProfile(); + profile.StdioEnvironment["AGENT_KEY"] = "unrelated-parent-value"; + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Environment, "AGENT_KEY"); + profile.Authentication = null; + + var resolved = CredentialBindingResolver.Resolve(profile); + + Assert.False(resolved.IsSuccess); + Assert.Null(resolved.Value); + Assert.Contains("not set", resolved.Error); + } + + [Theory] + [InlineData("Host", null)] + [InlineData("Sec-WebSocket-Protocol", null)] + [InlineData("Acp-Connection-Id", null)] + [InlineData("Header\r\nInjection", null)] + [InlineData("Authorization", "Bearer\r\nOther:")] + public void Resolve_InvalidHeaderMetadata_RejectsBeforeInjection(string name, string? scheme) + { + var profile = CreateNetworkProfile(); + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Header, name, scheme); + + var resolved = CredentialBindingResolver.Resolve(profile); + + Assert.False(resolved.IsSuccess); + Assert.DoesNotContain(Secret, resolved.Error); + } + + [Fact] + public void Resolve_HeaderSecretWithNewline_RejectsWithoutEcho() + { + var profile = CreateNetworkProfile(); + profile.Authentication!.Token = Secret + "\r\nInjected: value"; + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Header, "Authorization"); + + var resolved = CredentialBindingResolver.Resolve(profile); + + Assert.False(resolved.IsSuccess); + Assert.DoesNotContain(Secret, resolved.Error); + } + + [Theory] + [InlineData("")] + [InlineData(" AGENT_KEY")] + [InlineData("AGENT_KEY\n")] + [InlineData("A=B")] + [InlineData("PATH")] + [InlineData("PathExt")] + public void Resolve_InvalidEnvironmentBinding_RejectsBeforeLaunching(string name) + { + var profile = CreateStdioProfile(); + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Environment, name); + + var resolved = CredentialBindingResolver.Resolve(profile); + + Assert.False(resolved.IsSuccess); + Assert.DoesNotContain(Secret, resolved.Error); + } + + [Theory] + [InlineData("https://user:password@agent.example/acp")] + [InlineData("https://agent.example/acp#fragment")] + [InlineData("wss://agent.example/acp")] + public void Resolve_HeaderEndpointOutsideTransportContract_RejectsBeforeConnecting(string endpoint) + { + var profile = CreateNetworkProfile(); + profile.ServerUrl = endpoint; + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Header, "X-Key"); + + Assert.False(CredentialBindingResolver.Resolve(profile).IsSuccess); + } + + [Fact] + public void Resolve_BindingWithUnknownSourceOrTarget_DoesNotFallBack() + { + var profile = CreateNetworkProfile(); + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, (CredentialSource)9, CredentialTarget.Header, "X-Key"); + Assert.False(CredentialBindingResolver.Resolve(profile).IsSuccess); + + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, (CredentialTarget)9, "X-Key"); + Assert.False(CredentialBindingResolver.Resolve(profile).IsSuccess); + } + + [Fact] + public void Resolve_EnvironmentNameAlias_ProducesOneUnambiguousSecretDestination() + { + var profile = CreateStdioProfile(); + profile.StdioEnvironment["agent_key"] = "old-value"; + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Environment, "AGENT_KEY"); + + var resolved = CredentialBindingResolver.Resolve(profile).Value!; + + Assert.Equal(Secret, resolved.Environment["AGENT_KEY"]); + Assert.False(resolved.Environment.ContainsKey("agent_key")); + } + + [Fact] + public void Clone_EditAndClear_DoNotMutateLoadedProfile() + { + var profile = CreateStdioProfile(); + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Environment, "AGENT_KEY"); + + var copy = profile.Clone(); + copy.Authentication!.Token = "replacement"; + copy.StdioArguments.Add("other"); + copy.StdioEnvironment.Clear(); + copy.CredentialBinding = null; + + Assert.Equal(Secret, profile.Authentication!.Token); + Assert.Empty(profile.StdioArguments); + Assert.Single(profile.StdioEnvironment); + Assert.NotNull(profile.CredentialBinding); + } + + private static ServerConfiguration CreateStdioProfile() => new() + { + Transport = TransportType.Stdio, + StdioCommand = "agent", + StdioEnvironment = new() { ["MODEL"] = "small" }, + Authentication = new AuthenticationConfig { Token = Secret }, + }; + + private static ServerConfiguration CreateNetworkProfile() => new() + { + Transport = TransportType.StreamableHttp, + ServerUrl = "https://agent.example/acp", + Authentication = new AuthenticationConfig { Token = Secret }, + }; +} diff --git a/tests/SalmonEgg.Infrastructure.Tests/SalmonEgg.Infrastructure.Tests.csproj b/tests/SalmonEgg.Infrastructure.Tests/SalmonEgg.Infrastructure.Tests.csproj index 59dd4d5be..86f8fbf05 100644 --- a/tests/SalmonEgg.Infrastructure.Tests/SalmonEgg.Infrastructure.Tests.csproj +++ b/tests/SalmonEgg.Infrastructure.Tests/SalmonEgg.Infrastructure.Tests.csproj @@ -26,6 +26,11 @@ + + + + + diff --git a/tests/SalmonEgg.Infrastructure.Tests/Storage/ConfigSyncPackageServiceTests.cs b/tests/SalmonEgg.Infrastructure.Tests/Storage/ConfigSyncPackageServiceTests.cs index 93df59865..4b4cb4360 100644 --- a/tests/SalmonEgg.Infrastructure.Tests/Storage/ConfigSyncPackageServiceTests.cs +++ b/tests/SalmonEgg.Infrastructure.Tests/Storage/ConfigSyncPackageServiceTests.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; +using SalmonEgg.Domain.Models; +using SalmonEgg.Domain.Services; using SalmonEgg.Infrastructure.Storage; namespace SalmonEgg.Infrastructure.Tests.Storage; @@ -110,6 +112,41 @@ await File.WriteAllTextAsync( || name.Contains(ConfigurationFileTransactionArtifacts.RollbackSuffix, StringComparison.Ordinal)); } + [Fact] + public async Task CreatePackageAsync_CredentialBinding_DefaultPackageContainsMetadataOnlyAndExplicitSecretSnapshotStillWorks() + { + var fileStore = new FileSystemAppFileStore(); + var storage = new PlainTextFileSecureStorage(fileStore, _appData); + var configurations = new ConfigurationManager(storage, fileStore, _appData, NullLogger.Instance); + var profile = new ServerConfiguration + { + Id = "bound-agent", + Transport = TransportType.StreamableHttp, + ServerUrl = "https://agent.example/acp", + Authentication = new AuthenticationConfig { Token = "package-secret-canary" }, + }; + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Header, "X-Agent-Key"); + await configurations.SaveConfigurationAsync(profile); + + var package = await _packageService.CreatePackageAsync(includeSecrets: false, TestContext.Current.CancellationToken); + using var archive = new ZipArchive(new MemoryStream(package), ZipArchiveMode.Read); + foreach (var entry in archive.Entries) + { + using var reader = new StreamReader(entry.Open()); + var content = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain("package-secret-canary", content); + if (entry.FullName == "files/config/servers/bound-agent.yaml") + { + Assert.Contains("credential_binding:", content); + Assert.Contains("X-Agent-Key", content); + } + } + + var snapshots = new ConfigurationSecretSnapshotService(storage, fileStore, _appData); + var explicitSecrets = await snapshots.ExportAsync(TestContext.Current.CancellationToken); + Assert.Contains(explicitSecrets.Entries, entry => entry.ProfileId == profile.Id && entry.Value == "package-secret-canary"); + } + [Fact] public async Task CreatePackageAsync_ManifestFilesUsePortableSlashSeparators() { diff --git a/tests/SalmonEgg.Infrastructure.Tests/Storage/ConfigurationManagerTests.cs b/tests/SalmonEgg.Infrastructure.Tests/Storage/ConfigurationManagerTests.cs index d537e21e9..3ab0ed2d6 100644 --- a/tests/SalmonEgg.Infrastructure.Tests/Storage/ConfigurationManagerTests.cs +++ b/tests/SalmonEgg.Infrastructure.Tests/Storage/ConfigurationManagerTests.cs @@ -94,6 +94,60 @@ public async Task SaveConfigurationAsync_WithStaleRevision_RejectsWithoutChangin Assert.Equal("first-token", reloaded.Authentication?.Token); } + [Fact] + public async Task SaveConfigurationAsync_CredentialBinding_RoundTripsOnlyMetadataAndRetainsBindingOnClear() + { + var config = CreateTestConfiguration("credential-binding"); + config.Transport = TransportType.StreamableHttp; + config.ServerUrl = "https://agent.example/acp"; + config.Authentication = new AuthenticationConfig { Token = "binding-secret-canary" }; + config.CredentialBinding = CredentialBindingPolicy.Create(config, CredentialSource.Token, CredentialTarget.Header, "Authorization", "Bearer"); + + await _configManager.SaveConfigurationAsync(config); + var yaml = await File.ReadAllTextAsync(GetServerYamlPath(config.Id), TestContext.Current.CancellationToken); + var loaded = await _configManager.LoadConfigurationAsync(config.Id); + + Assert.DoesNotContain("binding-secret-canary", yaml); + Assert.Contains("credential_binding:", yaml); + Assert.Contains("source: token", yaml); + Assert.Equal(config.CredentialBinding, loaded!.CredentialBinding); + Assert.Equal("Bearer binding-secret-canary", CredentialBindingResolver.Resolve(loaded).Value!.HeaderValue); + + loaded.Authentication = null; + await _configManager.SaveConfigurationAsync(loaded); + var cleared = await _configManager.LoadConfigurationAsync(config.Id); + + Assert.Equal(config.CredentialBinding, cleared!.CredentialBinding); + Assert.False(CredentialBindingResolver.Resolve(cleared).IsSuccess); + Assert.Null(cleared.Authentication); + } + + [Fact] + public async Task SaveConfigurationAsync_BoundCredentialWriteFailure_RestoresOldTargetAndSecretTogether() + { + var original = CreateTestConfiguration("bound-rollback"); + original.Transport = TransportType.StreamableHttp; + original.ServerUrl = "https://first.example/acp"; + original.Authentication = new AuthenticationConfig { Token = "old-bound-secret" }; + original.CredentialBinding = CredentialBindingPolicy.Create(original, CredentialSource.Token, CredentialTarget.Header, "X-Agent-Key"); + await _configManager.SaveConfigurationAsync(original); + var candidate = original.Clone(); + candidate.ServerUrl = "https://second.example/acp"; + candidate.Authentication!.Token = "new-bound-secret"; + candidate.CredentialBinding = CredentialBindingPolicy.Create(candidate, CredentialSource.Token, CredentialTarget.Header, "Authorization", "Bearer"); + var failing = new ConfigurationManager(_secureStorage, new WriteFailingAppFileStore(), new AppDataService(), NullLogger.Instance); + + await Assert.ThrowsAsync(() => failing.SaveConfigurationAsync(candidate)); + var reloaded = await _configManager.LoadConfigurationAsync(original.Id); + + Assert.Equal(original.ServerUrl, reloaded!.ServerUrl); + Assert.Equal(original.CredentialBinding, reloaded.CredentialBinding); + Assert.Equal("old-bound-secret", CredentialBindingResolver.Resolve(reloaded).Value!.HeaderValue); + var yaml = await File.ReadAllTextAsync(GetServerYamlPath(original.Id), TestContext.Current.CancellationToken); + Assert.DoesNotContain("old-bound-secret", yaml); + Assert.DoesNotContain("new-bound-secret", yaml); + } + [Fact] public async Task SaveConfigurationAsync_AcrossManagerInstances_SerializesAndRejectsStaleWriter() { diff --git a/tests/SalmonEgg.Infrastructure.Tests/Transport/CredentialTransportCanaryTests.cs b/tests/SalmonEgg.Infrastructure.Tests/Transport/CredentialTransportCanaryTests.cs new file mode 100644 index 000000000..03da25dba --- /dev/null +++ b/tests/SalmonEgg.Infrastructure.Tests/Transport/CredentialTransportCanaryTests.cs @@ -0,0 +1,300 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Logging; +using Moq; +using SalmonEgg.Acp.Client; +using SalmonEgg.Acp.Protocol; +using SalmonEgg.Domain.Models; +using SalmonEgg.Domain.Services; +using SalmonEgg.Infrastructure.Client; +using SalmonEgg.Infrastructure.Network; +using SalmonEgg.Infrastructure.Transport; +using Serilog; +using Serilog.Core; +using Serilog.Events; + +namespace SalmonEgg.Infrastructure.Tests.Transport; + +/// Real local processes and loopback peers. The dedicated Linux gate requires zero skips. +public sealed class CredentialTransportCanaryTests +{ + private const string Secret = "transport-credential-canary"; + private const string Header = "X-Agent-Credential"; + private const string Initialize = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1}}"; + private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(10); + + [Theory] + [InlineData(TransportType.StreamableHttp)] + [InlineData(TransportType.WebSocket)] + public async Task BoundTransport_RealPeer_ReceivesSnapshotAndUpdatedConnectionReceivesNewCredential(TransportType kind) + { + await using var peer = await CredentialPeer.StartAsync(kind); + var sink = new RecordingLogSink(); + using var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger(); + var profile = CreateProfile(kind, peer.Url, Secret); + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Header, Header, "Bearer"); + using (var transport = CreateFactory(logger).CreateTransport(profile)) + { + // A caller editing its profile after factory creation cannot retarget or replace this connection's secret. + profile.Authentication!.Token = "replacement-credential"; + using var client = new AcpClient(new DomainAcpTransportAdapter(transport)); + await client.InitializeAsync(new InitializeParams(new ClientInfo("canary", "1"), new ClientCapabilities()), TestContext.Current.CancellationToken); + await peer.StreamReady.Task.WaitAsync(Deadline, TestContext.Current.CancellationToken); + if (kind == TransportType.StreamableHttp) + { + Assert.True(await transport.SendMessageAsync("{\"jsonrpc\":\"2.0\",\"method\":\"canary/notification\"}", TestContext.Current.CancellationToken)); + } + await client.DisconnectAsync(); + } + + var firstRequests = peer.Requests.ToArray(); + Assert.NotEmpty(firstRequests); + Assert.All(firstRequests, request => Assert.Equal("Bearer " + Secret, request.Credential)); + Assert.All(firstRequests, request => Assert.DoesNotContain(Secret, request.Body)); + if (kind == TransportType.StreamableHttp) + { + Assert.Contains(firstRequests, request => request.Method == "GET"); + Assert.Contains(firstRequests, request => request.Method == "DELETE"); + Assert.Equal(2, firstRequests.Count(request => request.Method == "POST")); + } + + using (var transport = CreateFactory(logger).CreateTransport(profile)) + using (var client = new AcpClient(new DomainAcpTransportAdapter(transport))) + { + await client.InitializeAsync(new InitializeParams(new ClientInfo("canary", "1"), new ClientCapabilities()), TestContext.Current.CancellationToken); + await client.DisconnectAsync(); + } + + Assert.Contains(peer.Requests.Skip(firstRequests.Length), request => request.Credential == "Bearer replacement-credential"); + Assert.DoesNotContain(Secret, sink.Text); + Assert.DoesNotContain("replacement-credential", sink.Text); + } + + [Theory] + [InlineData(TransportType.StreamableHttp, false)] + [InlineData(TransportType.StreamableHttp, true)] + [InlineData(TransportType.WebSocket, false)] + [InlineData(TransportType.WebSocket, true)] + public async Task BoundTransport_Redirect_NeverSendsCredentialToAnotherOriginOrPath(TransportType kind, bool anotherOrigin) + { + await using var other = await CredentialPeer.StartAsync(kind); + await using var source = await CredentialPeer.StartAsync(kind); + source.RedirectTo = anotherOrigin ? other.HttpUrl + "/unapproved" : source.HttpUrl + "/unapproved"; + var profile = CreateProfile(kind, source.Url, Secret); + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Header, Header); + var resolution = CredentialBindingResolver.Resolve(profile).Value!; + + if (kind == TransportType.StreamableHttp) + { + using var http = new StreamableHttpTransport(Log.Logger, connectTimeout: Deadline, credential: resolution); + await http.ConnectAsync(source.Url, TestContext.Current.CancellationToken); + await Assert.ThrowsAnyAsync(() => http.SendAsync(Initialize, TestContext.Current.CancellationToken)); + } + else + { + using var socket = new WebSocketTransport(Log.Logger, connectTimeout: Deadline, credential: resolution); + await Assert.ThrowsAnyAsync(() => socket.ConnectAsync(source.Url, TestContext.Current.CancellationToken)); + } + + Assert.NotEmpty(source.Requests); + Assert.All(source.Requests, request => Assert.Equal("/acp", request.Path)); + Assert.All(source.Requests, request => Assert.Equal(Secret, request.Credential)); + Assert.Empty(other.Requests); + } + + [Fact] + public async Task StdioBinding_RealProcess_ReceivesOnlyChildEnvironmentAndNeverArguments() + { + Assert.SkipUnless(OperatingSystem.IsLinux(), "Requires the real Linux /proc process environment and /bin/sh."); + var directory = Path.Combine(Path.GetTempPath(), "acp-credential-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var script = Path.Combine(directory, "agent.sh"); + await File.WriteAllTextAsync(script, """ + printf '{"jsonrpc":"2.0","method":"canary/ready","params":{"pid":%s}}\n' "$$" + while IFS= read -r line; do :; done + """, TestContext.Current.CancellationToken); + var parentValue = Environment.GetEnvironmentVariable("SALMONEGG_CREDENTIAL_CANARY"); + var profile = new ServerConfiguration + { + Transport = TransportType.Stdio, + StdioCommand = "/bin/sh", + StdioArguments = [script], + Authentication = new AuthenticationConfig { Token = Secret }, + }; + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Environment, "SALMONEGG_CREDENTIAL_CANARY"); + try + { + using var transport = CreateFactory(Log.Logger).CreateTransport(profile); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + transport.MessageReceived += (_, message) => + { + using var frame = JsonDocument.Parse(message.Message); + ready.TrySetResult(frame.RootElement.GetProperty("params").GetProperty("pid").GetInt32()); + }; + + Assert.True(await transport.ConnectAsync(TestContext.Current.CancellationToken)); + var pid = await ready.Task.WaitAsync(Deadline, TestContext.Current.CancellationToken); + var environment = await File.ReadAllTextAsync($"/proc/{pid}/environ", TestContext.Current.CancellationToken); + var arguments = await File.ReadAllTextAsync($"/proc/{pid}/cmdline", TestContext.Current.CancellationToken); + + Assert.Contains("SALMONEGG_CREDENTIAL_CANARY=" + Secret + '\0', environment); + Assert.DoesNotContain(Secret, arguments); + Assert.Equal(parentValue, Environment.GetEnvironmentVariable("SALMONEGG_CREDENTIAL_CANARY")); + Assert.False(profile.StdioEnvironment.ContainsKey("SALMONEGG_CREDENTIAL_CANARY")); + Assert.True(await transport.DisconnectAsync()); + Assert.False(Directory.Exists($"/proc/{pid}")); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void Factory_UnsupportedWebSocketHeaders_FailsBeforeCreatingConnection() + { + var profile = CreateProfile(TransportType.WebSocket, "wss://agent.example/acp", Secret); + profile.CredentialBinding = CredentialBindingPolicy.Create(profile, CredentialSource.Token, CredentialTarget.Header, Header); + + var error = Assert.Throws(() => CreateFactory(Log.Logger, supportsWebSocketHeaders: false).CreateTransport(profile)); + + Assert.Contains("HTTP endpoint", error.Message); + Assert.DoesNotContain(Secret, error.Message); + } + + private static TransportFactory CreateFactory(Serilog.ILogger logger, bool supportsWebSocketHeaders = true) + { + var capabilities = new Mock(); + capabilities.SetupGet(value => value.SupportsStdioTransport).Returns(true); + capabilities.SetupGet(value => value.SupportsWebSocketRequestHeaders).Returns(supportsWebSocketHeaders); + return new TransportFactory(logger, new TransportSupportPolicy(capabilities.Object), new DesktopStdioTransportFactory()); + } + + private static ServerConfiguration CreateProfile(TransportType kind, string url, string secret) => new() + { + Transport = kind, + ServerUrl = url, + Proxy = new ProxyConfig { Mode = ProxyMode.None }, + Authentication = new AuthenticationConfig { Token = secret }, + }; + + private sealed record CapturedRequest(string Method, string Path, string Credential, string Body); + + private sealed class RecordingLogSink : ILogEventSink + { + private readonly ConcurrentQueue _messages = new(); + public string Text => string.Join('\n', _messages); + public void Emit(LogEvent logEvent) => _messages.Enqueue(logEvent.RenderMessage() + logEvent.Exception); + } + + private sealed class CredentialPeer(WebApplication app) : IAsyncDisposable + { + private readonly CancellationTokenSource _stop = new(); + public ConcurrentQueue Requests { get; } = new(); + public TaskCompletionSource StreamReady { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public string HttpUrl => app.Urls.Single(); + public string Url { get; private set; } = string.Empty; + public string? RedirectTo { get; set; } + + public static async Task StartAsync(TransportType kind) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0, + listener => listener.Protocols = kind == TransportType.WebSocket ? HttpProtocols.Http1 : HttpProtocols.Http2)); + var app = builder.Build(); + app.UseWebSockets(); + var peer = new CredentialPeer(app); + app.Run(peer.HandleAsync); + await app.StartAsync(TestContext.Current.CancellationToken); + peer.Url = (kind == TransportType.WebSocket ? peer.HttpUrl.Replace("http://", "ws://", StringComparison.Ordinal) : peer.HttpUrl) + "/acp"; + return peer; + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await app.StopAsync(timeout.Token); + await app.DisposeAsync(); + _stop.Dispose(); + } + + private async Task HandleAsync(HttpContext context) + { + using var requestStop = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token, context.RequestAborted); + try + { + using var reader = new StreamReader(context.Request.Body); + var body = await reader.ReadToEndAsync(requestStop.Token); + Requests.Enqueue(new CapturedRequest(context.Request.Method, context.Request.Path, + context.Request.Headers[Header].ToString(), body)); + if (RedirectTo is { } destination && context.Request.Path == "/acp") + { + context.Response.StatusCode = StatusCodes.Status302Found; + context.Response.Headers.Location = destination; + return; + } + if (context.WebSockets.IsWebSocketRequest) + { + await HandleWebSocketAsync(context, requestStop.Token); + return; + } + if (HttpMethods.IsGet(context.Request.Method)) + { + context.Response.ContentType = "text/event-stream"; + await context.Response.WriteAsync(": connected\n\n", requestStop.Token); + await context.Response.Body.FlushAsync(requestStop.Token); + StreamReady.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, requestStop.Token); + return; + } + if (body.Contains("\"initialize\"", StringComparison.Ordinal)) + { + using var frame = JsonDocument.Parse(body); + context.Response.Headers["Acp-Connection-Id"] = "credential-canary"; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(InitializeResponse(frame.RootElement), requestStop.Token); + return; + } + context.Response.StatusCode = StatusCodes.Status202Accepted; + } + catch (OperationCanceledException) when (requestStop.IsCancellationRequested) + { + } + catch (WebSocketException) when (_stop.IsCancellationRequested) + { + } + } + + private async Task HandleWebSocketAsync(HttpContext context, CancellationToken cancellationToken) + { + using var socket = await context.WebSockets.AcceptWebSocketAsync(); + StreamReady.TrySetResult(); + var buffer = new byte[4096]; + while (socket.State == WebSocketState.Open) + { + var result = await socket.ReceiveAsync(buffer.AsMemory(), cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, cancellationToken); + return; + } + using var frame = JsonDocument.Parse(buffer.AsMemory(0, result.Count)); + var response = Encoding.UTF8.GetBytes(InitializeResponse(frame.RootElement)); + await socket.SendAsync(response.AsMemory(), WebSocketMessageType.Text, true, cancellationToken); + } + } + + private static string InitializeResponse(JsonElement request) + => "{\"jsonrpc\":\"2.0\",\"id\":" + request.GetProperty("id").GetRawText() + + ",\"result\":{\"protocolVersion\":1,\"agentCapabilities\":{},\"agentInfo\":{\"name\":\"credential-peer\",\"version\":\"1\"}}}"; + } +} From eaf432c0eeb1dac8e693b1541d146b9cecb5b1dc Mon Sep 17 00:00:00 2001 From: Shangxin Date: Wed, 9 Sep 2026 03:40:07 +0000 Subject: [PATCH 02/11] test(auth): verify credential bindings in the shipped CLI --- .../gates/run-cli-release-artifact-smoke.sh | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/scripts/gates/run-cli-release-artifact-smoke.sh b/scripts/gates/run-cli-release-artifact-smoke.sh index 48d14f59b..5e69a5ac5 100755 --- a/scripts/gates/run-cli-release-artifact-smoke.sh +++ b/scripts/gates/run-cli-release-artifact-smoke.sh @@ -245,8 +245,35 @@ expect_exit 0 "has-credential succeeds" expect_contains "$LAST_OUT" "token:" "has-credential reports token presence" expect_not_contains "$LAST_OUT" "$SECRET" "has-credential never prints the value" +echo "[artifact-smoke] 6a. explicit credential destination and retargeting" +run_cli --allow-insecure-storage config server update "$SERVER_ID" \ + --credential-source token --credential-header X-Agent-Key --credential-scheme Bearer +expect_exit 0 "explicit header binding succeeds" +run_cli config server show "$SERVER_ID" +expect_exit 0 "show of a credential-bound profile succeeds" +expect_contains "$LAST_OUT" "token -> header X-Agent-Key" "show reports the non-secret destination" +expect_contains "$LAST_OUT" "scheme: Bearer" "show reports the explicit scheme" +expect_not_contains "$LAST_OUT$LAST_ERR" "$SECRET" "show does not expose the bound credential" + +run_cli config server update "$SERVER_ID" --url "https://unapproved.example/acp" +expect_exit 2 "changing the destination requires an explicit new binding" +run_cli config server show "$SERVER_ID" +expect_not_contains "$LAST_OUT" "unapproved.example" "a refused retarget leaves the stored destination intact" + +run_cli --allow-insecure-storage config server update "$SERVER_ID" --url "https://approved.example/acp" \ + --credential-source token --credential-header X-Agent-Key --credential-scheme Bearer +expect_exit 0 "explicit rebinding approves the new destination" + run_cli clear-credential "$SERVER_ID" expect_exit 0 "clear-credential succeeds" +run_cli config server show "$SERVER_ID" +expect_contains "$LAST_OUT" "token -> header X-Agent-Key" "clearing the secret keeps its destination binding" +expect_contains "$LAST_OUT" "credential: unavailable" "cleared bound credentials cannot fall back to another value" + +run_cli config server update "$SERVER_ID" --clear-credential-binding +expect_exit 0 "explicit unbinding succeeds" +run_cli config server show "$SERVER_ID" +expect_not_contains "$LAST_OUT" "binding:" "unbinding removes the injection target" check if grep -rqF "$SECRET" "$APP_DATA_ROOT" 2>/dev/null; then fail "clear-credential left the secret on disk" From 869f8ecb7a5b9fd3c89b2a0d8d01efbe47d047d4 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Wed, 9 Sep 2026 05:23:09 +0000 Subject: [PATCH 03/11] feat(acp): bind profile credentials and guard connection reuse --- .../Views/ConfigurationEditorDialog.xaml | 43 ++ .../Settings/AgentProfileEditorPage.xaml | 37 ++ .../Resources/CoreStrings.en-US.resx | 17 + .../Resources/CoreStrings.en.resx | 17 + .../Resources/CoreStrings.resx | 17 + .../Resources/CoreStrings.zh-Hans.resx | 17 + .../Services/Chat/AcpChatCoordinator.cs | 101 +++-- .../Services/Chat/AcpChatServiceAdapter.cs | 42 ++ .../Services/Chat/AcpConnectionReuseKey.cs | 14 +- ...onfigurationEditorViewModel.Credentials.cs | 140 ++++++ .../ConfigurationEditorViewModel.cs | 102 ++--- .../AcpChatCoordinatorTests.Credentials.cs | 406 ++++++++++++++++++ .../Chat/AcpChatCoordinatorTests.cs | 2 +- ...urationEditorViewModelTests.Credentials.cs | 292 +++++++++++++ .../ConfigurationEditorViewModelTests.cs | 2 +- 15 files changed, 1172 insertions(+), 77 deletions(-) create mode 100644 src/SalmonEgg.Presentation.Core/ViewModels/ConfigurationEditorViewModel.Credentials.cs create mode 100644 tests/SalmonEgg.Presentation.Core.Tests/Chat/AcpChatCoordinatorTests.Credentials.cs create mode 100644 tests/SalmonEgg.Presentation.Core.Tests/Settings/ConfigurationEditorViewModelTests.Credentials.cs diff --git a/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml b/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml index 281cd767b..2d1656a0d 100644 --- a/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml +++ b/SalmonEgg/SalmonEgg/Presentation/Views/ConfigurationEditorDialog.xaml @@ -81,6 +81,49 @@ + + + + + + + + + + + + + + + + +