feat(java-client): CLI-15 ReplayGap surface + CLI-04 typed-command parity (5/5 complete)

Completes both findings across all five clients — done locally on the Mac now
that homebrew openjdk@17 is available (JAVA_HOME=/opt/homebrew/opt/openjdk@17).

CLI-15: new MxEventStreamItem record + MxEventStream.nextItem() surfaces the
gateway's ReplayGap sentinel as a typed, non-terminal signal (isReplayGap()/
replayGap()/event()); existing Iterator<MxEvent> path unchanged, sentinel never
swallowed/synthesized. Javadoc covers gap semantics + resume contract.

CLI-04: typed single-item helpers on MxGatewaySession — Phase 1
adviseSupervisory/writeSecured/writeSecured2/authenticateUser/archestrAUserToId,
Phase 2 addBufferedItem/setBufferedUpdateInterval/suspend/activate (unregister
already present). Each routes through invokeCommand -> ensureProtocolSuccess +
ensureMxAccessSuccess (same validation as bulk). MXAccess parity preserved.
Credentials flow only into the request proto; exceptions carry only the reply and
gRPC status text is scrubbed via MxGatewaySecrets.redactCredentials — tests
assert the password/secured value is absent from getMessage()/toString()/CLI
output. New CLI subcommands write-secured/authenticate-user (credential via
--password/--password-env, prints only the user id).

