Skip to content

Add powerShell(script): run PowerShell without quoting or escaping - #160

Merged
bertysentry merged 7 commits into
mainfrom
feature/powershell-method
Aug 11, 2026
Merged

Add powerShell(script): run PowerShell without quoting or escaping#160
bertysentry merged 7 commits into
mainfrom
feature/powershell-method

Conversation

@bertysentry

Copy link
Copy Markdown
Contributor

Closes #157

What

Adds a powerShell(String script) method to WinRMClient, alongside command(...):

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

How

  • The script is encoded as base64 UTF-16LE and run as powershell.exe -NoProfile -NonInteractive -EncodedCommand <base64>, so no quoting or escaping of the script is ever needed — quotes, pipes, newlines and $variables reach PowerShell exactly as written.
  • Returns the existing CommandRequest, so every option and both terminals keep working unchanged: timeout(...), charset(...), stdin(...), onStdout(...), execute() / start().
  • A script whose encoded invocation exceeds cmd.exe's 8191-character command-line limit (scripts beyond roughly 3000 characters) is rejected client-side with an IllegalArgumentException pointing at the file-based alternative: command("powershell.exe -NoProfile -File ...").upload(...).

Tests

  • Wire-level test against FakeWsmanServer: the command element carries the -EncodedCommand invocation, and decoding its base64 as UTF-16LE yields the exact script (quotes, $_, newline, non-ASCII).
  • Validation tests: blank script and over-long script both throw IllegalArgumentException.

Documentation

  • src/site/markdown/commands.md: new "Running PowerShell" section (exit-code semantics, size limit and the -File alternative, Windows PowerShell vs pwsh).
  • README and site index: one short example each.

🤖 Generated with Claude Code

)

The script travels base64-encoded as powershell.exe -NoProfile
-NonInteractive -EncodedCommand, so quotes, pipes, newlines and
$variables pass through verbatim. Returns the regular CommandRequest:
every option and both terminals apply unchanged. Scripts whose encoded
invocation exceeds cmd.exe's 8191-character line limit are rejected
with a pointer at command("powershell -File ...").upload(...).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b99c3ecef5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +167 to +168
final String commandLine = POWERSHELL_PREFIX
+ Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE));

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 upload path rewriting for PowerShell scripts

When a caller uses client.powerShell("Get-Content C:\\local\\x").upload(path), this encodes the local path before CommandRequest.prepare() invokes ShellFileCopy.copyLocalFilesToRemote(). That helper can only replace plaintext paths in the command line, so it transfers the file but sends a payload that still decodes to the client-side path, causing the remote script to fail to find it. Encode only after upload rewriting, or explicitly reject .upload() for these requests, rather than returning an ordinary CommandRequest whose documented option silently misbehaves.

Useful? React with 👍 / 👎.

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

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 👍 / 👎.

Codex review: powerShell(script).upload(file) encoded the script before
ShellFileCopy rewrote the local paths, so the transferred file was never
referenced by the running script. The raw script now rides the
CommandRequest and is encoded in prepare(), after the rewriting; the
over-long-script check still fails eagerly at the powerShell() call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bertysentry

Copy link
Copy Markdown
Contributor Author

Codex feedback addressed in e4e9e16:

  • P2 (upload path rewriting) — Fixed. The raw script now travels on the CommandRequest and is encoded in prepare(), after ShellFileCopy rewrites the local paths — so powerShell("& 'C:\local\collect.ps1'").upload(...) runs the remote copy. The over-long-script check still fails eagerly at the powerShell(...) call site. Covered by a new wire-level test (powerShellUploadRewritesThePathInsideTheScriptBeforeEncodingIt) asserting the decoded base64 references the content-addressed remote path and not the local one.
  • P1 (README) — Keeping the two-line example, deliberately: README's stated scope is "a quick start and a few representative examples", and powerShell(...) is a headline entry point on par with command(...) and wql(...), not a minor option (issue Add a PowerShell convenience method: client.powerShell(script) #157 explicitly called for one short README example). The full documentation lives in commands.md as required.

