Skip to content
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ try (WinRMClient client = WinRMClient.builder("server01.acme.com")
CommandResult result = client.command("ipconfig /all").execute();
System.out.println(result.stdout());

// PowerShell — delivered encoded (-EncodedCommand): no quoting or escaping needed
CommandResult ps = client.powerShell("Get-Service | Where-Object Status -eq 'Running'").execute();
Comment on lines +94 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the PowerShell option out of the terse README

This adds a new API option to the quick-start even though the existing quick-start and examples remain valid. The repository rules reserve README changes for updates that invalidate or alter existing material and explicitly prohibit cataloguing minor features there; the full PowerShell documentation already belongs in the site documentation added by this commit, so remove these README lines.

AGENTS.md reference: AGENTS.md:L25-L29

Useful? React with 👍 / 👎.


// Copy a file to the host (through the WinRM channel itself — no SMB)
client.uploadFile(Path.of("collect.ps1"), "C:\\Windows\\Temp\\collect.ps1");
}
Expand Down
133 changes: 130 additions & 3 deletions src/main/java/org/metricshub/winrm/CommandRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
Expand All @@ -50,8 +52,54 @@ public final class CommandRequest {
/** How many bytes of pre-supplied input are read and sent at a time. */
private static final int STDIN_BUFFER_SIZE = 64 * 1024;

/** The invocation an encoded PowerShell script is appended to. */
private static final String POWERSHELL_PREFIX = "powershell.exe -NoProfile -NonInteractive -EncodedCommand ";

/**
* The invocation running a PowerShell script transferred as a file — the automatic fallback
* for scripts too long to ride the command line encoded ({@code %s} is the path of the remote
* copy). The file's <i>content</i> is run as a <b>dot-sourced</b> script block rather than
* executed with {@code -File}, keeping the script behaving like the encoded form: it stays
* pathless ({@code $PSScriptRoot} and {@code $MyInvocation.MyCommand.Path} are empty in both
* forms), a top-level {@code param(...)} block keeps working, dot-sourcing runs the top level
* in the session scope exactly like {@code -EncodedCommand} does, and the execution policy —
* which only governs script files — never applies. The one remaining observable difference is
* {@code $MyInvocation}'s own metadata ({@code InvocationName}, {@code Line}), which reflects
* this wrapper invocation for a transferred script. The file is read with a BOM-aware .NET
* method rather than {@code Get-Content -Raw}, which PowerShell 2.0 (Windows Server 2008 R2)
* does not have.
*/
private static final String POWERSHELL_FILE_INVOCATION = "powershell.exe -NoProfile -NonInteractive -Command " +
"\". ([ScriptBlock]::Create([System.IO.File]::ReadAllText('%s')))\"";

/**
* Local name of the fallback script file. The name is constant: the file is created in a
* fresh temporary directory (no collisions), and the transfer content-addresses the remote
* copy ({@code winrm-powershell.<digest>.ps1}) — so re-running an identical script finds the
* remote copy already present and skips the transfer.
*/
private static final String POWERSHELL_FILE_NAME = "winrm-powershell.ps1";

/**
* The UTF-8 byte order mark, prepended to the fallback script file: without it, Windows
* PowerShell reads the file with the ANSI code page and mangles every non-ASCII character.
*/
private static final byte[] UTF8_BOM = { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF };

/** cmd.exe rejects command lines longer than 8191 characters. */
private static final int MAX_COMMAND_LINE_LENGTH = 8191;

/**
* The {@code CMD.EXE /C (...)} wrapper an uploaded request adds around the invocation. The
* encoded-form length check always reserves room for it, so the encode-or-file decision does
* not depend on whether {@link #upload(Path...)} is called.
*/
private static final int CMD_WRAPPER_LENGTH = "CMD.EXE /C ()".length();

private final WinRMClient client;
/** The command line — or, for a {@link WinRMClient#powerShell(String)} request, the raw script text. */
private final String commandLine;
private final boolean powerShell;
private String workingDirectory;
private final Map<String, String> environment = new LinkedHashMap<>();
private Duration timeout;
Expand All @@ -76,12 +124,72 @@ private interface StdinSource {
* @param commandLine the command line to execute
*/
CommandRequest(final WinRMClient client, final String commandLine) {
this(client, commandLine, false);
}

/**
* Create the request.
*
* @param client the client the command runs on
* @param commandLine the command line to execute — or, when {@code powerShell} is set, the raw
* script text, turned into a {@code powershell.exe} invocation when the command starts
* (after any {@link #upload(Path...)} path rewriting)
* @param powerShell whether the text is a PowerShell script
*/
CommandRequest(final WinRMClient client, final String commandLine, final boolean powerShell) {
Utils.checkNonBlank(commandLine, "commandLine");
this.client = client;
this.commandLine = commandLine;
this.powerShell = powerShell;
this.timeout = client.defaultTimeout();
}

/**
* Build the {@code powershell.exe -EncodedCommand} invocation delivering the given script
* verbatim (base64 of its UTF-16LE bytes, the encoding {@code -EncodedCommand} expects) — or
* report that the script is too long to ride the command line and must travel as a file.
*
* @param script the PowerShell script
* @return the invocation command line, or {@code null} when it would exceed the remote
* shell's command-line limit (room for the {@code CMD.EXE /C} wrapper included, so
* the decision does not depend on {@link #upload(Path...)})
*/
private static String encodePowerShell(final String script) {
final String encoded = POWERSHELL_PREFIX
+ Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE));
return encoded.length() > MAX_COMMAND_LINE_LENGTH - CMD_WRAPPER_LENGTH ? null : encoded;
}

