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
+86 -14
View File
@@ -21,7 +21,6 @@ import (
"syscall"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect"
@@ -90,6 +89,10 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
return runAdvise(ctx, args[1:], stdout, stderr)
case "advise-supervisory":
return runAdviseSupervisory(ctx, args[1:], stdout, stderr)
case "write-secured":
return runWriteSecured(ctx, args[1:], stdout, stderr)
case "authenticate-user":
return runAuthenticateUser(ctx, args[1:], stdout, stderr)
case "subscribe-bulk":
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
case "unsubscribe-bulk":
@@ -383,21 +386,90 @@ func runAdviseSupervisory(ctx context.Context, args []string, stdout, stderr io.
}
defer client.Close()
reply, err := client.Invoke(ctx, &pb.MxCommandRequest{
SessionId: *sessionID,
Command: &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
Payload: &pb.MxCommand_AdviseSupervisory{
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
ServerHandle: int32(*serverHandle),
ItemHandle: int32(*itemHandle),
},
},
},
})
session := mxgateway.NewSessionForID(client, *sessionID)
reply, err := session.AdviseSupervisoryRaw(ctx, int32(*serverHandle), int32(*itemHandle))
return writeCommandOutput(stdout, *jsonOutput, "advise-supervisory", options, reply, err)
}
func runWriteSecured(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("write-secured", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
itemHandle := flags.Int("item-handle", 0, "MXAccess item handle")
currentUserID := flags.Int("current-user-id", 0, "MXAccess current user id")
verifierUserID := flags.Int("verifier-user-id", 0, "MXAccess verifier user id")
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
valueText := flags.String("value", "", "value text")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" {
return errors.New("session-id is required")
}
value, err := parseValue(*valueType, *valueText)
if err != nil {
return err
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
reply, err := session.WriteSecuredRaw(ctx, int32(*serverHandle), int32(*itemHandle), int32(*currentUserID), int32(*verifierUserID), value)
return writeCommandOutput(stdout, *jsonOutput, "write-secured", options, reply, err)
}
func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("authenticate-user", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
sessionID := flags.String("session-id", "", "gateway session id")
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
verifyUser := flags.String("verify-user", "", "MXAccess user to authenticate")
// The credential is never accepted echoed on the command line by default:
// prefer the environment variable so it stays out of shell history and the
// process table. The -password flag remains for non-interactive scripting.
password := flags.String("password", "", "verify-user password (prefer -password-env)")
passwordEnv := flags.String("password-env", "MXGATEWAY_VERIFY_PASSWORD", "environment variable containing the verify-user password")
if err := flags.Parse(args); err != nil {
return err
}
if *sessionID == "" {
return errors.New("session-id is required")
}
if *verifyUser == "" {
return errors.New("verify-user is required")
}
resolvedPassword := *password
if resolvedPassword == "" && *passwordEnv != "" {
resolvedPassword = os.Getenv(*passwordEnv)
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
session := mxgateway.NewSessionForID(client, *sessionID)
// The raw reply carries only the resolved user id, never the credential, so
// writeCommandOutput can render it as-is; the credential is additionally
// scrubbed from any surfaced error by AuthenticateUserRaw.
reply, err := session.AuthenticateUserRaw(ctx, int32(*serverHandle), *verifyUser, resolvedPassword)
return writeCommandOutput(stdout, *jsonOutput, "authenticate-user", options, reply, err)
}
func runSubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -1295,7 +1367,7 @@ type protojsonMessage interface {
}
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured|write-secured-bulk|write-secured2-bulk|authenticate-user|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
}
// batchEOR is the end-of-result sentinel emitted to stdout after every command
+30
View File
@@ -568,6 +568,36 @@ func TestRunAdviseSupervisoryRequiresSessionID(t *testing.T) {
}
}
// TestRunWriteSecuredRequiresSessionID pins the write-secured session-id guard so
// it fails fast before dialing.
func TestRunWriteSecuredRequiresSessionID(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{"write-secured", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
if err == nil || !strings.Contains(err.Error(), "session-id is required") {
t.Fatalf("write-secured without -session-id error = %v", err)
}
}
// TestRunAuthenticateUserRequiresVerifyUser pins that authenticate-user fails fast
// (before dialing) when the user is absent, and never echoes any credential in
// the guard error.
func TestRunAuthenticateUserRequiresVerifyUser(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{
"authenticate-user",
"-session-id", "s1",
"-password", "hunter2-password",
"-plaintext",
"-api-key", "test",
}, &stdout, &stderr)
if err == nil || !strings.Contains(err.Error(), "verify-user is required") {
t.Fatalf("authenticate-user without -verify-user error = %v", err)
}
if strings.Contains(err.Error(), "hunter2-password") {
t.Fatalf("authenticate-user guard error leaked the credential: %v", err)
}
}
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
// guard so a write-bulk with unequal item-handles / values counts fails fast
// before any dial.