From 89043cb2b6d9d525c69905a7ef62b684697908d7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 18 May 2026 22:42:27 -0400 Subject: [PATCH] Resolve Client.Dotnet-004..008 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client.Dotnet-004: documented DefaultCallTimeout as both the per-attempt deadline and the shared retry budget, and removed DeadlineExceeded from the transient-retry set (a client-imposed deadline cannot be helped by retrying). Client.Dotnet-005: RegisterAsync/AddItemAsync/AddItem2Async silently returned 0 when a successful reply lacked the typed payload. They now throw a descriptive MxGatewayException. Client.Dotnet-006: added XML docs to the previously undocumented public members MaxGrpcMessageBytes, GatewayProtocolVersion, WorkerProtocolVersion. Client.Dotnet-007: corrected the AcknowledgeAlarmAsync XML comment — the RPC requires the admin scope, not a non-existent invoke:alarm-ack sub-scope. Client.Dotnet-008: the CLI redactor missed env-var-sourced keys because the caller passed only the --api-key option. Redaction now uses the same resolver, stripping env-var keys too. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MxGatewayClientCli.cs | 35 ++++++--- .../MxGatewayClientCliTests.cs | 37 +++++++++ .../MxGatewayClientSessionTests.cs | 78 +++++++++++++++++++ .../MxGateway.Client/MxGatewayClient.cs | 7 +- .../MxGatewayClientContractInfo.cs | 10 +++ .../MxGatewayClientOptions.cs | 12 ++- .../MxGatewayClientRetryPolicy.cs | 7 +- .../MxGateway.Client/MxGatewaySession.cs | 26 ++++++- clients/dotnet/README.md | 3 +- code-reviews/Client.Dotnet/findings.md | 22 +++--- 10 files changed, 208 insertions(+), 29 deletions(-) diff --git a/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs b/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs index 2fcc66f..c07aab5 100644 --- a/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs +++ b/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs @@ -122,7 +122,10 @@ public static class MxGatewayClientCli } catch (Exception exception) when (exception is not OperationCanceledException) { - string? apiKey = arguments.GetOptional("api-key"); + // Redact the effective API key — whether it came from --api-key or from + // the (documented default) --api-key-env environment variable — so a + // transport error message that echoes the bearer token is never printed. + string? apiKey = TryResolveApiKey(arguments); string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey); if (arguments.HasFlag("json")) @@ -167,6 +170,27 @@ public static class MxGatewayClientCli } private static string ResolveApiKey(CliArguments arguments) + { + string? apiKey = TryResolveApiKey(arguments); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + return apiKey; + } + + string apiKeyEnvironmentName = arguments.GetOptional("api-key-env") + ?? "MXGATEWAY_API_KEY"; + + throw new ArgumentException( + $"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}."); + } + + /// + /// Resolves the effective API key from --api-key or, failing that, the + /// environment variable named by --api-key-env (default + /// MXGATEWAY_API_KEY). Returns when no key is + /// configured; used for redaction where a missing key must not throw. + /// + private static string? TryResolveApiKey(CliArguments arguments) { string? apiKey = arguments.GetOptional("api-key"); if (!string.IsNullOrWhiteSpace(apiKey)) @@ -177,14 +201,7 @@ public static class MxGatewayClientCli string apiKeyEnvironmentName = arguments.GetOptional("api-key-env") ?? "MXGATEWAY_API_KEY"; - apiKey = Environment.GetEnvironmentVariable(apiKeyEnvironmentName); - if (!string.IsNullOrWhiteSpace(apiKey)) - { - return apiKey; - } - - throw new ArgumentException( - $"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}."); + return Environment.GetEnvironmentVariable(apiKeyEnvironmentName); } private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command) diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs index b950869..05b2072 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs @@ -106,6 +106,43 @@ public sealed class MxGatewayClientCliTests Assert.Contains("[redacted]", error.ToString()); } + /// + /// Verifies that error output redacts the API key even when it was sourced from + /// the --api-key-env environment variable rather than passed via + /// --api-key — the documented default credential path. + /// + [Fact] + public async Task RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable() + { + const string environmentVariableName = "MXGATEWAY_TEST_API_KEY_REDACT"; + using var output = new StringWriter(); + using var error = new StringWriter(); + + Environment.SetEnvironmentVariable(environmentVariableName, "env-secret-api-key"); + try + { + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "open-session", + "--endpoint", + "http://localhost:5000", + "--api-key-env", + environmentVariableName, + ], + output, + error, + _ => throw new InvalidOperationException("boom env-secret-api-key")); + + Assert.Equal(1, exitCode); + Assert.DoesNotContain("env-secret-api-key", error.ToString()); + Assert.Contains("[redacted]", error.ToString()); + } + finally + { + Environment.SetEnvironmentVariable(environmentVariableName, null); + } + } + /// Verifies that stream-events with max-events limit stops output in non-JSON format. [Fact] public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput() diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs index 3afa614..db222b6 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs @@ -378,6 +378,84 @@ public sealed class MxGatewayClientSessionTests Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken); } + /// + /// Verifies that a client-imposed is not + /// retried. The deadline budget is shared across the whole safe-unary operation, so + /// an immediate retry would only fail again — the call must surface the failure. + /// + [Fact] + public async Task InvokeAsync_DoesNotRetrySafeDiagnosticCommand_OnDeadlineExceeded() + { + FakeGatewayTransport transport = CreateTransport(); + transport.InvokeExceptions.Enqueue( + new RpcException(new Status(StatusCode.DeadlineExceeded, "deadline exceeded"))); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.Ping, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + await Assert.ThrowsAsync(async () => await session.InvokeAsync( + new MxCommandRequest + { + SessionId = session.SessionId, + Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() }, + })); + + Assert.Single(transport.InvokeCalls); + } + + /// + /// Verifies that a successful register reply missing the typed register + /// payload throws a descriptive rather than + /// silently returning a zero server handle. + /// + [Fact] + public async Task RegisterAsync_Throws_WhenSuccessfulReplyMissingPayload() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.Register, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + MxGatewayException exception = await Assert.ThrowsAsync( + async () => await session.RegisterAsync("client-name")); + + Assert.Contains("register", exception.Message, StringComparison.Ordinal); + } + + /// + /// Verifies that a successful add-item reply missing the typed add_item + /// payload throws a descriptive rather than + /// silently returning a zero item handle. + /// + [Fact] + public async Task AddItemAsync_Throws_WhenSuccessfulReplyMissingPayload() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.AddItem, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + + MxGatewayException exception = await Assert.ThrowsAsync( + async () => await session.AddItemAsync(1, "Area.Pump.Speed")); + + Assert.Contains("add_item", exception.Message, StringComparison.Ordinal); + } + private static MxGatewayClient CreateClient(FakeGatewayTransport transport) { return new MxGatewayClient(transport.Options, transport); diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClient.cs b/clients/dotnet/MxGateway.Client/MxGatewayClient.cs index 5660b8b..f46b7ed 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayClient.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayClient.cs @@ -184,9 +184,10 @@ public sealed class MxGatewayClient : IAsyncDisposable /// /// Acknowledges an active MXAccess alarm condition through the gateway. The - /// gateway authenticates the request against the API key's invoke:alarm-ack - /// scope and forwards the acknowledge to the worker's MXAccess session; - /// the resulting is returned in the reply. + /// gateway authorizes against the API + /// key's admin scope (there is no finer-grained alarm-ack sub-scope) + /// and forwards the acknowledge to the worker's MXAccess session; the + /// resulting is returned in the reply. /// /// The acknowledge request — alarm reference, comment, operator user. /// Cancellation token for the operation. diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs b/clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs index 4869faa..1ab8cc3 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs @@ -7,9 +7,19 @@ namespace MxGateway.Client; /// public static class MxGatewayClientContractInfo { + /// + /// Gets the gateway gRPC protocol version compiled into this client package. + /// A client and gateway are wire-compatible only when this value matches the + /// gateway's advertised gateway protocol version. + /// public const uint GatewayProtocolVersion = GatewayContractInfo.GatewayProtocolVersion; + /// + /// Gets the worker frame protocol version compiled into this client package. + /// Exposed for diagnostics so callers can report the worker protocol the + /// shared contracts were generated against. + /// public const uint WorkerProtocolVersion = GatewayContractInfo.WorkerProtocolVersion; } diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs b/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs index bb900c9..0840301 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs @@ -38,7 +38,12 @@ public sealed class MxGatewayClientOptions public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10); /// - /// Gets the default timeout for unary gRPC calls. + /// Gets the timeout budget for a unary gRPC operation. This is both the gRPC + /// deadline stamped on each individual attempt and the overall budget for the + /// whole safe-unary operation: for retryable calls the initial attempt, every + /// retry, and the backoff delays between them all share this single budget. + /// It is therefore an upper bound on the total wall-clock time a safe-unary + /// call can take, not a fresh per-retry allowance. /// public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30); @@ -47,6 +52,11 @@ public sealed class MxGatewayClientOptions /// public TimeSpan? StreamTimeout { get; init; } + /// + /// Gets the maximum size, in bytes, of a single gRPC message the client will + /// send or receive. Applied to both the send and receive limits of the + /// underlying channel. Defaults to 16 MiB. + /// public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024; /// diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClientRetryPolicy.cs b/clients/dotnet/MxGateway.Client/MxGatewayClientRetryPolicy.cs index af6e63d..a4a9e08 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayClientRetryPolicy.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayClientRetryPolicy.cs @@ -61,8 +61,13 @@ internal static class MxGatewayClientRetryPolicy private static bool IsTransientStatus(StatusCode statusCode) { + // DeadlineExceeded is intentionally NOT treated as transient. The deadline + // on every unary call is client-imposed (CreateCallOptions stamps the + // DefaultCallTimeout budget), and that same budget is shared across the + // initial attempt plus all retries plus backoff. A DeadlineExceeded means + // the shared budget is exhausted, so an immediate retry would only fail + // again — burning the remaining budget on a call that cannot succeed. return statusCode is StatusCode.Unavailable - or StatusCode.DeadlineExceeded or StatusCode.ResourceExhausted; } } diff --git a/clients/dotnet/MxGateway.Client/MxGatewaySession.cs b/clients/dotnet/MxGateway.Client/MxGatewaySession.cs index ffc023e..9876071 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewaySession.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewaySession.cs @@ -101,7 +101,8 @@ public sealed class MxGatewaySession : IAsyncDisposable MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken) .ConfigureAwait(false); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); - return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value; + return reply.Register?.ServerHandle + ?? throw CreateMissingPayloadException(reply, "register"); } /// @@ -143,7 +144,8 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken) .ConfigureAwait(false); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); - return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value; + return reply.AddItem?.ItemHandle + ?? throw CreateMissingPayloadException(reply, "add_item"); } /// @@ -194,7 +196,8 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken) .ConfigureAwait(false); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); - return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value; + return reply.AddItem2?.ItemHandle + ?? throw CreateMissingPayloadException(reply, "add_item2"); } /// @@ -723,4 +726,21 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Builds the exception thrown when a command reply passed protocol and + /// MXAccess success checks but is missing the typed handle-bearing payload + /// the command contract requires. Surfacing this as a clear error avoids + /// silently handing a zero handle to the caller (it would otherwise fall + /// through to , which is 0 when the + /// reply carries no return value). + /// + private static MxGatewayException CreateMissingPayloadException( + MxCommandReply reply, + string expectedPayload) + { + return new MxGatewayException( + $"Gateway reply for command kind={reply.Kind} reported success but is missing " + + $"the required '{expectedPayload}' payload; cannot resolve a handle. " + + $"session={reply.SessionId}; correlation={reply.CorrelationId}"); + } } diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index 7cb9977..c4183f7 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -142,7 +142,8 @@ dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint htt `smoke` opens a session, registers a client, adds one item, advises it, optionally writes a value when `--type` and `--value` are supplied, reads a bounded event stream, and closes the session in a `finally` block. CLI error -output redacts API keys supplied through `--api-key`. +output redacts the effective API key, whether it was supplied through +`--api-key` or resolved from the `--api-key-env` environment variable. ## Galaxy Repository Browse diff --git a/code-reviews/Client.Dotnet/findings.md b/code-reviews/Client.Dotnet/findings.md index b2ba5fe..7cc9ddc 100644 --- a/code-reviews/Client.Dotnet/findings.md +++ b/code-reviews/Client.Dotnet/findings.md @@ -7,7 +7,7 @@ | Review date | 2026-05-18 | | Commit reviewed | `3cc53a8` | | Status | Reviewed | -| Open findings | 5 | +| Open findings | 0 | ## Checklist coverage @@ -78,13 +78,13 @@ | Severity | Low | | Category | Error handling & resilience | | Location | `clients/dotnet/MxGateway.Client/MxGatewayClient.cs:283-294`, `clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs:392-403` | -| Status | Open | +| Status | Resolved | **Description:** `ExecuteSafeUnaryAsync` wraps the whole Polly retry pipeline in a single linked CTS cancelled after `Options.DefaultCallTimeout`, while `CreateCallOptions` also stamps each individual call with a `DefaultCallTimeout` gRPC deadline. The retry pipeline therefore shares one `DefaultCallTimeout` budget across the initial attempt plus all retries plus backoff delays. The README/XML docs describe `DefaultCallTimeout` as a per-call timeout, which misrepresents this. `DeadlineExceeded` is also classified as transient, so an attempt that exhausts the shared budget is retried only to immediately fail again. **Recommendation:** Decide whether `DefaultCallTimeout` is per-attempt or per-operation and make code and docs consistent — e.g. a separate per-attempt deadline and a distinct overall-operation timeout. Reconsider retrying on `DeadlineExceeded` when the deadline was client-imposed. -**Resolution:** _(open)_ +**Resolution:** (2026-05-18) Confirmed against source: the shared linked-CTS budget plus per-call deadline both use `DefaultCallTimeout`, and `IsTransientStatus` listed `DeadlineExceeded`. Resolved as a per-operation budget (the simpler, non-breaking choice): the `DefaultCallTimeout` XML doc in `MxGatewayClientOptions.cs` now states it is both the per-attempt gRPC deadline and the overall budget shared across the initial attempt, every retry, and the backoff delays — an upper bound on total wall-clock time, not a fresh per-retry allowance. Removed `DeadlineExceeded` from `MxGatewayClientRetryPolicy.IsTransientStatus`: every unary deadline is client-imposed (`CreateCallOptions` stamps the shared budget), so a `DeadlineExceeded` means the budget is exhausted and an immediate retry can only fail again. Regression test `MxGatewayClientSessionTests.InvokeAsync_DoesNotRetrySafeDiagnosticCommand_OnDeadlineExceeded` asserts the safe diagnostic command (`Ping`) is attempted exactly once and the failure surfaces; verified red against the original transient set (the call retried and succeeded). ### Client.Dotnet-005 @@ -93,13 +93,13 @@ | Severity | Low | | Category | Correctness & logic bugs | | Location | `clients/dotnet/MxGateway.Client/MxGatewaySession.cs:82,124,175` | -| Status | Open | +| Status | Resolved | **Description:** `RegisterAsync`/`AddItemAsync`/`AddItem2Async` return `reply.?.ServerHandle ?? reply.ReturnValue.Int32Value`. After `EnsureMxAccessSuccess()` passes, a missing typed payload silently falls back to `ReturnValue.Int32Value`, which for a reply carrying no return value is `0`. A caller then uses `0` as a `ServerHandle`/`ItemHandle`, producing a confusing downstream invalid-handle failure rather than a clear "gateway reply missing payload" error. **Recommendation:** If the typed sub-message is the contract for these commands, treat its absence on an otherwise-successful reply as an error (throw a descriptive `MxGatewayException`) rather than falling through to `ReturnValue.Int32Value`. -**Resolution:** _(open)_ +**Resolution:** (2026-05-18) Confirmed against source and `mxaccess_gateway.proto`: `register`/`add_item`/`add_item2` are members of the `MxCommandReply.payload` oneof, so the typed accessor is `null` whenever the worker did not set that case — and the fallback returned `ReturnValue.Int32Value` (0 for a reply with no return value). The typed sub-message is the contract for these handle-returning commands, so its absence on an otherwise-successful reply is now an error: `RegisterAsync`/`AddItemAsync`/`AddItem2Async` throw via a new private `MxGatewaySession.CreateMissingPayloadException` helper that builds a descriptive `MxGatewayException` naming the missing payload, kind, session, and correlation id. Regression tests `MxGatewayClientSessionTests.RegisterAsync_Throws_WhenSuccessfulReplyMissingPayload` and `AddItemAsync_Throws_WhenSuccessfulReplyMissingPayload` enqueue an `Ok` reply with no typed payload and assert the descriptive throw; verified red against the original fallback (returned `0` instead of throwing). ### Client.Dotnet-006 @@ -108,13 +108,13 @@ | Severity | Low | | Category | Code organization & conventions | | Location | `clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs:50`, `clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs:10-14` | -| Status | Open | +| Status | Resolved | **Description:** `MxGatewayClientOptions.MaxGrpcMessageBytes` and the two `const`s in `MxGatewayClientContractInfo` are public members with no XML doc comments, inconsistent with every other public member in the assembly and with the repo's documented C# style emphasis on a documented public surface. **Recommendation:** Add `` doc comments to `MaxGrpcMessageBytes`, `GatewayProtocolVersion`, and `WorkerProtocolVersion`. -**Resolution:** _(open)_ +**Resolution:** (2026-05-18) Confirmed: all three public members lacked XML docs while every other public member in the assembly is documented. Added `` comments to `MxGatewayClientOptions.MaxGrpcMessageBytes` (describing the 16 MiB default applied to both send and receive limits), and to `MxGatewayClientContractInfo.GatewayProtocolVersion` and `WorkerProtocolVersion` (describing their wire-compatibility / diagnostics purpose). Pure documentation change — no test needed; build remains warning-clean. ### Client.Dotnet-007 @@ -123,13 +123,13 @@ | Severity | Low | | Category | Documentation & comments | | Location | `clients/dotnet/MxGateway.Client/MxGatewayClient.cs:185-192` | -| Status | Open | +| Status | Resolved | **Description:** The `AcknowledgeAlarmAsync` XML comment states the gateway authenticates against an `invoke:alarm-ack` scope, but `CLAUDE.md` documents the scope set without any `invoke:alarm-ack` sub-scope. The comment may describe an intended finer-grained scope that does not exist, misleading integrators about what API key they need. **Recommendation:** Reconcile the comment with the actual server-side scope check, or update the scope documentation if sub-scopes were genuinely added; keep client doc and gateway auth model in sync. -**Resolution:** _(open)_ +**Resolution:** (2026-05-18) Confirmed against the server-side authorization model: `GatewayGrpcScopeResolver.ResolveRequiredScope` has no arm for `AcknowledgeAlarmRequest`, so it falls to the `_ => GatewayScopes.Admin` default — the RPC actually requires the `admin` scope. No `invoke:alarm-ack` sub-scope exists anywhere in `GatewayScopes`. The client XML comment on `AcknowledgeAlarmAsync` was wrong, not the docs. Corrected the comment to state the gateway authorizes `AcknowledgeAlarmRequest` against the API key's `admin` scope and that there is no finer-grained alarm-ack sub-scope. Pure documentation change — no test needed. ### Client.Dotnet-008 @@ -138,10 +138,10 @@ | Severity | Low | | Category | Correctness & logic bugs | | Location | `clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs:9-17` | -| Status | Open | +| Status | Resolved | **Description:** The CLI redactor only removes the API key string when it was supplied via `--api-key`; `RunCoreAsync` passes `arguments.GetOptional("api-key")` to `Redact`. When the key comes from an environment variable (`--api-key-env`, the documented default path), `apiKey` is `null` and no redaction occurs. If a gRPC/transport error message ever echoes the bearer token, it would be printed unredacted. **Recommendation:** Resolve the effective API key (same logic as `ResolveApiKey`) before redacting, so the env-var-sourced key is also stripped from error output. -**Resolution:** _(open)_ +**Resolution:** (2026-05-18) Confirmed against source: `MxGatewayClientCli.RunCoreAsync`'s catch block redacted only `arguments.GetOptional("api-key")`, so an env-var-sourced key (`--api-key-env`, default `MXGATEWAY_API_KEY`) was never stripped. Note `MxGatewayCliSecretRedactor` itself is correct — the defect was the caller passing the wrong value. Extracted a non-throwing `TryResolveApiKey` helper (used by both the existing `ResolveApiKey` and the catch block) that resolves `--api-key` then the `--api-key-env` environment variable; the catch block now redacts that effective key. Updated `clients/dotnet/README.md` (`smoke` paragraph) to state the CLI redacts the effective key whether from `--api-key` or `--api-key-env`. Regression test `MxGatewayClientCliTests.RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable` sets a test env var, forces a transport error echoing the key, and asserts the key is absent and `[redacted]` is present; verified red against the original `GetOptional("api-key")`-only redaction (key printed unredacted).