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
@@ -112,6 +112,24 @@ public static class MxGatewayClientCli
.ConfigureAwait(false),
"advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"unregister" => await UnregisterAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"add-buffered-item" => await AddBufferedItemAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"set-buffered-update-interval" => await SetBufferedUpdateIntervalAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"suspend" => await SuspendAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"activate" => await ActivateAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"write-secured" => await WriteSecuredAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"write-secured2" => await WriteSecured2Async(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"authenticate-user" => await AuthenticateUserAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"archestra-user-to-id" => await ArchestraUserToIdAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"subscribe-bulk" => await SubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
@@ -157,9 +175,15 @@ public static class MxGatewayClientCli
{
// Client.Dotnet-028: redact the *effective* key — from --api-key or the
// --api-key-env environment variable — so an env-var-sourced key echoed
// in a transport error never reaches stderr unredacted.
// in a transport error never reaches stderr unredacted. CLI-04: also
// redact MXAccess credentials (AuthenticateUser password, WriteSecured
// payloads) that could otherwise be echoed back in a surfaced error.
string? apiKey = TryResolveApiKey(arguments);
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
string message = MxGatewayCliSecretRedactor.Redact(
exception.Message,
apiKey,
TryResolveVerifyUserPassword(arguments),
arguments.GetOptional("value"));
if (forceJsonErrors || arguments.HasFlag("json"))
{
@@ -319,6 +343,48 @@ public static class MxGatewayClientCli
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
}
/// <summary>
/// Resolves the effective MXAccess verify-user credential from
/// <c>--verify-user-password</c> or, failing that, the
/// <c>--verify-user-password-env</c>-named environment variable (default
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>). The credential is never echoed;
/// this resolver exists so the error-redaction catch block can strip it
/// from any surfaced error (CLI-04), mirroring <see cref="TryResolveApiKey"/>.
/// </summary>
private static string? TryResolveVerifyUserPassword(CliArguments arguments)
{
string? password = arguments.GetOptional("verify-user-password");
if (!string.IsNullOrEmpty(password))
{
return password;
}
string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env")
?? "MXGATEWAY_VERIFY_USER_PASSWORD";
return Environment.GetEnvironmentVariable(passwordEnvironmentName);
}
/// <summary>
/// Resolves the verify-user credential for <c>authenticate-user</c>, throwing
/// a redaction-safe error when neither the flag nor the env var is set. The
/// thrown message names only the option/env var, never the value.
/// </summary>
private static string ResolveVerifyUserPassword(CliArguments arguments)
{
string? password = TryResolveVerifyUserPassword(arguments);
if (!string.IsNullOrEmpty(password))
{
return password;
}
string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env")
?? "MXGATEWAY_VERIFY_USER_PASSWORD";
throw new ArgumentException(
$"Verify-user password is required. Pass --verify-user-password or set {passwordEnvironmentName}.");
}
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
{
var cancellation = new CancellationTokenSource();
@@ -475,6 +541,215 @@ public static class MxGatewayClientCli
cancellationToken);
}
private static Task<int> UnregisterAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.Unregister,
Unregister = new UnregisterCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
},
},
cancellationToken);
}
private static Task<int> AddBufferedItemAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.AddBufferedItem,
AddBufferedItem = new AddBufferedItemCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
ItemDefinition = arguments.GetRequired("item"),
ItemContext = arguments.GetOptional("item-context") ?? string.Empty,
},
},
cancellationToken);
}
private static Task<int> SetBufferedUpdateIntervalAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.SetBufferedUpdateInterval,
SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
UpdateIntervalMilliseconds = arguments.GetInt32("interval-ms"),
},
},
cancellationToken);
}
private static Task<int> SuspendAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.Suspend,
Suspend = new SuspendCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
ItemHandle = arguments.GetInt32("item-handle"),
},
},
cancellationToken);
}
private static Task<int> ActivateAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.Activate,
Activate = new ActivateCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
ItemHandle = arguments.GetInt32("item-handle"),
},
},
cancellationToken);
}
private static Task<int> WriteSecuredAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.WriteSecured,
WriteSecured = new WriteSecuredCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
ItemHandle = arguments.GetInt32("item-handle"),
CurrentUserId = arguments.GetInt32("current-user-id"),
VerifierUserId = arguments.GetInt32("verifier-user-id", 0),
Value = ParseValue(arguments),
},
},
cancellationToken);
}
private static Task<int> WriteSecured2Async(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.WriteSecured2,
WriteSecured2 = new WriteSecured2Command
{
ServerHandle = arguments.GetInt32("server-handle"),
ItemHandle = arguments.GetInt32("item-handle"),
CurrentUserId = arguments.GetInt32("current-user-id"),
VerifierUserId = arguments.GetInt32("verifier-user-id", 0),
Value = ParseValue(arguments),
TimestampValue = ParseTimestampValue(arguments),
},
},
cancellationToken);
}
private static Task<int> AuthenticateUserAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
// The credential is resolved from --verify-user-password or its env var and
// is never echoed. On any surfaced error the RunCoreAsync catch block routes
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.AuthenticateUser,
AuthenticateUser = new AuthenticateUserCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
VerifyUser = arguments.GetRequired("verify-user"),
VerifyUserPassword = ResolveVerifyUserPassword(arguments),
},
},
cancellationToken);
}
private static Task<int> ArchestraUserToIdAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return InvokeAndWriteAsync(
arguments,
client,
output,
new MxCommand
{
Kind = MxCommandKind.ArchestraUserToId,
ArchestraUserToId = new ArchestrAUserToIdCommand
{
ServerHandle = arguments.GetInt32("server-handle"),
UserIdGuid = arguments.GetRequired("user-guid"),
},
},
cancellationToken);
}
private static Task<int> SubscribeBulkAsync(
CliArguments arguments,
IMxGatewayCliClient client,
@@ -2029,6 +2304,15 @@ public static class MxGatewayClientCli
or "add-item"
or "advise"
or "advise-supervisory"
or "unregister"
or "add-buffered-item"
or "set-buffered-update-interval"
or "suspend"
or "activate"
or "write-secured"
or "write-secured2"
or "authenticate-user"
or "archestra-user-to-id"
or "subscribe-bulk"
or "unsubscribe-bulk"
or "read-bulk"
@@ -2092,6 +2376,15 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet advise-supervisory --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet unregister --session-id <id> --server-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet add-buffered-item --session-id <id> --server-handle <n> --item <ref> [--item-context <s>] [--json]");
writer.WriteLine("mxgw-dotnet set-buffered-update-interval --session-id <id> --server-handle <n> --interval-ms <n> [--json]");
writer.WriteLine("mxgw-dotnet suspend --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet activate --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet write-secured --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> --current-user-id <n> [--verifier-user-id <n>] [--json]");
writer.WriteLine("mxgw-dotnet write-secured2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> --current-user-id <n> [--verifier-user-id <n>] [--timestamp <iso>] [--json]");
writer.WriteLine("mxgw-dotnet authenticate-user --session-id <id> --server-handle <n> --verify-user <user> (--verify-user-password <pw> | --verify-user-password-env <ENVVAR>) [--json]");
writer.WriteLine("mxgw-dotnet archestra-user-to-id --session-id <id> --server-handle <n> --user-guid <guid> [--json]");
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]");