Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5db8495
Command stdin and interactive shell: stdin() builders, RemoteProcess.…
bertysentry Jul 28, 2026
e9a471a
Address Codex review: pipe-mode declaration, stateful stdin encoding,…
bertysentry Jul 28, 2026
d23a612
Address Codex round 2: preserve 3-arg startCommand overrides; sub-flo…
bertysentry Jul 28, 2026
6a4f656
Address Codex round 3: tolerate completion racing queued input; bound…
bertysentry Jul 28, 2026
76d1cd5
Address Codex round 4: timeout translation for Send/interrupt, suppre…
bertysentry Jul 28, 2026
494b652
Address Codex round 5: probe stdin for redirection; decouple the shel…
bertysentry Jul 28, 2026
6dfdd05
Address Codex round 6: seekable-fd probe plus explicit --stdin; split…
bertysentry Jul 28, 2026
71e299d
Address Codex round 7: lazy stdin encoder; bound shell input records …
bertysentry Jul 28, 2026
bf87599
Address Codex round 8: the last stdin call wins
bertysentry Jul 28, 2026
f244e14
Make the interactive shell echo-free: cmd.exe /Q over pipe-mode stdin
bertysentry Jul 29, 2026
a3f81a5
Address Codex round 9: real stdin charset; propagate local read failures
bertysentry Jul 29, 2026
e0477ba
Address Codex round 10: pump tests exercise the production shell conf…
bertysentry Jul 29, 2026
6566878
Fix mangled non-ASCII input in the interactive shell: console-mode st…
bertysentry Jul 29, 2026
674b9a9
Remove a formatter temp file committed by accident
bertysentry Jul 29, 2026
8087838
Run the interactive shell under a single-byte code page: the real cau…
bertysentry Jul 29, 2026
991c6dc
Address Codex round 11: restore the timeout Javadoc; keep bounded pol…
bertysentry Jul 29, 2026
19d7887
Address Codex round 12: resolve the shell's code page and charset as …
bertysentry Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@ try (RemoteProcess p = client.command("wevtutil qe System /f:text").start()) {
int exitCode = p.waitFor(); // or waitFor(Duration) for a deadline
}

// Commands can be fed standard input — pre-supplied (stdin(String|Path|InputStream),
// the remote equivalent of a `< file` redirection)...
CommandResult sorted = client.command("sort").stdin(Path.of("data.txt")).execute();

// ...or written interactively through the process handle (flush() delivers, close() is EOF):
try (RemoteProcess p = client.command("some-repl.exe").stdin().start()) {
try (BufferedWriter in = p.stdin()) {
in.write("first request\n");
in.flush();
System.out.println(p.stdout().readLine());
}
p.waitFor();
}