@codex please review again

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e4e9e16f6c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

actualCommand = String.format("CMD.EXE /C (%s)", updatedCommand);
// A PowerShell script is encoded only now, AFTER the rewriting: the local paths it
// references must be replaced while they are still plain text.
actualCommand = String.format("CMD.EXE /C (%s)", powerShell ? encodePowerShell(updatedCommand) : updatedCommand);

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 Include the upload wrapper in the PowerShell length check

When .upload() is used and the encoded invocation is 8179–8191 characters long, encodePowerShell(updatedCommand) accepts it, but this line adds the 13-character CMD.EXE /C (...) wrapper afterward. The resulting command exceeds the documented 8191-character cmd.exe limit and fails remotely instead of being rejected with the intended IllegalArgumentException; account for the wrapper when validating uploaded PowerShell requests.

Useful? React with 👍 / 👎.

…157)

Codex review: an encoded invocation of 8179-8191 characters passed the
eager check but exceeded cmd.exe's 8191-character limit once upload()
added the 13-character CMD.EXE /C ( ) wrapper. The check now always
reserves that room, so a script accepted by powerShell() stays valid
whether or not upload() is called afterward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bertysentry

Copy link
Copy Markdown
Contributor Author

P2 addressed: the length check in encodePowerShell now reserves the 13 characters of the CMD.EXE /C (...) wrapper, so a script accepted by powerShell(...) can never be pushed over cmd.exe's 8191-character limit by a later upload(...). Boundary-tested (powerShellLengthCheckReservesRoomForTheUploadWrapper: 3040-character script accepted, 3046 rejected).

@codex please review again

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: b712fba96c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Instead of rejecting a script whose -EncodedCommand invocation exceeds
cmd.exe's 8191-character line limit, powerShell() now handles it
itself: the script is written to a local temporary .ps1 file (UTF-8
with a BOM, so -File decodes non-ASCII correctly), transferred through
the WinRM channel exactly like an upload(), and run with
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass
-File. The remote copy is content-addressed, so re-running an
identical script skips the transfer. The caller never sees the
command-line length limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bertysentry

Copy link
Copy Markdown
Contributor Author

New in this PR: powerShell(...) no longer throws for long scripts — the command-line length limit is now handled inside the library. A script whose -EncodedCommand invocation would exceed cmd.exe's 8191-character limit is automatically written to a local temporary .ps1 (UTF-8 with BOM, so -File decodes non-ASCII correctly), transferred through the WinRM channel exactly like upload(...) (content-addressed remote copy, so identical re-runs skip the transfer), and run with powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File. The temp file lives in a fresh per-call temp directory and is deleted right after the transfer.

Wire-level tests cover both sides of the threshold: a 3040-character script still rides the command line encoded, a longer one runs from the content-addressed remote file.

@codex please review again

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 623ac520de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 👍 / 👎.

Codex review: -File gives the script a backing file path, so
$PSScriptRoot and $MyInvocation.MyCommand.Path suddenly resolve when a
script crosses the length threshold — the fallback was not observably
identical to the encoded form. The invocation is now
powershell.exe -Command "& ([ScriptBlock]::Create((Get-Content -Raw
-LiteralPath '<remote>')))": the script stays pathless in both forms,
top-level param(...) blocks keep working, and the execution policy
(which only governs script files) never applies, so -ExecutionPolicy
Bypass is gone too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bertysentry

Copy link
Copy Markdown
Contributor Author

