diff --git a/packages/gateway-v2/ssh_handler.go b/packages/gateway-v2/ssh_handler.go
index b12ff52d..a8a9f0c1 100644
--- a/packages/gateway-v2/ssh_handler.go
+++ b/packages/gateway-v2/ssh_handler.go
@@ -22,6 +22,7 @@ type sshExecEnvelope struct {
Username string `json:"username"`
Password string `json:"password"`
PrivateKey string `json:"privateKey"`
+ Passphrase string `json:"passphrase"`
Certificate string `json:"certificate"`
TimeoutMs int `json:"timeoutMs"`
}
@@ -45,18 +46,25 @@ type sshExecErrorBody struct {
Message string `json:"message"`
}
+func parseSSHExecPrivateKey(privateKey, passphrase string) (ssh.Signer, error) {
+ if passphrase != "" {
+ return ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(passphrase))
+ }
+ return ssh.ParsePrivateKey([]byte(privateKey))
+}
+
func buildSSHExecAuth(env sshExecEnvelope) ([]ssh.AuthMethod, error) {
switch env.AuthMethod {
case "password":
return []ssh.AuthMethod{ssh.Password(env.Password)}, nil
case "public-key":
- signer, err := ssh.ParsePrivateKey([]byte(env.PrivateKey))
+ signer, err := parseSSHExecPrivateKey(env.PrivateKey, env.Passphrase)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
case "certificate":
- signer, err := ssh.ParsePrivateKey([]byte(env.PrivateKey))
+ signer, err := parseSSHExecPrivateKey(env.PrivateKey, env.Passphrase)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
diff --git a/packages/gateway-v2/winrm/winrm.go b/packages/gateway-v2/winrm/winrm.go
index a91eb65c..01b79e25 100644
--- a/packages/gateway-v2/winrm/winrm.go
+++ b/packages/gateway-v2/winrm/winrm.go
@@ -495,6 +495,205 @@ func RemoveFiles(ctx context.Context, creds Credentials, paths []string) error {
return nil
}
+// CommandResult is the outcome of a command run on the host. A non-zero ExitCode means the command failed.
+type CommandResult struct {
+ Stdout string
+ Stderr string
+ ExitCode int
+ Truncated bool
+}
+
+// maxCommandOutputBytes caps the output captured per stream so a verbose command can't exhaust memory.
+const maxCommandOutputBytes = 64 * 1024
+
+// limitedBuffer buffers up to limit bytes and sets truncated once more arrives. Writes never fail. The
+// retained tail means truncation can drop output without losing the trailer, which is written last.
+type limitedBuffer struct {
+ buf *bytes.Buffer
+ limit int
+ truncated bool
+ tail []byte
+}
+
+const commandTailBytes = 512
+
+func (w *limitedBuffer) Write(p []byte) (int, error) {
+ if remaining := w.limit - w.buf.Len(); remaining > 0 {
+ if len(p) > remaining {
+ w.buf.Write(p[:remaining])
+ w.truncated = true
+ } else {
+ w.buf.Write(p)
+ }
+ } else if len(p) > 0 {
+ w.truncated = true
+ }
+
+ w.tail = append(w.tail, p...)
+ if len(w.tail) > commandTailBytes {
+ w.tail = append([]byte(nil), w.tail[len(w.tail)-commandTailBytes:]...)
+ }
+ return len(p), nil
+}
+
+// commandScriptTemplate wraps the operator's command (%[1]s) so it reports its exit code in a stdout
+// trailer tagged with a per-run nonce (%[2]s). An exit from inside the try block arrives as 0.
+const commandScriptTemplate = `$ErrorActionPreference = 'Stop'
+$%[3]s = 0
+try {
+%[1]s
+if (-not $?) { $%[3]s = if ($LASTEXITCODE) { $LASTEXITCODE } else { 1 } }
+} catch {
+[Console]::Error.WriteLine($_.Exception.Message)
+$%[3]s = 1
+}
+[Console]::Out.WriteLine('')
+[Console]::Out.WriteLine('%[2]s:' + $%[3]s)
+`
+
+func buildCommandScript(command, nonce string) string {
+ return fmt.Sprintf(commandScriptTemplate, command, nonce, commandOutcomeVariable(nonce))
+}
+
+func commandOutcomeVariable(nonce string) string {
+ return strings.ReplaceAll(nonce, "-", "")
+}
+
+func newCommandNonce() (string, error) {
+ buf := make([]byte, 16)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("failed to prepare the command: %w", err)
+ }
+ return "infisical-" + hex.EncodeToString(buf), nil
+}
+
+// takeCommandTrailer splits the trailer off stdout, matching the marker anywhere so output with no
+// trailing newline can't hide it.
+func takeCommandTrailer(stdout, nonce string) (code int, remaining string, ok bool) {
+ marker := nonce + ":"
+ start := strings.LastIndex(stdout, marker)
+ if start < 0 {
+ return 0, stdout, false
+ }
+
+ code, err := strconv.Atoi(strings.TrimSpace(stdout[start+len(marker):]))
+ if err != nil {
+ return 0, stdout, false
+ }
+ // Drop only the blank line the script writes, so output ending in blank lines keeps them.
+ return code, strings.TrimSuffix(strings.TrimSuffix(stdout[:start], "\n"), "\r"), true
+}
+
+// noOutcomeMessage covers both causes: PowerShell parses the whole script before running any of it.
+const noOutcomeMessage = "The command did not report a result: it either called exit, which stops the " +
+ "script early, or did not parse. Use `throw \"reason\"` to fail the sync deliberately."
+
+// maxEncodedCommandChars bounds the -EncodedCommand command line, which Windows caps at 8191 characters.
+const maxEncodedCommandChars = 8000
+
+var clixmlEntities = strings.NewReplacer("<", "<", ">", ">", "&", "&", """, `"`, "'", "'")
+
+// normalizePowerShellStderr converts the CLIXML PowerShell writes to stderr when it has no console.
+func normalizePowerShellStderr(s string) string {
+ if !strings.HasPrefix(strings.TrimSpace(s), "#< CLIXML") {
+ return s
+ }
+ matches := clixmlErrSegment.FindAllStringSubmatch(s, -1)
+ if len(matches) == 0 {
+ // Keep the raw payload rather than dropping the diagnostic.
+ return s
+ }
+
+ var parts []string
+ for _, match := range matches {
+ text := match[1]
+ text = strings.ReplaceAll(text, "_x000D__x000A_", "\n")
+ text = strings.ReplaceAll(text, "_x000D_", "")
+ text = strings.ReplaceAll(text, "_x000A_", "\n")
+ text = clixmlEntities.Replace(text)
+ if trimmed := strings.TrimSpace(text); trimmed != "" {
+ parts = append(parts, trimmed)
+ }
+ }
+ return strings.Join(parts, "\n")
+}
+
+// resolveCommandOutcome turns what the script reported into the code and stderr the caller sees. An
+// unstated outcome has to fail: the script carries no `exit`, so powershell.exe returns 0 either way.
+func resolveCommandOutcome(code int, stated bool, stderr string) (int, string) {
+ if stated {
+ return code, stderr
+ }
+ // Prepended, because the caller quotes the first line of stderr as the failure reason.
+ return 1, strings.TrimSpace(noOutcomeMessage + "\n" + stderr)
+}
+
+// RunCommand runs a command on the host. The script goes on the command line as -EncodedCommand, so it
+// is length-bounded and visible in the host's process table.
+func RunCommand(
+ ctx context.Context,
+ creds Credentials,
+ command string,
+ timeout time.Duration,
+) (CommandResult, error) {
+ nonce, err := newCommandNonce()
+ if err != nil {
+ return CommandResult{}, err
+ }
+
+ encoded := winrm.Powershell(buildCommandScript(command, nonce))
+ if encoded == "" {
+ return CommandResult{}, errors.New("failed to encode the command")
+ }
+ if len(encoded) > maxEncodedCommandChars {
+ return CommandResult{}, fmt.Errorf(
+ "the command is too long to run on Windows once encoded (%d of %d characters). Shorten it, "+
+ "or move the logic into a script on the host and call that script",
+ len(encoded), maxEncodedCommandChars,
+ )
+ }
+
+ client, clientErr := newClient(ctx, creds)
+ if clientErr != nil {
+ return CommandResult{}, clientErr
+ }
+
+ runCtx := ctx
+ if timeout > 0 {
+ var cancel context.CancelFunc
+ runCtx, cancel = context.WithTimeout(ctx, timeout)
+ defer cancel()
+ }
+
+ var stdout, stderr bytes.Buffer
+ stdoutWriter := &limitedBuffer{buf: &stdout, limit: maxCommandOutputBytes}
+ stderrWriter := &limitedBuffer{buf: &stderr, limit: maxCommandOutputBytes}
+
+ _, runErr := client.RunWithContext(runCtx, encoded, stdoutWriter, stderrWriter)
+ if runErr != nil {
+ if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
+ return CommandResult{}, fmt.Errorf("command timed out after %s", timeout)
+ }
+ return CommandResult{}, wrapTransportError(runErr)
+ }
+
+ result := CommandResult{
+ Stderr: normalizePowerShellStderr(stderr.String()),
+ Truncated: stdoutWriter.truncated || stderrWriter.truncated,
+ }
+
+ code, remaining, stated := takeCommandTrailer(stdout.String(), nonce)
+ result.Stdout = remaining
+ if !stated {
+ // Truncation drops the head, so fall back to the retained tail.
+ code, _, stated = takeCommandTrailer(string(stdoutWriter.tail), nonce)
+ }
+
+ result.ExitCode, result.Stderr = resolveCommandOutcome(code, stated, result.Stderr)
+
+ return result, nil
+}
+
// escapePowerShellSingleQuotes escapes a value for a PowerShell single-quoted string by doubling the single quote.
func escapePowerShellSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "''")
diff --git a/packages/gateway-v2/winrm/winrm_command_test.go b/packages/gateway-v2/winrm/winrm_command_test.go
new file mode 100644
index 00000000..ffe2c2dc
--- /dev/null
+++ b/packages/gateway-v2/winrm/winrm_command_test.go
@@ -0,0 +1,322 @@
+package winrm
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/masterzen/winrm"
+)
+
+func TestEscapePowerShellSingleQuotesNeutralizesInjection(t *testing.T) {
+ got := escapePowerShellSingleQuotes(`it's'; Remove-Item C:\ -Recurse; #`)
+
+ if !strings.Contains(got, `it''s''; Remove-Item C:\ -Recurse; #`) {
+ t.Fatalf("expected doubled single quotes, got %q", got)
+ }
+ if strings.Contains(got, `'it's'`) {
+ t.Fatalf("single quote was not escaped, got %q", got)
+ }
+}
+
+func TestBuildCommandScriptPropagatesFailures(t *testing.T) {
+ script := buildCommandScript("Write-Output ok", "infisical-nonce123")
+
+ for _, expected := range []string{
+ "$ErrorActionPreference = 'Stop'",
+ "if (-not $?) { $infisicalnonce123 = if ($LASTEXITCODE) { $LASTEXITCODE } else { 1 } }",
+ "[Console]::Error.WriteLine($_.Exception.Message)",
+ "[Console]::Out.WriteLine('infisical-nonce123:' + $infisicalnonce123)",
+ } {
+ if !strings.Contains(script, expected) {
+ t.Fatalf("expected script to contain %q, got:\n%s", expected, script)
+ }
+ }
+ if strings.Contains(script, "exit $LASTEXITCODE") || strings.Contains(script, "exit 1") {
+ t.Fatalf("the script must state its outcome rather than exit with a code, got:\n%s", script)
+ }
+}
+
+func TestTakeCommandTrailer(t *testing.T) {
+ const nonce = "infisical-abc"
+
+ t.Run("reads the stated code and strips the trailer", func(t *testing.T) {
+ // The blank line is the separator the script writes before the trailer.
+ code, remaining, ok := takeCommandTrailer("reloaded\r\n\r\n"+nonce+":7\r\n", nonce)
+ if !ok || code != 7 {
+ t.Fatalf("expected code 7 to be read, got code=%d ok=%v", code, ok)
+ }
+ // The command's own newline survives; only the separator the script added is removed.
+ if remaining != "reloaded\r\n" {
+ t.Fatalf("expected only the trailer and its separator to be stripped, got %q", remaining)
+ }
+ })
+
+ t.Run("reports no trailer when the command exited early", func(t *testing.T) {
+ if _, _, ok := takeCommandTrailer("partial output\r\n", nonce); ok {
+ t.Fatal("expected output with no trailer to report none")
+ }
+ })
+
+ t.Run("finds the trailer even when the command left no newline before it", func(t *testing.T) {
+ code, remaining, ok := takeCommandTrailer("no trailing newline"+nonce+":3", nonce)
+ if !ok || code != 3 {
+ t.Fatalf("expected the marker to be found mid-line, got code=%d ok=%v", code, ok)
+ }
+ if remaining != "no trailing newline" {
+ t.Fatalf("expected the command's own output to survive, got %q", remaining)
+ }
+ })
+
+ t.Run("keeps the last marker when output contains an earlier lookalike", func(t *testing.T) {
+ code, _, ok := takeCommandTrailer("echoed "+nonce+":9\r\n"+nonce+":0", nonce)
+ if !ok || code != 0 {
+ t.Fatalf("expected the trailing marker to win, got code=%d ok=%v", code, ok)
+ }
+ })
+
+ t.Run("a command echoing a trailer of its own cannot forge one", func(t *testing.T) {
+ if _, _, ok := takeCommandTrailer("infisical-guessed:0\r\n", nonce); ok {
+ t.Fatal("expected a foreign marker to be ignored")
+ }
+ })
+
+ t.Run("leaves stdout untouched when there is no trailer", func(t *testing.T) {
+ _, remaining, _ := takeCommandTrailer("just output", nonce)
+ if remaining != "just output" {
+ t.Fatalf("expected stdout to survive unchanged, got %q", remaining)
+ }
+ })
+}
+
+func TestNewCommandNonceIsUniquePerRun(t *testing.T) {
+ first, err := newCommandNonce()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ second, _ := newCommandNonce()
+
+ if first == second {
+ t.Fatal("expected a fresh nonce per run so a command cannot predict it")
+ }
+ if !strings.HasPrefix(first, "infisical-") || len(first) < 20 {
+ t.Fatalf("unexpected nonce shape %q", first)
+ }
+}
+
+func TestNormalizePowerShellStderrExtractsClixmlText(t *testing.T) {
+ clixml := `#< CLIXML
+` +
+ `Cannot find any service with service name 'NoSuchSvc'._x000D__x000A_` +
+ `At line:1 char:1`
+
+ got := normalizePowerShellStderr(clixml)
+
+ if strings.Contains(got, "CLIXML") || strings.Contains(got, " element, otherwise extraction succeeds and the fallback isn't hit.
+ clixml := "#< CLIXML\n" + `7`
+
+ got := normalizePowerShellStderr(clixml)
+
+ if got != clixml {
+ t.Fatalf("expected the raw payload to be returned unchanged, got %q", got)
+ }
+}
+
+func TestNormalizePowerShellStderrDropsEmptyEnvelope(t *testing.T) {
+ got := normalizePowerShellStderr("#< CLIXML\n ")
+
+ if got != "" {
+ t.Fatalf("expected an all-blank envelope to yield an empty string, got %q", got)
+ }
+}
+
+func TestBuildCommandScriptDoesNotDuplicateProgressPreference(t *testing.T) {
+ if strings.Contains(buildCommandScript("Write-Output ok", "infisical-nonce123"), "$ProgressPreference") {
+ t.Fatal("expected the wrapper to leave $ProgressPreference to winrm.Powershell")
+ }
+}
+
+func TestRunCommandRejectsAnOverlongEncodedCommand(t *testing.T) {
+ // The length check runs before any connection, so no host is needed.
+ _, err := RunCommand(context.Background(), Credentials{}, strings.Repeat("a", 10_000), time.Second)
+
+ if err == nil {
+ t.Fatal("expected an over-long command to be rejected")
+ }
+ if !strings.Contains(err.Error(), "too long to run on Windows once encoded") {
+ t.Fatalf("expected the encoded-length error, got %v", err)
+ }
+}
+
+func TestHandlerCommandCapAlwaysFitsInsideTheEncodedCeiling(t *testing.T) {
+ // Mirrors maxWinrmCommandChars in the parent package.
+ const handlerCommandCap = 2048
+
+ encoded := winrm.Powershell(buildCommandScript(strings.Repeat("a", handlerCommandCap), "infisical-0123456789abcdef0123456789abcdef"))
+
+ if len(encoded) > maxEncodedCommandChars {
+ t.Fatalf("a command at the handler's %d-char cap encodes to %d, past the %d ceiling",
+ handlerCommandCap, len(encoded), maxEncodedCommandChars)
+ }
+ t.Logf("handler cap %d chars -> %d encoded, ceiling %d", handlerCommandCap, len(encoded), maxEncodedCommandChars)
+}
+
+func TestResolveCommandOutcome(t *testing.T) {
+ t.Run("uses the code the script stated", func(t *testing.T) {
+ code, stderr := resolveCommandOutcome(5, true, "boom")
+ if code != 5 || stderr != "boom" {
+ t.Fatalf("expected the stated code to pass through, got code=%d stderr=%q", code, stderr)
+ }
+ })
+
+ t.Run("an unstated outcome fails and never reports success", func(t *testing.T) {
+ // The script carries no `exit`, so powershell.exe returns 0 for a failed command too. Taking
+ // the process exit code here would report a failed command as a successful sync.
+ code, stderr := resolveCommandOutcome(0, false, "")
+ if code == 0 {
+ t.Fatal("expected an unstated outcome to fail, not to inherit a zero exit code")
+ }
+ if !strings.Contains(stderr, "did not report a result") {
+ t.Fatalf("expected the reason to be explained, got %q", stderr)
+ }
+ })
+
+ t.Run("the explanation covers a parse error, not only exit", func(t *testing.T) {
+ // PowerShell parses the whole script before running any of it, so a command that does not parse
+ // produces no trailer either. Blaming exit alone would misname the commonest mistake.
+ _, stderr := resolveCommandOutcome(0, false, "")
+ if !strings.Contains(stderr, "did not parse") {
+ t.Fatalf("expected parse failures to be covered, got %q", stderr)
+ }
+ })
+
+ t.Run("leads with the explanation so the caller quotes it", func(t *testing.T) {
+ _, stderr := resolveCommandOutcome(0, false, "some earlier noise")
+ if !strings.HasPrefix(stderr, "The command did not report a result") {
+ t.Fatalf("expected the explanation first, got %q", stderr)
+ }
+ })
+}
+
+func TestLimitedBufferKeepsTheTailPastTheCap(t *testing.T) {
+ // A command that floods stdout and then fails must still be reported as failed: the trailer is the
+ // last thing written, so it has to survive the head being capped.
+ var buf bytes.Buffer
+ writer := &limitedBuffer{buf: &buf, limit: 16}
+
+ writer.Write([]byte(strings.Repeat("x", 4096)))
+ writer.Write([]byte("\nnonce-abc:7\n"))
+
+ if !writer.truncated {
+ t.Fatal("expected the head to be marked truncated")
+ }
+ code, _, ok := takeCommandTrailer(string(writer.tail), "nonce-abc")
+ if !ok || code != 7 {
+ t.Fatalf("expected the trailer to survive truncation, got code=%d ok=%v", code, ok)
+ }
+ if len(writer.tail) > commandTailBytes {
+ t.Fatalf("expected the tail to stay bounded, got %d bytes", len(writer.tail))
+ }
+}
+
+func TestTakeCommandTrailerKeepsDeliberateBlankLines(t *testing.T) {
+ // Only the separator the script writes is removed, so output that ends in blank lines keeps them.
+ _, remaining, ok := takeCommandTrailer("line\n\n\n\nnonce-abc:0\n", "nonce-abc")
+ if !ok {
+ t.Fatal("expected the trailer to be found")
+ }
+ if remaining != "line\n\n\n" {
+ t.Fatalf("expected only the separator to be stripped, got %q", remaining)
+ }
+}
+
+func TestBuildCommandScriptNamesTheOutcomeVariablePerRun(t *testing.T) {
+ // The command shares scope with the wrapper, so a fixed name could be assigned by the command
+ // itself and report the wrong outcome. Naming it after the nonce makes that impossible to hit.
+ first := buildCommandScript("Write-Output ok", "infisical-aaaa")
+ second := buildCommandScript("Write-Output ok", "infisical-bbbb")
+
+ if !strings.Contains(first, "$infisicalaaaa") || !strings.Contains(second, "$infisicalbbbb") {
+ t.Fatal("expected the outcome variable to be named after the run's nonce")
+ }
+ if strings.Contains(first, "$InfisicalExitCode") {
+ t.Fatal("expected no fixed variable name a command could collide with")
+ }
+ if strings.Contains(commandOutcomeVariable("infisical-aaaa"), "-") {
+ t.Fatal("a PowerShell identifier cannot contain a dash")
+ }
+}
+
+func TestBuildCommandScriptSurvivesACommandTouchingTheOldName(t *testing.T) {
+ // The exact collision the review raised: a command that assigns the wrapper's variable.
+ script := buildCommandScript("$InfisicalExitCode = 0; throw \"boom\"", "infisical-cccc")
+
+ // The command's assignment hits its own variable, not the one the trailer reports.
+ if !strings.Contains(script, "[Console]::Out.WriteLine('infisical-cccc:' + $infisicalcccc)") {
+ t.Fatalf("expected the trailer to read the run's own variable, got:\n%s", script)
+ }
+}
diff --git a/packages/gateway-v2/winrm_handler.go b/packages/gateway-v2/winrm_handler.go
index dd5055cc..64c7dcd7 100644
--- a/packages/gateway-v2/winrm_handler.go
+++ b/packages/gateway-v2/winrm_handler.go
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"net/http"
+ "strings"
"sync"
"time"
@@ -67,6 +68,19 @@ type winrmRemoveParams struct {
Paths []string `json:"paths"`
}
+type winrmRunCommandParams struct {
+ winrmTransportParams
+ Command string `json:"command"`
+ TimeoutMs int `json:"timeoutMs"`
+}
+
+type winrmRunCommandResult struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ ExitCode int `json:"exitCode"`
+ Truncated bool `json:"truncated"`
+}
+
type winrmRotateParams struct {
winrmTransportParams
Kind string `json:"kind"` // "local" or "domain"
@@ -106,6 +120,10 @@ const (
winrmOpDeadline = 120 * time.Second
winrmConnDeadline = winrmOpDeadline + 15*time.Second
maxWinrmRequestBodyBytes = 4 * 1024 * 1024
+
+ maxWinrmCommandChars = 2048
+ defaultWinrmCommandTimeout = 30 * time.Second
+ maxWinrmCommandTimeout = 90 * time.Second
)
// serveWinrmOverTLS reads a single HTTP request off the TLS relay connection and dispatches it to the mux.
@@ -153,6 +171,7 @@ var serveWinrmMux = sync.OnceValue(func() *http.ServeMux {
mux.HandleFunc("/v1/test-connection", wrapWinrm(handleWinrmConnectionTest))
mux.HandleFunc("/v1/deliver-files", wrapWinrm(handleWinrmDeliverFiles))
mux.HandleFunc("/v1/remove-files", wrapWinrm(handleWinrmRemoveFiles))
+ mux.HandleFunc("/v1/run-command", wrapWinrm(handleWinrmRunCommand))
mux.HandleFunc("/v1/enumerate-accounts", wrapWinrm(handleWinrmEnumerateAccounts))
mux.HandleFunc("/v1/enumerate-dependencies", wrapWinrm(handleWinrmEnumerateDependencies))
mux.HandleFunc("/v1/rotate-credential", wrapWinrm(handleWinrmRotateCredential))
@@ -292,6 +311,43 @@ func handleWinrmRemoveFiles(ctx context.Context, env *winrmRequestEnvelope) (any
return map[string]any{"removed": len(p.Paths)}, nil
}
+// handleWinrmRunCommand runs a single command on the host. A non-zero exit code is not an RPC error
+func handleWinrmRunCommand(ctx context.Context, env *winrmRequestEnvelope) (any, error) {
+ var p winrmRunCommandParams
+ if err := json.Unmarshal(env.Params, &p); err != nil {
+ return nil, fmt.Errorf("malformed run-command params")
+ }
+ if strings.TrimSpace(p.Command) == "" {
+ return nil, fmt.Errorf("command is required")
+ }
+ if len(p.Command) > maxWinrmCommandChars {
+ return nil, fmt.Errorf("command exceeds %d characters", maxWinrmCommandChars)
+ }
+
+ result, err := winrm.RunCommand(ctx, credsFromEnv(ctx, env, p.winrmTransportParams), p.Command, resolveWinrmCommandTimeout(p.TimeoutMs))
+ if err != nil {
+ return nil, err
+ }
+ return winrmRunCommandResult{
+ Stdout: result.Stdout,
+ Stderr: result.Stderr,
+ ExitCode: result.ExitCode,
+ Truncated: result.Truncated,
+ }, nil
+}
+
+// resolveWinrmCommandTimeout clamps a requested timeout in milliseconds to the ceiling.
+func resolveWinrmCommandTimeout(timeoutMs int) time.Duration {
+ // Compare in milliseconds: converting to a Duration first overflows int64 for large values.
+ if timeoutMs <= 0 {
+ return defaultWinrmCommandTimeout
+ }
+ if timeoutMs > int(maxWinrmCommandTimeout/time.Millisecond) {
+ return maxWinrmCommandTimeout
+ }
+ return time.Duration(timeoutMs) * time.Millisecond
+}
+
func handleWinrmEnumerateAccounts(ctx context.Context, env *winrmRequestEnvelope) (any, error) {
var tp winrmTransportParams
if len(env.Params) > 0 {
diff --git a/packages/gateway-v2/winrm_run_command_test.go b/packages/gateway-v2/winrm_run_command_test.go
new file mode 100644
index 00000000..bf241306
--- /dev/null
+++ b/packages/gateway-v2/winrm_run_command_test.go
@@ -0,0 +1,38 @@
+package gatewayv2
+
+import (
+ "testing"
+ "time"
+)
+
+func TestResolveWinrmCommandTimeout(t *testing.T) {
+ cases := []struct {
+ name string
+ timeoutMs int
+ want time.Duration
+ }{
+ {"unset falls back to the default", 0, defaultWinrmCommandTimeout},
+ {"negative falls back to the default", -1, defaultWinrmCommandTimeout},
+ {"a value inside the range is honoured", 45_000, 45 * time.Second},
+ {"the ceiling itself is honoured", int(maxWinrmCommandTimeout / time.Millisecond), maxWinrmCommandTimeout},
+ {"above the ceiling clamps down to it", 600_000, maxWinrmCommandTimeout},
+ {"a value large enough to overflow a Duration still clamps", 9_300_000_000_000, maxWinrmCommandTimeout},
+ }
+
+ for _, c := range cases {
+ if got := resolveWinrmCommandTimeout(c.timeoutMs); got != c.want {
+ t.Errorf("%s: resolveWinrmCommandTimeout(%d) = %s, want %s", c.name, c.timeoutMs, got, c.want)
+ }
+ }
+}
+
+func TestWinrmCommandBoundsAreConsistent(t *testing.T) {
+ if defaultWinrmCommandTimeout > maxWinrmCommandTimeout {
+ t.Fatal("the default timeout must fit inside the ceiling")
+ }
+ // The op deadline must be longer than the longest command, otherwise the envelope times out first.
+ if maxWinrmCommandTimeout >= winrmOpDeadline {
+ t.Fatalf("maxWinrmCommandTimeout (%s) must stay under winrmOpDeadline (%s)",
+ maxWinrmCommandTimeout, winrmOpDeadline)
+ }
+}