feat(clients): CLI-04 typed single-item command parity (+CLI-30 unregister) — 4/5 clients

Every parity-critical single-item MXAccess command now has a typed session
helper instead of only a raw-Invoke escape hatch. Added per client:
- Phase 1: AdviseSupervisory, WriteSecured, WriteSecured2, AuthenticateUser,
  ArchestrAUserToId
- Phase 2: AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate
- CLI-30: Unregister (Rust + .NET; Go/Python already had it)

Each wraps the existing raw-command machinery (no new wire surface) and runs the
same MXAccess-level reply validation (hresult < 0 + MxStatusProxy). MXAccess
parity preserved: WriteSecured before AuthenticateUser+AdviseSupervisory surfaces
the native failure unchanged (not pre-validated/reordered). Credentials
(AuthenticateUser password, WriteSecured payloads) route through each client's
secret-redaction seam and never reach logs/exceptions/ToString/Debug/Display;
each suite asserts a distinctive credential is absent from surfaced errors. New
CLI subcommands source credentials via flag/env, never echoed.

- .NET: 21 helpers (validated + Raw), CLI subcommands, multi-secret CLI redactor.
  Build clean (0 warn), 102 passed.
- Go: 9 helpers + *Raw variants, redactSecrets seam, promoted CLI advise-supervisory
  to typed. gofmt/vet/build/test clean.
- Rust: 10 helpers incl. unregister; verified ensure_mxaccess_success runs on
  secured paths; error.rs credential scrub. fmt/check/test/clippy clean.
- Python: 9 async helpers, redact_secret seam + _invoke_redacted, CLI commands.
  145 passed.
- Shared doc: ClientLibrariesDesign "Typed Command Parity" section.

