diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java
index b75bba7..f33df20 100644
--- a/src/main/java/org/metricshub/winrm/WinRMClient.java
+++ b/src/main/java/org/metricshub/winrm/WinRMClient.java
@@ -329,6 +329,7 @@ public static final class Builder {
private boolean allowDelegation;
private boolean trustAllCertificates;
private int consoleCodePage;
+ private boolean loadUserProfile;
private SSLContext sslContext;
private Duration timeout = DEFAULT_TIMEOUT;
private int retries;
@@ -587,6 +588,27 @@ public Builder consoleCodePage(final int consoleCodePage) {
return this;
}
+ /**
+ * Load the user profile in the remote command shell — the opposite of
+ * {@code winrs -noprofile}. Default: the profile is not loaded, and unless something else
+ * already loaded it on the host (an interactive session, for example), commands run with
+ * the default profile: {@code %USERPROFILE%} is {@code C:\Users\Default}, {@code %APPDATA%}
+ * is not set, and {@code HKEY_CURRENT_USER} is not the user's own registry hive.
+ *
+ * The setting applies to every shell this client creates — for commands, file transfers and
+ * remote file operations — including a shell silently recreated after the server reaped the
+ * previous one. {@code winrs} documents that loading the profile requires the user to be a
+ * local administrator on the host; when the host refuses it, the shell creation fails with a
+ * {@link org.metricshub.winrm.exceptions.WinRMFaultException} carrying the fault code and
+ * detail.
+ *
+ * @return this builder
+ */
+ public Builder loadUserProfile() {
+ this.loadUserProfile = true;
+ return this;
+ }
+
/**
* Build the client. This does not connect yet: the connection is established and
* authenticated by the first operation.
@@ -633,6 +655,7 @@ public WinRMClient build() {
sslContext,
trustAllCertificates,
consoleCodePage,
+ loadUserProfile,
retries,
toMillis(retryDelay)
);
diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java
index fe21f15..52690d9 100644
--- a/src/main/java/org/metricshub/winrm/cli/CliArguments.java
+++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java
@@ -85,6 +85,7 @@ enum Operation {
private final boolean forwardStdin;
private final String directory;
private final Map environment;
+ private final boolean loadUserProfile;
private final String input;
private final Path localFile;
private final String glob;
@@ -118,6 +119,7 @@ private CliArguments(final Builder builder) {
forwardStdin = builder.forwardStdin;
directory = builder.directory;
environment = builder.environment;
+ loadUserProfile = builder.loadUserProfile;
input = builder.input;
localFile = builder.localFile;
glob = builder.glob;
@@ -206,6 +208,9 @@ private static int parseOption(final Builder builder, final String[] arguments,
case "--env":
parseEnvironmentVariable(builder, optionValue(arguments, index, option), option);
return nextIndex(argument, index);
+ case "--profile":
+ builder.loadUserProfile = true;
+ return index + 1;
case "--ntlm":
builder.ntlm = true;
return index + 1;
@@ -470,6 +475,9 @@ private static void validate(final Builder builder) throws CliUsageException {
if (!builder.environment.isEmpty() && !runsInShell) {
throw new CliUsageException("--env requires the command or shell subcommand");
}
+ if (builder.loadUserProfile && !runsInShell) {
+ throw new CliUsageException("--profile requires the command or shell subcommand");
+ }
if (builder.filesOnly && builder.directoriesOnly) {
throw new CliUsageException("--files-only and --directories-only are mutually exclusive");
}
@@ -731,6 +739,10 @@ Map environment() {
return environment;
}
+ boolean loadUserProfile() {
+ return loadUserProfile;
+ }
+
/** The query, the command line, or the remote path of a file subcommand. */
String input() {
return input;
@@ -815,6 +827,7 @@ private static final class Builder {
private boolean forwardStdin;
private String directory;
private final Map environment = new LinkedHashMap<>();
+ private boolean loadUserProfile;
private Integer port;
private long timeout = DEFAULT_TIMEOUT;
private String input;
diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java
index 842b90c..2a9ebeb 100644
--- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java
+++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java
@@ -610,6 +610,9 @@ static FluentRemoteOperations connect(final CliArguments arguments, final int co
if (arguments.allowDelegate()) {
builder.allowDelegation();
}
+ if (arguments.loadUserProfile()) {
+ builder.loadUserProfile();
+ }
final List authentications = arguments.authentications();
if (authentications != null && !authentications.isEmpty()) {
builder.authentication(
@@ -734,6 +737,7 @@ private static String help() {
" -t, --timeout Operation timeout in milliseconds (default: 60000)\n" +
" -d, --directory Working directory of the remote command or shell\n" +
" --env Environment variable of the remote command or shell (repeatable)\n" +
+ " --profile Load the user profile in the remote command or shell\n" +
" -i, --stdin Forward the local standard input to the remote command\n" +
" --https Use HTTPS\n" +
" --https-permissive Trust any HTTPS certificate and hostname (insecure)\n" +
diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java
index 35c0115..b53fc2e 100644
--- a/src/main/java/org/metricshub/winrm/light/Envelopes.java
+++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java
@@ -149,16 +149,19 @@ static String release(final String url, final String namespace, final String con
* empty omits the {@code rsp:Environment} block
* @param codePage the console code page of the shell ({@code WINRS_CODEPAGE}); 0 uses
* {@link #CODEPAGE_UTF8}, the default that makes every command's output UTF-8
+ * @param loadUserProfile whether the shell loads the user profile ({@code WINRS_NOPROFILE} is
+ * its negation)
*/
static String createShell(
final String url,
final String workingDirectory,
final Map environment,
final long timeoutMs,
- final int codePage
+ final int codePage,
+ final boolean loadUserProfile
) {
final String optionSet = "" +
- "TRUE" +
+ "" + (loadUserProfile ? "FALSE" : "TRUE") + "" +
"" + (codePage > 0 ? String.valueOf(codePage) : CODEPAGE_UTF8)
+ "" +
"";
diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java
index f1286ea..3d8ce84 100644
--- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java
+++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java
@@ -202,6 +202,7 @@ public static LightWinRMService createInstance(
sslContext,
trustAllCertificates,
consoleCodePage,
+ false,
connectRetries,
retryDelay
);
@@ -209,7 +210,8 @@ public static LightWinRMService createInstance(
/**
* Create a light WinRM executor that may delegate the caller's Kerberos credentials to the host,
- * so remote commands can authenticate onward as the caller (the second hop).
+ * so remote commands can authenticate onward as the caller (the second hop), and may load the
+ * user profile in the command shell.
*
* @param winRMEndpoint endpoint with credentials (mandatory)
* @param timeout timeout in milliseconds (must be > 0)
@@ -225,6 +227,8 @@ public static LightWinRMService createInstance(
* server certificate and skip hostname verification — insecure, testing only
* @param consoleCodePage the console code page of the command shell; 0 keeps the default 65001,
* which makes command output UTF-8 whatever the remote locale
+ * @param loadUserProfile whether the command shell loads the user profile (registry hive,
+ * per-user environment variables); {@code false} is the historical behavior
* @param connectRetries how many times one round trip may re-attempt to connect and authenticate
* (must be >= 0); 0 keeps the historical fail-fast behavior
* @param retryDelay the pause in milliseconds before each retry (must be >= 0)
@@ -240,6 +244,7 @@ public static LightWinRMService createInstance(
final SSLContext sslContext,
final boolean trustAllCertificates,
final int consoleCodePage,
+ final boolean loadUserProfile,
final int connectRetries,
final long retryDelay
) throws WinRMException {
@@ -292,6 +297,7 @@ public static LightWinRMService createInstance(
authScheme,
winRMEndpoint.getRawUsername(),
consoleCodePage,
+ loadUserProfile,
connectRetries,
retryDelay
);
diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java
index 0913604..a539e08 100644
--- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java
+++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java
@@ -86,6 +86,7 @@ final class WsmanClient implements AutoCloseable {
private final long timeoutMs;
private final int consoleCodePage;
+ private final boolean loadUserProfile;
private final String url;
private final String rawUsername;
private final AuthScheme auth;
@@ -183,11 +184,13 @@ private static void checkNotCancelled() throws InterruptedException {
final AuthScheme auth,
final String rawUsername,
final int consoleCodePage,
+ final boolean loadUserProfile,
final int connectRetries,
final long retryDelayMs
) {
this.timeoutMs = timeoutMs;
this.consoleCodePage = consoleCodePage;
+ this.loadUserProfile = loadUserProfile;
this.connectRetries = connectRetries;
this.retryDelayMs = retryDelayMs;
// A non-null socket factory selects HTTPS: TLS wraps the transport and the SOAP travels plaintext.
@@ -962,7 +965,7 @@ private void createShell(
final boolean failOnQuietTimeout
) throws Exception {
final Document doc = exchange(
- Envelopes.createShell(url, workingDirectory, environment, timeoutMs, consoleCodePage),
+ Envelopes.createShell(url, workingDirectory, environment, timeoutMs, consoleCodePage, loadUserProfile),
"Create shell",
timeoutMs,
failOnQuietTimeout
diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md
index 22e65a0..3189367 100644
--- a/src/site/markdown/cli.md
+++ b/src/site/markdown/cli.md
@@ -50,8 +50,9 @@ own options, in any order.
| `-pf, --password-file ` | Read the password from a UTF-8 file (preferred for automation, see below). |
| `-P, --port ` | Target port. Default: 5985 for HTTP, 5986 for HTTPS. |
| `-t, --timeout ` | Operation timeout in milliseconds. Default: 60000. See [Timeout semantics](#timeout-semantics). |
-| `-d, --directory ` | Working directory the remote command or interactive shell starts in, like `winrs -d` (only with `command` and `shell`). Default: the remote user's profile directory. |
+| `-d, --directory ` | Working directory the remote command or interactive shell starts in, like `winrs -d` (only with `command` and `shell`). Default: the user's profile directory, or `C:\Users\Default` when that profile is not loaded (see `--profile`). |
| `--env ` | Environment variable set in the remote shell, like `winrs -env` (only with `command` and `shell`). Repeatable — one occurrence per variable; the value is split on the first `=`, so it may itself contain `=`. |
+| `--profile` | Load the user profile in the remote shell (only with `command` and `shell`): `%APPDATA%`, the user's `HKEY_CURRENT_USER` hive. Not loaded by default, the reverse of `winrs`, where `-noprofile` turns it off; see [Loading the user profile](commands.html#loading-the-user-profile). |
| `-i, --stdin` | Forward the local standard input to the remote command (only with `command`); see below. |
| `--https` | Connect over HTTPS. |
| `--https-permissive` | Trust any HTTPS certificate and hostname. Intentionally insecure: testing and isolated hosts only. Requires `--https`. |
diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md
index 104f207..38718bb 100644
--- a/src/site/markdown/commands.md
+++ b/src/site/markdown/commands.md
@@ -53,6 +53,33 @@ 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). |
+### Loading the user profile
+
+By default the remote shell does **not** load the user profile, the equivalent of
+`winrs -noprofile`. A command then sees the user's own profile only if something else already
+loaded it on the host, such as an interactive or disconnected session. Otherwise the shell runs
+with the default profile: `%USERPROFILE%` and the starting directory are `C:\Users\Default`,
+`%APPDATA%` and `%LOCALAPPDATA%` are not set, `%TEMP%` is `C:\Windows\Temp`, and
+`HKEY_CURRENT_USER` is not the user's registry hive. When a command needs them, build the client
+with `loadUserProfile()`:
+
+```java
+try (WinRMClient client = WinRMClient.builder("server.example.com")
+ .credentials("DOMAIN\\user", password)
+ .loadUserProfile()
+ .build()) {
+ client.command("reg query HKCU\\Software\\Vendor").execute();
+}
+```
+
+The profile is loaded when the remote shell is created, so this is a client setting. It applies
+to every shell the client creates, for commands, file transfers and remote file operations alike,
+including a shell recreated after the server reaped the previous one. Microsoft's `winrs`
+documentation warns that loading the profile fails for a user who is not a local administrator on
+the host: the command then fails with a
+[`WinRMFaultException`](apidocs/org/metricshub/winrm/exceptions/WinRMFaultException.html) carrying
+the fault code and detail. The CLI's `--profile` option does the same.
+
## Running PowerShell
`powerShell(...)` prepares a PowerShell script execution the same way `command(...)` prepares a
diff --git a/src/site/markdown/migrating-from-winrm4j.md b/src/site/markdown/migrating-from-winrm4j.md
index c70b228..d918a2c 100644
--- a/src/site/markdown/migrating-from-winrm4j.md
+++ b/src/site/markdown/migrating-from-winrm4j.md
@@ -156,6 +156,10 @@ the switch:
which mangles any non-ASCII output on non-English hosts. This client creates the remote shell
with code page **65001 (UTF-8)**, so accented and non-Latin output decodes correctly whatever
the remote locale — no configuration needed ([Character encoding](commands.html#character-encoding)).
+* **The user profile is not loaded by default.** winrm4j hardcodes `WINRS_NOPROFILE=FALSE`, so its
+ commands see the user's profile: `%APPDATA%`, the user's `HKEY_CURRENT_USER` hive. This client
+ does not load it unless the builder calls `loadUserProfile()`
+ ([Loading the user profile](commands.html#loading-the-user-profile)).
* **Timeout semantics.** winrm4j's `operationTimeout` is the WSMan `Receive` polling timeout (how
long each poll waits for output), and separate CXF settings govern connect/receive at the HTTP
level. Here a single `timeout(Duration)` (default 30 s) is a **wall-clock deadline for the
diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java
index c1d2e3f..653db7d 100644
--- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java
+++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java
@@ -833,7 +833,7 @@ void expiredCachedShellIsRecreatedAndTheCommandRetried() throws Exception {
)
.enqueue(200, envelope(signalResponse()));
- try (WinRMClient client = builder(PASSWORD).build()) {
+ try (WinRMClient client = builder(PASSWORD).loadUserProfile().build()) {
assertEquals(
"first",
client
@@ -868,6 +868,10 @@ void expiredCachedShellIsRecreatedAndTheCommandRetried() throws Exception {
.contains("42"),
creates.get(1)
);
+ // The client's loadUserProfile() rides every shell it creates, the recreated one included.
+ for (final String create : creates) {
+ assertTrue(create.contains("FALSE"), create);
+ }
}
@Test
diff --git a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java
index baaa0cc..00134cf 100644
--- a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java
+++ b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java
@@ -47,6 +47,7 @@ void parsesDefaultsAndClearsDirectPassword() throws Exception {
assertEquals(CliArguments.DEFAULT_TIMEOUT, parsed.timeout());
assertEquals(List.of(AuthenticationEnum.NTLM), parsed.authentications());
assertFalse(parsed.allowDelegate());
+ assertFalse(parsed.loadUserProfile());
final char[] password = parsed.password();
parsed.close();
@@ -422,6 +423,10 @@ void rejectsInvalidArguments() {
"--env requires the command or shell subcommand",
concat(base, "--env", "A=b", "wql", "SELECT Name FROM Win32_Service")
},
+ {
+ "--profile requires the command or shell subcommand",
+ concat(base, "--profile", "wql", "SELECT Name FROM Win32_Service")
+ },
{ "-P must be between 1 and 65535", concat(base, "-P", "65536", "command", "whoami") },
{ "-t must be greater than zero", concat(base, "-t", "0", "command", "whoami") },
{ "missing subcommand (wql, command, shell, ls, stat, cat, or get)", base },
diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java
index 87c5906..316c450 100644
--- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java
+++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java
@@ -78,6 +78,7 @@ void helpAndVersionDoNotConnect() throws Exception {
assertTrue(help.stdout.contains("-P, --port"));
assertTrue(help.stdout.contains("-d, --directory"));
assertTrue(help.stdout.contains("--env "));
+ assertTrue(help.stdout.contains("--profile"));
assertTrue(help.stdout.contains("--kerberos-kdc"));
assertTrue(help.stdout.contains("--kerberos-realm"));
assertTrue(help.stdout.contains("--allow-delegate"));
@@ -201,6 +202,7 @@ void sendsTheWorkingDirectoryInTheCreateShellRequest() throws Exception {
"C:\\build",
"--env",
"BUILD_NUMBER=42",
+ "--profile",
"exec",
"build.cmd"
},
@@ -216,6 +218,8 @@ void sendsTheWorkingDirectoryInTheCreateShellRequest() throws Exception {
create.contains("42"),
create
);
+ // And --profile, as the WINRS_NOPROFILE option.
+ assertTrue(create.contains("FALSE"), create);
}
}
@@ -288,6 +292,7 @@ public int read() {
"C:\\build",
"--env",
"CONFIG=release",
+ "--profile",
"shell"
},
WinRmCli::connect,
@@ -304,6 +309,8 @@ public int read() {
create.contains("release"),
create
);
+ // The session's own connection carries --profile too.
+ assertTrue(create.contains("FALSE"), create);
final String command = requests.get(2);
assertTrue(command.contains("cmd.exe /Q"), command);
assertTrue(command.contains("FALSE"), command);
diff --git a/src/test/java/org/metricshub/winrm/light/BasicAuthCloseRaceTest.java b/src/test/java/org/metricshub/winrm/light/BasicAuthCloseRaceTest.java
index 9e21dcc..ab6055b 100644
--- a/src/test/java/org/metricshub/winrm/light/BasicAuthCloseRaceTest.java
+++ b/src/test/java/org/metricshub/winrm/light/BasicAuthCloseRaceTest.java
@@ -47,6 +47,7 @@ void closeWhileOperationInFlightStillErasesTheBasicCredential() throws Exception
scheme,
USERNAME,
65001,
+ false,
0,
0L
);