// Middle ground: tail the output live, keep the blocking terminal and its full result.
client.command("longRunningThing.exe")
.onStdout(chunk -> log.info(chunk))
Expand Down Expand Up @@ -197,11 +211,19 @@ java -jar target/winrm-java-<version>-standalone.jar \
exec ipconfig /all
```

Open an interactive session on the remote host (`winrs`-style, line-oriented):

```bash
java -jar target/winrm-java-<version>-standalone.jar \
-h server.example.net -u 'DOMAIN\user' -pf password.txt shell
```

Use `--help` for the option list and `--version` for the build version. The CLI is built on the
streaming API: WQL rows are written to stdout as UTF-8 [JSON Lines](https://jsonlines.org/) **as
the enumeration pages arrive**, and remote command stdout and stderr are forwarded **live** to the
corresponding local streams while the command runs. Diagnostics go only to stderr, and the exit
codes are stable for scripting.
corresponding local streams while the command runs — with local stdin forwarded to the remote
command when it is piped or redirected (`... command sort < data.txt`). Diagnostics go only to
stderr, and the exit codes are stable for scripting.

The full manual — options, password handling, Kerberos configuration, streaming and timeout
semantics, exit codes — is the
Expand Down
92 changes: 92 additions & 0 deletions src/main/java/org/metricshub/winrm/ChunkEncoder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package org.metricshub.winrm;

/*-
* ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
* WinRM Java Client
* ჻჻჻჻჻჻
* Copyright (C) 2023 - 2026 MetricsHub
* ჻჻჻჻჻჻
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
*/

import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CoderResult;
import java.nio.charset.CodingErrorAction;

/**
* Incremental, stateful charset encoding for streamed command input — the mirror image of
* {@link ChunkDecoder}. The text fed to a command's standard input arrives in flush-sized pieces,
* and a per-piece {@code String.getBytes(Charset)} would not encode it the way one whole-string
* encode would: a stateful charset (e.g. UTF-16) writes its byte-order mark on every call instead
* of once, and a surrogate pair split across two pieces becomes two replacement characters. A
* single {@link CharsetEncoder} carried across calls keeps that state: the mark is emitted once,
* and a trailing lone high surrogate is withheld until the character is completed by the next
* piece. Malformed and unmappable input is replaced, matching {@link String#getBytes(Charset)}.
*/
final class ChunkEncoder {

private final CharsetEncoder encoder;

// Unencoded tail of the previous piece — a lone high surrogate — replayed in front of the
// next piece.
private char[] pending = new char[0];

ChunkEncoder(final Charset charset) {
this.encoder = charset
.newEncoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
}

/**
* Encode the next piece of input, returning the bytes that are complete so far. A trailing
* lone high surrogate is withheld until the next call completes the pair — unless
* {@code endOfInput} is set, which flushes the encoder: the dangling half becomes a
* replacement, exactly as a whole-string encode would render it.
*/
byte[] encode(final String piece, final boolean endOfInput) {
final CharBuffer in = CharBuffer.allocate(pending.length + piece.length());
in.put(pending);
in.put(piece);
in.flip();

final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
final ByteBuffer out = ByteBuffer.allocate(Math.max(16, in.remaining() * 4));
CoderResult result;
do {
result = encoder.encode(in, out, endOfInput);
bytes.write(out.array(), 0, out.position());
out.clear();
// With REPLACE in force the only non-underflow result is overflow: loop for more room.
} while (result.isOverflow());

if (endOfInput) {
do {
result = encoder.flush(out);
bytes.write(out.array(), 0, out.position());
out.clear();
} while (result.isOverflow());
pending = new char[0];
} else {
// Whatever the encoder left in the input is an incomplete character: carry it over.
pending = new char[in.remaining()];
in.get(pending);
}
return bytes.toByteArray();
}
}
60 changes: 60 additions & 0 deletions src/main/java/org/metricshub/winrm/CommandCursor.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,66 @@ default Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRem
return next();
}

/**
* Cadence variant of {@link #poll(long)}: ask the server to answer within
* {@code askMillis} — the polling cadence — while allowing the answer itself up to
* {@code maxWaitMillis} to arrive. A polling consumer (e.g. an interactive session pump)
* wants short idle rounds, but must not fail the stream when a loaded or distant server
* takes longer than one cadence to get its answer across; {@link #poll(long)} is exactly
* this call with {@code askMillis == maxWaitMillis}.
* <p>
* The default implementation delegates to {@link #poll(long)} with the full wait.
*
* @param askMillis when the server should answer at the latest — with output when it has
* any, with the protocol's "nothing yet" otherwise
* @param maxWaitMillis how long to block at most, capped by the cursor's per-round-trip
* timeout
* @return the next chunk of raw output — empty when nothing arrived — or {@code null} once
* the command has completed
* @throws TimeoutException when the server does not even answer the bounded request
* @throws WindowsRemoteException for any other failure while receiving
*/
default Chunk poll(final long askMillis, final long maxWaitMillis) throws TimeoutException, WindowsRemoteException {
return poll(maxWaitMillis);
}

/**
* Feed standard input to the running command — the WSMan Send operation, carrying the bytes to
* the command's {@code stdin} stream. Input larger than one envelope's worth is split into
* several Send requests automatically. A Send is an ordinary request on the executor's serial
* connection: it alternates with {@link #next()}/{@link #poll(long)} on the caller's thread,
* it never runs concurrently with them.
* <p>
* The default implementation throws {@link UnsupportedOperationException}: only executors that
* support command input (such as the built-in lightweight backend) implement this method.
*
* @param data the input bytes (possibly empty — with {@code end}, a pure end-of-input Send)
* @param end {@code true} to mark the end of input: the command's stdin then reaches EOF, and
* no further input may be sent
* @throws IllegalStateException when the command has already completed or the cursor is closed
* @throws TimeoutException when the server does not answer the Send in time
* @throws WindowsRemoteException for any other failure while sending
*/
default void send(final byte[] data, final boolean end) throws TimeoutException, WindowsRemoteException {
throw new UnsupportedOperationException(getClass().getName() + " does not support command input.");
}

/**
* Interrupt the command the way a console Ctrl+C would — the WSMan Signal operation with the
* {@code ctrl_c} code. Unlike {@link #close()}'s terminate Signal, it interrupts the command's
* child process without ending the command itself: the cursor stays fully usable. A no-op once
* the command has completed or the cursor is closed.
* <p>
* The default implementation throws {@link UnsupportedOperationException}: only executors that
* support it (such as the built-in lightweight backend) implement this method.
*
* @throws TimeoutException when the server does not answer the Signal in time
* @throws WindowsRemoteException for any other failure while signaling
*/
default void interrupt() throws TimeoutException, WindowsRemoteException {
throw new UnsupportedOperationException(getClass().getName() + " does not support command interruption.");
}

/**
* Get the command's exit code.
*
Expand Down
Loading
Loading