Java client typed parity is batched to windev (no local JRE); CLI-04 + CLI-30
stay open until it lands.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
Joseph Doherty
2026-07-09 16:41:43 -04:00
parent 4090a478c8
commit bde042b4d4
21 changed files with 3496 additions and 97 deletions
@@ -129,6 +129,120 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
[Fact]
public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.WriteSecured,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"write-secured",
"--endpoint", "http://localhost:5000",
"--api-key", "test-api-key",
"--session-id", "session-fixture",
"--server-handle", "12",
"--item-handle", "34",
"--type", "int32",
"--value", "123",
"--current-user-id", "5",
"--verifier-user-id", "6",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind);
Assert.Equal(123, request.Command.WriteSecured.Value.Int32Value);
Assert.Equal(5, request.Command.WriteSecured.CurrentUserId);
Assert.Equal(6, request.Command.WriteSecured.VerifierUserId);
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
/// credential from the flag, and that the credential never appears in stdout/stderr.
/// </summary>
[Fact]
public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
{
const string password = "cli-secret-credential-987";
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AuthenticateUser,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AuthenticateUser = new AuthenticateUserReply { UserId = 4242 },
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"authenticate-user",
"--endpoint", "http://localhost:5000",
"--api-key", "test-api-key",
"--session-id", "session-fixture",
"--server-handle", "12",
"--verify-user", "operator",
"--verify-user-password", password,
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind);
Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser);
Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
Assert.DoesNotContain(password, output.ToString());
Assert.DoesNotContain(password, error.ToString());
}
/// <summary>
/// CLI-04: a surfaced error for authenticate-user must have the credential
/// redacted (never echoed to stderr), mirroring the API-key redaction seam.
/// </summary>
[Fact]
public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
{
const string password = "leaky-credential-value";
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = await MxGatewayClientCli.RunAsync(
[
"authenticate-user",
"--endpoint", "http://localhost:5000",
"--api-key", "test-api-key",
"--session-id", "session-fixture",
"--server-handle", "12",
"--verify-user", "operator",
"--verify-user-password", password,
],
output,
error,
_ => throw new InvalidOperationException($"boom {password}"));
Assert.Equal(1, exitCode);
Assert.DoesNotContain(password, error.ToString());
Assert.Contains("[redacted]", error.ToString());
}
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
[Fact]
public async Task RunAsync_ErrorOutput_RedactsApiKey()
@@ -415,6 +415,297 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(7, el.Value.Int32Value);
}
/// <summary>Verifies that unregister builds an unregister command with the server handle.</summary>
[Fact]
public async Task UnregisterAsync_BuildsUnregisterCommand()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Unregister,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await session.UnregisterAsync(12);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.Unregister, request.Command.Kind);
Assert.Equal(12, request.Command.Unregister.ServerHandle);
}
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
[Fact]
public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AdviseSupervisory,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await session.AdviseSupervisoryAsync(12, 34);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.AdviseSupervisory, request.Command.Kind);
Assert.Equal(12, request.Command.AdviseSupervisory.ServerHandle);
Assert.Equal(34, request.Command.AdviseSupervisory.ItemHandle);
}
/// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary>
[Fact]
public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AddBufferedItem,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AddBufferedItem = new AddBufferedItemReply { ItemHandle = 77 },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
int itemHandle = await session.AddBufferedItemAsync(12, "Area001.Pump001.Speed", "runtime");
Assert.Equal(77, itemHandle);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.AddBufferedItem, request.Command.Kind);
Assert.Equal(12, request.Command.AddBufferedItem.ServerHandle);
Assert.Equal("Area001.Pump001.Speed", request.Command.AddBufferedItem.ItemDefinition);
Assert.Equal("runtime", request.Command.AddBufferedItem.ItemContext);
}
/// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary>
[Fact]
public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.SetBufferedUpdateInterval,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await session.SetBufferedUpdateIntervalAsync(12, 500);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.SetBufferedUpdateInterval, request.Command.Kind);
Assert.Equal(12, request.Command.SetBufferedUpdateInterval.ServerHandle);
Assert.Equal(500, request.Command.SetBufferedUpdateInterval.UpdateIntervalMilliseconds);
}
/// <summary>Verifies that suspend builds the command and returns the reply status.</summary>
[Fact]
public async Task SuspendAsync_BuildsCommandAndReturnsStatus()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Suspend,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Suspend = new SuspendReply { Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok } },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxStatusProxy? status = await session.SuspendAsync(12, 34);
Assert.NotNull(status);
Assert.Equal(1, status!.Success);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.Suspend, request.Command.Kind);
Assert.Equal(12, request.Command.Suspend.ServerHandle);
Assert.Equal(34, request.Command.Suspend.ItemHandle);
}
/// <summary>Verifies that activate builds the command and returns the reply status.</summary>
[Fact]
public async Task ActivateAsync_BuildsCommandAndReturnsStatus()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Activate,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Activate = new ActivateReply { Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok } },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxStatusProxy? status = await session.ActivateAsync(12, 34);
Assert.NotNull(status);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.Activate, request.Command.Kind);
Assert.Equal(34, request.Command.Activate.ItemHandle);
}
/// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary>
[Fact]
public async Task WriteSecuredAsync_BuildsWriteSecuredCommand()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.WriteSecured,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxValue value = 123.ToMxValue();
await session.WriteSecuredAsync(12, 34, value, currentUserId: 5, verifierUserId: 6);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind);
Assert.Equal(12, request.Command.WriteSecured.ServerHandle);
Assert.Equal(34, request.Command.WriteSecured.ItemHandle);
Assert.Same(value, request.Command.WriteSecured.Value);
Assert.Equal(5, request.Command.WriteSecured.CurrentUserId);
Assert.Equal(6, request.Command.WriteSecured.VerifierUserId);
}
/// <summary>
/// MXAccess parity: WriteSecured issued before a prior AuthenticateUser fails
/// natively. The client must surface that scripted failure (as an
/// <see cref="MxAccessException"/>) rather than pre-validating it away.
/// </summary>
[Fact]
public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.WriteSecured,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure },
Hresult = unchecked((int)0x80040200),
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxAccessException exception = await Assert.ThrowsAsync<MxAccessException>(
async () => await session.WriteSecuredAsync(12, 34, 123.ToMxValue(), currentUserId: 0, verifierUserId: 0));
// The native HRESULT is surfaced; the request payload is never in the message.
Assert.Contains("WriteSecured", exception.Message);
Assert.Single(transport.InvokeCalls);
}
/// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary>
[Fact]
public async Task WriteSecured2Async_BuildsWriteSecured2Command()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.WriteSecured2,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxValue value = 123.ToMxValue();
MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue();
await session.WriteSecured2Async(12, 34, value, timestampValue, currentUserId: 5, verifierUserId: 6);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.WriteSecured2, request.Command.Kind);
Assert.Same(value, request.Command.WriteSecured2.Value);
Assert.Same(timestampValue, request.Command.WriteSecured2.TimestampValue);
Assert.Equal(5, request.Command.WriteSecured2.CurrentUserId);
Assert.Equal(6, request.Command.WriteSecured2.VerifierUserId);
}
/// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary>
[Fact]
public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AuthenticateUser,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AuthenticateUser = new AuthenticateUserReply { UserId = 4242 },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
int userId = await session.AuthenticateUserAsync(12, "operator", "s3cr3t-p@ss");
Assert.Equal(4242, userId);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind);
Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser);
Assert.Equal("s3cr3t-p@ss", request.Command.AuthenticateUser.VerifyUserPassword);
}
/// <summary>
/// SECRET REDACTION: when AuthenticateUser fails, the surfaced exception message
/// must never contain the credential — the error path is built only from
/// reply-derived diagnostics, not the request payload.
/// </summary>
[Fact]
public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage()
{
const string password = "super-secret-credential-123";
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AuthenticateUser,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure },
Hresult = unchecked((int)0x80040210),
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxAccessException exception = await Assert.ThrowsAsync<MxAccessException>(
async () => await session.AuthenticateUserAsync(12, "operator", password));
Assert.DoesNotContain(password, exception.Message);
Assert.DoesNotContain(password, exception.ToString());
}
/// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary>
[Fact]
public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.ArchestraUserToId,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
ArchestraUserToId = new ArchestrAUserToIdReply { UserId = 909 },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
int userId = await session.ArchestraUserToIdAsync(12, "BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
Assert.Equal(909, userId);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.ArchestraUserToId, request.Command.Kind);
Assert.Equal("BCC47053-9542-4D65-BDAA-BCDEA6A32A73", request.Command.ArchestraUserToId.UserIdGuid);
}
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
{
return new MxGatewayClient(transport.Options, transport);