From 5ada4cf891531b6df25736f48e316b13d2135a4b Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 28 Sep 2026 00:54:22 +0200 Subject: [PATCH] Configurable user-profile loading: loadUserProfile() and --profile (#139) The shell Create request hardcoded WINRS_NOPROFILE=TRUE, so commands could never get the user's profile. Unless something else had already loaded it on the host (an interactive session, for example), they ran with the Default profile: USERPROFILE and the starting directory were C:\Users\Default, APPDATA was unset, and HKEY_CURRENT_USER was not the user's hive. - WinRMClient.Builder.loadUserProfile() sends WINRS_NOPROFILE=FALSE on every shell the client creates: commands, file transfers, remote file operations, and a shell recreated after the server reaped the previous one. Not loading the profile stays the default. - It is a client setting, next to consoleCodePage (WINRS_CODEPAGE rides the same OptionSet), rather than the per-command option the issue proposed. With a per-command option pinned by the first command, a long-lived client would get whatever its first command happened to ask for, and the option would have had to be threaded through the WindowsRemoteExecutor SPI and every file-transfer leg. - The unreleased LightWinRMService.createInstance overload added by #141 takes the new parameter; no further overload. - CLI: --profile, for the command and shell subcommands. - Docs: a "Loading the user profile" section in commands.md, the cli.md options table (and the -d default, which is C:\Users\Default when the profile is not loaded), and the winrm4j migration page: winrm4j always loads the profile. Verified live on Windows Server 2008 R2 with an account that has no session: without the profile, USERPROFILE and the starting directory are C:\Users\Default, APPDATA is unset and an HKCU marker is invisible; with --profile, all of them are the user's own. On hosts where the account's profile was already loaded, both modes behave the same. Co-Authored-By: Claude Opus 5.5 --- .../org/metricshub/winrm/WinRMClient.java | 23 ++++++++++++++++ .../metricshub/winrm/cli/CliArguments.java | 13 +++++++++ .../org/metricshub/winrm/cli/WinRmCli.java | 4 +++ .../org/metricshub/winrm/light/Envelopes.java | 7 +++-- .../winrm/light/LightWinRMService.java | 8 +++++- .../metricshub/winrm/light/WsmanClient.java | 5 +++- src/site/markdown/cli.md | 3 ++- src/site/markdown/commands.md | 27 +++++++++++++++++++ src/site/markdown/migrating-from-winrm4j.md | 4 +++ .../org/metricshub/winrm/WinRMClientTest.java | 6 ++++- .../winrm/cli/CliArgumentsTest.java | 5 ++++ .../metricshub/winrm/cli/WinRmCliTest.java | 7 +++++ .../winrm/light/BasicAuthCloseRaceTest.java | 1 + 13 files changed, 107 insertions(+), 6 deletions(-) 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 );