dsh-winrm 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/README.zh.md +8 -5
- package/lib/client.js +70 -70
- package/lib/index.js +206 -62
- package/lib/types/engine/client.d.ts +5 -5
- package/lib/types/powershell.d.ts +84 -26
- package/package.json +16 -15
- package/scripts/verify-live.mts +116 -0
- package/src/engine/client.ts +22 -20
- package/src/powershell.ts +194 -59
package/src/powershell.ts
CHANGED
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PowerShell snippet builders
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* PowerShell snippet builders and the WinRM UTF-8 envelope.
|
|
3
|
+
*
|
|
4
|
+
* The user script is delivered on the WinRS **stdin** stream, not on the
|
|
5
|
+
* command line. WinRS hands the command line to cmd.exe, which caps it at
|
|
6
|
+
* ~8191 characters; `-EncodedCommand` inflates a script by 2.67x
|
|
7
|
+
* (UTF-16LE → base64), so the command line can carry only about 1.5 KB of
|
|
8
|
+
* script. That silently broke every large payload — most visibly the 48 KiB
|
|
9
|
+
* transfer chunk, whose embedded base64 alone is ~65K characters.
|
|
10
|
+
*
|
|
11
|
+
* So the command line is now a *constant* outer script and the user script
|
|
12
|
+
* rides stdin, which has no such limit (verified to 200 KB).
|
|
13
|
+
*
|
|
14
|
+
* The outer script reads a byte count, then that many bytes, starts a child
|
|
15
|
+
* `powershell.exe` and feeds it the script on its stdin. The process boundary
|
|
16
|
+
* is what makes `exit N` and an uncaught `throw` survivable: they terminate
|
|
17
|
+
* the *child*, and the outer script is still alive to print the envelope.
|
|
18
|
+
* `-EncodedCommand` for the child also sidesteps the execution policy
|
|
19
|
+
* entirely — the policy governs `.ps1` files, not encoded commands — so no
|
|
20
|
+
* temp file is written and no policy block can lose the envelope.
|
|
21
|
+
*
|
|
22
|
+
* The child body is `& (...) 2>&1 | Out-String -Stream`. Both halves matter:
|
|
23
|
+
* `2>&1` pulls error records into the success stream, `Out-String -Stream`
|
|
24
|
+
* stringifies them onto stdout *line by line*. Plain `Out-String` would also
|
|
25
|
+
* stringify them but buffers the whole pipeline, discarding everything
|
|
26
|
+
* produced before a mid-script `exit`; bare `2>&1` without `Out-String`
|
|
27
|
+
* leaves the records to be re-serialized as CLIXML on stderr, where the
|
|
28
|
+
* envelope's CLIXML stripping deletes the text outright.
|
|
29
|
+
*
|
|
30
|
+
* The envelope is three lines — a marker with the exit code, then one base64
|
|
31
|
+
* line per stream:
|
|
32
|
+
*
|
|
33
|
+
* __DSH_WINRM__<code>
|
|
34
|
+
* O:<base64 of UTF-8 stdout>
|
|
35
|
+
* E:<base64 of UTF-8 stderr>
|
|
36
|
+
*
|
|
37
|
+
* Every payload is ASCII, so the transport cannot corrupt it and Chinese
|
|
38
|
+
* output survives losslessly. The `O:` / `E:` labels are load-bearing: the
|
|
39
|
+
* WinRM client trims each response chunk, which can eat the newline between
|
|
40
|
+
* two lines, and base64 never contains `:`, so the labels stay parseable
|
|
41
|
+
* even when the separators vanish.
|
|
6
42
|
*/
|
|
7
43
|
|
|
8
44
|
/** Marker line prefix carrying the exit code: '__DSH_WINRM__<code>'. */
|
|
9
45
|
export const ENVELOPE_MARKER = '__DSH_WINRM__'
|
|
10
46
|
|
|
47
|
+
/** Max characters per stdin Send; split on code points, never mid-surrogate-pair. */
|
|
48
|
+
export const STDIN_CHUNK_CHARS = 16_384
|
|
49
|
+
|
|
11
50
|
/** PS single-quoted literal (doubles embedded quotes). */
|
|
12
51
|
export function sq(value: string): string {
|
|
13
52
|
return "'" + value.replace(/'/g, "''") + "'"
|
|
@@ -19,48 +58,136 @@ export function encodeCommand(script: string): string {
|
|
|
19
58
|
}
|
|
20
59
|
|
|
21
60
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
61
|
+
* Child script: reads the user script from its own stdin, runs it, and lets
|
|
62
|
+
* `exit` end *this* process with the right code.
|
|
63
|
+
*
|
|
64
|
+
* The output shape is fixed by how PowerShell surfaces errors. A terminating
|
|
65
|
+
* `throw` is reported by the `catch`; a non-terminating error only reaches the
|
|
66
|
+
* success stream via `2>&1`, and `Out-String -Stream` turns each such record
|
|
67
|
+
* into text as it arrives. `$LASTEXITCODE` is preferred over the accumulated
|
|
68
|
+
* code so `cmd /c exit 7` reports 7 rather than 0.
|
|
24
69
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
70
|
+
const CHILD_SCRIPT = [
|
|
71
|
+
"$ErrorActionPreference='Continue'",
|
|
72
|
+
'[Console]::OutputEncoding=[Text.Encoding]::UTF8',
|
|
73
|
+
'$i=[Console]::OpenStandardInput()',
|
|
74
|
+
'$m=New-Object System.IO.MemoryStream',
|
|
75
|
+
'$i.CopyTo($m)',
|
|
76
|
+
'$s=[Text.Encoding]::UTF8.GetString($m.ToArray())',
|
|
77
|
+
'if($s.Length -gt 0 -and [int]$s[0] -eq 0xFEFF){$s=$s.Substring(1)}',
|
|
78
|
+
'$c=0',
|
|
79
|
+
'try{ &([scriptblock]::Create($s)) 2>&1 | Out-String -Stream -Width 4096',
|
|
80
|
+
'if($null -ne $LASTEXITCODE){$c=$LASTEXITCODE}',
|
|
81
|
+
'}catch{ $_|Out-String; $c=1 }',
|
|
82
|
+
'exit $c',
|
|
83
|
+
'',
|
|
84
|
+
].join('\r\n')
|
|
37
85
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Outer script (constant, so the command line never grows with the payload):
|
|
88
|
+
* read `<byteCount>\n<utf8 script>` from stdin, run it in a child
|
|
89
|
+
* powershell.exe, then print the three-line envelope.
|
|
90
|
+
*
|
|
91
|
+
* The child is started with redirected streams *before* anything is written to
|
|
92
|
+
* its stdin, and both readers are started before the write, so a child that
|
|
93
|
+
* fills a pipe buffer cannot deadlock the outer script. Closing the writer is
|
|
94
|
+
* what gives the child a real EOF — without it the child would block forever
|
|
95
|
+
* on `CopyTo`.
|
|
96
|
+
*/
|
|
97
|
+
const OUTER_SCRIPT = [
|
|
98
|
+
"$ErrorActionPreference='Continue'",
|
|
99
|
+
"$z=@'",
|
|
100
|
+
CHILD_SCRIPT,
|
|
101
|
+
"'@",
|
|
102
|
+
"$a=''",
|
|
103
|
+
"$e=''",
|
|
104
|
+
'$c=0',
|
|
105
|
+
'try{',
|
|
106
|
+
' $i=[Console]::OpenStandardInput()',
|
|
107
|
+
" $d=''",
|
|
108
|
+
' while($true){$b=$i.ReadByte();if($b -lt 0 -or $b -eq 10){break};if($b -ne 13){$d+=[char]$b}}',
|
|
109
|
+
' $n=[int]$d',
|
|
110
|
+
' $u=New-Object byte[] $n',
|
|
111
|
+
' $r=0',
|
|
112
|
+
' while($r -lt $n){$k=$i.Read($u,$r,$n-$r);if($k -le 0){break};$r+=$k}',
|
|
113
|
+
' $s=[Text.Encoding]::UTF8.GetString($u,0,$r)',
|
|
114
|
+
' $p=New-Object System.Diagnostics.ProcessStartInfo',
|
|
115
|
+
" $p.FileName='powershell.exe'",
|
|
116
|
+
' $p.UseShellExecute=$false',
|
|
117
|
+
' $p.RedirectStandardOutput=$true',
|
|
118
|
+
' $p.RedirectStandardError=$true',
|
|
119
|
+
' $p.RedirectStandardInput=$true',
|
|
120
|
+
' $p.StandardOutputEncoding=[Text.Encoding]::UTF8',
|
|
121
|
+
' $p.StandardErrorEncoding=[Text.Encoding]::UTF8',
|
|
122
|
+
' $p.CreateNoWindow=$true',
|
|
123
|
+
" $p.Arguments='-NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand '+[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($z))",
|
|
124
|
+
' $q=[System.Diagnostics.Process]::Start($p)',
|
|
125
|
+
' $x=$q.StandardOutput.ReadToEndAsync()',
|
|
126
|
+
' $y=$q.StandardError.ReadToEndAsync()',
|
|
127
|
+
' $w=New-Object System.IO.StreamWriter($q.StandardInput.BaseStream,(New-Object System.Text.UTF8Encoding($false)))',
|
|
128
|
+
' $w.Write($s)',
|
|
129
|
+
' $w.Flush()',
|
|
130
|
+
' $w.Close()',
|
|
131
|
+
' $q.WaitForExit()',
|
|
132
|
+
' $a=[string]$x.Result',
|
|
133
|
+
' $e=[string]$y.Result',
|
|
134
|
+
' $c=$q.ExitCode',
|
|
135
|
+
'}catch{',
|
|
136
|
+
' $a=[string]$a+[string]($_|Out-String)',
|
|
137
|
+
' $c=1',
|
|
138
|
+
'}',
|
|
139
|
+
sq(ENVELOPE_MARKER) + '+[string]$c',
|
|
140
|
+
"'O:'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$a))",
|
|
141
|
+
"'E:'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$e))",
|
|
142
|
+
'',
|
|
143
|
+
].join('\r\n')
|
|
42
144
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
145
|
+
/**
|
|
146
|
+
* The constant powershell.exe command line that runs the envelope.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately takes no payload: keeping the command line under cmd.exe's
|
|
149
|
+
* ~8191-character cap is the whole point of the stdin channel.
|
|
150
|
+
*/
|
|
151
|
+
export function powershellCommandLine(): string {
|
|
152
|
+
return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(OUTER_SCRIPT)
|
|
46
153
|
}
|
|
47
154
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
155
|
+
/**
|
|
156
|
+
* The stdin payload for one command: the UTF-8 **byte** count, a newline, then
|
|
157
|
+
* the raw script.
|
|
158
|
+
*
|
|
159
|
+
* The count is in bytes, not characters, because the outer script sizes its
|
|
160
|
+
* receive buffer from it — a script with multi-byte characters would otherwise
|
|
161
|
+
* be truncated mid-character.
|
|
162
|
+
* @param userScript - the PowerShell script to run.
|
|
163
|
+
* @returns the payload to send on WinRS stdin.
|
|
164
|
+
*/
|
|
165
|
+
export function scriptPayload(userScript: string): string {
|
|
166
|
+
return String(Buffer.byteLength(userScript, 'utf8')) + '\n' + userScript
|
|
51
167
|
}
|
|
52
168
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Split a payload into Send-sized pieces without cutting a surrogate pair.
|
|
171
|
+
*
|
|
172
|
+
* `Command.doSendInput` encodes each piece to UTF-8 independently, so a lone
|
|
173
|
+
* surrogate at a chunk boundary would be replaced with U+FFFD and corrupt the
|
|
174
|
+
* script. Iterating with `for…of` yields whole code points, so a pair always
|
|
175
|
+
* stays in one piece.
|
|
176
|
+
* @param payload - the full stdin payload.
|
|
177
|
+
* @returns the pieces to send in order; never empty.
|
|
178
|
+
*/
|
|
179
|
+
export function splitScriptPayload(payload: string): string[] {
|
|
180
|
+
const pieces: string[] = []
|
|
181
|
+
let piece = ''
|
|
182
|
+
for (const ch of payload) {
|
|
183
|
+
if (piece.length + ch.length > STDIN_CHUNK_CHARS) {
|
|
184
|
+
pieces.push(piece)
|
|
185
|
+
piece = ''
|
|
186
|
+
}
|
|
187
|
+
piece += ch
|
|
188
|
+
}
|
|
189
|
+
if (piece !== '' || pieces.length === 0) pieces.push(piece)
|
|
190
|
+
return pieces
|
|
64
191
|
}
|
|
65
192
|
|
|
66
193
|
// ------------------------------------------------------------ snippets
|
|
@@ -160,25 +287,32 @@ export function psWriteChunk(p: string, b64: string, append: boolean): string {
|
|
|
160
287
|
].join('\r\n')
|
|
161
288
|
}
|
|
162
289
|
|
|
290
|
+
/** Decode one base64 envelope field, tolerating whitespace the transport added. */
|
|
291
|
+
function decodeField(base64: string): string {
|
|
292
|
+
return Buffer.from(base64.replace(/\s+/g, ''), 'base64').toString('utf8')
|
|
293
|
+
}
|
|
294
|
+
|
|
163
295
|
/**
|
|
164
|
-
* Parse the envelope
|
|
296
|
+
* Parse the three-line envelope: { exitCode, stdout, stderr } or null when absent.
|
|
165
297
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* negative.
|
|
298
|
+
* Fields are located by their `O:` / `E:` labels rather than by splitting on
|
|
299
|
+
* newlines, because the WinRM client trims every response chunk and can drop
|
|
300
|
+
* the newline that separated two lines. base64's alphabet has no colon, so a
|
|
301
|
+
* label can only ever be a delimiter and the search cannot land on payload
|
|
302
|
+
* data. The exit code is signed because `$LASTEXITCODE` can be negative.
|
|
303
|
+
* @param output - the raw stdout accumulated from the WinRS stdout stream.
|
|
304
|
+
* @returns the decoded envelope, or null when the marker is missing.
|
|
171
305
|
*/
|
|
172
|
-
export function parseEnvelope(output: string): { exitCode: number;
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
306
|
+
export function parseEnvelope(output: string): { exitCode: number; stdout: string; stderr: string } | null {
|
|
307
|
+
const text = output.trim()
|
|
308
|
+
const head = new RegExp('^' + ENVELOPE_MARKER + '(-?\\d+)').exec(text)
|
|
309
|
+
if (head === null) return null
|
|
310
|
+
const stdoutAt = text.indexOf('O:')
|
|
311
|
+
const stderrAt = stdoutAt === -1 ? -1 : text.indexOf('E:', stdoutAt + 2)
|
|
312
|
+
return {
|
|
313
|
+
exitCode: Number.parseInt(head[1], 10),
|
|
314
|
+
stdout: stdoutAt === -1 ? '' : decodeField(text.slice(stdoutAt + 2, stderrAt === -1 ? undefined : stderrAt)),
|
|
315
|
+
stderr: stderrAt === -1 ? '' : decodeField(text.slice(stderrAt + 2)),
|
|
182
316
|
}
|
|
183
317
|
}
|
|
184
318
|
|
|
@@ -189,7 +323,7 @@ const CLIXML_HEADER = /^#< CLIXML\r?$/gm
|
|
|
189
323
|
const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g
|
|
190
324
|
|
|
191
325
|
/**
|
|
192
|
-
* Remove PowerShell's CLIXML host records from
|
|
326
|
+
* Remove PowerShell's CLIXML host records from a stderr stream.
|
|
193
327
|
*
|
|
194
328
|
* PowerShell marks its stderr stream with a `#< CLIXML` header line and
|
|
195
329
|
* serializes the remote host's own progress/verbose records into `<Objs>`
|
|
@@ -198,9 +332,10 @@ const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g
|
|
|
198
332
|
* header and a record block (for example `[Console]::Error`) is real output,
|
|
199
333
|
* not part of the CLIXML payload.
|
|
200
334
|
*
|
|
201
|
-
* The script's own
|
|
202
|
-
* envelope
|
|
203
|
-
*
|
|
335
|
+
* The script's own PowerShell errors are already stringified into the
|
|
336
|
+
* envelope's stdout by `Out-String -Stream`, so this only removes
|
|
337
|
+
* transport-level noise such as module-load progress.
|
|
338
|
+
* @param stderr - raw stderr text captured from the WinRS stream or the envelope.
|
|
204
339
|
* @returns the stderr text without CLIXML host records.
|
|
205
340
|
*/
|
|
206
341
|
export function stripClixml(stderr: string): string {
|