/**
* Run the fallback path for a script too long to ride the command line: write it (UTF-8 with
* a BOM) to a local temporary file, transfer that file to the remote host exactly like an
* {@link #upload(Path...)}, and return the invocation running the remote copy's content as a
* script block. The local file is deleted before returning; the remote copy is
* content-addressed, so re-running an identical script skips the transfer.
*/
private String powerShellFromFile(final String script, final long timeoutMillis, final long start)
throws IOException, TimeoutException, WindowsRemoteException {
final Path directory = Files.createTempDirectory("winrm-ps");
final Path file = directory.resolve(POWERSHELL_FILE_NAME);
try {
final byte[] body = script.getBytes(StandardCharsets.UTF_8);
final byte[] content = new byte[UTF8_BOM.length + body.length];
System.arraycopy(UTF8_BOM, 0, content, 0, UTF8_BOM.length);
System.arraycopy(body, 0, content, UTF8_BOM.length, body.length);
Files.write(file, content);
return ShellFileCopy.copyLocalFilesToRemote(
client.executor(),
String.format(POWERSHELL_FILE_INVOCATION, file),
List.of(file.toString()),
environment,
TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to transfer the PowerShell script")
);
} finally {
Files.deleteIfExists(file);
Files.deleteIfExists(directory);
}
}