gradle test: 106 tests, 0 failures (58 client + 48 cli); no generated churn.
Shared docs: ClientLibrariesDesign + CLAUDE.md updated to "all five clients".

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
Joseph Doherty
2026-07-09 16:57:13 -04:00
parent 908951be9d
commit 1cc0fa4007
9 changed files with 890 additions and 27 deletions
@@ -155,6 +155,8 @@ public final class MxGatewayCli implements Callable<Integer> {
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
commandLine.addSubcommand(
"advise-supervisory", new AdviseSupervisoryCommand(clientFactory));
commandLine.addSubcommand("write-secured", new WriteSecuredCommand(clientFactory));
commandLine.addSubcommand("authenticate-user", new AuthenticateUserCommand(clientFactory));
commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("read-bulk", new ReadBulkCommand(clientFactory));
@@ -1074,6 +1076,106 @@ public final class MxGatewayCli implements Callable<Integer> {
}
}
@Command(
name = "write-secured",
description = "Invokes MXAccess WriteSecured (verified single-item write).")
static final class WriteSecuredCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
int itemHandle;
@Option(names = "--type", defaultValue = "string", description = "Value type.")
String type;
@Option(names = "--value", required = true, description = "Value text (credential-sensitive; never echoed).")
String value;
@Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
int currentUserId;
@Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
int verifierUserId;
WriteSecuredCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
// The secured write value is credential-sensitive: it goes only
// into the request. The reply printed below never carries it.
MxCommandReply reply = client.session(sessionId)
.writeSecuredRaw(
serverHandle, itemHandle, currentUserId, verifierUserId, parseValue(type, value));
writeOutput("write-secured", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(
name = "authenticate-user",
description = "Invokes MXAccess AuthenticateUser and prints the resolved user id.")
static final class AuthenticateUserCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--verify-user", required = true, description = "Galaxy user name to authenticate.")
String verifyUser;
@Option(
names = "--password",
description = "Galaxy user password (credential; prefer --password-env). Never echoed.")
String password = "";
@Option(
names = "--password-env",
defaultValue = "MXGATEWAY_VERIFY_PASSWORD",
description = "Environment variable holding the password when --password is omitted.")
String passwordEnv;
AuthenticateUserCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
// Resolve the credential from the flag or environment. It flows only
// into the request; it is never written to output, logs, or errors.
String resolvedPassword = password == null || password.isBlank()
? System.getenv(passwordEnv)
: password;
if (resolvedPassword == null) {
resolvedPassword = "";
}
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
int userId = client.session(sessionId)
.authenticateUser(serverHandle, verifyUser, resolvedPassword);
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "authenticate-user");
output.put("options", common.redactedJsonMap());
output.put("verifyUser", verifyUser);
output.put("userId", userId);
out.println(jsonObject(output));
} else {
out.println(userId);
}
}
return 0;
}
}
@Command(name = "subscribe-bulk", description = "Invokes MXAccess SubscribeBulk.")
static final class SubscribeBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
@@ -1864,6 +1966,11 @@ public final class MxGatewayCli implements Callable<Integer> {
MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId);
MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value);
int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword);
List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items);
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
@@ -1982,13 +2089,7 @@ public final class MxGatewayCli implements Callable<Integer> {
@Override
public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
return session.invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
.setAdviseSupervisory(
mxaccess_gateway.v1.MxaccessGateway.AdviseSupervisoryCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
return session.adviseSupervisoryRaw(serverHandle, itemHandle);
}
@Override
@@ -1996,6 +2097,17 @@ public final class MxGatewayCli implements Callable<Integer> {
return session.writeRaw(serverHandle, itemHandle, value, userId);
}
@Override
public MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
return session.writeSecuredRaw(serverHandle, itemHandle, currentUserId, verifierUserId, value);
}
@Override
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
return session.authenticateUser(serverHandle, verifyUser, verifyUserPassword);
}
@Override
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
return session.subscribeBulk(serverHandle, items);
@@ -168,6 +168,49 @@ final class MxGatewayCliTests {
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\""));
}
@Test
void writeSecuredCommandForwardsUserIdsAndValue() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"write-secured",
"--session-id", "session-cli",
"--server-handle", "12",
"--item-handle", "34",
"--type", "int32",
"--value", "77",
"--current-user-id", "100",
"--verifier-user-id", "200",
"--json");
assertEquals(0, run.exitCode());
assertEquals(77, factory.client.session.lastWriteSecuredValue.getInt32Value());
assertEquals(100, factory.client.session.lastWriteSecuredCurrentUserId);
assertEquals(200, factory.client.session.lastWriteSecuredVerifierUserId);
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_WRITE_SECURED\""));
}
@Test
void authenticateUserCommandForwardsCredentialAndPrintsUserIdWithoutEchoingPassword() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"authenticate-user",
"--session-id", "session-cli",
"--server-handle", "3",
"--verify-user", "operator",
"--password", "super-secret-pw",
"--json");
assertEquals(0, run.exitCode());
// The credential reaches the session (request) but never the output.
assertEquals("operator", factory.client.session.lastAuthenticateUser);
assertEquals("super-secret-pw", factory.client.session.lastAuthenticatePassword);
assertTrue(run.output().contains("\"userId\":4242"), run.output());
assertFalse(run.output().contains("super-secret-pw"), "password must never be echoed");
assertFalse(run.errors().contains("super-secret-pw"), "password must never be echoed to stderr");
}
// ---- ping subcommand (D4) ----
@Test
@@ -1257,6 +1300,11 @@ final class MxGatewayCliTests {
private boolean adviseCalled;
private boolean adviseSupervisoryCalled;
private MxValue lastWriteValue;
private MxValue lastWriteSecuredValue;
private int lastWriteSecuredCurrentUserId;
private int lastWriteSecuredVerifierUserId;
private String lastAuthenticateUser;
private String lastAuthenticatePassword;
private String lastPingMessage;
private long lastReadBulkTimeoutMs;
private List<String> lastReadBulkItems;
@@ -1341,6 +1389,25 @@ final class MxGatewayCliTests {
.build();
}
@Override
public MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
lastWriteSecuredValue = value;
lastWriteSecuredCurrentUserId = currentUserId;
lastWriteSecuredVerifierUserId = verifierUserId;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED)
.setProtocolStatus(ok())
.build();
}
@Override
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
lastAuthenticateUser = verifyUser;
lastAuthenticatePassword = verifyUserPassword;
return 4242;
}
@Override
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
List<SubscribeResult> results = new ArrayList<>();