From c3cfa917ccbe9c190ef6234d968991e1fd62de3d Mon Sep 17 00:00:00 2001 From: Jonathan Chun Date: Fri, 21 Aug 2026 13:44:36 -0400 Subject: [PATCH] fix(winrm): decode CLIXML stream serialization on stderr PowerShell remoting serializes progress/verbose/error streams as CLIXML on stderr. 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 errors arrive as unreadable XML. Decode at the source: progress/debug records are dropped, error/warning/verbose records become plain text, non-CLIXML stderr and malformed blobs pass through verbatim. Blob fixtures captured from a real 3DEXPERIENCE host on 2026-08-21. Co-Authored-By: Claude Fable 5 --- winrm/clixml.go | 113 +++++++++++++++++++++++++++++++++++++++++++ winrm/clixml_test.go | 53 ++++++++++++++++++++ winrm/winrm.go | 6 ++- 3 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 winrm/clixml.go create mode 100644 winrm/clixml_test.go diff --git a/winrm/clixml.go b/winrm/clixml.go new file mode 100644 index 0000000..396fcc9 --- /dev/null +++ b/winrm/clixml.go @@ -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, "") + if open < 0 || end < 0 || end < open { + // Truncated or malformed blob: keep it verbatim. + out.WriteString(blob) + break + } + end += len("") + + 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 +// 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 () 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") +} diff --git a/winrm/clixml_test.go b/winrm/clixml_test.go new file mode 100644 index 0000000..a6a36f2 --- /dev/null +++ b/winrm/clixml_test.go @@ -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 +System.Management.Automation.PSCustomObjectSystem.Object1Preparing modules for first use.0-1-1Completed-1 1Preparing modules for first use.0-1-1Completed-1 ` + +const errorBlob = `#< CLIXML +Get-Item : Cannot find path 'C:\missing'._x000D__x000A_At line:1 char:1_x000D__x000A_System.Management.Automation.PSCustomObject` + +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, "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) + } +} diff --git a/winrm/winrm.go b/winrm/winrm.go index ec700b9..e6d7728 100644 --- a/winrm/winrm.go +++ b/winrm/winrm.go @@ -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