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/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 { createHash, randomBytes } from "node:crypto";
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
- * The UTF-8 WinRM session script: runs `userScript` inside a script block,
22
- * merges stderr, prints the exit-code marker and base64 of UTF-8 output.
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
- function wrapEnvelope(userScript) {
25
- return [
26
- "$ErrorActionPreference = 'Continue'",
27
- "$__out = & { " + userScript + " } 2>&1 | Out-String -Width 4096",
28
- "$__code = 0",
29
- "if ($null -ne $LASTEXITCODE) { $__code = $LASTEXITCODE }",
30
- "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
31
- "'__DSH_WINRM__' + [string]$__code",
32
- "[System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__out))",
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
- /** One-shot script body for the PowerShell session already created by WinRM. */
37
- function winrmPowerShellScript(userScript) {
38
- return wrapEnvelope(userScript);
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
- /** The full powershell.exe command line (ASCII-safe) for a script body. */
41
- function powershellCommandLine(script) {
42
- return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(script);
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
- /** Plain: remote file size in bytes (0 when missing). */
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 -eq $__f) { \"0\" } elseif ($__f.PSIsContainer) { \"0\" } else { [string]$__f.Length }",
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
- * Parse the envelope output: { exitCode, text } or null when absent.
330
+ * Wrap one payload in a stream frame: `DSHZ` + uint32LE length + mode + pad.
133
331
  *
134
- * The base64 body is optional and the separating newline may be missing: a
135
- * command that printed nothing emits the marker alone (an empty string has
136
- * no base64 body), and a transport that trims each response drops the
137
- * trailing newline. The exit code is signed because `$LASTEXITCODE` can be
138
- * negative.
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 match = /^__DSH_WINRM__(-?\d+)(?:\r?\n([\s\S]*))?$/.exec(output);
142
- if (match === null) return null;
143
- const exitCode = Number.parseInt(match[1], 10);
144
- const payload = (match[2] ?? "").trim();
145
- try {
146
- return {
147
- exitCode,
148
- text: Buffer.from(payload, "base64").toString("utf8")
149
- };
150
- } catch {
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 the WinRS stderr stream.
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 error output is already merged into stdout by the
172
- * envelope, so this only removes transport-level noise.
173
- * @param stderr - raw stderr text captured from the WinRS stream.
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 rides the
186
- * UTF-8 base64 envelope (marker + base64 of UTF-8 output) so Chinese and
187
- * any codepage output survives the WinRM transport losslessly; the envelope
188
- * is base64-encoded with `-EncodedCommand`, keeping the command line a
189
- * single ASCII-safe line that WinRS/cmd.exe cannot mangle.
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 encoded script → receive until the
249
- * command completes → delete. Bounded by an overall deadline; on timeout the
250
- * delete is fired without blocking the caller.
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 envelope - the wrapped UTF-8 envelope script (already prepared).
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, envelope, timeoutMs, authMethod) {
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 envelope - the wrapped UTF-8 envelope script.
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, envelope, timeoutMs) {
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, envelope, timeoutMs, authMethod);
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 envelope = winrmPowerShellScript(script);
959
+ const payload = scriptPayload(script);
347
960
  let outcome;
348
961
  try {
349
- outcome = await runWinrm(conn, envelope, timeoutMs);
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(outcome.stderr) : outcome.stderr;
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.text : raw,
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 result = await runScript(conn, psFileSize(remotePath), { timeoutMs });
406
- const value = Number.parseInt(result.stdout.trim(), 10);
407
- return Number.isFinite(value) ? value : 0;
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
- * The WinRM base64 chunk bridge is reliable for small files but breaks for
442
- * large artifacts (the WinRM/HTTP envelope rejects large payloads), so whole
443
- * files ride the remote admin share. This module mounts the remote host's
444
- * admin share with `net use` (password passed via stdin, never argv), copies
445
- * whole files with node:fs, then verifies the SHA-256 on both ends.
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") try {
841
- const outcome = await uploadSmb(entry, localPath, remotePath);
842
- onProgress?.({
843
- phase: "done",
844
- file: remotePath,
845
- transferred: outcome.bytes,
846
- total: outcome.bytes,
847
- percent: 100
848
- });
849
- return {
850
- bytes: outcome.bytes,
851
- channel: outcome.channel
852
- };
853
- } catch (error) {
854
- if (channel === "smb") throw error;
855
- onProgress?.({
856
- phase: "connecting",
857
- file: remotePath,
858
- transferred: 0,
859
- total: data.length,
860
- percent: 0
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 bytes = await uploadBuffer(connOf(entry), remotePath, data, (transferred, total) => {
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") try {
898
- const outcome = await downloadSmb(entry, remotePath, localPath);
899
- onProgress?.({
900
- phase: "done",
901
- file: remotePath,
902
- transferred: outcome.bytes,
903
- total: outcome.bytes,
904
- percent: 100
905
- });
906
- return {
907
- bytes: outcome.bytes,
908
- channel: outcome.channel
909
- };
910
- } catch (error) {
911
- if (channel === "smb") throw error;
912
- onProgress?.({
913
- phase: "connecting",
914
- file: remotePath,
915
- transferred: 0,
916
- total: 0,
917
- percent: 0
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 { createWriteStream } = await import("node:fs");
921
- const sink = createWriteStream(localPath, { mode: 384 });
1950
+ const { open, rm } = await import("node:fs/promises");
1951
+ let sink = null;
1952
+ let completed = false;
922
1953
  let bytes = 0;
923
- await new Promise((resolve, reject) => {
924
- sink.on("error", reject);
925
- downloadChunks(connOf(entry), remotePath, (b64) => {
926
- const buffer = Buffer.from(b64, "base64");
927
- bytes += buffer.length;
928
- sink.write(buffer);
929
- }, (transferred, total) => onProgress?.({
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 (base64 chunks), or auto (default, SMB first with WinRM fallback)."
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 (base64 chunks), or auto (default, SMB first with WinRM fallback)."
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: {