/**
* Set the working directory of the remote process. The remote command shell is created on
* the first command a client executes and is reused afterward, so this setting takes effect
Expand Down Expand Up @@ -497,22 +605,41 @@ private Prepared prepare(final long timeoutMillis, final long start)
String actualCommand = commandLine;
String actualWorkingDirectory = workingDirectory;
Map<String, String> actualEnvironment = environment;
boolean transfersCreatedTheShell = false;

if (!uploads.isEmpty()) {
// Copy the files through the command shell and rewrite the command to reference the
// remote copies. The transfer commands are what actually creates the shell, so the
// shell-scoped environment must ride them — the real command then inherits it. The
// working directory is not carried over: with uploads it has never applied, and the
// transfer commands were built for the default directory.
// transfer commands were built for the default directory. A PowerShell script is
// rewritten here as plain text, BEFORE it is encoded below.
final List<String> localFiles = uploads.stream().map(Path::toString).collect(Collectors.toList());
final String updatedCommand = ShellFileCopy.copyLocalFilesToRemote(
actualCommand = ShellFileCopy.copyLocalFilesToRemote(
client.executor(),
commandLine,
localFiles,
environment,
TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to copy the local files")
);
actualCommand = String.format("CMD.EXE /C (%s)", updatedCommand);
transfersCreatedTheShell = true;
}

if (powerShell) {
// actualCommand holds the (possibly rewritten) script text. A script short enough
// rides the command line encoded; a longer one travels as a temporary file and runs
// with -File — transparently, the caller never sees the command-line length limit.
final String encoded = encodePowerShell(actualCommand);
if (encoded != null) {
actualCommand = encoded;
} else {
actualCommand = powerShellFromFile(actualCommand, timeoutMillis, start);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve PowerShell invocation semantics across the fallback

When an oversized script inspects $PSScriptRoot or $MyInvocation.MyCommand.Path, switching it to -File changes observable behavior: the encoded form has no backing script path, while the fallback exposes the content-addressed remote .ps1 path. Consequently, merely pushing an otherwise identical script over the length threshold can change path resolution or control flow, despite the fallback being presented as transparent; execute the transferred contents in command/script-block mode or explicitly account for this semantic difference.

Useful? React with 👍 / 👎.

transfersCreatedTheShell = true;
}
}

if (transfersCreatedTheShell) {
actualCommand = String.format("CMD.EXE /C (%s)", actualCommand);
actualWorkingDirectory = null;
// Already applied when the transfer commands created the shell.
actualEnvironment = null;
Expand Down
44 changes: 44 additions & 0 deletions src/main/java/org/metricshub/winrm/WinRMClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,50 @@ public CommandRequest command(final String commandLine) {
return new CommandRequest(this, commandLine);
}

/**
* Prepare a PowerShell script execution. Nothing is sent until
* {@link CommandRequest#execute()} is called.
* <p>
* The script travels base64-encoded ({@code powershell.exe -NoProfile -NonInteractive
* -EncodedCommand ...}), so it needs <b>no quoting or escaping whatsoever</b>: quotes, pipes,
* newlines, and {@code $variables} reach PowerShell exactly as written.
*
* <pre>{@code
* CommandResult result = client.powerShell(
* "Get-Service | Where-Object { $_.Status -eq 'Running' } | Select-Object -First 5 Name"
* ).execute();
* }</pre>
*
* The returned request is the same as for {@link #command(String)}: every option and both
* terminals apply unchanged — including {@link CommandRequest#upload(Path...)}, whose path
* rewriting happens on the script text <i>before</i> it is encoded, so a script referencing an
* uploaded file runs against the remote copy. {@code powershell.exe} exits with 0 on success
* and 1 when the script ends with a terminating error; call {@code exit <n>} in the script for
* a specific exit code.
* <p>
* There is no practical script size limit. A script whose encoded invocation would not fit
* the remote shell's command line (roughly 3000 characters of script) is automatically
* transferred as a temporary {@code .ps1} file — through the WinRM connection itself, exactly
* like {@link CommandRequest#upload(Path...)} — and its content run as a dot-sourced script
* block, which keeps the script behaving like the encoded form: pathless
* ({@code $PSScriptRoot} and {@code $MyInvocation.MyCommand.Path} stay empty either way),
* top-level scope and {@code param(...)} intact, and out of reach of the host's execution
* policy (which only governs script files). Only {@code $MyInvocation}'s own metadata
* ({@code InvocationName}, {@code Line}) reflects the wrapper invocation for a transferred
* script. The remote copy is content-addressed, so re-running an identical script skips the
* transfer. Like any request with uploads, the transfer commands are then what creates the
* remote shell, so the shell-scoped {@link CommandRequest#workingDirectory(String)} does not
* apply.
*
* @param script the PowerShell script to execute, verbatim
* @return the request, to configure and execute
* @throws IllegalArgumentException when the script is blank
*/
public CommandRequest powerShell(final String script) {
Utils.checkNonBlank(script, "script");
return new CommandRequest(this, script, true);
}

