dsh-winrm 0.2.0 → 0.3.1

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,8 +1,11 @@
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";
@@ -143,6 +146,55 @@ function powershellCommandLine() {
143
146
  return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(OUTER_SCRIPT);
144
147
  }
145
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);
196
+ }
197
+ /**
146
198
  * The stdin payload for one command: the UTF-8 **byte** count, a newline, then
147
199
  * the raw script.
148
200
  *
@@ -226,11 +278,16 @@ function psListDir(dir) {
226
278
  ""
227
279
  ].join("\r\n");
228
280
  }
229
- /** 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
+ */
230
287
  function psFileSize(p) {
231
288
  return [
232
289
  "$__f = Get-Item -LiteralPath " + sq(p) + " -ErrorAction SilentlyContinue",
233
- "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 }",
234
291
  ""
235
292
  ].join("\r\n");
236
293
  }
@@ -265,6 +322,227 @@ function psWriteChunk(p, b64, append) {
265
322
  ""
266
323
  ].join("\r\n");
267
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;
329
+ /**
330
+ * Wrap one payload in a stream frame: `DSHZ` + uint32LE length + mode + pad.
331
+ *
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
+ }
268
546
  /** Decode one base64 envelope field, tolerating whitespace the transport added. */
269
547
  function decodeField(base64) {
270
548
  return Buffer.from(base64.replace(/\s+/g, ""), "base64").toString("utf8");
@@ -316,6 +594,197 @@ function stripClixml(stderr) {
316
594
  return stderr.replace(CLIXML_HEADER, "").replace(CLIXML_RECORDS, "").trim();
317
595
  }
318
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
319
788
  //#region src/engine/client.ts
320
789
  /**
321
790
  * WinRM transport bridge.
@@ -545,13 +1014,21 @@ async function testConnection(conn) {
545
1014
  }
546
1015
  }
547
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
+ */
548
1023
  async function remoteFileSize(conn, remotePath, timeoutMs = 6e4) {
549
- const result = await runScript(conn, psFileSize(remotePath), { timeoutMs });
550
- const value = Number.parseInt(result.stdout.trim(), 10);
551
- 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;
552
1028
  }
553
1029
  async function downloadChunks(conn, remotePath, onChunk, onProgress, timeoutMs = 6e4) {
554
1030
  const total = await remoteFileSize(conn, remotePath, timeoutMs);
1031
+ if (total === null) throw new Error(`remote file not found: ${remotePath}`);
555
1032
  if (total === 0) return 0;
556
1033
  let offset = 0;
557
1034
  for (;;) {
@@ -577,16 +1054,355 @@ async function uploadBuffer(conn, remotePath, data, onProgress, timeoutMs = 6e4)
577
1054
  }
578
1055
  return written;
579
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
+ }
580
1396
  //#endregion
581
1397
  //#region src/smb.ts
582
1398
  /**
583
1399
  * SMB transfer bridge.
584
1400
  *
585
- * The WinRM base64 chunk bridge is reliable for small files but breaks for
586
- * large artifacts (the WinRM/HTTP envelope rejects large payloads), so whole
587
- * files ride the remote admin share. This module mounts the remote host's
588
- * admin share with `net use` (password passed via stdin, never argv), copies
589
- * 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.
590
1406
  */
591
1407
  /**
592
1408
  * Convert a Windows drive path to its UNC admin-share form.
@@ -669,6 +1485,67 @@ async function remoteSha256(host, remotePath) {
669
1485
  if (match === null) throw new Error("SMB remote hash verification failed: " + stdout);
670
1486
  return match[1].toUpperCase();
671
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
+ }
672
1549
  /**
673
1550
  * Copy one local file to a remote Windows host over SMB, verifying the
674
1551
  * remote SHA-256 matches the local source.
@@ -981,30 +1858,34 @@ var WinRmEngine = class {
981
1858
  total: data.length,
982
1859
  percent: 0
983
1860
  });
984
- if (channel !== "winrm") try {
985
- const outcome = await uploadSmb(entry, localPath, remotePath);
986
- onProgress?.({
987
- phase: "done",
988
- file: remotePath,
989
- transferred: outcome.bytes,
990
- total: outcome.bytes,
991
- percent: 100
992
- });
993
- return {
994
- bytes: outcome.bytes,
995
- channel: outcome.channel
996
- };
997
- } catch (error) {
998
- if (channel === "smb") throw error;
999
- onProgress?.({
1000
- phase: "connecting",
1001
- file: remotePath,
1002
- transferred: 0,
1003
- total: data.length,
1004
- percent: 0
1005
- });
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);
1006
1886
  }
1007
- const bytes = await uploadBuffer(connOf(entry), remotePath, data, (transferred, total) => {
1887
+ const conn = connOf(entry);
1888
+ const report = (transferred, total) => {
1008
1889
  onProgress?.({
1009
1890
  phase: "transferring",
1010
1891
  file: remotePath,
@@ -1012,7 +1893,9 @@ var WinRmEngine = class {
1012
1893
  total,
1013
1894
  percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
1014
1895
  });
1015
- });
1896
+ };
1897
+ let bytes = await uploadStream(conn, remotePath, data, report);
1898
+ if (bytes === null) bytes = await uploadBuffer(conn, remotePath, data, report);
1016
1899
  onProgress?.({
1017
1900
  phase: "done",
1018
1901
  file: remotePath,
@@ -1038,49 +1921,72 @@ var WinRmEngine = class {
1038
1921
  total: 0,
1039
1922
  percent: 0
1040
1923
  });
1041
- if (channel !== "winrm") try {
1042
- const outcome = await downloadSmb(entry, remotePath, localPath);
1043
- onProgress?.({
1044
- phase: "done",
1045
- file: remotePath,
1046
- transferred: outcome.bytes,
1047
- total: outcome.bytes,
1048
- percent: 100
1049
- });
1050
- return {
1051
- bytes: outcome.bytes,
1052
- channel: outcome.channel
1053
- };
1054
- } catch (error) {
1055
- if (channel === "smb") throw error;
1056
- onProgress?.({
1057
- phase: "connecting",
1058
- file: remotePath,
1059
- transferred: 0,
1060
- total: 0,
1061
- percent: 0
1062
- });
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);
1063
1949
  }
1064
- const { createWriteStream } = await import("node:fs");
1065
- const sink = createWriteStream(localPath, { mode: 384 });
1950
+ const { open, rm } = await import("node:fs/promises");
1951
+ let sink = null;
1952
+ let completed = false;
1066
1953
  let bytes = 0;
1067
- await new Promise((resolve, reject) => {
1068
- sink.on("error", reject);
1069
- downloadChunks(connOf(entry), remotePath, (b64) => {
1070
- const buffer = Buffer.from(b64, "base64");
1071
- bytes += buffer.length;
1072
- sink.write(buffer);
1073
- }, (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?.({
1074
1965
  phase: "transferring",
1075
1966
  file: remotePath,
1076
1967
  transferred,
1077
1968
  total,
1078
1969
  percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
1079
- })).then(() => sink.end(resolve), (error) => {
1080
- sink.destroy();
1081
- reject(error);
1082
1970
  });
1083
- });
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
+ }
1084
1990
  onProgress?.({
1085
1991
  phase: "done",
1086
1992
  file: remotePath,
@@ -2265,7 +3171,7 @@ function winrmProcessTool(engine) {
2265
3171
  function winrmUploadTool(engine) {
2266
3172
  return defineTool({
2267
3173
  name: "winrm_upload",
2268
- description: "Upload a local file to a configured Windows host (SMB admin share preferred, base64 WinRM chunk fallback). The local path is on THIS machine (the dsh host). Triggers: upload file to Windows server, deploy artifact, copy config to Windows server.",
3174
+ description: "Upload a local file to a configured Windows host. The bytes go over the SMB admin share when it is reachable, otherwise they are streamed over WinRM in one shell with per-chunk gzip (the 48 KiB base64 chunk path is kept only as a fallback). The local path is on THIS machine (the dsh host). Triggers: upload file to Windows server, deploy artifact, copy config to Windows server.",
2269
3175
  parameters: {
2270
3176
  alias: {
2271
3177
  type: "string",
@@ -2289,7 +3195,7 @@ function winrmUploadTool(engine) {
2289
3195
  "smb",
2290
3196
  "winrm"
2291
3197
  ],
2292
- 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)."
2293
3199
  }
2294
3200
  },
2295
3201
  output: {
@@ -2333,7 +3239,7 @@ function winrmUploadTool(engine) {
2333
3239
  function winrmDownloadTool(engine) {
2334
3240
  return defineTool({
2335
3241
  name: "winrm_download",
2336
- description: "Download a remote FILE from a configured Windows host to a local path on this machine (SMB admin share preferred, base64 WinRM chunk fallback). Directory download is not supported — download files individually. Triggers: download file from Windows server, fetch remote log/artifact.",
3242
+ description: "Download a remote FILE from a configured Windows host to a local path on this machine. The bytes come over the SMB admin share when it is reachable, otherwise they are streamed over WinRM as parallel byte ranges (the 48 KiB base64 chunk path is kept only as a fallback). Directory download is not supported — download files individually. Triggers: download file from Windows server, fetch remote log/artifact.",
2337
3243
  parameters: {
2338
3244
  alias: {
2339
3245
  type: "string",
@@ -2357,7 +3263,7 @@ function winrmDownloadTool(engine) {
2357
3263
  "smb",
2358
3264
  "winrm"
2359
3265
  ],
2360
- 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)."
2361
3267
  }
2362
3268
  },
2363
3269
  output: {
@@ -2529,7 +3435,7 @@ const DEFAULT_ENABLED = true;
2529
3435
  /** Order of the announcement section within the tool-guidance band. */
2530
3436
  const SECTION_ORDER = 151;
2531
3437
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
2532
- const WINRM_GUIDANCE = "本机已安装 dsh-winrm 插件(DSH 远程 Windows 运维,WinRM/PowerShell Remoting):侧边栏「Windows」入口;仿照 dsh-ssh 插件开发。能力:主机配置存 ~/.dsh/dsh-winrm.json(GUI 配置后 agent 方可使用);winrm_list 列出主机、winrm_exec 执行 PowerShell 命令(UTF-8 信封,中文不乱码)、winrm_service 服务管理(start/stop/restart/启动类型)、winrm_process 进程管理(list/kill)、winrm_upload/winrm_download 文件传输(base64 分块,无 SMB 依赖)、winrm_cluster 集群并发执行;Web 控制台走 WebSocket。认证:通过原生 Node.js WinRM 客户端(winrm-client)的 NTLM/Negotiate 与 Basic 实现,兼容本地账户与域账户;传输支持 http(5985)/https(5986)。前提:目标机需启用 WinRM(scripts/enable-winrm.ps1),本机无需 Python —— WinRM 走纯 Node 依赖。HTTP 仅建议受信内网使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出可能含敏感信息;传输/执行消耗真实远程资源,先确认再操作。用户提到「Windows 服务器 / WinRM / PowerShell 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
3438
+ const WINRM_GUIDANCE = "本机已安装 dsh-winrm 插件(DSH 远程 Windows 运维,WinRM/PowerShell Remoting):侧边栏「Windows」入口;仿照 dsh-ssh 插件开发。能力:主机配置存 ~/.dsh/dsh-winrm.json(GUI 配置后 agent 方可使用);winrm_list 列出主机、winrm_exec 执行 PowerShell 命令(UTF-8 信封,中文不乱码)、winrm_service 服务管理(start/stop/restart/启动类型)、winrm_process 进程管理(list/kill)、winrm_upload/winrm_download 文件传输(SMB 管理共享可达时优先,否则走 WinRM 流式传输,无 SMB 依赖,大文件经 SHA256 校验)、winrm_cluster 集群并发执行;Web 控制台走 WebSocket。认证:通过原生 Node.js WinRM 客户端(winrm-client)的 NTLM/Negotiate 与 Basic 实现,兼容本地账户与域账户;传输支持 http(5985)/https(5986)。前提:目标机需启用 WinRM(scripts/enable-winrm.ps1),本机无需 Python —— WinRM 走纯 Node 依赖。HTTP 仅建议受信内网使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出可能含敏感信息;传输/执行消耗真实远程资源,先确认再操作。用户提到「Windows 服务器 / WinRM / PowerShell 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
2533
3439
  /**
2534
3440
  * Mount the WinRM engine, routes, tools, and announcement.
2535
3441
  * @param ctx - host plugin context carrying webServer/tools/systemPrompt.