P2 addressed: the fallback no longer uses -File. The transferred file's content is now run as a script block — powershell.exe -NoProfile -NonInteractive -Command "& ([ScriptBlock]::Create((Get-Content -Raw -LiteralPath '<remote>')))" — so the two forms are observably identical: $PSScriptRoot and $MyInvocation.MyCommand.Path stay empty on both sides of the length threshold, top-level param(...) blocks keep working, and the execution policy (which only governs script files) never applies — which also let me drop -ExecutionPolicy Bypass. Wire-level test updated to assert the script-block invocation and the absence of -File.

@codex please review again

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a20c0202f3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

* policy — which only governs script files — never applies.
*/
private static final String POWERSHELL_FILE_INVOCATION = "powershell.exe -NoProfile -NonInteractive -Command " +
"\"& ([ScriptBlock]::Create((Get-Content -Raw -LiteralPath '%s')))\"";

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 invocation metadata in the long-script fallback

For long scripts that inspect $MyInvocation.InvocationName or $MyInvocation.Line, the new & wrapper is itself observable: the transferred script block reports that it was invoked with the call operator and exposes the wrapper line, whereas the top-level -EncodedCommand form does not. A script can therefore still change output or control flow solely by crossing the length threshold; this is fresh evidence introduced by the replacement invocation, so either use a fallback with matching invocation semantics or document the remaining difference instead of describing the forms as observably identical.

Useful? React with 👍 / 👎.

…ence (#157)

Codex review: the call-operator wrapper was itself observable through
$MyInvocation.InvocationName/.Line, so the fallback could not honestly
be called observably identical. The script block is now dot-sourced,
which additionally runs the top level in the session scope exactly like
-EncodedCommand does; the documentation now names the one remaining
observable difference ($MyInvocation's own metadata) instead of
claiming identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bertysentry

Copy link
Copy Markdown
Contributor Author

P2 addressed, both halves:

  • The wrapper now dot-sources the script block (. ([ScriptBlock]::Create(...)) instead of & (...)), which also runs the script's top level in the session scope exactly like -EncodedCommand does — one less semantic difference.
  • The documentation (Javadoc + commands.md) no longer claims the forms are "observably identical": it now names the one remaining observable difference — $MyInvocation's own metadata (InvocationName, Line) reflects the wrapper invocation for a transferred script. Any wrapper is introspectable through $MyInvocation, so this residue is documented rather than chased.

@codex please review again

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1024b26bc6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

* this wrapper invocation for a transferred script.
*/
private static final String POWERSHELL_FILE_INVOCATION = "powershell.exe -NoProfile -NonInteractive -Command " +
"\". ([ScriptBlock]::Create((Get-Content -Raw -LiteralPath '%s')))\"";

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 Avoid Get-Content -Raw on PowerShell 2 hosts

On Windows Server 2008 R2 with its built-in Windows PowerShell 2.0—a host version explicitly accommodated elsewhere in this repository—Get-Content does not support the -Raw parameter. Short scripts still work through -EncodedCommand, but scripts crossing the fallback threshold fail before their content is executed. Read the file through an API available on PowerShell 2, such as a BOM-aware .NET file method, or establish a newer minimum PowerShell version.

Useful? React with 👍 / 👎.

Codex review: Get-Content -Raw appeared in PowerShell 3.0, so the
long-script fallback failed on Windows Server 2008 R2 hosts that the
encoded form handles fine. The file is now read with the BOM-aware
[System.IO.File]::ReadAllText, available since PowerShell 2.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bertysentry

Copy link
Copy Markdown
Contributor Author

P2 addressed: the fallback now reads the transferred file with [System.IO.File]::ReadAllText(...) — BOM-aware and available on PowerShell 2.0 (Windows Server 2008 R2) — instead of Get-Content -Raw, which appeared in PowerShell 3.0. Wire-level test updated.

@codex please review again

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: ddb785d4dc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@bertysentry
bertysentry merged commit 9edef81 into main Aug 11, 2026
5 checks passed
@bertysentry
bertysentry deleted the feature/powershell-method branch August 11, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a PowerShell convenience method: client.powerShell(script)

1 participant