Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions winrm/clixml.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package winrm

import (
"encoding/xml"
"regexp"
"strconv"
"strings"
)

// PowerShell remoting serializes its non-stdout streams (progress, verbose,
// warning, error) as CLIXML blobs on stderr, prefixed with "#< CLIXML".
// Left undecoded, an exit-0 command whose only stderr is a "Preparing
// modules for first use" progress record looks like a failure to agents and
// tests, and real error records arrive as unreadable XML. Decode at the
// source so every consumer — agent, UI, logs — sees plain text.

const clixmlMarker = "#< CLIXML"

// clixmlEscapeRe matches PowerShell's _xHHHH_ character escapes (e.g.
// _x000D__x000A_ for CRLF).
var clixmlEscapeRe = regexp.MustCompile(`_x([0-9A-Fa-f]{4})_`)

type clixmlObjs struct {
Nodes []clixmlNode `xml:",any"`
}

type clixmlNode struct {
XMLName xml.Name
Stream string `xml:"S,attr"`
Text string `xml:",chardata"`
}

// DecodeCLIXMLStderr rewrites stderr from a remote PowerShell invocation into
// plain text. CLIXML blobs are parsed: progress and debug records are dropped
// entirely, and error/warning/verbose/info string records are decoded to
// their message text. Text outside CLIXML blobs passes through untouched, and
// a blob that fails to parse is preserved as-is rather than swallowed.
func DecodeCLIXMLStderr(stderr string) string {
if !strings.Contains(stderr, clixmlMarker) {
return stderr
}

var out strings.Builder
rest := stderr
for {
start := strings.Index(rest, clixmlMarker)
if start < 0 {
out.WriteString(rest)
break
}
out.WriteString(rest[:start])
blob := rest[start:]

open := strings.Index(blob, "<Objs")
end := strings.Index(blob, "</Objs>")
if open < 0 || end < 0 || end < open {
// Truncated or malformed blob: keep it verbatim.
out.WriteString(blob)
break
}
end += len("</Objs>")

decoded, ok := decodeClixmlDocument(blob[open:end])
if !ok {
out.WriteString(blob[:end])
} else {
out.WriteString(decoded)
}
rest = blob[end:]
}

return strings.TrimSpace(out.String())
}

// decodeClixmlDocument extracts the human-relevant text from one <Objs>
// document. Returns ok=false if the XML does not parse.
func decodeClixmlDocument(doc string) (string, bool) {
var objs clixmlObjs
if err := xml.Unmarshal([]byte(doc), &objs); err != nil {
return "", false
}

var lines []string
for _, node := range objs.Nodes {
// Progress records (<Obj S="progress">) and debug chatter carry no
// diagnostic value; drop them. Only string records from the error,
// warning, verbose, and information streams are kept.
if node.XMLName.Local != "S" {
continue
}
switch node.Stream {
case "Error", "Warning", "Verbose", "Info", "Information":
text := strings.TrimSpace(decodeClixmlEscapes(node.Text))
if text != "" {
lines = append(lines, text)
}
}
}
return strings.Join(lines, "\n"), true
}

// decodeClixmlEscapes rewrites _xHHHH_ escapes to their characters, then
// normalizes CRLF to LF.
func decodeClixmlEscapes(s string) string {
s = clixmlEscapeRe.ReplaceAllStringFunc(s, func(m string) string {
code, err := strconv.ParseUint(m[2:6], 16, 32)
if err != nil {
return m
}
return string(rune(code))
})
return strings.ReplaceAll(s, "\r\n", "\n")
}
53 changes: 53 additions & 0 deletions winrm/clixml_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package winrm

import (
"strings"
"testing"
)

// Captured verbatim from a real 3DEXPERIENCE host (2026-08-21): two progress
// records for "Preparing modules for first use" on an exit-0 command.
const progressBlob = `#< CLIXML
<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04"><Obj S="progress" RefId="0"><TN RefId="0"><T>System.Management.Automation.PSCustomObject</T><T>System.Object</T></TN><MS><I64 N="SourceId">1</I64><PR N="Record"><AV>Preparing modules for first use.</AV><AI>0</AI><Nil /><PI>-1</PI><PC>-1</PC><T>Completed</T><SR>-1</SR><SD> </SD></PR></MS></Obj><Obj S="progress" RefId="1"><TNRef RefId="0" /><MS><I64 N="SourceId">1</I64><PR N="Record"><AV>Preparing modules for first use.</AV><AI>0</AI><Nil /><PI>-1</PI><PC>-1</PC><T>Completed</T><SR>-1</SR><SD> </SD></PR></MS></Obj></Objs>`

const errorBlob = `#< CLIXML
<Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04"><S S="Error">Get-Item : Cannot find path 'C:\missing'._x000D__x000A_</S><S S="Error">At line:1 char:1_x000D__x000A_</S><Obj S="progress" RefId="0"><TN RefId="0"><T>System.Management.Automation.PSCustomObject</T></TN></Obj></Objs>`

func TestDecodeCLIXMLStderr_DropsProgressRecords(t *testing.T) {
if got := DecodeCLIXMLStderr(progressBlob); got != "" {
t.Errorf("progress-only blob should decode to empty stderr, got %q", got)
}
}

func TestDecodeCLIXMLStderr_KeepsErrorText(t *testing.T) {
got := DecodeCLIXMLStderr(errorBlob)
if !strings.Contains(got, "Cannot find path 'C:\\missing'") {
t.Errorf("error record text lost: %q", got)
}
if strings.Contains(got, "<Objs") || strings.Contains(got, "CLIXML") || strings.Contains(got, "_x000D_") {
t.Errorf("decoded output still contains XML or escapes: %q", got)
}
}

func TestDecodeCLIXMLStderr_PassthroughAndMalformed(t *testing.T) {
plain := "plain stderr text\n"
if got := DecodeCLIXMLStderr(plain); got != plain {
t.Errorf("non-CLIXML stderr mangled: %q", got)
}

truncated := "#< CLIXML\n<Objs Version=\"1.1.0.1\"><S S=\"Error\">half a blob"
if got := DecodeCLIXMLStderr(truncated); !strings.Contains(got, "half a blob") {
t.Errorf("truncated blob should be preserved verbatim, got %q", got)
}
}

func TestDecodeCLIXMLStderr_TextAroundBlobs(t *testing.T) {
mixed := "real error line\n" + progressBlob + "\ntrailing text"
got := DecodeCLIXMLStderr(mixed)
if !strings.Contains(got, "real error line") || !strings.Contains(got, "trailing text") {
t.Errorf("text outside blob lost: %q", got)
}
if strings.Contains(got, "CLIXML") {
t.Errorf("blob survived decoding: %q", got)
}
}
6 changes: 4 additions & 2 deletions winrm/winrm.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,10 @@ func (c *winrmClient) Execute(ctx context.Context, command string, timeout time.
}

return ssh.ExecResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
Stdout: stdout.String(),
// Decode remoting's CLIXML stream serialization here so agents, the
// UI, and logs never see progress-record XML noise on exit-0 calls.
Stderr: DecodeCLIXMLStderr(stderr.String()),
ExitCode: exitCode,
RuntimeMs: runtime,
}, nil
Expand Down
Loading