+ * The script travels base64-encoded ({@code powershell.exe -NoProfile -NonInteractive
+ * -EncodedCommand ...}), so it needs no quoting or escaping whatsoever: quotes, pipes,
+ * newlines, and {@code $variables} reach PowerShell exactly as written.
+ *
+ *
} in the script for
+ * a specific exit code.
+ *
+ * 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
diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md
index 63536e9..71060a4 100644
--- a/src/site/markdown/commands.md
+++ b/src/site/markdown/commands.md
@@ -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
@@ -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)` / `onStderr(Consumer)` | 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 ` 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:
diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md
index 3c51226..ec3e928 100644
--- a/src/site/markdown/index.md
+++ b/src/site/markdown/index.md
@@ -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()`,
@@ -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`
diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java
index b65038d..c1d2e3f 100644
--- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java
+++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java
@@ -39,7 +39,10 @@
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.util.Base64;
import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -281,6 +284,211 @@ void environmentVariablesAreSentInTheCreateShellRequest() throws Exception {
assertTrue(environment < workingDirectory && workingDirectory < inputStreams, create);
}
+ @Test
+ void powerShellEncodesTheScriptSoNoEscapingIsNeeded() throws Exception {
+ server
+ .enqueue(200, envelope(resourceCreated("SHELL-1")))
+ .enqueue(200, envelope(commandResponse("CMD-1")))
+ .enqueue(
+ 200,
+ envelope(
+ receiveResponse(stream("stdout", "CMD-1", "Spooler".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))
+ )
+ )
+ .enqueue(200, envelope(signalResponse()));
+
+ // Quotes, pipes, a $variable, a newline, and non-ASCII text: none of it needs escaping.
+ final String script = "Get-Service | Where-Object { $_.Status -eq 'Running' } |\n"
+ + "ForEach-Object { \"état: $($_.Name)\" }";
+
+ try (WinRMClient client = builder(PASSWORD).build()) {
+ final CommandResult result = client.powerShell(script).execute();
+ assertEquals("Spooler", result.stdout());
+ assertEquals(0, result.exitCode());
+ }
+
+ final String command = server.decryptedRequests().get(1);
+ final Matcher matcher = Pattern
+ .compile("powershell\\.exe -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/=]+)")
+ .matcher(command);
+ assertTrue(matcher.find(), command);
+ // What powershell.exe decodes on the host is exactly the script, UTF-16LE as -EncodedCommand expects.
+ assertEquals(script, new String(Base64.getDecoder().decode(matcher.group(1)), StandardCharsets.UTF_16LE));
+ }
+
+ @Test
+ void powerShellUploadRewritesThePathInsideTheScriptBeforeEncodingIt(@TempDir final java.nio.file.Path tempDir)
+ throws Exception {
+ // The script references a LOCAL file handed to upload(...): the path must be rewritten to
+ // the remote copy while the script is still plain text — an already-encoded script would
+ // keep the client-side path and fail on the host.
+ final byte[] content = "Write-Output 'collect'".getBytes(StandardCharsets.UTF_8);
+ final java.nio.file.Path localFile = tempDir.resolve("collect.ps1");
+ java.nio.file.Files.write(localFile, content);
+ final StringBuilder digest = new StringBuilder();
+ for (final byte b : java.security.MessageDigest.getInstance("SHA-256").digest(content)) {
+ digest.append(String.format("%02x", b));
+ }
+ final String certutil = "SHA256 hash of file x:\r\n" +
+ digest +
+ "\r\nCertUtil: -hashfile command completed successfully.\r\n";
+
+ server
+ // ShellFileCopy locates the Windows directory with a WQL query (no shell involved)...
+ .enqueue(200, envelope(enumerationDone(instance("Win32_OperatingSystem", "WindowsDirectory", "C:\\Windows"))))
+ // ...then its first command leg (cleanup + MKDIR) creates the shell...
+ .enqueue(200, envelope(resourceCreated("SHELL-1")))
+ .enqueue(200, envelope(commandResponse("CMD-1")))
+ .enqueue(200, envelope(receiveResponse("", done("CMD-1", 0))))
+ .enqueue(200, envelope(signalResponse()))
+ // ...the digest probe reports an identical remote copy (transfer skipped)...
+ .enqueue(200, envelope(commandResponse("CMD-2")))
+ .enqueue(
+ 200,
+ envelope(
+ receiveResponse(stream("stdout", "CMD-2", certutil.getBytes(StandardCharsets.UTF_8)), done("CMD-2", 0))
+ )
+ )
+ .enqueue(200, envelope(signalResponse()))
+ // ...and the encoded PowerShell invocation runs in the SAME shell.
+ .enqueue(200, envelope(commandResponse("CMD-3")))
+ .enqueue(
+ 200,
+ envelope(
+ receiveResponse(stream("stdout", "CMD-3", "collect".getBytes(StandardCharsets.UTF_8)), done("CMD-3", 0))
+ )
+ )
+ .enqueue(200, envelope(signalResponse()));
+
+ final String script = "& '" + localFile + "' -Verbose";
+ try (WinRMClient client = builder(PASSWORD).build()) {
+ final CommandResult result = client.powerShell(script).upload(localFile).execute();
+ assertEquals("collect", result.stdout());
+ }
+
+ final String command = server
+ .decryptedRequests()
+ .stream()
+ .filter(r -> r.contains("-EncodedCommand"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError(String.join("\n---\n", server.decryptedRequests())));
+ final Matcher matcher = Pattern.compile("-EncodedCommand ([A-Za-z0-9+/=]+)").matcher(command);
+ assertTrue(matcher.find(), command);
+ final String decoded = new String(Base64.getDecoder().decode(matcher.group(1)), StandardCharsets.UTF_16LE);
+ // The decoded script calls the content-addressed remote copy, not the local file.
+ assertTrue(decoded.contains("collect." + digest.substring(0, 12) + ".ps1"), decoded);
+ assertFalse(decoded.contains(localFile.toString()), decoded);
+ assertTrue(decoded.endsWith("' -Verbose"), decoded);
+ }
+
+ @Test
+ void powerShellRejectsABlankScript() throws Exception {
+ try (WinRMClient client = builder(PASSWORD).build()) {
+ assertThrows(IllegalArgumentException.class, () -> client.powerShell(null));
+ assertThrows(IllegalArgumentException.class, () -> client.powerShell(" \n\t"));
+ }
+ }
+
+ @Test
+ void powerShellKeepsAScriptJustUnderTheLimitEncoded() throws Exception {
+ // 3040 characters encode to an 8166-character invocation, which still fits the remote
+ // shell's 8191-character command line WITH room for the CMD.EXE /C ( ) wrapper an uploaded
+ // request would add — so it rides the command line, no file transfer involved.
+ server
+ .enqueue(200, envelope(resourceCreated("SHELL-1")))
+ .enqueue(200, envelope(commandResponse("CMD-1")))
+ .enqueue(
+ 200,
+ envelope(receiveResponse(stream("stdout", "CMD-1", "ok".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0)))
+ )
+ .enqueue(200, envelope(signalResponse()));
+
+ try (WinRMClient client = builder(PASSWORD).build()) {
+ assertEquals("ok", client.powerShell("x".repeat(3040)).execute().stdout());
+ }
+
+ final String command = server.decryptedRequests().get(1);
+ assertTrue(command.contains("-EncodedCommand"), command);
+ }
+
+ @Test
+ void powerShellRunsALongScriptFromATransferredFile() throws Exception {
+ // 4000 characters encode to a ~10700-character invocation, beyond cmd.exe's 8191 limit:
+ // the script must travel as a temporary .ps1 file — transferred through the WinRM channel
+ // like an upload — whose content is then run as a script block. The caller never sees any
+ // of this.
+ final String script = "Write-Output '" + "x".repeat(4000) + "'";
+
+ // The transferred file is the script encoded as UTF-8 with a BOM (Windows PowerShell
+ // would read a BOM-less file with the ANSI code page).
+ final byte[] body = script.getBytes(StandardCharsets.UTF_8);
+ final byte[] content = new byte[3 + body.length];
+ content[0] = (byte) 0xEF;
+ content[1] = (byte) 0xBB;
+ content[2] = (byte) 0xBF;
+ System.arraycopy(body, 0, content, 3, body.length);
+ final StringBuilder digest = new StringBuilder();
+ for (final byte b : java.security.MessageDigest.getInstance("SHA-256").digest(content)) {
+ digest.append(String.format("%02x", b));
+ }
+ final String certutil = "SHA256 hash of file x:\r\n" +
+ digest +
+ "\r\nCertUtil: -hashfile command completed successfully.\r\n";
+
+ server
+ // ShellFileCopy locates the Windows directory with a WQL query (no shell involved)...
+ .enqueue(200, envelope(enumerationDone(instance("Win32_OperatingSystem", "WindowsDirectory", "C:\\Windows"))))
+ // ...then its first command leg (cleanup + MKDIR) creates the shell...
+ .enqueue(200, envelope(resourceCreated("SHELL-1")))
+ .enqueue(200, envelope(commandResponse("CMD-1")))
+ .enqueue(200, envelope(receiveResponse("", done("CMD-1", 0))))
+ .enqueue(200, envelope(signalResponse()))
+ // ...the digest probe reports an identical remote copy (transfer skipped)...
+ .enqueue(200, envelope(commandResponse("CMD-2")))
+ .enqueue(
+ 200,
+ envelope(
+ receiveResponse(stream("stdout", "CMD-2", certutil.getBytes(StandardCharsets.UTF_8)), done("CMD-2", 0))
+ )
+ )
+ .enqueue(200, envelope(signalResponse()))
+ // ...and the -File invocation runs in the SAME shell.
+ .enqueue(200, envelope(commandResponse("CMD-3")))
+ .enqueue(
+ 200,
+ envelope(
+ receiveResponse(
+ stream("stdout", "CMD-3", "x".repeat(4000).getBytes(StandardCharsets.UTF_8)),
+ done("CMD-3", 0)
+ )
+ )
+ )
+ .enqueue(200, envelope(signalResponse()));
+
+ try (WinRMClient client = builder(PASSWORD).build()) {
+ final CommandResult result = client.powerShell(script).execute();
+ assertEquals("x".repeat(4000), result.stdout());
+ assertEquals(0, result.exitCode());
+ }
+
+ final String command = server
+ .decryptedRequests()
+ .stream()
+ .filter(r -> r.contains("[ScriptBlock]::Create"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError(String.join("\n---\n", server.decryptedRequests())));
+ // The invocation reads the content-addressed remote copy and runs its CONTENT as a script
+ // block — not -File, so the script stays pathless exactly like the encoded form
+ // ($PSScriptRoot and $MyInvocation.MyCommand.Path are empty either way).
+ assertTrue(command.contains("powershell.exe -NoProfile -NonInteractive -Command"), command);
+ // The file is read with a BOM-aware .NET method: Get-Content -Raw does not exist on
+ // PowerShell 2.0 hosts (Windows Server 2008 R2).
+ assertTrue(command.contains("[System.IO.File]::ReadAllText('C:\\Windows\\Temp\\winrm-upload-"), command);
+ assertTrue(command.contains("winrm-powershell." + digest.substring(0, 12) + ".ps1'"), command);
+ assertFalse(command.contains("-EncodedCommand"), command);
+ assertFalse(command.contains("-File "), command);
+ }
+
@Test
void uploadsCarryTheEnvironmentIntoTheShellTheyCreate(@TempDir final java.nio.file.Path tempDir) throws Exception {
// .environment(...) combined with .upload(...): the transfer commands run FIRST and are