dsh-winrm 0.1.1 → 0.3.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 +14 -7
- package/README.zh.md +14 -7
- package/lib/client.js +73 -73
- package/lib/index.js +1184 -134
- package/lib/types/engine/client.d.ts +53 -6
- package/lib/types/engine/wsman.d.ts +153 -0
- package/lib/types/engine.d.ts +4 -4
- package/lib/types/powershell.d.ts +194 -27
- package/lib/types/smb.d.ts +24 -5
- package/package.json +17 -16
- package/scripts/verify-live.mts +116 -0
- package/src/engine/client.ts +460 -24
- package/src/engine/wsman.ts +357 -0
- package/src/engine.ts +104 -40
- package/src/powershell.ts +491 -60
- package/src/smb.ts +57 -5
- package/src/tools.ts +2 -2
package/lib/index.js
CHANGED
|
@@ -1,14 +1,61 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { createGunzip, gzipSync } from "node:zlib";
|
|
2
4
|
import { Command, Shell } from "winrm-client";
|
|
5
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
6
|
import { spawn } from "node:child_process";
|
|
4
7
|
import { copyFile, readFile, stat, unlink } from "node:fs/promises";
|
|
5
|
-
import {
|
|
8
|
+
import { createConnection } from "node:net";
|
|
6
9
|
import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
7
10
|
import { basename, dirname, join, resolve } from "node:path";
|
|
8
11
|
import { homedir, tmpdir } from "node:os";
|
|
9
12
|
import { WebSocket, WebSocketServer } from "ws";
|
|
10
13
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
11
14
|
//#region src/powershell.ts
|
|
15
|
+
/**
|
|
16
|
+
* PowerShell snippet builders and the WinRM UTF-8 envelope.
|
|
17
|
+
*
|
|
18
|
+
* The user script is delivered on the WinRS **stdin** stream, not on the
|
|
19
|
+
* command line. WinRS hands the command line to cmd.exe, which caps it at
|
|
20
|
+
* ~8191 characters; `-EncodedCommand` inflates a script by 2.67x
|
|
21
|
+
* (UTF-16LE → base64), so the command line can carry only about 1.5 KB of
|
|
22
|
+
* script. That silently broke every large payload — most visibly the 48 KiB
|
|
23
|
+
* transfer chunk, whose embedded base64 alone is ~65K characters.
|
|
24
|
+
*
|
|
25
|
+
* So the command line is now a *constant* outer script and the user script
|
|
26
|
+
* rides stdin, which has no such limit (verified to 200 KB).
|
|
27
|
+
*
|
|
28
|
+
* The outer script reads a byte count, then that many bytes, starts a child
|
|
29
|
+
* `powershell.exe` and feeds it the script on its stdin. The process boundary
|
|
30
|
+
* is what makes `exit N` and an uncaught `throw` survivable: they terminate
|
|
31
|
+
* the *child*, and the outer script is still alive to print the envelope.
|
|
32
|
+
* `-EncodedCommand` for the child also sidesteps the execution policy
|
|
33
|
+
* entirely — the policy governs `.ps1` files, not encoded commands — so no
|
|
34
|
+
* temp file is written and no policy block can lose the envelope.
|
|
35
|
+
*
|
|
36
|
+
* The child body is `& (...) 2>&1 | Out-String -Stream`. Both halves matter:
|
|
37
|
+
* `2>&1` pulls error records into the success stream, `Out-String -Stream`
|
|
38
|
+
* stringifies them onto stdout *line by line*. Plain `Out-String` would also
|
|
39
|
+
* stringify them but buffers the whole pipeline, discarding everything
|
|
40
|
+
* produced before a mid-script `exit`; bare `2>&1` without `Out-String`
|
|
41
|
+
* leaves the records to be re-serialized as CLIXML on stderr, where the
|
|
42
|
+
* envelope's CLIXML stripping deletes the text outright.
|
|
43
|
+
*
|
|
44
|
+
* The envelope is three lines — a marker with the exit code, then one base64
|
|
45
|
+
* line per stream:
|
|
46
|
+
*
|
|
47
|
+
* __DSH_WINRM__<code>
|
|
48
|
+
* O:<base64 of UTF-8 stdout>
|
|
49
|
+
* E:<base64 of UTF-8 stderr>
|
|
50
|
+
*
|
|
51
|
+
* Every payload is ASCII, so the transport cannot corrupt it and Chinese
|
|
52
|
+
* output survives losslessly. The `O:` / `E:` labels are load-bearing: the
|
|
53
|
+
* WinRM client trims each response chunk, which can eat the newline between
|
|
54
|
+
* two lines, and base64 never contains `:`, so the labels stay parseable
|
|
55
|
+
* even when the separators vanish.
|
|
56
|
+
*/
|
|
57
|
+
/** Marker line prefix carrying the exit code: '__DSH_WINRM__<code>'. */
|
|
58
|
+
const ENVELOPE_MARKER = "__DSH_WINRM__";
|
|
12
59
|
/** PS single-quoted literal (doubles embedded quotes). */
|
|
13
60
|
function sq(value) {
|
|
14
61
|
return "'" + value.replace(/'/g, "''") + "'";
|
|
@@ -18,28 +65,170 @@ function encodeCommand(script) {
|
|
|
18
65
|
return Buffer.from(script, "utf16le").toString("base64");
|
|
19
66
|
}
|
|
20
67
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
68
|
+
* Outer script (constant, so the command line never grows with the payload):
|
|
69
|
+
* read `<byteCount>\n<utf8 script>` from stdin, run it in a child
|
|
70
|
+
* powershell.exe, then print the three-line envelope.
|
|
71
|
+
*
|
|
72
|
+
* The child is started with redirected streams *before* anything is written to
|
|
73
|
+
* its stdin, and both readers are started before the write, so a child that
|
|
74
|
+
* fills a pipe buffer cannot deadlock the outer script. Closing the writer is
|
|
75
|
+
* what gives the child a real EOF — without it the child would block forever
|
|
76
|
+
* on `CopyTo`.
|
|
23
77
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"$
|
|
29
|
-
"
|
|
30
|
-
"[Console]::
|
|
31
|
-
"
|
|
32
|
-
"
|
|
78
|
+
const OUTER_SCRIPT = [
|
|
79
|
+
"$ErrorActionPreference='Continue'",
|
|
80
|
+
"$z=@'",
|
|
81
|
+
[
|
|
82
|
+
"$ErrorActionPreference='Continue'",
|
|
83
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
84
|
+
"$i=[Console]::OpenStandardInput()",
|
|
85
|
+
"$m=New-Object System.IO.MemoryStream",
|
|
86
|
+
"$i.CopyTo($m)",
|
|
87
|
+
"$s=[Text.Encoding]::UTF8.GetString($m.ToArray())",
|
|
88
|
+
"if($s.Length -gt 0 -and [int]$s[0] -eq 0xFEFF){$s=$s.Substring(1)}",
|
|
89
|
+
"$c=0",
|
|
90
|
+
"try{ &([scriptblock]::Create($s)) 2>&1 | Out-String -Stream -Width 4096",
|
|
91
|
+
"if($null -ne $LASTEXITCODE){$c=$LASTEXITCODE}",
|
|
92
|
+
"}catch{ $_|Out-String; $c=1 }",
|
|
93
|
+
"exit $c",
|
|
33
94
|
""
|
|
34
|
-
].join("\r\n")
|
|
95
|
+
].join("\r\n"),
|
|
96
|
+
"'@",
|
|
97
|
+
"$a=''",
|
|
98
|
+
"$e=''",
|
|
99
|
+
"$c=0",
|
|
100
|
+
"try{",
|
|
101
|
+
" $i=[Console]::OpenStandardInput()",
|
|
102
|
+
" $d=''",
|
|
103
|
+
" while($true){$b=$i.ReadByte();if($b -lt 0 -or $b -eq 10){break};if($b -ne 13){$d+=[char]$b}}",
|
|
104
|
+
" $n=[int]$d",
|
|
105
|
+
" $u=New-Object byte[] $n",
|
|
106
|
+
" $r=0",
|
|
107
|
+
" while($r -lt $n){$k=$i.Read($u,$r,$n-$r);if($k -le 0){break};$r+=$k}",
|
|
108
|
+
" $s=[Text.Encoding]::UTF8.GetString($u,0,$r)",
|
|
109
|
+
" $p=New-Object System.Diagnostics.ProcessStartInfo",
|
|
110
|
+
" $p.FileName='powershell.exe'",
|
|
111
|
+
" $p.UseShellExecute=$false",
|
|
112
|
+
" $p.RedirectStandardOutput=$true",
|
|
113
|
+
" $p.RedirectStandardError=$true",
|
|
114
|
+
" $p.RedirectStandardInput=$true",
|
|
115
|
+
" $p.StandardOutputEncoding=[Text.Encoding]::UTF8",
|
|
116
|
+
" $p.StandardErrorEncoding=[Text.Encoding]::UTF8",
|
|
117
|
+
" $p.CreateNoWindow=$true",
|
|
118
|
+
" $p.Arguments='-NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand '+[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($z))",
|
|
119
|
+
" $q=[System.Diagnostics.Process]::Start($p)",
|
|
120
|
+
" $x=$q.StandardOutput.ReadToEndAsync()",
|
|
121
|
+
" $y=$q.StandardError.ReadToEndAsync()",
|
|
122
|
+
" $w=New-Object System.IO.StreamWriter($q.StandardInput.BaseStream,(New-Object System.Text.UTF8Encoding($false)))",
|
|
123
|
+
" $w.Write($s)",
|
|
124
|
+
" $w.Flush()",
|
|
125
|
+
" $w.Close()",
|
|
126
|
+
" $q.WaitForExit()",
|
|
127
|
+
" $a=[string]$x.Result",
|
|
128
|
+
" $e=[string]$y.Result",
|
|
129
|
+
" $c=$q.ExitCode",
|
|
130
|
+
"}catch{",
|
|
131
|
+
" $a=[string]$a+[string]($_|Out-String)",
|
|
132
|
+
" $c=1",
|
|
133
|
+
"}",
|
|
134
|
+
sq(ENVELOPE_MARKER) + "+[string]$c",
|
|
135
|
+
"'O:'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$a))",
|
|
136
|
+
"'E:'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$e))",
|
|
137
|
+
""
|
|
138
|
+
].join("\r\n");
|
|
139
|
+
/**
|
|
140
|
+
* The constant powershell.exe command line that runs the envelope.
|
|
141
|
+
*
|
|
142
|
+
* Deliberately takes no payload: keeping the command line under cmd.exe's
|
|
143
|
+
* ~8191-character cap is the whole point of the stdin channel.
|
|
144
|
+
*/
|
|
145
|
+
function powershellCommandLine() {
|
|
146
|
+
return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(OUTER_SCRIPT);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Bootstrap for the streaming upload path: read `<byteCount>\n<script>` from
|
|
150
|
+
* stdin, then run that script **in this process** so it can keep draining the
|
|
151
|
+
* same stdin stream.
|
|
152
|
+
*
|
|
153
|
+
* This exists because {@link psReceiveStream} outgrew the command line. WinRS
|
|
154
|
+
* hands the command line to cmd.exe, whose limit is about 8191 characters, and
|
|
155
|
+
* the receiver is 3424 characters of PowerShell — 9206 once base64-of-UTF-16
|
|
156
|
+
* expands it, over the cap. Shipping the receiver on stdin instead costs one
|
|
157
|
+
* extra `Send` and keeps the command line a constant 1154 characters no matter
|
|
158
|
+
* how the receiver grows.
|
|
159
|
+
*
|
|
160
|
+
* Running the receiver in-process (rather than in a child, as
|
|
161
|
+
* {@link OUTER_SCRIPT} does) is what makes the handoff work: the child would
|
|
162
|
+
* need its own stdin, and the payload has to keep flowing into whatever reads
|
|
163
|
+
* the file. A fresh `[Console]::OpenStandardInput()` inside the receiver picks
|
|
164
|
+
* up exactly where this bootstrap stopped reading, because `Read` on the raw
|
|
165
|
+
* stream never buffers ahead of the count it was asked for.
|
|
166
|
+
*/
|
|
167
|
+
const STREAM_BOOTSTRAP = [
|
|
168
|
+
"$ErrorActionPreference='Continue'",
|
|
169
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
170
|
+
"$i=[Console]::OpenStandardInput()",
|
|
171
|
+
"$d=''",
|
|
172
|
+
"while($true){$b=$i.ReadByte();if($b -lt 0 -or $b -eq 10){break};if($b -ne 13){$d+=[char]$b}}",
|
|
173
|
+
"$n=[int]$d",
|
|
174
|
+
"$u=New-Object byte[] $n",
|
|
175
|
+
"$r=0",
|
|
176
|
+
"while($r -lt $n){$k=$i.Read($u,$r,$n-$r);if($k -le 0){break};$r+=$k}",
|
|
177
|
+
"$s=[Text.Encoding]::UTF8.GetString($u,0,$r)",
|
|
178
|
+
"$c=0",
|
|
179
|
+
"try{",
|
|
180
|
+
" &([scriptblock]::Create($s))",
|
|
181
|
+
"}catch{",
|
|
182
|
+
" [Console]::Error.WriteLine([string]($_|Out-String))",
|
|
183
|
+
" $c=1",
|
|
184
|
+
"}",
|
|
185
|
+
"exit $c",
|
|
186
|
+
""
|
|
187
|
+
].join("\r\n");
|
|
188
|
+
/**
|
|
189
|
+
* The constant powershell.exe command line for a streaming upload.
|
|
190
|
+
*
|
|
191
|
+
* The receiver travels on stdin right behind it, so this stays well clear of
|
|
192
|
+
* cmd.exe's cap and does not change when the receiver does.
|
|
193
|
+
*/
|
|
194
|
+
function streamCommandLine() {
|
|
195
|
+
return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(STREAM_BOOTSTRAP);
|
|
35
196
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
197
|
+
/**
|
|
198
|
+
* The stdin payload for one command: the UTF-8 **byte** count, a newline, then
|
|
199
|
+
* the raw script.
|
|
200
|
+
*
|
|
201
|
+
* The count is in bytes, not characters, because the outer script sizes its
|
|
202
|
+
* receive buffer from it — a script with multi-byte characters would otherwise
|
|
203
|
+
* be truncated mid-character.
|
|
204
|
+
* @param userScript - the PowerShell script to run.
|
|
205
|
+
* @returns the payload to send on WinRS stdin.
|
|
206
|
+
*/
|
|
207
|
+
function scriptPayload(userScript) {
|
|
208
|
+
return String(Buffer.byteLength(userScript, "utf8")) + "\n" + userScript;
|
|
39
209
|
}
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Split a payload into Send-sized pieces without cutting a surrogate pair.
|
|
212
|
+
*
|
|
213
|
+
* `Command.doSendInput` encodes each piece to UTF-8 independently, so a lone
|
|
214
|
+
* surrogate at a chunk boundary would be replaced with U+FFFD and corrupt the
|
|
215
|
+
* script. Iterating with `for…of` yields whole code points, so a pair always
|
|
216
|
+
* stays in one piece.
|
|
217
|
+
* @param payload - the full stdin payload.
|
|
218
|
+
* @returns the pieces to send in order; never empty.
|
|
219
|
+
*/
|
|
220
|
+
function splitScriptPayload(payload) {
|
|
221
|
+
const pieces = [];
|
|
222
|
+
let piece = "";
|
|
223
|
+
for (const ch of payload) {
|
|
224
|
+
if (piece.length + ch.length > 16384) {
|
|
225
|
+
pieces.push(piece);
|
|
226
|
+
piece = "";
|
|
227
|
+
}
|
|
228
|
+
piece += ch;
|
|
229
|
+
}
|
|
230
|
+
if (piece !== "" || pieces.length === 0) pieces.push(piece);
|
|
231
|
+
return pieces;
|
|
43
232
|
}
|
|
44
233
|
/** JSON: full service table (CIM). */
|
|
45
234
|
function psListServices() {
|
|
@@ -89,11 +278,16 @@ function psListDir(dir) {
|
|
|
89
278
|
""
|
|
90
279
|
].join("\r\n");
|
|
91
280
|
}
|
|
92
|
-
/**
|
|
281
|
+
/**
|
|
282
|
+
* Plain: remote file size in bytes. Emits nothing at all when the path is
|
|
283
|
+
* missing or is a directory, so the caller can tell "absent" from "empty" —
|
|
284
|
+
* both used to collapse to the string "0", which made a download of a
|
|
285
|
+
* nonexistent file look like a successful transfer of a zero-byte one.
|
|
286
|
+
*/
|
|
93
287
|
function psFileSize(p) {
|
|
94
288
|
return [
|
|
95
289
|
"$__f = Get-Item -LiteralPath " + sq(p) + " -ErrorAction SilentlyContinue",
|
|
96
|
-
"if ($null -
|
|
290
|
+
"if ($null -ne $__f -and -not $__f.PSIsContainer) { [string]$__f.Length }",
|
|
97
291
|
""
|
|
98
292
|
].join("\r\n");
|
|
99
293
|
}
|
|
@@ -128,38 +322,260 @@ function psWriteChunk(p, b64, append) {
|
|
|
128
322
|
""
|
|
129
323
|
].join("\r\n");
|
|
130
324
|
}
|
|
325
|
+
/** Magic stamped into the frame header that precedes every stream chunk. */
|
|
326
|
+
const STREAM_FRAME_MAGIC = "DSHZ";
|
|
327
|
+
/** Remote-side buffer used while draining stdin (or inflating a frame) into the file. */
|
|
328
|
+
const STREAM_BUFFER_BYTES = 262144;
|
|
131
329
|
/**
|
|
132
|
-
*
|
|
330
|
+
* Wrap one payload in a stream frame: `DSHZ` + uint32LE length + mode + pad.
|
|
133
331
|
*
|
|
134
|
-
* The
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
332
|
+
* The frame is what makes a compressed stream legal on a pipe. stdin cannot
|
|
333
|
+
* seek, so the receiver has to be handed the exact byte count of the next gzip
|
|
334
|
+
* member before it lets `GZipStream` touch it; otherwise the inflater would
|
|
335
|
+
* read into the member that follows and the stream would desynchronize. The
|
|
336
|
+
* mode byte travels with the payload so the client can switch between gzip and
|
|
337
|
+
* raw per chunk.
|
|
338
|
+
* @param payload - gzip member bytes or raw file bytes.
|
|
339
|
+
* @param mode - `STREAM_FRAME_GZIP` or `STREAM_FRAME_RAW`.
|
|
340
|
+
* @returns the framed chunk to place on the stdin stream.
|
|
341
|
+
*/
|
|
342
|
+
function buildStreamFrame(payload, mode) {
|
|
343
|
+
const frame = Buffer.alloc(10 + payload.length);
|
|
344
|
+
frame.write(STREAM_FRAME_MAGIC, 0, "ascii");
|
|
345
|
+
frame.writeUInt32LE(payload.length, 4);
|
|
346
|
+
frame[8] = mode;
|
|
347
|
+
frame[9] = 0;
|
|
348
|
+
payload.copy(frame, 10);
|
|
349
|
+
return frame;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Streaming receiver: drain framed stdin chunks into the file named by the
|
|
353
|
+
* header frame, until the declared byte count lands.
|
|
354
|
+
*
|
|
355
|
+
* This is the fast upload path. Instead of shipping base64 text inside a
|
|
356
|
+
* PowerShell script — which costs one full shell create/command/delete cycle
|
|
357
|
+
* per 48 KiB — the remote shell starts this script once and the client then
|
|
358
|
+
* pushes bytes straight down the WinRS stdin stream. The only per-chunk cost
|
|
359
|
+
* left is a single `Send`, so a whole file costs one shell and one command no
|
|
360
|
+
* matter how large it is.
|
|
361
|
+
*
|
|
362
|
+
* The script deliberately takes **no arguments**. The destination path and
|
|
363
|
+
* total byte count arrive as the first frame on stdin, which keeps the command
|
|
364
|
+
* line a constant size: WinRS hands the command line to cmd.exe, whose limit
|
|
365
|
+
* is about 8191 characters, and a long remote path interpolated into the
|
|
366
|
+
* script would eat into that budget (the script is base64-of-UTF-16 on the
|
|
367
|
+
* wire, so every character costs 2.67). Reading them from the stream removes
|
|
368
|
+
* the limit entirely and mirrors how `powershellCommandLine` stays constant.
|
|
369
|
+
*
|
|
370
|
+
* Every wire chunk is a frame: `DSHZ` + 4-byte little-endian payload length +
|
|
371
|
+
* 1 mode byte + 1 reserved, followed by exactly that many payload bytes. The
|
|
372
|
+
* explicit frame is what makes a *pipe* usable for a compressed stream: stdin
|
|
373
|
+
* cannot seek, so the receiver must be told how long the next gzip member is
|
|
374
|
+
* before it hands the bytes to `GZipStream`, or the inflater would read into
|
|
375
|
+
* the member that follows. The mode byte lets the client pick per chunk — gzip
|
|
376
|
+
* when it actually shrinks the data, raw bytes when it would not — so
|
|
377
|
+
* incompressible files never pay compression overhead.
|
|
378
|
+
*
|
|
379
|
+
* The script prints the three-line envelope on stdout with the byte count as
|
|
380
|
+
* its payload, so the caller can prove the transfer landed complete.
|
|
381
|
+
* @returns a PowerShell script suitable for `-EncodedCommand`.
|
|
382
|
+
*/
|
|
383
|
+
function psReceiveStream() {
|
|
384
|
+
return [
|
|
385
|
+
"$ErrorActionPreference = 'Stop'",
|
|
386
|
+
"$__a = ''",
|
|
387
|
+
"$__e = ''",
|
|
388
|
+
"$__c = 0",
|
|
389
|
+
"try {",
|
|
390
|
+
" $__i = [Console]::OpenStandardInput()",
|
|
391
|
+
" $__hdr = New-Object byte[] " + String(10),
|
|
392
|
+
" $__got = 0",
|
|
393
|
+
" while ($__got -lt $__hdr.Length) {",
|
|
394
|
+
" $__k = $__i.Read($__hdr, $__got, $__hdr.Length - $__got)",
|
|
395
|
+
" if ($__k -le 0) { break }",
|
|
396
|
+
" $__got += $__k",
|
|
397
|
+
" }",
|
|
398
|
+
" if ($__got -lt $__hdr.Length) { throw " + sq("upload stream ended before its header") + " }",
|
|
399
|
+
" if (-not ($__hdr[0] -eq 68 -and $__hdr[1] -eq 83 -and $__hdr[2] -eq 72 -and $__hdr[3] -eq 90)) { throw " + sq("upload stream lost frame alignment") + " }",
|
|
400
|
+
" if ($__hdr[8] -ne " + String(2) + ") { throw " + sq("upload stream started without a header frame") + " }",
|
|
401
|
+
" $__size = 0",
|
|
402
|
+
" for ($__j = 0; $__j -lt 4; $__j++) { $__size = $__size -bor ([int]$__hdr[4 + $__j] -shl (8 * $__j)) }",
|
|
403
|
+
" $__meta = New-Object byte[] $__size",
|
|
404
|
+
" $__got = 0",
|
|
405
|
+
" while ($__got -lt $__size) {",
|
|
406
|
+
" $__k = $__i.Read($__meta, $__got, $__size - $__got)",
|
|
407
|
+
" if ($__k -le 0) { break }",
|
|
408
|
+
" $__got += $__k",
|
|
409
|
+
" }",
|
|
410
|
+
" if ($__got -lt $__size) { throw " + sq("upload stream truncated its header frame") + " }",
|
|
411
|
+
" $__text = [System.Text.Encoding]::UTF8.GetString($__meta)",
|
|
412
|
+
" $__cut = $__text.IndexOf([char]10)",
|
|
413
|
+
" if ($__cut -lt 0) { throw " + sq("upload header frame is malformed") + " }",
|
|
414
|
+
" $__n = [long]$__text.Substring(0, $__cut)",
|
|
415
|
+
" $__p = $__text.Substring($__cut + 1)",
|
|
416
|
+
" $__d = [System.IO.Path]::GetDirectoryName($__p)",
|
|
417
|
+
" if (-not [string]::IsNullOrEmpty($__d)) { [System.IO.Directory]::CreateDirectory($__d) | Out-Null }",
|
|
418
|
+
" $__fs = New-Object System.IO.FileStream($__p, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)",
|
|
419
|
+
" $__w = 0",
|
|
420
|
+
" try {",
|
|
421
|
+
" $__buf = New-Object byte[] " + String(STREAM_BUFFER_BYTES),
|
|
422
|
+
" while ($__w -lt $__n) {",
|
|
423
|
+
" $__got = 0",
|
|
424
|
+
" while ($__got -lt $__hdr.Length) {",
|
|
425
|
+
" $__k = $__i.Read($__hdr, $__got, $__hdr.Length - $__got)",
|
|
426
|
+
" if ($__k -le 0) { break }",
|
|
427
|
+
" $__got += $__k",
|
|
428
|
+
" }",
|
|
429
|
+
" if ($__got -lt $__hdr.Length) { break }",
|
|
430
|
+
" if (-not ($__hdr[0] -eq 68 -and $__hdr[1] -eq 83 -and $__hdr[2] -eq 72 -and $__hdr[3] -eq 90)) { throw " + sq("upload stream lost frame alignment") + " }",
|
|
431
|
+
" $__size = 0",
|
|
432
|
+
" for ($__j = 0; $__j -lt 4; $__j++) { $__size = $__size -bor ([int]$__hdr[4 + $__j] -shl (8 * $__j)) }",
|
|
433
|
+
" $__mode = $__hdr[8]",
|
|
434
|
+
" $__blob = New-Object byte[] $__size",
|
|
435
|
+
" $__got = 0",
|
|
436
|
+
" while ($__got -lt $__size) {",
|
|
437
|
+
" $__k = $__i.Read($__blob, $__got, $__size - $__got)",
|
|
438
|
+
" if ($__k -le 0) { break }",
|
|
439
|
+
" $__got += $__k",
|
|
440
|
+
" }",
|
|
441
|
+
" if ($__got -lt $__size) { break }",
|
|
442
|
+
" if ($__mode -eq " + String(1) + ") {",
|
|
443
|
+
" $__ms = New-Object System.IO.MemoryStream(, $__blob)",
|
|
444
|
+
" $__gz = New-Object System.IO.Compression.GZipStream($__ms, [System.IO.Compression.CompressionMode]::Decompress)",
|
|
445
|
+
" try {",
|
|
446
|
+
" while ($true) {",
|
|
447
|
+
" $__k = $__gz.Read($__buf, 0, $__buf.Length)",
|
|
448
|
+
" if ($__k -le 0) { break }",
|
|
449
|
+
" $__fs.Write($__buf, 0, $__k)",
|
|
450
|
+
" $__w += $__k",
|
|
451
|
+
" }",
|
|
452
|
+
" } finally { $__gz.Dispose(); $__ms.Dispose() }",
|
|
453
|
+
" } else {",
|
|
454
|
+
" $__fs.Write($__blob, 0, $__blob.Length)",
|
|
455
|
+
" $__w += $__blob.Length",
|
|
456
|
+
" }",
|
|
457
|
+
" }",
|
|
458
|
+
" } finally { $__fs.Dispose() }",
|
|
459
|
+
" $__a = [string]$__w",
|
|
460
|
+
"} catch {",
|
|
461
|
+
" $__a = [string]$__a + [string]($_.Exception.Message)",
|
|
462
|
+
" $__c = 1",
|
|
463
|
+
"}",
|
|
464
|
+
sq(ENVELOPE_MARKER) + " + [string]$__c",
|
|
465
|
+
"'O:' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__a))",
|
|
466
|
+
"'E:' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__e))",
|
|
467
|
+
""
|
|
468
|
+
].join("\r\n");
|
|
469
|
+
}
|
|
470
|
+
/** Remote-side read buffer used by {@link psSendStream} while draining the file to stdout. */
|
|
471
|
+
const SEND_BUFFER_BYTES = 262144;
|
|
472
|
+
/**
|
|
473
|
+
* Streaming sender: write one byte range of the file to standard output,
|
|
474
|
+
* gzip-compressed, for the download path.
|
|
475
|
+
*
|
|
476
|
+
* This is the mirror of {@link psReceiveStream}. The old download path asked
|
|
477
|
+
* for 48 KiB at a time, and every chunk cost a full shell create / command /
|
|
478
|
+
* delete cycle — five HTTP round trips each — so a 2 MiB file took 129 s. Here
|
|
479
|
+
* the shell is created once and the client then simply receives; the only
|
|
480
|
+
* per-chunk cost left is one `Receive`, and the target streams the file as fast
|
|
481
|
+
* as the transport will drain it.
|
|
482
|
+
*
|
|
483
|
+
* Two details make this work:
|
|
484
|
+
*
|
|
485
|
+
* - **Only stderr may carry text.** Anything written to the PowerShell success
|
|
486
|
+
* stream would be merged into the payload and corrupt the file, so the byte
|
|
487
|
+
* count and any error go to `[Console]::Error` and the exit code, not stdout.
|
|
488
|
+
* - **The range is positional.** `Position` plus a byte count lets several
|
|
489
|
+
* shells serve different parts of one file concurrently; each writes to its
|
|
490
|
+
* own offset on the client, so completion order does not matter.
|
|
491
|
+
*
|
|
492
|
+
* Output is gzip-compressed unconditionally. Measured against this target, a
|
|
493
|
+
* compressible 16 MiB file collapsed from 121 round trips to 1 (2.0 s), and an
|
|
494
|
+
* incompressible 2 MiB file was no slower than raw (9457 ms vs 9582 ms), so the
|
|
495
|
+
* compression pays for itself or costs nothing.
|
|
496
|
+
* @param remotePath - file to read on the target.
|
|
497
|
+
* @param start - first byte offset of this worker's range.
|
|
498
|
+
* @param length - number of bytes this worker must send.
|
|
499
|
+
* @returns a PowerShell script suitable for `-EncodedCommand`.
|
|
500
|
+
*/
|
|
501
|
+
function psSendStream(remotePath, start, length) {
|
|
502
|
+
return [
|
|
503
|
+
"$ErrorActionPreference = 'Stop'",
|
|
504
|
+
"$__o = [Console]::OpenStandardOutput()",
|
|
505
|
+
"$__n = 0",
|
|
506
|
+
"try {",
|
|
507
|
+
" $__fs = [System.IO.File]::OpenRead(" + sq(remotePath) + ")",
|
|
508
|
+
" try {",
|
|
509
|
+
" $__fs.Position = [long]" + String(start),
|
|
510
|
+
" $__left = [long]" + String(length),
|
|
511
|
+
" $__gz = New-Object System.IO.Compression.GZipStream($__o, [System.IO.Compression.CompressionMode]::Compress, $true)",
|
|
512
|
+
" try {",
|
|
513
|
+
" $__buf = New-Object byte[] " + String(SEND_BUFFER_BYTES),
|
|
514
|
+
" while ($__left -gt 0) {",
|
|
515
|
+
" $__want = [int][Math]::Min([long]$__buf.Length, $__left)",
|
|
516
|
+
" $__k = $__fs.Read($__buf, 0, $__want)",
|
|
517
|
+
" if ($__k -le 0) { break }",
|
|
518
|
+
" $__gz.Write($__buf, 0, $__k)",
|
|
519
|
+
" $__n += $__k",
|
|
520
|
+
" $__left -= $__k",
|
|
521
|
+
" }",
|
|
522
|
+
" $__gz.Flush()",
|
|
523
|
+
" } finally { $__gz.Dispose() }",
|
|
524
|
+
" $__o.Flush()",
|
|
525
|
+
" } finally { $__fs.Dispose() }",
|
|
526
|
+
" [Console]::Error.WriteLine('__DSH_DL__ OK ' + [string]$__n)",
|
|
527
|
+
"} catch {",
|
|
528
|
+
" [Console]::Error.WriteLine('__DSH_DL__ ERR ' + [string]($_.Exception.Message))",
|
|
529
|
+
" throw",
|
|
530
|
+
"}",
|
|
531
|
+
""
|
|
532
|
+
].join("\r\n");
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Wrap an arbitrary script in the constant powershell.exe invocation.
|
|
536
|
+
*
|
|
537
|
+
* Only safe for scripts that fit cmd.exe's ~8191 character command line (the
|
|
538
|
+
* script is base64-of-UTF-16 on the wire, so it costs 2.67 characters each);
|
|
539
|
+
* {@link psReceiveStream} outgrew that and travels on stdin instead.
|
|
540
|
+
* @param script - the PowerShell script to run.
|
|
541
|
+
* @returns the command line to hand to WinRS.
|
|
542
|
+
*/
|
|
543
|
+
function scriptCommandLine(script) {
|
|
544
|
+
return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(script);
|
|
545
|
+
}
|
|
546
|
+
/** Decode one base64 envelope field, tolerating whitespace the transport added. */
|
|
547
|
+
function decodeField(base64) {
|
|
548
|
+
return Buffer.from(base64.replace(/\s+/g, ""), "base64").toString("utf8");
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Parse the three-line envelope: { exitCode, stdout, stderr } or null when absent.
|
|
552
|
+
*
|
|
553
|
+
* Fields are located by their `O:` / `E:` labels rather than by splitting on
|
|
554
|
+
* newlines, because the WinRM client trims every response chunk and can drop
|
|
555
|
+
* the newline that separated two lines. base64's alphabet has no colon, so a
|
|
556
|
+
* label can only ever be a delimiter and the search cannot land on payload
|
|
557
|
+
* data. The exit code is signed because `$LASTEXITCODE` can be negative.
|
|
558
|
+
* @param output - the raw stdout accumulated from the WinRS stdout stream.
|
|
559
|
+
* @returns the decoded envelope, or null when the marker is missing.
|
|
139
560
|
*/
|
|
140
561
|
function parseEnvelope(output) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
return {
|
|
152
|
-
exitCode,
|
|
153
|
-
text: payload
|
|
154
|
-
};
|
|
155
|
-
}
|
|
562
|
+
const text = output.trim();
|
|
563
|
+
const head = (/* @__PURE__ */ new RegExp("^__DSH_WINRM__(-?\\d+)")).exec(text);
|
|
564
|
+
if (head === null) return null;
|
|
565
|
+
const stdoutAt = text.indexOf("O:");
|
|
566
|
+
const stderrAt = stdoutAt === -1 ? -1 : text.indexOf("E:", stdoutAt + 2);
|
|
567
|
+
return {
|
|
568
|
+
exitCode: Number.parseInt(head[1], 10),
|
|
569
|
+
stdout: stdoutAt === -1 ? "" : decodeField(text.slice(stdoutAt + 2, stderrAt === -1 ? void 0 : stderrAt)),
|
|
570
|
+
stderr: stderrAt === -1 ? "" : decodeField(text.slice(stderrAt + 2))
|
|
571
|
+
};
|
|
156
572
|
}
|
|
157
573
|
/** The `#< CLIXML` stream header PowerShell writes once before its serialized records. */
|
|
158
574
|
const CLIXML_HEADER = /^#< CLIXML\r?$/gm;
|
|
159
575
|
/** One serialized record block (`<Objs …>…</Objs>`) holding host progress/verbose records. */
|
|
160
576
|
const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g;
|
|
161
577
|
/**
|
|
162
|
-
* Remove PowerShell's CLIXML host records from
|
|
578
|
+
* Remove PowerShell's CLIXML host records from a stderr stream.
|
|
163
579
|
*
|
|
164
580
|
* PowerShell marks its stderr stream with a `#< CLIXML` header line and
|
|
165
581
|
* serializes the remote host's own progress/verbose records into `<Objs>`
|
|
@@ -168,25 +584,217 @@ const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g;
|
|
|
168
584
|
* header and a record block (for example `[Console]::Error`) is real output,
|
|
169
585
|
* not part of the CLIXML payload.
|
|
170
586
|
*
|
|
171
|
-
* The script's own
|
|
172
|
-
* envelope
|
|
173
|
-
*
|
|
587
|
+
* The script's own PowerShell errors are already stringified into the
|
|
588
|
+
* envelope's stdout by `Out-String -Stream`, so this only removes
|
|
589
|
+
* transport-level noise such as module-load progress.
|
|
590
|
+
* @param stderr - raw stderr text captured from the WinRS stream or the envelope.
|
|
174
591
|
* @returns the stderr text without CLIXML host records.
|
|
175
592
|
*/
|
|
176
593
|
function stripClixml(stderr) {
|
|
177
594
|
return stderr.replace(CLIXML_HEADER, "").replace(CLIXML_RECORDS, "").trim();
|
|
178
595
|
}
|
|
179
596
|
//#endregion
|
|
597
|
+
//#region src/engine/wsman.ts
|
|
598
|
+
/**
|
|
599
|
+
* Raw WSMan `Send` for the streaming upload path.
|
|
600
|
+
*
|
|
601
|
+
* The bundled `winrm-client` can only put text on the WinRS stdin stream: its
|
|
602
|
+
* `doSendInput` base64-encodes the input *again* on top of whatever the caller
|
|
603
|
+
* already encoded, and it has no way to close the stream. Both are fatal for a
|
|
604
|
+
* bulk transfer — double base64 costs 1.78x the bytes on the wire, and a
|
|
605
|
+
* stdin stream that never ends cannot tell the remote receiver "that is all".
|
|
606
|
+
*
|
|
607
|
+
* This module therefore builds the `Send` envelope itself and posts it through
|
|
608
|
+
* the library's own `sendHttp`, which keeps Basic/NTLM/SPNEGO and the HTTPS
|
|
609
|
+
* options exactly as the rest of the transport uses them. The stream carries
|
|
610
|
+
* raw file bytes, so the wire cost is 1.0x plus the envelope.
|
|
611
|
+
*
|
|
612
|
+
* The library is reached through `createRequire` rather than a static import
|
|
613
|
+
* because `dist/src/utils/http.js` is an internal CommonJS path with no
|
|
614
|
+
* `exports` map behind it. `loadSendHttp` returns null when that path is gone
|
|
615
|
+
* (a future `winrm-client` layout change), which lets the caller fall back to
|
|
616
|
+
* the old chunked path instead of failing the transfer.
|
|
617
|
+
*/
|
|
618
|
+
/**
|
|
619
|
+
* Envelope ceiling declared on every Send, in bytes.
|
|
620
|
+
*
|
|
621
|
+
* This is what the *client* promises the server it will never exceed, and the
|
|
622
|
+
* server enforces its own `MaxEnvelopeSizekb` on top. 512000 is the value the
|
|
623
|
+
* target host reports, and it is far above the library's hardcoded 153600 —
|
|
624
|
+
* raising it is what makes 256 KiB chunks legal.
|
|
625
|
+
*/
|
|
626
|
+
const WS_MAX_ENVELOPE_BYTES = 512e3;
|
|
627
|
+
let sendHttpCache;
|
|
628
|
+
/**
|
|
629
|
+
* Resolve the library's `sendHttp`, or null when the internal path is absent.
|
|
630
|
+
* @returns the send function, or null when it cannot be loaded.
|
|
631
|
+
*/
|
|
632
|
+
function loadSendHttp() {
|
|
633
|
+
if (sendHttpCache !== void 0) return sendHttpCache;
|
|
634
|
+
try {
|
|
635
|
+
const mod = createRequire(import.meta.url)("winrm-client/dist/src/utils/http.js");
|
|
636
|
+
sendHttpCache = typeof mod.sendHttp === "function" ? mod.sendHttp : null;
|
|
637
|
+
} catch {
|
|
638
|
+
sendHttpCache = null;
|
|
639
|
+
}
|
|
640
|
+
return sendHttpCache;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Build one WSMan `Send` envelope carrying `chunk` on the stdin stream.
|
|
644
|
+
*
|
|
645
|
+
* The header mirrors what the library emits, with three deliberate changes:
|
|
646
|
+
* `MaxEnvelopeSize` is raised to `limit`, the `Send` action replaces the
|
|
647
|
+
* command's, and the stream body is the caller's base64 verbatim — no second
|
|
648
|
+
* encode. `Name="stdin"` and `CommandId` are what bind the bytes to the
|
|
649
|
+
* running receiver's standard input.
|
|
650
|
+
* @param shellId - target shell from `Shell.doCreateShell`.
|
|
651
|
+
* @param commandId - target command from `Command.doExecuteCommand`.
|
|
652
|
+
* @param chunk - raw bytes to place on the remote stdin stream.
|
|
653
|
+
* @param limit - envelope ceiling to declare.
|
|
654
|
+
* @returns the SOAP envelope as a UTF-8 string.
|
|
655
|
+
*/
|
|
656
|
+
function buildSendEnvelope(shellId, commandId, chunk, limit) {
|
|
657
|
+
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?><s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\" xmlns:wsa=\"http://schemas.xmlsoap.org/ws/2004/08/addressing\" xmlns:wsman=\"http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd\" xmlns:p=\"http://schemas.microsoft.com/wbem/wsman/1/wsman.xsd\" xmlns:rsp=\"http://schemas.microsoft.com/wbem/wsman/1/windows/shell\"><s:Header><wsa:To>http://windows-host:5985/wsman</wsa:To><wsman:ResourceURI mustUnderstand=\"true\">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd</wsman:ResourceURI><wsa:ReplyTo><wsa:Address mustUnderstand=\"true\">http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address></wsa:ReplyTo><wsman:MaxEnvelopeSize mustUnderstand=\"true\">" + String(limit) + "</wsman:MaxEnvelopeSize><wsa:MessageID>uuid:" + randomUUID() + "</wsa:MessageID><wsman:Locale mustUnderstand=\"false\" xml:lang=\"en-US\"/><wsman:OperationTimeout>PT60S</wsman:OperationTimeout><wsa:Action mustUnderstand=\"true\">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Send</wsa:Action><wsman:SelectorSet><wsman:Selector Name=\"ShellId\">" + shellId + "</wsman:Selector></wsman:SelectorSet></s:Header><s:Body><rsp:Send><rsp:Stream CommandId=\"" + commandId + "\" Name=\"stdin\">" + chunk.toString("base64") + "</rsp:Stream></rsp:Send></s:Body></s:Envelope>";
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Push one raw slice onto the remote stdin stream.
|
|
661
|
+
*
|
|
662
|
+
* A rejection here is safe to retry with a smaller slice: the server validates
|
|
663
|
+
* the envelope size before dispatching it to the shell, so nothing reached the
|
|
664
|
+
* receiver's stdin when the call fails on size.
|
|
665
|
+
* @param target - connection parameters (auth already selected).
|
|
666
|
+
* @param shellId - target shell.
|
|
667
|
+
* @param commandId - target command.
|
|
668
|
+
* @param chunk - raw bytes to send.
|
|
669
|
+
* @param sendHttp - the resolved library function.
|
|
670
|
+
* @param timeoutMs - per-request HTTP timeout.
|
|
671
|
+
*/
|
|
672
|
+
async function sendStdinChunk(target, shellId, commandId, chunk, sendHttp, timeoutMs) {
|
|
673
|
+
await sendHttp(buildSendEnvelope(shellId, commandId, chunk, WS_MAX_ENVELOPE_BYTES), target.host, target.port, target.path, target.username, target.password, target.authMethod, timeoutMs, target.useHttps, target.rejectUnauthorized);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Whether a failed Send was refused for exceeding the envelope ceiling.
|
|
677
|
+
*
|
|
678
|
+
* WSMan reports this as a SOAP fault rather than a status code, and the exact
|
|
679
|
+
* wording differs between the HTTP and HTTPS stacks, so the test looks for any
|
|
680
|
+
* of the size-related phrasings plus the plain 413 status. It deliberately
|
|
681
|
+
* does not match a bare "size" in unrelated text.
|
|
682
|
+
* @param error - the rejection to classify.
|
|
683
|
+
* @returns true when retrying with a smaller slice is the right response.
|
|
684
|
+
*/
|
|
685
|
+
function isEnvelopeTooLarge(error) {
|
|
686
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
687
|
+
return /\b413\b/.test(message) || /envelope/i.test(message) || /MaxEnvelopeSize/i.test(message) || /maximum.*(size|length)/i.test(message);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Build one WSMan `Receive` envelope asking for stdout and stderr.
|
|
691
|
+
*
|
|
692
|
+
* The library's own `buildReceiveOutputRequest` cannot be reused here because
|
|
693
|
+
* it hardcodes `MaxEnvelopeSize` at 153600 in its shared header. Measured
|
|
694
|
+
* against this target, that ceiling is what caps each response at 128 KiB —
|
|
695
|
+
* raising it to 512000 lets one `Receive` return 128 KiB per stream, which is
|
|
696
|
+
* the most the server will put in a single response regardless.
|
|
697
|
+
* @param shellId - target shell.
|
|
698
|
+
* @param commandId - target command whose streams are wanted.
|
|
699
|
+
* @param limit - envelope ceiling to declare.
|
|
700
|
+
* @returns the SOAP envelope as a UTF-8 string.
|
|
701
|
+
*/
|
|
702
|
+
function buildReceiveEnvelope(shellId, commandId, limit) {
|
|
703
|
+
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?><s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\" xmlns:wsa=\"http://schemas.xmlsoap.org/ws/2004/08/addressing\" xmlns:wsman=\"http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd\" xmlns:rsp=\"http://schemas.microsoft.com/wbem/wsman/1/windows/shell\"><s:Header><wsa:To>http://windows-host:5985/wsman</wsa:To><wsman:ResourceURI mustUnderstand=\"true\">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd</wsman:ResourceURI><wsa:ReplyTo><wsa:Address mustUnderstand=\"true\">http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address></wsa:ReplyTo><wsman:MaxEnvelopeSize mustUnderstand=\"true\">" + String(limit) + "</wsman:MaxEnvelopeSize><wsa:MessageID>uuid:" + randomUUID() + "</wsa:MessageID><wsman:Locale mustUnderstand=\"false\" xml:lang=\"en-US\"/><wsman:OperationTimeout>PT60S</wsman:OperationTimeout><wsa:Action mustUnderstand=\"true\">http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Receive</wsa:Action><wsman:SelectorSet><wsman:Selector Name=\"ShellId\">" + shellId + "</wsman:Selector></wsman:SelectorSet></s:Header><s:Body><rsp:Receive><rsp:DesiredStream CommandId=\"" + commandId + "\">stdout stderr</rsp:DesiredStream></rsp:Receive></s:Body></s:Envelope>";
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Decode a base64 text node that the XML parser may have coerced to a number.
|
|
707
|
+
*
|
|
708
|
+
* fast-xml-parser runs with `parseTagValue` enabled, so an all-digit text node
|
|
709
|
+
* comes back as a JS number: `"0123"` parses to `123`, `"0000"` to `0`, and
|
|
710
|
+
* `"1e55"` to a float. A base64 payload is usually long enough to contain a
|
|
711
|
+
* non-digit and stay a string, but the final short chunk of a stream is exactly
|
|
712
|
+
* where a leading zero can appear, and silently reinterpreting it would corrupt
|
|
713
|
+
* the file. Converting through `String()` recovers the digits for every value
|
|
714
|
+
* that lost only leading zeros; a value that came back in exponent form cannot
|
|
715
|
+
* be recovered and is rejected rather than guessed at.
|
|
716
|
+
* @param text - the raw text node as the parser produced it.
|
|
717
|
+
* @returns the base64 text, or null when it cannot be reconstructed.
|
|
718
|
+
*/
|
|
719
|
+
function coerceBase64(text) {
|
|
720
|
+
if (typeof text === "string") return text.replace(/\s+/g, "");
|
|
721
|
+
if (typeof text === "number" && Number.isFinite(text) && Number.isInteger(text) && text >= 0) return String(text);
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Read the streams and exit code out of a Receive response.
|
|
726
|
+
*
|
|
727
|
+
* The library's `extractStreams` cannot be used for binary payloads: it decodes
|
|
728
|
+
* every stream through `extractText`, which falls back to `String(obj)` for an
|
|
729
|
+
* element that has attributes but no text node — turning the terminal
|
|
730
|
+
* `End="true"` marker into the literal string `'[object Object]'` — and the
|
|
731
|
+
* library's `doReceiveOutputNonBlocking` then `.trim()`s the decoded text,
|
|
732
|
+
* which would eat whitespace bytes at either end of a real payload. This
|
|
733
|
+
* reader keeps the distinction between "no text" (zero bytes) and "text" and
|
|
734
|
+
* never trims.
|
|
735
|
+
* @param response - the parsed XML object returned by `sendHttp`.
|
|
736
|
+
* @returns the decoded streams and exit code.
|
|
737
|
+
* @throws when a stream's text node cannot be decoded as base64.
|
|
738
|
+
*/
|
|
739
|
+
function readReceiveResult(response) {
|
|
740
|
+
const receive = ((response?.["s:Envelope"])?.["s:Body"])?.["rsp:ReceiveResponse"];
|
|
741
|
+
const streams = [];
|
|
742
|
+
if (receive !== void 0) {
|
|
743
|
+
const raw = receive["rsp:Stream"];
|
|
744
|
+
const list = raw === void 0 || raw === null ? [] : Array.isArray(raw) ? raw : [raw];
|
|
745
|
+
for (const element of list) {
|
|
746
|
+
const node = element;
|
|
747
|
+
const text = node !== null && typeof node === "object" && "_" in node ? node["_"] : "";
|
|
748
|
+
const attributes = node?.["$"] ?? {};
|
|
749
|
+
const name = String(attributes["@_Name"] ?? "");
|
|
750
|
+
const end = String(attributes["@_End"] ?? "") === "true";
|
|
751
|
+
if (text === "" || text === null || text === void 0) {
|
|
752
|
+
streams.push({
|
|
753
|
+
name,
|
|
754
|
+
data: Buffer.alloc(0),
|
|
755
|
+
end
|
|
756
|
+
});
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
const base64 = coerceBase64(text);
|
|
760
|
+
if (base64 === null) throw new Error("receive stream returned an undecodable text node: " + String(text));
|
|
761
|
+
streams.push({
|
|
762
|
+
name,
|
|
763
|
+
data: base64 === "" ? Buffer.alloc(0) : Buffer.from(base64, "base64"),
|
|
764
|
+
end
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const rawExit = (receive?.["rsp:CommandState"])?.["rsp:ExitCode"];
|
|
769
|
+
const exitCode = rawExit === void 0 || rawExit === null ? void 0 : Number(rawExit);
|
|
770
|
+
return {
|
|
771
|
+
streams,
|
|
772
|
+
exitCode: exitCode !== void 0 && Number.isFinite(exitCode) ? exitCode : void 0
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Fetch one Receive response for a running command.
|
|
777
|
+
* @param target - connection parameters (auth already selected).
|
|
778
|
+
* @param shellId - target shell.
|
|
779
|
+
* @param commandId - target command.
|
|
780
|
+
* @param sendHttp - the resolved library function.
|
|
781
|
+
* @param timeoutMs - per-request HTTP timeout.
|
|
782
|
+
* @returns the decoded streams and exit code.
|
|
783
|
+
*/
|
|
784
|
+
async function receiveOutput(target, shellId, commandId, sendHttp, timeoutMs) {
|
|
785
|
+
return readReceiveResult(await sendHttp(buildReceiveEnvelope(shellId, commandId, WS_MAX_ENVELOPE_BYTES), target.host, target.port, target.path, target.username, target.password, target.authMethod, timeoutMs, target.useHttps, target.rejectUnauthorized));
|
|
786
|
+
}
|
|
787
|
+
//#endregion
|
|
180
788
|
//#region src/engine/client.ts
|
|
181
789
|
/**
|
|
182
790
|
* WinRM transport bridge.
|
|
183
791
|
*
|
|
184
792
|
* Runs each PowerShell payload over a native Node.js WinRM client
|
|
185
|
-
* (winrm-client) — no Python/pywinrm subprocess. The script
|
|
186
|
-
*
|
|
187
|
-
* any codepage output survives the
|
|
188
|
-
*
|
|
189
|
-
*
|
|
793
|
+
* (winrm-client) — no Python/pywinrm subprocess. The script is delivered on
|
|
794
|
+
* the WinRS **stdin** stream and answers on the UTF-8 base64 envelope, so
|
|
795
|
+
* Chinese and any codepage output survives the transport losslessly and the
|
|
796
|
+
* command line stays a constant, ASCII-safe line that WinRS/cmd.exe cannot
|
|
797
|
+
* mangle. See powershell.ts for why the command line cannot carry the script.
|
|
190
798
|
*
|
|
191
799
|
* Auth is chosen per attempt (HTTPS: Basic first, then NTLM; HTTP: NTLM
|
|
192
800
|
* first, then Basic) with fallback, so a host that only enables one scheme
|
|
@@ -245,17 +853,17 @@ function baseParams(conn, authMethod) {
|
|
|
245
853
|
};
|
|
246
854
|
}
|
|
247
855
|
/**
|
|
248
|
-
* One shell lifecycle: create → run the
|
|
249
|
-
* command completes → delete. Bounded by
|
|
250
|
-
* delete is fired without blocking
|
|
856
|
+
* One shell lifecycle: create → run the constant envelope script → push the
|
|
857
|
+
* payload on stdin → receive until the command completes → delete. Bounded by
|
|
858
|
+
* an overall deadline; on timeout the delete is fired without blocking.
|
|
251
859
|
* @param conn - connection parameters.
|
|
252
|
-
* @param
|
|
860
|
+
* @param payload - the stdin payload (`<byteCount>\n<script>`).
|
|
253
861
|
* @param timeoutMs - command execution budget measured from just after Execute.
|
|
254
862
|
* @param authMethod - the auth scheme for this attempt.
|
|
255
863
|
* @returns stdout/stderr captured from the WinRS streams.
|
|
256
864
|
* @throws WinrmTimedOut when the deadline passes; other errors are auth/transport failures.
|
|
257
865
|
*/
|
|
258
|
-
async function attemptOnce(conn,
|
|
866
|
+
async function attemptOnce(conn, payload, timeoutMs, authMethod) {
|
|
259
867
|
const base = baseParams(conn, authMethod);
|
|
260
868
|
const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS;
|
|
261
869
|
const state = {
|
|
@@ -273,11 +881,16 @@ async function attemptOnce(conn, envelope, timeoutMs, authMethod) {
|
|
|
273
881
|
}).catch(() => void 0);
|
|
274
882
|
throw new WinrmTimedOut();
|
|
275
883
|
}
|
|
276
|
-
const command = powershellCommandLine(envelope);
|
|
277
884
|
const commandId = await Command.doExecuteCommand({
|
|
278
885
|
...base,
|
|
279
886
|
shellId,
|
|
280
|
-
command
|
|
887
|
+
command: powershellCommandLine()
|
|
888
|
+
});
|
|
889
|
+
for (const piece of splitScriptPayload(payload)) await Command.doSendInput({
|
|
890
|
+
...base,
|
|
891
|
+
shellId,
|
|
892
|
+
commandId,
|
|
893
|
+
input: piece
|
|
281
894
|
});
|
|
282
895
|
const receive = {
|
|
283
896
|
...base,
|
|
@@ -324,16 +937,16 @@ async function attemptOnce(conn, envelope, timeoutMs, authMethod) {
|
|
|
324
937
|
* succeeds. A timeout aborts immediately (retrying auth would waste the
|
|
325
938
|
* remaining budget on a reachable-but-slow host).
|
|
326
939
|
* @param conn - connection parameters.
|
|
327
|
-
* @param
|
|
940
|
+
* @param payload - the stdin payload (`<byteCount>\n<script>`).
|
|
328
941
|
* @param timeoutMs - command execution budget.
|
|
329
942
|
* @returns captured stdout/stderr.
|
|
330
943
|
* @throws WinrmTimedOut on deadline; the last auth/transport error when every scheme fails.
|
|
331
944
|
*/
|
|
332
|
-
async function runWinrm(conn,
|
|
945
|
+
async function runWinrm(conn, payload, timeoutMs) {
|
|
333
946
|
const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
|
|
334
947
|
let lastError;
|
|
335
948
|
for (const authMethod of candidates) try {
|
|
336
|
-
return await attemptOnce(conn,
|
|
949
|
+
return await attemptOnce(conn, payload, timeoutMs, authMethod);
|
|
337
950
|
} catch (error) {
|
|
338
951
|
if (error instanceof WinrmTimedOut) throw error;
|
|
339
952
|
lastError = error;
|
|
@@ -343,10 +956,10 @@ async function runWinrm(conn, envelope, timeoutMs) {
|
|
|
343
956
|
async function runScript(conn, script, options = {}) {
|
|
344
957
|
const started = Date.now();
|
|
345
958
|
const timeoutMs = options.timeoutMs ?? 6e4;
|
|
346
|
-
const
|
|
959
|
+
const payload = scriptPayload(script);
|
|
347
960
|
let outcome;
|
|
348
961
|
try {
|
|
349
|
-
outcome = await runWinrm(conn,
|
|
962
|
+
outcome = await runWinrm(conn, payload, timeoutMs);
|
|
350
963
|
} catch (error) {
|
|
351
964
|
if (error instanceof WinrmTimedOut) return {
|
|
352
965
|
stdout: "",
|
|
@@ -360,13 +973,13 @@ async function runScript(conn, script, options = {}) {
|
|
|
360
973
|
const raw = outcome.stdout;
|
|
361
974
|
const parsed = parseEnvelope(raw);
|
|
362
975
|
const exitCode = parsed !== null ? parsed.exitCode : 1;
|
|
363
|
-
const stderr = parsed !== null ? stripClixml(
|
|
976
|
+
const stderr = parsed !== null ? stripClixml(parsed.stderr) : outcome.stderr;
|
|
364
977
|
options.onChunk?.({
|
|
365
978
|
output: raw,
|
|
366
979
|
stderr
|
|
367
980
|
});
|
|
368
981
|
return {
|
|
369
|
-
stdout: parsed !== null ? parsed.
|
|
982
|
+
stdout: parsed !== null ? parsed.stdout : raw,
|
|
370
983
|
stderr,
|
|
371
984
|
exitCode,
|
|
372
985
|
timedOut: false,
|
|
@@ -401,13 +1014,21 @@ async function testConnection(conn) {
|
|
|
401
1014
|
}
|
|
402
1015
|
}
|
|
403
1016
|
const TRANSFER_CHUNK = 48 * 1024;
|
|
1017
|
+
/**
|
|
1018
|
+
* Size of a remote file, or null when the path does not exist or is a
|
|
1019
|
+
* directory. The distinction matters: a caller that treats "absent" as "empty"
|
|
1020
|
+
* writes an empty local file and reports success for a download that copied
|
|
1021
|
+
* nothing.
|
|
1022
|
+
*/
|
|
404
1023
|
async function remoteFileSize(conn, remotePath, timeoutMs = 6e4) {
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
1024
|
+
const text = (await runScript(conn, psFileSize(remotePath), { timeoutMs })).stdout.trim();
|
|
1025
|
+
if (text === "") return null;
|
|
1026
|
+
const value = Number.parseInt(text, 10);
|
|
1027
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
408
1028
|
}
|
|
409
1029
|
async function downloadChunks(conn, remotePath, onChunk, onProgress, timeoutMs = 6e4) {
|
|
410
1030
|
const total = await remoteFileSize(conn, remotePath, timeoutMs);
|
|
1031
|
+
if (total === null) throw new Error(`remote file not found: ${remotePath}`);
|
|
411
1032
|
if (total === 0) return 0;
|
|
412
1033
|
let offset = 0;
|
|
413
1034
|
for (;;) {
|
|
@@ -433,16 +1054,355 @@ async function uploadBuffer(conn, remotePath, data, onProgress, timeoutMs = 6e4)
|
|
|
433
1054
|
}
|
|
434
1055
|
return written;
|
|
435
1056
|
}
|
|
1057
|
+
/** Raw slice size for the streaming path: 256 KiB, comfortably inside `MAX_SEND_SLICE_BYTES`. */
|
|
1058
|
+
const STREAM_CHUNK_BYTES = 262144;
|
|
1059
|
+
/**
|
|
1060
|
+
* Compress a slice only when it actually shrinks.
|
|
1061
|
+
*
|
|
1062
|
+
* Gzip on already-compressed input (zips, images, installers) produces output
|
|
1063
|
+
* marginally *larger* than its input, and the remote side then pays to inflate
|
|
1064
|
+
* it for nothing — measured at ~3.8x the wall time of sending the same bytes
|
|
1065
|
+
* raw. Local compression is cheap (~3.4 ms per 256 KiB even when it fails to
|
|
1066
|
+
* compress), so probing every slice costs almost nothing and the ratio is the
|
|
1067
|
+
* only thing that decides.
|
|
1068
|
+
*/
|
|
1069
|
+
const STREAM_GZIP_MIN_RATIO = .85;
|
|
1070
|
+
/** Per-Send HTTP budget; a single slice is small, so this only bounds a stalled connection. */
|
|
1071
|
+
const STREAM_SEND_TIMEOUT_MS = 12e4;
|
|
1072
|
+
/**
|
|
1073
|
+
* One streaming transfer over a single shell: create → run the receiver →
|
|
1074
|
+
* push the receiver script, the header frame and every data frame down stdin →
|
|
1075
|
+
* receive the envelope → delete the shell.
|
|
1076
|
+
*
|
|
1077
|
+
* The receiver script is the first thing on stdin because it does not fit the
|
|
1078
|
+
* command line (see `streamCommandLine`); the bootstrap in the command line
|
|
1079
|
+
* reads it and runs it in-process so it can keep reading the same stream.
|
|
1080
|
+
* @param conn - connection parameters.
|
|
1081
|
+
* @param remotePath - destination path on the target.
|
|
1082
|
+
* @param data - the file contents to send.
|
|
1083
|
+
* @param onProgress - called with cumulative bytes after each frame.
|
|
1084
|
+
* @param timeoutMs - whole-transfer budget.
|
|
1085
|
+
* @param authMethod - the auth scheme for this attempt.
|
|
1086
|
+
* @param sendHttp - the resolved library send function.
|
|
1087
|
+
* @returns the number of file bytes the receiver acknowledged writing.
|
|
1088
|
+
* @throws WinrmTimedOut when the deadline passes; other errors are transport failures.
|
|
1089
|
+
*/
|
|
1090
|
+
async function streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp) {
|
|
1091
|
+
const base = baseParams(conn, authMethod);
|
|
1092
|
+
const target = {
|
|
1093
|
+
host: conn.host,
|
|
1094
|
+
port: conn.port,
|
|
1095
|
+
path: conn.path,
|
|
1096
|
+
username: conn.username,
|
|
1097
|
+
password: conn.password,
|
|
1098
|
+
authMethod,
|
|
1099
|
+
useHttps: conn.useHttps ?? false,
|
|
1100
|
+
rejectUnauthorized: conn.rejectUnauthorized ?? true
|
|
1101
|
+
};
|
|
1102
|
+
const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS;
|
|
1103
|
+
const state = {
|
|
1104
|
+
shellId: void 0,
|
|
1105
|
+
timedOut: false,
|
|
1106
|
+
settled: false
|
|
1107
|
+
};
|
|
1108
|
+
const total = data.length;
|
|
1109
|
+
const inner = async () => {
|
|
1110
|
+
const shellId = await Shell.doCreateShell(base);
|
|
1111
|
+
state.shellId = shellId;
|
|
1112
|
+
if (state.settled) {
|
|
1113
|
+
Shell.doDeleteShell({
|
|
1114
|
+
...base,
|
|
1115
|
+
shellId
|
|
1116
|
+
}).catch(() => void 0);
|
|
1117
|
+
throw new WinrmTimedOut();
|
|
1118
|
+
}
|
|
1119
|
+
const commandId = await Command.doExecuteCommand({
|
|
1120
|
+
...base,
|
|
1121
|
+
shellId,
|
|
1122
|
+
command: streamCommandLine()
|
|
1123
|
+
});
|
|
1124
|
+
await sendStdinChunk(target, shellId, commandId, Buffer.from(scriptPayload(psReceiveStream()), "utf8"), sendHttp, STREAM_SEND_TIMEOUT_MS);
|
|
1125
|
+
const header = Buffer.from(String(total) + "\n" + remotePath, "utf8");
|
|
1126
|
+
await sendStdinChunk(target, shellId, commandId, buildStreamFrame(header, 2), sendHttp, STREAM_SEND_TIMEOUT_MS);
|
|
1127
|
+
let offset = 0;
|
|
1128
|
+
let slice = STREAM_CHUNK_BYTES;
|
|
1129
|
+
let ceiling = STREAM_CHUNK_BYTES;
|
|
1130
|
+
while (offset < total) {
|
|
1131
|
+
if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut();
|
|
1132
|
+
const take = Math.min(slice, total - offset);
|
|
1133
|
+
const chunk = data.subarray(offset, offset + take);
|
|
1134
|
+
const gz = gzipSync(chunk, { level: 6 });
|
|
1135
|
+
const compress = gz.length < chunk.length * STREAM_GZIP_MIN_RATIO;
|
|
1136
|
+
const frame = buildStreamFrame(compress ? gz : chunk, compress ? 1 : 0);
|
|
1137
|
+
try {
|
|
1138
|
+
await sendStdinChunk(target, shellId, commandId, frame, sendHttp, STREAM_SEND_TIMEOUT_MS);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
if (isEnvelopeTooLarge(error) && slice > 16384) {
|
|
1141
|
+
slice = Math.max(16384, Math.floor(slice / 2));
|
|
1142
|
+
ceiling = slice;
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
throw error;
|
|
1146
|
+
}
|
|
1147
|
+
offset += take;
|
|
1148
|
+
onProgress?.(offset, total);
|
|
1149
|
+
if (slice < ceiling) slice = Math.min(ceiling, slice * 2);
|
|
1150
|
+
}
|
|
1151
|
+
const receive = {
|
|
1152
|
+
...base,
|
|
1153
|
+
shellId,
|
|
1154
|
+
commandId
|
|
1155
|
+
};
|
|
1156
|
+
const commandDeadline = Date.now() + timeoutMs;
|
|
1157
|
+
let stdout = "";
|
|
1158
|
+
let stderr = "";
|
|
1159
|
+
for (;;) {
|
|
1160
|
+
if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut();
|
|
1161
|
+
const chunk = await Command.doReceiveOutputNonBlocking(receive);
|
|
1162
|
+
stdout += chunk.output;
|
|
1163
|
+
stderr += chunk.stderr;
|
|
1164
|
+
if (chunk.isComplete) break;
|
|
1165
|
+
}
|
|
1166
|
+
const parsed = parseEnvelope(stdout);
|
|
1167
|
+
if (parsed === null) throw new Error("streaming upload did not report completion: " + (stripClixml(stderr) || stdout).trim());
|
|
1168
|
+
if (parsed.exitCode !== 0) throw new Error("streaming upload failed on the target: " + parsed.stdout.trim());
|
|
1169
|
+
const written = Number.parseInt(parsed.stdout.trim(), 10);
|
|
1170
|
+
if (!Number.isFinite(written) || written !== total) throw new Error(`streaming upload wrote ${parsed.stdout.trim()} of ${total} bytes`);
|
|
1171
|
+
return written;
|
|
1172
|
+
};
|
|
1173
|
+
try {
|
|
1174
|
+
return await raceDeadline(inner(), hardDeadline - Date.now());
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
if (error instanceof WinrmTimedOut || Date.now() >= hardDeadline) {
|
|
1177
|
+
state.timedOut = true;
|
|
1178
|
+
throw error instanceof WinrmTimedOut ? error : new WinrmTimedOut();
|
|
1179
|
+
}
|
|
1180
|
+
throw error;
|
|
1181
|
+
} finally {
|
|
1182
|
+
state.settled = true;
|
|
1183
|
+
const shellId = state.shellId;
|
|
1184
|
+
if (shellId !== void 0) {
|
|
1185
|
+
const deletion = Shell.doDeleteShell({
|
|
1186
|
+
...base,
|
|
1187
|
+
shellId
|
|
1188
|
+
}).catch(() => void 0);
|
|
1189
|
+
if (state.timedOut);
|
|
1190
|
+
else await Promise.race([deletion, sleep(DELETE_TIMEOUT_MS)]);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Upload a buffer over a single WinRS stdin stream.
|
|
1196
|
+
*
|
|
1197
|
+
* This replaces the per-chunk shell cycle: one shell, one command and one
|
|
1198
|
+
* `Send` per 256 KiB, against a receiver that drains the stream straight into
|
|
1199
|
+
* the file. Measured at ~10 s for 2 MiB where the old path took 310 s.
|
|
1200
|
+
*
|
|
1201
|
+
* Returns null — rather than throwing — when the transport this path depends
|
|
1202
|
+
* on is unavailable (a future `winrm-client` layout that drops the internal
|
|
1203
|
+
* module it borrows), so the caller can fall back to {@link uploadBuffer}
|
|
1204
|
+
* instead of failing the transfer.
|
|
1205
|
+
* @param conn - connection parameters.
|
|
1206
|
+
* @param remotePath - destination path on the target.
|
|
1207
|
+
* @param data - the file contents to send.
|
|
1208
|
+
* @param onProgress - called with cumulative bytes after each frame.
|
|
1209
|
+
* @param timeoutMs - whole-transfer budget.
|
|
1210
|
+
* @returns bytes written, or null when the streaming path is unavailable.
|
|
1211
|
+
*/
|
|
1212
|
+
async function uploadStream(conn, remotePath, data, onProgress, timeoutMs = 6e5) {
|
|
1213
|
+
const sendHttp = loadSendHttp();
|
|
1214
|
+
if (sendHttp === null) return null;
|
|
1215
|
+
const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
|
|
1216
|
+
let lastError;
|
|
1217
|
+
for (const authMethod of candidates) try {
|
|
1218
|
+
return await streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp);
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
if (error instanceof WinrmTimedOut) throw error;
|
|
1221
|
+
lastError = error;
|
|
1222
|
+
}
|
|
1223
|
+
throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error("all WinRM authentication methods failed");
|
|
1224
|
+
}
|
|
1225
|
+
/** Smallest byte range worth giving a worker; below this the shell setup dominates. */
|
|
1226
|
+
const DOWNLOAD_MIN_RANGE_BYTES = 524288;
|
|
1227
|
+
/** Most shells to open for one download. Measured 3.9x on 16 MiB at four; more adds target load. */
|
|
1228
|
+
const DOWNLOAD_MAX_WORKERS = 4;
|
|
1229
|
+
/** Per-Receive HTTP timeout. The server long-polls, so this only bounds a stalled transport. */
|
|
1230
|
+
const DOWNLOAD_RECEIVE_TIMEOUT_MS = 12e4;
|
|
1231
|
+
/**
|
|
1232
|
+
* One worker's slice of a download: own shell, own command, own byte range.
|
|
1233
|
+
* @param conn - connection parameters.
|
|
1234
|
+
* @param remotePath - file being read.
|
|
1235
|
+
* @param start - first byte offset of this worker's range.
|
|
1236
|
+
* @param length - bytes this worker must deliver.
|
|
1237
|
+
* @param onData - receives each decompressed piece together with its file offset.
|
|
1238
|
+
* @param timeoutMs - budget for the whole range.
|
|
1239
|
+
* @param authMethod - the auth scheme for this attempt.
|
|
1240
|
+
* @param sendHttp - the resolved library HTTP function.
|
|
1241
|
+
* @returns the number of file bytes this worker delivered.
|
|
1242
|
+
*/
|
|
1243
|
+
async function downloadWorkerOnce(conn, remotePath, start, length, onData, timeoutMs, authMethod, sendHttp) {
|
|
1244
|
+
const base = baseParams(conn, authMethod);
|
|
1245
|
+
const target = {
|
|
1246
|
+
host: conn.host,
|
|
1247
|
+
port: conn.port,
|
|
1248
|
+
path: conn.path,
|
|
1249
|
+
username: conn.username,
|
|
1250
|
+
password: conn.password,
|
|
1251
|
+
authMethod,
|
|
1252
|
+
useHttps: conn.useHttps ?? false,
|
|
1253
|
+
rejectUnauthorized: conn.rejectUnauthorized ?? true
|
|
1254
|
+
};
|
|
1255
|
+
const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS;
|
|
1256
|
+
const state = {
|
|
1257
|
+
shellId: void 0,
|
|
1258
|
+
timedOut: false,
|
|
1259
|
+
settled: false
|
|
1260
|
+
};
|
|
1261
|
+
const inner = async () => {
|
|
1262
|
+
const shellId = await Shell.doCreateShell(base);
|
|
1263
|
+
state.shellId = shellId;
|
|
1264
|
+
if (state.settled) {
|
|
1265
|
+
Shell.doDeleteShell({
|
|
1266
|
+
...base,
|
|
1267
|
+
shellId
|
|
1268
|
+
}).catch(() => void 0);
|
|
1269
|
+
throw new WinrmTimedOut();
|
|
1270
|
+
}
|
|
1271
|
+
const commandId = await Command.doExecuteCommand({
|
|
1272
|
+
...base,
|
|
1273
|
+
shellId,
|
|
1274
|
+
command: scriptCommandLine(psSendStream(remotePath, start, length))
|
|
1275
|
+
});
|
|
1276
|
+
const inflater = createGunzip();
|
|
1277
|
+
let inflateError;
|
|
1278
|
+
inflater.on("error", (error) => {
|
|
1279
|
+
inflateError = error;
|
|
1280
|
+
});
|
|
1281
|
+
let written = 0;
|
|
1282
|
+
let stderr = "";
|
|
1283
|
+
inflater.on("data", (piece) => {
|
|
1284
|
+
onData(start + written, piece);
|
|
1285
|
+
written += piece.length;
|
|
1286
|
+
});
|
|
1287
|
+
let stdoutEnded = false;
|
|
1288
|
+
let exitCode;
|
|
1289
|
+
while (!stdoutEnded) {
|
|
1290
|
+
if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut();
|
|
1291
|
+
const result = await receiveOutput(target, shellId, commandId, sendHttp, DOWNLOAD_RECEIVE_TIMEOUT_MS);
|
|
1292
|
+
let sawData = false;
|
|
1293
|
+
for (const stream of result.streams) {
|
|
1294
|
+
if (stream.data.length === 0) {
|
|
1295
|
+
if (stream.name === "stdout" && stream.end) stdoutEnded = true;
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
sawData = true;
|
|
1299
|
+
if (stream.name === "stdout") {
|
|
1300
|
+
inflater.write(stream.data);
|
|
1301
|
+
if (stream.end) stdoutEnded = true;
|
|
1302
|
+
} else stderr += stream.data.toString("utf8");
|
|
1303
|
+
}
|
|
1304
|
+
if (inflateError !== void 0) throw inflateError;
|
|
1305
|
+
if (result.exitCode !== void 0) exitCode = result.exitCode;
|
|
1306
|
+
if (!sawData && exitCode !== void 0) break;
|
|
1307
|
+
}
|
|
1308
|
+
await new Promise((resolve, reject) => {
|
|
1309
|
+
inflater.on("error", reject);
|
|
1310
|
+
inflater.on("end", resolve);
|
|
1311
|
+
inflater.end();
|
|
1312
|
+
});
|
|
1313
|
+
if (inflateError !== void 0) throw inflateError;
|
|
1314
|
+
if (exitCode !== void 0 && exitCode !== 0) throw new Error("streaming download failed on the target: " + stderr.trim());
|
|
1315
|
+
if (written !== length) throw new Error(`streaming download delivered ${written} of ${length} bytes`);
|
|
1316
|
+
return written;
|
|
1317
|
+
};
|
|
1318
|
+
try {
|
|
1319
|
+
return await raceDeadline(inner(), hardDeadline - Date.now());
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
state.timedOut = error instanceof WinrmTimedOut;
|
|
1322
|
+
throw error;
|
|
1323
|
+
} finally {
|
|
1324
|
+
state.settled = true;
|
|
1325
|
+
const shellId = state.shellId;
|
|
1326
|
+
if (shellId !== void 0) try {
|
|
1327
|
+
await raceDeadline(Shell.doDeleteShell({
|
|
1328
|
+
...base,
|
|
1329
|
+
shellId
|
|
1330
|
+
}), DELETE_TIMEOUT_MS);
|
|
1331
|
+
} catch {}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Stream a remote file to a consumer, in parallel byte ranges.
|
|
1336
|
+
*
|
|
1337
|
+
* The old path asked for 48 KiB per shell lifecycle, so a 2 MiB file cost 43
|
|
1338
|
+
* round-trip cycles and 129 s. Measured against this target, one `Receive`
|
|
1339
|
+
* returns at most 128 KiB no matter how large an envelope is declared, which
|
|
1340
|
+
* makes a download latency-bound rather than bandwidth-bound; the fix is
|
|
1341
|
+
* therefore to run several ranges at once. Each worker opens its own shell and
|
|
1342
|
+
* streams its range gzip-compressed, and because ranges are absolute, the
|
|
1343
|
+
* caller can place each piece by offset and completion order does not matter.
|
|
1344
|
+
*
|
|
1345
|
+
* The remote side compresses unconditionally: a compressible 16 MiB file
|
|
1346
|
+
* arrives in one round trip (2.0 s), and an incompressible file was measured no
|
|
1347
|
+
* slower than raw (9457 ms vs 9582 ms).
|
|
1348
|
+
* @param conn - connection parameters.
|
|
1349
|
+
* @param remotePath - file to read.
|
|
1350
|
+
* @param onData - receives each decompressed piece with its absolute file offset.
|
|
1351
|
+
* @param onProgress - receives cumulative bytes delivered and the total size.
|
|
1352
|
+
* @param timeoutMs - budget for the whole download.
|
|
1353
|
+
* @returns the number of bytes delivered, or null when streaming is unavailable.
|
|
1354
|
+
*/
|
|
1355
|
+
async function downloadStream(conn, remotePath, onData, onProgress, timeoutMs = 6e5) {
|
|
1356
|
+
const sendHttp = loadSendHttp();
|
|
1357
|
+
if (sendHttp === null) return null;
|
|
1358
|
+
const total = await remoteFileSize(conn, remotePath);
|
|
1359
|
+
if (total === null) throw new Error(`remote file not found: ${remotePath}`);
|
|
1360
|
+
if (total === 0) return 0;
|
|
1361
|
+
const workers = Math.max(1, Math.min(DOWNLOAD_MAX_WORKERS, Math.ceil(total / DOWNLOAD_MIN_RANGE_BYTES)));
|
|
1362
|
+
const rangeSize = Math.ceil(total / workers);
|
|
1363
|
+
let transferred = 0;
|
|
1364
|
+
let generation = 0;
|
|
1365
|
+
let live = true;
|
|
1366
|
+
const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
|
|
1367
|
+
let lastError;
|
|
1368
|
+
try {
|
|
1369
|
+
for (const authMethod of candidates) {
|
|
1370
|
+
transferred = 0;
|
|
1371
|
+
const mine = ++generation;
|
|
1372
|
+
const report = (offset, data) => {
|
|
1373
|
+
if (!live || mine !== generation) return;
|
|
1374
|
+
onData(offset, data);
|
|
1375
|
+
transferred += data.length;
|
|
1376
|
+
onProgress?.(transferred, total);
|
|
1377
|
+
};
|
|
1378
|
+
try {
|
|
1379
|
+
const ranges = [];
|
|
1380
|
+
for (let start = 0; start < total; start += rangeSize) ranges.push({
|
|
1381
|
+
start,
|
|
1382
|
+
length: Math.min(rangeSize, total - start)
|
|
1383
|
+
});
|
|
1384
|
+
await Promise.all(ranges.map((range) => downloadWorkerOnce(conn, remotePath, range.start, range.length, report, timeoutMs, authMethod, sendHttp)));
|
|
1385
|
+
return transferred;
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
if (error instanceof WinrmTimedOut) throw error;
|
|
1388
|
+
lastError = error;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error("all WinRM authentication methods failed");
|
|
1392
|
+
} finally {
|
|
1393
|
+
live = false;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
436
1396
|
//#endregion
|
|
437
1397
|
//#region src/smb.ts
|
|
438
1398
|
/**
|
|
439
1399
|
* SMB transfer bridge.
|
|
440
1400
|
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
1401
|
+
* SMB is the fast channel when the admin share is reachable, so `auto` tries it
|
|
1402
|
+
* first and falls back to the WinRM streaming path. This module mounts the
|
|
1403
|
+
* remote host's admin share with `net use` (password passed via stdin, never
|
|
1404
|
+
* argv), copies whole files with node:fs, then verifies the SHA-256 on both
|
|
1405
|
+
* ends.
|
|
446
1406
|
*/
|
|
447
1407
|
/**
|
|
448
1408
|
* Convert a Windows drive path to its UNC admin-share form.
|
|
@@ -525,6 +1485,67 @@ async function remoteSha256(host, remotePath) {
|
|
|
525
1485
|
if (match === null) throw new Error("SMB remote hash verification failed: " + stdout);
|
|
526
1486
|
return match[1].toUpperCase();
|
|
527
1487
|
}
|
|
1488
|
+
/** How long a reachability verdict is trusted before probing again. */
|
|
1489
|
+
const SMB_PROBE_TTL_MS = 6e4;
|
|
1490
|
+
/** Budget for the TCP reachability probe; far below `net use`'s own timeout. */
|
|
1491
|
+
const SMB_PROBE_TIMEOUT_MS = 3e3;
|
|
1492
|
+
const smbProbeCache = /* @__PURE__ */ new Map();
|
|
1493
|
+
/**
|
|
1494
|
+
* Whether the target's SMB port (445) accepts a connection.
|
|
1495
|
+
*
|
|
1496
|
+
* `net use` is the only way to mount the admin share, but it has no useful
|
|
1497
|
+
* timeout: against a host whose 445 is filtered or whose SMB service is down it
|
|
1498
|
+
* spends a full minute before failing. That minute was being paid on *every*
|
|
1499
|
+
* `auto` upload before the WinRM fallback could even start — measured at 61 s
|
|
1500
|
+
* and 58 s on the test host, against a 12 s streaming transfer. A bare TCP
|
|
1501
|
+
* connect answers the same question in milliseconds.
|
|
1502
|
+
*
|
|
1503
|
+
* A negative verdict is cached briefly so a burst of transfers does not re-probe
|
|
1504
|
+
* for each one; a positive verdict is cached too, but the `net use` that follows
|
|
1505
|
+
* still reports its own failure if the share turns out to be unmountable.
|
|
1506
|
+
* @param host - target hostname or address.
|
|
1507
|
+
* @returns true when port 445 accepted a connection.
|
|
1508
|
+
*/
|
|
1509
|
+
async function smbReachable(host) {
|
|
1510
|
+
const cached = smbProbeCache.get(host);
|
|
1511
|
+
if (cached !== void 0 && Date.now() - cached.at < SMB_PROBE_TTL_MS) return cached.reachable;
|
|
1512
|
+
const reachable = await new Promise((resolve) => {
|
|
1513
|
+
const socket = createConnection({
|
|
1514
|
+
host,
|
|
1515
|
+
port: 445
|
|
1516
|
+
});
|
|
1517
|
+
let settled = false;
|
|
1518
|
+
const finish = (value) => {
|
|
1519
|
+
if (settled) return;
|
|
1520
|
+
settled = true;
|
|
1521
|
+
socket.destroy();
|
|
1522
|
+
resolve(value);
|
|
1523
|
+
};
|
|
1524
|
+
const timer = setTimeout(() => finish(false), SMB_PROBE_TIMEOUT_MS);
|
|
1525
|
+
socket.once("connect", () => {
|
|
1526
|
+
clearTimeout(timer);
|
|
1527
|
+
finish(true);
|
|
1528
|
+
});
|
|
1529
|
+
socket.once("error", () => {
|
|
1530
|
+
clearTimeout(timer);
|
|
1531
|
+
finish(false);
|
|
1532
|
+
});
|
|
1533
|
+
socket.once("timeout", () => {
|
|
1534
|
+
clearTimeout(timer);
|
|
1535
|
+
finish(false);
|
|
1536
|
+
});
|
|
1537
|
+
socket.setTimeout(SMB_PROBE_TIMEOUT_MS);
|
|
1538
|
+
});
|
|
1539
|
+
smbProbeCache.set(host, {
|
|
1540
|
+
reachable,
|
|
1541
|
+
at: Date.now()
|
|
1542
|
+
});
|
|
1543
|
+
return reachable;
|
|
1544
|
+
}
|
|
1545
|
+
/** Error thrown when SMB was requested or probed and the target is not listening. */
|
|
1546
|
+
function smbUnavailable(host) {
|
|
1547
|
+
return /* @__PURE__ */ new Error("SMB is not reachable on " + host + ":445 (the admin share needs TCP 445 open and the Server service running)");
|
|
1548
|
+
}
|
|
528
1549
|
/**
|
|
529
1550
|
* Copy one local file to a remote Windows host over SMB, verifying the
|
|
530
1551
|
* remote SHA-256 matches the local source.
|
|
@@ -837,30 +1858,34 @@ var WinRmEngine = class {
|
|
|
837
1858
|
total: data.length,
|
|
838
1859
|
percent: 0
|
|
839
1860
|
});
|
|
840
|
-
if (channel !== "winrm")
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1861
|
+
if (channel !== "winrm") {
|
|
1862
|
+
if (await smbReachable(entry.host)) try {
|
|
1863
|
+
const outcome = await uploadSmb(entry, localPath, remotePath);
|
|
1864
|
+
onProgress?.({
|
|
1865
|
+
phase: "done",
|
|
1866
|
+
file: remotePath,
|
|
1867
|
+
transferred: outcome.bytes,
|
|
1868
|
+
total: outcome.bytes,
|
|
1869
|
+
percent: 100
|
|
1870
|
+
});
|
|
1871
|
+
return {
|
|
1872
|
+
bytes: outcome.bytes,
|
|
1873
|
+
channel: outcome.channel
|
|
1874
|
+
};
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
if (channel === "smb") throw error;
|
|
1877
|
+
onProgress?.({
|
|
1878
|
+
phase: "connecting",
|
|
1879
|
+
file: remotePath,
|
|
1880
|
+
transferred: 0,
|
|
1881
|
+
total: data.length,
|
|
1882
|
+
percent: 0
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
else if (channel === "smb") throw smbUnavailable(entry.host);
|
|
862
1886
|
}
|
|
863
|
-
const
|
|
1887
|
+
const conn = connOf(entry);
|
|
1888
|
+
const report = (transferred, total) => {
|
|
864
1889
|
onProgress?.({
|
|
865
1890
|
phase: "transferring",
|
|
866
1891
|
file: remotePath,
|
|
@@ -868,7 +1893,9 @@ var WinRmEngine = class {
|
|
|
868
1893
|
total,
|
|
869
1894
|
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
|
|
870
1895
|
});
|
|
871
|
-
}
|
|
1896
|
+
};
|
|
1897
|
+
let bytes = await uploadStream(conn, remotePath, data, report);
|
|
1898
|
+
if (bytes === null) bytes = await uploadBuffer(conn, remotePath, data, report);
|
|
872
1899
|
onProgress?.({
|
|
873
1900
|
phase: "done",
|
|
874
1901
|
file: remotePath,
|
|
@@ -894,49 +1921,72 @@ var WinRmEngine = class {
|
|
|
894
1921
|
total: 0,
|
|
895
1922
|
percent: 0
|
|
896
1923
|
});
|
|
897
|
-
if (channel !== "winrm")
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1924
|
+
if (channel !== "winrm") {
|
|
1925
|
+
if (await smbReachable(entry.host)) try {
|
|
1926
|
+
const outcome = await downloadSmb(entry, remotePath, localPath);
|
|
1927
|
+
onProgress?.({
|
|
1928
|
+
phase: "done",
|
|
1929
|
+
file: remotePath,
|
|
1930
|
+
transferred: outcome.bytes,
|
|
1931
|
+
total: outcome.bytes,
|
|
1932
|
+
percent: 100
|
|
1933
|
+
});
|
|
1934
|
+
return {
|
|
1935
|
+
bytes: outcome.bytes,
|
|
1936
|
+
channel: outcome.channel
|
|
1937
|
+
};
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
if (channel === "smb") throw error;
|
|
1940
|
+
onProgress?.({
|
|
1941
|
+
phase: "connecting",
|
|
1942
|
+
file: remotePath,
|
|
1943
|
+
transferred: 0,
|
|
1944
|
+
total: 0,
|
|
1945
|
+
percent: 0
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
else if (channel === "smb") throw smbUnavailable(entry.host);
|
|
919
1949
|
}
|
|
920
|
-
const {
|
|
921
|
-
|
|
1950
|
+
const { open, rm } = await import("node:fs/promises");
|
|
1951
|
+
let sink = null;
|
|
1952
|
+
let completed = false;
|
|
922
1953
|
let bytes = 0;
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
sink.write(
|
|
929
|
-
}
|
|
1954
|
+
let queue = Promise.resolve();
|
|
1955
|
+
const put = (offset, data) => {
|
|
1956
|
+
bytes += data.length;
|
|
1957
|
+
queue = queue.then(async () => {
|
|
1958
|
+
if (sink === null) sink = await open(localPath, "w", 384);
|
|
1959
|
+
await sink.write(data, 0, data.length, offset);
|
|
1960
|
+
});
|
|
1961
|
+
queue.catch(() => void 0);
|
|
1962
|
+
};
|
|
1963
|
+
const report = (transferred, total) => {
|
|
1964
|
+
onProgress?.({
|
|
930
1965
|
phase: "transferring",
|
|
931
1966
|
file: remotePath,
|
|
932
1967
|
transferred,
|
|
933
1968
|
total,
|
|
934
1969
|
percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
|
|
935
|
-
})).then(() => sink.end(resolve), (error) => {
|
|
936
|
-
sink.destroy();
|
|
937
|
-
reject(error);
|
|
938
1970
|
});
|
|
939
|
-
}
|
|
1971
|
+
};
|
|
1972
|
+
try {
|
|
1973
|
+
const conn = connOf(entry);
|
|
1974
|
+
if (await downloadStream(conn, remotePath, put, report) === null) {
|
|
1975
|
+
let offset = 0;
|
|
1976
|
+
await downloadChunks(conn, remotePath, (b64) => {
|
|
1977
|
+
const buffer = Buffer.from(b64, "base64");
|
|
1978
|
+
put(offset, buffer);
|
|
1979
|
+
offset += buffer.length;
|
|
1980
|
+
}, report);
|
|
1981
|
+
}
|
|
1982
|
+
await queue;
|
|
1983
|
+
if (sink === null) sink = await open(localPath, "w", 384);
|
|
1984
|
+
completed = true;
|
|
1985
|
+
} finally {
|
|
1986
|
+
await queue.catch(() => void 0);
|
|
1987
|
+
if (sink !== null) await sink.close();
|
|
1988
|
+
if (!completed && sink !== null) await rm(localPath, { force: true }).catch(() => void 0);
|
|
1989
|
+
}
|
|
940
1990
|
onProgress?.({
|
|
941
1991
|
phase: "done",
|
|
942
1992
|
file: remotePath,
|
|
@@ -2145,7 +3195,7 @@ function winrmUploadTool(engine) {
|
|
|
2145
3195
|
"smb",
|
|
2146
3196
|
"winrm"
|
|
2147
3197
|
],
|
|
2148
|
-
description: "Transfer channel: smb (admin share), winrm (
|
|
3198
|
+
description: "Transfer channel: smb (admin share), winrm (streamed over the WinRS stdin channel), or auto (default, SMB first with WinRM fallback)."
|
|
2149
3199
|
}
|
|
2150
3200
|
},
|
|
2151
3201
|
output: {
|
|
@@ -2213,7 +3263,7 @@ function winrmDownloadTool(engine) {
|
|
|
2213
3263
|
"smb",
|
|
2214
3264
|
"winrm"
|
|
2215
3265
|
],
|
|
2216
|
-
description: "Transfer channel: smb (admin share), winrm (
|
|
3266
|
+
description: "Transfer channel: smb (admin share), winrm (streamed over parallel WinRS ranges), or auto (default, SMB first with WinRM fallback)."
|
|
2217
3267
|
}
|
|
2218
3268
|
},
|
|
2219
3269
|
output: {
|