/**
* Copy a local file to an explicit path on the remote host, through the WinRM connection
* itself (no SMB, no extra port). The transfer is digest-verified and skipped when the
Expand Down
41 changes: 40 additions & 1 deletion src/site/markdown/commands.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
keywords: command, execute, cmd, stdout, stderr, stdin, exit code, file copy, script
keywords: command, execute, cmd, powershell, encodedcommand, stdout, stderr, stdin, exit code, file copy, script
description: Execute remote commands with the fluent WinRMClient API, capture output and exit codes, feed standard input, and copy local files to the host.

# Remote Commands
Expand Down Expand Up @@ -53,6 +53,45 @@ Everything between `command(...)` and `execute()` is optional:
| `stdinCharset(Charset)` | the output charset | The charset used to *encode* standard input, when it differs from the output charset (see below). |
| `onStdout(Consumer<String>)` / `onStderr(Consumer<String>)` | none | Callbacks receiving each chunk of output live while `execute()` runs (see below). |

## Running PowerShell

`powerShell(...)` prepares a PowerShell script execution the same way `command(...)` prepares a
command line. The script travels base64-encoded
(`powershell.exe -NoProfile -NonInteractive -EncodedCommand …`), so **no quoting or escaping is
ever needed**: quotes, pipes, newlines, and `$variables` reach PowerShell exactly as written.

```java
CommandResult result = client.powerShell(
"Get-Service | Where-Object { $_.Status -eq 'Running' } | Select-Object -First 5 Name"
).execute();
```

It returns the same request object as `command(...)`: every option and terminal described on this
page — `timeout(...)`, `charset(...)`, `stdin(...)`, `onStdout(...)`, `execute()`, `start()` —
works unchanged.

Points to know:

* **Exit code** — `powershell.exe` exits with 0 on success and 1 when the script ends with a
terminating error; call `exit <n>` in the script for a specific code.
* **Uploads** — `upload(...)` works as with any command: references to the uploaded files in the
script are rewritten to the remote copies *before* the script is encoded.
* **Script size** — there is none to worry about. A script short enough rides the command line
encoded; a longer one (roughly 3000 characters and up, where the encoded invocation would no
longer fit the remote shell's 8191-character command line) is automatically transferred as a
temporary `.ps1` file — through the WinRM connection itself, exactly like `upload(...)` — and
its **content** run as a dot-sourced script block (`[ScriptBlock]::Create`), which keeps the
script behaving like the encoded form: pathless (`$PSScriptRoot` and
`$MyInvocation.MyCommand.Path` stay empty either way), top-level scope and `param(...)` intact,
and out of reach of the host's execution policy (which only governs script files). The one
remaining observable difference is `$MyInvocation`'s own metadata (`InvocationName`, `Line`),
which reflects the wrapper invocation for a transferred script. The remote copy is
[content-addressed](file-transfers.html), so re-running an identical script skips the transfer;
and like any request with uploads, the transfer commands are then what creates the remote
shell, so the shell-scoped `workingDirectory(...)` does not apply.
* **Windows PowerShell** — the script runs in `powershell.exe` (Windows PowerShell 5.x, present on
every supported Windows). To target PowerShell 7+, invoke `pwsh` yourself with `command(...)`.

## The result

[`CommandResult`](apidocs/org/metricshub/winrm/CommandResult.html) is an immutable value:
Expand Down
12 changes: 10 additions & 2 deletions src/site/markdown/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ The **WinRM Java Client** is a small library that talks to the Windows Remote Ma

* run **WQL / WMI queries** such as `SELECT Name, State FROM Win32_Service` and read the rows back
([WQL Queries](wql.html)), and
* **execute remote commands**, capturing standard output, standard error and the exit code —
optionally copying local script files to the host first ([Remote Commands](commands.html)).
* **execute remote commands** — `cmd.exe` command lines or PowerShell scripts — capturing standard
output, standard error and the exit code, optionally copying local script files to the host
first ([Remote Commands](commands.html)).

Both operations can also **stream**: WQL rows are consumed page by page as they arrive
(`stream()`), and command output is consumed while the command is still running (`start()`,
Expand Down Expand Up @@ -103,6 +104,13 @@ CommandResult result = client.command("ipconfig /all").execute();
System.out.println(result.stdout());
```

And so do PowerShell scripts — delivered base64-encoded (`-EncodedCommand`), so they need no
quoting or escaping at all:

```java
CommandResult ps = client.powerShell("Get-Service | Where-Object Status -eq 'Running'").execute();
```

Failures are reported through the unchecked
[`WinRMClientException`](apidocs/org/metricshub/winrm/exceptions/WinRMClientException.html)
hierarchy. The static one-shot helpers that predate `WinRMClient`
Expand Down
Loading
Loading